From 1c3bfaae6f41834bf24613a999898ca47f250d4e Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Fri, 3 May 2019 13:15:29 -0400 Subject: [PATCH 001/105] [lens] Initial Commit (#35627) * [visualization editor] Initial Commit * [lens] Add more complete initial state * [lens] Fix type issues * [lens] Remove feature control * [lens] Bring back feature control and add tests * [lens] Update plugin structure and naming per comments * replace any usage by safe casting * [lens] Respond to review comments * [lens] Remove unused EditorFrameState type --- x-pack/index.js | 2 + x-pack/plugins/lens/common/constants.ts | 7 + x-pack/plugins/lens/common/index.ts | 7 + x-pack/plugins/lens/index.ts | 67 ++++++++ x-pack/plugins/lens/package.json | 11 ++ x-pack/plugins/lens/public/app_plugin/app.tsx | 28 ++++ .../plugins/lens/public/app_plugin/index.ts | 7 + .../plugins/lens/public/app_plugin/plugin.tsx | 35 +++++ .../editor_frame_plugin/editor_frame.tsx | 37 +++++ .../lens/public/editor_frame_plugin/index.ts | 7 + .../public/editor_frame_plugin/plugin.tsx | 55 +++++++ x-pack/plugins/lens/public/index.scss | 0 x-pack/plugins/lens/public/index.ts | 27 ++++ .../lens/public/indexpattern_plugin/index.ts | 7 + .../indexpattern_plugin/indexpattern.tsx | 58 +++++++ .../public/indexpattern_plugin/plugin.tsx | 22 +++ x-pack/plugins/lens/public/types.ts | 147 ++++++++++++++++++ .../apis/security/privileges.ts | 62 ++++++++ .../apis/xpack_main/features/features.ts | 1 + yarn.lock | 142 ++++++++++++++++- 20 files changed, 724 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/lens/common/constants.ts create mode 100644 x-pack/plugins/lens/common/index.ts create mode 100644 x-pack/plugins/lens/index.ts create mode 100644 x-pack/plugins/lens/package.json create mode 100644 x-pack/plugins/lens/public/app_plugin/app.tsx create mode 100644 x-pack/plugins/lens/public/app_plugin/index.ts create mode 100644 x-pack/plugins/lens/public/app_plugin/plugin.tsx create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/index.ts create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx create mode 100644 x-pack/plugins/lens/public/index.scss create mode 100644 x-pack/plugins/lens/public/index.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/index.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx create mode 100644 x-pack/plugins/lens/public/types.ts diff --git a/x-pack/index.js b/x-pack/index.js index 86bed4bcc8851..65a01d1fe025b 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -38,6 +38,7 @@ import { translations } from './plugins/translations'; import { upgradeAssistant } from './plugins/upgrade_assistant'; import { uptime } from './plugins/uptime'; import { ossTelemetry } from './plugins/oss_telemetry'; +import { lens } from './plugins/lens'; module.exports = function (kibana) { return [ @@ -75,5 +76,6 @@ module.exports = function (kibana) { upgradeAssistant(kibana), uptime(kibana), ossTelemetry(kibana), + lens(kibana), ]; }; diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts new file mode 100644 index 0000000000000..eeb295fd776e2 --- /dev/null +++ b/x-pack/plugins/lens/common/constants.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 const PLUGIN_ID = 'lens'; diff --git a/x-pack/plugins/lens/common/index.ts b/x-pack/plugins/lens/common/index.ts new file mode 100644 index 0000000000000..358d0d5b7e076 --- /dev/null +++ b/x-pack/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/plugins/lens/index.ts b/x-pack/plugins/lens/index.ts new file mode 100644 index 0000000000000..5d868d9f63a18 --- /dev/null +++ b/x-pack/plugins/lens/index.ts @@ -0,0 +1,67 @@ +/* + * 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 { Server } from 'hapi'; +import { resolve } from 'path'; + +import { PLUGIN_ID } from './common'; + +const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; + +export const lens = (kibana: any) => { + return new kibana.Plugin({ + id: PLUGIN_ID, + configPrefix: `xpack.${PLUGIN_ID}`, + require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter'], + publicDir: resolve(__dirname, 'public'), + + uiExports: { + app: { + title: NOT_INTERNATIONALIZED_PRODUCT_NAME, + description: 'Explore and visualize data.', + main: `plugins/${PLUGIN_ID}/index`, + }, + styleSheetPaths: resolve(__dirname, 'public/index.scss'), + }, + + config: () => { + return Joi.object({ + enabled: Joi.boolean().default(true), + }).default(); + }, + + init(server: Server) { + server.plugins.xpack_main.registerFeature({ + id: PLUGIN_ID, + name: NOT_INTERNATIONALIZED_PRODUCT_NAME, + navLinkId: PLUGIN_ID, + app: [PLUGIN_ID, 'kibana'], + catalogue: [PLUGIN_ID], + privileges: { + all: { + api: [PLUGIN_ID], + catalogue: [PLUGIN_ID], + savedObject: { + all: [], + read: [], + }, + ui: ['show'], + }, + read: { + api: [PLUGIN_ID], + catalogue: [PLUGIN_ID], + savedObject: { + all: [], + read: [], + }, + ui: ['show'], + }, + }, + }); + }, + }); +}; diff --git a/x-pack/plugins/lens/package.json b/x-pack/plugins/lens/package.json new file mode 100644 index 0000000000000..3d61a09ba55c3 --- /dev/null +++ b/x-pack/plugins/lens/package.json @@ -0,0 +1,11 @@ +{ + "author": "Elastic", + "name": "lens", + "version": "7.0.0", + "private": true, + "license": "Elastic-License", + "devDependencies": {}, + "dependencies": { + "@elastic/charts": "^4.0.0" + } +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx new file mode 100644 index 0000000000000..3e16a78083092 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/app.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 { I18nProvider } from '@kbn/i18n/react'; +import React, { useCallback } from 'react'; + +import { EditorFrameSetup } from '../types'; + +export function App({ editorFrame }: { editorFrame: EditorFrameSetup }) { + const renderFrame = useCallback(node => { + if (node !== null) { + editorFrame.render(node); + } + }, []); + + return ( + +
+

Lens

+ +
+
+ + ); +} diff --git a/x-pack/plugins/lens/public/app_plugin/index.ts b/x-pack/plugins/lens/public/app_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/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/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/plugins/lens/public/app_plugin/plugin.tsx new file mode 100644 index 0000000000000..c885d44dff765 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/plugin.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 { editorFrameSetup, editorFrameStop } from '../editor_frame_plugin'; +import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin'; +import { App } from './app'; + +export class AppPlugin { + constructor() {} + + setup() { + // 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 editorFrame = editorFrameSetup(); + + editorFrame.registerDatasource('indexpattern', indexPattern); + + return ; + } + + stop() { + indexPatternDatasourceStop(); + editorFrameStop(); + } +} + +const app = new AppPlugin(); + +export const appSetup = () => app.setup(); +export const appStop = () => app.stop(); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx new file mode 100644 index 0000000000000..091862e635d99 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx @@ -0,0 +1,37 @@ +/* + * 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 { Datasource, Visualization } from '../types'; + +interface EditorFrameProps { + datasources: { [key: string]: Datasource }; + visualizations: { [key: string]: Visualization }; +} + +export function EditorFrame(props: EditorFrameProps) { + const keys = Object.keys(props.datasources); + + return ( +
+

Editor Frame

+ + {keys.map(key => ( +
{ + if (domElement) { + props.datasources[key].renderDataPanel(domElement, { + state: {}, + setState: () => {}, + }); + } + }} + /> + ))} +
+ ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/index.ts b/x-pack/plugins/lens/public/editor_frame_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/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/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx new file mode 100644 index 0000000000000..9d9224e462a5a --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -0,0 +1,55 @@ +/* + * 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 { Datasource, Visualization, EditorFrameSetup } from '../types'; + +import { EditorFrame } from './editor_frame'; + +class EditorFramePlugin { + constructor() {} + + private datasources: { [key: string]: Datasource } = {}; + private visualizations: { [key: string]: Visualization } = {}; + + private element: Element | null = null; + + public setup(): EditorFrameSetup { + return { + render: domElement => { + this.element = domElement; + render( + , + domElement + ); + }, + registerDatasource: (name, datasource) => { + // casting it to an unknown datasource. This doesn't introduce runtime errors + // because each type T is always also an unknown, but typescript won't do it + // on it's own because we are loosing type information here. + // So it's basically explicitly saying "I'm dropping the information about type T here + // because this information isn't useful to me." but without using any which can leak + this.datasources[name] = datasource as Datasource; + }, + registerVisualization: (name, visualization) => { + this.visualizations[name] = visualization as Visualization; + }, + }; + } + + public stop() { + if (this.element) { + unmountComponentAtNode(this.element); + } + return {}; + } +} + +const editorFrame = new EditorFramePlugin(); + +export const editorFrameSetup = () => editorFrame.setup(); +export const editorFrameStop = () => editorFrame.stop(); diff --git a/x-pack/plugins/lens/public/index.scss b/x-pack/plugins/lens/public/index.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts new file mode 100644 index 0000000000000..874249ff75d85 --- /dev/null +++ b/x-pack/plugins/lens/public/index.ts @@ -0,0 +1,27 @@ +/* + * 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 { IScope } from 'angular'; +import { render, unmountComponentAtNode } from 'react-dom'; +import chrome from 'ui/chrome'; +import { 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(); + }); + + return render(appSetup(), el); +} + +chrome.setRootController(PLUGIN_ID, Root); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/index.ts b/x-pack/plugins/lens/public/indexpattern_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/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/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx new file mode 100644 index 0000000000000..5b024be3b718e --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render } from 'react-dom'; +import { Datasource, DataType } from '..'; +import { DatasourceDimensionPanelProps, DatasourceDataPanelProps } from '../types'; + +interface IndexPatternPrivateState { + query: object; +} + +// Not stateful. State is persisted to the frame +export const indexPatternDatasource: Datasource = { + async initialize() { + return { query: {} }; + }, + + toExpression(state: IndexPatternPrivateState) { + return `${JSON.stringify(state.query)}`; + }, + + renderDataPanel(domElement: Element, props: DatasourceDataPanelProps) { + render(
Index Pattern Data Source
, domElement); + }, + + getPublicAPI() { + // TODO: Provide state to each of these + return { + getTableSpec: () => [], + getOperationForColumnId: (columnId: string) => ({ + id: '', + // User-facing label for the operation + label: '', + dataType: 'string' as DataType, + // A bucketed operation has many values the same + isBucketed: false, + }), + + renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => {}, + + removeColumnInTableSpec: (columnId: string) => [], + moveColumnTo: (columnId: string, targetIndex: number) => {}, + duplicateColumn: (columnId: string) => [], + }; + }, + + getDatasourceSuggestionsForField() { + return []; + }, + + getDatasourceSuggestionsFromCurrentState() { + return []; + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx new file mode 100644 index 0000000000000..851beba8ba1ab --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { indexPatternDatasource } from './indexpattern'; + +class IndexPatternDatasourcePlugin { + constructor() {} + + setup() { + return indexPatternDatasource; + } + + stop() {} +} + +const plugin = new IndexPatternDatasourcePlugin(); + +export const indexPatternDatasourceSetup = () => plugin.setup(); +export const indexPatternDatasourceStop = () => plugin.stop(); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts new file mode 100644 index 0000000000000..0f6e7a801732b --- /dev/null +++ b/x-pack/plugins/lens/public/types.ts @@ -0,0 +1,147 @@ +/* + * 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 EditorFrameSetup { + render: (domElement: Element) => void; + // generic type on the API functions to pull the "unknown vs. specific type" error into the implementation + registerDatasource: (name: string, datasource: Datasource) => void; + registerVisualization: (name: string, visualization: Visualization) => void; +} + +// Hints the default nesting to the data source. 0 is the highest priority +export type DimensionPriority = 0 | 1 | 2; + +// For switching between visualizations and correctly matching columns +export type DimensionRole = + | 'splitChart' + | 'series' + | 'primary' + | 'secondary' + | 'color' + | 'size' + | string; // Some visualizations will use custom names that have other meaning + +export interface TableColumns { + columnId: string; + operation: Operation; +} + +export interface DatasourceSuggestion { + state: T; + tableColumns: TableColumns[]; +} + +/** + * Interface for the datasource registry + */ +export interface Datasource { + // For initializing from saved object + initialize: (state?: T) => Promise; + + renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps) => void; + + toExpression: (state: T) => string; + + getDatasourceSuggestionsForField: (state: T) => Array>; + getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; + + getPublicAPI: (state: T, setState: (newState: T) => void) => 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; + + // Render can be called many times + renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => void; + + removeColumnInTableSpec: (columnId: string) => TableSpec; + moveColumnTo: (columnId: string, targetIndex: number) => void; + duplicateColumn: (columnId: string) => TableSpec; +} + +export interface DatasourceDataPanelProps { + state: T; + setState: (newState: T) => void; +} + +// The only way a visualization has to restrict the query building +export interface DatasourceDimensionPanelProps { + // If no columnId is passed, it will render as empty + columnId?: string; + + // Visualizations can restrict operations based on their own rules + filterOperations: (operation: Operation) => boolean; + + // Visualizations can hint at the role this dimension would play, which + // affects the default ordering of the query + suggestedPriority?: DimensionPriority; +} + +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 { + // Operation ID is a reference to the operation + id: string; + // User-facing label for the operation + label: string; + // 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; + + // Extra meta-information like cardinality, color +} + +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 VisualizationProps { + datasource: DatasourcePublicAPI; + state: T; + setState: (newState: T) => void; +} + +export interface SuggestionRequest { + // Roles currently being used + roles: DimensionRole[]; + // It is up to the Visualization to rank these tables + tableColumns: { [datasourceSuggestionId: string]: TableColumns }; + state?: T; // State is only passed if the visualization is active +} + +export interface VisualizationSuggestion { + score: number; + title: string; + state: T; + datasourceSuggestionId: string; +} + +export interface Visualization { + renderConfigPanel: (props: VisualizationProps) => void; + + toExpression: (state: T, datasource: DatasourcePublicAPI) => string; + + // Frame will request the list of roles currently being used when calling `getInitialStateFromOtherVisualization` + getMappingOfTableToRoles: (state: T, datasource: DatasourcePublicAPI) => DimensionRole[]; + + // The frame will call this function on all visualizations when the table changes, or when + // rendering additional ways of using the data + getSuggestions: (options: SuggestionRequest) => Array>; +} diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index e521e81763446..3885be63b69a3 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -915,6 +915,47 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `ui:${version}:savedObjectsManagement/config/read`, ], }, + 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: [ @@ -1119,6 +1160,11 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `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: [ @@ -1222,6 +1268,11 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `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: { @@ -1425,6 +1476,11 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `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: [ @@ -1528,6 +1584,11 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `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: { @@ -1600,6 +1661,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { uptime: ['all', 'read'], apm: ['all', 'read'], code: ['all', 'read'], + lens: ['all', 'read'], }, global: ['all', 'read'], space: ['all', 'read'], diff --git a/x-pack/test/api_integration/apis/xpack_main/features/features.ts b/x-pack/test/api_integration/apis/xpack_main/features/features.ts index ddeff89513ad6..fefcb4b46df52 100644 --- a/x-pack/test/api_integration/apis/xpack_main/features/features.ts +++ b/x-pack/test/api_integration/apis/xpack_main/features/features.ts @@ -42,6 +42,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { 'logs', 'maps', 'uptime', + 'lens', ].sort() ); }); diff --git a/yarn.lock b/yarn.lock index 65d4203f833cc..a2c8bca9ff4f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1317,6 +1317,32 @@ lodash "^4.17.11" to-fast-properties "^2.0.0" +"@elastic/charts@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-4.0.0.tgz#321313873985deb69106d479ee6a827eb81c890a" + integrity sha512-j/DfpdsKOx/QEbWJ6CvLlLP6XUYWqAHyuho3+38HngnoZSPzLGLte/ymW1CnaNYthQGTS9/4a47aZCkvQ6EdBw== + dependencies: + "@types/d3-shape" "^1.3.1" + "@types/luxon" "^1.11.1" + classnames "^2.2.6" + d3-array "^2.0.3" + d3-collection "^1.0.7" + d3-scale "^2.2.2" + d3-shape "^1.3.4" + fp-ts "^1.14.2" + konva "^2.6.0" + lodash "^4.17.11" + luxon "^1.11.3" + mobx "^4.9.2" + mobx-react "^5.4.3" + newtype-ts "^0.2.4" + prop-types "^15.7.2" + react "^16.8.3" + react-dom "^16.8.3" + react-konva "16.8.3" + react-spring "^8.0.8" + resize-observer-polyfill "^1.5.1" + "@elastic/elasticsearch@^7.0.0-rc.2": version "7.0.0-rc.2" resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-7.0.0-rc.2.tgz#2fb07978d647a257af3976b170e3f61704ba0a18" @@ -3271,6 +3297,11 @@ resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== +"@types/luxon@^1.11.1": + version "1.12.0" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-1.12.0.tgz#acf14294d18e6eba427a5e5d7dfce0f5cd2a9400" + integrity sha512-+UzPmwHSEEyv7aGlNkVpuFxp/BirXgl8NnPGCtmyx2KXIzAapoW3IqSVk87/Z3PUk8vEL8Pe1HXEMJbNBOQgtg== + "@types/mime-db@*": version "1.27.0" resolved "https://registry.yarnpkg.com/@types/mime-db/-/mime-db-1.27.0.tgz#9bc014a1fd1fdf47649c1a54c6dd7966b8284792" @@ -7373,7 +7404,7 @@ classnames@2.2.5, classnames@2.x, classnames@^2.2.4: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d" integrity sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0= -classnames@^2.2.3, classnames@^2.2.5: +classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== @@ -8783,7 +8814,7 @@ d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0: resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== -d3-array@^2.0.2: +d3-array@^2.0.2, d3-array@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.0.3.tgz#9c0531eda701e416f28a030e3d4e6179ba74f19f" integrity sha512-C7g4aCOoJa+/K5hPVqZLG8wjYHsTUROTk7Z1Ep9F4P5l+WVrvV0+6nAZ1wKTRLMhFWpGbozxUpyjIPZYAaLi+g== @@ -8959,7 +8990,7 @@ d3-scale@^1.0.5: d3-time "1" d3-time-format "2" -d3-scale@^2.1.2: +d3-scale@^2.1.2, d3-scale@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f" integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw== @@ -8985,6 +9016,13 @@ d3-shape@^1.2.2: dependencies: d3-path "1" +d3-shape@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.5.tgz#e81aea5940f59f0a79cfccac012232a8987c6033" + integrity sha512-VKazVR3phgD+MUCldapHD7P9kcrvPcexeX/PkMJmkUov4JM8IxsSg1DvbYoYich9AtdTsa5nNk2++ImPiDiSxg== + dependencies: + d3-path "1" + d3-time-format@2, d3-time-format@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.3.tgz#ae06f8e0126a9d60d6364eac5b1533ae1bac826b" @@ -11902,6 +11940,11 @@ fp-ts@^1.0.0: resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-1.12.0.tgz#d333310e4ac104cdcb6bea47908e381bb09978e7" integrity sha512-fWwnAgVlTsV26Ruo9nx+fxNHIm6l1puE1VJ/C0XJ3nRQJJJIgRHYw6sigB3MuNFZL1o4fpGlhwFhcbxHK0RsOA== +fp-ts@^1.14.2: + version "1.17.1" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-1.17.1.tgz#8ee8f4aa7107654b837f99f6aef915e655b1ce5e" + integrity sha512-B/pJfKxcPbDLzD4FJIQQeDtgPkYpihtS14/pbJJf5FwZ8FX4g3lXVraN4De7PSYOeV7RX/1dpc+Ri7F9N9fYtQ== + fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" @@ -13695,7 +13738,7 @@ hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0, hoist-non-react- resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" integrity sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw== -hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" integrity sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA== @@ -16384,6 +16427,11 @@ known-css-properties@^0.3.0: resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.3.0.tgz#a3d135bbfc60ee8c6eacf2f7e7e6f2d4755e49a4" integrity sha512-QMQcnKAiQccfQTqtBh/qwquGZ2XK/DXND1jrcN9M8gMMy99Gwla7GQjndVUsEqIaRyP6bsFRuhwRj5poafBGJQ== +konva@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/konva/-/konva-2.6.0.tgz#43165b95e32a4378ce532d9113c914f4998409c3" + integrity sha512-LCOoavICTD9PYoAqtWo8sbxYtCiXdgEeY7vj/Sq8b2bwFmrQr9Ak0RkD4/jxAf5fcUQRL5e1zPLyfRpVndp20A== + kopy@^8.2.0: version "8.2.5" resolved "https://registry.yarnpkg.com/kopy/-/kopy-8.2.5.tgz#6c95f312e981ab917680d7e5de3cdf29a1bf221f" @@ -17390,6 +17438,11 @@ lru-queue@0.1: dependencies: es5-ext "~0.10.2" +luxon@^1.11.3: + version "1.12.1" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.12.1.tgz#924bd61404f70b0cc5168918cb0ac108e52aacc4" + integrity sha512-Zv/qJb2X1ESTrlniAViWx2aqGwi2cVpeoZFTbPdPiCu4EsadKsmb/QCH8HQjMUpDZKKJIHKHsJxV5Rwpq47HKQ== + lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" @@ -18093,6 +18146,19 @@ mkdirp@^0.3.5, mkdirp@~0.3.5: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.5.tgz#de3e5f8961c88c787ee1368df849ac4413eca8d7" integrity sha1-3j5fiWHIjHh+4TaN+EmsRBPsqNc= +mobx-react@^5.4.3: + version "5.4.3" + resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-5.4.3.tgz#6709b7dd89670c40e9815914ac2ca49cc02bfb47" + integrity sha512-WC8yFlwvJ91hy8j6CrydAuFteUafcuvdITFQeHl3LRIf5ayfT/4W3M/byhEYD2BcJWejeXr8y4Rh2H26RunCRQ== + dependencies: + hoist-non-react-statics "^3.0.0" + react-lifecycles-compat "^3.0.2" + +mobx@^4.9.2: + version "4.9.4" + resolved "https://registry.yarnpkg.com/mobx/-/mobx-4.9.4.tgz#bb37a0e4e05f0b02be89ced9d23445cad73377ad" + integrity sha512-RaEpydw7D1ebp1pdFHrEMZcLk4nALAZyHAroCPQpqLzuIXIxJpLmMIe5PUZwYHqvlcWL6DVqDYCANZpPOi9iXA== + mocha@3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/mocha/-/mocha-3.3.0.tgz#d29b7428d3f52c82e2e65df1ecb7064e1aabbfb5" @@ -18163,6 +18229,11 @@ monaco-editor@^0.14.3: resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.14.3.tgz#7cc4a4096a3821f52fea9b10489b527ef3034e22" integrity sha512-RhaO4xXmWn/p0WrkEOXe4PoZj6xOcvDYjoAh0e1kGUrQnP1IOpc0m86Ceuaa2CLEMDINqKijBSmqhvBQnsPLHQ== +monocle-ts@^1.0.0: + version "1.7.1" + resolved "https://registry.yarnpkg.com/monocle-ts/-/monocle-ts-1.7.1.tgz#03a615938aa90983a4fa29749969d30f72d80ba1" + integrity sha512-X9OzpOyd/R83sYex8NYpJjUzi/MLQMvGNVfxDYiIvs+QMXMEUDwR61MQoARFN10Cqz5h/mbFSPnIQNUIGhYd2Q== + monotone-convex-hull-2d@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/monotone-convex-hull-2d/-/monotone-convex-hull-2d-1.0.1.tgz#47f5daeadf3c4afd37764baa1aa8787a40eee08c" @@ -18408,6 +18479,14 @@ nested-object-assign@^1.0.1: resolved "https://registry.yarnpkg.com/nested-object-assign/-/nested-object-assign-1.0.3.tgz#5aca69390d9affe5a612152b5f0843ae399ac597" integrity sha512-kgq1CuvLyUcbcIuTiCA93cQ2IJFSlRwXcN+hLcb2qLJwC2qrePHGZZa7IipyWqaWF6tQjdax2pQnVxdq19Zzwg== +newtype-ts@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/newtype-ts/-/newtype-ts-0.2.4.tgz#a02a8f160a3d179f871848d687a93de73a964a41" + integrity sha512-HrzPdG0+0FK1qHbc3ld/HXu252OYgmN993bFxUtZ6NFCLUk1eq+yKwdvP07BblXQibGqMWNXBUrNoLUq23Ma2Q== + dependencies: + fp-ts "^1.0.0" + monocle-ts "^1.0.0" + next-tick@1: version "1.0.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" @@ -20467,7 +20546,7 @@ prop-types@^15.5.7: loose-envify "^1.3.1" object-assign "^4.1.1" -prop-types@^15.6.0, prop-types@^15.6.2: +prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -21168,6 +21247,16 @@ react-dom@^16.8.1: prop-types "^15.6.2" scheduler "^0.13.5" +react-dom@^16.8.3: + version "16.8.6" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f" + integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.13.6" + react-draggable@3.x, "react-draggable@^2.2.6 || ^3.0.3": version "3.0.5" resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.5.tgz#c031e0ed4313531f9409d6cd84c8ebcec0ddfe2d" @@ -21324,6 +21413,14 @@ react-is@~16.3.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.3.2.tgz#f4d3d0e2f5fbb6ac46450641eb2e25bf05d36b22" integrity sha512-ybEM7YOr4yBgFd6w8dJqwxegqZGJNBZl6U27HnGKuTZmDvVrD5quWOK/wAnMywiZzW+Qsk+l4X2c70+thp/A8Q== +react-konva@16.8.3: + version "16.8.3" + resolved "https://registry.yarnpkg.com/react-konva/-/react-konva-16.8.3.tgz#e55390040ea54675a0ef0d40b4fa93731e6d7b03" + integrity sha512-gU36TBxcPZANQOV5prAFnpRSNp2ikAT7zCICHTBJvOzAfa8Yhcyaey6EIrD+NTT/4y0PyGFBIkmWq6zdrlNrQg== + dependencies: + react-reconciler "^0.20.1" + scheduler "^0.13.3" + react-lib-adler32@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/react-lib-adler32/-/react-lib-adler32-1.0.3.tgz#63df1aed274eabcc1c5067077ea281ec30888ba7" @@ -21415,6 +21512,16 @@ react-portal@^3.2.0: dependencies: prop-types "^15.5.8" +react-reconciler@^0.20.1: + version "0.20.4" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.20.4.tgz#3da6a95841592f849cb4edd3d38676c86fd920b2" + integrity sha512-kxERc4H32zV2lXMg/iMiwQHOtyqf15qojvkcZ5Ja2CPkjVohHw9k70pdDBwrnQhLVetUJBSYyqU3yqrlVTOajA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.13.6" + react-redux-request@^1.5.6: version "1.5.6" resolved "https://registry.yarnpkg.com/react-redux-request/-/react-redux-request-1.5.6.tgz#8c514dc88264d225e113b4b54a265064e8020651" @@ -21565,6 +21672,13 @@ react-sizeme@^2.3.6: invariant "^2.2.2" lodash "^4.17.4" +react-spring@^8.0.8: + version "8.0.19" + resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-8.0.19.tgz#62f4f396b4b73fa402838200a1c80374338cb12e" + integrity sha512-DjrwjXqqVEitj6e6GqdW5dUp1BoVyeFQhEcXvPfoQxwyIVSJ9smNt8CNjSvoQqRujVllE7XKaJRWSZO/ewd1/A== + dependencies: + "@babel/runtime" "^7.3.1" + react-sticky@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/react-sticky/-/react-sticky-6.0.3.tgz#7a18b643e1863da113d7f7036118d2a75d9ecde4" @@ -21713,6 +21827,16 @@ react@^16.8.1: prop-types "^15.6.2" scheduler "^0.13.5" +react@^16.8.3: + version "16.8.6" + resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe" + integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.13.6" + reactcss@1.2.3, reactcss@^1.2.0: version "1.2.3" resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd" @@ -23060,6 +23184,14 @@ scheduler@^0.13.2: loose-envify "^1.1.0" object-assign "^4.1.1" +scheduler@^0.13.3, scheduler@^0.13.6: + version "0.13.6" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889" + integrity sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.13.5: version "0.13.5" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.5.tgz#b7226625167041298af3b98088a9dbbf6d7733a8" From 11a9173d93883c13de82f9adb906609c282b69c1 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 7 May 2019 11:51:18 -0400 Subject: [PATCH 002/105] [lens] Initial state for IndexPatternDatasource (#36052) * [lens] Add first tests to indexpattern data source * Respond to review comments * Fix type definitions --- .../public/editor_frame_plugin/plugin.tsx | 4 +- .../indexpattern_plugin/indexpattern.test.ts | 89 +++++++++++++++++ .../indexpattern_plugin/indexpattern.tsx | 95 +++++++++++++++---- x-pack/plugins/lens/public/types.ts | 27 ++++-- 4 files changed, 189 insertions(+), 26 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx index 9d9224e462a5a..21ef8977eb9c8 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -33,10 +33,10 @@ class EditorFramePlugin { // on it's own because we are loosing type information here. // So it's basically explicitly saying "I'm dropping the information about type T here // because this information isn't useful to me." but without using any which can leak - this.datasources[name] = datasource as Datasource; + this.datasources[name] = datasource as Datasource; }, registerVisualization: (name, visualization) => { - this.visualizations[name] = visualization as Visualization; + this.visualizations[name] = visualization as Visualization; }, }; } diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts new file mode 100644 index 0000000000000..b561d9b4463e7 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.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 { indexPatternDatasource, IndexPatternPersistedState } from './indexpattern'; +import { DatasourcePublicAPI, Operation } from '../types'; + +describe('IndexPattern Data Source', () => { + let persistedState: IndexPatternPersistedState; + + beforeEach(() => { + persistedState = { + currentIndexPattern: '', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'My Op', + dataType: 'string', + isBucketed: false, + + // Private + operationType: 'value', + }, + }, + }; + }); + + describe('#initialize', () => { + it('should load a default state', async () => { + const state = await indexPatternDatasource.initialize(); + expect(state).toEqual({ + currentIndexPattern: '', + indexPatterns: {}, + columns: {}, + columnOrder: [], + }); + }); + + it('should initialize from saved state', async () => { + const state = await indexPatternDatasource.initialize(persistedState); + + expect(state).toEqual({ + ...persistedState, + indexPatterns: {}, + }); + }); + }); + + describe('#getPersistedState', () => { + it('should persist from saved state', async () => { + const state = await indexPatternDatasource.initialize(persistedState); + + expect(indexPatternDatasource.getPersistableState(state)).toEqual(persistedState); + }); + }); + + describe('#getPublicAPI', () => { + let publicAPI: DatasourcePublicAPI; + + beforeEach(async () => { + const initialState = await indexPatternDatasource.initialize(persistedState); + publicAPI = indexPatternDatasource.getPublicAPI(initialState, () => {}); + }); + + describe('getTableSpec', () => { + it('should include col1', () => { + expect(publicAPI.getTableSpec()).toEqual([ + { + columnId: 'col1', + }, + ]); + }); + }); + + describe('getOperationForColumnId', () => { + it('should get an operation for col1', () => { + expect(publicAPI.getOperationForColumnId('col1')).toEqual({ + id: 'op1', + label: 'My Op', + dataType: 'string', + isBucketed: false, + } as Operation); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 5b024be3b718e..29b018320a6db 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -9,36 +9,97 @@ import { render } from 'react-dom'; import { Datasource, DataType } from '..'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps } from '../types'; -interface IndexPatternPrivateState { - query: object; +type OperationType = 'value' | 'terms' | 'date_histogram'; + +interface IndexPatternColumn { + // Public + operationId: string; + label: string; + dataType: DataType; + isBucketed: false; + + // Private + operationType: OperationType; +} + +export interface IndexPattern { + id: string; + fields: Field[]; + title: string; + timeFieldName?: string; } +export interface Field { + name: string; + type: string; + aggregatable: boolean; + searchable: boolean; +} + +export interface IndexPatternPersistedState { + currentIndexPattern: string; + + columnOrder: string[]; + columns: { + [columnId: string]: IndexPatternColumn; + }; +} + +export type IndexPatternPrivateState = IndexPatternPersistedState & { + indexPatterns: { [id: string]: IndexPattern }; +}; + // Not stateful. State is persisted to the frame -export const indexPatternDatasource: Datasource = { - async initialize() { - return { query: {} }; +export const indexPatternDatasource: Datasource< + IndexPatternPrivateState, + IndexPatternPersistedState +> = { + async initialize(state?: IndexPatternPersistedState) { + // TODO: Make fetch request to load indexPatterns from saved objects + if (state) { + return { + ...state, + indexPatterns: {}, + }; + } + return { + currentIndexPattern: '', + indexPatterns: {}, + columns: {}, + columnOrder: [], + }; + }, + + getPersistableState({ currentIndexPattern, columns, columnOrder }: IndexPatternPrivateState) { + return { currentIndexPattern, columns, columnOrder }; }, toExpression(state: IndexPatternPrivateState) { - return `${JSON.stringify(state.query)}`; + return `${JSON.stringify(state.columns)}`; }, renderDataPanel(domElement: Element, props: DatasourceDataPanelProps) { render(
Index Pattern Data Source
, domElement); }, - getPublicAPI() { - // TODO: Provide state to each of these + getPublicAPI(state, setState) { return { - getTableSpec: () => [], - getOperationForColumnId: (columnId: string) => ({ - id: '', - // User-facing label for the operation - label: '', - dataType: 'string' as DataType, - // A bucketed operation has many values the same - isBucketed: false, - }), + getTableSpec: () => { + return state.columnOrder.map(colId => ({ columnId: colId })); + }, + getOperationForColumnId: (columnId: string) => { + const column = state.columns[columnId]; + if (columnId) { + const { dataType, label, isBucketed, operationId } = column; + return { + id: operationId, + label, + dataType, + isBucketed, + }; + } + return null; + }, renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => {}, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 0f6e7a801732b..96586abb8b5aa 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -7,8 +7,8 @@ export interface EditorFrameSetup { render: (domElement: Element) => void; // generic type on the API functions to pull the "unknown vs. specific type" error into the implementation - registerDatasource: (name: string, datasource: Datasource) => void; - registerVisualization: (name: string, visualization: Visualization) => void; + registerDatasource: (name: string, datasource: Datasource) => void; + registerVisualization: (name: string, visualization: Visualization) => void; } // Hints the default nesting to the data source. 0 is the highest priority @@ -37,9 +37,14 @@ export interface DatasourceSuggestion { /** * Interface for the datasource registry */ -export interface Datasource { - // For initializing from saved object - initialize: (state?: T) => Promise; +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; renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps) => void; @@ -56,7 +61,7 @@ export interface Datasource { */ export interface DatasourcePublicAPI { getTableSpec: () => TableSpec; - getOperationForColumnId: (columnId: string) => Operation; + getOperationForColumnId: (columnId: string) => Operation | null; // Render can be called many times renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => void; @@ -133,7 +138,15 @@ export interface VisualizationSuggestion { datasourceSuggestionId: string; } -export interface Visualization { +export interface Visualization { + // 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 + // visualizations should validate their arguments + initialize: (state?: P) => T; + + // Given the current state, which parts should be saved? + getPersistableState: (state: T) => P; + renderConfigPanel: (props: VisualizationProps) => void; toExpression: (state: T, datasource: DatasourcePublicAPI) => string; From c7e07043fe504734d9f1be45a5fa73fcb7d5bc8c Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Thu, 9 May 2019 11:46:21 -0400 Subject: [PATCH 003/105] [lens] Editor frame initializes datasources and visualizations (#36060) * [lens] Editor frame initializes datasources and visualizations * Respond to review comments * Fix build issues * Fix state management issue --- .../plugins/lens/public/app_plugin/plugin.tsx | 4 + .../editor_frame_plugin/editor_frame.tsx | 116 +++++++++++++++--- .../public/editor_frame_plugin/plugin.tsx | 22 +++- x-pack/plugins/lens/public/types.ts | 2 +- .../public/xy_visualization_plugin/index.ts | 7 ++ .../public/xy_visualization_plugin/plugin.tsx | 22 ++++ .../xy_visualization.test.ts | 43 +++++++ .../xy_visualization.tsx | 37 ++++++ 8 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/index.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx diff --git a/x-pack/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/plugins/lens/public/app_plugin/plugin.tsx index c885d44dff765..1a096d7c1326c 100644 --- a/x-pack/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/app_plugin/plugin.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { editorFrameSetup, editorFrameStop } from '../editor_frame_plugin'; import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin'; +import { xyVisualizationSetup, xyVisualizationStop } from '../xy_visualization_plugin'; import { App } from './app'; export class AppPlugin { @@ -16,15 +17,18 @@ export class AppPlugin { // 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 xyVisualization = xyVisualizationSetup(); const editorFrame = editorFrameSetup(); editorFrame.registerDatasource('indexpattern', indexPattern); + editorFrame.registerVisualization('xy', xyVisualization); return ; } stop() { indexPatternDatasourceStop(); + xyVisualizationStop(); editorFrameStop(); } } diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx index 091862e635d99..40b9ba40cd430 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx @@ -4,34 +4,120 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useReducer, useEffect } from 'react'; import { Datasource, Visualization } from '../types'; interface EditorFrameProps { datasources: { [key: string]: Datasource }; visualizations: { [key: string]: Visualization }; + + initialDatasource?: string; +} + +interface DatasourceState { + datasourceName: string; + visualizationName: string; + + datasourceState: any; + visualizationState: any; +} + +interface UpdateDatasourceAction { + type: 'UPDATE_DATASOURCE'; + payload: any; +} + +interface UpdateVisualizationAction { + type: 'UPDATE_VISUALIZATION'; + payload: any; +} + +type Action = UpdateDatasourceAction | UpdateVisualizationAction; + +function stateReducer(state: DatasourceState, action: Action): DatasourceState { + switch (action.type) { + case 'UPDATE_DATASOURCE': + return { + ...state, + datasourceState: action.payload, + }; + case 'UPDATE_VISUALIZATION': + return { + ...state, + visualizationState: action.payload, + }; + } + return state; } export function EditorFrame(props: EditorFrameProps) { - const keys = Object.keys(props.datasources); + const dsKeys = Object.keys(props.datasources); + const vKeys = Object.keys(props.visualizations); + + const [state, dispatch] = useReducer(stateReducer, { + datasourceName: props.initialDatasource || dsKeys[0], + visualizationName: vKeys[0], + + datasourceState: null, + visualizationState: null, + }); + + useEffect(() => { + const vState = props.visualizations[state.visualizationName].initialize(); + props.datasources[state.datasourceName].initialize().then(dsState => { + dispatch({ + type: 'UPDATE_DATASOURCE', + payload: dsState, + }); + }); + + dispatch({ + type: 'UPDATE_VISUALIZATION', + payload: vState, + }); + }, []); return (

Editor Frame

- {keys.map(key => ( -
{ - if (domElement) { - props.datasources[key].renderDataPanel(domElement, { - state: {}, - setState: () => {}, - }); - } - }} - /> - ))} +
{ + if (domElement) { + props.datasources[state.datasourceName].renderDataPanel(domElement, { + state: state.datasourceState, + setState: newState => + dispatch({ + type: 'UPDATE_DATASOURCE', + payload: newState, + }), + }); + } + }} + /> + +
{ + if (domElement) { + props.visualizations[state.visualizationName].renderConfigPanel(domElement, { + datasource: props.datasources[state.datasourceName].getPublicAPI( + state.datasourceState, + newState => + dispatch({ + type: 'UPDATE_DATASOURCE', + payload: newState, + }) + ), + state: state.visualizationState, + setState: newState => + dispatch({ + type: 'UPDATE_VISUALIZATION', + payload: newState, + }), + }); + } + }} + />
); } diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx index 21ef8977eb9c8..6a0a82877cefb 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -13,8 +13,14 @@ import { EditorFrame } from './editor_frame'; class EditorFramePlugin { constructor() {} - private datasources: { [key: string]: Datasource } = {}; - private visualizations: { [key: string]: Visualization } = {}; + private datasources: { + [key: string]: Datasource; + } = {}; + private visualizations: { + [key: string]: Visualization; + } = {}; + + private initialDatasource?: string; private element: Element | null = null; @@ -23,7 +29,11 @@ class EditorFramePlugin { render: domElement => { this.element = domElement; render( - , + , domElement ); }, @@ -33,7 +43,11 @@ class EditorFramePlugin { // on it's own because we are loosing type information here. // So it's basically explicitly saying "I'm dropping the information about type T here // because this information isn't useful to me." but without using any which can leak - this.datasources[name] = datasource as Datasource; + this.datasources[name] = datasource as Datasource; + + if (!this.initialDatasource) { + this.initialDatasource = name; + } }, registerVisualization: (name, visualization) => { this.visualizations[name] = visualization as Visualization; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 96586abb8b5aa..ab70ff10e1cb5 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -147,7 +147,7 @@ export interface Visualization { // Given the current state, which parts should be saved? getPersistableState: (state: T) => P; - renderConfigPanel: (props: VisualizationProps) => void; + renderConfigPanel: (domElement: Element, props: VisualizationProps) => void; toExpression: (state: T, datasource: DatasourcePublicAPI) => string; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/index.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/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/plugins/lens/public/xy_visualization_plugin/plugin.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx new file mode 100644 index 0000000000000..14f2511572f28 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { xyVisualization } from './xy_visualization'; + +class XyVisualizationPlugin { + constructor() {} + + setup() { + return xyVisualization; + } + + stop() {} +} + +const plugin = new XyVisualizationPlugin(); + +export const xyVisualizationSetup = () => plugin.setup(); +export const xyVisualizationStop = () => plugin.stop(); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts new file mode 100644 index 0000000000000..a750d810d116c --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { xyVisualization, XyVisualizationPersistedState } from './xy_visualization'; + +describe('IndexPattern Data Source', () => { + let persistedState: XyVisualizationPersistedState; + + beforeEach(() => { + persistedState = { + roles: [], + }; + }); + + describe('#initialize', () => { + it('loads default state', () => { + expect(xyVisualization.initialize()).toEqual({ + roles: [], + }); + }); + + it('loads from persisted state', () => { + expect(xyVisualization.initialize(persistedState)).toEqual({ + roles: [], + }); + }); + }); + + describe('#getPersistableState', () => { + it('persists the state as given', () => { + expect( + xyVisualization.getPersistableState({ + roles: [], + }) + ).toEqual({ + roles: [], + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx new file mode 100644 index 0000000000000..7ec2994161216 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -0,0 +1,37 @@ +/* + * 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 { Visualization, DimensionRole } from '../types'; + +export interface XyVisualizationState { + roles: DimensionRole[]; +} + +export type XyVisualizationPersistedState = XyVisualizationState; + +export const xyVisualization: Visualization = { + initialize() { + return { + roles: [], + }; + }, + + getPersistableState(state) { + return state; + }, + + renderConfigPanel: (domElement, props) => { + render(
XY Visualization
, domElement); + }, + + getSuggestions: options => [], + + getMappingOfTableToRoles: (state, datasource) => [], + + toExpression: state => '', +}; From 0dfd683456b82f29442b9c4a27932878bbea7b96 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Fri, 10 May 2019 08:48:41 -0400 Subject: [PATCH 004/105] [lens][draft] Lens/drag drop (#36268) Add basic drag / drop component to Lens --- .../__snapshots__/drag_drop.test.tsx.snap | 20 +++ .../lens/public/drag_drop/drag_drop.scss | 7 + .../lens/public/drag_drop/drag_drop.test.tsx | 104 +++++++++++++ .../lens/public/drag_drop/drag_drop.tsx | 138 ++++++++++++++++++ x-pack/plugins/lens/public/drag_drop/index.ts | 8 + .../lens/public/drag_drop/providers.tsx | 85 +++++++++++ .../plugins/lens/public/drag_drop/readme.md | 69 +++++++++ 7 files changed, 431 insertions(+) create mode 100644 x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap create mode 100644 x-pack/plugins/lens/public/drag_drop/drag_drop.scss create mode 100644 x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx create mode 100644 x-pack/plugins/lens/public/drag_drop/drag_drop.tsx create mode 100644 x-pack/plugins/lens/public/drag_drop/index.ts create mode 100644 x-pack/plugins/lens/public/drag_drop/providers.tsx create mode 100644 x-pack/plugins/lens/public/drag_drop/readme.md diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap new file mode 100644 index 0000000000000..d18a2db614f55 --- /dev/null +++ b/x-pack/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/plugins/lens/public/drag_drop/drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss new file mode 100644 index 0000000000000..f0b3238f76f2e --- /dev/null +++ b/x-pack/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/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx new file mode 100644 index 0000000000000..9e78c9b48bf2b --- /dev/null +++ b/x-pack/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/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx new file mode 100644 index 0000000000000..35326a46a6820 --- /dev/null +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -0,0 +1,138 @@ +/* + * 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. + */ +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; +} + +/** + * 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/plugins/lens/public/drag_drop/index.ts b/x-pack/plugins/lens/public/drag_drop/index.ts new file mode 100644 index 0000000000000..e597bb8b6e893 --- /dev/null +++ b/x-pack/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/plugins/lens/public/drag_drop/providers.tsx b/x-pack/plugins/lens/public/drag_drop/providers.tsx new file mode 100644 index 0000000000000..c0b4eb563b32b --- /dev/null +++ b/x-pack/plugins/lens/public/drag_drop/providers.tsx @@ -0,0 +1,85 @@ +/* + * 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'; + +/** + * 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 = (dragging: unknown) => setState({ dragging }); + + 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) { + return {children}; +} diff --git a/x-pack/plugins/lens/public/drag_drop/readme.md b/x-pack/plugins/lens/public/drag_drop/readme.md new file mode 100644 index 0000000000000..8d11cb6226927 --- /dev/null +++ b/x-pack/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. From dd51978a9e063b2e6e679f29640fe1cf45281554 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 10 May 2019 20:37:20 +0200 Subject: [PATCH 005/105] remove local package (#36456) --- package.json | 2 +- x-pack/plugins/lens/package.json | 11 ----------- yarn.lock | 34 ++++---------------------------- 3 files changed, 5 insertions(+), 42 deletions(-) delete mode 100644 x-pack/plugins/lens/package.json diff --git a/package.json b/package.json index 7fffad48ce437..cd483b7306710 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@babel/core": "^7.3.4", "@babel/polyfill": "^7.2.5", "@babel/register": "^7.0.0", - "@elastic/charts": "^3.11.2", + "@elastic/charts": "^4.2.1", "@elastic/datemath": "5.0.2", "@elastic/eui": "10.4.0", "@elastic/filesaver": "1.1.2", diff --git a/x-pack/plugins/lens/package.json b/x-pack/plugins/lens/package.json deleted file mode 100644 index 3d61a09ba55c3..0000000000000 --- a/x-pack/plugins/lens/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "author": "Elastic", - "name": "lens", - "version": "7.0.0", - "private": true, - "license": "Elastic-License", - "devDependencies": {}, - "dependencies": { - "@elastic/charts": "^4.0.0" - } -} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 89319014d3903..f00ee894f18c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1325,36 +1325,10 @@ lodash "^4.17.11" to-fast-properties "^2.0.0" -"@elastic/charts@^3.11.2": - version "3.11.2" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-3.11.2.tgz#3644eeb7c0d17d6c368cfb380c2d2ef268655d72" - integrity sha512-ILU4ijT5GZC4usnbab/IVq1brQ6tFgkfBPwa4ixRtN5TlS+BWjPPVPFfIO3j4K9PijXE6XISfs8L2HkH1IDdtA== - dependencies: - "@types/d3-shape" "^1.3.1" - "@types/luxon" "^1.11.1" - classnames "^2.2.6" - d3-array "^2.0.3" - d3-collection "^1.0.7" - d3-scale "^2.2.2" - d3-shape "^1.3.4" - fp-ts "^1.14.2" - konva "^2.6.0" - lodash "^4.17.11" - luxon "^1.11.3" - mobx "^4.9.2" - mobx-react "^5.4.3" - newtype-ts "^0.2.4" - prop-types "^15.7.2" - react "^16.8.3" - react-dom "^16.8.3" - react-konva "16.8.3" - react-spring "^8.0.8" - resize-observer-polyfill "^1.5.1" - -"@elastic/charts@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-4.0.0.tgz#321313873985deb69106d479ee6a827eb81c890a" - integrity sha512-j/DfpdsKOx/QEbWJ6CvLlLP6XUYWqAHyuho3+38HngnoZSPzLGLte/ymW1CnaNYthQGTS9/4a47aZCkvQ6EdBw== +"@elastic/charts@^4.2.1": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-4.2.1.tgz#af50f73a4e41e30f07a2de3ee814fc9277fc476e" + integrity sha512-f01basHDEYnGN0eCNX6TpbmChWeuaK5jlvmsmmd1sL5odC26iz6ARE3JlhuqmwuYKm5c1saTE2aSQ3l0rc/DFA== dependencies: "@types/d3-shape" "^1.3.1" "@types/luxon" "^1.11.1" From ddb68e21332f3b91e7cac2918d1239d0e8731835 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Sun, 12 May 2019 15:30:50 +0200 Subject: [PATCH 006/105] [lens] Native renderer (#36165) * Add nativerenderer component * Use native renderer in app and editor frame --- x-pack/plugins/lens/public/app_plugin/app.tsx | 11 +- .../editor_frame_plugin/editor_frame.tsx | 59 +++--- x-pack/plugins/lens/public/index.ts | 2 +- .../lens/public/native_renderer/index.ts | 7 + .../native_renderer/native_renderer.test.tsx | 187 ++++++++++++++++++ .../native_renderer/native_renderer.tsx | 80 ++++++++ 6 files changed, 305 insertions(+), 41 deletions(-) create mode 100644 x-pack/plugins/lens/public/native_renderer/index.ts create mode 100644 x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx create mode 100644 x-pack/plugins/lens/public/native_renderer/native_renderer.tsx diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 3e16a78083092..29f1c2eec03f0 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -5,23 +5,18 @@ */ import { I18nProvider } from '@kbn/i18n/react'; -import React, { useCallback } from 'react'; +import React from 'react'; import { EditorFrameSetup } from '../types'; +import { NativeRenderer } from '../native_renderer'; export function App({ editorFrame }: { editorFrame: EditorFrameSetup }) { - const renderFrame = useCallback(node => { - if (node !== null) { - editorFrame.render(node); - } - }, []); - return (

Lens

-
+
); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx index 40b9ba40cd430..64bc73cd0447a 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx @@ -6,6 +6,7 @@ import React, { useReducer, useEffect } from 'react'; import { Datasource, Visualization } from '../types'; +import { NativeRenderer } from '../native_renderer'; interface EditorFrameProps { datasources: { [key: string]: Datasource }; @@ -81,41 +82,35 @@ export function EditorFrame(props: EditorFrameProps) {

Editor Frame

-
{ - if (domElement) { - props.datasources[state.datasourceName].renderDataPanel(domElement, { - state: state.datasourceState, - setState: newState => - dispatch({ - type: 'UPDATE_DATASOURCE', - payload: newState, - }), - }); - } + + dispatch({ + type: 'UPDATE_DATASOURCE', + payload: newState, + }), }} /> -
{ - if (domElement) { - props.visualizations[state.visualizationName].renderConfigPanel(domElement, { - datasource: props.datasources[state.datasourceName].getPublicAPI( - state.datasourceState, - newState => - dispatch({ - type: 'UPDATE_DATASOURCE', - payload: newState, - }) - ), - state: state.visualizationState, - setState: newState => - dispatch({ - type: 'UPDATE_VISUALIZATION', - payload: newState, - }), - }); - } + + dispatch({ + type: 'UPDATE_DATASOURCE', + payload: newState, + }) + ), + state: state.visualizationState, + setState: (newState: any) => + dispatch({ + type: 'UPDATE_VISUALIZATION', + payload: newState, + }), }} />
diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index 874249ff75d85..f8f074cdb99bf 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -6,8 +6,8 @@ export * from './types'; -import { IScope } from 'angular'; import { render, unmountComponentAtNode } from 'react-dom'; +import { IScope } from 'angular'; import chrome from 'ui/chrome'; import { appSetup, appStop } from './app_plugin'; diff --git a/x-pack/plugins/lens/public/native_renderer/index.ts b/x-pack/plugins/lens/public/native_renderer/index.ts new file mode 100644 index 0000000000000..0ef9bd8807bc5 --- /dev/null +++ b/x-pack/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/plugins/lens/public/native_renderer/native_renderer.test.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx new file mode 100644 index 0000000000000..af5196165f9a5 --- /dev/null +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx @@ -0,0 +1,187 @@ +/* + * 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 not render again if props do not change', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(1); + }); + + it('should not render again if props do not change shallowly', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(1); + }); + + it('should not render again for unchanged callback functions', () => { + const renderSpy = jest.fn(); + const testCallback = () => {}; + const testState = { a: 'abc' }; + + render( + , + mountpoint + ); + render( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(1); + }); + + it('should render again once if props change', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(2); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).lastCalledWith(containerElement, { a: 'def' }); + }); + + it('should render again once if props is just a string', () => { + const renderSpy = jest.fn(); + const testProps = 'abc'; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks(, mountpoint); + renderAndTriggerHooks(, mountpoint); + expect(renderSpy).toHaveBeenCalledTimes(2); + 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 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/plugins/lens/public/native_renderer/native_renderer.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx new file mode 100644 index 0000000000000..f0eb4b829c153 --- /dev/null +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx @@ -0,0 +1,80 @@ +/* + * 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, useRef } from 'react'; + +export interface NativeRendererProps { + render: (domElement: Element, props: T) => void; + nativeProps: T; + tag?: string; + children?: never; +} + +function is(x: unknown, y: unknown) { + return (x === y && (x !== 0 || 1 / (x as number) === 1 / (y as number))) || (x !== x && y !== y); +} + +function isShallowDifferent(objA: T, objB: T): boolean { + if (is(objA, objB)) { + return false; + } + + if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { + return true; + } + + const keysA = Object.keys(objA) as Array; + const keysB = Object.keys(objB) as Array; + + if (keysA.length !== keysB.length) { + return true; + } + + for (let i = 0; i < keysA.length; i++) { + if (!window.hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { + return true; + } + } + + return false; +} + +/** + * A component which takes care of providing a mountpoint for a generic render + * function which takes an html element and an optional props object. + * It also takes care of calling render again if the props object changes. + * 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 }: NativeRendererProps) { + const elementRef = useRef(null); + const propsRef = useRef(null); + + function renderAndUpdate(element: Element) { + elementRef.current = element; + propsRef.current = nativeProps; + render(element, nativeProps); + } + + useEffect( + () => { + if (elementRef.current && isShallowDifferent(propsRef.current, nativeProps)) { + renderAndUpdate(elementRef.current); + } + }, + [nativeProps] + ); + + return React.createElement(tag || 'div', { + ref: element => { + if (element && element !== elementRef.current) { + renderAndUpdate(element); + } + }, + }); +} From 2961d760ecb0fa2e8407c94032eaef75086d51ba Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 21 May 2019 04:24:07 -0400 Subject: [PATCH 007/105] [Lens] No explicit any (#36515) --- .eslintrc.js | 10 ++++++++++ x-pack/plugins/lens/index.ts | 3 ++- .../plugins/lens/public/drag_drop/drag_drop.test.tsx | 2 +- .../lens/public/editor_frame_plugin/editor_frame.tsx | 12 ++++++------ 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index d09551ef27422..70c1d8ff53f0c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -407,6 +407,16 @@ module.exports = { }, }, + /** + * Lens overrides + */ + { + files: ['x-pack/plugins/lens/**/*.ts', 'x-pack/plugins/lens/**/*.tsx'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, + /** * disable jsx-a11y for kbn-ui-framework */ diff --git a/x-pack/plugins/lens/index.ts b/x-pack/plugins/lens/index.ts index 5d868d9f63a18..8955bccbde268 100644 --- a/x-pack/plugins/lens/index.ts +++ b/x-pack/plugins/lens/index.ts @@ -7,12 +7,13 @@ import * as Joi from 'joi'; import { Server } from 'hapi'; import { resolve } from 'path'; +import { LegacyPluginInitializer } from 'src/legacy/types'; import { PLUGIN_ID } from './common'; const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; -export const lens = (kibana: any) => { +export const lens: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ id: PLUGIN_ID, configPrefix: `xpack.${PLUGIN_ID}`, diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx index 9e78c9b48bf2b..7471039a482bf 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx @@ -90,7 +90,7 @@ describe('DragDrop', () => { test('droppable is reflected in the className', () => { const component = render( { + onDrop={(x: unknown) => { throw x; }} droppable diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx index 64bc73cd0447a..9ab026db082a2 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx @@ -19,18 +19,18 @@ interface DatasourceState { datasourceName: string; visualizationName: string; - datasourceState: any; - visualizationState: any; + datasourceState: unknown; + visualizationState: unknown; } interface UpdateDatasourceAction { type: 'UPDATE_DATASOURCE'; - payload: any; + payload: unknown; } interface UpdateVisualizationAction { type: 'UPDATE_VISUALIZATION'; - payload: any; + payload: unknown; } type Action = UpdateDatasourceAction | UpdateVisualizationAction; @@ -86,7 +86,7 @@ export function EditorFrame(props: EditorFrameProps) { render={props.datasources[state.datasourceName].renderDataPanel} nativeProps={{ state: state.datasourceState, - setState: (newState: any) => + setState: (newState: unknown) => dispatch({ type: 'UPDATE_DATASOURCE', payload: newState, @@ -106,7 +106,7 @@ export function EditorFrame(props: EditorFrameProps) { }) ), state: state.visualizationState, - setState: (newState: any) => + setState: (newState: unknown) => dispatch({ type: 'UPDATE_VISUALIZATION', payload: newState, From 4581a2f67d1a6f4df0e6daa8483c2b84b0948f85 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 22 May 2019 11:42:54 +0200 Subject: [PATCH 008/105] [Lens] Implement basic editor frame state handling (#36443) --- x-pack/plugins/lens/public/app_plugin/app.tsx | 6 +- .../plugins/lens/public/app_plugin/plugin.tsx | 12 +- .../editor_frame_plugin/editor_frame.tsx | 118 ----- .../editor_frame/config_panel_wrapper.tsx | 63 +++ .../editor_frame/data_panel_wrapper.tsx | 58 +++ .../editor_frame/editor_frame.test.tsx | 442 ++++++++++++++++++ .../editor_frame/editor_frame.tsx | 95 ++++ .../editor_frame/frame_layout.tsx | 23 + .../editor_frame_plugin/editor_frame/index.ts | 7 + .../editor_frame/state_management.test.ts | 145 ++++++ .../editor_frame/state_management.ts | 107 +++++ .../editor_frame_plugin/plugin.test.tsx | 112 +++++ .../public/editor_frame_plugin/plugin.tsx | 55 ++- x-pack/plugins/lens/public/types.ts | 14 +- 14 files changed, 1101 insertions(+), 156 deletions(-) delete mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/index.ts create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 29f1c2eec03f0..16860142e28d0 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -7,16 +7,16 @@ import { I18nProvider } from '@kbn/i18n/react'; import React from 'react'; -import { EditorFrameSetup } from '../types'; +import { EditorFrameInstance } from '../types'; import { NativeRenderer } from '../native_renderer'; -export function App({ editorFrame }: { editorFrame: EditorFrameSetup }) { +export function App({ editorFrame }: { editorFrame: EditorFrameInstance }) { return (

Lens

- +
); diff --git a/x-pack/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/plugins/lens/public/app_plugin/plugin.tsx index 1a096d7c1326c..857cee9adbc64 100644 --- a/x-pack/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/app_plugin/plugin.tsx @@ -9,8 +9,11 @@ import { editorFrameSetup, editorFrameStop } from '../editor_frame_plugin'; import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin'; import { xyVisualizationSetup, xyVisualizationStop } from '../xy_visualization_plugin'; import { App } from './app'; +import { EditorFrameInstance } from '../types'; export class AppPlugin { + private instance: EditorFrameInstance | null = null; + constructor() {} setup() { @@ -23,10 +26,17 @@ export class AppPlugin { editorFrame.registerDatasource('indexpattern', indexPattern); editorFrame.registerVisualization('xy', xyVisualization); - return ; + this.instance = editorFrame.createInstance({}); + + return ; } stop() { + if (this.instance) { + this.instance.unmount(); + } + + // TODO this will be handled by the plugin platform itself indexPatternDatasourceStop(); xyVisualizationStop(); editorFrameStop(); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx deleted file mode 100644 index 9ab026db082a2..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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, { useReducer, useEffect } from 'react'; -import { Datasource, Visualization } from '../types'; -import { NativeRenderer } from '../native_renderer'; - -interface EditorFrameProps { - datasources: { [key: string]: Datasource }; - visualizations: { [key: string]: Visualization }; - - initialDatasource?: string; -} - -interface DatasourceState { - datasourceName: string; - visualizationName: string; - - datasourceState: unknown; - visualizationState: unknown; -} - -interface UpdateDatasourceAction { - type: 'UPDATE_DATASOURCE'; - payload: unknown; -} - -interface UpdateVisualizationAction { - type: 'UPDATE_VISUALIZATION'; - payload: unknown; -} - -type Action = UpdateDatasourceAction | UpdateVisualizationAction; - -function stateReducer(state: DatasourceState, action: Action): DatasourceState { - switch (action.type) { - case 'UPDATE_DATASOURCE': - return { - ...state, - datasourceState: action.payload, - }; - case 'UPDATE_VISUALIZATION': - return { - ...state, - visualizationState: action.payload, - }; - } - return state; -} - -export function EditorFrame(props: EditorFrameProps) { - const dsKeys = Object.keys(props.datasources); - const vKeys = Object.keys(props.visualizations); - - const [state, dispatch] = useReducer(stateReducer, { - datasourceName: props.initialDatasource || dsKeys[0], - visualizationName: vKeys[0], - - datasourceState: null, - visualizationState: null, - }); - - useEffect(() => { - const vState = props.visualizations[state.visualizationName].initialize(); - props.datasources[state.datasourceName].initialize().then(dsState => { - dispatch({ - type: 'UPDATE_DATASOURCE', - payload: dsState, - }); - }); - - dispatch({ - type: 'UPDATE_VISUALIZATION', - payload: vState, - }); - }, []); - - return ( -
-

Editor Frame

- - - dispatch({ - type: 'UPDATE_DATASOURCE', - payload: newState, - }), - }} - /> - - - dispatch({ - type: 'UPDATE_DATASOURCE', - payload: newState, - }) - ), - state: state.visualizationState, - setState: (newState: unknown) => - dispatch({ - type: 'UPDATE_VISUALIZATION', - payload: newState, - }), - }} - /> -
- ); -} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx new file mode 100644 index 0000000000000..b1329ee6fc2a8 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.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, { useMemo } from 'react'; +import { EuiSelect } from '@elastic/eui'; +import { NativeRenderer } from '../../native_renderer'; +import { Action } from './state_management'; +import { Visualization, DatasourcePublicAPI } from '../../types'; + +interface ConfigPanelWrapperProps { + visualizationState: unknown; + visualizationMap: Record; + activeVisualizationId: string | null; + dispatch: (action: Action) => void; + datasourcePublicAPI: DatasourcePublicAPI; +} + +export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { + const setVisualizationState = useMemo( + () => (newState: unknown) => { + props.dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + newState, + }); + }, + [props.dispatch] + ); + + return ( + <> + ({ + value: visualizationId, + text: visualizationId, + }))} + value={props.activeVisualizationId || undefined} + onChange={e => { + props.dispatch({ + type: 'SWITCH_VISUALIZATION', + newVisualizationId: e.target.value, + // TODO we probably want to have a separate API to "force" a visualization switch + // which isn't a result of a picked suggestion + initialState: props.visualizationMap[e.target.value].initialize(), + }); + }} + /> + {props.activeVisualizationId && ( + + )} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx new file mode 100644 index 0000000000000..4ad0fe3285802 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiSelect } from '@elastic/eui'; +import { DatasourceDataPanelProps, Datasource } from '../..'; +import { NativeRenderer } from '../../native_renderer'; +import { Action } from './state_management'; + +interface DataPanelWrapperProps { + datasourceState: unknown; + datasourceMap: Record; + activeDatasource: string | null; + datasourceIsLoading: boolean; + dispatch: (action: Action) => void; +} + +export function DataPanelWrapper(props: DataPanelWrapperProps) { + const setDatasourceState = useMemo( + () => (newState: unknown) => { + props.dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + newState, + }); + }, + [props.dispatch] + ); + + const datasourceProps: DatasourceDataPanelProps = { + state: props.datasourceState, + setState: setDatasourceState, + }; + + return ( + <> + ({ + value: datasourceId, + text: datasourceId, + }))} + value={props.activeDatasource || undefined} + onChange={e => { + props.dispatch({ type: 'SWITCH_DATASOURCE', newDatasourceId: e.target.value }); + }} + /> + {props.activeDatasource && !props.datasourceIsLoading && ( + + )} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx new file mode 100644 index 0000000000000..32785d2d95753 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -0,0 +1,442 @@ +/* + * 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 { mount, ReactWrapper } from 'enzyme'; +import { EditorFrame } from './editor_frame'; +import { Visualization, Datasource, DatasourcePublicAPI } from '../../types'; +import { act } from 'react-dom/test-utils'; + +// calling this function will wait for all pending Promises from mock +// datasources to be processed by its callers. +const waitForPromises = () => new Promise(resolve => setImmediate(resolve)); + +describe('editor_frame', () => { + const getMockVisualization = () => ({ + getMappingOfTableToRoles: jest.fn(), + getPersistableState: jest.fn(), + getSuggestions: jest.fn(), + initialize: jest.fn(), + renderConfigPanel: jest.fn(), + toExpression: jest.fn(), + }); + + const getMockDatasource = () => ({ + getDatasourceSuggestionsForField: jest.fn(), + getDatasourceSuggestionsFromCurrentState: jest.fn(), + getPersistableState: jest.fn(), + getPublicAPI: jest.fn(), + initialize: jest.fn(() => Promise.resolve()), + renderDataPanel: jest.fn(), + toExpression: jest.fn(), + }); + + let mockVisualization: Visualization; + let mockDatasource: Datasource; + + let mockVisualization2: Visualization; + let mockDatasource2: Datasource; + + beforeEach(() => { + mockVisualization = getMockVisualization(); + mockVisualization2 = getMockVisualization(); + + mockDatasource = getMockDatasource(); + mockDatasource2 = getMockDatasource(); + }); + + describe('initialization', () => { + it('should initialize initial datasource and visualization if present', () => { + act(() => { + mount( + + ); + }); + + expect(mockVisualization.initialize).toHaveBeenCalled(); + 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 not render something before datasource is initialized', () => { + act(() => { + mount( + + ); + }); + + expect(mockVisualization.renderConfigPanel).not.toHaveBeenCalled(); + expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled(); + }); + + 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" + /> + ); + }); + + 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" + /> + ); + + await waitForPromises(); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: initialState }) + ); + }); + }); + + 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, + }) + ); + + // don't re-render datasource when visulization changes + expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(1); + }); + + it('should re-render data panel after state update', async () => { + mount( + + ); + + await waitForPromises(); + + const updatedState = {}; + 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 () => { + mount( + + ); + + await waitForPromises(); + + const updatedPublicAPI = {}; + mockDatasource.getPublicAPI = jest.fn( + () => (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({ + datasource: updatedPublicAPI, + }) + ); + }); + }); + + describe('datasource public api communication', () => { + it('should pass the datasource api to the visualization', async () => { + const publicAPI = ({} as unknown) as DatasourcePublicAPI; + + mockDatasource.getPublicAPI = () => publicAPI; + + mount( + + ); + + await waitForPromises(); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ datasource: publicAPI }) + ); + }); + + it('should give access to the datasource state in the datasource factory function', async () => { + const datasourceState = {}; + mockDatasource.initialize = () => Promise.resolve(datasourceState); + + mount( + + ); + + await waitForPromises(); + + expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith( + datasourceState, + expect.any(Function) + ); + }); + + it('should re-create the public api after state has been set', async () => { + mount( + + ); + + await waitForPromises(); + + const updatedState = {}; + const setDatasourceState = (mockDatasource.getPublicAPI as jest.Mock).mock.calls[0][1]; + act(() => { + setDatasourceState(updatedState); + }); + + expect(mockDatasource.getPublicAPI).toHaveBeenCalledTimes(2); + expect(mockDatasource.getPublicAPI).toHaveBeenLastCalledWith( + updatedState, + expect.any(Function) + ); + }); + }); + + describe('switching', () => { + let instance: ReactWrapper; + beforeEach(async () => { + instance = mount( + + ); + await waitForPromises(); + + // necessary to flush elements to dom synchronously + instance.update(); + }); + + 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('select[data-test-subj="datasource-switch"]') + .simulate('change', { target: { value: 'testDatasource2' } }); + }); + expect(mockDatasource2.initialize).toHaveBeenCalled(); + }); + + it('should call datasource render with new state on switch', async () => { + const initialState = {}; + mockDatasource2.initialize = () => Promise.resolve(initialState); + + instance + .find('select[data-test-subj="datasource-switch"]') + .simulate('change', { target: { value: 'testDatasource2' } }); + + await waitForPromises(); + + expect(mockDatasource2.renderDataPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: initialState }) + ); + }); + + it('should initialize other visualization on switch', async () => { + act(() => { + instance + .find('select[data-test-subj="visualization-switch"]') + .simulate('change', { target: { value: 'testVis2' } }); + }); + expect(mockVisualization2.initialize).toHaveBeenCalled(); + }); + + it('should call visualization render with new state on switch', async () => { + const initialState = {}; + mockVisualization2.initialize = () => initialState; + + act(() => { + instance + .find('select[data-test-subj="visualization-switch"]') + .simulate('change', { target: { value: 'testVis2' } }); + }); + + expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: initialState }) + ); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx new file mode 100644 index 0000000000000..ee5023c06f3f7 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx @@ -0,0 +1,95 @@ +/* + * 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, useMemo } from 'react'; +import { Datasource, 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'; + +export interface EditorFrameProps { + datasourceMap: Record; + visualizationMap: Record; + + initialDatasourceId: string | null; + initialVisualizationId: string | null; +} + +export function EditorFrame(props: EditorFrameProps) { + const [state, dispatch] = useReducer(reducer, props, getInitialState); + + // Initialize current datasource + useEffect( + () => { + let datasourceGotSwitched = false; + if (state.datasource.isLoading && state.datasource.activeId) { + props.datasourceMap[state.datasource.activeId].initialize().then(datasourceState => { + if (!datasourceGotSwitched) { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + newState: datasourceState, + }); + } + }); + + return () => { + datasourceGotSwitched = true; + }; + } + }, + [state.datasource.activeId, state.datasource.isLoading] + ); + + // create public datasource api for current state + // as soon as datasource is available and memoize it + const datasourcePublicAPI = useMemo( + () => + state.datasource.activeId && !state.datasource.isLoading + ? props.datasourceMap[state.datasource.activeId].getPublicAPI( + state.datasource.state, + (newState: unknown) => { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + newState, + }); + } + ) + : undefined, + [ + props.datasourceMap, + state.datasource.isLoading, + state.datasource.activeId, + state.datasource.state, + ] + ); + + return ( + + } + configPanel={ + state.datasource.activeId && + !state.datasource.isLoading && ( + + ) + } + /> + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx new file mode 100644 index 0000000000000..182d0c9f0b592 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx @@ -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. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export interface FrameLayoutProps { + dataPanel: React.ReactNode; + configPanel: React.ReactNode; +} + +export function FrameLayout(props: FrameLayoutProps) { + return ( + + {/* TODO style this and add workspace prop and loading flags */} + {props.dataPanel} + {props.configPanel} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/index.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/index.ts new file mode 100644 index 0000000000000..41558caafc64c --- /dev/null +++ b/x-pack/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/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts new file mode 100644 index 0000000000000..373b321309586 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts @@ -0,0 +1,145 @@ +/* + * 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'; + +describe('editor_frame state management', () => { + describe('initialization', () => { + let props: EditorFrameProps; + + beforeEach(() => { + props = { + datasourceMap: { testDatasource: ({} as unknown) as Datasource }, + visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization }, + initialDatasourceId: 'testDatasource', + initialVisualizationId: 'testVis', + }; + }); + + it('should store initial datasource and visualization', () => { + const initialState = getInitialState(props); + expect(initialState.datasource.activeId).toEqual('testDatasource'); + expect(initialState.visualization.activeId).toEqual('testVis'); + }); + + it('should initialize visualization', () => { + const initialVisState = {}; + props.visualizationMap.testVis.initialize = jest.fn(() => initialVisState); + + const initialState = getInitialState(props); + + expect(initialState.visualization.state).toBe(initialVisState); + expect(props.visualizationMap.testVis.initialize).toHaveBeenCalled(); + }); + + it('should not initialize visualization if no initial visualization is passed in', () => { + const initialState = getInitialState({ ...props, initialVisualizationId: null }); + + expect(initialState.visualization.state).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( + { + datasource: { + activeId: 'testDatasource', + state: {}, + isLoading: false, + }, + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'UPDATE_VISUALIZATION_STATE', + newState: newVisState, + } + ); + + expect(newState.visualization.state).toBe(newVisState); + }); + + it('should update the datasource state on update', () => { + const newDatasourceState = {}; + const newState = reducer( + { + datasource: { + activeId: 'testDatasource', + state: {}, + isLoading: false, + }, + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'UPDATE_DATASOURCE_STATE', + newState: newDatasourceState, + } + ); + + expect(newState.datasource.state).toBe(newDatasourceState); + }); + + it('should should switch active visualization', () => { + const testVisState = {}; + const newVisState = {}; + const newState = reducer( + { + datasource: { + activeId: 'testDatasource', + state: {}, + isLoading: false, + }, + visualization: { + activeId: 'testVis', + state: testVisState, + }, + }, + { + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'testVis2', + initialState: newVisState, + } + ); + + expect(newState.visualization.state).toBe(newVisState); + }); + + it('should should switch active datasource and purge visualization state', () => { + const newState = reducer( + { + datasource: { + activeId: 'testDatasource', + state: {}, + isLoading: false, + }, + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'SWITCH_DATASOURCE', + newDatasourceId: 'testDatasource2', + } + ); + + expect(newState.visualization.state).toEqual(null); + expect(newState.visualization.activeId).toBe(null); + expect(newState.datasource.activeId).toBe('testDatasource2'); + expect(newState.datasource.state).toBe(null); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts new file mode 100644 index 0000000000000..2358da104378b --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts @@ -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 { EditorFrameProps } from '.'; + +export interface EditorFrameState { + visualization: { + activeId: string | null; + state: unknown; + }; + datasource: { + activeId: string | null; + state: unknown; + isLoading: boolean; + }; +} + +export type Action = + | { + type: 'UPDATE_DATASOURCE_STATE'; + newState: unknown; + } + | { + type: 'UPDATE_VISUALIZATION_STATE'; + newState: unknown; + } + | { + type: 'SWITCH_VISUALIZATION'; + newVisualizationId: string; + initialState: unknown; + } + | { + type: 'SWITCH_DATASOURCE'; + newDatasourceId: string; + }; + +export const getInitialState = (props: EditorFrameProps): EditorFrameState => { + return { + datasource: { + state: null, + isLoading: Boolean(props.initialDatasourceId), + activeId: props.initialDatasourceId, + }, + visualization: { + state: props.initialVisualizationId + ? props.visualizationMap[props.initialVisualizationId].initialize() + : null, + activeId: props.initialVisualizationId, + }, + }; +}; + +export const reducer = (state: EditorFrameState, action: Action): EditorFrameState => { + switch (action.type) { + case 'SWITCH_DATASOURCE': + return { + ...state, + datasource: { + ...state.datasource, + isLoading: true, + state: null, + activeId: action.newDatasourceId, + }, + visualization: { + ...state.visualization, + // purge visualization on datasource switch + state: null, + activeId: null, + }, + }; + case 'SWITCH_VISUALIZATION': + return { + ...state, + visualization: { + ...state.visualization, + activeId: action.newVisualizationId, + state: action.initialState, + }, + }; + case 'UPDATE_DATASOURCE_STATE': + return { + ...state, + datasource: { + ...state.datasource, + // when the datasource state is updated, the initialization is complete + isLoading: false, + state: action.newState, + }, + }; + 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/plugins/lens/public/editor_frame_plugin/plugin.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx new file mode 100644 index 0000000000000..1ca641b2e6e37 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx @@ -0,0 +1,112 @@ +/* + * 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 { Visualization, Datasource } from '../types'; + +const nextTick = () => new Promise(resolve => setTimeout(resolve)); + +describe('editor_frame plugin', () => { + let pluginInstance: EditorFramePlugin; + let mountpoint: Element; + + beforeEach(() => { + pluginInstance = new EditorFramePlugin(); + mountpoint = document.createElement('div'); + }); + + afterEach(() => { + mountpoint.remove(); + }); + + it('should create an editor frame instance which mounts and unmounts', () => { + expect(() => { + const publicAPI = pluginInstance.setup(); + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint); + instance.unmount(); + }).not.toThrowError(); + }); + + it('should render something in the provided dom element', () => { + const publicAPI = pluginInstance.setup(); + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint); + + expect(mountpoint.hasChildNodes()).toBe(true); + + instance.unmount(); + }); + + it('should not have child nodes after unmount', () => { + const publicAPI = pluginInstance.setup(); + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint); + instance.unmount(); + + expect(mountpoint.hasChildNodes()).toBe(false); + }); + + it('should initialize and render provided datasource', async () => { + const publicAPI = pluginInstance.setup(); + const mockDatasource = { + getDatasourceSuggestionsForField: jest.fn(), + getDatasourceSuggestionsFromCurrentState: jest.fn(), + getPersistableState: jest.fn(), + getPublicAPI: jest.fn(), + initialize: jest.fn(() => Promise.resolve()), + renderDataPanel: jest.fn(), + toExpression: jest.fn(), + }; + + publicAPI.registerDatasource('test', mockDatasource); + + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint); + + await nextTick(); + + expect(mockDatasource.initialize).toHaveBeenCalled(); + expect(mockDatasource.renderDataPanel).toHaveBeenCalled(); + + instance.unmount(); + }); + + it('should initialize visualization and render config panel', async () => { + const publicAPI = pluginInstance.setup(); + const mockDatasource: Datasource = { + getDatasourceSuggestionsForField: jest.fn(), + getDatasourceSuggestionsFromCurrentState: jest.fn(), + getPersistableState: jest.fn(), + getPublicAPI: jest.fn(), + initialize: jest.fn(() => Promise.resolve()), + renderDataPanel: jest.fn(), + toExpression: jest.fn(), + }; + + const mockVisualization: Visualization = { + getMappingOfTableToRoles: jest.fn(), + getPersistableState: jest.fn(), + getSuggestions: jest.fn(), + initialize: jest.fn(), + renderConfigPanel: jest.fn(), + toExpression: jest.fn(), + }; + + publicAPI.registerDatasource('test', mockDatasource); + publicAPI.registerVisualization('test', mockVisualization); + + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint); + + await nextTick(); + + expect(mockVisualization.initialize).toHaveBeenCalled(); + expect(mockVisualization.renderConfigPanel).toHaveBeenCalled(); + + instance.unmount(); + }); +}); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx index 6a0a82877cefb..07c1841601140 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -6,48 +6,48 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { Datasource, Visualization, EditorFrameSetup } from '../types'; +import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; import { EditorFrame } from './editor_frame'; -class EditorFramePlugin { +export class EditorFramePlugin { constructor() {} - private datasources: { - [key: string]: Datasource; - } = {}; - private visualizations: { - [key: string]: Visualization; - } = {}; + private datasources: Record = {}; + private visualizations: Record = {}; - private initialDatasource?: string; + private createInstance(): EditorFrameInstance { + let domElement: Element; - private element: Element | null = null; + function unmount() { + if (domElement) { + unmountComponentAtNode(domElement); + } + } - public setup(): EditorFrameSetup { return { - render: domElement => { - this.element = domElement; + mount: element => { + unmount(); + domElement = element; render( , domElement ); }, - registerDatasource: (name, datasource) => { - // casting it to an unknown datasource. This doesn't introduce runtime errors - // because each type T is always also an unknown, but typescript won't do it - // on it's own because we are loosing type information here. - // So it's basically explicitly saying "I'm dropping the information about type T here - // because this information isn't useful to me." but without using any which can leak - this.datasources[name] = datasource as Datasource; + unmount, + }; + } - if (!this.initialDatasource) { - this.initialDatasource = name; - } + public setup(): EditorFrameSetup { + return { + createInstance: this.createInstance.bind(this), + registerDatasource: (name, datasource) => { + this.datasources[name] = datasource as Datasource; }, registerVisualization: (name, visualization) => { this.visualizations[name] = visualization as Visualization; @@ -56,9 +56,6 @@ class EditorFramePlugin { } public stop() { - if (this.element) { - unmountComponentAtNode(this.element); - } return {}; } } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index ab70ff10e1cb5..a0328cb3bd988 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -4,8 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +// eslint-disable-next-line +export interface EditorFrameOptions {} + +export interface EditorFrameInstance { + mount: (element: Element) => void; + unmount: () => void; +} export interface EditorFrameSetup { - render: (domElement: Element) => void; + createInstance: (options: EditorFrameOptions) => EditorFrameInstance; // generic type on the API functions to pull the "unknown vs. specific type" error into the implementation registerDatasource: (name: string, datasource: Datasource) => void; registerVisualization: (name: string, visualization: Visualization) => void; @@ -139,12 +146,9 @@ export interface VisualizationSuggestion { } export interface Visualization { - // 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 - // visualizations should validate their arguments + // For initializing from saved object initialize: (state?: P) => T; - // Given the current state, which parts should be saved? getPersistableState: (state: T) => P; renderConfigPanel: (domElement: Element, props: VisualizationProps) => void; From 7837bd586c6c4ae4efc0734127d366bbdd68bcc0 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 22 May 2019 18:06:38 -0400 Subject: [PATCH 009/105] [lens] Load index patterns and render in data panel (#36463) * [lens] Editor frame initializes datasources and visualizations * Respond to review comments * Fix build issues * remove local package * [lens] Load index patterns into data source * Redo types for Index Pattern Datasource * Fix one more type * Respond to review comments --- .../indexpattern_plugin/__mocks__/loader.ts | 62 +++++++ .../indexpattern_plugin/indexpattern.test.ts | 75 +++++++- .../indexpattern_plugin/indexpattern.tsx | 169 ++++++++++-------- .../lens/public/indexpattern_plugin/loader.ts | 41 +++++ .../public/indexpattern_plugin/plugin.tsx | 6 +- 5 files changed, 273 insertions(+), 80 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/loader.ts diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts new file mode 100644 index 0000000000000..1bb56464138d1 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts @@ -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. + */ + +export function getIndexPatterns() { + return new Promise(resolve => { + resolve([ + { + id: '1', + title: '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, + }, + ], + }, + { + id: '2', + title: 'Fake Rollup 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, + }, + ], + }, + ]); + }); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts index b561d9b4463e7..40c119de69667 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts @@ -4,15 +4,78 @@ * you may not use this file except in compliance with the Elastic License. */ -import { indexPatternDatasource, IndexPatternPersistedState } from './indexpattern'; -import { DatasourcePublicAPI, Operation } from '../types'; +import { + getIndexPatternDatasource, + IndexPatternPersistedState, + IndexPatternPrivateState, +} from './indexpattern'; +import { DatasourcePublicAPI, Operation, Datasource } from '../types'; + +jest.mock('./loader'); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: '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, + }, + ], + }, + 2: { + id: '2', + title: 'Fake Rollup 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 Source', () => { let persistedState: IndexPatternPersistedState; + let indexPatternDatasource: Datasource; beforeEach(() => { + // @ts-ignore + indexPatternDatasource = getIndexPatternDatasource(); + persistedState = { - currentIndexPattern: '', + currentIndexPattern: '1', columnOrder: ['col1'], columns: { col1: { @@ -32,8 +95,8 @@ describe('IndexPattern Data Source', () => { it('should load a default state', async () => { const state = await indexPatternDatasource.initialize(); expect(state).toEqual({ - currentIndexPattern: '', - indexPatterns: {}, + currentIndexPattern: '1', + indexPatterns: expectedIndexPatterns, columns: {}, columnOrder: [], }); @@ -44,7 +107,7 @@ describe('IndexPattern Data Source', () => { expect(state).toEqual({ ...persistedState, - indexPatterns: {}, + indexPatterns: expectedIndexPatterns, }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 29b018320a6db..0355bb8ae18c8 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -5,9 +5,12 @@ */ import React from 'react'; +import { Chrome } from 'ui/chrome'; +import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { render } from 'react-dom'; import { Datasource, DataType } from '..'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps } from '../types'; +import { getIndexPatterns } from './loader'; type OperationType = 'value' | 'terms' | 'date_histogram'; @@ -24,14 +27,15 @@ interface IndexPatternColumn { export interface IndexPattern { id: string; - fields: Field[]; + fields: IndexPatternField[]; title: string; timeFieldName?: string; } -export interface Field { +export interface IndexPatternField { name: string; type: string; + esTypes?: string[]; aggregatable: boolean; searchable: boolean; } @@ -40,80 +44,101 @@ export interface IndexPatternPersistedState { currentIndexPattern: string; columnOrder: string[]; - columns: { - [columnId: string]: IndexPatternColumn; - }; + columns: Record; } export type IndexPatternPrivateState = IndexPatternPersistedState & { - indexPatterns: { [id: string]: IndexPattern }; + indexPatterns: Record; }; -// Not stateful. State is persisted to the frame -export const indexPatternDatasource: Datasource< - IndexPatternPrivateState, - IndexPatternPersistedState -> = { - async initialize(state?: IndexPatternPersistedState) { - // TODO: Make fetch request to load indexPatterns from saved objects - if (state) { +export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: ToastNotifications) { + // Not stateful. State is persisted to the frame + const indexPatternDatasource: Datasource = { + async initialize(state?: IndexPatternPersistedState) { + const indexPatternObjects = await getIndexPatterns(chrome, toastNotifications); + const indexPatterns: Record = {}; + + if (indexPatternObjects) { + indexPatternObjects.forEach(obj => { + indexPatterns[obj.id] = obj; + }); + } + + if (state) { + return { + ...state, + indexPatterns, + }; + } return { - ...state, - indexPatterns: {}, + currentIndexPattern: indexPatternObjects ? indexPatternObjects[0].id : '', + indexPatterns, + columns: {}, + columnOrder: [], }; - } - return { - currentIndexPattern: '', - indexPatterns: {}, - columns: {}, - columnOrder: [], - }; - }, - - getPersistableState({ currentIndexPattern, columns, columnOrder }: IndexPatternPrivateState) { - return { currentIndexPattern, columns, columnOrder }; - }, - - toExpression(state: IndexPatternPrivateState) { - return `${JSON.stringify(state.columns)}`; - }, - - renderDataPanel(domElement: Element, props: DatasourceDataPanelProps) { - render(
Index Pattern Data Source
, domElement); - }, - - getPublicAPI(state, setState) { - return { - getTableSpec: () => { - return state.columnOrder.map(colId => ({ columnId: colId })); - }, - getOperationForColumnId: (columnId: string) => { - const column = state.columns[columnId]; - if (columnId) { - const { dataType, label, isBucketed, operationId } = column; - return { - id: operationId, - label, - dataType, - isBucketed, - }; - } - return null; - }, - - renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => {}, - - removeColumnInTableSpec: (columnId: string) => [], - moveColumnTo: (columnId: string, targetIndex: number) => {}, - duplicateColumn: (columnId: string) => [], - }; - }, - - getDatasourceSuggestionsForField() { - return []; - }, - - getDatasourceSuggestionsFromCurrentState() { - return []; - }, -}; + }, + + getPersistableState({ currentIndexPattern, columns, columnOrder }: IndexPatternPrivateState) { + return { currentIndexPattern, columns, columnOrder }; + }, + + toExpression(state: IndexPatternPrivateState) { + return `${JSON.stringify(state.columns)}`; + }, + + renderDataPanel( + domElement: Element, + props: DatasourceDataPanelProps + ) { + render( +
+ Index Pattern Data Source +
+ {props.state.currentIndexPattern && + Object.keys(props.state.indexPatterns).map(key => ( +
{props.state.indexPatterns[key].title}
+ ))} +
+
, + domElement + ); + }, + + getPublicAPI(state, setState) { + return { + getTableSpec: () => { + return state.columnOrder.map(colId => ({ columnId: colId })); + }, + getOperationForColumnId: (columnId: string) => { + const column = state.columns[columnId]; + if (columnId) { + const { dataType, label, isBucketed, operationId } = column; + return { + id: operationId, + label, + dataType, + isBucketed, + }; + } + return null; + }, + + renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => {}, + + removeColumnInTableSpec: (columnId: string) => [], + moveColumnTo: (columnId: string, targetIndex: number) => {}, + duplicateColumn: (columnId: string) => [], + }; + }, + + getDatasourceSuggestionsForField() { + return []; + }, + + getDatasourceSuggestionsFromCurrentState() { + return []; + }, + }; + + return indexPatternDatasource; +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts new file mode 100644 index 0000000000000..3de7d511c4b49 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts @@ -0,0 +1,41 @@ +/* + * 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'; +import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; +import { SavedObjectAttributes } from 'src/legacy/server/saved_objects/service/saved_objects_client'; +import { IndexPatternField } from './indexpattern'; + +interface IndexPatternAttributes extends SavedObjectAttributes { + title: string; + timeFieldName: string | null; + fields: string; + fieldFormatMap: string; +} + +export const getIndexPatterns = (chrome: Chrome, toastNotifications: ToastNotifications) => { + 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 } = savedObject; + return Object.assign(attributes, { + id, + title: attributes.title, + fields: (JSON.parse(attributes.fields) as IndexPatternField[]).filter( + ({ type, esTypes }) => type !== 'string' || (esTypes && esTypes.includes('keyword')) + ), + }); + }); + }) + .catch(err => { + toastNotifications.addDanger('Failed to load index patterns'); + }); +}; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx index 851beba8ba1ab..38fd82705dfab 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { indexPatternDatasource } from './indexpattern'; +import chrome from 'ui/chrome'; +import { toastNotifications } from 'ui/notify'; +import { getIndexPatternDatasource } from './indexpattern'; class IndexPatternDatasourcePlugin { constructor() {} setup() { - return indexPatternDatasource; + return getIndexPatternDatasource(chrome, toastNotifications); } stop() {} From 63d59da452fc53b6d97d9b68c0302b27eb37e744 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Thu, 23 May 2019 15:12:53 -0400 Subject: [PATCH 010/105] [draft] Lens/line chart renderer (#36827) Expression logic for the Lens xy chart. --- x-pack/plugins/lens/public/types.ts | 8 + .../__snapshots__/xy_expression.test.tsx.snap | 151 ++++++++++ .../xy_expression.test.tsx | 139 +++++++++ .../xy_visualization_plugin/xy_expression.tsx | 268 ++++++++++++++++++ 4 files changed, 566 insertions(+) create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a0328cb3bd988..99bfbba1c87b8 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -124,6 +124,14 @@ export interface TableSpecColumn { // TableSpec is managed by visualizations export type TableSpec = TableSpecColumn[]; +// This is a temporary type definition, to be replaced with +// the official Kibana Datatable type definition. +export interface KibanaDatatable { + type: 'kibana_datatable'; + rows: Array>; + columns: Array<{ id: string; name: string }>; +} + export interface VisualizationProps { datasource: DatasourcePublicAPI; state: T; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap new file mode 100644 index 0000000000000..85e0598114ce8 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap @@ -0,0 +1,151 @@ +// 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 line 1`] = ` + + + + + + +`; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx new file mode 100644 index 0000000000000..bc5c513bb0f1b --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx @@ -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 { Position } from '@elastic/charts'; +import { + legendConfig, + LegendConfig, + xConfig, + XConfig, + YConfig, + yConfig, + XYArgs, + xyChart, + XYChart, +} from './xy_expression'; +import { KibanaDatatable } from '../types'; +import React from 'react'; +import { shallow } from 'enzyme'; + +function sampleArgs() { + const data: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }, { id: 'c', name: 'c' }], + rows: [{ a: 1, b: 2, c: 3 }, { a: 1, b: 5, c: 4 }], + }; + + const args: XYArgs = { + seriesType: 'line', + title: 'My fanci line chart', + legend: { + type: 'lens_xy_legendConfig', + isVisible: false, + position: Position.Top, + }, + y: { + type: 'lens_xy_yConfig', + accessors: ['a', 'b'], + position: Position.Left, + showGridlines: false, + title: 'A and B', + }, + x: { + type: 'lens_xy_xConfig', + accessor: 'c', + position: Position.Bottom, + showGridlines: false, + title: 'C', + }, + splitSeriesAccessors: [], + stackAccessors: [], + }; + + 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('xConfig produces the correct arguments', () => { + const args: XConfig = { + accessor: 'foo', + position: Position.Right, + showGridlines: true, + title: 'Foooo!', + }; + + expect(xConfig.fn(null, args, {})).toEqual({ + type: 'lens_xy_xConfig', + ...args, + }); + }); + + test('yConfig produces the correct arguments', () => { + const args: YConfig = { + accessors: ['bar'], + position: Position.Bottom, + showGridlines: true, + title: 'Barrrrrr!', + }; + + expect(yConfig.fn(null, args, {})).toEqual({ + type: 'lens_xy_yConfig', + ...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', () => { + test('it renders line', () => { + const { data, args } = sampleArgs(); + + expect( + shallow() + ).toMatchSnapshot(); + }); + + test('it renders bar', () => { + const { data, args } = sampleArgs(); + + expect( + shallow() + ).toMatchSnapshot(); + }); + + test('it renders area', () => { + const { data, args } = sampleArgs(); + + expect( + shallow() + ).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx new file mode 100644 index 0000000000000..f52e6f69beb27 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.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 ReactDOM from 'react-dom'; +import { + Position, + Chart, + Settings, + Axis, + LineSeries, + getAxisId, + getSpecId, + AreaSeries, + BarSeries, +} from '@elastic/charts'; +import { ContextFunction, ArgumentType } from '../../../canvas/canvas_plugin_src/functions/types'; +import { KibanaDatatable } from '../types'; + +/** + * This file contains TypeScript type definitions and their equivalent expression + * definitions, for configuring and rendering an XY chart. The XY chart serves + * triple duty as a bar, line, or area chart. + * + * The xy_chart expression function serves mostly as a passthrough to the xy_chart_renderer + * which does the heavy-lifting. + */ + +export interface LegendConfig { + isVisible: boolean; + position: Position; +} + +type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' }; + +export const legendConfig: ContextFunction< + '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: 'Specifies whether or not the legend is visible.', + }, + position: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: 'Specifies the legend position.', + }, + }, + fn: function fn(_context: unknown, args: LegendConfig) { + return { + type: 'lens_xy_legendConfig', + ...args, + }; + }, +}; + +interface AxisConfig { + title: string; + showGridlines: boolean; + position: Position; +} + +const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = { + title: { + types: ['string'], + help: 'The axis title', + }, + showGridlines: { + types: ['boolean'], + help: 'Show / hide axis grid lines.', + }, + position: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: 'The position of the axis', + }, +}; + +export interface YConfig extends AxisConfig { + accessors: string[]; +} + +type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; + +export const yConfig: ContextFunction<'lens_xy_yConfig', null, YConfig, YConfigResult> = { + name: 'lens_xy_yConfig', + aliases: [], + type: 'lens_xy_yConfig', + help: `Configure the xy chart's y axis`, + context: { + types: ['null'], + }, + args: { + ...axisConfig, + accessors: { + types: ['string'], + help: 'The columns to display on the y axis.', + multi: true, + }, + }, + fn: function fn(_context: unknown, args: YConfig) { + return { + type: 'lens_xy_yConfig', + ...args, + }; + }, +}; + +export interface XConfig extends AxisConfig { + accessor: string; +} + +type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; + +export const xConfig: ContextFunction<'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, + }; + }, +}; + +export interface XYArgs { + seriesType: 'bar' | 'line' | 'area'; + title: string; + legend: LegendConfigResult; + y: YConfigResult; + x: XConfigResult; + splitSeriesAccessors: string[]; + stackAccessors: string[]; +} + +export interface XYChartProps { + data: KibanaDatatable; + args: XYArgs; +} + +// TODO: Specify the TypeScript type of this definition, once the +// ContextFunction has moved to core and has the correct signature: +// ContextFunction<'lens_xy_chart', KibanaDatatable, XYArgs, XYRender> +export const xyChart = { + name: 'lens_xy_chart', + type: 'render', + args: { + seriesType: { + types: ['string'], + options: ['bar', 'line', 'area'], + help: 'The type of chart to display.', + }, + title: { + types: ['string'], + help: 'The char title.', + }, + legend: { + types: ['lens_xy_legendConfig'], + help: 'Configure the chart legend.', + }, + y: { + types: ['lens_xy_yConfig'], + help: 'The y axis configuration', + }, + x: { + types: ['lens_xy_xConfig'], + help: 'The x axis configuration', + }, + splitSeriesAccessors: { + types: ['string'], + multi: true, + help: 'The columns used to split the series.', + }, + stackAccessors: { + types: ['string'], + multi: true, + help: 'The columns used to stack the series.', + }, + }, + context: { + types: ['kibana_datatable'], + }, + fn(data: KibanaDatatable, args: XYArgs) { + return { + type: 'render', + as: 'lens_xy_chart_renderer', + value: { + data, + args, + }, + }; + }, +}; + +export const xyChartRenderer = { + name: 'lens_xy_chart_renderer', + displayName: 'XY Chart', + reuseDomNode: true, + render: async (domNode: HTMLDivElement, config: XYChartProps, _handlers: unknown) => { + ReactDOM.render(, domNode); + }, +}; + +export function XYChart({ data, args }: XYChartProps) { + const { legend, x, y, splitSeriesAccessors, stackAccessors, seriesType } = args; + const seriesProps = { + splitSeriesAccessors, + stackAccessors, + id: getSpecId(y.accessors.join(',')), + xAccessor: x.accessor, + yAccessors: y.accessors, + data: data.rows, + }; + + return ( + + + + + + + + {seriesType === 'line' ? ( + + ) : seriesType === 'bar' ? ( + + ) : ( + + )} + + ); +} From 3902b92dd2d7fb1cc5959aea1df647a69e36c51b Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Fri, 24 May 2019 13:27:58 -0400 Subject: [PATCH 011/105] [lens] Index pattern data panel (initial) (#37015) * [lens] Index pattern switcher * Respond to review comments --- .../__snapshots__/indexpattern.test.tsx.snap | 64 +++++++++++++++ ...xpattern.test.ts => indexpattern.test.tsx} | 64 ++++++++++++++- .../indexpattern_plugin/indexpattern.tsx | 81 ++++++++++++++----- 3 files changed, 188 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap rename x-pack/plugins/lens/public/indexpattern_plugin/{indexpattern.test.ts => indexpattern.test.tsx} (69%) diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap b/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap new file mode 100644 index 0000000000000..fb8abf6ccb664 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IndexPattern Data Source #getPublicAPI renderDimensionPanel should render a dimension panel 1`] = ` +
+ Dimension Panel +
+`; + +exports[`IndexPattern Data Source #renderDataPanel should match snapshot 1`] = ` +
+ Index Pattern Data Source +
+ +
+
+ timestamp +
+
+ bytes +
+
+ source +
+
+
+
+`; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx similarity index 69% rename from x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts rename to x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index 40c119de69667..4130c42617eb2 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -4,10 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { shallow } from 'enzyme'; +import React from 'react'; +import { EuiComboBox } from '@elastic/eui'; import { getIndexPatternDatasource, IndexPatternPersistedState, IndexPatternPrivateState, + IndexPatternDataPanel, + IndexPatternDimensionPanel, } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; @@ -75,7 +80,7 @@ describe('IndexPattern Data Source', () => { indexPatternDatasource = getIndexPatternDatasource(); persistedState = { - currentIndexPattern: '1', + currentIndexPatternId: '1', columnOrder: ['col1'], columns: { col1: { @@ -95,7 +100,7 @@ describe('IndexPattern Data Source', () => { it('should load a default state', async () => { const state = await indexPatternDatasource.initialize(); expect(state).toEqual({ - currentIndexPattern: '1', + currentIndexPatternId: '1', indexPatterns: expectedIndexPatterns, columns: {}, columnOrder: [], @@ -104,7 +109,6 @@ describe('IndexPattern Data Source', () => { it('should initialize from saved state', async () => { const state = await indexPatternDatasource.initialize(persistedState); - expect(state).toEqual({ ...persistedState, indexPatterns: expectedIndexPatterns, @@ -112,6 +116,40 @@ describe('IndexPattern Data Source', () => { }); }); + describe('#renderDataPanel', () => { + let state: IndexPatternPrivateState; + + beforeEach(async () => { + state = await indexPatternDatasource.initialize(persistedState); + }); + + it('should match snapshot', () => { + expect( + shallow( {}} />) + ).toMatchSnapshot(); + }); + + it('should call setState when the index pattern is switched', async () => { + const setState = jest.fn(); + + const wrapper = shallow(); + + const comboBox = wrapper.find(EuiComboBox); + + comboBox.prop('onChange')!([ + { + label: expectedIndexPatterns['2'].title, + value: '2', + }, + ]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + currentIndexPatternId: '2', + }); + }); + }); + describe('#getPersistedState', () => { it('should persist from saved state', async () => { const state = await indexPatternDatasource.initialize(persistedState); @@ -148,5 +186,25 @@ describe('IndexPattern Data Source', () => { } as Operation); }); }); + + describe('renderDimensionPanel', () => { + let state: IndexPatternPrivateState; + + beforeEach(async () => { + state = await indexPatternDatasource.initialize(persistedState); + }); + + it('should render a dimension panel', () => { + const wrapper = shallow( + {}} + filterOperations={(operation: Operation) => true} + /> + ); + + expect(wrapper).toMatchSnapshot(); + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 0355bb8ae18c8..140c54031677f 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -5,9 +5,10 @@ */ import React from 'react'; +import { render } from 'react-dom'; import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; -import { render } from 'react-dom'; +import { EuiComboBox } from '@elastic/eui'; import { Datasource, DataType } from '..'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps } from '../types'; import { getIndexPatterns } from './loader'; @@ -41,7 +42,7 @@ export interface IndexPatternField { } export interface IndexPatternPersistedState { - currentIndexPattern: string; + currentIndexPatternId: string; columnOrder: string[]; columns: Record; @@ -51,6 +52,56 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { indexPatterns: Record; }; +export function IndexPatternDataPanel(props: DatasourceDataPanelProps) { + return ( +
+ Index Pattern Data Source +
+ ({ + label: title, + value: id, + }))} + selectedOptions={ + props.state.currentIndexPatternId + ? [ + { + label: props.state.indexPatterns[props.state.currentIndexPatternId].title, + value: props.state.indexPatterns[props.state.currentIndexPatternId].id, + }, + ] + : undefined + } + singleSelection={{ asPlainText: true }} + isClearable={false} + onChange={choices => { + props.setState({ + ...props.state, + currentIndexPatternId: choices[0].value as string, + }); + }} + /> +
+ {props.state.currentIndexPatternId && + props.state.indexPatterns[props.state.currentIndexPatternId].fields.map(field => ( +
{field.name}
+ ))} +
+
+
+ ); +} + +export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { + state: IndexPatternPrivateState; + setState: (newState: IndexPatternPrivateState) => void; +}; + +export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { + return
Dimension Panel
; +} + export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: ToastNotifications) { // Not stateful. State is persisted to the frame const indexPatternDatasource: Datasource = { @@ -71,15 +122,15 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To }; } return { - currentIndexPattern: indexPatternObjects ? indexPatternObjects[0].id : '', + currentIndexPatternId: indexPatternObjects ? indexPatternObjects[0].id : '', indexPatterns, columns: {}, columnOrder: [], }; }, - getPersistableState({ currentIndexPattern, columns, columnOrder }: IndexPatternPrivateState) { - return { currentIndexPattern, columns, columnOrder }; + getPersistableState({ currentIndexPatternId, columns, columnOrder }: IndexPatternPrivateState) { + return { currentIndexPatternId, columns, columnOrder }; }, toExpression(state: IndexPatternPrivateState) { @@ -90,18 +141,7 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To domElement: Element, props: DatasourceDataPanelProps ) { - render( -
- Index Pattern Data Source -
- {props.state.currentIndexPattern && - Object.keys(props.state.indexPatterns).map(key => ( -
{props.state.indexPatterns[key].title}
- ))} -
-
, - domElement - ); + render(, domElement); }, getPublicAPI(state, setState) { @@ -123,7 +163,12 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To return null; }, - renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => {}, + renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { + render( + , + domElement + ); + }, removeColumnInTableSpec: (columnId: string) => [], moveColumnTo: (columnId: string, targetIndex: number) => {}, From 76556f3ebd25dc78215d0fd51044e53b10103be2 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 27 May 2019 09:11:35 +0200 Subject: [PATCH 012/105] [Lens] Editor state 2 (#36513) --- .../editor_frame/editor_frame.test.tsx | 313 ++++++++++++++++-- .../editor_frame/editor_frame.tsx | 60 +++- .../editor_frame/frame_layout.tsx | 10 +- .../editor_frame/state_management.test.ts | 28 ++ .../editor_frame/state_management.ts | 5 + .../editor_frame/suggestion_helpers.test.ts | 154 +++++++++ .../editor_frame/suggestion_helpers.ts | 61 ++++ .../editor_frame/suggestion_panel.tsx | 65 ++++ .../editor_frame/workspace_panel.tsx | 99 ++++++ .../editor_frame_plugin/mock_extensions.ts | 46 +++ .../editor_frame_plugin/plugin.test.tsx | 41 +-- .../public/editor_frame_plugin/plugin.tsx | 21 +- x-pack/plugins/lens/public/types.ts | 23 +- .../xy_visualization.tsx | 6 +- 14 files changed, 831 insertions(+), 101 deletions(-) create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/mock_extensions.ts diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index 32785d2d95753..904c11d2f29de 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -5,35 +5,18 @@ */ import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; +import { ReactWrapper } from 'enzyme'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EditorFrame } from './editor_frame'; import { Visualization, Datasource, DatasourcePublicAPI } from '../../types'; import { act } from 'react-dom/test-utils'; +import { createMockVisualization, createMockDatasource } from '../mock_extensions'; // calling this function will wait for all pending Promises from mock // datasources to be processed by its callers. -const waitForPromises = () => new Promise(resolve => setImmediate(resolve)); +const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); describe('editor_frame', () => { - const getMockVisualization = () => ({ - getMappingOfTableToRoles: jest.fn(), - getPersistableState: jest.fn(), - getSuggestions: jest.fn(), - initialize: jest.fn(), - renderConfigPanel: jest.fn(), - toExpression: jest.fn(), - }); - - const getMockDatasource = () => ({ - getDatasourceSuggestionsForField: jest.fn(), - getDatasourceSuggestionsFromCurrentState: jest.fn(), - getPersistableState: jest.fn(), - getPublicAPI: jest.fn(), - initialize: jest.fn(() => Promise.resolve()), - renderDataPanel: jest.fn(), - toExpression: jest.fn(), - }); - let mockVisualization: Visualization; let mockDatasource: Datasource; @@ -41,11 +24,11 @@ describe('editor_frame', () => { let mockDatasource2: Datasource; beforeEach(() => { - mockVisualization = getMockVisualization(); - mockVisualization2 = getMockVisualization(); + mockVisualization = createMockVisualization(); + mockVisualization2 = createMockVisualization(); - mockDatasource = getMockDatasource(); - mockDatasource2 = getMockDatasource(); + mockDatasource = createMockDatasource(); + mockDatasource2 = createMockDatasource(); }); describe('initialization', () => { @@ -439,4 +422,284 @@ describe('editor_frame', () => { ); }); }); + + 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 () => { + mount( + + ); + + await waitForPromises(); + + expect(mockVisualization.getSuggestions).toHaveBeenCalled(); + expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); + }); + + it('should display suggestions in descending order', async () => { + const instance = mount( + [ + { + tableIndex: 0, + score: 0.5, + state: {}, + title: 'Suggestion2', + }, + { + tableIndex: 0, + score: 0.8, + state: {}, + title: 'Suggestion1', + }, + ], + }, + testVis2: { + ...mockVisualization, + getSuggestions: () => [ + { + tableIndex: 0, + score: 0.4, + state: {}, + title: 'Suggestion4', + }, + { + tableIndex: 0, + score: 0.45, + state: {}, + title: 'Suggestion3', + }, + ], + }, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsFromCurrentState: () => [{ state: {}, tableColumns: [] }], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + const suggestions = instance.find('[data-test-subj="suggestion"]'); + expect(suggestions.map(el => el.text())).toEqual([ + 'Suggestion1', + 'Suggestion2', + 'Suggestion3', + 'Suggestion4', + ]); + }); + + it('should switch to suggested visualization', async () => { + const newDatasourceState = {}; + const suggestionVisState = {}; + const instance = mount( + [ + { + tableIndex: 0, + score: 0.8, + state: suggestionVisState, + title: 'Suggestion1', + }, + ], + }, + testVis2: mockVisualization2, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsFromCurrentState: () => [ + { state: newDatasourceState, tableColumns: [] }, + ], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis2" + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + + act(() => { + instance.find('[data-test-subj="suggestion"]').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( + [ + { + tableIndex: 0, + score: 0.2, + state: {}, + title: 'Suggestion1', + }, + { + tableIndex: 0, + score: 0.8, + state: suggestionVisState, + title: 'Suggestion2', + }, + ], + }, + testVis2: mockVisualization2, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsForField: () => [{ state: {}, tableColumns: [] }], + getDatasourceSuggestionsFromCurrentState: () => [{ state: {}, tableColumns: [] }], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + + act(() => { + instance.find('[data-test-subj="lnsDragDrop"]').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( + [ + { + tableIndex: 0, + score: 0.2, + state: {}, + title: 'Suggestion1', + }, + { + tableIndex: 0, + score: 0.6, + state: {}, + title: 'Suggestion2', + }, + ], + }, + testVis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + tableIndex: 0, + score: 0.8, + state: suggestionVisState, + title: 'Suggestion3', + }, + ], + }, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsForField: () => [{ state: {}, tableColumns: [] }], + getDatasourceSuggestionsFromCurrentState: () => [{ state: {}, tableColumns: [] }], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + + act(() => { + instance.find('[data-test-subj="lnsDragDrop"]').simulate('drop'); + }); + + expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + state: suggestionVisState, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx index ee5023c06f3f7..fb0bb662915ba 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx @@ -10,6 +10,8 @@ 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'; export interface EditorFrameProps { datasourceMap: Record; @@ -67,6 +69,52 @@ export function EditorFrame(props: EditorFrameProps) { ] ); + if (state.datasource.activeId && !state.datasource.isLoading) { + return ( + + } + configPanel={ + + } + workspacePanel={ + + } + suggestionsPanel={ + + } + /> + ); + } + return ( } - configPanel={ - state.datasource.activeId && - !state.datasource.isLoading && ( - - ) - } /> ); } diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx index 182d0c9f0b592..f62722bf71b85 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx @@ -9,7 +9,9 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; export interface FrameLayoutProps { dataPanel: React.ReactNode; - configPanel: React.ReactNode; + configPanel?: React.ReactNode; + suggestionsPanel?: React.ReactNode; + workspacePanel?: React.ReactNode; } export function FrameLayout(props: FrameLayoutProps) { @@ -17,7 +19,11 @@ export function FrameLayout(props: FrameLayoutProps) { {/* TODO style this and add workspace prop and loading flags */} {props.dataPanel} - {props.configPanel} + {props.workspacePanel} + + {props.configPanel} + {props.suggestionsPanel} + ); } diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts index 373b321309586..615c9607877ed 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts @@ -117,6 +117,34 @@ describe('editor_frame state management', () => { 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( + { + datasource: { + activeId: 'testDatasource', + state: {}, + isLoading: false, + }, + visualization: { + activeId: 'testVis', + state: testVisState, + }, + }, + { + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'testVis2', + initialState: newVisState, + datasourceState: newDatasourceState, + } + ); + + expect(newState.visualization.state).toBe(newVisState); + expect(newState.datasource.state).toBe(newDatasourceState); + }); + it('should should switch active datasource and purge visualization state', () => { const newState = reducer( { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts index 2358da104378b..ec24a0269c58c 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts @@ -31,6 +31,7 @@ export type Action = type: 'SWITCH_VISUALIZATION'; newVisualizationId: string; initialState: unknown; + datasourceState?: unknown; } | { type: 'SWITCH_DATASOURCE'; @@ -79,6 +80,10 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta activeId: action.newVisualizationId, state: action.initialState, }, + datasource: { + ...state.datasource, + state: action.datasourceState ? action.datasourceState : state.datasource.state, + }, }; case 'UPDATE_DATASOURCE_STATE': return { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts new file mode 100644 index 0000000000000..f79a4b1000991 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts @@ -0,0 +1,154 @@ +/* + * 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 } from '../mock_extensions'; +import { TableColumn } from '../../types'; + +describe('suggestion helpers', () => { + it('should return suggestions array', () => { + const mockVisualization = createMockVisualization(); + const suggestedState = {}; + const suggestions = getSuggestions( + [{ state: {}, tableColumns: [] }], + { + vis1: { + ...mockVisualization, + getSuggestions: () => [ + { tableIndex: 0, score: 0.5, title: 'Test', state: suggestedState }, + ], + }, + }, + 'vis1', + {} + ); + expect(suggestions.length).toBe(1); + expect(suggestions[0].state).toBe(suggestedState); + }); + + it('should concatenate suggestions from all visualizations', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const suggestions = getSuggestions( + [{ state: {}, tableColumns: [] }], + { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { tableIndex: 0, score: 0.5, title: 'Test', state: {} }, + { tableIndex: 0, score: 0.5, title: 'Test2', state: {} }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [{ tableIndex: 0, score: 0.5, title: 'Test3', state: {} }], + }, + }, + 'vis1', + {} + ); + expect(suggestions.length).toBe(3); + }); + + it('should rank the visualizations by score', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const suggestions = getSuggestions( + [{ state: {}, tableColumns: [] }], + { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { tableIndex: 0, score: 0.2, title: 'Test', state: {} }, + { tableIndex: 0, score: 0.8, title: 'Test2', state: {} }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [{ tableIndex: 0, score: 0.6, title: 'Test3', state: {} }], + }, + }, + 'vis1', + {} + ); + 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: TableColumn[] = []; + const table2: TableColumn[] = []; + getSuggestions( + [{ state: {}, tableColumns: table1 }, { state: {}, tableColumns: table2 }], + { + vis1: mockVisualization1, + vis2: mockVisualization2, + }, + 'vis1', + {} + ); + expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[0]).toBe(table1); + expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[1]).toBe(table2); + expect(mockVisualization2.getSuggestions.mock.calls[0][0].tables[0]).toBe(table1); + expect(mockVisualization2.getSuggestions.mock.calls[0][0].tables[1]).toBe(table2); + }); + + it('should map the suggestion ids back to the correct datasource states', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const tableState1 = {}; + const tableState2 = {}; + const suggestions = getSuggestions( + [{ state: tableState1, tableColumns: [] }, { state: tableState2, tableColumns: [] }], + { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { tableIndex: 0, score: 0.3, title: 'Test', state: {} }, + { tableIndex: 1, score: 0.2, title: 'Test2', state: {} }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [{ tableIndex: 1, score: 0.1, title: 'Test3', state: {} }], + }, + }, + 'vis1', + {} + ); + expect(suggestions[0].datasourceState).toBe(tableState1); + expect(suggestions[1].datasourceState).toBe(tableState2); + expect(suggestions[1].datasourceState).toBe(tableState2); + }); + + it('should pass the state of the currently active visualization to getSuggestions', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const currentState = {}; + getSuggestions( + [{ state: {}, tableColumns: [] }, { state: {}, tableColumns: [] }], + { + vis1: mockVisualization1, + vis2: mockVisualization2, + }, + 'vis1', + currentState + ); + expect(mockVisualization1.getSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + state: currentState, + }) + ); + expect(mockVisualization2.getSuggestions).not.toHaveBeenCalledWith( + expect.objectContaining({ + state: currentState, + }) + ); + }); +}); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts new file mode 100644 index 0000000000000..c6f5d4d4538f3 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts @@ -0,0 +1,61 @@ +/* + * 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 { Visualization, DatasourceSuggestion } from '../../types'; +import { Action } from './state_management'; + +export interface Suggestion { + visualizationId: string; + datasourceState: unknown; + score: number; + title: string; + state: unknown; +} + +/** + * 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( + datasourceTableSuggestions: DatasourceSuggestion[], + visualizationMap: Record, + activeVisualizationId: string | null, + visualizationState: unknown +): Suggestion[] { + const datasourceTables = datasourceTableSuggestions.map(({ tableColumns }) => tableColumns); + + return ( + Object.entries(visualizationMap) + .map(([visualizationId, visualization]) => { + return visualization + .getSuggestions({ + tables: datasourceTables, + state: visualizationId === activeVisualizationId ? visualizationState : undefined, + }) + .map(({ tableIndex: datasourceSuggestionId, ...suggestion }) => ({ + ...suggestion, + visualizationId, + datasourceState: datasourceTableSuggestions[datasourceSuggestionId].state, + })); + }) + // TODO why is flatMap not available here? + .reduce((globalList, currentList) => [...globalList, ...currentList], []) + .sort(({ score: scoreA }, { score: scoreB }) => scoreB - scoreA) + ); +} + +export function toSwitchAction(suggestion: Suggestion): Action { + return { + type: 'SWITCH_VISUALIZATION', + newVisualizationId: suggestion.visualizationId, + initialState: suggestion.state, + datasourceState: suggestion.datasourceState, + }; +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx new file mode 100644 index 0000000000000..9d9730db37651 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.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 { FormattedMessage } from '@kbn/i18n/react'; + +import { Action } from './state_management'; +import { Datasource, Visualization } from '../../types'; +import { getSuggestions, toSwitchAction } from './suggestion_helpers'; + +export interface SuggestionPanelProps { + activeDatasource: Datasource; + datasourceState: unknown; + activeVisualizationId: string | null; + visualizationMap: Record; + visualizationState: unknown; + dispatch: (action: Action) => void; +} + +export function SuggestionPanel({ + activeDatasource, + datasourceState, + activeVisualizationId, + visualizationMap, + visualizationState, + dispatch, +}: SuggestionPanelProps) { + const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsFromCurrentState( + datasourceState + ); + + const suggestions = getSuggestions( + datasourceSuggestions, + visualizationMap, + activeVisualizationId, + visualizationState + ); + + return ( + <> +

+ +

+ {suggestions.map((suggestion, index) => { + return ( + + ); + })} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx new file mode 100644 index 0000000000000..761b77757df62 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -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 React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { Action } from './state_management'; +import { Datasource, Visualization, DatasourcePublicAPI } from '../../types'; +import { DragDrop } from '../../drag_drop'; +import { getSuggestions, toSwitchAction } from './suggestion_helpers'; + +export interface WorkspacePanelProps { + activeDatasource: Datasource; + datasourceState: unknown; + activeVisualizationId: string | null; + visualizationMap: Record; + visualizationState: unknown; + datasourcePublicAPI: DatasourcePublicAPI; + dispatch: (action: Action) => void; +} + +interface ExpressionRendererProps { + expression: string; +} + +function ExpressionRenderer(props: ExpressionRendererProps) { + // TODO: actually render the expression and move this to a generic folder as it can be re-used for + // suggestion rendering + return {props.expression}; +} + +export function WorkspacePanel({ + activeDatasource, + activeVisualizationId, + datasourceState, + visualizationMap, + visualizationState, + datasourcePublicAPI, + dispatch, +}: WorkspacePanelProps) { + function onDrop() { + const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsForField( + datasourceState + ); + + const suggestions = getSuggestions( + datasourceSuggestions, + visualizationMap, + activeVisualizationId, + visualizationState + ); + + if (suggestions.length === 0) { + // TODO specify and implement behavior in case of no valid suggestions + return; + } + + const suggestion = suggestions[0]; + + // TODO heuristically present the suggestions in a modal instead of just picking the first one + dispatch(toSwitchAction(suggestion)); + } + + function renderEmptyWorkspace() { + return ( +

+ +

+ ); + } + + function renderVisualization() { + if (activeVisualizationId === null) { + return renderEmptyWorkspace(); + } + + const activeVisualization = visualizationMap[activeVisualizationId]; + const datasourceExpression = activeDatasource.toExpression(datasourceState); + const visualizationExpression = activeVisualization.toExpression( + visualizationState, + datasourcePublicAPI + ); + const expression = `${datasourceExpression} | ${visualizationExpression}`; + + return ; + } + + return ( + + {renderVisualization()} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/mock_extensions.ts b/x-pack/plugins/lens/public/editor_frame_plugin/mock_extensions.ts new file mode 100644 index 0000000000000..45162d7c07960 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/mock_extensions.ts @@ -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 { DatasourcePublicAPI, Visualization, Datasource } from '../types'; + +export function createMockVisualization(): jest.Mocked { + return { + getPersistableState: jest.fn(_state => ({})), + getSuggestions: jest.fn(_options => []), + initialize: jest.fn(_state => ({})), + renderConfigPanel: jest.fn(), + toExpression: jest.fn((_state, _datasource) => ''), + }; +} + +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(), + removeColumnInTableSpec: jest.fn(), + moveColumnTo: jest.fn(), + duplicateColumn: jest.fn(), + }; + + return { + getDatasourceSuggestionsForField: jest.fn(_state => []), + getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []), + getPersistableState: jest.fn(), + getPublicAPI: jest.fn((_state, _setState) => publicAPIMock), + initialize: jest.fn(_state => Promise.resolve()), + renderDataPanel: jest.fn(), + toExpression: jest.fn(_state => ''), + + // 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, + }; +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx index 1ca641b2e6e37..1d81f315bf525 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx @@ -5,9 +5,11 @@ */ import { EditorFramePlugin } from './plugin'; -import { Visualization, Datasource } from '../types'; +import { createMockDatasource, createMockVisualization } from './mock_extensions'; -const nextTick = () => new Promise(resolve => setTimeout(resolve)); +// 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)); describe('editor_frame plugin', () => { let pluginInstance: EditorFramePlugin; @@ -51,23 +53,14 @@ describe('editor_frame plugin', () => { }); it('should initialize and render provided datasource', async () => { + const mockDatasource = createMockDatasource(); const publicAPI = pluginInstance.setup(); - const mockDatasource = { - getDatasourceSuggestionsForField: jest.fn(), - getDatasourceSuggestionsFromCurrentState: jest.fn(), - getPersistableState: jest.fn(), - getPublicAPI: jest.fn(), - initialize: jest.fn(() => Promise.resolve()), - renderDataPanel: jest.fn(), - toExpression: jest.fn(), - }; - publicAPI.registerDatasource('test', mockDatasource); const instance = publicAPI.createInstance({}); instance.mount(mountpoint); - await nextTick(); + await waitForPromises(); expect(mockDatasource.initialize).toHaveBeenCalled(); expect(mockDatasource.renderDataPanel).toHaveBeenCalled(); @@ -76,25 +69,9 @@ describe('editor_frame plugin', () => { }); it('should initialize visualization and render config panel', async () => { + const mockDatasource = createMockDatasource(); + const mockVisualization = createMockVisualization(); const publicAPI = pluginInstance.setup(); - const mockDatasource: Datasource = { - getDatasourceSuggestionsForField: jest.fn(), - getDatasourceSuggestionsFromCurrentState: jest.fn(), - getPersistableState: jest.fn(), - getPublicAPI: jest.fn(), - initialize: jest.fn(() => Promise.resolve()), - renderDataPanel: jest.fn(), - toExpression: jest.fn(), - }; - - const mockVisualization: Visualization = { - getMappingOfTableToRoles: jest.fn(), - getPersistableState: jest.fn(), - getSuggestions: jest.fn(), - initialize: jest.fn(), - renderConfigPanel: jest.fn(), - toExpression: jest.fn(), - }; publicAPI.registerDatasource('test', mockDatasource); publicAPI.registerVisualization('test', mockVisualization); @@ -102,7 +79,7 @@ describe('editor_frame plugin', () => { const instance = publicAPI.createInstance({}); instance.mount(mountpoint); - await nextTick(); + await waitForPromises(); expect(mockVisualization.initialize).toHaveBeenCalled(); expect(mockVisualization.renderConfigPanel).toHaveBeenCalled(); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx index 07c1841601140..7c12d95cb69c1 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -6,8 +6,9 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; +import { I18nProvider } from '@kbn/i18n/react'; +import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; import { EditorFrame } from './editor_frame'; export class EditorFramePlugin { @@ -29,13 +30,19 @@ export class EditorFramePlugin { mount: element => { unmount(); domElement = element; + + const firstDatasourceId = Object.keys(this.datasources)[0]; + const firstVisualizationId = Object.keys(this.visualizations)[0]; + render( - , + + + , domElement ); }, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 99bfbba1c87b8..7f73a0764d056 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -21,24 +21,14 @@ export interface EditorFrameSetup { // Hints the default nesting to the data source. 0 is the highest priority export type DimensionPriority = 0 | 1 | 2; -// For switching between visualizations and correctly matching columns -export type DimensionRole = - | 'splitChart' - | 'series' - | 'primary' - | 'secondary' - | 'color' - | 'size' - | string; // Some visualizations will use custom names that have other meaning - -export interface TableColumns { +export interface TableColumn { columnId: string; operation: Operation; } export interface DatasourceSuggestion { state: T; - tableColumns: TableColumns[]; + tableColumns: TableColumn[]; } /** @@ -139,10 +129,8 @@ export interface VisualizationProps { } export interface SuggestionRequest { - // Roles currently being used - roles: DimensionRole[]; // It is up to the Visualization to rank these tables - tableColumns: { [datasourceSuggestionId: string]: TableColumns }; + tables: TableColumn[][]; state?: T; // State is only passed if the visualization is active } @@ -150,7 +138,7 @@ export interface VisualizationSuggestion { score: number; title: string; state: T; - datasourceSuggestionId: string; + tableIndex: number; } export interface Visualization { @@ -163,9 +151,6 @@ export interface Visualization { toExpression: (state: T, datasource: DatasourcePublicAPI) => string; - // Frame will request the list of roles currently being used when calling `getInitialStateFromOtherVisualization` - getMappingOfTableToRoles: (state: T, datasource: DatasourcePublicAPI) => DimensionRole[]; - // The frame will call this function on all visualizations when the table changes, or when // rendering additional ways of using the data getSuggestions: (options: SuggestionRequest) => Array>; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index 7ec2994161216..c5d69175e90cd 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -6,10 +6,10 @@ import React from 'react'; import { render } from 'react-dom'; -import { Visualization, DimensionRole } from '../types'; +import { Visualization } from '../types'; export interface XyVisualizationState { - roles: DimensionRole[]; + roles: string[]; } export type XyVisualizationPersistedState = XyVisualizationState; @@ -31,7 +31,5 @@ export const xyVisualization: Visualization [], - getMappingOfTableToRoles: (state, datasource) => [], - toExpression: state => '', }; From f88ee1e4d0718df7d88333507ec51ce87bc3df68 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 29 May 2019 12:29:37 -0400 Subject: [PATCH 013/105] [lens] Dimension panel that generates columns (#37117) * [lens] Dimension panel that generates columns * Update from review comments --- .../__snapshots__/indexpattern.test.tsx.snap | 29 +++++++ .../indexpattern_plugin/indexpattern.test.tsx | 62 ++++++++++++++ .../indexpattern_plugin/indexpattern.tsx | 83 ++++++++++++++++--- x-pack/plugins/lens/public/types.ts | 2 +- .../xy_visualization.tsx | 18 +++- 5 files changed, 179 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap b/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap index fb8abf6ccb664..45760f0bf5efb 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap @@ -3,6 +3,35 @@ exports[`IndexPattern Data Source #getPublicAPI renderDimensionPanel should render a dimension panel 1`] = `
Dimension Panel +
`; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index 4130c42617eb2..9e19f7bd2c4d3 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -199,12 +199,74 @@ describe('IndexPattern Data Source', () => { {}} + columnId={'col2'} filterOperations={(operation: Operation) => true} /> ); expect(wrapper).toMatchSnapshot(); }); + + it('should call the filterOperations function', () => { + const filterOperations = jest.fn().mockReturnValue(true); + + shallow( + {}} + columnId={'col2'} + filterOperations={filterOperations} + /> + ); + + expect(filterOperations).toBeCalledTimes(3); + }); + + it('should filter out all selections if the filter returns false', () => { + const wrapper = shallow( + {}} + columnId={'col2'} + filterOperations={() => false} + /> + ); + + expect(wrapper.find(EuiComboBox)!.prop('options')!.length).toEqual(0); + }); + + it('should update the datasource state on selection', () => { + const setState = jest.fn(); + + const wrapper = shallow( + true} + /> + ); + + const comboBox = wrapper.find(EuiComboBox)!; + const firstOption = comboBox.prop('options')![0]; + + comboBox.prop('onChange')!([firstOption]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col2: { + operationId: firstOption.value, + label: 'Value of timestamp', + dataType: 'date', + isBucketed: false, + operationType: 'value', + }, + }, + columnOrder: ['col1', 'col2'], + }); + }); }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 140c54031677f..81549315fe41b 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -20,7 +20,7 @@ interface IndexPatternColumn { operationId: string; label: string; dataType: DataType; - isBucketed: false; + isBucketed: boolean; // Private operationType: OperationType; @@ -99,7 +99,69 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { }; export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { - return
Dimension Panel
; + const fields = props.state.indexPatterns[props.state.currentIndexPatternId].fields; + const columns: IndexPatternColumn[] = fields.map((field, index) => ({ + operationId: `${index}`, + label: `Value of ${field.name}`, + dataType: field.type as DataType, + isBucketed: false, + + operationType: 'value' as OperationType, + })); + + const filteredColumns = columns.filter(col => { + const { operationId, label, dataType, isBucketed } = col; + + return props.filterOperations({ + id: operationId, + label, + dataType, + isBucketed, + }); + }); + + const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; + + return ( +
+ Dimension Panel + ({ + label: col.label, + value: col.operationId, + }))} + selectedOptions={ + selectedColumn + ? [ + { + label: selectedColumn.label, + value: selectedColumn.operationId, + }, + ] + : [] + } + singleSelection={{ asPlainText: true }} + isClearable={false} + onChange={choices => { + const column: IndexPatternColumn = columns.find( + ({ operationId }) => operationId === choices[0].value + )!; + const newColumns: IndexPatternPrivateState['columns'] = { + ...props.state.columns, + [props.columnId]: column, + }; + + props.setState({ + ...props.state, + columns: newColumns, + // Order is not meaningful until we aggregate + columnOrder: Object.keys(newColumns), + }); + }} + /> +
+ ); } export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: ToastNotifications) { @@ -151,16 +213,13 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To }, getOperationForColumnId: (columnId: string) => { const column = state.columns[columnId]; - if (columnId) { - const { dataType, label, isBucketed, operationId } = column; - return { - id: operationId, - label, - dataType, - isBucketed, - }; - } - return null; + const { dataType, label, isBucketed, operationId } = column; + return { + id: operationId, + label, + dataType, + isBucketed, + }; }, renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 7f73a0764d056..f77822191c6fd 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -76,7 +76,7 @@ export interface DatasourceDataPanelProps { // The only way a visualization has to restrict the query building export interface DatasourceDimensionPanelProps { // If no columnId is passed, it will render as empty - columnId?: string; + columnId: string; // Visualizations can restrict operations based on their own rules filterOperations: (operation: Operation) => boolean; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index c5d69175e90cd..1da16b073b148 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -6,7 +6,8 @@ import React from 'react'; import { render } from 'react-dom'; -import { Visualization } from '../types'; +import { Visualization, Operation } from '../types'; +import { NativeRenderer } from '../native_renderer'; export interface XyVisualizationState { roles: string[]; @@ -26,7 +27,20 @@ export const xyVisualization: Visualization { - render(
XY Visualization
, domElement); + render( +
+ XY Visualization + true, + suggestedOrder: 1, + }} + render={props.datasource.renderDimensionPanel} + /> +
, + domElement + ); }, getSuggestions: options => [], From c3df4c27e23868b69dee54c823af35d196b4230e Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 29 May 2019 16:32:02 -0400 Subject: [PATCH 014/105] [lens] Generate esdocs queries from index pattern (#37361) * [lens] Generate esdocs queries from index pattern * Remove unused code --- .../indexpattern_plugin/indexpattern.test.tsx | 42 +++++++++++++++++++ .../indexpattern_plugin/indexpattern.tsx | 13 +++++- .../xy_visualization.tsx | 8 ++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index 9e19f7bd2c4d3..418cb9eab2443 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -91,6 +91,7 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'value', + sourceField: 'op', }, }, }; @@ -158,6 +159,46 @@ describe('IndexPattern Data Source', () => { }); }); + describe('#toExpression', () => { + it('should generate an empty expression when no columns are selected', async () => { + const state = await indexPatternDatasource.initialize(); + expect(indexPatternDatasource.toExpression(state)).toEqual(''); + }); + + it('should generate an expression for a values query', async () => { + const queryPersistedState: IndexPatternPersistedState = { + currentIndexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + operationId: 'op1', + label: 'My Op', + dataType: 'string', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'op', + }, + col2: { + operationId: 'op2', + label: 'My Op 2', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'op2', + }, + }, + }; + const state = await indexPatternDatasource.initialize(queryPersistedState); + expect(indexPatternDatasource.toExpression(state)).toMatchInlineSnapshot( + `"esdocs index=\\"1\\" fields=\\"op, op2\\" sort=\\"op, DESC\\""` + ); + }); + }); + describe('#getPublicAPI', () => { let publicAPI: DatasourcePublicAPI; @@ -262,6 +303,7 @@ describe('IndexPattern Data Source', () => { dataType: 'date', isBucketed: false, operationType: 'value', + sourceField: 'timestamp', }, }, columnOrder: ['col1', 'col2'], diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 81549315fe41b..ec558609e3900 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -24,6 +24,7 @@ interface IndexPatternColumn { // Private operationType: OperationType; + sourceField: string; } export interface IndexPattern { @@ -107,6 +108,7 @@ export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProp isBucketed: false, operationType: 'value' as OperationType, + sourceField: field.name, })); const filteredColumns = columns.filter(col => { @@ -196,7 +198,16 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To }, toExpression(state: IndexPatternPrivateState) { - return `${JSON.stringify(state.columns)}`; + if (state.columnOrder.length === 0) { + return ''; + } + + const fieldNames = state.columnOrder.map(col => state.columns[col].sourceField); + const expression = `esdocs index="${state.currentIndexPatternId}" fields="${fieldNames.join( + ', ' + )}" sort="${fieldNames[0]}, DESC"`; + + return expression; }, renderDataPanel( diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index 1da16b073b148..b5a6111c9570f 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -38,6 +38,14 @@ export const xyVisualization: Visualization + true, + suggestedOrder: 2, + }} + render={props.datasource.renderDimensionPanel} + />
, domElement ); From f179ee6dd9d58bda8a7f006f00c0fd592b4a8254 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Thu, 30 May 2019 18:23:46 -0400 Subject: [PATCH 015/105] Update yarn.lock from yarn kbn bootstrap --- yarn.lock | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/yarn.lock b/yarn.lock index dc4c38323cfd8..6f93b40306ab3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3335,20 +3335,6 @@ resolved "https://registry.yarnpkg.com/@types/deepmerge/-/deepmerge-2.1.0.tgz#22f175e5cb55874fe818caa6fd50a1d98fc3d748" integrity sha512-/0Ct/q5g+SgaACZ+A0ylY3071nEBN7QDnTWiCtaB3fx24UpoAQXf25yNVloOYVUis7jytM1F1WC78+EOwXkQJQ== -"@types/del@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/del/-/del-3.0.0.tgz#1c8cd8b6e38da3b572352ca8eaf5527931426288" - integrity sha512-18mSs54BvzV8+TTQxt0ancig6tsuPZySnhp3cQkWFFDmDMavU4pmWwR+bHHqRBWODYqpzIzVkqKLuk/fP6yypQ== - dependencies: - "@types/glob" "*" - -"@types/del@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@types/del/-/del-3.0.1.tgz#4712da8c119873cbbf533ad8dbf1baac5940ac5d" - integrity sha512-y6qRq6raBuu965clKgx6FHuiPu3oHdtmzMPXi8Uahsjdq1L6DL5fS/aY5/s71YwM7k6K1QIWvem5vNwlnNGIkQ== - dependencies: - "@types/glob" "*" - "@types/delete-empty@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964" From 657500073cb3112a99fa45e2eb2c38e5b8da05c9 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Fri, 31 May 2019 11:44:07 -0400 Subject: [PATCH 016/105] [Lens] Add basic Lens xy chart suggestions (#37030) Basic xy chart suggestions --- .../editor_frame/editor_frame.test.tsx | 47 ++-- .../editor_frame/suggestion_helpers.test.ts | 49 ++-- .../editor_frame/suggestion_helpers.ts | 4 +- x-pack/plugins/lens/public/types.ts | 13 +- .../xy_expression.test.tsx | 3 - .../xy_visualization_plugin/xy_expression.tsx | 6 +- .../xy_suggestions.test.ts | 233 ++++++++++++++++++ .../xy_visualization_plugin/xy_suggestions.ts | 102 ++++++++ .../xy_visualization.test.ts | 72 ++++-- .../xy_visualization.tsx | 40 ++- 10 files changed, 487 insertions(+), 82 deletions(-) create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index 904c11d2f29de..6574d504bfe35 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { ReactWrapper } from 'enzyme'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EditorFrame } from './editor_frame'; -import { Visualization, Datasource, DatasourcePublicAPI } from '../../types'; +import { Visualization, Datasource, DatasourcePublicAPI, DatasourceSuggestion } from '../../types'; import { act } from 'react-dom/test-utils'; import { createMockVisualization, createMockDatasource } from '../mock_extensions'; @@ -16,6 +16,17 @@ import { createMockVisualization, createMockDatasource } from '../mock_extension // datasources to be processed by its callers. const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); +function generateSuggestion(datasourceSuggestionId = 1, state = {}): DatasourceSuggestion { + return { + state: {}, + table: { + columns: [], + datasourceSuggestionId: 1, + isMultiRow: true, + }, + }; +} + describe('editor_frame', () => { let mockVisualization: Visualization; let mockDatasource: Datasource; @@ -475,13 +486,13 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - tableIndex: 0, + datasourceSuggestionId: 0, score: 0.5, state: {}, title: 'Suggestion2', }, { - tableIndex: 0, + datasourceSuggestionId: 0, score: 0.8, state: {}, title: 'Suggestion1', @@ -492,13 +503,13 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - tableIndex: 0, + datasourceSuggestionId: 0, score: 0.4, state: {}, title: 'Suggestion4', }, { - tableIndex: 0, + datasourceSuggestionId: 0, score: 0.45, state: {}, title: 'Suggestion3', @@ -509,7 +520,7 @@ describe('editor_frame', () => { datasourceMap={{ testDatasource: { ...mockDatasource, - getDatasourceSuggestionsFromCurrentState: () => [{ state: {}, tableColumns: [] }], + getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], }, }} initialDatasourceId="testDatasource" @@ -540,7 +551,7 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - tableIndex: 0, + datasourceSuggestionId: 0, score: 0.8, state: suggestionVisState, title: 'Suggestion1', @@ -552,9 +563,7 @@ describe('editor_frame', () => { datasourceMap={{ testDatasource: { ...mockDatasource, - getDatasourceSuggestionsFromCurrentState: () => [ - { state: newDatasourceState, tableColumns: [] }, - ], + getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], }, }} initialDatasourceId="testDatasource" @@ -595,13 +604,13 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - tableIndex: 0, + datasourceSuggestionId: 0, score: 0.2, state: {}, title: 'Suggestion1', }, { - tableIndex: 0, + datasourceSuggestionId: 0, score: 0.8, state: suggestionVisState, title: 'Suggestion2', @@ -613,8 +622,8 @@ describe('editor_frame', () => { datasourceMap={{ testDatasource: { ...mockDatasource, - getDatasourceSuggestionsForField: () => [{ state: {}, tableColumns: [] }], - getDatasourceSuggestionsFromCurrentState: () => [{ state: {}, tableColumns: [] }], + getDatasourceSuggestionsForField: () => [generateSuggestion()], + getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], }, }} initialDatasourceId="testDatasource" @@ -648,13 +657,13 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - tableIndex: 0, + datasourceSuggestionId: 0, score: 0.2, state: {}, title: 'Suggestion1', }, { - tableIndex: 0, + datasourceSuggestionId: 0, score: 0.6, state: {}, title: 'Suggestion2', @@ -665,7 +674,7 @@ describe('editor_frame', () => { ...mockVisualization2, getSuggestions: () => [ { - tableIndex: 0, + datasourceSuggestionId: 0, score: 0.8, state: suggestionVisState, title: 'Suggestion3', @@ -676,8 +685,8 @@ describe('editor_frame', () => { datasourceMap={{ testDatasource: { ...mockDatasource, - getDatasourceSuggestionsForField: () => [{ state: {}, tableColumns: [] }], - getDatasourceSuggestionsFromCurrentState: () => [{ state: {}, tableColumns: [] }], + getDatasourceSuggestionsForField: () => [generateSuggestion()], + getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], }, }} initialDatasourceId="testDatasource" diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts index f79a4b1000991..437faa2779f3e 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts @@ -6,19 +6,24 @@ import { getSuggestions } from './suggestion_helpers'; import { createMockVisualization } from '../mock_extensions'; -import { TableColumn } from '../../types'; +import { TableSuggestion } from '../../types'; + +const generateSuggestion = (datasourceSuggestionId: number = 1, state = {}) => ({ + state, + table: { datasourceSuggestionId, columns: [], isMultiRow: false }, +}); describe('suggestion helpers', () => { it('should return suggestions array', () => { const mockVisualization = createMockVisualization(); const suggestedState = {}; const suggestions = getSuggestions( - [{ state: {}, tableColumns: [] }], + [generateSuggestion()], { vis1: { ...mockVisualization, getSuggestions: () => [ - { tableIndex: 0, score: 0.5, title: 'Test', state: suggestedState }, + { datasourceSuggestionId: 0, score: 0.5, title: 'Test', state: suggestedState }, ], }, }, @@ -33,18 +38,20 @@ describe('suggestion helpers', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); const suggestions = getSuggestions( - [{ state: {}, tableColumns: [] }], + [generateSuggestion()], { vis1: { ...mockVisualization1, getSuggestions: () => [ - { tableIndex: 0, score: 0.5, title: 'Test', state: {} }, - { tableIndex: 0, score: 0.5, title: 'Test2', state: {} }, + { datasourceSuggestionId: 0, score: 0.5, title: 'Test', state: {} }, + { datasourceSuggestionId: 0, score: 0.5, title: 'Test2', state: {} }, ], }, vis2: { ...mockVisualization2, - getSuggestions: () => [{ tableIndex: 0, score: 0.5, title: 'Test3', state: {} }], + getSuggestions: () => [ + { datasourceSuggestionId: 0, score: 0.5, title: 'Test3', state: {} }, + ], }, }, 'vis1', @@ -57,18 +64,20 @@ describe('suggestion helpers', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); const suggestions = getSuggestions( - [{ state: {}, tableColumns: [] }], + [generateSuggestion()], { vis1: { ...mockVisualization1, getSuggestions: () => [ - { tableIndex: 0, score: 0.2, title: 'Test', state: {} }, - { tableIndex: 0, score: 0.8, title: 'Test2', state: {} }, + { datasourceSuggestionId: 0, score: 0.2, title: 'Test', state: {} }, + { datasourceSuggestionId: 0, score: 0.8, title: 'Test2', state: {} }, ], }, vis2: { ...mockVisualization2, - getSuggestions: () => [{ tableIndex: 0, score: 0.6, title: 'Test3', state: {} }], + getSuggestions: () => [ + { datasourceSuggestionId: 0, score: 0.6, title: 'Test3', state: {} }, + ], }, }, 'vis1', @@ -82,10 +91,10 @@ describe('suggestion helpers', () => { it('should call all suggestion getters with all available data tables', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); - const table1: TableColumn[] = []; - const table2: TableColumn[] = []; + const table1: TableSuggestion = { datasourceSuggestionId: 0, columns: [], isMultiRow: true }; + const table2: TableSuggestion = { datasourceSuggestionId: 1, columns: [], isMultiRow: true }; getSuggestions( - [{ state: {}, tableColumns: table1 }, { state: {}, tableColumns: table2 }], + [{ state: {}, table: table1 }, { state: {}, table: table2 }], { vis1: mockVisualization1, vis2: mockVisualization2, @@ -105,18 +114,20 @@ describe('suggestion helpers', () => { const tableState1 = {}; const tableState2 = {}; const suggestions = getSuggestions( - [{ state: tableState1, tableColumns: [] }, { state: tableState2, tableColumns: [] }], + [generateSuggestion(1, tableState1), generateSuggestion(1, tableState2)], { vis1: { ...mockVisualization1, getSuggestions: () => [ - { tableIndex: 0, score: 0.3, title: 'Test', state: {} }, - { tableIndex: 1, score: 0.2, title: 'Test2', state: {} }, + { datasourceSuggestionId: 0, score: 0.3, title: 'Test', state: {} }, + { datasourceSuggestionId: 1, score: 0.2, title: 'Test2', state: {} }, ], }, vis2: { ...mockVisualization2, - getSuggestions: () => [{ tableIndex: 1, score: 0.1, title: 'Test3', state: {} }], + getSuggestions: () => [ + { datasourceSuggestionId: 1, score: 0.1, title: 'Test3', state: {} }, + ], }, }, 'vis1', @@ -132,7 +143,7 @@ describe('suggestion helpers', () => { const mockVisualization2 = createMockVisualization(); const currentState = {}; getSuggestions( - [{ state: {}, tableColumns: [] }, { state: {}, tableColumns: [] }], + [generateSuggestion(1), generateSuggestion(2)], { vis1: mockVisualization1, vis2: mockVisualization2, diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts index c6f5d4d4538f3..459f5d89fb9c3 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts @@ -29,7 +29,7 @@ export function getSuggestions( activeVisualizationId: string | null, visualizationState: unknown ): Suggestion[] { - const datasourceTables = datasourceTableSuggestions.map(({ tableColumns }) => tableColumns); + const datasourceTables = datasourceTableSuggestions.map(({ table }) => table); return ( Object.entries(visualizationMap) @@ -39,7 +39,7 @@ export function getSuggestions( tables: datasourceTables, state: visualizationId === activeVisualizationId ? visualizationState : undefined, }) - .map(({ tableIndex: datasourceSuggestionId, ...suggestion }) => ({ + .map(({ datasourceSuggestionId, ...suggestion }) => ({ ...suggestion, visualizationId, datasourceState: datasourceTableSuggestions[datasourceSuggestionId].state, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index f77822191c6fd..9932f8b3c703c 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -11,6 +11,7 @@ export interface EditorFrameInstance { mount: (element: Element) => void; unmount: () => void; } + export interface EditorFrameSetup { createInstance: (options: EditorFrameOptions) => EditorFrameInstance; // generic type on the API functions to pull the "unknown vs. specific type" error into the implementation @@ -26,9 +27,15 @@ export interface TableColumn { operation: Operation; } +export interface TableSuggestion { + datasourceSuggestionId: number; + isMultiRow: boolean; + columns: TableColumn[]; +} + export interface DatasourceSuggestion { state: T; - tableColumns: TableColumn[]; + table: TableSuggestion; } /** @@ -130,7 +137,7 @@ export interface VisualizationProps { export interface SuggestionRequest { // It is up to the Visualization to rank these tables - tables: TableColumn[][]; + tables: TableSuggestion[]; state?: T; // State is only passed if the visualization is active } @@ -138,7 +145,7 @@ export interface VisualizationSuggestion { score: number; title: string; state: T; - tableIndex: number; + datasourceSuggestionId: number; } export interface Visualization { diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx index bc5c513bb0f1b..2a9fcd2fc4e33 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx @@ -31,19 +31,16 @@ function sampleArgs() { seriesType: 'line', title: 'My fanci line chart', legend: { - type: 'lens_xy_legendConfig', isVisible: false, position: Position.Top, }, y: { - type: 'lens_xy_yConfig', accessors: ['a', 'b'], position: Position.Left, showGridlines: false, title: 'A and B', }, x: { - type: 'lens_xy_xConfig', accessor: 'c', position: Position.Bottom, showGridlines: false, diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx index f52e6f69beb27..4adcd7ce7a1c5 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx @@ -152,9 +152,9 @@ export const xConfig: ContextFunction<'lens_xy_xConfig', null, XConfig, XConfigR export interface XYArgs { seriesType: 'bar' | 'line' | 'area'; title: string; - legend: LegendConfigResult; - y: YConfigResult; - x: XConfigResult; + legend: LegendConfig; + y: YConfig; + x: XConfig; splitSeriesAccessors: string[]; stackAccessors: string[]; } diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts new file mode 100644 index 0000000000000..bc68cf206d573 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts @@ -0,0 +1,233 @@ +/* + * 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 { TableColumn, VisualizationSuggestion } from '../types'; +import { XYArgs } from './xy_expression'; + +describe('xy_suggestions', () => { + function numCol(columnId: string): TableColumn { + return { + columnId, + operation: { + dataType: 'number', + id: `avg_${columnId}`, + label: `Avg ${columnId}`, + isBucketed: false, + }, + }; + } + + function strCol(columnId: string): TableColumn { + return { + columnId, + operation: { + dataType: 'string', + id: `terms_${columnId}`, + label: `Top 5 ${columnId}`, + isBucketed: true, + }, + }; + } + + function dateCol(columnId: string): TableColumn { + return { + columnId, + operation: { + dataType: 'date', + id: `date_histogram_${columnId}`, + isBucketed: true, + label: `${columnId} histogram`, + }, + }; + } + + // Helper that plucks out the important part of a suggestion for + // most test assertions + function suggestionSubset(suggestion: VisualizationSuggestion) { + const { seriesType, splitSeriesAccessors, stackAccessors, x, y } = suggestion.state; + + return { + seriesType, + splitSeriesAccessors, + stackAccessors, + x: x.accessor, + y: y.accessors, + }; + } + + 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( + getSuggestions({ + tables: [ + { datasourceSuggestionId: 0, isMultiRow: true, columns: [dateCol('a')] }, + { datasourceSuggestionId: 1, isMultiRow: true, columns: [strCol('foo'), strCol('bar')] }, + { datasourceSuggestionId: 2, isMultiRow: false, columns: [strCol('foo'), numCol('bar')] }, + { datasourceSuggestionId: 3, isMultiRow: true, columns: [unknownCol(), numCol('bar')] }, + ], + }) + ).toEqual([]); + }); + + test('suggests a basic x y chart with date on x', () => { + const [suggestion, ...rest] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + }, + ], + }); + + expect(rest.length).toEqual(0); + expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` +Object { + "seriesType": "line", + "splitSeriesAccessors": Array [], + "stackAccessors": Array [], + "x": "date", + "y": Array [ + "bytes", + ], +} +`); + }); + + test('suggests a split x y chart with date on x', () => { + const [suggestion, ...rest] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + }, + ], + }); + + expect(rest.length).toEqual(0); + expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` +Object { + "seriesType": "line", + "splitSeriesAccessors": Array [ + "product", + ], + "stackAccessors": Array [], + "x": "date", + "y": Array [ + "price", + "quantity", + ], +} +`); + }); + + test('supports multiple suggestions', () => { + const [s1, s2, ...rest] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [numCol('price'), dateCol('date')], + }, + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [numCol('count'), strCol('country')], + }, + ], + }); + + expect(rest.length).toEqual(0); + expect([suggestionSubset(s1), suggestionSubset(s2)]).toMatchInlineSnapshot(` +Array [ + Object { + "seriesType": "line", + "splitSeriesAccessors": Array [], + "stackAccessors": Array [], + "x": "date", + "y": Array [ + "price", + ], + }, + Object { + "seriesType": "bar", + "splitSeriesAccessors": Array [], + "stackAccessors": Array [], + "x": "country", + "y": Array [ + "count", + ], + }, +] +`); + }); + + test('handles two numeric values', () => { + const [suggestion] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [numCol('quantity'), numCol('price')], + }, + ], + }); + + expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` +Object { + "seriesType": "bar", + "splitSeriesAccessors": Array [], + "stackAccessors": Array [], + "x": "quantity", + "y": Array [ + "price", + ], +} +`); + }); + + test('handles unbucketed suggestions', () => { + const [suggestion] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [ + numCol('num votes'), + { + columnId: 'mybool', + operation: { + dataType: 'boolean', + id: 'mybool', + isBucketed: false, + label: 'Yes / No', + }, + }, + ], + }, + ], + }); + + expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` +Object { + "seriesType": "bar", + "splitSeriesAccessors": Array [], + "stackAccessors": Array [], + "x": "mybool", + "y": Array [ + "num votes", + ], +} +`); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts new file mode 100644 index 0000000000000..f1b49a2318424 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { partition } from 'lodash'; +import { Position } from '@elastic/charts'; +import { XYArgs } from './xy_expression'; +import { SuggestionRequest, VisualizationSuggestion, TableColumn, TableSuggestion } from '../types'; + +const columnSortOrder = { + date: 0, + string: 1, + boolean: 2, + number: 3, +}; + +/** + * Generate suggestions for the xy chart. + * + * @param opts + */ +export function getSuggestions( + opts: SuggestionRequest +): Array> { + return opts.tables + .filter( + ({ isMultiRow, columns }) => + // 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. + isMultiRow && + columns.length > 1 && + columns.some(col => col.operation.dataType === 'number') && + !columns.some(col => !columnSortOrder.hasOwnProperty(col.operation.dataType)) + ) + .map(table => getSuggestionForColumns(table)); +} + +function getSuggestionForColumns(table: TableSuggestion): VisualizationSuggestion { + const [buckets, values] = partition( + prioritizeColumns(table.columns), + col => col.operation.isBucketed + ); + + if (buckets.length >= 1) { + const [x, splitBy] = buckets; + return getSuggestion(table.datasourceSuggestionId, x, values, splitBy); + } else { + const [x, ...yValues] = values; + return getSuggestion(table.datasourceSuggestionId, x, yValues); + } +} + +// 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: TableColumn[]) { + return [...columns].sort( + (a, b) => columnSortOrder[a.operation.dataType] - columnSortOrder[b.operation.dataType] + ); +} + +function getSuggestion( + datasourceSuggestionId: number, + xValue: TableColumn, + yValues: TableColumn[], + splitBy?: TableColumn +): VisualizationSuggestion { + const yTitle = yValues.map(col => col.operation.label).join(' & '); + const xTitle = xValue.operation.label; + const isDate = xValue.operation.dataType === 'date'; + + // TODO: Localize the title, label, etc + const preposition = isDate ? 'over' : 'of'; + const title = `${yTitle} ${preposition} ${xTitle}`; + return { + title, + score: 1, + datasourceSuggestionId, + state: { + title, + legend: { isVisible: true, position: Position.Right }, + seriesType: isDate ? 'line' : 'bar', + splitSeriesAccessors: splitBy && isDate ? [splitBy.columnId] : [], + stackAccessors: splitBy && !isDate ? [splitBy.columnId] : [], + x: { + accessor: xValue.columnId, + position: Position.Bottom, + showGridlines: false, + title: xTitle, + }, + y: { + accessors: yValues.map(col => col.columnId), + position: Position.Left, + showGridlines: false, + title: yTitle, + }, + }, + }; +} diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts index a750d810d116c..51996de5e0352 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -4,40 +4,68 @@ * you may not use this file except in compliance with the Elastic License. */ -import { xyVisualization, XyVisualizationPersistedState } from './xy_visualization'; +import { xyVisualization, State } from './xy_visualization'; +import { Position } from '@elastic/charts'; -describe('IndexPattern Data Source', () => { - let persistedState: XyVisualizationPersistedState; - - beforeEach(() => { - persistedState = { - roles: [], - }; - }); +function exampleState(): State { + return { + legend: { position: Position.Bottom, isVisible: true }, + seriesType: 'area', + splitSeriesAccessors: [], + stackAccessors: [], + title: 'Foo', + x: { + accessor: 'a', + position: Position.Bottom, + showGridlines: true, + title: 'Baz', + }, + y: { + accessors: ['b', 'c'], + position: Position.Left, + showGridlines: true, + title: 'Bar', + }, + }; +} +describe('IndexPattern Data Source', () => { describe('#initialize', () => { it('loads default state', () => { - expect(xyVisualization.initialize()).toEqual({ - roles: [], - }); + expect(xyVisualization.initialize()).toMatchInlineSnapshot(` +Object { + "legend": Object { + "isVisible": true, + "position": "right", + }, + "seriesType": "line", + "splitSeriesAccessors": Array [], + "stackAccessors": Array [], + "title": "Empty line chart", + "x": Object { + "accessor": "", + "position": "bottom", + "showGridlines": false, + "title": "Uknown", + }, + "y": Object { + "accessors": Array [], + "position": "left", + "showGridlines": false, + "title": "Uknown", + }, +} +`); }); it('loads from persisted state', () => { - expect(xyVisualization.initialize(persistedState)).toEqual({ - roles: [], - }); + expect(xyVisualization.initialize(exampleState())).toEqual(exampleState()); }); }); describe('#getPersistableState', () => { it('persists the state as given', () => { - expect( - xyVisualization.getPersistableState({ - roles: [], - }) - ).toEqual({ - roles: [], - }); + expect(xyVisualization.getPersistableState(exampleState())).toEqual(exampleState()); }); }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index b5a6111c9570f..dbe678ad6ffa4 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -6,20 +6,40 @@ import React from 'react'; import { render } from 'react-dom'; +import { Position } from '@elastic/charts'; import { Visualization, Operation } from '../types'; +import { getSuggestions } from './xy_suggestions'; +import { XYArgs } from './xy_expression'; import { NativeRenderer } from '../native_renderer'; -export interface XyVisualizationState { - roles: string[]; -} +export type State = XYArgs; +export type PersistableState = XYArgs; -export type XyVisualizationPersistedState = XyVisualizationState; +export const xyVisualization: Visualization = { + getSuggestions, -export const xyVisualization: Visualization = { - initialize() { - return { - roles: [], - }; + initialize(state) { + return ( + state || { + title: 'Empty line chart', + legend: { isVisible: true, position: Position.Right }, + seriesType: 'line', + splitSeriesAccessors: [], + stackAccessors: [], + x: { + accessor: '', + position: Position.Bottom, + showGridlines: false, + title: 'Uknown', + }, + y: { + accessors: [], + position: Position.Left, + showGridlines: false, + title: 'Uknown', + }, + } + ); }, getPersistableState(state) { @@ -51,7 +71,5 @@ export const xyVisualization: Visualization [], - toExpression: state => '', }; From 9d5b3ea4c0acc4a25d4e314e92e611ba34e26726 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 3 Jun 2019 13:52:39 +0200 Subject: [PATCH 017/105] [Lens] Expression rendering (#37648) --- .../beats_management/types/kibana.d.ts | 41 ------------------- x-pack/plugins/lens/index.ts | 2 +- .../editor_frame/editor_frame.test.tsx | 29 ++++++++++++- .../editor_frame/editor_frame.tsx | 4 ++ .../editor_frame/state_management.test.ts | 2 + .../editor_frame/suggestion_helpers.test.ts | 2 +- .../editor_frame/workspace_panel.tsx | 15 ++----- .../{mock_extensions.ts => mocks.ts} | 23 +++++++++++ .../editor_frame_plugin/plugin.test.tsx | 22 +++++++--- .../public/editor_frame_plugin/plugin.tsx | 25 ++++++++--- 10 files changed, 99 insertions(+), 66 deletions(-) delete mode 100644 x-pack/plugins/beats_management/types/kibana.d.ts rename x-pack/plugins/lens/public/editor_frame_plugin/{mock_extensions.ts => mocks.ts} (69%) diff --git a/x-pack/plugins/beats_management/types/kibana.d.ts b/x-pack/plugins/beats_management/types/kibana.d.ts deleted file mode 100644 index 07a5985f55a13..0000000000000 --- a/x-pack/plugins/beats_management/types/kibana.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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. - */ - -declare module 'ui/autocomplete_providers' { - import { StaticIndexPattern } from 'ui/index_patterns'; - - export type AutocompleteProvider = ( - args: { - config: { - get(configKey: string): any; - }; - indexPatterns: StaticIndexPattern[]; - boolFilter: any; - } - ) => GetSuggestions; - - export type GetSuggestions = ( - args: { - query: string; - selectionStart: number; - selectionEnd: number; - } - ) => Promise; - - export type AutocompleteSuggestionType = 'field' | 'value' | 'operator' | 'conjunction'; - - export interface AutocompleteSuggestion { - description: string; - end: number; - start: number; - text: string; - type: AutocompleteSuggestionType; - } - - export function addAutocompleteProvider(language: string, provider: AutocompleteProvider): void; - - export function getAutocompleteProvider(language: string): AutocompleteProvider | undefined; -} diff --git a/x-pack/plugins/lens/index.ts b/x-pack/plugins/lens/index.ts index 8955bccbde268..6fdcd5213b3a6 100644 --- a/x-pack/plugins/lens/index.ts +++ b/x-pack/plugins/lens/index.ts @@ -17,7 +17,7 @@ export const lens: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ id: PLUGIN_ID, configPrefix: `xpack.${PLUGIN_ID}`, - require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter'], + require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter', 'data'], publicDir: resolve(__dirname, 'public'), uiExports: { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index 6574d504bfe35..c97e8264123a0 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -10,7 +10,12 @@ import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EditorFrame } from './editor_frame'; import { Visualization, Datasource, DatasourcePublicAPI, DatasourceSuggestion } from '../../types'; import { act } from 'react-dom/test-utils'; -import { createMockVisualization, createMockDatasource } from '../mock_extensions'; +import { + createMockVisualization, + createMockDatasource, + createExpressionRendererMock, +} from '../mocks'; +import { ExpressionRenderer } from 'src/legacy/core_plugins/data/public'; // calling this function will wait for all pending Promises from mock // datasources to be processed by its callers. @@ -34,12 +39,16 @@ describe('editor_frame', () => { let mockVisualization2: Visualization; let mockDatasource2: Datasource; + let expressionRendererMock: ExpressionRenderer; + beforeEach(() => { mockVisualization = createMockVisualization(); mockVisualization2 = createMockVisualization(); mockDatasource = createMockDatasource(); mockDatasource2 = createMockDatasource(); + + expressionRendererMock = createExpressionRendererMock(); }); describe('initialization', () => { @@ -55,6 +64,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); }); @@ -75,6 +85,7 @@ describe('editor_frame', () => { }} initialDatasourceId={null} initialVisualizationId={null} + ExpressionRenderer={expressionRendererMock} /> ); }); @@ -95,6 +106,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); }); @@ -124,6 +136,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); }); @@ -153,6 +166,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -177,6 +191,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -212,6 +227,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -244,6 +260,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -286,6 +303,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -311,6 +329,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -333,6 +352,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -367,6 +387,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); await waitForPromises(); @@ -447,6 +468,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -469,6 +491,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -525,6 +548,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -568,6 +592,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis2" + ExpressionRenderer={expressionRendererMock} /> ); @@ -628,6 +653,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); @@ -691,6 +717,7 @@ describe('editor_frame', () => { }} initialDatasourceId="testDatasource" initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} /> ); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx index fb0bb662915ba..5032467d5f87d 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx @@ -5,6 +5,7 @@ */ import React, { useEffect, useReducer, useMemo } from 'react'; +import { ExpressionRenderer } from '../../../../../../src/legacy/core_plugins/data/public'; import { Datasource, Visualization } from '../../types'; import { reducer, getInitialState } from './state_management'; import { DataPanelWrapper } from './data_panel_wrapper'; @@ -19,6 +20,8 @@ export interface EditorFrameProps { initialDatasourceId: string | null; initialVisualizationId: string | null; + + ExpressionRenderer: ExpressionRenderer; } export function EditorFrame(props: EditorFrameProps) { @@ -99,6 +102,7 @@ export function EditorFrame(props: EditorFrameProps) { visualizationState={state.visualization.state} visualizationMap={props.visualizationMap} dispatch={dispatch} + ExpressionRenderer={props.ExpressionRenderer} /> } suggestionsPanel={ diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts index 615c9607877ed..5d832c5738d84 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts @@ -7,6 +7,7 @@ import { getInitialState, reducer } from './state_management'; import { EditorFrameProps } from '.'; import { Datasource, Visualization } from '../../types'; +import { createExpressionRendererMock } from '../mocks'; describe('editor_frame state management', () => { describe('initialization', () => { @@ -18,6 +19,7 @@ describe('editor_frame state management', () => { visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization }, initialDatasourceId: 'testDatasource', initialVisualizationId: 'testVis', + ExpressionRenderer: createExpressionRendererMock(), }; }); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts index 437faa2779f3e..b048b6e840484 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts @@ -5,7 +5,7 @@ */ import { getSuggestions } from './suggestion_helpers'; -import { createMockVisualization } from '../mock_extensions'; +import { createMockVisualization } from '../mocks'; import { TableSuggestion } from '../../types'; const generateSuggestion = (datasourceSuggestionId: number = 1, state = {}) => ({ diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx index 761b77757df62..eac88722aa719 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ExpressionRenderer } from '../../../../../../src/legacy/core_plugins/data/public'; import { Action } from './state_management'; import { Datasource, Visualization, DatasourcePublicAPI } from '../../types'; import { DragDrop } from '../../drag_drop'; @@ -20,16 +21,7 @@ export interface WorkspacePanelProps { visualizationState: unknown; datasourcePublicAPI: DatasourcePublicAPI; dispatch: (action: Action) => void; -} - -interface ExpressionRendererProps { - expression: string; -} - -function ExpressionRenderer(props: ExpressionRendererProps) { - // TODO: actually render the expression and move this to a generic folder as it can be re-used for - // suggestion rendering - return {props.expression}; + ExpressionRenderer: ExpressionRenderer; } export function WorkspacePanel({ @@ -40,6 +32,7 @@ export function WorkspacePanel({ visualizationState, datasourcePublicAPI, dispatch, + ExpressionRenderer: ExpressionRendererComponent, }: WorkspacePanelProps) { function onDrop() { const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsForField( @@ -88,7 +81,7 @@ export function WorkspacePanel({ ); const expression = `${datasourceExpression} | ${visualizationExpression}`; - return ; + return ; } return ( diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/mock_extensions.ts b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.ts similarity index 69% rename from x-pack/plugins/lens/public/editor_frame_plugin/mock_extensions.ts rename to x-pack/plugins/lens/public/editor_frame_plugin/mocks.ts index 45162d7c07960..7cf549b6d4752 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/mock_extensions.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { DataSetup } from 'src/legacy/core_plugins/data/public'; import { DatasourcePublicAPI, Visualization, Datasource } from '../types'; +import { EditorFrameSetupPlugins } from './plugin'; export function createMockVisualization(): jest.Mocked { return { @@ -44,3 +46,24 @@ export function createMockDatasource(): DatasourceMock { publicAPIMock, }; } + +type Omit = Pick>; + +export type MockedDependencies = Omit & { + data: Omit & { expressions: jest.Mocked }; +}; + +export function createExpressionRendererMock() { + return jest.fn(() => null); +} + +export function createMockDependencies() { + return ({ + data: { + expressions: { + ExpressionRenderer: createExpressionRendererMock(), + run: jest.fn(_ => Promise.resolve({ type: 'render', as: 'test', value: undefined })), + }, + }, + } as unknown) as MockedDependencies; +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx index 1d81f315bf525..9875c7a8396d2 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx @@ -5,19 +5,29 @@ */ import { EditorFramePlugin } from './plugin'; -import { createMockDatasource, createMockVisualization } from './mock_extensions'; +import { + createMockDependencies, + MockedDependencies, + createMockDatasource, + createMockVisualization, +} from './mocks'; // 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)); +// mock away actual data plugin to prevent all of it being loaded +jest.mock('../../../../../src/legacy/core_plugins/data/public', () => {}); + describe('editor_frame plugin', () => { let pluginInstance: EditorFramePlugin; let mountpoint: Element; + let pluginDependencies: MockedDependencies; beforeEach(() => { pluginInstance = new EditorFramePlugin(); mountpoint = document.createElement('div'); + pluginDependencies = createMockDependencies(); }); afterEach(() => { @@ -26,7 +36,7 @@ describe('editor_frame plugin', () => { it('should create an editor frame instance which mounts and unmounts', () => { expect(() => { - const publicAPI = pluginInstance.setup(); + const publicAPI = pluginInstance.setup(null, pluginDependencies); const instance = publicAPI.createInstance({}); instance.mount(mountpoint); instance.unmount(); @@ -34,7 +44,7 @@ describe('editor_frame plugin', () => { }); it('should render something in the provided dom element', () => { - const publicAPI = pluginInstance.setup(); + const publicAPI = pluginInstance.setup(null, pluginDependencies); const instance = publicAPI.createInstance({}); instance.mount(mountpoint); @@ -44,7 +54,7 @@ describe('editor_frame plugin', () => { }); it('should not have child nodes after unmount', () => { - const publicAPI = pluginInstance.setup(); + const publicAPI = pluginInstance.setup(null, pluginDependencies); const instance = publicAPI.createInstance({}); instance.mount(mountpoint); instance.unmount(); @@ -54,7 +64,7 @@ describe('editor_frame plugin', () => { it('should initialize and render provided datasource', async () => { const mockDatasource = createMockDatasource(); - const publicAPI = pluginInstance.setup(); + const publicAPI = pluginInstance.setup(null, pluginDependencies); publicAPI.registerDatasource('test', mockDatasource); const instance = publicAPI.createInstance({}); @@ -71,7 +81,7 @@ describe('editor_frame plugin', () => { it('should initialize visualization and render config panel', async () => { const mockDatasource = createMockDatasource(); const mockVisualization = createMockVisualization(); - const publicAPI = pluginInstance.setup(); + const publicAPI = pluginInstance.setup(null, pluginDependencies); publicAPI.registerDatasource('test', mockDatasource); publicAPI.registerVisualization('test', mockVisualization); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx index 7c12d95cb69c1..0dc6636b255b2 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -7,15 +7,25 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; - +import { CoreSetup } from 'src/core/public'; +import { + DataSetup, + data, + ExpressionRenderer, +} from '../../../../../src/legacy/core_plugins/data/public'; import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; import { EditorFrame } from './editor_frame'; +export interface EditorFrameSetupPlugins { + data: DataSetup; +} + export class EditorFramePlugin { constructor() {} + private ExpressionRenderer: ExpressionRenderer | null = null; - private datasources: Record = {}; - private visualizations: Record = {}; + private readonly datasources: Record = {}; + private readonly visualizations: Record = {}; private createInstance(): EditorFrameInstance { let domElement: Element; @@ -41,6 +51,7 @@ export class EditorFramePlugin { visualizationMap={this.visualizations} initialDatasourceId={firstDatasourceId || null} initialVisualizationId={firstVisualizationId || null} + ExpressionRenderer={this.ExpressionRenderer!} /> , domElement @@ -50,7 +61,8 @@ export class EditorFramePlugin { }; } - public setup(): EditorFrameSetup { + public setup(_core: CoreSetup | null, plugins: EditorFrameSetupPlugins): EditorFrameSetup { + this.ExpressionRenderer = plugins.data.expressions.ExpressionRenderer; return { createInstance: this.createInstance.bind(this), registerDatasource: (name, datasource) => { @@ -69,5 +81,8 @@ export class EditorFramePlugin { const editorFrame = new EditorFramePlugin(); -export const editorFrameSetup = () => editorFrame.setup(); +export const editorFrameSetup = () => + editorFrame.setup(null, { + data, + }); export const editorFrameStop = () => editorFrame.stop(); From b5ffb1288e4ae279b468cd960a3a0c044c45add5 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 5 Jun 2019 09:13:05 +0200 Subject: [PATCH 018/105] [Lens] Expression handling (#37876) --- .../kbn-interpreter/src/common/index.d.ts | 8 +- .../kbn-interpreter/src/common/lib/ast.d.ts | 16 +- .../public/expressions/expression_runner.ts | 18 +- .../expressions/expressions_service.test.tsx | 48 ++- .../public/expressions/expressions_service.ts | 1 + .../renderers/dropdown_filter/index.tsx | 5 +- .../editor_frame/editor_frame.test.tsx | 41 +++ .../editor_frame/expression_helpers.ts | 42 +++ .../editor_frame/workspace_panel.test.tsx | 277 ++++++++++++++++++ .../editor_frame/workspace_panel.tsx | 85 +++++- .../{mocks.ts => mocks.tsx} | 14 +- .../indexpattern_plugin/indexpattern.test.tsx | 2 +- .../indexpattern_plugin/indexpattern.tsx | 2 +- x-pack/plugins/lens/public/types.ts | 6 +- .../public/xy_visualization_plugin/plugin.tsx | 55 +++- .../xy_expression.test.tsx | 2 +- .../xy_visualization_plugin/xy_expression.tsx | 33 ++- .../xy_visualization.tsx | 2 +- 18 files changed, 609 insertions(+), 48 deletions(-) create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/expression_helpers.ts create mode 100644 x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx rename x-pack/plugins/lens/public/editor_frame_plugin/{mocks.ts => mocks.tsx} (85%) diff --git a/packages/kbn-interpreter/src/common/index.d.ts b/packages/kbn-interpreter/src/common/index.d.ts index a8917b7a65df1..b859e368afda5 100644 --- a/packages/kbn-interpreter/src/common/index.d.ts +++ b/packages/kbn-interpreter/src/common/index.d.ts @@ -19,4 +19,10 @@ export { Registry } from './lib/registry'; -export { fromExpression, Ast } from './lib/ast'; +export { + fromExpression, + toExpression, + Ast, + ExpressionArgAST, + 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 2b0328bda9392..dc7ab39f5a9d8 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.d.ts +++ b/packages/kbn-interpreter/src/common/lib/ast.d.ts @@ -17,6 +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(ast: Ast): string; diff --git a/src/legacy/core_plugins/data/public/expressions/expression_runner.ts b/src/legacy/core_plugins/data/public/expressions/expression_runner.ts index 26951ea605bf2..335889be514ce 100644 --- a/src/legacy/core_plugins/data/public/expressions/expression_runner.ts +++ b/src/legacy/core_plugins/data/public/expressions/expression_runner.ts @@ -27,6 +27,12 @@ export interface ExpressionRunnerOptions { context?: object; getInitialContext?: () => object; element?: Element; + /** + * If an element is specified, but the response of the expression run can't be rendered + * because it isn't a valid response or the specified renderer isn't available, + * this callback is called with the given result. + */ + onRenderFailure?: (result: Result) => void; } export type ExpressionRunner = ( @@ -37,7 +43,10 @@ export type ExpressionRunner = ( export const createRunFn = ( renderersRegistry: RenderFunctionsRegistry, interpreterPromise: Promise -): ExpressionRunner => async (expressionOrAst, { element, context, getInitialContext }) => { +): ExpressionRunner => async ( + expressionOrAst, + { element, context, getInitialContext, onRenderFailure } +) => { // TODO: make interpreter initialization synchronous to avoid this const interpreter = await interpreterPromise; const ast = @@ -53,7 +62,7 @@ export const createRunFn = ( }); if (element) { - if (response.type === 'render' && response.as) { + if (response.type === 'render' && response.as && renderersRegistry.get(response.as) !== null) { renderersRegistry.get(response.as).render(element, response.value, { onDestroy: fn => { // TODO implement @@ -63,8 +72,9 @@ export const createRunFn = ( }, }); } else { - // eslint-disable-next-line no-console - console.log('Unexpected result of expression', response); + if (onRenderFailure) { + onRenderFailure(response); + } } } diff --git a/src/legacy/core_plugins/data/public/expressions/expressions_service.test.tsx b/src/legacy/core_plugins/data/public/expressions/expressions_service.test.tsx index 9a464da2731c8..e743ac6ef6d08 100644 --- a/src/legacy/core_plugins/data/public/expressions/expressions_service.test.tsx +++ b/src/legacy/core_plugins/data/public/expressions/expressions_service.test.tsx @@ -39,18 +39,22 @@ const waitForInterpreterRun = async () => { await new Promise(resolve => setTimeout(resolve)); }; +const RENDERER_ID = 'mockId'; + describe('expressions_service', () => { + let interpretAstMock: jest.Mocked['interpretAst']; let interpreterMock: jest.Mocked; let renderFunctionMock: jest.Mocked; let setupPluginsMock: ExpressionsServiceDependencies; - const expressionResult: Result = { type: 'render', as: 'abc', value: {} }; + const expressionResult: Result = { type: 'render', as: RENDERER_ID, value: {} }; let api: ExpressionsSetup; let testExpression: string; let testAst: Ast; beforeEach(() => { - interpreterMock = { interpretAst: jest.fn(_ => Promise.resolve(expressionResult)) }; + interpretAstMock = jest.fn(_ => Promise.resolve(expressionResult)); + interpreterMock = { interpretAst: interpretAstMock }; renderFunctionMock = ({ render: jest.fn(), } as unknown) as jest.Mocked; @@ -58,7 +62,7 @@ describe('expressions_service', () => { interpreter: { getInterpreter: () => Promise.resolve({ interpreter: interpreterMock }), renderersRegistry: ({ - get: () => renderFunctionMock, + get: (id: string) => (id === RENDERER_ID ? renderFunctionMock : null), } as unknown) as RenderFunctionsRegistry, }, }; @@ -101,6 +105,44 @@ describe('expressions_service', () => { ); }); + it('should return the result of the interpreter run', async () => { + const response = await api.run(testAst, {}); + expect(response).toBe(expressionResult); + }); + + it('should call on render failure if the response is not valid', async () => { + const errorResult = { type: 'error', error: {} }; + interpretAstMock.mockReturnValue(Promise.resolve(errorResult)); + const renderFailureSpy = jest.fn(); + const response = await api.run(testAst, { + element: document.createElement('div'), + onRenderFailure: renderFailureSpy, + }); + expect(renderFailureSpy).toHaveBeenCalledWith(errorResult); + expect(response).toBe(response); + }); + + it('should call on render failure if the renderer is not known', async () => { + const errorResult = { type: 'render', as: 'unknown_id' }; + interpretAstMock.mockReturnValue(Promise.resolve(errorResult)); + const renderFailureSpy = jest.fn(); + const response = await api.run(testAst, { + element: document.createElement('div'), + onRenderFailure: renderFailureSpy, + }); + expect(renderFailureSpy).toHaveBeenCalledWith(errorResult); + expect(response).toBe(response); + }); + + it('should not call on render failure if the runner does not render', async () => { + const errorResult = { type: 'error', error: {} }; + interpretAstMock.mockReturnValue(Promise.resolve(errorResult)); + const renderFailureSpy = jest.fn(); + const response = await api.run(testAst, { onRenderFailure: renderFailureSpy }); + expect(renderFailureSpy).not.toHaveBeenCalled(); + expect(response).toBe(response); + }); + it('should call the render function with the result and element', async () => { const element = document.createElement('div'); diff --git a/src/legacy/core_plugins/data/public/expressions/expressions_service.ts b/src/legacy/core_plugins/data/public/expressions/expressions_service.ts index 308fd44d6bc08..f22caf3d43ece 100644 --- a/src/legacy/core_plugins/data/public/expressions/expressions_service.ts +++ b/src/legacy/core_plugins/data/public/expressions/expressions_service.ts @@ -46,6 +46,7 @@ export interface Result { type: string; as?: string; value?: unknown; + error?: unknown; } interface RenderHandlers { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/index.tsx index d9a4eaf20ab9f..bfe8d3ec8f834 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/index.tsx +++ b/x-pack/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/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index c97e8264123a0..d18bb564522e2 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -177,6 +177,47 @@ describe('editor_frame', () => { expect.objectContaining({ state: initialState }) ); }); + + it('should render the resulting expression using the expression renderer', async () => { + 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": "datasource", + "type": "function", + }, + Object { + "arguments": Object {}, + "function": "vis", + "type": "function", + }, + ], + "type": "expression", +} +`); + }); }); describe('state update', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/expression_helpers.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/expression_helpers.ts new file mode 100644 index 0000000000000..ad6cdb0d7e4ee --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/expression_helpers.ts @@ -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 { Ast, fromExpression } from '@kbn/interpreter/common'; +import { Visualization, Datasource, DatasourcePublicAPI } from '../../types'; + +export function buildExpression( + visualization: Visualization | null, + visualizationState: unknown, + datasource: Datasource, + datasourceState: unknown, + datasourcePublicAPI: DatasourcePublicAPI +): Ast | null { + if (visualization === null) { + return null; + } + const datasourceExpression = datasource.toExpression(datasourceState); + const visualizationExpression = visualization.toExpression( + visualizationState, + datasourcePublicAPI + ); + + if (datasourceExpression === null || visualizationExpression === null) { + return null; + } + + const parsedDatasourceExpression = + typeof datasourceExpression === 'string' + ? fromExpression(datasourceExpression) + : datasourceExpression; + const parsedVisualizationExpression = + typeof visualizationExpression === 'string' + ? fromExpression(visualizationExpression) + : visualizationExpression; + return { + type: 'expression', + chain: [...parsedDatasourceExpression.chain, ...parsedVisualizationExpression.chain], + }; +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx new file mode 100644 index 0000000000000..cfb51a0adce1d --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx @@ -0,0 +1,277 @@ +/* + * 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/data/public'; +import { Visualization } from '../../types'; +import { + createMockVisualization, + createMockDatasource, + createExpressionRendererMock, + DatasourceMock, +} from '../mocks'; +import { WorkspacePanel, WorkspacePanelProps } from './workspace_panel'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { ReactWrapper } from 'enzyme'; + +const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); + +describe('workspace_panel', () => { + let mockVisualization: Visualization; + let mockDatasource: DatasourceMock; + + let expressionRendererMock: jest.Mock; + + let instance: ReactWrapper; + + beforeEach(() => { + mockVisualization = 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"]').length).toBe(1); + expect(instance.find(expressionRendererMock).length).toBe(0); + }); + + it('should render an explanatory text if the visualization does not produce an expression', () => { + instance = mount( + 'datasource' }} + datasourceState={{}} + activeVisualizationId="vis" + visualizationMap={{ + vis: { ...mockVisualization, toExpression: () => null }, + }} + visualizationState={{}} + datasourcePublicAPI={mockDatasource.publicAPIMock} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + expect(instance.find('[data-test-subj="empty-workspace"]').length).toBe(1); + expect(instance.find(expressionRendererMock).length).toBe(0); + }); + + it('should render an explanatory text if the datasource does not produce an expression', () => { + instance = mount( + null }} + datasourceState={{}} + activeVisualizationId="vis" + visualizationMap={{ + vis: { ...mockVisualization, toExpression: () => 'vis' }, + }} + visualizationState={{}} + datasourcePublicAPI={mockDatasource.publicAPIMock} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + expect(instance.find('[data-test-subj="empty-workspace"]').length).toBe(1); + expect(instance.find(expressionRendererMock).length).toBe(0); + }); + + it('should render the resulting expression using the expression renderer', () => { + instance = mount( + 'datasource', + }} + datasourceState={{}} + activeVisualizationId="vis" + visualizationMap={{ + vis: { ...mockVisualization, toExpression: () => 'vis' }, + }} + visualizationState={{}} + datasourcePublicAPI={mockDatasource.publicAPIMock} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` +Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource", + "type": "function", + }, + Object { + "arguments": Object {}, + "function": "vis", + "type": "function", + }, + ], + "type": "expression", +} +`); + }); + + describe('expression failures', () => { + it('should show an error message if the expression fails to parse', () => { + instance = mount( + 'datasource ||', + }} + datasourceState={{}} + activeVisualizationId="vis" + visualizationMap={{ + vis: { ...mockVisualization, toExpression: () => 'vis' }, + }} + visualizationState={{}} + datasourcePublicAPI={mockDatasource.publicAPIMock} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + expect(instance.find('[data-test-subj="expression-failure"]').length).toBe(1); + expect(instance.find(expressionRendererMock).length).toBe(0); + }); + + it('should show an error message if the expression fails to render', async () => { + expressionRendererMock = jest.fn(({ onRenderFailure }) => { + Promise.resolve().then(() => onRenderFailure!({ type: 'error' })); + return ; + }); + + instance = mount( + 'datasource', + }} + datasourceState={{}} + activeVisualizationId="vis" + visualizationMap={{ + vis: { ...mockVisualization, toExpression: () => 'vis' }, + }} + visualizationState={{}} + datasourcePublicAPI={mockDatasource.publicAPIMock} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + // "wait" for the expression to execute + await waitForPromises(); + + instance.update(); + + expect(instance.find('[data-test-subj="expression-failure"]').length).toBe(1); + expect(instance.find(expressionRendererMock).length).toBe(0); + }); + + it('should not attempt to run the expression again if it does not change', async () => { + expressionRendererMock = jest.fn(({ onRenderFailure }) => { + Promise.resolve().then(() => onRenderFailure!({ type: 'error' })); + return ; + }); + + instance = mount( + 'datasource', + }} + datasourceState={{}} + activeVisualizationId="vis" + visualizationMap={{ + vis: { ...mockVisualization, toExpression: () => 'vis' }, + }} + visualizationState={{}} + datasourcePublicAPI={mockDatasource.publicAPIMock} + 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 () => { + expressionRendererMock = jest.fn(({ onRenderFailure }) => { + Promise.resolve().then(() => onRenderFailure!({ type: 'error' })); + return ; + }); + + instance = mount( + 'datasource', + }} + datasourceState={{}} + activeVisualizationId="vis" + visualizationMap={{ + vis: { ...mockVisualization, toExpression: () => 'vis' }, + }} + visualizationState={{}} + datasourcePublicAPI={mockDatasource.publicAPIMock} + 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).length).toBe(1); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx index eac88722aa719..0590cf9db1b9c 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -4,14 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; +import { toExpression } from '@kbn/interpreter/common'; import { ExpressionRenderer } from '../../../../../../src/legacy/core_plugins/data/public'; import { Action } from './state_management'; import { Datasource, Visualization, DatasourcePublicAPI } from '../../types'; import { DragDrop } from '../../drag_drop'; import { getSuggestions, toSwitchAction } from './suggestion_helpers'; +import { buildExpression } from './expression_helpers'; export interface WorkspacePanelProps { activeDatasource: Datasource; @@ -59,7 +62,7 @@ export function WorkspacePanel({ function renderEmptyWorkspace() { return ( -

+

(undefined); - const activeVisualization = visualizationMap[activeVisualizationId]; - const datasourceExpression = activeDatasource.toExpression(datasourceState); - const visualizationExpression = activeVisualization.toExpression( - visualizationState, - datasourcePublicAPI + const activeVisualization = activeVisualizationId + ? visualizationMap[activeVisualizationId] + : null; + const expression = useMemo( + () => { + try { + return buildExpression( + activeVisualization, + visualizationState, + activeDatasource, + datasourceState, + datasourcePublicAPI + ); + } catch (e) { + setExpressionError(e.toString()); + } + }, + [ + activeVisualization, + visualizationState, + activeDatasource, + datasourceState, + datasourcePublicAPI, + ] ); - const expression = `${datasourceExpression} | ${visualizationExpression}`; - return ; + 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 ( diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/mocks.ts b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx similarity index 85% rename from x-pack/plugins/lens/public/editor_frame_plugin/mocks.ts rename to x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx index 7cf549b6d4752..a472a56bec386 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/mocks.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DataSetup } from 'src/legacy/core_plugins/data/public'; +import React from 'react'; +import { DataSetup, ExpressionRendererProps } from 'src/legacy/core_plugins/data/public'; import { DatasourcePublicAPI, Visualization, Datasource } from '../types'; import { EditorFrameSetupPlugins } from './plugin'; @@ -14,7 +15,7 @@ export function createMockVisualization(): jest.Mocked { getSuggestions: jest.fn(_options => []), initialize: jest.fn(_state => ({})), renderConfigPanel: jest.fn(), - toExpression: jest.fn((_state, _datasource) => ''), + toExpression: jest.fn((_state, _datasource) => null), }; } @@ -39,7 +40,7 @@ export function createMockDatasource(): DatasourceMock { getPublicAPI: jest.fn((_state, _setState) => publicAPIMock), initialize: jest.fn(_state => Promise.resolve()), renderDataPanel: jest.fn(), - toExpression: jest.fn(_state => ''), + toExpression: jest.fn(_state => null), // 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 @@ -53,8 +54,11 @@ export type MockedDependencies = Omit & { data: Omit & { expressions: jest.Mocked }; }; -export function createExpressionRendererMock() { - return jest.fn(() => null); +export function createExpressionRendererMock(): jest.Mock< + React.ReactElement, + [ExpressionRendererProps] +> { + return jest.fn(_ => ); } export function createMockDependencies() { diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index 418cb9eab2443..ac0cc03fbd3ab 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -162,7 +162,7 @@ describe('IndexPattern Data Source', () => { describe('#toExpression', () => { it('should generate an empty expression when no columns are selected', async () => { const state = await indexPatternDatasource.initialize(); - expect(indexPatternDatasource.toExpression(state)).toEqual(''); + expect(indexPatternDatasource.toExpression(state)).toEqual(null); }); it('should generate an expression for a values query', async () => { diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index ec558609e3900..3ca66e2ab0db4 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -199,7 +199,7 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To toExpression(state: IndexPatternPrivateState) { if (state.columnOrder.length === 0) { - return ''; + return null; } const fieldNames = state.columnOrder.map(col => state.columns[col].sourceField); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 9932f8b3c703c..d6b0071fe803f 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Ast } from '@kbn/interpreter/common'; + // eslint-disable-next-line export interface EditorFrameOptions {} @@ -52,7 +54,7 @@ export interface Datasource { renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps) => void; - toExpression: (state: T) => string; + toExpression: (state: T) => Ast | string | null; getDatasourceSuggestionsForField: (state: T) => Array>; getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; @@ -156,7 +158,7 @@ export interface Visualization { renderConfigPanel: (domElement: Element, props: VisualizationProps) => void; - toExpression: (state: T, datasource: DatasourcePublicAPI) => string; + toExpression: (state: T, datasource: DatasourcePublicAPI) => 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 diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx index 14f2511572f28..55b5aa049077b 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -4,12 +4,57 @@ * 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'; import { xyVisualization } from './xy_visualization'; +import { + renderersRegistry, + functionsRegistry, + // @ts-ignore untyped dependency +} from '../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { ExpressionFunction } from '../../../../../src/legacy/core_plugins/interpreter/public'; +import { legendConfig, xConfig, yConfig, xyChart, xyChartRenderer } from './xy_expression'; + +// 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 +interface RenderHandlers { + done: () => void; + onDestroy: (fn: () => void) => void; +} +export interface RenderFunction { + name: string; + displayName: string; + help: string; + validate: () => void; + reuseDomNode: boolean; + render: (domNode: Element, data: T, handlers: RenderHandlers) => void; +} + +export interface InterpreterSetup { + renderersRegistry: Registry; + functionsRegistry: Registry< + ExpressionFunction, + ExpressionFunction + >; +} + +export interface XyVisualizationPluginSetupPlugins { + interpreter: InterpreterSetup; +} + class XyVisualizationPlugin { constructor() {} - setup() { + setup(_core: CoreSetup | null, { interpreter }: XyVisualizationPluginSetupPlugins) { + interpreter.functionsRegistry.register(() => legendConfig); + interpreter.functionsRegistry.register(() => xConfig); + interpreter.functionsRegistry.register(() => yConfig); + interpreter.functionsRegistry.register(() => xyChart); + + interpreter.renderersRegistry.register(() => xyChartRenderer as RenderFunction); + return xyVisualization; } @@ -18,5 +63,11 @@ class XyVisualizationPlugin { const plugin = new XyVisualizationPlugin(); -export const xyVisualizationSetup = () => plugin.setup(); +export const xyVisualizationSetup = () => + plugin.setup(null, { + interpreter: { + renderersRegistry, + functionsRegistry, + }, + }); export const xyVisualizationStop = () => plugin.stop(); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx index 2a9fcd2fc4e33..eb7292f4515ee 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx @@ -100,7 +100,7 @@ describe('xy_expression', () => { test('it renders with the specified data and args', () => { const { data, args } = sampleArgs(); - expect(xyChart.fn(data, args)).toEqual({ + expect(xyChart.fn(data, args, {})).toEqual({ type: 'render', as: 'lens_xy_chart_renderer', value: { data, args }, diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx index 4adcd7ce7a1c5..144083035bdad 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx @@ -17,8 +17,12 @@ import { AreaSeries, BarSeries, } from '@elastic/charts'; -import { ContextFunction, ArgumentType } from '../../../canvas/canvas_plugin_src/functions/types'; +import { + ExpressionFunction, + ArgumentType, +} from '../../../../../src/legacy/core_plugins/interpreter/public'; import { KibanaDatatable } from '../types'; +import { RenderFunction } from './plugin'; /** * This file contains TypeScript type definitions and their equivalent expression @@ -36,7 +40,7 @@ export interface LegendConfig { type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' }; -export const legendConfig: ContextFunction< +export const legendConfig: ExpressionFunction< 'lens_xy_legendConfig', null, LegendConfig, @@ -96,7 +100,7 @@ export interface YConfig extends AxisConfig { type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; -export const yConfig: ContextFunction<'lens_xy_yConfig', null, YConfig, YConfigResult> = { +export const yConfig: ExpressionFunction<'lens_xy_yConfig', null, YConfig, YConfigResult> = { name: 'lens_xy_yConfig', aliases: [], type: 'lens_xy_yConfig', @@ -126,7 +130,7 @@ export interface XConfig extends AxisConfig { type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; -export const xConfig: ContextFunction<'lens_xy_xConfig', null, XConfig, XConfigResult> = { +export const xConfig: ExpressionFunction<'lens_xy_xConfig', null, XConfig, XConfigResult> = { name: 'lens_xy_xConfig', aliases: [], type: 'lens_xy_xConfig', @@ -164,12 +168,16 @@ export interface XYChartProps { args: XYArgs; } -// TODO: Specify the TypeScript type of this definition, once the -// ContextFunction has moved to core and has the correct signature: -// ContextFunction<'lens_xy_chart', KibanaDatatable, XYArgs, XYRender> -export const xyChart = { +export interface XYRender { + type: 'render'; + as: 'lens_xy_chart_renderer'; + value: XYChartProps; +} + +export const xyChart: ExpressionFunction<'lens_xy_chart', KibanaDatatable, XYArgs, XYRender> = ({ name: 'lens_xy_chart', type: 'render', + help: 'An X/Y chart', args: { seriesType: { types: ['string'], @@ -216,13 +224,16 @@ export const xyChart = { }, }; }, -}; + // 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', KibanaDatatable, XYArgs, XYRender>; -export const xyChartRenderer = { +export const xyChartRenderer: RenderFunction = { name: 'lens_xy_chart_renderer', displayName: 'XY Chart', + help: 'X/Y Chart Renderer', + validate: () => {}, reuseDomNode: true, - render: async (domNode: HTMLDivElement, config: XYChartProps, _handlers: unknown) => { + render: async (domNode: Element, config: XYChartProps, _handlers: unknown) => { ReactDOM.render(, domNode); }, }; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index dbe678ad6ffa4..beefaaaa7b45d 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -71,5 +71,5 @@ export const xyVisualization: Visualization = { ); }, - toExpression: state => '', + toExpression: state => null, }; From 717eaff2e9aab6c757905231c15111aa0c15e3de Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Thu, 6 Jun 2019 09:58:04 -0400 Subject: [PATCH 019/105] [Lens] Lens/xy config panel (#37391) Basic xy chart configuration panel --- .../editor_frame/editor_frame.test.tsx | 3 - .../lens/public/editor_frame_plugin/mocks.tsx | 1 + .../indexpattern_plugin/indexpattern.tsx | 5 + .../native_renderer/native_renderer.test.tsx | 60 +-- .../native_renderer/native_renderer.tsx | 57 +-- x-pack/plugins/lens/public/types.ts | 1 + .../public/xy_visualization_plugin/plugin.tsx | 3 +- .../public/xy_visualization_plugin/types.ts | 146 +++++++ .../xy_config_panel.test.tsx | 375 ++++++++++++++++++ .../xy_config_panel.tsx | 313 +++++++++++++++ .../xy_expression.test.tsx | 13 +- .../xy_visualization_plugin/xy_expression.tsx | 151 +------ .../xy_suggestions.test.ts | 4 +- .../xy_visualization_plugin/xy_suggestions.ts | 10 +- .../xy_visualization.test.ts | 24 +- .../xy_visualization.tsx | 60 +-- 16 files changed, 902 insertions(+), 324 deletions(-) create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/types.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index d18bb564522e2..a2d4918d6a4bb 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -252,9 +252,6 @@ Object { state: updatedState, }) ); - - // don't re-render datasource when visulization changes - expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(1); }); it('should re-render data panel after state update', async () => { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx index a472a56bec386..5d2d9e5bc5309 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -27,6 +27,7 @@ export function createMockDatasource(): DatasourceMock { const publicAPIMock: jest.Mocked = { getTableSpec: jest.fn(() => []), getOperationForColumnId: jest.fn(), + generateColumnId: jest.fn(), renderDimensionPanel: jest.fn(), removeColumnInTableSpec: jest.fn(), moveColumnTo: jest.fn(), diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 3ca66e2ab0db4..22614914d4dda 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -10,6 +10,7 @@ import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; import { Datasource, DataType } from '..'; +import uuid from 'uuid'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps } from '../types'; import { getIndexPatterns } from './loader'; @@ -232,6 +233,10 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To isBucketed, }; }, + generateColumnId: () => { + // TODO: Come up with a more compact form of generating unique column ids + return uuid.v4(); + }, renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { render( diff --git a/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx index af5196165f9a5..9b29174a06d69 100644 --- a/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx @@ -39,59 +39,7 @@ describe('native_renderer', () => { expect(renderSpy).toHaveBeenCalledWith(containerElement, testProps); }); - it('should not render again if props do not change', () => { - const renderSpy = jest.fn(); - const testProps = { a: 'abc' }; - - renderAndTriggerHooks( - , - mountpoint - ); - renderAndTriggerHooks( - , - mountpoint - ); - expect(renderSpy).toHaveBeenCalledTimes(1); - }); - - it('should not render again if props do not change shallowly', () => { - const renderSpy = jest.fn(); - const testProps = { a: 'abc' }; - - renderAndTriggerHooks( - , - mountpoint - ); - renderAndTriggerHooks( - , - mountpoint - ); - expect(renderSpy).toHaveBeenCalledTimes(1); - }); - - it('should not render again for unchanged callback functions', () => { - const renderSpy = jest.fn(); - const testCallback = () => {}; - const testState = { a: 'abc' }; - - render( - , - mountpoint - ); - render( - , - mountpoint - ); - expect(renderSpy).toHaveBeenCalledTimes(1); - }); - - it('should render again once if props change', () => { + it('should render again if props change', () => { const renderSpy = jest.fn(); const testProps = { a: 'abc' }; @@ -107,12 +55,12 @@ describe('native_renderer', () => { , mountpoint ); - expect(renderSpy).toHaveBeenCalledTimes(2); + expect(renderSpy).toHaveBeenCalledTimes(3); const containerElement = mountpoint.firstElementChild; expect(renderSpy).lastCalledWith(containerElement, { a: 'def' }); }); - it('should render again once if props is just a string', () => { + it('should render again if props is just a string', () => { const renderSpy = jest.fn(); const testProps = 'abc'; @@ -122,7 +70,7 @@ describe('native_renderer', () => { ); renderAndTriggerHooks(, mountpoint); renderAndTriggerHooks(, mountpoint); - expect(renderSpy).toHaveBeenCalledTimes(2); + expect(renderSpy).toHaveBeenCalledTimes(3); const containerElement = mountpoint.firstElementChild; expect(renderSpy).lastCalledWith(containerElement, 'def'); }); diff --git a/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx index f0eb4b829c153..3bc042660e646 100644 --- a/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx @@ -4,77 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useRef } from 'react'; +import React from 'react'; export interface NativeRendererProps { render: (domElement: Element, props: T) => void; nativeProps: T; tag?: string; - children?: never; -} - -function is(x: unknown, y: unknown) { - return (x === y && (x !== 0 || 1 / (x as number) === 1 / (y as number))) || (x !== x && y !== y); -} - -function isShallowDifferent(objA: T, objB: T): boolean { - if (is(objA, objB)) { - return false; - } - - if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { - return true; - } - - const keysA = Object.keys(objA) as Array; - const keysB = Object.keys(objB) as Array; - - if (keysA.length !== keysB.length) { - return true; - } - - for (let i = 0; i < keysA.length; i++) { - if (!window.hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { - return true; - } - } - - return false; } /** * A component which takes care of providing a mountpoint for a generic render * function which takes an html element and an optional props object. - * It also takes care of calling render again if the props object changes. * 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 }: NativeRendererProps) { - const elementRef = useRef(null); - const propsRef = useRef(null); - - function renderAndUpdate(element: Element) { - elementRef.current = element; - propsRef.current = nativeProps; - render(element, nativeProps); - } - - useEffect( - () => { - if (elementRef.current && isShallowDifferent(propsRef.current, nativeProps)) { - renderAndUpdate(elementRef.current); - } - }, - [nativeProps] - ); - return React.createElement(tag || 'div', { - ref: element => { - if (element && element !== elementRef.current) { - renderAndUpdate(element); - } - }, + ref: el => el && render(el, nativeProps), }); } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index d6b0071fe803f..07b12cef50726 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -68,6 +68,7 @@ export interface Datasource { export interface DatasourcePublicAPI { getTableSpec: () => TableSpec; getOperationForColumnId: (columnId: string) => Operation | null; + generateColumnId: () => string; // Render can be called many times renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => void; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx index 55b5aa049077b..78e3b1964b99c 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -14,7 +14,8 @@ import { // @ts-ignore untyped dependency } from '../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { ExpressionFunction } from '../../../../../src/legacy/core_plugins/interpreter/public'; -import { legendConfig, xConfig, yConfig, xyChart, xyChartRenderer } from './xy_expression'; +import { xyChart, xyChartRenderer } from './xy_expression'; +import { legendConfig, xConfig, yConfig } from './types'; // 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 diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/types.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/types.ts new file mode 100644 index 0000000000000..7ffe0669d35e9 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/types.ts @@ -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 { Position } from '@elastic/charts'; +import { + ExpressionFunction, + ArgumentType, +} from '../../../../../src/legacy/core_plugins/interpreter/public'; + +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: 'Specifies whether or not the legend is visible.', + }, + position: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: 'Specifies the legend position.', + }, + }, + fn: function fn(_context: unknown, args: LegendConfig) { + return { + type: 'lens_xy_legendConfig', + ...args, + }; + }, +}; + +interface AxisConfig { + title: string; + showGridlines: boolean; + position: Position; +} + +const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = { + title: { + types: ['string'], + help: 'The axis title', + }, + showGridlines: { + types: ['boolean'], + help: 'Show / hide axis grid lines.', + }, + position: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: 'The position of the axis', + }, +}; + +export interface YConfig extends AxisConfig { + accessors: string[]; +} + +type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; + +export const yConfig: ExpressionFunction<'lens_xy_yConfig', null, YConfig, YConfigResult> = { + name: 'lens_xy_yConfig', + aliases: [], + type: 'lens_xy_yConfig', + help: `Configure the xy chart's y axis`, + context: { + types: ['null'], + }, + args: { + ...axisConfig, + accessors: { + types: ['string'], + help: 'The columns to display on the y axis.', + multi: true, + }, + }, + fn: function fn(_context: unknown, args: YConfig) { + return { + type: 'lens_xy_yConfig', + ...args, + }; + }, +}; + +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, + }; + }, +}; + +export type SeriesType = 'bar' | 'horizontal_bar' | 'line' | 'area'; + +export interface XYArgs { + seriesType: SeriesType; + title: string; + legend: LegendConfig; + y: YConfig; + x: XConfig; + splitSeriesAccessors: string[]; + stackAccessors: string[]; +} + +export type State = XYArgs; +export type PersistableState = XYArgs; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx new file mode 100644 index 0000000000000..cf5762c58fc84 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx @@ -0,0 +1,375 @@ +/* + * 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 { XYConfigPanel } from './xy_config_panel'; +import { DatasourcePublicAPI, DatasourceDimensionPanelProps, Operation } from '../types'; +import { State, SeriesType } from './types'; +import { Position } from '@elastic/charts'; +import { NativeRendererProps } from '../native_renderer'; + +describe('XYConfigPanel', () => { + function mockDatasource(): DatasourcePublicAPI { + return { + duplicateColumn: () => [], + getOperationForColumnId: () => null, + generateColumnId: () => 'TESTID', + getTableSpec: () => [], + moveColumnTo: () => {}, + removeColumnInTableSpec: () => [], + renderDimensionPanel: () => {}, + }; + } + + function testState(): State { + return { + legend: { isVisible: true, position: Position.Right }, + seriesType: 'bar', + splitSeriesAccessors: [], + stackAccessors: [], + title: 'Test Chart', + x: { + accessor: 'foo', + position: Position.Bottom, + showGridlines: true, + title: 'X', + }, + y: { + accessors: ['bar'], + position: Position.Left, + showGridlines: true, + title: 'Y', + }, + }; + } + + function testSubj(component: ReactWrapper, subj: string) { + return component + .find(`[data-test-subj="${subj}"]`) + .first() + .props(); + } + + test('toggles axis position when going from horizontal bar to any other type', () => { + const changeSeriesType = (fromSeriesType: SeriesType, toSeriesType: SeriesType) => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_seriesType').onChange as Function)(toSeriesType); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(changeSeriesType('line', 'horizontal_bar')).toMatchObject({ + seriesType: 'horizontal_bar', + x: { position: Position.Left }, + y: { position: Position.Bottom }, + }); + expect(changeSeriesType('horizontal_bar', 'bar')).toMatchObject({ + seriesType: 'bar', + x: { position: Position.Bottom }, + y: { position: Position.Left }, + }); + expect(changeSeriesType('horizontal_bar', 'line')).toMatchObject({ + seriesType: 'line', + x: { position: Position.Bottom }, + y: { position: Position.Left }, + }); + expect(changeSeriesType('horizontal_bar', 'area')).toMatchObject({ + seriesType: 'area', + x: { position: Position.Bottom }, + y: { position: Position.Left }, + }); + }); + + test('allows toggling of legend visibility', () => { + const toggleIsVisible = (isVisible: boolean) => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_legendIsVisible').onChange as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(toggleIsVisible(false)).toMatchObject({ + legend: { isVisible: true }, + }); + expect(toggleIsVisible(true)).toMatchObject({ + legend: { isVisible: false }, + }); + }); + + test('allows editing the chart title', () => { + const testSetTitle = (title: string) => { + const setState = jest.fn(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_title').onChange as Function)({ target: { value: title } }); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(testSetTitle('Hoi')).toMatchObject({ + title: 'Hoi', + }); + expect(testSetTitle('There!')).toMatchObject({ + title: 'There!', + }); + }); + + test('allows changing legend position', () => { + const testLegendPosition = (position: Position) => { + const setState = jest.fn(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_legendPosition').onChange as Function)(position); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(testLegendPosition(Position.Bottom)).toMatchObject({ + legend: { position: Position.Bottom }, + }); + expect(testLegendPosition(Position.Top)).toMatchObject({ + legend: { position: Position.Top }, + }); + expect(testLegendPosition(Position.Left)).toMatchObject({ + legend: { position: Position.Left }, + }); + expect(testLegendPosition(Position.Right)).toMatchObject({ + legend: { position: Position.Right }, + }); + }); + + test('allows editing the x axis title', () => { + const testSetTitle = (title: string) => { + const setState = jest.fn(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_xTitle').onChange as Function)({ target: { value: title } }); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(testSetTitle('Hoi')).toMatchObject({ + x: { title: 'Hoi' }, + }); + expect(testSetTitle('There!')).toMatchObject({ + x: { title: 'There!' }, + }); + }); + + test('the x dimension panel accepts any operations', () => { + const datasource = { + ...mockDatasource(), + renderDimensionPanel: jest.fn(), + }; + 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', + id: 'foo', + 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(ops); + }); + + test('allows toggling the x axis gridlines', () => { + const toggleXGridlines = (showGridlines: boolean) => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_xShowGridlines').onChange as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(toggleXGridlines(true)).toMatchObject({ + x: { showGridlines: false }, + }); + expect(toggleXGridlines(false)).toMatchObject({ + x: { showGridlines: true }, + }); + }); + + test('allows editing the y axis title', () => { + const testSetTitle = (title: string) => { + const setState = jest.fn(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_yTitle').onChange as Function)({ target: { value: title } }); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(testSetTitle('Hoi')).toMatchObject({ + y: { title: 'Hoi' }, + }); + expect(testSetTitle('There!')).toMatchObject({ + y: { title: 'There!' }, + }); + }); + + test('the y dimension panel accepts numeric operations', () => { + const datasource = { + ...mockDatasource(), + renderDimensionPanel: jest.fn(), + }; + const state = testState(); + const component = mount( + + ); + + const panel = testSubj(component, 'lnsXY_yDimensionPanel_a'); + const nativeProps = (panel as NativeRendererProps).nativeProps; + const { filterOperations } = nativeProps; + const exampleOperation: Operation = { + dataType: 'number', + id: 'foo', + 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 removeColumnInTableSpec = jest.fn(); + const datasource = { + ...mockDatasource(), + removeColumnInTableSpec, + }; + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_yDimensionPanel_remove_b').onClick as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + expect(setState.mock.calls[0][0]).toMatchObject({ + y: { accessors: ['a', 'c'] }, + }); + expect(removeColumnInTableSpec).toHaveBeenCalledTimes(1); + expect(removeColumnInTableSpec).toHaveBeenCalledWith('b'); + }); + + test('allows adding y dimensions', () => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + 'zed' }} + setState={setState} + state={{ ...state, y: { ...state.y, accessors: ['a', 'b', 'c'] } }} + /> + ); + + (testSubj(component, 'lnsXY_yDimensionPanel_add').onClick as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + expect(setState.mock.calls[0][0]).toMatchObject({ + y: { accessors: ['a', 'b', 'c', 'zed'] }, + }); + }); + + test('allows toggling the y axis gridlines', () => { + const toggleYGridlines = (showGridlines: boolean) => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_yShowGridlines').onChange as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(toggleYGridlines(true)).toMatchObject({ + y: { showGridlines: false }, + }); + expect(toggleYGridlines(false)).toMatchObject({ + y: { showGridlines: true }, + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx new file mode 100644 index 0000000000000..017fd2ccec4b3 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx @@ -0,0 +1,313 @@ +/* + * 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 { Position } from '@elastic/charts'; +import { + EuiFieldText, + EuiButtonGroup, + EuiForm, + EuiFormRow, + EuiSwitch, + EuiButtonIcon, + EuiButton, + IconType, +} from '@elastic/eui'; +import { State, SeriesType } from './types'; +import { VisualizationProps, Operation } from '../types'; +import { NativeRenderer } from '../native_renderer'; + +const chartTypeIcons: Array<{ id: SeriesType; label: string; iconType: IconType }> = [ + { + id: 'line', + label: 'Line', + iconType: 'visLine', + }, + { + id: 'area', + label: 'Area', + iconType: 'visArea', + }, + { + id: 'bar', + label: 'Bar', + iconType: 'visBarVertical', + }, + { + id: 'horizontal_bar', + label: 'Horizontal Bar', + iconType: 'visBarHorizontal', + }, +]; + +const positionIcons = [ + { + id: Position.Left, + label: 'Left', + iconType: 'arrowLeft', + }, + { + id: Position.Top, + label: 'Top', + iconType: 'arrowUp', + }, + { + id: Position.Bottom, + label: 'Bottom', + iconType: 'arrowDown', + }, + { + id: Position.Right, + label: 'Right', + iconType: 'arrowRight', + }, +]; + +export function XYConfigPanel(props: VisualizationProps) { + const { state, datasource, setState } = props; + + return ( + + + { + const isHorizontal = seriesType === 'horizontal_bar'; + setState({ + ...state, + seriesType: seriesType as SeriesType, + x: { + ...state.x, + position: isHorizontal ? Position.Left : Position.Bottom, + }, + y: { + ...state.y, + position: isHorizontal ? Position.Bottom : Position.Left, + }, + }); + }} + isIconOnly + /> + + + + setState({ ...state, title: e.target.value })} + aria-label={i18n.translate('xpack.lens.xyChart.chartTitleAriaLabel', { + defaultMessage: 'Title', + })} + /> + + + + + setState({ + ...state, + legend: { ...state.legend, isVisible: !state.legend.isVisible }, + }) + } + /> + + + {state.legend.isVisible && ( + + + setState({ ...state, legend: { ...state.legend, position: position as Position } }) + } + isIconOnly + /> + + )} + + + <> + + setState({ ...state, x: { ...state.x, title: e.target.value } })} + aria-label={i18n.translate('xpack.lens.xyChart.xTitleAriaLabel', { + defaultMessage: 'Title', + })} + /> + + + + true, + }} + /> + + + + + setState({ ...state, x: { ...state.x, showGridlines: !state.x.showGridlines } }) + } + /> + + + + + + <> + + setState({ ...state, y: { ...state.y, title: e.target.value } })} + aria-label={i18n.translate('xpack.lens.xyChart.yTitleAriaLabel', { + defaultMessage: 'Title', + })} + /> + + + + <> + {state.y.accessors.map(accessor => ( +
+ + !op.isBucketed && op.dataType === 'number', + }} + /> + { + datasource.removeColumnInTableSpec(accessor); + setState({ + ...state, + y: { + ...state.y, + accessors: state.y.accessors.filter(col => col !== accessor), + }, + }); + }} + aria-label={i18n.translate('xpack.lens.xyChart.yRemoveAriaLabel', { + defaultMessage: 'Remove', + })} + /> +
+ ))} + + setState({ + ...state, + y: { + ...state.y, + accessors: [...state.y.accessors, datasource.generateColumnId()], + }, + }) + } + iconType="plusInCircle" + /> + +
+ + + + setState({ ...state, y: { ...state.y, showGridlines: !state.y.showGridlines } }) + } + /> + + +
+
+ ); +} diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx index eb7292f4515ee..3b6f05f86d6d5 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx @@ -5,20 +5,11 @@ */ import { Position } from '@elastic/charts'; -import { - legendConfig, - LegendConfig, - xConfig, - XConfig, - YConfig, - yConfig, - XYArgs, - xyChart, - XYChart, -} from './xy_expression'; +import { xyChart, XYChart } from './xy_expression'; import { KibanaDatatable } from '../types'; import React from 'react'; import { shallow } from 'enzyme'; +import { XYArgs, LegendConfig, legendConfig, XConfig, xConfig, YConfig, yConfig } from './types'; function sampleArgs() { const data: KibanaDatatable = { diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx index 144083035bdad..9b2b9290b54f1 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx @@ -7,7 +7,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { - Position, Chart, Settings, Axis, @@ -17,152 +16,11 @@ import { AreaSeries, BarSeries, } from '@elastic/charts'; -import { - ExpressionFunction, - ArgumentType, -} from '../../../../../src/legacy/core_plugins/interpreter/public'; +import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; +import { XYArgs } from './types'; import { KibanaDatatable } from '../types'; import { RenderFunction } from './plugin'; -/** - * This file contains TypeScript type definitions and their equivalent expression - * definitions, for configuring and rendering an XY chart. The XY chart serves - * triple duty as a bar, line, or area chart. - * - * The xy_chart expression function serves mostly as a passthrough to the xy_chart_renderer - * which does the heavy-lifting. - */ - -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: 'Specifies whether or not the legend is visible.', - }, - position: { - types: ['string'], - options: [Position.Top, Position.Right, Position.Bottom, Position.Left], - help: 'Specifies the legend position.', - }, - }, - fn: function fn(_context: unknown, args: LegendConfig) { - return { - type: 'lens_xy_legendConfig', - ...args, - }; - }, -}; - -interface AxisConfig { - title: string; - showGridlines: boolean; - position: Position; -} - -const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = { - title: { - types: ['string'], - help: 'The axis title', - }, - showGridlines: { - types: ['boolean'], - help: 'Show / hide axis grid lines.', - }, - position: { - types: ['string'], - options: [Position.Top, Position.Right, Position.Bottom, Position.Left], - help: 'The position of the axis', - }, -}; - -export interface YConfig extends AxisConfig { - accessors: string[]; -} - -type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; - -export const yConfig: ExpressionFunction<'lens_xy_yConfig', null, YConfig, YConfigResult> = { - name: 'lens_xy_yConfig', - aliases: [], - type: 'lens_xy_yConfig', - help: `Configure the xy chart's y axis`, - context: { - types: ['null'], - }, - args: { - ...axisConfig, - accessors: { - types: ['string'], - help: 'The columns to display on the y axis.', - multi: true, - }, - }, - fn: function fn(_context: unknown, args: YConfig) { - return { - type: 'lens_xy_yConfig', - ...args, - }; - }, -}; - -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, - }; - }, -}; - -export interface XYArgs { - seriesType: 'bar' | 'line' | 'area'; - title: string; - legend: LegendConfig; - y: YConfig; - x: XConfig; - splitSeriesAccessors: string[]; - stackAccessors: string[]; -} - export interface XYChartProps { data: KibanaDatatable; args: XYArgs; @@ -227,6 +85,11 @@ export const xyChart: ExpressionFunction<'lens_xy_chart', KibanaDatatable, XYArg // 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', KibanaDatatable, XYArgs, XYRender>; +export interface XYChartProps { + data: KibanaDatatable; + args: XYArgs; +} + export const xyChartRenderer: RenderFunction = { name: 'lens_xy_chart_renderer', displayName: 'XY Chart', diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts index bc68cf206d573..83665dd8a6fe4 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts @@ -6,7 +6,7 @@ import { getSuggestions } from './xy_suggestions'; import { TableColumn, VisualizationSuggestion } from '../types'; -import { XYArgs } from './xy_expression'; +import { State } from './types'; describe('xy_suggestions', () => { function numCol(columnId: string): TableColumn { @@ -47,7 +47,7 @@ describe('xy_suggestions', () => { // Helper that plucks out the important part of a suggestion for // most test assertions - function suggestionSubset(suggestion: VisualizationSuggestion) { + function suggestionSubset(suggestion: VisualizationSuggestion) { const { seriesType, splitSeriesAccessors, stackAccessors, x, y } = suggestion.state; return { diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts index f1b49a2318424..28ef677e49644 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts @@ -6,8 +6,8 @@ import { partition } from 'lodash'; import { Position } from '@elastic/charts'; -import { XYArgs } from './xy_expression'; import { SuggestionRequest, VisualizationSuggestion, TableColumn, TableSuggestion } from '../types'; +import { State } from './types'; const columnSortOrder = { date: 0, @@ -22,8 +22,8 @@ const columnSortOrder = { * @param opts */ export function getSuggestions( - opts: SuggestionRequest -): Array> { + opts: SuggestionRequest +): Array> { return opts.tables .filter( ({ isMultiRow, columns }) => @@ -38,7 +38,7 @@ export function getSuggestions( .map(table => getSuggestionForColumns(table)); } -function getSuggestionForColumns(table: TableSuggestion): VisualizationSuggestion { +function getSuggestionForColumns(table: TableSuggestion): VisualizationSuggestion { const [buckets, values] = partition( prioritizeColumns(table.columns), col => col.operation.isBucketed @@ -67,7 +67,7 @@ function getSuggestion( xValue: TableColumn, yValues: TableColumn[], splitBy?: TableColumn -): VisualizationSuggestion { +): VisualizationSuggestion { const yTitle = yValues.map(col => col.operation.label).join(' & '); const xTitle = xValue.operation.label; const isDate = xValue.operation.dataType === 'date'; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts index 51996de5e0352..6fa876bed0663 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { xyVisualization, State } from './xy_visualization'; +import { xyVisualization } from './xy_visualization'; import { Position } from '@elastic/charts'; +import { State } from './types'; function exampleState(): State { return { @@ -32,7 +33,18 @@ function exampleState(): State { describe('IndexPattern Data Source', () => { describe('#initialize', () => { it('loads default state', () => { - expect(xyVisualization.initialize()).toMatchInlineSnapshot(` + const initialState = xyVisualization.initialize(); + + expect(initialState.x.accessor).toBeDefined(); + expect(initialState.y.accessors[0]).toBeDefined(); + expect(initialState.x.accessor).not.toEqual(initialState.y.accessors[0]); + + // These change with each generation, so we'll ignore them + // in our match snapshot test. + delete initialState.x.accessor; + delete initialState.y.accessors; + + expect(initialState).toMatchInlineSnapshot(` Object { "legend": Object { "isVisible": true, @@ -41,18 +53,16 @@ Object { "seriesType": "line", "splitSeriesAccessors": Array [], "stackAccessors": Array [], - "title": "Empty line chart", + "title": "Empty XY Chart", "x": Object { - "accessor": "", "position": "bottom", "showGridlines": false, - "title": "Uknown", + "title": "X", }, "y": Object { - "accessors": Array [], "position": "left", "showGridlines": false, - "title": "Uknown", + "title": "Y", }, } `); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index beefaaaa7b45d..58cc7422631cb 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -7,13 +7,12 @@ import React from 'react'; import { render } from 'react-dom'; import { Position } from '@elastic/charts'; -import { Visualization, Operation } from '../types'; +import uuid from 'uuid'; +import { I18nProvider } from '@kbn/i18n/react'; import { getSuggestions } from './xy_suggestions'; -import { XYArgs } from './xy_expression'; -import { NativeRenderer } from '../native_renderer'; - -export type State = XYArgs; -export type PersistableState = XYArgs; +import { XYConfigPanel } from './xy_config_panel'; +import { Visualization } from '../types'; +import { State, PersistableState } from './types'; export const xyVisualization: Visualization = { getSuggestions, @@ -21,55 +20,36 @@ export const xyVisualization: Visualization = { initialize(state) { return ( state || { - title: 'Empty line chart', - legend: { isVisible: true, position: Position.Right }, seriesType: 'line', - splitSeriesAccessors: [], - stackAccessors: [], + title: 'Empty XY Chart', + legend: { isVisible: true, position: Position.Right }, x: { - accessor: '', + accessor: uuid.v4(), position: Position.Bottom, showGridlines: false, - title: 'Uknown', + title: 'X', }, y: { - accessors: [], + accessors: [uuid.v4()], position: Position.Left, showGridlines: false, - title: 'Uknown', + title: 'Y', }, + splitSeriesAccessors: [], + stackAccessors: [], } ); }, - getPersistableState(state) { - return state; - }, + getPersistableState: state => state, - renderConfigPanel: (domElement, props) => { + renderConfigPanel: (domElement, props) => render( -
- XY Visualization - true, - suggestedOrder: 1, - }} - render={props.datasource.renderDimensionPanel} - /> - true, - suggestedOrder: 2, - }} - render={props.datasource.renderDimensionPanel} - /> -
, + + + , domElement - ); - }, + ), - toExpression: state => null, + toExpression: () => '', }; From 21b9431acd8e2b13ab3d9200461060a2a2ad216a Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 7 Jun 2019 10:11:51 +0200 Subject: [PATCH 020/105] [Lens] Xy expression building (#37967) --- x-pack/plugins/lens/public/index.scss | 5 + x-pack/plugins/lens/public/index.ts | 6 ++ .../xy_visualization.test.ts.snap | 93 +++++++++++++++++++ .../xy_expression.scss | 4 + .../xy_visualization.test.ts | 11 ++- .../xy_visualization.tsx | 65 ++++++++++++- 6 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.scss diff --git a/x-pack/plugins/lens/public/index.scss b/x-pack/plugins/lens/public/index.scss index e69de29bb2d1d..6c01d745e0202 100644 --- a/x-pack/plugins/lens/public/index.scss +++ b/x-pack/plugins/lens/public/index.scss @@ -0,0 +1,5 @@ +// Import the EUI global scope so we can use EUI constants +@import 'src/legacy/ui/public/styles/_styling_constants'; + +@import "./drag_drop/drag_drop.scss"; +@import "./xy_visualization_plugin/xy_expression.scss"; \ No newline at end of file diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index f8f074cdb99bf..ad0b78c63e710 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -6,6 +6,12 @@ export * from './types'; +import 'ui/autoload/all'; +import 'uiExports/fieldFormats'; +import 'uiExports/search'; +import 'uiExports/visRequestHandlers'; +import 'uiExports/visResponseHandlers'; + import { render, unmountComponentAtNode } from 'react-dom'; import { IScope } from 'angular'; import chrome from 'ui/chrome'; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap new file mode 100644 index 0000000000000..5f8dd2ef2b3d0 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`XY visualization #toExpression should map to a valid AST 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "legend": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "isVisible": Array [ + true, + ], + "position": Array [ + "bottom", + ], + }, + "function": "lens_xy_legendConfig", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "seriesType": Array [ + "area", + ], + "splitSeriesAccessors": Array [], + "stackAccessors": Array [], + "title": Array [ + "Foo", + ], + "x": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + "a", + ], + "position": Array [ + "bottom", + ], + "showGridlines": Array [ + true, + ], + "title": Array [ + "Baz", + ], + }, + "function": "lens_xy_xConfig", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "y": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessors": Array [ + "b", + "c", + ], + "position": Array [ + "left", + ], + "showGridlines": Array [ + true, + ], + "title": Array [ + "Bar", + ], + }, + "function": "lens_xy_yConfig", + "type": "function", + }, + ], + "type": "expression", + }, + ], + }, + "function": "lens_xy_chart", + "type": "function", + }, + ], + "type": "expression", +} +`; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.scss b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.scss new file mode 100644 index 0000000000000..93986078f68b1 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.scss @@ -0,0 +1,4 @@ + .lnsChart { + // TODO style this dependent on the screen height (see POC) + height: 500px; + } \ No newline at end of file diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts index 6fa876bed0663..d2b458e87219c 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -6,6 +6,7 @@ import { xyVisualization } from './xy_visualization'; import { Position } from '@elastic/charts'; +import { DatasourcePublicAPI } from '../types'; import { State } from './types'; function exampleState(): State { @@ -30,7 +31,7 @@ function exampleState(): State { }; } -describe('IndexPattern Data Source', () => { +describe('XY visualization', () => { describe('#initialize', () => { it('loads default state', () => { const initialState = xyVisualization.initialize(); @@ -78,4 +79,12 @@ Object { expect(xyVisualization.getPersistableState(exampleState())).toEqual(exampleState()); }); }); + + describe('#toExpression', () => { + it('should map to a valid AST', () => { + expect( + xyVisualization.toExpression(exampleState(), {} as DatasourcePublicAPI) + ).toMatchSnapshot(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index 58cc7422631cb..44b3a64b17560 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -51,5 +51,68 @@ export const xyVisualization: Visualization = { domElement ), - toExpression: () => '', + toExpression: state => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_chart', + arguments: { + seriesType: [state.seriesType], + title: [state.title], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_legendConfig', + arguments: { + isVisible: [state.legend.isVisible], + position: [state.legend.position], + }, + }, + ], + }, + ], + x: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_xConfig', + arguments: { + title: [state.x.title], + showGridlines: [state.x.showGridlines], + position: [state.x.position], + accessor: [state.x.accessor], + }, + }, + ], + }, + ], + y: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_yConfig', + arguments: { + title: [state.y.title], + showGridlines: [state.y.showGridlines], + position: [state.y.position], + accessors: state.y.accessors, + }, + }, + ], + }, + ], + splitSeriesAccessors: state.splitSeriesAccessors, + stackAccessors: state.stackAccessors, + }, + }, + ], + }), }; From b594b6c699ad6728b742cecd0697cabb49066e8d Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 7 Jun 2019 17:15:20 +0200 Subject: [PATCH 021/105] [Lens] Initialize visualization with datasource api (#38142) --- .../editor_frame/config_panel_wrapper.tsx | 6 +- .../editor_frame/data_panel_wrapper.tsx | 6 +- .../editor_frame/editor_frame.test.tsx | 70 +++++++++++++++---- .../editor_frame/editor_frame.tsx | 62 ++++++++++------ .../editor_frame/state_management.test.ts | 13 ++-- .../editor_frame/state_management.ts | 4 +- x-pack/plugins/lens/public/types.ts | 2 +- .../xy_visualization.test.ts.snap | 2 +- .../xy_visualization.test.ts | 22 +++--- .../xy_visualization.tsx | 7 +- 10 files changed, 131 insertions(+), 63 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx index b1329ee6fc2a8..911a3d2291c74 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx @@ -44,11 +44,13 @@ export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { newVisualizationId: e.target.value, // TODO we probably want to have a separate API to "force" a visualization switch // which isn't a result of a picked suggestion - initialState: props.visualizationMap[e.target.value].initialize(), + initialState: props.visualizationMap[e.target.value].initialize( + props.datasourcePublicAPI + ), }); }} /> - {props.activeVisualizationId && ( + {props.activeVisualizationId && props.visualizationState !== null && ( void; } -export function DataPanelWrapper(props: DataPanelWrapperProps) { +export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { const setDatasourceState = useMemo( () => (newState: unknown) => { props.dispatch({ @@ -55,4 +55,4 @@ export function DataPanelWrapper(props: DataPanelWrapperProps) { )} ); -} +}); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index a2d4918d6a4bb..17b99b31c45fc 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -8,12 +8,13 @@ import React from 'react'; import { ReactWrapper } from 'enzyme'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EditorFrame } from './editor_frame'; -import { Visualization, Datasource, DatasourcePublicAPI, DatasourceSuggestion } from '../../types'; +import { Visualization, DatasourcePublicAPI, DatasourceSuggestion } from '../../types'; import { act } from 'react-dom/test-utils'; import { createMockVisualization, createMockDatasource, createExpressionRendererMock, + DatasourceMock, } from '../mocks'; import { ExpressionRenderer } from 'src/legacy/core_plugins/data/public'; @@ -34,10 +35,10 @@ function generateSuggestion(datasourceSuggestionId = 1, state = {}): DatasourceS describe('editor_frame', () => { let mockVisualization: Visualization; - let mockDatasource: Datasource; + let mockDatasource: DatasourceMock; let mockVisualization2: Visualization; - let mockDatasource2: Datasource; + let mockDatasource2: DatasourceMock; let expressionRendererMock: ExpressionRenderer; @@ -52,7 +53,7 @@ describe('editor_frame', () => { }); describe('initialization', () => { - it('should initialize initial datasource and visualization if present', () => { + it('should initialize initial datasource', () => { act(() => { mount( { ); }); - expect(mockVisualization.initialize).toHaveBeenCalled(); expect(mockDatasource.initialize).toHaveBeenCalled(); }); @@ -115,6 +115,54 @@ describe('editor_frame', () => { 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 datasource api into visualization initialize', async () => { + act(() => { + mount( + + ); + }); + + expect(mockVisualization.initialize).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(mockVisualization.initialize).toHaveBeenCalledWith(mockDatasource.publicAPIMock); + }); + it('should render data panel after initialization is complete', async () => { const initialState = {}; let databaseInitialized: ({}) => void; @@ -306,7 +354,7 @@ Object { const updatedPublicAPI = {}; mockDatasource.getPublicAPI = jest.fn( - () => (updatedPublicAPI as unknown) as DatasourcePublicAPI + _ => (updatedPublicAPI as unknown) as DatasourcePublicAPI ); const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] @@ -327,10 +375,6 @@ Object { describe('datasource public api communication', () => { it('should pass the datasource api to the visualization', async () => { - const publicAPI = ({} as unknown) as DatasourcePublicAPI; - - mockDatasource.getPublicAPI = () => publicAPI; - mount( { const datasourceState = {}; - mockDatasource.initialize = () => Promise.resolve(datasourceState); + mockDatasource.initialize.mockResolvedValue(datasourceState); mount( { const initialState = {}; - mockDatasource2.initialize = () => Promise.resolve(initialState); + mockDatasource2.initialize.mockResolvedValue(initialState); instance .find('select[data-test-subj="datasource-switch"]') diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx index 5032467d5f87d..38292d7f65e78 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx @@ -27,6 +27,29 @@ export interface EditorFrameProps { export function EditorFrame(props: EditorFrameProps) { const [state, dispatch] = useReducer(reducer, props, getInitialState); + // create public datasource api for current state + // as soon as datasource is available and memoize it + const datasourcePublicAPI = useMemo( + () => + state.datasource.activeId && !state.datasource.isLoading + ? props.datasourceMap[state.datasource.activeId].getPublicAPI( + state.datasource.state, + (newState: unknown) => { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + newState, + }); + } + ) + : undefined, + [ + props.datasourceMap, + state.datasource.isLoading, + state.datasource.activeId, + state.datasource.state, + ] + ); + // Initialize current datasource useEffect( () => { @@ -49,27 +72,24 @@ export function EditorFrame(props: EditorFrameProps) { [state.datasource.activeId, state.datasource.isLoading] ); - // create public datasource api for current state - // as soon as datasource is available and memoize it - const datasourcePublicAPI = useMemo( - () => - state.datasource.activeId && !state.datasource.isLoading - ? props.datasourceMap[state.datasource.activeId].getPublicAPI( - state.datasource.state, - (newState: unknown) => { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - newState, - }); - } - ) - : undefined, - [ - props.datasourceMap, - state.datasource.isLoading, - state.datasource.activeId, - state.datasource.state, - ] + // Initialize visualization as soon as datasource is ready + useEffect( + () => { + if ( + datasourcePublicAPI && + state.visualization.state === null && + state.visualization.activeId !== null + ) { + const initialVisualizationState = props.visualizationMap[ + state.visualization.activeId + ].initialize(datasourcePublicAPI); + dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + newState: initialVisualizationState, + }); + } + }, + [datasourcePublicAPI, state.visualization.activeId, state.visualization.state] ); if (state.datasource.activeId && !state.datasource.isLoading) { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts index 5d832c5738d84..5b767d2d05582 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts @@ -29,20 +29,19 @@ describe('editor_frame state management', () => { expect(initialState.visualization.activeId).toEqual('testVis'); }); - it('should initialize visualization', () => { - const initialVisState = {}; - props.visualizationMap.testVis.initialize = jest.fn(() => initialVisState); - + it('should not initialize visualization but set active id', () => { const initialState = getInitialState(props); - expect(initialState.visualization.state).toBe(initialVisState); - expect(props.visualizationMap.testVis.initialize).toHaveBeenCalled(); + expect(initialState.visualization.state).toBe(null); + expect(initialState.visualization.activeId).toBe('testVis'); + expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled(); }); - it('should not initialize visualization if no initial visualization is passed in', () => { + 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(); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts index ec24a0269c58c..fbf1b03a2ad29 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts @@ -46,9 +46,7 @@ export const getInitialState = (props: EditorFrameProps): EditorFrameState => { activeId: props.initialDatasourceId, }, visualization: { - state: props.initialVisualizationId - ? props.visualizationMap[props.initialVisualizationId].initialize() - : null, + state: null, activeId: props.initialVisualizationId, }, }; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 07b12cef50726..bcdeea279ee85 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -153,7 +153,7 @@ export interface VisualizationSuggestion { export interface Visualization { // For initializing from saved object - initialize: (state?: P) => T; + initialize: (datasource: DatasourcePublicAPI, state?: P) => T; getPersistableState: (state: T) => P; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap index 5f8dd2ef2b3d0..678272922f013 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`XY visualization #toExpression should map to a valid AST 1`] = ` +exports[`xy_visualization #toExpression should map to a valid AST 1`] = ` Object { "chain": Array [ Object { diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts index d2b458e87219c..3590f86d09fe9 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -8,6 +8,7 @@ import { xyVisualization } from './xy_visualization'; import { Position } from '@elastic/charts'; import { DatasourcePublicAPI } from '../types'; import { State } from './types'; +import { createMockDatasource } from '../editor_frame_plugin/mocks'; function exampleState(): State { return { @@ -31,20 +32,19 @@ function exampleState(): State { }; } -describe('XY visualization', () => { +describe('xy_visualization', () => { describe('#initialize', () => { it('loads default state', () => { - const initialState = xyVisualization.initialize(); + const mockDatasource = createMockDatasource(); + mockDatasource.publicAPIMock.generateColumnId + .mockReturnValue('test-id1') + .mockReturnValueOnce('test-id2'); + const initialState = xyVisualization.initialize(mockDatasource.publicAPIMock); expect(initialState.x.accessor).toBeDefined(); expect(initialState.y.accessors[0]).toBeDefined(); expect(initialState.x.accessor).not.toEqual(initialState.y.accessors[0]); - // These change with each generation, so we'll ignore them - // in our match snapshot test. - delete initialState.x.accessor; - delete initialState.y.accessors; - expect(initialState).toMatchInlineSnapshot(` Object { "legend": Object { @@ -56,11 +56,15 @@ Object { "stackAccessors": Array [], "title": "Empty XY Chart", "x": Object { + "accessor": "test-id2", "position": "bottom", "showGridlines": false, "title": "X", }, "y": Object { + "accessors": Array [ + "test-id1", + ], "position": "left", "showGridlines": false, "title": "Y", @@ -70,7 +74,9 @@ Object { }); it('loads from persisted state', () => { - expect(xyVisualization.initialize(exampleState())).toEqual(exampleState()); + expect( + xyVisualization.initialize(createMockDatasource().publicAPIMock, exampleState()) + ).toEqual(exampleState()); }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index 44b3a64b17560..d7f2f978cc01a 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { render } from 'react-dom'; import { Position } from '@elastic/charts'; -import uuid from 'uuid'; import { I18nProvider } from '@kbn/i18n/react'; import { getSuggestions } from './xy_suggestions'; import { XYConfigPanel } from './xy_config_panel'; @@ -17,20 +16,20 @@ import { State, PersistableState } from './types'; export const xyVisualization: Visualization = { getSuggestions, - initialize(state) { + initialize(datasource, state) { return ( state || { seriesType: 'line', title: 'Empty XY Chart', legend: { isVisible: true, position: Position.Right }, x: { - accessor: uuid.v4(), + accessor: datasource.generateColumnId(), position: Position.Bottom, showGridlines: false, title: 'X', }, y: { - accessors: [uuid.v4()], + accessors: [datasource.generateColumnId()], position: Position.Left, showGridlines: false, title: 'Y', From 8f231d29c91971acdb1b9568c5328b02bbd6cd40 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Fri, 7 Jun 2019 15:00:06 -0400 Subject: [PATCH 022/105] [lens] Dimension panel lets users select operations and fields individually (#37573) * [lens] Dimension panel lets users select operations and fields individually * Split files and add tests * Fix dimension labeling and add clear button * Support more aggregations, aggregation nesting, rollups, and clearing * Fix esaggs expression * Increase top-level test coverage of dimension panel * Update from review comments --- x-pack/plugins/lens/public/index.scss | 5 +- x-pack/plugins/lens/public/index.ts | 1 + .../indexpattern_plugin/__mocks__/loader.ts | 50 ++- .../__mocks__/operations.ts | 19 ++ .../__snapshots__/indexpattern.test.tsx.snap | 41 +-- .../dimension_panel.test.tsx | 293 ++++++++++++++++ .../indexpattern_plugin/dimension_panel.tsx | 189 +++++++++++ .../indexpattern_plugin/indexpattern.scss | 3 + .../indexpattern_plugin/indexpattern.test.tsx | 163 ++++----- .../indexpattern_plugin/indexpattern.tsx | 177 +++++----- .../lens/public/indexpattern_plugin/loader.ts | 36 +- .../indexpattern_plugin/operations.test.ts | 319 ++++++++++++++++++ .../public/indexpattern_plugin/operations.ts | 221 ++++++++++++ .../indexpattern_plugin/to_expression.ts | 94 ++++++ 14 files changed, 1380 insertions(+), 231 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.scss create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/operations.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts diff --git a/x-pack/plugins/lens/public/index.scss b/x-pack/plugins/lens/public/index.scss index 6c01d745e0202..760223bd61642 100644 --- a/x-pack/plugins/lens/public/index.scss +++ b/x-pack/plugins/lens/public/index.scss @@ -1,5 +1,6 @@ // Import the EUI global scope so we can use EUI constants @import 'src/legacy/ui/public/styles/_styling_constants'; -@import "./drag_drop/drag_drop.scss"; -@import "./xy_visualization_plugin/xy_expression.scss"; \ No newline at end of file +@import './drag_drop/drag_drop.scss'; +@import './xy_visualization_plugin/xy_expression.scss'; +@import './indexpattern_plugin/indexpattern'; diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index ad0b78c63e710..532b6e66d2b27 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -7,6 +7,7 @@ export * from './types'; import 'ui/autoload/all'; +// Used to run esaggs queries import 'uiExports/fieldFormats'; import 'uiExports/search'; import 'uiExports/visRequestHandlers'; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts index 1bb56464138d1..7823768896d64 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts @@ -9,7 +9,7 @@ export function getIndexPatterns() { resolve([ { id: '1', - title: 'Fake Index Pattern', + title: 'my-fake-index-pattern', timeFieldName: 'timestamp', fields: [ { @@ -34,7 +34,7 @@ export function getIndexPatterns() { }, { id: '2', - title: 'Fake Rollup Pattern', + title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', fields: [ { @@ -56,6 +56,52 @@ export function getIndexPatterns() { 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', + }, + }, + }, + }, }, ]); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts new file mode 100644 index 0000000000000..0d7fcdecbc340 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const actual = jest.requireActual('../operations'); + +jest.spyOn(actual, 'getPotentialColumns'); +jest.spyOn(actual, 'getColumnOrder'); + +export const { + getPotentialColumns, + getColumnOrder, + getOperations, + getOperationDisplay, + getOperationTypesForField, + getOperationResultType, +} = actual; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap b/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap index 45760f0bf5efb..578eae643574f 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap @@ -1,40 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`IndexPattern Data Source #getPublicAPI renderDimensionPanel should render a dimension panel 1`] = ` -
- Dimension Panel - -
-`; - exports[`IndexPattern Data Source #renderDataPanel should match snapshot 1`] = `
Index Pattern Data Source @@ -48,11 +13,11 @@ exports[`IndexPattern Data Source #renderDataPanel should match snapshot 1`] = ` options={ Array [ Object { - "label": "Fake Index Pattern", + "label": "my-fake-index-pattern", "value": "1", }, Object { - "label": "Fake Rollup Pattern", + "label": "my-fake-restricted-pattern", "value": "2", }, ] @@ -60,7 +25,7 @@ exports[`IndexPattern Data Source #renderDataPanel should match snapshot 1`] = ` selectedOptions={ Array [ Object { - "label": "Fake Index Pattern", + "label": "my-fake-index-pattern", "value": "1", }, ] diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx new file mode 100644 index 0000000000000..2e1fb83a90571 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx @@ -0,0 +1,293 @@ +/* + * 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, shallow } from 'enzyme'; +import React from 'react'; +import { EuiComboBox } from '@elastic/eui'; +import { IndexPatternPrivateState } from './indexpattern'; +import { getColumnOrder, getPotentialColumns } from './operations'; +import { IndexPatternDimensionPanel } from './dimension_panel'; + +jest.mock('./operations'); + +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('IndexPatternDimensionPanel', () => { + let state: IndexPatternPrivateState; + + beforeEach(() => { + state = { + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'date', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'timestamp', + }, + }, + }; + + jest.clearAllMocks(); + }); + + it('should display a call to action in the popover button', () => { + const wrapper = mount( + {}} + columnId={'col2'} + filterOperations={() => true} + /> + ); + expect( + wrapper + .find('[data-test-subj="indexPattern-dimensionPopover-button"]') + .first() + .text() + ).toEqual('Configure dimension'); + }); + + it('should pass the right arguments to getPotentialColumns', async () => { + shallow( + {}} + columnId={'col1'} + filterOperations={() => true} + suggestedPriority={1} + /> + ); + + expect(getPotentialColumns as jest.Mock).toHaveBeenCalledWith(state, 1); + }); + + it('should call the filterOperations function', () => { + const filterOperations = jest.fn().mockReturnValue(true); + + shallow( + {}} + columnId={'col2'} + filterOperations={filterOperations} + /> + ); + + expect(filterOperations).toBeCalled(); + }); + + it('should not show any choices if the filter returns false', () => { + const wrapper = shallow( + {}} + columnId={'col2'} + filterOperations={() => false} + /> + ); + + expect(wrapper.find(EuiComboBox)!.prop('options')!.length).toEqual(0); + }); + + it('should list all field names in sorted order', () => { + const wrapper = shallow( + {}} + columnId={'col1'} + filterOperations={() => true} + /> + ); + + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options!.map(({ label }) => label)).toEqual([ + 'bytes', + 'documents', + 'source', + 'timestamp', + ]); + }); + + it("should disable functions that won't work with the current column", () => { + const setState = jest.fn(); + + const wrapper = shallow( + true} + /> + ); + + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-value"]').prop('color') + ).toEqual('primary'); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-value"]').prop('isDisabled') + ).toEqual(false); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-terms"]').prop('isDisabled') + ).toEqual(true); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').prop('isDisabled') + ).toEqual(false); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-sum"]').prop('isDisabled') + ).toEqual(true); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-avg"]').prop('isDisabled') + ).toEqual(true); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-count"]').prop('isDisabled') + ).toEqual(true); + }); + + it('should update the datasource state on selection of a value operation', () => { + const setState = jest.fn(); + + const wrapper = shallow( + true} + suggestedPriority={1} + /> + ); + + const comboBox = wrapper.find(EuiComboBox)!; + const firstOption = comboBox.prop('options')![0]; + + comboBox.prop('onChange')!([firstOption]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col2: expect.objectContaining({ + sourceField: firstOption.label, + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }); + }); + + it('should always request the new sort order when changing the function', () => { + const setState = jest.fn(); + + const wrapper = shallow( + true} + suggestedPriority={1} + /> + ); + + wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').simulate('click'); + + expect(getColumnOrder).toHaveBeenCalledWith({ + col1: expect.objectContaining({ + sourceField: 'timestamp', + operationType: 'date_histogram', + }), + }); + }); + + it('should update the datasource state when the user makes a selection', () => { + const setState = jest.fn(); + + const wrapper = shallow( + op.dataType === 'number'} + /> + ); + + const comboBox = wrapper.find(EuiComboBox)!; + const firstField = comboBox.prop('options')![0]; + + comboBox.prop('onChange')!([firstField]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col2: expect.objectContaining({ + operationId: firstField.value, + label: 'Value of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'value', + sourceField: 'bytes', + }), + }, + columnOrder: ['col1', 'col2'], + }); + }); + + it('should clear the dimension with the clear button', () => { + const setState = jest.fn(); + + const wrapper = shallow( + true} + /> + ); + + const clearButton = wrapper.find('[data-test-subj="indexPattern-dimensionPopover-remove"]'); + + clearButton.simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: {}, + columnOrder: [], + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx new file mode 100644 index 0000000000000..fd3600a3a616a --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx @@ -0,0 +1,189 @@ +/* + * 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 { + EuiButtonIcon, + EuiComboBox, + EuiPopover, + EuiButtonEmpty, + EuiFlexItem, + EuiFlexGroup, +} from '@elastic/eui'; +import { DatasourceDimensionPanelProps } from '../types'; +import { IndexPatternColumn, IndexPatternPrivateState, columnToOperation } from './indexpattern'; + +import { + getOperationDisplay, + getOperations, + getPotentialColumns, + getColumnOrder, +} from './operations'; + +export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { + state: IndexPatternPrivateState; + setState: (newState: IndexPatternPrivateState) => void; +}; + +export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { + const [isOpen, setOpen] = useState(false); + + const operations = getOperations(); + const operationPanels = getOperationDisplay(); + + const columns = getPotentialColumns(props.state, props.suggestedPriority); + + const filteredColumns = columns.filter(col => { + return props.filterOperations(columnToOperation(col)); + }); + + const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; + + const uniqueColumnsByField = _.uniq(filteredColumns, col => col.sourceField); + + const functionsFromField = selectedColumn + ? filteredColumns.filter(col => { + return col.sourceField === selectedColumn.sourceField; + }) + : filteredColumns; + + return ( + + + { + setOpen(false); + }} + ownFocus + anchorPosition="rightCenter" + button={ + + { + setOpen(!isOpen); + }} + > + + {selectedColumn + ? selectedColumn.label + : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { + defaultMessage: 'Configure dimension', + })} + + + + } + > + + + ({ + label: col.sourceField, + value: col.operationId, + }))} + selectedOptions={ + selectedColumn + ? [ + { + label: selectedColumn.sourceField, + value: selectedColumn.operationId, + }, + ] + : [] + } + singleSelection={{ asPlainText: true }} + isClearable={false} + onChange={choices => { + const column: IndexPatternColumn = columns.find( + ({ operationId }) => operationId === choices[0].value + )!; + const newColumns: IndexPatternPrivateState['columns'] = { + ...props.state.columns, + [props.columnId]: column, + }; + + props.setState({ + ...props.state, + columns: newColumns, + columnOrder: getColumnOrder(newColumns), + }); + }} + /> + + +
+ {operations.map(o => ( + col.operationType === o)} + onClick={() => { + if (!selectedColumn) { + return; + } + + const newColumn: IndexPatternColumn = filteredColumns.find( + col => + col.operationType === o && col.sourceField === selectedColumn.sourceField + )!; + + const newColumns = { + ...props.state.columns, + [props.columnId]: newColumn, + }; + + props.setState({ + ...props.state, + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }); + }} + > + {operationPanels[o].displayName} + + ))} +
+
+
+
+
+ {selectedColumn && ( + + { + const newColumns: IndexPatternPrivateState['columns'] = { + ...props.state.columns, + }; + delete newColumns[props.columnId]; + + props.setState({ + ...props.state, + columns: newColumns, + columnOrder: getColumnOrder(newColumns), + }); + }} + /> + + )} +
+ ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.scss b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.scss new file mode 100644 index 0000000000000..ac1b7d4ab754b --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.scss @@ -0,0 +1,3 @@ +.lnsIndexPattern__dimensionPopover { + max-width: 600px; +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index ac0cc03fbd3ab..f5fa6da12eb3c 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -12,7 +12,6 @@ import { IndexPatternPersistedState, IndexPatternPrivateState, IndexPatternDataPanel, - IndexPatternDimensionPanel, } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; @@ -21,7 +20,7 @@ jest.mock('./loader'); const expectedIndexPatterns = { 1: { id: '1', - title: 'Fake Index Pattern', + title: 'my-fake-index-pattern', timeFieldName: 'timestamp', fields: [ { @@ -46,7 +45,7 @@ const expectedIndexPatterns = { }, 2: { id: '2', - title: 'Fake Rollup Pattern', + title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', fields: [ { @@ -54,18 +53,50 @@ const expectedIndexPatterns = { 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', + }, + }, }, ], }, @@ -178,7 +209,7 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'value', - sourceField: 'op', + sourceField: 'source', }, col2: { operationId: 'op2', @@ -188,15 +219,52 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'value', - sourceField: 'op2', + sourceField: 'bytes', }, }, }; const state = await indexPatternDatasource.initialize(queryPersistedState); expect(indexPatternDatasource.toExpression(state)).toMatchInlineSnapshot( - `"esdocs index=\\"1\\" fields=\\"op, op2\\" sort=\\"op, DESC\\""` + `"esdocs index=\\"my-fake-index-pattern\\" fields=\\"source, bytes\\" sort=\\"source, DESC\\""` ); }); + + it('should generate an expression for an aggregated query', async () => { + const queryPersistedState: IndexPatternPersistedState = { + currentIndexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + operationId: 'op1', + label: 'Count of Documents', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + sourceField: 'document', + }, + col2: { + operationId: 'op2', + label: 'Date', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + }, + }, + }; + const state = await indexPatternDatasource.initialize(queryPersistedState); + expect(indexPatternDatasource.toExpression(state)).toMatchInlineSnapshot(` +"esaggs + index=\\"1\\" + metricsAtAllLevels=\\"false\\" + partialRows=\\"false\\" + aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1h\\",\\"drop_partials\\":false,\\"min_doc_count\\":1,\\"extended_bounds\\":{}}}]'" +`); + }); }); describe('#getPublicAPI', () => { @@ -227,88 +295,5 @@ describe('IndexPattern Data Source', () => { } as Operation); }); }); - - describe('renderDimensionPanel', () => { - let state: IndexPatternPrivateState; - - beforeEach(async () => { - state = await indexPatternDatasource.initialize(persistedState); - }); - - it('should render a dimension panel', () => { - const wrapper = shallow( - {}} - columnId={'col2'} - filterOperations={(operation: Operation) => true} - /> - ); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should call the filterOperations function', () => { - const filterOperations = jest.fn().mockReturnValue(true); - - shallow( - {}} - columnId={'col2'} - filterOperations={filterOperations} - /> - ); - - expect(filterOperations).toBeCalledTimes(3); - }); - - it('should filter out all selections if the filter returns false', () => { - const wrapper = shallow( - {}} - columnId={'col2'} - filterOperations={() => false} - /> - ); - - expect(wrapper.find(EuiComboBox)!.prop('options')!.length).toEqual(0); - }); - - it('should update the datasource state on selection', () => { - const setState = jest.fn(); - - const wrapper = shallow( - true} - /> - ); - - const comboBox = wrapper.find(EuiComboBox)!; - const firstOption = comboBox.prop('options')![0]; - - comboBox.prop('onChange')!([firstOption]); - - expect(setState).toHaveBeenCalledWith({ - ...state, - columns: { - ...state.columns, - col2: { - operationId: firstOption.value, - label: 'Value of timestamp', - dataType: 'date', - isBucketed: false, - operationType: 'value', - sourceField: 'timestamp', - }, - }, - columnOrder: ['col1', 'col2'], - }); - }); - }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 22614914d4dda..940f89ae1d660 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -4,19 +4,34 @@ * 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 { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; -import { Datasource, DataType } from '..'; import uuid from 'uuid'; -import { DatasourceDimensionPanelProps, DatasourceDataPanelProps } from '../types'; +import { Datasource, DataType } from '..'; +import { + DatasourceDimensionPanelProps, + DatasourceDataPanelProps, + DimensionPriority, +} from '../types'; import { getIndexPatterns } from './loader'; - -type OperationType = 'value' | 'terms' | 'date_histogram'; - -interface IndexPatternColumn { +import { toExpression } from './to_expression'; +import { IndexPatternDimensionPanel } from './dimension_panel'; + +export type OperationType = + | 'value' + | 'terms' + | 'date_histogram' + | 'sum' + | 'avg' + | 'min' + | 'max' + | 'count'; + +export interface IndexPatternColumn { // Public operationId: string; label: string; @@ -26,13 +41,14 @@ interface IndexPatternColumn { // Private operationType: OperationType; sourceField: string; + suggestedOrder?: DimensionPriority; } export interface IndexPattern { id: string; fields: IndexPatternField[]; title: string; - timeFieldName?: string; + timeFieldName?: string | null; } export interface IndexPatternField { @@ -41,6 +57,19 @@ export interface IndexPatternField { 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; + } + > + >; } export interface IndexPatternPersistedState { @@ -95,76 +124,50 @@ export function IndexPatternDataPanel(props: DatasourceDataPanelProps void; -}; - -export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { - const fields = props.state.indexPatterns[props.state.currentIndexPatternId].fields; - const columns: IndexPatternColumn[] = fields.map((field, index) => ({ - operationId: `${index}`, - label: `Value of ${field.name}`, - dataType: field.type as DataType, - isBucketed: false, - - operationType: 'value' as OperationType, - sourceField: field.name, - })); - - const filteredColumns = columns.filter(col => { - const { operationId, label, dataType, isBucketed } = col; +export function columnToOperation(column: IndexPatternColumn) { + const { dataType, label, isBucketed, operationId } = column; + return { + id: operationId, + label, + dataType, + isBucketed, + }; +} - return props.filterOperations({ - id: operationId, - label, - dataType, - isBucketed, +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 selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; - - return ( -
- Dimension Panel - ({ - label: col.label, - value: col.operationId, - }))} - selectedOptions={ - selectedColumn - ? [ - { - label: selectedColumn.label, - value: selectedColumn.operationId, - }, - ] - : [] - } - singleSelection={{ asPlainText: true }} - isClearable={false} - onChange={choices => { - const column: IndexPatternColumn = columns.find( - ({ operationId }) => operationId === choices[0].value - )!; - const newColumns: IndexPatternPrivateState['columns'] = { - ...props.state.columns, - [props.columnId]: column, - }; + const { id, title, timeFieldName } = indexPattern; - props.setState({ - ...props.state, - columns: newColumns, - // Order is not meaningful until we aggregate - columnOrder: Object.keys(newColumns), - }); - }} - /> -
- ); + return { + id, + title, + timeFieldName: timeFieldName || undefined, + fields: newFields, + }; } export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: ToastNotifications) { @@ -176,7 +179,7 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To if (indexPatternObjects) { indexPatternObjects.forEach(obj => { - indexPatterns[obj.id] = obj; + indexPatterns[obj.id] = addRestrictionsToFields(obj); }); } @@ -198,18 +201,7 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To return { currentIndexPatternId, columns, columnOrder }; }, - toExpression(state: IndexPatternPrivateState) { - if (state.columnOrder.length === 0) { - return null; - } - - const fieldNames = state.columnOrder.map(col => state.columns[col].sourceField); - const expression = `esdocs index="${state.currentIndexPatternId}" fields="${fieldNames.join( - ', ' - )}" sort="${fieldNames[0]}, DESC"`; - - return expression; - }, + toExpression, renderDataPanel( domElement: Element, @@ -224,14 +216,7 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To return state.columnOrder.map(colId => ({ columnId: colId })); }, getOperationForColumnId: (columnId: string) => { - const column = state.columns[columnId]; - const { dataType, label, isBucketed, operationId } = column; - return { - id: operationId, - label, - dataType, - isBucketed, - }; + return columnToOperation(state.columns[columnId]); }, generateColumnId: () => { // TODO: Come up with a more compact form of generating unique column ids @@ -240,7 +225,11 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { render( - , + setState(newState)} + {...props} + />, domElement ); }, diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts index 3de7d511c4b49..41aa3737cde9b 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts @@ -9,30 +9,54 @@ import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { SavedObjectAttributes } from 'src/legacy/server/saved_objects/service/saved_objects_client'; import { IndexPatternField } from './indexpattern'; -interface IndexPatternAttributes extends SavedObjectAttributes { +interface SavedIndexPatternAttributes extends SavedObjectAttributes { title: string; timeFieldName: string | null; fields: string; fieldFormatMap: string; + typeMeta: string; } +interface SavedRestrictionsObject { + aggs: Record< + string, + Record< + string, + { + agg: string; + interval?: number; + fixed_interval?: string; + delay?: string; + time_zone?: string; + } + > + >; +} +type SavedRestrictionsInfo = SavedRestrictionsObject | undefined; + export const getIndexPatterns = (chrome: Chrome, toastNotifications: ToastNotifications) => { const savedObjectsClient = chrome.getSavedObjectsClient(); return savedObjectsClient - .find({ + .find({ type: 'index-pattern', perPage: 1000, // TODO: Paginate index patterns }) .then(resp => { return resp.savedObjects.map(savedObject => { - const { id, attributes } = savedObject; - return Object.assign(attributes, { + const { id, attributes, type } = savedObject; + return { + ...attributes, id, + type, title: attributes.title, fields: (JSON.parse(attributes.fields) as IndexPatternField[]).filter( - ({ type, esTypes }) => type !== 'string' || (esTypes && esTypes.includes('keyword')) + ({ type: fieldType, esTypes }) => + fieldType !== 'string' || (esTypes && esTypes.includes('keyword')) ), - }); + typeMeta: attributes.typeMeta + ? (JSON.parse(attributes.typeMeta) as SavedRestrictionsInfo) + : undefined, + }; }); }) .catch(err => { diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts new file mode 100644 index 0000000000000..9bc64b8b24e38 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -0,0 +1,319 @@ +/* + * 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, getPotentialColumns, getColumnOrder } from './operations'; +import { IndexPatternPrivateState } from './indexpattern'; + +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(['value', 'terms'])); + }); + + it('should return operations on numbers', () => { + expect( + getOperationTypesForField({ + type: 'number', + name: 'a', + aggregatable: true, + searchable: true, + }) + ).toEqual(expect.arrayContaining(['value', 'avg', 'sum', 'min', 'max'])); + }); + + it('should return operations on dates', () => { + expect( + getOperationTypesForField({ + type: 'date', + name: 'a', + aggregatable: true, + searchable: true, + }) + ).toEqual(expect.arrayContaining(['value', '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: 'dates', + 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('getPotentialColumns', () => { + let state: IndexPatternPrivateState; + + beforeEach(() => { + state = { + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'date', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'timestamp', + }, + }, + }; + }); + + it('should include priority', () => { + const columns = getPotentialColumns(state, 1); + + expect(columns.every(col => col.suggestedOrder === 1)).toEqual(true); + }); + + it('should list operations by field for a regular index pattern', () => { + const columns = getPotentialColumns(state); + + expect(columns.map(col => [col.sourceField, col.operationType])).toMatchInlineSnapshot(` +Array [ + Array [ + "bytes", + "value", + ], + Array [ + "bytes", + "sum", + ], + Array [ + "bytes", + "avg", + ], + Array [ + "bytes", + "min", + ], + Array [ + "bytes", + "max", + ], + Array [ + "documents", + "count", + ], + Array [ + "source", + "value", + ], + Array [ + "source", + "terms", + ], + Array [ + "timestamp", + "value", + ], + Array [ + "timestamp", + "date_histogram", + ], +] +`); + }); + }); +}); + +describe('getColumnOrder', () => { + it('should work for empty columns', () => { + expect(getColumnOrder({})).toEqual([]); + }); + + it('should work for one column', () => { + expect( + getColumnOrder({ + col1: { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'string', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'timestamp', + }, + }) + ).toEqual(['col1']); + }); + + it('should put any number of aggregations before metrics', () => { + expect( + getColumnOrder({ + col1: { + operationId: 'op1', + label: 'Top Values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'value', + sourceField: 'timestamp', + }, + col2: { + operationId: 'op2', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'bytes', + }, + col3: { + operationId: 'op3', + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + }, + }) + ).toEqual(['col1', 'col3', 'col2']); + }); + + it('should reorder aggregations based on suggested priority', () => { + expect( + getColumnOrder({ + col1: { + operationId: 'op1', + label: 'Top Values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'value', + sourceField: 'timestamp', + suggestedOrder: 2, + }, + col2: { + operationId: 'op2', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'bytes', + suggestedOrder: 0, + }, + col3: { + operationId: 'op3', + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + suggestedOrder: 1, + }, + }) + ).toEqual(['col3', 'col1', 'col2']); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts new file mode 100644 index 0000000000000..4a0df7a6a977a --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts @@ -0,0 +1,221 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { DataType, DimensionPriority } from '../types'; +import { + IndexPatternColumn, + IndexPatternField, + IndexPatternPrivateState, + OperationType, +} from './indexpattern'; + +export function getOperations(): OperationType[] { + return ['value', 'terms', 'date_histogram', 'sum', 'avg', 'min', 'max', 'count']; +} + +export function getOperationDisplay(): Record< + OperationType, + { + type: OperationType; + displayName: string; + ofName: (name: string) => string; + } +> { + return { + value: { + type: 'value', + displayName: i18n.translate('xpack.lens.indexPattern.value', { + defaultMessage: 'Value', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.valueOf', { + defaultMessage: 'Value of {name}', + values: { name }, + }), + }, + terms: { + type: 'terms', + displayName: i18n.translate('xpack.lens.indexPattern.terms', { + defaultMessage: 'Top Values', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.termsOf', { + defaultMessage: 'Top Values of {name}', + values: { name }, + }), + }, + date_histogram: { + type: 'date_histogram', + displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { + defaultMessage: 'Date Histogram', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.dateHistogramOf', { + defaultMessage: 'Date Histogram of {name}', + values: { name }, + }), + }, + sum: { + type: 'sum', + displayName: i18n.translate('xpack.lens.indexPattern.sum', { + defaultMessage: 'Sum', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.sumOf', { + defaultMessage: 'Sum of {name}', + values: { name }, + }), + }, + avg: { + type: 'avg', + displayName: i18n.translate('xpack.lens.indexPattern.average', { + defaultMessage: 'Average', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.averageOf', { + defaultMessage: 'Average of {name}', + values: { name }, + }), + }, + min: { + 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 }, + }), + }, + max: { + 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 }, + }), + }, + count: { + type: 'count', + displayName: i18n.translate('xpack.lens.indexPattern.count', { + defaultMessage: 'Count', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.countOf', { + defaultMessage: 'Count of {name}', + values: { name }, + }), + }, + }; +} + +export function getOperationTypesForField({ + type, + aggregationRestrictions, +}: IndexPatternField): OperationType[] { + if (aggregationRestrictions) { + const validOperations = getOperations(); + return Object.keys(aggregationRestrictions).filter(key => + // Filter out operations that are available, but that aren't yet supported by the client + validOperations.includes(key as OperationType) + ) as OperationType[]; + } + + switch (type) { + case 'date': + return ['value', 'date_histogram']; + case 'number': + return ['value', 'sum', 'avg', 'min', 'max']; + case 'string': + return ['value', 'terms']; + } + return []; +} + +export function getOperationResultType({ type }: IndexPatternField, op: OperationType): DataType { + switch (op) { + case 'value': + return type as DataType; + case 'avg': + case 'min': + case 'max': + case 'count': + case 'sum': + return 'number'; + case 'date_histogram': + return 'date'; + case 'terms': + return 'string'; + } +} + +export function getPotentialColumns( + state: IndexPatternPrivateState, + suggestedOrder?: DimensionPriority +): IndexPatternColumn[] { + const fields = state.indexPatterns[state.currentIndexPatternId].fields; + + const operationPanels = getOperationDisplay(); + + const columns: IndexPatternColumn[] = fields + .map((field, index) => { + const validOperations = getOperationTypesForField(field); + + return validOperations.map(op => ({ + operationId: `${index}${op}`, + label: operationPanels[op].ofName(field.name), + dataType: getOperationResultType(field, op), + isBucketed: op === 'terms' || op === 'date_histogram', + + operationType: op, + sourceField: field.name, + suggestedOrder, + })); + }) + .reduce((prev, current) => prev.concat(current)); + + columns.push({ + operationId: 'count', + label: i18n.translate('xpack.lens.indexPatternOperations.countOfDocuments', { + defaultMessage: 'Count of Documents', + }), + dataType: 'number', + isBucketed: false, + + operationType: 'count', + sourceField: 'documents', + suggestedOrder, + }); + + columns.sort(({ sourceField }, { sourceField: sourceField2 }) => + sourceField.localeCompare(sourceField2) + ); + + return columns; +} + +export function getColumnOrder(columns: Record): string[] { + const entries = Object.entries(columns); + + const [aggregations, metrics] = _.partition(entries, col => col[1].isBucketed); + + return aggregations + .sort(([id, col], [id2, col2]) => { + return ( + // Sort undefined orders last + (col.suggestedOrder !== undefined ? col.suggestedOrder : Number.MAX_SAFE_INTEGER) - + (col2.suggestedOrder !== undefined ? col2.suggestedOrder : Number.MAX_SAFE_INTEGER) + ); + }) + .map(([id]) => id) + .concat(metrics.map(([id]) => id)); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts new file mode 100644 index 0000000000000..9e9f113665fdb --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -0,0 +1,94 @@ +/* + * 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 } from './indexpattern'; + +export function toExpression(state: IndexPatternPrivateState) { + if (state.columnOrder.length === 0) { + return null; + } + + const fieldNames = state.columnOrder.map(col => state.columns[col].sourceField); + const sortedColumns = state.columnOrder.map(col => state.columns[col]); + + const indexName = state.indexPatterns[state.currentIndexPatternId].title; + + if (sortedColumns.every(({ operationType }) => operationType === 'value')) { + return `esdocs index="${indexName}" fields="${fieldNames.join(', ')}" sort="${ + fieldNames[0] + }, DESC"`; + } else if (sortedColumns.length) { + const firstMetric = sortedColumns.findIndex(({ isBucketed }) => !isBucketed); + const aggs = sortedColumns.map((col, index) => { + if (col.operationType === 'date_histogram') { + return { + id: state.columnOrder[index], + enabled: true, + type: 'date_histogram', + schema: 'segment', + params: { + field: col.sourceField, + // TODO: This range should be passed in from somewhere else + timeRange: { + from: 'now-1d', + to: 'now', + }, + useNormalizedEsInterval: true, + interval: '1h', + drop_partials: false, + min_doc_count: 1, + extended_bounds: {}, + }, + }; + } else if (col.operationType === 'terms') { + return { + id: state.columnOrder[index], + enabled: true, + type: 'terms', + schema: 'segment', + params: { + field: col.sourceField, + orderBy: state.columnOrder[firstMetric] || undefined, + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }; + } else if (col.operationType === 'count') { + return { + id: state.columnOrder[index], + enabled: true, + type: 'count', + schema: 'metric', + params: {}, + }; + } else { + return { + id: state.columnOrder[index], + enabled: true, + type: col.operationType, + schema: 'metric', + params: { + field: col.sourceField, + }, + }; + } + }); + + return `esaggs + index="${state.currentIndexPatternId}" + metricsAtAllLevels="false" + partialRows="false" + aggConfigs='${JSON.stringify(aggs)}'`; + } + + return ''; +} From 142cb9d79cb1159dffb6210173be748ed8978f2b Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 12 Jun 2019 18:13:32 +0200 Subject: [PATCH 023/105] [Lens] Rename columns (#38278) --- .../indexpattern_plugin/indexpattern.test.tsx | 4 +- .../public/indexpattern_plugin/plugin.tsx | 34 +++++- .../rename_columns.test.ts | 103 ++++++++++++++++++ .../indexpattern_plugin/rename_columns.ts | 64 +++++++++++ .../indexpattern_plugin/to_expression.ts | 21 +++- 5 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/rename_columns.test.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/rename_columns.ts diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index f5fa6da12eb3c..8962d553a0488 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -225,7 +225,7 @@ describe('IndexPattern Data Source', () => { }; const state = await indexPatternDatasource.initialize(queryPersistedState); expect(indexPatternDatasource.toExpression(state)).toMatchInlineSnapshot( - `"esdocs index=\\"my-fake-index-pattern\\" fields=\\"source, bytes\\" sort=\\"source, DESC\\""` + `"esdocs index=\\"my-fake-index-pattern\\" fields=\\"source, bytes\\" sort=\\"source, DESC\\" | lens_rename_columns idMap='{\\"source\\":\\"col1\\",\\"bytes\\":\\"col2\\"}'"` ); }); @@ -262,7 +262,7 @@ describe('IndexPattern Data Source', () => { index=\\"1\\" metricsAtAllLevels=\\"false\\" partialRows=\\"false\\" - aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1h\\",\\"drop_partials\\":false,\\"min_doc_count\\":1,\\"extended_bounds\\":{}}}]'" + aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1h\\",\\"drop_partials\\":false,\\"min_doc_count\\":1,\\"extended_bounds\\":{}}}]' | lens_rename_columns idMap='{\\"col-0-col1\\":\\"col1\\",\\"col-1-col2\\":\\"col2\\"}'" `); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx index 38fd82705dfab..493ae2b026778 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/plugin.tsx @@ -4,14 +4,39 @@ * 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'; import chrome from 'ui/chrome'; import { toastNotifications } from 'ui/notify'; import { getIndexPatternDatasource } from './indexpattern'; +import { + functionsRegistry, + // @ts-ignore untyped dependency +} from '../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { ExpressionFunction } from '../../../../../src/legacy/core_plugins/interpreter/public'; +import { renameColumns } from './rename_columns'; + +// 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 { + interpreter: InterpreterSetup; +} + +export interface InterpreterSetup { + functionsRegistry: Registry< + ExpressionFunction, + ExpressionFunction + >; +} + class IndexPatternDatasourcePlugin { constructor() {} - setup() { + setup(_core: CoreSetup | null, { interpreter }: IndexPatternDatasourcePluginPlugins) { + interpreter.functionsRegistry.register(() => renameColumns); return getIndexPatternDatasource(chrome, toastNotifications); } @@ -20,5 +45,10 @@ class IndexPatternDatasourcePlugin { const plugin = new IndexPatternDatasourcePlugin(); -export const indexPatternDatasourceSetup = () => plugin.setup(); +export const indexPatternDatasourceSetup = () => + plugin.setup(null, { + interpreter: { + functionsRegistry, + }, + }); export const indexPatternDatasourceStop = () => plugin.stop(); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/rename_columns.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/rename_columns.test.ts new file mode 100644 index 0000000000000..5eb28af0ae3e4 --- /dev/null +++ b/x-pack/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/types'; + +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/plugins/lens/public/indexpattern_plugin/rename_columns.ts b/x-pack/plugins/lens/public/indexpattern_plugin/rename_columns.ts new file mode 100644 index 0000000000000..1740d449b62cd --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/rename_columns.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 { i18n } from '@kbn/i18n'; +import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; +import { KibanaDatatable } from '../types'; + +interface RemapArgs { + idMap: string; +} + +export const renameColumns: ExpressionFunction< + 'lens_rename_columns', + KibanaDatatable, + RemapArgs, + KibanaDatatable +> = { + name: 'lens_rename_columns', + type: 'kibana_datatable', + help: i18n.translate('lens.functions.renameColumns.help', { + defaultMessage: 'A helper to rename the columns of a datatable', + }), + args: { + idMap: { + types: ['string'], + help: i18n.translate('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/plugins/lens/public/indexpattern_plugin/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts index 9e9f113665fdb..142dbb19a78c3 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -19,9 +19,16 @@ export function toExpression(state: IndexPatternPrivateState) { const indexName = state.indexPatterns[state.currentIndexPatternId].title; if (sortedColumns.every(({ operationType }) => operationType === 'value')) { + const idMap = fieldNames.reduce( + (currentIdMap, fieldName, index) => ({ + ...currentIdMap, + [fieldName]: state.columnOrder[index], + }), + {} as Record + ); return `esdocs index="${indexName}" fields="${fieldNames.join(', ')}" sort="${ fieldNames[0] - }, DESC"`; + }, DESC" | lens_rename_columns idMap='${JSON.stringify(idMap)}'`; } else if (sortedColumns.length) { const firstMetric = sortedColumns.findIndex(({ isBucketed }) => !isBucketed); const aggs = sortedColumns.map((col, index) => { @@ -83,12 +90,20 @@ export function toExpression(state: IndexPatternPrivateState) { } }); + const idMap = state.columnOrder.reduce( + (currentIdMap, columnId, index) => ({ + ...currentIdMap, + [`col-${index}-${columnId}`]: columnId, + }), + {} as Record + ); + return `esaggs index="${state.currentIndexPatternId}" metricsAtAllLevels="false" partialRows="false" - aggConfigs='${JSON.stringify(aggs)}'`; + aggConfigs='${JSON.stringify(aggs)}' | lens_rename_columns idMap='${JSON.stringify(idMap)}'`; } - return ''; + return null; } From 307cb1262a96a6ea8e0c5cffe3b0bc52df111bc6 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Wed, 12 Jun 2019 14:56:00 -0400 Subject: [PATCH 024/105] [Lens] Lens/index pattern drag drop (#37711) * Basic xy chart suggestions * Re-apply XY config panel after force merge * Initial integration of lens drag and drop * Tweak naming, remove irellevant comment * Tweaks per Wylie's feedback * Add xy chart internationalization Tweak types per Joe's feedback * Update xy chart i18n implementation * Fix i18n id * Add drop tests to the lens index pattern * improve tests --- .../lens/public/drag_drop/drag_drop.tsx | 2 +- .../editor_frame/config_panel_wrapper.tsx | 5 +- .../editor_frame/data_panel_wrapper.tsx | 4 +- .../editor_frame/editor_frame.test.tsx | 2 +- .../editor_frame/frame_layout.tsx | 21 +- x-pack/plugins/lens/public/index.scss | 2 +- .../__snapshots__/indexpattern.test.tsx.snap | 45 ++- .../dimension_panel.test.tsx | 218 ++++++++++++++ .../indexpattern_plugin/dimension_panel.tsx | 280 ++++++++++-------- .../indexpattern_plugin/indexpattern.test.tsx | 16 +- .../indexpattern_plugin/indexpattern.tsx | 9 +- .../lens/public/indexpattern_plugin/mocks.ts | 14 + x-pack/plugins/lens/public/types.ts | 5 + .../xy_config_panel.test.tsx | 38 ++- .../xy_config_panel.tsx | 2 + 15 files changed, 513 insertions(+), 150 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/mocks.ts diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 35326a46a6820..d7cc6857e7d96 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -13,7 +13,7 @@ type DroppableEvent = React.DragEvent; /** * A function that handles a drop event. */ -type DropHandler = (item: unknown) => void; +export type DropHandler = (item: unknown) => void; /** * The argument to the DragDrop component. diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx index 911a3d2291c74..6d16e3aff0fd1 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useContext } from 'react'; import { EuiSelect } from '@elastic/eui'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; import { Visualization, DatasourcePublicAPI } from '../../types'; +import { DragContext } from '../../drag_drop'; interface ConfigPanelWrapperProps { visualizationState: unknown; @@ -19,6 +20,7 @@ interface ConfigPanelWrapperProps { } export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { + const context = useContext(DragContext); const setVisualizationState = useMemo( () => (newState: unknown) => { props.dispatch({ @@ -54,6 +56,7 @@ export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { { ); const datasourceProps: DatasourceDataPanelProps = { + dragDropContext: useContext(DragContext), state: props.datasourceState, setState: setDatasourceState, }; diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index 17b99b31c45fc..b06c60fed9df9 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -326,7 +326,7 @@ Object { setDatasourceState(updatedState); }); - expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(2); + expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(3); expect(mockDatasource.renderDataPanel).toHaveBeenLastCalledWith( expect.any(Element), expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx index f62722bf71b85..12e7e49dc2d4c 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { RootDragDropProvider } from '../../drag_drop'; export interface FrameLayoutProps { dataPanel: React.ReactNode; @@ -16,14 +17,16 @@ export interface FrameLayoutProps { export function FrameLayout(props: FrameLayoutProps) { return ( - - {/* TODO style this and add workspace prop and loading flags */} - {props.dataPanel} - {props.workspacePanel} - - {props.configPanel} - {props.suggestionsPanel} - - + + + {/* TODO style this and add workspace prop and loading flags */} + {props.dataPanel} + {props.workspacePanel} + + {props.configPanel} + {props.suggestionsPanel} + + + ); } diff --git a/x-pack/plugins/lens/public/index.scss b/x-pack/plugins/lens/public/index.scss index 760223bd61642..6e5372e233a0d 100644 --- a/x-pack/plugins/lens/public/index.scss +++ b/x-pack/plugins/lens/public/index.scss @@ -3,4 +3,4 @@ @import './drag_drop/drag_drop.scss'; @import './xy_visualization_plugin/xy_expression.scss'; -@import './indexpattern_plugin/indexpattern'; +@import './indexpattern_plugin/indexpattern'; \ No newline at end of file diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap b/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap index 578eae643574f..5b260ec1b7458 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap @@ -1,7 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`IndexPattern Data Source #renderDataPanel should match snapshot 1`] = ` -
+ Index Pattern Data Source
-
timestamp -
-
+ bytes -
-
+ source -
+
-
+ `; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx index 2e1fb83a90571..751ac3a9678f9 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx @@ -10,6 +10,8 @@ import { EuiComboBox } from '@elastic/eui'; import { IndexPatternPrivateState } from './indexpattern'; import { getColumnOrder, getPotentialColumns } from './operations'; import { IndexPatternDimensionPanel } from './dimension_panel'; +import { DragContextState, DropHandler } from '../drag_drop'; +import { createMockedDragDropContext } from './mocks'; jest.mock('./operations'); @@ -43,6 +45,7 @@ const expectedIndexPatterns = { describe('IndexPatternDimensionPanel', () => { let state: IndexPatternPrivateState; + let dragDropContext: DragContextState; beforeEach(() => { state = { @@ -63,12 +66,15 @@ describe('IndexPatternDimensionPanel', () => { }, }; + dragDropContext = createMockedDragDropContext(); + jest.clearAllMocks(); }); it('should display a call to action in the popover button', () => { const wrapper = mount( {}} columnId={'col2'} @@ -86,6 +92,7 @@ describe('IndexPatternDimensionPanel', () => { it('should pass the right arguments to getPotentialColumns', async () => { shallow( {}} columnId={'col1'} @@ -102,6 +109,7 @@ describe('IndexPatternDimensionPanel', () => { shallow( {}} columnId={'col2'} @@ -115,6 +123,7 @@ describe('IndexPatternDimensionPanel', () => { it('should not show any choices if the filter returns false', () => { const wrapper = shallow( {}} columnId={'col2'} @@ -128,6 +137,7 @@ describe('IndexPatternDimensionPanel', () => { it('should list all field names in sorted order', () => { const wrapper = shallow( {}} columnId={'col1'} @@ -150,6 +160,7 @@ describe('IndexPatternDimensionPanel', () => { const wrapper = shallow( { const wrapper = shallow( { const wrapper = shallow( { const wrapper = shallow( { const wrapper = shallow( { columnOrder: [], }); }); + + describe('drag and drop', () => { + function dragDropState() { + return { + ...state, + currentIndexPatternId: 'foo', + indexPatterns: { + foo: { + id: 'foo', + title: 'Foo pattern', + fields: [{ aggregatable: true, name: 'bar', searchable: true, type: 'number' }], + }, + }, + }; + } + + it('is not droppable if no drag is happening', () => { + const component = mount( + {}} + columnId={'col2'} + filterOperations={() => true} + /> + ); + + expect( + component + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('droppable') + ).toBeFalsy(); + }); + + it('is not droppable if the dragged item has no type', () => { + const component = shallow( + {}} + columnId={'col2'} + filterOperations={() => true} + /> + ); + + expect( + component + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('droppable') + ).toBeFalsy(); + }); + + it('is not droppable if field is not supported by filterOperations', () => { + const component = shallow( + {}} + columnId={'col2'} + filterOperations={() => false} + /> + ); + + expect( + component + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('droppable') + ).toBeFalsy(); + }); + + it('is droppable if the field is supported by filterOperations', () => { + const component = shallow( + {}} + columnId={'col2'} + filterOperations={op => op.dataType === 'number'} + /> + ); + + expect( + component + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('droppable') + ).toBeTruthy(); + }); + + it('appends the dropped column when a field is dropped', () => { + const dragging = { type: 'number', name: 'bar' }; + const testState = dragDropState(); + const setState = jest.fn(); + const component = shallow( + op.dataType === 'number'} + /> + ); + + const onDrop = component + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; + + onDrop(dragging); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith( + expect.objectContaining({ + columns: expect.objectContaining({ + ...testState.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }), + }) + ); + }); + + it('updates a column when a field is dropped', () => { + const dragging = { type: 'number', name: 'bar' }; + const testState = dragDropState(); + const setState = jest.fn(); + const component = shallow( + op.dataType === 'number'} + /> + ); + + const onDrop = component + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; + + onDrop(dragging); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith( + expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }), + }) + ); + }); + + it('ignores drops of unsupported fields', () => { + const dragging = { type: 'number', name: 'baz' }; + const testState = dragDropState(); + const setState = jest.fn(); + const component = shallow( + op.dataType === 'number'} + /> + ); + + const onDrop = component + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; + + onDrop(dragging); + + expect(setState).not.toBeCalled(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx index fd3600a3a616a..43292d806f73a 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx @@ -16,7 +16,12 @@ import { EuiFlexGroup, } from '@elastic/eui'; import { DatasourceDimensionPanelProps } from '../types'; -import { IndexPatternColumn, IndexPatternPrivateState, columnToOperation } from './indexpattern'; +import { + IndexPatternColumn, + IndexPatternPrivateState, + columnToOperation, + IndexPatternField, +} from './indexpattern'; import { getOperationDisplay, @@ -24,10 +29,12 @@ import { getPotentialColumns, getColumnOrder, } from './operations'; +import { DragContextState, DragDrop, ChildDragDropProvider } from '../drag_drop'; export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { state: IndexPatternPrivateState; setState: (newState: IndexPatternPrivateState) => void; + dragDropContext: DragContextState; }; export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { @@ -44,6 +51,30 @@ export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProp const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; + function canHandleDrop() { + const { dragging } = props.dragDropContext; + const field = dragging as IndexPatternField; + + return ( + !!field && + !!field.type && + filteredColumns.some(({ sourceField }) => sourceField === (field as IndexPatternField).name) + ); + } + + function changeColumn(column: IndexPatternColumn) { + const newColumns: IndexPatternPrivateState['columns'] = { + ...props.state.columns, + [props.columnId]: column, + }; + + props.setState({ + ...props.state, + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }); + } + const uniqueColumnsByField = _.uniq(filteredColumns, col => col.sourceField); const functionsFromField = selectedColumn @@ -53,137 +84,148 @@ export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProp : filteredColumns; return ( - - - { - setOpen(false); - }} - ownFocus - anchorPosition="rightCenter" - button={ - - { - setOpen(!isOpen); - }} - > - - {selectedColumn - ? selectedColumn.label - : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { - defaultMessage: 'Configure dimension', - })} - - - + + { + const column = columns.find( + ({ sourceField }) => sourceField === (field as IndexPatternField).name + ); + + if (!column) { + // TODO: What do we do if we couldn't find a column? + return; } - > - - - ({ - label: col.sourceField, - value: col.operationId, - }))} - selectedOptions={ - selectedColumn - ? [ - { - label: selectedColumn.sourceField, - value: selectedColumn.operationId, - }, - ] - : [] - } - singleSelection={{ asPlainText: true }} - isClearable={false} - onChange={choices => { - const column: IndexPatternColumn = columns.find( - ({ operationId }) => operationId === choices[0].value - )!; - const newColumns: IndexPatternPrivateState['columns'] = { - ...props.state.columns, - [props.columnId]: column, - }; - props.setState({ - ...props.state, - columns: newColumns, - columnOrder: getColumnOrder(newColumns), - }); - }} - /> - - -
- {operations.map(o => ( + changeColumn(column); + }} + > + + + { + setOpen(false); + }} + ownFocus + anchorPosition="rightCenter" + button={ + col.operationType === o)} + data-test-subj="indexPattern-dimensionPopover-button" onClick={() => { - if (!selectedColumn) { - return; - } - - const newColumn: IndexPatternColumn = filteredColumns.find( - col => - col.operationType === o && col.sourceField === selectedColumn.sourceField + setOpen(!isOpen); + }} + > + + {selectedColumn + ? selectedColumn.label + : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { + defaultMessage: 'Configure dimension', + })} + + + + } + > + + + ({ + label: col.sourceField, + value: col.operationId, + }))} + selectedOptions={ + selectedColumn + ? [ + { + label: selectedColumn.sourceField, + value: selectedColumn.operationId, + }, + ] + : [] + } + singleSelection={{ asPlainText: true }} + isClearable={false} + onChange={choices => { + const column: IndexPatternColumn = columns.find( + ({ operationId }) => operationId === choices[0].value )!; - - const newColumns = { + const newColumns: IndexPatternPrivateState['columns'] = { ...props.state.columns, - [props.columnId]: newColumn, + [props.columnId]: column, }; props.setState({ ...props.state, - columnOrder: getColumnOrder(newColumns), columns: newColumns, + columnOrder: getColumnOrder(newColumns), }); }} - > - {operationPanels[o].displayName} - - ))} -
+ /> +
+ +
+ {operations.map(o => ( + col.operationType === o)} + onClick={() => { + if (!selectedColumn) { + return; + } + + const newColumn: IndexPatternColumn = filteredColumns.find( + col => + col.operationType === o && + col.sourceField === selectedColumn.sourceField + )!; + + changeColumn(newColumn); + }} + > + {operationPanels[o].displayName} + + ))} +
+
+
+
+
+ {selectedColumn && ( + + { + const newColumns: IndexPatternPrivateState['columns'] = { + ...props.state.columns, + }; + delete newColumns[props.columnId]; + + props.setState({ + ...props.state, + columns: newColumns, + columnOrder: getColumnOrder(newColumns), + }); + }} + /> -
- - - {selectedColumn && ( - - { - const newColumns: IndexPatternPrivateState['columns'] = { - ...props.state.columns, - }; - delete newColumns[props.columnId]; - - props.setState({ - ...props.state, - columns: newColumns, - columnOrder: getColumnOrder(newColumns), - }); - }} - /> - - )} - + )} + + + ); } diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index 8962d553a0488..00ee692981dde 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -14,6 +14,7 @@ import { IndexPatternDataPanel, } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; +import { createMockedDragDropContext } from './mocks'; jest.mock('./loader'); @@ -157,14 +158,25 @@ describe('IndexPattern Data Source', () => { it('should match snapshot', () => { expect( - shallow( {}} />) + shallow( + {}} + /> + ) ).toMatchSnapshot(); }); it('should call setState when the index pattern is switched', async () => { const setState = jest.fn(); - const wrapper = shallow(); + const wrapper = shallow( + + ); const comboBox = wrapper.find(EuiComboBox); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 940f89ae1d660..04bee936d7ff3 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -18,6 +18,7 @@ import { DimensionPriority, } from '../types'; import { getIndexPatterns } from './loader'; +import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; @@ -85,7 +86,7 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { export function IndexPatternDataPanel(props: DatasourceDataPanelProps) { return ( -
+ Index Pattern Data Source
{props.state.currentIndexPatternId && props.state.indexPatterns[props.state.currentIndexPatternId].fields.map(field => ( -
{field.name}
+ + {field.name} + ))}
-
+ ); } diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/mocks.ts b/x-pack/plugins/lens/public/indexpattern_plugin/mocks.ts new file mode 100644 index 0000000000000..b24d53e0f552f --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/mocks.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. + */ + +import { DragContextState } from '../drag_drop'; + +export function createMockedDragDropContext(): jest.Mocked { + return { + dragging: undefined, + setDragging: jest.fn(), + }; +} diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index bcdeea279ee85..dc7f2e04609ef 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -5,6 +5,7 @@ */ import { Ast } from '@kbn/interpreter/common'; +import { DragContextState } from './drag_drop'; // eslint-disable-next-line export interface EditorFrameOptions {} @@ -80,6 +81,7 @@ export interface DatasourcePublicAPI { export interface DatasourceDataPanelProps { state: T; + dragDropContext: DragContextState; setState: (newState: T) => void; } @@ -88,6 +90,8 @@ export interface DatasourceDimensionPanelProps { // If no columnId is passed, it will render as empty columnId: string; + dragDropContext: DragContextState; + // Visualizations can restrict operations based on their own rules filterOperations: (operation: Operation) => boolean; @@ -133,6 +137,7 @@ export interface KibanaDatatable { } export interface VisualizationProps { + dragDropContext: DragContextState; datasource: DatasourcePublicAPI; state: T; setState: (newState: T) => void; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx index cf5762c58fc84..95c4543f32547 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx @@ -14,6 +14,8 @@ import { Position } from '@elastic/charts'; import { NativeRendererProps } from '../native_renderer'; describe('XYConfigPanel', () => { + const dragDropContext = { dragging: undefined, setDragging: jest.fn() }; + function mockDatasource(): DatasourcePublicAPI { return { duplicateColumn: () => [], @@ -61,6 +63,7 @@ describe('XYConfigPanel', () => { const state = testState(); const component = mount( { const state = testState(); const component = mount( { const testSetTitle = (title: string) => { const setState = jest.fn(); const component = mount( - + ); (testSubj(component, 'lnsXY_title').onChange as Function)({ target: { value: title } }); @@ -146,7 +155,12 @@ describe('XYConfigPanel', () => { const testLegendPosition = (position: Position) => { const setState = jest.fn(); const component = mount( - + ); (testSubj(component, 'lnsXY_legendPosition').onChange as Function)(position); @@ -173,7 +187,12 @@ describe('XYConfigPanel', () => { const testSetTitle = (title: string) => { const setState = jest.fn(); const component = mount( - + ); (testSubj(component, 'lnsXY_xTitle').onChange as Function)({ target: { value: title } }); @@ -198,6 +217,7 @@ describe('XYConfigPanel', () => { const state = testState(); const component = mount( { const state = testState(); const component = mount( { const testSetTitle = (title: string) => { const setState = jest.fn(); const component = mount( - + ); (testSubj(component, 'lnsXY_yTitle').onChange as Function)({ target: { value: title } }); @@ -278,6 +304,7 @@ describe('XYConfigPanel', () => { const state = testState(); const component = mount( { const state = testState(); const component = mount( { const state = testState(); const component = mount( 'zed' }} setState={setState} state={{ ...state, y: { ...state.y, accessors: ['a', 'b', 'c'] } }} @@ -353,6 +382,7 @@ describe('XYConfigPanel', () => { const state = testState(); const component = mount( ) { render={datasource.renderDimensionPanel} nativeProps={{ columnId: state.x.accessor, + dragDropContext: props.dragDropContext, // TODO: Filter out invalid x-dimension operations filterOperations: () => true, }} @@ -253,6 +254,7 @@ export function XYConfigPanel(props: VisualizationProps) { render={datasource.renderDimensionPanel} nativeProps={{ columnId: accessor, + dragDropContext: props.dragDropContext, filterOperations: (op: Operation) => !op.isBucketed && op.dataType === 'number', }} From c83651c84f49fdba43f521c87c04ee3b7ad630b9 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Fri, 14 Jun 2019 09:31:20 -0400 Subject: [PATCH 025/105] [lens] Only allow aggregated dimensions (#38820) * [lens] Only allow aggregated dimensions * [lens] Index pattern suggest on drop * Fully remove value * Revert "[lens] Index pattern suggest on drop" This reverts commit 604c6ed68ca394441ddafa662bdfc5f421de300c. * Fix type errors --- .../dimension_panel.test.tsx | 18 +++----- .../indexpattern_plugin/indexpattern.test.tsx | 39 ++-------------- .../indexpattern_plugin/indexpattern.tsx | 10 +---- .../indexpattern_plugin/operations.test.ts | 44 +++++++------------ .../public/indexpattern_plugin/operations.ts | 22 +++------- .../indexpattern_plugin/to_expression.ts | 16 +------ 6 files changed, 31 insertions(+), 118 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx index 751ac3a9678f9..6a3020dffcee0 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx @@ -55,12 +55,12 @@ describe('IndexPatternDimensionPanel', () => { columns: { col1: { operationId: 'op1', - label: 'Value of timestamp', + label: 'Date Histogram of timestamp', dataType: 'date', - isBucketed: false, + isBucketed: true, // Private - operationType: 'value', + operationType: 'date_histogram', sourceField: 'timestamp', }, }, @@ -169,17 +169,14 @@ describe('IndexPatternDimensionPanel', () => { ); expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-value"]').prop('color') + wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').prop('color') ).toEqual('primary'); expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-value"]').prop('isDisabled') + wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').prop('isDisabled') ).toEqual(false); expect( wrapper.find('[data-test-subj="lns-indexPatternDimension-terms"]').prop('isDisabled') ).toEqual(true); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').prop('isDisabled') - ).toEqual(false); expect( wrapper.find('[data-test-subj="lns-indexPatternDimension-sum"]').prop('isDisabled') ).toEqual(true); @@ -271,11 +268,6 @@ describe('IndexPatternDimensionPanel', () => { ...state.columns, col2: expect.objectContaining({ operationId: firstField.value, - label: 'Value of bytes', - dataType: 'number', - isBucketed: false, - operationType: 'value', - sourceField: 'bytes', }), }, columnOrder: ['col1', 'col2'], diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index 00ee692981dde..f918a16fd8825 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -119,10 +119,10 @@ describe('IndexPattern Data Source', () => { operationId: 'op1', label: 'My Op', dataType: 'string', - isBucketed: false, + isBucketed: true, // Private - operationType: 'value', + operationType: 'terms', sourceField: 'op', }, }, @@ -208,39 +208,6 @@ describe('IndexPattern Data Source', () => { expect(indexPatternDatasource.toExpression(state)).toEqual(null); }); - it('should generate an expression for a values query', async () => { - const queryPersistedState: IndexPatternPersistedState = { - currentIndexPatternId: '1', - columnOrder: ['col1', 'col2'], - columns: { - col1: { - operationId: 'op1', - label: 'My Op', - dataType: 'string', - isBucketed: false, - - // Private - operationType: 'value', - sourceField: 'source', - }, - col2: { - operationId: 'op2', - label: 'My Op 2', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'value', - sourceField: 'bytes', - }, - }, - }; - const state = await indexPatternDatasource.initialize(queryPersistedState); - expect(indexPatternDatasource.toExpression(state)).toMatchInlineSnapshot( - `"esdocs index=\\"my-fake-index-pattern\\" fields=\\"source, bytes\\" sort=\\"source, DESC\\" | lens_rename_columns idMap='{\\"source\\":\\"col1\\",\\"bytes\\":\\"col2\\"}'"` - ); - }); - it('should generate an expression for an aggregated query', async () => { const queryPersistedState: IndexPatternPersistedState = { currentIndexPatternId: '1', @@ -303,7 +270,7 @@ describe('IndexPattern Data Source', () => { id: 'op1', label: 'My Op', dataType: 'string', - isBucketed: false, + isBucketed: true, } as Operation); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 04bee936d7ff3..671b3576c7271 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -22,15 +22,7 @@ import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; -export type OperationType = - | 'value' - | 'terms' - | 'date_histogram' - | 'sum' - | 'avg' - | 'min' - | 'max' - | 'count'; +export type OperationType = 'terms' | 'date_histogram' | 'sum' | 'avg' | 'min' | 'max' | 'count'; export interface IndexPatternColumn { // Public diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts index 9bc64b8b24e38..09ff0ed27832b 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -45,7 +45,7 @@ describe('getOperationTypesForField', () => { aggregatable: true, searchable: true, }) - ).toEqual(expect.arrayContaining(['value', 'terms'])); + ).toEqual(expect.arrayContaining(['terms'])); }); it('should return operations on numbers', () => { @@ -56,7 +56,7 @@ describe('getOperationTypesForField', () => { aggregatable: true, searchable: true, }) - ).toEqual(expect.arrayContaining(['value', 'avg', 'sum', 'min', 'max'])); + ).toEqual(expect.arrayContaining(['avg', 'sum', 'min', 'max'])); }); it('should return operations on dates', () => { @@ -67,7 +67,7 @@ describe('getOperationTypesForField', () => { aggregatable: true, searchable: true, }) - ).toEqual(expect.arrayContaining(['value', 'date_histogram'])); + ).toEqual(expect.arrayContaining(['date_histogram'])); }); it('should return no operations on unknown types', () => { @@ -149,12 +149,12 @@ describe('getOperationTypesForField', () => { columns: { col1: { operationId: 'op1', - label: 'Value of timestamp', + label: 'Date Histogram of timestamp', dataType: 'date', - isBucketed: false, + isBucketed: true, // Private - operationType: 'value', + operationType: 'date_histogram', sourceField: 'timestamp', }, }, @@ -172,10 +172,6 @@ describe('getOperationTypesForField', () => { expect(columns.map(col => [col.sourceField, col.operationType])).toMatchInlineSnapshot(` Array [ - Array [ - "bytes", - "value", - ], Array [ "bytes", "sum", @@ -196,18 +192,10 @@ Array [ "documents", "count", ], - Array [ - "source", - "value", - ], Array [ "source", "terms", ], - Array [ - "timestamp", - "value", - ], Array [ "timestamp", "date_histogram", @@ -228,12 +216,12 @@ describe('getColumnOrder', () => { getColumnOrder({ col1: { operationId: 'op1', - label: 'Value of timestamp', - dataType: 'string', - isBucketed: false, + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, // Private - operationType: 'value', + operationType: 'date_histogram', sourceField: 'timestamp', }, }) @@ -250,8 +238,8 @@ describe('getColumnOrder', () => { isBucketed: true, // Private - operationType: 'value', - sourceField: 'timestamp', + operationType: 'terms', + sourceField: 'category', }, col2: { operationId: 'op2', @@ -260,7 +248,7 @@ describe('getColumnOrder', () => { isBucketed: false, // Private - operationType: 'value', + operationType: 'avg', sourceField: 'bytes', }, col3: { @@ -287,8 +275,8 @@ describe('getColumnOrder', () => { isBucketed: true, // Private - operationType: 'value', - sourceField: 'timestamp', + operationType: 'terms', + sourceField: 'category', suggestedOrder: 2, }, col2: { @@ -298,7 +286,7 @@ describe('getColumnOrder', () => { isBucketed: false, // Private - operationType: 'value', + operationType: 'avg', sourceField: 'bytes', suggestedOrder: 0, }, diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts index 4a0df7a6a977a..6b252eb65fc51 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts @@ -15,7 +15,8 @@ import { } from './indexpattern'; export function getOperations(): OperationType[] { - return ['value', 'terms', 'date_histogram', 'sum', 'avg', 'min', 'max', 'count']; + // Raw value is not listed in the MVP + return ['terms', 'date_histogram', 'sum', 'avg', 'min', 'max', 'count']; } export function getOperationDisplay(): Record< @@ -27,17 +28,6 @@ export function getOperationDisplay(): Record< } > { return { - value: { - type: 'value', - displayName: i18n.translate('xpack.lens.indexPattern.value', { - defaultMessage: 'Value', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.valueOf', { - defaultMessage: 'Value of {name}', - values: { name }, - }), - }, terms: { type: 'terms', displayName: i18n.translate('xpack.lens.indexPattern.terms', { @@ -132,19 +122,17 @@ export function getOperationTypesForField({ switch (type) { case 'date': - return ['value', 'date_histogram']; + return ['date_histogram']; case 'number': - return ['value', 'sum', 'avg', 'min', 'max']; + return ['sum', 'avg', 'min', 'max']; case 'string': - return ['value', 'terms']; + return ['terms']; } return []; } export function getOperationResultType({ type }: IndexPatternField, op: OperationType): DataType { switch (op) { - case 'value': - return type as DataType; case 'avg': case 'min': case 'max': diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts index 142dbb19a78c3..3890eb32a2468 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -13,23 +13,9 @@ export function toExpression(state: IndexPatternPrivateState) { return null; } - const fieldNames = state.columnOrder.map(col => state.columns[col].sourceField); const sortedColumns = state.columnOrder.map(col => state.columns[col]); - const indexName = state.indexPatterns[state.currentIndexPatternId].title; - - if (sortedColumns.every(({ operationType }) => operationType === 'value')) { - const idMap = fieldNames.reduce( - (currentIdMap, fieldName, index) => ({ - ...currentIdMap, - [fieldName]: state.columnOrder[index], - }), - {} as Record - ); - return `esdocs index="${indexName}" fields="${fieldNames.join(', ')}" sort="${ - fieldNames[0] - }, DESC" | lens_rename_columns idMap='${JSON.stringify(idMap)}'`; - } else if (sortedColumns.length) { + if (sortedColumns.length) { const firstMetric = sortedColumns.findIndex(({ isBucketed }) => !isBucketed); const aggs = sortedColumns.map((col, index) => { if (col.operationType === 'date_histogram') { From f73057cf97d296dd55587d02736efd738b65ad8a Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Fri, 14 Jun 2019 14:07:47 -0400 Subject: [PATCH 026/105] [lens] Suggest on drop (#38848) * [lens] Index pattern suggest on drop * Add test for suggestion without date field --- .../editor_frame/workspace_panel.test.tsx | 132 +++++++++++- .../editor_frame/workspace_panel.tsx | 5 +- .../lens/public/editor_frame_plugin/mocks.tsx | 2 +- .../indexpattern_plugin/indexpattern.test.tsx | 200 ++++++++++++++++++ .../indexpattern_plugin/indexpattern.tsx | 101 ++++++++- .../public/indexpattern_plugin/operations.ts | 34 +-- x-pack/plugins/lens/public/types.ts | 2 +- 7 files changed, 457 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx index cfb51a0adce1d..9efa5da6a6cf1 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx @@ -21,7 +21,7 @@ import { ReactWrapper } from 'enzyme'; const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); describe('workspace_panel', () => { - let mockVisualization: Visualization; + let mockVisualization: jest.Mocked; let mockDatasource: DatasourceMock; let expressionRendererMock: jest.Mock; @@ -274,4 +274,134 @@ Object { expect(instance.find(expressionRendererMock).length).toBe(1); }); }); + + describe('suggestions from dropping in workspace panel', () => { + let mockDispatch: jest.Mock; + + beforeEach(() => { + mockDispatch = jest.fn(); + instance = mount( + + ); + }); + + it('should immediately transition if exactly one suggestion is returned', () => { + const expectedTable = { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [], + }; + mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ + { + state: {}, + table: expectedTable, + }, + ]); + mockVisualization.getSuggestions.mockReturnValueOnce([ + { + score: 0.5, + title: 'my title', + state: {}, + datasourceSuggestionId: 0, + }, + ]); + + instance.childAt(0).prop('onDrop')({ + name: '@timestamp', + type: 'date', + searchable: false, + aggregatable: false, + }); + + expect(mockDatasource.getDatasourceSuggestionsForField).toHaveBeenCalledTimes(1); + expect(mockVisualization.getSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + tables: [expectedTable], + }) + ); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'vis', + initialState: {}, + datasourceState: {}, + }); + }); + + it('should immediately transition to the first suggestion if there are multiple', () => { + mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ + { + state: {}, + table: { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [], + }, + }, + { + state: {}, + table: { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [], + }, + }, + ]); + mockVisualization.getSuggestions.mockReturnValueOnce([ + { + score: 0.8, + title: 'first suggestion', + state: { + isFirst: true, + }, + datasourceSuggestionId: 1, + }, + { + score: 0.5, + title: 'second suggestion', + state: {}, + datasourceSuggestionId: 0, + }, + ]); + + instance.childAt(0).prop('onDrop')({ + name: '@timestamp', + type: 'date', + searchable: false, + aggregatable: false, + }); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'vis', + initialState: { + isFirst: true, + }, + datasourceState: {}, + }); + }); + + it("should do nothing when the visualization can't use the suggestions", () => { + instance.childAt(0).prop('onDrop')({ + name: '@timestamp', + type: 'date', + searchable: false, + aggregatable: false, + }); + + expect(mockDatasource.getDatasourceSuggestionsForField).toHaveBeenCalledTimes(1); + expect(mockVisualization.getSuggestions).toHaveBeenCalledTimes(1); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx index 92ba491ab0576..6d1bb17b25624 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -37,9 +37,10 @@ export function WorkspacePanel({ dispatch, ExpressionRenderer: ExpressionRendererComponent, }: WorkspacePanelProps) { - function onDrop() { + function onDrop(item: unknown) { const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsForField( - datasourceState + datasourceState, + item ); const suggestions = getSuggestions( diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx index 5d2d9e5bc5309..d6e9e2f530fc0 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -35,7 +35,7 @@ export function createMockDatasource(): DatasourceMock { }; return { - getDatasourceSuggestionsForField: jest.fn(_state => []), + getDatasourceSuggestionsForField: jest.fn((_state, item) => []), getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []), getPersistableState: jest.fn(), getPublicAPI: jest.fn((_state, _setState) => publicAPIMock), diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index f918a16fd8825..affddb88277a5 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -246,6 +246,206 @@ describe('IndexPattern Data Source', () => { }); }); + describe('#getDatasourceSuggestionsForField', () => { + describe('with no previous selections', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + columnOrder: [], + columns: {}, + }); + }); + + it('should apply a bucketed aggregation for a string field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + }), + col2: expect.objectContaining({ + operationType: 'count', + sourceField: 'documents', + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + }); + }); + + it('should apply a bucketed aggregation for a date field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + col2: expect.objectContaining({ + operationType: 'count', + sourceField: 'documents', + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + }); + }); + + it('should select a metric for a number field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + sourceField: 'timestamp', + operationType: 'date_histogram', + }), + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'sum', + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + }); + }); + + it('should not make any suggestions for a number without a time field', async () => { + const state: IndexPatternPrivateState = { + currentIndexPatternId: '1', + columnOrder: [], + columns: {}, + indexPatterns: { + 1: { + id: '1', + title: 'no timefield', + fields: [ + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + ], + }, + }, + }; + + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(state, { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }); + + expect(suggestions).toHaveLength(0); + }); + }); + + describe('with a prior column', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + initialState = await indexPatternDatasource.initialize(persistedState); + }); + + it('should not suggest for string', () => { + expect( + indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }) + ).toHaveLength(0); + }); + + it('should not suggest for date', () => { + expect( + indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }) + ).toHaveLength(0); + }); + + it('should not suggest for number', () => { + expect( + indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }) + ).toHaveLength(0); + }); + }); + }); + describe('#getPublicAPI', () => { let publicAPI: DatasourcePublicAPI; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 671b3576c7271..94660e1335bea 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -7,6 +7,7 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; +import { i18n } from '@kbn/i18n'; import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; @@ -16,11 +17,13 @@ import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, DimensionPriority, + DatasourceSuggestion, } from '../types'; import { getIndexPatterns } from './loader'; import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; +import { makeOperation, getOperationTypesForField } from './operations'; export type OperationType = 'terms' | 'date_histogram' | 'sum' | 'avg' | 'min' | 'max' | 'count'; @@ -235,11 +238,105 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To }; }, - getDatasourceSuggestionsForField() { + getDatasourceSuggestionsForField( + state, + item + ): Array> { + const field: IndexPatternField = item as IndexPatternField; + + if (Object.keys(state.columns).length) { + // Not sure how to suggest multiple fields yet + return []; + } + + const operations = getOperationTypesForField(field); + const hasBucket = operations.find(op => op === 'date_histogram' || op === 'terms'); + + if (hasBucket) { + const column = makeOperation(0, hasBucket, field); + + const countColumn: IndexPatternColumn = { + operationId: 'count', + label: i18n.translate('xpack.lens.indexPatternOperations.countOfDocuments', { + defaultMessage: 'Count of Documents', + }), + dataType: 'number', + isBucketed: false, + + operationType: 'count', + sourceField: 'documents', + }; + + const suggestion: DatasourceSuggestion = { + state: { + ...state, + columns: { + col1: column, + col2: countColumn, + }, + columnOrder: ['col1', 'col2'], + }, + + table: { + columns: [ + { + columnId: 'col1', + operation: columnToOperation(column), + }, + { + columnId: 'col2', + operation: columnToOperation(countColumn), + }, + ], + isMultiRow: true, + datasourceSuggestionId: 0, + }, + }; + + return [suggestion]; + } else if (state.indexPatterns[state.currentIndexPatternId].timeFieldName) { + const currentIndexPattern = state.indexPatterns[state.currentIndexPatternId]; + const dateField = currentIndexPattern.fields.find( + f => f.name === currentIndexPattern.timeFieldName + )!; + + const column = makeOperation(0, operations[0], field); + + const dateColumn = makeOperation(1, 'date_histogram', dateField); + + const suggestion: DatasourceSuggestion = { + state: { + ...state, + columns: { + col1: dateColumn, + col2: column, + }, + columnOrder: ['col1', 'col2'], + }, + + table: { + columns: [ + { + columnId: 'col1', + operation: columnToOperation(column), + }, + { + columnId: 'col2', + operation: columnToOperation(dateColumn), + }, + ], + isMultiRow: true, + datasourceSuggestionId: 0, + }, + }; + + return [suggestion]; + } + return []; }, - getDatasourceSuggestionsFromCurrentState() { + getDatasourceSuggestionsFromCurrentState(state) { return []; }, }; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts index 6b252eb65fc51..2a2e6a82722c1 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts @@ -146,28 +146,38 @@ export function getOperationResultType({ type }: IndexPatternField, op: Operatio } } +export function makeOperation( + index: number, + op: OperationType, + field: IndexPatternField, + suggestedOrder?: DimensionPriority +): IndexPatternColumn { + const operationPanels = getOperationDisplay(); + return { + operationId: `${index}${op}`, + label: operationPanels[op].ofName(field.name), + dataType: getOperationResultType(field, op), + isBucketed: op === 'terms' || op === 'date_histogram', + + operationType: op, + sourceField: field.name, + suggestedOrder, + }; +} + export function getPotentialColumns( state: IndexPatternPrivateState, suggestedOrder?: DimensionPriority ): IndexPatternColumn[] { const fields = state.indexPatterns[state.currentIndexPatternId].fields; - const operationPanels = getOperationDisplay(); - const columns: IndexPatternColumn[] = fields .map((field, index) => { const validOperations = getOperationTypesForField(field); - return validOperations.map(op => ({ - operationId: `${index}${op}`, - label: operationPanels[op].ofName(field.name), - dataType: getOperationResultType(field, op), - isBucketed: op === 'terms' || op === 'date_histogram', - - operationType: op, - sourceField: field.name, - suggestedOrder, - })); + return validOperations.map(op => { + return makeOperation(index, op, field, suggestedOrder); + }); }) .reduce((prev, current) => prev.concat(current)); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index dc7f2e04609ef..367d1bdd99c79 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -57,7 +57,7 @@ export interface Datasource { toExpression: (state: T) => Ast | string | null; - getDatasourceSuggestionsForField: (state: T) => Array>; + getDatasourceSuggestionsForField: (state: T, field: unknown) => Array>; getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; getPublicAPI: (state: T, setState: (newState: T) => void) => DatasourcePublicAPI; From acad4e7df6b8ac6975a782cee72b97c33f811fc1 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 17 Jun 2019 11:11:27 +0200 Subject: [PATCH 027/105] fix merge --- .../lens/public/editor_frame_plugin/plugin.test.tsx | 2 +- x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx index 9875c7a8396d2..59c3c85f9dd28 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx @@ -17,7 +17,7 @@ import { const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); // mock away actual data plugin to prevent all of it being loaded -jest.mock('../../../../../src/legacy/core_plugins/data/public', () => {}); +jest.mock('../../../../../src/legacy/core_plugins/data/public/setup', () => {}); describe('editor_frame plugin', () => { let pluginInstance: EditorFramePlugin; diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx index 0dc6636b255b2..5fd1e169be42d 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -8,11 +8,8 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { CoreSetup } from 'src/core/public'; -import { - DataSetup, - data, - ExpressionRenderer, -} from '../../../../../src/legacy/core_plugins/data/public'; +import { DataSetup, ExpressionRenderer } from '../../../../../src/legacy/core_plugins/data/public'; +import { data } from '../../../../../src/legacy/core_plugins/data/public/setup'; import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; import { EditorFrame } from './editor_frame'; From f780d02675fc1ec423e353a03aefed3861dbf458 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 18 Jun 2019 11:06:31 +0200 Subject: [PATCH 028/105] [Lens] Parameter configurations and new dimension config flow (#38863) --- .../editor_frame/frame_layout.tsx | 6 +- .../editor_frame/suggestion_helpers.test.ts | 4 +- .../editor_frame/workspace_panel.test.tsx | 22 +- .../__mocks__/operations.ts | 3 +- .../__mocks__/state_helpers.ts | 18 + .../indexpattern_plugin/dimension_panel.tsx | 231 ------------- .../dimension_panel.test.tsx | 320 ++++++++++++++---- .../dimension_panel/dimension_panel.tsx | 97 ++++++ .../dimension_panel/field_select.tsx | 139 ++++++++ .../dimension_panel/index.ts | 7 + .../dimension_panel/settings.tsx | 111 ++++++ .../indexpattern_plugin/indexpattern.test.tsx | 14 +- .../indexpattern_plugin/indexpattern.tsx | 71 ++-- .../lens/public/indexpattern_plugin/loader.ts | 2 +- .../operation_definitions/count.tsx | 40 +++ .../date_histogram.test.tsx | 170 ++++++++++ .../operation_definitions/date_histogram.tsx | 158 +++++++++ .../operation_definitions/metrics.tsx | 110 ++++++ .../operation_definitions/terms.test.tsx | 154 +++++++++ .../operation_definitions/terms.tsx | 165 +++++++++ .../indexpattern_plugin/operations.test.ts | 126 +------ .../public/indexpattern_plugin/operations.ts | 267 +++++---------- .../indexpattern_plugin/state_helpers.test.ts | 214 ++++++++++++ .../indexpattern_plugin/state_helpers.ts | 105 ++++++ .../indexpattern_plugin/to_expression.ts | 70 +--- .../xy_suggestions.test.ts | 6 +- 26 files changed, 1948 insertions(+), 682 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/state_helpers.ts delete mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx rename x-pack/plugins/lens/public/indexpattern_plugin/{ => dimension_panel}/dimension_panel.test.tsx (59%) create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/index.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.ts diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx index 12e7e49dc2d4c..cae1b6b90ccd9 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx @@ -20,9 +20,9 @@ export function FrameLayout(props: FrameLayoutProps) { {/* TODO style this and add workspace prop and loading flags */} - {props.dataPanel} - {props.workspacePanel} - + {props.dataPanel} + {props.workspacePanel} + {props.configPanel} {props.suggestionsPanel} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts index b048b6e840484..850cdfc2b3c0f 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts @@ -30,7 +30,7 @@ describe('suggestion helpers', () => { 'vis1', {} ); - expect(suggestions.length).toBe(1); + expect(suggestions).toHaveLength(1); expect(suggestions[0].state).toBe(suggestedState); }); @@ -57,7 +57,7 @@ describe('suggestion helpers', () => { 'vis1', {} ); - expect(suggestions.length).toBe(3); + expect(suggestions).toHaveLength(3); }); it('should rank the visualizations by score', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx index 9efa5da6a6cf1..d294e9741f373 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx @@ -56,8 +56,8 @@ describe('workspace_panel', () => { /> ); - expect(instance.find('[data-test-subj="empty-workspace"]').length).toBe(1); - expect(instance.find(expressionRendererMock).length).toBe(0); + 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', () => { @@ -76,8 +76,8 @@ describe('workspace_panel', () => { /> ); - expect(instance.find('[data-test-subj="empty-workspace"]').length).toBe(1); - expect(instance.find(expressionRendererMock).length).toBe(0); + 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', () => { @@ -96,8 +96,8 @@ describe('workspace_panel', () => { /> ); - expect(instance.find('[data-test-subj="empty-workspace"]').length).toBe(1); - expect(instance.find(expressionRendererMock).length).toBe(0); + 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', () => { @@ -158,8 +158,8 @@ Object { /> ); - expect(instance.find('[data-test-subj="expression-failure"]').length).toBe(1); - expect(instance.find(expressionRendererMock).length).toBe(0); + expect(instance.find('[data-test-subj="expression-failure"]')).toHaveLength(1); + expect(instance.find(expressionRendererMock)).toHaveLength(0); }); it('should show an error message if the expression fails to render', async () => { @@ -191,8 +191,8 @@ Object { instance.update(); - expect(instance.find('[data-test-subj="expression-failure"]').length).toBe(1); - expect(instance.find(expressionRendererMock).length).toBe(0); + expect(instance.find('[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 () => { @@ -271,7 +271,7 @@ Object { expect(expressionRendererMock).toHaveBeenCalledTimes(2); - expect(instance.find(expressionRendererMock).length).toBe(1); + expect(instance.find(expressionRendererMock)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts index 0d7fcdecbc340..9c07b07ae27fa 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts @@ -7,7 +7,7 @@ const actual = jest.requireActual('../operations'); jest.spyOn(actual, 'getPotentialColumns'); -jest.spyOn(actual, 'getColumnOrder'); +jest.spyOn(actual.operationDefinitionMap.date_histogram, 'inlineOptions'); export const { getPotentialColumns, @@ -16,4 +16,5 @@ export const { getOperationDisplay, getOperationTypesForField, getOperationResultType, + operationDefinitionMap, } = actual; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/state_helpers.ts b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/state_helpers.ts new file mode 100644 index 0000000000000..1df52a3fd80ea --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/state_helpers.ts @@ -0,0 +1,18 @@ +/* + * 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'); + +export const { + getColumnOrder, + changeColumn, + deleteColumn, + updateColumnParam, + sortByField, + hasField, +} = actual; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx deleted file mode 100644 index 43292d806f73a..0000000000000 --- a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx +++ /dev/null @@ -1,231 +0,0 @@ -/* - * 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 { - EuiButtonIcon, - EuiComboBox, - EuiPopover, - EuiButtonEmpty, - EuiFlexItem, - EuiFlexGroup, -} from '@elastic/eui'; -import { DatasourceDimensionPanelProps } from '../types'; -import { - IndexPatternColumn, - IndexPatternPrivateState, - columnToOperation, - IndexPatternField, -} from './indexpattern'; - -import { - getOperationDisplay, - getOperations, - getPotentialColumns, - getColumnOrder, -} from './operations'; -import { DragContextState, DragDrop, ChildDragDropProvider } from '../drag_drop'; - -export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { - state: IndexPatternPrivateState; - setState: (newState: IndexPatternPrivateState) => void; - dragDropContext: DragContextState; -}; - -export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { - const [isOpen, setOpen] = useState(false); - - const operations = getOperations(); - const operationPanels = getOperationDisplay(); - - const columns = getPotentialColumns(props.state, props.suggestedPriority); - - const filteredColumns = columns.filter(col => { - return props.filterOperations(columnToOperation(col)); - }); - - const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; - - function canHandleDrop() { - const { dragging } = props.dragDropContext; - const field = dragging as IndexPatternField; - - return ( - !!field && - !!field.type && - filteredColumns.some(({ sourceField }) => sourceField === (field as IndexPatternField).name) - ); - } - - function changeColumn(column: IndexPatternColumn) { - const newColumns: IndexPatternPrivateState['columns'] = { - ...props.state.columns, - [props.columnId]: column, - }; - - props.setState({ - ...props.state, - columnOrder: getColumnOrder(newColumns), - columns: newColumns, - }); - } - - const uniqueColumnsByField = _.uniq(filteredColumns, col => col.sourceField); - - const functionsFromField = selectedColumn - ? filteredColumns.filter(col => { - return col.sourceField === selectedColumn.sourceField; - }) - : filteredColumns; - - return ( - - { - const column = columns.find( - ({ sourceField }) => sourceField === (field as IndexPatternField).name - ); - - if (!column) { - // TODO: What do we do if we couldn't find a column? - return; - } - - changeColumn(column); - }} - > - - - { - setOpen(false); - }} - ownFocus - anchorPosition="rightCenter" - button={ - - { - setOpen(!isOpen); - }} - > - - {selectedColumn - ? selectedColumn.label - : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { - defaultMessage: 'Configure dimension', - })} - - - - } - > - - - ({ - label: col.sourceField, - value: col.operationId, - }))} - selectedOptions={ - selectedColumn - ? [ - { - label: selectedColumn.sourceField, - value: selectedColumn.operationId, - }, - ] - : [] - } - singleSelection={{ asPlainText: true }} - isClearable={false} - onChange={choices => { - const column: IndexPatternColumn = columns.find( - ({ operationId }) => operationId === choices[0].value - )!; - const newColumns: IndexPatternPrivateState['columns'] = { - ...props.state.columns, - [props.columnId]: column, - }; - - props.setState({ - ...props.state, - columns: newColumns, - columnOrder: getColumnOrder(newColumns), - }); - }} - /> - - -
- {operations.map(o => ( - col.operationType === o)} - onClick={() => { - if (!selectedColumn) { - return; - } - - const newColumn: IndexPatternColumn = filteredColumns.find( - col => - col.operationType === o && - col.sourceField === selectedColumn.sourceField - )!; - - changeColumn(newColumn); - }} - > - {operationPanels[o].displayName} - - ))} -
-
-
-
-
- {selectedColumn && ( - - { - const newColumns: IndexPatternPrivateState['columns'] = { - ...props.state.columns, - }; - delete newColumns[props.columnId]; - - props.setState({ - ...props.state, - columns: newColumns, - columnOrder: getColumnOrder(newColumns), - }); - }} - /> - - )} -
-
-
- ); -} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx similarity index 59% rename from x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx rename to x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx index 6a3020dffcee0..863bd005a5a5c 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx @@ -6,14 +6,16 @@ import { mount, shallow } from 'enzyme'; import React from 'react'; -import { EuiComboBox } from '@elastic/eui'; -import { IndexPatternPrivateState } from './indexpattern'; -import { getColumnOrder, getPotentialColumns } from './operations'; +import { EuiComboBox, EuiContextMenuItem } from '@elastic/eui'; +import { IndexPatternPrivateState } from '../indexpattern'; +import { changeColumn } from '../state_helpers'; +import { getPotentialColumns, operationDefinitionMap } from '../operations'; import { IndexPatternDimensionPanel } from './dimension_panel'; -import { DragContextState, DropHandler } from '../drag_drop'; -import { createMockedDragDropContext } from './mocks'; +import { DropHandler, DragContextState } from '../../drag_drop'; +import { createMockedDragDropContext } from '../mocks'; -jest.mock('./operations'); +jest.mock('../state_helpers'); +jest.mock('../operations'); const expectedIndexPatterns = { 1: { @@ -61,6 +63,9 @@ describe('IndexPatternDimensionPanel', () => { // Private operationType: 'date_histogram', + params: { + interval: '1d', + }, sourceField: 'timestamp', }, }, @@ -71,7 +76,7 @@ describe('IndexPatternDimensionPanel', () => { jest.clearAllMocks(); }); - it('should display a call to action in the popover button', () => { + it('should display a configure button if dimension has no column yet', () => { const wrapper = mount( { ); expect( wrapper - .find('[data-test-subj="indexPattern-dimensionPopover-button"]') + .find('[data-test-subj="indexPattern-configure-dimension"]') .first() .text() ).toEqual('Configure dimension'); @@ -120,8 +125,27 @@ describe('IndexPatternDimensionPanel', () => { expect(filterOperations).toBeCalled(); }); + it('should show field select combo box on click', () => { + const wrapper = mount( + {}} + columnId={'col2'} + filterOperations={() => true} + /> + ); + + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); + + expect(wrapper.find(EuiComboBox)).toHaveLength(1); + }); + it('should not show any choices if the filter returns false', () => { - const wrapper = shallow( + const wrapper = mount( { /> ); - expect(wrapper.find(EuiComboBox)!.prop('options')!.length).toEqual(0); + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); + + expect(wrapper.find(EuiComboBox)!.prop('options')!).toHaveLength(0); + }); + + it('should render the inline options directly', () => { + mount( + {}} + columnId={'col1'} + filterOperations={() => false} + /> + ); + + expect(operationDefinitionMap.date_histogram.inlineOptions as jest.Mock).toHaveBeenCalledTimes( + 1 + ); + }); + + it('should not render the settings button if there are no settings or options', () => { + const wrapper = mount( + {}} + columnId={'col1'} + filterOperations={() => false} + /> + ); + + expect(wrapper.find('[data-test-subj="indexPattern-dimensionPopover-button"]')).toHaveLength(0); }); - it('should list all field names in sorted order', () => { - const wrapper = shallow( + it('should render the settings button if there are settings', () => { + const wrapper = mount( + {}} + columnId={'col1'} + filterOperations={() => false} + /> + ); + + expect( + wrapper.find('EuiButtonIcon[data-test-subj="indexPattern-dimensionPopover-button"]').length + ).toBe(1); + }); + + it('should list all field names and document as a whole in sorted order', () => { + const wrapper = mount( { /> ); + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); + const options = wrapper.find(EuiComboBox).prop('options'); - expect(options!.map(({ label }) => label)).toEqual([ + expect(options![0].label).toEqual('Document'); + + expect(options![1].options!.map(({ label }) => label)).toEqual([ 'bytes', - 'documents', 'source', 'timestamp', ]); }); - it("should disable functions that won't work with the current column", () => { + it('should show all functions that work with the current column', () => { const setState = jest.fn(); - const wrapper = shallow( + const wrapper = mount( true} /> ); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').prop('color') - ).toEqual('primary'); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').prop('isDisabled') - ).toEqual(false); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-terms"]').prop('isDisabled') - ).toEqual(true); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-sum"]').prop('isDisabled') - ).toEqual(true); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-avg"]').prop('isDisabled') - ).toEqual(true); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-count"]').prop('isDisabled') - ).toEqual(true); + wrapper + .find('[data-test-subj="indexPattern-dimensionPopover-button"]') + .first() + .simulate('click'); + + expect(wrapper.find(EuiContextMenuItem).map(instance => instance.text())).toEqual([ + 'Minimum', + 'Maximum', + 'Average', + 'Sum', + ]); }); - it('should update the datasource state on selection of a value operation', () => { + it('should update the datasource state on selection of an operation', () => { const setState = jest.fn(); - const wrapper = shallow( + const wrapper = mount( true} suggestedPriority={1} /> ); - const comboBox = wrapper.find(EuiComboBox)!; - const firstOption = comboBox.prop('options')![0]; + wrapper + .find('[data-test-subj="indexPattern-dimensionPopover-button"]') + .first() + .simulate('click'); - comboBox.prop('onChange')!([firstOption]); + wrapper + .find('[data-test-subj="lns-indexPatternDimension-min"]') + .first() + .simulate('click'); expect(setState).toHaveBeenCalledWith({ ...state, columns: { ...state.columns, - col2: expect.objectContaining({ - sourceField: firstOption.label, + col1: expect.objectContaining({ + operationType: 'min', + sourceField: 'bytes', // Other parts of this don't matter for this test }), }, - columnOrder: ['col1', 'col2'], }); }); - it('should always request the new sort order when changing the function', () => { + it('should update the datasource state on selection of a field', () => { const setState = jest.fn(); - const wrapper = shallow( + const wrapper = mount( { /> ); - wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').simulate('click'); + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); - expect(getColumnOrder).toHaveBeenCalledWith({ - col1: expect.objectContaining({ - sourceField: 'timestamp', - operationType: 'date_histogram', - }), + const comboBox = wrapper.find(EuiComboBox)!; + const option = comboBox.prop('options')![1].options![1]; + + comboBox.prop('onChange')!([option]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + // Other parts of this don't matter for this test + }), + }, }); }); - it('should update the datasource state when the user makes a selection', () => { + it('should add a column on selection of a field', () => { const setState = jest.fn(); - const wrapper = shallow( + const wrapper = mount( op.dataType === 'number'} + filterOperations={() => true} + suggestedPriority={1} /> ); + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); + const comboBox = wrapper.find(EuiComboBox)!; - const firstField = comboBox.prop('options')![0]; + const option = comboBox.prop('options')![1].options![0]; - comboBox.prop('onChange')!([firstField]); + comboBox.prop('onChange')!([option]); expect(setState).toHaveBeenCalledWith({ ...state, columns: { ...state.columns, col2: expect.objectContaining({ - operationId: firstField.value, + 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 setState = jest.fn(); + + const wrapper = mount( + true} + suggestedPriority={1} + /> + ); + + wrapper + .find('[data-test-subj="indexPattern-dimensionPopover-button"]') + .first() + .simulate('click'); + + wrapper + .find('[data-test-subj="lns-indexPatternDimension-min"]') + .first() + .simulate('click'); + + expect(changeColumn).toHaveBeenCalledWith( + expect.anything(), + 'col1', + expect.objectContaining({ + sourceField: 'bytes', + operationType: 'min', + }) + ); + }); + it('should clear the dimension with the clear button', () => { const setState = jest.fn(); - const wrapper = shallow( + const wrapper = mount( { /> ); - const clearButton = wrapper.find('[data-test-subj="indexPattern-dimensionPopover-remove"]'); + const clearButton = wrapper.find( + 'EuiButtonIcon[data-test-subj="indexPattern-dimensionPopover-remove"]' + ); clearButton.simulate('click'); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx new file mode 100644 index 0000000000000..bd318be148e17 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx @@ -0,0 +1,97 @@ +/* + * 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 { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { DatasourceDimensionPanelProps } from '../../types'; +import { + IndexPatternColumn, + IndexPatternPrivateState, + columnToOperation, + IndexPatternField, +} from '../indexpattern'; + +import { getPotentialColumns, operationDefinitionMap } from '../operations'; +import { FieldSelect } from './field_select'; +import { Settings } from './settings'; +import { DragContextState, ChildDragDropProvider, DragDrop } from '../../drag_drop'; +import { changeColumn, hasField } from '../state_helpers'; + +export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { + state: IndexPatternPrivateState; + setState: (newState: IndexPatternPrivateState) => void; + dragDropContext: DragContextState; +}; + +export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { + const columns = getPotentialColumns(props.state, props.suggestedPriority); + + const filteredColumns = columns.filter(col => { + return props.filterOperations(columnToOperation(col)); + }); + + const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; + + const ParamEditor = + selectedColumn && operationDefinitionMap[selectedColumn.operationType].inlineOptions; + + function findColumnByField(field: IndexPatternField) { + return filteredColumns.find(col => hasField(col) && col.sourceField === field.name); + } + + function canHandleDrop() { + const { dragging } = props.dragDropContext; + const field = dragging as IndexPatternField; + + return !!field && !!field.type && !!findColumnByField(field as IndexPatternField); + } + + return ( + + { + const column = findColumnByField(field as IndexPatternField); + + if (!column) { + // TODO: What do we do if we couldn't find a column? + return; + } + + props.setState(changeColumn(props.state, props.columnId, column)); + }} + > + + + + + + + + {ParamEditor && ( + + + + )} + + + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx new file mode 100644 index 0000000000000..b14f9503dadf3 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx @@ -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 _ from 'lodash'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox, EuiButtonEmpty, EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; +import { IndexPatternColumn, FieldBasedIndexPatternColumn } from '../indexpattern'; +import { IndexPatternDimensionPanelProps } from './dimension_panel'; +import { changeColumn, deleteColumn, hasField, sortByField } from '../state_helpers'; + +export interface FieldSelectProps extends IndexPatternDimensionPanelProps { + selectedColumn: IndexPatternColumn; + filteredColumns: IndexPatternColumn[]; +} + +export function FieldSelect({ + selectedColumn, + filteredColumns, + state, + columnId, + setState, +}: FieldSelectProps) { + const [isFieldSelectOpen, setFieldSelectOpen] = useState(false); + const fieldColumns = filteredColumns.filter(hasField) as FieldBasedIndexPatternColumn[]; + + const uniqueColumnsByField = sortByField( + _.uniq( + fieldColumns + .filter(col => selectedColumn && col.operationType === selectedColumn.operationType) + .concat(fieldColumns), + col => col.sourceField + ) + ); + + const fieldOptions = []; + const fieldLessColumn = filteredColumns.find(column => !hasField(column)); + if (fieldLessColumn) { + fieldOptions.push({ + label: i18n.translate('xpack.lens.indexPattern.documentField', { + defaultMessage: 'Document', + }), + value: fieldLessColumn.operationId, + }); + } + + if (uniqueColumnsByField.length > 0) { + fieldOptions.push({ + label: i18n.translate('xpack.lens.indexPattern.individualFieldsLabel', { + defaultMessage: 'Individual fields', + }), + options: uniqueColumnsByField.map(col => ({ + label: col.sourceField, + value: col.operationId, + })), + }); + } + + return ( + <> + + {!isFieldSelectOpen ? ( + setFieldSelectOpen(true)} + > + {selectedColumn + ? selectedColumn.label + : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { + defaultMessage: 'Configure dimension', + })} + + ) : ( + { + if (el) { + el.focus(); + } + }} + onBlur={() => { + setFieldSelectOpen(false); + }} + data-test-subj="indexPattern-dimension-field" + placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', { + defaultMessage: 'Field', + })} + options={fieldOptions} + selectedOptions={ + selectedColumn && hasField(selectedColumn) + ? [ + { + label: selectedColumn.sourceField, + value: selectedColumn.operationId, + }, + ] + : [] + } + singleSelection={{ asPlainText: true }} + isClearable={true} + onChange={choices => { + setFieldSelectOpen(false); + + if (choices.length === 0) { + setState(deleteColumn(state, columnId)); + return; + } + + const column: IndexPatternColumn = filteredColumns.find( + ({ operationId }) => operationId === choices[0].value + )!; + + setState(changeColumn(state, columnId, column)); + }} + /> + )} + + {selectedColumn && ( + + { + setState(deleteColumn(state, columnId)); + }} + /> + + )} + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/index.ts b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/index.ts new file mode 100644 index 0000000000000..88e5588ce0e01 --- /dev/null +++ b/x-pack/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/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx new file mode 100644 index 0000000000000..b9f0cf771a34a --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx @@ -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 _ from 'lodash'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiPopover, + EuiButtonIcon, + EuiFlexItem, + EuiContextMenuItem, + EuiContextMenuPanel, +} from '@elastic/eui'; +import { IndexPatternColumn } from '../indexpattern'; +import { IndexPatternDimensionPanelProps } from './dimension_panel'; +import { operationDefinitionMap, getOperations, getOperationDisplay } from '../operations'; +import { changeColumn, hasField } from '../state_helpers'; + +export interface SettingsProps extends IndexPatternDimensionPanelProps { + selectedColumn: IndexPatternColumn; + filteredColumns: IndexPatternColumn[]; +} + +export function Settings({ + selectedColumn, + filteredColumns, + state, + columnId, + setState, +}: SettingsProps) { + const [isSettingsOpen, setSettingsOpen] = useState(false); + const contextOptionBuilder = + selectedColumn && operationDefinitionMap[selectedColumn.operationType].contextMenu; + const contextOptions = contextOptionBuilder + ? contextOptionBuilder({ + state, + setState, + columnId, + }) + : []; + const operations = getOperations(); + const operationPanels = getOperationDisplay(); + const functionsFromField = selectedColumn + ? filteredColumns.filter(col => { + return ( + (!hasField(selectedColumn) && !hasField(col)) || + (hasField(selectedColumn) && + hasField(col) && + col.sourceField === selectedColumn.sourceField) + ); + }) + : filteredColumns; + + const operationMenuItems = operations + .filter(o => selectedColumn && functionsFromField.some(col => col.operationType === o)) + .map(o => ( + { + const newColumn: IndexPatternColumn = filteredColumns.find( + col => + col.operationType === o && + (!hasField(col) || + !hasField(selectedColumn) || + col.sourceField === selectedColumn.sourceField) + )!; + + setState(changeColumn(state, columnId, newColumn)); + }} + > + {operationPanels[o].displayName} + + )); + + return selectedColumn && (operationMenuItems.length > 1 || contextOptions.length > 0) ? ( + + { + setSettingsOpen(false); + }} + ownFocus + anchorPosition="leftCenter" + panelPaddingSize="none" + button={ + + { + setSettingsOpen(!isSettingsOpen); + }} + iconType="gear" + aria-label={i18n.translate('xpack.lens.indexPattern.settingsLabel', { + defaultMessage: 'Settings', + })} + /> + + } + > + {operationMenuItems.concat(contextOptions)} + + + ) : null; +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index affddb88277a5..cb2c610aff548 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -124,6 +124,10 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'terms', sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + }, }, }, }; @@ -221,7 +225,6 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'count', - sourceField: 'document', }, col2: { operationId: 'op2', @@ -232,6 +235,9 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'date_histogram', sourceField: 'timestamp', + params: { + interval: '1d', + }, }, }, }; @@ -241,7 +247,7 @@ describe('IndexPattern Data Source', () => { index=\\"1\\" metricsAtAllLevels=\\"false\\" partialRows=\\"false\\" - aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1h\\",\\"drop_partials\\":false,\\"min_doc_count\\":1,\\"extended_bounds\\":{}}}]' | lens_rename_columns idMap='{\\"col-0-col1\\":\\"col1\\",\\"col-1-col2\\":\\"col2\\"}'" + aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"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\\"}'" `); }); }); @@ -277,7 +283,6 @@ describe('IndexPattern Data Source', () => { }), col2: expect.objectContaining({ operationType: 'count', - sourceField: 'documents', }), }, }) @@ -315,7 +320,6 @@ describe('IndexPattern Data Source', () => { }), col2: expect.objectContaining({ operationType: 'count', - sourceField: 'documents', }), }, }) @@ -353,7 +357,7 @@ describe('IndexPattern Data Source', () => { }), col2: expect.objectContaining({ sourceField: 'bytes', - operationType: 'sum', + operationType: 'min', }), }, }) diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 94660e1335bea..3ae7a20c24540 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -7,7 +7,6 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; -import { i18n } from '@kbn/i18n'; import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; @@ -23,11 +22,20 @@ import { getIndexPatterns } from './loader'; import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; -import { makeOperation, getOperationTypesForField } from './operations'; +import { buildColumnForOperationType, getOperationTypesForField } from './operations'; -export type OperationType = 'terms' | 'date_histogram' | 'sum' | 'avg' | 'min' | 'max' | 'count'; +export type OperationType = IndexPatternColumn['operationType']; -export interface IndexPatternColumn { +export type IndexPatternColumn = + | DateHistogramIndexPatternColumn + | TermsIndexPatternColumn + | SumIndexPatternColumn + | AvgIndexPatternColumn + | MinIndexPatternColumn + | MaxIndexPatternColumn + | CountIndexPatternColumn; + +export interface BaseIndexPatternColumn { // Public operationId: string; label: string; @@ -36,10 +44,45 @@ export interface IndexPatternColumn { // Private operationType: OperationType; + suggestedOrder?: DimensionPriority; +} + +type Omit = Pick>; +type ParameterlessIndexPatternColumn< + TOperationType extends OperationType, + TBase extends BaseIndexPatternColumn = FieldBasedIndexPatternColumn +> = Omit & { operationType: TOperationType }; + +export interface FieldBasedIndexPatternColumn extends BaseIndexPatternColumn { sourceField: string; suggestedOrder?: DimensionPriority; } +export interface DateHistogramIndexPatternColumn extends FieldBasedIndexPatternColumn { + operationType: 'date_histogram'; + params: { + interval: string; + timeZone?: string; + }; +} + +export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { + operationType: 'terms'; + params: { + size: number; + orderBy: { type: 'alphabetical' } | { type: 'column'; columnId: string }; + }; +} + +export type CountIndexPatternColumn = ParameterlessIndexPatternColumn< + 'count', + BaseIndexPatternColumn +>; +export type SumIndexPatternColumn = ParameterlessIndexPatternColumn<'sum'>; +export type AvgIndexPatternColumn = ParameterlessIndexPatternColumn<'avg'>; +export type MinIndexPatternColumn = ParameterlessIndexPatternColumn<'min'>; +export type MaxIndexPatternColumn = ParameterlessIndexPatternColumn<'max'>; + export interface IndexPattern { id: string; fields: IndexPatternField[]; @@ -253,19 +296,9 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To const hasBucket = operations.find(op => op === 'date_histogram' || op === 'terms'); if (hasBucket) { - const column = makeOperation(0, hasBucket, field); - - const countColumn: IndexPatternColumn = { - operationId: 'count', - label: i18n.translate('xpack.lens.indexPatternOperations.countOfDocuments', { - defaultMessage: 'Count of Documents', - }), - dataType: 'number', - isBucketed: false, - - operationType: 'count', - sourceField: 'documents', - }; + const column = buildColumnForOperationType(0, hasBucket, undefined, field); + + const countColumn = buildColumnForOperationType(1, 'count'); const suggestion: DatasourceSuggestion = { state: { @@ -300,9 +333,9 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To f => f.name === currentIndexPattern.timeFieldName )!; - const column = makeOperation(0, operations[0], field); + const column = buildColumnForOperationType(0, operations[0], undefined, field); - const dateColumn = makeOperation(1, 'date_histogram', dateField); + const dateColumn = buildColumnForOperationType(1, 'date_histogram', undefined, dateField); const suggestion: DatasourceSuggestion = { state: { diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts index 41aa3737cde9b..8ad9e1beb8bf9 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts @@ -24,8 +24,8 @@ interface SavedRestrictionsObject { string, { agg: string; - interval?: number; fixed_interval?: string; + calendar_interval?: string; delay?: string; time_zone?: string; } diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx new file mode 100644 index 0000000000000..d37504ad32fe5 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/count.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 { i18n } from '@kbn/i18n'; +import { CountIndexPatternColumn } from '../indexpattern'; +import { DimensionPriority } from '../../types'; +import { OperationDefinition } from '../operations'; + +export const countOperation: OperationDefinition = { + type: 'count', + displayName: i18n.translate('xpack.lens.indexPattern.count', { + defaultMessage: 'Count', + }), + isApplicableWithoutField: true, + isApplicableForField: ({ aggregationRestrictions, type }) => { + return false; + }, + buildColumn(operationId: string, suggestedOrder?: DimensionPriority): CountIndexPatternColumn { + return { + operationId, + label: i18n.translate('xpack.lens.indexPattern.countOf', { + defaultMessage: 'Count of documents', + }), + dataType: 'number', + operationType: 'count', + suggestedOrder, + isBucketed: false, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: 'count', + schema: 'metric', + params: {}, + }), +}; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx new file mode 100644 index 0000000000000..94cd3e6495672 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx @@ -0,0 +1,170 @@ +/* + * 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 { dateHistogramOperation } from './date_histogram'; +import { shallow } from 'enzyme'; +import { DateHistogramIndexPatternColumn, IndexPatternPrivateState } from '../indexpattern'; +import { EuiRange } from '@elastic/eui'; + +describe('date_histogram', () => { + let state: IndexPatternPrivateState; + const InlineOptions = dateHistogramOperation.inlineOptions!; + + beforeEach(() => { + state = { + indexPatterns: { + 1: { + id: '1', + title: 'Mock Indexpattern', + fields: [ + { + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + }, + ], + }, + }, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: 'w', + }, + sourceField: 'timestamp', + }, + }, + }; + }); + + describe('buildColumn', () => { + it('should create column object with default params', () => { + const column = dateHistogramOperation.buildColumn('op', 0, { + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + }); + expect(column.params.interval).toEqual('h'); + }); + + it('should create column object with restrictions', () => { + const column = dateHistogramOperation.buildColumn('op', 0, { + 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.columns.col1 as DateHistogramIndexPatternColumn, + 'col1' + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + interval: 'w', + field: 'timestamp', + }), + }) + ); + }); + }); + + 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 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, + columns: { + ...state.columns, + col1: { + ...state.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/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx new file mode 100644 index 0000000000000..35a7de3ef6b6e --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx @@ -0,0 +1,158 @@ +/* + * 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 } from '@elastic/eui'; +import { IndexPatternField, DateHistogramIndexPatternColumn } from '../indexpattern'; +import { DimensionPriority } from '../../types'; +import { OperationDefinition } from '../operations'; +import { updateColumnParam } from '../state_helpers'; + +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.dateHistogramOf', { + defaultMessage: 'Date Histogram of {name}', + values: { name }, + }); +} + +export const dateHistogramOperation: OperationDefinition = { + type: 'date_histogram', + displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { + defaultMessage: 'Date Histogram', + }), + isApplicableWithoutField: false, + isApplicableForField: ({ aggregationRestrictions, type }) => { + return Boolean( + type === 'date' && (!aggregationRestrictions || aggregationRestrictions.date_histogram) + ); + }, + buildColumn( + operationId: string, + suggestedOrder?: DimensionPriority, + field?: IndexPatternField + ): DateHistogramIndexPatternColumn { + if (!field) { + throw new Error('Invariant error: date histogram operation requires field'); + } + let interval = 'h'; + 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 { + operationId, + label: ofName(field.name), + dataType: 'date', + operationType: 'date_histogram', + suggestedOrder, + sourceField: field.name, + isBucketed: true, + params: { + interval, + timeZone, + }, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: 'date_histogram', + schema: 'segment', + params: { + field: column.sourceField, + // TODO: This range should be passed in from somewhere else + timeRange: { + from: 'now-1d', + to: 'now', + }, + time_zone: column.params.timeZone, + useNormalizedEsInterval: true, + interval: column.params.interval, + drop_partials: false, + min_doc_count: 1, + extended_bounds: {}, + }, + }), + inlineOptions: ({ state, setState, columnId }) => { + const column = state.columns[columnId] as DateHistogramIndexPatternColumn; + + const field = + column && + state.indexPatterns[state.currentIndexPatternId].fields.find( + currentField => currentField.name === column.sourceField + ); + const intervalIsRestricted = + field!.aggregationRestrictions && field!.aggregationRestrictions.date_histogram; + + const intervals = ['M', 'w', 'd', 'h']; + + function intervalToNumeric(interval: string) { + return intervals.indexOf(interval); + } + + function numericToInterval(i: number) { + return intervals[i]; + } + + return ( + + + {intervalIsRestricted ? ( + + ) : ( + ({ label: interval, value: index }))} + onChange={(e: React.ChangeEvent) => + setState( + updateColumnParam( + state, + column, + 'interval', + numericToInterval(Number(e.target.value)) + ) + ) + } + aria-label={i18n.translate('xpack.lens.indexPattern.dateHistogram.interval', { + defaultMessage: 'Level of detail', + })} + /> + )} + + + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx new file mode 100644 index 0000000000000..4e5bb97276e20 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx @@ -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 { i18n } from '@kbn/i18n'; +import { + IndexPatternField, + FieldBasedIndexPatternColumn, + MinIndexPatternColumn, + SumIndexPatternColumn, + AvgIndexPatternColumn, + MaxIndexPatternColumn, +} from '../indexpattern'; +import { DimensionPriority } from '../../types'; +import { OperationDefinition } from '../operations'; + +function buildMetricOperation( + type: T['operationType'], + displayName: string, + ofName: (name: string) => string +) { + const operationDefinition: OperationDefinition = { + type, + displayName, + isApplicableWithoutField: false, + isApplicableForField: ({ aggregationRestrictions, type: fieldType }: IndexPatternField) => { + return Boolean( + fieldType === 'number' && (!aggregationRestrictions || aggregationRestrictions[type]) + ); + }, + buildColumn( + operationId: string, + suggestedOrder?: DimensionPriority, + field?: IndexPatternField + ): T { + if (!field) { + throw new Error(`Invariant: A ${type} operation can only be built with a field`); + } + return { + operationId, + label: ofName(field ? field.name : ''), + dataType: 'number', + operationType: type, + suggestedOrder, + sourceField: field ? field.name : '', + isBucketed: false, + } as T; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: column.operationType, + schema: 'metric', + params: { + field: column.sourceField, + }, + }), + }; + return operationDefinition; +} + +export const minOperation = buildMetricOperation( + 'min', + i18n.translate('xpack.lens.indexPattern.min', { + defaultMessage: 'Minimum', + }), + name => + i18n.translate('xpack.lens.indexPattern.minOf', { + defaultMessage: 'Minimum of {name}', + values: { name }, + }) +); + +export const maxOperation = buildMetricOperation( + 'max', + i18n.translate('xpack.lens.indexPattern.min', { + defaultMessage: 'Maximum', + }), + name => + i18n.translate('xpack.lens.indexPattern.minOf', { + defaultMessage: 'Maximum of {name}', + values: { name }, + }) +); + +export const averageOperation = buildMetricOperation( + 'avg', + i18n.translate('xpack.lens.indexPattern.min', { + defaultMessage: 'Average', + }), + name => + i18n.translate('xpack.lens.indexPattern.minOf', { + defaultMessage: 'Average of {name}', + values: { name }, + }) +); + +export const sumOperation = buildMetricOperation( + 'sum', + i18n.translate('xpack.lens.indexPattern.min', { + defaultMessage: 'Sum', + }), + name => + i18n.translate('xpack.lens.indexPattern.minOf', { + defaultMessage: 'Sum of {name}', + values: { name }, + }) +); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx new file mode 100644 index 0000000000000..4876ea42fc787 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx @@ -0,0 +1,154 @@ +/* + * 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 { termsOperation } from './terms'; +import { shallow } from 'enzyme'; +import { IndexPatternPrivateState, TermsIndexPatternColumn } from '../indexpattern'; +import { EuiRange, EuiSelect } from '@elastic/eui'; + +describe('terms', () => { + let state: IndexPatternPrivateState; + const InlineOptions = termsOperation.inlineOptions!; + const contextMenu = termsOperation.contextMenu!; + + beforeEach(() => { + state = { + indexPatterns: {}, + currentIndexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + operationId: 'op1', + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 5, + }, + sourceField: 'category', + }, + col2: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }, + }; + }); + + describe('toEsAggsConfig', () => { + it('should reflect params correctly', () => { + const esAggsConfig = termsOperation.toEsAggsConfig( + state.columns.col1 as TermsIndexPatternColumn, + 'col1' + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + orderBy: '_key', + field: 'category', + size: 5, + }), + }) + ); + }); + }); + + describe('popover param editor', () => { + it('should render current value and options', () => { + const setStateSpy = jest.fn(); + const PartialMenu = () => ( + <>{contextMenu({ state, setState: setStateSpy, columnId: 'col1' })}/> + ); + const instance = shallow(); + + expect(instance.find(EuiSelect).prop('value')).toEqual('alphabetical'); + expect( + instance + .find(EuiSelect) + .prop('options') + .map(({ value }) => value) + ).toEqual(['column$$$col2', 'alphabetical']); + }); + + it('should update state with the order value', () => { + const setStateSpy = jest.fn(); + const PartialMenu = () => ( + <>{contextMenu({ state, setState: setStateSpy, columnId: 'col1' })}/> + ); + const instance = shallow(); + + instance.find(EuiSelect).prop('onChange')!({ + target: { + value: 'column$$$col2', + }, + } as React.ChangeEvent); + + expect(setStateSpy).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col1: { + ...state.columns.col1, + params: { + ...(state.columns.col1 as TermsIndexPatternColumn).params, + orderBy: { + type: 'column', + columnId: 'col2', + }, + }, + }, + }, + }); + }); + }); + + describe('inline param editor', () => { + it('should render current value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + expect(instance.find(EuiRange).prop('value')).toEqual(5); + }); + + 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, + columns: { + ...state.columns, + col1: { + ...state.columns.col1, + params: { + ...(state.columns.col1 as TermsIndexPatternColumn).params, + size: 7, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx new file mode 100644 index 0000000000000..d8a98eba90f86 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -0,0 +1,165 @@ +/* + * 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, EuiContextMenuItem } from '@elastic/eui'; +import { IndexPatternField, TermsIndexPatternColumn } from '../indexpattern'; +import { DimensionPriority } from '../../types'; +import { OperationDefinition } from '../operations'; +import { updateColumnParam } from '../state_helpers'; + +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 }, + }); +} + +export const termsOperation: OperationDefinition = { + type: 'terms', + displayName: i18n.translate('xpack.lens.indexPattern.terms', { + defaultMessage: 'Top Values', + }), + isApplicableWithoutField: false, + isApplicableForField: ({ aggregationRestrictions, type }) => { + return Boolean( + type === 'string' && (!aggregationRestrictions || aggregationRestrictions.terms) + ); + }, + buildColumn( + operationId: string, + suggestedOrder?: DimensionPriority, + field?: IndexPatternField + ): TermsIndexPatternColumn { + return { + operationId, + label: ofName(field ? field.name : ''), + dataType: 'string', + operationType: 'terms', + suggestedOrder, + sourceField: field ? field.name : '', + isBucketed: true, + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + }, + }; + }, + 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: 'desc', + size: column.params.size, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }), + contextMenu: ({ state, setState, columnId: currentColumnId }) => { + const currentColumn = state.columns[currentColumnId] as TermsIndexPatternColumn; + 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.columns) + .filter(([_columnId, column]) => !column.isBucketed) + .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 [ + + + ) => + setState( + updateColumnParam(state, currentColumn, 'orderBy', fromValue(e.target.value)) + ) + } + /> + + , + ]; + }, + inlineOptions: ({ state, setState, columnId: currentColumnId }) => { + const currentColumn = state.columns[currentColumnId] as TermsIndexPatternColumn; + return ( + + + ) => + setState(updateColumnParam(state, currentColumn, 'size', Number(e.target.value))) + } + aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', { + defaultMessage: 'Number of values', + })} + /> + + + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts index 09ff0ed27832b..c42ae4def66e1 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOperationTypesForField, getPotentialColumns, getColumnOrder } from './operations'; +import { getOperationTypesForField, getPotentialColumns } from './operations'; import { IndexPatternPrivateState } from './indexpattern'; +import { hasField } from './state_helpers'; const expectedIndexPatterns = { 1: { @@ -121,7 +122,7 @@ describe('getOperationTypesForField', () => { it('should return operations on dates', () => { expect( getOperationTypesForField({ - type: 'dates', + type: 'date', name: 'a', aggregatable: true, searchable: true, @@ -156,6 +157,9 @@ describe('getOperationTypesForField', () => { // Private operationType: 'date_histogram', sourceField: 'timestamp', + params: { + interval: 'h', + }, }, }, }; @@ -170,27 +174,29 @@ describe('getOperationTypesForField', () => { it('should list operations by field for a regular index pattern', () => { const columns = getPotentialColumns(state); - expect(columns.map(col => [col.sourceField, col.operationType])).toMatchInlineSnapshot(` + expect( + columns.map(col => [hasField(col) ? col.sourceField : '_documents_', col.operationType]) + ).toMatchInlineSnapshot(` Array [ Array [ "bytes", - "sum", + "min", ], Array [ "bytes", - "avg", + "max", ], Array [ "bytes", - "min", + "avg", ], Array [ - "bytes", - "max", + "_documents_", + "count", ], Array [ - "documents", - "count", + "bytes", + "sum", ], Array [ "source", @@ -205,103 +211,3 @@ Array [ }); }); }); - -describe('getColumnOrder', () => { - it('should work for empty columns', () => { - expect(getColumnOrder({})).toEqual([]); - }); - - it('should work for one column', () => { - expect( - getColumnOrder({ - col1: { - operationId: 'op1', - label: 'Date Histogram of timestamp', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - }, - }) - ).toEqual(['col1']); - }); - - it('should put any number of aggregations before metrics', () => { - expect( - getColumnOrder({ - col1: { - operationId: 'op1', - label: 'Top Values of category', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - sourceField: 'category', - }, - col2: { - operationId: 'op2', - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'avg', - sourceField: 'bytes', - }, - col3: { - operationId: 'op3', - label: 'Date Histogram of timestamp', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - }, - }) - ).toEqual(['col1', 'col3', 'col2']); - }); - - it('should reorder aggregations based on suggested priority', () => { - expect( - getColumnOrder({ - col1: { - operationId: 'op1', - label: 'Top Values of category', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - sourceField: 'category', - suggestedOrder: 2, - }, - col2: { - operationId: 'op2', - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'avg', - sourceField: 'bytes', - suggestedOrder: 0, - }, - col3: { - operationId: 'op3', - label: 'Date Histogram of timestamp', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - suggestedOrder: 1, - }, - }) - ).toEqual(['col3', 'col1', 'col2']); - }); -}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts index 2a2e6a82722c1..20edaf338fd61 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts @@ -4,165 +4,106 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { DataType, DimensionPriority } from '../types'; +import { DimensionPriority } from '../types'; import { IndexPatternColumn, IndexPatternField, IndexPatternPrivateState, OperationType, + BaseIndexPatternColumn, } from './indexpattern'; +import { termsOperation } from './operation_definitions/terms'; +import { + minOperation, + averageOperation, + sumOperation, + maxOperation, +} from './operation_definitions/metrics'; +import { dateHistogramOperation } from './operation_definitions/date_histogram'; +import { countOperation } from './operation_definitions/count'; +import { sortByField } from './state_helpers'; + +type PossibleOperationDefinitions< + U extends IndexPatternColumn = IndexPatternColumn +> = U extends IndexPatternColumn ? OperationDefinition : never; + +type PossibleOperationDefinitionMapEntyries< + U extends PossibleOperationDefinitions = PossibleOperationDefinitions +> = U extends PossibleOperationDefinitions ? { [K in U['type']]: U } : never; + +type UnionToIntersection = (U extends U ? (k: U) => void : never) extends ((k: infer I) => void) + ? I + : never; + +// this type makes sure that there is an operation definition for each column type +export type AllOperationDefinitions = UnionToIntersection; + +export const operationDefinitionMap: AllOperationDefinitions = { + terms: termsOperation, + date_histogram: dateHistogramOperation, + min: minOperation, + max: maxOperation, + avg: averageOperation, + sum: sumOperation, + count: countOperation, +}; +const operationDefinitions: PossibleOperationDefinitions[] = Object.values(operationDefinitionMap); export function getOperations(): OperationType[] { - // Raw value is not listed in the MVP - return ['terms', 'date_histogram', 'sum', 'avg', 'min', 'max', 'count']; + return Object.keys(operationDefinitionMap) as OperationType[]; } -export function getOperationDisplay(): Record< - OperationType, - { - type: OperationType; - displayName: string; - ofName: (name: string) => string; - } -> { - return { - terms: { - type: 'terms', - displayName: i18n.translate('xpack.lens.indexPattern.terms', { - defaultMessage: 'Top Values', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.termsOf', { - defaultMessage: 'Top Values of {name}', - values: { name }, - }), - }, - date_histogram: { - type: 'date_histogram', - displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { - defaultMessage: 'Date Histogram', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.dateHistogramOf', { - defaultMessage: 'Date Histogram of {name}', - values: { name }, - }), - }, - sum: { - type: 'sum', - displayName: i18n.translate('xpack.lens.indexPattern.sum', { - defaultMessage: 'Sum', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.sumOf', { - defaultMessage: 'Sum of {name}', - values: { name }, - }), - }, - avg: { - type: 'avg', - displayName: i18n.translate('xpack.lens.indexPattern.average', { - defaultMessage: 'Average', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.averageOf', { - defaultMessage: 'Average of {name}', - values: { name }, - }), - }, - min: { - 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 }, - }), - }, - max: { - 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 }, - }), - }, - count: { - type: 'count', - displayName: i18n.translate('xpack.lens.indexPattern.count', { - defaultMessage: 'Count', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.countOf', { - defaultMessage: 'Count of {name}', - values: { name }, - }), - }, - }; +export interface ParamEditorProps { + state: IndexPatternPrivateState; + setState: (newState: IndexPatternPrivateState) => void; + columnId: string; +} +export interface OperationDefinition { + type: C['operationType']; + displayName: string; + // TODO make this a function dependend on the indexpattern with typeMeta information + isApplicableWithoutField: boolean; + isApplicableForField: (field: IndexPatternField) => boolean; + buildColumn: ( + operationId: string, + suggestedOrder?: DimensionPriority, + field?: IndexPatternField + ) => C; + inlineOptions?: React.ComponentType; + contextMenu?: (props: ParamEditorProps) => JSX.Element[]; + toEsAggsConfig: (column: C, columnId: string) => unknown; } -export function getOperationTypesForField({ - type, - aggregationRestrictions, -}: IndexPatternField): OperationType[] { - if (aggregationRestrictions) { - const validOperations = getOperations(); - return Object.keys(aggregationRestrictions).filter(key => - // Filter out operations that are available, but that aren't yet supported by the client - validOperations.includes(key as OperationType) - ) as OperationType[]; - } - - switch (type) { - case 'date': - return ['date_histogram']; - case 'number': - return ['sum', 'avg', 'min', 'max']; - case 'string': - return ['terms']; - } - return []; +export function getOperationDisplay() { + const display = {} as Record< + OperationType, + { + type: OperationType; + displayName: string; + } + >; + operationDefinitions.forEach(({ type, displayName }) => { + display[type] = { + type, + displayName, + }; + }); + return display; } -export function getOperationResultType({ type }: IndexPatternField, op: OperationType): DataType { - switch (op) { - case 'avg': - case 'min': - case 'max': - case 'count': - case 'sum': - return 'number'; - case 'date_histogram': - return 'date'; - case 'terms': - return 'string'; - } +export function getOperationTypesForField(field: IndexPatternField): OperationType[] { + return operationDefinitions + .filter(definition => definition.isApplicableForField(field)) + .map(({ type }) => type); } -export function makeOperation( +export function buildColumnForOperationType( index: number, - op: OperationType, - field: IndexPatternField, - suggestedOrder?: DimensionPriority + op: T, + suggestedOrder?: DimensionPriority, + field?: IndexPatternField ): IndexPatternColumn { - const operationPanels = getOperationDisplay(); - return { - operationId: `${index}${op}`, - label: operationPanels[op].ofName(field.name), - dataType: getOperationResultType(field, op), - isBucketed: op === 'terms' || op === 'date_histogram', - - operationType: op, - sourceField: field.name, - suggestedOrder, - }; + return operationDefinitionMap[op].buildColumn(`${index}${op}`, suggestedOrder, field); } export function getPotentialColumns( @@ -175,45 +116,17 @@ export function getPotentialColumns( .map((field, index) => { const validOperations = getOperationTypesForField(field); - return validOperations.map(op => { - return makeOperation(index, op, field, suggestedOrder); - }); + return validOperations.map(op => + buildColumnForOperationType(index, op, suggestedOrder, field) + ); }) .reduce((prev, current) => prev.concat(current)); - columns.push({ - operationId: 'count', - label: i18n.translate('xpack.lens.indexPatternOperations.countOfDocuments', { - defaultMessage: 'Count of Documents', - }), - dataType: 'number', - isBucketed: false, - - operationType: 'count', - sourceField: 'documents', - suggestedOrder, + operationDefinitions.forEach(operation => { + if (operation.isApplicableWithoutField) { + columns.push(operation.buildColumn(operation.type, suggestedOrder)); + } }); - columns.sort(({ sourceField }, { sourceField: sourceField2 }) => - sourceField.localeCompare(sourceField2) - ); - - return columns; -} - -export function getColumnOrder(columns: Record): string[] { - const entries = Object.entries(columns); - - const [aggregations, metrics] = _.partition(entries, col => col[1].isBucketed); - - return aggregations - .sort(([id, col], [id2, col2]) => { - return ( - // Sort undefined orders last - (col.suggestedOrder !== undefined ? col.suggestedOrder : Number.MAX_SAFE_INTEGER) - - (col2.suggestedOrder !== undefined ? col2.suggestedOrder : Number.MAX_SAFE_INTEGER) - ); - }) - .map(([id]) => id) - .concat(metrics.map(([id]) => id)); + return sortByField(columns); } diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts new file mode 100644 index 0000000000000..88ba0b923d6ee --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts @@ -0,0 +1,214 @@ +/* + * 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, getColumnOrder, changeColumn } from './state_helpers'; +import { IndexPatternPrivateState, DateHistogramIndexPatternColumn } from './indexpattern'; + +describe('state_helpers', () => { + describe('updateColumnParam', () => { + it('should set the param for the given column', () => { + const currentColumn: DateHistogramIndexPatternColumn = { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }; + + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }; + + expect(updateColumnParam(state, currentColumn, 'interval', 'M').columns.col1).toEqual({ + ...currentColumn, + params: { interval: 'M' }, + }); + }); + }); + + describe('changeColumn', () => { + it('should update order on changing the column', () => { + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + operationId: 'op1', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col2: { + operationId: 'op1', + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }; + expect( + changeColumn(state, 'col2', { + operationId: 'op1', + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }) + ).toEqual( + expect.objectContaining({ + columnOrder: ['col2', 'col1'], + }) + ); + }); + }); + + describe('getColumnOrder', () => { + it('should work for empty columns', () => { + expect(getColumnOrder({})).toEqual([]); + }); + + it('should work for one column', () => { + expect( + getColumnOrder({ + col1: { + operationId: 'op1', + 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: { + operationId: 'op1', + label: 'Top Values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + }, + }, + col2: { + operationId: 'op2', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col3: { + operationId: 'op3', + 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: { + operationId: 'op1', + label: 'Top Values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + }, + suggestedOrder: 2, + }, + col2: { + operationId: 'op2', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + suggestedOrder: 0, + }, + col3: { + operationId: 'op3', + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + suggestedOrder: 1, + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col3', 'col1', 'col2']); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.ts b/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.ts new file mode 100644 index 0000000000000..aec5bf5a3b7b4 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.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. + */ + +import _ from 'lodash'; +import { + IndexPatternPrivateState, + IndexPatternColumn, + BaseIndexPatternColumn, + FieldBasedIndexPatternColumn, +} from './indexpattern'; + +export function getColumnOrder(columns: Record): string[] { + const entries = Object.entries(columns); + + const [aggregations, metrics] = _.partition(entries, col => col[1].isBucketed); + + return aggregations + .sort(([id, col], [id2, col2]) => { + return ( + // Sort undefined orders last + (col.suggestedOrder !== undefined ? col.suggestedOrder : Number.MAX_SAFE_INTEGER) - + (col2.suggestedOrder !== undefined ? col2.suggestedOrder : Number.MAX_SAFE_INTEGER) + ); + }) + .map(([id]) => id) + .concat(metrics.map(([id]) => id)); +} + +export function updateColumnParam< + C extends BaseIndexPatternColumn & { params: object }, + K extends keyof C['params'] +>( + state: IndexPatternPrivateState, + currentColumn: C, + paramName: K, + value: C['params'][K] +): IndexPatternPrivateState { + const columnId = Object.entries(state.columns).find( + ([_columnId, column]) => column === currentColumn + )![0]; + + if (!('params' in state.columns[columnId])) { + throw new Error('Invariant: no params in this column'); + } + + return { + ...state, + columns: { + ...state.columns, + [columnId]: ({ + ...currentColumn, + params: { + ...currentColumn.params, + [paramName]: value, + }, + } as unknown) as IndexPatternColumn, + }, + }; +} + +export function changeColumn( + state: IndexPatternPrivateState, + columnId: string, + newColumn: IndexPatternColumn +) { + const newColumns: IndexPatternPrivateState['columns'] = { + ...state.columns, + [columnId]: newColumn, + }; + + return { + ...state, + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }; +} + +export function deleteColumn(state: IndexPatternPrivateState, columnId: string) { + const newColumns: IndexPatternPrivateState['columns'] = { + ...state.columns, + }; + delete newColumns[columnId]; + + return { + ...state, + columns: newColumns, + columnOrder: getColumnOrder(newColumns), + }; +} + +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); + }); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts index 3890eb32a2468..c284f51bffe65 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -6,7 +6,8 @@ import _ from 'lodash'; -import { IndexPatternPrivateState } from './indexpattern'; +import { IndexPatternPrivateState, IndexPatternColumn } from './indexpattern'; +import { operationDefinitionMap, OperationDefinition } from './operations'; export function toExpression(state: IndexPatternPrivateState) { if (state.columnOrder.length === 0) { @@ -15,65 +16,18 @@ export function toExpression(state: IndexPatternPrivateState) { const sortedColumns = state.columnOrder.map(col => state.columns[col]); + function getEsAggsConfig(column: C, columnId: string) { + // Typescript is not smart enough to infer that definitionMap[C['operationType']] is always OperationDefinition, + // but this is made sure by the typing of the operation map + const operationDefinition = (operationDefinitionMap[ + column.operationType + ] as unknown) as OperationDefinition; + return operationDefinition.toEsAggsConfig(column, columnId); + } + if (sortedColumns.length) { - const firstMetric = sortedColumns.findIndex(({ isBucketed }) => !isBucketed); const aggs = sortedColumns.map((col, index) => { - if (col.operationType === 'date_histogram') { - return { - id: state.columnOrder[index], - enabled: true, - type: 'date_histogram', - schema: 'segment', - params: { - field: col.sourceField, - // TODO: This range should be passed in from somewhere else - timeRange: { - from: 'now-1d', - to: 'now', - }, - useNormalizedEsInterval: true, - interval: '1h', - drop_partials: false, - min_doc_count: 1, - extended_bounds: {}, - }, - }; - } else if (col.operationType === 'terms') { - return { - id: state.columnOrder[index], - enabled: true, - type: 'terms', - schema: 'segment', - params: { - field: col.sourceField, - orderBy: state.columnOrder[firstMetric] || undefined, - order: 'desc', - size: 5, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, - }; - } else if (col.operationType === 'count') { - return { - id: state.columnOrder[index], - enabled: true, - type: 'count', - schema: 'metric', - params: {}, - }; - } else { - return { - id: state.columnOrder[index], - enabled: true, - type: col.operationType, - schema: 'metric', - params: { - field: col.sourceField, - }, - }; - } + return getEsAggsConfig(col, state.columnOrder[index]); }); const idMap = state.columnOrder.reduce( diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts index 83665dd8a6fe4..b034c9fe78b27 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts @@ -89,7 +89,7 @@ describe('xy_suggestions', () => { ], }); - expect(rest.length).toEqual(0); + expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Object { "seriesType": "line", @@ -114,7 +114,7 @@ Object { ], }); - expect(rest.length).toEqual(0); + expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Object { "seriesType": "line", @@ -147,7 +147,7 @@ Object { ], }); - expect(rest.length).toEqual(0); + expect(rest).toHaveLength(0); expect([suggestionSubset(s1), suggestionSubset(s2)]).toMatchInlineSnapshot(` Array [ Object { From 4c6904470cf136a792aab9667577f3447b49a712 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 21 Jun 2019 16:13:43 +0200 Subject: [PATCH 029/105] fix eslint failure --- .../plugins/lens/public/editor_frame_plugin/plugin.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index e331ea8d21a93..4112a928342b5 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -8,7 +8,10 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { CoreSetup } from 'src/core/public'; - import { DataSetup, ExpressionRenderer } from '../../../../../../src/legacy/core_plugins/data/public'; +import { + DataSetup, + ExpressionRenderer, +} from '../../../../../../src/legacy/core_plugins/data/public'; import { data } from '../../../../../../src/legacy/core_plugins/data/public/setup'; import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; import { EditorFrame } from './editor_frame'; From abed39ad7a1a76030165dd771bdc73d766238ac4 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Fri, 21 Jun 2019 12:53:42 -0400 Subject: [PATCH 030/105] [lens] Fix build by updating saved objects and i18n (#39391) * [lens] Update location of saved objects code * Update internatationalization * Remove added file --- .i18nrc.json | 1 + .../operation_definitions/metrics.tsx | 12 ++++++------ .../public/indexpattern_plugin/rename_columns.ts | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.i18nrc.json b/.i18nrc.json index d675eb02479aa..c791e06daa2ba 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -38,6 +38,7 @@ "xpack.indexLifecycleMgmt": "x-pack/legacy/plugins/index_lifecycle_management", "xpack.infra": "x-pack/legacy/plugins/infra", "xpack.kueryAutocomplete": "x-pack/legacy/plugins/kuery_autocomplete", + "xpack.lens": "x-pack/legacy/plugins/lens", "xpack.licenseMgmt": "x-pack/legacy/plugins/license_management", "xpack.maps": "x-pack/legacy/plugins/maps", "xpack.ml": "x-pack/legacy/plugins/ml", diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx index 4e5bb97276e20..cb460f8ede090 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx @@ -75,11 +75,11 @@ export const minOperation = buildMetricOperation( export const maxOperation = buildMetricOperation( 'max', - i18n.translate('xpack.lens.indexPattern.min', { + i18n.translate('xpack.lens.indexPattern.max', { defaultMessage: 'Maximum', }), name => - i18n.translate('xpack.lens.indexPattern.minOf', { + i18n.translate('xpack.lens.indexPattern.maxOf', { defaultMessage: 'Maximum of {name}', values: { name }, }) @@ -87,11 +87,11 @@ export const maxOperation = buildMetricOperation( export const averageOperation = buildMetricOperation( 'avg', - i18n.translate('xpack.lens.indexPattern.min', { + i18n.translate('xpack.lens.indexPattern.avg', { defaultMessage: 'Average', }), name => - i18n.translate('xpack.lens.indexPattern.minOf', { + i18n.translate('xpack.lens.indexPattern.avgOf', { defaultMessage: 'Average of {name}', values: { name }, }) @@ -99,11 +99,11 @@ export const averageOperation = buildMetricOperation( export const sumOperation = buildMetricOperation( 'sum', - i18n.translate('xpack.lens.indexPattern.min', { + i18n.translate('xpack.lens.indexPattern.sum', { defaultMessage: 'Sum', }), name => - i18n.translate('xpack.lens.indexPattern.minOf', { + i18n.translate('xpack.lens.indexPattern.sumOf', { defaultMessage: 'Sum of {name}', values: { name }, }) 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 index 1740d449b62cd..01361ada9bf02 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.ts @@ -20,13 +20,13 @@ export const renameColumns: ExpressionFunction< > = { name: 'lens_rename_columns', type: 'kibana_datatable', - help: i18n.translate('lens.functions.renameColumns.help', { + 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('lens.functions.renameColumns.idMap.help', { + 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.', }), From 85d4543910e29ef86ed9fc87997010159a3b8e58 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Mon, 24 Jun 2019 14:12:23 -0400 Subject: [PATCH 031/105] [lens] Fix arguments to esaggs using booleans (#39462) --- .../lens/public/indexpattern_plugin/indexpattern.test.tsx | 4 ++-- .../plugins/lens/public/indexpattern_plugin/to_expression.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index cb2c610aff548..3211a1ea2275e 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -245,8 +245,8 @@ describe('IndexPattern Data Source', () => { expect(indexPatternDatasource.toExpression(state)).toMatchInlineSnapshot(` "esaggs index=\\"1\\" - metricsAtAllLevels=\\"false\\" - partialRows=\\"false\\" + metricsAtAllLevels=false + partialRows=false aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"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\\"}'" `); }); 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 index c284f51bffe65..207bc03391e22 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -40,8 +40,8 @@ export function toExpression(state: IndexPatternPrivateState) { return `esaggs index="${state.currentIndexPatternId}" - metricsAtAllLevels="false" - partialRows="false" + metricsAtAllLevels=false + partialRows=false aggConfigs='${JSON.stringify(aggs)}' | lens_rename_columns idMap='${JSON.stringify(idMap)}'`; } From 02a36982702b2c41ec73983278aa3174eed568d3 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Mon, 24 Jun 2019 14:13:55 -0400 Subject: [PATCH 032/105] [lens] Datatable visualization plugin (#39390) * [lens] Datatable visualization plugin * Fix merge issues and add tests * Update from review * Fix file locations --- .../plugins/lens/public/app_plugin/plugin.tsx | 7 + .../expression.tsx | 141 +++++++++++ .../datatable_visualization_plugin/index.ts | 7 + .../datatable_visualization_plugin/plugin.tsx | 46 ++++ .../visualization.test.tsx | 155 ++++++++++++ .../visualization.tsx | 221 ++++++++++++++++++ .../indexpattern_plugin/indexpattern.tsx | 5 +- .../indexpattern_plugin/to_expression.ts | 24 +- .../plugins/lens/public/interpreter_types.ts | 33 +++ .../public/xy_visualization_plugin/plugin.tsx | 27 +-- .../xy_visualization_plugin/xy_expression.tsx | 2 +- 11 files changed, 630 insertions(+), 38 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx create mode 100644 x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/index.ts create mode 100644 x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx create mode 100644 x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx create mode 100644 x-pack/legacy/plugins/lens/public/interpreter_types.ts diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index 857cee9adbc64..a559c0a94465b 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -8,6 +8,10 @@ import React from 'react'; import { editorFrameSetup, editorFrameStop } from '../editor_frame_plugin'; import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin'; import { xyVisualizationSetup, xyVisualizationStop } from '../xy_visualization_plugin'; +import { + datatableVisualizationSetup, + datatableVisualizationStop, +} from '../datatable_visualization_plugin'; import { App } from './app'; import { EditorFrameInstance } from '../types'; @@ -20,11 +24,13 @@ export class AppPlugin { // 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 editorFrame = editorFrameSetup(); editorFrame.registerDatasource('indexpattern', indexPattern); editorFrame.registerVisualization('xy', xyVisualization); + editorFrame.registerVisualization('datatable', datatableVisualization); this.instance = editorFrame.createInstance({}); @@ -39,6 +45,7 @@ export class AppPlugin { // TODO this will be handled by the plugin platform itself indexPatternDatasourceStop(); xyVisualizationStop(); + datatableVisualizationStop(); editorFrameStop(); } } 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..44799bc7a403b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx @@ -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 React from 'react'; +import ReactDOM from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable } from '@elastic/eui'; +import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; +import { KibanaDatatable } from '../types'; +import { RenderFunction } from '../interpreter_types'; + +export interface DatatableColumns { + columnIds: string[]; + labels: string[]; +} + +interface Args { + columns: DatatableColumns; +} + +export interface DatatableProps { + data: KibanaDatatable; + 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: ['kibana_datatable'], + }, + 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 interface DatatableProps { + data: KibanaDatatable; + args: Args; +} + +export const datatableRenderer: RenderFunction = { + 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) { + return ( + { + return { + field: props.args.columns.columnIds[index], + name: props.args.columns.labels[index], + }; + }) + .filter(({ field }) => !!field)} + items={props.data.rows} + /> + ); +} 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..356e18ddc8419 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.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 { Registry } from '@kbn/interpreter/target/common'; +import { CoreSetup } from 'src/core/public'; +import { datatableVisualization } from './visualization'; + +import { + renderersRegistry, + functionsRegistry, + // @ts-ignore untyped dependency +} from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { InterpreterSetup, RenderFunction } from '../interpreter_types'; +import { datatable, datatableColumns, datatableRenderer } from './expression'; + +export interface DatatableVisualizationPluginSetupPlugins { + interpreter: InterpreterSetup; +} + +class DatatableVisualizationPlugin { + constructor() {} + + setup(_core: CoreSetup | null, { interpreter }: DatatableVisualizationPluginSetupPlugins) { + interpreter.functionsRegistry.register(() => datatableColumns); + interpreter.functionsRegistry.register(() => datatable); + interpreter.renderersRegistry.register(() => datatableRenderer as RenderFunction); + + return datatableVisualization; + } + + stop() {} +} + +const plugin = new DatatableVisualizationPlugin(); + +export const datatableVisualizationSetup = () => + plugin.setup(null, { + interpreter: { + renderersRegistry, + functionsRegistry, + }, + }); +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..2cde89fe2d5d8 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx @@ -0,0 +1,155 @@ +/* + * 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, + DatatableConfigPanel, + datatableVisualization, +} from './visualization'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { Operation, DataType } from '../types'; + +describe('Datatable Visualization', () => { + describe('#initialize', () => { + it('should initialize from the empty state', () => { + const datasource = createMockDatasource(); + datasource.publicAPIMock.generateColumnId.mockReturnValueOnce('id'); + expect(datatableVisualization.initialize(datasource.publicAPIMock)).toEqual({ + columns: [{ id: 'id', label: '' }], + }); + }); + + it('should initialize from a persisted state', () => { + const datasource = createMockDatasource(); + const expectedState: DatatableVisualizationState = { + columns: [{ id: 'saved', label: 'label' }], + }; + expect(datasource.publicAPIMock.generateColumnId).not.toHaveBeenCalled(); + expect(datatableVisualization.initialize(datasource.publicAPIMock, expectedState)).toEqual( + expectedState + ); + }); + }); + + describe('#getPersistableState', () => { + it('should persist the internal state', () => { + const expectedState: DatatableVisualizationState = { + columns: [{ id: 'saved', label: 'label' }], + }; + expect(datatableVisualization.getPersistableState(expectedState)).toEqual(expectedState); + }); + }); + + describe('DatatableConfigPanel', () => { + it('should update the column label', () => { + const setState = jest.fn(); + const wrapper = mount( + {} }} + datasource={createMockDatasource().publicAPIMock} + setState={setState} + state={{ columns: [{ id: 'saved', label: 'label' }] }} + /> + ); + + const labelEditor = wrapper.find('[data-test-subj="lnsDatatable-columnLabel"]').at(1); + + act(() => { + labelEditor.simulate('change', { target: { value: 'New Label' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + columns: [{ id: 'saved', label: 'New Label' }], + }); + }); + + it('should allow all operations to be shown', () => { + const setState = jest.fn(); + const datasource = createMockDatasource(); + + mount( + {} }} + datasource={datasource.publicAPIMock} + setState={setState} + state={{ columns: [{ id: 'saved', label: 'label' }] }} + /> + ); + + expect(datasource.publicAPIMock.renderDimensionPanel).toHaveBeenCalled(); + + const filterOperations = + datasource.publicAPIMock.renderDimensionPanel.mock.calls[0][1].filterOperations; + + const baseOperation: Operation = { + dataType: 'string', + isBucketed: true, + label: '', + id: '', + }; + 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('should remove a column', () => { + const setState = jest.fn(); + const wrapper = mount( + {} }} + datasource={createMockDatasource().publicAPIMock} + setState={setState} + state={{ columns: [{ id: 'saved', label: '' }, { id: 'second', label: '' }] }} + /> + ); + + act(() => { + wrapper + .find('[data-test-subj="lnsDatatable_dimensionPanelRemove_saved"]') + .first() + .simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + columns: [{ id: 'second', label: '' }], + }); + }); + + it('should be able to add more columns', () => { + const setState = jest.fn(); + const datasource = createMockDatasource(); + const wrapper = mount( + {} }} + datasource={datasource.publicAPIMock} + setState={setState} + state={{ columns: [{ id: 'saved', label: 'label' }] }} + /> + ); + + datasource.publicAPIMock.generateColumnId.mockReturnValueOnce('newId'); + + act(() => { + wrapper + .find('[data-test-subj="lnsDatatable_dimensionPanel_add"]') + .first() + .simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + columns: [{ id: 'saved', label: 'label' }, { id: 'newId', label: '' }], + }); + }); + }); +}); 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..1cd7993922f6d --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -0,0 +1,221 @@ +/* + * 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 { + EuiButtonIcon, + EuiForm, + EuiFieldText, + EuiFormRow, + EuiButton, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import { + SuggestionRequest, + Visualization, + VisualizationProps, + VisualizationSuggestion, +} from '../types'; +import { NativeRenderer } from '../native_renderer'; + +export interface DatatableVisualizationState { + columns: Array<{ + id: string; + label: string; + }>; +} + +export function DatatableConfigPanel(props: VisualizationProps) { + const { state, datasource, setState } = props; + + return ( + + {state.columns.map(({ id, label }, index) => { + const operation = datasource.getOperationForColumnId(id); + return ( + <> + + { + const newColumns = [...state.columns]; + newColumns[index] = { ...newColumns[index], label: e.target.value }; + setState({ + ...state, + columns: newColumns, + }); + }} + placeholder={ + operation + ? operation.label + : i18n.translate('xpack.lens.datatable.columnTitlePlaceholder', { + defaultMessage: 'Title', + }) + } + aria-label={i18n.translate('xpack.lens.datatable.columnTitlePlaceholder', { + defaultMessage: 'Title', + })} + /> + + + + + + true, + }} + /> + + + + { + datasource.removeColumnInTableSpec(id); + const newColumns = [...state.columns]; + newColumns.splice(index, 1); + setState({ + ...state, + columns: newColumns, + }); + }} + aria-label={i18n.translate('xpack.lens.datatable.removeColumnAriaLabel', { + defaultMessage: 'Remove', + })} + /> + + + + + ); + })} + +
+ { + const newColumns = [...state.columns]; + newColumns.push({ + id: datasource.generateColumnId(), + label: '', + }); + setState({ + ...state, + columns: newColumns, + }); + }} + iconType="plusInCircle" + /> +
+
+ ); +} + +export const datatableVisualization: Visualization< + DatatableVisualizationState, + DatatableVisualizationState +> = { + initialize(datasource, state) { + return ( + state || { + columns: [ + { + id: datasource.generateColumnId(), + label: '', + }, + ], + } + ); + }, + + getPersistableState: state => state, + + getSuggestions({ + tables, + }: SuggestionRequest): Array< + VisualizationSuggestion + > { + return tables.map(table => { + const title = i18n.translate('xpack.lens.datatable.visualizationOf', { + defaultMessage: 'Table: ${operations}', + values: { + operations: table.columns.map(col => col.operation.label).join(' & '), + }, + }); + + return { + title, + score: 1, + datasourceSuggestionId: table.datasourceSuggestionId, + state: { + columns: table.columns.map(col => ({ + id: col.columnId, + label: col.operation.label, + })), + }, + }; + }); + }, + + renderConfigPanel: (domElement, props) => + render( + + + , + domElement + ), + + toExpression: (state, datasource) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable', + arguments: { + columns: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable_columns', + arguments: { + columnIds: state.columns.map(({ id }) => id), + labels: state.columns.map(({ id, label }) => { + if (label) { + return label; + } + const operation = datasource.getOperationForColumnId(id); + return operation ? operation.label : ''; + }), + }, + }, + ], + }, + ], + }, + }, + ], + }), +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index af454ffb00108..3556c38453c95 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -11,7 +11,7 @@ import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; import uuid from 'uuid'; -import { Datasource, DataType } from '../../public'; +import { Datasource, DataType } from '..'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, @@ -257,6 +257,9 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To return state.columnOrder.map(colId => ({ columnId: colId })); }, getOperationForColumnId: (columnId: string) => { + if (!state.columns[columnId]) { + return null; + } return columnToOperation(state.columns[columnId]); }, generateColumnId: () => { 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 index 207bc03391e22..6f39b521d20d8 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -14,8 +14,6 @@ export function toExpression(state: IndexPatternPrivateState) { return null; } - const sortedColumns = state.columnOrder.map(col => state.columns[col]); - function getEsAggsConfig(column: C, columnId: string) { // Typescript is not smart enough to infer that definitionMap[C['operationType']] is always OperationDefinition, // but this is made sure by the typing of the operation map @@ -25,16 +23,22 @@ export function toExpression(state: IndexPatternPrivateState) { return operationDefinition.toEsAggsConfig(column, columnId); } - if (sortedColumns.length) { - const aggs = sortedColumns.map((col, index) => { - return getEsAggsConfig(col, state.columnOrder[index]); + const columnEntries = state.columnOrder.map( + colId => [colId, state.columns[colId]] as [string, IndexPatternColumn] + ); + + if (columnEntries.length) { + const aggs = columnEntries.map(([colId, col]) => { + return getEsAggsConfig(col, colId); }); - const idMap = state.columnOrder.reduce( - (currentIdMap, columnId, index) => ({ - ...currentIdMap, - [`col-${index}-${columnId}`]: columnId, - }), + const idMap = columnEntries.reduce( + (currentIdMap, [colId], index) => { + return { + ...currentIdMap, + [`col-${index}-${colId}`]: colId, + }; + }, {} as Record ); diff --git a/x-pack/legacy/plugins/lens/public/interpreter_types.ts b/x-pack/legacy/plugins/lens/public/interpreter_types.ts new file mode 100644 index 0000000000000..b24f39080f827 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/interpreter_types.ts @@ -0,0 +1,33 @@ +/* + * 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'; +// @ts-ignore untyped module +import { ExpressionFunction } from '../../../../../src/legacy/core_plugins/interpreter/public'; + +// 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 +interface RenderHandlers { + done: () => void; + onDestroy: (fn: () => void) => void; +} +export interface RenderFunction { + name: string; + displayName: string; + help: string; + validate: () => void; + reuseDomNode: boolean; + render: (domNode: Element, data: T, handlers: RenderHandlers) => void; +} + +export interface InterpreterSetup { + renderersRegistry: Registry; + functionsRegistry: Registry< + ExpressionFunction, + ExpressionFunction + >; +} 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 index 79ecc5c33af87..bb89646715645 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -4,7 +4,6 @@ * 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'; import { xyVisualization } from './xy_visualization'; @@ -13,34 +12,10 @@ import { functionsRegistry, // @ts-ignore untyped dependency } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; -import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/public'; +import { InterpreterSetup, RenderFunction } from '../interpreter_types'; import { xyChart, xyChartRenderer } from './xy_expression'; import { legendConfig, xConfig, yConfig } from './types'; -// 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 -interface RenderHandlers { - done: () => void; - onDestroy: (fn: () => void) => void; -} -export interface RenderFunction { - name: string; - displayName: string; - help: string; - validate: () => void; - reuseDomNode: boolean; - render: (domNode: Element, data: T, handlers: RenderHandlers) => void; -} - -export interface InterpreterSetup { - renderersRegistry: Registry; - functionsRegistry: Registry< - ExpressionFunction, - ExpressionFunction - >; -} - export interface XyVisualizationPluginSetupPlugins { interpreter: InterpreterSetup; } 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 index 9b2b9290b54f1..a60040f5555dc 100644 --- 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 @@ -19,7 +19,7 @@ import { import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; import { XYArgs } from './types'; import { KibanaDatatable } from '../types'; -import { RenderFunction } from './plugin'; +import { RenderFunction } from '../interpreter_types'; export interface XYChartProps { data: KibanaDatatable; From cbcabf1a10b5bf939fa1b3eba354446033575be8 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Mon, 24 Jun 2019 15:28:06 -0400 Subject: [PATCH 033/105] [lens] Use first suggestion when switching visualizations (#39377) --- .../editor_frame/config_panel_wrapper.tsx | 34 +++++++++++--- .../editor_frame/editor_frame.test.tsx | 42 +++++++++++++++--- .../indexpattern_plugin/indexpattern.test.tsx | 44 +++++++++++++++++++ .../indexpattern_plugin/indexpattern.tsx | 22 +++++++++- 4 files changed, 130 insertions(+), 12 deletions(-) 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 index 6d16e3aff0fd1..677b37beab190 100644 --- 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 @@ -19,6 +19,30 @@ interface ConfigPanelWrapperProps { datasourcePublicAPI: DatasourcePublicAPI; } +function getSuggestedVisualizationState( + visualization: Visualization, + datasource: DatasourcePublicAPI +) { + const suggestions = visualization.getSuggestions({ + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: datasource.getTableSpec().map(col => ({ + ...col, + operation: datasource.getOperationForColumnId(col.columnId)!, + })), + }, + ], + }); + + if (!suggestions.length) { + return visualization.initialize(datasource); + } + + return visualization.initialize(datasource, suggestions[0].state); +} + export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const context = useContext(DragContext); const setVisualizationState = useMemo( @@ -41,14 +65,14 @@ export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { }))} value={props.activeVisualizationId || undefined} onChange={e => { + const newState = getSuggestedVisualizationState( + props.visualizationMap[e.target.value], + props.datasourcePublicAPI + ); props.dispatch({ type: 'SWITCH_VISUALIZATION', newVisualizationId: e.target.value, - // TODO we probably want to have a separate API to "force" a visualization switch - // which isn't a result of a picked suggestion - initialState: props.visualizationMap[e.target.value].initialize( - props.datasourcePublicAPI - ), + initialState: newState, }); }} /> 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 index b06c60fed9df9..8d67c632ea5c9 100644 --- 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 @@ -37,7 +37,7 @@ describe('editor_frame', () => { let mockVisualization: Visualization; let mockDatasource: DatasourceMock; - let mockVisualization2: Visualization; + let mockVisualization2: jest.Mocked; let mockDatasource2: DatasourceMock; let expressionRendererMock: ExpressionRenderer; @@ -520,9 +520,17 @@ Object { expect(mockVisualization2.initialize).toHaveBeenCalled(); }); - it('should call visualization render with new state on switch', async () => { - const initialState = {}; - mockVisualization2.initialize = () => initialState; + 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, + datasourceSuggestionId: 0, + state: initialState, + }, + ]); act(() => { instance @@ -530,9 +538,33 @@ Object { .simulate('change', { target: { value: 'testVis2' } }); }); + expect(mockDatasource.publicAPIMock.getTableSpec).toHaveBeenCalled(); + expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); + expect(mockVisualization2.initialize).toHaveBeenCalledWith( + mockDatasource.publicAPIMock, + initialState + ); expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( expect.any(Element), - expect.objectContaining({ state: initialState }) + 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 }); + + act(() => { + instance + .find('select[data-test-subj="visualization-switch"]') + .simulate('change', { target: { value: 'testVis2' } }); + }); + + expect(mockDatasource.publicAPIMock.getTableSpec).toHaveBeenCalled(); + expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); + expect(mockVisualization2.initialize).toHaveBeenCalledWith(mockDatasource.publicAPIMock); + expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: { initial: true } }) ); }); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index 3211a1ea2275e..c3610ab2cf95d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -450,6 +450,46 @@ describe('IndexPattern Data Source', () => { }); }); + describe('#getDatasourceSuggestionsFromCurrentState', () => { + it('returns no suggestions if there are no columns', () => { + expect( + indexPatternDatasource.getDatasourceSuggestionsFromCurrentState({ + indexPatterns: expectedIndexPatterns, + columnOrder: [], + columns: {}, + currentIndexPatternId: '1', + }) + ).toEqual([]); + }); + + it('returns a single suggestion containing the current columns', async () => { + const state = await indexPatternDatasource.initialize(persistedState); + expect(indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state)).toEqual([ + { + state: { + ...persistedState, + indexPatterns: expectedIndexPatterns, + }, + table: { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [ + { + columnId: 'col1', + operation: { + id: 'op1', + label: 'My Op', + dataType: 'string', + isBucketed: true, + }, + }, + ], + }, + }, + ]); + }); + }); + describe('#getPublicAPI', () => { let publicAPI: DatasourcePublicAPI; @@ -477,6 +517,10 @@ describe('IndexPattern Data Source', () => { 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 index 3556c38453c95..a1b3d9b6efe3d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -372,8 +372,26 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To return []; }, - getDatasourceSuggestionsFromCurrentState(state) { - return []; + getDatasourceSuggestionsFromCurrentState( + state + ): Array> { + if (!state.columnOrder.length) { + return []; + } + return [ + { + state, + + table: { + columns: state.columnOrder.map(id => ({ + columnId: id, + operation: columnToOperation(state.columns[id]), + })), + isMultiRow: true, + datasourceSuggestionId: 0, + }, + }, + ]; }, }; From 6cb569fd54991f58221087e727d6a7eaef155785 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 25 Jun 2019 15:32:11 -0400 Subject: [PATCH 034/105] [lens] Label each Y axis with its operation label (#39461) * [lens] Label each Y axis with its operation label * Remove comment * Add link to chart issue --- .../__snapshots__/xy_expression.test.tsx.snap | 45 ++++++++++--------- .../xy_visualization.test.ts.snap | 4 ++ .../public/xy_visualization_plugin/types.ts | 26 +++++++++-- .../xy_expression.test.tsx | 18 +++++++- .../xy_visualization_plugin/xy_expression.tsx | 27 +++++++++-- .../xy_visualization.test.ts | 31 ++++++++++++- .../xy_visualization.tsx | 6 ++- 7 files changed, 125 insertions(+), 32 deletions(-) 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 index 85e0598114ce8..c47fc662e8b4a 100644 --- 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 @@ -8,6 +8,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` @@ -58,6 +59,7 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` @@ -108,6 +110,7 @@ exports[`xy_expression XYChart component it renders line 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 index 678272922f013..c1f13d5db63ab 100644 --- 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 @@ -66,6 +66,10 @@ Object { "b", "c", ], + "labels": Array [ + "b", + "c", + ], "position": Array [ "left", ], 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 index 2f5d587ff892c..71e83943616ac 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -71,10 +71,15 @@ const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = }, }; -export interface YConfig extends AxisConfig { +export interface YState extends AxisConfig { accessors: string[]; } +export type YConfig = AxisConfig & + YState & { + labels: string[]; + }; + type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; export const yConfig: ExpressionFunction<'lens_xy_yConfig', null, YConfig, YConfigResult> = { @@ -92,6 +97,11 @@ export const yConfig: ExpressionFunction<'lens_xy_yConfig', null, YConfig, YConf help: 'The columns to display on the y axis.', multi: true, }, + labels: { + types: ['string'], + help: '', + multi: true, + }, }, fn: function fn(_context: unknown, args: YConfig) { return { @@ -142,5 +152,15 @@ export interface XYArgs { stackAccessors: string[]; } -export type State = XYArgs; -export type PersistableState = XYArgs; +export interface XYState { + seriesType: SeriesType; + title: string; + legend: LegendConfig; + y: YState; + x: XConfig; + splitSeriesAccessors: string[]; + stackAccessors: string[]; +} + +export type State = XYState; +export type PersistableState = XYState; 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 index 3b6f05f86d6d5..b065dc22d1730 100644 --- 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Position } from '@elastic/charts'; +import { BarSeries, Position } from '@elastic/charts'; import { xyChart, XYChart } from './xy_expression'; import { KibanaDatatable } from '../types'; import React from 'react'; @@ -27,6 +27,7 @@ function sampleArgs() { }, y: { accessors: ['a', 'b'], + labels: ['Label A', 'Label B'], position: Position.Left, showGridlines: false, title: 'A and B', @@ -75,6 +76,7 @@ describe('xy_expression', () => { test('yConfig produces the correct arguments', () => { const args: YConfig = { accessors: ['bar'], + labels: [''], position: Position.Bottom, showGridlines: true, title: 'Barrrrrr!', @@ -123,5 +125,19 @@ describe('xy_expression', () => { shallow() ).toMatchSnapshot(); }); + + test('it remaps rows based on the labels', () => { + const { data, args } = sampleArgs(); + + const chart = shallow(); + const barSeries = chart.find(BarSeries); + + expect(barSeries.prop('yAccessors')).toEqual(['Label A', 'Label B']); + expect(barSeries.prop('data')[0]).toEqual({ + 'Label A': 1, + 'Label B': 2, + c: 3, + }); + }); }); }); 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 index a60040f5555dc..ea3555631f126 100644 --- 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 @@ -103,18 +103,37 @@ export const xyChartRenderer: RenderFunction = { export function XYChart({ data, args }: XYChartProps) { const { legend, x, y, splitSeriesAccessors, stackAccessors, seriesType } = args; + // TODO: Stop mapping data once elastic-charts allows axis naming + // https://github.com/elastic/elastic-charts/issues/245 const seriesProps = { splitSeriesAccessors, stackAccessors, - id: getSpecId(y.accessors.join(',')), + id: getSpecId(y.labels.join(',')), xAccessor: x.accessor, - yAccessors: y.accessors, - data: data.rows, + yAccessors: y.labels, + data: data.rows.map(row => { + const newRow: typeof row = {}; + + // Remap data to { 'Count of documents': 5 } + Object.keys(row).forEach(key => { + const labelIndex = y.accessors.indexOf(key); + if (labelIndex > -1) { + newRow[y.labels[labelIndex]] = row[key]; + } else { + newRow[key] = row[key]; + } + }); + return newRow; + }), }; return ( - + { it('should map to a valid AST', () => { expect( - xyVisualization.toExpression(exampleState(), {} as DatasourcePublicAPI) + xyVisualization.toExpression(exampleState(), createMockDatasource().publicAPIMock) ).toMatchSnapshot(); }); + + it('should default to labeling all columns with their column label', () => { + const mockDatasource = createMockDatasource(); + + mockDatasource.publicAPIMock.getOperationForColumnId + .mockReturnValueOnce({ + label: 'First', + } as Operation) + .mockReturnValueOnce({ + label: 'Second', + } as Operation); + + const expression = xyVisualization.toExpression( + exampleState(), + mockDatasource.publicAPIMock + )! as Ast; + + expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledTimes(2); + expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('b'); + expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('c'); + + expect((expression.chain[0].arguments.y[0] as Ast).chain[0].arguments.labels).toEqual([ + 'First', + 'Second', + ]); + }); }); }); 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 index d7f2f978cc01a..494b653c6d944 100644 --- 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 @@ -50,7 +50,7 @@ export const xyVisualization: Visualization = { domElement ), - toExpression: state => ({ + toExpression: (state, datasource) => ({ type: 'expression', chain: [ { @@ -103,6 +103,10 @@ export const xyVisualization: Visualization = { showGridlines: [state.y.showGridlines], position: [state.y.position], accessors: state.y.accessors, + labels: state.y.accessors.map(accessor => { + const operation = datasource.getOperationForColumnId(accessor); + return operation ? operation.label : accessor; + }), }, }, ], From 306d38431e8da70adc263361f1c9420b7d8eac66 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 27 Jun 2019 09:24:56 +0200 Subject: [PATCH 035/105] [Lens] Suggestion preview rendering (#39576) --- .../expressions/expression_renderer.tsx | 3 + .../visualization.tsx | 3 +- .../__mocks__/suggestion_helpers.ts | 11 ++ .../editor_frame/config_panel_wrapper.tsx | 27 +-- .../editor_frame/editor_frame.test.tsx | 15 +- .../editor_frame/editor_frame.tsx | 2 + .../editor_frame/expression_helpers.ts | 33 ++-- .../editor_frame/index.scss | 2 + .../editor_frame/suggestion_helpers.test.ts | 107 ++++++++-- .../editor_frame/suggestion_helpers.ts | 19 +- .../editor_frame/suggestion_panel.scss | 21 ++ .../editor_frame/suggestion_panel.test.tsx | 181 +++++++++++++++++ .../editor_frame/suggestion_panel.tsx | 123 ++++++++++-- .../editor_frame/workspace_panel.scss | 3 + .../editor_frame/workspace_panel.test.tsx | 6 +- .../editor_frame/workspace_panel.tsx | 4 +- x-pack/legacy/plugins/lens/public/index.scss | 3 +- x-pack/legacy/plugins/lens/public/types.ts | 7 +- .../xy_visualization.test.ts.snap | 6 + .../xy_visualization_plugin/to_expression.ts | 80 ++++++++ .../public/xy_visualization_plugin/types.ts | 6 + .../xy_expression.scss | 3 +- .../xy_visualization_plugin/xy_expression.tsx | 2 + .../xy_suggestions.test.ts | 187 +++++++++++------- .../xy_visualization_plugin/xy_suggestions.ts | 81 +++++--- .../xy_visualization.tsx | 70 +------ 26 files changed, 766 insertions(+), 239 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/__mocks__/suggestion_helpers.ts create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.scss create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.scss create mode 100644 x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts diff --git a/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx b/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx index 78fb43772504c..9c6443e8a254d 100644 --- a/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx +++ b/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx @@ -37,6 +37,7 @@ export type ExpressionRendererProps = Pick< * this callback is called with the given result. */ onRenderFailure?: (result: Result) => void; + className?: string; }; export type ExpressionRenderer = React.FC; @@ -44,6 +45,7 @@ export type ExpressionRenderer = React.FC; export const createRenderer = (run: ExpressionRunner): ExpressionRenderer => ({ expression, onRenderFailure, + className, ...options }: ExpressionRendererProps) => { const mountpoint: React.MutableRefObject = useRef(null); @@ -63,6 +65,7 @@ export const createRenderer = (run: ExpressionRunner): ExpressionRenderer => ({ return (
{ mountpoint.current = el; }} 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 index 1cd7993922f6d..d4d9476240e68 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -158,7 +158,7 @@ export const datatableVisualization: Visualization< > { return tables.map(table => { const title = i18n.translate('xpack.lens.datatable.visualizationOf', { - defaultMessage: 'Table: ${operations}', + defaultMessage: 'Table: {operations}', values: { operations: table.columns.map(col => col.operation.label).join(' & '), }, @@ -174,6 +174,7 @@ export const datatableVisualization: Visualization< label: col.operation.label, })), }, + previewIcon: 'visTable', }; }); }, 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..94d162aa5f1b0 --- /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, toSwitchAction } = actual; 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 index 677b37beab190..1341266ba56a5 100644 --- 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 @@ -23,18 +23,21 @@ function getSuggestedVisualizationState( visualization: Visualization, datasource: DatasourcePublicAPI ) { - const suggestions = visualization.getSuggestions({ - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: datasource.getTableSpec().map(col => ({ - ...col, - operation: datasource.getOperationForColumnId(col.columnId)!, - })), - }, - ], - }); + const suggestions = visualization.getSuggestions( + { + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: datasource.getTableSpec().map(col => ({ + ...col, + operation: datasource.getOperationForColumnId(col.columnId)!, + })), + }, + ], + }, + datasource + ); if (!suggestions.length) { return visualization.initialize(datasource); 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 index 8d67c632ea5c9..90e66336e2880 100644 --- 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 @@ -529,6 +529,7 @@ Object { score: 1, datasourceSuggestionId: 0, state: initialState, + previewIcon: 'empty', }, ]); @@ -627,12 +628,14 @@ Object { score: 0.5, state: {}, title: 'Suggestion2', + previewIcon: 'empty', }, { datasourceSuggestionId: 0, score: 0.8, state: {}, title: 'Suggestion1', + previewIcon: 'empty', }, ], }, @@ -644,12 +647,14 @@ Object { score: 0.4, state: {}, title: 'Suggestion4', + previewIcon: 'empty', }, { datasourceSuggestionId: 0, score: 0.45, state: {}, title: 'Suggestion3', + previewIcon: 'empty', }, ], }, @@ -670,7 +675,7 @@ Object { // TODO why is this necessary? instance.update(); - const suggestions = instance.find('[data-test-subj="suggestion"]'); + const suggestions = instance.find('[data-test-subj="suggestion-title"]'); expect(suggestions.map(el => el.text())).toEqual([ 'Suggestion1', 'Suggestion2', @@ -693,6 +698,7 @@ Object { score: 0.8, state: suggestionVisState, title: 'Suggestion1', + previewIcon: 'empty', }, ], }, @@ -716,7 +722,7 @@ Object { instance.update(); act(() => { - instance.find('[data-test-subj="suggestion"]').simulate('click'); + instance.find('[data-test-subj="suggestion-title"]').simulate('click'); }); expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(1); @@ -747,12 +753,14 @@ Object { score: 0.2, state: {}, title: 'Suggestion1', + previewIcon: 'empty', }, { datasourceSuggestionId: 0, score: 0.8, state: suggestionVisState, title: 'Suggestion2', + previewIcon: 'empty', }, ], }, @@ -801,12 +809,14 @@ Object { score: 0.2, state: {}, title: 'Suggestion1', + previewIcon: 'empty', }, { datasourceSuggestionId: 0, score: 0.6, state: {}, title: 'Suggestion2', + previewIcon: 'empty', }, ], }, @@ -818,6 +828,7 @@ Object { score: 0.8, state: suggestionVisState, title: 'Suggestion3', + previewIcon: 'empty', }, ], }, 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 index 696949ca41c81..e1353530215b1 100644 --- 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 @@ -129,10 +129,12 @@ export function EditorFrame(props: EditorFrameProps) { } /> 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 index ad6cdb0d7e4ee..8566035c29bf2 100644 --- 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 @@ -7,21 +7,12 @@ import { Ast, fromExpression } from '@kbn/interpreter/common'; import { Visualization, Datasource, DatasourcePublicAPI } from '../../types'; -export function buildExpression( - visualization: Visualization | null, - visualizationState: unknown, +export function prependDatasourceExpression( + visualizationExpression: Ast | string | null, datasource: Datasource, - datasourceState: unknown, - datasourcePublicAPI: DatasourcePublicAPI + datasourceState: unknown ): Ast | null { - if (visualization === null) { - return null; - } const datasourceExpression = datasource.toExpression(datasourceState); - const visualizationExpression = visualization.toExpression( - visualizationState, - datasourcePublicAPI - ); if (datasourceExpression === null || visualizationExpression === null) { return null; @@ -40,3 +31,21 @@ export function buildExpression( chain: [...parsedDatasourceExpression.chain, ...parsedVisualizationExpression.chain], }; } + +export function buildExpression( + visualization: Visualization | null, + visualizationState: unknown, + datasource: Datasource, + datasourceState: unknown, + datasourcePublicAPI: DatasourcePublicAPI +): Ast | null { + if (visualization === null) { + return null; + } + const visualizationExpression = visualization.toExpression( + visualizationState, + datasourcePublicAPI + ); + + return prependDatasourceExpression(visualizationExpression, datasource, datasourceState); +} 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..182e36df61797 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss @@ -0,0 +1,2 @@ +@import './workspace_panel.scss'; +@import './suggestion_panel.scss'; \ No newline at end of file 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 index 850cdfc2b3c0f..7acc626bcc918 100644 --- 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 @@ -5,7 +5,7 @@ */ import { getSuggestions } from './suggestion_helpers'; -import { createMockVisualization } from '../mocks'; +import { createMockVisualization, createMockDatasource } from '../mocks'; import { TableSuggestion } from '../../types'; const generateSuggestion = (datasourceSuggestionId: number = 1, state = {}) => ({ @@ -23,12 +23,19 @@ describe('suggestion helpers', () => { vis1: { ...mockVisualization, getSuggestions: () => [ - { datasourceSuggestionId: 0, score: 0.5, title: 'Test', state: suggestedState }, + { + datasourceSuggestionId: 0, + score: 0.5, + title: 'Test', + state: suggestedState, + previewIcon: 'empty', + }, ], }, }, 'vis1', - {} + {}, + createMockDatasource().publicAPIMock ); expect(suggestions).toHaveLength(1); expect(suggestions[0].state).toBe(suggestedState); @@ -43,19 +50,38 @@ describe('suggestion helpers', () => { vis1: { ...mockVisualization1, getSuggestions: () => [ - { datasourceSuggestionId: 0, score: 0.5, title: 'Test', state: {} }, - { datasourceSuggestionId: 0, score: 0.5, title: 'Test2', state: {} }, + { + datasourceSuggestionId: 0, + score: 0.5, + title: 'Test', + state: {}, + previewIcon: 'empty', + }, + { + datasourceSuggestionId: 0, + score: 0.5, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, ], }, vis2: { ...mockVisualization2, getSuggestions: () => [ - { datasourceSuggestionId: 0, score: 0.5, title: 'Test3', state: {} }, + { + datasourceSuggestionId: 0, + score: 0.5, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, ], }, }, 'vis1', - {} + {}, + createMockDatasource().publicAPIMock ); expect(suggestions).toHaveLength(3); }); @@ -69,19 +95,38 @@ describe('suggestion helpers', () => { vis1: { ...mockVisualization1, getSuggestions: () => [ - { datasourceSuggestionId: 0, score: 0.2, title: 'Test', state: {} }, - { datasourceSuggestionId: 0, score: 0.8, title: 'Test2', state: {} }, + { + datasourceSuggestionId: 0, + score: 0.2, + title: 'Test', + state: {}, + previewIcon: 'empty', + }, + { + datasourceSuggestionId: 0, + score: 0.8, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, ], }, vis2: { ...mockVisualization2, getSuggestions: () => [ - { datasourceSuggestionId: 0, score: 0.6, title: 'Test3', state: {} }, + { + datasourceSuggestionId: 0, + score: 0.6, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, ], }, }, 'vis1', - {} + {}, + createMockDatasource().publicAPIMock ); expect(suggestions[0].score).toBe(0.8); expect(suggestions[1].score).toBe(0.6); @@ -100,7 +145,8 @@ describe('suggestion helpers', () => { vis2: mockVisualization2, }, 'vis1', - {} + {}, + createMockDatasource().publicAPIMock ); expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[0]).toBe(table1); expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[1]).toBe(table2); @@ -119,19 +165,38 @@ describe('suggestion helpers', () => { vis1: { ...mockVisualization1, getSuggestions: () => [ - { datasourceSuggestionId: 0, score: 0.3, title: 'Test', state: {} }, - { datasourceSuggestionId: 1, score: 0.2, title: 'Test2', state: {} }, + { + datasourceSuggestionId: 0, + score: 0.3, + title: 'Test', + state: {}, + previewIcon: 'empty', + }, + { + datasourceSuggestionId: 1, + score: 0.2, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, ], }, vis2: { ...mockVisualization2, getSuggestions: () => [ - { datasourceSuggestionId: 1, score: 0.1, title: 'Test3', state: {} }, + { + datasourceSuggestionId: 1, + score: 0.1, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, ], }, }, 'vis1', - {} + {}, + createMockDatasource().publicAPIMock ); expect(suggestions[0].datasourceState).toBe(tableState1); expect(suggestions[1].datasourceState).toBe(tableState2); @@ -141,6 +206,7 @@ describe('suggestion helpers', () => { it('should pass the state of the currently active visualization to getSuggestions', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); + const datasourcePublicAPI = createMockDatasource().publicAPIMock; const currentState = {}; getSuggestions( [generateSuggestion(1), generateSuggestion(2)], @@ -149,17 +215,20 @@ describe('suggestion helpers', () => { vis2: mockVisualization2, }, 'vis1', - currentState + currentState, + datasourcePublicAPI ); expect(mockVisualization1.getSuggestions).toHaveBeenCalledWith( expect.objectContaining({ state: currentState, - }) + }), + datasourcePublicAPI ); expect(mockVisualization2.getSuggestions).not.toHaveBeenCalledWith( expect.objectContaining({ state: currentState, - }) + }), + datasourcePublicAPI ); }); }); 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 index 459f5d89fb9c3..891f492bbcce1 100644 --- 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 @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Visualization, DatasourceSuggestion } from '../../types'; +import { Ast } from '@kbn/interpreter/common'; +import { Visualization, DatasourceSuggestion, DatasourcePublicAPI } from '../../types'; import { Action } from './state_management'; export interface Suggestion { @@ -13,6 +14,8 @@ export interface Suggestion { score: number; title: string; state: unknown; + previewExpression?: Ast | string; + previewIcon: string; } /** @@ -27,7 +30,8 @@ export function getSuggestions( datasourceTableSuggestions: DatasourceSuggestion[], visualizationMap: Record, activeVisualizationId: string | null, - visualizationState: unknown + visualizationState: unknown, + datasourcePublicAPI: DatasourcePublicAPI ): Suggestion[] { const datasourceTables = datasourceTableSuggestions.map(({ table }) => table); @@ -35,10 +39,13 @@ export function getSuggestions( Object.entries(visualizationMap) .map(([visualizationId, visualization]) => { return visualization - .getSuggestions({ - tables: datasourceTables, - state: visualizationId === activeVisualizationId ? visualizationState : undefined, - }) + .getSuggestions( + { + tables: datasourceTables, + state: visualizationId === activeVisualizationId ? visualizationState : undefined, + }, + datasourcePublicAPI + ) .map(({ datasourceSuggestionId, ...suggestion }) => ({ ...suggestion, visualizationId, 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..2139b314ae314 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.scss @@ -0,0 +1,21 @@ +.lnsSidebar__suggestions { + > * { + margin-top: $euiSizeS; + } +} + +$suggestionHeight: 120px; + +.lnsSidebar__suggestionIcon { + width: 100%; + height: $suggestionHeight; + display: flex; + align-items: center; + justify-content: center; + padding: $euiSize; +} + + .lnsSuggestionChartWrapper { + height: $suggestionHeight; + pointer-events: none; + } \ No newline at end of file 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..70e2366d38cc8 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.test.tsx @@ -0,0 +1,181 @@ +/* + * 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, +} from '../mocks'; +import { ExpressionRenderer } from 'src/legacy/core_plugins/data/public'; +import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel'; +import { getSuggestions, Suggestion } from './suggestion_helpers'; +import { fromExpression } from '@kbn/interpreter/target/common'; +import { EuiIcon } 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, + state: suggestion1State, + visualizationId: 'vis', + title: 'Suggestion1', + }, + { + datasourceState: {}, + previewIcon: 'empty', + score: 0.5, + state: suggestion2State, + visualizationId: 'vis', + title: 'Suggestion2', + }, + ] as Suggestion[]); + + defaultProps = { + activeDatasource: mockDatasource, + datasourceState: {}, + activeVisualizationId: 'vis', + visualizationMap: { + vis: mockVisualization, + }, + visualizationState: {}, + dispatch: dispatchMock, + ExpressionRenderer: expressionRendererMock, + datasourcePublicAPI: mockDatasource.publicAPIMock, + }; + }); + + it('should list passed in suggestions', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="suggestion-title"]').map(el => el.text())).toEqual([ + 'Suggestion1', + 'Suggestion2', + ]); + }); + + it('should dispatch visualization switch action if suggestion is clicked', () => { + const wrapper = mount(); + + wrapper + .find('[data-test-subj="suggestion-title"]') + .first() + .simulate('click'); + + expect(dispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SWITCH_VISUALIZATION', + initialState: suggestion1State, + }) + ); + }); + + it('should render preview expression if there is one', () => { + (getSuggestions as jest.Mock).mockReturnValue([ + { + datasourceState: {}, + previewIcon: 'empty', + score: 0.5, + state: suggestion1State, + visualizationId: 'vis', + title: 'Suggestion1', + }, + { + datasourceState: {}, + previewIcon: 'empty', + score: 0.5, + state: 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": "datasource_expression", + "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', () => { + (getSuggestions as jest.Mock).mockReturnValue([ + { + datasourceState: {}, + previewIcon: 'visTable', + score: 0.5, + state: suggestion1State, + visualizationId: 'vis', + title: 'Suggestion1', + }, + { + datasourceState: {}, + previewIcon: 'empty', + score: 0.5, + state: 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 index 9d9730db37651..7c86b8e1522df 100644 --- 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 @@ -4,12 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip } from '@elastic/eui'; +import { toExpression } from '@kbn/interpreter/common'; +import { i18n } from '@kbn/i18n'; import { Action } from './state_management'; -import { Datasource, Visualization } from '../../types'; -import { getSuggestions, toSwitchAction } from './suggestion_helpers'; +import { Datasource, Visualization, DatasourcePublicAPI } from '../../types'; +import { getSuggestions, toSwitchAction, Suggestion } from './suggestion_helpers'; +import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/data/public'; +import { prependDatasourceExpression } from './expression_helpers'; export interface SuggestionPanelProps { activeDatasource: Datasource; @@ -17,9 +22,75 @@ export interface SuggestionPanelProps { activeVisualizationId: string | null; visualizationMap: Record; visualizationState: unknown; + datasourcePublicAPI: DatasourcePublicAPI; dispatch: (action: Action) => void; + ExpressionRenderer: ExpressionRenderer; } +const SuggestionPreview = ({ + suggestion, + dispatch, + previewExpression, + ExpressionRenderer: ExpressionRendererComponent, +}: { + suggestion: Suggestion; + dispatch: (action: Action) => void; + ExpressionRenderer: ExpressionRenderer; + previewExpression?: string; +}) => { + const [expressionError, setExpressionError] = useState(false); + + useEffect( + () => { + setExpressionError(false); + }, + [previewExpression] + ); + + return ( + { + dispatch(toSwitchAction(suggestion)); + }} + > + +

{suggestion.title}

+
+ {expressionError ? ( +
+ +
+ ) : previewExpression ? ( + { + // eslint-disable-next-line no-console + console.error(`Failed to render preview: `, e); + setExpressionError(true); + }} + /> + ) : ( +
+ +
+ )} +
+ ); +}; + export function SuggestionPanel({ activeDatasource, datasourceState, @@ -27,6 +98,8 @@ export function SuggestionPanel({ visualizationMap, visualizationState, dispatch, + datasourcePublicAPI, + ExpressionRenderer: ExpressionRendererComponent, }: SuggestionPanelProps) { const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsFromCurrentState( datasourceState @@ -36,30 +109,38 @@ export function SuggestionPanel({ datasourceSuggestions, visualizationMap, activeVisualizationId, - visualizationState + visualizationState, + datasourcePublicAPI ); return ( - <> -

- -

+
+ +

+ +

+
{suggestions.map((suggestion, index) => { + const previewExpression = suggestion.previewExpression + ? prependDatasourceExpression( + suggestion.previewExpression, + activeDatasource, + suggestion.datasourceState + ) + : null; return ( - + ); })} - +
); } diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.scss b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.scss new file mode 100644 index 0000000000000..03c3534e1e12b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.scss @@ -0,0 +1,3 @@ + .lnsChartWrapper { + height: 500px; + } \ No newline at end of file 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 index 1294cbb2aa498..1b5d89f50bdd4 100644 --- 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 @@ -314,6 +314,7 @@ Object { title: 'my title', state: {}, datasourceSuggestionId: 0, + previewIcon: 'empty', }, ]); @@ -328,7 +329,8 @@ Object { expect(mockVisualization.getSuggestions).toHaveBeenCalledWith( expect.objectContaining({ tables: [expectedTable], - }) + }), + expect.anything() ); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SWITCH_VISUALIZATION', @@ -365,12 +367,14 @@ Object { isFirst: true, }, datasourceSuggestionId: 1, + previewIcon: 'empty', }, { score: 0.5, title: 'second suggestion', state: {}, datasourceSuggestionId: 0, + previewIcon: 'empty', }, ]); 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 index dc9b5ffce6b49..b65941e1838c2 100644 --- 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 @@ -47,7 +47,8 @@ export function WorkspacePanel({ datasourceSuggestions, visualizationMap, activeVisualizationId, - visualizationState + visualizationState, + datasourcePublicAPI ); if (suggestions.length === 0) { @@ -137,6 +138,7 @@ export function WorkspacePanel({ } else { return ( { setExpressionError(e); diff --git a/x-pack/legacy/plugins/lens/public/index.scss b/x-pack/legacy/plugins/lens/public/index.scss index 6e5372e233a0d..12f384081fe7a 100644 --- a/x-pack/legacy/plugins/lens/public/index.scss +++ b/x-pack/legacy/plugins/lens/public/index.scss @@ -3,4 +3,5 @@ @import './drag_drop/drag_drop.scss'; @import './xy_visualization_plugin/xy_expression.scss'; -@import './indexpattern_plugin/indexpattern'; \ No newline at end of file +@import './indexpattern_plugin/indexpattern'; +@import './editor_frame_plugin/editor_frame/index'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 367d1bdd99c79..0e38cc3c32a12 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -154,6 +154,8 @@ export interface VisualizationSuggestion { title: string; state: T; datasourceSuggestionId: number; + previewExpression?: Ast | string; + previewIcon: string; } export interface Visualization { @@ -168,5 +170,8 @@ export interface Visualization { // The frame will call this function on all visualizations when the table changes, or when // rendering additional ways of using the data - getSuggestions: (options: SuggestionRequest) => Array>; + getSuggestions: ( + options: SuggestionRequest, + datasource: DatasourcePublicAPI + ) => Array>; } 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 index c1f13d5db63ab..2cf89a4a58196 100644 --- 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 @@ -40,6 +40,9 @@ Object { "accessor": Array [ "a", ], + "hide": Array [ + false, + ], "position": Array [ "bottom", ], @@ -66,6 +69,9 @@ Object { "b", "c", ], + "hide": Array [ + false, + ], "labels": Array [ "b", "c", 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..1807145996126 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts @@ -0,0 +1,80 @@ +/* + * 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 { State } from './types'; +import { DatasourcePublicAPI } from '../types'; + +export const toExpression = (state: State, datasource: DatasourcePublicAPI): Ast => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_chart', + arguments: { + seriesType: [state.seriesType], + title: [state.title], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_legendConfig', + arguments: { + isVisible: [state.legend.isVisible], + position: [state.legend.position], + }, + }, + ], + }, + ], + x: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_xConfig', + arguments: { + title: [state.x.title], + showGridlines: [state.x.showGridlines], + position: [state.x.position], + accessor: [state.x.accessor], + hide: [Boolean(state.x.hide)], + }, + }, + ], + }, + ], + y: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_yConfig', + arguments: { + title: [state.y.title], + showGridlines: [state.y.showGridlines], + position: [state.y.position], + accessors: state.y.accessors, + hide: [Boolean(state.y.hide)], + labels: state.y.accessors.map(accessor => { + const operation = datasource.getOperationForColumnId(accessor); + return operation ? operation.label : accessor; + }), + }, + }, + ], + }, + ], + splitSeriesAccessors: state.splitSeriesAccessors, + stackAccessors: state.stackAccessors, + }, + }, + ], +}); 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 index 71e83943616ac..57a5bcd4966c8 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -53,6 +53,7 @@ interface AxisConfig { title: string; showGridlines: boolean; position: Position; + hide?: boolean; } const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = { @@ -69,6 +70,11 @@ const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = options: [Position.Top, Position.Right, Position.Bottom, Position.Left], help: 'The position of the axis', }, + hide: { + types: ['boolean'], + default: false, + help: 'Show / hide axis', + }, }; export interface YState extends AxisConfig { 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 index 93986078f68b1..9ba7326af6a56 100644 --- 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 @@ -1,4 +1,3 @@ .lnsChart { - // TODO style this dependent on the screen height (see POC) - height: 500px; + height: 100%; } \ No newline at end of file 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 index ea3555631f126..ad97576902462 100644 --- 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 @@ -140,6 +140,7 @@ export function XYChart({ data, args }: XYChartProps) { position={x.position} title={x.title} showGridLines={x.showGridlines} + hide={x.hide} /> {seriesType === 'line' ? ( 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 index b034c9fe78b27..82c3b91e3246f 100644 --- 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 @@ -7,6 +7,8 @@ import { getSuggestions } from './xy_suggestions'; import { TableColumn, VisualizationSuggestion } from '../types'; import { State } from './types'; +import { Ast } from '@kbn/interpreter/target/common'; +import { createMockDatasource } from '../editor_frame_plugin/mocks'; describe('xy_suggestions', () => { function numCol(columnId: string): TableColumn { @@ -67,27 +69,41 @@ describe('xy_suggestions', () => { }; expect( - getSuggestions({ - tables: [ - { datasourceSuggestionId: 0, isMultiRow: true, columns: [dateCol('a')] }, - { datasourceSuggestionId: 1, isMultiRow: true, columns: [strCol('foo'), strCol('bar')] }, - { datasourceSuggestionId: 2, isMultiRow: false, columns: [strCol('foo'), numCol('bar')] }, - { datasourceSuggestionId: 3, isMultiRow: true, columns: [unknownCol(), numCol('bar')] }, - ], - }) + getSuggestions( + { + tables: [ + { datasourceSuggestionId: 0, isMultiRow: true, columns: [dateCol('a')] }, + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [strCol('foo'), strCol('bar')], + }, + { + datasourceSuggestionId: 2, + isMultiRow: false, + columns: [strCol('foo'), numCol('bar')], + }, + { datasourceSuggestionId: 3, isMultiRow: true, columns: [unknownCol(), numCol('bar')] }, + ], + }, + createMockDatasource().publicAPIMock + ) ).toEqual([]); }); test('suggests a basic x y chart with date on x', () => { - const [suggestion, ...rest] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: [numCol('bytes'), dateCol('date')], - }, - ], - }); + const [suggestion, ...rest] = getSuggestions( + { + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + }, + ], + }, + createMockDatasource().publicAPIMock + ); expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` @@ -104,15 +120,18 @@ Object { }); test('suggests a split x y chart with date on x', () => { - const [suggestion, ...rest] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], - }, - ], - }); + const [suggestion, ...rest] = getSuggestions( + { + tables: [ + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + }, + ], + }, + createMockDatasource().publicAPIMock + ); expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` @@ -132,20 +151,23 @@ Object { }); test('supports multiple suggestions', () => { - const [s1, s2, ...rest] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: [numCol('price'), dateCol('date')], - }, - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('count'), strCol('country')], - }, - ], - }); + const [s1, s2, ...rest] = getSuggestions( + { + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [numCol('price'), dateCol('date')], + }, + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [numCol('count'), strCol('country')], + }, + ], + }, + createMockDatasource().publicAPIMock + ); expect(rest).toHaveLength(0); expect([suggestionSubset(s1), suggestionSubset(s2)]).toMatchInlineSnapshot(` @@ -173,15 +195,18 @@ Array [ }); test('handles two numeric values', () => { - const [suggestion] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('quantity'), numCol('price')], - }, - ], - }); + const [suggestion] = getSuggestions( + { + tables: [ + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [numCol('quantity'), numCol('price')], + }, + ], + }, + createMockDatasource().publicAPIMock + ); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Object { @@ -197,26 +222,29 @@ Object { }); test('handles unbucketed suggestions', () => { - const [suggestion] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [ - numCol('num votes'), - { - columnId: 'mybool', - operation: { - dataType: 'boolean', - id: 'mybool', - isBucketed: false, - label: 'Yes / No', + const [suggestion] = getSuggestions( + { + tables: [ + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [ + numCol('num votes'), + { + columnId: 'mybool', + operation: { + dataType: 'boolean', + id: 'mybool', + isBucketed: false, + label: 'Yes / No', + }, }, - }, - ], - }, - ], - }); + ], + }, + ], + }, + createMockDatasource().publicAPIMock + ); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Object { @@ -230,4 +258,27 @@ Object { } `); }); + + test('adds a preview expression with disabled axes and legend', () => { + const [suggestion] = getSuggestions( + { + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + }, + ], + }, + createMockDatasource().publicAPIMock + ); + + 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.x[0] as Ast).chain[0].arguments.hide[0]).toBeTruthy(); + expect((expression.chain[0].arguments.y[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 index 28ef677e49644..a9207d409eb84 100644 --- 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 @@ -6,8 +6,15 @@ import { partition } from 'lodash'; import { Position } from '@elastic/charts'; -import { SuggestionRequest, VisualizationSuggestion, TableColumn, TableSuggestion } from '../types'; +import { + SuggestionRequest, + VisualizationSuggestion, + TableColumn, + TableSuggestion, + DatasourcePublicAPI, +} from '../types'; import { State } from './types'; +import { toExpression } from './to_expression'; const columnSortOrder = { date: 0, @@ -22,7 +29,8 @@ const columnSortOrder = { * @param opts */ export function getSuggestions( - opts: SuggestionRequest + opts: SuggestionRequest, + datasource: DatasourcePublicAPI ): Array> { return opts.tables .filter( @@ -35,10 +43,13 @@ export function getSuggestions( columns.some(col => col.operation.dataType === 'number') && !columns.some(col => !columnSortOrder.hasOwnProperty(col.operation.dataType)) ) - .map(table => getSuggestionForColumns(table)); + .map(table => getSuggestionForColumns(datasource, table)); } -function getSuggestionForColumns(table: TableSuggestion): VisualizationSuggestion { +function getSuggestionForColumns( + datasource: DatasourcePublicAPI, + table: TableSuggestion +): VisualizationSuggestion { const [buckets, values] = partition( prioritizeColumns(table.columns), col => col.operation.isBucketed @@ -46,10 +57,10 @@ function getSuggestionForColumns(table: TableSuggestion): VisualizationSuggestio if (buckets.length >= 1) { const [x, splitBy] = buckets; - return getSuggestion(table.datasourceSuggestionId, x, values, splitBy); + return getSuggestion(datasource, table.datasourceSuggestionId, x, values, splitBy); } else { const [x, ...yValues] = values; - return getSuggestion(table.datasourceSuggestionId, x, yValues); + return getSuggestion(datasource, table.datasourceSuggestionId, x, yValues); } } @@ -63,6 +74,7 @@ function prioritizeColumns(columns: TableColumn[]) { } function getSuggestion( + datasource: DatasourcePublicAPI, datasourceSuggestionId: number, xValue: TableColumn, yValues: TableColumn[], @@ -75,28 +87,49 @@ function getSuggestion( // TODO: Localize the title, label, etc const preposition = isDate ? 'over' : 'of'; const title = `${yTitle} ${preposition} ${xTitle}`; + const state: State = { + title, + legend: { isVisible: true, position: Position.Right }, + seriesType: isDate ? 'line' : 'bar', + splitSeriesAccessors: splitBy && isDate ? [splitBy.columnId] : [], + stackAccessors: splitBy && !isDate ? [splitBy.columnId] : [], + x: { + accessor: xValue.columnId, + position: Position.Bottom, + showGridlines: false, + title: xTitle, + }, + y: { + accessors: yValues.map(col => col.columnId), + position: Position.Left, + showGridlines: false, + title: yTitle, + }, + }; + return { title, score: 1, datasourceSuggestionId, - state: { - title, - legend: { isVisible: true, position: Position.Right }, - seriesType: isDate ? 'line' : 'bar', - splitSeriesAccessors: splitBy && isDate ? [splitBy.columnId] : [], - stackAccessors: splitBy && !isDate ? [splitBy.columnId] : [], - x: { - accessor: xValue.columnId, - position: Position.Bottom, - showGridlines: false, - title: xTitle, + state, + previewIcon: isDate ? 'visLine' : 'visBar', + previewExpression: toExpression( + { + ...state, + x: { + ...state.x, + hide: true, + }, + y: { + ...state.y, + hide: true, + }, + legend: { + ...state.legend, + isVisible: false, + }, }, - y: { - accessors: yValues.map(col => col.columnId), - position: Position.Left, - showGridlines: false, - title: yTitle, - }, - }, + datasource + ), }; } 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 index 494b653c6d944..efb9cc7fc1518 100644 --- 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 @@ -12,6 +12,7 @@ import { getSuggestions } from './xy_suggestions'; import { XYConfigPanel } from './xy_config_panel'; import { Visualization } from '../types'; import { State, PersistableState } from './types'; +import { toExpression } from './to_expression'; export const xyVisualization: Visualization = { getSuggestions, @@ -50,72 +51,5 @@ export const xyVisualization: Visualization = { domElement ), - toExpression: (state, datasource) => ({ - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_xy_chart', - arguments: { - seriesType: [state.seriesType], - title: [state.title], - legend: [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_xy_legendConfig', - arguments: { - isVisible: [state.legend.isVisible], - position: [state.legend.position], - }, - }, - ], - }, - ], - x: [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_xy_xConfig', - arguments: { - title: [state.x.title], - showGridlines: [state.x.showGridlines], - position: [state.x.position], - accessor: [state.x.accessor], - }, - }, - ], - }, - ], - y: [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_xy_yConfig', - arguments: { - title: [state.y.title], - showGridlines: [state.y.showGridlines], - position: [state.y.position], - accessors: state.y.accessors, - labels: state.y.accessors.map(accessor => { - const operation = datasource.getOperationForColumnId(accessor); - return operation ? operation.label : accessor; - }), - }, - }, - ], - }, - ], - splitSeriesAccessors: state.splitSeriesAccessors, - stackAccessors: state.stackAccessors, - }, - }, - ], - }), + toExpression, }; From 2297f95ecd4cedf7e9a6bf96e7895ee22cda7d32 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 27 Jun 2019 14:07:42 +0200 Subject: [PATCH 036/105] [Lens] Popover configs (#39565) --- x-pack/legacy/plugins/lens/public/index.scss | 4 +- .../__mocks__/operations.ts | 2 +- .../dimension_panel/_index.scss | 2 + .../dimension_panel/_popover.scss | 30 + .../dimension_panel/_summary.scss | 32 + .../dimension_panel/dimension_panel.test.tsx | 650 +++++++++++------- .../dimension_panel/dimension_panel.tsx | 60 +- .../dimension_panel/field_select.tsx | 175 +++-- .../dimension_panel/popover_editor.tsx | 206 ++++++ .../dimension_panel/settings.tsx | 111 --- .../indexpattern_plugin/indexpattern.scss | 2 + .../date_histogram.test.tsx | 2 +- .../operation_definitions/date_histogram.tsx | 3 +- .../operation_definitions/terms.test.tsx | 15 +- .../operation_definitions/terms.tsx | 47 +- .../public/indexpattern_plugin/operations.ts | 4 +- .../indexpattern_plugin/state_helpers.test.ts | 42 ++ .../indexpattern_plugin/state_helpers.ts | 15 +- 18 files changed, 860 insertions(+), 542 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_index.scss create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_popover.scss create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_summary.scss create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx delete mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx diff --git a/x-pack/legacy/plugins/lens/public/index.scss b/x-pack/legacy/plugins/lens/public/index.scss index 12f384081fe7a..6beb75e3b4dbb 100644 --- a/x-pack/legacy/plugins/lens/public/index.scss +++ b/x-pack/legacy/plugins/lens/public/index.scss @@ -1,7 +1,7 @@ // Import the EUI global scope so we can use EUI constants @import 'src/legacy/ui/public/styles/_styling_constants'; -@import './drag_drop/drag_drop.scss'; @import './xy_visualization_plugin/xy_expression.scss'; @import './indexpattern_plugin/indexpattern'; -@import './editor_frame_plugin/editor_frame/index'; \ No newline at end of file +@import './drag_drop/drag_drop.scss'; +@import './editor_frame_plugin/editor_frame/index'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts index 9c07b07ae27fa..0602feff52d95 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts @@ -7,7 +7,7 @@ const actual = jest.requireActual('../operations'); jest.spyOn(actual, 'getPotentialColumns'); -jest.spyOn(actual.operationDefinitionMap.date_histogram, 'inlineOptions'); +jest.spyOn(actual.operationDefinitionMap.date_histogram, 'paramEditor'); export const { getPotentialColumns, 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..b5701daf31d7e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_popover.scss @@ -0,0 +1,30 @@ +.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__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..f16b2acc8ba03 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_summary.scss @@ -0,0 +1,32 @@ +.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%; +} + +.lnsConfigPanel__summaryIcon { + margin-right: $euiSizeXS; +} + +.lnsConfigPanel__summaryLink { + max-width: 100%; + display: flex; +} + +.lnsConfigPanel__summaryField { + color: $euiColorPrimary; +} 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 index 863bd005a5a5c..b5bd083472566 100644 --- 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 @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mount, shallow } from 'enzyme'; +import { ReactWrapper, ShallowWrapper } from 'enzyme'; import React from 'react'; -import { EuiComboBox, EuiContextMenuItem } from '@elastic/eui'; +import { EuiComboBox, EuiSideNav, EuiPopover } from '@elastic/eui'; import { IndexPatternPrivateState } from '../indexpattern'; import { changeColumn } from '../state_helpers'; -import { getPotentialColumns, operationDefinitionMap } from '../operations'; -import { IndexPatternDimensionPanel } from './dimension_panel'; +import { getPotentialColumns } from '../operations'; +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'; jest.mock('../state_helpers'); jest.mock('../operations'); @@ -35,6 +36,12 @@ const expectedIndexPatterns = { aggregatable: true, searchable: true, }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, { name: 'source', type: 'string', @@ -46,9 +53,19 @@ const expectedIndexPatterns = { }; 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, @@ -71,21 +88,29 @@ describe('IndexPatternDimensionPanel', () => { }, }; + setState = jest.fn(); + dragDropContext = createMockedDragDropContext(); + defaultProps = { + dragDropContext, + state, + setState, + columnId: 'col1', + filterOperations: () => true, + }; + jest.clearAllMocks(); }); + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + it('should display a configure button if dimension has no column yet', () => { - const wrapper = mount( - {}} - columnId={'col2'} - filterOperations={() => true} - /> - ); + wrapper = mount(); expect( wrapper .find('[data-test-subj="indexPattern-configure-dimension"]') @@ -95,16 +120,7 @@ describe('IndexPatternDimensionPanel', () => { }); it('should pass the right arguments to getPotentialColumns', async () => { - shallow( - {}} - columnId={'col1'} - filterOperations={() => true} - suggestedPriority={1} - /> - ); + wrapper = shallow(); expect(getPotentialColumns as jest.Mock).toHaveBeenCalledWith(state, 1); }); @@ -112,154 +128,93 @@ describe('IndexPatternDimensionPanel', () => { it('should call the filterOperations function', () => { const filterOperations = jest.fn().mockReturnValue(true); - shallow( - {}} - columnId={'col2'} - filterOperations={filterOperations} - /> + wrapper = shallow( + ); expect(filterOperations).toBeCalled(); }); it('should show field select combo box on click', () => { - const wrapper = mount( - {}} - columnId={'col2'} - filterOperations={() => true} - /> - ); + wrapper = mount(); - wrapper - .find('[data-test-subj="indexPattern-configure-dimension"]') - .first() - .simulate('click'); + openPopover(); expect(wrapper.find(EuiComboBox)).toHaveLength(1); }); it('should not show any choices if the filter returns false', () => { - const wrapper = mount( + wrapper = mount( {}} + {...defaultProps} columnId={'col2'} filterOperations={() => false} /> ); - wrapper - .find('[data-test-subj="indexPattern-configure-dimension"]') - .first() - .simulate('click'); + openPopover(); expect(wrapper.find(EuiComboBox)!.prop('options')!).toHaveLength(0); }); - it('should render the inline options directly', () => { - mount( - {}} - columnId={'col1'} - filterOperations={() => false} - /> - ); + it('should list all field names and document as a whole in prioritized order', () => { + wrapper = mount(); - expect(operationDefinitionMap.date_histogram.inlineOptions as jest.Mock).toHaveBeenCalledTimes( - 1 - ); - }); + openPopover(); - it('should not render the settings button if there are no settings or options', () => { - const wrapper = mount( - {}} - columnId={'col1'} - filterOperations={() => false} - /> - ); + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options![0].label).toEqual('Document'); - expect(wrapper.find('[data-test-subj="indexPattern-dimensionPopover-button"]')).toHaveLength(0); + expect(options![1].options!.map(({ label }) => label)).toEqual([ + 'timestamp', + 'bytes', + 'memory', + 'source', + ]); }); - it('should render the settings button if there are settings', () => { - const wrapper = mount( + it('should indicate fields which are imcompatible for the operation of the current column', () => { + wrapper = mount( {}} - columnId={'col1'} - filterOperations={() => false} - /> - ); - - expect( - wrapper.find('EuiButtonIcon[data-test-subj="indexPattern-dimensionPopover-button"]').length - ).toBe(1); - }); - - it('should list all field names and document as a whole in sorted order', () => { - const wrapper = mount( - {}} - columnId={'col1'} - filterOperations={() => true} /> ); - wrapper - .find('[data-test-subj="indexPattern-configure-dimension"]') - .first() - .simulate('click'); + openPopover(); const options = wrapper.find(EuiComboBox).prop('options'); - expect(options![0].label).toEqual('Document'); + expect(options![0].className).toContain('incompatible'); - expect(options![1].options!.map(({ label }) => label)).toEqual([ - 'bytes', - 'source', - 'timestamp', - ]); + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0].className + ).toContain('incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0].className + ).not.toContain('incompatible'); }); - it('should show all functions that work with the current column', () => { - const setState = jest.fn(); - - const wrapper = mount( + it('should indicate operations which are incompatible for the field of the current column', () => { + wrapper = mount( { }, }, }} - setState={setState} - columnId={'col1'} - filterOperations={() => true} /> ); - wrapper - .find('[data-test-subj="indexPattern-dimensionPopover-button"]') - .first() - .simulate('click'); + openPopover(); - expect(wrapper.find(EuiContextMenuItem).map(instance => instance.text())).toEqual([ - 'Minimum', - 'Maximum', - 'Average', - 'Sum', - ]); + const options = (wrapper.find(EuiSideNav).prop('items')[0].items as unknown) as Array<{ + name: string; + className: string; + }>; + + expect(options.find(({ name }) => name === 'Minimum')!.className).not.toContain('incompatible'); + + expect(options.find(({ name }) => name === 'Date Histogram')!.className).toContain( + 'incompatible' + ); + }); + + it('should keep the operation when switching to another field compatible with this operation', () => { + const initialState: IndexPatternPrivateState = { + ...state, + columns: { + ...state.columns, + col1: { + operationId: 'op1', + 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')!; + + comboBox.prop('onChange')!([option]); + + expect(setState).toHaveBeenCalledWith({ + ...initialState, + columns: { + ...state.columns, + col1: expect.objectContaining({ + operationType: 'max', + sourceField: 'memory', + // Other parts of this don't matter for this test + }), + }, + }); }); - it('should update the datasource state on selection of an operation', () => { - const setState = jest.fn(); + it('should switch operations when selecting a field that requires another operation', () => { + wrapper = mount(); + + openPopover(); - const wrapper = mount( + const comboBox = wrapper.find(EuiComboBox)!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; + + comboBox.prop('onChange')!([option]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.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( { }, }, }} - setState={setState} - columnId={'col1'} - filterOperations={() => true} - suggestedPriority={1} /> ); - wrapper - .find('[data-test-subj="indexPattern-dimensionPopover-button"]') - .first() - .simulate('click'); + openPopover(); - wrapper - .find('[data-test-subj="lns-indexPatternDimension-min"]') - .first() - .simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); expect(setState).toHaveBeenCalledWith({ ...state, @@ -347,61 +351,203 @@ describe('IndexPatternDimensionPanel', () => { }); }); - it('should update the datasource state on selection of a field', () => { - const setState = jest.fn(); + it('should not set the state if selecting the currently active operation', () => { + wrapper = mount(); - const wrapper = mount( - true} - suggestedPriority={1} - /> - ); + openPopover(); wrapper - .find('[data-test-subj="indexPattern-configure-dimension"]') - .first() + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') .simulate('click'); - const comboBox = wrapper.find(EuiComboBox)!; - const option = comboBox.prop('options')![1].options![1]; + expect(setState).not.toHaveBeenCalled(); + }); - comboBox.prop('onChange')!([option]); + it('should update label on label input changes', () => { + wrapper = mount(); + + openPopover(); + + wrapper + .find('input[data-test-subj="indexPattern-label-edit"]') + .simulate('change', { target: { value: 'New Label' } }); expect(setState).toHaveBeenCalledWith({ ...state, columns: { ...state.columns, col1: expect.objectContaining({ - operationType: 'terms', - sourceField: 'source', + label: 'New Label', // Other parts of this don't matter for this test }), }, }); }); - it('should add a column on selection of a field', () => { - const setState = jest.fn(); + describe('transient invalid state', () => { + it('should not set the state if selecting an operation incompatible with the current field', () => { + wrapper = mount(); + + openPopover(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-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-indexPatternDimension-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-indexPatternDimension-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-indexPatternDimension-terms"]').simulate('click'); + + 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-indexPatternDimension-terms"]').simulate('click'); + + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options![0].className).toContain('incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0].className + ).toContain('incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'source')[0].className + ).not.toContain('incompatible'); + }); + + it('should set datasource state if compatible field is selected for operation', () => { + wrapper = mount(); + + openPopover(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + + const comboBox = wrapper.find(EuiComboBox)!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; + + comboBox.prop('onChange')!([option]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + 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'); + + comboBox.prop('onChange')!([options![1].options![0]]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // 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'); - const wrapper = mount( + expect(options![0].className).toContain('incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0].className + ).toContain('incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'bytes')[0].className + ).not.toContain('incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0].className + ).not.toContain('incompatible'); + }); + + it('should show all operations that are not filtered out', () => { + wrapper = mount( true} - suggestedPriority={1} + {...defaultProps} + filterOperations={op => !op.isBucketed && op.dataType === 'number'} /> ); - wrapper - .find('[data-test-subj="indexPattern-configure-dimension"]') - .first() - .simulate('click'); + openPopover(); + + expect( + wrapper + .find(EuiSideNav) + .prop('items')[0] + .items.map(({ name }) => name) + ).toEqual(['Count', 'Maximum', 'Average', 'Sum', 'Minimum']); + }); + + 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]; @@ -422,46 +568,33 @@ describe('IndexPatternDimensionPanel', () => { }); it('should use helper function when changing the function', () => { - const setState = jest.fn(); - - const wrapper = mount( - true} - suggestedPriority={1} - /> - ); + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }; + wrapper = mount(); - wrapper - .find('[data-test-subj="indexPattern-dimensionPopover-button"]') - .first() - .simulate('click'); + openPopover(); wrapper .find('[data-test-subj="lns-indexPatternDimension-min"]') .first() - .simulate('click'); + .prop('onClick')!({} as React.MouseEvent<{}, MouseEvent>); expect(changeColumn).toHaveBeenCalledWith( - expect.anything(), + initialState, 'col1', expect.objectContaining({ sourceField: 'bytes', @@ -471,17 +604,7 @@ describe('IndexPatternDimensionPanel', () => { }); it('should clear the dimension with the clear button', () => { - const setState = jest.fn(); - - const wrapper = mount( - true} - /> - ); + wrapper = mount(); const clearButton = wrapper.find( 'EuiButtonIcon[data-test-subj="indexPattern-dimensionPopover-remove"]' @@ -496,6 +619,20 @@ describe('IndexPatternDimensionPanel', () => { }); }); + it('should clear the dimension when removing the selection in field combobox', () => { + wrapper = mount(); + + openPopover(); + + wrapper.find(EuiComboBox).prop('onChange')!([]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: {}, + columnOrder: [], + }); + }); + describe('drag and drop', () => { function dragDropState() { return { @@ -512,18 +649,10 @@ describe('IndexPatternDimensionPanel', () => { } it('is not droppable if no drag is happening', () => { - const component = mount( - {}} - columnId={'col2'} - filterOperations={() => true} - /> - ); + wrapper = mount(); expect( - component + wrapper .find('[data-test-subj="indexPattern-dropTarget"]') .first() .prop('droppable') @@ -531,21 +660,19 @@ describe('IndexPatternDimensionPanel', () => { }); it('is not droppable if the dragged item has no type', () => { - const component = shallow( + wrapper = shallow( {}} - columnId={'col2'} - filterOperations={() => true} /> ); expect( - component + wrapper .find('[data-test-subj="indexPattern-dropTarget"]') .first() .prop('droppable') @@ -553,21 +680,20 @@ describe('IndexPatternDimensionPanel', () => { }); it('is not droppable if field is not supported by filterOperations', () => { - const component = shallow( + wrapper = shallow( {}} - columnId={'col2'} filterOperations={() => false} /> ); expect( - component + wrapper .find('[data-test-subj="indexPattern-dropTarget"]') .first() .prop('droppable') @@ -575,21 +701,20 @@ describe('IndexPatternDimensionPanel', () => { }); it('is droppable if the field is supported by filterOperations', () => { - const component = shallow( + wrapper = shallow( {}} - columnId={'col2'} filterOperations={op => op.dataType === 'number'} /> ); expect( - component + wrapper .find('[data-test-subj="indexPattern-dropTarget"]') .first() .prop('droppable') @@ -599,21 +724,20 @@ describe('IndexPatternDimensionPanel', () => { it('appends the dropped column when a field is dropped', () => { const dragging = { type: 'number', name: 'bar' }; const testState = dragDropState(); - const setState = jest.fn(); - const component = shallow( + wrapper = shallow( op.dataType === 'number'} /> ); - const onDrop = component + const onDrop = wrapper .find('[data-test-subj="indexPattern-dropTarget"]') .first() .prop('onDrop') as DropHandler; @@ -637,21 +761,19 @@ describe('IndexPatternDimensionPanel', () => { it('updates a column when a field is dropped', () => { const dragging = { type: 'number', name: 'bar' }; const testState = dragDropState(); - const setState = jest.fn(); - const component = shallow( + wrapper = shallow( op.dataType === 'number'} /> ); - const onDrop = component + const onDrop = wrapper .find('[data-test-subj="indexPattern-dropTarget"]') .first() .prop('onDrop') as DropHandler; @@ -671,24 +793,22 @@ describe('IndexPatternDimensionPanel', () => { ); }); - it('ignores drops of unsupported fields', () => { + it('ignores drops of incompatible fields', () => { const dragging = { type: 'number', name: 'baz' }; const testState = dragDropState(); - const setState = jest.fn(); - const component = shallow( + wrapper = shallow( op.dataType === 'number'} /> ); - const onDrop = component + const onDrop = wrapper .find('[data-test-subj="indexPattern-dropTarget"]') .first() .prop('onDrop') as DropHandler; 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 index bd318be148e17..2671da17bfd3a 100644 --- 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 @@ -6,7 +6,8 @@ import _ from 'lodash'; import React from 'react'; -import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { DatasourceDimensionPanelProps } from '../../types'; import { IndexPatternColumn, @@ -15,11 +16,10 @@ import { IndexPatternField, } from '../indexpattern'; -import { getPotentialColumns, operationDefinitionMap } from '../operations'; -import { FieldSelect } from './field_select'; -import { Settings } from './settings'; +import { getPotentialColumns } from '../operations'; +import { PopoverEditor } from './popover_editor'; import { DragContextState, ChildDragDropProvider, DragDrop } from '../../drag_drop'; -import { changeColumn, hasField } from '../state_helpers'; +import { changeColumn, hasField, deleteColumn } from '../state_helpers'; export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { state: IndexPatternPrivateState; @@ -36,9 +36,6 @@ export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProp const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; - const ParamEditor = - selectedColumn && operationDefinitionMap[selectedColumn.operationType].inlineOptions; - function findColumnByField(field: IndexPatternField) { return filteredColumns.find(col => hasField(col) && col.sourceField === field.name); } @@ -53,6 +50,7 @@ export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProp return ( { @@ -66,30 +64,32 @@ export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProp props.setState(changeColumn(props.state, props.columnId, column)); }} > - + + + + - - - - + {selectedColumn && ( + + { + props.setState(deleteColumn(props.state, props.columnId)); + }} + /> + + )} - {ParamEditor && ( - - - - )} 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 index b14f9503dadf3..9cb3232aeb295 100644 --- 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 @@ -5,37 +5,54 @@ */ import _ from 'lodash'; -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiComboBox, EuiButtonEmpty, EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; -import { IndexPatternColumn, FieldBasedIndexPatternColumn } from '../indexpattern'; -import { IndexPatternDimensionPanelProps } from './dimension_panel'; -import { changeColumn, deleteColumn, hasField, sortByField } from '../state_helpers'; +import { EuiComboBox } from '@elastic/eui'; +import classNames from 'classnames'; +import { + IndexPatternColumn, + FieldBasedIndexPatternColumn, + OperationType, + BaseIndexPatternColumn, +} from '../indexpattern'; +import { hasField, sortByField } from '../state_helpers'; -export interface FieldSelectProps extends IndexPatternDimensionPanelProps { - selectedColumn: IndexPatternColumn; +export interface FieldSelectProps { + incompatibleSelectedOperationType: OperationType | null; + selectedColumn?: IndexPatternColumn; filteredColumns: IndexPatternColumn[]; + onChangeColumn: (newColumn: IndexPatternColumn) => void; + onDeleteColumn: () => void; } export function FieldSelect({ + incompatibleSelectedOperationType, selectedColumn, filteredColumns, - state, - columnId, - setState, + onChangeColumn, + onDeleteColumn, }: FieldSelectProps) { - const [isFieldSelectOpen, setFieldSelectOpen] = useState(false); const fieldColumns = filteredColumns.filter(hasField) as FieldBasedIndexPatternColumn[]; const uniqueColumnsByField = sortByField( _.uniq( fieldColumns - .filter(col => selectedColumn && col.operationType === selectedColumn.operationType) + .filter(col => + incompatibleSelectedOperationType + ? col.operationType === incompatibleSelectedOperationType + : selectedColumn && col.operationType === selectedColumn.operationType + ) .concat(fieldColumns), col => col.sourceField ) ); + function isCompatibleWithCurrentOperation(col: BaseIndexPatternColumn) { + return incompatibleSelectedOperationType + ? col.operationType === incompatibleSelectedOperationType + : !selectedColumn || col.operationType === selectedColumn.operationType; + } + const fieldOptions = []; const fieldLessColumn = filteredColumns.find(column => !hasField(column)); if (fieldLessColumn) { @@ -44,6 +61,11 @@ export function FieldSelect({ defaultMessage: 'Document', }), value: fieldLessColumn.operationId, + className: classNames({ + 'lnsConfigPanel__fieldOption--incompatible': !isCompatibleWithCurrentOperation( + fieldLessColumn + ), + }), }); } @@ -52,88 +74,63 @@ export function FieldSelect({ label: i18n.translate('xpack.lens.indexPattern.individualFieldsLabel', { defaultMessage: 'Individual fields', }), - options: uniqueColumnsByField.map(col => ({ - label: col.sourceField, - value: col.operationId, - })), + options: uniqueColumnsByField + .map(col => ({ + label: col.sourceField, + value: col.operationId, + compatible: isCompatibleWithCurrentOperation(col), + })) + .sort(({ compatible: a }, { compatible: b }) => { + if (a && !b) { + return -1; + } + if (!a && b) { + return 1; + } + return 0; + }) + .map(({ label, value, compatible }) => ({ + label, + value, + className: classNames({ 'lnsConfigPanel__fieldOption--incompatible': !compatible }), + })), }); } return ( - <> - - {!isFieldSelectOpen ? ( - setFieldSelectOpen(true)} - > - {selectedColumn - ? selectedColumn.label - : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { - defaultMessage: 'Configure dimension', - })} - - ) : ( - { - if (el) { - el.focus(); - } - }} - onBlur={() => { - setFieldSelectOpen(false); - }} - data-test-subj="indexPattern-dimension-field" - placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', { - defaultMessage: 'Field', - })} - options={fieldOptions} - selectedOptions={ - selectedColumn && hasField(selectedColumn) - ? [ - { - label: selectedColumn.sourceField, - value: selectedColumn.operationId, - }, - ] - : [] - } - singleSelection={{ asPlainText: true }} - isClearable={true} - onChange={choices => { - setFieldSelectOpen(false); - - if (choices.length === 0) { - setState(deleteColumn(state, columnId)); - return; - } + { + if (choices.length === 0) { + onDeleteColumn(); + return; + } - const column: IndexPatternColumn = filteredColumns.find( - ({ operationId }) => operationId === choices[0].value - )!; + const column: IndexPatternColumn = filteredColumns.find( + ({ operationId }) => operationId === choices[0].value + )!; - setState(changeColumn(state, columnId, column)); - }} - /> - )} - - {selectedColumn && ( - - { - setState(deleteColumn(state, columnId)); - }} - /> - - )} - + onChangeColumn(column); + }} + /> ); } 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..2254f1b474644 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx @@ -0,0 +1,206 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiPopover, + EuiFlexItem, + EuiFlexGroup, + EuiSideNav, + EuiCallOut, + EuiFormRow, + EuiFieldText, + EuiLink, +} from '@elastic/eui'; +import classNames from 'classnames'; +import { IndexPatternColumn, OperationType } from '../indexpattern'; +import { IndexPatternDimensionPanelProps } from './dimension_panel'; +import { operationDefinitionMap, getOperationDisplay } from '../operations'; +import { hasField, deleteColumn, changeColumn } from '../state_helpers'; +import { FieldSelect } from './field_select'; + +const operationPanels = getOperationDisplay(); + +function getOperationTypes( + filteredColumns: IndexPatternColumn[], + selectedColumn?: IndexPatternColumn +) { + const columnsFromField = selectedColumn + ? filteredColumns.filter(col => { + return ( + (!hasField(selectedColumn) && !hasField(col)) || + (hasField(selectedColumn) && + hasField(col) && + col.sourceField === selectedColumn.sourceField) + ); + }) + : filteredColumns; + const possibleOperationTypes = filteredColumns.map(col => ({ + operationType: col.operationType, + compatibleWithCurrentField: false, + })); + const validOperationTypes = columnsFromField.map(col => ({ + operationType: col.operationType, + compatibleWithCurrentField: true, + })); + return _.uniq([...validOperationTypes, ...possibleOperationTypes], 'operationType'); +} + +export interface PopoverEditorProps extends IndexPatternDimensionPanelProps { + selectedColumn?: IndexPatternColumn; + filteredColumns: IndexPatternColumn[]; +} + +export function PopoverEditor(props: PopoverEditorProps) { + const { selectedColumn, filteredColumns, state, columnId, setState } = props; + const [isPopoverOpen, setPopoverOpen] = useState(false); + const [ + incompatibleSelectedOperationType, + setInvalidOperationType, + ] = useState(null); + + const ParamEditor = + selectedColumn && operationDefinitionMap[selectedColumn.operationType].paramEditor; + + const sideNavItems = [ + { + name: '', + id: '0', + items: getOperationTypes(filteredColumns, selectedColumn).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-${operationType}`, + onClick() { + if (!selectedColumn || !compatibleWithCurrentField) { + setInvalidOperationType(operationType); + return; + } + if (incompatibleSelectedOperationType) { + setInvalidOperationType(null); + } + if (selectedColumn.operationType === operationType) { + return; + } + const newColumn: IndexPatternColumn = filteredColumns.find( + col => + col.operationType === operationType && + (!hasField(col) || + !hasField(selectedColumn) || + col.sourceField === selectedColumn.sourceField) + )!; + setState(changeColumn(state, columnId, newColumn)); + }, + }) + ), + }, + ]; + + return ( + { + setPopoverOpen(true); + }} + data-test-subj="indexPattern-configure-dimension" + > + {selectedColumn + ? selectedColumn.label + : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { + defaultMessage: 'Configure dimension', + })} + + } + isOpen={isPopoverOpen} + closePopover={() => { + setPopoverOpen(false); + setInvalidOperationType(null); + }} + anchorPosition="leftUp" + withTitle + panelPaddingSize="s" + > + + + { + setState(deleteColumn(state, columnId)); + }} + onChangeColumn={column => { + setState(changeColumn(state, columnId, column)); + setInvalidOperationType(null); + }} + /> + + + + + + + + {incompatibleSelectedOperationType && selectedColumn && ( + +

+ +

+
+ )} + {!incompatibleSelectedOperationType && ParamEditor && ( + + )} + {!incompatibleSelectedOperationType && selectedColumn && ( + + { + setState( + changeColumn(state, columnId, { + ...selectedColumn, + label: e.target.value, + }) + ); + }} + /> + + )} +
+
+
+
+
+ ); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx deleted file mode 100644 index b9f0cf771a34a..0000000000000 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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 { - EuiPopover, - EuiButtonIcon, - EuiFlexItem, - EuiContextMenuItem, - EuiContextMenuPanel, -} from '@elastic/eui'; -import { IndexPatternColumn } from '../indexpattern'; -import { IndexPatternDimensionPanelProps } from './dimension_panel'; -import { operationDefinitionMap, getOperations, getOperationDisplay } from '../operations'; -import { changeColumn, hasField } from '../state_helpers'; - -export interface SettingsProps extends IndexPatternDimensionPanelProps { - selectedColumn: IndexPatternColumn; - filteredColumns: IndexPatternColumn[]; -} - -export function Settings({ - selectedColumn, - filteredColumns, - state, - columnId, - setState, -}: SettingsProps) { - const [isSettingsOpen, setSettingsOpen] = useState(false); - const contextOptionBuilder = - selectedColumn && operationDefinitionMap[selectedColumn.operationType].contextMenu; - const contextOptions = contextOptionBuilder - ? contextOptionBuilder({ - state, - setState, - columnId, - }) - : []; - const operations = getOperations(); - const operationPanels = getOperationDisplay(); - const functionsFromField = selectedColumn - ? filteredColumns.filter(col => { - return ( - (!hasField(selectedColumn) && !hasField(col)) || - (hasField(selectedColumn) && - hasField(col) && - col.sourceField === selectedColumn.sourceField) - ); - }) - : filteredColumns; - - const operationMenuItems = operations - .filter(o => selectedColumn && functionsFromField.some(col => col.operationType === o)) - .map(o => ( - { - const newColumn: IndexPatternColumn = filteredColumns.find( - col => - col.operationType === o && - (!hasField(col) || - !hasField(selectedColumn) || - col.sourceField === selectedColumn.sourceField) - )!; - - setState(changeColumn(state, columnId, newColumn)); - }} - > - {operationPanels[o].displayName} - - )); - - return selectedColumn && (operationMenuItems.length > 1 || contextOptions.length > 0) ? ( - - { - setSettingsOpen(false); - }} - ownFocus - anchorPosition="leftCenter" - panelPaddingSize="none" - button={ - - { - setSettingsOpen(!isSettingsOpen); - }} - iconType="gear" - aria-label={i18n.translate('xpack.lens.indexPattern.settingsLabel', { - defaultMessage: 'Settings', - })} - /> - - } - > - {operationMenuItems.concat(contextOptions)} - - - ) : null; -} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss index ac1b7d4ab754b..877afd3fcbbc4 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss @@ -1,3 +1,5 @@ +@import './dimension_panel/index'; + .lnsIndexPattern__dimensionPopover { max-width: 600px; } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx index 94cd3e6495672..59b463a545651 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx @@ -12,7 +12,7 @@ import { EuiRange } from '@elastic/eui'; describe('date_histogram', () => { let state: IndexPatternPrivateState; - const InlineOptions = dateHistogramOperation.inlineOptions!; + const InlineOptions = dateHistogramOperation.paramEditor!; beforeEach(() => { state = { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx index 35a7de3ef6b6e..753a950f07cdd 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx @@ -92,7 +92,7 @@ export const dateHistogramOperation: OperationDefinition { + paramEditor: ({ state, setState, columnId }) => { const column = state.columns[columnId] as DateHistogramIndexPatternColumn; const field = @@ -112,7 +112,6 @@ export const dateHistogramOperation: OperationDefinition { let state: IndexPatternPrivateState; - const InlineOptions = termsOperation.inlineOptions!; - const contextMenu = termsOperation.contextMenu!; + const InlineOptions = termsOperation.paramEditor!; beforeEach(() => { state = { @@ -69,10 +68,9 @@ describe('terms', () => { describe('popover param editor', () => { it('should render current value and options', () => { const setStateSpy = jest.fn(); - const PartialMenu = () => ( - <>{contextMenu({ state, setState: setStateSpy, columnId: 'col1' })}/> + const instance = shallow( + ); - const instance = shallow(); expect(instance.find(EuiSelect).prop('value')).toEqual('alphabetical'); expect( @@ -85,10 +83,9 @@ describe('terms', () => { it('should update state with the order value', () => { const setStateSpy = jest.fn(); - const PartialMenu = () => ( - <>{contextMenu({ state, setState: setStateSpy, columnId: 'col1' })}/> + const instance = shallow( + ); - const instance = shallow(); instance.find(EuiSelect).prop('onChange')!({ target: { @@ -113,9 +110,7 @@ describe('terms', () => { }, }); }); - }); - describe('inline param editor', () => { it('should render current value', () => { const setStateSpy = jest.fn(); const instance = shallow( diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx index d8a98eba90f86..98e3443549ced 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiForm, EuiFormRow, EuiRange, EuiSelect, EuiContextMenuItem } from '@elastic/eui'; +import { EuiForm, EuiFormRow, EuiRange, EuiSelect } from '@elastic/eui'; import { IndexPatternField, TermsIndexPatternColumn } from '../indexpattern'; import { DimensionPriority } from '../../types'; import { OperationDefinition } from '../operations'; @@ -78,7 +78,7 @@ export const termsOperation: OperationDefinition = { missingBucketLabel: 'Missing', }, }), - contextMenu: ({ state, setState, columnId: currentColumnId }) => { + paramEditor: ({ state, setState, columnId: currentColumnId }) => { const currentColumn = state.columns[currentColumnId] as TermsIndexPatternColumn; const SEPARATOR = '$$$'; function toValue(orderBy: TermsIndexPatternColumn['params']['orderBy']) { @@ -113,31 +113,6 @@ export const termsOperation: OperationDefinition = { defaultMessage: 'Alphabetical', }), }); - return [ - - - ) => - setState( - updateColumnParam(state, currentColumn, 'orderBy', fromValue(e.target.value)) - ) - } - /> - - , - ]; - }, - inlineOptions: ({ state, setState, columnId: currentColumnId }) => { - const currentColumn = state.columns[currentColumnId] as TermsIndexPatternColumn; return ( = { })} /> + + ) => + setState( + updateColumnParam(state, currentColumn, 'orderBy', fromValue(e.target.value)) + ) + } + aria-label={i18n.translate('xpack.lens.indexPattern.terms.orderBy', { + defaultMessage: 'Order by', + })} + /> + ); }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index 20edaf338fd61..b5a0591084599 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -58,6 +58,7 @@ export interface ParamEditorProps { setState: (newState: IndexPatternPrivateState) => void; columnId: string; } + export interface OperationDefinition { type: C['operationType']; displayName: string; @@ -69,8 +70,7 @@ export interface OperationDefinition { suggestedOrder?: DimensionPriority, field?: IndexPatternField ) => C; - inlineOptions?: React.ComponentType; - contextMenu?: (props: ParamEditorProps) => JSX.Element[]; + paramEditor?: React.ComponentType; toEsAggsConfig: (column: C, columnId: string) => unknown; } 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 index 88ba0b923d6ee..c021119323b11 100644 --- 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 @@ -89,6 +89,48 @@ describe('state_helpers', () => { }) ); }); + + it('should carry over params from old column if the operation type stays the same', () => { + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }, + }; + expect( + changeColumn(state, 'col2', { + operationId: 'op2', + label: 'Date histogram of order_date', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'order_date', + params: { + interval: 'w', + }, + }).columns.col1 + ).toEqual( + expect.objectContaining({ + params: { interval: 'h' }, + }) + ); + }); }); describe('getColumnOrder', () => { 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 index aec5bf5a3b7b4..0ee3222c1e5d7 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts @@ -64,11 +64,22 @@ export function updateColumnParam< export function changeColumn( state: IndexPatternPrivateState, columnId: string, - newColumn: IndexPatternColumn + newColumn: IndexPatternColumn, + { keepParams }: { keepParams: boolean } = { keepParams: true } ) { + const oldColumn = state.columns[columnId]; + + const updatedColumn = + keepParams && + oldColumn && + oldColumn.operationType === newColumn.operationType && + 'params' in oldColumn + ? ({ ...newColumn, params: oldColumn.params } as IndexPatternColumn) + : newColumn; + const newColumns: IndexPatternPrivateState['columns'] = { ...state.columns, - [columnId]: newColumn, + [columnId]: updatedColumn, }; return { From 95d7735ed7143685ad3cffc1f5687fcc809e7e55 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 28 Jun 2019 11:49:10 +0200 Subject: [PATCH 037/105] [Lens] Basic layouting (#39587) --- .../plugins/lens/public/app_plugin/app.tsx | 6 +- .../editor_frame/frame_layout.tsx | 17 +++-- .../editor_frame/index.scss | 76 ++++++++++++++++++- .../editor_frame/workspace_panel.scss | 3 - .../editor_frame/workspace_panel.test.tsx | 7 +- .../editor_frame/workspace_panel.tsx | 34 +++++++-- .../native_renderer/native_renderer.test.tsx | 18 +++++ .../native_renderer/native_renderer.tsx | 7 +- 8 files changed, 138 insertions(+), 30 deletions(-) delete mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.scss diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index 16860142e28d0..0e469dbeb5634 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -13,11 +13,7 @@ import { NativeRenderer } from '../native_renderer'; export function App({ editorFrame }: { editorFrame: EditorFrameInstance }) { return ( -
-

Lens

- - -
+
); } 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 index cae1b6b90ccd9..18f619f9d54db 100644 --- 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 @@ -5,7 +5,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiPage, EuiPageSideBar, EuiPageBody } from '@elastic/eui'; import { RootDragDropProvider } from '../../drag_drop'; export interface FrameLayoutProps { @@ -18,15 +18,16 @@ export interface FrameLayoutProps { export function FrameLayout(props: FrameLayoutProps) { return ( - - {/* TODO style this and add workspace prop and loading flags */} - {props.dataPanel} - {props.workspacePanel} - + + {props.dataPanel} + + {props.workspacePanel} + + {props.configPanel} {props.suggestionsPanel} - - + + ); } 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 index 182e36df61797..d384c03195538 100644 --- 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 @@ -1,2 +1,76 @@ -@import './workspace_panel.scss'; + +.lnsPage { + padding: 0; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +.lnsSidebar { + @include euiScrollBar; + overflow: hidden auto; + padding: $euiSize; + margin: 0; + flex: 1 0 18%; + min-width: ($euiSize * 16); + height: 100%; + display: flex; + flex-direction: column; +} + +.lnsSidebar--right { + min-width: ($euiSize * 18); +} + +.lnsPageBody { + @include euiScrollBar; + padding-top: $euiSize; + padding-bottom: $euiSize; + overflow: hidden auto; + + &:first-child { + padding-left: $euiSize; + } + + .lnsPageContent { + padding: 0; + display: flex; + flex-direction: column; + + .lnsPageContentHeader { + padding: $euiSize; + border-bottom: $euiBorderThin; + margin-bottom: 0; + } + + .lnsPageContentBody { + flex-grow: 1; + padding: $euiSizeXL; + display: flex; + align-items: stretch; + justify-content: stretch; + + > * { + flex: 1 1 100%; + display: flex; + align-items: center; + justify-content: center; + overflow-x: hidden; + } + } + } +} + +.lnsExpressionOutput { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow-x: hidden; +} + @import './suggestion_panel.scss'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.scss b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.scss deleted file mode 100644 index 03c3534e1e12b..0000000000000 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.scss +++ /dev/null @@ -1,3 +0,0 @@ - .lnsChartWrapper { - height: 500px; - } \ No newline at end of file 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 index 1b5d89f50bdd4..ef8b152ee9a1e 100644 --- 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 @@ -17,6 +17,7 @@ import { import { WorkspacePanel, WorkspacePanelProps } from './workspace_panel'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; +import { DragDrop } from '../../drag_drop'; const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); @@ -318,7 +319,7 @@ Object { }, ]); - instance.childAt(0).prop('onDrop')({ + instance.find(DragDrop).prop('onDrop')!({ name: '@timestamp', type: 'date', searchable: false, @@ -378,7 +379,7 @@ Object { }, ]); - instance.childAt(0).prop('onDrop')({ + instance.find(DragDrop).prop('onDrop')!({ name: '@timestamp', type: 'date', searchable: false, @@ -396,7 +397,7 @@ Object { }); it("should do nothing when the visualization can't use the suggestions", () => { - instance.childAt(0).prop('onDrop')({ + instance.find(DragDrop).prop('onDrop')!({ name: '@timestamp', type: 'date', searchable: false, 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 index b65941e1838c2..d17b06c3b1a9e 100644 --- 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 @@ -4,15 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useContext } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; +import { + EuiCodeBlock, + EuiSpacer, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiTitle, + EuiPageContentBody, +} from '@elastic/eui'; import { toExpression } from '@kbn/interpreter/common'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/data/public'; import { Action } from './state_management'; import { Datasource, Visualization, DatasourcePublicAPI } from '../../types'; -import { DragDrop } from '../../drag_drop'; +import { DragDrop, DragContext } from '../../drag_drop'; import { getSuggestions, toSwitchAction } from './suggestion_helpers'; import { buildExpression } from './expression_helpers'; @@ -37,6 +45,7 @@ export function WorkspacePanel({ dispatch, ExpressionRenderer: ExpressionRendererComponent, }: WorkspacePanelProps) { + const dragDropContext = useContext(DragContext); function onDrop(item: unknown) { const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsForField( datasourceState, @@ -138,7 +147,7 @@ export function WorkspacePanel({ } else { return ( { setExpressionError(e); @@ -149,8 +158,19 @@ export function WorkspacePanel({ } return ( - - {renderVisualization()} - + + + + +

New Visualization

+
+
+
+ + + {renderVisualization()} + + +
); } 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 index 9b29174a06d69..07642e7936d25 100644 --- 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 @@ -121,6 +121,24 @@ describe('native_renderer', () => { 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' }; 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 index 3bc042660e646..08464dd65f67e 100644 --- a/x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.tsx +++ b/x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { HTMLAttributes } from 'react'; -export interface NativeRendererProps { +export interface NativeRendererProps extends HTMLAttributes { render: (domElement: Element, props: T) => void; nativeProps: T; tag?: string; @@ -20,8 +20,9 @@ export interface NativeRendererProps { * * @param props */ -export function NativeRenderer({ render, nativeProps, tag }: NativeRendererProps) { +export function NativeRenderer({ render, nativeProps, tag, ...rest }: NativeRendererProps) { return React.createElement(tag || 'div', { + ...rest, ref: el => el && render(el, nativeProps), }); } From d85722789a3aa11121781da94199965ae32454fd Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 28 Jun 2019 15:17:00 +0200 Subject: [PATCH 038/105] remove datasource public API in suggestions (#39772) --- .../editor_frame/config_panel_wrapper.tsx | 27 ++- .../editor_frame/editor_frame.test.tsx | 4 +- .../editor_frame/editor_frame.tsx | 1 - .../editor_frame/suggestion_helpers.test.ts | 27 +-- .../editor_frame/suggestion_helpers.ts | 16 +- .../editor_frame/suggestion_panel.test.tsx | 1 - .../editor_frame/suggestion_panel.tsx | 7 +- .../editor_frame/workspace_panel.test.tsx | 3 +- .../editor_frame/workspace_panel.tsx | 3 +- x-pack/legacy/plugins/lens/public/types.ts | 5 +- .../xy_visualization_plugin/to_expression.ts | 20 +- .../xy_suggestions.test.ts | 190 ++++++++---------- .../xy_visualization_plugin/xy_suggestions.ts | 36 ++-- .../xy_visualization.test.ts | 1 - 14 files changed, 151 insertions(+), 190 deletions(-) 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 index 1341266ba56a5..677b37beab190 100644 --- 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 @@ -23,21 +23,18 @@ function getSuggestedVisualizationState( visualization: Visualization, datasource: DatasourcePublicAPI ) { - const suggestions = visualization.getSuggestions( - { - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: datasource.getTableSpec().map(col => ({ - ...col, - operation: datasource.getOperationForColumnId(col.columnId)!, - })), - }, - ], - }, - datasource - ); + const suggestions = visualization.getSuggestions({ + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: datasource.getTableSpec().map(col => ({ + ...col, + operation: datasource.getOperationForColumnId(col.columnId)!, + })), + }, + ], + }); if (!suggestions.length) { return visualization.initialize(datasource); 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 index 90e66336e2880..8306e6476a967 100644 --- 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 @@ -353,8 +353,8 @@ Object { await waitForPromises(); const updatedPublicAPI = {}; - mockDatasource.getPublicAPI = jest.fn( - _ => (updatedPublicAPI as unknown) as DatasourcePublicAPI + mockDatasource.getPublicAPI.mockReturnValue( + (updatedPublicAPI as unknown) as DatasourcePublicAPI ); const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] 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 index e1353530215b1..edd549f916d07 100644 --- 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 @@ -129,7 +129,6 @@ export function EditorFrame(props: EditorFrameProps) { ({ @@ -34,8 +34,7 @@ describe('suggestion helpers', () => { }, }, 'vis1', - {}, - createMockDatasource().publicAPIMock + {} ); expect(suggestions).toHaveLength(1); expect(suggestions[0].state).toBe(suggestedState); @@ -80,8 +79,7 @@ describe('suggestion helpers', () => { }, }, 'vis1', - {}, - createMockDatasource().publicAPIMock + {} ); expect(suggestions).toHaveLength(3); }); @@ -125,8 +123,7 @@ describe('suggestion helpers', () => { }, }, 'vis1', - {}, - createMockDatasource().publicAPIMock + {} ); expect(suggestions[0].score).toBe(0.8); expect(suggestions[1].score).toBe(0.6); @@ -145,8 +142,7 @@ describe('suggestion helpers', () => { vis2: mockVisualization2, }, 'vis1', - {}, - createMockDatasource().publicAPIMock + {} ); expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[0]).toBe(table1); expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[1]).toBe(table2); @@ -195,8 +191,7 @@ describe('suggestion helpers', () => { }, }, 'vis1', - {}, - createMockDatasource().publicAPIMock + {} ); expect(suggestions[0].datasourceState).toBe(tableState1); expect(suggestions[1].datasourceState).toBe(tableState2); @@ -206,7 +201,6 @@ describe('suggestion helpers', () => { it('should pass the state of the currently active visualization to getSuggestions', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); - const datasourcePublicAPI = createMockDatasource().publicAPIMock; const currentState = {}; getSuggestions( [generateSuggestion(1), generateSuggestion(2)], @@ -215,20 +209,17 @@ describe('suggestion helpers', () => { vis2: mockVisualization2, }, 'vis1', - currentState, - datasourcePublicAPI + currentState ); expect(mockVisualization1.getSuggestions).toHaveBeenCalledWith( expect.objectContaining({ state: currentState, - }), - datasourcePublicAPI + }) ); expect(mockVisualization2.getSuggestions).not.toHaveBeenCalledWith( expect.objectContaining({ state: currentState, - }), - datasourcePublicAPI + }) ); }); }); 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 index 891f492bbcce1..a9886387c6c74 100644 --- 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 @@ -5,7 +5,7 @@ */ import { Ast } from '@kbn/interpreter/common'; -import { Visualization, DatasourceSuggestion, DatasourcePublicAPI } from '../../types'; +import { Visualization, DatasourceSuggestion } from '../../types'; import { Action } from './state_management'; export interface Suggestion { @@ -30,8 +30,7 @@ export function getSuggestions( datasourceTableSuggestions: DatasourceSuggestion[], visualizationMap: Record, activeVisualizationId: string | null, - visualizationState: unknown, - datasourcePublicAPI: DatasourcePublicAPI + visualizationState: unknown ): Suggestion[] { const datasourceTables = datasourceTableSuggestions.map(({ table }) => table); @@ -39,13 +38,10 @@ export function getSuggestions( Object.entries(visualizationMap) .map(([visualizationId, visualization]) => { return visualization - .getSuggestions( - { - tables: datasourceTables, - state: visualizationId === activeVisualizationId ? visualizationState : undefined, - }, - datasourcePublicAPI - ) + .getSuggestions({ + tables: datasourceTables, + state: visualizationId === activeVisualizationId ? visualizationState : undefined, + }) .map(({ datasourceSuggestionId, ...suggestion }) => ({ ...suggestion, visualizationId, 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 index 70e2366d38cc8..e3409a8eca7e9 100644 --- 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 @@ -68,7 +68,6 @@ describe('suggestion_panel', () => { visualizationState: {}, dispatch: dispatchMock, ExpressionRenderer: expressionRendererMock, - datasourcePublicAPI: mockDatasource.publicAPIMock, }; }); 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 index 7c86b8e1522df..9be220cd3f11f 100644 --- 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 @@ -11,7 +11,7 @@ import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip } from '@elastic/eui'; import { toExpression } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; import { Action } from './state_management'; -import { Datasource, Visualization, DatasourcePublicAPI } from '../../types'; +import { Datasource, Visualization } from '../../types'; import { getSuggestions, toSwitchAction, Suggestion } from './suggestion_helpers'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/data/public'; import { prependDatasourceExpression } from './expression_helpers'; @@ -22,7 +22,6 @@ export interface SuggestionPanelProps { activeVisualizationId: string | null; visualizationMap: Record; visualizationState: unknown; - datasourcePublicAPI: DatasourcePublicAPI; dispatch: (action: Action) => void; ExpressionRenderer: ExpressionRenderer; } @@ -98,7 +97,6 @@ export function SuggestionPanel({ visualizationMap, visualizationState, dispatch, - datasourcePublicAPI, ExpressionRenderer: ExpressionRendererComponent, }: SuggestionPanelProps) { const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsFromCurrentState( @@ -109,8 +107,7 @@ export function SuggestionPanel({ datasourceSuggestions, visualizationMap, activeVisualizationId, - visualizationState, - datasourcePublicAPI + visualizationState ); return ( 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 index ef8b152ee9a1e..dadce6d856bd3 100644 --- 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 @@ -330,8 +330,7 @@ Object { expect(mockVisualization.getSuggestions).toHaveBeenCalledWith( expect.objectContaining({ tables: [expectedTable], - }), - expect.anything() + }) ); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SWITCH_VISUALIZATION', 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 index d17b06c3b1a9e..7a0017d83ad3a 100644 --- 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 @@ -56,8 +56,7 @@ export function WorkspacePanel({ datasourceSuggestions, visualizationMap, activeVisualizationId, - visualizationState, - datasourcePublicAPI + visualizationState ); if (suggestions.length === 0) { diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 0e38cc3c32a12..6be550a2342cd 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -170,8 +170,5 @@ export interface Visualization { // The frame will call this function on all visualizations when the table changes, or when // rendering additional ways of using the data - getSuggestions: ( - options: SuggestionRequest, - datasource: DatasourcePublicAPI - ) => Array>; + getSuggestions: (options: SuggestionRequest) => Array>; } 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 index 1807145996126..33750937cebe8 100644 --- 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 @@ -8,7 +8,22 @@ import { Ast } from '@kbn/interpreter/common'; import { State } from './types'; import { DatasourcePublicAPI } from '../types'; -export const toExpression = (state: State, datasource: DatasourcePublicAPI): Ast => ({ +export const toExpression = (state: State, datasource: DatasourcePublicAPI): Ast => { + const labels: Partial> = {}; + state.y.accessors.forEach(columnId => { + const operation = datasource.getOperationForColumnId(columnId); + if (operation && operation.label) { + labels[columnId] = operation.label; + } + }); + + return buildExpression(state, labels); +}; + +export const buildExpression = ( + state: State, + columnLabels: Partial> +): Ast => ({ type: 'expression', chain: [ { @@ -64,8 +79,7 @@ export const toExpression = (state: State, datasource: DatasourcePublicAPI): Ast accessors: state.y.accessors, hide: [Boolean(state.y.hide)], labels: state.y.accessors.map(accessor => { - const operation = datasource.getOperationForColumnId(accessor); - return operation ? operation.label : accessor; + return columnLabels[accessor] || accessor; }), }, }, 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 index 82c3b91e3246f..98500c78bf266 100644 --- 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 @@ -8,7 +8,6 @@ import { getSuggestions } from './xy_suggestions'; import { TableColumn, VisualizationSuggestion } from '../types'; import { State } from './types'; import { Ast } from '@kbn/interpreter/target/common'; -import { createMockDatasource } from '../editor_frame_plugin/mocks'; describe('xy_suggestions', () => { function numCol(columnId: string): TableColumn { @@ -69,41 +68,35 @@ describe('xy_suggestions', () => { }; expect( - getSuggestions( - { - tables: [ - { datasourceSuggestionId: 0, isMultiRow: true, columns: [dateCol('a')] }, - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [strCol('foo'), strCol('bar')], - }, - { - datasourceSuggestionId: 2, - isMultiRow: false, - columns: [strCol('foo'), numCol('bar')], - }, - { datasourceSuggestionId: 3, isMultiRow: true, columns: [unknownCol(), numCol('bar')] }, - ], - }, - createMockDatasource().publicAPIMock - ) - ).toEqual([]); - }); - - test('suggests a basic x y chart with date on x', () => { - const [suggestion, ...rest] = getSuggestions( - { + getSuggestions({ tables: [ + { datasourceSuggestionId: 0, isMultiRow: true, columns: [dateCol('a')] }, { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, isMultiRow: true, - columns: [numCol('bytes'), dateCol('date')], + columns: [strCol('foo'), strCol('bar')], }, + { + datasourceSuggestionId: 2, + isMultiRow: false, + columns: [strCol('foo'), numCol('bar')], + }, + { datasourceSuggestionId: 3, isMultiRow: true, columns: [unknownCol(), numCol('bar')] }, ], - }, - createMockDatasource().publicAPIMock - ); + }) + ).toEqual([]); + }); + + test('suggests a basic x y chart with date on x', () => { + const [suggestion, ...rest] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + }, + ], + }); expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` @@ -120,18 +113,15 @@ Object { }); test('suggests a split x y chart with date on x', () => { - const [suggestion, ...rest] = getSuggestions( - { - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], - }, - ], - }, - createMockDatasource().publicAPIMock - ); + const [suggestion, ...rest] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + }, + ], + }); expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` @@ -151,23 +141,20 @@ Object { }); test('supports multiple suggestions', () => { - const [s1, s2, ...rest] = getSuggestions( - { - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: [numCol('price'), dateCol('date')], - }, - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('count'), strCol('country')], - }, - ], - }, - createMockDatasource().publicAPIMock - ); + const [s1, s2, ...rest] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [numCol('price'), dateCol('date')], + }, + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [numCol('count'), strCol('country')], + }, + ], + }); expect(rest).toHaveLength(0); expect([suggestionSubset(s1), suggestionSubset(s2)]).toMatchInlineSnapshot(` @@ -195,18 +182,15 @@ Array [ }); test('handles two numeric values', () => { - const [suggestion] = getSuggestions( - { - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('quantity'), numCol('price')], - }, - ], - }, - createMockDatasource().publicAPIMock - ); + const [suggestion] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [numCol('quantity'), numCol('price')], + }, + ], + }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Object { @@ -222,29 +206,26 @@ Object { }); test('handles unbucketed suggestions', () => { - const [suggestion] = getSuggestions( - { - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [ - numCol('num votes'), - { - columnId: 'mybool', - operation: { - dataType: 'boolean', - id: 'mybool', - isBucketed: false, - label: 'Yes / No', - }, + const [suggestion] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [ + numCol('num votes'), + { + columnId: 'mybool', + operation: { + dataType: 'boolean', + id: 'mybool', + isBucketed: false, + label: 'Yes / No', }, - ], - }, - ], - }, - createMockDatasource().publicAPIMock - ); + }, + ], + }, + ], + }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Object { @@ -260,18 +241,15 @@ Object { }); test('adds a preview expression with disabled axes and legend', () => { - const [suggestion] = getSuggestions( - { - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: [numCol('bytes'), dateCol('date')], - }, - ], - }, - createMockDatasource().publicAPIMock - ); + const [suggestion] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + }, + ], + }); const expression = suggestion.previewExpression! as Ast; 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 index a9207d409eb84..eade88e47848f 100644 --- 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 @@ -6,15 +6,9 @@ import { partition } from 'lodash'; import { Position } from '@elastic/charts'; -import { - SuggestionRequest, - VisualizationSuggestion, - TableColumn, - TableSuggestion, - DatasourcePublicAPI, -} from '../types'; +import { SuggestionRequest, VisualizationSuggestion, TableColumn, TableSuggestion } from '../types'; import { State } from './types'; -import { toExpression } from './to_expression'; +import { buildExpression } from './to_expression'; const columnSortOrder = { date: 0, @@ -29,8 +23,7 @@ const columnSortOrder = { * @param opts */ export function getSuggestions( - opts: SuggestionRequest, - datasource: DatasourcePublicAPI + opts: SuggestionRequest ): Array> { return opts.tables .filter( @@ -43,13 +36,10 @@ export function getSuggestions( columns.some(col => col.operation.dataType === 'number') && !columns.some(col => !columnSortOrder.hasOwnProperty(col.operation.dataType)) ) - .map(table => getSuggestionForColumns(datasource, table)); + .map(table => getSuggestionForColumns(table)); } -function getSuggestionForColumns( - datasource: DatasourcePublicAPI, - table: TableSuggestion -): VisualizationSuggestion { +function getSuggestionForColumns(table: TableSuggestion): VisualizationSuggestion { const [buckets, values] = partition( prioritizeColumns(table.columns), col => col.operation.isBucketed @@ -57,10 +47,10 @@ function getSuggestionForColumns( if (buckets.length >= 1) { const [x, splitBy] = buckets; - return getSuggestion(datasource, table.datasourceSuggestionId, x, values, splitBy); + return getSuggestion(table.datasourceSuggestionId, x, values, splitBy); } else { const [x, ...yValues] = values; - return getSuggestion(datasource, table.datasourceSuggestionId, x, yValues); + return getSuggestion(table.datasourceSuggestionId, x, yValues); } } @@ -74,7 +64,6 @@ function prioritizeColumns(columns: TableColumn[]) { } function getSuggestion( - datasource: DatasourcePublicAPI, datasourceSuggestionId: number, xValue: TableColumn, yValues: TableColumn[], @@ -107,13 +96,20 @@ function getSuggestion( }, }; + const labels: Partial> = {}; + yValues.forEach(({ columnId, operation: { label } }) => { + if (label) { + labels[columnId] = label; + } + }); + return { title, score: 1, datasourceSuggestionId, state, previewIcon: isDate ? 'visLine' : 'visBar', - previewExpression: toExpression( + previewExpression: buildExpression( { ...state, x: { @@ -129,7 +125,7 @@ function getSuggestion( isVisible: false, }, }, - datasource + labels ), }; } 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 index b4ddc5617eaec..9ece60dae459e 100644 --- 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 @@ -113,7 +113,6 @@ Object { expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledTimes(2); expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('b'); expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('c'); - expect((expression.chain[0].arguments.y[0] as Ast).chain[0].arguments.labels).toEqual([ 'First', 'Second', From 1dd57f6cd337bdf3e51dc0ae838b9f770c3127ad Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Fri, 28 Jun 2019 13:15:02 -0400 Subject: [PATCH 039/105] [Lens] Basic save / load (#39257) Add basic routing, save, and load to Lens --- x-pack/legacy/plugins/lens/index.ts | 18 +- x-pack/legacy/plugins/lens/mappings.json | 18 ++ .../plugins/lens/public/app_plugin/app.tsx | 13 +- .../editor_frame/editor_frame.test.tsx | 30 +++ .../editor_frame/editor_frame.tsx | 108 +++++++-- .../editor_frame/frame_layout.tsx | 23 +- .../editor_frame/index.scss | 20 ++ .../editor_frame/save.test.ts | 177 ++++++++++++++ .../editor_frame_plugin/editor_frame/save.ts | 49 ++++ .../editor_frame/state_management.test.ts | 188 +++++++++++++++ .../editor_frame/state_management.ts | 52 +++++ .../editor_frame/workspace_panel.tsx | 28 +-- .../editor_frame/workspace_panel_wrapper.tsx | 39 ++++ .../initializable_component.test.tsx | 75 ++++++ .../initializable_component.tsx | 41 ++++ .../editor_frame_plugin/plugin.test.tsx | 220 ++++++++++++++---- .../public/editor_frame_plugin/plugin.tsx | 139 +++++++++-- .../plugins/lens/public/persistence/index.ts | 7 + .../persistence/saved_object_store.test.ts | 140 +++++++++++ .../public/persistence/saved_object_store.ts | 84 +++++++ x-pack/legacy/plugins/lens/public/types.ts | 4 +- .../xy_visualization.test.ts.snap | 3 - .../xy_visualization_plugin/to_expression.ts | 1 - .../public/xy_visualization_plugin/types.ts | 2 - .../xy_config_panel.test.tsx | 27 --- .../xy_config_panel.tsx | 18 -- .../xy_expression.test.tsx | 1 - .../xy_visualization_plugin/xy_expression.tsx | 4 - .../xy_visualization_plugin/xy_suggestions.ts | 1 - .../xy_visualization.test.ts | 1 - 30 files changed, 1353 insertions(+), 178 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/mappings.json create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.ts create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel_wrapper.tsx create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.tsx create mode 100644 x-pack/legacy/plugins/lens/public/persistence/index.ts create mode 100644 x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts create mode 100644 x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index 6fdcd5213b3a6..33cf892435708 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -8,6 +8,7 @@ import * as Joi from 'joi'; import { Server } from 'hapi'; import { resolve } from 'path'; import { LegacyPluginInitializer } from 'src/legacy/types'; +import mappings from './mappings.json'; import { PLUGIN_ID } from './common'; @@ -27,7 +28,22 @@ export const lens: LegacyPluginInitializer = kibana => { main: `plugins/${PLUGIN_ID}/index`, }, styleSheetPaths: resolve(__dirname, 'public/index.scss'), - }, + mappings, + savedObjectsManagement: { + lens: { + defaultSearchField: 'title', + isImportableAndExportable: true, + getTitle: (obj: { attributes: { title: string } }) => obj.attributes.title, + getInAppUrl: (obj: { id: string }) => ({ + path: `/app/lens#/edit/${encodeURIComponent(obj.id)}`, + uiCapabilitiesPath: 'lens.show', + }), + }, + }, + // TODO: savedObjectsManagement is not in the uiExports type definition, + // so, we have to either fix the type signature and deal with merge + // conflicts, or simply cas to any here, and fix this later. + } as any, config: () => { return Joi.object({ diff --git a/x-pack/legacy/plugins/lens/mappings.json b/x-pack/legacy/plugins/lens/mappings.json new file mode 100644 index 0000000000000..4c860a7171829 --- /dev/null +++ b/x-pack/legacy/plugins/lens/mappings.json @@ -0,0 +1,18 @@ +{ + "lens": { + "properties": { + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + }, + "datasourceType": { + "type": "keyword" + }, + "state": { + "type": "text" + } + } + } +} diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index 0e469dbeb5634..c877ef4ce5292 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -6,14 +6,23 @@ import { I18nProvider } from '@kbn/i18n/react'; import React from 'react'; - +import { toastNotifications } from 'ui/notify'; import { EditorFrameInstance } from '../types'; import { NativeRenderer } from '../native_renderer'; export function App({ editorFrame }: { editorFrame: EditorFrameInstance }) { return ( - + + toastNotifications.addDanger({ + title: e.message, + }), + }} + /> ); } 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 index 8306e6476a967..4bcf4d8b5e42e 100644 --- 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 @@ -42,6 +42,15 @@ describe('editor_frame', () => { let expressionRendererMock: ExpressionRenderer; + const defaultProps = { + store: { + save: jest.fn(), + load: jest.fn(), + }, + redirectTo: jest.fn(), + onError: jest.fn(), + }; + beforeEach(() => { mockVisualization = createMockVisualization(); mockVisualization2 = createMockVisualization(); @@ -57,6 +66,7 @@ describe('editor_frame', () => { act(() => { mount( { act(() => { mount( { act(() => { mount( { act(() => { mount( { act(() => { mount( { act(() => { mount( { mount( initialState }, }} @@ -229,6 +245,7 @@ describe('editor_frame', () => { it('should render the resulting expression using the expression renderer', async () => { const instance = mount( 'vis' }, }} @@ -272,6 +289,7 @@ Object { it('should re-render config panel after state update', async () => { mount( { mount( { mount( { mount( { mount( { instance = mount( { mount( { mount( { const instance = mount( ; visualizationMap: Record; - + redirectTo: (path: string) => void; initialDatasourceId: string | null; initialVisualizationId: string | null; - ExpressionRenderer: ExpressionRenderer; + onError: (e: { message: string }) => void; } export function EditorFrame(props: EditorFrameProps) { const [state, dispatch] = useReducer(reducer, props, getInitialState); + const { onError } = props; // create public datasource api for current state // as soon as datasource is available and memoize it @@ -50,26 +58,46 @@ export function EditorFrame(props: EditorFrameProps) { ] ); + useEffect( + () => { + if (props.doc) { + dispatch({ + type: 'VISUALIZATION_LOADED', + doc: props.doc, + }); + } else { + dispatch({ + type: 'RESET', + state: getInitialState(props), + }); + } + }, + [props.doc] + ); + // Initialize current datasource useEffect( () => { let datasourceGotSwitched = false; if (state.datasource.isLoading && state.datasource.activeId) { - props.datasourceMap[state.datasource.activeId].initialize().then(datasourceState => { - if (!datasourceGotSwitched) { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - newState: datasourceState, - }); - } - }); + props.datasourceMap[state.datasource.activeId] + .initialize(props.doc && props.doc.state.datasource) + .then(datasourceState => { + if (!datasourceGotSwitched) { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + newState: datasourceState, + }); + } + }) + .catch(onError); return () => { datasourceGotSwitched = true; }; } }, - [state.datasource.activeId, state.datasource.isLoading] + [props.doc, state.datasource.activeId, state.datasource.isLoading] ); // Initialize visualization as soon as datasource is ready @@ -92,9 +120,41 @@ export function EditorFrame(props: EditorFrameProps) { [datasourcePublicAPI, state.visualization.activeId, state.visualization.state] ); - if (state.datasource.activeId && !state.datasource.isLoading) { + const datasource = + state.datasource.activeId && !state.datasource.isLoading + ? props.datasourceMap[state.datasource.activeId] + : undefined; + + const visualization = state.visualization.activeId + ? props.visualizationMap[state.visualization.activeId] + : undefined; + + if (datasource) { return ( + { + if (datasource && visualization) { + save({ + datasource, + dispatch, + visualization, + state, + redirectTo: props.redirectTo, + store: props.store, + }).catch(onError); + } + }} + disabled={state.saving || !state.datasource.activeId || !state.visualization.activeId} + > + {i18n.translate('xpack.lens.editorFrame.Save', { + defaultMessage: 'Save', + })} + + + } dataPanel={ } workspacePanel={ - + + + } suggestionsPanel={ - {props.dataPanel} - - {props.workspacePanel} - - - {props.configPanel} - {props.suggestionsPanel} - +
{props.navPanel}
+ +
+ {props.dataPanel} + + {props.workspacePanel} + + + {props.configPanel} + {props.suggestionsPanel} + +
); 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 index d384c03195538..b57fe73adb8b2 100644 --- 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 @@ -7,6 +7,16 @@ right: 0; bottom: 0; overflow: hidden; + flex-direction: column; +} + +.lnsHeader { + padding: $euiSize; + padding-bottom: 0; +} + +.lnsPageMainContent { + display: flex; } .lnsSidebar { @@ -73,4 +83,14 @@ overflow-x: hidden; } +.lnsTitleInput { + width: 100%; + min-width: 100%; + border: 0; + font: inherit; + background: transparent; + box-shadow: none; + font-size: 1.2em; +} + @import './suggestion_panel.scss'; \ No newline at end of file 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..ef4eed2b63a5d --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts @@ -0,0 +1,177 @@ +/* + * 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 { save, Props } from './save'; +import { Action } from './state_management'; + +describe('save editor frame state', () => { + const saveArgs: Props = { + dispatch: jest.fn(), + redirectTo: jest.fn(), + datasource: { getPersistableState: x => x }, + visualization: { getPersistableState: x => x }, + state: { + title: 'aaa', + datasource: { activeId: '1', isLoading: false, state: {} }, + saving: false, + visualization: { activeId: '2', state: {} }, + }, + store: { + async save() { + return { id: 'foo' }; + }, + }, + }; + + it('dispatches saved status actions before and after saving', async () => { + let saved = false; + + const dispatch = jest.fn((action: Action) => { + if ( + (action.type === 'SAVING' && action.isSaving && saved) || + (action.type === 'SAVING' && !action.isSaving && !saved) + ) { + throw new Error('Saving status was incorrectly set'); + } + }); + + await save({ + ...saveArgs, + dispatch, + state: { + title: 'aaa', + datasource: { activeId: '1', isLoading: false, state: {} }, + saving: false, + visualization: { activeId: '2', state: {} }, + }, + store: { + async save() { + saved = true; + return { id: 'foo' }; + }, + }, + }); + + expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: true }); + expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: false }); + }); + + it('allows saves if an error occurs', async () => { + const dispatch = jest.fn(); + + await expect( + save({ + ...saveArgs, + dispatch, + state: { + title: 'aaa', + datasource: { activeId: '1', isLoading: false, state: {} }, + saving: false, + visualization: { activeId: '2', state: {} }, + }, + store: { + async save() { + throw new Error('aw shnap!'); + }, + }, + }) + ).rejects.toThrow(); + + expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: true }); + expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: false }); + }); + + it('transforms from internal state to persisted doc format', async () => { + const store = { + save: jest.fn(async () => ({ id: 'bar' })), + }; + await save({ + ...saveArgs, + store, + datasource: { + getPersistableState(state) { + return { + stuff: `${state}_datsource_persisted`, + }; + }, + }, + state: { + title: 'bbb', + datasource: { activeId: '1', isLoading: false, state: '2' }, + saving: false, + visualization: { activeId: '3', state: '4' }, + }, + visualization: { + getPersistableState(state) { + return { + things: `${state}_vis_persisted`, + }; + }, + }, + }); + + expect(store.save).toHaveBeenCalledWith({ + datasourceType: '1', + id: undefined, + state: { + datasource: { stuff: '2_datsource_persisted' }, + visualization: { things: '4_vis_persisted' }, + }, + title: 'bbb', + type: 'lens', + visualizationType: '3', + }); + }); + + it('redirects to the edit screen if the id changes', async () => { + const redirectTo = jest.fn(); + const dispatch = jest.fn(); + await save({ + ...saveArgs, + dispatch, + redirectTo, + state: { + title: 'ccc', + datasource: { activeId: '1', isLoading: false, state: {} }, + saving: false, + visualization: { activeId: '2', state: {} }, + }, + store: { + async save() { + return { id: 'bazinga' }; + }, + }, + }); + + expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_PERSISTED_ID', id: 'bazinga' }); + expect(redirectTo).toHaveBeenCalledWith('/edit/bazinga'); + }); + + it('does not redirect to the edit screen if the id does not change', async () => { + const redirectTo = jest.fn(); + const dispatch = jest.fn(); + await save({ + ...saveArgs, + dispatch, + redirectTo, + state: { + title: 'ddd', + datasource: { activeId: '1', isLoading: false, state: {} }, + persistedId: 'foo', + saving: false, + visualization: { activeId: '2', state: {} }, + }, + store: { + async save() { + return { id: 'foo' }; + }, + }, + }); + + expect(dispatch.mock.calls.some(({ type }) => type === 'UPDATE_PERSISTED_ID')).toBeFalsy(); + expect(redirectTo).not.toHaveBeenCalled(); + }); +}); 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..472220e83a44e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.ts @@ -0,0 +1,49 @@ +/* + * 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 { Action, EditorFrameState } from './state_management'; +import { Document } from '../../persistence/saved_object_store'; + +export interface Props { + datasource: { getPersistableState: (state: unknown) => unknown }; + dispatch: (value: Action) => void; + redirectTo: (path: string) => void; + state: EditorFrameState; + store: { save: (doc: Document) => Promise<{ id: string }> }; + visualization: { getPersistableState: (state: unknown) => unknown }; +} + +export async function save({ + datasource, + dispatch, + redirectTo, + state, + store, + visualization, +}: Props) { + try { + dispatch({ type: 'SAVING', isSaving: true }); + + const doc = await store.save({ + id: state.persistedId, + title: state.title, + type: 'lens', + visualizationType: state.visualization.activeId, + datasourceType: state.datasource.activeId, + state: { + datasource: datasource.getPersistableState(state.datasource.state), + visualization: visualization.getPersistableState(state.visualization.state), + }, + }); + + if (doc.id !== state.persistedId) { + dispatch({ type: 'UPDATE_PERSISTED_ID', id: doc.id }); + redirectTo(`/edit/${doc.id}`); + } + } finally { + dispatch({ type: 'SAVING', isSaving: false }); + } +} 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 index 5b767d2d05582..5f1861269dc0e 100644 --- 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 @@ -15,6 +15,12 @@ describe('editor_frame state management', () => { beforeEach(() => { props = { + onError: jest.fn(), + redirectTo: jest.fn(), + store: { + load: jest.fn(), + save: jest.fn(), + }, datasourceMap: { testDatasource: ({} as unknown) as Datasource }, visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization }, initialDatasourceId: 'testDatasource', @@ -56,6 +62,8 @@ describe('editor_frame state management', () => { state: {}, isLoading: false, }, + saving: false, + title: 'aaa', visualization: { activeId: 'testVis', state: {}, @@ -79,6 +87,8 @@ describe('editor_frame state management', () => { state: {}, isLoading: false, }, + saving: false, + title: 'bbb', visualization: { activeId: 'testVis', state: {}, @@ -103,6 +113,8 @@ describe('editor_frame state management', () => { state: {}, isLoading: false, }, + saving: false, + title: 'ccc', visualization: { activeId: 'testVis', state: testVisState, @@ -129,6 +141,8 @@ describe('editor_frame state management', () => { state: {}, isLoading: false, }, + saving: false, + title: 'ddd', visualization: { activeId: 'testVis', state: testVisState, @@ -154,6 +168,8 @@ describe('editor_frame state management', () => { state: {}, isLoading: false, }, + saving: false, + title: 'eee', visualization: { activeId: 'testVis', state: {}, @@ -170,5 +186,177 @@ describe('editor_frame state management', () => { expect(newState.datasource.activeId).toBe('testDatasource2'); expect(newState.datasource.state).toBe(null); }); + + it('should mark as saving', () => { + const newState = reducer( + { + datasource: { + activeId: 'a', + state: {}, + isLoading: false, + }, + saving: false, + title: 'fff', + visualization: { + activeId: 'b', + state: {}, + }, + }, + { + type: 'SAVING', + isSaving: true, + } + ); + + expect(newState.saving).toBeTruthy(); + }); + + it('should mark as saved', () => { + const newState = reducer( + { + datasource: { + activeId: 'a', + state: {}, + isLoading: false, + }, + saving: false, + title: 'hhh', + visualization: { + activeId: 'b', + state: {}, + }, + }, + { + type: 'SAVING', + isSaving: false, + } + ); + + expect(newState.saving).toBeFalsy(); + }); + + it('should change the persisted id', () => { + const newState = reducer( + { + datasource: { + activeId: 'a', + state: {}, + isLoading: false, + }, + saving: false, + title: 'iii', + visualization: { + activeId: 'b', + state: {}, + }, + }, + { + type: 'UPDATE_PERSISTED_ID', + id: 'baz', + } + ); + + expect(newState.persistedId).toEqual('baz'); + }); + + it('should reset the state', () => { + const newState = reducer( + { + datasource: { + activeId: 'a', + state: {}, + isLoading: false, + }, + saving: false, + title: 'jjj', + visualization: { + activeId: 'b', + state: {}, + }, + }, + { + type: 'RESET', + state: { + datasource: { + activeId: 'z', + isLoading: false, + state: { hola: 'muchacho' }, + }, + persistedId: 'bar', + saving: false, + title: 'lll', + visualization: { + activeId: 'q', + state: { my: 'viz' }, + }, + }, + } + ); + + expect(newState).toMatchObject({ + datasource: { + activeId: 'z', + isLoading: false, + state: { hola: 'muchacho' }, + }, + persistedId: 'bar', + saving: false, + visualization: { + activeId: 'q', + state: { my: 'viz' }, + }, + }); + }); + + it('should load the state from the doc', () => { + const newState = reducer( + { + datasource: { + activeId: 'a', + state: {}, + isLoading: false, + }, + saving: false, + title: 'mmm', + visualization: { + activeId: 'b', + state: {}, + }, + }, + { + type: 'VISUALIZATION_LOADED', + doc: { + datasourceType: 'a', + id: 'b', + state: { + datasource: { foo: 'c' }, + visualization: { bar: 'd' }, + }, + title: 'heyo!', + type: 'lens', + visualizationType: 'line', + }, + } + ); + + expect(newState).toEqual({ + datasource: { + activeId: 'a', + isLoading: true, + state: { + foo: 'c', + }, + }, + persistedId: 'b', + saving: false, + 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 index 5d999337038a1..df56544e5c134 100644 --- 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 @@ -4,9 +4,14 @@ * 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; + saving: boolean; + title: string; visualization: { activeId: string | null; state: unknown; @@ -19,6 +24,22 @@ export interface EditorFrameState { } export type Action = + | { + type: 'RESET'; + state: EditorFrameState; + } + | { + type: 'SAVING'; + isSaving: boolean; + } + | { + type: 'UPDATE_TITLE'; + title: string; + } + | { + type: 'UPDATE_PERSISTED_ID'; + id: string; + } | { type: 'UPDATE_DATASOURCE_STATE'; newState: unknown; @@ -27,6 +48,10 @@ export type Action = type: 'UPDATE_VISUALIZATION_STATE'; newState: unknown; } + | { + type: 'VISUALIZATION_LOADED'; + doc: Document; + } | { type: 'SWITCH_VISUALIZATION'; newVisualizationId: string; @@ -40,6 +65,8 @@ export type Action = export const getInitialState = (props: EditorFrameProps): EditorFrameState => { return { + saving: false, + title: i18n.translate('xpack.lens.chartTitle', { defaultMessage: 'New visualization' }), datasource: { state: null, isLoading: Boolean(props.initialDatasourceId), @@ -54,6 +81,31 @@ export const getInitialState = (props: EditorFrameProps): EditorFrameState => { export const reducer = (state: EditorFrameState, action: Action): EditorFrameState => { switch (action.type) { + case 'SAVING': + return { ...state, saving: action.isSaving }; + case 'RESET': + return action.state; + case 'UPDATE_PERSISTED_ID': + return { ...state, persistedId: action.id }; + case 'UPDATE_TITLE': + return { ...state, title: action.title }; + case 'VISUALIZATION_LOADED': + return { + ...state, + persistedId: action.doc.id, + title: action.doc.title, + datasource: { + ...state.datasource, + activeId: action.doc.datasourceType || null, + isLoading: true, + state: action.doc.state.datasource, + }, + visualization: { + ...state.visualization, + activeId: action.doc.visualizationType, + state: action.doc.state.visualization, + }, + }; case 'SWITCH_DATASOURCE': return { ...state, 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 index 7a0017d83ad3a..796fce7ddbcf5 100644 --- 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 @@ -6,16 +6,7 @@ import React, { useState, useEffect, useMemo, useContext } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiCodeBlock, - EuiSpacer, - EuiPageContent, - EuiPageContentHeader, - EuiPageContentHeaderSection, - EuiTitle, - EuiPageContentBody, -} from '@elastic/eui'; - +import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; import { toExpression } from '@kbn/interpreter/common'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/data/public'; import { Action } from './state_management'; @@ -157,19 +148,8 @@ export function WorkspacePanel({ } return ( - - - - -

New Visualization

-
-
-
- - - {renderVisualization()} - - -
+ + {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/initializable_component.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.test.tsx new file mode 100644 index 0000000000000..3f935d91054b8 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.test.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 React from 'react'; +import { render, mount } from 'enzyme'; +import { InitializableComponent } from './initializable_component'; + +function resolvable() { + let resolve: (value: {}) => void; + + return { + promise: new Promise(res => (resolve = res)), + resolve: (x: {}) => resolve(x), + }; +} + +describe('InitializableComponent', () => { + test('renders nothing if loading', () => { + const component = render( + Promise.resolve({ hello: 'world' })} + render={props =>
{props!.hello}
} + /> + ); + + expect(component).toMatchInlineSnapshot(`null`); + }); + + test('passes the resolved props to render', async () => { + const initPromise = Promise.resolve({ test: 'props' }); + const mockRender = jest.fn(() =>
); + + mount( initPromise} render={mockRender} />); + + await initPromise; + expect(mockRender).toHaveBeenCalledWith({ test: 'props' }); + }); + + test('allows an undefined resolve', async () => { + const initPromise = Promise.resolve(); + const mockRender = jest.fn(() =>
); + + mount( initPromise} render={mockRender} />); + + await initPromise; + expect(mockRender).toHaveBeenCalledWith(undefined); + }); + + test('ignores stale promise results', async () => { + const firstInit = resolvable(); + const secondInit = resolvable(); + const mockRender = jest.fn(() =>
); + + const component = mount( + firstInit.promise} render={mockRender} /> + ); + + component.setProps({ + watch: ['b'], + init: () => secondInit.promise, + render: mockRender, + }); + + firstInit.resolve({ hello: 1 }); + secondInit.resolve({ hello: 2 }); + await secondInit.promise; + + expect(mockRender).not.toHaveBeenCalledWith({ hello: 1 }); + expect(mockRender).toHaveBeenCalledWith({ hello: 2 }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.tsx new file mode 100644 index 0000000000000..a5da8adb7e485 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.tsx @@ -0,0 +1,41 @@ +/* + * 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 { useState, useEffect } from 'react'; + +interface Props { + watch: unknown[]; + init: () => Promise; + render: (props: T) => JSX.Element | null; +} + +export function InitializableComponent(props: Props) { + const [state, setState] = useState<{ isLoading: boolean; result?: T }>({ + isLoading: true, + result: undefined, + }); + + useEffect(() => { + let isStale = false; + + props.init().then(result => { + if (!isStale) { + setState({ isLoading: false, result }); + } + }); + + return () => { + isStale = true; + }; + }, props.watch); + + if (state.isLoading) { + // TODO: Handle the loading / undefined result case + return null; + } + + return props.render(state.result!); +} 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 index c34d8bc85d578..ae169aa67148e 100644 --- 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 @@ -4,21 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EditorFramePlugin } from './plugin'; +import React from 'react'; +import { EditorFramePlugin, init, InitializedEditor } from './plugin'; import { createMockDependencies, MockedDependencies, createMockDatasource, createMockVisualization, } from './mocks'; +import { SavedObjectStore, Document } from '../persistence'; +import { shallow, mount } from 'enzyme'; -// 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)); +jest.mock('ui/chrome', () => ({ + getSavedObjectsClient: jest.fn(), +})); // mock away actual data plugin to prevent all of it being loaded jest.mock('../../../../../../src/legacy/core_plugins/data/public/setup', () => {}); +function mockStore(): SavedObjectStore { + return { + load: jest.fn(), + save: jest.fn(), + }; +} + describe('editor_frame plugin', () => { let pluginInstance: EditorFramePlugin; let mountpoint: Element; @@ -38,62 +48,178 @@ describe('editor_frame plugin', () => { expect(() => { const publicAPI = pluginInstance.setup(null, pluginDependencies); const instance = publicAPI.createInstance({}); - instance.mount(mountpoint); + instance.mount(mountpoint, { onError: jest.fn() }); instance.unmount(); }).not.toThrowError(); }); - it('should render something in the provided dom element', () => { - const publicAPI = pluginInstance.setup(null, pluginDependencies); - const instance = publicAPI.createInstance({}); - instance.mount(mountpoint); - - expect(mountpoint.hasChildNodes()).toBe(true); - - instance.unmount(); - }); - it('should not have child nodes after unmount', () => { const publicAPI = pluginInstance.setup(null, pluginDependencies); const instance = publicAPI.createInstance({}); - instance.mount(mountpoint); + instance.mount(mountpoint, { onError: jest.fn() }); instance.unmount(); expect(mountpoint.hasChildNodes()).toBe(false); }); - it('should initialize and render provided datasource', async () => { - const mockDatasource = createMockDatasource(); - const publicAPI = pluginInstance.setup(null, pluginDependencies); - publicAPI.registerDatasource('test', mockDatasource); - - const instance = publicAPI.createInstance({}); - instance.mount(mountpoint); - - await waitForPromises(); - - expect(mockDatasource.initialize).toHaveBeenCalled(); - expect(mockDatasource.renderDataPanel).toHaveBeenCalled(); - - instance.unmount(); + describe('init', () => { + it('should do nothing if the persistedId is undefined', async () => { + const store = mockStore(); + expect( + await init({ + store, + onError: jest.fn(), + }) + ).toEqual({}); + expect(store.load).not.toHaveBeenCalled(); + }); + + it('should load the document, if persistedId is defined', async () => { + const doc: Document = { + datasourceType: 'indexpattern', + id: 'hoi', + state: { datasource: 'foo', visualization: 'bar' }, + title: 'shazm', + visualizationType: 'fanci', + type: 'lens', + }; + + const store = { + ...mockStore(), + load: jest.fn(async () => doc), + }; + + expect( + await init({ + persistedId: 'hoi', + store, + onError: jest.fn(), + }) + ).toEqual({ doc }); + + expect(store.load).toHaveBeenCalledWith('hoi'); + }); + + it('should call onError if an error occurs while loading', async () => { + const error = new Error('dang!'); + const store = { + ...mockStore(), + load: jest.fn(async () => { + throw error; + }), + }; + const onError = jest.fn(); + + expect( + await init({ + persistedId: 'hoi', + store, + onError, + }) + ).toEqual({ error }); + + expect(onError).toHaveBeenCalledWith(error); + }); + + it('should not call onError if a 404 error occurs while loading', async () => { + const error = new Object({ statusCode: 404 }); + const store = { + ...mockStore(), + load: jest.fn(async () => { + throw error; + }), + }; + const onError = jest.fn(); + + expect( + await init({ + persistedId: 'hoi', + store, + onError, + }) + ).toEqual({ error }); + + expect(onError).not.toHaveBeenCalled(); + }); }); - it('should initialize visualization and render config panel', async () => { - const mockDatasource = createMockDatasource(); - const mockVisualization = createMockVisualization(); - const publicAPI = pluginInstance.setup(null, pluginDependencies); - - publicAPI.registerDatasource('test', mockDatasource); - publicAPI.registerVisualization('test', mockVisualization); - - const instance = publicAPI.createInstance({}); - instance.mount(mountpoint); - - await waitForPromises(); - - expect(mockVisualization.initialize).toHaveBeenCalled(); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalled(); - - instance.unmount(); + describe('render', () => { + it('renders 404 if given a 404 error', () => { + const error = { statusCode: 404, message: 'Ruh roh!' }; + const result = shallow( +
} + routeProps={{ history: { push: jest.fn() } }} + store={mockStore()} + onError={jest.fn()} + /> + ); + expect(result).toMatchInlineSnapshot(``); + }); + + it('redirects via route history', () => { + const historyPush = jest.fn(); + const component = mount( +
} + routeProps={{ history: { push: historyPush } }} + store={mockStore()} + onError={jest.fn()} + /> + ); + + const redirectTo = component.find('[data-test-subj="lnsEditorFrame"]').prop('redirectTo') as ( + path: string + ) => void; + redirectTo('mehnewurl'); + expect(historyPush).toHaveBeenCalledWith('mehnewurl'); + }); + + it('uses the document datasource and visualization types, if available', () => { + const component = mount( +
} + routeProps={{ history: { push: jest.fn() } }} + store={mockStore()} + onError={jest.fn()} + /> + ); + + const frame = component.find('[data-test-subj="lnsEditorFrame"]'); + + expect(frame.prop('initialDatasourceId')).toEqual('b'); + expect(frame.prop('initialVisualizationId')).toEqual('d'); + }); + + it('uses the first datasource and visualization type, if there is no document', () => { + const component = mount( +
} + routeProps={{ history: { push: jest.fn() } }} + store={mockStore()} + onError={jest.fn()} + /> + ); + + const frame = component.find('[data-test-subj="lnsEditorFrame"]'); + + expect(frame.prop('initialDatasourceId')).toEqual('a'); + expect(frame.prop('initialVisualizationId')).toEqual('c'); + }); }); }); 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 index 4112a928342b5..b55a84d32c17a 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -8,28 +8,60 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { CoreSetup } from 'src/core/public'; +import { HashRouter, Switch, Route, RouteComponentProps } from 'react-router-dom'; +import chrome from 'ui/chrome'; import { DataSetup, ExpressionRenderer, } from '../../../../../../src/legacy/core_plugins/data/public'; import { data } from '../../../../../../src/legacy/core_plugins/data/public/setup'; -import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; +import { + Datasource, + Visualization, + EditorFrameSetup, + EditorFrameInstance, + ErrorCallback, +} from '../types'; import { EditorFrame } from './editor_frame'; +import { SavedObjectIndexStore, SavedObjectStore, Document } from '../persistence'; +import { InitializableComponent } from './initializable_component'; export interface EditorFrameSetupPlugins { data: DataSetup; } +interface InitializationResult { + doc?: Document; + error?: { message: string }; +} + +interface InitializationProps { + persistedId?: string; + store: SavedObjectStore; + onError: ErrorCallback; +} + +interface RenderProps extends InitializationResult { + routeProps: { history: { push: (path: string) => void } }; + store: SavedObjectStore; + onError: ErrorCallback; + datasources: Record; + visualizations: Record; + expressionRenderer: ExpressionRenderer; +} + export class EditorFramePlugin { constructor() {} - private ExpressionRenderer: ExpressionRenderer | null = null; + private ExpressionRenderer: ExpressionRenderer | null = null; private readonly datasources: Record = {}; private readonly visualizations: Record = {}; private createInstance(): EditorFrameInstance { let domElement: Element; + const store = new SavedObjectIndexStore(chrome.getSavedObjectsClient()); + function unmount() { if (domElement) { unmountComponentAtNode(domElement); @@ -37,22 +69,41 @@ export class EditorFramePlugin { } return { - mount: element => { - unmount(); + mount: (element, { onError }) => { domElement = element; - const firstDatasourceId = Object.keys(this.datasources)[0]; - const firstVisualizationId = Object.keys(this.visualizations)[0]; + const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { + const persistedId = routeProps.match.params.id; + + return ( + init({ persistedId, store, onError })} + render={({ doc, error }) => ( + + )} + /> + ); + }; render( - + + + + + + + , domElement ); @@ -85,4 +136,66 @@ export const editorFrameSetup = () => editorFrame.setup(null, { data, }); + export const editorFrameStop = () => editorFrame.stop(); + +function NotFound() { + return

TODO: 404 Page

; +} + +function is404(error?: unknown) { + return error && (error as { statusCode: number }).statusCode === 404; +} + +export async function init({ + persistedId, + store, + onError, +}: InitializationProps): Promise { + if (!persistedId) { + return {}; + } else { + return store + .load(persistedId) + .then(doc => ({ doc })) + .catch((error: Error) => { + if (!is404(error)) { + onError(error); + } + return { error }; + }); + } +} + +export function InitializedEditor({ + doc, + error, + routeProps, + onError, + store, + datasources, + visualizations, + expressionRenderer, +}: RenderProps) { + const firstDatasourceId = Object.keys(datasources)[0]; + const firstVisualizationId = Object.keys(visualizations)[0]; + + if (is404(error)) { + return ; + } + + return ( + routeProps.history.push(path)} + doc={doc} + /> + ); +} 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..53d2a0cc08ad1 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts @@ -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 { 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', + datasourceType: 'indexpattern', + state: { + datasource: { type: 'index_pattern', indexPattern: '.kibana_test' }, + visualization: { x: 'foo', y: 'baz' }, + }, + }); + + expect(doc).toEqual({ + id: 'FOO', + title: 'Hello', + visualizationType: 'bar', + datasourceType: 'indexpattern', + state: { + datasource: { type: 'index_pattern', indexPattern: '.kibana_test' }, + visualization: { x: 'foo', y: 'baz' }, + }, + }); + + expect(client.create).toHaveBeenCalledTimes(1); + expect(client.create).toHaveBeenCalledWith('lens', { + datasourceType: 'indexpattern', + title: 'Hello', + visualizationType: 'bar', + state: JSON.stringify({ + datasource: { type: 'index_pattern', indexPattern: '.kibana_test' }, + visualization: { x: 'foo', y: 'baz' }, + }), + }); + }); + + 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', + datasourceType: 'indexpattern', + state: { + datasource: { type: 'index_pattern', indexPattern: 'lotr' }, + visualization: { gear: ['staff', 'pointy hat'] }, + }, + }); + + expect(doc).toEqual({ + id: 'Gandalf', + title: 'Even the very wise cannot see all ends.', + visualizationType: 'line', + datasourceType: 'indexpattern', + state: { + datasource: { type: 'index_pattern', indexPattern: 'lotr' }, + visualization: { gear: ['staff', 'pointy hat'] }, + }, + }); + + expect(client.update).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledWith('lens', 'Gandalf', { + title: 'Even the very wise cannot see all ends.', + visualizationType: 'line', + datasourceType: 'indexpattern', + state: JSON.stringify({ + datasource: { type: 'index_pattern', indexPattern: 'lotr' }, + visualization: { gear: ['staff', 'pointy hat'] }, + }), + }); + }); + }); + + describe('load', () => { + test('parses the visState', 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 } }', + }, + })); + const doc = await store.load('Paul'); + + expect(doc).toEqual({ + id: 'Paul', + type: 'lens', + title: 'Hope clouds observation.', + visualizationType: 'dune', + state: { + datasource: { giantWorms: true }, + }, + }); + + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledWith('lens', 'Paul'); + }); + + 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..930ee36ea3729 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts @@ -0,0 +1,84 @@ +/* + * 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 { SavedObjectAttributes } from 'target/types/server'; + +export interface Document { + id?: string; + type?: string; + visualizationType: string | null; + datasourceType: string | null; + title: string; + state: { + datasource: unknown; + visualization: unknown; + }; +} + +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?: { 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; + const attributes = { + ...rest, + state: JSON.stringify(rest.state), + }; + 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, + state: JSON.parse(((attributes as unknown) as { state: string }).state as string), + } as Document; + } +} diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 6be550a2342cd..c532d2d69b0a8 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -10,8 +10,10 @@ import { DragContextState } from './drag_drop'; // eslint-disable-next-line export interface EditorFrameOptions {} +export type ErrorCallback = (e: { message: string }) => void; + export interface EditorFrameInstance { - mount: (element: Element) => void; + mount: (element: Element, props: { onError: ErrorCallback }) => void; unmount: () => void; } 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 index 2cf89a4a58196..a2aebeff9c3b6 100644 --- 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 @@ -29,9 +29,6 @@ Object { ], "splitSeriesAccessors": Array [], "stackAccessors": Array [], - "title": Array [ - "Foo", - ], "x": Array [ Object { "chain": Array [ 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 index 33750937cebe8..62fcda60c9950 100644 --- 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 @@ -31,7 +31,6 @@ export const buildExpression = ( function: 'lens_xy_chart', arguments: { seriesType: [state.seriesType], - title: [state.title], legend: [ { type: 'expression', 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 index 57a5bcd4966c8..069adaedd95f9 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -150,7 +150,6 @@ export type SeriesType = 'bar' | 'horizontal_bar' | 'line' | 'area'; export interface XYArgs { seriesType: SeriesType; - title: string; legend: LegendConfig; y: YConfig; x: XConfig; @@ -160,7 +159,6 @@ export interface XYArgs { export interface XYState { seriesType: SeriesType; - title: string; legend: LegendConfig; y: YState; x: XConfig; 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 index 95c4543f32547..df051298f2c20 100644 --- 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 @@ -34,7 +34,6 @@ describe('XYConfigPanel', () => { seriesType: 'bar', splitSeriesAccessors: [], stackAccessors: [], - title: 'Test Chart', x: { accessor: 'foo', position: Position.Bottom, @@ -125,32 +124,6 @@ describe('XYConfigPanel', () => { }); }); - test('allows editing the chart title', () => { - const testSetTitle = (title: string) => { - const setState = jest.fn(); - const component = mount( - - ); - - (testSubj(component, 'lnsXY_title').onChange as Function)({ target: { value: title } }); - - expect(setState).toHaveBeenCalledTimes(1); - return setState.mock.calls[0][0]; - }; - - expect(testSetTitle('Hoi')).toMatchObject({ - title: 'Hoi', - }); - expect(testSetTitle('There!')).toMatchObject({ - title: 'There!', - }); - }); - test('allows changing legend position', () => { const testLegendPosition = (position: Position) => { const setState = jest.fn(); 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 index 86883b4e629e3..b570525b10dcc 100644 --- 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 @@ -105,24 +105,6 @@ export function XYConfigPanel(props: VisualizationProps) { /> - - setState({ ...state, title: e.target.value })} - aria-label={i18n.translate('xpack.lens.xyChart.chartTitleAriaLabel', { - defaultMessage: 'Title', - })} - /> - - Date: Tue, 2 Jul 2019 14:37:19 -0400 Subject: [PATCH 040/105] [lens] Fix lint error --- .../plugins/lens/public/indexpattern_plugin/indexpattern.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index a1b3d9b6efe3d..75fd323c6048f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -11,7 +11,6 @@ import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; import uuid from 'uuid'; -import { Datasource, DataType } from '..'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, @@ -23,6 +22,7 @@ import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; import { buildColumnForOperationType, getOperationTypesForField } from './operations'; +import { Datasource, DataType } from '..'; export type OperationType = IndexPatternColumn['operationType']; From d2b8d91a451a8ca1b150871d182cad6d6601dac7 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Fri, 5 Jul 2019 14:00:22 -0400 Subject: [PATCH 041/105] [lens] Use node scripts/eslint.js --fix to fix errors --- .../editor_frame/editor_frame.tsx | 103 ++++++++---------- .../editor_frame/suggestion_panel.tsx | 9 +- .../editor_frame/workspace_panel.tsx | 56 +++++----- 3 files changed, 75 insertions(+), 93 deletions(-) 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 index 4b0ae459e2018..9c1e06f54178b 100644 --- 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 @@ -58,67 +58,58 @@ export function EditorFrame(props: EditorFrameProps) { ] ); - useEffect( - () => { - if (props.doc) { - dispatch({ - type: 'VISUALIZATION_LOADED', - doc: props.doc, - }); - } else { - dispatch({ - type: 'RESET', - state: getInitialState(props), - }); - } - }, - [props.doc] - ); + useEffect(() => { + if (props.doc) { + dispatch({ + type: 'VISUALIZATION_LOADED', + doc: props.doc, + }); + } else { + dispatch({ + type: 'RESET', + state: getInitialState(props), + }); + } + }, [props.doc]); // Initialize current datasource - useEffect( - () => { - let datasourceGotSwitched = false; - if (state.datasource.isLoading && state.datasource.activeId) { - props.datasourceMap[state.datasource.activeId] - .initialize(props.doc && props.doc.state.datasource) - .then(datasourceState => { - if (!datasourceGotSwitched) { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - newState: datasourceState, - }); - } - }) - .catch(onError); + useEffect(() => { + let datasourceGotSwitched = false; + if (state.datasource.isLoading && state.datasource.activeId) { + props.datasourceMap[state.datasource.activeId] + .initialize(props.doc && props.doc.state.datasource) + .then(datasourceState => { + if (!datasourceGotSwitched) { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + newState: datasourceState, + }); + } + }) + .catch(onError); - return () => { - datasourceGotSwitched = true; - }; - } - }, - [props.doc, state.datasource.activeId, state.datasource.isLoading] - ); + return () => { + datasourceGotSwitched = true; + }; + } + }, [props.doc, state.datasource.activeId, state.datasource.isLoading]); // Initialize visualization as soon as datasource is ready - useEffect( - () => { - if ( - datasourcePublicAPI && - state.visualization.state === null && - state.visualization.activeId !== null - ) { - const initialVisualizationState = props.visualizationMap[ - state.visualization.activeId - ].initialize(datasourcePublicAPI); - dispatch({ - type: 'UPDATE_VISUALIZATION_STATE', - newState: initialVisualizationState, - }); - } - }, - [datasourcePublicAPI, state.visualization.activeId, state.visualization.state] - ); + useEffect(() => { + if ( + datasourcePublicAPI && + state.visualization.state === null && + state.visualization.activeId !== null + ) { + const initialVisualizationState = props.visualizationMap[ + state.visualization.activeId + ].initialize(datasourcePublicAPI); + dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + newState: initialVisualizationState, + }); + } + }, [datasourcePublicAPI, state.visualization.activeId, state.visualization.state]); const datasource = state.datasource.activeId && !state.datasource.isLoading 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 index 9be220cd3f11f..efa2628d58ce0 100644 --- 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 @@ -39,12 +39,9 @@ const SuggestionPreview = ({ }) => { const [expressionError, setExpressionError] = useState(false); - useEffect( - () => { - setExpressionError(false); - }, - [previewExpression] - ); + useEffect(() => { + setExpressionError(false); + }, [previewExpression]); return ( { - try { - return buildExpression( - activeVisualization, - visualizationState, - activeDatasource, - datasourceState, - datasourcePublicAPI - ); - } catch (e) { - setExpressionError(e.toString()); - } - }, - [ - activeVisualization, - visualizationState, - activeDatasource, - datasourceState, - datasourcePublicAPI, - ] - ); + const expression = useMemo(() => { + try { + return buildExpression( + activeVisualization, + visualizationState, + activeDatasource, + datasourceState, + datasourcePublicAPI + ); + } catch (e) { + setExpressionError(e.toString()); + } + }, [ + activeVisualization, + visualizationState, + activeDatasource, + datasourceState, + datasourcePublicAPI, + ]); - useEffect( - () => { - // reset expression error if component attempts to run it again - if (expressionError) { - setExpressionError(undefined); - } - }, - [expression] - ); + useEffect(() => { + // reset expression error if component attempts to run it again + if (expressionError) { + setExpressionError(undefined); + } + }, [expression]); if (expression === null) { return renderEmptyWorkspace(); From e2dc494c94b0043b78ee3c53473ea3ed184b7405 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 9 Jul 2019 13:48:53 -0400 Subject: [PATCH 042/105] [lens] Include link to lens from Visualize (#40542) --- x-pack/legacy/plugins/lens/index.ts | 2 +- x-pack/legacy/plugins/lens/public/index.ts | 1 - .../lens/public/register_vis_type_alias.ts | 22 +++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index 33cf892435708..88a1b25fc4e39 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -9,7 +9,6 @@ import { Server } from 'hapi'; import { resolve } from 'path'; import { LegacyPluginInitializer } from 'src/legacy/types'; import mappings from './mappings.json'; - import { PLUGIN_ID } from './common'; const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; @@ -29,6 +28,7 @@ export const lens: LegacyPluginInitializer = kibana => { }, styleSheetPaths: resolve(__dirname, 'public/index.scss'), mappings, + visTypes: ['plugins/lens/register_vis_type_alias'], savedObjectsManagement: { lens: { defaultSearchField: 'title', diff --git a/x-pack/legacy/plugins/lens/public/index.ts b/x-pack/legacy/plugins/lens/public/index.ts index 532b6e66d2b27..c71a3adf22485 100644 --- a/x-pack/legacy/plugins/lens/public/index.ts +++ b/x-pack/legacy/plugins/lens/public/index.ts @@ -17,7 +17,6 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { IScope } from 'angular'; import chrome from 'ui/chrome'; import { appSetup, appStop } from './app_plugin'; - import { PLUGIN_ID } from '../common'; // TODO: Convert this to the "new platform" way of doing UI 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..595eb4d0e350b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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'; + +const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; + +visualizations.types.visTypeAliasRegistry.add({ + aliasUrl: '/app/lens/', + name: NOT_INTERNATIONALIZED_PRODUCT_NAME, + 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', +}); From cbccad665224e0197174e4a5a1629e4ea5199b35 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 10 Jul 2019 15:03:40 -0400 Subject: [PATCH 043/105] [lens] Support stacking in xy visualization (#40546) * [lens] Support stacking in xy visualization * Use chart type switcher for stacked and horizontal xy charts * Clean up remaining isStacked code * Fix type error --- .../__snapshots__/xy_expression.test.tsx.snap | 223 ++++++++++++++++++ .../xy_visualization.test.ts.snap | 1 - .../xy_visualization_plugin/to_expression.ts | 1 - .../public/xy_visualization_plugin/types.ts | 11 +- .../xy_config_panel.test.tsx | 52 +++- .../xy_config_panel.tsx | 43 +++- .../xy_expression.test.tsx | 65 ++++- .../xy_visualization_plugin/xy_expression.tsx | 26 +- .../xy_suggestions.test.ts | 125 +++++----- .../xy_visualization_plugin/xy_suggestions.ts | 3 +- .../xy_visualization.test.ts | 50 ++-- .../xy_visualization.tsx | 3 +- 12 files changed, 476 insertions(+), 127 deletions(-) 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 index c47fc662e8b4a..0c072d1fe5bf2 100644 --- 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 @@ -7,6 +7,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` > @@ -58,6 +59,59 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` > + + + + +`; + +exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` + + @@ -109,6 +163,7 @@ exports[`xy_expression XYChart component it renders line 1`] = ` > @@ -152,3 +207,171 @@ 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 index a2aebeff9c3b6..3b726271038c0 100644 --- 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 @@ -28,7 +28,6 @@ Object { "area", ], "splitSeriesAccessors": Array [], - "stackAccessors": Array [], "x": Array [ Object { "chain": Array [ 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 index 62fcda60c9950..80ee1ca4e67da 100644 --- 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 @@ -86,7 +86,6 @@ export const buildExpression = ( }, ], splitSeriesAccessors: state.splitSeriesAccessors, - stackAccessors: state.stackAccessors, }, }, ], 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 index 069adaedd95f9..d2b3b962f10ee 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -146,7 +146,14 @@ export const xConfig: ExpressionFunction<'lens_xy_xConfig', null, XConfig, XConf }, }; -export type SeriesType = 'bar' | 'horizontal_bar' | 'line' | 'area'; +export type SeriesType = + | 'bar' + | 'horizontal_bar' + | 'line' + | 'area' + | 'bar_stacked' + | 'horizontal_bar_stacked' + | 'area_stacked'; export interface XYArgs { seriesType: SeriesType; @@ -154,7 +161,6 @@ export interface XYArgs { y: YConfig; x: XConfig; splitSeriesAccessors: string[]; - stackAccessors: string[]; } export interface XYState { @@ -163,7 +169,6 @@ export interface XYState { y: YState; x: XConfig; splitSeriesAccessors: string[]; - stackAccessors: string[]; } export type State = XYState; 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 index df051298f2c20..e1ce69999fed8 100644 --- 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 @@ -7,6 +7,7 @@ import React 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 { DatasourcePublicAPI, DatasourceDimensionPanelProps, Operation } from '../types'; import { State, SeriesType } from './types'; @@ -33,7 +34,6 @@ describe('XYConfigPanel', () => { legend: { isVisible: true, position: Position.Right }, seriesType: 'bar', splitSeriesAccessors: [], - stackAccessors: [], x: { accessor: 'foo', position: Position.Bottom, @@ -56,6 +56,56 @@ describe('XYConfigPanel', () => { .props(); } + test('disables stacked chart types without a split series', () => { + const component = mount( + {}} + state={testState()} + /> + ); + + const options = component + .find('[data-test-subj="lnsXY_seriesType"]') + .first() + .prop('options') as EuiButtonGroupProps['options']; + + expect(options.map(({ id }) => id)).toEqual([ + 'line', + 'area', + 'bar', + 'horizontal_bar', + 'area_stacked', + 'bar_stacked', + 'horizontal_bar_stacked', + ]); + + expect(options.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([ + 'area_stacked', + 'bar_stacked', + 'horizontal_bar_stacked', + ]); + }); + + test('enables all stacked chart types when there is a split series', () => { + const component = mount( + {}} + state={{ ...testState(), splitSeriesAccessors: ['c'] }} + /> + ); + + const options = component + .find('[data-test-subj="lnsXY_seriesType"]') + .first() + .prop('options') as EuiButtonGroupProps['options']; + + expect(options.every(({ isDisabled }) => !isDisabled)).toEqual(true); + }); + test('toggles axis position when going from horizontal bar to any other type', () => { const changeSeriesType = (fromSeriesType: SeriesType, toSeriesType: SeriesType) => { const setState = jest.fn(); 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 index b570525b10dcc..b2d999f7fb348 100644 --- 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 @@ -24,22 +24,51 @@ import { NativeRenderer } from '../native_renderer'; const chartTypeIcons: Array<{ id: SeriesType; label: string; iconType: IconType }> = [ { id: 'line', - label: 'Line', + label: i18n.translate('xpack.lens.xyVisualization.lineChartLabel', { + defaultMessage: 'Line', + }), iconType: 'visLine', }, { id: 'area', - label: 'Area', + label: i18n.translate('xpack.lens.xyVisualization.areaChartLabel', { + defaultMessage: 'Area', + }), iconType: 'visArea', }, { id: 'bar', - label: 'Bar', + label: i18n.translate('xpack.lens.xyVisualization.barChartLabel', { + defaultMessage: 'Bar', + }), iconType: 'visBarVertical', }, { id: 'horizontal_bar', - label: 'Horizontal Bar', + label: i18n.translate('xpack.lens.xyVisualization.horizontalBarChartLabel', { + defaultMessage: 'Horizontal Bar', + }), + iconType: 'visBarHorizontal', + }, + { + id: 'area_stacked', + label: i18n.translate('xpack.lens.xyVisualization.stackedAreaChartLabel', { + defaultMessage: 'Stacked Area', + }), + iconType: 'visArea', + }, + { + id: 'bar_stacked', + label: i18n.translate('xpack.lens.xyVisualization.stackedBarChartLabel', { + defaultMessage: 'Stacked Bar', + }), + iconType: 'visBarVertical', + }, + { + id: 'horizontal_bar_stacked', + label: i18n.translate('xpack.lens.xyVisualization.stackedHorizontalBarChartLabel', { + defaultMessage: 'Stacked Horizontal Bar', + }), iconType: 'visBarHorizontal', }, ]; @@ -84,7 +113,11 @@ export function XYConfigPanel(props: VisualizationProps) { name="chartType" className="eui-displayInlineBlock" data-test-subj="lnsXY_seriesType" - options={chartTypeIcons} + options={chartTypeIcons.map(type => + type.id.includes('stacked') && state.splitSeriesAccessors.length === 0 + ? { ...type, isDisabled: true } + : type + )} idSelected={state.seriesType} onChange={seriesType => { const isHorizontal = seriesType === 'horizontal_bar'; 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 index 7f1f01bd75c51..7e1124a0213d4 100644 --- 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BarSeries, Position } from '@elastic/charts'; +import { AreaSeries, BarSeries, Position, LineSeries, Settings } from '@elastic/charts'; import { xyChart, XYChart } from './xy_expression'; import { KibanaDatatable } from '../types'; import React from 'react'; @@ -38,7 +38,6 @@ function sampleArgs() { title: 'C', }, splitSeriesAccessors: [], - stackAccessors: [], }; return { data, args }; @@ -104,25 +103,69 @@ describe('xy_expression', () => { test('it renders line', () => { const { data, args } = sampleArgs(); - expect( - shallow() - ).toMatchSnapshot(); + const component = shallow(); + expect(component).toMatchSnapshot(); + expect(component.find(LineSeries)).toHaveLength(1); }); test('it renders bar', () => { const { data, args } = sampleArgs(); - expect( - shallow() - ).toMatchSnapshot(); + const component = shallow(); + expect(component).toMatchSnapshot(); + expect(component.find(BarSeries)).toHaveLength(1); }); test('it renders area', () => { const { data, args } = sampleArgs(); - expect( - shallow() - ).toMatchSnapshot(); + 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); + }); + + 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 remaps rows based on the labels', () => { 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 index 46ba2614b682f..2392114e26999 100644 --- 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 @@ -39,7 +39,16 @@ export const xyChart: ExpressionFunction<'lens_xy_chart', KibanaDatatable, XYArg args: { seriesType: { types: ['string'], - options: ['bar', 'line', 'area'], + options: [ + 'bar', + 'line', + 'area', + 'horizontal_bar', + 'bar_stacked', + 'line_stacked', + 'area_stacked', + 'horizontal_bar_stacked', + ], help: 'The type of chart to display.', }, legend: { @@ -59,11 +68,6 @@ export const xyChart: ExpressionFunction<'lens_xy_chart', KibanaDatatable, XYArg multi: true, help: 'The columns used to split the series.', }, - stackAccessors: { - types: ['string'], - multi: true, - help: 'The columns used to stack the series.', - }, }, context: { types: ['kibana_datatable'], @@ -98,12 +102,12 @@ export const xyChartRenderer: RenderFunction = { }; export function XYChart({ data, args }: XYChartProps) { - const { legend, x, y, splitSeriesAccessors, stackAccessors, seriesType } = args; + const { legend, x, y, splitSeriesAccessors, seriesType } = args; // TODO: Stop mapping data once elastic-charts allows axis naming // https://github.com/elastic/elastic-charts/issues/245 const seriesProps = { splitSeriesAccessors, - stackAccessors, + stackAccessors: seriesType.includes('stacked') ? [x.accessor] : [], id: getSpecId(y.labels.join(',')), xAccessor: x.accessor, yAccessors: y.labels, @@ -129,6 +133,7 @@ export function XYChart({ data, args }: XYChartProps) { showLegend={legend.isVisible} legendPosition={legend.position} showLegendDisplayValue={false} + rotation={seriesType.includes('horizontal') ? 90 : 0} /> - ) : seriesType === 'bar' ? ( + ) : seriesType === 'bar' || + seriesType === 'bar_stacked' || + seriesType === 'horizontal_bar' || + seriesType === 'horizontal_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 index 98500c78bf266..d0e82b808f46a 100644 --- 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 @@ -49,12 +49,11 @@ describe('xy_suggestions', () => { // Helper that plucks out the important part of a suggestion for // most test assertions function suggestionSubset(suggestion: VisualizationSuggestion) { - const { seriesType, splitSeriesAccessors, stackAccessors, x, y } = suggestion.state; + const { seriesType, splitSeriesAccessors, x, y } = suggestion.state; return { seriesType, splitSeriesAccessors, - stackAccessors, x: x.accessor, y: y.accessors, }; @@ -100,16 +99,15 @@ describe('xy_suggestions', () => { expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` -Object { - "seriesType": "line", - "splitSeriesAccessors": Array [], - "stackAccessors": Array [], - "x": "date", - "y": Array [ - "bytes", - ], -} -`); + Object { + "seriesType": "bar", + "splitSeriesAccessors": Array [], + "x": "date", + "y": Array [ + "bytes", + ], + } + `); }); test('suggests a split x y chart with date on x', () => { @@ -125,19 +123,18 @@ Object { expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` -Object { - "seriesType": "line", - "splitSeriesAccessors": Array [ - "product", - ], - "stackAccessors": Array [], - "x": "date", - "y": Array [ - "price", - "quantity", - ], -} -`); + Object { + "seriesType": "line", + "splitSeriesAccessors": Array [ + "product", + ], + "x": "date", + "y": Array [ + "price", + "quantity", + ], + } + `); }); test('supports multiple suggestions', () => { @@ -158,27 +155,25 @@ Object { expect(rest).toHaveLength(0); expect([suggestionSubset(s1), suggestionSubset(s2)]).toMatchInlineSnapshot(` -Array [ - Object { - "seriesType": "line", - "splitSeriesAccessors": Array [], - "stackAccessors": Array [], - "x": "date", - "y": Array [ - "price", - ], - }, - Object { - "seriesType": "bar", - "splitSeriesAccessors": Array [], - "stackAccessors": Array [], - "x": "country", - "y": Array [ - "count", - ], - }, -] -`); + Array [ + Object { + "seriesType": "bar", + "splitSeriesAccessors": Array [], + "x": "date", + "y": Array [ + "price", + ], + }, + Object { + "seriesType": "bar", + "splitSeriesAccessors": Array [], + "x": "country", + "y": Array [ + "count", + ], + }, + ] + `); }); test('handles two numeric values', () => { @@ -193,16 +188,15 @@ Array [ }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` -Object { - "seriesType": "bar", - "splitSeriesAccessors": Array [], - "stackAccessors": Array [], - "x": "quantity", - "y": Array [ - "price", - ], -} -`); + Object { + "seriesType": "bar", + "splitSeriesAccessors": Array [], + "x": "quantity", + "y": Array [ + "price", + ], + } + `); }); test('handles unbucketed suggestions', () => { @@ -228,16 +222,15 @@ Object { }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` -Object { - "seriesType": "bar", - "splitSeriesAccessors": Array [], - "stackAccessors": Array [], - "x": "mybool", - "y": Array [ - "num votes", - ], -} -`); + Object { + "seriesType": "bar", + "splitSeriesAccessors": Array [], + "x": "mybool", + "y": Array [ + "num votes", + ], + } + `); }); test('adds a preview expression with disabled axes and legend', () => { 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 index 786a8001bff00..322932990836b 100644 --- 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 @@ -78,9 +78,8 @@ function getSuggestion( const title = `${yTitle} ${preposition} ${xTitle}`; const state: State = { legend: { isVisible: true, position: Position.Right }, - seriesType: isDate ? 'line' : 'bar', + seriesType: splitBy && isDate ? 'line' : 'bar', splitSeriesAccessors: splitBy && isDate ? [splitBy.columnId] : [], - stackAccessors: splitBy && !isDate ? [splitBy.columnId] : [], x: { accessor: xValue.columnId, position: Position.Bottom, 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 index 2ad4b262990a0..b62ae393715b8 100644 --- 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 @@ -16,7 +16,6 @@ function exampleState(): State { legend: { position: Position.Bottom, isVisible: true }, seriesType: 'area', splitSeriesAccessors: [], - stackAccessors: [], x: { accessor: 'a', position: Position.Bottom, @@ -46,31 +45,30 @@ describe('xy_visualization', () => { expect(initialState.x.accessor).not.toEqual(initialState.y.accessors[0]); expect(initialState).toMatchInlineSnapshot(` -Object { - "legend": Object { - "isVisible": true, - "position": "right", - }, - "seriesType": "line", - "splitSeriesAccessors": Array [], - "stackAccessors": Array [], - "title": "Empty XY Chart", - "x": Object { - "accessor": "test-id2", - "position": "bottom", - "showGridlines": false, - "title": "X", - }, - "y": Object { - "accessors": Array [ - "test-id1", - ], - "position": "left", - "showGridlines": false, - "title": "Y", - }, -} -`); + Object { + "legend": Object { + "isVisible": true, + "position": "right", + }, + "seriesType": "bar", + "splitSeriesAccessors": Array [], + "title": "Empty XY Chart", + "x": Object { + "accessor": "test-id2", + "position": "bottom", + "showGridlines": false, + "title": "X", + }, + "y": Object { + "accessors": Array [ + "test-id1", + ], + "position": "left", + "showGridlines": false, + "title": "Y", + }, + } + `); }); it('loads from persisted state', () => { 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 index efb9cc7fc1518..28f6309cd4bec 100644 --- 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 @@ -20,7 +20,7 @@ export const xyVisualization: Visualization = { initialize(datasource, state) { return ( state || { - seriesType: 'line', + seriesType: 'bar', title: 'Empty XY Chart', legend: { isVisible: true, position: Position.Right }, x: { @@ -36,7 +36,6 @@ export const xyVisualization: Visualization = { title: 'Y', }, splitSeriesAccessors: [], - stackAccessors: [], } ); }, From b0a2325bcc8ee92e27ec3c66975c1509c54b010c Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Wed, 10 Jul 2019 18:31:55 -0400 Subject: [PATCH 044/105] [Lens] Add xy split series support (#39726) * Add split series to lens xy chart --- .../visualization.test.tsx | 8 +- .../visualization.tsx | 5 +- .../editor_frame/editor_frame.test.tsx | 1 - .../editor_frame/suggestion_panel.tsx | 1 - .../lens/public/editor_frame_plugin/mocks.tsx | 1 - .../public/id_generator/id_generator.test.ts | 13 +++ .../lens/public/id_generator/id_generator.ts | 11 ++ .../plugins/lens/public/id_generator/index.ts | 7 ++ .../indexpattern_plugin/indexpattern.test.tsx | 47 ++++++++ .../indexpattern_plugin/indexpattern.tsx | 23 ++-- x-pack/legacy/plugins/lens/public/types.ts | 3 +- .../multi_column_editor.tsx | 74 +++++++++++++ .../xy_config_panel.test.tsx | 28 ++++- .../xy_config_panel.tsx | 102 +++++++++--------- .../xy_suggestions.test.ts | 83 ++++++++------ .../xy_visualization_plugin/xy_suggestions.ts | 3 +- .../xy_visualization.test.ts | 18 ++-- .../xy_visualization.tsx | 7 +- 18 files changed, 321 insertions(+), 114 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/id_generator/id_generator.test.ts create mode 100644 x-pack/legacy/plugins/lens/public/id_generator/id_generator.ts create mode 100644 x-pack/legacy/plugins/lens/public/id_generator/index.ts create mode 100644 x-pack/legacy/plugins/lens/public/xy_visualization_plugin/multi_column_editor.tsx 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 index 2cde89fe2d5d8..d43008f68c330 100644 --- 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 @@ -14,12 +14,15 @@ import { import { mount } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { Operation, DataType } from '../types'; +import { generateId } from '../id_generator'; + +jest.mock('../id_generator'); describe('Datatable Visualization', () => { describe('#initialize', () => { it('should initialize from the empty state', () => { const datasource = createMockDatasource(); - datasource.publicAPIMock.generateColumnId.mockReturnValueOnce('id'); + (generateId as jest.Mock).mockReturnValueOnce('id'); expect(datatableVisualization.initialize(datasource.publicAPIMock)).toEqual({ columns: [{ id: 'id', label: '' }], }); @@ -30,7 +33,6 @@ describe('Datatable Visualization', () => { const expectedState: DatatableVisualizationState = { columns: [{ id: 'saved', label: 'label' }], }; - expect(datasource.publicAPIMock.generateColumnId).not.toHaveBeenCalled(); expect(datatableVisualization.initialize(datasource.publicAPIMock, expectedState)).toEqual( expectedState ); @@ -138,7 +140,7 @@ describe('Datatable Visualization', () => { /> ); - datasource.publicAPIMock.generateColumnId.mockReturnValueOnce('newId'); + (generateId as jest.Mock).mockReturnValueOnce('newId'); act(() => { wrapper 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 index d4d9476240e68..7bd1dbedd1ac0 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -24,6 +24,7 @@ import { VisualizationSuggestion, } from '../types'; import { NativeRenderer } from '../native_renderer'; +import { generateId } from '../id_generator'; export interface DatatableVisualizationState { columns: Array<{ @@ -117,7 +118,7 @@ export function DatatableConfigPanel(props: VisualizationProps { const newColumns = [...state.columns]; newColumns.push({ - id: datasource.generateColumnId(), + id: generateId(), label: '', }); setState({ @@ -141,7 +142,7 @@ export const datatableVisualization: Visualization< state || { columns: [ { - id: datasource.generateColumnId(), + id: generateId(), label: '', }, ], 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 index 4bcf4d8b5e42e..d3f7666d0ac5d 100644 --- 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 @@ -469,7 +469,6 @@ Object { setDatasourceState(updatedState); }); - expect(mockDatasource.getPublicAPI).toHaveBeenCalledTimes(2); expect(mockDatasource.getPublicAPI).toHaveBeenLastCalledWith( updatedState, expect.any(Function) 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 index efa2628d58ce0..90af8106c4459 100644 --- 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 @@ -6,7 +6,6 @@ import React, { useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; - import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip } from '@elastic/eui'; import { toExpression } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; 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 index d6e9e2f530fc0..cb75fab6e457f 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -27,7 +27,6 @@ export function createMockDatasource(): DatasourceMock { const publicAPIMock: jest.Mocked = { getTableSpec: jest.fn(() => []), getOperationForColumnId: jest.fn(), - generateColumnId: jest.fn(), renderDimensionPanel: jest.fn(), removeColumnInTableSpec: jest.fn(), moveColumnTo: jest.fn(), 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/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index c3610ab2cf95d..b39f00af1e1fd 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -12,6 +12,7 @@ import { IndexPatternPersistedState, IndexPatternPrivateState, IndexPatternDataPanel, + IndexPatternColumn, } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; import { createMockedDragDropContext } from './mocks'; @@ -508,6 +509,52 @@ describe('IndexPattern Data Source', () => { }); }); + 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', + operationId: 'bar', + operationType: 'max', + sourceField: 'baz', + suggestedOrder: 0, + }; + const columns: Record = { + a: { + ...sampleColumn, + suggestedOrder: 0, + }, + b: { + ...sampleColumn, + suggestedOrder: 1, + }, + c: { + ...sampleColumn, + suggestedOrder: 2, + }, + }; + const api = indexPatternDatasource.getPublicAPI( + { + ...initialState, + columnOrder: ['a', 'b', 'c'], + columns, + }, + setState + ); + + api.removeColumnInTableSpec('b'); + + expect(setState.mock.calls[0][0].columnOrder).toEqual(['a', 'c']); + expect(setState.mock.calls[0][0].columns).toEqual({ + a: columns.a, + c: columns.c, + }); + }); + }); + describe('getOperationForColumnId', () => { it('should get an operation for col1', () => { expect(publicAPI.getOperationForColumnId('col1')).toEqual({ diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 75fd323c6048f..69774e24c1388 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -10,12 +10,12 @@ import { render } from 'react-dom'; import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; -import uuid from 'uuid'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, DimensionPriority, DatasourceSuggestion, + Operation, } from '../types'; import { getIndexPatterns } from './loader'; import { ChildDragDropProvider, DragDrop } from '../drag_drop'; @@ -165,7 +165,7 @@ export function IndexPatternDataPanel(props: DatasourceDataPanelProps(prop: string, object: Record): Record { + const result = { ...object }; + delete result[prop]; + return result; +} + export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: ToastNotifications) { // Not stateful. State is persisted to the frame const indexPatternDatasource: Datasource = { @@ -262,11 +268,6 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To } return columnToOperation(state.columns[columnId]); }, - generateColumnId: () => { - // TODO: Come up with a more compact form of generating unique column ids - return uuid.v4(); - }, - renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { render( [], + removeColumnInTableSpec: (columnId: string) => { + setState({ + ...state, + columnOrder: state.columnOrder.filter(id => id !== columnId), + columns: removeProperty(columnId, state.columns), + }); + }, moveColumnTo: (columnId: string, targetIndex: number) => {}, duplicateColumn: (columnId: string) => [], }; diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index c532d2d69b0a8..9812db7440891 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -71,12 +71,11 @@ export interface Datasource { export interface DatasourcePublicAPI { getTableSpec: () => TableSpec; getOperationForColumnId: (columnId: string) => Operation | null; - generateColumnId: () => string; // Render can be called many times renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => void; - removeColumnInTableSpec: (columnId: string) => TableSpec; + removeColumnInTableSpec: (columnId: string) => void; moveColumnTo: (columnId: string, targetIndex: number) => void; duplicateColumn: (columnId: string) => TableSpec; } diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/multi_column_editor.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/multi_column_editor.tsx new file mode 100644 index 0000000000000..f0f804eacf864 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/multi_column_editor.tsx @@ -0,0 +1,74 @@ +/* + * 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 { EuiButtonIcon, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { NativeRenderer } from '../native_renderer'; +import { generateId } from '../id_generator'; +import { DatasourcePublicAPI, Operation } from '../types'; +import { DragContextState } from '../drag_drop'; + +interface Props { + accessors: string[]; + datasource: DatasourcePublicAPI; + dragDropContext: DragContextState; + onRemove: (accessor: string) => void; + onAdd: (accessor: string) => void; + filterOperations: (op: Operation) => boolean; + suggestedPriority?: 0 | 1 | 2 | undefined; + testSubj: string; +} + +export function MultiColumnEditor({ + accessors, + datasource, + dragDropContext, + onRemove, + onAdd, + filterOperations, + suggestedPriority, + testSubj, +}: Props) { + return ( + <> + {accessors.map((accessor, i) => ( +
+ + {i === accessors.length - 1 ? null : ( + { + datasource.removeColumnInTableSpec(accessor); + onRemove(accessor); + }} + aria-label={i18n.translate('xpack.lens.xyChart.removeAriaLabel', { + defaultMessage: 'Remove', + })} + /> + )} +
+ ))} + onAdd(generateId())} + iconType="plusInCircle" + /> + + ); +} 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 index e1ce69999fed8..e5d992ecd8550 100644 --- 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 @@ -13,6 +13,9 @@ import { DatasourcePublicAPI, DatasourceDimensionPanelProps, Operation } from '. import { State, SeriesType } from './types'; import { Position } from '@elastic/charts'; import { NativeRendererProps } from '../native_renderer'; +import { generateId } from '../id_generator'; + +jest.mock('../id_generator'); describe('XYConfigPanel', () => { const dragDropContext = { dragging: undefined, setDragging: jest.fn() }; @@ -21,7 +24,6 @@ describe('XYConfigPanel', () => { return { duplicateColumn: () => [], getOperationForColumnId: () => null, - generateColumnId: () => 'TESTID', getTableSpec: () => [], moveColumnTo: () => {}, removeColumnInTableSpec: () => [], @@ -380,12 +382,13 @@ describe('XYConfigPanel', () => { }); test('allows adding y dimensions', () => { + (generateId as jest.Mock).mockReturnValueOnce('zed'); const setState = jest.fn(); const state = testState(); const component = mount( 'zed' }} + datasource={mockDatasource()} setState={setState} state={{ ...state, y: { ...state.y, accessors: ['a', 'b', 'c'] } }} /> @@ -399,6 +402,27 @@ describe('XYConfigPanel', () => { }); }); + test('allows adding split dimensions', () => { + (generateId as jest.Mock).mockReturnValueOnce('foo'); + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_splitSeriesDimensionPanel_add').onClick as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + expect(setState.mock.calls[0][0]).toMatchObject({ + splitSeriesAccessors: ['a', 'b', 'c', 'foo'], + }); + }); + test('allows toggling the y axis gridlines', () => { const toggleYGridlines = (showGridlines: boolean) => { const setState = jest.fn(); 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 index b2d999f7fb348..fdaa448584536 100644 --- 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 @@ -13,13 +13,12 @@ import { EuiForm, EuiFormRow, EuiSwitch, - EuiButtonIcon, - EuiButton, IconType, } from '@elastic/eui'; import { State, SeriesType } from './types'; -import { VisualizationProps, Operation } from '../types'; +import { VisualizationProps } from '../types'; import { NativeRenderer } from '../native_renderer'; +import { MultiColumnEditor } from './multi_column_editor'; const chartTypeIcons: Array<{ id: SeriesType; label: string; iconType: IconType }> = [ { @@ -176,6 +175,30 @@ export function XYConfigPanel(props: VisualizationProps) {
)} + + + setState({ ...state, splitSeriesAccessors: [...state.splitSeriesAccessors, accessor] }) + } + onRemove={accessor => + setState({ + ...state, + splitSeriesAccessors: state.splitSeriesAccessors.filter(col => col !== accessor), + }) + } + filterOperations={op => op.isBucketed && op.dataType !== 'date'} + suggestedPriority={0} + testSubj="splitSeriesDimensionPanel" + /> + + ) { defaultMessage: 'Value', })} > - <> - {state.y.accessors.map(accessor => ( -
- - !op.isBucketed && op.dataType === 'number', - }} - /> - { - datasource.removeColumnInTableSpec(accessor); - setState({ - ...state, - y: { - ...state.y, - accessors: state.y.accessors.filter(col => col !== accessor), - }, - }); - }} - aria-label={i18n.translate('xpack.lens.xyChart.yRemoveAriaLabel', { - defaultMessage: 'Remove', - })} - /> -
- ))} - - setState({ - ...state, - y: { - ...state.y, - accessors: [...state.y.accessors, datasource.generateColumnId()], - }, - }) - } - iconType="plusInCircle" - /> - + + setState({ + ...state, + y: { + ...state.y, + accessors: [...state.y.accessors, accessor], + }, + }) + } + onRemove={accessor => + setState({ + ...state, + y: { + ...state.y, + accessors: state.y.accessors.filter(col => col !== accessor), + }, + }) + } + filterOperations={op => !op.isBucketed && op.dataType === 'number'} + testSubj="yDimensionPanel" + />
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 index d0e82b808f46a..9ea0345f2367a 100644 --- 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 @@ -8,6 +8,9 @@ import { getSuggestions } from './xy_suggestions'; import { TableColumn, VisualizationSuggestion } from '../types'; import { State } from './types'; import { Ast } from '@kbn/interpreter/target/common'; +import { generateId } from '../id_generator'; + +jest.mock('../id_generator'); describe('xy_suggestions', () => { function numCol(columnId: string): TableColumn { @@ -87,6 +90,7 @@ describe('xy_suggestions', () => { }); test('suggests a basic x y chart with date on x', () => { + (generateId as jest.Mock).mockReturnValueOnce('aaa'); const [suggestion, ...rest] = getSuggestions({ tables: [ { @@ -101,7 +105,9 @@ describe('xy_suggestions', () => { expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Object { "seriesType": "bar", - "splitSeriesAccessors": Array [], + "splitSeriesAccessors": Array [ + "aaa", + ], "x": "date", "y": Array [ "bytes", @@ -123,21 +129,22 @@ describe('xy_suggestions', () => { expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Object { - "seriesType": "line", - "splitSeriesAccessors": Array [ - "product", - ], - "x": "date", - "y": Array [ - "price", - "quantity", - ], - } - `); + Object { + "seriesType": "line", + "splitSeriesAccessors": Array [ + "product", + ], + "x": "date", + "y": Array [ + "price", + "quantity", + ], + } + `); }); test('supports multiple suggestions', () => { + (generateId as jest.Mock).mockReturnValueOnce('bbb').mockReturnValueOnce('ccc'); const [s1, s2, ...rest] = getSuggestions({ tables: [ { @@ -158,7 +165,9 @@ describe('xy_suggestions', () => { Array [ Object { "seriesType": "bar", - "splitSeriesAccessors": Array [], + "splitSeriesAccessors": Array [ + "bbb", + ], "x": "date", "y": Array [ "price", @@ -166,7 +175,9 @@ describe('xy_suggestions', () => { }, Object { "seriesType": "bar", - "splitSeriesAccessors": Array [], + "splitSeriesAccessors": Array [ + "ccc", + ], "x": "country", "y": Array [ "count", @@ -177,6 +188,7 @@ describe('xy_suggestions', () => { }); test('handles two numeric values', () => { + (generateId as jest.Mock).mockReturnValueOnce('ddd'); const [suggestion] = getSuggestions({ tables: [ { @@ -188,18 +200,21 @@ describe('xy_suggestions', () => { }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Object { - "seriesType": "bar", - "splitSeriesAccessors": Array [], - "x": "quantity", - "y": Array [ - "price", - ], - } - `); + Object { + "seriesType": "bar", + "splitSeriesAccessors": Array [ + "ddd", + ], + "x": "quantity", + "y": Array [ + "price", + ], + } + `); }); test('handles unbucketed suggestions', () => { + (generateId as jest.Mock).mockReturnValueOnce('eee'); const [suggestion] = getSuggestions({ tables: [ { @@ -222,15 +237,17 @@ describe('xy_suggestions', () => { }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Object { - "seriesType": "bar", - "splitSeriesAccessors": Array [], - "x": "mybool", - "y": Array [ - "num votes", - ], - } - `); + Object { + "seriesType": "bar", + "splitSeriesAccessors": Array [ + "eee", + ], + "x": "mybool", + "y": Array [ + "num votes", + ], + } + `); }); test('adds a preview expression with disabled axes and legend', () => { 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 index 322932990836b..213901aa3cd18 100644 --- 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 @@ -8,6 +8,7 @@ import { partition } from 'lodash'; import { Position } from '@elastic/charts'; import { SuggestionRequest, VisualizationSuggestion, TableColumn, TableSuggestion } from '../types'; import { State } from './types'; +import { generateId } from '../id_generator'; import { buildExpression } from './to_expression'; const columnSortOrder = { @@ -79,7 +80,7 @@ function getSuggestion( const state: State = { legend: { isVisible: true, position: Position.Right }, seriesType: splitBy && isDate ? 'line' : 'bar', - splitSeriesAccessors: splitBy && isDate ? [splitBy.columnId] : [], + splitSeriesAccessors: splitBy && isDate ? [splitBy.columnId] : [generateId()], x: { accessor: xValue.columnId, position: Position.Bottom, 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 index b62ae393715b8..a2eeffaab4a7b 100644 --- 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 @@ -10,6 +10,9 @@ import { Ast } from '@kbn/interpreter/target/common'; import { Operation } from '../types'; import { State } from './types'; import { createMockDatasource } from '../editor_frame_plugin/mocks'; +import { generateId } from '../id_generator'; + +jest.mock('../id_generator'); function exampleState(): State { return { @@ -34,10 +37,11 @@ function exampleState(): State { describe('xy_visualization', () => { describe('#initialize', () => { it('loads default state', () => { + (generateId as jest.Mock) + .mockReturnValueOnce('test-id1') + .mockReturnValueOnce('test-id2') + .mockReturnValue('test-id3'); const mockDatasource = createMockDatasource(); - mockDatasource.publicAPIMock.generateColumnId - .mockReturnValue('test-id1') - .mockReturnValueOnce('test-id2'); const initialState = xyVisualization.initialize(mockDatasource.publicAPIMock); expect(initialState.x.accessor).toBeDefined(); @@ -51,17 +55,19 @@ describe('xy_visualization', () => { "position": "right", }, "seriesType": "bar", - "splitSeriesAccessors": Array [], + "splitSeriesAccessors": Array [ + "test-id3", + ], "title": "Empty XY Chart", "x": Object { - "accessor": "test-id2", + "accessor": "test-id1", "position": "bottom", "showGridlines": false, "title": "X", }, "y": Object { "accessors": Array [ - "test-id1", + "test-id2", ], "position": "left", "showGridlines": false, 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 index 28f6309cd4bec..9408fc7d7f428 100644 --- 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 @@ -13,6 +13,7 @@ import { XYConfigPanel } from './xy_config_panel'; import { Visualization } from '../types'; import { State, PersistableState } from './types'; import { toExpression } from './to_expression'; +import { generateId } from '../id_generator'; export const xyVisualization: Visualization = { getSuggestions, @@ -24,18 +25,18 @@ export const xyVisualization: Visualization = { title: 'Empty XY Chart', legend: { isVisible: true, position: Position.Right }, x: { - accessor: datasource.generateColumnId(), + accessor: generateId(), position: Position.Bottom, showGridlines: false, title: 'X', }, y: { - accessors: [datasource.generateColumnId()], + accessors: [generateId()], position: Position.Left, showGridlines: false, title: 'Y', }, - splitSeriesAccessors: [], + splitSeriesAccessors: [generateId()], } ); }, From 5c6f55253b0762b829ea579f6fbcaac2489ede3d Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Fri, 12 Jul 2019 15:00:51 -0400 Subject: [PATCH 045/105] [lens] Lens Filter Ratio (#40196) * WIP filter ratio * Fix test issues * Pass dependencies through plugin like new platform * Pass props into filter ratio popover editor * Provide mocks to filter_ratio popover test * Add another test * Clean up to prepare for review * Clean up unnecessary changes * Respond to review comments * Fix tests --- .../query/query_bar/components/query_bar.tsx | 1 - src/plugins/data/common/index.ts | 1 + src/plugins/data/common/query/index.ts | 20 ++ x-pack/dev-tools/jest/create_jest_config.js | 1 + .../datatable_visualization_plugin/plugin.tsx | 1 - x-pack/legacy/plugins/lens/public/index.ts | 2 + .../dimension_panel/dimension_panel.test.tsx | 132 ++++++++---- .../dimension_panel/dimension_panel.tsx | 4 + .../dimension_panel/field_select.tsx | 18 +- .../dimension_panel/popover_editor.tsx | 8 +- .../indexpattern_plugin/filter_ratio.test.ts | 73 +++++++ .../indexpattern_plugin/filter_ratio.ts | 74 +++++++ .../indexpattern_plugin/indexpattern.test.tsx | 21 +- .../indexpattern_plugin/indexpattern.tsx | 37 +++- .../filter_ratio.test.tsx | 202 ++++++++++++++++++ .../operation_definitions/filter_ratio.tsx | 144 +++++++++++++ .../indexpattern_plugin/operations.test.ts | 6 + .../public/indexpattern_plugin/operations.ts | 6 + .../public/indexpattern_plugin/plugin.tsx | 38 +++- .../indexpattern_plugin/to_expression.ts | 23 +- .../plugins/lens/public/interpreter_types.ts | 1 - .../public/xy_visualization_plugin/plugin.tsx | 1 - 22 files changed, 743 insertions(+), 71 deletions(-) create mode 100644 src/plugins/data/common/query/index.ts create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.test.ts create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.tsx index e1df6286bd120..a2eaff8ca36f0 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.tsx @@ -381,5 +381,4 @@ export class QueryBarUI extends Component { } } -// @ts-ignore export const QueryBar = injectI18n(QueryBarUI); diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index af870208f4865..782d7545803a2 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -18,3 +18,4 @@ */ export * from './expressions'; +export * from './query'; 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/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index fa8cae2b6b86e..006e5c1d31243 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -46,6 +46,7 @@ export function createJestConfig({ ], transform: { '^.+\\.(js|tsx?)$': `${kibanaDirectory}/src/dev/jest/babel_transform.js`, + '^.+\\.html?$': 'jest-raw-loader', }, transformIgnorePatterns: [ // ignore all node_modules except @elastic/eui which requires babel transforms to handle dynamic import() 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 index 356e18ddc8419..7bcddae13e1ac 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx @@ -11,7 +11,6 @@ import { datatableVisualization } from './visualization'; import { renderersRegistry, functionsRegistry, - // @ts-ignore untyped dependency } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { InterpreterSetup, RenderFunction } from '../interpreter_types'; import { datatable, datatableColumns, datatableRenderer } from './expression'; diff --git a/x-pack/legacy/plugins/lens/public/index.ts b/x-pack/legacy/plugins/lens/public/index.ts index c71a3adf22485..aec29edaa728a 100644 --- a/x-pack/legacy/plugins/lens/public/index.ts +++ b/x-pack/legacy/plugins/lens/public/index.ts @@ -7,6 +7,8 @@ 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'; 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 index b5bd083472566..0f0ba36fbf8ee 100644 --- 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 @@ -6,7 +6,10 @@ import { ReactWrapper, ShallowWrapper } from 'enzyme'; import React from 'react'; +import { act } from 'react-dom/test-utils'; import { EuiComboBox, EuiSideNav, EuiPopover } from '@elastic/eui'; +import { data } from '../../../../../../../src/legacy/core_plugins/data/public/setup'; +import { localStorage } from 'ui/storage/storage_service'; import { IndexPatternPrivateState } from '../indexpattern'; import { changeColumn } from '../state_helpers'; import { getPotentialColumns } from '../operations'; @@ -15,9 +18,17 @@ import { DropHandler, DragContextState } from '../../drag_drop'; import { createMockedDragDropContext } from '../mocks'; import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +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'); +jest.mock('ui/storage/storage_service'); +// 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', @@ -98,6 +109,8 @@ describe('IndexPatternDimensionPanel', () => { setState, columnId: 'col1', filterOperations: () => true, + dataPlugin: data, + storage: localStorage, }; jest.clearAllMocks(); @@ -164,6 +177,8 @@ describe('IndexPatternDimensionPanel', () => { 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([ @@ -273,7 +288,9 @@ describe('IndexPatternDimensionPanel', () => { const comboBox = wrapper.find(EuiComboBox)!; const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'memory')!; - comboBox.prop('onChange')!([option]); + act(() => { + comboBox.prop('onChange')!([option]); + }); expect(setState).toHaveBeenCalledWith({ ...initialState, @@ -296,7 +313,9 @@ describe('IndexPatternDimensionPanel', () => { const comboBox = wrapper.find(EuiComboBox)!; const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; - comboBox.prop('onChange')!([option]); + act(() => { + comboBox.prop('onChange')!([option]); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -336,7 +355,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -356,9 +377,11 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') - .simulate('click'); + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + }); expect(setState).not.toHaveBeenCalled(); }); @@ -368,9 +391,11 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper - .find('input[data-test-subj="indexPattern-label-edit"]') - .simulate('change', { target: { value: 'New Label' } }); + act(() => { + wrapper + .find('input[data-test-subj="indexPattern-label-edit"]') + .simulate('change', { target: { value: 'New Label' } }); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -390,7 +415,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + }); expect(setState).not.toHaveBeenCalled(); }); @@ -428,7 +455,9 @@ describe('IndexPatternDimensionPanel', () => { wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); - wrapper.find(EuiPopover).prop('closePopover')!(); + act(() => { + wrapper.find(EuiPopover).prop('closePopover')!(); + }); openPopover(); @@ -459,12 +488,16 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + }); const comboBox = wrapper.find(EuiComboBox)!; const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; - comboBox.prop('onChange')!([option]); + act(() => { + comboBox.prop('onChange')!([option]); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -488,7 +521,9 @@ describe('IndexPatternDimensionPanel', () => { const comboBox = wrapper.find(EuiComboBox); const options = comboBox.prop('options'); - comboBox.prop('onChange')!([options![1].options![0]]); + act(() => { + comboBox.prop('onChange')!([options![1].options![0]]); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -541,7 +576,7 @@ describe('IndexPatternDimensionPanel', () => { .find(EuiSideNav) .prop('items')[0] .items.map(({ name }) => name) - ).toEqual(['Count', 'Maximum', 'Average', 'Sum', 'Minimum']); + ).toEqual(['Maximum', 'Average', 'Sum', 'Minimum', 'Count', 'Filter Ratio']); }); it('should add a column on selection of a field', () => { @@ -552,7 +587,9 @@ describe('IndexPatternDimensionPanel', () => { const comboBox = wrapper.find(EuiComboBox)!; const option = comboBox.prop('options')![1].options![0]; - comboBox.prop('onChange')!([option]); + act(() => { + comboBox.prop('onChange')!([option]); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -588,10 +625,12 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper - .find('[data-test-subj="lns-indexPatternDimension-min"]') - .first() - .prop('onClick')!({} as React.MouseEvent<{}, MouseEvent>); + act(() => { + wrapper + .find('[data-test-subj="lns-indexPatternDimension-min"]') + .first() + .prop('onClick')!({} as React.MouseEvent<{}, MouseEvent>); + }); expect(changeColumn).toHaveBeenCalledWith( initialState, @@ -610,7 +649,9 @@ describe('IndexPatternDimensionPanel', () => { 'EuiButtonIcon[data-test-subj="indexPattern-dimensionPopover-remove"]' ); - clearButton.simulate('click'); + act(() => { + clearButton.simulate('click'); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -624,7 +665,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find(EuiComboBox).prop('onChange')!([]); + act(() => { + wrapper.find(EuiComboBox).prop('onChange')!([]); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -642,7 +685,14 @@ describe('IndexPatternDimensionPanel', () => { foo: { id: 'foo', title: 'Foo pattern', - fields: [{ aggregatable: true, name: 'bar', searchable: true, type: 'number' }], + fields: [ + { + aggregatable: true, + name: 'bar', + searchable: true, + type: 'number', + }, + ], }, }, }; @@ -737,12 +787,14 @@ describe('IndexPatternDimensionPanel', () => { /> ); - const onDrop = wrapper - .find('[data-test-subj="indexPattern-dropTarget"]') - .first() - .prop('onDrop') as DropHandler; + act(() => { + const onDrop = wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; - onDrop(dragging); + onDrop(dragging); + }); expect(setState).toBeCalledTimes(1); expect(setState).toHaveBeenCalledWith( @@ -773,12 +825,14 @@ describe('IndexPatternDimensionPanel', () => { /> ); - const onDrop = wrapper - .find('[data-test-subj="indexPattern-dropTarget"]') - .first() - .prop('onDrop') as DropHandler; + act(() => { + const onDrop = wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; - onDrop(dragging); + onDrop(dragging); + }); expect(setState).toBeCalledTimes(1); expect(setState).toHaveBeenCalledWith( @@ -808,12 +862,14 @@ describe('IndexPatternDimensionPanel', () => { /> ); - const onDrop = wrapper - .find('[data-test-subj="indexPattern-dropTarget"]') - .first() - .prop('onDrop') as DropHandler; + act(() => { + const onDrop = wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; - onDrop(dragging); + onDrop(dragging); + }); expect(setState).not.toBeCalled(); }); 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 index 2671da17bfd3a..55c7cddd7f527 100644 --- 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 @@ -7,7 +7,9 @@ import _ from 'lodash'; import React from 'react'; import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon } from '@elastic/eui'; +import { Storage } from 'ui/storage'; import { i18n } from '@kbn/i18n'; +import { DataSetup } from '../../../../../../../src/legacy/core_plugins/data/public'; import { DatasourceDimensionPanelProps } from '../../types'; import { IndexPatternColumn, @@ -25,6 +27,8 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { state: IndexPatternPrivateState; setState: (newState: IndexPatternPrivateState) => void; dragDropContext: DragContextState; + dataPlugin: DataSetup; + storage: Storage; }; export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { 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 index 9cb3232aeb295..215419cd3e999 100644 --- 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 @@ -48,22 +48,26 @@ export function FieldSelect({ ); function isCompatibleWithCurrentOperation(col: BaseIndexPatternColumn) { - return incompatibleSelectedOperationType - ? col.operationType === incompatibleSelectedOperationType - : !selectedColumn || col.operationType === selectedColumn.operationType; + if (incompatibleSelectedOperationType) { + return col.operationType === incompatibleSelectedOperationType; + } + return !selectedColumn || col.operationType === selectedColumn.operationType; } const fieldOptions = []; - const fieldLessColumn = filteredColumns.find(column => !hasField(column)); - if (fieldLessColumn) { + const fieldlessColumn = + filteredColumns.find(column => !hasField(column) && isCompatibleWithCurrentOperation(column)) || + filteredColumns.find(column => !hasField(column)); + + if (fieldlessColumn) { fieldOptions.push({ label: i18n.translate('xpack.lens.indexPattern.documentField', { defaultMessage: 'Document', }), - value: fieldLessColumn.operationId, + value: fieldlessColumn.operationId, className: classNames({ 'lnsConfigPanel__fieldOption--incompatible': !isCompatibleWithCurrentOperation( - fieldLessColumn + fieldlessColumn ), }), }); 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 index 2254f1b474644..053c8e08349b1 100644 --- 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 @@ -179,7 +179,13 @@ export function PopoverEditor(props: PopoverEditorProps) { )} {!incompatibleSelectedOperationType && ParamEditor && ( - + )} {!incompatibleSelectedOperationType && selectedColumn && ( 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..11102cf7a5a07 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.test.ts @@ -0,0 +1,73 @@ +/* + * 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/types'; + +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' }], + 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' }], + 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' }], + 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' }, { id: 'extra', name: 'C' }], + rows: [{ bucket: 0.5, extra: 'first' }], + type: 'kibana_datatable', + }); + }); +}); 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..1fe57f42fc987 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts @@ -0,0 +1,74 @@ +/* + * 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 '../types'; + +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); + } + + return { + type: 'kibana_datatable', + rows: newRows, + columns: data.columns.filter(col => !col.id.includes('filter-ratio')), + }; + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index b39f00af1e1fd..db4bced265b0c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -7,6 +7,11 @@ import { shallow } from 'enzyme'; import React from 'react'; import { EuiComboBox } from '@elastic/eui'; +import chromeMock from 'ui/chrome'; +import { data as dataMock } from '../../../../../../src/legacy/core_plugins/data/public/setup'; +import { localStorage as storageMock } from 'ui/storage/storage_service'; +import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { toastNotifications as notificationsMock } from 'ui/notify'; import { getIndexPatternDatasource, IndexPatternPersistedState, @@ -18,6 +23,13 @@ import { DatasourcePublicAPI, Operation, Datasource } from '../types'; import { createMockedDragDropContext } from './mocks'; jest.mock('./loader'); +// chrome, notify, storage are used by ./plugin +jest.mock('ui/chrome'); +jest.mock('ui/notify'); +jest.mock('ui/storage/storage_service'); +// 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: { @@ -109,8 +121,13 @@ describe('IndexPattern Data Source', () => { let indexPatternDatasource: Datasource; beforeEach(() => { - // @ts-ignore - indexPatternDatasource = getIndexPatternDatasource(); + indexPatternDatasource = getIndexPatternDatasource({ + chrome: chromeMock, + storage: storageMock, + interpreter: { functionsRegistry }, + toastNotifications: notificationsMock, + data: dataMock, + }); persistedState = { currentIndexPatternId: '1', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 69774e24c1388..7328684d09777 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -7,9 +7,8 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; -import { Chrome } from 'ui/chrome'; -import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n/react'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, @@ -17,11 +16,13 @@ import { DatasourceSuggestion, Operation, } from '../types'; +import { Query } from '../../../../../../src/legacy/core_plugins/data/public/query'; import { getIndexPatterns } from './loader'; import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; import { buildColumnForOperationType, getOperationTypesForField } from './operations'; +import { IndexPatternDatasourcePluginPlugins } from './plugin'; import { Datasource, DataType } from '..'; export type OperationType = IndexPatternColumn['operationType']; @@ -33,7 +34,8 @@ export type IndexPatternColumn = | AvgIndexPatternColumn | MinIndexPatternColumn | MaxIndexPatternColumn - | CountIndexPatternColumn; + | CountIndexPatternColumn + | FilterRatioIndexPatternColumn; export interface BaseIndexPatternColumn { // Public @@ -74,6 +76,14 @@ export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { }; } +export interface FilterRatioIndexPatternColumn extends BaseIndexPatternColumn { + operationType: 'filter_ratio'; + params: { + numerator: Query; + denominator: Query; + }; +} + export type CountIndexPatternColumn = ParameterlessIndexPatternColumn< 'count', BaseIndexPatternColumn @@ -217,7 +227,12 @@ function removeProperty(prop: string, object: Record): Record = { async initialize(state?: IndexPatternPersistedState) { @@ -270,11 +285,15 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To }, renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { render( - setState(newState)} - {...props} - />, + + setState(newState)} + dataPlugin={data} + storage={storage} + {...props} + /> + , domElement ); }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx new file mode 100644 index 0000000000000..c48a381426664 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx @@ -0,0 +1,202 @@ +/* + * 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 { filterRatioOperation } from './filter_ratio'; +import { FilterRatioIndexPatternColumn, IndexPatternPrivateState } from '../indexpattern'; + +describe('filter_ratio', () => { + let state: IndexPatternPrivateState; + let storageMock: any; + let dataMock: any; + const InlineOptions = filterRatioOperation.paramEditor!; + + beforeEach(() => { + state = { + indexPatterns: { + 1: { + id: '1', + title: 'Mock Indexpattern', + fields: [], + }, + }, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Filter Ratio', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'filter_ratio', + params: { + numerator: { query: '', language: 'kuery' }, + denominator: { query: '', language: 'kuery' }, + }, + }, + }, + }; + + class QueryBarInput { + props: any; + constructor(props: any) { + this.props = props; + } + render() { + return <>; + } + } + + storageMock = { + getItem() {}, + }; + dataMock = { + query: { ui: { QueryBarInput } }, + }; + }); + + describe('buildColumn', () => { + it('should create column object with default params', () => { + const column = filterRatioOperation.buildColumn('op', 0); + 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.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(['1']); + }); + + 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', + } as any); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + col1: { + ...state.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', + } as any); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + col1: { + ...state.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/operation_definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx new file mode 100644 index 0000000000000..56a5cd77c5afd --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.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 React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiFormRow } from '@elastic/eui'; +import { Query } from '../../../../../../../src/legacy/core_plugins/data/public/query'; +import { FilterRatioIndexPatternColumn } from '../indexpattern'; +import { DimensionPriority } from '../../types'; +import { OperationDefinition } from '../operations'; +import { updateColumnParam } from '../state_helpers'; + +export const filterRatioOperation: OperationDefinition = { + type: 'filter_ratio', + displayName: i18n.translate('xpack.lens.indexPattern.filterRatio', { + defaultMessage: 'Filter Ratio', + }), + isApplicableWithoutField: true, + isApplicableForField: () => false, + buildColumn( + operationId: string, + suggestedOrder?: DimensionPriority + ): FilterRatioIndexPatternColumn { + return { + operationId, + label: i18n.translate('xpack.lens.indexPattern.filterRatio', { + defaultMessage: 'Filter Ratio', + }), + dataType: 'number', + operationType: 'filter_ratio', + suggestedOrder, + isBucketed: false, + 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: '', + }, + ], + }, + }), + paramEditor: ({ state, setState, columnId: currentColumnId, dataPlugin, storage }) => { + const [hasDenominator, setDenominator] = useState(false); + + const { QueryBarInput } = dataPlugin!.query.ui; + + return ( +
+ + { + setState( + updateColumnParam( + state, + state.columns[currentColumnId] as FilterRatioIndexPatternColumn, + 'numerator', + newQuery + ) + ); + }} + /> + + + + {hasDenominator ? ( + { + setState( + updateColumnParam( + state, + state.columns[currentColumnId] as FilterRatioIndexPatternColumn, + 'denominator', + newQuery + ) + ); + }} + /> + ) : ( + <> + + + + setDenominator(true)} + > + + + + + )} + +
+ ); + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts index c42ae4def66e1..f016515db32ea 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -8,6 +8,8 @@ import { getOperationTypesForField, getPotentialColumns } from './operations'; import { IndexPatternPrivateState } from './indexpattern'; import { hasField } from './state_helpers'; +jest.mock('./loader'); + const expectedIndexPatterns = { 1: { id: '1', @@ -206,6 +208,10 @@ Array [ "timestamp", "date_histogram", ], + Array [ + "_documents_", + "filter_ratio", + ], ] `); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index b5a0591084599..8471e2b0ad8f4 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Storage } from 'ui/storage'; +import { DataSetup } from '../../../../../../src/legacy/core_plugins/data/public'; import { DimensionPriority } from '../types'; import { IndexPatternColumn, @@ -21,6 +23,7 @@ import { } from './operation_definitions/metrics'; import { dateHistogramOperation } from './operation_definitions/date_histogram'; import { countOperation } from './operation_definitions/count'; +import { filterRatioOperation } from './operation_definitions/filter_ratio'; import { sortByField } from './state_helpers'; type PossibleOperationDefinitions< @@ -46,6 +49,7 @@ export const operationDefinitionMap: AllOperationDefinitions = { avg: averageOperation, sum: sumOperation, count: countOperation, + filter_ratio: filterRatioOperation, }; const operationDefinitions: PossibleOperationDefinitions[] = Object.values(operationDefinitionMap); @@ -57,6 +61,8 @@ export interface ParamEditorProps { state: IndexPatternPrivateState; setState: (newState: IndexPatternPrivateState) => void; columnId: string; + dataPlugin?: DataSetup; + storage?: Storage; } export interface OperationDefinition { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx index 7c186f35dc71a..ea969219b3e1f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx @@ -6,23 +6,29 @@ import { Registry } from '@kbn/interpreter/target/common'; import { CoreSetup } from 'src/core/public'; -import chrome from 'ui/chrome'; +// The following dependencies on ui/* and src/legacy/core_plugins must be mocked when testing +import chrome, { Chrome } from 'ui/chrome'; import { toastNotifications } from 'ui/notify'; -import { getIndexPatternDatasource } from './indexpattern'; - -import { - functionsRegistry, - // @ts-ignore untyped dependency -} from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { Storage } from 'ui/storage'; +import { localStorage } from 'ui/storage/storage_service'; +import { DataSetup } from '../../../../../../src/legacy/core_plugins/data/public'; +import { data as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/setup'; 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'; // 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: DataSetup; + storage: Storage; + toastNotifications: typeof toastNotifications; } export interface InterpreterSetup { @@ -35,9 +41,19 @@ export interface InterpreterSetup { class IndexPatternDatasourcePlugin { constructor() {} - setup(_core: CoreSetup | null, { interpreter }: IndexPatternDatasourcePluginPlugins) { + setup( + _core: CoreSetup | null, + { interpreter, data, storage, toastNotifications: toast }: IndexPatternDatasourcePluginPlugins + ) { interpreter.functionsRegistry.register(() => renameColumns); - return getIndexPatternDatasource(chrome, toastNotifications); + interpreter.functionsRegistry.register(() => calculateFilterRatio); + return getIndexPatternDatasource({ + chrome, + interpreter, + toastNotifications: toast, + data, + storage, + }); } stop() {} @@ -47,8 +63,12 @@ const plugin = new IndexPatternDatasourcePlugin(); export const indexPatternDatasourceSetup = () => plugin.setup(null, { + chrome, interpreter: { functionsRegistry, }, + data: dataSetup, + storage: localStorage, + toastNotifications, }); export const indexPatternDatasourceStop = () => plugin.stop(); 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 index 6f39b521d20d8..67887ef186f09 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -7,7 +7,11 @@ import _ from 'lodash'; import { IndexPatternPrivateState, IndexPatternColumn } from './indexpattern'; -import { operationDefinitionMap, OperationDefinition } from './operations'; +import { + buildColumnForOperationType, + operationDefinitionMap, + OperationDefinition, +} from './operations'; export function toExpression(state: IndexPatternPrivateState) { if (state.columnOrder.length === 0) { @@ -42,6 +46,23 @@ export function toExpression(state: IndexPatternPrivateState) { {} as Record ); + const filterRatios = columnEntries.filter( + ([colId, col]) => col.operationType === 'filter_ratio' + ); + + if (filterRatios.length) { + const countColumn = buildColumnForOperationType(columnEntries.length, 'count', 2); + aggs.push(getEsAggsConfig(countColumn, 'filter-ratio')); + + return `esaggs + index="${state.currentIndexPatternId}" + metricsAtAllLevels=false + partialRows=false + aggConfigs='${JSON.stringify(aggs)}' | lens_rename_columns idMap='${JSON.stringify( + idMap + )}' | ${filterRatios.map(([id]) => `lens_calculate_filter_ratio id=${id}`).join(' | ')}`; + } + return `esaggs index="${state.currentIndexPatternId}" metricsAtAllLevels=false diff --git a/x-pack/legacy/plugins/lens/public/interpreter_types.ts b/x-pack/legacy/plugins/lens/public/interpreter_types.ts index b24f39080f827..fe02ab11757cc 100644 --- a/x-pack/legacy/plugins/lens/public/interpreter_types.ts +++ b/x-pack/legacy/plugins/lens/public/interpreter_types.ts @@ -5,7 +5,6 @@ */ import { Registry } from '@kbn/interpreter/target/common'; -// @ts-ignore untyped module import { ExpressionFunction } from '../../../../../src/legacy/core_plugins/interpreter/public'; // TODO these are intermediary types because interpreter is not typed yet 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 index bb89646715645..9c9482a05e8f1 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -10,7 +10,6 @@ import { xyVisualization } from './xy_visualization'; import { renderersRegistry, functionsRegistry, - // @ts-ignore untyped dependency } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { InterpreterSetup, RenderFunction } from '../interpreter_types'; import { xyChart, xyChartRenderer } from './xy_expression'; From 4e361a6de1f38b2cbd1024181bf4b52884193863 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 15 Jul 2019 18:40:03 +0200 Subject: [PATCH 046/105] [Lens] Terms order direction (#39884) --- .../__mocks__/operations.ts | 1 + .../dimension_panel/dimension_panel.tsx | 3 +- .../dimension_panel/field_select.tsx | 2 +- .../dimension_panel/popover_editor.tsx | 3 +- .../indexpattern_plugin/indexpattern.test.tsx | 1 + .../indexpattern_plugin/indexpattern.tsx | 34 ++- .../operation_definitions/count.tsx | 6 +- .../date_histogram.test.tsx | 4 +- .../operation_definitions/date_histogram.tsx | 1 + .../filter_ratio.test.tsx | 2 +- .../operation_definitions/filter_ratio.tsx | 3 +- .../operation_definitions/metrics.tsx | 1 + .../operation_definitions/terms.test.tsx | 223 +++++++++++++++++- .../operation_definitions/terms.tsx | 72 +++++- .../indexpattern_plugin/operations.test.ts | 2 +- .../public/indexpattern_plugin/operations.ts | 14 +- .../indexpattern_plugin/state_helpers.test.ts | 152 +++++++++++- .../indexpattern_plugin/state_helpers.ts | 81 ++++--- .../indexpattern_plugin/to_expression.ts | 7 +- .../lens/public/indexpattern_plugin/utils.ts | 21 ++ 20 files changed, 561 insertions(+), 72 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/utils.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts index 0602feff52d95..81a77344f3993 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts @@ -8,6 +8,7 @@ const actual = jest.requireActual('../operations'); jest.spyOn(actual, 'getPotentialColumns'); jest.spyOn(actual.operationDefinitionMap.date_histogram, 'paramEditor'); +jest.spyOn(actual.operationDefinitionMap.terms, 'onOtherColumnChanged'); export const { getPotentialColumns, 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 index 55c7cddd7f527..719d4fc5a89af 100644 --- 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 @@ -21,7 +21,8 @@ import { import { getPotentialColumns } from '../operations'; import { PopoverEditor } from './popover_editor'; import { DragContextState, ChildDragDropProvider, DragDrop } from '../../drag_drop'; -import { changeColumn, hasField, deleteColumn } from '../state_helpers'; +import { changeColumn, deleteColumn } from '../state_helpers'; +import { hasField } from '../utils'; export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { state: IndexPatternPrivateState; 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 index 215419cd3e999..6fb1b8eb9f21a 100644 --- 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 @@ -15,7 +15,7 @@ import { OperationType, BaseIndexPatternColumn, } from '../indexpattern'; -import { hasField, sortByField } from '../state_helpers'; +import { hasField, sortByField } from '../utils'; export interface FieldSelectProps { incompatibleSelectedOperationType: OperationType | null; 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 index 053c8e08349b1..560007d44862e 100644 --- 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 @@ -22,8 +22,9 @@ import classNames from 'classnames'; import { IndexPatternColumn, OperationType } from '../indexpattern'; import { IndexPatternDimensionPanelProps } from './dimension_panel'; import { operationDefinitionMap, getOperationDisplay } from '../operations'; -import { hasField, deleteColumn, changeColumn } from '../state_helpers'; +import { deleteColumn, changeColumn } from '../state_helpers'; import { FieldSelect } from './field_select'; +import { hasField } from '../utils'; const operationPanels = getOperationDisplay(); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index db4bced265b0c..b8d19fc1987bb 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -145,6 +145,7 @@ describe('IndexPattern Data Source', () => { params: { size: 5, orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', }, }, }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 7328684d09777..01d6ffd6018de 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -73,6 +73,7 @@ export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { params: { size: number; orderBy: { type: 'alphabetical' } | { type: 'column'; columnId: string }; + orderDirection: 'asc' | 'desc'; }; } @@ -325,9 +326,18 @@ export function getIndexPatternDatasource({ const hasBucket = operations.find(op => op === 'date_histogram' || op === 'terms'); if (hasBucket) { - const column = buildColumnForOperationType(0, hasBucket, undefined, field); - - const countColumn = buildColumnForOperationType(1, 'count'); + const countColumn = buildColumnForOperationType(1, 'count', state.columns); + + // let column know about count column + const column = buildColumnForOperationType( + 0, + hasBucket, + { + col2: countColumn, + }, + undefined, + field + ); const suggestion: DatasourceSuggestion = { state: { @@ -362,9 +372,21 @@ export function getIndexPatternDatasource({ f => f.name === currentIndexPattern.timeFieldName )!; - const column = buildColumnForOperationType(0, operations[0], undefined, field); - - const dateColumn = buildColumnForOperationType(1, 'date_histogram', undefined, dateField); + const column = buildColumnForOperationType( + 0, + operations[0], + state.columns, + undefined, + field + ); + + const dateColumn = buildColumnForOperationType( + 1, + 'date_histogram', + state.columns, + undefined, + dateField + ); const suggestion: DatasourceSuggestion = { state: { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx index d37504ad32fe5..fd3b0aa06339e 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx @@ -18,7 +18,11 @@ export const countOperation: OperationDefinition = { isApplicableForField: ({ aggregationRestrictions, type }) => { return false; }, - buildColumn(operationId: string, suggestedOrder?: DimensionPriority): CountIndexPatternColumn { + buildColumn( + operationId: string, + columns: {}, + suggestedOrder?: DimensionPriority + ): CountIndexPatternColumn { return { operationId, label: i18n.translate('xpack.lens.indexPattern.countOf', { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx index 59b463a545651..e823040fee752 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx @@ -53,7 +53,7 @@ describe('date_histogram', () => { describe('buildColumn', () => { it('should create column object with default params', () => { - const column = dateHistogramOperation.buildColumn('op', 0, { + const column = dateHistogramOperation.buildColumn('op', {}, 0, { name: 'timestamp', type: 'date', esTypes: ['date'], @@ -64,7 +64,7 @@ describe('date_histogram', () => { }); it('should create column object with restrictions', () => { - const column = dateHistogramOperation.buildColumn('op', 0, { + const column = dateHistogramOperation.buildColumn('op', {}, 0, { name: 'timestamp', type: 'date', esTypes: ['date'], diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx index 753a950f07cdd..43b03b330bd63 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx @@ -45,6 +45,7 @@ export const dateHistogramOperation: OperationDefinition { describe('buildColumn', () => { it('should create column object with default params', () => { - const column = filterRatioOperation.buildColumn('op', 0); + const column = filterRatioOperation.buildColumn('op', {}, 0); expect(column.params.numerator).toEqual({ query: '', language: 'kuery' }); expect(column.params.denominator).toEqual({ query: '', language: 'kuery' }); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx index 56a5cd77c5afd..7e413d05f9517 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiFormRow } from '@elastic/eui'; import { Query } from '../../../../../../../src/legacy/core_plugins/data/public/query'; -import { FilterRatioIndexPatternColumn } from '../indexpattern'; +import { FilterRatioIndexPatternColumn, IndexPatternColumn } from '../indexpattern'; import { DimensionPriority } from '../../types'; import { OperationDefinition } from '../operations'; import { updateColumnParam } from '../state_helpers'; @@ -23,6 +23,7 @@ export const filterRatioOperation: OperationDefinition false, buildColumn( operationId: string, + _columns: Partial>, suggestedOrder?: DimensionPriority ): FilterRatioIndexPatternColumn { return { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx index cb460f8ede090..41d5471e307f1 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx @@ -32,6 +32,7 @@ function buildMetricOperation( }, buildColumn( operationId: string, + columns: {}, suggestedOrder?: DimensionPriority, field?: IndexPatternField ): T { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx index 92b9197580775..9c69b4fd4e20e 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx @@ -31,6 +31,7 @@ describe('terms', () => { params: { orderBy: { type: 'alphabetical' }, size: 5, + orderDirection: 'asc', }, sourceField: 'category', }, @@ -65,29 +66,185 @@ describe('terms', () => { }); }); + describe('buildColumn', () => { + it('should use existing metric column as order column', () => { + const termsColumn = termsOperation.buildColumn('abc', { + col1: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }); + 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 = { + operationId: 'op1', + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col1' }, + size: 5, + orderDirection: 'asc', + }, + sourceField: 'category', + }; + const updatedColumn = termsOperation.onOtherColumnChanged!(initialColumn, { + col1: { + operationId: 'op1', + 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!( + { + operationId: 'op1', + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col1' }, + size: 5, + 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!( + { + operationId: 'op1', + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col1' }, + size: 5, + orderDirection: 'asc', + }, + sourceField: 'category', + }, + { + col1: { + operationId: 'op1', + 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 value and options', () => { + it('should render current order by value and options', () => { const setStateSpy = jest.fn(); const instance = shallow( ); - expect(instance.find(EuiSelect).prop('value')).toEqual('alphabetical'); - expect( - instance - .find(EuiSelect) - .prop('options') - .map(({ value }) => value) - ).toEqual(['column$$$col2', 'alphabetical']); + 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 value', () => { + it('should update state with the order by value', () => { const setStateSpy = jest.fn(); const instance = shallow( ); - instance.find(EuiSelect).prop('onChange')!({ + instance + .find(EuiSelect) + .find('[data-test-subj="indexPattern-terms-orderBy"]') + .prop('onChange')!({ target: { value: 'column$$$col2', }, @@ -111,7 +268,51 @@ describe('terms', () => { }); }); - it('should render current value', () => { + 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, + columns: { + ...state.columns, + col1: { + ...state.columns.col1, + params: { + ...(state.columns.col1 as TermsIndexPatternColumn).params, + orderDirection: 'desc', + }, + }, + }, + }); + }); + + it('should render current size value', () => { const setStateSpy = jest.fn(); const instance = shallow( diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx index 98e3443549ced..e36456ebe5dd3 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiForm, EuiFormRow, EuiRange, EuiSelect } from '@elastic/eui'; -import { IndexPatternField, TermsIndexPatternColumn } from '../indexpattern'; +import { IndexPatternField, TermsIndexPatternColumn, IndexPatternColumn } from '../indexpattern'; import { DimensionPriority } from '../../types'; import { OperationDefinition } from '../operations'; import { updateColumnParam } from '../state_helpers'; @@ -31,6 +31,10 @@ function ofName(name: string) { }); } +function isSortableByColumn(column: IndexPatternColumn) { + return !column.isBucketed && column.operationType !== 'filter_ratio'; +} + export const termsOperation: OperationDefinition = { type: 'terms', displayName: i18n.translate('xpack.lens.indexPattern.terms', { @@ -44,9 +48,14 @@ export const termsOperation: OperationDefinition = { }, buildColumn( operationId: string, + columns: Partial>, suggestedOrder?: DimensionPriority, field?: IndexPatternField ): TermsIndexPatternColumn { + const existingMetricColumn = Object.entries(columns) + .filter(([_columnId, column]) => column && isSortableByColumn(column)) + .map(([id]) => id)[0]; + return { operationId, label: ofName(field ? field.name : ''), @@ -57,7 +66,10 @@ export const termsOperation: OperationDefinition = { isBucketed: true, params: { size: 5, - orderBy: { type: 'alphabetical' }, + orderBy: existingMetricColumn + ? { type: 'column', columnId: existingMetricColumn } + : { type: 'alphabetical' }, + orderDirection: existingMetricColumn ? 'desc' : 'asc', }, }; }, @@ -70,7 +82,7 @@ export const termsOperation: OperationDefinition = { field: column.sourceField, orderBy: column.params.orderBy.type === 'alphabetical' ? '_key' : column.params.orderBy.columnId, - order: 'desc', + order: column.params.orderDirection, size: column.params.size, otherBucket: false, otherBucketLabel: 'Other', @@ -78,6 +90,23 @@ export const termsOperation: OperationDefinition = { missingBucketLabel: 'Missing', }, }), + 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, columnId: currentColumnId }) => { const currentColumn = state.columns[currentColumnId] as TermsIndexPatternColumn; const SEPARATOR = '$$$'; @@ -100,7 +129,7 @@ export const termsOperation: OperationDefinition = { } const orderOptions = Object.entries(state.columns) - .filter(([_columnId, column]) => !column.isBucketed) + .filter(([_columnId, column]) => isSortableByColumn(column)) .map(([columnId, column]) => { return { value: toValue({ type: 'column', columnId }), @@ -140,6 +169,7 @@ export const termsOperation: OperationDefinition = { })} > ) => @@ -152,6 +182,40 @@ export const termsOperation: OperationDefinition = { })} />
+ + ) => + setState( + updateColumnParam(state, currentColumn, 'orderDirection', 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.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts index f016515db32ea..a835fa76ade8b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -6,7 +6,7 @@ import { getOperationTypesForField, getPotentialColumns } from './operations'; import { IndexPatternPrivateState } from './indexpattern'; -import { hasField } from './state_helpers'; +import { hasField } from './utils'; jest.mock('./loader'); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index 8471e2b0ad8f4..0d9912e60d7b5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -24,7 +24,7 @@ import { import { dateHistogramOperation } from './operation_definitions/date_histogram'; import { countOperation } from './operation_definitions/count'; import { filterRatioOperation } from './operation_definitions/filter_ratio'; -import { sortByField } from './state_helpers'; +import { sortByField } from './utils'; type PossibleOperationDefinitions< U extends IndexPatternColumn = IndexPatternColumn @@ -73,9 +73,14 @@ export interface OperationDefinition { isApplicableForField: (field: IndexPatternField) => boolean; buildColumn: ( operationId: string, + columns: Partial>, suggestedOrder?: DimensionPriority, field?: IndexPatternField ) => C; + onOtherColumnChanged?: ( + currentColumn: C, + columns: Partial> + ) => C; paramEditor?: React.ComponentType; toEsAggsConfig: (column: C, columnId: string) => unknown; } @@ -106,10 +111,11 @@ export function getOperationTypesForField(field: IndexPatternField): OperationTy export function buildColumnForOperationType( index: number, op: T, + columns: Partial>, suggestedOrder?: DimensionPriority, field?: IndexPatternField ): IndexPatternColumn { - return operationDefinitionMap[op].buildColumn(`${index}${op}`, suggestedOrder, field); + return operationDefinitionMap[op].buildColumn(`${index}${op}`, columns, suggestedOrder, field); } export function getPotentialColumns( @@ -123,14 +129,14 @@ export function getPotentialColumns( const validOperations = getOperationTypesForField(field); return validOperations.map(op => - buildColumnForOperationType(index, op, suggestedOrder, field) + buildColumnForOperationType(index, op, state.columns, suggestedOrder, field) ); }) .reduce((prev, current) => prev.concat(current)); operationDefinitions.forEach(operation => { if (operation.isApplicableWithoutField) { - columns.push(operation.buildColumn(operation.type, suggestedOrder)); + columns.push(operation.buildColumn(operation.type, state.columns, suggestedOrder)); } }); 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 index c021119323b11..2f4684d5c617d 100644 --- 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 @@ -4,10 +4,102 @@ * you may not use this file except in compliance with the Elastic License. */ -import { updateColumnParam, getColumnOrder, changeColumn } from './state_helpers'; -import { IndexPatternPrivateState, DateHistogramIndexPatternColumn } from './indexpattern'; +import { updateColumnParam, changeColumn, getColumnOrder, deleteColumn } from './state_helpers'; +import { + IndexPatternPrivateState, + DateHistogramIndexPatternColumn, + TermsIndexPatternColumn, + AvgIndexPatternColumn, +} from './indexpattern'; +import { operationDefinitionMap } from './operations'; + +jest.mock('./operations'); describe('state_helpers', () => { + describe('deleteColumn', () => { + it('should remove column', () => { + const termsColumn: TermsIndexPatternColumn = { + operationId: 'op2', + 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', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }, + }; + + expect(deleteColumn(state, 'col2').columns).toEqual({ + col1: termsColumn, + }); + }); + + it('should execute adjustments for other columns', () => { + const termsColumn: TermsIndexPatternColumn = { + operationId: 'op2', + 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', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }, + }; + + deleteColumn(state, 'col2'); + + expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { + col1: termsColumn, + }); + }); + }); + describe('updateColumnParam', () => { it('should set the param for the given column', () => { const currentColumn: DateHistogramIndexPatternColumn = { @@ -131,6 +223,60 @@ describe('state_helpers', () => { }) ); }); + + it('should execute adjustments for other columns', () => { + const termsColumn: TermsIndexPatternColumn = { + operationId: 'op2', + 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 = { + operationId: 'op1', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }; + + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }, + }; + + changeColumn(state, 'col2', newColumn); + + expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { + col1: termsColumn, + col2: newColumn, + }); + }); }); describe('getColumnOrder', () => { @@ -175,6 +321,7 @@ describe('state_helpers', () => { orderBy: { type: 'alphabetical', }, + orderDirection: 'asc', }, }, col2: { @@ -221,6 +368,7 @@ describe('state_helpers', () => { orderBy: { type: 'alphabetical', }, + orderDirection: 'asc', }, suggestedOrder: 2, }, 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 index 0ee3222c1e5d7..394700d4469b6 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts @@ -9,25 +9,8 @@ import { IndexPatternPrivateState, IndexPatternColumn, BaseIndexPatternColumn, - FieldBasedIndexPatternColumn, } from './indexpattern'; - -export function getColumnOrder(columns: Record): string[] { - const entries = Object.entries(columns); - - const [aggregations, metrics] = _.partition(entries, col => col[1].isBucketed); - - return aggregations - .sort(([id, col], [id2, col2]) => { - return ( - // Sort undefined orders last - (col.suggestedOrder !== undefined ? col.suggestedOrder : Number.MAX_SAFE_INTEGER) - - (col2.suggestedOrder !== undefined ? col2.suggestedOrder : Number.MAX_SAFE_INTEGER) - ); - }) - .map(([id]) => id) - .concat(metrics.map(([id]) => id)); -} +import { operationDefinitionMap, OperationDefinition } from './operations'; export function updateColumnParam< C extends BaseIndexPatternColumn & { params: object }, @@ -61,6 +44,25 @@ export function updateColumnParam< }; } +function adjustColumnReferencesForChangedColumn( + columns: IndexPatternPrivateState['columns'], + columnId: string +) { + const newColumns = { ...columns }; + Object.keys(newColumns).forEach(currentColumnId => { + if (currentColumnId !== columnId) { + const currentColumn = newColumns[currentColumnId] as BaseIndexPatternColumn; + const operationDefinition = operationDefinitionMap[ + currentColumn.operationType + ] as OperationDefinition; + newColumns[currentColumnId] = (operationDefinition.onOtherColumnChanged + ? operationDefinition.onOtherColumnChanged(currentColumn, newColumns) + : currentColumn) as IndexPatternColumn; + } + }); + return newColumns; +} + export function changeColumn( state: IndexPatternPrivateState, columnId: string, @@ -77,10 +79,13 @@ export function changeColumn( ? ({ ...newColumn, params: oldColumn.params } as IndexPatternColumn) : newColumn; - const newColumns: IndexPatternPrivateState['columns'] = { - ...state.columns, - [columnId]: updatedColumn, - }; + const newColumns: IndexPatternPrivateState['columns'] = adjustColumnReferencesForChangedColumn( + { + ...state.columns, + [columnId]: updatedColumn, + }, + columnId + ); return { ...state, @@ -90,27 +95,33 @@ export function changeColumn( } export function deleteColumn(state: IndexPatternPrivateState, columnId: string) { - const newColumns: IndexPatternPrivateState['columns'] = { + const columns: IndexPatternPrivateState['columns'] = { ...state.columns, }; - delete newColumns[columnId]; + delete columns[columnId]; + + const newColumns = adjustColumnReferencesForChangedColumn(columns, columnId); return { ...state, - columns: newColumns, columnOrder: getColumnOrder(newColumns), + columns: newColumns, }; } -export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { - return 'sourceField' in column; -} +export function getColumnOrder(columns: Record): string[] { + const entries = Object.entries(columns); -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); - }); + const [aggregations, metrics] = _.partition(entries, col => col[1].isBucketed); + + return aggregations + .sort(([id, col], [id2, col2]) => { + return ( + // Sort undefined orders last + (col.suggestedOrder !== undefined ? col.suggestedOrder : Number.MAX_SAFE_INTEGER) - + (col2.suggestedOrder !== undefined ? col2.suggestedOrder : Number.MAX_SAFE_INTEGER) + ); + }) + .map(([id]) => id) + .concat(metrics.map(([id]) => id)); } 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 index 67887ef186f09..8ed92c2b5fca0 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -51,7 +51,12 @@ export function toExpression(state: IndexPatternPrivateState) { ); if (filterRatios.length) { - const countColumn = buildColumnForOperationType(columnEntries.length, 'count', 2); + const countColumn = buildColumnForOperationType( + columnEntries.length, + 'count', + state.columns, + 2 + ); aggs.push(getEsAggsConfig(countColumn, 'filter-ratio')); return `esaggs 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..33b0efdf350c0 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/utils.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. + */ + +import _ from 'lodash'; +import { BaseIndexPatternColumn, FieldBasedIndexPatternColumn } from './indexpattern'; + +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); + }); +} From dd49f8a23b9e202d2c7ab78e94ce185d5c3533ae Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 16 Jul 2019 17:04:56 +0200 Subject: [PATCH 047/105] fix types --- .../legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index cb75fab6e457f..6cf0223191f63 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -13,7 +13,7 @@ export function createMockVisualization(): jest.Mocked { return { getPersistableState: jest.fn(_state => ({})), getSuggestions: jest.fn(_options => []), - initialize: jest.fn(_state => ({})), + initialize: jest.fn((_datasource, _state?) => ({})), renderConfigPanel: jest.fn(), toExpression: jest.fn((_state, _datasource) => null), }; @@ -38,7 +38,7 @@ export function createMockDatasource(): DatasourceMock { getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []), getPersistableState: jest.fn(), getPublicAPI: jest.fn((_state, _setState) => publicAPIMock), - initialize: jest.fn(_state => Promise.resolve()), + initialize: jest.fn((_state?) => Promise.resolve()), renderDataPanel: jest.fn(), toExpression: jest.fn(_state => null), From 92029169f5eaa9918c09d420baa423c4f2f3b4c3 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Wed, 17 Jul 2019 11:13:39 -0400 Subject: [PATCH 048/105] [Lens] Data panel styling and optimizations (#40787) Style the data panel (mostly Joe Reuter's doing). Optimize a bunch of the Lens stack. --- .../debounced_component.test.tsx | 29 ++ .../debounced_component.tsx | 26 ++ .../lens/public/debounced_component/index.ts | 7 + .../lens/public/drag_drop/providers.test.tsx | 40 +++ .../lens/public/drag_drop/providers.tsx | 7 +- .../editor_frame/config_panel_wrapper.tsx | 6 +- .../editor_frame/data_panel_wrapper.tsx | 64 +++- .../editor_frame/editor_frame.test.tsx | 27 +- .../editor_frame/index.scss | 24 +- .../editor_frame/suggestion_panel.tsx | 5 +- .../editor_frame/workspace_panel.test.tsx | 20 +- .../editor_frame/workspace_panel.tsx | 6 +- .../__snapshots__/indexpattern.test.tsx.snap | 87 ------ .../indexpattern_plugin/datapanel.test.tsx | 270 ++++++++++++++++ .../public/indexpattern_plugin/datapanel.tsx | 291 ++++++++++++++++++ .../dimension_panel/dimension_panel.test.tsx | 5 +- .../dimension_panel/dimension_panel.tsx | 17 +- .../dimension_panel/field_select.tsx | 27 +- .../indexpattern_plugin/field_icon.test.tsx | 48 +++ .../public/indexpattern_plugin/field_icon.tsx | 34 ++ .../public/indexpattern_plugin/field_item.tsx | 54 ++++ .../indexpattern_plugin/indexpattern.scss | 69 ++++- .../indexpattern_plugin/indexpattern.test.tsx | 50 --- .../indexpattern_plugin/indexpattern.tsx | 57 +--- .../indexpattern_plugin/operations.test.ts | 7 +- .../public/indexpattern_plugin/operations.ts | 12 +- 26 files changed, 1039 insertions(+), 250 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.tsx create mode 100644 x-pack/legacy/plugins/lens/public/debounced_component/index.ts create mode 100644 x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx delete mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx 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/providers.test.tsx b/x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx new file mode 100644 index 0000000000000..c296dd9ab063a --- /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: any[] = []; + 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 index c0b4eb563b32b..3e2b7312274c9 100644 --- a/x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx +++ b/x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; /** * The shape of the drag / drop context. @@ -64,7 +64,7 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } const [state, setState] = useState<{ dragging: unknown }>({ dragging: undefined, }); - const setDragging = (dragging: unknown) => setState({ dragging }); + const setDragging = useMemo(() => (dragging: unknown) => setState({ dragging }), [setState]); return ( @@ -81,5 +81,6 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } * @param props */ export function ChildDragDropProvider({ dragging, setDragging, children }: ProviderProps) { - return {children}; + const value = useMemo(() => ({ dragging, setDragging }), [setDragging, dragging]); + return {children}; } 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 index 677b37beab190..aa3513455dece 100644 --- 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useContext } from 'react'; +import React, { useMemo, useContext, memo } from 'react'; import { EuiSelect } from '@elastic/eui'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; @@ -43,7 +43,7 @@ function getSuggestedVisualizationState( return visualization.initialize(datasource, suggestions[0].state); } -export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { +export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const context = useContext(DragContext); const setVisualizationState = useMemo( () => (newState: unknown) => { @@ -89,4 +89,4 @@ export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { )} ); -} +}); 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 index a10f53a4895a9..8a560e862d607 100644 --- 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 @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, memo, useContext } from 'react'; -import { EuiSelect } from '@elastic/eui'; +import React, { useMemo, memo, useContext, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; import { DatasourceDataPanelProps, Datasource } from '../../../public'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; @@ -36,21 +37,58 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { setState: setDatasourceState, }; + const [showDatasourceSwitcher, setDatasourceSwitcher] = useState(false); + return ( <> - ({ - value: datasourceId, - text: datasourceId, - }))} - value={props.activeDatasource || undefined} - onChange={e => { - props.dispatch({ type: 'SWITCH_DATASOURCE', newDatasourceId: e.target.value }); - }} - /> + {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 index d3f7666d0ac5d..73ed3330c3393 100644 --- 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 @@ -338,14 +338,16 @@ Object { await waitForPromises(); - const updatedState = {}; + const updatedState = { + title: 'shazm', + }; const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] .setState; act(() => { setDatasourceState(updatedState); }); - expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(3); + expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(2); expect(mockDatasource.renderDataPanel).toHaveBeenLastCalledWith( expect.any(Element), expect.objectContaining({ @@ -501,6 +503,10 @@ Object { instance.update(); }); + afterEach(() => { + instance.unmount(); + }); + it('should have initialized only the initial datasource and visualization', () => { expect(mockDatasource.initialize).toHaveBeenCalled(); expect(mockDatasource2.initialize).not.toHaveBeenCalled(); @@ -511,9 +517,12 @@ Object { it('should initialize other datasource on switch', async () => { act(() => { - instance - .find('select[data-test-subj="datasource-switch"]') - .simulate('change', { target: { value: 'testDatasource2' } }); + 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(); }); @@ -522,9 +531,11 @@ Object { const initialState = {}; mockDatasource2.initialize.mockResolvedValue(initialState); - instance - .find('select[data-test-subj="datasource-switch"]') - .simulate('change', { target: { value: 'testDatasource2' } }); + instance.find('button[data-test-subj="datasource-switch"]').simulate('click'); + + (document.querySelector( + '[data-test-subj="datasource-switch-testDatasource2"]' + ) as HTMLButtonElement).click(); await waitForPromises(); 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 index b57fe73adb8b2..16590b36db86c 100644 --- 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 @@ -17,22 +17,34 @@ .lnsPageMainContent { display: flex; + overflow: auto; } .lnsSidebar { - @include euiScrollBar; - overflow: hidden auto; - padding: $euiSize; margin: 0; flex: 1 0 18%; - min-width: ($euiSize * 16); - height: 100%; + min-width: ($euiSize * 22); display: flex; flex-direction: column; + position: relative; } .lnsSidebar--right { - min-width: ($euiSize * 18); + min-width: ($euiSize * 22); + @include euiScrollBar; + overflow: hidden auto; + padding: $euiSize; +} + +.lnsSidebarContainer { + flex: 1 0 100%; + overflow: hidden; +} + +.lnsDatasourceSwitch { + position: absolute; + right: $euiSize + $euiSizeXS; + top: $euiSize + $euiSizeXS; } .lnsPageBody { 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 index 90af8106c4459..e0ae92e6bb5c3 100644 --- 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 @@ -14,6 +14,7 @@ import { Datasource, Visualization } from '../../types'; import { getSuggestions, toSwitchAction, Suggestion } from './suggestion_helpers'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/data/public'; import { prependDatasourceExpression } from './expression_helpers'; +import { debouncedComponent } from '../../debounced_component'; export interface SuggestionPanelProps { activeDatasource: Datasource; @@ -86,7 +87,9 @@ const SuggestionPreview = ({ ); }; -export function SuggestionPanel({ +export const SuggestionPanel = debouncedComponent(InnerSuggestionPanel, 2000); + +function InnerSuggestionPanel({ activeDatasource, datasourceState, activeVisualizationId, 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 index dadce6d856bd3..5dc613baf0176 100644 --- 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 @@ -14,7 +14,7 @@ import { createExpressionRendererMock, DatasourceMock, } from '../mocks'; -import { WorkspacePanel, WorkspacePanelProps } from './workspace_panel'; +import { InnerWorkspacePanel, WorkspacePanelProps } from './workspace_panel'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { DragDrop } from '../../drag_drop'; @@ -43,7 +43,7 @@ describe('workspace_panel', () => { it('should render an explanatory text if no visualization is active', () => { instance = mount( - { it('should render an explanatory text if the visualization does not produce an expression', () => { instance = mount( - 'datasource' }} datasourceState={{}} activeVisualizationId="vis" @@ -83,7 +83,7 @@ describe('workspace_panel', () => { it('should render an explanatory text if the datasource does not produce an expression', () => { instance = mount( - null }} datasourceState={{}} activeVisualizationId="vis" @@ -103,7 +103,7 @@ describe('workspace_panel', () => { it('should render the resulting expression using the expression renderer', () => { instance = mount( - 'datasource', @@ -142,7 +142,7 @@ Object { describe('expression failures', () => { it('should show an error message if the expression fails to parse', () => { instance = mount( - 'datasource ||', @@ -170,7 +170,7 @@ Object { }); instance = mount( - 'datasource', @@ -203,7 +203,7 @@ Object { }); instance = mount( - 'datasource', @@ -239,7 +239,7 @@ Object { }); instance = mount( - 'datasource', @@ -282,7 +282,7 @@ Object { beforeEach(() => { mockDispatch = jest.fn(); instance = mount( - - Index Pattern Data Source -
- -
- - timestamp - - - bytes - - - source - -
-
-
-`; 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..a77305a5c3178 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -0,0 +1,270 @@ +/* + * 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, { ChangeEvent, ReactElement } from 'react'; +import { EuiComboBox, EuiFieldSearch, EuiContextMenuPanel } from '@elastic/eui'; +import { IndexPatternPrivateState } from './indexpattern'; +import { DatasourceDataPanelProps } from '../types'; +import { createMockedDragDropContext } from './mocks'; +import { IndexPatternDataPanel } from './datapanel'; +import { FieldItem } from './field_item'; +import { act } from 'react-dom/test-utils'; + +jest.mock('./loader'); + +const initialState: IndexPatternPrivateState = { + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'My Op', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderDirection: 'asc', + orderBy: { + type: 'alphabetical', + }, + }, + }, + }, + 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, + }, + 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 Panel', () => { + let defaultProps: DatasourceDataPanelProps; + + beforeEach(() => { + defaultProps = { + state: initialState, + setState: jest.fn(), + dragDropContext: createMockedDragDropContext(), + }; + }); + + 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'); + + const comboBox = wrapper.find(EuiComboBox); + + comboBox.prop('onChange')!([ + { + label: initialState.indexPatterns['2'].title, + value: '2', + }, + ]); + + expect(defaultProps.setState).toHaveBeenCalledWith({ + ...initialState, + currentIndexPatternId: '2', + }); + }); + + 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', async () => { + const wrapper = shallow(); + + act(() => { + wrapper.find(EuiFieldSearch).prop('onChange')!({ target: { value: 'mem' } } as ChangeEvent< + HTMLInputElement + >); + }); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'memory', + ]); + }); + + it('should filter down by type', async () => { + const wrapper = shallow(); + + act(() => { + (wrapper + .find(EuiContextMenuPanel) + .prop('items')! + .find( + item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' + )! as ReactElement).props.onClick(); + }); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'bytes', + 'memory', + ]); + }); + + it('should toggle type if clicked again', async () => { + const wrapper = shallow(); + + act(() => { + (wrapper + .find(EuiContextMenuPanel) + .prop('items')! + .find( + item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' + )! as ReactElement).props.onClick(); + }); + + act(() => { + (wrapper + .find(EuiContextMenuPanel) + .prop('items')! + .find( + item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' + )! as ReactElement).props.onClick(); + }); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'bytes', + 'memory', + 'source', + 'timestamp', + ]); + }); + + it('should filter down by type and by name', async () => { + const wrapper = shallow(); + + act(() => { + wrapper.find(EuiFieldSearch).prop('onChange')!({ target: { value: 'mem' } } as ChangeEvent< + HTMLInputElement + >); + }); + + act(() => { + (wrapper + .find(EuiContextMenuPanel) + .prop('items')! + .find( + item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' + )! as ReactElement).props.onClick(); + }); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'memory', + ]); + }); +}); 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..4491050150f50 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -0,0 +1,291 @@ +/* + * 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 { + EuiComboBox, + EuiFieldSearch, + // @ts-ignore + EuiHighlight, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiButtonEmpty, + EuiFilterGroup, + EuiFilterButton, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiContextMenuPanelProps, + EuiPopover, + EuiCallOut, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DatasourceDataPanelProps, DataType } from '../types'; +import { IndexPatternPrivateState, IndexPatternField } from './indexpattern'; +import { ChildDragDropProvider } from '../drag_drop'; +import { FieldItem } from './field_item'; +import { FieldIcon } from './field_icon'; + +// TODO the typings for EuiContextMenuPanel are incorrect - watchedItemProps is missing. This can be removed when the types are adjusted +const FixedEuiContextMenuPanel = (EuiContextMenuPanel as any) 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' }), +}; + +export function IndexPatternDataPanel(props: DatasourceDataPanelProps) { + const [state, setState] = useState({ + nameFilter: '', + typeFilter: [] as DataType[], + showIndexPatternSwitcher: false, + isTypeFilterOpen: false, + }); + const [pageSize, setPageSize] = useState(PAGINATION_SIZE); + const [scrollContainer, setScrollContainer] = useState(undefined); + 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(); + } + }, [state.nameFilter, state.typeFilter, props.state.currentIndexPatternId]); + + if (Object.keys(props.state.indexPatterns).length === 0) { + return ( + + + +

+ +

+
+
+
+ ); + } + + const allFields = props.state.indexPatterns[props.state.currentIndexPatternId].fields; + const filteredFields = allFields + .filter( + (field: IndexPatternField) => + field.name.toLowerCase().includes(state.nameFilter.toLowerCase()) && + supportedFieldTypes.includes(field.type) + ) + .slice(0, pageSize); + + const availableFieldTypes = _.uniq(filteredFields.map(({ type }) => type)); + const availableFilteredTypes = state.typeFilter.filter(type => + availableFieldTypes.includes(type) + ); + + return ( + + + +
+ {!state.showIndexPatternSwitcher ? ( + <> + +

+ {props.state.indexPatterns[props.state.currentIndexPatternId].title}{' '} +

+
+ setState({ ...state, showIndexPatternSwitcher: true })} + size="xs" + > + ( + + ) + + + ) : ( + ({ + label: title, + value: id, + }))} + inputRef={el => { + if (el) { + el.focus(); + } + }} + selectedOptions={ + props.state.currentIndexPatternId + ? [ + { + label: props.state.indexPatterns[props.state.currentIndexPatternId].title, + value: props.state.indexPatterns[props.state.currentIndexPatternId].id, + }, + ] + : undefined + } + singleSelection={{ asPlainText: true }} + isClearable={false} + onBlur={() => { + setState({ ...state, showIndexPatternSwitcher: false }); + }} + onChange={choices => { + props.setState({ + ...props.state, + currentIndexPatternId: choices[0].value as string, + }); + + setState({ + ...state, + showIndexPatternSwitcher: false, + nameFilter: '', + typeFilter: [], + }); + }} + /> + )} +
+
+ + + + { + setState({ ...state, nameFilter: e.target.value }); + }} + aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameAriaLabel', { + defaultMessage: 'Search fields', + })} + /> + + + + setState({ ...state, isTypeFilterOpen: false })} + button={ + + setState({ ...state, isTypeFilterOpen: !state.isTypeFilterOpen }) + } + iconType="arrowDown" + data-test-subj="indexPatternTypeFilterButton" + isSelected={state.isTypeFilterOpen} + numFilters={availableFieldTypes.length} + hasActiveFilters={availableFilteredTypes.length > 0} + numActiveFilters={availableFilteredTypes.length} + > + {i18n.translate('xpack.lens.indexPatterns.typeFilterLabel', { + defaultMessage: 'Types', + })} + + } + > + ( + + setState({ + ...state, + typeFilter: state.typeFilter.includes(type) + ? state.typeFilter.filter(t => t !== type) + : [...state.typeFilter, type], + }) + } + > + {fieldTypeNames[type]} + + ))} + /> + + + + +
{ + if (el && !el.dataset.dynamicScroll) { + el.dataset.dynamicScroll = 'true'; + setScrollContainer(el); + } + }} + onScroll={lazyScroll} + > +
+ {filteredFields + .filter( + field => + state.typeFilter.length === 0 || + state.typeFilter.includes(field.type as DataType) + ) + .sort(sortFields) + .map(field => ( + + ))} +
+
+
+
+
+ ); +} 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 index 0f0ba36fbf8ee..2af755568c2a7 100644 --- 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 @@ -135,7 +135,10 @@ describe('IndexPatternDimensionPanel', () => { it('should pass the right arguments to getPotentialColumns', async () => { wrapper = shallow(); - expect(getPotentialColumns as jest.Mock).toHaveBeenCalledWith(state, 1); + expect(getPotentialColumns as jest.Mock).toHaveBeenCalledWith( + state.indexPatterns[state.currentIndexPatternId].fields, + 1 + ); }); it('should call the filterOperations function', () => { 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 index 719d4fc5a89af..e351acb07dc7b 100644 --- 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 @@ -5,7 +5,7 @@ */ import _ from 'lodash'; -import React from 'react'; +import React, { memo, useMemo } from 'react'; import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon } from '@elastic/eui'; import { Storage } from 'ui/storage'; import { i18n } from '@kbn/i18n'; @@ -32,8 +32,17 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { storage: Storage; }; -export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { - const columns = getPotentialColumns(props.state, props.suggestedPriority); +export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPanel( + props: IndexPatternDimensionPanelProps +) { + const columns = useMemo( + () => + getPotentialColumns( + props.state.indexPatterns[props.state.currentIndexPatternId].fields, + props.suggestedPriority + ), + [props.state.indexPatterns[props.state.currentIndexPatternId].fields, props.suggestedPriority] + ); const filteredColumns = columns.filter(col => { return props.filterOperations(columnToOperation(col)); @@ -99,4 +108,4 @@ export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProp ); -} +}); 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 index 6fb1b8eb9f21a..2f7388e5bbc70 100644 --- 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 @@ -7,14 +7,20 @@ import _ from 'lodash'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiComboBox } from '@elastic/eui'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiComboBoxOptionProps } from '@elastic/eui'; import classNames from 'classnames'; +import { + // @ts-ignore + EuiHighlight, +} from '@elastic/eui'; import { IndexPatternColumn, FieldBasedIndexPatternColumn, OperationType, BaseIndexPatternColumn, } from '../indexpattern'; +import { FieldIcon } from '../field_icon'; +import { DataType } from '../../types'; import { hasField, sortByField } from '../utils'; export interface FieldSelectProps { @@ -81,7 +87,7 @@ export function FieldSelect({ options: uniqueColumnsByField .map(col => ({ label: col.sourceField, - value: col.operationId, + value: { operationId: col.operationId, dataType: col.dataType }, compatible: isCompatibleWithCurrentOperation(col), })) .sort(({ compatible: a }, { compatible: b }) => { @@ -108,7 +114,7 @@ export function FieldSelect({ placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', { defaultMessage: 'Field', })} - options={fieldOptions} + options={(fieldOptions as unknown) as EuiComboBoxOptionProps[]} isInvalid={Boolean(incompatibleSelectedOperationType && selectedColumn)} selectedOptions={ selectedColumn @@ -130,11 +136,24 @@ export function FieldSelect({ } const column: IndexPatternColumn = filteredColumns.find( - ({ operationId }) => operationId === choices[0].value + ({ operationId }) => + operationId === ((choices[0].value as unknown) as { operationId: string }).operationId )!; onChangeColumn(column); }} + renderOption={(option, searchValue) => { + return ( + + + + + + {option.label} + + + ); + }} /> ); } 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..d1c2323200038 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx @@ -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 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); +} + +export type UnwrapArray = T extends Array ? P : T; + +export function FieldIcon({ type }: { type: DataType }) { + const icons: Partial>> = { + boolean: 'invert', + date: 'calendar', + }; + + const iconType = icons[type] || ICON_TYPES.find(t => t === type) || 'empty'; + const { colors } = palettes.euiPaletteColorBlind; + const colorIndex = stringToNum(iconType) % colors.length; + + 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..620f66dcb1354 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx @@ -0,0 +1,54 @@ +/* + * 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 { IndexPatternField } from './indexpattern'; +import { DragDrop } from '../drag_drop'; +import { FieldIcon } from './field_icon'; +import { DataType } from '..'; + +export interface FieldItemProps { + field: IndexPatternField; + highlight?: string; +} + +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') : undefined; +} + +export function FieldItem({ field, highlight }: 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/indexpattern.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss index 877afd3fcbbc4..021a4e73ecd53 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss @@ -1,5 +1,70 @@ @import './dimension_panel/index'; -.lnsIndexPattern__dimensionPopover { - max-width: 600px; +.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; +} + +.lnsIndexPatternDataPanel { + width: 100%; + height: 100%; + padding: $euiSize $euiSize 0; +} + +.lnsFieldListPanel__list-wrapper { + @include euiOverflowShadow; + 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; + @include euiScrollBar; +} + +.lnsFieldListPanel__field { + @include euiFontSizeS; + background: $euiColorEmptyShade; + border-radius: $euiBorderRadius; + padding: $euiSizeS; + display: flex; + align-items: center; + margin-bottom: $euiSizeXS; + font-weight: $euiFontWeightMedium; + transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance; + + &:hover { + @include euiBottomShadowMedium; + z-index: 2; + cursor: grab; + } +} + +.lnsFieldListPanel__fieldName { + margin-left: $euiSizeXS; } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index b8d19fc1987bb..c605260b0c04b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; -import React from 'react'; -import { EuiComboBox } from '@elastic/eui'; import chromeMock from 'ui/chrome'; import { data as dataMock } from '../../../../../../src/legacy/core_plugins/data/public/setup'; import { localStorage as storageMock } from 'ui/storage/storage_service'; @@ -16,11 +13,9 @@ import { getIndexPatternDatasource, IndexPatternPersistedState, IndexPatternPrivateState, - IndexPatternDataPanel, IndexPatternColumn, } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; -import { createMockedDragDropContext } from './mocks'; jest.mock('./loader'); // chrome, notify, storage are used by ./plugin @@ -172,51 +167,6 @@ describe('IndexPattern Data Source', () => { }); }); - describe('#renderDataPanel', () => { - let state: IndexPatternPrivateState; - - beforeEach(async () => { - state = await indexPatternDatasource.initialize(persistedState); - }); - - it('should match snapshot', () => { - expect( - shallow( - {}} - /> - ) - ).toMatchSnapshot(); - }); - - it('should call setState when the index pattern is switched', async () => { - const setState = jest.fn(); - - const wrapper = shallow( - - ); - - const comboBox = wrapper.find(EuiComboBox); - - comboBox.prop('onChange')!([ - { - label: expectedIndexPatterns['2'].title, - value: '2', - }, - ]); - - expect(setState).toHaveBeenCalledWith({ - ...state, - currentIndexPatternId: '2', - }); - }); - }); - describe('#getPersistedState', () => { it('should persist from saved state', async () => { const state = await indexPatternDatasource.initialize(persistedState); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 01d6ffd6018de..0bbb6f67d5b8a 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -7,7 +7,6 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; -import { EuiComboBox } from '@elastic/eui'; import { I18nProvider } from '@kbn/i18n/react'; import { DatasourceDimensionPanelProps, @@ -18,11 +17,11 @@ import { } from '../types'; import { Query } from '../../../../../../src/legacy/core_plugins/data/public/query'; import { getIndexPatterns } from './loader'; -import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; import { buildColumnForOperationType, getOperationTypesForField } from './operations'; import { IndexPatternDatasourcePluginPlugins } from './plugin'; +import { IndexPatternDataPanel } from './datapanel'; import { Datasource, DataType } from '..'; export type OperationType = IndexPatternColumn['operationType']; @@ -133,49 +132,6 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { indexPatterns: Record; }; -export function IndexPatternDataPanel(props: DatasourceDataPanelProps) { - return ( - - Index Pattern Data Source -
- ({ - label: title, - value: id, - }))} - selectedOptions={ - props.state.currentIndexPatternId - ? [ - { - label: props.state.indexPatterns[props.state.currentIndexPatternId].title, - value: props.state.indexPatterns[props.state.currentIndexPatternId].id, - }, - ] - : undefined - } - singleSelection={{ asPlainText: true }} - isClearable={false} - onChange={choices => { - props.setState({ - ...props.state, - currentIndexPatternId: choices[0].value as string, - }); - }} - /> -
- {props.state.currentIndexPatternId && - props.state.indexPatterns[props.state.currentIndexPatternId].fields.map(field => ( - - {field.name} - - ))} -
-
-
- ); -} - export function columnToOperation(column: IndexPatternColumn): Operation { const { dataType, label, isBucketed, operationId } = column; return { @@ -270,7 +226,12 @@ export function getIndexPatternDatasource({ domElement: Element, props: DatasourceDataPanelProps ) { - render(, domElement); + render( + + + , + domElement + ); }, getPublicAPI(state, setState) { @@ -306,8 +267,8 @@ export function getIndexPatternDatasource({ columns: removeProperty(columnId, state.columns), }); }, - moveColumnTo: (columnId: string, targetIndex: number) => {}, - duplicateColumn: (columnId: string) => [], + moveColumnTo: () => {}, + duplicateColumn: () => [], }; }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts index a835fa76ade8b..7ce2afc831d4a 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -168,13 +168,16 @@ describe('getOperationTypesForField', () => { }); it('should include priority', () => { - const columns = getPotentialColumns(state, 1); + const columns = getPotentialColumns( + state.indexPatterns[state.currentIndexPatternId].fields, + 1 + ); expect(columns.every(col => col.suggestedOrder === 1)).toEqual(true); }); it('should list operations by field for a regular index pattern', () => { - const columns = getPotentialColumns(state); + const columns = getPotentialColumns(state.indexPatterns[state.currentIndexPatternId].fields); expect( columns.map(col => [hasField(col) ? col.sourceField : '_documents_', col.operationType]) diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index 0d9912e60d7b5..700be3c17a159 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -119,26 +119,24 @@ export function buildColumnForOperationType( } export function getPotentialColumns( - state: IndexPatternPrivateState, + fields: IndexPatternField[], suggestedOrder?: DimensionPriority ): IndexPatternColumn[] { - const fields = state.indexPatterns[state.currentIndexPatternId].fields; - - const columns: IndexPatternColumn[] = fields + const result: IndexPatternColumn[] = fields .map((field, index) => { const validOperations = getOperationTypesForField(field); return validOperations.map(op => - buildColumnForOperationType(index, op, state.columns, suggestedOrder, field) + buildColumnForOperationType(index, op, {}, suggestedOrder, field) ); }) .reduce((prev, current) => prev.concat(current)); operationDefinitions.forEach(operation => { if (operation.isApplicableWithoutField) { - columns.push(operation.buildColumn(operation.type, state.columns, suggestedOrder)); + result.push(operation.buildColumn(operation.type, {}, suggestedOrder)); } }); - return sortByField(columns); + return sortByField(result); } From 9581e4e1c643881d27cd993e038d4bf65044aa32 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 18 Jul 2019 17:12:41 +0200 Subject: [PATCH 049/105] [Lens] Optimize dimension panel flow (#41114) --- .../dimension_panel/dimension_panel.test.tsx | 35 +++++++++++++++++++ .../dimension_panel/popover_editor.tsx | 24 ++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) 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 index 2af755568c2a7..06e2933956d7f 100644 --- 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 @@ -542,6 +542,41 @@ describe('IndexPatternDimensionPanel', () => { }); }); + it('should select operation directly if only one field is possible', () => { + const initialState = { + ...defaultProps.state, + indexPatterns: { + 1: { + ...defaultProps.state.indexPatterns['1'], + fields: defaultProps.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, + columns: { + ...state.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // 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(); 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 index 560007d44862e..b00fc980d5303 100644 --- 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 @@ -88,7 +88,19 @@ export function PopoverEditor(props: PopoverEditorProps) { }), 'data-test-subj': `lns-indexPatternDimension-${operationType}`, onClick() { - if (!selectedColumn || !compatibleWithCurrentField) { + if (!selectedColumn) { + const possibleColumns = _.uniq( + filteredColumns.filter(col => col.operationType === operationType), + 'sourceField' + ); + if (possibleColumns.length === 1) { + setState(changeColumn(state, columnId, possibleColumns[0])); + } else { + setInvalidOperationType(operationType); + } + return; + } + if (!compatibleWithCurrentField) { setInvalidOperationType(operationType); return; } @@ -179,6 +191,16 @@ export function PopoverEditor(props: PopoverEditorProps) {

)} + {incompatibleSelectedOperationType && !selectedColumn && ( + + )} {!incompatibleSelectedOperationType && ParamEditor && ( Date: Fri, 19 Jul 2019 13:57:28 +0200 Subject: [PATCH 050/105] [Lens] re-introduce no-explicit-any (#41454) --- .eslintrc.js | 2 +- src/legacy/plugin_discovery/types.ts | 4 ++ x-pack/legacy/plugins/lens/index.ts | 5 +- .../lens/public/drag_drop/providers.test.tsx | 2 +- .../public/indexpattern_plugin/datapanel.tsx | 2 +- .../lens/public/indexpattern_plugin/loader.ts | 1 + .../filter_ratio.test.tsx | 50 +++++++++---------- 7 files changed, 34 insertions(+), 32 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 293a15cd528cb..c7726b16b1937 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -626,7 +626,7 @@ module.exports = { * Lens overrides */ { - files: ['x-pack/plugins/lens/**/*.ts', 'x-pack/plugins/lens/**/*.tsx'], + files: ['x-pack/legacy/plugins/lens/**/*.ts', 'x-pack/legacy/plugins/lens/**/*.tsx'], rules: { '@typescript-eslint/no-explicit-any': 'error', }, diff --git a/src/legacy/plugin_discovery/types.ts b/src/legacy/plugin_discovery/types.ts index eb772e9970ed4..76b62b7eb693c 100644 --- a/src/legacy/plugin_discovery/types.ts +++ b/src/legacy/plugin_discovery/types.ts @@ -22,6 +22,8 @@ import { Capabilities } from '../../core/public'; // Disable lint errors for imports from src/core/* until SavedObjects migration is complete // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { SavedObjectsSchemaDefinition } from '../../core/server/saved_objects/schema'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { SavedObjectsManagementDefinition } from '../../core/server/saved_objects/management'; /** * Usage @@ -66,6 +68,8 @@ export interface LegacyPluginOptions { home: string[]; mappings: any; savedObjectSchemas: SavedObjectsSchemaDefinition; + savedObjectsManagement: SavedObjectsManagementDefinition; + visTypes: string[]; embeddableActions?: string[]; embeddableFactories?: string[]; }>; diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index 88a1b25fc4e39..d0e550be6e235 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -40,10 +40,7 @@ export const lens: LegacyPluginInitializer = kibana => { }), }, }, - // TODO: savedObjectsManagement is not in the uiExports type definition, - // so, we have to either fix the type signature and deal with merge - // conflicts, or simply cas to any here, and fix this later. - } as any, + }, config: () => { return Joi.object({ 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 index c296dd9ab063a..2a8735be426c0 100644 --- a/x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx +++ b/x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx @@ -12,7 +12,7 @@ jest.useFakeTimers(); describe('RootDragDropProvider', () => { test('reuses contexts for each render', () => { - const contexts: any[] = []; + const contexts: Array<{}> = []; const TestComponent = ({ name }: { name: string }) => { const context = useContext(DragContext); contexts.push(context); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx index 4491050150f50..d13142e150740 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -32,7 +32,7 @@ import { FieldItem } from './field_item'; import { FieldIcon } from './field_icon'; // TODO the typings for EuiContextMenuPanel are incorrect - watchedItemProps is missing. This can be removed when the types are adjusted -const FixedEuiContextMenuPanel = (EuiContextMenuPanel as any) as React.FunctionComponent< +const FixedEuiContextMenuPanel = (EuiContextMenuPanel as unknown) as React.FunctionComponent< EuiContextMenuPanelProps & { watchedItemProps: string[] } >; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts index 41989427c3e0f..74b0ff434f4cb 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts @@ -6,6 +6,7 @@ import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths import { SavedObjectAttributes } from 'src/core/server/saved_objects'; import { IndexPatternField } from './indexpattern'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx index 9dd6b06eb5c59..49358316ec8d5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx @@ -9,13 +9,25 @@ import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import { act } from 'react-dom/test-utils'; import { filterRatioOperation } from './filter_ratio'; import { FilterRatioIndexPatternColumn, IndexPatternPrivateState } from '../indexpattern'; +import { Storage } from 'ui/storage'; +import { DataSetup } from '../../../../../../../src/legacy/core_plugins/data/public'; describe('filter_ratio', () => { let state: IndexPatternPrivateState; - let storageMock: any; - let dataMock: any; + let storageMock: Storage; + let dataMock: DataSetup; const InlineOptions = filterRatioOperation.paramEditor!; + class MockQueryBarInput { + props: {}; + constructor(props: {}) { + this.props = props; + } + render() { + return <>; + } + } + beforeEach(() => { state = { indexPatterns: { @@ -44,22 +56,10 @@ describe('filter_ratio', () => { }, }; - class QueryBarInput { - props: any; - constructor(props: any) { - this.props = props; - } - render() { - return <>; - } - } - - storageMock = { - getItem() {}, - }; - dataMock = { - query: { ui: { QueryBarInput } }, - }; + storageMock = {} as Storage; + dataMock = ({ + query: { ui: { QueryBarInput: MockQueryBarInput } }, + } as unknown) as DataSetup; }); describe('buildColumn', () => { @@ -121,8 +121,8 @@ describe('filter_ratio', () => { /> ); - expect(wrapper.find('QueryBarInput')).toHaveLength(1); - expect(wrapper.find('QueryBarInput').prop('indexPatterns')).toEqual(['1']); + expect(wrapper.find(MockQueryBarInput)).toHaveLength(1); + expect(wrapper.find(MockQueryBarInput).prop('indexPatterns')).toEqual(['1']); }); it('should update the state when typing into the query bar', () => { @@ -137,10 +137,10 @@ describe('filter_ratio', () => { /> ); - wrapper.find('QueryBarInput').prop('onChange')!({ + wrapper.find(MockQueryBarInput).prop('onChange')!({ query: 'geo.src : "US"', language: 'kuery', - } as any); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -175,15 +175,15 @@ describe('filter_ratio', () => { .simulate('click'); }); - expect(wrapper.find('QueryBarInput')).toHaveLength(2); + expect(wrapper.find(MockQueryBarInput)).toHaveLength(2); wrapper - .find('QueryBarInput') + .find(MockQueryBarInput) .at(1) .prop('onChange')!({ query: 'geo.src : "US"', language: 'kuery', - } as any); + }); expect(setState).toHaveBeenCalledWith({ ...state, From e0719764d02a7a80257ba03b2349ffded2e35b66 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 22 Jul 2019 11:55:28 +0200 Subject: [PATCH 051/105] [Lens] No results marker (#41450) --- .../xy_config_panel.tsx | 2 +- .../xy_visualization_plugin/xy_expression.tsx | 27 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) 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 index fdaa448584536..3e7f32c62d627 100644 --- 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 @@ -20,7 +20,7 @@ import { VisualizationProps } from '../types'; import { NativeRenderer } from '../native_renderer'; import { MultiColumnEditor } from './multi_column_editor'; -const chartTypeIcons: Array<{ id: SeriesType; label: string; iconType: IconType }> = [ +export const chartTypeIcons: Array<{ id: SeriesType; label: string; iconType: IconType }> = [ { id: 'line', label: i18n.translate('xpack.lens.xyVisualization.lineChartLabel', { 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 index 2392114e26999..772b2a8f55f3b 100644 --- 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 @@ -17,9 +17,12 @@ import { BarSeries, } from '@elastic/charts'; import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; -import { XYArgs } from './types'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, IconType } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { XYArgs, SeriesType } from './types'; import { KibanaDatatable } from '../types'; import { RenderFunction } from '../interpreter_types'; +import { chartTypeIcons } from './xy_config_panel'; export interface XYChartProps { data: KibanaDatatable; @@ -101,7 +104,29 @@ export const xyChartRenderer: RenderFunction = { }, }; +function getIconForSeriesType(seriesType: SeriesType): IconType { + return chartTypeIcons.find(chartTypeIcon => chartTypeIcon.id === seriesType)!.iconType; +} + export function XYChart({ data, args }: XYChartProps) { + if (data.rows.length === 0) { + return ( + + + + + + + + + + + ); + } + const { legend, x, y, splitSeriesAccessors, seriesType } = args; // TODO: Stop mapping data once elastic-charts allows axis naming // https://github.com/elastic/elastic-charts/issues/245 From 7db032c155b8c7f20fca279e22d024b46135ee4d Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 24 Jul 2019 12:13:14 -0400 Subject: [PATCH 052/105] [lens] Support layers for visualizing results of multiple queries (#41290) * [lens] WIP add support for layers * [lens] WIP switch to nested tables * Get basic layering to work * Load multiple tables and render in one chart * Fix priority ordering * Reduce quantity of linting errors * Ensure that new xy layer state has a split column * Make the "add" y / split the trailing accessor * Various fixes for datasource public API and implementation * Unify datasource deletion and accessor removal * Fix broken scss * Fix xy visualization TypeScript errors? * Build basic suggestions * Restore save/load and fix typescript bugs * simplify init routine * fix save tests * fix persistence tests * fix state management tests * Ensure the data table is aligned to the top * Add layer support to Lens datatable * Give xy chart a default layer initially * Allow deletion of layers in xy charts * xy: Make split accessor singular Remove commented code blocks * Change expression type for lens_merge_tables * Fix XY chart rendering expression * Fix type errors relating to `layerId` in table suggestions * Pass around tables for suggestions with associated layerIds * fix tests in workspace panel * fix editor_frame tests * Fix xy tests, skip inapplicable tests that will be implemented in a separate PR * add some tests for multiple datasources and layers * Suggest that split series comes before X axis in XY chart * Get datatable suggestion working * Adjust how xy axis labels are computed * Datasource suggestions should handle layers and have tests * Fix linting in XY chart and remove commented code * Update snapshots from earlier change * Fix linting errors * More cleanup * Remove commented code * Test the multi-column editor * XY Visualization does not need to track datasourceId * Fix various comments * Remove unused xy prop Add datasource header to datatable config * Use operation labels for XY chart * Adding and removing layers is reflected in the datasource * rewrote datasource state init * clean up editor_frame frame api implementation * clean up editor frame --- x-pack/legacy/plugins/lens/mappings.json | 2 +- .../expression.tsx | 20 +- .../datatable_visualization_plugin/index.scss | 3 + .../visualization.test.tsx | 152 +++-- .../visualization.tsx | 262 ++++----- .../editor_frame/config_panel_wrapper.tsx | 52 +- .../editor_frame/data_panel_wrapper.tsx | 3 +- .../editor_frame/editor_frame.test.tsx | 358 ++++++++++-- .../editor_frame/editor_frame.tsx | 270 +++++---- .../editor_frame/expression_helpers.ts | 82 ++- .../editor_frame/index.scss | 1 + .../editor_frame/save.test.ts | 77 ++- .../editor_frame_plugin/editor_frame/save.ts | 13 +- .../editor_frame/state_management.test.ts | 222 +++++-- .../editor_frame/state_management.ts | 85 +-- .../editor_frame/suggestion_helpers.test.ts | 46 +- .../editor_frame/suggestion_helpers.ts | 40 +- .../editor_frame/suggestion_panel.test.tsx | 72 ++- .../editor_frame/suggestion_panel.tsx | 29 +- .../editor_frame/workspace_panel.test.tsx | 277 +++++++-- .../editor_frame/workspace_panel.tsx | 52 +- .../editor_frame_plugin/merge_tables.test.ts | 35 ++ .../editor_frame_plugin/merge_tables.ts | 52 ++ .../lens/public/editor_frame_plugin/mocks.tsx | 29 +- .../editor_frame_plugin/plugin.test.tsx | 21 +- .../public/editor_frame_plugin/plugin.tsx | 19 +- x-pack/legacy/plugins/lens/public/index.scss | 2 + .../indexpattern_plugin/datapanel.test.tsx | 34 +- .../dimension_panel/_summary.scss | 3 +- .../dimension_panel/dimension_panel.test.tsx | 460 +++++++++------ .../dimension_panel/dimension_panel.tsx | 38 +- .../dimension_panel/field_select.tsx | 2 +- .../dimension_panel/popover_editor.tsx | 81 ++- .../indexpattern_plugin/indexpattern.test.tsx | 552 +++++++++++++++--- .../indexpattern_plugin/indexpattern.tsx | 272 ++++++--- .../operation_definitions/count.tsx | 14 +- .../date_histogram.test.tsx | 107 ++-- .../operation_definitions/date_histogram.tsx | 27 +- .../filter_ratio.test.tsx | 82 ++- .../operation_definitions/filter_ratio.tsx | 26 +- .../operation_definitions/metrics.tsx | 11 +- .../operation_definitions/terms.test.tsx | 191 +++--- .../operation_definitions/terms.tsx | 36 +- .../indexpattern_plugin/operations.test.ts | 52 +- .../public/indexpattern_plugin/operations.ts | 84 ++- .../indexpattern_plugin/state_helpers.test.ts | 282 +++++---- .../indexpattern_plugin/state_helpers.ts | 103 ++-- .../indexpattern_plugin/to_expression.ts | 44 +- .../lens/public/multi_column_editor/index.ts | 7 + .../multi_column_editor.test.tsx | 71 +++ .../multi_column_editor.tsx | 41 +- .../persistence/saved_object_store.test.ts | 36 +- .../public/persistence/saved_object_store.ts | 5 +- x-pack/legacy/plugins/lens/public/types.ts | 51 +- .../__snapshots__/xy_expression.test.tsx.snap | 91 ++- .../xy_visualization.test.ts.snap | 75 +-- .../_xy_config_panel.scss | 3 + .../_xy_expression.scss | 3 + .../public/xy_visualization_plugin/index.scss | 2 + .../public/xy_visualization_plugin/plugin.tsx | 4 +- .../xy_visualization_plugin/to_expression.ts | 127 ++-- .../public/xy_visualization_plugin/types.ts | 115 ++-- .../xy_config_panel.test.tsx | 422 ++++--------- .../xy_config_panel.tsx | 410 ++++++------- .../xy_expression.scss | 3 - .../xy_expression.test.tsx | 150 ++--- .../xy_visualization_plugin/xy_expression.tsx | 153 ++--- .../xy_suggestions.test.ts | 187 +++--- .../xy_visualization_plugin/xy_suggestions.ts | 89 +-- .../xy_visualization.test.ts | 129 ++-- .../xy_visualization.tsx | 28 +- 71 files changed, 4411 insertions(+), 2568 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/index.scss create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/merge_tables.test.ts create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/merge_tables.ts create mode 100644 x-pack/legacy/plugins/lens/public/multi_column_editor/index.ts create mode 100644 x-pack/legacy/plugins/lens/public/multi_column_editor/multi_column_editor.test.tsx rename x-pack/legacy/plugins/lens/public/{xy_visualization_plugin => multi_column_editor}/multi_column_editor.tsx (57%) create mode 100644 x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_config_panel.scss create mode 100644 x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_expression.scss create mode 100644 x-pack/legacy/plugins/lens/public/xy_visualization_plugin/index.scss delete mode 100644 x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.scss diff --git a/x-pack/legacy/plugins/lens/mappings.json b/x-pack/legacy/plugins/lens/mappings.json index 4c860a7171829..f7bf55fd76a67 100644 --- a/x-pack/legacy/plugins/lens/mappings.json +++ b/x-pack/legacy/plugins/lens/mappings.json @@ -7,7 +7,7 @@ "visualizationType": { "type": "keyword" }, - "datasourceType": { + "activeDatasourceId": { "type": "keyword" }, "state": { 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 index 44799bc7a403b..076bb1c20ac10 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx @@ -9,7 +9,7 @@ import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { EuiBasicTable } from '@elastic/eui'; import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; -import { KibanaDatatable } from '../types'; +import { KibanaDatatable, LensMultiTable } from '../types'; import { RenderFunction } from '../interpreter_types'; export interface DatatableColumns { @@ -22,7 +22,7 @@ interface Args { } export interface DatatableProps { - data: KibanaDatatable; + data: LensMultiTable; args: Args; } @@ -56,7 +56,7 @@ export const datatable: ExpressionFunction< }, }, context: { - types: ['kibana_datatable'], + types: ['lens_multitable'], }, fn(data: KibanaDatatable, args: Args) { return { @@ -106,11 +106,6 @@ export const datatableColumns: ExpressionFunction< }, }; -export interface DatatableProps { - data: KibanaDatatable; - args: Args; -} - export const datatableRenderer: RenderFunction = { name: 'lens_datatable_renderer', displayName: i18n.translate('xpack.lens.datatable.visualizationName', { @@ -125,17 +120,20 @@ export const datatableRenderer: RenderFunction = { }; function DatatableComponent(props: DatatableProps) { + const [firstTable] = Object.values(props.data.tables); + return ( { + .map((field, index) => { return { - field: props.args.columns.columnIds[index], + field, name: props.args.columns.labels[index], }; }) .filter(({ field }) => !!field)} - items={props.data.rows} + items={firstTable ? firstTable.rows : []} /> ); } 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/visualization.test.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx index d43008f68c330..62df18c391445 100644 --- 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 @@ -8,79 +8,81 @@ import React from 'react'; import { createMockDatasource } from '../editor_frame_plugin/mocks'; import { DatatableVisualizationState, - DatatableConfigPanel, datatableVisualization, + DataTableLayer, } from './visualization'; import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; -import { Operation, DataType } from '../types'; +import { Operation, DataType, FramePublicAPI } from '../types'; import { generateId } from '../id_generator'; jest.mock('../id_generator'); +function mockFrame(): FramePublicAPI { + return { + addNewLayer: () => 'aaa', + removeLayer: () => {}, + datasourceLayers: {}, + }; +} + describe('Datatable Visualization', () => { describe('#initialize', () => { it('should initialize from the empty state', () => { - const datasource = createMockDatasource(); (generateId as jest.Mock).mockReturnValueOnce('id'); - expect(datatableVisualization.initialize(datasource.publicAPIMock)).toEqual({ - columns: [{ id: 'id', label: '' }], + expect(datatableVisualization.initialize(mockFrame(), undefined)).toEqual({ + layers: [ + { + layerId: 'aaa', + columns: ['id'], + }, + ], }); }); it('should initialize from a persisted state', () => { - const datasource = createMockDatasource(); const expectedState: DatatableVisualizationState = { - columns: [{ id: 'saved', label: 'label' }], + layers: [ + { + layerId: 'foo', + columns: ['saved'], + }, + ], }; - expect(datatableVisualization.initialize(datasource.publicAPIMock, expectedState)).toEqual( - expectedState - ); + expect(datatableVisualization.initialize(mockFrame(), expectedState)).toEqual(expectedState); }); }); describe('#getPersistableState', () => { it('should persist the internal state', () => { const expectedState: DatatableVisualizationState = { - columns: [{ id: 'saved', label: 'label' }], + layers: [ + { + layerId: 'baz', + columns: ['a', 'b', 'c'], + }, + ], }; expect(datatableVisualization.getPersistableState(expectedState)).toEqual(expectedState); }); }); - describe('DatatableConfigPanel', () => { - it('should update the column label', () => { - const setState = jest.fn(); - const wrapper = mount( - {} }} - datasource={createMockDatasource().publicAPIMock} - setState={setState} - state={{ columns: [{ id: 'saved', label: 'label' }] }} - /> - ); - - const labelEditor = wrapper.find('[data-test-subj="lnsDatatable-columnLabel"]').at(1); - - act(() => { - labelEditor.simulate('change', { target: { value: 'New Label' } }); - }); - - expect(setState).toHaveBeenCalledWith({ - columns: [{ id: 'saved', label: 'New Label' }], - }); - }); - - it('should allow all operations to be shown', () => { + describe('DataTableLayer', () => { + it('allows all kinds of operations', () => { const setState = jest.fn(); const datasource = createMockDatasource(); + const layer = { layerId: 'a', columns: ['b', 'c'] }; mount( - {} }} - datasource={datasource.publicAPIMock} + frame={{ + addNewLayer: jest.fn(), + removeLayer: jest.fn(), + datasourceLayers: { a: datasource.publicAPIMock }, + }} + layer={layer} setState={setState} - state={{ columns: [{ id: 'saved', label: 'label' }] }} + state={{ layers: [layer] }} /> ); @@ -105,52 +107,74 @@ describe('Datatable Visualization', () => { ); }); - it('should remove a column', () => { + it('allows columns to be removed', () => { const setState = jest.fn(); - const wrapper = mount( - {} }} - datasource={createMockDatasource().publicAPIMock} + frame={{ + addNewLayer: jest.fn(), + removeLayer: jest.fn(), + datasourceLayers: { a: datasource.publicAPIMock }, + }} + layer={layer} setState={setState} - state={{ columns: [{ id: 'saved', label: '' }, { id: 'second', label: '' }] }} + state={{ layers: [layer] }} /> ); - act(() => { - wrapper - .find('[data-test-subj="lnsDatatable_dimensionPanelRemove_saved"]') - .first() - .simulate('click'); - }); + const onRemove = component + .find('[data-test-subj="datatable_multicolumnEditor"]') + .first() + .prop('onRemove') as (k: string) => {}; + + onRemove('b'); expect(setState).toHaveBeenCalledWith({ - columns: [{ id: 'second', label: '' }], + layers: [ + { + layerId: 'a', + columns: ['c'], + }, + ], }); }); - it('should be able to add more columns', () => { + it('allows columns to be added', () => { + (generateId as jest.Mock).mockReturnValueOnce('d'); const setState = jest.fn(); const datasource = createMockDatasource(); - const wrapper = mount( - {} }} - datasource={datasource.publicAPIMock} + frame={{ + addNewLayer: jest.fn(), + removeLayer: jest.fn(), + datasourceLayers: { a: datasource.publicAPIMock }, + }} + layer={layer} setState={setState} - state={{ columns: [{ id: 'saved', label: 'label' }] }} + state={{ layers: [layer] }} /> ); - (generateId as jest.Mock).mockReturnValueOnce('newId'); + const onAdd = component + .find('[data-test-subj="datatable_multicolumnEditor"]') + .first() + .prop('onAdd') as () => {}; - act(() => { - wrapper - .find('[data-test-subj="lnsDatatable_dimensionPanel_add"]') - .first() - .simulate('click'); - }); + onAdd(); expect(setState).toHaveBeenCalledWith({ - columns: [{ id: 'saved', label: 'label' }, { id: 'newId', label: '' }], + 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 index 7bd1dbedd1ac0..279aa4e7baeac 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -6,130 +6,82 @@ import React from 'react'; import { render } from 'react-dom'; -import { - EuiButtonIcon, - EuiForm, - EuiFieldText, - EuiFormRow, - EuiButton, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; +import { EuiForm, EuiFormRow, EuiPanel } 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, } from '../types'; -import { NativeRenderer } from '../native_renderer'; import { generateId } from '../id_generator'; +import { NativeRenderer } from '../native_renderer'; -export interface DatatableVisualizationState { - columns: Array<{ - id: string; - label: string; - }>; +export interface LayerState { + layerId: string; + columns: string[]; } -export function DatatableConfigPanel(props: VisualizationProps) { - const { state, datasource, setState } = props; - - return ( - - {state.columns.map(({ id, label }, index) => { - const operation = datasource.getOperationForColumnId(id); - return ( - <> - - { - const newColumns = [...state.columns]; - newColumns[index] = { ...newColumns[index], label: e.target.value }; - setState({ - ...state, - columns: newColumns, - }); - }} - placeholder={ - operation - ? operation.label - : i18n.translate('xpack.lens.datatable.columnTitlePlaceholder', { - defaultMessage: 'Title', - }) - } - aria-label={i18n.translate('xpack.lens.datatable.columnTitlePlaceholder', { - defaultMessage: 'Title', - })} - /> - +export interface DatatableVisualizationState { + layers: LayerState[]; +} - - - - true, - }} - /> - +function newLayerState(layerId: string): LayerState { + return { + layerId, + columns: [generateId()], + }; +} - - { - datasource.removeColumnInTableSpec(id); - const newColumns = [...state.columns]; - newColumns.splice(index, 1); - setState({ - ...state, - columns: newColumns, - }); - }} - aria-label={i18n.translate('xpack.lens.datatable.removeColumnAriaLabel', { - defaultMessage: 'Remove', - })} - /> - - - - - ); - })} +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 newColumns = [...state.columns]; - newColumns.push({ - id: generateId(), - label: '', - }); - setState({ - ...state, - columns: newColumns, - }); - }} - iconType="plusInCircle" +export function DataTableLayer({ + layer, + frame, + state, + setState, + dragDropContext, +}: { layer: LayerState } & VisualizationProps) { + const datasource = frame.datasourceLayers[layer.layerId]; + return ( + + <> + -
-
+ + true} + layerId={layer.layerId} + onAdd={() => + 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" + /> + + + ); } @@ -137,15 +89,11 @@ export const datatableVisualization: Visualization< DatatableVisualizationState, DatatableVisualizationState > = { - initialize(datasource, state) { + initialize(frame, state) { + const layerId = Object.keys(frame.datasourceLayers)[0] || frame.addNewLayer(); return ( state || { - columns: [ - { - id: generateId(), - label: '', - }, - ], + layers: [newLayerState(layerId)], } ); }, @@ -170,10 +118,12 @@ export const datatableVisualization: Visualization< score: 1, datasourceSuggestionId: table.datasourceSuggestionId, state: { - columns: table.columns.map(col => ({ - id: col.columnId, - label: col.operation.label, - })), + layers: [ + { + layerId: table.layerId, + columns: table.columns.map(col => col.columnId), + }, + ], }, previewIcon: 'visTable', }; @@ -183,41 +133,53 @@ export const datatableVisualization: Visualization< renderConfigPanel: (domElement, props) => render( - + + {props.state.layers.map(layer => ( + + ))} + , domElement ), - toExpression: (state, datasource) => ({ - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_datatable', - arguments: { - columns: [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_datatable_columns', - arguments: { - columnIds: state.columns.map(({ id }) => id), - labels: state.columns.map(({ id, label }) => { - if (label) { - return label; - } - const operation = datasource.getOperationForColumnId(id); - return operation ? operation.label : ''; - }), + 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.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/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 index aa3513455dece..2bca6c670c770 100644 --- 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 @@ -8,7 +8,7 @@ import React, { useMemo, useContext, memo } from 'react'; import { EuiSelect } from '@elastic/eui'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; -import { Visualization, DatasourcePublicAPI } from '../../types'; +import { Visualization, FramePublicAPI, VisualizationSuggestion } from '../../types'; import { DragContext } from '../../drag_drop'; interface ConfigPanelWrapperProps { @@ -16,31 +16,37 @@ interface ConfigPanelWrapperProps { visualizationMap: Record; activeVisualizationId: string | null; dispatch: (action: Action) => void; - datasourcePublicAPI: DatasourcePublicAPI; + framePublicAPI: FramePublicAPI; } -function getSuggestedVisualizationState( - visualization: Visualization, - datasource: DatasourcePublicAPI -) { - const suggestions = visualization.getSuggestions({ - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: datasource.getTableSpec().map(col => ({ - ...col, - operation: datasource.getOperationForColumnId(col.columnId)!, - })), - }, - ], +function getSuggestedVisualizationState(frame: FramePublicAPI, visualization: Visualization) { + const datasources = Object.entries(frame.datasourceLayers); + + let results: VisualizationSuggestion[] = []; + + datasources.forEach(([layerId, datasource]) => { + const suggestions = visualization.getSuggestions({ + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: datasource.getTableSpec().map(col => ({ + ...col, + operation: datasource.getOperationForColumnId(col.columnId)!, + })), + layerId, + }, + ], + }); + + results = results.concat(suggestions); }); - if (!suggestions.length) { - return visualization.initialize(datasource); + if (!results.length) { + return visualization.initialize(frame); } - return visualization.initialize(datasource, suggestions[0].state); + return visualization.initialize(frame, results[0].state); } export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { @@ -66,8 +72,8 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config value={props.activeVisualizationId || undefined} onChange={e => { const newState = getSuggestedVisualizationState( - props.visualizationMap[e.target.value], - props.datasourcePublicAPI + props.framePublicAPI, + props.visualizationMap[e.target.value] ); props.dispatch({ type: 'SWITCH_VISUALIZATION', @@ -83,7 +89,7 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config dragDropContext: context, state: props.visualizationState, setState: setVisualizationState, - datasource: props.datasourcePublicAPI, + frame: props.framePublicAPI, }} /> )} 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 index 8a560e862d607..d0a17c75e1b51 100644 --- 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 @@ -26,9 +26,10 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { props.dispatch({ type: 'UPDATE_DATASOURCE_STATE', newState, + datasourceId: props.activeDatasource!, }); }, - [props.dispatch] + [props.dispatch, props.activeDatasource] ); const datasourceProps: DatasourceDataPanelProps = { 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 index 73ed3330c3393..8dbf80d9afc74 100644 --- 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 @@ -29,12 +29,13 @@ function generateSuggestion(datasourceSuggestionId = 1, state = {}): DatasourceS columns: [], datasourceSuggestionId: 1, isMultiRow: true, + layerId: 'first', }, }; } describe('editor_frame', () => { - let mockVisualization: Visualization; + let mockVisualization: jest.Mocked; let mockDatasource: DatasourceMock; let mockVisualization2: jest.Mocked; @@ -105,7 +106,48 @@ describe('editor_frame', () => { expect(mockDatasource.initialize).not.toHaveBeenCalled(); }); - it('should not render something before datasource is initialized', () => { + 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.initialize).toHaveBeenCalled(); }); - it('should pass the public datasource api into visualization initialize', async () => { + it('should pass the public frame api into visualization initialize', async () => { act(() => { mount( { await waitForPromises(); - expect(mockVisualization.initialize).toHaveBeenCalledWith(mockDatasource.publicAPIMock); + expect(mockVisualization.initialize).toHaveBeenCalledWith({ + datasourceLayers: {}, + addNewLayer: expect.any(Function), + removeLayer: expect.any(Function), + }); + }); + + 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 render data panel after initialization is complete', async () => { @@ -243,6 +317,7 @@ describe('editor_frame', () => { }); it('should render the resulting expression using the expression renderer', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); const instance = mount( { instance.update(); expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` -Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource", - "type": "function", - }, - Object { - "arguments": Object {}, - "function": "vis", - "type": "function", - }, - ], - "type": "expression", -} -`); + Object { + "chain": Array [ + 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.mockReturnValueOnce('datasource2_1'); + mockDatasource2.toExpression.mockReturnValueOnce('datasource2_2'); + mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource2.getLayers.mockReturnValue(['second', 'third']); + const instance = mount( + 'vis' }, + }} + datasourceMap={{ + testDatasource: mockDatasource, + testDatasource2: mockDatasource2, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} + doc={{ + activeDatasourceId: 'testDatasource', + visualizationType: 'testVis', + title: '', + state: { + datasourceStates: { + testDatasource: {}, + testDatasource2: {}, + }, + visualization: {}, + }, + }} + /> + ); + + await waitForPromises(); + + instance.update(); + + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + Object { + "chain": Array [ + 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": "datasource2_1", + "type": "function", + }, + ], + "type": "expression", + }, + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource2_2", + "type": "function", + }, + ], + "type": "expression", + }, + ], + }, + "function": "lens_merge_tables", + "type": "function", + }, + Object { + "arguments": Object {}, + "function": "vis", + "type": "function", + }, + ], + "type": "expression", + } + `); }); }); @@ -357,6 +543,7 @@ Object { }); it('should re-render config panel with updated datasource api after datasource state update', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); mount( { - it('should pass the datasource api to the visualization', async () => { + 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).toHaveBeenCalledWith( - expect.any(Element), - expect.objectContaining({ datasource: mockDatasource.publicAPIMock }) + 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( { + mockDatasource.getLayers.mockReturnValue(['first']); mount( { setDatasourceState(updatedState); }); expect(mockDatasource.getPublicAPI).toHaveBeenLastCalledWith( updatedState, - expect.any(Function) + expect.any(Function), + 'first' ); }); }); describe('switching', () => { let instance: ReactWrapper; + beforeEach(async () => { + mockDatasource.getLayers.mockReturnValue(['first']); instance = mount( [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.5, state: {}, title: 'Suggestion2', previewIcon: 'empty', }, { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.8, state: {}, title: 'Suggestion1', @@ -680,14 +950,14 @@ Object { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.4, state: {}, title: 'Suggestion4', previewIcon: 'empty', }, { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.45, state: {}, title: 'Suggestion3', @@ -732,7 +1002,7 @@ Object { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.8, state: suggestionVisState, title: 'Suggestion1', @@ -788,14 +1058,14 @@ Object { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.2, state: {}, title: 'Suggestion1', previewIcon: 'empty', }, { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.8, state: suggestionVisState, title: 'Suggestion2', @@ -845,14 +1115,14 @@ Object { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.2, state: {}, title: 'Suggestion1', previewIcon: 'empty', }, { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.6, state: {}, title: 'Suggestion2', @@ -864,7 +1134,7 @@ Object { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.8, state: suggestionVisState, title: 'Suggestion3', 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 index 9c1e06f54178b..cdc0f9c19e21e 100644 --- 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 @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useReducer, useMemo } from 'react'; +import React, { useEffect, useReducer } from 'react'; import { EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/data/public'; -import { Datasource, Visualization } from '../../types'; +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'; @@ -18,6 +18,7 @@ import { WorkspacePanel } from './workspace_panel'; import { SavedObjectStore, Document } from '../../persistence/saved_object_store'; import { save } from './save'; import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; +import { generateId } from '../../id_generator'; export interface EditorFrameProps { doc?: Document; @@ -35,28 +36,89 @@ export function EditorFrame(props: EditorFrameProps) { const [state, dispatch] = useReducer(reducer, props, getInitialState); const { onError } = props; - // create public datasource api for current state - // as soon as datasource is available and memoize it - const datasourcePublicAPI = useMemo( - () => - state.datasource.activeId && !state.datasource.isLoading - ? props.datasourceMap[state.datasource.activeId].getPublicAPI( - state.datasource.state, - (newState: unknown) => { + 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', - newState, + newState: datasourceState, + datasourceId, }); - } - ) - : undefined, - [ - props.datasourceMap, - state.datasource.isLoading, - state.datasource.activeId, - state.datasource.state, - ] - ); + }) + .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, + newState, + }); + }, + layer + ); + + datasourceLayers[layer] = publicAPI; + }); + }); + + const framePublicAPI: FramePublicAPI = { + datasourceLayers, + addNewLayer: () => { + const newLayerId = generateId(); + + const newState = props.datasourceMap[state.activeDatasourceId!].insertLayer( + state.datasourceStates[state.activeDatasourceId!].state, + newLayerId + ); + + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + datasourceId: state.activeDatasourceId!, + newState, + }); + + return newLayerId; + }, + removeLayer: (layerId: string) => { + const newState = props.datasourceMap[state.activeDatasourceId!].removeLayer( + state.datasourceStates[state.activeDatasourceId!].state, + layerId + ); + + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + datasourceId: state.activeDatasourceId!, + newState, + }); + }, + }; useEffect(() => { if (props.doc) { @@ -72,137 +134,115 @@ export function EditorFrame(props: EditorFrameProps) { } }, [props.doc]); - // Initialize current datasource - useEffect(() => { - let datasourceGotSwitched = false; - if (state.datasource.isLoading && state.datasource.activeId) { - props.datasourceMap[state.datasource.activeId] - .initialize(props.doc && props.doc.state.datasource) - .then(datasourceState => { - if (!datasourceGotSwitched) { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - newState: datasourceState, - }); - } - }) - .catch(onError); - - return () => { - datasourceGotSwitched = true; - }; - } - }, [props.doc, state.datasource.activeId, state.datasource.isLoading]); - - // Initialize visualization as soon as datasource is ready + // Initialize visualization as soon as all datasources are ready useEffect(() => { - if ( - datasourcePublicAPI && - state.visualization.state === null && - state.visualization.activeId !== null - ) { + if (allLoaded && state.visualization.state === null && state.visualization.activeId !== null) { const initialVisualizationState = props.visualizationMap[ state.visualization.activeId - ].initialize(datasourcePublicAPI); + ].initialize(framePublicAPI); dispatch({ type: 'UPDATE_VISUALIZATION_STATE', newState: initialVisualizationState, }); } - }, [datasourcePublicAPI, state.visualization.activeId, state.visualization.state]); + }, [allLoaded, state.visualization.activeId, state.visualization.state]); - const datasource = - state.datasource.activeId && !state.datasource.isLoading - ? props.datasourceMap[state.datasource.activeId] + 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 (datasource) { - return ( - - { - if (datasource && visualization) { - save({ - datasource, - dispatch, - visualization, - state, - redirectTo: props.redirectTo, - store: props.store, - }).catch(onError); - } - }} - disabled={state.saving || !state.datasource.activeId || !state.visualization.activeId} - > - {i18n.translate('xpack.lens.editorFrame.Save', { - defaultMessage: 'Save', - })} - - - } - dataPanel={ - - } - configPanel={ + return ( + + { + if (activeDatasource && visualization) { + save({ + activeDatasources: Object.keys(state.datasourceStates).reduce( + (datasourceMap, datasourceId) => ({ + ...datasourceMap, + [datasourceId]: props.datasourceMap[datasourceId], + }), + {} + ), + dispatch, + visualization, + state, + redirectTo: props.redirectTo, + store: props.store, + }).catch(onError); + } + }} + disabled={state.saving || !state.activeDatasourceId || !state.visualization.activeId} + > + {i18n.translate('xpack.lens.editorFrame.Save', { + defaultMessage: 'Save', + })} + + + } + dataPanel={ + + } + configPanel={ + allLoaded && ( - } - workspacePanel={ + ) + } + workspacePanel={ + allLoaded && ( - } - suggestionsPanel={ + ) + } + suggestionsPanel={ + allLoaded && ( - } - /> - ); - } - - return ( - + ) } /> ); 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 index 8566035c29bf2..3cc5beed0669b 100644 --- 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 @@ -4,48 +4,84 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Ast, fromExpression } from '@kbn/interpreter/common'; -import { Visualization, Datasource, DatasourcePublicAPI } from '../../types'; +import { Ast, fromExpression, ExpressionFunctionAST } from '@kbn/interpreter/common'; +import { Visualization, Datasource, FramePublicAPI } from '../../types'; export function prependDatasourceExpression( visualizationExpression: Ast | string | null, - datasource: Datasource, - datasourceState: unknown + datasourceMap: Record, + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + > ): Ast | null { - const datasourceExpression = datasource.toExpression(datasourceState); + const datasourceExpressions: Array<[string, Ast | string]> = []; - if (datasourceExpression === null || visualizationExpression === null) { + 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 parsedDatasourceExpression = - typeof datasourceExpression === 'string' - ? fromExpression(datasourceExpression) - : datasourceExpression; const parsedVisualizationExpression = typeof visualizationExpression === 'string' ? fromExpression(visualizationExpression) : visualizationExpression; + return { type: 'expression', - chain: [...parsedDatasourceExpression.chain, ...parsedVisualizationExpression.chain], + chain: [datafetchExpression, ...parsedVisualizationExpression.chain], }; } -export function buildExpression( - visualization: Visualization | null, - visualizationState: unknown, - datasource: Datasource, - datasourceState: unknown, - datasourcePublicAPI: DatasourcePublicAPI -): Ast | null { +export function buildExpression({ + visualization, + visualizationState, + datasourceMap, + datasourceStates, + framePublicAPI, +}: { + visualization: Visualization | null; + visualizationState: unknown; + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; + framePublicAPI: FramePublicAPI; +}): Ast | null { if (visualization === null) { return null; } - const visualizationExpression = visualization.toExpression( - visualizationState, - datasourcePublicAPI - ); + const visualizationExpression = visualization.toExpression(visualizationState, framePublicAPI); - return prependDatasourceExpression(visualizationExpression, datasource, datasourceState); + return prependDatasourceExpression(visualizationExpression, datasourceMap, datasourceStates); } 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 index 16590b36db86c..455d47f47ea74 100644 --- 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 @@ -18,6 +18,7 @@ .lnsPageMainContent { display: flex; overflow: auto; + flex-grow: 1; } .lnsSidebar { 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 index ef4eed2b63a5d..fd93b005572fa 100644 --- 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 @@ -6,16 +6,27 @@ import { save, Props } from './save'; import { Action } from './state_management'; +import { createMockDatasource } from '../mocks'; describe('save editor frame state', () => { const saveArgs: Props = { dispatch: jest.fn(), redirectTo: jest.fn(), - datasource: { getPersistableState: x => x }, + activeDatasources: { + indexpattern: { + getPersistableState: x => x, + }, + }, visualization: { getPersistableState: x => x }, state: { title: 'aaa', - datasource: { activeId: '1', isLoading: false, state: {} }, + datasourceStates: { + indexpattern: { + state: 'hello', + isLoading: false, + }, + }, + activeDatasourceId: 'indexpattern', saving: false, visualization: { activeId: '2', state: {} }, }, @@ -34,19 +45,13 @@ describe('save editor frame state', () => { (action.type === 'SAVING' && action.isSaving && saved) || (action.type === 'SAVING' && !action.isSaving && !saved) ) { - throw new Error('Saving status was incorrectly set'); + throw new Error('Saving status was incorrectly set' + action.isSaving + ' ' + saved); } }); await save({ ...saveArgs, dispatch, - state: { - title: 'aaa', - datasource: { activeId: '1', isLoading: false, state: {} }, - saving: false, - visualization: { activeId: '2', state: {} }, - }, store: { async save() { saved = true; @@ -66,12 +71,6 @@ describe('save editor frame state', () => { save({ ...saveArgs, dispatch, - state: { - title: 'aaa', - datasource: { activeId: '1', isLoading: false, state: {} }, - saving: false, - visualization: { activeId: '2', state: {} }, - }, store: { async save() { throw new Error('aw shnap!'); @@ -88,22 +87,30 @@ describe('save editor frame state', () => { const store = { save: jest.fn(async () => ({ id: 'bar' })), }; + const datasource = createMockDatasource(); + datasource.getPersistableState.mockImplementation(state => ({ + stuff: `${state}_datasource_persisted`, + })); + await save({ ...saveArgs, - store, - datasource: { - getPersistableState(state) { - return { - stuff: `${state}_datsource_persisted`, - }; - }, + activeDatasources: { + indexpattern: datasource, }, + store, state: { title: 'bbb', - datasource: { activeId: '1', isLoading: false, state: '2' }, + datasourceStates: { + indexpattern: { + state: '2', + isLoading: false, + }, + }, + activeDatasourceId: 'indexpattern', saving: false, visualization: { activeId: '3', state: '4' }, }, + visualization: { getPersistableState(state) { return { @@ -114,10 +121,14 @@ describe('save editor frame state', () => { }); expect(store.save).toHaveBeenCalledWith({ - datasourceType: '1', + activeDatasourceId: 'indexpattern', id: undefined, state: { - datasource: { stuff: '2_datsource_persisted' }, + datasourceStates: { + indexpattern: { + stuff: '2_datasource_persisted', + }, + }, visualization: { things: '4_vis_persisted' }, }, title: 'bbb', @@ -135,7 +146,13 @@ describe('save editor frame state', () => { redirectTo, state: { title: 'ccc', - datasource: { activeId: '1', isLoading: false, state: {} }, + datasourceStates: { + indexpattern: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'indexpattern', saving: false, visualization: { activeId: '2', state: {} }, }, @@ -159,7 +176,13 @@ describe('save editor frame state', () => { redirectTo, state: { title: 'ddd', - datasource: { activeId: '1', isLoading: false, state: {} }, + datasourceStates: { + indexpattern: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'indexpattern', persistedId: 'foo', saving: false, visualization: { activeId: '2', state: {} }, 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 index 472220e83a44e..723a193750e10 100644 --- 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 @@ -8,7 +8,7 @@ import { Action, EditorFrameState } from './state_management'; import { Document } from '../../persistence/saved_object_store'; export interface Props { - datasource: { getPersistableState: (state: unknown) => unknown }; + activeDatasources: Record unknown }>; dispatch: (value: Action) => void; redirectTo: (path: string) => void; state: EditorFrameState; @@ -17,7 +17,7 @@ export interface Props { } export async function save({ - datasource, + activeDatasources, dispatch, redirectTo, state, @@ -27,14 +27,19 @@ export async function save({ try { dispatch({ type: 'SAVING', isSaving: true }); + const datasourceStates: Record = {}; + Object.entries(activeDatasources).forEach(([id, datasource]) => { + datasourceStates[id] = datasource.getPersistableState(state.datasourceStates[id].state); + }); + const doc = await store.save({ id: state.persistedId, title: state.title, type: 'lens', visualizationType: state.visualization.activeId, - datasourceType: state.datasource.activeId, + activeDatasourceId: state.activeDatasourceId!, state: { - datasource: datasource.getPersistableState(state.datasource.state), + datasourceStates, visualization: visualization.getPersistableState(state.visualization.state), }, }); 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 index 5f1861269dc0e..b7b44bbb16688 100644 --- 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 @@ -31,7 +31,7 @@ describe('editor_frame state management', () => { it('should store initial datasource and visualization', () => { const initialState = getInitialState(props); - expect(initialState.datasource.activeId).toEqual('testDatasource'); + expect(initialState.activeDatasourceId).toEqual('testDatasource'); expect(initialState.visualization.activeId).toEqual('testVis'); }); @@ -43,6 +43,47 @@ describe('editor_frame state management', () => { expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled(); }); + it('should prefill state if doc is passed in', () => { + const initialState = getInitialState({ + ...props, + doc: { + activeDatasourceId: 'testDatasource', + state: { + datasourceStates: { + testDatasource: { internalState1: '' }, + testDatasource2: { internalState2: '' }, + }, + visualization: {}, + }, + 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 }); @@ -57,11 +98,13 @@ describe('editor_frame state management', () => { const newVisState = {}; const newState = reducer( { - datasource: { - activeId: 'testDatasource', - state: {}, - isLoading: false, + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, }, + activeDatasourceId: 'testDatasource', saving: false, title: 'aaa', visualization: { @@ -82,11 +125,13 @@ describe('editor_frame state management', () => { const newDatasourceState = {}; const newState = reducer( { - datasource: { - activeId: 'testDatasource', - state: {}, - isLoading: false, + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, }, + activeDatasourceId: 'testDatasource', saving: false, title: 'bbb', visualization: { @@ -97,10 +142,11 @@ describe('editor_frame state management', () => { { type: 'UPDATE_DATASOURCE_STATE', newState: newDatasourceState, + datasourceId: 'testDatasource', } ); - expect(newState.datasource.state).toBe(newDatasourceState); + expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState); }); it('should should switch active visualization', () => { @@ -108,11 +154,13 @@ describe('editor_frame state management', () => { const newVisState = {}; const newState = reducer( { - datasource: { - activeId: 'testDatasource', - state: {}, - isLoading: false, + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, }, + activeDatasourceId: 'testDatasource', saving: false, title: 'ccc', visualization: { @@ -136,11 +184,13 @@ describe('editor_frame state management', () => { const newDatasourceState = {}; const newState = reducer( { - datasource: { - activeId: 'testDatasource', - state: {}, - isLoading: false, + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, }, + activeDatasourceId: 'testDatasource', saving: false, title: 'ddd', visualization: { @@ -157,17 +207,51 @@ describe('editor_frame state management', () => { ); expect(newState.visualization.state).toBe(newVisState); - expect(newState.datasource.state).toBe(newDatasourceState); + expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState); }); - it('should should switch active datasource and purge visualization state', () => { + it('should should switch active datasource and initialize new state', () => { const newState = reducer( { - datasource: { - activeId: 'testDatasource', + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'testDatasource', + saving: false, + title: 'eee', + visualization: { + activeId: 'testVis', state: {}, - isLoading: false, }, + }, + { + 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', saving: false, title: 'eee', visualization: { @@ -181,20 +265,20 @@ describe('editor_frame state management', () => { } ); - expect(newState.visualization.state).toEqual(null); - expect(newState.visualization.activeId).toBe(null); - expect(newState.datasource.activeId).toBe('testDatasource2'); - expect(newState.datasource.state).toBe(null); + expect(newState.activeDatasourceId).toEqual('testDatasource2'); + expect(newState.datasourceStates.testDatasource2.state).toBe(datasource2State); }); it('should mark as saving', () => { const newState = reducer( { - datasource: { - activeId: 'a', - state: {}, - isLoading: false, + datasourceStates: { + a: { + state: {}, + isLoading: false, + }, }, + activeDatasourceId: 'a', saving: false, title: 'fff', visualization: { @@ -214,11 +298,13 @@ describe('editor_frame state management', () => { it('should mark as saved', () => { const newState = reducer( { - datasource: { - activeId: 'a', - state: {}, - isLoading: false, + datasourceStates: { + a: { + state: {}, + isLoading: false, + }, }, + activeDatasourceId: 'a', saving: false, title: 'hhh', visualization: { @@ -238,11 +324,13 @@ describe('editor_frame state management', () => { it('should change the persisted id', () => { const newState = reducer( { - datasource: { - activeId: 'a', - state: {}, - isLoading: false, + datasourceStates: { + a: { + state: {}, + isLoading: false, + }, }, + activeDatasourceId: 'a', saving: false, title: 'iii', visualization: { @@ -262,11 +350,13 @@ describe('editor_frame state management', () => { it('should reset the state', () => { const newState = reducer( { - datasource: { - activeId: 'a', - state: {}, - isLoading: false, + datasourceStates: { + a: { + state: {}, + isLoading: false, + }, }, + activeDatasourceId: 'a', saving: false, title: 'jjj', visualization: { @@ -277,11 +367,13 @@ describe('editor_frame state management', () => { { type: 'RESET', state: { - datasource: { - activeId: 'z', - isLoading: false, - state: { hola: 'muchacho' }, + datasourceStates: { + z: { + isLoading: false, + state: { hola: 'muchacho' }, + }, }, + activeDatasourceId: 'z', persistedId: 'bar', saving: false, title: 'lll', @@ -294,11 +386,13 @@ describe('editor_frame state management', () => { ); expect(newState).toMatchObject({ - datasource: { - activeId: 'z', - isLoading: false, - state: { hola: 'muchacho' }, + datasourceStates: { + z: { + isLoading: false, + state: { hola: 'muchacho' }, + }, }, + activeDatasourceId: 'z', persistedId: 'bar', saving: false, visualization: { @@ -311,11 +405,13 @@ describe('editor_frame state management', () => { it('should load the state from the doc', () => { const newState = reducer( { - datasource: { - activeId: 'a', - state: {}, - isLoading: false, + datasourceStates: { + a: { + state: {}, + isLoading: false, + }, }, + activeDatasourceId: 'a', saving: false, title: 'mmm', visualization: { @@ -326,10 +422,10 @@ describe('editor_frame state management', () => { { type: 'VISUALIZATION_LOADED', doc: { - datasourceType: 'a', id: 'b', + activeDatasourceId: 'a', state: { - datasource: { foo: 'c' }, + datasourceStates: { a: { foo: 'c' } }, visualization: { bar: 'd' }, }, title: 'heyo!', @@ -340,11 +436,13 @@ describe('editor_frame state management', () => { ); expect(newState).toEqual({ - datasource: { - activeId: 'a', - isLoading: true, - state: { - foo: 'c', + activeDatasourceId: 'a', + datasourceStates: { + a: { + isLoading: true, + state: { + foo: 'c', + }, }, }, persistedId: 'b', 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 index df56544e5c134..004bbc45194a1 100644 --- 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 @@ -16,11 +16,8 @@ export interface EditorFrameState { activeId: string | null; state: unknown; }; - datasource: { - activeId: string | null; - state: unknown; - isLoading: boolean; - }; + datasourceStates: Record; + activeDatasourceId: string | null; } export type Action = @@ -43,6 +40,7 @@ export type Action = | { type: 'UPDATE_DATASOURCE_STATE'; newState: unknown; + datasourceId: string; } | { type: 'UPDATE_VISUALIZATION_STATE'; @@ -64,14 +62,24 @@ export type Action = }; 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 { saving: false, title: i18n.translate('xpack.lens.chartTitle', { defaultMessage: 'New visualization' }), - datasource: { - state: null, - isLoading: Boolean(props.initialDatasourceId), - activeId: props.initialDatasourceId, - }, + datasourceStates, + activeDatasourceId: props.initialDatasourceId ? props.initialDatasourceId : null, visualization: { state: null, activeId: props.initialVisualizationId, @@ -94,12 +102,17 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta ...state, persistedId: action.doc.id, title: action.doc.title, - datasource: { - ...state.datasource, - activeId: action.doc.datasourceType || null, - isLoading: true, - state: action.doc.state.datasource, - }, + datasourceStates: Object.entries(action.doc.state.datasourceStates).reduce( + (stateMap, [datasourceId, datasourceState]) => ({ + ...stateMap, + [datasourceId]: { + isLoading: true, + state: datasourceState, + }, + }), + {} + ), + activeDatasourceId: action.doc.activeDatasourceId, visualization: { ...state.visualization, activeId: action.doc.visualizationType, @@ -109,40 +122,40 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta case 'SWITCH_DATASOURCE': return { ...state, - datasource: { - ...state.datasource, - isLoading: true, - state: null, - activeId: action.newDatasourceId, - }, - visualization: { - ...state.visualization, - // purge visualization on datasource switch - state: null, - activeId: null, + datasourceStates: { + ...state.datasourceStates, + [action.newDatasourceId]: state.datasourceStates[action.newDatasourceId] || { + state: null, + isLoading: true, + }, }, + activeDatasourceId: action.newDatasourceId, }; case 'SWITCH_VISUALIZATION': return { ...state, + datasourceStates: + state.activeDatasourceId && action.datasourceState + ? { + ...state.datasourceStates, + [state.activeDatasourceId]: { state: action.datasourceState, isLoading: false }, + } + : state.datasourceStates, visualization: { ...state.visualization, activeId: action.newVisualizationId, state: action.initialState, }, - datasource: { - ...state.datasource, - state: action.datasourceState ? action.datasourceState : state.datasource.state, - }, }; case 'UPDATE_DATASOURCE_STATE': return { ...state, - datasource: { - ...state.datasource, - // when the datasource state is updated, the initialization is complete - isLoading: false, - state: action.newState, + datasourceStates: { + ...state.datasourceStates, + [action.datasourceId]: { + state: action.newState, + isLoading: false, + }, }, }; case 'UPDATE_VISUALIZATION_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 index ac30bc2adae3e..a1408d9851398 100644 --- 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 @@ -6,11 +6,15 @@ import { getSuggestions } from './suggestion_helpers'; import { createMockVisualization } from '../mocks'; -import { TableSuggestion } from '../../types'; +import { TableSuggestion, DatasourceSuggestion } from '../../types'; -const generateSuggestion = (datasourceSuggestionId: number = 1, state = {}) => ({ +const generateSuggestion = ( + datasourceSuggestionId: number = 1, + state = {}, + layerId: string = 'first' +): DatasourceSuggestion => ({ state, - table: { datasourceSuggestionId, columns: [], isMultiRow: false }, + table: { datasourceSuggestionId, columns: [], isMultiRow: false, layerId }, }); describe('suggestion helpers', () => { @@ -24,7 +28,7 @@ describe('suggestion helpers', () => { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.5, title: 'Test', state: suggestedState, @@ -50,14 +54,14 @@ describe('suggestion helpers', () => { ...mockVisualization1, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.5, title: 'Test', state: {}, previewIcon: 'empty', }, { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.5, title: 'Test2', state: {}, @@ -69,7 +73,7 @@ describe('suggestion helpers', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.5, title: 'Test3', state: {}, @@ -94,14 +98,14 @@ describe('suggestion helpers', () => { ...mockVisualization1, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.2, title: 'Test', state: {}, previewIcon: 'empty', }, { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.8, title: 'Test2', state: {}, @@ -113,7 +117,7 @@ describe('suggestion helpers', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.6, title: 'Test3', state: {}, @@ -133,8 +137,18 @@ describe('suggestion helpers', () => { it('should call all suggestion getters with all available data tables', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); - const table1: TableSuggestion = { datasourceSuggestionId: 0, columns: [], isMultiRow: true }; - const table2: TableSuggestion = { datasourceSuggestionId: 1, columns: [], isMultiRow: true }; + const table1: TableSuggestion = { + datasourceSuggestionId: 0, + columns: [], + isMultiRow: true, + layerId: 'first', + }; + const table2: TableSuggestion = { + datasourceSuggestionId: 1, + columns: [], + isMultiRow: true, + layerId: 'first', + }; getSuggestions( [{ state: {}, table: table1 }, { state: {}, table: table2 }], { @@ -156,20 +170,20 @@ describe('suggestion helpers', () => { const tableState1 = {}; const tableState2 = {}; const suggestions = getSuggestions( - [generateSuggestion(1, tableState1), generateSuggestion(1, tableState2)], + [generateSuggestion(1, tableState1), generateSuggestion(2, tableState2)], { vis1: { ...mockVisualization1, getSuggestions: () => [ { - datasourceSuggestionId: 0, + datasourceSuggestionId: 1, score: 0.3, title: 'Test', state: {}, previewIcon: 'empty', }, { - datasourceSuggestionId: 1, + datasourceSuggestionId: 2, score: 0.2, title: 'Test2', state: {}, @@ -181,7 +195,7 @@ describe('suggestion helpers', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 2, score: 0.1, title: 'Test3', state: {}, 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 index a9886387c6c74..f2d1db4eb636c 100644 --- 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 @@ -5,7 +5,7 @@ */ import { Ast } from '@kbn/interpreter/common'; -import { Visualization, DatasourceSuggestion } from '../../types'; +import { Visualization, DatasourceSuggestion, TableSuggestion } from '../../types'; import { Action } from './state_management'; export interface Suggestion { @@ -32,26 +32,26 @@ export function getSuggestions( activeVisualizationId: string | null, visualizationState: unknown ): Suggestion[] { - const datasourceTables = datasourceTableSuggestions.map(({ table }) => table); + const datasourceTables: TableSuggestion[] = datasourceTableSuggestions.map(({ table }) => table); - return ( - Object.entries(visualizationMap) - .map(([visualizationId, visualization]) => { - return visualization - .getSuggestions({ - tables: datasourceTables, - state: visualizationId === activeVisualizationId ? visualizationState : undefined, - }) - .map(({ datasourceSuggestionId, ...suggestion }) => ({ - ...suggestion, - visualizationId, - datasourceState: datasourceTableSuggestions[datasourceSuggestionId].state, - })); - }) - // TODO why is flatMap not available here? - .reduce((globalList, currentList) => [...globalList, ...currentList], []) - .sort(({ score: scoreA }, { score: scoreB }) => scoreB - scoreA) - ); + return Object.entries(visualizationMap) + .map(([visualizationId, visualization]) => { + return visualization + .getSuggestions({ + tables: datasourceTables, + state: visualizationId === activeVisualizationId ? visualizationState : undefined, + }) + .map(({ datasourceSuggestionId, ...suggestion }) => ({ + ...suggestion, + visualizationId, + datasourceState: datasourceTableSuggestions.find( + datasourceSuggestion => + datasourceSuggestion.table.datasourceSuggestionId === datasourceSuggestionId + )!.state, + })); + }) + .reduce((globalList, currentList) => [...globalList, ...currentList], []) + .sort(({ score: scoreA }, { score: scoreB }) => scoreB - scoreA); } export function toSwitchAction(suggestion: Suggestion): Action { 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 index e3409a8eca7e9..effadb27ddb43 100644 --- 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 @@ -59,8 +59,16 @@ describe('suggestion_panel', () => { ] as Suggestion[]); defaultProps = { - activeDatasource: mockDatasource, - datasourceState: {}, + activeDatasourceId: 'mock', + datasourceMap: { + mock: mockDatasource, + }, + datasourceStates: { + mock: { + isLoading: false, + state: {}, + }, + }, activeVisualizationId: 'vis', visualizationMap: { vis: mockVisualization, @@ -97,6 +105,7 @@ describe('suggestion_panel', () => { }); it('should render preview expression if there is one', () => { + mockDatasource.getLayers.mockReturnValue(['first']); (getSuggestions as jest.Mock).mockReturnValue([ { datasourceState: {}, @@ -126,30 +135,47 @@ describe('suggestion_panel', () => { (expressionRendererMock as jest.Mock).mock.calls[0][0].expression ); expect(passedExpression).toMatchInlineSnapshot(` -Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource_expression", - "type": "function", - }, - Object { - "arguments": Object {}, - "function": "test", - "type": "function", - }, - Object { - "arguments": Object {}, - "function": "expression", - "type": "function", - }, - ], - "type": "expression", -} -`); + Object { + "chain": Array [ + 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: {}, 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 index e0ae92e6bb5c3..7d25fa64b2f8f 100644 --- 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 @@ -17,8 +17,15 @@ import { prependDatasourceExpression } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; export interface SuggestionPanelProps { - activeDatasource: Datasource; - datasourceState: unknown; + activeDatasourceId: string | null; + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; activeVisualizationId: string | null; visualizationMap: Record; visualizationState: unknown; @@ -90,17 +97,21 @@ const SuggestionPreview = ({ export const SuggestionPanel = debouncedComponent(InnerSuggestionPanel, 2000); function InnerSuggestionPanel({ - activeDatasource, - datasourceState, + activeDatasourceId, + datasourceMap, + datasourceStates, activeVisualizationId, visualizationMap, visualizationState, dispatch, ExpressionRenderer: ExpressionRendererComponent, }: SuggestionPanelProps) { - const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsFromCurrentState( - datasourceState - ); + if (!activeDatasourceId) { + return null; + } + const datasourceSuggestions = datasourceMap[ + activeDatasourceId + ].getDatasourceSuggestionsFromCurrentState(datasourceStates[activeDatasourceId].state); const suggestions = getSuggestions( datasourceSuggestions, @@ -123,8 +134,8 @@ function InnerSuggestionPanel({ const previewExpression = suggestion.previewExpression ? prependDatasourceExpression( suggestion.previewExpression, - activeDatasource, - suggestion.datasourceState + datasourceMap, + datasourceStates ) : null; return ( 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 index 5dc613baf0176..85054d8f7c702 100644 --- 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 @@ -13,11 +13,13 @@ import { 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 } from '../../drag_drop'; +import { Ast } from '@kbn/interpreter/common'; const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); @@ -44,14 +46,15 @@ describe('workspace_panel', () => { it('should render an explanatory text if no visualization is active', () => { instance = mount( {}} ExpressionRenderer={expressionRendererMock} /> @@ -64,14 +67,15 @@ describe('workspace_panel', () => { it('should render an explanatory text if the visualization does not produce an expression', () => { instance = mount( 'datasource' }} - datasourceState={{}} + activeDatasourceId={'mock'} + datasourceStates={{}} + datasourceMap={{}} + framePublicAPI={createMockFramePublicAPI()} activeVisualizationId="vis" visualizationMap={{ vis: { ...mockVisualization, toExpression: () => null }, }} visualizationState={{}} - datasourcePublicAPI={mockDatasource.publicAPIMock} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} /> @@ -84,14 +88,15 @@ describe('workspace_panel', () => { it('should render an explanatory text if the datasource does not produce an expression', () => { instance = mount( null }} - datasourceState={{}} + activeDatasourceId={'mock'} + datasourceStates={{}} + datasourceMap={{}} + framePublicAPI={createMockFramePublicAPI()} activeVisualizationId="vis" visualizationMap={{ vis: { ...mockVisualization, toExpression: () => 'vis' }, }} visualizationState={{}} - datasourcePublicAPI={mockDatasource.publicAPIMock} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} /> @@ -102,58 +107,180 @@ describe('workspace_panel', () => { }); 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( 'datasource', + activeDatasourceId={'mock'} + datasourceStates={{ + mock: { + state: {}, + isLoading: false, + }, + }} + datasourceMap={{ + mock: mockDatasource, }} - datasourceState={{}} + framePublicAPI={framePublicAPI} activeVisualizationId="vis" visualizationMap={{ vis: { ...mockVisualization, toExpression: () => 'vis' }, }} visualizationState={{}} - datasourcePublicAPI={mockDatasource.publicAPIMock} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} /> ); expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` -Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource", - "type": "function", - }, - Object { - "arguments": Object {}, - "function": "vis", - "type": "function", - }, - ], - "type": "expression", -} -`); + Object { + "chain": Array [ + 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[0].arguments.layerIds + ).toEqual(['first', 'second', 'third']); + expect( + (instance.find(expressionRendererMock).prop('expression') as Ast).chain[0].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", + }, + ] + `); }); 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( 'datasource ||', + activeDatasourceId={'mock'} + datasourceStates={{ + mock: { + state: {}, + isLoading: false, + }, }} - datasourceState={{}} + datasourceMap={{ + mock: mockDatasource, + }} + framePublicAPI={framePublicAPI} activeVisualizationId="vis" visualizationMap={{ vis: { ...mockVisualization, toExpression: () => 'vis' }, }} visualizationState={{}} - datasourcePublicAPI={mockDatasource.publicAPIMock} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} /> @@ -164,6 +291,12 @@ Object { }); 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 ; @@ -171,17 +304,22 @@ Object { instance = mount( 'datasource', + activeDatasourceId={'mock'} + datasourceStates={{ + mock: { + state: {}, + isLoading: false, + }, + }} + datasourceMap={{ + mock: mockDatasource, }} - datasourceState={{}} + framePublicAPI={framePublicAPI} activeVisualizationId="vis" visualizationMap={{ vis: { ...mockVisualization, toExpression: () => 'vis' }, }} visualizationState={{}} - datasourcePublicAPI={mockDatasource.publicAPIMock} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} /> @@ -197,6 +335,12 @@ Object { }); 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 ; @@ -204,17 +348,22 @@ Object { instance = mount( 'datasource', + activeDatasourceId={'mock'} + datasourceStates={{ + mock: { + state: {}, + isLoading: false, + }, + }} + datasourceMap={{ + mock: mockDatasource, }} - datasourceState={{}} + framePublicAPI={framePublicAPI} activeVisualizationId="vis" visualizationMap={{ vis: { ...mockVisualization, toExpression: () => 'vis' }, }} visualizationState={{}} - datasourcePublicAPI={mockDatasource.publicAPIMock} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} /> @@ -233,6 +382,12 @@ Object { }); 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 ; @@ -240,17 +395,22 @@ Object { instance = mount( 'datasource', + activeDatasourceId={'mock'} + datasourceStates={{ + mock: { + state: {}, + isLoading: false, + }, + }} + datasourceMap={{ + mock: mockDatasource, }} - datasourceState={{}} + framePublicAPI={framePublicAPI} activeVisualizationId="vis" visualizationMap={{ vis: { ...mockVisualization, toExpression: () => 'vis' }, }} visualizationState={{}} - datasourcePublicAPI={mockDatasource.publicAPIMock} dispatch={() => {}} ExpressionRenderer={expressionRendererMock} /> @@ -283,14 +443,22 @@ Object { mockDispatch = jest.fn(); instance = mount( @@ -301,6 +469,7 @@ Object { const expectedTable = { datasourceSuggestionId: 0, isMultiRow: true, + layerId: '1', columns: [], }; mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ @@ -348,6 +517,7 @@ Object { datasourceSuggestionId: 0, isMultiRow: true, columns: [], + layerId: '1', }, }, { @@ -356,6 +526,7 @@ Object { datasourceSuggestionId: 1, isMultiRow: true, columns: [], + layerId: '1', }, }, ]); 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 index 420e11a867ebe..84c3ac25503fc 100644 --- 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 @@ -10,19 +10,26 @@ import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; import { toExpression } from '@kbn/interpreter/common'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/data/public'; import { Action } from './state_management'; -import { Datasource, Visualization, DatasourcePublicAPI } from '../../types'; +import { Datasource, Visualization, FramePublicAPI } from '../../types'; import { DragDrop, DragContext } from '../../drag_drop'; import { getSuggestions, toSwitchAction } from './suggestion_helpers'; import { buildExpression } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; export interface WorkspacePanelProps { - activeDatasource: Datasource; - datasourceState: unknown; activeVisualizationId: string | null; visualizationMap: Record; visualizationState: unknown; - datasourcePublicAPI: DatasourcePublicAPI; + activeDatasourceId: string | null; + datasourceMap: Record; + datasourceStates: Record< + string, + { + state: unknown; + isLoading: boolean; + } + >; + framePublicAPI: FramePublicAPI; dispatch: (action: Action) => void; ExpressionRenderer: ExpressionRenderer; } @@ -31,21 +38,24 @@ export const WorkspacePanel = debouncedComponent(InnerWorkspacePanel); // Exported for testing purposes only. export function InnerWorkspacePanel({ - activeDatasource, + activeDatasourceId, activeVisualizationId, - datasourceState, visualizationMap, visualizationState, - datasourcePublicAPI, + datasourceMap, + datasourceStates, + framePublicAPI, dispatch, ExpressionRenderer: ExpressionRendererComponent, }: WorkspacePanelProps) { const dragDropContext = useContext(DragContext); function onDrop(item: unknown) { - const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsForField( - datasourceState, - item - ); + if (!activeDatasourceId) { + return; + } + const datasourceSuggestions = datasourceMap[ + activeDatasourceId + ].getDatasourceSuggestionsForField(datasourceStates[activeDatasourceId].state, item); const suggestions = getSuggestions( datasourceSuggestions, @@ -84,23 +94,17 @@ export function InnerWorkspacePanel({ : null; const expression = useMemo(() => { try { - return buildExpression( - activeVisualization, + return buildExpression({ + visualization: activeVisualization, visualizationState, - activeDatasource, - datasourceState, - datasourcePublicAPI - ); + datasourceMap, + datasourceStates, + framePublicAPI, + }); } catch (e) { setExpressionError(e.toString()); } - }, [ - activeVisualization, - visualizationState, - activeDatasource, - datasourceState, - datasourcePublicAPI, - ]); + }, [activeVisualization, visualizationState, datasourceMap, datasourceStates]); useEffect(() => { // reset expression error if component attempts to run it again 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..2769f1e1201b4 --- /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/types'; + +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..c7747ace106fd --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/merge_tables.ts @@ -0,0 +1,52 @@ +/* + * 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 { LensMultiTable, KibanaDatatable } 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 index 6cf0223191f63..225b9802f113d 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -6,16 +6,16 @@ import React from 'react'; import { DataSetup, ExpressionRendererProps } from 'src/legacy/core_plugins/data/public'; -import { DatasourcePublicAPI, Visualization, Datasource } from '../types'; +import { DatasourcePublicAPI, FramePublicAPI, Visualization, Datasource } from '../types'; import { EditorFrameSetupPlugins } from './plugin'; export function createMockVisualization(): jest.Mocked { return { getPersistableState: jest.fn(_state => ({})), getSuggestions: jest.fn(_options => []), - initialize: jest.fn((_datasource, _state?) => ({})), + initialize: jest.fn((_frame, _state?) => ({})), renderConfigPanel: jest.fn(), - toExpression: jest.fn((_state, _datasource) => null), + toExpression: jest.fn((_state, _frame) => null), }; } @@ -28,6 +28,7 @@ export function createMockDatasource(): DatasourceMock { getTableSpec: jest.fn(() => []), getOperationForColumnId: jest.fn(), renderDimensionPanel: jest.fn(), + renderLayerPanel: jest.fn(), removeColumnInTableSpec: jest.fn(), moveColumnTo: jest.fn(), duplicateColumn: jest.fn(), @@ -37,10 +38,13 @@ export function createMockDatasource(): DatasourceMock { getDatasourceSuggestionsForField: jest.fn((_state, item) => []), getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []), getPersistableState: jest.fn(), - getPublicAPI: jest.fn((_state, _setState) => publicAPIMock), + getPublicAPI: jest.fn((_state, _setState, _layerId) => publicAPIMock), initialize: jest.fn((_state?) => Promise.resolve()), renderDataPanel: jest.fn(), - toExpression: jest.fn(_state => null), + toExpression: jest.fn((_frame, _state) => null), + insertLayer: jest.fn((_state, _newLayerId) => {}), + removeLayer: jest.fn((_state, _layerId) => {}), + getLayers: jest.fn(_state => []), // 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 @@ -48,6 +52,16 @@ export function createMockDatasource(): DatasourceMock { }; } +export type FrameMock = jest.Mocked; + +export function createMockFramePublicAPI(): FrameMock { + return { + datasourceLayers: {}, + addNewLayer: jest.fn(() => ''), + removeLayer: jest.fn(), + }; +} + type Omit = Pick>; export type MockedDependencies = Omit & { @@ -69,5 +83,10 @@ export function createMockDependencies() { run: jest.fn(_ => Promise.resolve({ type: 'render', as: 'test', value: undefined })), }, }, + interpreter: { + functionsRegistry: { + register: jest.fn(), + }, + }, } as unknown) as MockedDependencies; } 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 index ae169aa67148e..93ac1475b795e 100644 --- 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 @@ -19,7 +19,8 @@ jest.mock('ui/chrome', () => ({ getSavedObjectsClient: jest.fn(), })); -// mock away actual data plugin to prevent all of it being loaded +// 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/setup', () => {}); function mockStore(): SavedObjectStore { @@ -76,9 +77,14 @@ describe('editor_frame plugin', () => { it('should load the document, if persistedId is defined', async () => { const doc: Document = { - datasourceType: 'indexpattern', id: 'hoi', - state: { datasource: 'foo', visualization: 'bar' }, + activeDatasourceId: 'indexpattern', + state: { + datasourceStates: { + indexpattern: 'foo', + }, + visualization: 'bar', + }, title: 'shazm', visualizationType: 'fanci', type: 'lens', @@ -184,9 +190,14 @@ describe('editor_frame plugin', () => { const component = mount( , + ExpressionFunction + >; +} export interface EditorFrameSetupPlugins { data: DataSetup; + interpreter: InterpreterSetup; } interface InitializationResult { @@ -113,6 +125,8 @@ export class EditorFramePlugin { } public setup(_core: CoreSetup | null, plugins: EditorFrameSetupPlugins): EditorFrameSetup { + plugins.interpreter.functionsRegistry.register(() => mergeTables); + this.ExpressionRenderer = plugins.data.expressions.ExpressionRenderer; return { createInstance: this.createInstance.bind(this), @@ -135,6 +149,9 @@ const editorFrame = new EditorFramePlugin(); export const editorFrameSetup = () => editorFrame.setup(null, { data, + interpreter: { + functionsRegistry, + }, }); export const editorFrameStop = () => editorFrame.stop(); @@ -191,7 +208,7 @@ export function InitializedEditor({ store={store} datasourceMap={datasources} visualizationMap={visualizations} - initialDatasourceId={(doc && doc.datasourceType) || firstDatasourceId || null} + initialDatasourceId={(doc && doc.activeDatasourceId) || firstDatasourceId || null} initialVisualizationId={(doc && doc.visualizationType) || firstVisualizationId || null} ExpressionRenderer={expressionRenderer} redirectTo={path => routeProps.history.push(path)} diff --git a/x-pack/legacy/plugins/lens/public/index.scss b/x-pack/legacy/plugins/lens/public/index.scss index 6beb75e3b4dbb..97f8c32aa633f 100644 --- a/x-pack/legacy/plugins/lens/public/index.scss +++ b/x-pack/legacy/plugins/lens/public/index.scss @@ -1,6 +1,8 @@ // Import the EUI global scope so we can use EUI constants @import 'src/legacy/ui/public/styles/_styling_constants'; +@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'; 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 index a77305a5c3178..40b35019e3bff 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -18,20 +18,26 @@ jest.mock('./loader'); const initialState: IndexPatternPrivateState = { currentIndexPatternId: '1', - columnOrder: ['col1'], - columns: { - col1: { - operationId: 'op1', - label: 'My Op', - dataType: 'string', - isBucketed: true, - operationType: 'terms', - sourceField: 'op', - params: { - size: 5, - orderDirection: 'asc', - orderBy: { - type: 'alphabetical', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'My Op', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderDirection: 'asc', + orderBy: { + type: 'alphabetical', + }, + }, + indexPatternId: '1', }, }, }, 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 index f16b2acc8ba03..b7f4e22ee251d 100644 --- 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 @@ -16,6 +16,7 @@ .lnsConfigPanel__summaryPopoverAnchor { max-width: 100%; + display: block; } .lnsConfigPanel__summaryIcon { @@ -23,7 +24,7 @@ } .lnsConfigPanel__summaryLink { - max-width: 100%; + width: 100%; display: flex; } 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 index 06e2933956d7f..ff70bfcdfa500 100644 --- 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 @@ -81,20 +81,26 @@ describe('IndexPatternDimensionPanel', () => { state = { indexPatterns: expectedIndexPatterns, currentIndexPatternId: '1', - columnOrder: ['col1'], - columns: { - col1: { - operationId: 'op1', - label: 'Date Histogram of timestamp', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - params: { - interval: '1d', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + indexPatternId: '1', + }, }, - sourceField: 'timestamp', }, }, }; @@ -108,6 +114,7 @@ describe('IndexPatternDimensionPanel', () => { state, setState, columnId: 'col1', + layerId: 'first', filterOperations: () => true, dataPlugin: data, storage: localStorage, @@ -128,17 +135,19 @@ describe('IndexPatternDimensionPanel', () => { wrapper .find('[data-test-subj="indexPattern-configure-dimension"]') .first() - .text() - ).toEqual('Configure dimension'); + .prop('iconType') + ).toEqual('plusInCircle'); }); it('should pass the right arguments to getPotentialColumns', async () => { wrapper = shallow(); - expect(getPotentialColumns as jest.Mock).toHaveBeenCalledWith( - state.indexPatterns[state.currentIndexPatternId].fields, - 1 - ); + expect(getPotentialColumns).toHaveBeenCalledWith({ + fields: state.indexPatterns[state.currentIndexPatternId].fields, + suggestedPriority: 1, + layerId: 'first', + layer: state.layers.first, + }); }); it('should call the filterOperations function', () => { @@ -198,17 +207,23 @@ describe('IndexPatternDimensionPanel', () => { {...defaultProps} state={{ ...state, - columns: { - ...state.columns, - col1: { - operationId: 'op1', - label: 'Max of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'max', - sourceField: 'bytes', + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + operationId: 'op1', + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + indexPatternId: '1', + }, + }, }, }, }} @@ -235,17 +250,23 @@ describe('IndexPatternDimensionPanel', () => { {...defaultProps} state={{ ...state, - columns: { - ...state.columns, - col1: { - operationId: 'op1', - label: 'Max of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'max', - sourceField: 'bytes', + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + operationId: 'op1', + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + indexPatternId: '1', + }, + }, }, }, }} @@ -269,17 +290,23 @@ describe('IndexPatternDimensionPanel', () => { it('should keep the operation when switching to another field compatible with this operation', () => { const initialState: IndexPatternPrivateState = { ...state, - columns: { - ...state.columns, - col1: { - operationId: 'op1', - label: 'Max of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'max', - sourceField: 'bytes', + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + operationId: 'op1', + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + indexPatternId: '1', + }, + }, }, }, }; @@ -297,13 +324,18 @@ describe('IndexPatternDimensionPanel', () => { expect(setState).toHaveBeenCalledWith({ ...initialState, - columns: { - ...state.columns, - col1: expect.objectContaining({ - operationType: 'max', - sourceField: 'memory', - // Other parts of this don't matter for this test - }), + 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 + }), + }, + }, }, }); }); @@ -322,13 +354,18 @@ describe('IndexPatternDimensionPanel', () => { expect(setState).toHaveBeenCalledWith({ ...state, - columns: { - ...state.columns, - col1: expect.objectContaining({ - operationType: 'terms', - sourceField: 'source', - // Other parts of this don't matter for this test - }), + 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 + }), + }, + }, }, }); }); @@ -339,17 +376,23 @@ describe('IndexPatternDimensionPanel', () => { {...defaultProps} state={{ ...state, - columns: { - ...state.columns, - col1: { - operationId: 'op1', - label: 'Max of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'max', - sourceField: 'bytes', + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + operationId: 'op1', + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + indexPatternId: '1', + }, + }, }, }, }} @@ -364,13 +407,18 @@ describe('IndexPatternDimensionPanel', () => { expect(setState).toHaveBeenCalledWith({ ...state, - columns: { - ...state.columns, - col1: expect.objectContaining({ - operationType: 'min', - sourceField: 'bytes', - // Other parts of this don't matter for this test - }), + 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 + }), + }, + }, }, }); }); @@ -402,12 +450,17 @@ describe('IndexPatternDimensionPanel', () => { expect(setState).toHaveBeenCalledWith({ ...state, - columns: { - ...state.columns, - col1: expect.objectContaining({ - label: 'New Label', - // Other parts of this don't matter for this test - }), + 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 + }), + }, + }, }, }); }); @@ -504,11 +557,17 @@ describe('IndexPatternDimensionPanel', () => { expect(setState).toHaveBeenCalledWith({ ...state, - columns: { - col1: expect.objectContaining({ - sourceField: 'source', - operationType: 'terms', - }), + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + sourceField: 'source', + operationType: 'terms', + }), + }, + }, }, }); }); @@ -530,27 +589,30 @@ describe('IndexPatternDimensionPanel', () => { expect(setState).toHaveBeenCalledWith({ ...state, - columns: { - ...state.columns, - col2: expect.objectContaining({ - sourceField: 'bytes', - operationType: 'avg', - // Other parts of this don't matter for this test - }), + 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'], + }, }, - columnOrder: ['col1', 'col2'], }); }); it('should select operation directly if only one field is possible', () => { const initialState = { - ...defaultProps.state, + ...state, indexPatterns: { 1: { - ...defaultProps.state.indexPatterns['1'], - fields: defaultProps.state.indexPatterns['1'].fields.filter( - field => field.name !== 'memory' - ), + ...state.indexPatterns['1'], + fields: state.indexPatterns['1'].fields.filter(field => field.name !== 'memory'), }, }, }; @@ -565,15 +627,20 @@ describe('IndexPatternDimensionPanel', () => { expect(setState).toHaveBeenCalledWith({ ...initialState, - columns: { - ...state.columns, - col2: expect.objectContaining({ - sourceField: 'bytes', - operationType: 'avg', - // Other parts of this don't matter for this test - }), + 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'], + }, }, - columnOrder: ['col1', 'col2'], }); }); @@ -631,31 +698,42 @@ describe('IndexPatternDimensionPanel', () => { expect(setState).toHaveBeenCalledWith({ ...state, - columns: { - ...state.columns, - col2: expect.objectContaining({ - sourceField: 'bytes', - // Other parts of this don't matter for this test - }), + 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'], + }, }, - columnOrder: ['col1', 'col2'], }); }); it('should use helper function when changing the function', () => { const initialState: IndexPatternPrivateState = { ...state, - columns: { - ...state.columns, - col1: { - operationId: 'op1', - label: 'Max of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'max', - sourceField: 'bytes', + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + operationId: 'op1', + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + indexPatternId: '1', + }, + }, }, }, }; @@ -670,14 +748,15 @@ describe('IndexPatternDimensionPanel', () => { .prop('onClick')!({} as React.MouseEvent<{}, MouseEvent>); }); - expect(changeColumn).toHaveBeenCalledWith( - initialState, - 'col1', - expect.objectContaining({ + 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', () => { @@ -693,8 +772,13 @@ describe('IndexPatternDimensionPanel', () => { expect(setState).toHaveBeenCalledWith({ ...state, - columns: {}, - columnOrder: [], + layers: { + first: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, }); }); @@ -709,16 +793,19 @@ describe('IndexPatternDimensionPanel', () => { expect(setState).toHaveBeenCalledWith({ ...state, - columns: {}, - columnOrder: [], + layers: { + first: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, }); }); describe('drag and drop', () => { - function dragDropState() { + function dragDropState(): IndexPatternPrivateState { return { - ...state, - currentIndexPatternId: 'foo', indexPatterns: { foo: { id: 'foo', @@ -733,11 +820,36 @@ describe('IndexPatternDimensionPanel', () => { ], }, }, + currentIndexPatternId: '1', + layers: { + myLayer: { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + indexPatternId: 'foo', + }, + }, + }, + }, }; } it('is not droppable if no drag is happening', () => { - wrapper = mount(); + wrapper = mount( + + ); expect( wrapper @@ -756,6 +868,7 @@ describe('IndexPatternDimensionPanel', () => { dragging: { name: 'bar' }, }} state={dragDropState()} + layerId="myLayer" /> ); @@ -777,6 +890,7 @@ describe('IndexPatternDimensionPanel', () => { }} state={dragDropState()} filterOperations={() => false} + layerId="myLayer" /> ); @@ -798,6 +912,7 @@ describe('IndexPatternDimensionPanel', () => { }} state={dragDropState()} filterOperations={op => op.dataType === 'number'} + layerId="myLayer" /> ); @@ -822,6 +937,7 @@ describe('IndexPatternDimensionPanel', () => { state={testState} columnId={'col2'} filterOperations={op => op.dataType === 'number'} + layerId="myLayer" /> ); @@ -835,17 +951,22 @@ describe('IndexPatternDimensionPanel', () => { }); expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith( - expect.objectContaining({ - columns: expect.objectContaining({ - ...testState.columns, - col2: expect.objectContaining({ - dataType: 'number', - sourceField: 'bar', - }), - }), - }) - ); + 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', () => { @@ -860,6 +981,7 @@ describe('IndexPatternDimensionPanel', () => { }} state={testState} filterOperations={op => op.dataType === 'number'} + layerId="myLayer" /> ); @@ -873,16 +995,19 @@ describe('IndexPatternDimensionPanel', () => { }); expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith( - expect.objectContaining({ - columns: expect.objectContaining({ - col1: expect.objectContaining({ - dataType: 'number', - sourceField: 'bar', + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), }), }), - }) - ); + }, + }); }); it('ignores drops of incompatible fields', () => { @@ -897,6 +1022,7 @@ describe('IndexPatternDimensionPanel', () => { }} state={testState} filterOperations={op => op.dataType === 'number'} + layerId="myLayer" /> ); 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 index e351acb07dc7b..226a584504757 100644 --- 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 @@ -30,25 +30,31 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { dragDropContext: DragContextState; dataPlugin: DataSetup; storage: Storage; + layerId: string; }; export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPanel( props: IndexPatternDimensionPanelProps ) { + const layerId = props.layerId; + const indexPatternId = props.state.layers[layerId].indexPatternId; const columns = useMemo( () => - getPotentialColumns( - props.state.indexPatterns[props.state.currentIndexPatternId].fields, - props.suggestedPriority - ), - [props.state.indexPatterns[props.state.currentIndexPatternId].fields, props.suggestedPriority] + getPotentialColumns({ + fields: props.state.indexPatterns[indexPatternId].fields, + suggestedPriority: props.suggestedPriority, + layer: props.state.layers[layerId], + layerId, + }), + [indexPatternId, props.suggestedPriority, layerId] ); const filteredColumns = columns.filter(col => { return props.filterOperations(columnToOperation(col)); }); - const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; function findColumnByField(field: IndexPatternField) { return filteredColumns.find(col => hasField(col) && col.sourceField === field.name); @@ -75,7 +81,14 @@ export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPan return; } - props.setState(changeColumn(props.state, props.columnId, column)); + props.setState( + changeColumn({ + state: props.state, + layerId, + columnId: props.columnId, + newColumn: column, + }) + ); }} > @@ -98,7 +111,16 @@ export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPan defaultMessage: 'Remove', })} onClick={() => { - props.setState(deleteColumn(props.state, props.columnId)); + 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 index 2f7388e5bbc70..4543ce7b10bf5 100644 --- 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 @@ -70,7 +70,7 @@ export function FieldSelect({ label: i18n.translate('xpack.lens.indexPattern.documentField', { defaultMessage: 'Document', }), - value: fieldlessColumn.operationId, + value: { operationId: fieldlessColumn.operationId }, className: classNames({ 'lnsConfigPanel__fieldOption--incompatible': !isCompatibleWithCurrentOperation( fieldlessColumn 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 index b00fc980d5303..de6259db5324c 100644 --- 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 @@ -17,6 +17,7 @@ import { EuiFormRow, EuiFieldText, EuiLink, + EuiButton, } from '@elastic/eui'; import classNames from 'classnames'; import { IndexPatternColumn, OperationType } from '../indexpattern'; @@ -59,7 +60,7 @@ export interface PopoverEditorProps extends IndexPatternDimensionPanelProps { } export function PopoverEditor(props: PopoverEditorProps) { - const { selectedColumn, filteredColumns, state, columnId, setState } = props; + const { selectedColumn, filteredColumns, state, columnId, setState, layerId } = props; const [isPopoverOpen, setPopoverOpen] = useState(false); const [ incompatibleSelectedOperationType, @@ -94,7 +95,14 @@ export function PopoverEditor(props: PopoverEditorProps) { 'sourceField' ); if (possibleColumns.length === 1) { - setState(changeColumn(state, columnId, possibleColumns[0])); + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: possibleColumns[0], + }) + ); } else { setInvalidOperationType(operationType); } @@ -117,7 +125,14 @@ export function PopoverEditor(props: PopoverEditorProps) { !hasField(selectedColumn) || col.sourceField === selectedColumn.sourceField) )!; - setState(changeColumn(state, columnId, newColumn)); + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn, + }) + ); }, }) ), @@ -130,19 +145,24 @@ export function PopoverEditor(props: PopoverEditorProps) { className="lnsConfigPanel__summaryPopover" anchorClassName="lnsConfigPanel__summaryPopoverAnchor" button={ - { - setPopoverOpen(true); - }} - data-test-subj="indexPattern-configure-dimension" - > - {selectedColumn - ? selectedColumn.label - : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { - defaultMessage: 'Configure dimension', - })} - + selectedColumn ? ( + { + setPopoverOpen(true); + }} + data-test-subj="indexPattern-configure-dimension" + > + {selectedColumn.label} + + ) : ( + setPopoverOpen(true)} + iconType="plusInCircle" + /> + ) } isOpen={isPopoverOpen} closePopover={() => { @@ -160,10 +180,23 @@ export function PopoverEditor(props: PopoverEditorProps) { selectedColumn={selectedColumn} incompatibleSelectedOperationType={incompatibleSelectedOperationType} onDeleteColumn={() => { - setState(deleteColumn(state, columnId)); + setState( + deleteColumn({ + state, + layerId, + columnId, + }) + ); }} onChangeColumn={column => { - setState(changeColumn(state, columnId, column)); + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: column, + }) + ); setInvalidOperationType(null); }} /> @@ -208,6 +241,7 @@ export function PopoverEditor(props: PopoverEditorProps) { columnId={columnId} storage={props.storage} dataPlugin={props.dataPlugin} + layerId={layerId} /> )} {!incompatibleSelectedOperationType && selectedColumn && ( @@ -217,9 +251,14 @@ export function PopoverEditor(props: PopoverEditorProps) { value={selectedColumn.label} onChange={e => { setState( - changeColumn(state, columnId, { - ...selectedColumn, - label: e.target.value, + changeColumn({ + state, + layerId, + columnId, + newColumn: { + ...selectedColumn, + label: e.target.value, + }, }) ); }} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index c605260b0c04b..7dc6b36612dfb 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -16,8 +16,10 @@ import { IndexPatternColumn, } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; +import { generateId } from '../id_generator'; jest.mock('./loader'); +jest.mock('../id_generator'); // chrome, notify, storage are used by ./plugin jest.mock('ui/chrome'); jest.mock('ui/notify'); @@ -126,21 +128,27 @@ describe('IndexPattern Data Source', () => { persistedState = { currentIndexPatternId: '1', - columnOrder: ['col1'], - columns: { - col1: { - operationId: 'op1', - label: 'My Op', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - sourceField: 'op', - params: { - size: 5, - orderBy: { type: 'alphabetical' }, - orderDirection: 'asc', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'My Op', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + indexPatternId: '1', + }, }, }, }, @@ -153,8 +161,7 @@ describe('IndexPattern Data Source', () => { expect(state).toEqual({ currentIndexPatternId: '1', indexPatterns: expectedIndexPatterns, - columns: {}, - columnOrder: [], + layers: {}, }); }); @@ -178,40 +185,47 @@ describe('IndexPattern Data Source', () => { describe('#toExpression', () => { it('should generate an empty expression when no columns are selected', async () => { const state = await indexPatternDatasource.initialize(); - expect(indexPatternDatasource.toExpression(state)).toEqual(null); + expect(indexPatternDatasource.toExpression(state, 'first')).toEqual(null); }); it('should generate an expression for an aggregated query', async () => { const queryPersistedState: IndexPatternPersistedState = { currentIndexPatternId: '1', - columnOrder: ['col1', 'col2'], - columns: { - col1: { - operationId: 'op1', - label: 'Count of Documents', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'count', - }, - col2: { - operationId: 'op2', - label: 'Date', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - params: { - interval: '1d', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + operationId: 'op1', + label: 'Count of Documents', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + indexPatternId: '1', + }, + col2: { + operationId: 'op2', + label: 'Date', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + indexPatternId: '1', + }, }, }, }, }; const state = await indexPatternDatasource.initialize(queryPersistedState); - expect(indexPatternDatasource.toExpression(state)).toMatchInlineSnapshot(` + expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(` "esaggs index=\\"1\\" metricsAtAllLevels=false @@ -222,15 +236,15 @@ describe('IndexPattern Data Source', () => { }); describe('#getDatasourceSuggestionsForField', () => { - describe('with no previous selections', () => { + describe('with no layer', () => { let initialState: IndexPatternPrivateState; beforeEach(async () => { initialState = await indexPatternDatasource.initialize({ currentIndexPatternId: '1', - columnOrder: [], - columns: {}, + layers: {}, }); + (generateId as jest.Mock).mockReturnValueOnce('suggestedLayer'); }); it('should apply a bucketed aggregation for a string field', () => { @@ -244,14 +258,18 @@ describe('IndexPattern Data Source', () => { expect(suggestions).toHaveLength(1); expect(suggestions[0].state).toEqual( expect.objectContaining({ - columnOrder: ['col1', 'col2'], - columns: { - col1: expect.objectContaining({ - operationType: 'terms', - sourceField: 'source', - }), - col2: expect.objectContaining({ - operationType: 'count', + layers: { + suggestedLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + }), + col2: expect.objectContaining({ + operationType: 'count', + }), + }, }), }, }) @@ -267,6 +285,7 @@ describe('IndexPattern Data Source', () => { columnId: 'col2', }), ], + layerId: 'suggestedLayer', }); }); @@ -281,14 +300,18 @@ describe('IndexPattern Data Source', () => { expect(suggestions).toHaveLength(1); expect(suggestions[0].state).toEqual( expect.objectContaining({ - columnOrder: ['col1', 'col2'], - columns: { - col1: expect.objectContaining({ - operationType: 'date_histogram', - sourceField: 'timestamp', - }), - col2: expect.objectContaining({ - operationType: 'count', + layers: { + suggestedLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + col2: expect.objectContaining({ + operationType: 'count', + }), + }, }), }, }) @@ -304,6 +327,7 @@ describe('IndexPattern Data Source', () => { columnId: 'col2', }), ], + layerId: 'suggestedLayer', }); }); @@ -318,15 +342,199 @@ describe('IndexPattern Data Source', () => { expect(suggestions).toHaveLength(1); expect(suggestions[0].state).toEqual( expect.objectContaining({ - columnOrder: ['col1', 'col2'], - columns: { - col1: expect.objectContaining({ - sourceField: 'timestamp', - operationType: 'date_histogram', + layers: { + suggestedLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + col2: expect.objectContaining({ + operationType: 'min', + sourceField: 'bytes', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + datasourceSuggestionId: 0, + 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', + 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, { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }); + + expect(suggestions).toHaveLength(0); + }); + }); + + describe('with a previous empty layer', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + layers: { + previousLayer: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, + }); + }); + + it('should apply a bucketed aggregation for a string field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }); + + 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({ + datasourceSuggestionId: 0, + 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, { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }); + + 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', + }), + }, }), - col2: expect.objectContaining({ - sourceField: 'bytes', - operationType: 'min', + }, + }) + ); + expect(suggestions[0].table).toEqual({ + datasourceSuggestionId: 0, + 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, { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }); + + 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: 'min', + sourceField: 'bytes', + }), + }, }), }, }) @@ -342,14 +550,13 @@ describe('IndexPattern Data Source', () => { columnId: 'col2', }), ], + layerId: 'previousLayer', }); }); it('should not make any suggestions for a number without a time field', async () => { const state: IndexPatternPrivateState = { currentIndexPatternId: '1', - columnOrder: [], - columns: {}, indexPatterns: { 1: { id: '1', @@ -364,6 +571,13 @@ describe('IndexPattern Data Source', () => { ], }, }, + layers: { + previousLayer: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, }; const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(state, { @@ -377,7 +591,7 @@ describe('IndexPattern Data Source', () => { }); }); - describe('with a prior column', () => { + describe('with a prior layer that contains configuration', () => { let initialState: IndexPatternPrivateState; beforeEach(async () => { @@ -417,6 +631,79 @@ describe('IndexPattern Data Source', () => { ).toHaveLength(0); }); }); + + describe('finding the layer that is using the current index pattern', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + layers: { + previousLayer: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + currentLayer: { + indexPatternId: '2', + columns: {}, + columnOrder: [], + }, + }, + }); + }); + + it('suggests on the layer that matches by indexPatternId', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }); + + 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({ + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + layerId: 'currentLayer', + }); + }); + }); }); describe('#getDatasourceSuggestionsFromCurrentState', () => { @@ -424,8 +711,13 @@ describe('IndexPattern Data Source', () => { expect( indexPatternDatasource.getDatasourceSuggestionsFromCurrentState({ indexPatterns: expectedIndexPatterns, - columnOrder: [], - columns: {}, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, currentIndexPatternId: '1', }) ).toEqual([]); @@ -453,18 +745,105 @@ describe('IndexPattern Data Source', () => { }, }, ], + layerId: 'first', }, }, ]); }); }); + 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', + }; + expect(indexPatternDatasource.insertLayer(state, 'newLayer')).toEqual({ + ...state, + layers: { + ...state.layers, + newLayer: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, + }); + }); + }); + + describe('#removeLayer', () => { + it('should remove a layer', () => { + const state = { + 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({ + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }) + ).toEqual(['first', 'second']); + }); + }); + describe('#getPublicAPI', () => { let publicAPI: DatasourcePublicAPI; beforeEach(async () => { const initialState = await indexPatternDatasource.initialize(persistedState); - publicAPI = indexPatternDatasource.getPublicAPI(initialState, () => {}); + publicAPI = indexPatternDatasource.getPublicAPI(initialState, () => {}, 'first'); }); describe('getTableSpec', () => { @@ -488,35 +867,42 @@ describe('IndexPattern Data Source', () => { operationId: 'bar', operationType: 'max', sourceField: 'baz', - suggestedOrder: 0, + suggestedPriority: 0, + indexPatternId: '1', }; const columns: Record = { a: { ...sampleColumn, - suggestedOrder: 0, + suggestedPriority: 0, }, b: { ...sampleColumn, - suggestedOrder: 1, + suggestedPriority: 1, }, c: { ...sampleColumn, - suggestedOrder: 2, + suggestedPriority: 2, }, }; const api = indexPatternDatasource.getPublicAPI( { ...initialState, - columnOrder: ['a', 'b', 'c'], - columns, + layers: { + first: { + ...initialState.layers.first, + columns, + columnOrder: ['a', 'b', 'c'], + }, + }, }, - setState + setState, + 'first' ); api.removeColumnInTableSpec('b'); - expect(setState.mock.calls[0][0].columnOrder).toEqual(['a', 'c']); - expect(setState.mock.calls[0][0].columns).toEqual({ + 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, }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 0bbb6f67d5b8a..67639346a3ad1 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -14,6 +14,7 @@ import { DimensionPriority, DatasourceSuggestion, Operation, + DatasourceLayerPanelProps, } from '../types'; import { Query } from '../../../../../../src/legacy/core_plugins/data/public/query'; import { getIndexPatterns } from './loader'; @@ -22,6 +23,7 @@ import { IndexPatternDimensionPanel } from './dimension_panel'; import { buildColumnForOperationType, getOperationTypesForField } from './operations'; import { IndexPatternDatasourcePluginPlugins } from './plugin'; import { IndexPatternDataPanel } from './datapanel'; +import { generateId } from '../id_generator'; import { Datasource, DataType } from '..'; export type OperationType = IndexPatternColumn['operationType']; @@ -45,7 +47,8 @@ export interface BaseIndexPatternColumn { // Private operationType: OperationType; - suggestedOrder?: DimensionPriority; + suggestedPriority?: DimensionPriority; + indexPatternId: string; } type Omit = Pick>; @@ -56,7 +59,7 @@ type ParameterlessIndexPatternColumn< export interface FieldBasedIndexPatternColumn extends BaseIndexPatternColumn { sourceField: string; - suggestedOrder?: DimensionPriority; + suggestedPriority?: DimensionPriority; } export interface DateHistogramIndexPatternColumn extends FieldBasedIndexPatternColumn { @@ -121,11 +124,15 @@ export interface IndexPatternField { >; } -export interface IndexPatternPersistedState { - currentIndexPatternId: 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 & { @@ -184,6 +191,35 @@ function removeProperty(prop: string, object: Record): Record + _.isEqual(f, field) + ); + + if (isMatchingActive !== -1) { + return state.currentIndexPatternId; + } + } + + const matchingIndexPattern = Object.values(state.indexPatterns).find(indexPattern => { + if (indexPattern.id === state.currentIndexPatternId) { + return; + } + + const hasMatch = indexPattern.fields.findIndex(f => _.isEqual(f, field)); + + if (hasMatch !== -1) { + return indexPattern.id; + } + }); + + return matchingIndexPattern ? matchingIndexPattern.id : null; +} + export function getIndexPatternDatasource({ chrome, toastNotifications, @@ -211,13 +247,39 @@ export function getIndexPatternDatasource({ return { currentIndexPatternId: indexPatternObjects ? indexPatternObjects[0].id : '', indexPatterns, - columns: {}, - columnOrder: [], + layers: {}, }; }, - getPersistableState({ currentIndexPatternId, columns, columnOrder }: IndexPatternPrivateState) { - return { currentIndexPatternId, columns, columnOrder }; + 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, @@ -234,16 +296,20 @@ export function getIndexPatternDatasource({ ); }, - getPublicAPI(state, setState) { + getPublicAPI(state, setState, layerId) { return { getTableSpec: () => { - return state.columnOrder.map(colId => ({ columnId: colId })); + return state.layers[layerId].columnOrder.map(colId => ({ columnId: colId })); }, getOperationForColumnId: (columnId: string) => { - if (!state.columns[columnId]) { - return null; + const layer = Object.values(state.layers).find(l => + l.columnOrder.find(id => id === columnId) + ); + + if (layer) { + return columnToOperation(layer.columns[columnId]); } - return columnToOperation(state.columns[columnId]); + return null; }, renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { render( @@ -253,6 +319,7 @@ export function getIndexPatternDatasource({ setState={newState => setState(newState)} dataPlugin={data} storage={storage} + layerId={props.layerId} {...props} /> , @@ -260,11 +327,26 @@ export function getIndexPatternDatasource({ ); }, + renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => { + render( + + {state.indexPatterns[state.layers[props.layerId].indexPatternId].title} + , + domElement + ); + }, + removeColumnInTableSpec: (columnId: string) => { setState({ ...state, - columnOrder: state.columnOrder.filter(id => id !== columnId), - columns: removeProperty(columnId, state.columns), + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: state.layers[layerId].columnOrder.filter(id => id !== columnId), + columns: removeProperty(columnId, state.layers[layerId].columns), + }, + }, }); }, moveColumnTo: () => {}, @@ -276,38 +358,77 @@ export function getIndexPatternDatasource({ state, item ): Array> { + const layers = Object.keys(state.layers); const field: IndexPatternField = item as IndexPatternField; - if (Object.keys(state.columns).length) { - // Not sure how to suggest multiple fields yet - return []; + const indexPatternId = getIndexPatternIdFromField(state, field); + + let layerId; + let layer: IndexPatternLayer; + + if (indexPatternId) { + layerId = layers.find(id => state.layers[id].indexPatternId === indexPatternId); + } + + if (!layerId) { + // The field we're suggesting on might 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 + layerId = generateId(); + layer = { + indexPatternId: state.currentIndexPatternId, + columnOrder: [], + columns: {}, + }; + } else { + layer = state.layers[layerId]; + if (layer.columnOrder.length) { + // We aren't suggesting ways of using the field to replace the existing user configuration + // This is a good place for future extension + return []; + } } const operations = getOperationTypesForField(field); const hasBucket = operations.find(op => op === 'date_histogram' || op === 'terms'); if (hasBucket) { - const countColumn = buildColumnForOperationType(1, 'count', state.columns); + const countColumn = buildColumnForOperationType({ + index: 1, + op: 'count', + columns: layer.columns, + indexPatternId: state.currentIndexPatternId, + layerId, + suggestedPriority: undefined, + }); // let column know about count column - const column = buildColumnForOperationType( - 0, - hasBucket, - { + const column = buildColumnForOperationType({ + index: 0, + layerId, + op: hasBucket, + indexPatternId: state.currentIndexPatternId, + columns: { col2: countColumn, }, - undefined, - field - ); + field, + suggestedPriority: undefined, + }); const suggestion: DatasourceSuggestion = { state: { ...state, - columns: { - col1: column, - col2: countColumn, + layers: { + ...state.layers, + [layerId]: { + indexPatternId: state.currentIndexPatternId, + columns: { + col1: column, + col2: countColumn, + }, + columnOrder: ['col1', 'col2'], + }, }, - columnOrder: ['col1', 'col2'], }, table: { @@ -323,6 +444,7 @@ export function getIndexPatternDatasource({ ], isMultiRow: true, datasourceSuggestionId: 0, + layerId, }, }; @@ -333,30 +455,40 @@ export function getIndexPatternDatasource({ f => f.name === currentIndexPattern.timeFieldName )!; - const column = buildColumnForOperationType( - 0, - operations[0], - state.columns, - undefined, - field - ); - - const dateColumn = buildColumnForOperationType( - 1, - 'date_histogram', - state.columns, - undefined, - dateField - ); + const column = buildColumnForOperationType({ + index: 0, + op: operations[0], + columns: layer.columns, + suggestedPriority: undefined, + field, + indexPatternId: state.currentIndexPatternId, + layerId, + }); + + const dateColumn = buildColumnForOperationType({ + index: 1, + op: 'date_histogram', + columns: layer.columns, + suggestedPriority: undefined, + field: dateField, + indexPatternId: state.currentIndexPatternId, + layerId, + }); const suggestion: DatasourceSuggestion = { state: { ...state, - columns: { - col1: dateColumn, - col2: column, + layers: { + ...state.layers, + [layerId]: { + ...layer, + columns: { + col1: dateColumn, + col2: column, + }, + columnOrder: ['col1', 'col2'], + }, }, - columnOrder: ['col1', 'col2'], }, table: { @@ -372,6 +504,7 @@ export function getIndexPatternDatasource({ ], isMultiRow: true, datasourceSuggestionId: 0, + layerId, }, }; @@ -384,23 +517,30 @@ export function getIndexPatternDatasource({ getDatasourceSuggestionsFromCurrentState( state ): Array> { - if (!state.columnOrder.length) { - return []; - } - return [ - { - state, + const layers = Object.entries(state.layers); - table: { - columns: state.columnOrder.map(id => ({ - columnId: id, - operation: columnToOperation(state.columns[id]), - })), - isMultiRow: true, - datasourceSuggestionId: 0, - }, - }, - ]; + return layers + .map(([layerId, layer], index) => { + if (layer.columnOrder.length === 0) { + return; + } + return { + state, + + table: { + columns: layer.columnOrder.map(id => ({ + columnId: id, + operation: columnToOperation(layer.columns[id]), + })), + isMultiRow: true, + datasourceSuggestionId: index, + layerId, + }, + }; + }) + .reduce((prev, current) => (current ? prev.concat([current]) : prev), [] as Array< + DatasourceSuggestion + >); }, }; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx index fd3b0aa06339e..50af9e6d96e95 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx @@ -6,7 +6,6 @@ import { i18n } from '@kbn/i18n'; import { CountIndexPatternColumn } from '../indexpattern'; -import { DimensionPriority } from '../../types'; import { OperationDefinition } from '../operations'; export const countOperation: OperationDefinition = { @@ -15,14 +14,8 @@ export const countOperation: OperationDefinition = { defaultMessage: 'Count', }), isApplicableWithoutField: true, - isApplicableForField: ({ aggregationRestrictions, type }) => { - return false; - }, - buildColumn( - operationId: string, - columns: {}, - suggestedOrder?: DimensionPriority - ): CountIndexPatternColumn { + isApplicableForField: () => false, + buildColumn({ operationId, suggestedPriority, indexPatternId }) { return { operationId, label: i18n.translate('xpack.lens.indexPattern.countOf', { @@ -30,8 +23,9 @@ export const countOperation: OperationDefinition = { }), dataType: 'number', operationType: 'count', - suggestedOrder, + suggestedPriority, isBucketed: false, + indexPatternId, }; }, toEsAggsConfig: (column, columnId) => ({ diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx index e823040fee752..6c2280ae38b7e 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx @@ -16,6 +16,7 @@ describe('date_histogram', () => { beforeEach(() => { state = { + currentIndexPatternId: '1', indexPatterns: { 1: { id: '1', @@ -31,21 +32,26 @@ describe('date_histogram', () => { ], }, }, - currentIndexPatternId: '1', - columnOrder: ['col1'], - columns: { - col1: { - operationId: 'op1', - label: 'Value of timestamp', - dataType: 'date', - isBucketed: true, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, - // Private - operationType: 'date_histogram', - params: { - interval: 'w', + // Private + operationType: 'date_histogram', + params: { + interval: 'w', + }, + sourceField: 'timestamp', + indexPatternId: '1', + }, }, - sourceField: 'timestamp', }, }, }; @@ -53,28 +59,42 @@ describe('date_histogram', () => { describe('buildColumn', () => { it('should create column object with default params', () => { - const column = dateHistogramOperation.buildColumn('op', {}, 0, { - name: 'timestamp', - type: 'date', - esTypes: ['date'], - aggregatable: true, - searchable: true, + const column = dateHistogramOperation.buildColumn({ + operationId: 'op', + columns: {}, + suggestedPriority: 0, + layerId: 'first', + indexPatternId: '1', + field: { + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + }, }); - expect(column.params.interval).toEqual('h'); + expect(column.params.interval).toEqual('d'); }); it('should create column object with restrictions', () => { - const column = dateHistogramOperation.buildColumn('op', {}, 0, { - name: 'timestamp', - type: 'date', - esTypes: ['date'], - aggregatable: true, - searchable: true, - aggregationRestrictions: { - date_histogram: { - agg: 'date_histogram', - time_zone: 'UTC', - calendar_interval: '1y', + const column = dateHistogramOperation.buildColumn({ + operationId: 'op', + columns: {}, + suggestedPriority: 0, + layerId: 'first', + indexPatternId: '1', + field: { + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + time_zone: 'UTC', + calendar_interval: '1y', + }, }, }, }); @@ -86,7 +106,7 @@ describe('date_histogram', () => { describe('toEsAggsConfig', () => { it('should reflect params correctly', () => { const esAggsConfig = dateHistogramOperation.toEsAggsConfig( - state.columns.col1 as DateHistogramIndexPatternColumn, + state.layers.first.columns.col1 as DateHistogramIndexPatternColumn, 'col1' ); expect(esAggsConfig).toEqual( @@ -104,7 +124,7 @@ describe('date_histogram', () => { it('should render current value', () => { const setStateSpy = jest.fn(); const instance = shallow( - + ); expect(instance.find(EuiRange).prop('value')).toEqual(1); @@ -113,7 +133,7 @@ describe('date_histogram', () => { it('should update state with the interval value', () => { const setStateSpy = jest.fn(); const instance = shallow( - + ); instance.find(EuiRange).prop('onChange')!({ @@ -123,12 +143,18 @@ describe('date_histogram', () => { } as React.ChangeEvent); expect(setStateSpy).toHaveBeenCalledWith({ ...state, - columns: { - ...state.columns, - col1: { - ...state.columns.col1, - params: { - interval: 'd', + layers: { + ...state.layers, + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + ...state.layers.first.columns.col1, + params: { + interval: 'd', + }, + }, }, }, }, @@ -161,6 +187,7 @@ describe('date_histogram', () => { }} setState={setStateSpy} columnId="col1" + layerId="first" /> ); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx index 43b03b330bd63..f47fc478ddd54 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx @@ -43,16 +43,21 @@ export const dateHistogramOperation: OperationDefinition { - const column = state.columns[columnId] as DateHistogramIndexPatternColumn; + paramEditor: ({ state, setState, columnId, layerId }) => { + const column = state.layers[layerId].columns[columnId] as DateHistogramIndexPatternColumn; const field = column && @@ -140,6 +146,7 @@ export const dateHistogramOperation: OperationDefinition { }, }, currentIndexPatternId: '1', - columnOrder: ['col1'], - columns: { - col1: { - operationId: 'op1', - label: 'Filter Ratio', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'filter_ratio', - params: { - numerator: { query: '', language: 'kuery' }, - denominator: { query: '', language: 'kuery' }, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Filter Ratio', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'filter_ratio', + params: { + numerator: { query: '', language: 'kuery' }, + denominator: { query: '', language: 'kuery' }, + }, + indexPatternId: '1', + }, }, }, }, @@ -64,7 +70,13 @@ describe('filter_ratio', () => { describe('buildColumn', () => { it('should create column object with default params', () => { - const column = filterRatioOperation.buildColumn('op', {}, 0); + const column = filterRatioOperation.buildColumn({ + operationId: 'op', + indexPatternId: '1', + layerId: 'first', + columns: {}, + suggestedPriority: undefined, + }); expect(column.params.numerator).toEqual({ query: '', language: 'kuery' }); expect(column.params.denominator).toEqual({ query: '', language: 'kuery' }); }); @@ -73,7 +85,7 @@ describe('filter_ratio', () => { describe('toEsAggsConfig', () => { it('should reflect params correctly', () => { const esAggsConfig = filterRatioOperation.toEsAggsConfig( - state.columns.col1 as FilterRatioIndexPatternColumn, + state.layers.first.columns.col1 as FilterRatioIndexPatternColumn, 'col1' ); expect(esAggsConfig).toEqual( @@ -100,6 +112,7 @@ describe('filter_ratio', () => { expect(() => { shallowWithIntl( { it('should show only the numerator by default', () => { const wrapper = shallowWithIntl( { const setState = jest.fn(); const wrapper = shallowWithIntl( { expect(setState).toHaveBeenCalledWith({ ...state, - columns: { - col1: { - ...state.columns.col1, - params: { - numerator: { query: 'geo.src : "US"', language: 'kuery' }, - denominator: { query: '', language: 'kuery' }, + 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' }, + }, + }, }, }, }, @@ -160,6 +181,7 @@ describe('filter_ratio', () => { const setState = jest.fn(); const wrapper = shallowWithIntl( { expect(setState).toHaveBeenCalledWith({ ...state, - columns: { - col1: { - ...state.columns.col1, - params: { - numerator: { query: '', language: 'kuery' }, - denominator: { query: 'geo.src : "US"', language: 'kuery' }, + 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/operation_definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx index 7e413d05f9517..ea2a09ead109f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx @@ -9,8 +9,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiFormRow } from '@elastic/eui'; import { Query } from '../../../../../../../src/legacy/core_plugins/data/public/query'; -import { FilterRatioIndexPatternColumn, IndexPatternColumn } from '../indexpattern'; -import { DimensionPriority } from '../../types'; +import { FilterRatioIndexPatternColumn } from '../indexpattern'; import { OperationDefinition } from '../operations'; import { updateColumnParam } from '../state_helpers'; @@ -21,11 +20,7 @@ export const filterRatioOperation: OperationDefinition false, - buildColumn( - operationId: string, - _columns: Partial>, - suggestedOrder?: DimensionPriority - ): FilterRatioIndexPatternColumn { + buildColumn({ operationId, suggestedPriority, indexPatternId }) { return { operationId, label: i18n.translate('xpack.lens.indexPattern.filterRatio', { @@ -33,8 +28,9 @@ export const filterRatioOperation: OperationDefinition { + paramEditor: ({ state, setState, columnId: currentColumnId, dataPlugin, storage, layerId }) => { const [hasDenominator, setDenominator] = useState(false); const { QueryBarInput } = dataPlugin!.query.ui; @@ -75,7 +71,8 @@ export const filterRatioOperation: OperationDefinition( @@ -30,12 +29,7 @@ function buildMetricOperation( fieldType === 'number' && (!aggregationRestrictions || aggregationRestrictions[type]) ); }, - buildColumn( - operationId: string, - columns: {}, - suggestedOrder?: DimensionPriority, - field?: IndexPatternField - ): T { + buildColumn({ operationId, suggestedPriority, field, indexPatternId }): T { if (!field) { throw new Error(`Invariant: A ${type} operation can only be built with a field`); } @@ -44,9 +38,10 @@ function buildMetricOperation( label: ofName(field ? field.name : ''), dataType: 'number', operationType: type, - suggestedOrder, + suggestedPriority, sourceField: field ? field.name : '', isBucketed: false, + indexPatternId, } as T; }, toEsAggsConfig: (column, columnId) => ({ diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx index 9c69b4fd4e20e..139808bc196af 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx @@ -18,31 +18,38 @@ describe('terms', () => { state = { indexPatterns: {}, currentIndexPatternId: '1', - columnOrder: ['col1', 'col2'], - columns: { - col1: { - operationId: 'op1', - label: 'Top value of category', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - params: { - orderBy: { type: 'alphabetical' }, - size: 5, - orderDirection: 'asc', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + operationId: 'op1', + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 5, + orderDirection: 'asc', + }, + sourceField: 'category', + indexPatternId: '1', + }, + col2: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + indexPatternId: '1', + }, }, - sourceField: 'category', - }, - col2: { - operationId: 'op1', - label: 'Count', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'count', }, }, }; @@ -51,7 +58,7 @@ describe('terms', () => { describe('toEsAggsConfig', () => { it('should reflect params correctly', () => { const esAggsConfig = termsOperation.toEsAggsConfig( - state.columns.col1 as TermsIndexPatternColumn, + state.layers.first.columns.col1 as TermsIndexPatternColumn, 'col1' ); expect(esAggsConfig).toEqual( @@ -68,15 +75,22 @@ describe('terms', () => { describe('buildColumn', () => { it('should use existing metric column as order column', () => { - const termsColumn = termsOperation.buildColumn('abc', { - col1: { - operationId: 'op1', - label: 'Count', - dataType: 'number', - isBucketed: false, + const termsColumn = termsOperation.buildColumn({ + operationId: 'abc', + layerId: 'first', + indexPatternId: '1', + suggestedPriority: undefined, + columns: { + col1: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, - // Private - operationType: 'count', + // Private + operationType: 'count', + indexPatternId: '1', + }, }, }); expect(termsColumn.params).toEqual( @@ -103,6 +117,7 @@ describe('terms', () => { orderDirection: 'asc', }, sourceField: 'category', + indexPatternId: '1', }; const updatedColumn = termsOperation.onOtherColumnChanged!(initialColumn, { col1: { @@ -113,6 +128,7 @@ describe('terms', () => { // Private operationType: 'count', + indexPatternId: '1', }, }); expect(updatedColumn).toBe(initialColumn); @@ -134,6 +150,7 @@ describe('terms', () => { orderDirection: 'asc', }, sourceField: 'category', + indexPatternId: '1', }, {} ); @@ -160,6 +177,7 @@ describe('terms', () => { orderDirection: 'asc', }, sourceField: 'category', + indexPatternId: '1', }, { col1: { @@ -174,6 +192,7 @@ describe('terms', () => { interval: 'w', }, sourceField: 'timestamp', + indexPatternId: '1', }, } ); @@ -189,7 +208,7 @@ describe('terms', () => { 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); @@ -208,25 +227,32 @@ describe('terms', () => { ); @@ -238,7 +264,7 @@ describe('terms', () => { it('should update state with the order by value', () => { const setStateSpy = jest.fn(); const instance = shallow( - + ); instance @@ -252,15 +278,20 @@ describe('terms', () => { expect(setStateSpy).toHaveBeenCalledWith({ ...state, - columns: { - ...state.columns, - col1: { - ...state.columns.col1, - params: { - ...(state.columns.col1 as TermsIndexPatternColumn).params, - orderBy: { - type: 'column', - columnId: 'col2', + 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', + }, + }, }, }, }, @@ -271,7 +302,7 @@ describe('terms', () => { it('should render current order direction value and options', () => { const setStateSpy = jest.fn(); const instance = shallow( - + ); const select = instance @@ -285,7 +316,7 @@ describe('terms', () => { it('should update state with the order direction value', () => { const setStateSpy = jest.fn(); const instance = shallow( - + ); instance @@ -299,13 +330,18 @@ describe('terms', () => { expect(setStateSpy).toHaveBeenCalledWith({ ...state, - columns: { - ...state.columns, - col1: { - ...state.columns.col1, - params: { - ...(state.columns.col1 as TermsIndexPatternColumn).params, - orderDirection: 'desc', + 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', + }, + }, }, }, }, @@ -315,7 +351,7 @@ describe('terms', () => { it('should render current size value', () => { const setStateSpy = jest.fn(); const instance = shallow( - + ); expect(instance.find(EuiRange).prop('value')).toEqual(5); @@ -324,7 +360,7 @@ describe('terms', () => { it('should update state with the size value', () => { const setStateSpy = jest.fn(); const instance = shallow( - + ); instance.find(EuiRange).prop('onChange')!({ @@ -334,13 +370,18 @@ describe('terms', () => { } as React.ChangeEvent); expect(setStateSpy).toHaveBeenCalledWith({ ...state, - columns: { - ...state.columns, - col1: { - ...state.columns.col1, - params: { - ...(state.columns.col1 as TermsIndexPatternColumn).params, - size: 7, + 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/operation_definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx index e36456ebe5dd3..a3705526f4474 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -7,8 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiForm, EuiFormRow, EuiRange, EuiSelect } from '@elastic/eui'; -import { IndexPatternField, TermsIndexPatternColumn, IndexPatternColumn } from '../indexpattern'; -import { DimensionPriority } from '../../types'; +import { TermsIndexPatternColumn, IndexPatternColumn } from '../indexpattern'; import { OperationDefinition } from '../operations'; import { updateColumnParam } from '../state_helpers'; @@ -46,12 +45,7 @@ export const termsOperation: OperationDefinition = { type === 'string' && (!aggregationRestrictions || aggregationRestrictions.terms) ); }, - buildColumn( - operationId: string, - columns: Partial>, - suggestedOrder?: DimensionPriority, - field?: IndexPatternField - ): TermsIndexPatternColumn { + buildColumn({ operationId, suggestedPriority, columns, field, indexPatternId }) { const existingMetricColumn = Object.entries(columns) .filter(([_columnId, column]) => column && isSortableByColumn(column)) .map(([id]) => id)[0]; @@ -61,9 +55,10 @@ export const termsOperation: OperationDefinition = { label: ofName(field ? field.name : ''), dataType: 'string', operationType: 'terms', - suggestedOrder, + suggestedPriority, sourceField: field ? field.name : '', isBucketed: true, + indexPatternId, params: { size: 5, orderBy: existingMetricColumn @@ -107,8 +102,8 @@ export const termsOperation: OperationDefinition = { } return currentColumn; }, - paramEditor: ({ state, setState, columnId: currentColumnId }) => { - const currentColumn = state.columns[currentColumnId] as TermsIndexPatternColumn; + paramEditor: ({ state, setState, columnId: currentColumnId, layerId }) => { + const currentColumn = state.layers[layerId].columns[currentColumnId] as TermsIndexPatternColumn; const SEPARATOR = '$$$'; function toValue(orderBy: TermsIndexPatternColumn['params']['orderBy']) { if (orderBy.type === 'alphabetical') { @@ -128,7 +123,7 @@ export const termsOperation: OperationDefinition = { }; } - const orderOptions = Object.entries(state.columns) + const orderOptions = Object.entries(state.layers[layerId].columns) .filter(([_columnId, column]) => isSortableByColumn(column)) .map(([columnId, column]) => { return { @@ -156,7 +151,9 @@ export const termsOperation: OperationDefinition = { value={currentColumn.params.size} showInput onChange={(e: React.ChangeEvent) => - setState(updateColumnParam(state, currentColumn, 'size', Number(e.target.value))) + setState( + updateColumnParam(state, layerId, currentColumn, 'size', Number(e.target.value)) + ) } aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', { defaultMessage: 'Number of values', @@ -174,7 +171,13 @@ export const termsOperation: OperationDefinition = { value={toValue(currentColumn.params.orderBy)} onChange={(e: React.ChangeEvent) => setState( - updateColumnParam(state, currentColumn, 'orderBy', fromValue(e.target.value)) + updateColumnParam( + state, + layerId, + currentColumn, + 'orderBy', + fromValue(e.target.value) + ) ) } aria-label={i18n.translate('xpack.lens.indexPattern.terms.orderBy', { @@ -206,9 +209,8 @@ export const termsOperation: OperationDefinition = { value={currentColumn.params.orderDirection} onChange={(e: React.ChangeEvent) => setState( - updateColumnParam(state, currentColumn, 'orderDirection', e.target.value as - | 'asc' - | 'desc') + updateColumnParam(state, layerId, currentColumn, 'orderDirection', e.target + .value as 'asc' | 'desc') ) } aria-label={i18n.translate('xpack.lens.indexPattern.terms.orderBy', { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts index 7ce2afc831d4a..bb6a72c136d27 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -148,19 +148,25 @@ describe('getOperationTypesForField', () => { state = { indexPatterns: expectedIndexPatterns, currentIndexPatternId: '1', - columnOrder: ['col1'], - columns: { - col1: { - operationId: 'op1', - label: 'Date Histogram of timestamp', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - params: { - interval: 'h', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + indexPatternId: '1', + params: { + interval: 'h', + }, + }, }, }, }, @@ -168,16 +174,22 @@ describe('getOperationTypesForField', () => { }); it('should include priority', () => { - const columns = getPotentialColumns( - state.indexPatterns[state.currentIndexPatternId].fields, - 1 - ); - - expect(columns.every(col => col.suggestedOrder === 1)).toEqual(true); + const columns = getPotentialColumns({ + fields: state.indexPatterns[state.currentIndexPatternId].fields, + suggestedPriority: 1, + layerId: 'first', + layer: state.layers.first, + }); + + expect(columns.every(col => col.suggestedPriority === 1)).toEqual(true); }); it('should list operations by field for a regular index pattern', () => { - const columns = getPotentialColumns(state.indexPatterns[state.currentIndexPatternId].fields); + const columns = getPotentialColumns({ + fields: state.indexPatterns[state.currentIndexPatternId].fields, + layerId: 'first', + layer: state.layers.first, + }); expect( columns.map(col => [hasField(col) ? col.sourceField : '_documents_', col.operationType]) diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index 700be3c17a159..b8ec30c43e88b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -11,6 +11,7 @@ import { IndexPatternColumn, IndexPatternField, IndexPatternPrivateState, + IndexPatternLayer, OperationType, BaseIndexPatternColumn, } from './indexpattern'; @@ -61,6 +62,7 @@ export interface ParamEditorProps { state: IndexPatternPrivateState; setState: (newState: IndexPatternPrivateState) => void; columnId: string; + layerId: string; dataPlugin?: DataSetup; storage?: Storage; } @@ -71,12 +73,14 @@ export interface OperationDefinition { // TODO make this a function dependend on the indexpattern with typeMeta information isApplicableWithoutField: boolean; isApplicableForField: (field: IndexPatternField) => boolean; - buildColumn: ( - operationId: string, - columns: Partial>, - suggestedOrder?: DimensionPriority, - field?: IndexPatternField - ) => C; + buildColumn: (arg: { + operationId: string; + suggestedPriority: DimensionPriority | undefined; + layerId: string; + indexPatternId: string; + columns: Partial>; + field?: IndexPatternField; + }) => C; onOtherColumnChanged?: ( currentColumn: C, columns: Partial> @@ -108,33 +112,73 @@ export function getOperationTypesForField(field: IndexPatternField): OperationTy .map(({ type }) => type); } -export function buildColumnForOperationType( - index: number, - op: T, - columns: Partial>, - suggestedOrder?: DimensionPriority, - field?: IndexPatternField -): IndexPatternColumn { - return operationDefinitionMap[op].buildColumn(`${index}${op}`, columns, suggestedOrder, field); +export function buildColumnForOperationType({ + index, + op, + columns, + field, + layerId, + indexPatternId, + suggestedPriority, +}: { + index: number; + op: T; + columns: Partial>; + suggestedPriority: DimensionPriority | undefined; + layerId: string; + indexPatternId: string; + field?: IndexPatternField; +}): IndexPatternColumn { + return operationDefinitionMap[op].buildColumn({ + operationId: `${index}${op}`, + columns, + suggestedPriority, + field, + layerId, + indexPatternId, + }); } -export function getPotentialColumns( - fields: IndexPatternField[], - suggestedOrder?: DimensionPriority -): IndexPatternColumn[] { +export function getPotentialColumns({ + fields, + suggestedPriority, + layerId, + layer, +}: { + fields: IndexPatternField[]; + suggestedPriority?: DimensionPriority; + layerId: string; + layer: IndexPatternLayer; +}): IndexPatternColumn[] { const result: IndexPatternColumn[] = fields .map((field, index) => { const validOperations = getOperationTypesForField(field); return validOperations.map(op => - buildColumnForOperationType(index, op, {}, suggestedOrder, field) + buildColumnForOperationType({ + index, + op, + columns: layer.columns, + suggestedPriority, + field, + indexPatternId: layer.indexPatternId, + layerId, + }) ); }) .reduce((prev, current) => prev.concat(current)); operationDefinitions.forEach(operation => { if (operation.isApplicableWithoutField) { - result.push(operation.buildColumn(operation.type, {}, suggestedOrder)); + result.push( + operation.buildColumn({ + operationId: operation.type, + suggestedPriority, + layerId, + columns: layer.columns, + indexPatternId: layer.indexPatternId, + }) + ); } }); 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 index 2f4684d5c617d..2c9eef5fbfd4c 100644 --- 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 @@ -32,27 +32,36 @@ describe('state_helpers', () => { orderDirection: 'asc', size: 5, }, + indexPatternId: '', }; const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', - columnOrder: ['col1', 'col2'], - columns: { - col1: termsColumn, - col2: { - operationId: 'op1', - label: 'Count', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'count', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + indexPatternId: '1', + }, + }, }, }, }; - expect(deleteColumn(state, 'col2').columns).toEqual({ + expect( + deleteColumn({ state, columnId: 'col2', layerId: 'first' }).layers.first.columns + ).toEqual({ col1: termsColumn, }); }); @@ -72,27 +81,38 @@ describe('state_helpers', () => { orderDirection: 'asc', size: 5, }, + indexPatternId: '', }; const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', - columnOrder: ['col1', 'col2'], - columns: { - col1: termsColumn, - col2: { - operationId: 'op1', - label: 'Count', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'count', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + indexPatternId: '1', + }, + }, }, }, }; - deleteColumn(state, 'col2'); + deleteColumn({ + state, + columnId: 'col2', + layerId: 'first', + }); expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { col1: termsColumn, @@ -114,18 +134,26 @@ describe('state_helpers', () => { interval: '1d', }, sourceField: 'timestamp', + indexPatternId: '1', }; const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', - columnOrder: ['col1'], - columns: { - col1: currentColumn, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }, }, }; - expect(updateColumnParam(state, currentColumn, 'interval', 'M').columns.col1).toEqual({ + expect( + updateColumnParam(state, 'first', currentColumn, 'interval', 'M').layers.first.columns.col1 + ).toEqual({ ...currentColumn, params: { interval: 'M' }, }); @@ -137,86 +165,114 @@ describe('state_helpers', () => { const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', - columnOrder: ['col1', 'col2'], - columns: { - col1: { - operationId: 'op1', - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'avg', - sourceField: 'bytes', - }, - col2: { - operationId: 'op1', - label: 'Max of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'max', - sourceField: 'bytes', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + operationId: 'op1', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + indexPatternId: '1', + }, + col2: { + operationId: 'op1', + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + indexPatternId: '1', + }, + }, }, }, }; expect( - changeColumn(state, 'col2', { - operationId: 'op1', - label: 'Date histogram of timestamp', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - params: { - interval: '1d', + changeColumn({ + state, + columnId: 'col2', + layerId: 'first', + newColumn: { + operationId: 'op1', + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + indexPatternId: '1', }, - sourceField: 'timestamp', }) - ).toEqual( - expect.objectContaining({ - columnOrder: ['col2', 'col1'], - }) - ); + ).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', - columnOrder: ['col1'], - columns: { - col1: { - operationId: 'op1', - label: 'Date histogram of timestamp', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + indexPatternId: '1', + }, + }, + }, + }, + }; + expect( + changeColumn({ + state, + layerId: 'first', + columnId: 'col2', + newColumn: { + operationId: 'op2', + label: 'Date histogram of order_date', dataType: 'date', isBucketed: true, // Private operationType: 'date_histogram', - sourceField: 'timestamp', + sourceField: 'order_date', params: { - interval: 'h', + interval: 'w', }, + indexPatternId: '1', }, - }, - }; - expect( - changeColumn(state, 'col2', { - operationId: 'op2', - label: 'Date histogram of order_date', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - sourceField: 'order_date', - params: { - interval: 'w', - }, - }).columns.col1 + }).layers.first.columns.col1 ).toEqual( expect.objectContaining({ params: { interval: 'h' }, @@ -239,6 +295,7 @@ describe('state_helpers', () => { orderDirection: 'asc', size: 5, }, + indexPatternId: '1', }; const newColumn: AvgIndexPatternColumn = { @@ -250,27 +307,39 @@ describe('state_helpers', () => { // Private operationType: 'avg', sourceField: 'bytes', + indexPatternId: '1', }; const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', - columnOrder: ['col1', 'col2'], - columns: { - col1: termsColumn, - col2: { - operationId: 'op1', - label: 'Count', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'count', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + indexPatternId: '1', + }, + }, }, }, }; - changeColumn(state, 'col2', newColumn); + changeColumn({ + state, + layerId: 'first', + columnId: 'col2', + newColumn, + }); expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { col1: termsColumn, @@ -299,6 +368,7 @@ describe('state_helpers', () => { params: { interval: 'h', }, + indexPatternId: '1', }, }) ).toEqual(['col1']); @@ -323,6 +393,7 @@ describe('state_helpers', () => { }, orderDirection: 'asc', }, + indexPatternId: '1', }, col2: { operationId: 'op2', @@ -333,6 +404,7 @@ describe('state_helpers', () => { // Private operationType: 'avg', sourceField: 'bytes', + indexPatternId: '1', }, col3: { operationId: 'op3', @@ -346,6 +418,7 @@ describe('state_helpers', () => { params: { interval: '1d', }, + indexPatternId: '1', }, }) ).toEqual(['col1', 'col3', 'col2']); @@ -370,7 +443,8 @@ describe('state_helpers', () => { }, orderDirection: 'asc', }, - suggestedOrder: 2, + suggestedPriority: 2, + indexPatternId: '1', }, col2: { operationId: 'op2', @@ -381,7 +455,8 @@ describe('state_helpers', () => { // Private operationType: 'avg', sourceField: 'bytes', - suggestedOrder: 0, + suggestedPriority: 0, + indexPatternId: '1', }, col3: { operationId: 'op3', @@ -392,10 +467,11 @@ describe('state_helpers', () => { // Private operationType: 'date_histogram', sourceField: 'timestamp', - suggestedOrder: 1, + suggestedPriority: 1, params: { interval: '1d', }, + indexPatternId: '1', }, }) ).toEqual(['col3', 'col1', 'col2']); 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 index 394700d4469b6..9f95b3726ea3a 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts @@ -17,35 +17,42 @@ export function updateColumnParam< K extends keyof C['params'] >( state: IndexPatternPrivateState, + layerId: string, currentColumn: C, paramName: K, value: C['params'][K] ): IndexPatternPrivateState { - const columnId = Object.entries(state.columns).find( + const columnId = Object.entries(state.layers[layerId].columns).find( ([_columnId, column]) => column === currentColumn )![0]; - if (!('params' in state.columns[columnId])) { + if (!('params' in state.layers[layerId].columns[columnId])) { throw new Error('Invariant: no params in this column'); } return { ...state, - columns: { - ...state.columns, - [columnId]: ({ - ...currentColumn, - params: { - ...currentColumn.params, - [paramName]: value, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columns: { + ...state.layers[layerId].columns, + [columnId]: ({ + ...currentColumn, + params: { + ...currentColumn.params, + [paramName]: value, + }, + } as unknown) as IndexPatternColumn, }, - } as unknown) as IndexPatternColumn, + }, }, }; } function adjustColumnReferencesForChangedColumn( - columns: IndexPatternPrivateState['columns'], + columns: Record, columnId: string ) { const newColumns = { ...columns }; @@ -63,25 +70,32 @@ function adjustColumnReferencesForChangedColumn( return newColumns; } -export function changeColumn( - state: IndexPatternPrivateState, - columnId: string, - newColumn: IndexPatternColumn, - { keepParams }: { keepParams: boolean } = { keepParams: true } -) { - const oldColumn = state.columns[columnId]; +export function changeColumn({ + state, + layerId, + columnId, + newColumn, + keepParams, +}: { + state: IndexPatternPrivateState; + layerId: string; + columnId: string; + newColumn: IndexPatternColumn; + keepParams?: boolean; +}): IndexPatternPrivateState { + const oldColumn = state.layers[layerId].columns[columnId]; const updatedColumn = - keepParams && + (typeof keepParams === 'boolean' ? keepParams : true) && oldColumn && oldColumn.operationType === newColumn.operationType && 'params' in oldColumn ? ({ ...newColumn, params: oldColumn.params } as IndexPatternColumn) : newColumn; - const newColumns: IndexPatternPrivateState['columns'] = adjustColumnReferencesForChangedColumn( + const newColumns = adjustColumnReferencesForChangedColumn( { - ...state.columns, + ...state.layers[layerId].columns, [columnId]: updatedColumn, }, columnId @@ -89,37 +103,56 @@ export function changeColumn( return { ...state, - columnOrder: getColumnOrder(newColumns), - columns: newColumns, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }, + }, }; } -export function deleteColumn(state: IndexPatternPrivateState, columnId: string) { - const columns: IndexPatternPrivateState['columns'] = { - ...state.columns, - }; - delete columns[columnId]; - - const newColumns = adjustColumnReferencesForChangedColumn(columns, columnId); +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, - columnOrder: getColumnOrder(newColumns), - columns: newColumns, + 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, col => col[1].isBucketed); + const [aggregations, metrics] = _.partition(entries, ([id, col]) => col.isBucketed); return aggregations .sort(([id, col], [id2, col2]) => { return ( // Sort undefined orders last - (col.suggestedOrder !== undefined ? col.suggestedOrder : Number.MAX_SAFE_INTEGER) - - (col2.suggestedOrder !== undefined ? col2.suggestedOrder : Number.MAX_SAFE_INTEGER) + (col.suggestedPriority !== undefined ? col.suggestedPriority : Number.MAX_SAFE_INTEGER) - + (col2.suggestedPriority !== undefined ? col2.suggestedPriority : Number.MAX_SAFE_INTEGER) ); }) .map(([id]) => id) 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 index 8ed92c2b5fca0..8b9c4d3d9df27 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -13,8 +13,13 @@ import { OperationDefinition, } from './operations'; -export function toExpression(state: IndexPatternPrivateState) { - if (state.columnOrder.length === 0) { +function getExpressionForLayer( + indexPatternId: string, + layerId: string, + columns: Record, + columnOrder: string[] +) { + if (columnOrder.length === 0) { return null; } @@ -27,8 +32,8 @@ export function toExpression(state: IndexPatternPrivateState) { return operationDefinition.toEsAggsConfig(column, columnId); } - const columnEntries = state.columnOrder.map( - colId => [colId, state.columns[colId]] as [string, IndexPatternColumn] + const columnEntries = columnOrder.map( + colId => [colId, columns[colId]] as [string, IndexPatternColumn] ); if (columnEntries.length) { @@ -51,16 +56,18 @@ export function toExpression(state: IndexPatternPrivateState) { ); if (filterRatios.length) { - const countColumn = buildColumnForOperationType( - columnEntries.length, - 'count', - state.columns, - 2 - ); + const countColumn = buildColumnForOperationType({ + index: columnEntries.length, + op: 'count', + columns, + suggestedPriority: 2, + layerId, + indexPatternId, + }); aggs.push(getEsAggsConfig(countColumn, 'filter-ratio')); return `esaggs - index="${state.currentIndexPatternId}" + index="${indexPatternId}" metricsAtAllLevels=false partialRows=false aggConfigs='${JSON.stringify(aggs)}' | lens_rename_columns idMap='${JSON.stringify( @@ -69,7 +76,7 @@ export function toExpression(state: IndexPatternPrivateState) { } return `esaggs - index="${state.currentIndexPatternId}" + index="${indexPatternId}" metricsAtAllLevels=false partialRows=false aggConfigs='${JSON.stringify(aggs)}' | lens_rename_columns idMap='${JSON.stringify(idMap)}'`; @@ -77,3 +84,16 @@ export function toExpression(state: IndexPatternPrivateState) { return null; } + +export function toExpression(state: IndexPatternPrivateState, layerId: string) { + if (state.layers[layerId]) { + return getExpressionForLayer( + state.layers[layerId].indexPatternId, + layerId, + state.layers[layerId].columns, + state.layers[layerId].columnOrder + ); + } + + return null; +} 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/xy_visualization_plugin/multi_column_editor.tsx b/x-pack/legacy/plugins/lens/public/multi_column_editor/multi_column_editor.tsx similarity index 57% rename from x-pack/legacy/plugins/lens/public/xy_visualization_plugin/multi_column_editor.tsx rename to x-pack/legacy/plugins/lens/public/multi_column_editor/multi_column_editor.tsx index f0f804eacf864..2e46f43d79622 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/multi_column_editor.tsx +++ b/x-pack/legacy/plugins/lens/public/multi_column_editor/multi_column_editor.tsx @@ -4,11 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiButtonIcon, EuiButton } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import React, { useEffect } from 'react'; import { NativeRenderer } from '../native_renderer'; -import { generateId } from '../id_generator'; import { DatasourcePublicAPI, Operation } from '../types'; import { DragContextState } from '../drag_drop'; @@ -17,10 +14,11 @@ interface Props { datasource: DatasourcePublicAPI; dragDropContext: DragContextState; onRemove: (accessor: string) => void; - onAdd: (accessor: string) => void; + onAdd: () => void; filterOperations: (op: Operation) => boolean; suggestedPriority?: 0 | 1 | 2 | undefined; testSubj: string; + layerId: string; } export function MultiColumnEditor({ @@ -32,10 +30,19 @@ export function MultiColumnEditor({ 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, i) => ( + {accessors.map(accessor => (
- {i === accessors.length - 1 ? null : ( - { - datasource.removeColumnInTableSpec(accessor); - onRemove(accessor); - }} - aria-label={i18n.translate('xpack.lens.xyChart.removeAriaLabel', { - defaultMessage: 'Remove', - })} - /> - )}
))} - onAdd(generateId())} - iconType="plusInCircle" - /> ); } 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 index 53d2a0cc08ad1..f46164d9abb9f 100644 --- 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 @@ -26,9 +26,11 @@ describe('LensStore', () => { const doc = await store.save({ title: 'Hello', visualizationType: 'bar', - datasourceType: 'indexpattern', + activeDatasourceId: 'indexpattern', state: { - datasource: { type: 'index_pattern', indexPattern: '.kibana_test' }, + datasourceStates: { + indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, + }, visualization: { x: 'foo', y: 'baz' }, }, }); @@ -37,20 +39,24 @@ describe('LensStore', () => { id: 'FOO', title: 'Hello', visualizationType: 'bar', - datasourceType: 'indexpattern', + activeDatasourceId: 'indexpattern', state: { - datasource: { type: 'index_pattern', indexPattern: '.kibana_test' }, + datasourceStates: { + indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, + }, visualization: { x: 'foo', y: 'baz' }, }, }); expect(client.create).toHaveBeenCalledTimes(1); expect(client.create).toHaveBeenCalledWith('lens', { - datasourceType: 'indexpattern', title: 'Hello', visualizationType: 'bar', + activeDatasourceId: 'indexpattern', state: JSON.stringify({ - datasource: { type: 'index_pattern', indexPattern: '.kibana_test' }, + datasourceStates: { + indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, + }, visualization: { x: 'foo', y: 'baz' }, }), }); @@ -62,9 +68,11 @@ describe('LensStore', () => { id: 'Gandalf', title: 'Even the very wise cannot see all ends.', visualizationType: 'line', - datasourceType: 'indexpattern', + activeDatasourceId: 'indexpattern', state: { - datasource: { type: 'index_pattern', indexPattern: 'lotr' }, + datasourceStates: { + indexpattern: { type: 'index_pattern', indexPattern: 'lotr' }, + }, visualization: { gear: ['staff', 'pointy hat'] }, }, }); @@ -73,9 +81,11 @@ describe('LensStore', () => { id: 'Gandalf', title: 'Even the very wise cannot see all ends.', visualizationType: 'line', - datasourceType: 'indexpattern', + activeDatasourceId: 'indexpattern', state: { - datasource: { type: 'index_pattern', indexPattern: 'lotr' }, + datasourceStates: { + indexpattern: { type: 'index_pattern', indexPattern: 'lotr' }, + }, visualization: { gear: ['staff', 'pointy hat'] }, }, }); @@ -84,9 +94,11 @@ describe('LensStore', () => { expect(client.update).toHaveBeenCalledWith('lens', 'Gandalf', { title: 'Even the very wise cannot see all ends.', visualizationType: 'line', - datasourceType: 'indexpattern', + activeDatasourceId: 'indexpattern', state: JSON.stringify({ - datasource: { type: 'index_pattern', indexPattern: 'lotr' }, + datasourceStates: { + indexpattern: { type: 'index_pattern', indexPattern: 'lotr' }, + }, visualization: { gear: ['staff', 'pointy hat'] }, }), }); 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 index 930ee36ea3729..d90f1d4514174 100644 --- a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts +++ b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts @@ -10,10 +10,11 @@ export interface Document { id?: string; type?: string; visualizationType: string | null; - datasourceType: string | null; title: string; + activeDatasourceId: string; + // The state is saved as a JSON string for now state: { - datasource: unknown; + datasourceStates: Record; visualization: unknown; }; } diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 9812db7440891..3c75377991b3e 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -27,7 +27,7 @@ export interface EditorFrameSetup { // Hints the default nesting to the data source. 0 is the highest priority export type DimensionPriority = 0 | 1 | 2; -export interface TableColumn { +export interface TableSuggestionColumn { columnId: string; operation: Operation; } @@ -35,7 +35,8 @@ export interface TableColumn { export interface TableSuggestion { datasourceSuggestionId: number; isMultiRow: boolean; - columns: TableColumn[]; + columns: TableSuggestionColumn[]; + layerId: string; } export interface DatasourceSuggestion { @@ -55,14 +56,18 @@ export interface Datasource { // 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) => Ast | string | null; + toExpression: (state: T, layerId: string) => Ast | string | null; getDatasourceSuggestionsForField: (state: T, field: unknown) => Array>; getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; - getPublicAPI: (state: T, setState: (newState: T) => void) => DatasourcePublicAPI; + getPublicAPI: (state: T, setState: (newState: T) => void, layerId: string) => DatasourcePublicAPI; } /** @@ -74,12 +79,21 @@ export interface DatasourcePublicAPI { // 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; @@ -88,7 +102,7 @@ export interface DatasourceDataPanelProps { // The only way a visualization has to restrict the query building export interface DatasourceDimensionPanelProps { - // If no columnId is passed, it will render as empty + layerId: string; columnId: string; dragDropContext: DragContextState; @@ -99,6 +113,11 @@ export interface DatasourceDimensionPanelProps { // 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'; @@ -121,14 +140,11 @@ export interface Operation { // Extra meta-information like cardinality, color } -export interface TableSpecColumn { - // Column IDs are the keys for internal state in data sources and visualizations - columnId: string; +export interface LensMultiTable { + type: 'lens_multitable'; + tables: Record; } -// TableSpec is managed by visualizations -export type TableSpec = TableSpecColumn[]; - // This is a temporary type definition, to be replaced with // the official Kibana Datatable type definition. export interface KibanaDatatable { @@ -139,7 +155,7 @@ export interface KibanaDatatable { export interface VisualizationProps { dragDropContext: DragContextState; - datasource: DatasourcePublicAPI; + frame: FramePublicAPI; state: T; setState: (newState: T) => void; } @@ -159,15 +175,22 @@ export interface VisualizationSuggestion { previewIcon: string; } +export interface FramePublicAPI { + datasourceLayers: Record; + // Adds a new layer. This has a side effect of updating the datasource state + addNewLayer: () => string; + removeLayer: (layerId: string) => void; +} + export interface Visualization { // For initializing from saved object - initialize: (datasource: DatasourcePublicAPI, state?: P) => T; + initialize: (frame: FramePublicAPI, state?: P) => T; getPersistableState: (state: T) => P; renderConfigPanel: (domElement: Element, props: VisualizationProps) => void; - toExpression: (state: T, datasource: DatasourcePublicAPI) => Ast | string | null; + 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 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 index 0c072d1fe5bf2..5eda0db6d6c40 100644 --- 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 @@ -15,13 +15,13 @@ exports[`xy_expression XYChart component it renders area 1`] = ` id="x" position="bottom" showGridLines={false} - title="C" + title="" /> legendConfig); interpreter.functionsRegistry.register(() => xConfig); - interpreter.functionsRegistry.register(() => yConfig); + interpreter.functionsRegistry.register(() => layerConfig); interpreter.functionsRegistry.register(() => xyChart); interpreter.renderersRegistry.register(() => xyChartRenderer as RenderFunction); 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 index 80ee1ca4e67da..cec80c9dbed96 100644 --- 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 @@ -5,24 +5,63 @@ */ import { Ast } from '@kbn/interpreter/common'; -import { State } from './types'; -import { DatasourcePublicAPI } from '../types'; +import { State, LayerConfig } from './types'; +import { FramePublicAPI } from '../types'; -export const toExpression = (state: State, datasource: DatasourcePublicAPI): Ast => { - const labels: Partial> = {}; - state.y.accessors.forEach(columnId => { - const operation = datasource.getOperationForColumnId(columnId); - if (operation && operation.label) { - labels[columnId] = operation.label; - } - }); +function xyTitles(layer: LayerConfig, frame: FramePublicAPI) { + const defaults = { + xTitle: 'x', + yTitle: 'y', + }; - return buildExpression(state, labels); + 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)) + ), + }; + }), + }; + + return buildExpression(stateWithValidAccessors, xyTitles(state.layers[0], frame), frame); }; export const buildExpression = ( state: State, - columnLabels: Partial> + { xTitle, yTitle }: { xTitle: string; yTitle: string }, + frame?: FramePublicAPI ): Ast => ({ type: 'expression', chain: [ @@ -30,7 +69,8 @@ export const buildExpression = ( type: 'function', function: 'lens_xy_chart', arguments: { - seriesType: [state.seriesType], + xTitle: [xTitle], + yTitle: [yTitle], legend: [ { type: 'expression', @@ -46,46 +86,43 @@ export const buildExpression = ( ], }, ], - x: [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_xy_xConfig', - arguments: { - title: [state.x.title], - showGridlines: [state.x.showGridlines], - position: [state.x.position], - accessor: [state.x.accessor], - hide: [Boolean(state.x.hide)], - }, - }, - ], - }, - ], - y: [ - { + 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; + } + }); + } + + return { type: 'expression', chain: [ { type: 'function', - function: 'lens_xy_yConfig', + function: 'lens_xy_layer', arguments: { - title: [state.y.title], - showGridlines: [state.y.showGridlines], - position: [state.y.position], - accessors: state.y.accessors, - hide: [Boolean(state.y.hide)], - labels: state.y.accessors.map(accessor => { - return columnLabels[accessor] || accessor; - }), + layerId: [layer.layerId], + + title: [layer.title], + showGridlines: [layer.showGridlines], + position: [layer.position], + hide: [Boolean(layer.hide)], + + xAccessor: [layer.xAccessor], + splitAccessor: [layer.splitAccessor], + seriesType: [layer.seriesType], + accessors: layer.accessors, + columnToLabel: [JSON.stringify(columnToLabel)], }, }, ], - }, - ], - splitSeriesAccessors: state.splitSeriesAccessors, + }; + }), }, }, ], 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 index d2b3b962f10ee..90579747e6862 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -81,66 +81,91 @@ export interface YState extends AxisConfig { accessors: string[]; } -export type YConfig = AxisConfig & - YState & { - labels: string[]; - }; +export interface XConfig extends AxisConfig { + accessor: string; +} -type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; +type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; -export const yConfig: ExpressionFunction<'lens_xy_yConfig', null, YConfig, YConfigResult> = { - name: 'lens_xy_yConfig', +export const xConfig: ExpressionFunction<'lens_xy_xConfig', null, XConfig, XConfigResult> = { + name: 'lens_xy_xConfig', aliases: [], - type: 'lens_xy_yConfig', - help: `Configure the xy chart's y axis`, + type: 'lens_xy_xConfig', + help: `Configure the xy chart's x axis`, context: { types: ['null'], }, args: { ...axisConfig, - accessors: { - types: ['string'], - help: 'The columns to display on the y axis.', - multi: true, - }, - labels: { + accessor: { types: ['string'], - help: '', - multi: true, + help: 'The column to display on the x axis.', }, }, - fn: function fn(_context: unknown, args: YConfig) { + fn: function fn(_context: unknown, args: XConfig) { return { - type: 'lens_xy_yConfig', + type: 'lens_xy_xConfig', ...args, }; }, }; -export interface XConfig extends AxisConfig { - accessor: string; -} - -type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; +type LayerConfigResult = LayerArgs & { type: 'lens_xy_layer' }; -export const xConfig: ExpressionFunction<'lens_xy_xConfig', null, XConfig, XConfigResult> = { - name: 'lens_xy_xConfig', +export const layerConfig: ExpressionFunction< + 'lens_xy_layer', + null, + LayerArgs, + LayerConfigResult +> = { + name: 'lens_xy_layer', aliases: [], - type: 'lens_xy_xConfig', - help: `Configure the xy chart's x axis`, + type: 'lens_xy_layer', + help: `Configure a layer in the xy chart`, context: { types: ['null'], }, args: { ...axisConfig, - accessor: { + layerId: { types: ['string'], - help: 'The column to display on the x axis.', + help: '', + }, + xAccessor: { + types: ['string'], + help: '', + }, + seriesType: { + types: ['string'], + options: [ + 'bar', + 'line', + 'area', + 'horizontal_bar', + 'bar_stacked', + 'area_stacked', + 'horizontal_bar_stacked', + ], + help: 'The type of chart to display.', + }, + 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: XConfig) { + fn: function fn(_context: unknown, args: LayerArgs) { return { - type: 'lens_xy_xConfig', + type: 'lens_xy_layer', ...args, }; }, @@ -155,20 +180,30 @@ export type SeriesType = | 'horizontal_bar_stacked' | 'area_stacked'; -export interface XYArgs { +export type LayerConfig = AxisConfig & { + layerId: string; + xAccessor: string; + accessors: string[]; seriesType: SeriesType; + splitAccessor: string; +}; + +export type LayerArgs = LayerConfig & { + columnToLabel?: string; // Actually a JSON key-value pair +}; + +// Arguments to XY chart expression, with computed properties +export interface XYArgs { + xTitle: string; + yTitle: string; legend: LegendConfig; - y: YConfig; - x: XConfig; - splitSeriesAccessors: string[]; + layers: LayerArgs[]; } +// Persisted parts of the state export interface XYState { - seriesType: SeriesType; legend: LegendConfig; - y: YState; - x: XConfig; - splitSeriesAccessors: string[]; + layers: LayerConfig[]; } export type State = XYState; 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 index e5d992ecd8550..15fcec52cbfca 100644 --- 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 @@ -9,45 +9,35 @@ 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 { DatasourcePublicAPI, DatasourceDimensionPanelProps, Operation } from '../types'; -import { State, SeriesType } from './types'; +import { DatasourceDimensionPanelProps, Operation, FramePublicAPI } from '../types'; +import { State } 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'; jest.mock('../id_generator'); describe('XYConfigPanel', () => { const dragDropContext = { dragging: undefined, setDragging: jest.fn() }; - function mockDatasource(): DatasourcePublicAPI { - return { - duplicateColumn: () => [], - getOperationForColumnId: () => null, - getTableSpec: () => [], - moveColumnTo: () => {}, - removeColumnInTableSpec: () => [], - renderDimensionPanel: () => {}, - }; - } + let frame: FramePublicAPI; function testState(): State { return { legend: { isVisible: true, position: Position.Right }, - seriesType: 'bar', - splitSeriesAccessors: [], - x: { - accessor: 'foo', - position: Position.Bottom, - showGridlines: true, - title: 'X', - }, - y: { - accessors: ['bar'], - position: Position.Left, - showGridlines: true, - title: 'Y', - }, + layers: [ + { + seriesType: 'bar', + layerId: 'first', + splitAccessor: 'baz', + xAccessor: 'foo', + position: Position.Bottom, + showGridlines: true, + title: 'X', + accessors: ['bar'], + }, + ], }; } @@ -58,13 +48,27 @@ describe('XYConfigPanel', () => { .props(); } - test('disables stacked chart types without a split series', () => { + 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('enables stacked chart types even when there is no split series', () => { + const state = testState(); const component = mount( {}} - state={testState()} + frame={frame} + setState={jest.fn()} + state={{ ...state, layers: [{ ...state.layers[0], xAccessor: 'shazm' }] }} /> ); @@ -83,169 +87,18 @@ describe('XYConfigPanel', () => { 'horizontal_bar_stacked', ]); - expect(options.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([ - 'area_stacked', - 'bar_stacked', - 'horizontal_bar_stacked', - ]); - }); - - test('enables all stacked chart types when there is a split series', () => { - const component = mount( - {}} - state={{ ...testState(), splitSeriesAccessors: ['c'] }} - /> - ); - - const options = component - .find('[data-test-subj="lnsXY_seriesType"]') - .first() - .prop('options') as EuiButtonGroupProps['options']; - - expect(options.every(({ isDisabled }) => !isDisabled)).toEqual(true); - }); - - test('toggles axis position when going from horizontal bar to any other type', () => { - const changeSeriesType = (fromSeriesType: SeriesType, toSeriesType: SeriesType) => { - const setState = jest.fn(); - const state = testState(); - const component = mount( - - ); - - (testSubj(component, 'lnsXY_seriesType').onChange as Function)(toSeriesType); - - expect(setState).toHaveBeenCalledTimes(1); - return setState.mock.calls[0][0]; - }; - - expect(changeSeriesType('line', 'horizontal_bar')).toMatchObject({ - seriesType: 'horizontal_bar', - x: { position: Position.Left }, - y: { position: Position.Bottom }, - }); - expect(changeSeriesType('horizontal_bar', 'bar')).toMatchObject({ - seriesType: 'bar', - x: { position: Position.Bottom }, - y: { position: Position.Left }, - }); - expect(changeSeriesType('horizontal_bar', 'line')).toMatchObject({ - seriesType: 'line', - x: { position: Position.Bottom }, - y: { position: Position.Left }, - }); - expect(changeSeriesType('horizontal_bar', 'area')).toMatchObject({ - seriesType: 'area', - x: { position: Position.Bottom }, - y: { position: Position.Left }, - }); - }); - - test('allows toggling of legend visibility', () => { - const toggleIsVisible = (isVisible: boolean) => { - const setState = jest.fn(); - const state = testState(); - const component = mount( - - ); - - (testSubj(component, 'lnsXY_legendIsVisible').onChange as Function)(); - - expect(setState).toHaveBeenCalledTimes(1); - return setState.mock.calls[0][0]; - }; - - expect(toggleIsVisible(false)).toMatchObject({ - legend: { isVisible: true }, - }); - expect(toggleIsVisible(true)).toMatchObject({ - legend: { isVisible: false }, - }); - }); - - test('allows changing legend position', () => { - const testLegendPosition = (position: Position) => { - const setState = jest.fn(); - const component = mount( - - ); - - (testSubj(component, 'lnsXY_legendPosition').onChange as Function)(position); - - expect(setState).toHaveBeenCalledTimes(1); - return setState.mock.calls[0][0]; - }; - - expect(testLegendPosition(Position.Bottom)).toMatchObject({ - legend: { position: Position.Bottom }, - }); - expect(testLegendPosition(Position.Top)).toMatchObject({ - legend: { position: Position.Top }, - }); - expect(testLegendPosition(Position.Left)).toMatchObject({ - legend: { position: Position.Left }, - }); - expect(testLegendPosition(Position.Right)).toMatchObject({ - legend: { position: Position.Right }, - }); - }); - - test('allows editing the x axis title', () => { - const testSetTitle = (title: string) => { - const setState = jest.fn(); - const component = mount( - - ); - - (testSubj(component, 'lnsXY_xTitle').onChange as Function)({ target: { value: title } }); - - expect(setState).toHaveBeenCalledTimes(1); - return setState.mock.calls[0][0]; - }; - - expect(testSetTitle('Hoi')).toMatchObject({ - x: { title: 'Hoi' }, - }); - expect(testSetTitle('There!')).toMatchObject({ - x: { title: 'There!' }, - }); + expect(options.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); }); - test('the x dimension panel accepts any operations', () => { - const datasource = { - ...mockDatasource(), - renderDimensionPanel: jest.fn(), - }; + test('the x dimension panel accepts only bucketed operations', () => { + // TODO: this should eventually also accept raw operation const state = testState(); const component = mount( ); @@ -258,87 +111,39 @@ describe('XYConfigPanel', () => { 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(ops); - }); - - test('allows toggling the x axis gridlines', () => { - const toggleXGridlines = (showGridlines: boolean) => { - const setState = jest.fn(); - const state = testState(); - const component = mount( - - ); - - (testSubj(component, 'lnsXY_xShowGridlines').onChange as Function)(); - - expect(setState).toHaveBeenCalledTimes(1); - return setState.mock.calls[0][0]; - }; - - expect(toggleXGridlines(true)).toMatchObject({ - x: { showGridlines: false }, - }); - expect(toggleXGridlines(false)).toMatchObject({ - x: { showGridlines: true }, - }); - }); - - test('allows editing the y axis title', () => { - const testSetTitle = (title: string) => { - const setState = jest.fn(); - const component = mount( - - ); - - (testSubj(component, 'lnsXY_yTitle').onChange as Function)({ target: { value: title } }); - - expect(setState).toHaveBeenCalledTimes(1); - return setState.mock.calls[0][0]; - }; - - expect(testSetTitle('Hoi')).toMatchObject({ - y: { title: 'Hoi' }, - }); - expect(testSetTitle('There!')).toMatchObject({ - y: { title: 'There!' }, - }); + expect(ops.filter(filterOperations)).toEqual(bucketedOps); }); test('the y dimension panel accepts numeric operations', () => { - const datasource = { - ...mockDatasource(), - renderDimensionPanel: jest.fn(), - }; const state = testState(); const component = mount( ); - const panel = testSubj(component, 'lnsXY_yDimensionPanel_a'); - const nativeProps = (panel as NativeRendererProps).nativeProps; - const { filterOperations } = nativeProps; + const filterOperations = component + .find('[data-test-subj="lensXY_yDimensionPanel"]') + .first() + .prop('filterOperations') as (op: Operation) => boolean; + const exampleOperation: Operation = { dataType: 'number', id: 'foo', @@ -355,98 +160,123 @@ describe('XYConfigPanel', () => { }); test('allows removal of y dimensions', () => { - const removeColumnInTableSpec = jest.fn(); - const datasource = { - ...mockDatasource(), - removeColumnInTableSpec, - }; const setState = jest.fn(); const state = testState(); const component = mount( ); - (testSubj(component, 'lnsXY_yDimensionPanel_remove_b').onClick as Function)(); + 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({ - y: { accessors: ['a', 'c'] }, + layers: [ + { + ...state.layers[0], + accessors: ['a', 'c'], + }, + ], }); - expect(removeColumnInTableSpec).toHaveBeenCalledTimes(1); - expect(removeColumnInTableSpec).toHaveBeenCalledWith('b'); }); - test('allows adding y dimensions', () => { + test('allows adding a y axis dimension', () => { (generateId as jest.Mock).mockReturnValueOnce('zed'); const setState = jest.fn(); const state = testState(); const component = mount( ); - (testSubj(component, 'lnsXY_yDimensionPanel_add').onClick as Function)(); + 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({ - y: { accessors: ['a', 'b', 'c', 'zed'] }, + layers: [ + { + ...state.layers[0], + accessors: ['a', 'b', 'c', 'zed'], + }, + ], }); }); - test('allows adding split dimensions', () => { - (generateId as jest.Mock).mockReturnValueOnce('foo'); - const setState = jest.fn(); - const state = testState(); - const component = mount( - - ); + 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( + + ); - (testSubj(component, 'lnsXY_splitSeriesDimensionPanel_add').onClick as Function)(); + component + .find('[data-test-subj="lnsXY_layer_add"]') + .first() + .simulate('click'); - expect(setState).toHaveBeenCalledTimes(1); - expect(setState.mock.calls[0][0]).toMatchObject({ - splitSeriesAccessors: ['a', 'b', 'c', 'foo'], + 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', + }), + ], + }); }); - }); - - test('allows toggling the y axis gridlines', () => { - const toggleYGridlines = (showGridlines: boolean) => { + it('removes layers', () => { const setState = jest.fn(); const state = testState(); const component = mount( ); - (testSubj(component, 'lnsXY_yShowGridlines').onChange as Function)(); + component + .find('[data-test-subj="lnsXY_layer_remove"]') + .first() + .simulate('click'); + expect(frame.removeLayer).toHaveBeenCalled(); expect(setState).toHaveBeenCalledTimes(1); - return setState.mock.calls[0][0]; - }; - - expect(toggleYGridlines(true)).toMatchObject({ - y: { showGridlines: false }, - }); - expect(toggleYGridlines(false)).toMatchObject({ - y: { showGridlines: true }, + 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 index 3e7f32c62d627..14d1523df6ec9 100644 --- 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 @@ -6,19 +6,22 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { Position } from '@elastic/charts'; import { - EuiFieldText, + EuiButton, EuiButtonGroup, EuiForm, EuiFormRow, - EuiSwitch, + EuiPanel, IconType, + EuiButtonIcon, } from '@elastic/eui'; -import { State, SeriesType } from './types'; +import { State, SeriesType, LayerConfig } from './types'; import { VisualizationProps } from '../types'; import { NativeRenderer } from '../native_renderer'; -import { MultiColumnEditor } from './multi_column_editor'; +import { MultiColumnEditor } from '../multi_column_editor'; +import { generateId } from '../id_generator'; export const chartTypeIcons: Array<{ id: SeriesType; label: string; iconType: IconType }> = [ { @@ -72,258 +75,179 @@ export const chartTypeIcons: Array<{ id: SeriesType; label: string; iconType: Ic }, ]; -const positionIcons = [ - { - id: Position.Left, - label: 'Left', - iconType: 'arrowLeft', - }, - { - id: Position.Top, - label: 'Top', - iconType: 'arrowUp', - }, - { - id: Position.Bottom, - label: 'Bottom', - iconType: 'arrowDown', - }, - { - id: Position.Right, - label: 'Right', - iconType: 'arrowRight', - }, -]; +type UnwrapArray = T extends Array ? P : T; -export function XYConfigPanel(props: VisualizationProps) { - const { state, datasource, setState } = props; +function updateLayer(state: State, layer: UnwrapArray, index: number): State { + const newLayers = [...state.layers]; + newLayers[index] = layer; - return ( - - - - type.id.includes('stacked') && state.splitSeriesAccessors.length === 0 - ? { ...type, isDisabled: true } - : type - )} - idSelected={state.seriesType} - onChange={seriesType => { - const isHorizontal = seriesType === 'horizontal_bar'; - setState({ - ...state, - seriesType: seriesType as SeriesType, - x: { - ...state.x, - position: isHorizontal ? Position.Left : Position.Bottom, - }, - y: { - ...state.y, - position: isHorizontal ? Position.Bottom : Position.Left, - }, - }); - }} - isIconOnly - /> - - - - - setState({ - ...state, - legend: { ...state.legend, isVisible: !state.legend.isVisible }, - }) - } - /> - + return { + ...state, + layers: newLayers, + }; +} - {state.legend.isVisible && ( - - - setState({ ...state, legend: { ...state.legend, position: position as Position } }) - } - isIconOnly - /> - - )} +function newLayerState(layerId: string): LayerConfig { + return { + layerId, + xAccessor: generateId(), + seriesType: 'bar_stacked', + accessors: [generateId()], + title: '', + showGridlines: false, + position: Position.Left, + splitAccessor: generateId(), + }; +} - - - setState({ ...state, splitSeriesAccessors: [...state.splitSeriesAccessors, accessor] }) - } - onRemove={accessor => - setState({ - ...state, - splitSeriesAccessors: state.splitSeriesAccessors.filter(col => col !== accessor), - }) - } - filterOperations={op => op.isBucketed && op.dataType !== 'date'} - suggestedPriority={0} - testSubj="splitSeriesDimensionPanel" - /> - +export function XYConfigPanel(props: VisualizationProps) { + const { state, setState, frame } = props; - - <> - - setState({ ...state, x: { ...state.x, title: e.target.value } })} - aria-label={i18n.translate('xpack.lens.xyChart.xTitleAriaLabel', { - defaultMessage: 'Title', + return ( + + {state.layers.map((layer, index) => ( + + + - - - - true, - }} - /> - + > + <> + { + frame.removeLayer(layer.layerId); + setState({ ...state, layers: state.layers.filter(l => l !== layer) }); + }} + aria-label={i18n.translate('xpack.lens.xyChart.removeLayer', { + defaultMessage: 'Remove layer', + })} + /> + + + - - - setState({ ...state, x: { ...state.x, showGridlines: !state.x.showGridlines } }) - } - /> - - - + > + { + setState( + updateLayer(state, { ...layer, seriesType: seriesType as SeriesType }, index) + ); + }} + isIconOnly + /> + - - <> - - setState({ ...state, y: { ...state.y, title: e.target.value } })} - aria-label={i18n.translate('xpack.lens.xyChart.yTitleAriaLabel', { - defaultMessage: 'Title', + - + > + operation.isBucketed, + suggestedPriority: 1, + layerId: layer.layerId, + }} + /> + - - - setState({ - ...state, - y: { - ...state.y, - accessors: [...state.y.accessors, accessor], - }, - }) - } - onRemove={accessor => - setState({ - ...state, - y: { - ...state.y, - accessors: state.y.accessors.filter(col => col !== accessor), - }, - }) - } - filterOperations={op => !op.isBucketed && op.dataType === 'number'} - testSubj="yDimensionPanel" - /> - + + operation.isBucketed, + suggestedPriority: 0, + layerId: layer.layerId, + }} + /> + - - - setState({ ...state, y: { ...state.y, showGridlines: !state.y.showGridlines } }) - } - /> - - + > + + setState( + updateLayer( + state, + { + ...layer, + accessors: [...layer.accessors, generateId()], + }, + index + ) + ) + } + onRemove={accessor => + setState( + updateLayer( + state, + { + ...layer, + accessors: layer.accessors.filter(col => col !== accessor), + }, + index + ) + ) + } + filterOperations={op => !op.isBucketed && op.dataType === 'number'} + data-test-subj="lensXY_yDimensionPanel" + testSubj="lensXY_yDimensionPanel" + layerId={layer.layerId} + /> + + +
+ ))} + + + { + setState({ + ...state, + layers: [...state.layers, newLayerState(frame.addNewLayer())], + }); + }} + iconType="plusInCircle" + > + + ); 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 deleted file mode 100644 index 9ba7326af6a56..0000000000000 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.scss +++ /dev/null @@ -1,3 +0,0 @@ - .lnsChart { - height: 100%; - } \ No newline at end of file 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 index 7e1124a0213d4..aaa748cf0bb12 100644 --- 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 @@ -6,38 +6,43 @@ import { AreaSeries, BarSeries, Position, LineSeries, Settings } from '@elastic/charts'; import { xyChart, XYChart } from './xy_expression'; -import { KibanaDatatable } from '../types'; +import { LensMultiTable } from '../types'; import React from 'react'; import { shallow } from 'enzyme'; -import { XYArgs, LegendConfig, legendConfig, XConfig, xConfig, YConfig, yConfig } from './types'; +import { XYArgs, LegendConfig, legendConfig, layerConfig, LayerConfig } from './types'; function sampleArgs() { - const data: KibanaDatatable = { - type: 'kibana_datatable', - columns: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }, { id: 'c', name: 'c' }], - rows: [{ a: 1, b: 2, c: 3 }, { a: 1, b: 5, c: 4 }], + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }, { id: 'c', name: 'c' }], + rows: [{ a: 1, b: 2, c: 3 }, { a: 1, b: 5, c: 4 }], + }, + }, }; const args: XYArgs = { - seriesType: 'line', + xTitle: '', + yTitle: '', legend: { isVisible: false, position: Position.Top, }, - y: { - accessors: ['a', 'b'], - labels: ['Label A', 'Label B'], - position: Position.Left, - showGridlines: false, - title: 'A and B', - }, - x: { - accessor: 'c', - position: Position.Bottom, - showGridlines: false, - title: 'C', - }, - splitSeriesAccessors: [], + layers: [ + { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + position: Position.Left, + showGridlines: false, + title: 'A and B', + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + }, + ], }; return { data, args }; @@ -57,31 +62,20 @@ describe('xy_expression', () => { }); }); - test('xConfig produces the correct arguments', () => { - const args: XConfig = { - accessor: 'foo', - position: Position.Right, - showGridlines: true, - title: 'Foooo!', - }; - - expect(xConfig.fn(null, args, {})).toEqual({ - type: 'lens_xy_xConfig', - ...args, - }); - }); - - test('yConfig produces the correct arguments', () => { - const args: YConfig = { - accessors: ['bar'], - labels: [''], - position: Position.Bottom, - showGridlines: true, - title: 'Barrrrrr!', + test('layerConfig produces the correct arguments', () => { + const args: LayerConfig = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + position: Position.Left, + showGridlines: false, + title: 'A and B', + splitAccessor: 'd', }; - expect(yConfig.fn(null, args, {})).toEqual({ - type: 'lens_xy_yConfig', + expect(layerConfig.fn(null, args, {})).toEqual({ + type: 'lens_xy_layer', ...args, }); }); @@ -103,32 +97,47 @@ describe('xy_expression', () => { test('it renders line', () => { const { data, args } = sampleArgs(); - const component = shallow(); + const component = shallow( + + ); expect(component).toMatchSnapshot(); expect(component.find(LineSeries)).toHaveLength(1); }); test('it renders bar', () => { const { data, args } = sampleArgs(); - - const component = shallow(); + const component = shallow( + + ); expect(component).toMatchSnapshot(); expect(component.find(BarSeries)).toHaveLength(1); }); test('it renders area', () => { const { data, args } = sampleArgs(); - - const component = shallow(); + 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); @@ -136,9 +145,11 @@ describe('xy_expression', () => { test('it renders stacked bar', () => { const { data, args } = sampleArgs(); - const component = shallow( - + ); expect(component).toMatchSnapshot(); expect(component.find(BarSeries)).toHaveLength(1); @@ -147,9 +158,11 @@ describe('xy_expression', () => { test('it renders stacked area', () => { const { data, args } = sampleArgs(); - const component = shallow( - + ); expect(component).toMatchSnapshot(); expect(component.find(AreaSeries)).toHaveLength(1); @@ -158,9 +171,11 @@ describe('xy_expression', () => { test('it renders stacked horizontal bar', () => { const { data, args } = sampleArgs(); - const component = shallow( - + ); expect(component).toMatchSnapshot(); expect(component.find(BarSeries)).toHaveLength(1); @@ -168,18 +183,21 @@ describe('xy_expression', () => { expect(component.find(Settings).prop('rotation')).toEqual(90); }); - test('it remaps rows based on the labels', () => { + test('it rewrites the rows based on provided labels', () => { const { data, args } = sampleArgs(); - const chart = shallow(); - const barSeries = chart.find(BarSeries); + const component = shallow(); + expect(component.find(LineSeries).prop('data')).toEqual([ + { 'Label A': 1, 'Label B': 2, c: 3 }, + { 'Label A': 1, 'Label B': 5, c: 4 }, + ]); + }); + + test('it uses labels as Y accessors', () => { + const { data, args } = sampleArgs(); - expect(barSeries.prop('yAccessors')).toEqual(['Label A', 'Label B']); - expect(barSeries.prop('data')[0]).toEqual({ - 'Label A': 1, - 'Label B': 2, - c: 3, - }); + const component = shallow(); + expect(component.find(LineSeries).prop('yAccessors')).toEqual(['Label A', 'Label B']); }); }); }); 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 index 772b2a8f55f3b..f65c8c990b976 100644 --- 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 @@ -15,17 +15,18 @@ import { getSpecId, AreaSeries, BarSeries, + Position, } from '@elastic/charts'; 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 { LensMultiTable } from '../types'; import { XYArgs, SeriesType } from './types'; -import { KibanaDatatable } from '../types'; import { RenderFunction } from '../interpreter_types'; import { chartTypeIcons } from './xy_config_panel'; export interface XYChartProps { - data: KibanaDatatable; + data: LensMultiTable; args: XYArgs; } @@ -35,47 +36,33 @@ export interface XYRender { value: XYChartProps; } -export const xyChart: ExpressionFunction<'lens_xy_chart', KibanaDatatable, XYArgs, XYRender> = ({ +export const xyChart: ExpressionFunction<'lens_xy_chart', LensMultiTable, XYArgs, XYRender> = ({ name: 'lens_xy_chart', type: 'render', help: 'An X/Y chart', args: { - seriesType: { + xTitle: { types: ['string'], - options: [ - 'bar', - 'line', - 'area', - 'horizontal_bar', - 'bar_stacked', - 'line_stacked', - 'area_stacked', - 'horizontal_bar_stacked', - ], - help: 'The type of chart to display.', + help: 'X axis title', + }, + yTitle: { + types: ['string'], + help: 'Y axis title', }, legend: { types: ['lens_xy_legendConfig'], help: 'Configure the chart legend.', }, - y: { - types: ['lens_xy_yConfig'], - help: 'The y axis configuration', - }, - x: { - types: ['lens_xy_xConfig'], - help: 'The x axis configuration', - }, - splitSeriesAccessors: { - types: ['string'], + layers: { + types: ['lens_xy_layer'], + help: 'Layers of visual series', multi: true, - help: 'The columns used to split the series.', }, }, context: { - types: ['kibana_datatable'], + types: ['lens_multitable'], }, - fn(data: KibanaDatatable, args: XYArgs) { + fn(data: LensMultiTable, args: XYArgs) { return { type: 'render', as: 'lens_xy_chart_renderer', @@ -86,10 +73,10 @@ export const xyChart: ExpressionFunction<'lens_xy_chart', KibanaDatatable, XYArg }; }, // 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', KibanaDatatable, XYArgs, XYRender>; +} as unknown) as ExpressionFunction<'lens_xy_chart', LensMultiTable, XYArgs, XYRender>; export interface XYChartProps { - data: KibanaDatatable; + data: LensMultiTable; args: XYArgs; } @@ -109,11 +96,14 @@ function getIconForSeriesType(seriesType: SeriesType): IconType { } export function XYChart({ data, args }: XYChartProps) { - if (data.rows.length === 0) { + const { legend, layers } = args; + + if (Object.values(data.tables).some(table => table.rows.length === 0)) { + const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; return ( - + @@ -127,65 +117,78 @@ export function XYChart({ data, args }: XYChartProps) { ); } - const { legend, x, y, splitSeriesAccessors, seriesType } = args; - // TODO: Stop mapping data once elastic-charts allows axis naming - // https://github.com/elastic/elastic-charts/issues/245 - const seriesProps = { - splitSeriesAccessors, - stackAccessors: seriesType.includes('stacked') ? [x.accessor] : [], - id: getSpecId(y.labels.join(',')), - xAccessor: x.accessor, - yAccessors: y.labels, - data: data.rows.map(row => { - const newRow: typeof row = {}; - - // Remap data to { 'Count of documents': 5 } - Object.keys(row).forEach(key => { - const labelIndex = y.accessors.indexOf(key); - if (labelIndex > -1) { - newRow[y.labels[labelIndex]] = row[key]; - } else { - newRow[key] = row[key]; - } - }); - return newRow; - }), - }; - return ( seriesType.includes('horizontal')) ? 90 : 0} /> - {seriesType === 'line' ? ( - - ) : seriesType === 'bar' || - seriesType === 'bar_stacked' || - seriesType === 'horizontal_bar' || - seriesType === 'horizontal_bar_stacked' ? ( - - ) : ( - + {layers.map( + ({ splitAccessor, seriesType, accessors, xAccessor, layerId, columnToLabel }, index) => { + if (!data.tables[layerId]) { + 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, + }; + + return seriesType === 'line' ? ( + + ) : seriesType === 'bar' || + seriesType === 'bar_stacked' || + seriesType === 'horizontal_bar' || + seriesType === 'horizontal_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 index 9ea0345f2367a..8ae338702f6b3 100644 --- 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 @@ -5,15 +5,15 @@ */ import { getSuggestions } from './xy_suggestions'; -import { TableColumn, VisualizationSuggestion } from '../types'; +import { TableSuggestionColumn, VisualizationSuggestion, DataType } from '../types'; import { State } from './types'; -import { Ast } from '@kbn/interpreter/target/common'; import { generateId } from '../id_generator'; +import { Ast } from '@kbn/interpreter/target/common'; jest.mock('../id_generator'); describe('xy_suggestions', () => { - function numCol(columnId: string): TableColumn { + function numCol(columnId: string): TableSuggestionColumn { return { columnId, operation: { @@ -25,7 +25,7 @@ describe('xy_suggestions', () => { }; } - function strCol(columnId: string): TableColumn { + function strCol(columnId: string): TableSuggestionColumn { return { columnId, operation: { @@ -37,7 +37,7 @@ describe('xy_suggestions', () => { }; } - function dateCol(columnId: string): TableColumn { + function dateCol(columnId: string): TableSuggestionColumn { return { columnId, operation: { @@ -52,38 +52,47 @@ describe('xy_suggestions', () => { // Helper that plucks out the important part of a suggestion for // most test assertions function suggestionSubset(suggestion: VisualizationSuggestion) { - const { seriesType, splitSeriesAccessors, x, y } = suggestion.state; - - return { + return suggestion.state.layers.map(({ seriesType, splitAccessor, xAccessor, accessors }) => ({ seriesType, - splitSeriesAccessors, - x: x.accessor, - y: y.accessors, - }; + splitAccessor, + x: xAccessor, + y: accessors, + })); } 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; + return { ...str, operation: { ...str.operation, dataType: 'wonkies' as DataType } }; }; expect( getSuggestions({ tables: [ - { datasourceSuggestionId: 0, isMultiRow: true, columns: [dateCol('a')] }, + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [dateCol('a')], + layerId: 'first', + }, { datasourceSuggestionId: 1, isMultiRow: true, columns: [strCol('foo'), strCol('bar')], + layerId: 'first', }, { datasourceSuggestionId: 2, isMultiRow: false, columns: [strCol('foo'), numCol('bar')], + layerId: 'first', + }, + { + datasourceSuggestionId: 3, + isMultiRow: true, + columns: [unknownCol(), numCol('bar')], + layerId: 'first', }, - { datasourceSuggestionId: 3, isMultiRow: true, columns: [unknownCol(), numCol('bar')] }, ], }) ).toEqual([]); @@ -97,23 +106,24 @@ describe('xy_suggestions', () => { datasourceSuggestionId: 0, isMultiRow: true, columns: [numCol('bytes'), dateCol('date')], + layerId: 'first', }, ], }); expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Object { - "seriesType": "bar", - "splitSeriesAccessors": Array [ - "aaa", - ], - "x": "date", - "y": Array [ - "bytes", - ], - } - `); + Array [ + Object { + "seriesType": "bar", + "splitAccessor": "aaa", + "x": "date", + "y": Array [ + "bytes", + ], + }, + ] + `); }); test('suggests a split x y chart with date on x', () => { @@ -123,23 +133,24 @@ describe('xy_suggestions', () => { datasourceSuggestionId: 1, isMultiRow: true, columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', }, ], }); expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Object { - "seriesType": "line", - "splitSeriesAccessors": Array [ - "product", - ], - "x": "date", - "y": Array [ - "price", - "quantity", - ], - } + Array [ + Object { + "seriesType": "line", + "splitAccessor": "product", + "x": "date", + "y": Array [ + "price", + "quantity", + ], + }, + ] `); }); @@ -151,40 +162,42 @@ describe('xy_suggestions', () => { datasourceSuggestionId: 0, isMultiRow: true, columns: [numCol('price'), dateCol('date')], + layerId: 'first', }, { datasourceSuggestionId: 1, isMultiRow: true, columns: [numCol('count'), strCol('country')], + layerId: 'first', }, ], }); expect(rest).toHaveLength(0); expect([suggestionSubset(s1), suggestionSubset(s2)]).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "bar", - "splitSeriesAccessors": Array [ - "bbb", - ], - "x": "date", - "y": Array [ - "price", - ], - }, - Object { - "seriesType": "bar", - "splitSeriesAccessors": Array [ - "ccc", - ], - "x": "country", - "y": Array [ - "count", - ], - }, - ] - `); + Array [ + Array [ + Object { + "seriesType": "bar", + "splitAccessor": "bbb", + "x": "date", + "y": Array [ + "price", + ], + }, + ], + Array [ + Object { + "seriesType": "bar", + "splitAccessor": "ccc", + "x": "country", + "y": Array [ + "count", + ], + }, + ], + ] + `); }); test('handles two numeric values', () => { @@ -195,22 +208,23 @@ describe('xy_suggestions', () => { datasourceSuggestionId: 1, isMultiRow: true, columns: [numCol('quantity'), numCol('price')], + layerId: 'first', }, ], }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Object { - "seriesType": "bar", - "splitSeriesAccessors": Array [ - "ddd", - ], - "x": "quantity", - "y": Array [ - "price", - ], - } - `); + Array [ + Object { + "seriesType": "bar", + "splitAccessor": "ddd", + "x": "quantity", + "y": Array [ + "price", + ], + }, + ] + `); }); test('handles unbucketed suggestions', () => { @@ -232,22 +246,23 @@ describe('xy_suggestions', () => { }, }, ], + layerId: 'first', }, ], }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Object { - "seriesType": "bar", - "splitSeriesAccessors": Array [ - "eee", - ], - "x": "mybool", - "y": Array [ - "num votes", - ], - } - `); + Array [ + Object { + "seriesType": "bar", + "splitAccessor": "eee", + "x": "mybool", + "y": Array [ + "num votes", + ], + }, + ] + `); }); test('adds a preview expression with disabled axes and legend', () => { @@ -257,6 +272,7 @@ describe('xy_suggestions', () => { datasourceSuggestionId: 0, isMultiRow: true, columns: [numCol('bytes'), dateCol('date')], + layerId: 'first', }, ], }); @@ -266,7 +282,8 @@ describe('xy_suggestions', () => { expect( (expression.chain[0].arguments.legend[0] as Ast).chain[0].arguments.isVisible[0] ).toBeFalsy(); - expect((expression.chain[0].arguments.x[0] as Ast).chain[0].arguments.hide[0]).toBeTruthy(); - expect((expression.chain[0].arguments.y[0] as Ast).chain[0].arguments.hide[0]).toBeTruthy(); + 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 index 213901aa3cd18..eeffcfd0036fe 100644 --- 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 @@ -6,7 +6,12 @@ import { partition } from 'lodash'; import { Position } from '@elastic/charts'; -import { SuggestionRequest, VisualizationSuggestion, TableColumn, TableSuggestion } from '../types'; +import { + SuggestionRequest, + VisualizationSuggestion, + TableSuggestionColumn, + TableSuggestion, +} from '../types'; import { State } from './types'; import { generateId } from '../id_generator'; import { buildExpression } from './to_expression'; @@ -37,10 +42,13 @@ export function getSuggestions( columns.some(col => col.operation.dataType === 'number') && !columns.some(col => !columnSortOrder.hasOwnProperty(col.operation.dataType)) ) - .map(table => getSuggestionForColumns(table)); + .map(table => getSuggestionForColumns(table, opts.state)); } -function getSuggestionForColumns(table: TableSuggestion): VisualizationSuggestion { +function getSuggestionForColumns( + table: TableSuggestion, + currentState?: State +): VisualizationSuggestion { const [buckets, values] = partition( prioritizeColumns(table.columns), col => col.operation.isBucketed @@ -48,17 +56,31 @@ function getSuggestionForColumns(table: TableSuggestion): VisualizationSuggestio if (buckets.length >= 1) { const [x, splitBy] = buckets; - return getSuggestion(table.datasourceSuggestionId, x, values, splitBy); + return getSuggestion( + table.datasourceSuggestionId, + table.layerId, + x, + values, + splitBy, + currentState + ); } else { const [x, ...yValues] = values; - return getSuggestion(table.datasourceSuggestionId, x, yValues); + return getSuggestion( + table.datasourceSuggestionId, + table.layerId, + x, + yValues, + undefined, + currentState + ); } } // 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: TableColumn[]) { +function prioritizeColumns(columns: TableSuggestionColumn[]) { return [...columns].sort( (a, b) => columnSortOrder[a.operation.dataType] - columnSortOrder[b.operation.dataType] ); @@ -66,9 +88,11 @@ function prioritizeColumns(columns: TableColumn[]) { function getSuggestion( datasourceSuggestionId: number, - xValue: TableColumn, - yValues: TableColumn[], - splitBy?: TableColumn + layerId: string, + xValue: TableSuggestionColumn, + yValues: TableSuggestionColumn[], + splitBy?: TableSuggestionColumn, + currentState?: State ): VisualizationSuggestion { const yTitle = yValues.map(col => col.operation.label).join(' & '); const xTitle = xValue.operation.label; @@ -78,30 +102,22 @@ function getSuggestion( const preposition = isDate ? 'over' : 'of'; const title = `${yTitle} ${preposition} ${xTitle}`; const state: State = { - legend: { isVisible: true, position: Position.Right }, - seriesType: splitBy && isDate ? 'line' : 'bar', - splitSeriesAccessors: splitBy && isDate ? [splitBy.columnId] : [generateId()], - x: { - accessor: xValue.columnId, - position: Position.Bottom, - showGridlines: false, - title: xTitle, - }, - y: { - accessors: yValues.map(col => col.columnId), - position: Position.Left, - showGridlines: false, - title: yTitle, - }, + legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right }, + layers: [ + ...(currentState ? currentState.layers.filter(layer => layer.layerId !== layerId) : []), + { + layerId, + xAccessor: xValue.columnId, + seriesType: splitBy && isDate ? 'line' : 'bar', + splitAccessor: splitBy && isDate ? splitBy.columnId : generateId(), + accessors: yValues.map(col => col.columnId), + position: Position.Left, + showGridlines: false, + title: yTitle, + }, + ], }; - const labels: Partial> = {}; - yValues.forEach(({ columnId, operation: { label } }) => { - if (label) { - labels[columnId] = label; - } - }); - return { title, score: 1, @@ -111,20 +127,13 @@ function getSuggestion( previewExpression: buildExpression( { ...state, - x: { - ...state.x, - hide: true, - }, - y: { - ...state.y, - hide: true, - }, + layers: state.layers.map(layer => ({ ...layer, hide: true })), legend: { ...state.legend, isVisible: false, }, }, - labels + { xTitle, yTitle } ), }; } 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 index a2eeffaab4a7b..060f645717ee4 100644 --- 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 @@ -6,31 +6,29 @@ import { xyVisualization } from './xy_visualization'; import { Position } from '@elastic/charts'; -import { Ast } from '@kbn/interpreter/target/common'; import { Operation } from '../types'; import { State } from './types'; -import { createMockDatasource } from '../editor_frame_plugin/mocks'; +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 { legend: { position: Position.Bottom, isVisible: true }, - seriesType: 'area', - splitSeriesAccessors: [], - x: { - accessor: 'a', - position: Position.Bottom, - showGridlines: true, - title: 'Baz', - }, - y: { - accessors: ['b', 'c'], - position: Position.Left, - showGridlines: true, - title: 'Bar', - }, + layers: [ + { + layerId: 'first', + seriesType: 'area', + splitAccessor: 'd', + position: Position.Bottom, + showGridlines: true, + title: 'Baz', + xAccessor: 'a', + accessors: ['b', 'c'], + }, + ], }; } @@ -41,46 +39,43 @@ describe('xy_visualization', () => { .mockReturnValueOnce('test-id1') .mockReturnValueOnce('test-id2') .mockReturnValue('test-id3'); - const mockDatasource = createMockDatasource(); - const initialState = xyVisualization.initialize(mockDatasource.publicAPIMock); + const mockFrame = createMockFramePublicAPI(); + const initialState = xyVisualization.initialize(mockFrame); - expect(initialState.x.accessor).toBeDefined(); - expect(initialState.y.accessors[0]).toBeDefined(); - expect(initialState.x.accessor).not.toEqual(initialState.y.accessors[0]); + 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 { + "layers": Array [ + Object { + "accessors": Array [ + "test-id1", + ], + "layerId": "", + "position": "top", + "seriesType": "bar", + "showGridlines": false, + "splitAccessor": "test-id2", + "title": "", + "xAccessor": "test-id3", + }, + ], "legend": Object { "isVisible": true, "position": "right", }, - "seriesType": "bar", - "splitSeriesAccessors": Array [ - "test-id3", - ], "title": "Empty XY Chart", - "x": Object { - "accessor": "test-id1", - "position": "bottom", - "showGridlines": false, - "title": "X", - }, - "y": Object { - "accessors": Array [ - "test-id2", - ], - "position": "left", - "showGridlines": false, - "title": "Y", - }, } `); }); it('loads from persisted state', () => { - expect( - xyVisualization.initialize(createMockDatasource().publicAPIMock, exampleState()) - ).toEqual(exampleState()); + expect(xyVisualization.initialize(createMockFramePublicAPI(), exampleState())).toEqual( + exampleState() + ); }); }); @@ -91,34 +86,42 @@ describe('xy_visualization', () => { }); describe('#toExpression', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource(); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockImplementation(col => { + return { label: `col_${col}` } as Operation; + }); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + it('should map to a valid AST', () => { - expect( - xyVisualization.toExpression(exampleState(), createMockDatasource().publicAPIMock) - ).toMatchSnapshot(); + expect(xyVisualization.toExpression(exampleState(), frame)).toMatchSnapshot(); }); it('should default to labeling all columns with their column label', () => { - const mockDatasource = createMockDatasource(); - - mockDatasource.publicAPIMock.getOperationForColumnId - .mockReturnValueOnce({ - label: 'First', - } as Operation) - .mockReturnValueOnce({ - label: 'Second', - } as Operation); + const expression = xyVisualization.toExpression(exampleState(), frame)! as Ast; - const expression = xyVisualization.toExpression( - exampleState(), - mockDatasource.publicAPIMock - )! as Ast; - - expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledTimes(2); expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('b'); expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('c'); - expect((expression.chain[0].arguments.y[0] as Ast).chain[0].arguments.labels).toEqual([ - 'First', - 'Second', + 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 index 9408fc7d7f428..35825dbc228db 100644 --- 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 @@ -18,25 +18,23 @@ import { generateId } from '../id_generator'; export const xyVisualization: Visualization = { getSuggestions, - initialize(datasource, state) { + initialize(frame, state) { return ( state || { - seriesType: 'bar', title: 'Empty XY Chart', legend: { isVisible: true, position: Position.Right }, - x: { - accessor: generateId(), - position: Position.Bottom, - showGridlines: false, - title: 'X', - }, - y: { - accessors: [generateId()], - position: Position.Left, - showGridlines: false, - title: 'Y', - }, - splitSeriesAccessors: [generateId()], + layers: [ + { + layerId: frame.addNewLayer(), + accessors: [generateId()], + position: Position.Top, + seriesType: 'bar', + showGridlines: false, + splitAccessor: generateId(), + title: '', + xAccessor: generateId(), + }, + ], } ); }, From 0143fcd0452824c34dabba089be0970b1425a194 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 25 Jul 2019 20:02:25 +0200 Subject: [PATCH 053/105] [Lens] Embeddable (#41361) --- .../expressions/expression_renderer.tsx | 2 +- .../legacy/plugins/lens/common/constants.ts | 6 + x-pack/legacy/plugins/lens/index.ts | 7 +- x-pack/legacy/plugins/lens/mappings.json | 3 + .../editor_frame/editor_frame.test.tsx | 106 +++++++++---- .../editor_frame/editor_frame.tsx | 4 +- .../editor_frame/expression_helpers.ts | 41 ++++- .../editor_frame/save.test.ts | 37 +++-- .../editor_frame_plugin/editor_frame/save.ts | 33 +++- .../editor_frame/state_management.test.ts | 6 + .../editor_frame/workspace_panel.test.tsx | 146 +++++++++-------- .../editor_frame/workspace_panel.tsx | 19 +-- .../embeddable/embeddable.test.tsx | 148 ++++++++++++++++++ .../embeddable/embeddable.tsx | 145 +++++++++++++++++ .../embeddable/embeddable_factory.ts | 103 ++++++++++++ .../embeddable/expression_wrapper.tsx | 69 ++++++++ .../lens/public/editor_frame_plugin/mocks.tsx | 9 +- .../editor_frame_plugin/plugin.test.tsx | 14 +- .../public/editor_frame_plugin/plugin.tsx | 29 +++- x-pack/legacy/plugins/lens/public/index.ts | 2 + .../indexpattern_plugin/indexpattern.tsx | 8 + .../persistence/saved_object_store.test.ts | 13 ++ .../public/persistence/saved_object_store.ts | 6 +- .../lens/public/register_embeddable.ts | 17 ++ x-pack/legacy/plugins/lens/public/types.ts | 6 + .../xy_visualization_plugin/xy_expression.tsx | 4 +- 26 files changed, 841 insertions(+), 142 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable.tsx create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable_factory.ts create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/expression_wrapper.tsx create mode 100644 x-pack/legacy/plugins/lens/public/register_embeddable.ts diff --git a/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx b/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx index c1899aa9df9a2..c87ab2a7311b6 100644 --- a/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx +++ b/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx @@ -58,7 +58,7 @@ export const createRenderer = (run: ExpressionRunner): ExpressionRenderer => ({ } }); } - }, [expression, mountpoint.current]); + }, [expression, mountpoint.current, options.context, options.getInitialContext]); return (
{ description: 'Explore and visualize data.', main: `plugins/${PLUGIN_ID}/index`, }, + embeddableFactories: ['plugins/lens/register_embeddable'], styleSheetPaths: resolve(__dirname, 'public/index.scss'), mappings, visTypes: ['plugins/lens/register_vis_type_alias'], @@ -35,7 +36,7 @@ export const lens: LegacyPluginInitializer = kibana => { isImportableAndExportable: true, getTitle: (obj: { attributes: { title: string } }) => obj.attributes.title, getInAppUrl: (obj: { id: string }) => ({ - path: `/app/lens#/edit/${encodeURIComponent(obj.id)}`, + path: getEditPath(obj.id), uiCapabilitiesPath: 'lens.show', }), }, @@ -63,7 +64,7 @@ export const lens: LegacyPluginInitializer = kibana => { all: [], read: [], }, - ui: ['show'], + ui: ['save', 'show'], }, read: { api: [PLUGIN_ID], diff --git a/x-pack/legacy/plugins/lens/mappings.json b/x-pack/legacy/plugins/lens/mappings.json index f7bf55fd76a67..9136447531be8 100644 --- a/x-pack/legacy/plugins/lens/mappings.json +++ b/x-pack/legacy/plugins/lens/mappings.json @@ -12,6 +12,9 @@ }, "state": { "type": "text" + }, + "expression": { + "type": "text" } } } 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 index 8dbf80d9afc74..e95e6c982a50c 100644 --- 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 @@ -130,12 +130,16 @@ describe('editor_frame', () => { activeDatasourceId: 'testDatasource', visualizationType: 'testVis', title: '', + expression: '', state: { datasourceStates: { testDatasource: datasource1State, testDatasource2: datasource2State, }, visualization: {}, + datasourceMetaData: { + filterableIndexPatterns: [], + }, }, }} /> @@ -341,38 +345,52 @@ describe('editor_frame', () => { instance.update(); expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` - Object { - "chain": Array [ - Object { - "arguments": Object { - "layerIds": Array [ - "first", - ], - "tables": Array [ - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource", - "type": "function", - }, - ], - "type": "expression", - }, - ], + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "kibana", + "type": "function", + }, + Object { + "arguments": Object { + "filters": Array [], + "query": Array [], + "timeRange": Array [], + }, + "function": "kibana_context", + "type": "function", + }, + Object { + "arguments": Object { + "layerIds": Array [ + "first", + ], + "tables": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource", + "type": "function", }, - "function": "lens_merge_tables", - "type": "function", - }, - Object { - "arguments": Object {}, - "function": "vis", - "type": "function", - }, - ], - "type": "expression", - } - `); + ], + "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 () => { @@ -398,12 +416,16 @@ describe('editor_frame', () => { activeDatasourceId: 'testDatasource', visualizationType: 'testVis', title: '', + expression: '', state: { datasourceStates: { testDatasource: {}, testDatasource2: {}, }, visualization: {}, + datasourceMetaData: { + filterableIndexPatterns: [], + }, }, }} /> @@ -416,6 +438,20 @@ describe('editor_frame', () => { expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` Object { "chain": Array [ + Object { + "arguments": Object {}, + "function": "kibana", + "type": "function", + }, + Object { + "arguments": Object { + "filters": Array [], + "query": Array [], + "timeRange": Array [], + }, + "function": "kibana_context", + "type": "function", + }, Object { "arguments": Object { "layerIds": Array [ @@ -608,12 +644,16 @@ describe('editor_frame', () => { activeDatasourceId: 'testDatasource', visualizationType: 'testVis', title: '', + expression: '', state: { datasourceStates: { testDatasource: {}, testDatasource2: {}, }, visualization: {}, + datasourceMetaData: { + filterableIndexPatterns: [], + }, }, }} /> @@ -656,12 +696,16 @@ describe('editor_frame', () => { activeDatasourceId: 'testDatasource', visualizationType: 'testVis', title: '', + expression: '', state: { datasourceStates: { testDatasource: datasource1State, testDatasource2: datasource2State, }, visualization: {}, + datasourceMetaData: { + filterableIndexPatterns: [], + }, }, }} /> 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 index cdc0f9c19e21e..12ba3ff6e3192 100644 --- 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 @@ -162,7 +162,7 @@ export function EditorFrame(props: EditorFrameProps) { - } dataPanel={ ; 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, + }, + }; + return prependKibanaContext( prependDatasourceExpression(visualizationExpression, datasourceMap, datasourceStates), - {} + expressionContext ); } 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 index 398f6021e4730..c630bc96704f3 100644 --- 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 @@ -5,7 +5,7 @@ */ import React from 'react'; -import { EuiPage, EuiPageSideBar, EuiPageBody, EuiHorizontalRule } from '@elastic/eui'; +import { EuiPage, EuiPageSideBar, EuiPageBody } from '@elastic/eui'; import { RootDragDropProvider } from '../../drag_drop'; export interface FrameLayoutProps { @@ -13,15 +13,12 @@ export interface FrameLayoutProps { configPanel?: React.ReactNode; suggestionsPanel?: React.ReactNode; workspacePanel?: React.ReactNode; - navPanel?: React.ReactNode; } export function FrameLayout(props: FrameLayoutProps) { return ( -
{props.navPanel}
-
{props.dataPanel} 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 index 67e5ef1f192ed..86c41868ff3dd 100644 --- 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 @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { save, Props } from './save'; -import { Action } from './state_management'; +import { getSavedObjectFormat, Props } from './save'; import { createMockDatasource, createMockVisualization } from '../mocks'; describe('save editor frame state', () => { @@ -14,8 +13,6 @@ describe('save editor frame state', () => { const mockDatasource = createMockDatasource(); mockDatasource.getPersistableState.mockImplementation(x => x); const saveArgs: Props = { - dispatch: jest.fn(), - redirectTo: jest.fn(), activeDatasources: { indexpattern: mockDatasource, }, @@ -29,7 +26,6 @@ describe('save editor frame state', () => { }, }, activeDatasourceId: 'indexpattern', - saving: false, visualization: { activeId: '2', state: {} }, }, activeDatasourceId: 'indexpattern', @@ -39,64 +35,12 @@ describe('save editor frame state', () => { datasourceLayers: { first: mockDatasource.publicAPIMock, }, - }, - store: { - async save() { - return { id: 'foo' }; - }, + query: { query: '', language: 'lucene' }, + dateRange: { fromDate: 'now-7d', toDate: 'now' }, }, }; - it('dispatches saved status actions before and after saving', async () => { - let saved = false; - - const dispatch = jest.fn((action: Action) => { - if ( - (action.type === 'SAVING' && action.isSaving && saved) || - (action.type === 'SAVING' && !action.isSaving && !saved) - ) { - throw new Error('Saving status was incorrectly set' + action.isSaving + ' ' + saved); - } - }); - - await save({ - ...saveArgs, - dispatch, - store: { - async save() { - saved = true; - return { id: 'foo' }; - }, - }, - }); - - expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: true }); - expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: false }); - }); - - it('allows saves if an error occurs', async () => { - const dispatch = jest.fn(); - - await expect( - save({ - ...saveArgs, - dispatch, - store: { - async save() { - throw new Error('aw shnap!'); - }, - }, - }) - ).rejects.toThrow(); - - expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: true }); - expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: false }); - }); - it('transforms from internal state to persisted doc format', async () => { - const store = { - save: jest.fn(async () => ({ id: 'bar' })), - }; const datasource = createMockDatasource(); datasource.getPersistableState.mockImplementation(state => ({ stuff: `${state}_datasource_persisted`, @@ -106,12 +50,12 @@ describe('save editor frame state', () => { visualization.getPersistableState.mockImplementation(state => ({ things: `${state}_vis_persisted`, })); - await save({ + + const doc = await getSavedObjectFormat({ ...saveArgs, activeDatasources: { indexpattern: datasource, }, - store, state: { title: 'bbb', datasourceStates: { @@ -121,13 +65,12 @@ describe('save editor frame state', () => { }, }, activeDatasourceId: 'indexpattern', - saving: false, visualization: { activeId: '3', state: '4' }, }, visualization, }); - expect(store.save).toHaveBeenCalledWith({ + expect(doc).toEqual({ activeDatasourceId: 'indexpattern', id: undefined, expression: '', @@ -141,71 +84,12 @@ describe('save editor frame state', () => { }, }, visualization: { things: '4_vis_persisted' }, + query: { query: '', language: 'lucene' }, + filters: [], }, title: 'bbb', type: 'lens', visualizationType: '3', }); }); - - it('redirects to the edit screen if the id changes', async () => { - const redirectTo = jest.fn(); - const dispatch = jest.fn(); - await save({ - ...saveArgs, - dispatch, - redirectTo, - state: { - title: 'ccc', - datasourceStates: { - indexpattern: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'indexpattern', - saving: false, - visualization: { activeId: '2', state: {} }, - }, - store: { - async save() { - return { id: 'bazinga' }; - }, - }, - }); - - expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_PERSISTED_ID', id: 'bazinga' }); - expect(redirectTo).toHaveBeenCalledWith('/edit/bazinga'); - }); - - it('does not redirect to the edit screen if the id does not change', async () => { - const redirectTo = jest.fn(); - const dispatch = jest.fn(); - await save({ - ...saveArgs, - dispatch, - redirectTo, - state: { - title: 'ddd', - datasourceStates: { - indexpattern: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'indexpattern', - persistedId: 'foo', - saving: false, - visualization: { activeId: '2', state: {} }, - }, - store: { - async save() { - return { id: 'foo' }; - }, - }, - }); - - expect(dispatch.mock.calls.some(({ type }) => type === 'UPDATE_PERSISTED_ID')).toBeFalsy(); - expect(redirectTo).not.toHaveBeenCalled(); - }); }); 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 index 91d32e996a45d..829955d910e25 100644 --- 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 @@ -6,76 +6,62 @@ import _ from 'lodash'; import { toExpression } from '@kbn/interpreter/target/common'; -import { Action, EditorFrameState } from './state_management'; +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; - dispatch: (value: Action) => void; - redirectTo: (path: string) => void; state: EditorFrameState; - store: { save: (doc: Document) => Promise<{ id: string }> }; visualization: Visualization; framePublicAPI: FramePublicAPI; activeDatasourceId: string; } -export async function save({ +export function getSavedObjectFormat({ activeDatasources, - dispatch, - redirectTo, state, - store, visualization, framePublicAPI, activeDatasourceId, -}: Props) { - try { - dispatch({ type: 'SAVING', isSaving: true }); +}: Props): Document { + const expression = buildExpression({ + visualization, + visualizationState: state.visualization.state, + datasourceMap: activeDatasources, + datasourceStates: state.datasourceStates, + framePublicAPI, + removeDateRange: true, + }); - const expression = buildExpression({ - visualization, - visualizationState: state.visualization.state, - datasourceMap: activeDatasources, - datasourceStates: state.datasourceStates, - framePublicAPI, - }); + const datasourceStates: Record = {}; + Object.entries(activeDatasources).forEach(([id, datasource]) => { + datasourceStates[id] = datasource.getPersistableState(state.datasourceStates[id].state); + }); - 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 + ); + }); - const filterableIndexPatterns: string[] = []; - Object.entries(activeDatasources).forEach(([id, datasource]) => { - filterableIndexPatterns.push( - ...datasource.getMetaData(state.datasourceStates[id].state).filterableIndexPatterns - ); - }); - - const doc = await store.save({ - id: state.persistedId, - title: state.title, - type: 'lens', - visualizationType: state.visualization.activeId, - expression: expression ? toExpression(expression) : '', - activeDatasourceId, - state: { - datasourceStates, - datasourceMetaData: { - filterableIndexPatterns: _.uniq(filterableIndexPatterns), - }, - visualization: visualization.getPersistableState(state.visualization.state), + return { + id: state.persistedId, + title: state.title, + type: 'lens', + visualizationType: state.visualization.activeId, + expression: expression ? toExpression(expression) : '', + activeDatasourceId, + state: { + datasourceStates, + datasourceMetaData: { + filterableIndexPatterns: _.uniq(filterableIndexPatterns, 'id'), }, - }); - - if (doc.id !== state.persistedId) { - dispatch({ type: 'UPDATE_PERSISTED_ID', id: doc.id }); - redirectTo(`/edit/${doc.id}`); - } - } finally { - dispatch({ type: 'SAVING', isSaving: false }); - } + 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 index eccca61814303..4bd2938587601 100644 --- 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 @@ -16,16 +16,14 @@ describe('editor_frame state management', () => { beforeEach(() => { props = { onError: jest.fn(), - redirectTo: jest.fn(), - store: { - load: jest.fn(), - save: 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(), + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + query: { query: '', language: 'lucene' }, }; }); @@ -58,6 +56,8 @@ describe('editor_frame state management', () => { datasourceMetaData: { filterableIndexPatterns: [], }, + query: { query: '', language: 'lucene' }, + filters: [], }, title: '', visualizationType: 'testVis', @@ -109,7 +109,6 @@ describe('editor_frame state management', () => { }, }, activeDatasourceId: 'testDatasource', - saving: false, title: 'aaa', visualization: { activeId: 'testVis', @@ -136,7 +135,6 @@ describe('editor_frame state management', () => { }, }, activeDatasourceId: 'testDatasource', - saving: false, title: 'bbb', visualization: { activeId: 'testVis', @@ -164,7 +162,6 @@ describe('editor_frame state management', () => { }, }, activeDatasourceId: 'testDatasource', - saving: false, title: 'bbb', visualization: { activeId: 'testVis', @@ -195,7 +192,6 @@ describe('editor_frame state management', () => { }, }, activeDatasourceId: 'testDatasource', - saving: false, title: 'ccc', visualization: { activeId: 'testVis', @@ -225,7 +221,6 @@ describe('editor_frame state management', () => { }, }, activeDatasourceId: 'testDatasource', - saving: false, title: 'ddd', visualization: { activeId: 'testVis', @@ -254,7 +249,6 @@ describe('editor_frame state management', () => { }, }, activeDatasourceId: 'testDatasource', - saving: false, title: 'eee', visualization: { activeId: 'testVis', @@ -286,7 +280,6 @@ describe('editor_frame state management', () => { }, }, activeDatasourceId: 'testDatasource', - saving: false, title: 'eee', visualization: { activeId: 'testVis', @@ -303,84 +296,6 @@ describe('editor_frame state management', () => { expect(newState.datasourceStates.testDatasource2.state).toBe(datasource2State); }); - it('should mark as saving', () => { - const newState = reducer( - { - datasourceStates: { - a: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'a', - saving: false, - title: 'fff', - visualization: { - activeId: 'b', - state: {}, - }, - }, - { - type: 'SAVING', - isSaving: true, - } - ); - - expect(newState.saving).toBeTruthy(); - }); - - it('should mark as saved', () => { - const newState = reducer( - { - datasourceStates: { - a: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'a', - saving: false, - title: 'hhh', - visualization: { - activeId: 'b', - state: {}, - }, - }, - { - type: 'SAVING', - isSaving: false, - } - ); - - expect(newState.saving).toBeFalsy(); - }); - - it('should change the persisted id', () => { - const newState = reducer( - { - datasourceStates: { - a: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'a', - saving: false, - title: 'iii', - visualization: { - activeId: 'b', - state: {}, - }, - }, - { - type: 'UPDATE_PERSISTED_ID', - id: 'baz', - } - ); - - expect(newState.persistedId).toEqual('baz'); - }); - it('should reset the state', () => { const newState = reducer( { @@ -391,7 +306,6 @@ describe('editor_frame state management', () => { }, }, activeDatasourceId: 'a', - saving: false, title: 'jjj', visualization: { activeId: 'b', @@ -409,7 +323,6 @@ describe('editor_frame state management', () => { }, activeDatasourceId: 'z', persistedId: 'bar', - saving: false, title: 'lll', visualization: { activeId: 'q', @@ -428,7 +341,6 @@ describe('editor_frame state management', () => { }, activeDatasourceId: 'z', persistedId: 'bar', - saving: false, visualization: { activeId: 'q', state: { my: 'viz' }, @@ -446,7 +358,6 @@ describe('editor_frame state management', () => { }, }, activeDatasourceId: 'a', - saving: false, title: 'mmm', visualization: { activeId: 'b', @@ -463,6 +374,8 @@ describe('editor_frame state management', () => { datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { a: { foo: 'c' } }, visualization: { bar: 'd' }, + query: { query: '', language: 'lucene' }, + filters: [], }, title: 'heyo!', type: 'lens', @@ -482,7 +395,6 @@ describe('editor_frame state management', () => { }, }, persistedId: 'b', - saving: false, title: 'heyo!', visualization: { activeId: 'line', 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 index bfa52341b9495..1e5eee9e30c70 100644 --- 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 @@ -10,7 +10,6 @@ import { Document } from '../../persistence/saved_object_store'; export interface EditorFrameState { persistedId?: string; - saving: boolean; title: string; visualization: { activeId: string | null; @@ -25,18 +24,10 @@ export type Action = type: 'RESET'; state: EditorFrameState; } - | { - type: 'SAVING'; - isSaving: boolean; - } | { type: 'UPDATE_TITLE'; title: string; } - | { - type: 'UPDATE_PERSISTED_ID'; - id: string; - } | { type: 'UPDATE_DATASOURCE_STATE'; newState: unknown; @@ -82,7 +73,6 @@ export const getInitialState = (props: EditorFrameProps): EditorFrameState => { } return { - saving: false, title: i18n.translate('xpack.lens.chartTitle', { defaultMessage: 'New visualization' }), datasourceStates, activeDatasourceId: props.initialDatasourceId ? props.initialDatasourceId : null, @@ -95,12 +85,8 @@ export const getInitialState = (props: EditorFrameProps): EditorFrameState => { export const reducer = (state: EditorFrameState, action: Action): EditorFrameState => { switch (action.type) { - case 'SAVING': - return { ...state, saving: action.isSaving }; case 'RESET': return action.state; - case 'UPDATE_PERSISTED_ID': - return { ...state, persistedId: action.id }; case 'UPDATE_TITLE': return { ...state, title: action.title }; case 'UPDATE_LAYER': 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 index 9bcfb82e4d19b..2a1706b670982 100644 --- 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 @@ -150,8 +150,12 @@ describe('workspace_panel', () => { Object { "arguments": Object { "filters": Array [], - "query": Array [], - "timeRange": Array [], + "query": Array [ + "{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}", + ], + "timeRange": Array [ + "{\\"from\\":\\"now-7d\\",\\"to\\":\\"now\\"}", + ], }, "function": "kibana_context", "type": "function", @@ -235,39 +239,91 @@ describe('workspace_panel', () => { 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", - }, - ] - `); + 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', () => { 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 index bb3e8fc3b64cc..c531387f3dde6 100644 --- 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 @@ -112,7 +112,14 @@ export function InnerWorkspacePanel({ } catch (e) { setExpressionError(e.toString()); } - }, [activeVisualization, visualizationState, datasourceMap, datasourceStates]); + }, [ + activeVisualization, + visualizationState, + datasourceMap, + datasourceStates, + framePublicAPI.dateRange, + framePublicAPI.query, + ]); useEffect(() => { // reset expression error if component attempts to run it again 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 index 316a1906af211..813c15b360952 100644 --- 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 @@ -25,6 +25,8 @@ const savedVis: Document = { datasourceMetaData: { filterableIndexPatterns: [], }, + query: { query: '', language: 'lucene' }, + filters: [], }, title: 'My title', visualizationType: '', 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 index 5117c5fe70511..515940a2249b0 100644 --- 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 @@ -73,9 +73,9 @@ export class EmbeddableFactory extends AbstractEmbeddableFactory { const savedVis = await store.load(savedObjectId); const promises = savedVis.state.datasourceMetaData.filterableIndexPatterns.map( - async indexPatternId => { + async ({ id }) => { try { - return await this.indexPatternService.get(indexPatternId); + 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 diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.test.tsx deleted file mode 100644 index 3f935d91054b8..0000000000000 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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, mount } from 'enzyme'; -import { InitializableComponent } from './initializable_component'; - -function resolvable() { - let resolve: (value: {}) => void; - - return { - promise: new Promise(res => (resolve = res)), - resolve: (x: {}) => resolve(x), - }; -} - -describe('InitializableComponent', () => { - test('renders nothing if loading', () => { - const component = render( - Promise.resolve({ hello: 'world' })} - render={props =>
{props!.hello}
} - /> - ); - - expect(component).toMatchInlineSnapshot(`null`); - }); - - test('passes the resolved props to render', async () => { - const initPromise = Promise.resolve({ test: 'props' }); - const mockRender = jest.fn(() =>
); - - mount( initPromise} render={mockRender} />); - - await initPromise; - expect(mockRender).toHaveBeenCalledWith({ test: 'props' }); - }); - - test('allows an undefined resolve', async () => { - const initPromise = Promise.resolve(); - const mockRender = jest.fn(() =>
); - - mount( initPromise} render={mockRender} />); - - await initPromise; - expect(mockRender).toHaveBeenCalledWith(undefined); - }); - - test('ignores stale promise results', async () => { - const firstInit = resolvable(); - const secondInit = resolvable(); - const mockRender = jest.fn(() =>
); - - const component = mount( - firstInit.promise} render={mockRender} /> - ); - - component.setProps({ - watch: ['b'], - init: () => secondInit.promise, - render: mockRender, - }); - - firstInit.resolve({ hello: 1 }); - secondInit.resolve({ hello: 2 }); - await secondInit.promise; - - expect(mockRender).not.toHaveBeenCalledWith({ hello: 1 }); - expect(mockRender).toHaveBeenCalledWith({ hello: 2 }); - }); -}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.tsx deleted file mode 100644 index a5da8adb7e485..0000000000000 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/initializable_component.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 { useState, useEffect } from 'react'; - -interface Props { - watch: unknown[]; - init: () => Promise; - render: (props: T) => JSX.Element | null; -} - -export function InitializableComponent(props: Props) { - const [state, setState] = useState<{ isLoading: boolean; result?: T }>({ - isLoading: true, - result: undefined, - }); - - useEffect(() => { - let isStale = false; - - props.init().then(result => { - if (!isStale) { - setState({ isLoading: false, result }); - } - }); - - return () => { - isStale = true; - }; - }, props.watch); - - if (state.isLoading) { - // TODO: Handle the loading / undefined result case - return null; - } - - return props.render(state.result!); -} 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 index 276517031b339..fa29ede4185a0 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -73,6 +73,8 @@ export function createMockFramePublicAPI(): FrameMock { datasourceLayers: {}, addNewLayer: jest.fn(() => ''), removeLayers: jest.fn(), + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + query: { query: '', language: 'lucene' }, }; } 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 index 1687f22cf767a..9a5e0e23ea23d 100644 --- 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 @@ -4,16 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EditorFramePlugin, init, InitializedEditor } from './plugin'; -import { - createMockDependencies, - MockedDependencies, - createMockDatasource, - createMockVisualization, -} from './mocks'; -import { SavedObjectStore, Document } from '../persistence'; -import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +import { EditorFramePlugin } from './plugin'; +import { createMockDependencies, MockedDependencies } from './mocks'; jest.mock('ui/chrome', () => ({ getSavedObjectsClient: jest.fn(), @@ -25,13 +17,6 @@ jest.mock('../../../../../../src/legacy/core_plugins/data/public/legacy', () => jest.mock('../../../../../../src/legacy/core_plugins/embeddable_api/public', () => {}); jest.mock('./embeddable/embeddable_factory', () => ({ EmbeddableFactory: class Mock {} })); -function mockStore(): SavedObjectStore { - return { - load: jest.fn(), - save: jest.fn(), - }; -} - describe('editor_frame plugin', () => { let pluginInstance: EditorFramePlugin; let mountpoint: Element; @@ -51,7 +36,12 @@ describe('editor_frame plugin', () => { expect(() => { const publicAPI = pluginInstance.setup(null, pluginDependencies); const instance = publicAPI.createInstance({}); - instance.mount(mountpoint, { onError: jest.fn() }); + instance.mount(mountpoint, { + onError: jest.fn(), + onChange: jest.fn(), + dateRange: { fromDate: '', toDate: '' }, + query: { query: '', language: 'lucene' }, + }); instance.unmount(); }).not.toThrowError(); }); @@ -59,188 +49,14 @@ describe('editor_frame plugin', () => { it('should not have child nodes after unmount', () => { const publicAPI = pluginInstance.setup(null, pluginDependencies); const instance = publicAPI.createInstance({}); - instance.mount(mountpoint, { onError: jest.fn() }); + instance.mount(mountpoint, { + onError: jest.fn(), + onChange: jest.fn(), + dateRange: { fromDate: '', toDate: '' }, + query: { query: '', language: 'lucene' }, + }); instance.unmount(); expect(mountpoint.hasChildNodes()).toBe(false); }); - - describe('init', () => { - it('should do nothing if the persistedId is undefined', async () => { - const store = mockStore(); - expect( - await init({ - store, - onError: jest.fn(), - }) - ).toEqual({}); - expect(store.load).not.toHaveBeenCalled(); - }); - - it('should load the document, if persistedId is defined', async () => { - const doc: Document = { - id: 'hoi', - expression: '', - activeDatasourceId: 'indexpattern', - state: { - datasourceStates: { - indexpattern: 'foo', - }, - visualization: 'bar', - datasourceMetaData: { - filterableIndexPatterns: [], - }, - }, - title: 'shazm', - visualizationType: 'fanci', - type: 'lens', - }; - - const store = { - ...mockStore(), - load: jest.fn(async () => doc), - }; - - expect( - await init({ - persistedId: 'hoi', - store, - onError: jest.fn(), - }) - ).toEqual({ doc }); - - expect(store.load).toHaveBeenCalledWith('hoi'); - }); - - it('should call onError if an error occurs while loading', async () => { - const error = new Error('dang!'); - const store = { - ...mockStore(), - load: jest.fn(async () => { - throw error; - }), - }; - const onError = jest.fn(); - - expect( - await init({ - persistedId: 'hoi', - store, - onError, - }) - ).toEqual({ error }); - - expect(onError).toHaveBeenCalledWith(error); - }); - - it('should not call onError if a 404 error occurs while loading', async () => { - const error = new Object({ statusCode: 404 }); - const store = { - ...mockStore(), - load: jest.fn(async () => { - throw error; - }), - }; - const onError = jest.fn(); - - expect( - await init({ - persistedId: 'hoi', - store, - onError, - }) - ).toEqual({ error }); - - expect(onError).not.toHaveBeenCalled(); - }); - }); - - describe('render', () => { - it('renders 404 if given a 404 error', () => { - const error = { statusCode: 404, message: 'Ruh roh!' }; - const result = shallow( -
} - routeProps={{ history: { push: jest.fn() } }} - store={mockStore()} - onError={jest.fn()} - /> - ); - expect(result).toMatchInlineSnapshot(``); - }); - - it('redirects via route history', () => { - const historyPush = jest.fn(); - const component = mount( -
} - routeProps={{ history: { push: historyPush } }} - store={mockStore()} - onError={jest.fn()} - /> - ); - - const redirectTo = component.find('[data-test-subj="lnsEditorFrame"]').prop('redirectTo') as ( - path: string - ) => void; - redirectTo('mehnewurl'); - expect(historyPush).toHaveBeenCalledWith('mehnewurl'); - }); - - it('uses the document datasource and visualization types, if available', () => { - const component = mount( -
} - routeProps={{ history: { push: jest.fn() } }} - store={mockStore()} - onError={jest.fn()} - /> - ); - - const frame = component.find('[data-test-subj="lnsEditorFrame"]'); - - expect(frame.prop('initialDatasourceId')).toEqual('b'); - expect(frame.prop('initialVisualizationId')).toEqual('d'); - }); - - it('uses the first datasource and visualization type, if there is no document', () => { - const component = mount( -
} - routeProps={{ history: { push: jest.fn() } }} - store={mockStore()} - onError={jest.fn()} - /> - ); - - const frame = component.find('[data-test-subj="lnsEditorFrame"]'); - - expect(frame.prop('initialDatasourceId')).toEqual('a'); - expect(frame.prop('initialVisualizationId')).toEqual('c'); - }); - }); }); 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 index 53f546272803a..757318eff2732 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -6,10 +6,9 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { Registry } from '@kbn/interpreter/target/common'; import { I18nProvider } from '@kbn/i18n/react'; +import { Registry } from '@kbn/interpreter/target/common'; import { CoreSetup } from 'src/core/public'; -import { HashRouter, Switch, Route, RouteComponentProps } from 'react-router-dom'; import chrome, { Chrome } from 'ui/chrome'; import { EmbeddablePlugin, @@ -19,16 +18,8 @@ import { ExpressionRenderer } from '../../../../../../src/legacy/core_plugins/da import { setup as data } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/public'; import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; -import { - Datasource, - Visualization, - EditorFrameSetup, - EditorFrameInstance, - ErrorCallback, -} from '../types'; +import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; import { EditorFrame } from './editor_frame'; -import { SavedObjectIndexStore, SavedObjectStore, Document } from '../persistence'; -import { InitializableComponent } from './initializable_component'; import { mergeTables } from './merge_tables'; import { EmbeddableFactory } from './embeddable/embeddable_factory'; @@ -46,86 +37,47 @@ export interface InterpreterSetup { >; } -interface InitializationResult { - doc?: Document; - error?: { message: string }; -} - -interface InitializationProps { - persistedId?: string; - store: SavedObjectStore; - onError: ErrorCallback; -} - -interface RenderProps extends InitializationResult { - routeProps: { history: { push: (path: string) => void } }; - store: SavedObjectStore; - onError: ErrorCallback; - datasources: Record; - visualizations: Record; - expressionRenderer: ExpressionRenderer; -} - export class EditorFramePlugin { constructor() {} private ExpressionRenderer: ExpressionRenderer | null = null; - private chrome: Chrome | null = null; private readonly datasources: Record = {}; private readonly visualizations: Record = {}; private createInstance(): EditorFrameInstance { let domElement: Element; - - const store = new SavedObjectIndexStore(this.chrome!.getSavedObjectsClient()); - - function unmount() { - if (domElement) { - unmountComponentAtNode(domElement); - } - } - return { - mount: (element, { onError }) => { + mount: (element, { doc, onError, dateRange, query, onChange }) => { domElement = element; - - const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { - const persistedId = routeProps.match.params.id; - - return ( - init({ persistedId, store, onError })} - render={({ doc, error }) => ( - - )} - /> - ); - }; + const firstDatasourceId = Object.keys(this.datasources)[0]; + const firstVisualizationId = Object.keys(this.visualizations)[0]; render( - - - - - - - + , domElement ); }, - unmount, + unmount() { + if (domElement) { + unmountComponentAtNode(domElement); + } + }, }; } @@ -133,7 +85,6 @@ export class EditorFramePlugin { plugins.interpreter.functionsRegistry.register(() => mergeTables); this.ExpressionRenderer = plugins.data.expressions.ExpressionRenderer; - this.chrome = plugins.chrome; plugins.embeddables.addEmbeddableFactory( new EmbeddableFactory( plugins.chrome, @@ -171,64 +122,3 @@ export const editorFrameSetup = () => }); export const editorFrameStop = () => editorFrame.stop(); - -function NotFound() { - return

TODO: 404 Page

; -} - -function is404(error?: unknown) { - return error && (error as { statusCode: number }).statusCode === 404; -} - -export async function init({ - persistedId, - store, - onError, -}: InitializationProps): Promise { - if (!persistedId) { - return {}; - } else { - return store - .load(persistedId) - .then(doc => ({ doc })) - .catch((error: Error) => { - if (!is404(error)) { - onError(error); - } - return { error }; - }); - } -} - -export function InitializedEditor({ - doc, - error, - routeProps, - onError, - store, - datasources, - visualizations, - expressionRenderer, -}: RenderProps) { - const firstDatasourceId = Object.keys(datasources)[0]; - const firstVisualizationId = Object.keys(visualizations)[0]; - - if (is404(error)) { - return ; - } - - return ( - routeProps.history.push(path)} - doc={doc} - /> - ); -} diff --git a/x-pack/legacy/plugins/lens/public/index.scss b/x-pack/legacy/plugins/lens/public/index.scss index 97f8c32aa633f..0a5fde0d47444 100644 --- a/x-pack/legacy/plugins/lens/public/index.scss +++ b/x-pack/legacy/plugins/lens/public/index.scss @@ -1,6 +1,7 @@ // 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'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index ea1be14b39e06..b7d761b42030a 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -239,12 +239,12 @@ describe('IndexPattern Data Source', () => { }; const state = await indexPatternDatasource.initialize(queryPersistedState); expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(` -"esaggs - index=\\"1\\" - metricsAtAllLevels=false - partialRows=false - aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"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\\"}'" -`); + "esaggs + index=\\"1\\" + metricsAtAllLevels=false + partialRows=false + 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\\"}'" + `); }); }); @@ -1065,6 +1065,40 @@ describe('IndexPattern Data Source', () => { }); }); + describe('#getMetadata', () => { + it('should return the title of the index patterns', () => { + expect( + indexPatternDatasource.getMetaData({ + 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; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 330c143abc87a..3c01dd2bba7e1 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -264,7 +264,12 @@ export function getIndexPatternDatasource({ getMetaData(state: IndexPatternPrivateState) { return { filterableIndexPatterns: _.uniq( - Object.values(state.layers).map(layer => layer.indexPatternId) + Object.values(state.layers) + .map(layer => layer.indexPatternId) + .map(indexPatternId => ({ + id: indexPatternId, + title: state.indexPatterns[indexPatternId].title, + })) ), }; }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx index ed81f4656711e..f173b242150ed 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx @@ -86,11 +86,6 @@ export const dateHistogramOperation: OperationDefinition { expression: '', activeDatasourceId: 'indexpattern', state: { - datasourceMetaData: { filterableIndexPatterns: [] }, + datasourceMetaData: { + filterableIndexPatterns: [], + }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, }, visualization: { x: 'foo', y: 'baz' }, + query: { query: '', language: 'lucene' }, + filters: [], }, }); @@ -44,11 +48,15 @@ describe('LensStore', () => { expression: '', activeDatasourceId: 'indexpattern', state: { - datasourceMetaData: { filterableIndexPatterns: [] }, + datasourceMetaData: { + filterableIndexPatterns: [], + }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, }, visualization: { x: 'foo', y: 'baz' }, + query: { query: '', language: 'lucene' }, + filters: [], }, }); @@ -65,6 +73,8 @@ describe('LensStore', () => { indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, }, visualization: { x: 'foo', y: 'baz' }, + query: { query: '', language: 'lucene' }, + filters: [], }), }); }); @@ -79,10 +89,10 @@ describe('LensStore', () => { activeDatasourceId: 'indexpattern', state: { datasourceMetaData: { filterableIndexPatterns: [] }, - datasourceStates: { - indexpattern: { type: 'index_pattern', indexPattern: 'lotr' }, - }, + datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, visualization: { gear: ['staff', 'pointy hat'] }, + query: { query: '', language: 'lucene' }, + filters: [], }, }); @@ -94,10 +104,10 @@ describe('LensStore', () => { activeDatasourceId: 'indexpattern', state: { datasourceMetaData: { filterableIndexPatterns: [] }, - datasourceStates: { - indexpattern: { type: 'index_pattern', indexPattern: 'lotr' }, - }, + datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, visualization: { gear: ['staff', 'pointy hat'] }, + query: { query: '', language: 'lucene' }, + filters: [], }, }); @@ -109,10 +119,10 @@ describe('LensStore', () => { activeDatasourceId: 'indexpattern', state: JSON.stringify({ datasourceMetaData: { filterableIndexPatterns: [] }, - datasourceStates: { - indexpattern: { type: 'index_pattern', indexPattern: 'lotr' }, - }, + datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, visualization: { gear: ['staff', 'pointy hat'] }, + query: { query: '', language: 'lucene' }, + filters: [], }), }); }); 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 index 74619909832d4..5846565dd05a7 100644 --- a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts +++ b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts @@ -5,7 +5,8 @@ */ import { SavedObjectAttributes } from 'target/types/server'; -import { DatasourceMetaData } from '../types'; +import { Filter } from '@kbn/es-query'; +import { Query } from 'src/plugins/data/common'; export interface Document { id?: string; @@ -15,9 +16,13 @@ export interface Document { activeDatasourceId: string; expression: string; state: { - datasourceMetaData: DatasourceMetaData; + datasourceMetaData: { + filterableIndexPatterns: Array<{ id: string; title: string }>; + }; datasourceStates: Record; visualization: unknown; + query: Query; + filters: Filter[]; }; } @@ -33,7 +38,7 @@ interface SavedObjectClient { id: string; type: string; attributes: SavedObjectAttributes; - error?: { message: string }; + error?: { statusCode: number; message: string }; }>; } diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 387acd6612810..77f592a67a5fd 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -6,15 +6,29 @@ import { Ast } from '@kbn/interpreter/common'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { Query } from 'src/plugins/data/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: { onError: ErrorCallback }) => void; + mount: (element: Element, props: EditorFrameProps) => void; unmount: () => void; } @@ -46,7 +60,7 @@ export interface DatasourceSuggestion { } export interface DatasourceMetaData { - filterableIndexPatterns: string[]; + filterableIndexPatterns: Array<{ id: string; title: string }>; } /** @@ -185,6 +199,12 @@ export interface VisualizationSuggestion { 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; 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 index e3b0232382bd8..9d7fffc17c086 100644 --- 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 @@ -18,6 +18,7 @@ import { Position, ScaleType, } 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'; @@ -91,7 +92,12 @@ export const xyChartRenderer: RenderFunction = { validate: () => {}, reuseDomNode: true, render: async (domNode: Element, config: XYChartProps, _handlers: unknown) => { - ReactDOM.render(, domNode); + ReactDOM.render( + + + , + domNode + ); }, }; From 23d453f65df58f93c3f692603dd7a73696ef71aa Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 7 Aug 2019 17:30:24 +0200 Subject: [PATCH 066/105] [Lens] Add some tests around document handling in dimension panel (#42670) --- .../dimension_panel/dimension_panel.test.tsx | 202 +++++++++++++++--- .../dimension_panel/field_select.tsx | 4 + .../dimension_panel/popover_editor.tsx | 4 +- 3 files changed, 183 insertions(+), 27 deletions(-) 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 index ee916e8f1a9c5..bc67cd4448d84 100644 --- 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 @@ -218,14 +218,14 @@ describe('IndexPatternDimensionPanel', () => { const options = wrapper.find(EuiComboBox).prop('options'); - expect(options![0].className).toContain('incompatible'); + expect(options![0]['data-test-subj']).toEqual('lns-documentOptionIncompatible'); expect( - options![1].options!.filter(({ label }) => label === 'timestamp')[0].className - ).toContain('incompatible'); + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); expect( - options![1].options!.filter(({ label }) => label === 'memory')[0].className - ).not.toContain('incompatible'); + 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', () => { @@ -260,12 +260,15 @@ describe('IndexPatternDimensionPanel', () => { 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')!.className).not.toContain('incompatible'); + expect(options.find(({ name }) => name === 'Minimum')!['data-test-subj']).not.toContain( + 'Incompatible' + ); - expect(options.find(({ name }) => name === 'Date Histogram')!.className).toContain( - 'incompatible' + expect(options.find(({ name }) => name === 'Date Histogram')!['data-test-subj']).toContain( + 'Incompatible' ); }); @@ -450,7 +453,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); act(() => { - wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); }); expect(setState).not.toHaveBeenCalled(); @@ -461,7 +466,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).not.toHaveLength(0); @@ -473,7 +480,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); wrapper .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') @@ -487,7 +496,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); act(() => { wrapper.find(EuiPopover).prop('closePopover')!(); @@ -503,18 +514,97 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); const options = wrapper.find(EuiComboBox).prop('options'); - expect(options![0].className).toContain('incompatible'); + expect(options![0]['data-test-subj']).toContain('Incompatible'); expect( - options![1].options!.filter(({ label }) => label === 'timestamp')[0].className - ).toContain('incompatible'); + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); expect( - options![1].options!.filter(({ label }) => label === 'source')[0].className - ).not.toContain('incompatible'); + 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', () => { @@ -523,7 +613,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); act(() => { - wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); }); const comboBox = wrapper.find(EuiComboBox)!; @@ -622,6 +714,31 @@ describe('IndexPatternDimensionPanel', () => { }); }); + 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(); @@ -631,17 +748,50 @@ describe('IndexPatternDimensionPanel', () => { const options = wrapper.find(EuiComboBox).prop('options'); - expect(options![0].className).toContain('incompatible'); + expect(options![0]['data-test-subj']).toContain('Incompatible'); expect( - options![1].options!.filter(({ label }) => label === 'timestamp')[0].className - ).toContain('incompatible'); + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); expect( - options![1].options!.filter(({ label }) => label === 'bytes')[0].className - ).not.toContain('incompatible'); + options![1].options!.filter(({ label }) => label === 'bytes')[0]['data-test-subj'] + ).not.toContain('Incompatible'); expect( - options![1].options!.filter(({ label }) => label === 'memory')[0].className - ).not.toContain('incompatible'); + 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', () => { 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 index a4558d8c87f6d..2874449bcb4ff 100644 --- 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 @@ -74,6 +74,9 @@ export function FieldSelect({ className: classNames({ 'lnsConfigPanel__fieldOption--incompatible': !isCurrentOperationApplicableWithoutField, }), + 'data-test-subj': `lns-documentOption${ + isCurrentOperationApplicableWithoutField ? '' : 'Incompatible' + }`, }); } @@ -109,6 +112,7 @@ export function FieldSelect({ label, value, className: classNames({ 'lnsConfigPanel__fieldOption--incompatible': !compatible }), + 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${label}`, })), }); } 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 index cc899caf8f4e7..7de3201206adf 100644 --- 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 @@ -127,7 +127,9 @@ export function PopoverEditor(props: PopoverEditorProps) { ), 'lnsConfigPanel__operation--incompatible': !compatibleWithCurrentField, }), - 'data-test-subj': `lns-indexPatternDimension-${operationType}`, + 'data-test-subj': `lns-indexPatternDimension${ + compatibleWithCurrentField ? '' : 'Incompatible' + }-${operationType}`, onClick() { if (!selectedColumn) { const possibleFields = fieldByOperation[operationType] || []; From 13ea53e6e4d988ee51800ed614e785520b0e8ccb Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 7 Aug 2019 17:57:39 +0200 Subject: [PATCH 067/105] [Lens] Terms operation boolean support (#42817) --- .../dimension_panel/dimension_panel.test.tsx | 25 ++++-- .../operation_definitions/date_histogram.tsx | 8 +- .../operation_definitions/metrics.tsx | 8 +- .../operation_definitions/terms.test.tsx | 79 +++++++++++++++++++ .../operation_definitions/terms.tsx | 13 ++- 5 files changed, 120 insertions(+), 13 deletions(-) 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 index bc67cd4448d84..75b8e2a8c1aaa 100644 --- 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 @@ -1010,7 +1010,10 @@ describe('IndexPatternDimensionPanel', () => { {...defaultProps} dragDropContext={{ ...dragDropContext, - dragging: { indexPatternId: 'foo', field: { type: 'number', name: 'bar' } }, + dragging: { + indexPatternId: 'foo', + field: { type: 'number', name: 'bar', aggregatable: true }, + }, }} state={dragDropState()} filterOperations={() => false} @@ -1032,7 +1035,10 @@ describe('IndexPatternDimensionPanel', () => { {...defaultProps} dragDropContext={{ ...dragDropContext, - dragging: { field: { type: 'number', name: 'bar' }, indexPatternId: 'foo' }, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }, }} state={dragDropState()} filterOperations={op => op.dataType === 'number'} @@ -1054,7 +1060,10 @@ describe('IndexPatternDimensionPanel', () => { {...defaultProps} dragDropContext={{ ...dragDropContext, - dragging: { field: { type: 'number', name: 'bar' }, indexPatternId: 'foo2' }, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo2', + }, }} state={dragDropState()} filterOperations={op => op.dataType === 'number'} @@ -1071,7 +1080,10 @@ describe('IndexPatternDimensionPanel', () => { }); it('appends the dropped column when a field is dropped', () => { - const dragging = { field: { type: 'number', name: 'bar' }, indexPatternId: 'foo' }; + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; const testState = dragDropState(); wrapper = shallow( { }); it('updates a column when a field is dropped', () => { - const dragging = { field: { type: 'number', name: 'bar' }, indexPatternId: 'foo' }; + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; const testState = dragDropState(); wrapper = shallow( [], - getPossibleOperationsForField: ({ aggregationRestrictions, type }) => { - if (type === 'date' && (!aggregationRestrictions || aggregationRestrictions.date_histogram)) { + getPossibleOperationsForField: ({ aggregationRestrictions, aggregatable, type }) => { + if ( + type === 'date' && + aggregatable && + (!aggregationRestrictions || aggregationRestrictions.date_histogram) + ) { return [ { dataType: 'date', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx index 27d5c9606754e..9dd574d8d5e8f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx @@ -23,8 +23,12 @@ function buildMetricOperation( type, displayName, getPossibleOperationsForDocument: () => [], - getPossibleOperationsForField: ({ aggregationRestrictions, type: fieldType }) => { - if (fieldType === 'number' && (!aggregationRestrictions || aggregationRestrictions[type])) { + getPossibleOperationsForField: ({ aggregationRestrictions, aggregatable, type: fieldType }) => { + if ( + fieldType === 'number' && + aggregatable && + (!aggregationRestrictions || aggregationRestrictions[type]) + ) { return [ { dataType: 'number', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx index f6e54009adaae..b1ee1dd4de2d2 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx @@ -70,7 +70,80 @@ describe('terms', () => { }); }); + describe('getPossibleOperationsForField', () => { + it('should return operation with the right type', () => { + expect( + termsOperation.getPossibleOperationsForField({ + aggregatable: true, + searchable: true, + name: 'test', + type: 'string', + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }) + ).toEqual([ + { + dataType: 'string', + isBucketed: true, + }, + ]); + + expect( + termsOperation.getPossibleOperationsForField({ + aggregatable: true, + searchable: true, + name: 'test', + type: 'boolean', + }) + ).toEqual([ + { + dataType: 'boolean', + isBucketed: true, + }, + ]); + }); + + it('should not return an operation if restrictions prevent terms', () => { + expect( + termsOperation.getPossibleOperationsForField({ + aggregatable: false, + searchable: true, + name: 'test', + type: 'string', + }) + ).toEqual([]); + + expect( + termsOperation.getPossibleOperationsForField({ + aggregatable: true, + aggregationRestrictions: {}, + searchable: true, + name: 'test', + type: 'string', + }) + ).toEqual([]); + }); + }); + describe('buildColumn', () => { + it('should use type from the passed field', () => { + const termsColumn = termsOperation.buildColumn({ + layerId: 'first', + suggestedPriority: undefined, + 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', @@ -85,6 +158,12 @@ describe('terms', () => { operationType: 'count', }, }, + field: { + aggregatable: true, + searchable: true, + type: 'boolean', + name: 'test', + }, }); expect(termsColumn.params).toEqual( expect.objectContaining({ diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx index 2f9863547bf84..6964e9fb89f3b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -10,6 +10,7 @@ import { EuiForm, EuiFormRow, EuiRange, EuiSelect } from '@elastic/eui'; import { TermsIndexPatternColumn, IndexPatternColumn } from '../indexpattern'; import { OperationDefinition } from '../operations'; import { updateColumnParam } from '../state_helpers'; +import { DataType } from '../../types'; type PropType = C extends React.ComponentType ? P : unknown; @@ -42,11 +43,15 @@ export const termsOperation: OperationDefinition = { defaultMessage: 'Top Values', }), getPossibleOperationsForDocument: () => [], - getPossibleOperationsForField: ({ aggregationRestrictions, type }) => { - if (type === 'string' && (!aggregationRestrictions || aggregationRestrictions.terms)) { + getPossibleOperationsForField: ({ aggregationRestrictions, aggregatable, type }) => { + if ( + (type === 'string' || type === 'boolean') && + aggregatable && + (!aggregationRestrictions || aggregationRestrictions.terms) + ) { return [ { - dataType: 'string', + dataType: type, isBucketed: true, }, ]; @@ -60,7 +65,7 @@ export const termsOperation: OperationDefinition = { return { label: ofName(field ? field.name : ''), - dataType: 'string', + dataType: field!.type as DataType, operationType: 'terms', suggestedPriority, sourceField: field ? field.name : '', From 36cd95545904c9e3ed22be73a16a894deac1c83c Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Thu, 8 Aug 2019 09:57:47 +0200 Subject: [PATCH 068/105] [lens] Minor UX/UI improvements in Lens (#42852) * Make dimension popover toggle when clicking button * Without suggestions hide suggestion panel --- .../editor_frame_plugin/editor_frame/suggestion_panel.tsx | 6 +++++- .../indexpattern_plugin/dimension_panel/popover_editor.tsx | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) 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 index 7d25fa64b2f8f..1ee732df85908 100644 --- 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 @@ -120,6 +120,10 @@ function InnerSuggestionPanel({ visualizationState ); + if (suggestions.length === 0) { + return null; + } + return (
@@ -130,7 +134,7 @@ function InnerSuggestionPanel({ /> - {suggestions.map((suggestion, index) => { + {suggestions.map(suggestion => { const previewExpression = suggestion.previewExpression ? prependDatasourceExpression( suggestion.previewExpression, 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 index 7de3201206adf..2b1e0da83d877 100644 --- 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 @@ -202,7 +202,7 @@ export function PopoverEditor(props: PopoverEditorProps) { { - setPopoverOpen(true); + setPopoverOpen(!isPopoverOpen); }} data-test-subj="indexPattern-configure-dimension" > @@ -212,7 +212,7 @@ export function PopoverEditor(props: PopoverEditorProps) { setPopoverOpen(true)} + onClick={() => setPopoverOpen(!isPopoverOpen)} iconType="plusInCircle" size="s" /> From 2e22935cad7a5abb5172a37cc4e71d8e906283ae Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Thu, 8 Aug 2019 16:30:24 +0200 Subject: [PATCH 069/105] Add missing translations (#42921) --- .../visualization.tsx | 10 ++++++- .../dimension_panel/popover_editor.tsx | 7 ++++- .../xy_visualization_plugin/xy_suggestions.ts | 27 ++++++++++++++++--- 3 files changed, 38 insertions(+), 6 deletions(-) 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 index b81f3bc0a44f9..79333ee7f820e 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -134,7 +134,15 @@ export const datatableVisualization: Visualization< const title = i18n.translate('xpack.lens.datatable.visualizationOf', { defaultMessage: 'Table: {operations}', values: { - operations: table.columns.map(col => col.operation.label).join(' & '), + operations: 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.', + }) + ), }, }); 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 index 2b1e0da83d877..34f766e3a2d0a 100644 --- 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 @@ -317,7 +317,12 @@ export function PopoverEditor(props: PopoverEditorProps) { /> )} {!incompatibleSelectedOperationType && selectedColumn && ( - + { - const yTitle = yValues.map(col => col.operation.label).join(' & '); + 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 isDate = xValue.operation.dataType === 'date'; - // TODO: Localize the title, label, etc - const preposition = isDate ? 'over' : 'of'; - const title = `${yTitle} ${preposition} ${xTitle}`; + const title = isDate + ? 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 }, + }); const seriesType: SeriesType = (currentState && currentState.preferredSeriesType) || (splitBy && isDate ? 'line' : 'bar'); const state: State = { From 22829464c85cddd72e888a8c8dd008cdcda42f9f Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Thu, 8 Aug 2019 19:28:48 -0400 Subject: [PATCH 070/105] [Lens] Config panel design (#42980) * Fix up design of config panel Does not include config popover --- .../visualization.tsx | 53 ++- .../editor_frame/chart_switch.test.tsx | 4 +- .../editor_frame/chart_switch.tsx | 128 ++++--- .../editor_frame/index.scss | 19 +- .../dimension_panel/_summary.scss | 1 + .../dimension_panel/dimension_panel.test.tsx | 2 +- .../dimension_panel/dimension_panel.tsx | 73 ++-- .../dimension_panel/popover_editor.tsx | 39 +- .../_xy_config_panel.scss | 21 +- .../xy_config_panel.tsx | 334 +++++++++--------- 10 files changed, 369 insertions(+), 305 deletions(-) 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 index 79333ee7f820e..e41ddae7cbe75 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { render } from 'react-dom'; -import { EuiForm, EuiFormRow, EuiPanel } from '@elastic/eui'; +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'; @@ -57,32 +57,31 @@ export function DataTableLayer({ }: { 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" /> - - - 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" - /> - - + ); } @@ -105,7 +104,7 @@ export const datatableVisualization: Visualization< getDescription(state) { return { - icon: 'empty', + icon: 'visTable', label: i18n.translate('xpack.lens.datatable.label', { defaultMessage: 'Datatable', }), 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 index 1d5fb3563d744..28e6e59540d83 100644 --- 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 @@ -169,7 +169,7 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('bolt'); + expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); }); it('should indicate data loss if no data will be used', () => { @@ -188,7 +188,7 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('bolt'); + expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); }); it('should not indicate data loss if there is no data', () => { 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 index 08d5e8650c374..4868c21bc5c5a 100644 --- 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 @@ -8,10 +8,11 @@ import React, { useState, useMemo } from 'react'; import { EuiIcon, EuiPopover, - EuiButton, EuiPopoverTitle, EuiKeyPadMenu, EuiKeyPadMenuItemButton, + EuiButtonEmpty, + EuiTitle, } from '@elastic/eui'; import { flatten } from 'lodash'; import { i18n } from '@kbn/i18n'; @@ -61,7 +62,9 @@ function VisualizationSummary(props: Props) { return ( <> - {description.icon && } + {description.icon && ( + + )} {description.label} ); @@ -183,61 +186,72 @@ export function ChartSwitch(props: Props) { ] ); - return ( - <> - setFlyoutOpen(!flyoutOpen)} - data-test-subj="lnsChartSwitchPopover" - > - - - } - isOpen={flyoutOpen} - closePopover={() => setFlyoutOpen(false)} - anchorPosition="leftUp" - > - - {i18n.translate('xpack.lens.configPanel.chooseVisualization', { - defaultMessage: 'Choose a visualization', + const popover = ( + setFlyoutOpen(!flyoutOpen)} + data-test-subj="lnsChartSwitchPopover" + > + ( + {i18n.translate('xpack.lens.configPanel.changeVisualization', { + defaultMessage: 'Change', })} - - - {(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' ? 'bolt' : undefined} - > - - - ))} - - - + ) + + } + 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} +

+
+
); } 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 index e64b357982ce9..ba6ae3bf71bd7 100644 --- 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 @@ -30,6 +30,20 @@ position: relative; } +.lnsSidebar__header { + padding: $euiSizeS 0; + + > * { + display: flex; + align-items: center; + } +} + +.lnsChartSwitch__summaryIcon { + margin-right: $euiSizeS; + transform: translateY(-2px); +} + .lnsSidebar--right { min-width: ($euiSize * 22); @include euiScrollBar; @@ -106,8 +120,5 @@ font-size: 1.2em; } -.lnsConfigPanelWrapper { - padding: $euiSize 0; -} -@import './suggestion_panel.scss'; \ No newline at end of file +@import './suggestion_panel.scss'; 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 index b7f4e22ee251d..0cf0dc66f9f67 100644 --- 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 @@ -26,6 +26,7 @@ .lnsConfigPanel__summaryLink { width: 100%; display: flex; + align-items: center; } .lnsConfigPanel__summaryField { 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 index 75b8e2a8c1aaa..c79543b35ae88 100644 --- 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 @@ -133,7 +133,7 @@ describe('IndexPatternDimensionPanel', () => { .find('[data-test-subj="indexPattern-configure-dimension"]') .first() .prop('iconType') - ).toEqual('plusInCircle'); + ).toEqual('plusInCircleFilled'); }); it('should call the filterOperations function', () => { 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 index 9c3588349fc69..dae1e073a704d 100644 --- 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 @@ -6,7 +6,7 @@ import _ from 'lodash'; import React, { memo, useMemo } from 'react'; -import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon } from '@elastic/eui'; +import { EuiButtonIcon } from '@elastic/eui'; import { Storage } from 'ui/storage'; import { i18n } from '@kbn/i18n'; import { DatasourceDimensionPanelProps } from '../../types'; @@ -125,44 +125,39 @@ export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPan ); }} > - - - - - - {selectedColumn && ( - - { - props.setState( - deleteColumn({ - state: props.state, - layerId, - columnId: props.columnId, - }) - ); - if (props.onRemove) { - props.onRemove(props.columnId); - } - }} - /> - - )} - - + + {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/popover_editor.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx index 34f766e3a2d0a..c383849fa7eb7 100644 --- 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 @@ -17,7 +17,8 @@ import { EuiFormRow, EuiFieldText, EuiLink, - EuiButton, + EuiButtonIcon, + EuiTextColor, } from '@elastic/eui'; import classNames from 'classnames'; import { @@ -196,7 +197,9 @@ export function PopoverEditor(props: PopoverEditorProps) { {selectedColumn.label}
) : ( - setPopoverOpen(!isPopoverOpen)} - iconType="plusInCircle" - size="s" - /> + <> + setPopoverOpen(!isPopoverOpen)} + />{' '} + + + + ) } isOpen={isPopoverOpen} 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 index 9d5538130260d..284c25f0f3792 100644 --- 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 @@ -1,3 +1,20 @@ -.lnsXyConfigPanel-layer { - border: 1px solid $euiColorLightestShade; +.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_config_panel.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx index 03892abc84c3e..249ea6b5b72ea 100644 --- 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 @@ -7,7 +7,6 @@ import _ from 'lodash'; import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiButtonGroup, @@ -19,6 +18,9 @@ import { EuiButtonIcon, EuiPopover, EuiSwitch, + EuiSpacer, + EuiButtonEmpty, + EuiPopoverFooter, } from '@elastic/eui'; import { State, SeriesType, LayerConfig, visualizationTypes } from './types'; import { VisualizationProps, OperationMetadata } from '../types'; @@ -67,6 +69,7 @@ function LayerSettings({ return ( - - + - + + ); } @@ -125,175 +129,177 @@ export function XYConfigPanel(props: VisualizationProps) { return ( - - setIsChartOptionsOpen(false)} - button={ - - <> - setIsChartOptionsOpen(true)} - > - - - - - } - > - <> - - { - setState({ - ...state, - isHorizontal: !state.isHorizontal, - }); - }} - data-test-subj="lnsXY_chart_horizontal" - /> - - - - + 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, 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 - ) + + + + + + + + + + + setState( + updateLayer( + state, + { + ...layer, + accessors: [...layer.accessors, generateId()], + }, + index ) - } - onRemove={accessor => - setState( - updateLayer( - state, - { - ...layer, - accessors: layer.accessors.filter(col => col !== accessor), - }, - 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} - /> - - - + ) + } + 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="plusInCircle" - > - - - + { + 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" + /> ); } From 629f63a860ba4abda721f83dbf49ef72ece9de9a Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Fri, 9 Aug 2019 15:02:12 +0200 Subject: [PATCH 071/105] Remove a couple of non-null assertions (#43013) * Remove a couple of non-null assertions * Remove orphaned import --- .../visualization.tsx | 5 +- .../embeddable/embeddable.tsx | 2 +- .../public/editor_frame_plugin/plugin.tsx | 81 +++++++++---------- .../public/indexpattern_plugin/field_item.tsx | 8 +- .../operation_definitions/terms.tsx | 9 ++- 5 files changed, 53 insertions(+), 52 deletions(-) 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 index e41ddae7cbe75..e50af0672c212 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -15,6 +15,7 @@ import { Visualization, VisualizationProps, VisualizationSuggestion, + Operation, } from '../types'; import { generateId } from '../id_generator'; import { NativeRenderer } from '../native_renderer'; @@ -179,7 +180,7 @@ export const datatableVisualization: Visualization< const datasource = frame.datasourceLayers[layer.layerId]; const operations = layer.columns .map(columnId => ({ columnId, operation: datasource.getOperationForColumnId(columnId) })) - .filter(o => o.operation); + .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); return { type: 'expression', @@ -199,7 +200,7 @@ export const datatableVisualization: Visualization< columnIds: operations.map(o => o.columnId), labels: operations.map( o => - o.operation!.label || + o.operation.label || i18n.translate('xpack.lens.datatable.na', { defaultMessage: 'N/A', }) 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 index 318cff621a9d9..728676b2ea869 100644 --- 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 @@ -63,7 +63,7 @@ export class Embeddable extends AbstractEmbeddable = {}; private readonly visualizations: Record = {}; - private 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); - } - }, - }; - } - public setup(_core: CoreSetup | null, plugins: EditorFrameSetupPlugins): EditorFrameSetup { plugins.interpreter.functionsRegistry.register(() => mergeTables); - this.ExpressionRenderer = plugins.data.expressions.ExpressionRenderer; plugins.embeddables.addEmbeddableFactory( new EmbeddableFactory( plugins.chrome, - this.ExpressionRenderer, + plugins.data.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: this.createInstance.bind(this), + createInstance, registerDatasource: (name, datasource) => { this.datasources[name] = datasource as Datasource; }, 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 index e3a3802320aaa..6911212094f65 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx @@ -20,11 +20,11 @@ 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') : undefined; + return str ? str.replace(/\./g, '.\u200B') : ''; } export function FieldItem({ field, indexPatternId, highlight }: FieldItemProps) { - const wrappableName = wrapOnDot(field.name)!; + const wrappableName = wrapOnDot(field.name); const wrappableHighlight = wrapOnDot(highlight); const highlightIndex = wrappableHighlight ? wrappableName.toLowerCase().indexOf(wrappableHighlight.toLowerCase()) @@ -35,8 +35,8 @@ export function FieldItem({ field, indexPatternId, highlight }: FieldItemProps) ) : ( {wrappableName.substr(0, highlightIndex)} - {wrappableName.substr(highlightIndex, wrappableHighlight!.length)} - {wrappableName.substr(highlightIndex + wrappableHighlight!.length)} + {wrappableName.substr(highlightIndex, wrappableHighlight.length)} + {wrappableName.substr(highlightIndex + wrappableHighlight.length)} ); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx index 6964e9fb89f3b..2b912f827fe11 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -59,16 +59,19 @@ export const termsOperation: OperationDefinition = { return []; }, buildColumn({ suggestedPriority, columns, field }) { + if (!field) { + throw new Error('Invariant error: terms operation requires field'); + } const existingMetricColumn = Object.entries(columns) .filter(([_columnId, column]) => column && isSortableByColumn(column)) .map(([id]) => id)[0]; return { - label: ofName(field ? field.name : ''), - dataType: field!.type as DataType, + label: ofName(field.name), + dataType: field.type as DataType, operationType: 'terms', suggestedPriority, - sourceField: field ? field.name : '', + sourceField: field.name, isBucketed: true, params: { size: DEFAULT_SIZE, From 7c9db79f55a440800e24224b045310ce0a60be4c Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 9 Aug 2019 17:41:54 +0200 Subject: [PATCH 072/105] [Lens] Switch indexpattern manually (#42599) --- .../__mocks__/operations.ts | 1 + .../__mocks__/state_helpers.ts | 3 + .../indexpattern_plugin/datapanel.test.tsx | 66 ++++- .../public/indexpattern_plugin/datapanel.tsx | 2 +- .../indexpattern_plugin/indexpattern.tsx | 8 +- .../indexpattern_plugin/layerpanel.test.tsx | 251 ++++++++++++++++++ .../public/indexpattern_plugin/layerpanel.tsx | 144 ++++++++++ .../operation_definitions/count.tsx | 3 + .../date_histogram.test.tsx | 79 ++++++ .../operation_definitions/date_histogram.tsx | 60 ++++- .../operation_definitions/filter_ratio.tsx | 4 + .../operation_definitions/metrics.tsx | 10 + .../operation_definitions/terms.tsx | 10 + .../public/indexpattern_plugin/operations.ts | 8 + .../indexpattern_plugin/state_helpers.test.ts | 229 +++++++++++++++- .../indexpattern_plugin/state_helpers.ts | 35 ++- 16 files changed, 893 insertions(+), 20 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.tsx diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts index e5b24ef18f1ec..a7ab5fc2c6faa 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts @@ -17,4 +17,5 @@ export const { getOperationTypesForField, getOperationResultType, operationDefinitionMap, + isColumnTransferable, } = actual; 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 index 1df52a3fd80ea..48de1b3d8b4f9 100644 --- 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 @@ -7,6 +7,7 @@ const actual = jest.requireActual('../state_helpers'); jest.spyOn(actual, 'changeColumn'); +jest.spyOn(actual, 'updateLayerIndexPattern'); export const { getColumnOrder, @@ -15,4 +16,6 @@ export const { 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 index 6e37f736d738d..56163d4079f8d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -20,14 +20,14 @@ const initialState: IndexPatternPrivateState = { layers: { first: { indexPatternId: '1', - columnOrder: ['col1'], + columnOrder: ['col1', 'col2'], columns: { col1: { label: 'My Op', dataType: 'string', isBucketed: true, operationType: 'terms', - sourceField: 'op', + sourceField: 'source', params: { size: 5, orderDirection: 'asc', @@ -36,6 +36,40 @@ const initialState: IndexPatternPrivateState = { }, }, }, + 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', + }, }, }, }, @@ -106,9 +140,6 @@ const initialState: IndexPatternPrivateState = { agg: 'histogram', interval: 1000, }, - avg: { - agg: 'avg', - }, max: { agg: 'max', }, @@ -133,6 +164,31 @@ const initialState: IndexPatternPrivateState = { }, ], }, + '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', () => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx index 07b48c0479b46..6096499a839bf 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -333,4 +333,4 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ ); }; -const MemoizedDataPanel = memo(InnerIndexPatternDataPanel); +export const MemoizedDataPanel = memo(InnerIndexPatternDataPanel); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 3c01dd2bba7e1..c2e937a256c41 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -8,7 +8,6 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; -import { EuiText } from '@elastic/eui'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, @@ -28,6 +27,7 @@ import { } from './indexpattern_suggestions'; import { isDraggedField } from './utils'; +import { LayerPanel } from './layerpanel'; import { Datasource, DataType } from '..'; export type OperationType = IndexPatternColumn['operationType']; @@ -317,11 +317,7 @@ export function getIndexPatternDatasource({ renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => { render( - - - {state.indexPatterns[state.layers[props.layerId].indexPatternId].title} - - , + setState(newState)} {...props} />, domElement ); }, 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..9ad3bf93f2b17 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx @@ -0,0 +1,251 @@ +/* + * 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('./state_helpers'); + +const initialState: IndexPatternPrivateState = { + currentIndexPatternId: '1', + 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..27b128eb35ef2 --- /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 } from '../types'; +import { IndexPatternPrivateState, IndexPatternLayer } from './indexpattern'; +import { isLayerTransferable, updateLayerIndexPattern } from './state_helpers'; + +export interface IndexPatternLayerPanelProps extends DatasourceLayerPanelProps { + state: IndexPatternPrivateState; + setState: (newState: IndexPatternPrivateState) => void; +} + +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/operation_definitions/count.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx index 8006af3ccc38d..20b96583e125c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx @@ -40,4 +40,7 @@ export const countOperation: OperationDefinition = { schema: 'metric', params: {}, }), + isTransferable: () => { + return true; + }, }; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx index f5fa7ff0a0732..b91c9c07bb601 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx @@ -146,6 +146,85 @@ describe('date_histogram', () => { }); }); + 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(); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx index 8a59161611e82..da436a849dc51 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx @@ -15,6 +15,8 @@ import { updateColumnParam } from '../state_helpers'; type PropType = C extends React.ComponentType ? P : unknown; +const supportedIntervals = ['M', 'w', 'd', 'h']; + // Add ticks to EuiRange component props const FixedEuiRange = (EuiRange as unknown) as React.ComponentType< PropType & { @@ -83,6 +85,51 @@ export const dateHistogramOperation: OperationDefinition { + 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, + }, + }; + } + }, toEsAggsConfig: (column, columnId) => ({ id: columnId, enabled: true, @@ -109,14 +156,12 @@ export const dateHistogramOperation: OperationDefinition @@ -136,11 +181,14 @@ export const dateHistogramOperation: OperationDefinition ({ label: interval, value: index }))} + ticks={supportedIntervals.map((interval, index) => ({ + label: interval, + value: index, + }))} onChange={(e: React.ChangeEvent) => setState( updateColumnParam( diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx index 3881adc302c86..d0c08519cd4cc 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx @@ -60,6 +60,10 @@ export const filterRatioOperation: OperationDefinition { + // TODO parse the KQL tree and check whether this would work out + return false; + }, paramEditor: ({ state, setState, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx index 9dd574d8d5e8f..41313b7cbc073 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx @@ -38,6 +38,16 @@ function buildMetricOperation( } return []; }, + 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 }): T { if (!field) { throw new Error(`Invariant: A ${type} operation can only be built with a field`); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx index 2b912f827fe11..97e006ff3df32 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -58,6 +58,16 @@ export const termsOperation: OperationDefinition = { } return []; }, + 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 }) { if (!field) { throw new Error('Invariant error: terms operation requires field'); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index e9045c3666ecb..4b9224df45757 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -84,6 +84,14 @@ export interface OperationDefinition { ) => C; paramEditor?: React.ComponentType; toEsAggsConfig: (column: C, columnId: string) => unknown; + isTransferable: (column: C, newIndexPattern: IndexPattern) => boolean; + transfer?: (column: C, newIndexPattern: IndexPattern) => C; +} + +export function isColumnTransferable(column: IndexPatternColumn, newIndexPattern: IndexPattern) { + return (operationDefinitionMap[column.operationType] as OperationDefinition< + IndexPatternColumn + >).isTransferable(column, newIndexPattern); } export function getOperationDisplay() { 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 index b90ba469518d4..a929802a830ee 100644 --- 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 @@ -4,12 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { updateColumnParam, changeColumn, getColumnOrder, deleteColumn } from './state_helpers'; +import { + updateColumnParam, + changeColumn, + getColumnOrder, + deleteColumn, + updateLayerIndexPattern, +} from './state_helpers'; import { IndexPatternPrivateState, DateHistogramIndexPatternColumn, TermsIndexPatternColumn, AvgIndexPatternColumn, + IndexPattern, + IndexPatternLayer, } from './indexpattern'; import { operationDefinitionMap } from './operations'; @@ -437,4 +445,223 @@ describe('state_helpers', () => { ).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 index 9f95b3726ea3a..e4031071e448c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts @@ -9,8 +9,10 @@ import { IndexPatternPrivateState, IndexPatternColumn, BaseIndexPatternColumn, + IndexPatternLayer, + IndexPattern, } from './indexpattern'; -import { operationDefinitionMap, OperationDefinition } from './operations'; +import { operationDefinitionMap, OperationDefinition, isColumnTransferable } from './operations'; export function updateColumnParam< C extends BaseIndexPatternColumn & { params: object }, @@ -158,3 +160,34 @@ export function getColumnOrder(columns: Record): str .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] as OperationDefinition< + IndexPatternColumn + >; + 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, + }; +} From 27be022ce55d4ef7709b1f2382f49445a6c3ecfb Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Fri, 9 Aug 2019 12:46:18 -0400 Subject: [PATCH 073/105] [Lens] Update frame to put suggestions at the bottom (#42997) --- .../editor_frame/chart_switch.tsx | 2 +- .../editor_frame/editor_frame.test.tsx | 19 +-- .../editor_frame/frame_layout.tsx | 2 +- .../editor_frame/index.scss | 29 +++-- .../editor_frame/suggestion_panel.scss | 58 +++++++-- .../editor_frame/suggestion_panel.test.tsx | 14 +- .../editor_frame/suggestion_panel.tsx | 122 +++++++++--------- .../public/indexpattern_plugin/datapanel.tsx | 20 ++- .../indexpattern_plugin/indexpattern.scss | 15 ++- 9 files changed, 176 insertions(+), 105 deletions(-) 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 index 4868c21bc5c5a..95eacf3483d62 100644 --- 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 @@ -200,7 +200,7 @@ export function ChartSwitch(props: Props) { > ( {i18n.translate('xpack.lens.configPanel.changeVisualization', { - defaultMessage: 'Change', + defaultMessage: 'change', })} ) 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 index 7d9ff8918ab29..716ea75e39824 100644 --- 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 @@ -17,6 +17,7 @@ import { DatasourceMock, } from '../mocks'; import { ExpressionRenderer } from 'src/legacy/core_plugins/data/public'; +import { EuiPanel, EuiToolTip } from '@elastic/eui'; // calling this function will wait for all pending Promises from mock // datasources to be processed by its callers. @@ -1119,13 +1120,12 @@ describe('editor_frame', () => { // TODO why is this necessary? instance.update(); - const suggestions = instance.find('[data-test-subj="suggestion-title"]'); - expect(suggestions.map(el => el.text())).toEqual([ - 'Suggestion1', - 'Suggestion2', - 'Suggestion3', - 'Suggestion4', - ]); + expect( + instance + .find('[data-test-subj="lnsSuggestion"]') + .find(EuiPanel) + .map(el => el.parents(EuiToolTip).prop('content')) + ).toEqual(['Suggestion1', 'Suggestion2', 'Suggestion3', 'Suggestion4']); }); it('should switch to suggested visualization', async () => { @@ -1167,7 +1167,10 @@ describe('editor_frame', () => { instance.update(); act(() => { - instance.find('[data-test-subj="suggestion-title"]').simulate('click'); + instance + .find('[data-test-subj="lnsSuggestion"]') + .first() + .simulate('click'); }); expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(1); 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 index c630bc96704f3..8a33178de70cf 100644 --- 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 @@ -23,10 +23,10 @@ export function FrameLayout(props: FrameLayoutProps) { {props.dataPanel} {props.workspacePanel} + {props.suggestionsPanel} {props.configPanel} - {props.suggestionsPanel}
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 index ba6ae3bf71bd7..72b5f1eb79638 100644 --- 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 @@ -1,3 +1,4 @@ +$lnsPanelMinWidth: $euiSize * 18; .lnsPage { padding: 0; @@ -24,7 +25,7 @@ .lnsSidebar { margin: 0; flex: 1 0 18%; - min-width: ($euiSize * 22); + min-width: $lnsPanelMinWidth + $euiSize; display: flex; flex-direction: column; position: relative; @@ -45,10 +46,13 @@ } .lnsSidebar--right { - min-width: ($euiSize * 22); @include euiScrollBar; - overflow: hidden auto; - padding: $euiSize; + min-width: $lnsPanelMinWidth + $euiSize; + overflow-x: hidden; + overflow-y: auto; + padding-top: $euiSize; + padding-right: $euiSize; + max-height: 100%; } .lnsSidebarContainer { @@ -64,31 +68,37 @@ .lnsPageBody { @include euiScrollBar; - padding-top: $euiSize; - padding-bottom: $euiSize; - overflow: hidden auto; + 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: $euiSize; + padding: $euiSizeS; border-bottom: $euiBorderThin; margin-bottom: 0; } .lnsPageContentBody { + @include euiScrollBar; flex-grow: 1; - padding: $euiSizeXL; display: flex; align-items: stretch; justify-content: stretch; + overflow: auto; > * { flex: 1 1 100%; @@ -108,6 +118,7 @@ align-items: center; justify-content: center; overflow-x: hidden; + padding: $euiSize; } .lnsTitleInput { 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 index 2139b314ae314..add9df3f10dbd 100644 --- 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 @@ -1,21 +1,59 @@ -.lnsSidebar__suggestions { - > * { - margin-top: $euiSizeS; + // 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; } } -$suggestionHeight: 120px; +// 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: $suggestionHeight; + height: 100%; display: flex; align-items: center; justify-content: center; - padding: $euiSize; + padding: $euiSizeS; } - .lnsSuggestionChartWrapper { - height: $suggestionHeight; - pointer-events: none; - } \ No newline at end of file +.lnsSuggestionChartWrapper { + height: $lnsSuggestionHeight - $euiSize; + pointer-events: none; +} 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 index effadb27ddb43..d2f3b8e895aa6 100644 --- 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 @@ -17,7 +17,7 @@ import { ExpressionRenderer } from 'src/legacy/core_plugins/data/public'; import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel'; import { getSuggestions, Suggestion } from './suggestion_helpers'; import { fromExpression } from '@kbn/interpreter/target/common'; -import { EuiIcon } from '@elastic/eui'; +import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; jest.mock('./suggestion_helpers'); @@ -82,17 +82,19 @@ describe('suggestion_panel', () => { it('should list passed in suggestions', () => { const wrapper = mount(); - expect(wrapper.find('[data-test-subj="suggestion-title"]').map(el => el.text())).toEqual([ - 'Suggestion1', - 'Suggestion2', - ]); + 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="suggestion-title"]') + .find('[data-test-subj="lnsSuggestion"]') .first() .simulate('click'); 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 index 1ee732df85908..aad24b4c3b9cd 100644 --- 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 @@ -6,7 +6,7 @@ import React, { useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip } from '@elastic/eui'; +import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip, EuiToolTip } from '@elastic/eui'; import { toExpression } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; import { Action } from './state_management'; @@ -51,46 +51,46 @@ const SuggestionPreview = ({ }, [previewExpression]); return ( - { - dispatch(toSwitchAction(suggestion)); - }} - > - -

{suggestion.title}

-
- {expressionError ? ( -
- + { + dispatch(toSwitchAction(suggestion)); + }} + > + {expressionError ? ( +
+ +
+ ) : previewExpression ? ( + { + // eslint-disable-next-line no-console + console.error(`Failed to render preview: `, e); + setExpressionError(true); + }} /> -
- ) : previewExpression ? ( - { - // eslint-disable-next-line no-console - console.error(`Failed to render preview: `, e); - setExpressionError(true); - }} - /> - ) : ( -
- -
- )} -
+ ) : ( +
+ +
+ )} + + ); }; @@ -125,8 +125,8 @@ function InnerSuggestionPanel({ } return ( -
- +
+

- {suggestions.map(suggestion => { - const previewExpression = suggestion.previewExpression - ? prependDatasourceExpression( - suggestion.previewExpression, - datasourceMap, - datasourceStates - ) - : null; - return ( - - ); - })} +
+ {suggestions.map(suggestion => { + const previewExpression = suggestion.previewExpression + ? prependDatasourceExpression( + suggestion.previewExpression, + datasourceMap, + datasourceStates + ) + : null; + return ( + + ); + })} +
); } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx index 6096499a839bf..ccc8ff6f83cec 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -124,7 +124,12 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ if (Object.keys(indexPatterns).length === 0) { return ( - + - +
{!showIndexPatternSwitcher ? ( @@ -232,7 +242,11 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
- + Date: Sun, 11 Aug 2019 10:46:08 +0200 Subject: [PATCH 074/105] fix type errors --- .../legacy/plugins/lens/public/indexpattern_plugin/loader.ts | 2 +- .../plugins/lens/public/persistence/saved_object_store.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts index 74b0ff434f4cb..8ed19d098134e 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts @@ -7,7 +7,7 @@ import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedObjectAttributes } from 'src/core/server/saved_objects'; +import { SavedObjectAttributes } from 'src/core/server'; import { IndexPatternField } from './indexpattern'; interface SavedIndexPatternAttributes extends SavedObjectAttributes { 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 index 5846565dd05a7..681042ed34ffe 100644 --- a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts +++ b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectAttributes } from 'target/types/server'; +// 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'; From c808106a9bcb11f14e487b4f9528e4a854cc771e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 13 Aug 2019 08:28:04 +0200 Subject: [PATCH 075/105] switch indexpattern on layer if there is only a single empty one (#43079) --- .../indexpattern_plugin/datapanel.test.tsx | 82 ++++++++++++++++++- .../public/indexpattern_plugin/datapanel.tsx | 15 +++- 2 files changed, 93 insertions(+), 4 deletions(-) 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 index 56163d4079f8d..9ff6297c28368 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -7,9 +7,9 @@ import { shallow } from 'enzyme'; import React, { ChangeEvent, ReactElement } from 'react'; import { EuiComboBox, EuiFieldSearch, EuiContextMenuPanel } from '@elastic/eui'; -import { IndexPatternPrivateState } from './indexpattern'; +import { IndexPatternPrivateState, IndexPatternColumn } from './indexpattern'; import { createMockedDragDropContext } from './mocks'; -import { InnerIndexPatternDataPanel } from './datapanel'; +import { InnerIndexPatternDataPanel, IndexPatternDataPanel, MemoizedDataPanel } from './datapanel'; import { FieldItem } from './field_item'; import { act } from 'react-dom/test-utils'; @@ -205,6 +205,84 @@ describe('IndexPattern Data Panel', () => { }; }); + 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( diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx index ccc8ff6f83cec..74c19ad2cf0e3 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; +import { mapValues, uniq } from 'lodash'; import React, { useState, useEffect, memo, useCallback } from 'react'; import { EuiComboBox, @@ -30,6 +30,7 @@ import { IndexPatternPrivateState, IndexPatternField, IndexPattern } from './ind 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< @@ -50,6 +51,11 @@ const fieldTypeNames: Record = { 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, @@ -62,6 +68,11 @@ export function IndexPatternDataPanel({ (newIndexPattern: string) => { setState({ ...state, + layers: isSingleEmptyLayer(state.layers) + ? mapValues(state.layers, layer => + updateLayerIndexPattern(layer, indexPatterns[newIndexPattern]) + ) + : state.layers, currentIndexPatternId: newIndexPattern, }); }, @@ -160,7 +171,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ ) .slice(0, pageSize); - const availableFieldTypes = _.uniq(filteredFields.map(({ type }) => type)); + const availableFieldTypes = uniq(filteredFields.map(({ type }) => type)); const availableFilteredTypes = state.typeFilter.filter(type => availableFieldTypes.includes(type) ); From 65ea9e33a1a5d5a2cf1acbdab879005b838d0683 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 13 Aug 2019 08:29:01 +0200 Subject: [PATCH 076/105] [Lens] Suggest reduced versions of current data table (#42537) --- .../visualization.tsx | 4 +- .../__mocks__/suggestion_helpers.ts | 2 +- .../editor_frame/chart_switch.test.tsx | 199 +++- .../editor_frame/chart_switch.tsx | 91 +- .../editor_frame/config_panel_wrapper.tsx | 12 +- .../editor_frame/editor_frame.test.tsx | 41 +- .../editor_frame/editor_frame.tsx | 3 + .../editor_frame/state_management.test.ts | 1 + .../editor_frame/state_management.ts | 15 +- .../editor_frame/suggestion_helpers.test.ts | 262 ++++- .../editor_frame/suggestion_helpers.ts | 117 +- .../editor_frame/suggestion_panel.test.tsx | 29 +- .../editor_frame/suggestion_panel.tsx | 25 +- .../editor_frame/workspace_panel.test.tsx | 2 + .../editor_frame/workspace_panel.tsx | 27 +- .../indexpattern_plugin/indexpattern.test.tsx | 732 ------------ .../indexpattern_suggestions.test.tsx | 1040 +++++++++++++++++ .../indexpattern_suggestions.tsx | 73 +- .../xy_visualization_plugin/xy_suggestions.ts | 3 +- 19 files changed, 1749 insertions(+), 929 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx 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 index e50af0672c212..d89a9c0276972 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -130,6 +130,7 @@ export const datatableVisualization: Visualization< }: SuggestionRequest): Array< VisualizationSuggestion > { + const maxColumnCount = Math.max.apply(undefined, tables.map(table => table.columns.length)); return tables.map(table => { const title = i18n.translate('xpack.lens.datatable.visualizationOf', { defaultMessage: 'Table: {operations}', @@ -148,7 +149,8 @@ export const datatableVisualization: Visualization< return { title, - score: 1, + // largest possible table will have a score of 0.2, less columns reduce score + score: (table.columns.length / maxColumnCount) * 0.2, datasourceSuggestionId: table.datasourceSuggestionId, state: { layers: [ 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 index 94d162aa5f1b0..c11ec237add5b 100644 --- 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 @@ -8,4 +8,4 @@ const actual = jest.requireActual('../suggestion_helpers'); jest.spyOn(actual, 'getSuggestions'); -export const { getSuggestions, toSwitchAction } = actual; +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 index 28e6e59540d83..a3d9f02c9def3 100644 --- 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 @@ -5,12 +5,13 @@ */ import React from 'react'; -import { createMockVisualization, createMockFramePublicAPI } from '../mocks'; +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 { @@ -83,6 +84,33 @@ describe('chart_switch', () => { } as FramePublicAPI; } + function mockDatasourceMap() { + const datasource = createMockDatasource(); + datasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { + state: {}, + table: { + columns: [], + datasourceSuggestionId: 0, + isMultiRow: true, + layerId: 'a', + }, + }, + ]); + return { + testDatasource: datasource, + }; + } + + function mockDatasourceStates() { + return { + testDatasource: { + state: {}, + isLoading: false, + }, + }; + } + function showFlyout(component: ReactWrapper) { component .find('[data-test-subj="lnsChartSwitchPopover"]') @@ -116,6 +144,8 @@ describe('chart_switch', () => { visualizationMap={visualizations} dispatch={dispatch} framePublicAPI={mockFrame(['a'])} + datasourceMap={mockDatasourceMap()} + datasourceStates={mockDatasourceStates()} /> ); @@ -125,6 +155,8 @@ describe('chart_switch', () => { initialState: 'suggestion visB', newVisualizationId: 'visB', type: 'SWITCH_VISUALIZATION', + datasourceId: 'testDatasource', + datasourceState: {}, }); }); @@ -142,6 +174,8 @@ describe('chart_switch', () => { visualizationMap={visualizations} dispatch={dispatch} framePublicAPI={frame} + datasourceMap={mockDatasourceMap()} + datasourceStates={mockDatasourceStates()} /> ); @@ -154,6 +188,61 @@ describe('chart_switch', () => { }); }); + 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, + }, + }, + ], + datasourceSuggestionId: 0, + layerId: 'first', + isMultiRow: true, + }, + }, + ]); + 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(); @@ -166,6 +255,8 @@ describe('chart_switch', () => { visualizationMap={visualizations} dispatch={dispatch} framePublicAPI={frame} + datasourceMap={mockDatasourceMap()} + datasourceStates={mockDatasourceStates()} /> ); @@ -185,6 +276,8 @@ describe('chart_switch', () => { visualizationMap={visualizations} dispatch={dispatch} framePublicAPI={frame} + datasourceMap={mockDatasourceMap()} + datasourceStates={mockDatasourceStates()} /> ); @@ -205,6 +298,8 @@ describe('chart_switch', () => { visualizationMap={visualizations} dispatch={dispatch} framePublicAPI={frame} + datasourceMap={mockDatasourceMap()} + datasourceStates={mockDatasourceStates()} /> ); @@ -230,6 +325,8 @@ describe('chart_switch', () => { visualizationMap={visualizations} dispatch={dispatch} framePublicAPI={frame} + datasourceMap={mockDatasourceMap()} + datasourceStates={mockDatasourceStates()} /> ); @@ -249,6 +346,8 @@ describe('chart_switch', () => { visualizationMap={mockVisualizations()} dispatch={jest.fn()} framePublicAPI={frame} + datasourceMap={mockDatasourceMap()} + datasourceStates={mockDatasourceStates()} /> ); @@ -258,6 +357,30 @@ describe('chart_switch', () => { 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(); @@ -277,16 +400,78 @@ describe('chart_switch', () => { visualizationMap={visualizations} dispatch={dispatch} framePublicAPI={frame} + datasourceMap={mockDatasourceMap()} + datasourceStates={mockDatasourceStates()} /> ); 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, + }, + }, + ], + datasourceSuggestionId: 0, + layerId: 'a', + isMultiRow: true, + }, + }, + ]); + + const component = mount( + + ); + + switchTo('subvisB', component); + expect(dispatch).toHaveBeenCalledWith({ - type: 'UPDATE_VISUALIZATION_STATE', - newState: 'therebedragons', - }); + 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', () => { @@ -305,6 +490,8 @@ describe('chart_switch', () => { visualizationMap={visualizations} dispatch={dispatch} framePublicAPI={mockFrame(['a'])} + datasourceMap={mockDatasourceMap()} + datasourceStates={mockDatasourceStates()} /> ); @@ -314,6 +501,8 @@ describe('chart_switch', () => { initialState: 'suggestion visB subvisB', newVisualizationId: 'visB', type: 'SWITCH_VISUALIZATION', + datasourceId: 'testDatasource', + datasourceState: {}, }); }); @@ -325,6 +514,8 @@ describe('chart_switch', () => { visualizationMap={mockVisualizations()} dispatch={jest.fn()} framePublicAPI={mockFrame(['a', 'b'])} + datasourceMap={mockDatasourceMap()} + datasourceStates={mockDatasourceStates()} /> ); 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 index 95eacf3483d62..daed3adf9cd46 100644 --- 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 @@ -16,15 +16,18 @@ import { } from '@elastic/eui'; import { flatten } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Visualization, FramePublicAPI } from '../../types'; +import { Visualization, FramePublicAPI, Datasource } from '../../types'; import { Action } from './state_management'; +import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; interface VisualizationSelection { visualizationId: string; subVisualizationId: string; getVisualizationState: () => unknown; - keptLayerId: string; - dataLoss: 'nothing' | 'layers' | 'everything'; + keptLayerIds: string[]; + dataLoss: 'nothing' | 'layers' | 'everything' | 'columns'; + datasourceId?: string; + datasourceState?: unknown; } interface Props { @@ -33,16 +36,14 @@ interface Props { visualizationId: string | null; visualizationState: unknown; framePublicAPI: FramePublicAPI; -} - -function dropUnusedLayers(frame: FramePublicAPI, layerId: string) { - // Remove any layers that are not used by the new visualization. If we don't do this, - // we get orphaned objects, and weird edge cases such as prompting the user that - // layers are going to be dropped, when the user is unaware of any extraneous layers. - const layerIds = Object.keys(frame.datasourceLayers).filter(id => { - return id !== layerId; - }); - frame.removeLayers(layerIds); + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; } function VisualizationSummary(props: Props) { @@ -76,22 +77,10 @@ export function ChartSwitch(props: Props) { const commitSelection = (selection: VisualizationSelection) => { setFlyoutOpen(false); - if (selection.dataLoss === 'everything' || selection.dataLoss === 'layers') { - dropUnusedLayers(props.framePublicAPI, selection.keptLayerId); - } - - if (selection.visualizationId !== props.visualizationId) { - props.dispatch({ - type: 'SWITCH_VISUALIZATION', - newVisualizationId: selection.visualizationId, - initialState: selection.getVisualizationState(), - }); - } else { - props.dispatch({ - type: 'UPDATE_VISUALIZATION_STATE', - newState: selection.getVisualizationState(), - }); - } + switchToSuggestion(props.framePublicAPI, props.dispatch, { + ...selection, + visualizationState: selection.getVisualizationState(), + }); }; function getSelection( @@ -107,7 +96,7 @@ export function ChartSwitch(props: Props) { visualizationId, subVisualizationId, dataLoss: 'nothing', - keptLayerId: '', + keptLayerIds: Object.keys(props.framePublicAPI.datasourceLayers), getVisualizationState: () => switchVisType(subVisualizationId, props.visualizationState), }; } @@ -117,30 +106,26 @@ export function ChartSwitch(props: Props) { ([_layerId, datasource]) => datasource.getTableSpec().length > 0 ); - // get top ranked suggestion for all layers of current state - const topSuggestion = newVisualization - .getSuggestions({ - tables: layers.map(([layerId, datasource], index) => ({ - datasourceSuggestionId: index, - isMultiRow: true, - columns: datasource.getTableSpec().map(col => ({ - ...col, - operation: datasource.getOperationForColumnId(col.columnId)!, - })), - layerId, - })), - }) - .map(suggestion => ({ suggestion, layerId: layers[suggestion.datasourceSuggestionId][0] })) - .sort( - ({ suggestion: { score: scoreA } }, { suggestion: { score: scoreB } }) => scoreB - scoreA - )[0]; + const topSuggestion = getSuggestions({ + datasourceMap: props.datasourceMap, + datasourceStates: props.datasourceStates, + visualizationMap: { [visualizationId]: newVisualization }, + activeVisualizationId: props.visualizationId, + visualizationState: props.visualizationState, + })[0]; - let dataLoss: VisualizationSelection['dataLoss'] = 'nothing'; + let dataLoss: VisualizationSelection['dataLoss']; - if (!topSuggestion && containsData) { + if (!containsData) { + dataLoss = 'nothing'; + } else if (!topSuggestion) { dataLoss = 'everything'; - } else if (layers.length > 1 && containsData) { + } else if (layers.length > 1) { dataLoss = 'layers'; + } else if (topSuggestion.columns !== layers[0][1].getTableSpec().length) { + dataLoss = 'columns'; + } else { + dataLoss = 'nothing'; } return { @@ -151,7 +136,7 @@ export function ChartSwitch(props: Props) { ? () => switchVisType( subVisualizationId, - newVisualization.initialize(props.framePublicAPI, topSuggestion.suggestion.state) + newVisualization.initialize(props.framePublicAPI, topSuggestion.visualizationState) ) : () => { return switchVisType( @@ -159,7 +144,9 @@ export function ChartSwitch(props: Props) { newVisualization.initialize(props.framePublicAPI) ); }, - keptLayerId: topSuggestion ? topSuggestion.layerId : '', + keptLayerIds: topSuggestion ? topSuggestion.keptLayerIds : [], + datasourceState: topSuggestion ? topSuggestion.datasourceState : undefined, + datasourceId: topSuggestion ? topSuggestion.datasourceId : undefined, }; } 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 index 7c7fc0906717b..67175a19237f5 100644 --- 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 @@ -7,7 +7,7 @@ import React, { useMemo, useContext, memo } from 'react'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; -import { Visualization, FramePublicAPI } from '../../types'; +import { Visualization, FramePublicAPI, Datasource } from '../../types'; import { DragContext } from '../../drag_drop'; import { ChartSwitch } from './chart_switch'; @@ -17,6 +17,14 @@ interface ConfigPanelWrapperProps { 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) { @@ -38,6 +46,8 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config visualizationMap={props.visualizationMap} visualizationId={props.activeVisualizationId} visualizationState={props.visualizationState} + datasourceMap={props.datasourceMap} + datasourceStates={props.datasourceStates} dispatch={props.dispatch} framePublicAPI={props.framePublicAPI} /> 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 index 716ea75e39824..1b83ed4f07e70 100644 --- 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 @@ -17,6 +17,7 @@ import { DatasourceMock, } from '../mocks'; import { ExpressionRenderer } from 'src/legacy/core_plugins/data/public'; +import { DragDrop } from '../../drag_drop'; import { EuiPanel, EuiToolTip } from '@elastic/eui'; // calling this function will wait for all pending Promises from mock @@ -902,6 +903,17 @@ describe('editor_frame', () => { beforeEach(async () => { mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { + state: {}, + table: { + columns: [], + datasourceSuggestionId: 0, + isMultiRow: true, + layerId: 'first', + }, + }, + ]); instance = mount( { expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); }); - it('should display suggestions in descending order', async () => { + it('should display top 3 suggestions in descending order', async () => { const instance = mount( { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.5, state: {}, title: 'Suggestion2', previewIcon: 'empty', }, { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.8, state: {}, title: 'Suggestion1', @@ -1088,14 +1100,14 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.4, state: {}, title: 'Suggestion4', previewIcon: 'empty', }, { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.45, state: {}, title: 'Suggestion3', @@ -1125,7 +1137,7 @@ describe('editor_frame', () => { .find('[data-test-subj="lnsSuggestion"]') .find(EuiPanel) .map(el => el.parents(EuiToolTip).prop('content')) - ).toEqual(['Suggestion1', 'Suggestion2', 'Suggestion3', 'Suggestion4']); + ).toEqual(['Suggestion1', 'Suggestion2', 'Suggestion3']); }); it('should switch to suggested visualization', async () => { @@ -1139,7 +1151,7 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.8, state: suggestionVisState, title: 'Suggestion1', @@ -1198,14 +1210,14 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.2, state: {}, title: 'Suggestion1', previewIcon: 'empty', }, { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.8, state: suggestionVisState, title: 'Suggestion2', @@ -1255,14 +1267,14 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.2, state: {}, title: 'Suggestion1', previewIcon: 'empty', }, { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.6, state: {}, title: 'Suggestion2', @@ -1274,7 +1286,7 @@ describe('editor_frame', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.8, state: suggestionVisState, title: 'Suggestion3', @@ -1307,7 +1319,10 @@ describe('editor_frame', () => { instance.update(); act(() => { - instance.find('[data-test-subj="lnsDragDrop"]').simulate('drop'); + instance.find(DragDrop).prop('onDrop')!({ + indexPatternId: '1', + field: {}, + }); }); expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( 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 index 07b8ca4fd52f5..ff393eabf8ca5 100644 --- 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 @@ -225,6 +225,8 @@ export function EditorFrame(props: EditorFrameProps) { configPanel={ allLoaded && ( { newVisualizationId: 'testVis2', initialState: newVisState, datasourceState: newDatasourceState, + datasourceId: 'testDatasource', } ); 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 index 1e5eee9e30c70..6f76cc15dafed 100644 --- 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 @@ -51,7 +51,13 @@ export type Action = type: 'SWITCH_VISUALIZATION'; newVisualizationId: string; initialState: unknown; - datasourceState?: unknown; + } + | { + type: 'SWITCH_VISUALIZATION'; + newVisualizationId: string; + initialState: unknown; + datasourceState: unknown; + datasourceId: string; } | { type: 'SWITCH_DATASOURCE'; @@ -141,10 +147,13 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta return { ...state, datasourceStates: - state.activeDatasourceId && action.datasourceState + 'datasourceId' in action && action.datasourceId ? { ...state.datasourceStates, - [state.activeDatasourceId]: { state: action.datasourceState, isLoading: false }, + [action.datasourceId]: { + ...state.datasourceStates[action.datasourceId], + state: action.datasourceState, + }, } : state.datasourceStates, visualization: { 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 index a1408d9851398..ddfb79cb07a18 100644 --- 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 @@ -5,11 +5,11 @@ */ import { getSuggestions } from './suggestion_helpers'; -import { createMockVisualization } from '../mocks'; +import { createMockVisualization, createMockDatasource, DatasourceMock } from '../mocks'; import { TableSuggestion, DatasourceSuggestion } from '../../types'; const generateSuggestion = ( - datasourceSuggestionId: number = 1, + datasourceSuggestionId: number = 0, state = {}, layerId: string = 'first' ): DatasourceSuggestion => ({ @@ -17,18 +17,42 @@ const generateSuggestion = ( table: { datasourceSuggestionId, columns: [], isMultiRow: false, layerId }, }); +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( - [generateSuggestion()], - { + const suggestions = getSuggestions({ + visualizationMap: { vis1: { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.5, title: 'Test', state: suggestedState, @@ -37,31 +61,35 @@ describe('suggestion helpers', () => { ], }, }, - 'vis1', - {} - ); + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); expect(suggestions).toHaveLength(1); - expect(suggestions[0].state).toBe(suggestedState); + expect(suggestions[0].visualizationState).toBe(suggestedState); }); it('should concatenate suggestions from all visualizations', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); - const suggestions = getSuggestions( - [generateSuggestion()], - { + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(), + ]); + const suggestions = getSuggestions({ + visualizationMap: { vis1: { ...mockVisualization1, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.5, title: 'Test', state: {}, previewIcon: 'empty', }, { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.5, title: 'Test2', state: {}, @@ -73,7 +101,7 @@ describe('suggestion helpers', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.5, title: 'Test3', state: {}, @@ -82,30 +110,91 @@ describe('suggestion helpers', () => { ], }, }, - 'vis1', - {} - ); + 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(); - const suggestions = getSuggestions( - [generateSuggestion()], - { + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(), + ]); + const suggestions = getSuggestions({ + visualizationMap: { vis1: { ...mockVisualization1, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.2, title: 'Test', state: {}, previewIcon: 'empty', }, { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.8, title: 'Test2', state: {}, @@ -117,7 +206,7 @@ describe('suggestion helpers', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.6, title: 'Test3', state: {}, @@ -126,9 +215,11 @@ describe('suggestion helpers', () => { ], }, }, - 'vis1', - {} - ); + 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); @@ -149,41 +240,49 @@ describe('suggestion helpers', () => { isMultiRow: true, layerId: 'first', }; - getSuggestions( - [{ state: {}, table: table1 }, { state: {}, table: table2 }], - { + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { state: {}, table: table1 }, + { state: {}, table: table2 }, + ]); + getSuggestions({ + visualizationMap: { vis1: mockVisualization1, vis2: mockVisualization2, }, - 'vis1', - {} - ); - expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[0]).toBe(table1); - expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[1]).toBe(table2); - expect(mockVisualization2.getSuggestions.mock.calls[0][0].tables[0]).toBe(table1); - expect(mockVisualization2.getSuggestions.mock.calls[0][0].tables[1]).toBe(table2); + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); + expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[0]).toEqual(table1); + expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[1]).toEqual(table2); + expect(mockVisualization2.getSuggestions.mock.calls[0][0].tables[0]).toEqual(table1); + expect(mockVisualization2.getSuggestions.mock.calls[0][0].tables[1]).toEqual(table2); }); - it('should map the suggestion ids back to the correct datasource states', () => { + it('should map the suggestion ids back to the correct datasource ids and states', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); const tableState1 = {}; const tableState2 = {}; - const suggestions = getSuggestions( - [generateSuggestion(1, tableState1), generateSuggestion(2, tableState2)], - { + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(0, tableState1), + generateSuggestion(1, tableState2), + ]); + const suggestions = getSuggestions({ + visualizationMap: { vis1: { ...mockVisualization1, getSuggestions: () => [ { - datasourceSuggestionId: 1, + datasourceSuggestionId: 0, score: 0.3, title: 'Test', state: {}, previewIcon: 'empty', }, { - datasourceSuggestionId: 2, + datasourceSuggestionId: 1, score: 0.2, title: 'Test2', state: {}, @@ -195,7 +294,7 @@ describe('suggestion helpers', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 2, + datasourceSuggestionId: 1, score: 0.1, title: 'Test3', state: {}, @@ -204,27 +303,37 @@ describe('suggestion helpers', () => { ], }, }, - 'vis1', - {} - ); + 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].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 = {}; - getSuggestions( - [generateSuggestion(1), generateSuggestion(2)], - { + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(0), + generateSuggestion(1), + ]); + getSuggestions({ + visualizationMap: { vis1: mockVisualization1, vis2: mockVisualization2, }, - 'vis1', - currentState - ); + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); expect(mockVisualization1.getSuggestions).toHaveBeenCalledWith( expect.objectContaining({ state: currentState, @@ -236,4 +345,47 @@ describe('suggestion helpers', () => { }) ); }); + + 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: () => [ + { + datasourceSuggestionId: 0, + score: 0.8, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + datasourceSuggestionId: 0, + 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 index f2d1db4eb636c..5d0894fd20e8b 100644 --- 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 @@ -4,16 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import _ from 'lodash'; import { Ast } from '@kbn/interpreter/common'; -import { Visualization, DatasourceSuggestion, TableSuggestion } from '../../types'; +import { Visualization, Datasource, FramePublicAPI } from '../../types'; import { Action } from './state_management'; export interface Suggestion { visualizationId: string; - datasourceState: unknown; + datasourceState?: unknown; + datasourceId?: string; + keptLayerIds: string[]; + columns: number; score: number; title: string; - state: unknown; + visualizationState: unknown; previewExpression?: Ast | string; previewIcon: string; } @@ -26,39 +30,102 @@ export interface Suggestion { * 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( - datasourceTableSuggestions: DatasourceSuggestion[], - visualizationMap: Record, - activeVisualizationId: string | null, - visualizationState: unknown -): Suggestion[] { - const datasourceTables: TableSuggestion[] = datasourceTableSuggestions.map(({ table }) => table); +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 + ); - return Object.entries(visualizationMap) - .map(([visualizationId, visualization]) => { + const allLayerIds = _.flatten( + datasources.map(([datasourceId, datasource]) => + datasource.getLayers(datasourceStates[datasourceId].state) + ) + ); + + const datasourceTableSuggestions = _.flatten( + datasources.map(([datasourceId, datasource]) => { + const datasourceState = datasourceStates[datasourceId].state; + return ( + (field + ? datasource.getDatasourceSuggestionsForField(datasourceState, field) + : datasource.getDatasourceSuggestionsFromCurrentState(datasourceState) + ) + // TODO have the datasource in there by default + .map(suggestion => ({ ...suggestion, datasourceId })) + ); + }) + ).map((suggestion, index) => ({ + ...suggestion, + table: { ...suggestion.table, datasourceSuggestionId: index }, + })); + + const datasourceTables = datasourceTableSuggestions.map(({ table }) => table); + + return _.flatten( + Object.entries(visualizationMap).map(([visualizationId, visualization]) => { return visualization .getSuggestions({ tables: datasourceTables, state: visualizationId === activeVisualizationId ? visualizationState : undefined, }) - .map(({ datasourceSuggestionId, ...suggestion }) => ({ - ...suggestion, - visualizationId, - datasourceState: datasourceTableSuggestions.find( - datasourceSuggestion => - datasourceSuggestion.table.datasourceSuggestionId === datasourceSuggestionId - )!.state, - })); + .map(({ datasourceSuggestionId, state, ...suggestion }) => { + const datasourceSuggestion = datasourceTableSuggestions[datasourceSuggestionId]; + return { + ...suggestion, + visualizationId, + visualizationState: state, + keptLayerIds: + visualizationId !== activeVisualizationId + ? [datasourceSuggestion.table.layerId] + : allLayerIds, + datasourceState: datasourceSuggestion.state, + datasourceId: datasourceSuggestion.datasourceId, + columns: datasourceSuggestion.table.columns.length, + }; + }); }) - .reduce((globalList, currentList) => [...globalList, ...currentList], []) - .sort(({ score: scoreA }, { score: scoreB }) => scoreB - scoreA); + ).sort((a, b) => b.score - a.score); } -export function toSwitchAction(suggestion: Suggestion): Action { - return { +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.state, + 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.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.test.tsx index d2f3b8e895aa6..66436941dc499 100644 --- 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 @@ -12,6 +12,7 @@ import { createMockDatasource, createExpressionRendererMock, DatasourceMock, + createMockFramePublicAPI, } from '../mocks'; import { ExpressionRenderer } from 'src/legacy/core_plugins/data/public'; import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel'; @@ -44,17 +45,19 @@ describe('suggestion_panel', () => { datasourceState: {}, previewIcon: 'empty', score: 0.5, - state: suggestion1State, + visualizationState: suggestion1State, visualizationId: 'vis', title: 'Suggestion1', + keptLayerIds: ['a'], }, { datasourceState: {}, previewIcon: 'empty', score: 0.5, - state: suggestion2State, + visualizationState: suggestion2State, visualizationId: 'vis', title: 'Suggestion2', + keptLayerIds: ['a'], }, ] as Suggestion[]); @@ -76,6 +79,7 @@ describe('suggestion_panel', () => { visualizationState: {}, dispatch: dispatchMock, ExpressionRenderer: expressionRendererMock, + frame: createMockFramePublicAPI(), }; }); @@ -106,6 +110,19 @@ describe('suggestion_panel', () => { ); }); + 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([ @@ -113,7 +130,7 @@ describe('suggestion_panel', () => { datasourceState: {}, previewIcon: 'empty', score: 0.5, - state: suggestion1State, + visualizationState: suggestion1State, visualizationId: 'vis', title: 'Suggestion1', }, @@ -121,7 +138,7 @@ describe('suggestion_panel', () => { datasourceState: {}, previewIcon: 'empty', score: 0.5, - state: suggestion2State, + visualizationState: suggestion2State, visualizationId: 'vis', title: 'Suggestion2', previewExpression: 'test | expression', @@ -183,7 +200,7 @@ describe('suggestion_panel', () => { datasourceState: {}, previewIcon: 'visTable', score: 0.5, - state: suggestion1State, + visualizationState: suggestion1State, visualizationId: 'vis', title: 'Suggestion1', }, @@ -191,7 +208,7 @@ describe('suggestion_panel', () => { datasourceState: {}, previewIcon: 'empty', score: 0.5, - state: suggestion2State, + visualizationState: suggestion2State, visualizationId: 'vis', title: 'Suggestion2', previewExpression: 'test | expression', 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 index aad24b4c3b9cd..e2403245a22b8 100644 --- 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 @@ -10,12 +10,14 @@ import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip, EuiToolTip } from '@elastic/eu import { toExpression } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; import { Action } from './state_management'; -import { Datasource, Visualization } from '../../types'; -import { getSuggestions, toSwitchAction, Suggestion } from './suggestion_helpers'; +import { Datasource, Visualization, FramePublicAPI } from '../../types'; +import { getSuggestions, Suggestion, switchToSuggestion } from './suggestion_helpers'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/data/public'; import { prependDatasourceExpression } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; +const MAX_SUGGESTIONS_DISPLAYED = 3; + export interface SuggestionPanelProps { activeDatasourceId: string | null; datasourceMap: Record; @@ -31,16 +33,19 @@ export interface SuggestionPanelProps { 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; }) => { @@ -57,7 +62,7 @@ const SuggestionPreview = ({ paddingSize="none" data-test-subj="lnsSuggestion" onClick={() => { - dispatch(toSwitchAction(suggestion)); + switchToSuggestion(frame, dispatch, suggestion); }} > {expressionError ? ( @@ -104,21 +109,20 @@ function InnerSuggestionPanel({ visualizationMap, visualizationState, dispatch, + frame, ExpressionRenderer: ExpressionRendererComponent, }: SuggestionPanelProps) { if (!activeDatasourceId) { return null; } - const datasourceSuggestions = datasourceMap[ - activeDatasourceId - ].getDatasourceSuggestionsFromCurrentState(datasourceStates[activeDatasourceId].state); - const suggestions = getSuggestions( - datasourceSuggestions, + const suggestions = getSuggestions({ + datasourceMap, + datasourceStates, visualizationMap, activeVisualizationId, - visualizationState - ); + visualizationState, + }).slice(0, MAX_SUGGESTIONS_DISPLAYED); if (suggestions.length === 0) { return null; @@ -147,6 +151,7 @@ function InnerSuggestionPanel({ { newVisualizationId: 'vis', initialState: {}, datasourceState: {}, + datasourceId: 'mock', }); }); @@ -721,6 +722,7 @@ describe('workspace_panel', () => { 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 index c531387f3dde6..cc7b7148adae9 100644 --- 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 @@ -12,7 +12,7 @@ import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins import { Action } from './state_management'; import { Datasource, Visualization, FramePublicAPI } from '../../types'; import { DragDrop, DragContext } from '../../drag_drop'; -import { getSuggestions, toSwitchAction } from './suggestion_helpers'; +import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; import { buildExpression } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; @@ -54,32 +54,29 @@ export function InnerWorkspacePanel({ if (!dragDropContext.dragging || !activeDatasourceId) { return; } - const datasourceSuggestions = datasourceMap[ - activeDatasourceId - ].getDatasourceSuggestionsForField( - datasourceStates[activeDatasourceId].state, - dragDropContext.dragging - ); const hasData = Object.values(framePublicAPI.datasourceLayers).some( datasource => datasource.getTableSpec().length > 0 ); - const suggestions = getSuggestions( - datasourceSuggestions, - hasData && activeVisualizationId - ? { [activeVisualizationId]: visualizationMap[activeVisualizationId] } - : visualizationMap, + const suggestions = getSuggestions({ + datasourceMap: { [activeDatasourceId]: datasourceMap[activeDatasourceId] }, + datasourceStates, + visualizationMap: + hasData && activeVisualizationId + ? { [activeVisualizationId]: visualizationMap[activeVisualizationId] } + : visualizationMap, activeVisualizationId, - visualizationState - ); + visualizationState, + field: dragDropContext.dragging, + }); return suggestions[0]; }, [dragDropContext.dragging]); function onDrop() { if (suggestionForDraggedField) { - dispatch(toSwitchAction(suggestionForDraggedField)); + switchToSuggestion(framePublicAPI, dispatch, suggestionForDraggedField); } } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index b7d761b42030a..31b3324d39757 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -16,7 +16,6 @@ import { IndexPatternColumn, } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; -import { generateId } from '../id_generator'; import { DataPluginDependencies } from './plugin'; jest.mock('./loader'); @@ -248,737 +247,6 @@ describe('IndexPattern Data Source', () => { }); }); - describe('#getDatasourceSuggestionsForField', () => { - describe('with no layer', () => { - let initialState: IndexPatternPrivateState; - - beforeEach(async () => { - initialState = await indexPatternDatasource.initialize({ - currentIndexPatternId: '1', - layers: {}, - }); - (generateId as jest.Mock).mockReturnValueOnce('suggestedLayer'); - }); - - 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({ - datasourceSuggestionId: 0, - 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({ - datasourceSuggestionId: 0, - 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: 'min', - sourceField: 'bytes', - }), - }, - }), - }, - }) - ); - expect(suggestions[0].table).toEqual({ - datasourceSuggestionId: 0, - 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', - 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 () => { - initialState = await indexPatternDatasource.initialize({ - currentIndexPatternId: '1', - layers: { - previousLayer: { - indexPatternId: '1', - columns: {}, - columnOrder: [], - }, - }, - }); - }); - - 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({ - datasourceSuggestionId: 0, - 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({ - datasourceSuggestionId: 0, - 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: 'min', - sourceField: 'bytes', - }), - }, - }), - }, - }) - ); - expect(suggestions[0].table).toEqual({ - datasourceSuggestionId: 0, - 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', - 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: 'Min of bytes', - operationType: 'min', - }, - }, - 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: 'Min of bytes', - operationType: 'min', - }, - }, - }, - }, - }, - { - 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({ - datasourceSuggestionId: 0, - 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('prepends a terms 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: ['newId', 'col1', '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: 'min', - 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: 'max', - sourceField: 'bytes', - }), - }, - }), - }, - }) - ); - }); - }); - - describe('finding the layer that is using the current index pattern', () => { - let initialState: IndexPatternPrivateState; - - beforeEach(async () => { - initialState = await indexPatternDatasource.initialize({ - currentIndexPatternId: '1', - layers: { - previousLayer: { - indexPatternId: '1', - columns: {}, - columnOrder: [], - }, - currentLayer: { - indexPatternId: '2', - columns: {}, - columnOrder: [], - }, - }, - }); - }); - - 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({ - datasourceSuggestionId: 0, - 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({ - indexPatterns: expectedIndexPatterns, - layers: { - first: { - indexPatternId: '1', - columnOrder: [], - columns: {}, - }, - }, - currentIndexPatternId: '1', - }) - ).toEqual([]); - }); - - it('returns a single suggestion containing the current columns', async () => { - const state = await indexPatternDatasource.initialize(persistedState); - expect(indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state)).toEqual([ - { - state: { - ...persistedState, - indexPatterns: expectedIndexPatterns, - }, - table: { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: [ - { - columnId: 'col1', - operation: { - label: 'My Op', - dataType: 'string', - isBucketed: true, - }, - }, - ], - layerId: 'first', - }, - }, - ]); - }); - }); - describe('#insertLayer', () => { it('should insert an empty layer into the previous state', () => { const state = { 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..0adafb1e77652 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx @@ -0,0 +1,1040 @@ +/* + * 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 { localStorage as storageMock } from 'ui/storage/storage_service'; +import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { toastNotifications as notificationsMock } from 'ui/notify'; +import { + getIndexPatternDatasource, + IndexPatternPersistedState, + IndexPatternPrivateState, +} from './indexpattern'; +import { Datasource, DatasourceSuggestion } from '../types'; +import { generateId } from '../id_generator'; +import { DataPluginDependencies } from './plugin'; + +jest.mock('./loader'); +jest.mock('../id_generator'); +// chrome, notify, storage are used by ./plugin +jest.mock('ui/chrome'); +jest.mock('ui/notify'); +jest.mock('ui/storage/storage_service'); +// 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({ + chrome: chromeMock, + storage: storageMock, + interpreter: { functionsRegistry }, + toastNotifications: notificationsMock, + data: dataMock as DataPluginDependencies, + }); + + 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 () => { + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + layers: {}, + }); + (generateId as jest.Mock).mockReturnValueOnce('suggestedLayer'); + }); + + 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({ + datasourceSuggestionId: 0, + 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({ + datasourceSuggestionId: 0, + 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: 'min', + sourceField: 'bytes', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + datasourceSuggestionId: 0, + 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', + 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 () => { + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + layers: { + previousLayer: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, + }); + }); + + 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({ + datasourceSuggestionId: 0, + 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({ + datasourceSuggestionId: 0, + 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: 'min', + sourceField: 'bytes', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + datasourceSuggestionId: 0, + 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', + 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: 'Min of bytes', + operationType: 'min', + }, + }, + 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: 'Min of bytes', + operationType: 'min', + }, + }, + }, + }, + }, + { + 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({ + datasourceSuggestionId: 0, + 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('prepends a terms 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: ['newId', 'col1', '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: 'min', + 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: 'max', + sourceField: 'bytes', + }), + }, + }), + }, + }) + ); + }); + }); + + describe('finding the layer that is using the current index pattern', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + layers: { + previousLayer: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + currentLayer: { + indexPatternId: '2', + columns: {}, + columnOrder: [], + }, + }, + }); + }); + + 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({ + datasourceSuggestionId: 0, + 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({ + 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: 'number', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }, + }, + }, + }); + expect(indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state)).toEqual([ + expect.objectContaining({ + table: { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [ + { + columnId: 'col1', + operation: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + }, + }, + ], + layerId: 'first', + }, + }), + expect.objectContaining({ + table: { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [ + { + columnId: 'col1', + operation: { + label: 'My Op 2', + dataType: 'number', + isBucketed: true, + }, + }, + ], + layerId: 'second', + }, + }), + ]); + }); + + it('returns simplified versions of table with more than 2 columns', async () => { + const state = await indexPatternDatasource.initialize({ + ...persistedState, + 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 + validateTable(suggestions[0], ['col1', 'col4', 'col5'], 1); + + // 1 bucket col, 1 metric col + validateTable(suggestions[1], ['col1', 'col4'], 1); + + // 2 bucket cols, 2 metric cols + validateTable(suggestions[2], ['col1', 'col2', 'col4', 'col5'], 2); + + // 2 bucket cols, 1 metric col + validateTable(suggestions[3], ['col1', 'col2', 'col4'], 2); + + // 3 bucket cols, 2 metric cols + validateTable(suggestions[4], ['col1', 'col2', 'col3', 'col4', 'col5'], 3); + + // 3 bucket cols, 1 metric col + validateTable(suggestions[5], ['col1', 'col2', 'col3', 'col4'], 3); + }); + }); +}); + +function validateTable( + 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(); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.tsx index 01373967d072f..1b5007cbe5558 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.tsx @@ -298,17 +298,70 @@ function createNewLayerWithMetricAggregation( export function getDatasourceSuggestionsFromCurrentState( state: IndexPatternPrivateState ): Array> { - const layers = Object.entries(state.layers); + return _.flatten( + Object.entries(state.layers || {}) + .filter(([_id, layer]) => layer.columnOrder.length) + .map(([layerId, layer], index) => { + if (layer.columnOrder.length === 0) { + return []; + } - return layers - .map(([layerId, layer], index) => { - if (layer.columnOrder.length === 0) { - return; - } + const onlyMetric = layer.columnOrder.every(columnId => !layer.columns[columnId].isBucketed); + const onlyBucket = layer.columnOrder.every(columnId => layer.columns[columnId].isBucketed); + if (onlyMetric || onlyBucket) { + // intermediary chart, don't try to suggest reduced versions + return buildSuggestion({ + state, + layerId, + isMultiRow: false, + datasourceSuggestionId: index, + }); + } + + return createSimplifiedTableSuggestions(state, layerId); + }) + ).map( + (suggestion, index): DatasourceSuggestion => ({ + ...suggestion, + table: { ...suggestion.table, datasourceSuggestionId: index }, + }) + ); +} + +function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layerId: string) { + const layer = state.layers[layerId]; - return buildSuggestion({ state, layerId, isMultiRow: true, datasourceSuggestionId: index }); + const availableBucketedColumns = layer.columnOrder.filter( + columnId => layer.columns[columnId].isBucketed + ); + const availableMetricColumns = layer.columnOrder.filter( + columnId => !layer.columns[columnId].isBucketed + ); + + return _.flatten( + availableBucketedColumns.map((_col, index) => { + const bucketedColumns = availableBucketedColumns.slice(0, index + 1); + const allMetricsSuggestion = buildLayerByColumnOrder(layer, [ + ...bucketedColumns, + ...availableMetricColumns, + ]); + + if (availableMetricColumns.length > 1) { + return [ + allMetricsSuggestion, + buildLayerByColumnOrder(layer, [...bucketedColumns, availableMetricColumns[0]]), + ]; + } else { + return allMetricsSuggestion; + } }) - .reduce((prev, current) => (current ? prev.concat([current]) : prev), [] as Array< - DatasourceSuggestion - >); + ).map(updatedLayer => buildSuggestion({ state, layerId, isMultiRow: true, updatedLayer })); +} + +function buildLayerByColumnOrder(layer: IndexPatternLayer, columnOrder: string[]) { + return { + ...layer, + columns: _.pick(layer.columns, columnOrder), + columnOrder, + }; } 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 index c2c2b53ce7f5c..82ee5b6ae1b7d 100644 --- 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 @@ -142,7 +142,8 @@ function getSuggestion( return { title, - score: 1, + // 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, datasourceSuggestionId, state, previewIcon: isDate ? 'visLine' : 'visBar', From 43a9bf1140e3887d39a0dc4afaf5e999f7eea94b Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 13 Aug 2019 08:33:27 +0200 Subject: [PATCH 077/105] [Lens] Field formatter support (#38874) --- .../interpreter/public/functions/esaggs.ts | 23 +++- .../loader/pipeline_helpers/build_pipeline.ts | 40 +------ .../loader/pipeline_helpers/utilities.ts | 67 ++++++++++-- .../expression_types/kibana_datatable.ts | 6 ++ .../expression.tsx | 32 ++++-- .../datatable_visualization_plugin/plugin.tsx | 20 +++- .../editor_frame_plugin/merge_tables.ts | 4 +- .../indexpattern_plugin/filter_ratio.ts | 3 +- .../indexpattern_plugin/indexpattern.test.tsx | 1 + .../indexpattern_plugin/rename_columns.ts | 3 +- .../indexpattern_plugin/to_expression.ts | 1 + x-pack/legacy/plugins/lens/public/types.ts | 10 +- .../__snapshots__/xy_expression.test.tsx.snap | 58 ++++++---- .../public/xy_visualization_plugin/plugin.tsx | 17 ++- .../xy_expression.test.tsx | 100 +++++++++++++++--- .../xy_visualization_plugin/xy_expression.tsx | 34 +++++- 16 files changed, 301 insertions(+), 118 deletions(-) diff --git a/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts b/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts index 3bf414cf02211..bb8feabb159cd 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts +++ b/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts @@ -36,6 +36,7 @@ import chrome from 'ui/chrome'; const courierRequestHandlerProvider = CourierRequestHandlerProvider; const courierRequestHandler = courierRequestHandlerProvider().handler; +import { createFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; import { ExpressionFunction } from '../../types'; import { KibanaContext, KibanaDatatable } from '../../common'; @@ -47,6 +48,7 @@ interface Arguments { index: string | null; metricsAtAllLevels: boolean; partialRows: boolean; + includeFormatHints: boolean; aggConfigs: string; } @@ -77,6 +79,11 @@ export const esaggs = (): ExpressionFunction = await courierRequestHandler({ + const response = await courierRequestHandler({ searchSource, aggs, timeRange: get(context, 'timeRange', null), @@ -115,10 +122,16 @@ export const esaggs = (): ExpressionFunction ({ - id: column.id, - name: column.name, - })), + columns: response.columns.map((column: any) => { + const cleanedColumn: KibanaDatatable['columns'][0] = { + id: column.id, + name: column.name, + }; + if (args.includeFormatHints) { + cleanedColumn.formatHint = createFormat(column.aggConfig); + } + return cleanedColumn; + }), }; }, }); diff --git a/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.ts b/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.ts index 83bb25f26e7d5..22e41dbe757b4 100644 --- a/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.ts +++ b/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.ts @@ -23,11 +23,8 @@ import { setBounds } from 'ui/agg_types/buckets/date_histogram'; import { SearchSource } from 'ui/courier'; import { AggConfig, Vis, VisParams, VisState } from 'ui/vis'; import moment from 'moment'; - -interface SchemaFormat { - id: string; - params?: any; -} +import { SerializedFieldFormat } from 'src/plugins/data/common'; +import { createFormat } from './utilities'; interface SchemaConfigParams { precision?: number; @@ -36,7 +33,7 @@ interface SchemaConfigParams { export interface SchemaConfig { accessor: number; - format: SchemaFormat | {}; + format: SerializedFieldFormat | {}; params: SchemaConfigParams; aggType: string; } @@ -78,37 +75,6 @@ const vislibCharts: string[] = [ ]; export const getSchemas = (vis: Vis, timeRange?: any): Schemas => { - const createFormat = (agg: AggConfig): SchemaFormat => { - const format: SchemaFormat = agg.params.field ? agg.params.field.format.toJSON() : {}; - const formats: any = { - date_range: () => ({ id: 'string' }), - percentile_ranks: () => ({ id: 'percent' }), - count: () => ({ id: 'number' }), - cardinality: () => ({ id: 'number' }), - date_histogram: () => ({ - id: 'date', - params: { - pattern: agg.buckets.getScaledDateFormat(), - }, - }), - terms: () => ({ - id: 'terms', - params: { - id: format.id, - otherBucketLabel: agg.params.otherBucketLabel, - missingBucketLabel: agg.params.missingBucketLabel, - ...format.params, - }, - }), - range: () => ({ - id: 'range', - params: { id: format.id, ...format.params }, - }), - }; - - return formats[agg.type.name] ? formats[agg.type.name]() : format; - }; - const createSchemaConfig = (accessor: number, agg: AggConfig): SchemaConfig => { if (agg.type.name === 'date_histogram') { agg.params.timeRange = timeRange; diff --git a/src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities.ts b/src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities.ts index 20128eb5a3a64..cec5ca39712d9 100644 --- a/src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities.ts +++ b/src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities.ts @@ -20,6 +20,7 @@ import { i18n } from '@kbn/i18n'; import { identity } from 'lodash'; import { AggConfig, Vis } from 'ui/vis'; +import { SerializedFieldFormat } from 'src/plugins/data/common'; // @ts-ignore import { FieldFormat } from '../../../../field_formats/field_format'; // @ts-ignore @@ -28,12 +29,24 @@ import chrome from '../../../chrome'; // @ts-ignore import { fieldFormats } from '../../../registry/field_formats'; +interface TermsFieldFormatParams { + otherBucketLabel: string; + missingBucketLabel: string; + id: string; +} + +function isTermsFieldFormat( + serializedFieldFormat: SerializedFieldFormat +): serializedFieldFormat is SerializedFieldFormat { + return serializedFieldFormat.id === 'terms'; +} + const config = chrome.getUiSettingsClient(); const getConfig = (...args: any[]): any => config.get(...args); const getDefaultFieldFormat = () => ({ convert: identity }); -const getFieldFormat = (id: string, params: object) => { +const getFieldFormat = (id: string | undefined, params: object = {}) => { const Format = fieldFormats.byId[id]; if (Format) { return new Format(params, getConfig); @@ -42,7 +55,42 @@ const getFieldFormat = (id: string, params: object) => { } }; -export const getFormat = (mapping: any) => { +export type FieldFormat = any; + +export const createFormat = (agg: AggConfig): SerializedFieldFormat => { + const format: SerializedFieldFormat = agg.params.field ? agg.params.field.format.toJSON() : {}; + const formats: Record SerializedFieldFormat> = { + date_range: () => ({ id: 'string' }), + percentile_ranks: () => ({ id: 'percent' }), + count: () => ({ id: 'number' }), + cardinality: () => ({ id: 'number' }), + date_histogram: () => ({ + id: 'date', + params: { + pattern: agg.buckets.getScaledDateFormat(), + }, + }), + terms: () => ({ + id: 'terms', + params: { + id: format.id, + otherBucketLabel: agg.params.otherBucketLabel, + missingBucketLabel: agg.params.missingBucketLabel, + ...format.params, + }, + }), + range: () => ({ + id: 'range', + params: { id: format.id, ...format.params }, + }), + }; + + return formats[agg.type.name] ? formats[agg.type.name]() : format; +}; + +export type FormatFactory = (mapping?: SerializedFieldFormat) => FieldFormat; + +export const getFormat: FormatFactory = (mapping: SerializedFieldFormat = {}): FieldFormat => { if (!mapping) { return getDefaultFieldFormat(); } @@ -59,16 +107,17 @@ export const getFormat = (mapping: any) => { }); }); return new RangeFormat(); - } else if (id === 'terms') { + } else if (isTermsFieldFormat(mapping) && mapping.params) { + const params = mapping.params; return { getConverterFor: (type: string) => { - const format = getFieldFormat(mapping.params.id, mapping.params); + const format = getFieldFormat(params.id, mapping.params); return (val: string) => { if (val === '__other__') { - return mapping.params.otherBucketLabel; + return params.otherBucketLabel; } if (val === '__missing__') { - return mapping.params.missingBucketLabel; + return params.missingBucketLabel; } const parsedUrl = { origin: window.location.origin, @@ -79,12 +128,12 @@ export const getFormat = (mapping: any) => { }; }, convert: (val: string, type: string) => { - const format = getFieldFormat(mapping.params.id, mapping.params); + const format = getFieldFormat(params.id, mapping.params); if (val === '__other__') { - return mapping.params.otherBucketLabel; + return params.otherBucketLabel; } if (val === '__missing__') { - return mapping.params.missingBucketLabel; + return params.missingBucketLabel; } const parsedUrl = { origin: window.location.origin, diff --git a/src/plugins/data/common/expressions/expression_types/kibana_datatable.ts b/src/plugins/data/common/expressions/expression_types/kibana_datatable.ts index d5622ff50dd83..571e9ec7ff1e9 100644 --- a/src/plugins/data/common/expressions/expression_types/kibana_datatable.ts +++ b/src/plugins/data/common/expressions/expression_types/kibana_datatable.ts @@ -21,9 +21,15 @@ import { map } from 'lodash'; const name = 'kibana_datatable'; +export interface SerializedFieldFormat { + id?: string; + params?: TParams; +} + interface Column { id: string; name: string; + formatHint?: SerializedFieldFormat; } interface Row { 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 index 076bb1c20ac10..0e53ee59761f5 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx @@ -8,9 +8,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { EuiBasicTable } from '@elastic/eui'; -import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; -import { KibanaDatatable, LensMultiTable } from '../types'; +import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; +import { LensMultiTable } from '../types'; import { RenderFunction } from '../interpreter_types'; +import { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities'; export interface DatatableColumns { columnIds: string[]; @@ -106,7 +107,9 @@ export const datatableColumns: ExpressionFunction< }, }; -export const datatableRenderer: RenderFunction = { +export const getDatatableRenderer = ( + formatFactory: FormatFactory +): RenderFunction => ({ name: 'lens_datatable_renderer', displayName: i18n.translate('xpack.lens.datatable.visualizationName', { defaultMessage: 'Datatable', @@ -115,12 +118,17 @@ export const datatableRenderer: RenderFunction = { validate: () => {}, reuseDomNode: true, render: async (domNode: Element, config: DatatableProps, _handlers: unknown) => { - ReactDOM.render(, domNode); + ReactDOM.render(, domNode); }, -}; +}); -function DatatableComponent(props: DatatableProps) { +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 ( !!field)} - items={firstTable ? firstTable.rows : []} + 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/plugin.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx index 7bcddae13e1ac..52f4f99513e7a 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx @@ -6,6 +6,7 @@ // import { Registry } from '@kbn/interpreter/target/common'; import { CoreSetup } from 'src/core/public'; +import { getFormat, FormatFactory } from 'ui/visualize/loader/pipeline_helpers/utilities'; import { datatableVisualization } from './visualization'; import { @@ -13,19 +14,29 @@ import { functionsRegistry, } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { InterpreterSetup, RenderFunction } from '../interpreter_types'; -import { datatable, datatableColumns, datatableRenderer } from './expression'; +import { datatable, datatableColumns, getDatatableRenderer } from './expression'; export interface DatatableVisualizationPluginSetupPlugins { interpreter: InterpreterSetup; + // 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, { interpreter }: DatatableVisualizationPluginSetupPlugins) { + setup( + _core: CoreSetup | null, + { interpreter, fieldFormat }: DatatableVisualizationPluginSetupPlugins + ) { interpreter.functionsRegistry.register(() => datatableColumns); interpreter.functionsRegistry.register(() => datatable); - interpreter.renderersRegistry.register(() => datatableRenderer as RenderFunction); + interpreter.renderersRegistry.register( + () => getDatatableRenderer(fieldFormat.formatFactory) as RenderFunction + ); return datatableVisualization; } @@ -41,5 +52,8 @@ export const datatableVisualizationSetup = () => renderersRegistry, functionsRegistry, }, + fieldFormat: { + formatFactory: getFormat, + }, }); export const datatableVisualizationStop = () => plugin.stop(); 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 index c7747ace106fd..2b7e35876bb63 100644 --- 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 @@ -5,8 +5,8 @@ */ import { i18n } from '@kbn/i18n'; -import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; -import { LensMultiTable, KibanaDatatable } from '../types'; +import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; +import { LensMultiTable } from '../types'; interface MergeTables { layerIds: string[]; 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 index 1fe57f42fc987..854284f98a8d0 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts @@ -5,8 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; -import { KibanaDatatable } from '../types'; +import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; interface FilterRatioKey { id: string; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index 31b3324d39757..0fe1410fb6761 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -242,6 +242,7 @@ describe('IndexPattern Data Source', () => { 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\\"}'" `); }); 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 index 01361ada9bf02..5a508bf2a84f6 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.ts @@ -5,8 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; -import { KibanaDatatable } from '../types'; +import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; interface RemapArgs { idMap: string; 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 index 46095cd434335..71e8473aa6839 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -74,6 +74,7 @@ function getExpressionForLayer( index="${indexPattern.id}" metricsAtAllLevels=false partialRows=false + includeFormatHints=true aggConfigs='${JSON.stringify(aggs)}' | lens_rename_columns idMap='${JSON.stringify(idMap)}'`; } diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 77f592a67a5fd..6deab82c5658e 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -6,7 +6,7 @@ import { Ast } from '@kbn/interpreter/common'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; -import { Query } from 'src/plugins/data/common'; +import { Query, KibanaDatatable } from 'src/plugins/data/common'; import { DragContextState } from './drag_drop'; import { Document } from './persistence'; @@ -167,14 +167,6 @@ export interface LensMultiTable { tables: Record; } -// This is a temporary type definition, to be replaced with -// the official Kibana Datatable type definition. -export interface KibanaDatatable { - type: 'kibana_datatable'; - rows: Array>; - columns: Array<{ id: string; name: string }>; -} - export interface VisualizationProps { dragDropContext: DragContextState; frame: FramePublicAPI; 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 index 71fb202a5cf3c..deaf7254d6789 100644 --- 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 @@ -15,12 +15,14 @@ exports[`xy_expression XYChart component it renders area 1`] = ` id="x" position="bottom" showGridLines={false} + tickFormat={[Function]} title="" /> -`; \ No newline at end of file +`; 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 index b16c26f6dee45..81098b0a420b2 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -5,6 +5,7 @@ */ import { CoreSetup } from 'src/core/public'; +import { getFormat, FormatFactory } from 'ui/visualize/loader/pipeline_helpers/utilities'; import { xyVisualization } from './xy_visualization'; import { @@ -12,23 +13,30 @@ import { functionsRegistry, } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { InterpreterSetup, RenderFunction } from '../interpreter_types'; -import { xyChart, xyChartRenderer } from './xy_expression'; +import { xyChart, getXyChartRenderer } from './xy_expression'; import { legendConfig, xConfig, layerConfig } from './types'; export interface XyVisualizationPluginSetupPlugins { interpreter: InterpreterSetup; + // TODO this is a simulated NP plugin. + // Once field formatters are actually migrated, the actual shim can be used + fieldFormat: { + formatFactory: FormatFactory; + }; } class XyVisualizationPlugin { constructor() {} - setup(_core: CoreSetup | null, { interpreter }: XyVisualizationPluginSetupPlugins) { + setup(_core: CoreSetup | null, { interpreter, fieldFormat }: XyVisualizationPluginSetupPlugins) { interpreter.functionsRegistry.register(() => legendConfig); interpreter.functionsRegistry.register(() => xConfig); interpreter.functionsRegistry.register(() => layerConfig); interpreter.functionsRegistry.register(() => xyChart); - interpreter.renderersRegistry.register(() => xyChartRenderer as RenderFunction); + interpreter.renderersRegistry.register( + () => getXyChartRenderer(fieldFormat.formatFactory) as RenderFunction + ); return xyVisualization; } @@ -44,5 +52,8 @@ export const xyVisualizationSetup = () => renderersRegistry, functionsRegistry, }, + fieldFormat: { + formatFactory: getFormat, + }, }); export const xyVisualizationStop = () => plugin.stop(); 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 index b21b3229efd57..a3d8a3b3f0133 100644 --- 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 @@ -4,6 +4,7 @@ * 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'; @@ -17,8 +18,16 @@ function sampleArgs() { tables: { first: { type: 'kibana_datatable', - columns: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }, { id: 'c', name: 'c' }], - rows: [{ a: 1, b: 2, c: 3 }, { a: 1, b: 5, c: 4 }], + 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' }], }, }, }; @@ -91,6 +100,15 @@ describe('xy_expression', () => { }); 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(); @@ -98,6 +116,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -110,6 +129,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -122,6 +142,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -134,6 +155,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -147,6 +169,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -160,6 +183,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -177,6 +201,7 @@ describe('xy_expression', () => { isHorizontal: true, layers: [{ ...args.layers[0], seriesType: 'bar_stacked' }], }} + formatFactory={getFormatSpy} /> ); expect(component).toMatchSnapshot(); @@ -188,28 +213,28 @@ describe('xy_expression', () => { test('it rewrites the rows based on provided labels', () => { const { data, args } = sampleArgs(); - const component = shallow(); + const component = shallow(); expect(component.find(LineSeries).prop('data')).toEqual([ - { 'Label A': 1, 'Label B': 2, c: 3 }, - { 'Label A': 1, 'Label B': 5, c: 4 }, + { '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(); + const component = shallow(); expect(component.find(LineSeries).prop('yAccessors')).toEqual(['Label A', 'Label B']); }); - test('it indicates a linear scale for a numeric X axis', () => { + test('it indicates an ordinal scale for a string X axis', () => { const { data, args } = sampleArgs(); - const component = shallow(); - expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Linear); + const component = shallow(); + expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); }); - test('it indicates an ordinal scale for a string X axis', () => { + test('it indicates a linear scale for a numeric X axis', () => { const { args } = sampleArgs(); const data: LensMultiTable = { @@ -218,13 +243,62 @@ describe('xy_expression', () => { first: { type: 'kibana_datatable', columns: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }, { id: 'c', name: 'c' }], - rows: [{ a: 1, b: 2, c: 'Hello' }, { a: 6, b: 5, c: 'World' }], + rows: [{ a: 1, b: 2, c: 3 }, { a: 6, b: 5, c: 9 }], }, }, }; - const component = shallow(); - expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); + const component = shallow(); + expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Linear); + }); + + 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 index 9d7fffc17c086..37680bbe8e3d9 100644 --- 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 @@ -22,6 +22,7 @@ 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 { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities'; import { LensMultiTable } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; import { RenderFunction } from '../interpreter_types'; @@ -85,7 +86,7 @@ export interface XYChartProps { args: XYArgs; } -export const xyChartRenderer: RenderFunction = { +export const getXyChartRenderer = (formatFactory: FormatFactory): RenderFunction => ({ name: 'lens_xy_chart_renderer', displayName: 'XY Chart', help: 'X/Y Chart Renderer', @@ -94,18 +95,24 @@ export const xyChartRenderer: RenderFunction = { 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 }: XYChartProps) { +export function XYChart({ + data, + args, + formatFactory, +}: XYChartProps & { + formatFactory: FormatFactory; +}) { const { legend, layers, isHorizontal } = args; if (Object.values(data.tables).every(table => table.rows.length === 0)) { @@ -127,6 +134,23 @@ export function XYChart({ data, args }: XYChartProps) { ); } + // 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( From 398a3de6ac53a16e86574d6b7daf816aae72ef57 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 13 Aug 2019 15:47:34 +0200 Subject: [PATCH 078/105] Fix bugs --- .../lens/public/app_plugin/app.test.tsx | 25 +++++++------ .../plugins/lens/public/app_plugin/app.tsx | 8 +--- .../plugins/lens/public/app_plugin/plugin.tsx | 6 +-- .../embeddable/embeddable.tsx | 2 +- .../embeddable/embeddable_factory.ts | 2 +- .../lens/public/editor_frame_plugin/mocks.tsx | 2 +- .../editor_frame_plugin/plugin.test.tsx | 1 - .../public/editor_frame_plugin/plugin.tsx | 11 +++--- .../indexpattern_plugin/datapanel.test.tsx | 1 + .../dimension_panel/dimension_panel.test.tsx | 10 ++--- .../dimension_panel/dimension_panel.tsx | 4 +- .../dimension_panel/popover_editor.tsx | 2 +- .../indexpattern_plugin/indexpattern.test.tsx | 8 ++-- .../indexpattern_plugin/indexpattern.tsx | 9 +++-- .../indexpattern_suggestions.test.tsx | 8 ++-- .../indexpattern_plugin/layerpanel.test.tsx | 1 + .../date_histogram.test.tsx | 17 ++++++--- .../filter_ratio.test.tsx | 37 +++++++------------ .../operation_definitions/filter_ratio.tsx | 18 ++++----- .../operation_definitions/terms.test.tsx | 26 +++++++++---- .../indexpattern_plugin/operations.test.ts | 1 + .../public/indexpattern_plugin/operations.ts | 6 +-- .../public/indexpattern_plugin/plugin.tsx | 18 ++------- .../indexpattern_plugin/state_helpers.test.ts | 1 + 24 files changed, 105 insertions(+), 119 deletions(-) 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 index b0b71f13ec8fb..3b30c040985c4 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -11,11 +11,17 @@ import { EditorFrameInstance } from '../types'; import { Chrome } from 'ui/chrome'; import { toastNotifications } from 'ui/notify'; import { Storage } from 'ui/storage'; -import { QueryBar as QueryBarType } from 'src/legacy/core_plugins/data/public/query'; import { Document, SavedObjectStore } from '../persistence'; import { mount } from 'enzyme'; +import { QueryBar } from '../../../../../../src/legacy/core_plugins/data/public/query'; +jest.mock('../../../../../../src/legacy/core_plugins/data/public/query', () => ({ + QueryBar: jest.fn(() => null), +})); + +jest.mock('ui/new_platform'); jest.mock('ui/notify'); +jest.mock('ui/chrome'); jest.mock('../persistence'); const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); @@ -33,7 +39,6 @@ function makeDefaultArgs(): jest.Mocked<{ store: Storage; docId?: string; docStorage: SavedObjectStore; - QueryBar: typeof QueryBarType; redirectTo: (id?: string) => void; }> { return ({ @@ -46,6 +51,8 @@ function makeDefaultArgs(): jest.Mocked<{ return { from: 'now-7d', to: 'now' }; } else if (type === 'search:queryLanguage') { return 'kuery'; + } else { + return []; } }), }; @@ -66,7 +73,6 @@ function makeDefaultArgs(): jest.Mocked<{ store: Storage; docId?: string; docStorage: SavedObjectStore; - QueryBar: typeof QueryBarType; redirectTo: (id?: string) => void; }>; } @@ -134,7 +140,7 @@ describe('Lens App', () => { await waitForPromises(); expect(args.docStorage.load).toHaveBeenCalledWith('1234'); - expect(args.QueryBar).toHaveBeenCalledWith( + expect(QueryBar).toHaveBeenCalledWith( expect.objectContaining({ dateRangeFrom: 'now-7d', dateRangeTo: 'now', @@ -303,7 +309,7 @@ describe('Lens App', () => { mount(); - expect(args.QueryBar).toHaveBeenCalledWith( + expect(QueryBar).toHaveBeenCalledWith( expect.objectContaining({ dateRangeFrom: 'now-7d', dateRangeTo: 'now', @@ -326,9 +332,7 @@ describe('Lens App', () => { const instance = mount(); - expect(args.QueryBar).toHaveBeenCalledTimes(1); - - expect(args.QueryBar).toHaveBeenCalledWith( + expect(QueryBar).toHaveBeenCalledWith( expect.objectContaining({ indexPatterns: [], }), @@ -343,8 +347,7 @@ describe('Lens App', () => { instance.update(); - expect(args.QueryBar).toHaveBeenCalledTimes(2); - expect(args.QueryBar).toHaveBeenCalledWith( + expect(QueryBar).toHaveBeenCalledWith( expect.objectContaining({ indexPatterns: ['newIndex'], }), @@ -368,7 +371,7 @@ describe('Lens App', () => { instance.update(); - expect(args.QueryBar).toHaveBeenCalledWith( + expect(QueryBar).toHaveBeenCalledWith( expect.objectContaining({ dateRangeFrom: 'now-14d', dateRangeTo: 'now-7d', diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index c0b891f6c4bc7..95bbda1102242 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -12,10 +12,7 @@ import { EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Storage } from 'ui/storage'; import { toastNotifications } from 'ui/notify'; import { Chrome } from 'ui/chrome'; -import { - Query, - QueryBar as QueryBarType, -} from '../../../../../../src/legacy/core_plugins/data/public/query'; +import { Query, QueryBar } from '../../../../../../src/legacy/core_plugins/data/public/query'; import { Document, SavedObjectStore } from '../persistence'; import { EditorFrameInstance } from '../types'; import { NativeRenderer } from '../native_renderer'; @@ -38,7 +35,6 @@ export function App({ chrome, docId, docStorage, - QueryBar, redirectTo, }: { editorFrame: EditorFrameInstance; @@ -46,7 +42,6 @@ export function App({ store: Storage; docId?: string; docStorage: SavedObjectStore; - QueryBar: typeof QueryBarType; redirectTo: (id?: string) => void; }) { const uiSettings = chrome.getUiSettingsClient(); @@ -175,6 +170,7 @@ export function App({ query={state.query} dateRangeFrom={state.dateRange && state.dateRange.fromDate} dateRangeTo={state.dateRange && state.dateRange.toDate} + uiSettings={uiSettings} />
diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index 3c47d8076fff6..02e14bde5be94 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -8,8 +8,7 @@ import React from 'react'; import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; import { HashRouter, Switch, Route, RouteComponentProps } from 'react-router-dom'; import chrome, { Chrome } from 'ui/chrome'; -import { localStorage } from 'ui/storage/storage_service'; -import { QueryBar } from '../../../../../../src/legacy/core_plugins/data/public/query'; +import { Storage } from 'ui/storage'; import { editorFrameSetup, editorFrameStop } from '../editor_frame_plugin'; import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin'; import { SavedObjectIndexStore } from '../persistence'; @@ -48,9 +47,8 @@ export class AppPlugin { return ( { 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 index 728676b2ea869..f50ee70731642 100644 --- 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 @@ -17,7 +17,7 @@ import { EmbeddableOutput, IContainer, EmbeddableInput, -} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/index'; +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; import { Document, DOC_TYPE } from '../../persistence'; import { ExpressionWrapper } from './expression_wrapper'; 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 index 515940a2249b0..edd219b64701b 100644 --- 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 @@ -19,7 +19,7 @@ import { ErrorEmbeddable, EmbeddableInput, IContainer, -} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/index'; +} 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'; 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 index fa29ede4185a0..a1bcc921104bc 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -103,7 +103,7 @@ export function createMockDependencies() { }, }, embeddables: { - addEmbeddableFactory: jest.fn(), + registerEmbeddableFactory: jest.fn(), }, chrome: { getSavedObjectsClient: () => {}, 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 index 9a5e0e23ea23d..09a22c61d0ccf 100644 --- 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 @@ -14,7 +14,6 @@ jest.mock('ui/chrome', () => ({ // 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', () => {}); -jest.mock('../../../../../../src/legacy/core_plugins/embeddable_api/public', () => {}); jest.mock('./embeddable/embeddable_factory', () => ({ EmbeddableFactory: class Mock {} })); describe('editor_frame plugin', () => { 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 index 11446be472cc1..08a760671555e 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -10,10 +10,8 @@ import { I18nProvider } from '@kbn/i18n/react'; import { Registry } from '@kbn/interpreter/target/common'; import { CoreSetup } from 'src/core/public'; import chrome, { Chrome } from 'ui/chrome'; -import { - EmbeddablePlugin, - embeddablePlugin, -} from '../../../../../../src/legacy/core_plugins/embeddable_api/public'; +import { Plugin as EmbeddablePlugin } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { setup as embeddablePlugin } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; import { setup as data } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/public'; import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; @@ -25,7 +23,7 @@ import { EmbeddableFactory } from './embeddable/embeddable_factory'; export interface EditorFrameSetupPlugins { data: typeof data; chrome: Chrome; - embeddables: EmbeddablePlugin; + embeddables: ReturnType; interpreter: InterpreterSetup; } @@ -45,7 +43,8 @@ export class EditorFramePlugin { public setup(_core: CoreSetup | null, plugins: EditorFrameSetupPlugins): EditorFrameSetup { plugins.interpreter.functionsRegistry.register(() => mergeTables); - plugins.embeddables.addEmbeddableFactory( + plugins.embeddables.registerEmbeddableFactory( + 'lens', new EmbeddableFactory( plugins.chrome, plugins.data.expressions.ExpressionRenderer, 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 index 9ff6297c28368..dfd4adde48560 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -13,6 +13,7 @@ import { InnerIndexPatternDataPanel, IndexPatternDataPanel, MemoizedDataPanel } import { FieldItem } from './field_item'; import { act } from 'react-dom/test-utils'; +jest.mock('ui/new_platform'); jest.mock('./loader'); const initialState: IndexPatternPrivateState = { 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 index c79543b35ae88..2ddfce6b7e0a5 100644 --- 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 @@ -8,22 +8,22 @@ import { ReactWrapper, ShallowWrapper } from 'enzyme'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { EuiComboBox, EuiSideNav, EuiPopover } from '@elastic/eui'; -import { localStorage } from 'ui/storage/storage_service'; 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 { DataPluginDependencies } from '..'; +import { UiSettingsClientContract } 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'); -jest.mock('ui/storage/storage_service'); // 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: {} } } })); @@ -113,8 +113,8 @@ describe('IndexPatternDimensionPanel', () => { columnId: 'col1', layerId: 'first', filterOperations: () => true, - storage: localStorage, - dataPluginDependencies: (undefined as unknown) as DataPluginDependencies, + storage: {} as Storage, + uiSettings: {} as UiSettingsClientContract, }; jest.clearAllMocks(); 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 index dae1e073a704d..689e5a0f52c26 100644 --- 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 @@ -9,6 +9,7 @@ import React, { memo, useMemo } from 'react'; import { EuiButtonIcon } from '@elastic/eui'; import { Storage } from 'ui/storage'; import { i18n } from '@kbn/i18n'; +import { UiSettingsClientContract } from 'src/core/public'; import { DatasourceDimensionPanelProps } from '../../types'; import { IndexPatternColumn, @@ -22,13 +23,12 @@ import { PopoverEditor } from './popover_editor'; import { DragContextState, ChildDragDropProvider, DragDrop } from '../../drag_drop'; import { changeColumn, deleteColumn } from '../state_helpers'; import { isDraggedField } from '../utils'; -import { DataPluginDependencies } from '..'; export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { state: IndexPatternPrivateState; setState: (newState: IndexPatternPrivateState) => void; dragDropContext: DragContextState; - dataPluginDependencies: DataPluginDependencies; + uiSettings: UiSettingsClientContract; storage: Storage; layerId: string; }; 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 index c383849fa7eb7..88763667a915f 100644 --- 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 @@ -333,7 +333,7 @@ export function PopoverEditor(props: PopoverEditorProps) { setState={setState} columnId={columnId} storage={props.storage} - dataPluginDependencies={props.dataPluginDependencies} + uiSettings={props.uiSettings} layerId={layerId} /> )} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index 0fe1410fb6761..336deef6147a3 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -6,7 +6,7 @@ import chromeMock from 'ui/chrome'; import { data as dataMock } from '../../../../../../src/legacy/core_plugins/data/public/setup'; -import { localStorage as storageMock } from 'ui/storage/storage_service'; +import { Storage } from 'ui/storage'; import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { toastNotifications as notificationsMock } from 'ui/notify'; import { @@ -16,14 +16,12 @@ import { IndexPatternColumn, } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; -import { DataPluginDependencies } from './plugin'; jest.mock('./loader'); jest.mock('../id_generator'); // chrome, notify, storage are used by ./plugin jest.mock('ui/chrome'); jest.mock('ui/notify'); -jest.mock('ui/storage/storage_service'); // 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: {} } } })); @@ -138,10 +136,10 @@ describe('IndexPattern Data Source', () => { beforeEach(() => { indexPatternDatasource = getIndexPatternDatasource({ chrome: chromeMock, - storage: storageMock, + storage: {} as Storage, interpreter: { functionsRegistry }, toastNotifications: notificationsMock, - data: dataMock as DataPluginDependencies, + data: dataMock, }); persistedState = { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index c2e937a256c41..b81ff9e553502 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -8,6 +8,7 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; +import { Storage } from 'ui/storage'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, @@ -19,7 +20,7 @@ import { Query } from '../../../../../../src/legacy/core_plugins/data/public/que import { getIndexPatterns } from './loader'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; -import { IndexPatternDatasourcePluginPlugins, DataPluginDependencies } from './plugin'; +import { IndexPatternDatasourcePluginPlugins } from './plugin'; import { IndexPatternDataPanel } from './datapanel'; import { getDatasourceSuggestionsForField, @@ -199,9 +200,9 @@ function removeProperty(prop: string, object: Record): Record & { data: DataPluginDependencies }) { +}: IndexPatternDatasourcePluginPlugins & { storage: Storage }) { + const uiSettings = chrome.getUiSettingsClient(); // Not stateful. State is persisted to the frame const indexPatternDatasource: Datasource = { async initialize(state?: IndexPatternPersistedState) { @@ -305,7 +306,7 @@ export function getIndexPatternDatasource({ setState(newState)} - dataPluginDependencies={data} + uiSettings={uiSettings} storage={storage} layerId={props.layerId} {...props} 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 index 0adafb1e77652..d86d88ed6ff02 100644 --- 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 @@ -6,7 +6,6 @@ import chromeMock from 'ui/chrome'; import { data as dataMock } from '../../../../../../src/legacy/core_plugins/data/public/setup'; -import { localStorage as storageMock } from 'ui/storage/storage_service'; import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { toastNotifications as notificationsMock } from 'ui/notify'; import { @@ -16,14 +15,13 @@ import { } from './indexpattern'; import { Datasource, DatasourceSuggestion } from '../types'; import { generateId } from '../id_generator'; -import { DataPluginDependencies } from './plugin'; +import { Storage } from 'ui/storage'; jest.mock('./loader'); jest.mock('../id_generator'); // chrome, notify, storage are used by ./plugin jest.mock('ui/chrome'); jest.mock('ui/notify'); -jest.mock('ui/storage/storage_service'); // 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: {} } } })); @@ -138,10 +136,10 @@ describe('IndexPattern Data Source suggestions', () => { beforeEach(() => { indexPatternDatasource = getIndexPatternDatasource({ chrome: chromeMock, - storage: storageMock, + storage: {} as Storage, interpreter: { functionsRegistry }, toastNotifications: notificationsMock, - data: dataMock as DataPluginDependencies, + data: dataMock, }); persistedState = { 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 index 9ad3bf93f2b17..46e381d69741b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx @@ -13,6 +13,7 @@ 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 = { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx index b91c9c07bb601..8e94087f4a5fb 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx @@ -9,7 +9,10 @@ import { dateHistogramOperation } from './date_histogram'; import { shallow } from 'enzyme'; import { DateHistogramIndexPatternColumn, IndexPatternPrivateState } from '../indexpattern'; import { EuiRange } from '@elastic/eui'; -import { DataPluginDependencies } from '..'; +import { UiSettingsClientContract } from 'src/core/public'; +import { Storage } from 'ui/storage'; + +jest.mock('ui/new_platform'); describe('date_histogram', () => { let state: IndexPatternPrivateState; @@ -234,7 +237,8 @@ describe('date_histogram', () => { setState={setStateSpy} columnId="col1" layerId="first" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); @@ -249,7 +253,8 @@ describe('date_histogram', () => { setState={setStateSpy} columnId="col2" layerId="second" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); @@ -264,7 +269,8 @@ describe('date_histogram', () => { setState={setStateSpy} columnId="col1" layerId="first" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); @@ -320,7 +326,8 @@ describe('date_histogram', () => { setState={setStateSpy} columnId="col1" layerId="first" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx index aa87c36d7df75..43d17c96a9810 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx @@ -10,24 +10,16 @@ import { act } from 'react-dom/test-utils'; import { filterRatioOperation } from './filter_ratio'; import { FilterRatioIndexPatternColumn, IndexPatternPrivateState } from '../indexpattern'; import { Storage } from 'ui/storage'; -import { DataPluginDependencies } from '../plugin'; +import { UiSettingsClientContract } from 'src/core/public'; +import { QueryBarInput } from '../../../../../../../src/legacy/core_plugins/data/public/query'; + +jest.mock('ui/new_platform'); describe('filter_ratio', () => { let state: IndexPatternPrivateState; let storageMock: Storage; - let mockedDependencies: DataPluginDependencies; const InlineOptions = filterRatioOperation.paramEditor!; - class MockQueryBarInput { - props: {}; - constructor(props: {}) { - this.props = props; - } - render() { - return <>; - } - } - beforeEach(() => { state = { indexPatterns: { @@ -61,9 +53,6 @@ describe('filter_ratio', () => { }; storageMock = {} as Storage; - mockedDependencies = ({ - components: { QueryBarInput: MockQueryBarInput }, - } as unknown) as DataPluginDependencies; }); describe('buildColumn', () => { @@ -113,7 +102,7 @@ describe('filter_ratio', () => { setState={jest.fn()} columnId="col1" storage={storageMock} - dataPluginDependencies={mockedDependencies} + uiSettings={{} as UiSettingsClientContract} /> ); }).not.toThrow(); @@ -127,12 +116,12 @@ describe('filter_ratio', () => { setState={jest.fn()} columnId="col1" storage={storageMock} - dataPluginDependencies={mockedDependencies} + uiSettings={{} as UiSettingsClientContract} /> ); - expect(wrapper.find(MockQueryBarInput)).toHaveLength(1); - expect(wrapper.find(MockQueryBarInput).prop('indexPatterns')).toEqual(['Mock Indexpattern']); + 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', () => { @@ -144,11 +133,11 @@ describe('filter_ratio', () => { setState={setState} columnId="col1" storage={storageMock} - dataPluginDependencies={mockedDependencies} + uiSettings={{} as UiSettingsClientContract} /> ); - wrapper.find(MockQueryBarInput).prop('onChange')!({ + wrapper.find(QueryBarInput).prop('onChange')!({ query: 'geo.src : "US"', language: 'kuery', }); @@ -182,7 +171,7 @@ describe('filter_ratio', () => { setState={setState} columnId="col1" storage={storageMock} - dataPluginDependencies={mockedDependencies} + uiSettings={{} as UiSettingsClientContract} /> ); @@ -193,10 +182,10 @@ describe('filter_ratio', () => { .simulate('click'); }); - expect(wrapper.find(MockQueryBarInput)).toHaveLength(2); + expect(wrapper.find(QueryBarInput)).toHaveLength(2); wrapper - .find(MockQueryBarInput) + .find(QueryBarInput) .at(1) .prop('onChange')!({ query: 'geo.src : "US"', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx index d0c08519cd4cc..3c8775c69593c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx @@ -8,7 +8,10 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiFormRow } from '@elastic/eui'; -import { Query } from '../../../../../../../src/legacy/core_plugins/data/public/query'; +import { + Query, + QueryBarInput, +} from '../../../../../../../src/legacy/core_plugins/data/public/query'; import { FilterRatioIndexPatternColumn } from '../indexpattern'; import { OperationDefinition } from '../operations'; import { updateColumnParam } from '../state_helpers'; @@ -64,18 +67,9 @@ export const filterRatioOperation: OperationDefinition { + paramEditor: ({ state, setState, columnId: currentColumnId, uiSettings, storage, layerId }) => { const [hasDenominator, setDenominator] = useState(false); - const { QueryBarInput } = pluginDependencies.components; - return (
{ setState( updateColumnParam( @@ -121,6 +116,7 @@ export const filterRatioOperation: OperationDefinition { setState( updateColumnParam( diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx index b1ee1dd4de2d2..e221545bed8f5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx @@ -9,7 +9,10 @@ import { termsOperation } from './terms'; import { shallow } from 'enzyme'; import { IndexPatternPrivateState, TermsIndexPatternColumn } from '../indexpattern'; import { EuiRange, EuiSelect } from '@elastic/eui'; -import { DataPluginDependencies } from '..'; +import { UiSettingsClientContract } from 'src/core/public'; +import { Storage } from 'ui/storage'; + +jest.mock('ui/new_platform'); describe('terms', () => { let state: IndexPatternPrivateState; @@ -275,7 +278,8 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); @@ -319,7 +323,8 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); @@ -336,7 +341,8 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); @@ -380,7 +386,8 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); @@ -400,7 +407,8 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); @@ -441,7 +449,8 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); @@ -456,7 +465,8 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" - dataPluginDependencies={({} as unknown) as DataPluginDependencies} + storage={{} as Storage} + uiSettings={{} as UiSettingsClientContract} /> ); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts index cdc3b040beb05..77145888d336b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -16,6 +16,7 @@ import { CountIndexPatternColumn, } from './indexpattern'; +jest.mock('ui/new_platform'); jest.mock('./loader'); const expectedIndexPatterns = { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index 4b9224df45757..25821f505fe08 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -6,6 +6,7 @@ import _ from 'lodash'; import { Storage } from 'ui/storage'; +import { UiSettingsClientContract } from 'src/core/public'; import { DimensionPriority, OperationMetadata } from '../types'; import { IndexPatternColumn, @@ -25,7 +26,6 @@ import { import { dateHistogramOperation } from './operation_definitions/date_histogram'; import { countOperation } from './operation_definitions/count'; import { filterRatioOperation } from './operation_definitions/filter_ratio'; -import { DataPluginDependencies } from './plugin'; type PossibleOperationDefinition< U extends IndexPatternColumn = IndexPatternColumn @@ -63,8 +63,8 @@ export interface ParamEditorProps { setState: (newState: IndexPatternPrivateState) => void; columnId: string; layerId: string; - dataPluginDependencies: DataPluginDependencies; - storage?: Storage; + uiSettings: UiSettingsClientContract; + storage: Storage; } export interface OperationDefinition { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx index 2096b7f012ffb..2f1c23b1bf9dc 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx @@ -10,22 +10,12 @@ import { CoreSetup } from 'src/core/public'; import chrome, { Chrome } from 'ui/chrome'; import { toastNotifications } from 'ui/notify'; import { Storage } from 'ui/storage'; -import { localStorage } from 'ui/storage/storage_service'; 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'; -import { QueryBarInput } from '../../../../../../src/legacy/core_plugins/data/public'; - -// TODO this is a temporary workaround because the QueryBar component is being re-written -// After the re-write, the component itself will be stateless and can be imported in the file -// that uses it And it takes a prop out of the chrome plugin which can be passed down through the plugin -// and dimension panel. -export type DataPluginDependencies = typeof dataSetup & { - components: { QueryBarInput: typeof QueryBarInput }; -}; // 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 @@ -35,7 +25,6 @@ export interface IndexPatternDatasourcePluginPlugins { chrome: Chrome; interpreter: InterpreterSetup; data: typeof dataSetup; - storage: Storage; toastNotifications: typeof toastNotifications; } @@ -51,7 +40,7 @@ class IndexPatternDatasourcePlugin { setup( _core: CoreSetup | null, - { interpreter, data, storage, toastNotifications: toast }: IndexPatternDatasourcePluginPlugins + { interpreter, data, toastNotifications: toast }: IndexPatternDatasourcePluginPlugins ) { interpreter.functionsRegistry.register(() => renameColumns); interpreter.functionsRegistry.register(() => calculateFilterRatio); @@ -59,8 +48,8 @@ class IndexPatternDatasourcePlugin { chrome, interpreter, toastNotifications: toast, - data: { ...data, components: { QueryBarInput } }, - storage, + data, + storage: new Storage(localStorage), }); } @@ -76,7 +65,6 @@ export const indexPatternDatasourceSetup = () => functionsRegistry, }, data: dataSetup, - storage: localStorage, toastNotifications, }); export const indexPatternDatasourceStop = () => plugin.stop(); 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 index a929802a830ee..e44dea4340777 100644 --- 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 @@ -21,6 +21,7 @@ import { } from './indexpattern'; import { operationDefinitionMap } from './operations'; +jest.mock('ui/new_platform'); jest.mock('./operations'); describe('state_helpers', () => { From e9f580765fb5c723e3bcd3a866cc6184822bbe9b Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Wed, 14 Aug 2019 12:19:08 -0400 Subject: [PATCH 079/105] [Lens] Add bucket nesting editor to indexpattern (#42869) --- .../bucket_nesting_editor.test.tsx | 237 ++++++++++++++++++ .../dimension_panel/bucket_nesting_editor.tsx | 96 +++++++ .../dimension_panel/popover_editor.tsx | 17 ++ 3 files changed, 350 insertions(+) create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/bucket_nesting_editor.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/bucket_nesting_editor.tsx 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/popover_editor.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx index 88763667a915f..ebf87fb1bdebb 100644 --- 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 @@ -32,6 +32,7 @@ import { operationDefinitionMap, getOperationDisplay, buildColumn } from '../ope import { deleteColumn, changeColumn } from '../state_helpers'; import { FieldSelect } from './field_select'; import { hasField } from '../utils'; +import { BucketNestingEditor } from './bucket_nesting_editor'; const operationPanels = getOperationDisplay(); @@ -363,6 +364,22 @@ export function PopoverEditor(props: PopoverEditorProps) { /> )} + { + setState({ + ...state, + layers: { + ...state.layers, + [props.layerId]: { + ...state.layers[props.layerId], + columnOrder, + }, + }, + }); + }} + /> From 4b09eba4806f12cbab4fca835bd97c22bc406d01 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Thu, 15 Aug 2019 16:35:50 -0400 Subject: [PATCH 080/105] [Lens] Remove unnecessary fields and indexing from mappings (#43285) --- x-pack/legacy/plugins/lens/mappings.json | 9 ++-- .../editor_frame/editor_frame.test.tsx | 7 ---- .../editor_frame/editor_frame.tsx | 1 - .../editor_frame/save.test.ts | 2 - .../editor_frame_plugin/editor_frame/save.ts | 3 -- .../editor_frame/state_management.test.ts | 2 - .../editor_frame/state_management.ts | 19 ++++++++- .../embeddable/embeddable.test.tsx | 1 - .../public/editor_frame_plugin/plugin.tsx | 3 +- .../persistence/saved_object_store.test.ts | 42 ++----------------- .../public/persistence/saved_object_store.ts | 14 ++----- 11 files changed, 31 insertions(+), 72 deletions(-) diff --git a/x-pack/legacy/plugins/lens/mappings.json b/x-pack/legacy/plugins/lens/mappings.json index 9136447531be8..8eccf22eb2235 100644 --- a/x-pack/legacy/plugins/lens/mappings.json +++ b/x-pack/legacy/plugins/lens/mappings.json @@ -7,14 +7,13 @@ "visualizationType": { "type": "keyword" }, - "activeDatasourceId": { - "type": "keyword" - }, "state": { - "type": "text" + "enabled": false, + "type": "object" }, "expression": { - "type": "text" + "index": false, + "type": "keyword" } } } 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 index 1b83ed4f07e70..3904c2c114543 100644 --- 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 @@ -154,7 +154,6 @@ describe('editor_frame', () => { initialVisualizationId="testVis" ExpressionRenderer={expressionRendererMock} doc={{ - activeDatasourceId: 'testDatasource', visualizationType: 'testVis', title: '', expression: '', @@ -482,7 +481,6 @@ describe('editor_frame', () => { initialVisualizationId="testVis" ExpressionRenderer={expressionRendererMock} doc={{ - activeDatasourceId: 'testDatasource', visualizationType: 'testVis', title: '', expression: '', @@ -725,7 +723,6 @@ describe('editor_frame', () => { initialVisualizationId="testVis" ExpressionRenderer={expressionRendererMock} doc={{ - activeDatasourceId: 'testDatasource', visualizationType: 'testVis', title: '', expression: '', @@ -779,7 +776,6 @@ describe('editor_frame', () => { initialVisualizationId="testVis" ExpressionRenderer={expressionRendererMock} doc={{ - activeDatasourceId: 'testDatasource', visualizationType: 'testVis', title: '', expression: '', @@ -1379,7 +1375,6 @@ describe('editor_frame', () => { expect(onChange).toHaveBeenNthCalledWith(1, { indexPatternTitles: ['resolved'], doc: { - activeDatasourceId: 'testDatasource', expression: '', id: undefined, state: { @@ -1397,7 +1392,6 @@ describe('editor_frame', () => { expect(onChange).toHaveBeenLastCalledWith({ indexPatternTitles: ['resolved'], doc: { - activeDatasourceId: 'testDatasource', expression: '', id: undefined, state: { @@ -1457,7 +1451,6 @@ describe('editor_frame', () => { expect(onChange).toHaveBeenNthCalledWith(3, { indexPatternTitles: [], doc: { - activeDatasourceId: 'testDatasource', expression: expect.stringContaining('vis "expression"'), id: undefined, state: { 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 index ff393eabf8ca5..823852eed6133 100644 --- 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 @@ -198,7 +198,6 @@ export function EditorFrame(props: EditorFrameProps) { ), visualization, state, - activeDatasourceId: state.activeDatasourceId!, framePublicAPI, }); 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 index 86c41868ff3dd..6bfe8f70d93c4 100644 --- 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 @@ -28,7 +28,6 @@ describe('save editor frame state', () => { activeDatasourceId: 'indexpattern', visualization: { activeId: '2', state: {} }, }, - activeDatasourceId: 'indexpattern', framePublicAPI: { addNewLayer: jest.fn(), removeLayers: jest.fn(), @@ -71,7 +70,6 @@ describe('save editor frame state', () => { }); expect(doc).toEqual({ - activeDatasourceId: 'indexpattern', id: undefined, expression: '', state: { 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 index 829955d910e25..6c414d9866033 100644 --- 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 @@ -16,7 +16,6 @@ export interface Props { state: EditorFrameState; visualization: Visualization; framePublicAPI: FramePublicAPI; - activeDatasourceId: string; } export function getSavedObjectFormat({ @@ -24,7 +23,6 @@ export function getSavedObjectFormat({ state, visualization, framePublicAPI, - activeDatasourceId, }: Props): Document { const expression = buildExpression({ visualization, @@ -53,7 +51,6 @@ export function getSavedObjectFormat({ type: 'lens', visualizationType: state.visualization.activeId, expression: expression ? toExpression(expression) : '', - activeDatasourceId, state: { datasourceStates, datasourceMetaData: { 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 index 22ff323507487..8320a79195442 100644 --- 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 @@ -45,7 +45,6 @@ describe('editor_frame state management', () => { const initialState = getInitialState({ ...props, doc: { - activeDatasourceId: 'testDatasource', expression: '', state: { datasourceStates: { @@ -370,7 +369,6 @@ describe('editor_frame state management', () => { doc: { id: 'b', expression: '', - activeDatasourceId: 'a', state: { datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { a: { foo: 'c' } }, 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 index 6f76cc15dafed..60e053bf19133 100644 --- 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 @@ -64,6 +64,21 @@ export type Action = 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'] = {}; @@ -81,7 +96,7 @@ export const getInitialState = (props: EditorFrameProps): EditorFrameState => { return { title: i18n.translate('xpack.lens.chartTitle', { defaultMessage: 'New visualization' }), datasourceStates, - activeDatasourceId: props.initialDatasourceId ? props.initialDatasourceId : null, + activeDatasourceId: getInitialDatasourceId(props), visualization: { state: null, activeId: props.initialVisualizationId, @@ -124,7 +139,7 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta }), {} ), - activeDatasourceId: action.doc.activeDatasourceId, + activeDatasourceId: getActiveDatasourceIdFromDoc(action.doc), visualization: { ...state.visualization, activeId: action.doc.visualizationType, 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 index 813c15b360952..2009eb232562b 100644 --- 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 @@ -17,7 +17,6 @@ jest.mock('../../../../../../../src/legacy/ui/public/inspector', () => ({ })); const savedVis: Document = { - activeDatasourceId: '', expression: 'my | expression', state: { visualization: {}, 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 index 08a760671555e..62339a4cc3afc 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -19,6 +19,7 @@ import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from 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 data; @@ -67,7 +68,7 @@ export class EditorFramePlugin { onError={onError} datasourceMap={this.datasources} visualizationMap={this.visualizations} - initialDatasourceId={(doc && doc.activeDatasourceId) || firstDatasourceId || null} + initialDatasourceId={getActiveDatasourceIdFromDoc(doc) || firstDatasourceId || null} initialVisualizationId={ (doc && doc.visualizationType) || firstVisualizationId || null } 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 index 12dff938d9be1..515d008d82586 100644 --- 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 @@ -27,7 +27,6 @@ describe('LensStore', () => { title: 'Hello', visualizationType: 'bar', expression: '', - activeDatasourceId: 'indexpattern', state: { datasourceMetaData: { filterableIndexPatterns: [], @@ -46,7 +45,6 @@ describe('LensStore', () => { title: 'Hello', visualizationType: 'bar', expression: '', - activeDatasourceId: 'indexpattern', state: { datasourceMetaData: { filterableIndexPatterns: [], @@ -64,10 +62,8 @@ describe('LensStore', () => { expect(client.create).toHaveBeenCalledWith('lens', { title: 'Hello', visualizationType: 'bar', - expression: '', - activeDatasourceId: 'indexpattern', - state: JSON.stringify({ + state: { datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, @@ -75,7 +71,7 @@ describe('LensStore', () => { visualization: { x: 'foo', y: 'baz' }, query: { query: '', language: 'lucene' }, filters: [], - }), + }, }); }); @@ -86,7 +82,6 @@ describe('LensStore', () => { title: 'Even the very wise cannot see all ends.', visualizationType: 'line', expression: '', - activeDatasourceId: 'indexpattern', state: { datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, @@ -101,7 +96,6 @@ describe('LensStore', () => { title: 'Even the very wise cannot see all ends.', visualizationType: 'line', expression: '', - activeDatasourceId: 'indexpattern', state: { datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, @@ -116,46 +110,18 @@ describe('LensStore', () => { title: 'Even the very wise cannot see all ends.', visualizationType: 'line', expression: '', - activeDatasourceId: 'indexpattern', - state: JSON.stringify({ + state: { datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, visualization: { gear: ['staff', 'pointy hat'] }, query: { query: '', language: 'lucene' }, filters: [], - }), + }, }); }); }); describe('load', () => { - test('parses the visState', 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 } }', - }, - })); - const doc = await store.load('Paul'); - - expect(doc).toEqual({ - id: 'Paul', - type: 'lens', - title: 'Hope clouds observation.', - visualizationType: 'dune', - state: { - datasource: { giantWorms: true }, - }, - }); - - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.get).toHaveBeenCalledWith('lens', 'Paul'); - }); - test('throws if an error is returned', async () => { const { client, store } = testStore(); client.get = jest.fn(async () => ({ 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 index 681042ed34ffe..5fa7e3f0aca4a 100644 --- a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts +++ b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts @@ -14,7 +14,6 @@ export interface Document { type?: string; visualizationType: string | null; title: string; - activeDatasourceId: string; expression: string; state: { datasourceMetaData: { @@ -62,18 +61,14 @@ export class SavedObjectIndexStore implements SavedObjectStore { async save(vis: Document) { const { id, type, ...rest } = vis; - const attributes = { - ...rest, - state: JSON.stringify(rest.state), - }; + // 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, - }; + return { ...vis, id: result.id }; } async load(id: string): Promise { @@ -87,7 +82,6 @@ export class SavedObjectIndexStore implements SavedObjectStore { ...attributes, id, type, - state: JSON.parse(((attributes as unknown) as { state: string }).state as string), } as Document; } } From 8f35c2b30f907808f285664001f800cf26b12d2b Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 19 Aug 2019 05:30:45 +0200 Subject: [PATCH 081/105] [Lens] Xy scale type (#42142) --- .../interpreter/public/functions/esaggs.ts | 4 +- .../indexpattern_plugin/indexpattern.tsx | 11 +- .../operation_definitions/count.tsx | 2 + .../operation_definitions/date_histogram.tsx | 2 + .../operation_definitions/filter_ratio.tsx | 2 + .../operation_definitions/metrics.tsx | 2 + .../operation_definitions/terms.test.tsx | 2 + .../operation_definitions/terms.tsx | 2 + .../indexpattern_plugin/operations.test.ts | 3 + .../public/indexpattern_plugin/operations.ts | 2 +- .../indexpattern_plugin/to_expression.ts | 1 + x-pack/legacy/plugins/lens/public/types.ts | 2 +- .../__snapshots__/xy_expression.test.tsx.snap | 14 ++ .../xy_visualization.test.ts.snap | 9 ++ .../public/xy_visualization_plugin/plugin.tsx | 33 +++- .../xy_visualization_plugin/to_expression.ts | 65 +++++++- .../public/xy_visualization_plugin/types.ts | 18 +++ .../xy_expression.test.tsx | 149 +++++++++++++++--- .../xy_visualization_plugin/xy_expression.tsx | 32 +++- .../xy_visualization_plugin/xy_suggestions.ts | 16 +- .../xy_visualization.test.ts | 9 +- 21 files changed, 330 insertions(+), 50 deletions(-) diff --git a/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts b/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts index bb8feabb159cd..c788d4a88d781 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts +++ b/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts @@ -119,7 +119,7 @@ export const esaggs = (): ExpressionFunction { @@ -133,5 +133,7 @@ export const esaggs = (): ExpressionFunction = { { dataType: 'number', isBucketed: false, + scale: 'ratio', }, ]; }, @@ -31,6 +32,7 @@ export const countOperation: OperationDefinition = { operationType: 'count', suggestedPriority, isBucketed: false, + scale: 'ratio', }; }, toEsAggsConfig: (column, columnId) => ({ diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx index da436a849dc51..e454c700bb8db 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx @@ -50,6 +50,7 @@ export const dateHistogramOperation: OperationDefinition( { dataType: 'number', isBucketed: false, + scale: 'ratio', }, ]; } @@ -59,6 +60,7 @@ function buildMetricOperation( suggestedPriority, sourceField: field ? field.name : '', isBucketed: false, + scale: 'ratio', } as T; }, toEsAggsConfig: (column, columnId) => ({ diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx index e221545bed8f5..1f639907b79d0 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx @@ -91,6 +91,7 @@ describe('terms', () => { { dataType: 'string', isBucketed: true, + scale: 'ordinal', }, ]); @@ -105,6 +106,7 @@ describe('terms', () => { { dataType: 'boolean', isBucketed: true, + scale: 'ordinal', }, ]); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx index 97e006ff3df32..ca0455c9372da 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -53,6 +53,7 @@ export const termsOperation: OperationDefinition = { { dataType: type, isBucketed: true, + scale: 'ordinal', }, ]; } @@ -80,6 +81,7 @@ export const termsOperation: OperationDefinition = { label: ofName(field.name), dataType: field.type as DataType, operationType: 'terms', + scale: 'ordinal', suggestedPriority, sourceField: field.name, isBucketed: true, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts index 77145888d336b..d4353f52c4a67 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -234,6 +234,7 @@ describe('getOperationTypesForField', () => { "operationMetaData": Object { "dataType": "string", "isBucketed": true, + "scale": "ordinal", }, "operations": Array [ Object { @@ -247,6 +248,7 @@ describe('getOperationTypesForField', () => { "operationMetaData": Object { "dataType": "date", "isBucketed": true, + "scale": "interval", }, "operations": Array [ Object { @@ -260,6 +262,7 @@ describe('getOperationTypesForField', () => { "operationMetaData": Object { "dataType": "number", "isBucketed": false, + "scale": "ratio", }, "operations": Array [ Object { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index 25821f505fe08..e7110364020d1 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -130,7 +130,7 @@ export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) { > = {}; const addToMap = (operation: OperationFieldTuple) => (operationMetadata: OperationMetadata) => { - const key = `${operationMetadata.dataType}-${operationMetadata.isBucketed ? 'true' : ''}`; + const key = JSON.stringify(operationMetadata); if (operationByMetadata[key]) { operationByMetadata[key].operations.push(operation); 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 index 71e8473aa6839..ab06f94117163 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -65,6 +65,7 @@ function getExpressionForLayer( 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(' | ')}`; diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 6deab82c5658e..3f448f00a8d75 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -158,7 +158,7 @@ export interface OperationMetadata { // 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 } 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 index deaf7254d6789..7127b4345f77b 100644 --- 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 @@ -40,6 +40,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` }, ] } + enableHistogramMode={false} id="Label D" key="0" splitSeriesAccessors={ @@ -48,6 +49,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` ] } stackAccessors={Array []} + timeZone="UTC" xAccessor="c" xScaleType="ordinal" yAccessors={ @@ -101,6 +103,7 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` }, ] } + enableHistogramMode={false} id="Label D" key="0" splitSeriesAccessors={ @@ -109,6 +112,7 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` ] } stackAccessors={Array []} + timeZone="UTC" xAccessor="c" xScaleType="ordinal" yAccessors={ @@ -162,6 +166,7 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` }, ] } + enableHistogramMode={false} id="Label D" key="0" splitSeriesAccessors={ @@ -170,6 +175,7 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` ] } stackAccessors={Array []} + timeZone="UTC" xAccessor="c" xScaleType="ordinal" yAccessors={ @@ -223,6 +229,7 @@ exports[`xy_expression XYChart component it renders line 1`] = ` }, ] } + enableHistogramMode={false} id="Label D" key="0" splitSeriesAccessors={ @@ -231,6 +238,7 @@ exports[`xy_expression XYChart component it renders line 1`] = ` ] } stackAccessors={Array []} + timeZone="UTC" xAccessor="c" xScaleType="ordinal" yAccessors={ @@ -284,6 +292,7 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` }, ] } + enableHistogramMode={false} id="Label D" key="0" splitSeriesAccessors={ @@ -296,6 +305,7 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` "c", ] } + timeZone="UTC" xAccessor="c" xScaleType="ordinal" yAccessors={ @@ -349,6 +359,7 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` }, ] } + enableHistogramMode={false} id="Label D" key="0" splitSeriesAccessors={ @@ -361,6 +372,7 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` "c", ] } + timeZone="UTC" xAccessor="c" xScaleType="ordinal" yAccessors={ @@ -414,6 +426,7 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = }, ] } + enableHistogramMode={false} id="Label D" key="0" splitSeriesAccessors={ @@ -426,6 +439,7 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = "c", ] } + timeZone="UTC" xAccessor="c" xScaleType="ordinal" yAccessors={ 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 index e27bcbf9c1d6f..b034e73ba914d 100644 --- 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 @@ -23,6 +23,9 @@ Object { "hide": Array [ false, ], + "isHistogram": Array [ + false, + ], "layerId": Array [ "first", ], @@ -38,6 +41,12 @@ Object { "xAccessor": Array [ "a", ], + "xScaleType": Array [ + "linear", + ], + "yScaleType": Array [ + "linear", + ], }, "function": "lens_xy_layer", "type": "function", 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 index 81098b0a420b2..6692fc17f05ac 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -4,20 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup } from 'src/core/public'; +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 { xyVisualization } from './xy_visualization'; import { renderersRegistry, functionsRegistry, } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; + +import { xyVisualization } from './xy_visualization'; import { InterpreterSetup, RenderFunction } from '../interpreter_types'; import { xyChart, getXyChartRenderer } from './xy_expression'; import { legendConfig, xConfig, layerConfig } from './types'; export interface XyVisualizationPluginSetupPlugins { interpreter: InterpreterSetup; + chrome: Chrome; // TODO this is a simulated NP plugin. // Once field formatters are actually migrated, the actual shim can be used fieldFormat: { @@ -25,17 +29,37 @@ export interface XyVisualizationPluginSetupPlugins { }; } +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, { interpreter, fieldFormat }: XyVisualizationPluginSetupPlugins) { + setup( + _core: CoreSetup | null, + { + interpreter, + fieldFormat: { formatFactory }, + chrome: { getUiSettingsClient }, + }: XyVisualizationPluginSetupPlugins + ) { interpreter.functionsRegistry.register(() => legendConfig); interpreter.functionsRegistry.register(() => xConfig); interpreter.functionsRegistry.register(() => layerConfig); interpreter.functionsRegistry.register(() => xyChart); interpreter.renderersRegistry.register( - () => getXyChartRenderer(fieldFormat.formatFactory) as RenderFunction + () => + getXyChartRenderer({ + formatFactory, + timeZone: getTimeZone(getUiSettingsClient()), + }) as RenderFunction ); return xyVisualization; @@ -55,5 +79,6 @@ export const xyVisualizationSetup = () => 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 index 2405b0a0d9ea1..e4e0e57b7926a 100644 --- 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 @@ -5,8 +5,9 @@ */ import { Ast } from '@kbn/interpreter/common'; +import { ScaleType } from '@elastic/charts'; import { State, LayerConfig } from './types'; -import { FramePublicAPI } from '../types'; +import { FramePublicAPI, OperationMetadata } from '../types'; function xyTitles(layer: LayerConfig, frame: FramePublicAPI) { const defaults = { @@ -55,12 +56,55 @@ export const toExpression = (state: State, frame: FramePublicAPI): Ast | null => }), }; - return buildExpression(stateWithValidAccessors, xyTitles(state.layers[0], frame), frame); + 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, + xyTitles(state.layers[0], frame), + metadata, + 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, { xTitle, yTitle }: { xTitle: string; yTitle: string }, + metadata: Record>, frame?: FramePublicAPI ): Ast => ({ type: 'expression', @@ -100,6 +144,16 @@ export const buildExpression = ( }); } + 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: [ @@ -113,6 +167,13 @@ export const buildExpression = ( 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, 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 index 2287ec3c3d1ef..c7c600172a2b9 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -131,6 +131,21 @@ export const layerConfig: ExpressionFunction< 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', @@ -166,6 +181,9 @@ export type LayerConfig = AxisConfig & { 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 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 index a3d8a3b3f0133..7eab6ce109e3d 100644 --- 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 @@ -10,7 +10,7 @@ import { xyChart, XYChart } from './xy_expression'; import { LensMultiTable } from '../types'; import React from 'react'; import { shallow } from 'enzyme'; -import { XYArgs, LegendConfig, legendConfig, layerConfig, LayerConfig } from './types'; +import { XYArgs, LegendConfig, legendConfig, layerConfig, LayerArgs } from './types'; function sampleArgs() { const data: LensMultiTable = { @@ -49,6 +49,9 @@ function sampleArgs() { title: 'A and B', splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, }, ], }; @@ -71,13 +74,16 @@ describe('xy_expression', () => { }); test('layerConfig produces the correct arguments', () => { - const args: LayerConfig = { + const args: LayerArgs = { layerId: 'first', seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], title: 'A and B', splitAccessor: 'd', + xScaleType: 'linear', + yScaleType: 'linear', + isHistogram: false, }; expect(layerConfig.fn(null, args, {})).toEqual({ @@ -117,6 +123,7 @@ describe('xy_expression', () => { data={data} args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'line' }] }} formatFactory={getFormatSpy} + timeZone="UTC" /> ); expect(component).toMatchSnapshot(); @@ -130,6 +137,7 @@ describe('xy_expression', () => { data={data} args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar' }] }} formatFactory={getFormatSpy} + timeZone="UTC" /> ); expect(component).toMatchSnapshot(); @@ -143,6 +151,7 @@ describe('xy_expression', () => { data={data} args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'area' }] }} formatFactory={getFormatSpy} + timeZone="UTC" /> ); expect(component).toMatchSnapshot(); @@ -156,6 +165,7 @@ describe('xy_expression', () => { data={data} args={{ ...args, isHorizontal: true, layers: [{ ...args.layers[0], seriesType: 'bar' }] }} formatFactory={getFormatSpy} + timeZone="UTC" /> ); expect(component).toMatchSnapshot(); @@ -170,6 +180,7 @@ describe('xy_expression', () => { data={data} args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar_stacked' }] }} formatFactory={getFormatSpy} + timeZone="UTC" /> ); expect(component).toMatchSnapshot(); @@ -184,6 +195,7 @@ describe('xy_expression', () => { data={data} args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'area_stacked' }] }} formatFactory={getFormatSpy} + timeZone="UTC" /> ); expect(component).toMatchSnapshot(); @@ -202,6 +214,7 @@ describe('xy_expression', () => { layers: [{ ...args.layers[0], seriesType: 'bar_stacked' }], }} formatFactory={getFormatSpy} + timeZone="UTC" /> ); expect(component).toMatchSnapshot(); @@ -210,10 +223,73 @@ describe('xy_expression', () => { 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(); + 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' }, @@ -223,39 +299,51 @@ describe('xy_expression', () => { test('it uses labels as Y accessors', () => { const { data, args } = sampleArgs(); - const component = shallow(); + const component = shallow( + + ); expect(component.find(LineSeries).prop('yAccessors')).toEqual(['Label A', 'Label B']); }); - test('it indicates an ordinal scale for a string X axis', () => { + test('it set the scale of the x axis according to the args prop', () => { const { data, args } = sampleArgs(); - const component = shallow(); + const component = shallow( + + ); expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); }); - test('it indicates a linear scale for a numeric X axis', () => { - const { args } = sampleArgs(); - - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: { - type: 'kibana_datatable', - columns: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }, { id: 'c', name: 'c' }], - rows: [{ a: 1, b: 2, c: 3 }, { a: 6, b: 5, c: 9 }], - }, - }, - }; + 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('xScaleType')).toEqual(ScaleType.Linear); + 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(); + shallow( + + ); expect(getFormatSpy).toHaveBeenCalledWith({ id: 'string' }); }); @@ -263,7 +351,14 @@ describe('xy_expression', () => { test('it gets a default formatter for y if there are multiple y accessors', () => { const { data, args } = sampleArgs(); - shallow(); + shallow( + + ); expect(getFormatSpy).toHaveBeenCalledTimes(2); expect(getFormatSpy).toHaveBeenCalledWith({ id: 'number' }); @@ -277,6 +372,7 @@ describe('xy_expression', () => { data={{ ...data }} args={{ ...args, layers: [{ ...args.layers[0], accessors: ['a'] }] }} formatFactory={getFormatSpy} + timeZone="UTC" /> ); expect(getFormatSpy).toHaveBeenCalledWith({ @@ -289,7 +385,12 @@ describe('xy_expression', () => { const { data, args } = sampleArgs(); const instance = shallow( - + ); const tickFormatter = instance 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 index 37680bbe8e3d9..0ab051bc15971 100644 --- 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 @@ -16,7 +16,6 @@ import { AreaSeries, BarSeries, Position, - ScaleType, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; @@ -86,7 +85,10 @@ export interface XYChartProps { args: XYArgs; } -export const getXyChartRenderer = (formatFactory: FormatFactory): RenderFunction => ({ +export const getXyChartRenderer = (dependencies: { + formatFactory: FormatFactory; + timeZone: string; +}): RenderFunction => ({ name: 'lens_xy_chart_renderer', displayName: 'XY Chart', help: 'X/Y Chart Renderer', @@ -95,7 +97,7 @@ export const getXyChartRenderer = (formatFactory: FormatFactory): RenderFunction render: async (domNode: Element, config: XYChartProps, _handlers: unknown) => { ReactDOM.render( - + , domNode ); @@ -110,8 +112,10 @@ export function XYChart({ data, args, formatFactory, + timeZone, }: XYChartProps & { formatFactory: FormatFactory; + timeZone: string; }) { const { legend, layers, isHorizontal } = args; @@ -179,7 +183,20 @@ export function XYChart({ /> {layers.map( - ({ splitAccessor, seriesType, accessors, xAccessor, layerId, columnToLabel }, index) => { + ( + { + splitAccessor, + seriesType, + accessors, + xAccessor, + layerId, + columnToLabel, + yScaleType, + xScaleType, + isHistogram, + }, + index + ) => { if (!data.tables[layerId] || data.tables[layerId].rows.length === 0) { return; } @@ -212,9 +229,10 @@ export function XYChart({ xAccessor, yAccessors, data: rows, - xScaleType: - typeof rows[0][xAccessor] === 'number' ? ScaleType.Linear : ScaleType.Ordinal, - yScaleType: ScaleType.Linear, + xScaleType, + yScaleType, + enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), + timeZone, }; return seriesType === 'line' ? ( 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 index 82ee5b6ae1b7d..7ec9f9a68ad83 100644 --- 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 @@ -12,6 +12,7 @@ import { VisualizationSuggestion, TableSuggestionColumn, TableSuggestion, + OperationMetadata, } from '../types'; import { State, SeriesType } from './types'; import { generateId } from '../id_generator'; @@ -140,6 +141,14 @@ function getSuggestion( ], }; + const metadata: Record = {}; + + [xValue, ...yValues, splitBy].forEach(col => { + if (col) { + metadata[col.columnId] = col.operation; + } + }); + 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 @@ -150,13 +159,16 @@ function getSuggestion( previewExpression: buildExpression( { ...state, - layers: state.layers.map(layer => ({ ...layer, hide: true })), + layers: state.layers + .filter(layer => layer.layerId === layerId) + .map(layer => ({ ...layer, hide: true })), legend: { ...state.legend, isVisible: false, }, }, - { xTitle, yTitle } + { xTitle, yTitle }, + { [layerId]: 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 index 1ee0e86f5b6b9..5fb41780838b6 100644 --- 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 @@ -95,8 +95,15 @@ describe('xy_visualization', () => { 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}` } as Operation; + return { label: `col_${col}`, dataType: 'number' } as Operation; }); frame.datasourceLayers = { From 54d9d41b9fb8bcb665d1be27bce8ee835159a16f Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 20 Aug 2019 07:38:00 -0400 Subject: [PATCH 082/105] [lens] Allow updater function to be used for updating state (#43373) --- .../editor_frame/data_panel_wrapper.tsx | 7 ++++--- .../editor_frame/editor_frame.tsx | 4 ++-- .../editor_frame/state_management.test.ts | 21 +++++++++---------- .../editor_frame/state_management.ts | 7 +++++-- .../dimension_panel/dimension_panel.tsx | 4 ++-- .../indexpattern_plugin/indexpattern.tsx | 7 ++----- .../public/indexpattern_plugin/layerpanel.tsx | 4 ++-- .../public/indexpattern_plugin/operations.ts | 4 ++-- x-pack/legacy/plugins/lens/public/types.ts | 6 ++++-- 9 files changed, 33 insertions(+), 31 deletions(-) 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 index d0a17c75e1b51..a013d9b1bceae 100644 --- 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 @@ -11,6 +11,7 @@ import { DatasourceDataPanelProps, Datasource } from '../../../public'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; import { DragContext } from '../../drag_drop'; +import { StateSetter } from '../../types'; interface DataPanelWrapperProps { datasourceState: unknown; @@ -21,11 +22,11 @@ interface DataPanelWrapperProps { } export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { - const setDatasourceState = useMemo( - () => (newState: unknown) => { + const setDatasourceState: StateSetter = useMemo( + () => updater => { props.dispatch({ type: 'UPDATE_DATASOURCE_STATE', - newState, + updater, datasourceId: props.activeDatasource!, }); }, 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 index 823852eed6133..927a1f1abf3dc 100644 --- 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 @@ -59,7 +59,7 @@ export function EditorFrame(props: EditorFrameProps) { .then(datasourceState => { dispatch({ type: 'UPDATE_DATASOURCE_STATE', - newState: datasourceState, + updater: datasourceState, datasourceId, }); }) @@ -84,7 +84,7 @@ export function EditorFrame(props: EditorFrameProps) { dispatch({ type: 'UPDATE_DATASOURCE_STATE', datasourceId: id, - newState, + updater: newState, }); }, layer 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 index 8320a79195442..a4a58b5398132 100644 --- 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 @@ -123,8 +123,8 @@ describe('editor_frame state management', () => { expect(newState.visualization.state).toBe(newVisState); }); - it('should update the datasource state on update', () => { - const newDatasourceState = {}; + it('should update the datasource state with passed in reducer', () => { + const datasourceReducer = jest.fn(() => ({ changed: true })); const newState = reducer( { datasourceStates: { @@ -142,16 +142,17 @@ describe('editor_frame state management', () => { }, { type: 'UPDATE_DATASOURCE_STATE', - newState: newDatasourceState, + updater: datasourceReducer, datasourceId: 'testDatasource', } ); - expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState); + expect(newState.datasourceStates.testDatasource.state).toEqual({ changed: true }); + expect(datasourceReducer).toHaveBeenCalledTimes(1); }); - it('should update the datasource state with passed in reducer', () => { - const layerReducer = jest.fn((_state, layerId) => ({ inserted: layerId })); + it('should update the layer state with passed in reducer', () => { + const newDatasourceState = {}; const newState = reducer( { datasourceStates: { @@ -168,15 +169,13 @@ describe('editor_frame state management', () => { }, }, { - type: 'UPDATE_LAYER', - layerId: 'abc', - updater: layerReducer, + type: 'UPDATE_DATASOURCE_STATE', + updater: newDatasourceState, datasourceId: 'testDatasource', } ); - expect(newState.datasourceStates.testDatasource.state).toEqual({ inserted: 'abc' }); - expect(layerReducer).toHaveBeenCalledTimes(1); + expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState); }); it('should should switch active visualization', () => { 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 index 60e053bf19133..27f315463f175 100644 --- 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 @@ -30,7 +30,7 @@ export type Action = } | { type: 'UPDATE_DATASOURCE_STATE'; - newState: unknown; + updater: unknown | ((prevState: unknown) => unknown); datasourceId: string; } | { @@ -183,7 +183,10 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta datasourceStates: { ...state.datasourceStates, [action.datasourceId]: { - state: action.newState, + state: + typeof action.updater === 'function' + ? action.updater(state.datasourceStates[action.datasourceId].state) + : action.updater, isLoading: false, }, }, 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 index 689e5a0f52c26..f6d80b38c6ab9 100644 --- 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 @@ -10,7 +10,7 @@ import { EuiButtonIcon } from '@elastic/eui'; import { Storage } from 'ui/storage'; import { i18n } from '@kbn/i18n'; import { UiSettingsClientContract } from 'src/core/public'; -import { DatasourceDimensionPanelProps } from '../../types'; +import { DatasourceDimensionPanelProps, StateSetter } from '../../types'; import { IndexPatternColumn, IndexPatternPrivateState, @@ -26,7 +26,7 @@ import { isDraggedField } from '../utils'; export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { state: IndexPatternPrivateState; - setState: (newState: IndexPatternPrivateState) => void; + setState: StateSetter; dragDropContext: DragContextState; uiSettings: UiSettingsClientContract; storage: Storage; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 16202ec50f28d..047cc09f683eb 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -302,7 +302,7 @@ export function getIndexPatternDatasource({ setState(newState)} + setState={setState} uiSettings={uiSettings} storage={storage} layerId={props.layerId} @@ -314,10 +314,7 @@ export function getIndexPatternDatasource({ }, renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => { - render( - setState(newState)} {...props} />, - domElement - ); + render(, domElement); }, removeColumnInTableSpec: (columnId: string) => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.tsx index 27b128eb35ef2..c221371dd6aa2 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.tsx @@ -18,13 +18,13 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; -import { DatasourceLayerPanelProps } from '../types'; +import { DatasourceLayerPanelProps, StateSetter } from '../types'; import { IndexPatternPrivateState, IndexPatternLayer } from './indexpattern'; import { isLayerTransferable, updateLayerIndexPattern } from './state_helpers'; export interface IndexPatternLayerPanelProps extends DatasourceLayerPanelProps { state: IndexPatternPrivateState; - setState: (newState: IndexPatternPrivateState) => void; + setState: StateSetter; } function LayerPanelChooser({ diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index e7110364020d1..ea36b8243e911 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import { Storage } from 'ui/storage'; import { UiSettingsClientContract } from 'src/core/public'; -import { DimensionPriority, OperationMetadata } from '../types'; +import { DimensionPriority, OperationMetadata, StateSetter } from '../types'; import { IndexPatternColumn, IndexPatternField, @@ -60,7 +60,7 @@ export function getOperations(): OperationType[] { export interface ParamEditorProps { state: IndexPatternPrivateState; - setState: (newState: IndexPatternPrivateState) => void; + setState: StateSetter; columnId: string; layerId: string; uiSettings: UiSettingsClientContract; diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 3f448f00a8d75..3b39558094529 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -63,6 +63,8 @@ export interface DatasourceMetaData { filterableIndexPatterns: Array<{ id: string; title: string }>; } +export type StateSetter = (newState: T | ((prevState: T) => T)) => void; + /** * Interface for the datasource registry */ @@ -88,7 +90,7 @@ export interface Datasource { getDatasourceSuggestionsForField: (state: T, field: unknown) => Array>; getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; - getPublicAPI: (state: T, setState: (newState: T) => void, layerId: string) => DatasourcePublicAPI; + getPublicAPI: (state: T, setState: StateSetter, layerId: string) => DatasourcePublicAPI; } /** @@ -118,7 +120,7 @@ export type TableSpec = TableSpecColumn[]; export interface DatasourceDataPanelProps { state: T; dragDropContext: DragContextState; - setState: (newState: T) => void; + setState: StateSetter; } // The only way a visualization has to restrict the query building From 2401b4f3350d08944f6f19339bcda92b4720a5a5 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Tue, 20 Aug 2019 15:46:58 -0400 Subject: [PATCH 083/105] [Lens] Lens metric visualization (#39364) --- .../plugins/lens/public/app_plugin/plugin.tsx | 11 +- .../visualization.test.tsx | 1 + .../editor_frame/chart_switch.test.tsx | 4 + .../indexpattern_plugin/datapanel.test.tsx | 4 + .../dimension_panel/dimension_panel.test.tsx | 10 ++ ...xpattern.test.tsx => indexpattern.test.ts} | 5 + .../indexpattern_plugin/indexpattern.tsx | 3 +- .../indexpattern_suggestions.test.tsx | 13 ++ ...stions.tsx => indexpattern_suggestions.ts} | 15 ++- .../indexpattern_plugin/layerpanel.test.tsx | 2 + .../operation_definitions/count.tsx | 2 + .../date_histogram.test.tsx | 4 + .../operation_definitions/date_histogram.tsx | 2 + .../filter_ratio.test.tsx | 1 + .../operation_definitions/filter_ratio.tsx | 2 + .../operation_definitions/metrics.tsx | 2 + .../operation_definitions/terms.test.tsx | 11 ++ .../operation_definitions/terms.tsx | 2 + .../indexpattern_plugin/operations.test.ts | 4 + .../indexpattern_plugin/state_helpers.test.ts | 29 ++++ .../auto_scale.test.tsx | 61 +++++++++ .../auto_scale.tsx | 119 +++++++++++++++++ .../metric_visualization_plugin/index.ts | 7 + .../metric_config_panel.test.tsx | 74 +++++++++++ .../metric_config_panel.tsx | 46 +++++++ .../metric_expression.test.tsx | 91 +++++++++++++ .../metric_expression.tsx | 118 +++++++++++++++++ .../metric_suggestions.test.ts | 125 ++++++++++++++++++ .../metric_suggestions.ts | 54 ++++++++ .../metric_visualization.test.ts | 100 ++++++++++++++ .../metric_visualization.tsx | 78 +++++++++++ .../metric_visualization_plugin/plugin.tsx | 84 ++++++++++++ .../metric_visualization_plugin/types.ts | 16 +++ .../multi_column_editor.test.tsx | 1 + .../lens/public/register_embeddable.ts | 2 + x-pack/legacy/plugins/lens/public/types.ts | 1 + .../public/xy_visualization_plugin/types.ts | 12 +- .../xy_config_panel.test.tsx | 2 + .../xy_config_panel.tsx | 2 +- .../xy_visualization_plugin/xy_expression.tsx | 13 +- .../xy_suggestions.test.ts | 4 + 41 files changed, 1118 insertions(+), 19 deletions(-) rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{indexpattern.test.tsx => indexpattern.test.ts} (98%) rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{indexpattern_suggestions.tsx => indexpattern_suggestions.ts} (97%) create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.tsx create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/index.ts create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.tsx create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.test.tsx create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.tsx create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.test.ts create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.ts create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/plugin.tsx create mode 100644 x-pack/legacy/plugins/lens/public/metric_visualization_plugin/types.ts diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index 02e14bde5be94..07bd55cbd4e93 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -7,12 +7,13 @@ import React from 'react'; import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; import { HashRouter, Switch, Route, RouteComponentProps } from 'react-router-dom'; -import chrome, { Chrome } from 'ui/chrome'; +import chrome from 'ui/chrome'; import { Storage } from 'ui/storage'; import { editorFrameSetup, 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, @@ -22,7 +23,6 @@ import { EditorFrameInstance } from '../types'; export class AppPlugin { private instance: EditorFrameInstance | null = null; - private chrome: Chrome | null = null; constructor() {} @@ -32,14 +32,14 @@ export class AppPlugin { const indexPattern = indexPatternDatasourceSetup(); const datatableVisualization = datatableVisualizationSetup(); const xyVisualization = xyVisualizationSetup(); + const metricVisualization = metricVisualizationSetup(); const editorFrame = editorFrameSetup(); + const store = new SavedObjectIndexStore(chrome!.getSavedObjectsClient()); editorFrame.registerDatasource('indexpattern', indexPattern); editorFrame.registerVisualization(xyVisualization); editorFrame.registerVisualization(datatableVisualization); - - this.chrome = chrome; - const store = new SavedObjectIndexStore(this.chrome!.getSavedObjectsClient()); + editorFrame.registerVisualization(metricVisualization); this.instance = editorFrame.createInstance({}); @@ -87,6 +87,7 @@ export class AppPlugin { // TODO this will be handled by the plugin platform itself indexPatternDatasourceStop(); xyVisualizationStop(); + metricVisualizationStop(); datatableVisualizationStop(); editorFrameStop(); } 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 index 177dfc9577028..39fcce7a2a90c 100644 --- 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 @@ -97,6 +97,7 @@ describe('Datatable Visualization', () => { const baseOperation: Operation = { dataType: 'string', isBucketed: true, + isMetric: false, label: '', }; expect(filterOperations({ ...baseOperation })).toEqual(true); 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 index a3d9f02c9def3..4413d3b0c474b 100644 --- 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 @@ -205,6 +205,7 @@ describe('chart_switch', () => { label: '', dataType: 'string', isBucketed: true, + isMetric: false, }, }, { @@ -213,6 +214,7 @@ describe('chart_switch', () => { label: '', dataType: 'number', isBucketed: false, + isMetric: true, }, }, ], @@ -433,6 +435,7 @@ describe('chart_switch', () => { label: '', dataType: 'string', isBucketed: true, + isMetric: false, }, }, { @@ -441,6 +444,7 @@ describe('chart_switch', () => { label: '', dataType: 'number', isBucketed: false, + isMetric: true, }, }, ], 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 index dfd4adde48560..7f74fd8c1f0b8 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -27,6 +27,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'string', isBucketed: true, + isMetric: false, operationType: 'terms', sourceField: 'source', params: { @@ -41,6 +42,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, + isMetric: true, operationType: 'avg', sourceField: 'memory', }, @@ -54,6 +56,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'string', isBucketed: true, + isMetric: false, operationType: 'terms', sourceField: 'source', params: { @@ -68,6 +71,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, + isMetric: true, operationType: 'avg', sourceField: 'bytes', }, 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 index 2ddfce6b7e0a5..f45b674e0c19b 100644 --- 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 @@ -89,6 +89,7 @@ describe('IndexPatternDimensionPanel', () => { label: 'Date Histogram of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -202,6 +203,7 @@ describe('IndexPatternDimensionPanel', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'max', @@ -243,6 +245,7 @@ describe('IndexPatternDimensionPanel', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'max', @@ -284,6 +287,7 @@ describe('IndexPatternDimensionPanel', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'max', @@ -368,6 +372,7 @@ describe('IndexPatternDimensionPanel', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'max', @@ -541,6 +546,7 @@ describe('IndexPatternDimensionPanel', () => { col2: { dataType: 'number', isBucketed: false, + isMetric: true, label: '', operationType: 'avg', sourceField: 'bytes', @@ -578,6 +584,7 @@ describe('IndexPatternDimensionPanel', () => { col2: { dataType: 'number', isBucketed: false, + isMetric: true, label: '', operationType: 'count', }, @@ -772,6 +779,7 @@ describe('IndexPatternDimensionPanel', () => { col2: { dataType: 'number', isBucketed: false, + isMetric: true, label: '', operationType: 'count', }, @@ -854,6 +862,7 @@ describe('IndexPatternDimensionPanel', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'max', @@ -956,6 +965,7 @@ describe('IndexPatternDimensionPanel', () => { label: 'Date Histogram of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts similarity index 98% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts index 336deef6147a3..8307b8e0ab828 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts @@ -153,6 +153,7 @@ describe('IndexPattern Data Source', () => { label: 'My Op', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -214,6 +215,7 @@ describe('IndexPattern Data Source', () => { label: 'Count of Documents', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'count', @@ -222,6 +224,7 @@ describe('IndexPattern Data Source', () => { label: 'Date', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -391,6 +394,7 @@ describe('IndexPattern Data Source', () => { const sampleColumn: IndexPatternColumn = { dataType: 'number', isBucketed: false, + isMetric: true, label: 'foo', operationType: 'max', sourceField: 'baz', @@ -441,6 +445,7 @@ describe('IndexPattern Data Source', () => { label: 'My Op', dataType: 'string', isBucketed: true, + isMetric: false, } as Operation); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 047cc09f683eb..388d4de25a792 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -143,12 +143,13 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { }; export function columnToOperation(column: IndexPatternColumn): Operation { - const { dataType, label, isBucketed, scale } = column; + const { dataType, label, isBucketed, isMetric, scale } = column; return { label, dataType, isBucketed, scale, + isMetric, }; } 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 index d86d88ed6ff02..37fee8f279d7a 100644 --- 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 @@ -153,6 +153,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -529,6 +530,7 @@ describe('IndexPattern Data Source suggestions', () => { col1: { dataType: 'string', isBucketed: true, + isMetric: false, sourceField: 'source', label: 'values of source', operationType: 'terms', @@ -541,6 +543,7 @@ describe('IndexPattern Data Source suggestions', () => { col2: { dataType: 'number', isBucketed: false, + isMetric: true, sourceField: 'bytes', label: 'Min of bytes', operationType: 'min', @@ -564,6 +567,7 @@ describe('IndexPattern Data Source suggestions', () => { col1: { dataType: 'date', isBucketed: true, + isMetric: false, sourceField: 'timestamp', label: 'date histogram of timestamp', operationType: 'date_histogram', @@ -574,6 +578,7 @@ describe('IndexPattern Data Source suggestions', () => { col2: { dataType: 'number', isBucketed: false, + isMetric: true, sourceField: 'bytes', label: 'Min of bytes', operationType: 'min', @@ -884,6 +889,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op 2', dataType: 'number', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -910,6 +916,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'string', isBucketed: true, + isMetric: false, }, }, ], @@ -927,6 +934,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op 2', dataType: 'number', isBucketed: true, + isMetric: false, }, }, ], @@ -947,6 +955,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'string', isBucketed: true, + isMetric: false, operationType: 'terms', sourceField: 'field1', @@ -960,6 +969,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'string', isBucketed: true, + isMetric: false, operationType: 'terms', sourceField: 'field2', @@ -973,6 +983,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'string', isBucketed: true, + isMetric: false, operationType: 'terms', sourceField: 'field3', @@ -986,6 +997,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'number', isBucketed: false, + isMetric: true, operationType: 'avg', sourceField: 'field4', @@ -994,6 +1006,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'number', isBucketed: false, + isMetric: true, operationType: 'min', sourceField: 'field5', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts similarity index 97% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.tsx rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts index 1b5007cbe5558..a3a04802b0a31 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts @@ -31,7 +31,12 @@ function buildSuggestion({ datasourceSuggestionId?: number; }) { const columnOrder = (updatedLayer || state.layers[layerId]).columnOrder; - const columns = (updatedLayer || state.layers[layerId]).columns; + const columnMap = (updatedLayer || state.layers[layerId]).columns; + const columns = columnOrder.map(columnId => ({ + columnId, + operation: columnToOperation(columnMap[columnId]), + })); + return { state: updatedLayer ? { @@ -44,11 +49,8 @@ function buildSuggestion({ : state, table: { - columns: columnOrder.map(columnId => ({ - columnId, - operation: columnToOperation(columns[columnId]), - })), - isMultiRow: isMultiRow || true, + columns, + isMultiRow: isMultiRow || columns.some(col => !columnMap[col.columnId].isMetric), datasourceSuggestionId: datasourceSuggestionId || 0, layerId, }, @@ -100,6 +102,7 @@ function getExistingLayerSuggestionsForField( } else if (!usableAsBucketOperation && operations.length > 0) { updatedLayer = addFieldAsMetricOperation(layer, layerId, indexPattern, field); } + return updatedLayer ? [ buildSuggestion({ 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 index 46e381d69741b..0faa6b4725896 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx @@ -27,6 +27,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'string', isBucketed: true, + isMetric: false, operationType: 'terms', sourceField: 'source', params: { @@ -41,6 +42,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, + isMetric: true, operationType: 'avg', sourceField: 'memory', }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx index 0cb4838faa12c..304999ca8f5bc 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx @@ -19,6 +19,7 @@ export const countOperation: OperationDefinition = { { dataType: 'number', isBucketed: false, + isMetric: true, scale: 'ratio', }, ]; @@ -32,6 +33,7 @@ export const countOperation: OperationDefinition = { operationType: 'count', suggestedPriority, isBucketed: false, + isMetric: true, scale: 'ratio', }; }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx index 8e94087f4a5fb..299d5ca8250c7 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx @@ -58,6 +58,7 @@ describe('date_histogram', () => { label: 'Value of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -76,6 +77,7 @@ describe('date_histogram', () => { label: 'Value of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -155,6 +157,7 @@ describe('date_histogram', () => { { dataType: 'date', isBucketed: true, + isMetric: false, label: '', operationType: 'date_histogram', sourceField: 'dateField', @@ -197,6 +200,7 @@ describe('date_histogram', () => { { dataType: 'date', isBucketed: true, + isMetric: false, label: '', operationType: 'date_histogram', sourceField: 'dateField', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx index e454c700bb8db..8f0b0ff0393d9 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx @@ -50,6 +50,7 @@ export const dateHistogramOperation: OperationDefinition { label: 'Filter Ratio', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'filter_ratio', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx index 3f4e7c3dc407d..7caada3c9f7a5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx @@ -27,6 +27,7 @@ export const filterRatioOperation: OperationDefinition( { dataType: 'number', isBucketed: false, + isMetric: true, scale: 'ratio', }, ]; @@ -60,6 +61,7 @@ function buildMetricOperation( suggestedPriority, sourceField: field ? field.name : '', isBucketed: false, + isMetric: true, scale: 'ratio', } as T; }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx index 1f639907b79d0..def66db01a7fe 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx @@ -31,6 +31,7 @@ describe('terms', () => { label: 'Top value of category', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -45,6 +46,7 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'count', @@ -91,6 +93,7 @@ describe('terms', () => { { dataType: 'string', isBucketed: true, + isMetric: false, scale: 'ordinal', }, ]); @@ -106,6 +109,7 @@ describe('terms', () => { { dataType: 'boolean', isBucketed: true, + isMetric: false, scale: 'ordinal', }, ]); @@ -158,6 +162,7 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'count', @@ -184,6 +189,7 @@ describe('terms', () => { label: 'Top value of category', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -199,6 +205,7 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'count', @@ -213,6 +220,7 @@ describe('terms', () => { label: 'Top value of category', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -238,6 +246,7 @@ describe('terms', () => { label: 'Top value of category', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -253,6 +262,7 @@ describe('terms', () => { label: 'Value of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -310,6 +320,7 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'filter_ratio', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx index ca0455c9372da..a11b49f6ab784 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -53,6 +53,7 @@ export const termsOperation: OperationDefinition = { { dataType: type, isBucketed: true, + isMetric: false, scale: 'ordinal', }, ]; @@ -85,6 +86,7 @@ export const termsOperation: OperationDefinition = { suggestedPriority, sourceField: field.name, isBucketed: true, + isMetric: false, params: { size: DEFAULT_SIZE, orderBy: existingMetricColumn diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts index d4353f52c4a67..49719c35a09c2 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -163,6 +163,7 @@ describe('getOperationTypesForField', () => { label: 'Date Histogram of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -234,6 +235,7 @@ describe('getOperationTypesForField', () => { "operationMetaData": Object { "dataType": "string", "isBucketed": true, + "isMetric": false, "scale": "ordinal", }, "operations": Array [ @@ -248,6 +250,7 @@ describe('getOperationTypesForField', () => { "operationMetaData": Object { "dataType": "date", "isBucketed": true, + "isMetric": false, "scale": "interval", }, "operations": Array [ @@ -262,6 +265,7 @@ describe('getOperationTypesForField', () => { "operationMetaData": Object { "dataType": "number", "isBucketed": false, + "isMetric": true, "scale": "ratio", }, "operations": Array [ 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 index e44dea4340777..897af8bbc28ff 100644 --- 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 @@ -31,6 +31,7 @@ describe('state_helpers', () => { label: 'Top values of source', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -55,6 +56,7 @@ describe('state_helpers', () => { label: 'Count', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'count', @@ -76,6 +78,7 @@ describe('state_helpers', () => { label: 'Top values of source', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -100,6 +103,7 @@ describe('state_helpers', () => { label: 'Count', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'count', @@ -127,6 +131,7 @@ describe('state_helpers', () => { label: 'Value of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -173,6 +178,7 @@ describe('state_helpers', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'avg', @@ -182,6 +188,7 @@ describe('state_helpers', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'max', @@ -200,6 +207,7 @@ describe('state_helpers', () => { label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -232,6 +240,7 @@ describe('state_helpers', () => { label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -253,6 +262,7 @@ describe('state_helpers', () => { label: 'Date histogram of order_date', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -274,6 +284,7 @@ describe('state_helpers', () => { label: 'Top values of source', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -289,6 +300,7 @@ describe('state_helpers', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'avg', @@ -308,6 +320,7 @@ describe('state_helpers', () => { label: 'Count', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'count', @@ -343,6 +356,7 @@ describe('state_helpers', () => { label: 'Value of timestamp', dataType: 'string', isBucketed: false, + isMetric: false, // Private operationType: 'date_histogram', @@ -362,6 +376,7 @@ describe('state_helpers', () => { label: 'Top Values of category', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -378,6 +393,7 @@ describe('state_helpers', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'avg', @@ -387,6 +403,7 @@ describe('state_helpers', () => { label: 'Date Histogram of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -406,6 +423,7 @@ describe('state_helpers', () => { label: 'Top Values of category', dataType: 'string', isBucketed: true, + isMetric: false, // Private operationType: 'terms', @@ -423,6 +441,7 @@ describe('state_helpers', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, + isMetric: true, // Private operationType: 'avg', @@ -433,6 +452,7 @@ describe('state_helpers', () => { label: 'Date Histogram of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -512,6 +532,7 @@ describe('state_helpers', () => { col1: { dataType: 'string', isBucketed: true, + isMetric: false, label: '', operationType: 'terms', sourceField: 'fieldA', @@ -524,6 +545,7 @@ describe('state_helpers', () => { col2: { dataType: 'number', isBucketed: false, + isMetric: true, label: '', operationType: 'avg', sourceField: 'xxx', @@ -545,6 +567,7 @@ describe('state_helpers', () => { col1: { dataType: 'string', isBucketed: true, + isMetric: false, label: '', operationType: 'date_histogram', sourceField: 'fieldC', @@ -555,6 +578,7 @@ describe('state_helpers', () => { col2: { dataType: 'number', isBucketed: false, + isMetric: true, label: '', operationType: 'avg', sourceField: 'fieldB', @@ -576,6 +600,7 @@ describe('state_helpers', () => { col1: { dataType: 'date', isBucketed: true, + isMetric: false, label: '', operationType: 'date_histogram', sourceField: 'fieldD', @@ -606,6 +631,7 @@ describe('state_helpers', () => { col1: { dataType: 'string', isBucketed: true, + isMetric: false, label: '', operationType: 'terms', sourceField: 'fieldA', @@ -618,6 +644,7 @@ describe('state_helpers', () => { col2: { dataType: 'number', isBucketed: false, + isMetric: true, label: '', operationType: 'avg', sourceField: 'fieldD', @@ -639,6 +666,7 @@ describe('state_helpers', () => { col1: { dataType: 'string', isBucketed: true, + isMetric: false, label: '', operationType: 'terms', sourceField: 'fieldA', @@ -651,6 +679,7 @@ describe('state_helpers', () => { col2: { dataType: 'number', isBucketed: false, + isMetric: true, label: '', operationType: 'min', sourceField: 'fieldC', 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..60008d1237d82 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.test.tsx @@ -0,0 +1,61 @@ +/* + * 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', () => { + expect(computeScale(mockElement(2000, 1000), mockElement(1000, 10000))).toBe(0.3); + }); + + 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..9ca58c1944803 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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[]; +} + +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); + + // 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 } = 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; +} + +/** + * 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 +) { + const MAX_SCALE = 1; + const MIN_SCALE = 0.3; + + 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)), MIN_SCALE); +} 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..e2c184a7a4803 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx @@ -0,0 +1,74 @@ +/* + * 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, + isMetric: false, + label: 'bar', + }; + const ops: Operation[] = [ + { ...exampleOperation, isMetric: true, dataType: 'number' }, + { ...exampleOperation, isMetric: false, dataType: 'number' }, + { ...exampleOperation, dataType: 'string' }, + { ...exampleOperation, isMetric: true, dataType: 'boolean' }, + { ...exampleOperation, isMetric: false, dataType: 'boolean' }, + { ...exampleOperation, dataType: 'date' }, + ]; + expect(columnId).toEqual('shazm'); + expect(ops.filter(filterOperations)).toEqual([ + { ...exampleOperation, isMetric: true, dataType: 'number' }, + { ...exampleOperation, isMetric: true, dataType: 'boolean' }, + ]); + }); +}); 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..e5d1d7dc731fc --- /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.isMetric; + +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..69d91d4c97fe1 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.test.tsx @@ -0,0 +1,91 @@ +/* + * 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'; + +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', + }; + + 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} />)) + .toMatchInlineSnapshot(` +
+ +
+ 10110 +
+
+ My fanci metric chart +
+
+
+ `); + }); + }); +}); 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..daff873feb18c --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.tsx @@ -0,0 +1,118 @@ +/* + * 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 { MetricConfig } from './types'; +import { LensMultiTable } from '../types'; +import { RenderFunction } from './plugin'; +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', + }, + }, + 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 +): RenderFunction => ({ + 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 } = 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}
+
{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..bf9d5ad4340f2 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.test.ts @@ -0,0 +1,125 @@ +/* + * 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 } from '..'; + +describe('metric_suggestions', () => { + function numCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'number', + label: `Avg ${columnId}`, + isBucketed: false, + isMetric: true, + }, + }; + } + + function strCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'string', + label: `Top 5 ${columnId}`, + isBucketed: true, + isMetric: false, + }, + }; + } + + function dateCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'date', + isBucketed: true, + isMetric: false, + 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( + getSuggestions({ + tables: [ + { columns: [dateCol('a')], datasourceSuggestionId: 0, isMultiRow: true, layerId: 'l1' }, + { + columns: [strCol('foo'), strCol('bar')], + datasourceSuggestionId: 1, + isMultiRow: true, + layerId: 'l1', + }, + { layerId: 'l1', datasourceSuggestionId: 2, isMultiRow: true, columns: [numCol('bar')] }, + { + columns: [unknownCol(), numCol('bar')], + datasourceSuggestionId: 3, + isMultiRow: true, + layerId: 'l1', + }, + { + columns: [numCol('bar'), numCol('baz')], + datasourceSuggestionId: 4, + isMultiRow: false, + layerId: 'l1', + }, + ], + }) + ).toEqual([]); + }); + + test('suggests a basic metric chart', () => { + const [suggestion, ...rest] = getSuggestions({ + tables: [ + { + columns: [numCol('bytes')], + datasourceSuggestionId: 0, + isMultiRow: false, + layerId: 'l1', + }, + ], + }); + + expect(rest).toHaveLength(0); + expect(suggestion).toMatchInlineSnapshot(` + Object { + "datasourceSuggestionId": 0, + "previewExpression": Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + "bytes", + ], + "title": Array [ + "", + ], + }, + "function": "lens_metric_chart", + "type": "function", + }, + ], + "type": "expression", + }, + "previewIcon": "visMetric", + "score": 1, + "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..85981be00c3a0 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.ts @@ -0,0 +1,54 @@ +/* + * 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( + opts: SuggestionRequest +): Array> { + return opts.tables + .filter( + ({ isMultiRow, columns }) => + // We only render metric charts for single-row queries. We require a single, numeric column. + !isMultiRow && columns.length === 1 && columns[0].operation.dataType === 'number' + ) + .map(table => getSuggestion(table)); +} + +function getSuggestion(table: TableSuggestion): VisualizationSuggestion { + const col = table.columns[0]; + const title = col.operation.label; + + return { + title, + score: 1, + datasourceSuggestionId: table.datasourceSuggestionId, + previewIcon: 'visMetric', + previewExpression: { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_metric_chart', + arguments: { + title: [''], + accessor: [col.columnId], + }, + }, + ], + }, + 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..fa68aa2c7122a --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts @@ -0,0 +1,100 @@ +/* + * 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, + isMetric: true, + 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..f8bfd15b49892 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/plugin.tsx @@ -0,0 +1,84 @@ +/* + * 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'; +import { FormatFactory, getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; +import { metricVisualization } from './metric_visualization'; +import { + renderersRegistry, + functionsRegistry, +} from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/public'; +import { metricChart, getMetricChartRenderer } from './metric_expression'; + +// 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 +interface RenderHandlers { + done: () => void; + onDestroy: (fn: () => void) => void; +} + +export interface RenderFunction { + name: string; + displayName: string; + help: string; + validate: () => void; + reuseDomNode: boolean; + render: (domNode: Element, data: T, handlers: RenderHandlers) => void; +} + +export interface InterpreterSetup { + renderersRegistry: Registry; + functionsRegistry: Registry< + ExpressionFunction, + ExpressionFunction + >; +} + +export interface MetricVisualizationPluginSetupPlugins { + interpreter: InterpreterSetup; + // 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, + { interpreter, fieldFormat }: MetricVisualizationPluginSetupPlugins + ) { + interpreter.functionsRegistry.register(() => metricChart); + + interpreter.renderersRegistry.register( + () => getMetricChartRenderer(fieldFormat.formatFactory) as RenderFunction + ); + + return metricVisualization; + } + + stop() {} +} + +const plugin = new MetricVisualizationPlugin(); + +export const metricVisualizationSetup = () => + plugin.setup(null, { + interpreter: { + renderersRegistry, + functionsRegistry, + }, + 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..89d41552639c4 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/types.ts @@ -0,0 +1,16 @@ +/* + * 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; +} + +export type PersistableState = State; 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 index 012c27d3ce3ff..08a94c2180ab9 100644 --- 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 @@ -49,6 +49,7 @@ describe('MultiColumnEditor', () => { dataType: 'number', id, isBucketed: true, + isMetric: false, label: 'BaaaZZZ!', }; }, diff --git a/x-pack/legacy/plugins/lens/public/register_embeddable.ts b/x-pack/legacy/plugins/lens/public/register_embeddable.ts index 0364b1a4eb65b..e488f8e3d9aa3 100644 --- a/x-pack/legacy/plugins/lens/public/register_embeddable.ts +++ b/x-pack/legacy/plugins/lens/public/register_embeddable.ts @@ -8,10 +8,12 @@ import { indexPatternDatasourceSetup } from './indexpattern_plugin'; import { xyVisualizationSetup } from './xy_visualization_plugin'; import { editorFrameSetup } 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(); diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 3b39558094529..8a71f36ea2f92 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -160,6 +160,7 @@ export interface OperationMetadata { // A bucketed operation is grouped by duplicate values, otherwise each row is // treated as unique isBucketed: boolean; + isMetric: boolean; scale?: 'ordinal' | 'interval' | 'ratio'; // Extra meta-information like cardinality, color } 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 index c7c600172a2b9..ee51f9ce5fa8e 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -35,12 +35,16 @@ export const legendConfig: ExpressionFunction< args: { isVisible: { types: ['boolean'], - help: 'Specifies whether or not the legend is visible.', + 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: 'Specifies the legend position.', + help: i18n.translate('xpack.lens.xyChart.position.help', { + defaultMessage: 'Specifies the legend position.', + }), }, }, fn: function fn(_context: unknown, args: LegendConfig) { @@ -59,7 +63,9 @@ interface AxisConfig { const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = { title: { types: ['string'], - help: 'The axis title', + help: i18n.translate('xpack.lens.xyChart.title.help', { + defaultMessage: 'The axis title', + }), }, hide: { types: ['boolean'], 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 index 64ceddac2021d..cc4a9c80853bf 100644 --- 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 @@ -148,6 +148,7 @@ describe('XYConfigPanel', () => { const exampleOperation: Operation = { dataType: 'number', isBucketed: false, + isMetric: true, label: 'bar', }; const bucketedOps: Operation[] = [ @@ -186,6 +187,7 @@ describe('XYConfigPanel', () => { const exampleOperation: Operation = { dataType: 'number', isBucketed: false, + isMetric: true, label: 'bar', }; const ops: Operation[] = [ 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 index 249ea6b5b72ea..835b2d9c0bccb 100644 --- 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 @@ -28,7 +28,7 @@ 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 isNumericMetric = (op: OperationMetadata) => op.isMetric && op.dataType === 'number'; const isBucketed = (op: OperationMetadata) => op.isBucketed; type UnwrapArray = T extends Array ? P : T; 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 index 0ab051bc15971..3d7af67e9483a 100644 --- 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 @@ -21,6 +21,7 @@ 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 { LensMultiTable } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; @@ -40,7 +41,9 @@ export interface XYRender { export const xyChart: ExpressionFunction<'lens_xy_chart', LensMultiTable, XYArgs, XYRender> = ({ name: 'lens_xy_chart', type: 'render', - help: 'An X/Y chart', + help: i18n.translate('xpack.lens.xyChart.help', { + defaultMessage: 'An X/Y chart', + }), args: { xTitle: { types: ['string'], @@ -52,7 +55,9 @@ export const xyChart: ExpressionFunction<'lens_xy_chart', LensMultiTable, XYArgs }, legend: { types: ['lens_xy_legendConfig'], - help: 'Configure the chart legend.', + help: i18n.translate('xpack.lens.xyChart.legend.help', { + defaultMessage: 'Configure the chart legend.', + }), }, layers: { types: ['lens_xy_layer'], @@ -91,7 +96,9 @@ export const getXyChartRenderer = (dependencies: { }): RenderFunction => ({ name: 'lens_xy_chart_renderer', displayName: 'XY Chart', - help: 'X/Y Chart Renderer', + help: i18n.translate('xpack.lens.xyChart.renderer.help', { + defaultMessage: 'X/Y Chart Renderer', + }), validate: () => {}, reuseDomNode: true, render: async (domNode: Element, config: XYChartProps, _handlers: unknown) => { 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 index 4005a51280595..d3422138fec65 100644 --- 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 @@ -20,6 +20,7 @@ describe('xy_suggestions', () => { dataType: 'number', label: `Avg ${columnId}`, isBucketed: false, + isMetric: true, }, }; } @@ -31,6 +32,7 @@ describe('xy_suggestions', () => { dataType: 'string', label: `Top 5 ${columnId}`, isBucketed: true, + isMetric: false, }, }; } @@ -41,6 +43,7 @@ describe('xy_suggestions', () => { operation: { dataType: 'date', isBucketed: true, + isMetric: false, label: `${columnId} histogram`, }, }; @@ -258,6 +261,7 @@ describe('xy_suggestions', () => { columnId: 'mybool', operation: { dataType: 'boolean', + isMetric: false, isBucketed: false, label: 'Yes / No', }, From ea662ec30567b15c1b84d902f5f148d22907b117 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Fri, 23 Aug 2019 10:26:30 +0200 Subject: [PATCH 084/105] Fix axis rotation (#43792) --- .../__snapshots__/xy_expression.test.tsx.snap | 8 ++++---- .../lens/public/xy_visualization_plugin/xy_expression.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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 index 7127b4345f77b..b6cd37a9f7568 100644 --- 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 @@ -139,14 +139,14 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` /> Date: Fri, 23 Aug 2019 11:55:35 +0200 Subject: [PATCH 085/105] [Lens] Auto date histogram (#43775) * Add auto date histogram * Improve documentation and cleanup * Add tests * Change test name --- .../indexpattern_plugin/__mocks__/loader.ts | 119 +-------------- .../dimension_panel/dimension_panel.tsx | 47 ++++-- .../dimension_panel/popover_editor.tsx | 49 +++++-- .../lens/public/indexpattern_plugin/mocks.ts | 117 +++++++++++++++ .../operation_definitions/count.tsx | 10 +- .../date_histogram.test.tsx | 120 ++++++++++++++- .../operation_definitions/date_histogram.tsx | 138 +++++++++++------- .../filter_ratio.test.tsx | 2 + .../operation_definitions/filter_ratio.tsx | 10 +- .../operation_definitions/metrics.tsx | 7 + .../operation_definitions/terms.test.tsx | 31 ++++ .../operation_definitions/terms.tsx | 9 +- .../public/indexpattern_plugin/operations.ts | 19 +++ .../indexpattern_plugin/state_helpers.ts | 4 +- 14 files changed, 483 insertions(+), 199 deletions(-) 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 index cf7012650b318..87a6dd522ec63 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts @@ -4,123 +4,10 @@ * 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([ - { - 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, - }, - ], - }, - { - 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', - }, - }, - }, - }, - }, - ]); + resolve([createMockedIndexPattern(), createMockedRestrictedIndexPattern()]); }); } 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 index f6d80b38c6ab9..5e917aee7ae9f 100644 --- 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 @@ -18,11 +18,16 @@ import { OperationType, } from '../indexpattern'; -import { getAvailableOperationsByMetadata, buildColumn } from '../operations'; +import { + getAvailableOperationsByMetadata, + buildColumn, + operationDefinitionMap, + OperationDefinition, +} from '../operations'; import { PopoverEditor } from './popover_editor'; import { DragContextState, ChildDragDropProvider, DragDrop } from '../../drag_drop'; import { changeColumn, deleteColumn } from '../state_helpers'; -import { isDraggedField } from '../utils'; +import { isDraggedField, hasField } from '../utils'; export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { state: IndexPatternPrivateState; @@ -109,18 +114,42 @@ export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPan return; } - props.setState( - changeColumn({ - state: props.state, - layerId, - columnId: props.columnId, - newColumn: buildColumn({ + 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 + ? (operationDefinitionMap[selectedColumn.operationType] as OperationDefinition< + IndexPatternColumn + >).onFieldChange(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, }) ); }} 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 index ebf87fb1bdebb..4b476936f1f76 100644 --- 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 @@ -28,7 +28,12 @@ import { IndexPatternField, } from '../indexpattern'; import { IndexPatternDimensionPanelProps, OperationFieldSupportMatrix } from './dimension_panel'; -import { operationDefinitionMap, getOperationDisplay, buildColumn } from '../operations'; +import { + operationDefinitionMap, + getOperationDisplay, + buildColumn, + OperationDefinition, +} from '../operations'; import { deleteColumn, changeColumn } from '../state_helpers'; import { FieldSelect } from './field_select'; import { hasField } from '../utils'; @@ -271,17 +276,36 @@ export function PopoverEditor(props: PopoverEditorProps) { ); }} onChoose={choice => { - const 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', - }); + 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. + const operation = operationDefinitionMap[ + choice.operationType + ] as OperationDefinition; + column = operation.onFieldChange( + 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({ @@ -289,6 +313,7 @@ export function PopoverEditor(props: PopoverEditorProps) { layerId, columnId, newColumn: column, + keepParams: false, }) ); setInvalidOperationType(null); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/mocks.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/mocks.ts index b24d53e0f552f..79d88136b5fa1 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/mocks.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/mocks.ts @@ -5,6 +5,123 @@ */ 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 { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx index 304999ca8f5bc..d751b944cebe2 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx @@ -8,6 +8,10 @@ import { i18n } from '@kbn/i18n'; import { CountIndexPatternColumn } from '../indexpattern'; import { OperationDefinition } from '../operations'; +const countLabel = i18n.translate('xpack.lens.indexPattern.countOf', { + defaultMessage: 'Count of documents', +}); + export const countOperation: OperationDefinition = { type: 'count', displayName: i18n.translate('xpack.lens.indexPattern.count', { @@ -26,9 +30,7 @@ export const countOperation: OperationDefinition = { }, buildColumn({ suggestedPriority }) { return { - label: i18n.translate('xpack.lens.indexPattern.countOf', { - defaultMessage: 'Count of documents', - }), + label: countLabel, dataType: 'number', operationType: 'count', suggestedPriority, @@ -37,6 +39,8 @@ export const countOperation: OperationDefinition = { scale: 'ratio', }; }, + // This cannot be called practically, since this is a fieldless operation + onFieldChange: oldColumn => ({ ...oldColumn }), toEsAggsConfig: (column, columnId) => ({ id: columnId, enabled: true, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx index 299d5ca8250c7..123a728f51b6f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx @@ -8,9 +8,10 @@ import React from 'react'; import { dateHistogramOperation } from './date_histogram'; import { shallow } from 'enzyme'; import { DateHistogramIndexPatternColumn, IndexPatternPrivateState } from '../indexpattern'; -import { EuiRange } from '@elastic/eui'; +import { EuiRange, EuiSwitch } from '@elastic/eui'; import { UiSettingsClientContract } from 'src/core/public'; import { Storage } from 'ui/storage'; +import { createMockedIndexPattern } from '../mocks'; jest.mock('ui/new_platform'); @@ -25,6 +26,7 @@ describe('date_histogram', () => { 1: { id: '1', title: 'Mock Indexpattern', + timeFieldName: 'timestamp', fields: [ { name: 'timestamp', @@ -88,16 +90,35 @@ describe('date_histogram', () => { }, }, }, + 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 default params', () => { + 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', @@ -106,6 +127,23 @@ describe('date_histogram', () => { 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'); }); @@ -114,6 +152,7 @@ describe('date_histogram', () => { columns: {}, suggestedPriority: 0, layerId: 'first', + indexPattern: createMockedIndexPattern(), field: { name: 'timestamp', type: 'date', @@ -151,6 +190,48 @@ describe('date_histogram', () => { }); }); + 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!( @@ -265,6 +346,41 @@ describe('date_histogram', () => { 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( diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx index 8f0b0ff0393d9..92cb474abe9d2 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx @@ -7,15 +7,16 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiForm, EuiFormRow, EuiRange } from '@elastic/eui'; -import { IndexPatternField, DateHistogramIndexPatternColumn } from '../indexpattern'; -import { DimensionPriority } from '../../types'; +import { EuiForm, EuiFormRow, EuiRange, EuiSwitch } from '@elastic/eui'; +import { DateHistogramIndexPatternColumn, IndexPattern } from '../indexpattern'; import { OperationDefinition } from '../operations'; import { updateColumnParam } from '../state_helpers'; 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< @@ -34,6 +35,10 @@ function ofName(name: string) { }); } +function supportsAutoInterval(fieldName: string, indexPattern: IndexPattern): boolean { + return indexPattern.timeFieldName ? indexPattern.timeFieldName === fieldName : false; +} + export const dateHistogramOperation: OperationDefinition = { type: 'date_histogram', displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { @@ -57,17 +62,11 @@ export const dateHistogramOperation: OperationDefinition { + 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, @@ -159,6 +174,8 @@ export const dateHistogramOperation: OperationDefinition) { + const interval = ev.target.checked ? defaultCustomInterval : autoInterval; + setState(updateColumnParam(state, layerId, column, 'interval', interval)); + } + return ( - - {intervalIsRestricted ? ( - - ) : ( - ({ - label: interval, - value: index, - }))} - onChange={(e: React.ChangeEvent) => - setState( - updateColumnParam( - state, - layerId, - column, - 'interval', - numericToInterval(Number(e.target.value)) - ) - ) - } - aria-label={i18n.translate('xpack.lens.indexPattern.dateHistogram.interval', { - defaultMessage: 'Level of detail', + {fieldAllowsAutoInterval && ( + + - )} - + + )} + {column.params.interval !== autoInterval && ( + + {intervalIsRestricted ? ( + + ) : ( + ({ + label: interval, + value: index, + }))} + onChange={(e: React.ChangeEvent) => + setState( + updateColumnParam( + state, + layerId, + column, + 'interval', + numericToInterval(Number(e.target.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/operation_definitions/filter_ratio.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx index 5300d7c69c6fb..51d8497cde1bf 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx @@ -12,6 +12,7 @@ import { FilterRatioIndexPatternColumn, IndexPatternPrivateState } from '../inde import { Storage } from 'ui/storage'; import { UiSettingsClientContract } from 'src/core/public'; import { QueryBarInput } from '../../../../../../../src/legacy/core_plugins/data/public/query'; +import { createMockedIndexPattern } from '../mocks'; jest.mock('ui/new_platform'); @@ -62,6 +63,7 @@ describe('filter_ratio', () => { layerId: 'first', columns: {}, suggestedPriority: undefined, + indexPattern: createMockedIndexPattern(), }); expect(column.params.numerator).toEqual({ query: '', language: 'kuery' }); expect(column.params.denominator).toEqual({ query: '', language: 'kuery' }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx index 7caada3c9f7a5..025bbf14b7e87 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx @@ -16,6 +16,10 @@ import { FilterRatioIndexPatternColumn } from '../indexpattern'; import { OperationDefinition } from '../operations'; import { updateColumnParam } from '../state_helpers'; +const filterRatioLabel = i18n.translate('xpack.lens.indexPattern.filterRatio', { + defaultMessage: 'Filter Ratio', +}); + export const filterRatioOperation: OperationDefinition = { type: 'filter_ratio', displayName: i18n.translate('xpack.lens.indexPattern.filterRatio', { @@ -34,9 +38,7 @@ export const filterRatioOperation: OperationDefinition ({ ...oldColumn }), toEsAggsConfig: (column, columnId) => ({ id: columnId, enabled: true, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx index 0e45411d90f1b..aadca0f1f7dbd 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx @@ -65,6 +65,13 @@ function buildMetricOperation( scale: 'ratio', } as T; }, + onFieldChange: (oldColumn, indexPattern, field) => { + return { + ...oldColumn, + label: ofName(field.name), + sourceField: field.name, + }; + }, toEsAggsConfig: (column, columnId) => ({ id: columnId, enabled: true, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx index def66db01a7fe..7514e5688f9e2 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx @@ -11,6 +11,7 @@ import { IndexPatternPrivateState, TermsIndexPatternColumn } from '../indexpatte import { EuiRange, EuiSelect } from '@elastic/eui'; import { UiSettingsClientContract } from 'src/core/public'; import { Storage } from 'ui/storage'; +import { createMockedIndexPattern } from '../mocks'; jest.mock('ui/new_platform'); @@ -75,6 +76,34 @@ describe('terms', () => { }); }); + 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('getPossibleOperationsForField', () => { it('should return operation with the right type', () => { expect( @@ -142,6 +171,7 @@ describe('terms', () => { const termsColumn = termsOperation.buildColumn({ layerId: 'first', suggestedPriority: undefined, + indexPattern: createMockedIndexPattern(), field: { aggregatable: true, searchable: true, @@ -157,6 +187,7 @@ describe('terms', () => { const termsColumn = termsOperation.buildColumn({ layerId: 'first', suggestedPriority: undefined, + indexPattern: createMockedIndexPattern(), columns: { col1: { label: 'Count', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx index a11b49f6ab784..c6f74902c5ef5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -26,7 +26,7 @@ const FixedEuiRange = (EuiRange as unknown) as React.ComponentType< function ofName(name: string) { return i18n.translate('xpack.lens.indexPattern.termsOf', { - defaultMessage: 'Top Values of {name}', + defaultMessage: 'Top values of {name}', values: { name }, }); } @@ -113,6 +113,13 @@ export const termsOperation: OperationDefinition = { 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 diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index ea36b8243e911..af8e2aaeb0380 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -77,6 +77,7 @@ export interface OperationDefinition { layerId: string; columns: Partial>; field?: IndexPatternField; + indexPattern: IndexPattern; }) => C; onOtherColumnChanged?: ( currentColumn: C, @@ -86,6 +87,23 @@ export interface OperationDefinition { toEsAggsConfig: (column: C, columnId: string) => unknown; isTransferable: (column: C, newIndexPattern: IndexPattern) => boolean; transfer?: (column: C, newIndexPattern: IndexPattern) => 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; } export function isColumnTransferable(column: IndexPatternColumn, newIndexPattern: IndexPattern) { @@ -196,5 +214,6 @@ export function buildColumn({ suggestedPriority, field, layerId, + indexPattern, }); } 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 index e4031071e448c..4a17f7774664b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts @@ -77,7 +77,7 @@ export function changeColumn({ layerId, columnId, newColumn, - keepParams, + keepParams = true, }: { state: IndexPatternPrivateState; layerId: string; @@ -88,7 +88,7 @@ export function changeColumn({ const oldColumn = state.layers[layerId].columns[columnId]; const updatedColumn = - (typeof keepParams === 'boolean' ? keepParams : true) && + keepParams && oldColumn && oldColumn.operationType === newColumn.operationType && 'params' in oldColumn From 6fa67150666b8ffdf8cced210c232fbaae1856bf Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 26 Aug 2019 18:32:23 +0200 Subject: [PATCH 086/105] [Lens] Fix query bar integration (#43865) --- .../plugins/lens/public/app_plugin/app.tsx | 48 +++++++++++++++++-- .../editor_frame/index.scss | 6 ++- .../date_histogram.test.tsx | 3 ++ .../operation_definitions/terms.test.tsx | 1 + .../apis/xpack_main/features/features.ts | 1 + .../common/suites/export.ts | 2 +- 6 files changed, 54 insertions(+), 7 deletions(-) diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index 95bbda1102242..2b768e621c17d 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -27,6 +27,25 @@ interface State { 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({ @@ -57,6 +76,13 @@ export function App({ toDate: timeDefaults.to, }, indexPatternTitles: [], + localQueryBarState: { + query: { query: '', language }, + dateRange: { + from: timeDefaults.from, + to: timeDefaults.to, + }, + }, }); const lastKnownDocRef = useRef(undefined); @@ -72,6 +98,10 @@ export function App({ isLoading: false, persistedDoc: doc, query: doc.state.query, + localQueryBarState: { + ...state.localQueryBarState, + query: doc.state.query, + }, indexPatternTitles: doc.state.datasourceMetaData.filterableIndexPatterns.map( ({ title }) => title ), @@ -152,7 +182,8 @@ export function App({ { + onSubmit={payload => { + const { dateRange, query } = payload; setState({ ...state, dateRange: { @@ -160,16 +191,25 @@ export function App({ 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.query} - dateRangeFrom={state.dateRange && state.dateRange.fromDate} - dateRangeTo={state.dateRange && state.dateRange.toDate} + query={state.localQueryBarState.query} + dateRangeFrom={ + state.localQueryBarState.dateRange && state.localQueryBarState.dateRange.from + } + dateRangeTo={ + state.localQueryBarState.dateRange && state.localQueryBarState.dateRange.to + } uiSettings={uiSettings} />
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 index 72b5f1eb79638..33571793a721c 100644 --- 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 @@ -115,12 +115,14 @@ $lnsPanelMinWidth: $euiSize * 18; width: 100%; height: 100%; display: flex; - align-items: center; - justify-content: center; overflow-x: hidden; padding: $euiSize; } +.lnsExpressionOutput > * { + flex: 1; +} + .lnsTitleInput { width: 100%; min-width: 100%; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx index 123a728f51b6f..f7a758febd8d2 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx @@ -98,6 +98,7 @@ describe('date_histogram', () => { label: 'Value of timestamp', dataType: 'date', isBucketed: true, + isMetric: false, // Private operationType: 'date_histogram', @@ -197,6 +198,7 @@ describe('date_histogram', () => { sourceField: 'timestamp', label: 'Date over timestamp', isBucketed: true, + isMetric: false, dataType: 'date', params: { interval: 'd', @@ -217,6 +219,7 @@ describe('date_histogram', () => { sourceField: 'timestamp', label: 'Date over timestamp', isBucketed: true, + isMetric: false, dataType: 'date', params: { interval: 'auto', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx index 7514e5688f9e2..4ce5d4bd32223 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx @@ -84,6 +84,7 @@ describe('terms', () => { label: 'Top values of source', isBucketed: true, dataType: 'string', + isMetric: false, params: { size: 5, orderBy: { diff --git a/x-pack/test/api_integration/apis/xpack_main/features/features.ts b/x-pack/test/api_integration/apis/xpack_main/features/features.ts index d803dcad90ac1..6fc7c842697ef 100644 --- a/x-pack/test/api_integration/apis/xpack_main/features/features.ts +++ b/x-pack/test/api_integration/apis/xpack_main/features/features.ts @@ -130,6 +130,7 @@ export default function({ getService }: FtrProviderContext) { 'canvas', 'code', 'infrastructure', + 'lens', 'logs', 'maps', 'uptime', 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 d109f47da3f52..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 Date: Wed, 28 Aug 2019 10:03:59 +0200 Subject: [PATCH 087/105] [Lens] Clean up operations code (#43784) --- .../dimension_panel/dimension_panel.tsx | 11 +- .../dimension_panel/popover_editor.tsx | 12 +- .../indexpattern_plugin/indexpattern.tsx | 66 +---- .../public/indexpattern_plugin/operations.ts | 219 ---------------- .../__mocks__/index.ts} | 4 +- .../operations/definitions/column_types.ts | 40 +++ .../definitions}/count.tsx | 28 +- .../definitions}/date_histogram.test.tsx | 13 +- .../definitions}/date_histogram.tsx | 68 ++--- .../definitions}/filter_ratio.test.tsx | 13 +- .../definitions}/filter_ratio.tsx | 67 +++-- .../operations/definitions/index.ts | 200 ++++++++++++++ .../definitions}/metrics.tsx | 65 ++--- .../definitions}/terms.test.tsx | 56 ++-- .../definitions}/terms.tsx | 62 +++-- .../indexpattern_plugin/operations/index.ts | 8 + .../{ => operations}/operations.test.ts | 17 +- .../operations/operations.ts | 246 ++++++++++++++++++ .../indexpattern_plugin/state_helpers.test.ts | 20 +- .../indexpattern_plugin/state_helpers.ts | 57 ++-- .../indexpattern_plugin/to_expression.ts | 13 +- .../lens/public/indexpattern_plugin/utils.ts | 6 +- 22 files changed, 747 insertions(+), 544 deletions(-) delete mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{__mocks__/operations.ts => operations/__mocks__/index.ts} (86%) create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/column_types.ts rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{operation_definitions => operations/definitions}/count.tsx (68%) rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{operation_definitions => operations/definitions}/date_histogram.test.tsx (94%) rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{operation_definitions => operations/definitions}/date_histogram.tsx (83%) rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{operation_definitions => operations/definitions}/filter_ratio.test.tsx (89%) rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{operation_definitions => operations/definitions}/filter_ratio.tsx (72%) create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/index.ts rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{operation_definitions => operations/definitions}/metrics.tsx (68%) rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{operation_definitions => operations/definitions}/terms.test.tsx (91%) rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{operation_definitions => operations/definitions}/terms.tsx (84%) create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/index.ts rename x-pack/legacy/plugins/lens/public/indexpattern_plugin/{ => operations}/operations.test.ts (96%) create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.ts 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 index 5e917aee7ae9f..0076a9f599bb9 100644 --- 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 @@ -18,12 +18,7 @@ import { OperationType, } from '../indexpattern'; -import { - getAvailableOperationsByMetadata, - buildColumn, - operationDefinitionMap, - OperationDefinition, -} from '../operations'; +import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations'; import { PopoverEditor } from './popover_editor'; import { DragContextState, ChildDragDropProvider, DragDrop } from '../../drag_drop'; import { changeColumn, deleteColumn } from '../state_helpers'; @@ -130,9 +125,7 @@ export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPan // 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 - ? (operationDefinitionMap[selectedColumn.operationType] as OperationDefinition< - IndexPatternColumn - >).onFieldChange(selectedColumn, currentIndexPattern, droppedItem.field) + ? changeField(selectedColumn, currentIndexPattern, droppedItem.field) : buildColumn({ columns: props.state.layers[props.layerId].columns, indexPattern: currentIndexPattern, 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 index 4b476936f1f76..960e81b98699b 100644 --- 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 @@ -32,7 +32,7 @@ import { operationDefinitionMap, getOperationDisplay, buildColumn, - OperationDefinition, + changeField, } from '../operations'; import { deleteColumn, changeColumn } from '../state_helpers'; import { FieldSelect } from './field_select'; @@ -284,14 +284,7 @@ export function PopoverEditor(props: PopoverEditorProps) { ) { // 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. - const operation = operationDefinitionMap[ - choice.operationType - ] as OperationDefinition; - column = operation.onFieldChange( - selectedColumn, - currentIndexPattern, - fieldMap[choice.field] - ); + column = changeField(selectedColumn, currentIndexPattern, fieldMap[choice.field]); } else { // Otherwise we'll use the buildColumn method to calculate a new column column = buildColumn({ @@ -358,6 +351,7 @@ export function PopoverEditor(props: PopoverEditorProps) { state={state} setState={setState} columnId={columnId} + currentColumn={state.layers[layerId].columns[columnId]} storage={props.storage} uiSettings={props.uiSettings} layerId={layerId} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 388d4de25a792..a7cbdd59c6e0f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -12,11 +12,9 @@ import { Storage } from 'ui/storage'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, - DimensionPriority, Operation, DatasourceLayerPanelProps, } from '../types'; -import { Query } from '../../../../../../src/legacy/core_plugins/data/public/query'; import { getIndexPatterns } from './loader'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; @@ -29,70 +27,10 @@ import { import { isDraggedField } from './utils'; import { LayerPanel } from './layerpanel'; +import { IndexPatternColumn } from './operations'; import { Datasource } from '..'; -export type OperationType = IndexPatternColumn['operationType']; - -export type IndexPatternColumn = - | DateHistogramIndexPatternColumn - | TermsIndexPatternColumn - | SumIndexPatternColumn - | AvgIndexPatternColumn - | MinIndexPatternColumn - | MaxIndexPatternColumn - | CountIndexPatternColumn - | FilterRatioIndexPatternColumn; - -export interface BaseIndexPatternColumn extends Operation { - // Private - operationType: OperationType; - suggestedPriority?: DimensionPriority; -} - -type Omit = Pick>; -type ParameterlessIndexPatternColumn< - TOperationType extends OperationType, - TBase extends BaseIndexPatternColumn = FieldBasedIndexPatternColumn -> = Omit & { operationType: TOperationType }; - -export interface FieldBasedIndexPatternColumn extends BaseIndexPatternColumn { - sourceField: string; - suggestedPriority?: DimensionPriority; -} - -export interface DateHistogramIndexPatternColumn extends FieldBasedIndexPatternColumn { - operationType: 'date_histogram'; - params: { - interval: string; - timeZone?: string; - }; -} - -export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { - operationType: 'terms'; - params: { - size: number; - orderBy: { type: 'alphabetical' } | { type: 'column'; columnId: string }; - orderDirection: 'asc' | 'desc'; - }; -} - -export interface FilterRatioIndexPatternColumn extends BaseIndexPatternColumn { - operationType: 'filter_ratio'; - params: { - numerator: Query; - denominator: Query; - }; -} - -export type CountIndexPatternColumn = ParameterlessIndexPatternColumn< - 'count', - BaseIndexPatternColumn ->; -export type SumIndexPatternColumn = ParameterlessIndexPatternColumn<'sum'>; -export type AvgIndexPatternColumn = ParameterlessIndexPatternColumn<'avg'>; -export type MinIndexPatternColumn = ParameterlessIndexPatternColumn<'min'>; -export type MaxIndexPatternColumn = ParameterlessIndexPatternColumn<'max'>; +export { OperationType, IndexPatternColumn } from './operations'; export interface IndexPattern { id: string; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts deleted file mode 100644 index af8e2aaeb0380..0000000000000 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ /dev/null @@ -1,219 +0,0 @@ -/* - * 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 { Storage } from 'ui/storage'; -import { UiSettingsClientContract } from 'src/core/public'; -import { DimensionPriority, OperationMetadata, StateSetter } from '../types'; -import { - IndexPatternColumn, - IndexPatternField, - IndexPatternPrivateState, - OperationType, - BaseIndexPatternColumn, - IndexPattern, -} from './indexpattern'; -import { termsOperation } from './operation_definitions/terms'; -import { - minOperation, - averageOperation, - sumOperation, - maxOperation, -} from './operation_definitions/metrics'; -import { dateHistogramOperation } from './operation_definitions/date_histogram'; -import { countOperation } from './operation_definitions/count'; -import { filterRatioOperation } from './operation_definitions/filter_ratio'; - -type PossibleOperationDefinition< - U extends IndexPatternColumn = IndexPatternColumn -> = U extends IndexPatternColumn ? OperationDefinition : never; - -type PossibleOperationDefinitionMapEntyries< - U extends PossibleOperationDefinition = PossibleOperationDefinition -> = U extends PossibleOperationDefinition ? { [K in U['type']]: U } : never; - -type UnionToIntersection = (U extends U ? (k: U) => void : never) extends ((k: infer I) => void) - ? I - : never; - -// this type makes sure that there is an operation definition for each column type -export type AllOperationDefinitions = UnionToIntersection; - -export const operationDefinitionMap: AllOperationDefinitions = { - terms: termsOperation, - date_histogram: dateHistogramOperation, - min: minOperation, - max: maxOperation, - avg: averageOperation, - sum: sumOperation, - count: countOperation, - filter_ratio: filterRatioOperation, -}; -const operationDefinitions: PossibleOperationDefinition[] = Object.values(operationDefinitionMap); - -export function getOperations(): OperationType[] { - return Object.keys(operationDefinitionMap) as OperationType[]; -} - -export interface ParamEditorProps { - state: IndexPatternPrivateState; - setState: StateSetter; - columnId: string; - layerId: string; - uiSettings: UiSettingsClientContract; - storage: Storage; -} - -export interface OperationDefinition { - type: C['operationType']; - displayName: string; - getPossibleOperationsForDocument: (indexPattern: IndexPattern) => OperationMetadata[]; - getPossibleOperationsForField: (field: IndexPatternField) => OperationMetadata[]; - buildColumn: (arg: { - suggestedPriority: DimensionPriority | undefined; - layerId: string; - columns: Partial>; - field?: IndexPatternField; - indexPattern: IndexPattern; - }) => C; - onOtherColumnChanged?: ( - currentColumn: C, - columns: Partial> - ) => C; - paramEditor?: React.ComponentType; - toEsAggsConfig: (column: C, columnId: string) => unknown; - isTransferable: (column: C, newIndexPattern: IndexPattern) => boolean; - transfer?: (column: C, newIndexPattern: IndexPattern) => 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; -} - -export function isColumnTransferable(column: IndexPatternColumn, newIndexPattern: IndexPattern) { - return (operationDefinitionMap[column.operationType] as OperationDefinition< - IndexPatternColumn - >).isTransferable(column, newIndexPattern); -} - -export function getOperationDisplay() { - const display = {} as Record< - OperationType, - { - type: OperationType; - displayName: string; - } - >; - operationDefinitions.forEach(({ type, displayName }) => { - display[type] = { - type, - displayName, - }; - }); - return display; -} - -export function getOperationTypesForField(field: IndexPatternField) { - return operationDefinitions - .filter( - operationDefinition => operationDefinition.getPossibleOperationsForField(field).length > 0 - ) - .map(({ type }) => type); -} - -type OperationFieldTuple = - | { type: 'field'; operationType: OperationType; field: string } - | { operationType: OperationType; type: 'document' }; - -export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) { - const operationByMetadata: Record< - string, - { operationMetaData: OperationMetadata; operations: OperationFieldTuple[] } - > = {}; - - const addToMap = (operation: OperationFieldTuple) => (operationMetadata: OperationMetadata) => { - const key = JSON.stringify(operationMetadata); - - if (operationByMetadata[key]) { - operationByMetadata[key].operations.push(operation); - } else { - operationByMetadata[key] = { - operationMetaData: operationMetadata, - operations: [operation], - }; - } - }; - - operationDefinitions.forEach(operationDefinition => { - operationDefinition - .getPossibleOperationsForDocument(indexPattern) - .forEach(addToMap({ type: 'document', operationType: operationDefinition.type })); - - indexPattern.fields.forEach(field => { - operationDefinition.getPossibleOperationsForField(field).forEach( - addToMap({ - type: 'field', - operationType: operationDefinition.type, - field: field.name, - }) - ); - }); - }); - - return Object.values(operationByMetadata); -} - -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: PossibleOperationDefinition; - - if (op) { - operationDefinition = operationDefinitionMap[op]; - } else if (asDocumentOperation) { - operationDefinition = operationDefinitions.find( - definition => definition.getPossibleOperationsForDocument(indexPattern).length !== 0 - )!; - } else if (field) { - operationDefinition = operationDefinitions.find( - definition => definition.getPossibleOperationsForField(field).length !== 0 - )!; - } - return operationDefinition!.buildColumn({ - columns, - suggestedPriority, - field, - layerId, - indexPattern, - }); -} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/__mocks__/index.ts similarity index 86% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/__mocks__/index.ts index a7ab5fc2c6faa..eeb19bba24006 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/__mocks__/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -const actual = jest.requireActual('../operations'); +const actual = jest.requireActual('../../operations'); jest.spyOn(actual.operationDefinitionMap.date_histogram, 'paramEditor'); jest.spyOn(actual.operationDefinitionMap.terms, 'onOtherColumnChanged'); @@ -17,5 +17,7 @@ export const { 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/operation_definitions/count.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/count.tsx similarity index 68% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/count.tsx index d751b944cebe2..8a47e68a279ad 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/count.tsx @@ -5,28 +5,30 @@ */ import { i18n } from '@kbn/i18n'; -import { CountIndexPatternColumn } from '../indexpattern'; -import { OperationDefinition } from '../operations'; +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', displayName: i18n.translate('xpack.lens.indexPattern.count', { defaultMessage: 'Count', }), - getPossibleOperationsForField: () => [], - getPossibleOperationsForDocument: () => { - return [ - { - dataType: 'number', - isBucketed: false, - isMetric: true, - scale: 'ratio', - }, - ]; + getPossibleOperationForDocument: () => { + return { + dataType: 'number', + isBucketed: false, + isMetric: true, + scale: 'ratio', + }; }, buildColumn({ suggestedPriority }) { return { @@ -39,8 +41,6 @@ export const countOperation: OperationDefinition = { scale: 'ratio', }; }, - // This cannot be called practically, since this is a fieldless operation - onFieldChange: oldColumn => ({ ...oldColumn }), toEsAggsConfig: (column, columnId) => ({ id: columnId, enabled: true, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.test.tsx similarity index 94% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.test.tsx index f7a758febd8d2..12015a281eaa9 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.test.tsx @@ -5,13 +5,14 @@ */ import React from 'react'; -import { dateHistogramOperation } from './date_histogram'; +import { DateHistogramIndexPatternColumn } from './date_histogram'; +import { dateHistogramOperation } from '.'; import { shallow } from 'enzyme'; -import { DateHistogramIndexPatternColumn, IndexPatternPrivateState } from '../indexpattern'; +import { IndexPatternPrivateState } from '../../indexpattern'; import { EuiRange, EuiSwitch } from '@elastic/eui'; import { UiSettingsClientContract } from 'src/core/public'; import { Storage } from 'ui/storage'; -import { createMockedIndexPattern } from '../mocks'; +import { createMockedIndexPattern } from '../../mocks'; jest.mock('ui/new_platform'); @@ -325,6 +326,7 @@ describe('date_histogram', () => { setState={setStateSpy} columnId="col1" layerId="first" + currentColumn={state.layers.first.columns.col1 as DateHistogramIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} /> @@ -340,6 +342,7 @@ describe('date_histogram', () => { state={state} setState={setStateSpy} columnId="col2" + currentColumn={state.layers.second.columns.col2 as DateHistogramIndexPatternColumn} layerId="second" storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} @@ -355,6 +358,7 @@ describe('date_histogram', () => { state={state} setState={jest.fn()} columnId="col1" + currentColumn={state.layers.third.columns.col1 as DateHistogramIndexPatternColumn} layerId="third" storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} @@ -372,6 +376,7 @@ describe('date_histogram', () => { setState={setStateSpy} columnId="col1" layerId="third" + currentColumn={state.layers.third.columns.col1 as DateHistogramIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} /> @@ -392,6 +397,7 @@ describe('date_histogram', () => { setState={setStateSpy} columnId="col1" layerId="first" + currentColumn={state.layers.first.columns.col1 as DateHistogramIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} /> @@ -449,6 +455,7 @@ describe('date_histogram', () => { setState={setStateSpy} columnId="col1" layerId="first" + currentColumn={state.layers.first.columns.col1 as DateHistogramIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} /> diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx similarity index 83% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx index 92cb474abe9d2..6c75141388514 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx @@ -8,9 +8,10 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiForm, EuiFormRow, EuiRange, EuiSwitch } from '@elastic/eui'; -import { DateHistogramIndexPatternColumn, IndexPattern } from '../indexpattern'; -import { OperationDefinition } from '../operations'; -import { updateColumnParam } from '../state_helpers'; +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; @@ -39,33 +40,34 @@ function supportsAutoInterval(fieldName: string, indexPattern: IndexPattern): bo 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', }), - getPossibleOperationsForDocument: () => [], - getPossibleOperationsForField: ({ aggregationRestrictions, aggregatable, type }) => { + getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( type === 'date' && aggregatable && (!aggregationRestrictions || aggregationRestrictions.date_histogram) ) { - return [ - { - dataType: 'date', - isBucketed: true, - isMetric: false, - scale: 'interval', - }, - ]; + return { + dataType: 'date', + isBucketed: true, + isMetric: false, + scale: 'interval', + }; } - return []; }, - buildColumn({ suggestedPriority, field, indexPattern }): DateHistogramIndexPatternColumn { - if (!field) { - throw new Error('Invariant error: date histogram buildColumn requires field'); - } + buildColumn({ suggestedPriority, field, indexPattern }) { let interval = indexPattern.timeFieldName === field.name ? autoInterval : defaultCustomInterval; let timeZone: string | undefined; if (field.aggregationRestrictions && field.aggregationRestrictions.date_histogram) { @@ -164,13 +166,11 @@ export const dateHistogramOperation: OperationDefinition { - const column = state.layers[layerId].columns[columnId] as DateHistogramIndexPatternColumn; - + paramEditor: ({ state, setState, currentColumn: currentColumn, layerId }) => { const field = - column && + currentColumn && state.indexPatterns[state.layers[layerId].indexPatternId].fields.find( - currentField => currentField.name === column.sourceField + currentField => currentField.name === currentColumn.sourceField ); const intervalIsRestricted = field!.aggregationRestrictions && field!.aggregationRestrictions.date_histogram; @@ -187,7 +187,9 @@ export const dateHistogramOperation: OperationDefinition) { const interval = ev.target.checked ? defaultCustomInterval : autoInterval; - setState(updateColumnParam(state, layerId, column, 'interval', interval)); + setState( + updateColumnParam({ state, layerId, currentColumn, paramName: 'interval', value: interval }) + ); } return ( @@ -198,12 +200,12 @@ export const dateHistogramOperation: OperationDefinition )} - {column.params.interval !== autoInterval && ( + {currentColumn.params.interval !== autoInterval && ( ) : ( @@ -222,7 +224,7 @@ export const dateHistogramOperation: OperationDefinition ({ label: interval, @@ -230,13 +232,13 @@ export const dateHistogramOperation: OperationDefinition) => setState( - updateColumnParam( + updateColumnParam({ state, layerId, - column, - 'interval', - numericToInterval(Number(e.target.value)) - ) + currentColumn, + paramName: 'interval', + value: numericToInterval(Number(e.target.value)), + }) ) } aria-label={i18n.translate('xpack.lens.indexPattern.dateHistogram.interval', { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.test.tsx similarity index 89% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.test.tsx index 51d8497cde1bf..94fe425543936 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.test.tsx @@ -7,12 +7,13 @@ import React from 'react'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import { act } from 'react-dom/test-utils'; -import { filterRatioOperation } from './filter_ratio'; -import { FilterRatioIndexPatternColumn, IndexPatternPrivateState } from '../indexpattern'; +import { FilterRatioIndexPatternColumn } from './filter_ratio'; +import { filterRatioOperation } from '.'; +import { IndexPatternPrivateState } from '../../indexpattern'; import { Storage } from 'ui/storage'; import { UiSettingsClientContract } from 'src/core/public'; -import { QueryBarInput } from '../../../../../../../src/legacy/core_plugins/data/public/query'; -import { createMockedIndexPattern } from '../mocks'; +import { QueryBarInput } from '../../../../../../../../src/legacy/core_plugins/data/public/query'; +import { createMockedIndexPattern } from '../../mocks'; jest.mock('ui/new_platform'); @@ -104,6 +105,7 @@ describe('filter_ratio', () => { state={state} setState={jest.fn()} columnId="col1" + currentColumn={state.layers.first.columns.col1 as FilterRatioIndexPatternColumn} storage={storageMock} uiSettings={{} as UiSettingsClientContract} /> @@ -118,6 +120,7 @@ describe('filter_ratio', () => { state={state} setState={jest.fn()} columnId="col1" + currentColumn={state.layers.first.columns.col1 as FilterRatioIndexPatternColumn} storage={storageMock} uiSettings={{} as UiSettingsClientContract} /> @@ -135,6 +138,7 @@ describe('filter_ratio', () => { state={state} setState={setState} columnId="col1" + currentColumn={state.layers.first.columns.col1 as FilterRatioIndexPatternColumn} storage={storageMock} uiSettings={{} as UiSettingsClientContract} /> @@ -173,6 +177,7 @@ describe('filter_ratio', () => { state={state} setState={setState} columnId="col1" + currentColumn={state.layers.first.columns.col1 as FilterRatioIndexPatternColumn} storage={storageMock} uiSettings={{} as UiSettingsClientContract} /> diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx similarity index 72% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx index 025bbf14b7e87..6dee938b1fce9 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx @@ -11,30 +11,35 @@ import { EuiButton, EuiFormRow } from '@elastic/eui'; import { Query, QueryBarInput, -} from '../../../../../../../src/legacy/core_plugins/data/public/query'; -import { FilterRatioIndexPatternColumn } from '../indexpattern'; -import { OperationDefinition } from '../operations'; -import { updateColumnParam } from '../state_helpers'; +} 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', displayName: i18n.translate('xpack.lens.indexPattern.filterRatio', { defaultMessage: 'Filter Ratio', }), - getPossibleOperationsForField: () => [], - getPossibleOperationsForDocument: () => { - return [ - { - dataType: 'number', - isBucketed: false, - isMetric: true, - scale: 'ratio', - }, - ]; + getPossibleOperationForDocument: () => { + return { + dataType: 'number', + isBucketed: false, + isMetric: true, + scale: 'ratio', + }; }, buildColumn({ suggestedPriority }) { return { @@ -51,8 +56,6 @@ export const filterRatioOperation: OperationDefinition ({ ...oldColumn }), toEsAggsConfig: (column, columnId) => ({ id: columnId, enabled: true, @@ -75,7 +78,7 @@ export const filterRatioOperation: OperationDefinition { + paramEditor: ({ state, setState, currentColumn, uiSettings, storage, layerId }) => { const [hasDenominator, setDenominator] = useState(false); return ( @@ -88,22 +91,19 @@ export const filterRatioOperation: OperationDefinition { setState( - updateColumnParam( + updateColumnParam({ state, layerId, - state.layers[layerId].columns[currentColumnId] as FilterRatioIndexPatternColumn, - 'numerator', - newQuery - ) + currentColumn, + paramName: 'numerator', + value: newQuery, + }) ); }} /> @@ -118,22 +118,19 @@ export const filterRatioOperation: OperationDefinition { setState( - updateColumnParam( + updateColumnParam({ state, layerId, - state.layers[layerId].columns[currentColumnId] as FilterRatioIndexPatternColumn, - 'denominator', - newQuery - ) + currentColumn, + paramName: 'denominator', + value: newQuery, + }) ); }} /> 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..b7ea189beb2c9 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/index.ts @@ -0,0 +1,200 @@ +/* + * 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 } 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; +} + +interface BaseOperationDefinitionProps { + type: C['operationType']; + /** + * 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/operation_definitions/metrics.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/metrics.tsx similarity index 68% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/metrics.tsx index aadca0f1f7dbd..f33bd1cfd3967 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/metrics.tsx @@ -5,40 +5,30 @@ */ import { i18n } from '@kbn/i18n'; -import { - FieldBasedIndexPatternColumn, - MinIndexPatternColumn, - SumIndexPatternColumn, - AvgIndexPatternColumn, - MaxIndexPatternColumn, -} from '../indexpattern'; -import { OperationDefinition } from '../operations'; +import { OperationDefinition } from '.'; +import { ParameterlessIndexPatternColumn } from './column_types'; -function buildMetricOperation( +function buildMetricOperation>( type: T['operationType'], displayName: string, ofName: (name: string) => string ) { - const operationDefinition: OperationDefinition = { + return { type, displayName, - getPossibleOperationsForDocument: () => [], - getPossibleOperationsForField: ({ aggregationRestrictions, aggregatable, type: fieldType }) => { + getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type: fieldType }) => { if ( fieldType === 'number' && aggregatable && (!aggregationRestrictions || aggregationRestrictions[type]) ) { - return [ - { - dataType: 'number', - isBucketed: false, - isMetric: true, - scale: 'ratio', - }, - ]; + return { + dataType: 'number', + isBucketed: false, + isMetric: true, + scale: 'ratio', + }; } - return []; }, isTransferable: (column, newIndexPattern) => { const newField = newIndexPattern.fields.find(field => field.name === column.sourceField); @@ -50,21 +40,16 @@ function buildMetricOperation( (!newField.aggregationRestrictions || newField.aggregationRestrictions![type]) ); }, - buildColumn({ suggestedPriority, field }): T { - if (!field) { - throw new Error(`Invariant: A ${type} operation can only be built with a field`); - } - return { - label: ofName(field ? field.name : ''), - dataType: 'number', - operationType: type, - suggestedPriority, - sourceField: field ? field.name : '', - isBucketed: false, - isMetric: true, - scale: 'ratio', - } as T; - }, + buildColumn: ({ suggestedPriority, field }) => ({ + label: ofName(field ? field.name : ''), + dataType: 'number', + operationType: type, + suggestedPriority, + sourceField: field ? field.name : '', + isBucketed: false, + isMetric: true, + scale: 'ratio', + }), onFieldChange: (oldColumn, indexPattern, field) => { return { ...oldColumn, @@ -81,10 +66,14 @@ function buildMetricOperation( field: column.sourceField, }, }), - }; - return operationDefinition; + } 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( 'min', i18n.translate('xpack.lens.indexPattern.min', { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.test.tsx similarity index 91% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.test.tsx index 4ce5d4bd32223..b6883a0cc3709 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.test.tsx @@ -5,13 +5,14 @@ */ import React from 'react'; -import { termsOperation } from './terms'; import { shallow } from 'enzyme'; -import { IndexPatternPrivateState, TermsIndexPatternColumn } from '../indexpattern'; +import { IndexPatternPrivateState } from '../../indexpattern'; import { EuiRange, EuiSelect } from '@elastic/eui'; import { UiSettingsClientContract } from 'src/core/public'; import { Storage } from 'ui/storage'; -import { createMockedIndexPattern } from '../mocks'; +import { createMockedIndexPattern } from '../../mocks'; +import { TermsIndexPatternColumn } from './terms'; +import { termsOperation } from '.'; jest.mock('ui/new_platform'); @@ -105,10 +106,10 @@ describe('terms', () => { }); }); - describe('getPossibleOperationsForField', () => { + describe('getPossibleOperationForField', () => { it('should return operation with the right type', () => { expect( - termsOperation.getPossibleOperationsForField({ + termsOperation.getPossibleOperationForField({ aggregatable: true, searchable: true, name: 'test', @@ -119,51 +120,47 @@ describe('terms', () => { }, }, }) - ).toEqual([ - { - dataType: 'string', - isBucketed: true, - isMetric: false, - scale: 'ordinal', - }, - ]); + ).toEqual({ + dataType: 'string', + isBucketed: true, + isMetric: false, + scale: 'ordinal', + }); expect( - termsOperation.getPossibleOperationsForField({ + termsOperation.getPossibleOperationForField({ aggregatable: true, searchable: true, name: 'test', type: 'boolean', }) - ).toEqual([ - { - dataType: 'boolean', - isBucketed: true, - isMetric: false, - scale: 'ordinal', - }, - ]); + ).toEqual({ + dataType: 'boolean', + isBucketed: true, + isMetric: false, + scale: 'ordinal', + }); }); it('should not return an operation if restrictions prevent terms', () => { expect( - termsOperation.getPossibleOperationsForField({ + termsOperation.getPossibleOperationForField({ aggregatable: false, searchable: true, name: 'test', type: 'string', }) - ).toEqual([]); + ).toEqual(undefined); expect( - termsOperation.getPossibleOperationsForField({ + termsOperation.getPossibleOperationForField({ aggregatable: true, aggregationRestrictions: {}, searchable: true, name: 'test', type: 'string', }) - ).toEqual([]); + ).toEqual(undefined); }); }); @@ -321,6 +318,7 @@ describe('terms', () => { state={state} setState={setStateSpy} columnId="col1" + currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} layerId="first" storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} @@ -368,6 +366,7 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" + currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} /> @@ -385,6 +384,7 @@ describe('terms', () => { state={state} setState={setStateSpy} columnId="col1" + currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} layerId="first" storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} @@ -431,6 +431,7 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" + currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} /> @@ -452,6 +453,7 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" + currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} /> @@ -494,6 +496,7 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" + currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} /> @@ -510,6 +513,7 @@ describe('terms', () => { setState={setStateSpy} columnId="col1" layerId="first" + currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} /> diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.tsx similarity index 84% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.tsx index c6f74902c5ef5..c13479c04c0c5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.tsx @@ -7,10 +7,11 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiForm, EuiFormRow, EuiRange, EuiSelect } from '@elastic/eui'; -import { TermsIndexPatternColumn, IndexPatternColumn } from '../indexpattern'; -import { OperationDefinition } from '../operations'; -import { updateColumnParam } from '../state_helpers'; -import { DataType } from '../../types'; +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; @@ -37,28 +38,28 @@ function isSortableByColumn(column: IndexPatternColumn) { 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', }), - getPossibleOperationsForDocument: () => [], - getPossibleOperationsForField: ({ aggregationRestrictions, aggregatable, type }) => { + getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( (type === 'string' || type === 'boolean') && aggregatable && (!aggregationRestrictions || aggregationRestrictions.terms) ) { - return [ - { - dataType: type, - isBucketed: true, - isMetric: false, - scale: 'ordinal', - }, - ]; + return { dataType: type, isBucketed: true, isMetric: false, scale: 'ordinal' }; } - return []; }, isTransferable: (column, newIndexPattern) => { const newField = newIndexPattern.fields.find(field => field.name === column.sourceField); @@ -71,9 +72,6 @@ export const termsOperation: OperationDefinition = { ); }, buildColumn({ suggestedPriority, columns, field }) { - if (!field) { - throw new Error('Invariant error: terms operation requires field'); - } const existingMetricColumn = Object.entries(columns) .filter(([_columnId, column]) => column && isSortableByColumn(column)) .map(([id]) => id)[0]; @@ -137,8 +135,7 @@ export const termsOperation: OperationDefinition = { } return currentColumn; }, - paramEditor: ({ state, setState, columnId: currentColumnId, layerId }) => { - const currentColumn = state.layers[layerId].columns[currentColumnId] as TermsIndexPatternColumn; + paramEditor: ({ state, setState, currentColumn, columnId: currentColumnId, layerId }) => { const SEPARATOR = '$$$'; function toValue(orderBy: TermsIndexPatternColumn['params']['orderBy']) { if (orderBy.type === 'alphabetical') { @@ -187,7 +184,13 @@ export const termsOperation: OperationDefinition = { showInput onChange={(e: React.ChangeEvent) => setState( - updateColumnParam(state, layerId, currentColumn, 'size', Number(e.target.value)) + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'size', + value: Number(e.target.value), + }) ) } aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', { @@ -206,13 +209,13 @@ export const termsOperation: OperationDefinition = { value={toValue(currentColumn.params.orderBy)} onChange={(e: React.ChangeEvent) => setState( - updateColumnParam( + updateColumnParam({ state, layerId, currentColumn, - 'orderBy', - fromValue(e.target.value) - ) + paramName: 'orderBy', + value: fromValue(e.target.value), + }) ) } aria-label={i18n.translate('xpack.lens.indexPattern.terms.orderBy', { @@ -244,8 +247,13 @@ export const termsOperation: OperationDefinition = { value={currentColumn.params.orderDirection} onChange={(e: React.ChangeEvent) => setState( - updateColumnParam(state, layerId, currentColumn, 'orderDirection', e.target - .value as 'asc' | 'desc') + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'orderDirection', + value: e.target.value as 'asc' | 'desc', + }) ) } aria-label={i18n.translate('xpack.lens.indexPattern.terms.orderBy', { 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.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.test.ts similarity index 96% rename from x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts rename to x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.test.ts index 49719c35a09c2..15a3b8ab19296 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.test.ts @@ -4,20 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - getOperationTypesForField, - getAvailableOperationsByMetadata, - buildColumn, -} from './operations'; -import { - IndexPatternPrivateState, - AvgIndexPatternColumn, - MinIndexPatternColumn, - CountIndexPatternColumn, -} from './indexpattern'; +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'); +jest.mock('../loader'); const expectedIndexPatterns = { 1: { 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..2c9d8a6a8d9eb --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.ts @@ -0,0 +1,246 @@ +/* + * 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) + ) + .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; +} + +/** + * 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 = operationDefinitions.find(definition => + getPossibleOperationForDocument(definition, indexPattern) + ); + } else if (field) { + operationDefinition = operationDefinitions.find(definition => + 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/state_helpers.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts index 897af8bbc28ff..1d366b931b89b 100644 --- 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 @@ -11,15 +11,11 @@ import { deleteColumn, updateLayerIndexPattern, } from './state_helpers'; -import { - IndexPatternPrivateState, - DateHistogramIndexPatternColumn, - TermsIndexPatternColumn, - AvgIndexPatternColumn, - IndexPattern, - IndexPatternLayer, -} from './indexpattern'; +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'); @@ -156,7 +152,13 @@ describe('state_helpers', () => { }; expect( - updateColumnParam(state, 'first', currentColumn, 'interval', 'M').layers.first.columns.col1 + updateColumnParam({ + state, + layerId: 'first', + currentColumn, + paramName: 'interval', + value: 'M', + }).layers.first.columns.col1 ).toEqual({ ...currentColumn, params: { interval: 'M' }, 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 index 4a17f7774664b..f2b55bbcb0dd5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts @@ -5,25 +5,26 @@ */ import _ from 'lodash'; -import { - IndexPatternPrivateState, - IndexPatternColumn, - BaseIndexPatternColumn, - IndexPatternLayer, - IndexPattern, -} from './indexpattern'; -import { operationDefinitionMap, OperationDefinition, isColumnTransferable } from './operations'; +import { IndexPatternPrivateState, IndexPatternLayer, IndexPattern } from './indexpattern'; +import { isColumnTransferable } from './operations'; +import { operationDefinitionMap, IndexPatternColumn } from './operations'; export function updateColumnParam< - C extends BaseIndexPatternColumn & { params: object }, + C extends IndexPatternColumn & { params: object }, K extends keyof C['params'] ->( - state: IndexPatternPrivateState, - layerId: string, - currentColumn: C, - paramName: K, - value: C['params'][K] -): IndexPatternPrivateState { +>({ + 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]; @@ -40,13 +41,13 @@ export function updateColumnParam< ...state.layers[layerId], columns: { ...state.layers[layerId].columns, - [columnId]: ({ + [columnId]: { ...currentColumn, params: { ...currentColumn.params, [paramName]: value, }, - } as unknown) as IndexPatternColumn, + }, }, }, }, @@ -60,19 +61,17 @@ function adjustColumnReferencesForChangedColumn( const newColumns = { ...columns }; Object.keys(newColumns).forEach(currentColumnId => { if (currentColumnId !== columnId) { - const currentColumn = newColumns[currentColumnId] as BaseIndexPatternColumn; - const operationDefinition = operationDefinitionMap[ - currentColumn.operationType - ] as OperationDefinition; - newColumns[currentColumnId] = (operationDefinition.onOtherColumnChanged + const currentColumn = newColumns[currentColumnId]; + const operationDefinition = operationDefinitionMap[currentColumn.operationType]; + newColumns[currentColumnId] = operationDefinition.onOtherColumnChanged ? operationDefinition.onOtherColumnChanged(currentColumn, newColumns) - : currentColumn) as IndexPatternColumn; + : currentColumn; } }); return newColumns; } -export function changeColumn({ +export function changeColumn({ state, layerId, columnId, @@ -82,7 +81,7 @@ export function changeColumn({ state: IndexPatternPrivateState; layerId: string; columnId: string; - newColumn: IndexPatternColumn; + newColumn: C; keepParams?: boolean; }): IndexPatternPrivateState { const oldColumn = state.layers[layerId].columns[columnId]; @@ -92,7 +91,7 @@ export function changeColumn({ oldColumn && oldColumn.operationType === newColumn.operationType && 'params' in oldColumn - ? ({ ...newColumn, params: oldColumn.params } as IndexPatternColumn) + ? { ...newColumn, params: oldColumn.params } : newColumn; const newColumns = adjustColumnReferencesForChangedColumn( @@ -175,9 +174,7 @@ export function updateLayerIndexPattern( isColumnTransferable(column, newIndexPattern) ); const newColumns: IndexPatternLayer['columns'] = _.mapValues(keptColumns, column => { - const operationDefinition = operationDefinitionMap[column.operationType] as OperationDefinition< - IndexPatternColumn - >; + const operationDefinition = operationDefinitionMap[column.operationType]; return operationDefinition.transfer ? operationDefinition.transfer(column, newIndexPattern) : column; 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 index ab06f94117163..9bd68aac90403 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import { IndexPatternPrivateState, IndexPatternColumn, IndexPattern } from './indexpattern'; -import { operationDefinitionMap, OperationDefinition, buildColumn } from './operations'; +import { buildColumn, operationDefinitionMap } from './operations'; function getExpressionForLayer( indexPattern: IndexPattern, @@ -20,17 +20,10 @@ function getExpressionForLayer( } function getEsAggsConfig(column: C, columnId: string) { - // Typescript is not smart enough to infer that definitionMap[C['operationType']] is always OperationDefinition, - // but this is made sure by the typing of the operation map - const operationDefinition = (operationDefinitionMap[ - column.operationType - ] as unknown) as OperationDefinition; - return operationDefinition.toEsAggsConfig(column, columnId); + return operationDefinitionMap[column.operationType].toEsAggsConfig(column, columnId); } - const columnEntries = columnOrder.map( - colId => [colId, columns[colId]] as [string, IndexPatternColumn] - ); + const columnEntries = columnOrder.map(colId => [colId, columns[colId]] as const); if (columnEntries.length) { const aggs = columnEntries.map(([colId, col]) => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/utils.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/utils.ts index 4871b10cbd99c..aab991a27856a 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/utils.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/utils.ts @@ -5,7 +5,11 @@ */ import _ from 'lodash'; -import { BaseIndexPatternColumn, FieldBasedIndexPatternColumn, DraggedField } from './indexpattern'; +import { DraggedField } from './indexpattern'; +import { + BaseIndexPatternColumn, + FieldBasedIndexPatternColumn, +} from './operations/definitions/column_types'; export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { return 'sourceField' in column; From 1bb09105926502cf13fd0954caee1e4aefab0455 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Thu, 29 Aug 2019 10:38:41 -0400 Subject: [PATCH 088/105] [Lens] Functional tests (#44279) Foundational layer for lens functional tests. Lens-specific page objects are not in this PR. --- .../lens/public/drag_drop/drag_drop.tsx | 7 +++- .../public/indexpattern_plugin/field_item.tsx | 1 + x-pack/legacy/plugins/lens/readme.md | 9 +++++ x-pack/test/functional/apps/lens/index.ts | 29 ++++++++++++++ .../apps/lens/indexpattern_datapanel.ts | 38 +++++++++++++++++++ x-pack/test/functional/config.js | 4 ++ 6 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 x-pack/legacy/plugins/lens/readme.md create mode 100644 x-pack/test/functional/apps/lens/index.ts create mode 100644 x-pack/test/functional/apps/lens/indexpattern_datapanel.ts 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 index d7cc6857e7d96..e1f765ae6e673 100644 --- a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx @@ -52,6 +52,11 @@ interface Props { * Indicates whether or not this component is draggable. */ draggable?: boolean; + + /** + * The optional test subject associated with this DOM element. + */ + dataTestSubj?: string; } /** @@ -123,7 +128,7 @@ export function DragDrop(props: Props) { return (
{ + before(async () => { + log.debug('Starting lens before method'); + browser.setWindowSize(1280, 800); + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('visualize/default'); + }); + + describe('', function() { + this.tags(['ciGroup4', 'skipFirefox']); + + loadTestFile(require.resolve('./indexpattern_datapanel')); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/indexpattern_datapanel.ts b/x-pack/test/functional/apps/lens/indexpattern_datapanel.ts new file mode 100644 index 0000000000000..f0fc37a2c18d9 --- /dev/null +++ b/x-pack/test/functional/apps/lens/indexpattern_datapanel.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. + */ + +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']); + const find = getService('find'); + + describe('indexpattern_datapanel', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('lens'); + }); + + it('should list the index pattern fields', async () => { + const fields = await find.allByCssSelector('[data-test-subj="lnsFieldListPanelField"]'); + const fieldText = await Promise.all(fields.map(field => field.getVisibleText())); + expect(fieldText).to.eql([ + '_score', + '@timestamp', + 'bytes', + 'id', + 'machine.ram', + 'memory', + 'meta.user.lastname', + 'phpmemory', + 'relatedContent.article:modified_time', + 'relatedContent.article:published_time', + 'utc_time', + ]); + }); + }); +} 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', }, From 65008c950759d8a306c6f4d84b812c1574991d01 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Thu, 29 Aug 2019 14:28:17 -0400 Subject: [PATCH 089/105] [Lens] Add Lens visualizations to Visualize list (#43398) --- .../listing/visualize_listing_table.js | 1 + src/legacy/plugin_discovery/types.ts | 1 + x-pack/legacy/plugins/lens/index.ts | 2 +- .../lens/public/register_vis_type_alias.ts | 21 ++++++++++++++++++- .../server/lib/register_oss_features.ts | 4 ++-- 5 files changed, 25 insertions(+), 4 deletions(-) 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 a660528cf2477..a2cdef75cf7f6 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/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index 36cc18c86b549..f205b14af9dab 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -25,6 +25,7 @@ export const lens: LegacyPluginInitializer = kibana => { 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'), @@ -53,7 +54,6 @@ export const lens: LegacyPluginInitializer = kibana => { server.plugins.xpack_main.registerFeature({ id: PLUGIN_ID, name: NOT_INTERNATIONALIZED_PRODUCT_NAME, - navLinkId: PLUGIN_ID, app: [PLUGIN_ID, 'kibana'], catalogue: [PLUGIN_ID], privileges: { 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 index 595eb4d0e350b..78b22e0974a09 100644 --- a/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts +++ b/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts @@ -10,7 +10,7 @@ import { visualizations } from '../../../../../src/legacy/core_plugins/visualiza const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; visualizations.types.visTypeAliasRegistry.add({ - aliasUrl: '/app/lens/', + aliasUrl: '/app/lens', name: NOT_INTERNATIONALIZED_PRODUCT_NAME, title: i18n.translate('xpack.lens.visTypeAlias.title', { defaultMessage: 'Lens Visualizations', @@ -19,4 +19,23 @@ visualizations.types.visTypeAliasRegistry.add({ 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: `/app/lens#/edit/${id}`, + icon: 'faceHappy', + isExperimental: true, + savedObjectType: type, + typeTitle: i18n.translate('xpack.lens.visTypeAlias.type', { defaultMessage: 'Lens' }), + }; + }, + }, + }, }); diff --git a/x-pack/legacy/plugins/xpack_main/server/lib/register_oss_features.ts b/x-pack/legacy/plugins/xpack_main/server/lib/register_oss_features.ts index 8573798b57b84..dea35d94b5ba3 100644 --- a/x-pack/legacy/plugins/xpack_main/server/lib/register_oss_features.ts +++ b/x-pack/legacy/plugins/xpack_main/server/lib/register_oss_features.ts @@ -46,7 +46,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => { privileges: { all: { savedObject: { - all: ['visualization', 'url', 'query'], + all: ['visualization', 'url', 'query', 'lens'], read: ['index-pattern', 'search'], }, ui: ['show', 'createShortUrl', 'delete', 'save', 'saveQuery'], @@ -54,7 +54,7 @@ const buildKibanaFeatures = (savedObjectTypes: string[]) => { read: { savedObject: { all: [], - read: ['index-pattern', 'search', 'visualization', 'query'], + read: ['index-pattern', 'search', 'visualization', 'query', 'lens'], }, ui: ['show'], }, From 39486783ea022469403745bfa56cafd6a0d595cb Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 2 Sep 2019 15:29:13 +0200 Subject: [PATCH 090/105] [Lens] Suggestion improvements (#43688) --- .../visualization.test.tsx | 1 - .../visualization.tsx | 77 ++-- .../editor_frame/chart_switch.test.tsx | 7 +- .../editor_frame/chart_switch.tsx | 27 +- .../editor_frame/editor_frame.test.tsx | 24 +- .../editor_frame/expression_helpers.ts | 18 +- .../editor_frame/suggestion_helpers.test.ts | 10 +- .../editor_frame/suggestion_helpers.ts | 5 +- .../editor_frame/suggestion_panel.test.tsx | 17 + .../editor_frame/suggestion_panel.tsx | 85 +++- .../editor_frame/workspace_panel.test.tsx | 10 +- .../indexpattern_plugin/datapanel.test.tsx | 4 - .../dimension_panel/dimension_panel.test.tsx | 10 - .../indexpattern_plugin/indexpattern.test.ts | 5 - .../indexpattern_plugin/indexpattern.tsx | 3 +- .../indexpattern_suggestions.test.tsx | 399 ++++++++++++++++-- .../indexpattern_suggestions.ts | 267 +++++++++--- .../indexpattern_plugin/layerpanel.test.tsx | 2 - .../operations/definitions/count.tsx | 3 +- .../definitions/date_histogram.test.tsx | 7 - .../operations/definitions/date_histogram.tsx | 2 - .../definitions/filter_ratio.test.tsx | 1 - .../operations/definitions/filter_ratio.tsx | 3 +- .../operations/definitions/index.ts | 6 + .../operations/definitions/metrics.tsx | 73 ++-- .../operations/definitions/terms.test.tsx | 12 - .../operations/definitions/terms.tsx | 3 +- .../operations/operations.test.ts | 6 +- .../operations/operations.ts | 18 +- .../indexpattern_plugin/state_helpers.test.ts | 29 -- .../metric_config_panel.test.tsx | 12 +- .../metric_config_panel.tsx | 2 +- .../metric_suggestions.test.ts | 25 +- .../metric_suggestions.ts | 22 +- .../metric_visualization.test.ts | 1 - .../multi_column_editor.test.tsx | 1 - x-pack/legacy/plugins/lens/public/types.ts | 80 +++- .../xy_visualization.test.ts.snap | 3 - .../xy_visualization_plugin/to_expression.ts | 9 +- .../public/xy_visualization_plugin/types.ts | 5 +- .../xy_config_panel.test.tsx | 7 - .../xy_config_panel.tsx | 3 +- .../xy_expression.test.tsx | 2 - .../xy_suggestions.test.ts | 298 +++++++++---- .../xy_visualization_plugin/xy_suggestions.ts | 264 +++++++++--- .../xy_visualization.test.ts | 2 - .../xy_visualization.tsx | 1 - 47 files changed, 1407 insertions(+), 464 deletions(-) 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 index 39fcce7a2a90c..177dfc9577028 100644 --- 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 @@ -97,7 +97,6 @@ describe('Datatable Visualization', () => { const baseOperation: Operation = { dataType: 'string', isBucketed: true, - isMetric: false, label: '', }; expect(filterOperations({ ...baseOperation })).toEqual(true); 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 index d89a9c0276972..8f9e736499069 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -127,42 +127,57 @@ export const datatableVisualization: Visualization< getSuggestions({ tables, + state, }: SuggestionRequest): Array< VisualizationSuggestion > { const maxColumnCount = Math.max.apply(undefined, tables.map(table => table.columns.length)); - return tables.map(table => { - const title = i18n.translate('xpack.lens.datatable.visualizationOf', { - defaultMessage: 'Table: {operations}', - values: { - operations: 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, - // largest possible table will have a score of 0.2, less columns reduce score - score: (table.columns.length / maxColumnCount) * 0.2, - datasourceSuggestionId: table.datasourceSuggestionId, - state: { - layers: [ - { - layerId: table.layerId, - columns: table.columns.map(col => col.columnId), + return ( + tables + // don't suggest current table if visualization is active + .filter(({ changeType }) => !state || changeType !== 'unchanged') + .map(table => { + 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, + // largest possible table will have a score of 0.2, fewer columns reduce score + score: (table.columns.length / maxColumnCount) * 0.2, + datasourceSuggestionId: table.datasourceSuggestionId, + state: { + layers: [ + { + layerId: table.layerId, + columns: table.columns.map(col => col.columnId), + }, + ], }, - ], - }, - previewIcon: 'visTable', - }; - }); + previewIcon: 'visTable', + // dont show suggestions for reduced versions or single-line tables + hide: table.changeType === 'reduced' || !table.isMultiRow, + }; + }) + ); }, renderConfigPanel: (domElement, props) => 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 index 4413d3b0c474b..5624553d92ddf 100644 --- 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 @@ -94,6 +94,7 @@ describe('chart_switch', () => { datasourceSuggestionId: 0, isMultiRow: true, layerId: 'a', + changeType: 'unchanged', }, }, ]); @@ -205,7 +206,6 @@ describe('chart_switch', () => { label: '', dataType: 'string', isBucketed: true, - isMetric: false, }, }, { @@ -214,13 +214,13 @@ describe('chart_switch', () => { label: '', dataType: 'number', isBucketed: false, - isMetric: true, }, }, ], datasourceSuggestionId: 0, layerId: 'first', isMultiRow: true, + changeType: 'unchanged', }, }, ]); @@ -435,7 +435,6 @@ describe('chart_switch', () => { label: '', dataType: 'string', isBucketed: true, - isMetric: false, }, }, { @@ -444,13 +443,13 @@ describe('chart_switch', () => { label: '', dataType: 'number', isBucketed: false, - isMetric: true, }, }, ], datasourceSuggestionId: 0, layerId: 'a', isMultiRow: true, + changeType: 'unchanged', }, }, ]); 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 index daed3adf9cd46..770f402bdf5f8 100644 --- 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 @@ -18,7 +18,7 @@ import { flatten } from 'lodash'; import { i18n } from '@kbn/i18n'; import { Visualization, FramePublicAPI, Datasource } from '../../types'; import { Action } from './state_management'; -import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; +import { getSuggestions, switchToSuggestion, Suggestion } from './suggestion_helpers'; interface VisualizationSelection { visualizationId: string; @@ -106,13 +106,7 @@ export function ChartSwitch(props: Props) { ([_layerId, datasource]) => datasource.getTableSpec().length > 0 ); - const topSuggestion = getSuggestions({ - datasourceMap: props.datasourceMap, - datasourceStates: props.datasourceStates, - visualizationMap: { [visualizationId]: newVisualization }, - activeVisualizationId: props.visualizationId, - visualizationState: props.visualizationState, - })[0]; + const topSuggestion = getTopSuggestion(props, visualizationId, newVisualization); let dataLoss: VisualizationSelection['dataLoss']; @@ -242,3 +236,20 @@ export function ChartSwitch(props: Props) {
); } +function getTopSuggestion( + props: Props, + visualizationId: string, + newVisualization: Visualization +): Suggestion | undefined { + return 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'; + })[0]; +} 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 index 3904c2c114543..c7d1eb543d10d 100644 --- 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 @@ -32,6 +32,7 @@ function generateSuggestion(datasourceSuggestionId = 1, state = {}): DatasourceS datasourceSuggestionId, isMultiRow: true, layerId: 'first', + changeType: 'unchanged', }, }; } @@ -907,6 +908,7 @@ describe('editor_frame', () => { datasourceSuggestionId: 0, isMultiRow: true, layerId: 'first', + changeType: 'unchanged', }, }, ]); @@ -1068,7 +1070,7 @@ describe('editor_frame', () => { expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); }); - it('should display top 3 suggestions in descending order', async () => { + it('should display top 5 suggestions in descending order', async () => { const instance = mount( { testVis: { ...mockVisualization, getSuggestions: () => [ + { + datasourceSuggestionId: 0, + score: 0.1, + state: {}, + title: 'Suggestion6', + previewIcon: 'empty', + }, { datasourceSuggestionId: 0, score: 0.5, state: {}, + title: 'Suggestion3', + previewIcon: 'empty', + }, + { + datasourceSuggestionId: 0, + score: 0.7, + state: {}, title: 'Suggestion2', previewIcon: 'empty', }, @@ -1099,14 +1115,14 @@ describe('editor_frame', () => { datasourceSuggestionId: 0, score: 0.4, state: {}, - title: 'Suggestion4', + title: 'Suggestion5', previewIcon: 'empty', }, { datasourceSuggestionId: 0, score: 0.45, state: {}, - title: 'Suggestion3', + title: 'Suggestion4', previewIcon: 'empty', }, ], @@ -1133,7 +1149,7 @@ describe('editor_frame', () => { .find('[data-test-subj="lnsSuggestion"]') .find(EuiPanel) .map(el => el.parents(EuiToolTip).prop('content')) - ).toEqual(['Suggestion1', 'Suggestion2', 'Suggestion3']); + ).toEqual(['Suggestion1', 'Suggestion2', 'Suggestion3', 'Suggestion4', 'Suggestion5']); }); it('should switch to suggested visualization', async () => { 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 index a16024e58b254..1b71f28260088 100644 --- 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 @@ -63,7 +63,7 @@ export function prependDatasourceExpression( } export function prependKibanaContext( - expression: Ast | string | null, + expression: Ast | string, { timeRange, query, @@ -73,8 +73,7 @@ export function prependKibanaContext( query?: Query; filters?: Filter[]; } -): Ast | null { - if (!expression) return null; +): Ast { const parsedExpression = typeof expression === 'string' ? fromExpression(expression) : expression; return { @@ -131,8 +130,15 @@ export function buildExpression({ }, }; - return prependKibanaContext( - prependDatasourceExpression(visualizationExpression, datasourceMap, datasourceStates), - expressionContext + 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/suggestion_helpers.test.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts index ddfb79cb07a18..cb4a5bf79e817 100644 --- 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 @@ -14,7 +14,13 @@ const generateSuggestion = ( layerId: string = 'first' ): DatasourceSuggestion => ({ state, - table: { datasourceSuggestionId, columns: [], isMultiRow: false, layerId }, + table: { + datasourceSuggestionId, + columns: [], + isMultiRow: false, + layerId, + changeType: 'unchanged', + }, }); let datasourceMap: Record; @@ -233,12 +239,14 @@ describe('suggestion helpers', () => { columns: [], isMultiRow: true, layerId: 'first', + changeType: 'unchanged', }; const table2: TableSuggestion = { datasourceSuggestionId: 1, columns: [], isMultiRow: true, layerId: 'first', + changeType: 'unchanged', }; datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ { state: {}, table: table1 }, 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 index 5d0894fd20e8b..b3aac25fd1fcf 100644 --- 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 @@ -6,7 +6,7 @@ import _ from 'lodash'; import { Ast } from '@kbn/interpreter/common'; -import { Visualization, Datasource, FramePublicAPI } from '../../types'; +import { Visualization, Datasource, FramePublicAPI, TableChangeType } from '../../types'; import { Action } from './state_management'; export interface Suggestion { @@ -20,6 +20,8 @@ export interface Suggestion { visualizationState: unknown; previewExpression?: Ast | string; previewIcon: string; + hide?: boolean; + changeType: TableChangeType; } /** @@ -100,6 +102,7 @@ export function getSuggestions({ datasourceState: datasourceSuggestion.state, datasourceId: datasourceSuggestion.datasourceId, columns: datasourceSuggestion.table.columns.length, + changeType: datasourceSuggestion.table.changeType, }; }); }) 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 index 66436941dc499..84b4f24bf0f9c 100644 --- 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 @@ -156,6 +156,23 @@ describe('suggestion_panel', () => { 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 [ 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 index e2403245a22b8..8c63d9d03806d 100644 --- 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 @@ -7,16 +7,16 @@ import React, { useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip, EuiToolTip } from '@elastic/eui'; -import { toExpression } from '@kbn/interpreter/common'; +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/data/public'; -import { prependDatasourceExpression } from './expression_helpers'; +import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; -const MAX_SUGGESTIONS_DISPLAYED = 3; +const MAX_SUGGESTIONS_DISPLAYED = 5; export interface SuggestionPanelProps { activeDatasourceId: string | null; @@ -122,7 +122,9 @@ function InnerSuggestionPanel({ visualizationMap, activeVisualizationId, visualizationState, - }).slice(0, MAX_SUGGESTIONS_DISPLAYED); + }) + .filter(suggestion => !suggestion.hide) + .slice(0, MAX_SUGGESTIONS_DISPLAYED); if (suggestions.length === 0) { return null; @@ -139,26 +141,63 @@ function InnerSuggestionPanel({
- {suggestions.map(suggestion => { - const previewExpression = suggestion.previewExpression - ? prependDatasourceExpression( - suggestion.previewExpression, - datasourceMap, - datasourceStates - ) - : 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 index c24836415c0f4..04f173c75eab2 100644 --- 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 @@ -7,7 +7,7 @@ import React from 'react'; import { ExpressionRendererProps } from '../../../../../../../src/legacy/core_plugins/data/public'; -import { Visualization, FramePublicAPI } from '../../types'; +import { Visualization, FramePublicAPI, TableSuggestion } from '../../types'; import { createMockVisualization, createMockDatasource, @@ -548,11 +548,12 @@ describe('workspace_panel', () => { } it('should immediately transition if exactly one suggestion is returned', () => { - const expectedTable = { + const expectedTable: TableSuggestion = { datasourceSuggestionId: 0, isMultiRow: true, layerId: '1', columns: [], + changeType: 'unchanged', }; mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ { @@ -597,6 +598,7 @@ describe('workspace_panel', () => { isMultiRow: true, layerId: '1', columns: [], + changeType: 'unchanged', }, }, ]); @@ -624,6 +626,7 @@ describe('workspace_panel', () => { isMultiRow: true, layerId: '1', columns: [], + changeType: 'unchanged', }, }, ]); @@ -651,6 +654,7 @@ describe('workspace_panel', () => { isMultiRow: true, layerId: '1', columns: [], + changeType: 'unchanged', }, }, ]); @@ -681,6 +685,7 @@ describe('workspace_panel', () => { isMultiRow: true, columns: [], layerId: '1', + changeType: 'unchanged', }, }, { @@ -690,6 +695,7 @@ describe('workspace_panel', () => { isMultiRow: true, columns: [], layerId: '1', + changeType: 'unchanged', }, }, ]); 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 index 7f74fd8c1f0b8..dfd4adde48560 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -27,7 +27,6 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'string', isBucketed: true, - isMetric: false, operationType: 'terms', sourceField: 'source', params: { @@ -42,7 +41,6 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, - isMetric: true, operationType: 'avg', sourceField: 'memory', }, @@ -56,7 +54,6 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'string', isBucketed: true, - isMetric: false, operationType: 'terms', sourceField: 'source', params: { @@ -71,7 +68,6 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, - isMetric: true, operationType: 'avg', sourceField: 'bytes', }, 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 index f45b674e0c19b..2ddfce6b7e0a5 100644 --- 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 @@ -89,7 +89,6 @@ describe('IndexPatternDimensionPanel', () => { label: 'Date Histogram of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -203,7 +202,6 @@ describe('IndexPatternDimensionPanel', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'max', @@ -245,7 +243,6 @@ describe('IndexPatternDimensionPanel', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'max', @@ -287,7 +284,6 @@ describe('IndexPatternDimensionPanel', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'max', @@ -372,7 +368,6 @@ describe('IndexPatternDimensionPanel', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'max', @@ -546,7 +541,6 @@ describe('IndexPatternDimensionPanel', () => { col2: { dataType: 'number', isBucketed: false, - isMetric: true, label: '', operationType: 'avg', sourceField: 'bytes', @@ -584,7 +578,6 @@ describe('IndexPatternDimensionPanel', () => { col2: { dataType: 'number', isBucketed: false, - isMetric: true, label: '', operationType: 'count', }, @@ -779,7 +772,6 @@ describe('IndexPatternDimensionPanel', () => { col2: { dataType: 'number', isBucketed: false, - isMetric: true, label: '', operationType: 'count', }, @@ -862,7 +854,6 @@ describe('IndexPatternDimensionPanel', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'max', @@ -965,7 +956,6 @@ describe('IndexPatternDimensionPanel', () => { label: 'Date Histogram of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', 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 index 8307b8e0ab828..336deef6147a3 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts @@ -153,7 +153,6 @@ describe('IndexPattern Data Source', () => { label: 'My Op', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -215,7 +214,6 @@ describe('IndexPattern Data Source', () => { label: 'Count of Documents', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'count', @@ -224,7 +222,6 @@ describe('IndexPattern Data Source', () => { label: 'Date', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -394,7 +391,6 @@ describe('IndexPattern Data Source', () => { const sampleColumn: IndexPatternColumn = { dataType: 'number', isBucketed: false, - isMetric: true, label: 'foo', operationType: 'max', sourceField: 'baz', @@ -445,7 +441,6 @@ describe('IndexPattern Data Source', () => { label: 'My Op', dataType: 'string', isBucketed: true, - isMetric: false, } as Operation); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index a7cbdd59c6e0f..874ee486b3c6b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -81,13 +81,12 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { }; export function columnToOperation(column: IndexPatternColumn): Operation { - const { dataType, label, isBucketed, isMetric, scale } = column; + const { dataType, label, isBucketed, scale } = column; return { label, dataType, isBucketed, scale, - isMetric, }; } 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 index 37fee8f279d7a..d7c61e1f1c73d 100644 --- 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 @@ -153,7 +153,6 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -175,11 +174,14 @@ describe('IndexPattern Data Source suggestions', () => { 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', () => { @@ -187,7 +189,6 @@ describe('IndexPattern Data Source suggestions', () => { field: { name: 'source', type: 'string', aggregatable: true, searchable: true }, indexPatternId: '1', }); - expect(suggestions).toHaveLength(1); expect(suggestions[0].state).toEqual( expect.objectContaining({ @@ -208,6 +209,8 @@ describe('IndexPattern Data Source suggestions', () => { }) ); expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, datasourceSuggestionId: 0, isMultiRow: true, columns: [ @@ -248,6 +251,8 @@ describe('IndexPattern Data Source suggestions', () => { }) ); expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, datasourceSuggestionId: 0, isMultiRow: true, columns: [ @@ -280,7 +285,7 @@ describe('IndexPattern Data Source suggestions', () => { sourceField: 'timestamp', }), col2: expect.objectContaining({ - operationType: 'min', + operationType: 'avg', sourceField: 'bytes', }), }, @@ -289,6 +294,8 @@ describe('IndexPattern Data Source suggestions', () => { }) ); expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, datasourceSuggestionId: 0, isMultiRow: true, columns: [ @@ -342,6 +349,7 @@ describe('IndexPattern Data Source suggestions', () => { let initialState: IndexPatternPrivateState; beforeEach(async () => { + jest.resetAllMocks(); initialState = await indexPatternDatasource.initialize({ currentIndexPatternId: '1', layers: { @@ -352,6 +360,8 @@ describe('IndexPattern Data Source suggestions', () => { }, }, }); + (generateId as jest.Mock).mockReturnValueOnce('col1'); + (generateId as jest.Mock).mockReturnValueOnce('col2'); }); it('should apply a bucketed aggregation for a string field', () => { @@ -380,6 +390,8 @@ describe('IndexPattern Data Source suggestions', () => { }) ); expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, datasourceSuggestionId: 0, isMultiRow: true, columns: [ @@ -420,6 +432,8 @@ describe('IndexPattern Data Source suggestions', () => { }) ); expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, datasourceSuggestionId: 0, isMultiRow: true, columns: [ @@ -452,7 +466,7 @@ describe('IndexPattern Data Source suggestions', () => { sourceField: 'timestamp', }), col2: expect.objectContaining({ - operationType: 'min', + operationType: 'avg', sourceField: 'bytes', }), }, @@ -461,6 +475,8 @@ describe('IndexPattern Data Source suggestions', () => { }) ); expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, datasourceSuggestionId: 0, isMultiRow: true, columns: [ @@ -530,7 +546,6 @@ describe('IndexPattern Data Source suggestions', () => { col1: { dataType: 'string', isBucketed: true, - isMetric: false, sourceField: 'source', label: 'values of source', operationType: 'terms', @@ -543,10 +558,9 @@ describe('IndexPattern Data Source suggestions', () => { col2: { dataType: 'number', isBucketed: false, - isMetric: true, sourceField: 'bytes', - label: 'Min of bytes', - operationType: 'min', + label: 'Avg of bytes', + operationType: 'avg', }, }, columnOrder: ['col1', 'col2'], @@ -567,7 +581,6 @@ describe('IndexPattern Data Source suggestions', () => { col1: { dataType: 'date', isBucketed: true, - isMetric: false, sourceField: 'timestamp', label: 'date histogram of timestamp', operationType: 'date_histogram', @@ -578,10 +591,9 @@ describe('IndexPattern Data Source suggestions', () => { col2: { dataType: 'number', isBucketed: false, - isMetric: true, sourceField: 'bytes', - label: 'Min of bytes', - operationType: 'min', + label: 'Avg of bytes', + operationType: 'avg', }, }, }, @@ -638,6 +650,8 @@ describe('IndexPattern Data Source suggestions', () => { }) ); expect(suggestions[0].table).toEqual({ + changeType: 'extended', + label: undefined, datasourceSuggestionId: 0, isMultiRow: true, columns: [ @@ -664,7 +678,7 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions).toHaveLength(0); }); - it('prepends a terms column on string field', () => { + 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', @@ -676,7 +690,7 @@ describe('IndexPattern Data Source suggestions', () => { layers: { previousLayer: initialState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['newId', 'col1', 'col2'], + columnOrder: ['col1', 'newId', 'col2'], columns: { ...initialState.layers.currentLayer.columns, newId: expect.objectContaining({ @@ -706,7 +720,7 @@ describe('IndexPattern Data Source suggestions', () => { columns: { ...initialState.layers.currentLayer.columns, newId: expect.objectContaining({ - operationType: 'min', + operationType: 'avg', sourceField: 'memory', }), }, @@ -732,7 +746,7 @@ describe('IndexPattern Data Source suggestions', () => { columns: { ...initialState.layers.currentLayer.columns, newId: expect.objectContaining({ - operationType: 'max', + operationType: 'sum', sourceField: 'bytes', }), }, @@ -747,6 +761,7 @@ describe('IndexPattern Data Source suggestions', () => { let initialState: IndexPatternPrivateState; beforeEach(async () => { + jest.resetAllMocks(); initialState = await indexPatternDatasource.initialize({ currentIndexPatternId: '1', layers: { @@ -762,6 +777,8 @@ describe('IndexPattern Data Source suggestions', () => { }, }, }); + (generateId as jest.Mock).mockReturnValueOnce('col1'); + (generateId as jest.Mock).mockReturnValueOnce('col2'); }); it('suggests on the layer that matches by indexPatternId', () => { @@ -804,6 +821,8 @@ describe('IndexPattern Data Source suggestions', () => { }) ); expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, datasourceSuggestionId: 0, isMultiRow: true, columns: [ @@ -887,9 +906,8 @@ describe('IndexPattern Data Source suggestions', () => { columns: { col1: { label: 'My Op 2', - dataType: 'number', + dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -909,6 +927,8 @@ describe('IndexPattern Data Source suggestions', () => { table: { datasourceSuggestionId: 0, isMultiRow: true, + changeType: 'unchanged', + label: undefined, columns: [ { columnId: 'col1', @@ -916,7 +936,7 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'string', isBucketed: true, - isMetric: false, + scale: undefined, }, }, ], @@ -927,14 +947,16 @@ describe('IndexPattern Data Source suggestions', () => { table: { datasourceSuggestionId: 1, isMultiRow: true, + changeType: 'unchanged', + label: undefined, columns: [ { columnId: 'col1', operation: { label: 'My Op 2', - dataType: 'number', + dataType: 'string', isBucketed: true, - isMetric: false, + scale: undefined, }, }, ], @@ -944,9 +966,208 @@ describe('IndexPattern Data Source suggestions', () => { ]); }); - it('returns simplified versions of table with more than 2 columns', async () => { + 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: { + datasourceSuggestionId: 0, + 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: { + datasourceSuggestionId: 2, + 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, + }, + ], + }, + }, layers: { first: { ...persistedState.layers.first, @@ -955,7 +1176,6 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'string', isBucketed: true, - isMetric: false, operationType: 'terms', sourceField: 'field1', @@ -969,7 +1189,6 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'string', isBucketed: true, - isMetric: false, operationType: 'terms', sourceField: 'field2', @@ -983,7 +1202,6 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'string', isBucketed: true, - isMetric: false, operationType: 'terms', sourceField: 'field3', @@ -997,7 +1215,6 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'number', isBucketed: false, - isMetric: true, operationType: 'avg', sourceField: 'field4', @@ -1006,7 +1223,6 @@ describe('IndexPattern Data Source suggestions', () => { label: 'My Op', dataType: 'number', isBucketed: false, - isMetric: true, operationType: 'min', sourceField: 'field5', @@ -1015,31 +1231,135 @@ describe('IndexPattern Data Source suggestions', () => { columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5'], }, }, - }); + }; const suggestions = indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state); // 1 bucket col, 2 metric cols - validateTable(suggestions[0], ['col1', 'col4', 'col5'], 1); + isTableWithBucketColumns(suggestions[0], ['col1', 'col4', 'col5'], 1); // 1 bucket col, 1 metric col - validateTable(suggestions[1], ['col1', 'col4'], 1); + isTableWithBucketColumns(suggestions[1], ['col1', 'col4'], 1); // 2 bucket cols, 2 metric cols - validateTable(suggestions[2], ['col1', 'col2', 'col4', 'col5'], 2); + isTableWithBucketColumns(suggestions[2], ['col1', 'col2', 'col4', 'col5'], 2); // 2 bucket cols, 1 metric col - validateTable(suggestions[3], ['col1', 'col2', 'col4'], 2); + isTableWithBucketColumns(suggestions[3], ['col1', 'col2', 'col4'], 2); // 3 bucket cols, 2 metric cols - validateTable(suggestions[4], ['col1', 'col2', 'col3', 'col4', 'col5'], 3); + isTableWithBucketColumns(suggestions[4], ['col1', 'col2', 'col3', 'col4', 'col5'], 3); // 3 bucket cols, 1 metric col - validateTable(suggestions[5], ['col1', 'col2', 'col3', 'col4'], 3); + 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, + }, + ], + }, + }, + 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, + }, + ], + }, + }, + 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 validateTable( +function isTableWithBucketColumns( suggestion: DatasourceSuggestion, columnIds: string[], numBuckets: number @@ -1049,3 +1369,12 @@ function validateTable( 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 index a3a04802b0a31..5ed236ee73933 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; +import _, { partition } from 'lodash'; +import { i18n } from '@kbn/i18n'; import { generateId } from '../id_generator'; -import { DatasourceSuggestion } from '../types'; +import { DatasourceSuggestion, TableChangeType } from '../types'; import { columnToOperation, IndexPatternField, @@ -14,28 +15,28 @@ import { IndexPatternPrivateState, IndexPattern, } from './indexpattern'; -import { buildColumn, getOperationTypesForField } from './operations'; +import { buildColumn, getOperationTypesForField, operationDefinitionMap } from './operations'; import { hasField } from './utils'; function buildSuggestion({ state, updatedLayer, layerId, - isMultiRow, datasourceSuggestionId, + label, + changeType, }: { state: IndexPatternPrivateState; layerId: string; + changeType: TableChangeType; updatedLayer?: IndexPatternLayer; - isMultiRow?: boolean; datasourceSuggestionId?: number; -}) { + label?: string; +}): DatasourceSuggestion { const columnOrder = (updatedLayer || state.layers[layerId]).columnOrder; const columnMap = (updatedLayer || state.layers[layerId]).columns; - const columns = columnOrder.map(columnId => ({ - columnId, - operation: columnToOperation(columnMap[columnId]), - })); + + const isMultiRow = Object.values(columnMap).some(column => column.isBucketed); return { state: updatedLayer @@ -49,10 +50,15 @@ function buildSuggestion({ : state, table: { - columns, - isMultiRow: isMultiRow || columns.some(col => !columnMap[col.columnId].isMetric), + columns: columnOrder.map(columnId => ({ + columnId, + operation: columnToOperation(columnMap[columnId]), + })), + isMultiRow, datasourceSuggestionId: datasourceSuggestionId || 0, layerId, + changeType, + label, }, }; } @@ -71,6 +77,9 @@ export function getDatasourceSuggestionsForField( // 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); @@ -109,6 +118,7 @@ function getExistingLayerSuggestionsForField( state, updatedLayer, layerId, + changeType: 'extended', }), ] : []; @@ -169,6 +179,7 @@ function addFieldAsBucketOperation( suggestedPriority: undefined, field, }); + const [buckets, metrics] = separateBucketColumns(layer); const newColumnId = generateId(); const updatedColumns = { ...layer.columns, @@ -176,7 +187,7 @@ function addFieldAsBucketOperation( }; let updatedColumnOrder: string[] = []; if (applicableBucketOperation === 'terms') { - updatedColumnOrder = [newColumnId, ...layer.columnOrder]; + updatedColumnOrder = [...buckets, newColumnId, ...metrics]; } else { const oldDateHistogramColumn = layer.columnOrder.find( columnId => layer.columns[columnId].operationType === 'date_histogram' @@ -187,13 +198,7 @@ function addFieldAsBucketOperation( columnId !== oldDateHistogramColumn ? columnId : newColumnId ); } else { - const bucketedColumns = layer.columnOrder.filter( - columnId => layer.columns[columnId].isBucketed - ); - const metricColumns = layer.columnOrder.filter( - columnId => !layer.columns[columnId].isBucketed - ); - updatedColumnOrder = [...bucketedColumns, newColumnId, ...metricColumns]; + updatedColumnOrder = [...buckets, newColumnId, ...metrics]; } } return { @@ -222,6 +227,7 @@ function getEmptyLayerSuggestionsForField( state, updatedLayer: newLayer, layerId, + changeType: 'initial', }), ] : []; @@ -240,13 +246,16 @@ function createNewLayerWithBucketAggregation( 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, + [col2]: countColumn, }, field, suggestedPriority: undefined, @@ -255,10 +264,10 @@ function createNewLayerWithBucketAggregation( return { indexPatternId: indexPattern.id, columns: { - col1: column, - col2: countColumn, + [col1]: column, + [col2]: countColumn, }, - columnOrder: ['col1', 'col2'], + columnOrder: [col1, col2], }; } @@ -288,13 +297,16 @@ function createNewLayerWithMetricAggregation( layerId, }); + const col1 = generateId(); + const col2 = generateId(); + return { indexPatternId: indexPattern.id, columns: { - col1: dateColumn, - col2: column, + [col1]: dateColumn, + [col2]: column, }, - columnOrder: ['col1', 'col2'], + columnOrder: [col1, col2], }; } @@ -305,23 +317,54 @@ export function getDatasourceSuggestionsFromCurrentState( Object.entries(state.layers || {}) .filter(([_id, layer]) => layer.columnOrder.length) .map(([layerId, layer], index) => { - if (layer.columnOrder.length === 0) { - return []; - } + 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, + datasourceSuggestionId: index, + 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, + datasourceSuggestionId: index, + changeType: 'unchanged', + }) + ); + } else { + suggestions.push(...createSimplifiedTableSuggestions(state, layerId)); - const onlyMetric = layer.columnOrder.every(columnId => !layer.columns[columnId].isBucketed); - const onlyBucket = layer.columnOrder.every(columnId => layer.columns[columnId].isBucketed); - if (onlyMetric || onlyBucket) { - // intermediary chart, don't try to suggest reduced versions - return buildSuggestion({ - state, - layerId, - isMultiRow: false, - datasourceSuggestionId: index, - }); + 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 createSimplifiedTableSuggestions(state, layerId); + return suggestions; }) ).map( (suggestion, index): DatasourceSuggestion => ({ @@ -331,18 +374,106 @@ export function getDatasourceSuggestionsFromCurrentState( ); } -function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layerId: string) { +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', + }); +} - const availableBucketedColumns = layer.columnOrder.filter( - columnId => layer.columns[columnId].isBucketed - ); - const availableMetricColumns = layer.columnOrder.filter( - columnId => !layer.columns[columnId].isBucketed +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 = buildLayerByColumnOrder({ ...layer, columns: { [newId]: newColumn } }, [ + 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 = buildLayerByColumnOrder( + { ...layer, columns: { ...layer.columns, [newId]: timeColumn } }, + [...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 = buildLayerByColumnOrder(layer, [ ...bucketedColumns, @@ -358,10 +489,48 @@ function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layer return allMetricsSuggestion; } }) - ).map(updatedLayer => buildSuggestion({ state, layerId, isMultiRow: true, updatedLayer })); + ) + .concat( + availableMetricColumns.map(columnId => { + // build suggestions with only metrics + return buildLayerByColumnOrder(layer, [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 buildLayerByColumnOrder(layer: IndexPatternLayer, columnOrder: string[]) { +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); +} + +function buildLayerByColumnOrder( + layer: IndexPatternLayer, + columnOrder: string[] +): IndexPatternLayer { return { ...layer, columns: _.pick(layer.columns, columnOrder), 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 index 0faa6b4725896..46e381d69741b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx @@ -27,7 +27,6 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'string', isBucketed: true, - isMetric: false, operationType: 'terms', sourceField: 'source', params: { @@ -42,7 +41,6 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, - isMetric: true, operationType: 'avg', sourceField: 'memory', }, 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 index 8a47e68a279ad..68a36787ec189 100644 --- 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 @@ -19,6 +19,7 @@ export type CountIndexPatternColumn = ParameterlessIndexPatternColumn< export const countOperation: OperationDefinition = { type: 'count', + priority: 2, displayName: i18n.translate('xpack.lens.indexPattern.count', { defaultMessage: 'Count', }), @@ -26,7 +27,6 @@ export const countOperation: OperationDefinition = { return { dataType: 'number', isBucketed: false, - isMetric: true, scale: 'ratio', }; }, @@ -37,7 +37,6 @@ export const countOperation: OperationDefinition = { operationType: 'count', suggestedPriority, isBucketed: false, - isMetric: true, scale: 'ratio', }; }, 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 index 12015a281eaa9..d77f9343fd124 100644 --- 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 @@ -61,7 +61,6 @@ describe('date_histogram', () => { label: 'Value of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -80,7 +79,6 @@ describe('date_histogram', () => { label: 'Value of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -99,7 +97,6 @@ describe('date_histogram', () => { label: 'Value of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -199,7 +196,6 @@ describe('date_histogram', () => { sourceField: 'timestamp', label: 'Date over timestamp', isBucketed: true, - isMetric: false, dataType: 'date', params: { interval: 'd', @@ -220,7 +216,6 @@ describe('date_histogram', () => { sourceField: 'timestamp', label: 'Date over timestamp', isBucketed: true, - isMetric: false, dataType: 'date', params: { interval: 'auto', @@ -242,7 +237,6 @@ describe('date_histogram', () => { { dataType: 'date', isBucketed: true, - isMetric: false, label: '', operationType: 'date_histogram', sourceField: 'dateField', @@ -285,7 +279,6 @@ describe('date_histogram', () => { { dataType: 'date', isBucketed: true, - isMetric: false, label: '', operationType: 'date_histogram', sourceField: 'dateField', 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 index 6c75141388514..a90e4e7a3068d 100644 --- 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 @@ -62,7 +62,6 @@ export const dateHistogramOperation: OperationDefinition { label: 'Filter Ratio', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'filter_ratio', 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 index 6dee938b1fce9..0d50801708b3e 100644 --- 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 @@ -30,6 +30,7 @@ export interface FilterRatioIndexPatternColumn extends BaseIndexPatternColumn { export const filterRatioOperation: OperationDefinition = { type: 'filter_ratio', + priority: 1, displayName: i18n.translate('xpack.lens.indexPattern.filterRatio', { defaultMessage: 'Filter Ratio', }), @@ -37,7 +38,6 @@ export const filterRatioOperation: OperationDefinition { 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. 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 index f33bd1cfd3967..43129f0f1e5d0 100644 --- 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 @@ -8,13 +8,20 @@ import { i18n } from '@kbn/i18n'; import { OperationDefinition } from '.'; import { ParameterlessIndexPatternColumn } from './column_types'; -function buildMetricOperation>( - type: T['operationType'], - displayName: string, - ofName: (name: string) => string -) { +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 ( @@ -25,7 +32,6 @@ function buildMetricOperation> return { dataType: 'number', isBucketed: false, - isMetric: true, scale: 'ratio', }; } @@ -41,13 +47,12 @@ function buildMetricOperation> ); }, buildColumn: ({ suggestedPriority, field }) => ({ - label: ofName(field ? field.name : ''), + label: ofName(field.name), dataType: 'number', operationType: type, suggestedPriority, - sourceField: field ? field.name : '', + sourceField: field.name, isBucketed: false, - isMetric: true, scale: 'ratio', }), onFieldChange: (oldColumn, indexPattern, field) => { @@ -74,50 +79,52 @@ export type AvgIndexPatternColumn = ParameterlessIndexPatternColumn<'avg'>; export type MinIndexPatternColumn = ParameterlessIndexPatternColumn<'min'>; export type MaxIndexPatternColumn = ParameterlessIndexPatternColumn<'max'>; -export const minOperation = buildMetricOperation( - 'min', - i18n.translate('xpack.lens.indexPattern.min', { +export const minOperation = buildMetricOperation({ + type: 'min', + displayName: i18n.translate('xpack.lens.indexPattern.min', { defaultMessage: 'Minimum', }), - name => + ofName: name => i18n.translate('xpack.lens.indexPattern.minOf', { defaultMessage: 'Minimum of {name}', values: { name }, - }) -); + }), +}); -export const maxOperation = buildMetricOperation( - 'max', - i18n.translate('xpack.lens.indexPattern.max', { +export const maxOperation = buildMetricOperation({ + type: 'max', + displayName: i18n.translate('xpack.lens.indexPattern.max', { defaultMessage: 'Maximum', }), - name => + ofName: name => i18n.translate('xpack.lens.indexPattern.maxOf', { defaultMessage: 'Maximum of {name}', values: { name }, - }) -); + }), +}); -export const averageOperation = buildMetricOperation( - 'avg', - i18n.translate('xpack.lens.indexPattern.avg', { +export const averageOperation = buildMetricOperation({ + type: 'avg', + priority: 2, + displayName: i18n.translate('xpack.lens.indexPattern.avg', { defaultMessage: 'Average', }), - name => + ofName: name => i18n.translate('xpack.lens.indexPattern.avgOf', { defaultMessage: 'Average of {name}', values: { name }, - }) -); + }), +}); -export const sumOperation = buildMetricOperation( - 'sum', - i18n.translate('xpack.lens.indexPattern.sum', { +export const sumOperation = buildMetricOperation({ + type: 'sum', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.sum', { defaultMessage: 'Sum', }), - name => + 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 index b6883a0cc3709..18b4e5e754f19 100644 --- 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 @@ -33,7 +33,6 @@ describe('terms', () => { label: 'Top value of category', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -48,7 +47,6 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'count', @@ -85,7 +83,6 @@ describe('terms', () => { label: 'Top values of source', isBucketed: true, dataType: 'string', - isMetric: false, params: { size: 5, orderBy: { @@ -123,7 +120,6 @@ describe('terms', () => { ).toEqual({ dataType: 'string', isBucketed: true, - isMetric: false, scale: 'ordinal', }); @@ -137,7 +133,6 @@ describe('terms', () => { ).toEqual({ dataType: 'boolean', isBucketed: true, - isMetric: false, scale: 'ordinal', }); }); @@ -191,7 +186,6 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'count', @@ -218,7 +212,6 @@ describe('terms', () => { label: 'Top value of category', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -234,7 +227,6 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'count', @@ -249,7 +241,6 @@ describe('terms', () => { label: 'Top value of category', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -275,7 +266,6 @@ describe('terms', () => { label: 'Top value of category', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -291,7 +281,6 @@ describe('terms', () => { label: 'Value of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -350,7 +339,6 @@ describe('terms', () => { label: 'Count', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'filter_ratio', 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 index c13479c04c0c5..6f1a1a3b5471c 100644 --- 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 @@ -58,7 +58,7 @@ export const termsOperation: OperationDefinition = { aggregatable && (!aggregationRestrictions || aggregationRestrictions.terms) ) { - return { dataType: type, isBucketed: true, isMetric: false, scale: 'ordinal' }; + return { dataType: type, isBucketed: true, scale: 'ordinal' }; } }, isTransferable: (column, newIndexPattern) => { @@ -84,7 +84,6 @@ export const termsOperation: OperationDefinition = { suggestedPriority, sourceField: field.name, isBucketed: true, - isMetric: false, params: { size: DEFAULT_SIZE, orderBy: existingMetricColumn 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 index 15a3b8ab19296..878b1dda7b1ce 100644 --- 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 @@ -156,7 +156,6 @@ describe('getOperationTypesForField', () => { label: 'Date Histogram of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -204,7 +203,7 @@ describe('getOperationTypesForField', () => { suggestedPriority: 0, field, }) as MinIndexPatternColumn; - expect(column.operationType).toEqual('min'); + expect(column.operationType).toEqual('avg'); expect(column.sourceField).toEqual(field.name); }); @@ -228,7 +227,6 @@ describe('getOperationTypesForField', () => { "operationMetaData": Object { "dataType": "string", "isBucketed": true, - "isMetric": false, "scale": "ordinal", }, "operations": Array [ @@ -243,7 +241,6 @@ describe('getOperationTypesForField', () => { "operationMetaData": Object { "dataType": "date", "isBucketed": true, - "isMetric": false, "scale": "interval", }, "operations": Array [ @@ -258,7 +255,6 @@ describe('getOperationTypesForField', () => { "operationMetaData": Object { "dataType": "number", "isBucketed": false, - "isMetric": true, "scale": "ratio", }, "operations": Array [ 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 index 2c9d8a6a8d9eb..0b5a1dd903462 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.ts @@ -62,6 +62,9 @@ export function getOperationTypesForField(field: IndexPatternField) { 'getPossibleOperationForField' in operationDefinition && operationDefinition.getPossibleOperationForField(field) ) + .sort( + (a, b) => (b.priority || Number.NEGATIVE_INFINITY) - (a.priority || Number.NEGATIVE_INFINITY) + ) .map(({ type }) => type); } @@ -154,6 +157,13 @@ function getPossibleOperationForField( : 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. @@ -208,12 +218,12 @@ export function buildColumn({ if (op) { operationDefinition = operationDefinitionMap[op]; } else if (asDocumentOperation) { - operationDefinition = operationDefinitions.find(definition => - getPossibleOperationForDocument(definition, indexPattern) + operationDefinition = getDefinition(definition => + Boolean(getPossibleOperationForDocument(definition, indexPattern)) ); } else if (field) { - operationDefinition = operationDefinitions.find(definition => - getPossibleOperationForField(definition, field) + operationDefinition = getDefinition(definition => + Boolean(getPossibleOperationForField(definition, field)) ); } 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 index 1d366b931b89b..d093f50c1bea1 100644 --- 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 @@ -27,7 +27,6 @@ describe('state_helpers', () => { label: 'Top values of source', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -52,7 +51,6 @@ describe('state_helpers', () => { label: 'Count', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'count', @@ -74,7 +72,6 @@ describe('state_helpers', () => { label: 'Top values of source', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -99,7 +96,6 @@ describe('state_helpers', () => { label: 'Count', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'count', @@ -127,7 +123,6 @@ describe('state_helpers', () => { label: 'Value of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -180,7 +175,6 @@ describe('state_helpers', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'avg', @@ -190,7 +184,6 @@ describe('state_helpers', () => { label: 'Max of bytes', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'max', @@ -209,7 +202,6 @@ describe('state_helpers', () => { label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -242,7 +234,6 @@ describe('state_helpers', () => { label: 'Date histogram of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -264,7 +255,6 @@ describe('state_helpers', () => { label: 'Date histogram of order_date', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -286,7 +276,6 @@ describe('state_helpers', () => { label: 'Top values of source', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -302,7 +291,6 @@ describe('state_helpers', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'avg', @@ -322,7 +310,6 @@ describe('state_helpers', () => { label: 'Count', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'count', @@ -358,7 +345,6 @@ describe('state_helpers', () => { label: 'Value of timestamp', dataType: 'string', isBucketed: false, - isMetric: false, // Private operationType: 'date_histogram', @@ -378,7 +364,6 @@ describe('state_helpers', () => { label: 'Top Values of category', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -395,7 +380,6 @@ describe('state_helpers', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'avg', @@ -405,7 +389,6 @@ describe('state_helpers', () => { label: 'Date Histogram of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -425,7 +408,6 @@ describe('state_helpers', () => { label: 'Top Values of category', dataType: 'string', isBucketed: true, - isMetric: false, // Private operationType: 'terms', @@ -443,7 +425,6 @@ describe('state_helpers', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - isMetric: true, // Private operationType: 'avg', @@ -454,7 +435,6 @@ describe('state_helpers', () => { label: 'Date Histogram of timestamp', dataType: 'date', isBucketed: true, - isMetric: false, // Private operationType: 'date_histogram', @@ -534,7 +514,6 @@ describe('state_helpers', () => { col1: { dataType: 'string', isBucketed: true, - isMetric: false, label: '', operationType: 'terms', sourceField: 'fieldA', @@ -547,7 +526,6 @@ describe('state_helpers', () => { col2: { dataType: 'number', isBucketed: false, - isMetric: true, label: '', operationType: 'avg', sourceField: 'xxx', @@ -569,7 +547,6 @@ describe('state_helpers', () => { col1: { dataType: 'string', isBucketed: true, - isMetric: false, label: '', operationType: 'date_histogram', sourceField: 'fieldC', @@ -580,7 +557,6 @@ describe('state_helpers', () => { col2: { dataType: 'number', isBucketed: false, - isMetric: true, label: '', operationType: 'avg', sourceField: 'fieldB', @@ -602,7 +578,6 @@ describe('state_helpers', () => { col1: { dataType: 'date', isBucketed: true, - isMetric: false, label: '', operationType: 'date_histogram', sourceField: 'fieldD', @@ -633,7 +608,6 @@ describe('state_helpers', () => { col1: { dataType: 'string', isBucketed: true, - isMetric: false, label: '', operationType: 'terms', sourceField: 'fieldA', @@ -646,7 +620,6 @@ describe('state_helpers', () => { col2: { dataType: 'number', isBucketed: false, - isMetric: true, label: '', operationType: 'avg', sourceField: 'fieldD', @@ -668,7 +641,6 @@ describe('state_helpers', () => { col1: { dataType: 'string', isBucketed: true, - isMetric: false, label: '', operationType: 'terms', sourceField: 'fieldA', @@ -681,7 +653,6 @@ describe('state_helpers', () => { col2: { dataType: 'number', isBucketed: false, - isMetric: true, label: '', operationType: 'min', sourceField: 'fieldC', 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 index e2c184a7a4803..ff2e55ac83dcc 100644 --- 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 @@ -54,21 +54,15 @@ describe('MetricConfigPanel', () => { const exampleOperation: Operation = { dataType: 'number', isBucketed: false, - isMetric: false, label: 'bar', }; const ops: Operation[] = [ - { ...exampleOperation, isMetric: true, dataType: 'number' }, - { ...exampleOperation, isMetric: false, dataType: 'number' }, + { ...exampleOperation, dataType: 'number' }, { ...exampleOperation, dataType: 'string' }, - { ...exampleOperation, isMetric: true, dataType: 'boolean' }, - { ...exampleOperation, isMetric: false, dataType: 'boolean' }, + { ...exampleOperation, dataType: 'boolean' }, { ...exampleOperation, dataType: 'date' }, ]; expect(columnId).toEqual('shazm'); - expect(ops.filter(filterOperations)).toEqual([ - { ...exampleOperation, isMetric: true, dataType: 'number' }, - { ...exampleOperation, isMetric: true, dataType: 'boolean' }, - ]); + 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 index e5d1d7dc731fc..d558f14fdd7c6 100644 --- 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 @@ -11,7 +11,7 @@ import { State } from './types'; import { VisualizationProps, OperationMetadata } from '../types'; import { NativeRenderer } from '../native_renderer'; -const isMetric = (op: OperationMetadata) => op.isMetric; +const isMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; export function MetricConfigPanel(props: VisualizationProps) { const { state, frame } = props; 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 index bf9d5ad4340f2..a28e8dc7300f1 100644 --- 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 @@ -15,7 +15,6 @@ describe('metric_suggestions', () => { dataType: 'number', label: `Avg ${columnId}`, isBucketed: false, - isMetric: true, }, }; } @@ -27,7 +26,6 @@ describe('metric_suggestions', () => { dataType: 'string', label: `Top 5 ${columnId}`, isBucketed: true, - isMetric: false, }, }; } @@ -38,7 +36,6 @@ describe('metric_suggestions', () => { operation: { dataType: 'date', isBucketed: true, - isMetric: false, label: `${columnId} histogram`, }, }; @@ -54,25 +51,40 @@ describe('metric_suggestions', () => { expect( getSuggestions({ tables: [ - { columns: [dateCol('a')], datasourceSuggestionId: 0, isMultiRow: true, layerId: 'l1' }, + { + columns: [dateCol('a')], + datasourceSuggestionId: 0, + isMultiRow: true, + layerId: 'l1', + changeType: 'unchanged', + }, { columns: [strCol('foo'), strCol('bar')], datasourceSuggestionId: 1, isMultiRow: true, layerId: 'l1', + changeType: 'unchanged', + }, + { + layerId: 'l1', + datasourceSuggestionId: 2, + isMultiRow: true, + columns: [numCol('bar')], + changeType: 'unchanged', }, - { layerId: 'l1', datasourceSuggestionId: 2, isMultiRow: true, columns: [numCol('bar')] }, { columns: [unknownCol(), numCol('bar')], datasourceSuggestionId: 3, isMultiRow: true, layerId: 'l1', + changeType: 'unchanged', }, { columns: [numCol('bar'), numCol('baz')], datasourceSuggestionId: 4, isMultiRow: false, layerId: 'l1', + changeType: 'unchanged', }, ], }) @@ -87,6 +99,7 @@ describe('metric_suggestions', () => { datasourceSuggestionId: 0, isMultiRow: false, layerId: 'l1', + changeType: 'unchanged', }, ], }); @@ -113,7 +126,7 @@ describe('metric_suggestions', () => { "type": "expression", }, "previewIcon": "visMetric", - "score": 1, + "score": 0.5, "state": Object { "accessor": "bytes", "layerId": "l1", 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 index 85981be00c3a0..9fc57fa3937d0 100644 --- 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 @@ -15,22 +15,26 @@ import { State } from './types'; export function getSuggestions( opts: SuggestionRequest ): Array> { - return opts.tables - .filter( - ({ isMultiRow, columns }) => - // We only render metric charts for single-row queries. We require a single, numeric column. - !isMultiRow && columns.length === 1 && columns[0].operation.dataType === 'number' - ) - .map(table => getSuggestion(table)); + return ( + opts.tables + .filter( + ({ isMultiRow, columns }) => + // We only render metric charts for single-row queries. We require a single, numeric column. + !isMultiRow && columns.length === 1 && columns[0].operation.dataType === 'number' + ) + // don't suggest current table if visualization is active + .filter(({ changeType }) => !opts.state || changeType !== 'unchanged') + .map(table => getSuggestion(table)) + ); } function getSuggestion(table: TableSuggestion): VisualizationSuggestion { const col = table.columns[0]; - const title = col.operation.label; + const title = table.label || col.operation.label; return { title, - score: 1, + score: 0.5, datasourceSuggestionId: table.datasourceSuggestionId, previewIcon: 'visMetric', previewExpression: { 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 index fa68aa2c7122a..b6de912089c4b 100644 --- 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 @@ -65,7 +65,6 @@ describe('metric_visualization', () => { id: 'a', dataType: 'number', isBucketed: false, - isMetric: true, label: 'shazm', }; }, 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 index 08a94c2180ab9..012c27d3ce3ff 100644 --- 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 @@ -49,7 +49,6 @@ describe('MultiColumnEditor', () => { dataType: 'number', id, isBucketed: true, - isMetric: false, label: 'BaaaZZZ!', }; }, diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 8a71f36ea2f92..00bb670839f81 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -47,13 +47,54 @@ export interface TableSuggestionColumn { 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 { + /** + * The id of this table. This id has to be included in the `VisualizationSuggestion` to map + * the visualization to the right table as there can be multiple tables in a single `SuggestionRequest`. + */ datasourceSuggestionId: number; + /** + * 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; @@ -160,9 +201,11 @@ export interface OperationMetadata { // A bucketed operation is grouped by duplicate values, otherwise each row is // treated as unique isBucketed: boolean; - isMetric: 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 { @@ -183,12 +226,47 @@ export interface SuggestionRequest { state?: T; // State is only passed if the visualization is active } +/** + * 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 id of the `TableSuggestion` object this visualization suggestion is based on. + * This is used to switch the datasource configuration to the right table. + */ datasourceSuggestionId: number; + /** + * 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; } 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 index b034e73ba914d..12902f548e45b 100644 --- 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 @@ -35,9 +35,6 @@ Object { "splitAccessor": Array [ "d", ], - "title": Array [ - "Baz", - ], "xAccessor": Array [ "a", ], 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 index e4e0e57b7926a..bbb27bae778b2 100644 --- 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 @@ -70,9 +70,9 @@ export const toExpression = (state: State, frame: FramePublicAPI): Ast | null => return buildExpression( stateWithValidAccessors, - xyTitles(state.layers[0], frame), metadata, - frame + frame, + xyTitles(state.layers[0], frame) ); }; @@ -103,9 +103,9 @@ export function getScaleType(metadata: OperationMetadata | null, defaultScale: S export const buildExpression = ( state: State, - { xTitle, yTitle }: { xTitle: string; yTitle: string }, metadata: Record>, - frame?: FramePublicAPI + frame?: FramePublicAPI, + { xTitle, yTitle }: { xTitle: string; yTitle: string } = { xTitle: '', yTitle: '' } ): Ast => ({ type: 'expression', chain: [ @@ -163,7 +163,6 @@ export const buildExpression = ( arguments: { layerId: [layer.layerId], - title: [layer.title], hide: [Boolean(layer.hide)], xAccessor: [layer.xAccessor], 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 index ee51f9ce5fa8e..742cc36be4ea6 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -177,13 +177,14 @@ export const layerConfig: ExpressionFunction< export type SeriesType = 'bar' | 'line' | 'area' | 'bar_stacked' | 'area_stacked'; -export type LayerConfig = AxisConfig & { +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 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 index cc4a9c80853bf..305cda47b3e19 100644 --- 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 @@ -35,7 +35,6 @@ describe('XYConfigPanel', () => { layerId: 'first', splitAccessor: 'baz', xAccessor: 'foo', - title: 'X', accessors: ['bar'], }, ], @@ -148,7 +147,6 @@ describe('XYConfigPanel', () => { const exampleOperation: Operation = { dataType: 'number', isBucketed: false, - isMetric: true, label: 'bar', }; const bucketedOps: Operation[] = [ @@ -187,7 +185,6 @@ describe('XYConfigPanel', () => { const exampleOperation: Operation = { dataType: 'number', isBucketed: false, - isMetric: true, label: 'bar', }; const ops: Operation[] = [ @@ -312,7 +309,6 @@ describe('XYConfigPanel', () => { layerId: 'first', splitAccessor: 'baz', xAccessor: 'foo', - title: 'X', accessors: ['bar'], }, { @@ -320,7 +316,6 @@ describe('XYConfigPanel', () => { layerId: 'second', splitAccessor: 'baz', xAccessor: 'foo', - title: 'Y', accessors: ['bar'], }, ], @@ -363,7 +358,6 @@ describe('XYConfigPanel', () => { layerId: 'first', splitAccessor: 'baz', xAccessor: 'foo', - title: 'X', accessors: ['bar'], }, { @@ -371,7 +365,6 @@ describe('XYConfigPanel', () => { layerId: 'second', splitAccessor: 'baz', xAccessor: 'foo', - title: 'Y', accessors: ['bar'], }, ], 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 index 835b2d9c0bccb..07ce118673bd1 100644 --- 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 @@ -28,7 +28,7 @@ import { NativeRenderer } from '../native_renderer'; import { MultiColumnEditor } from '../multi_column_editor'; import { generateId } from '../id_generator'; -const isNumericMetric = (op: OperationMetadata) => op.isMetric && op.dataType === 'number'; +const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; const isBucketed = (op: OperationMetadata) => op.isBucketed; type UnwrapArray = T extends Array ? P : T; @@ -49,7 +49,6 @@ function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig { seriesType, xAccessor: generateId(), accessors: [generateId()], - title: '', splitAccessor: generateId(), }; } 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 index 7eab6ce109e3d..0ac286c7bb83c 100644 --- 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 @@ -46,7 +46,6 @@ function sampleArgs() { seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], - title: 'A and B', splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', @@ -79,7 +78,6 @@ describe('xy_expression', () => { seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], - title: 'A and B', splitAccessor: 'd', xScaleType: 'linear', yScaleType: 'linear', 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 index d3422138fec65..ceab87e9d9516 100644 --- 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 @@ -6,7 +6,7 @@ import { getSuggestions } from './xy_suggestions'; import { TableSuggestionColumn, VisualizationSuggestion, DataType } from '../types'; -import { State } from './types'; +import { State, XYState } from './types'; import { generateId } from '../id_generator'; import { Ast } from '@kbn/interpreter/target/common'; @@ -20,7 +20,7 @@ describe('xy_suggestions', () => { dataType: 'number', label: `Avg ${columnId}`, isBucketed: false, - isMetric: true, + scale: 'ratio', }, }; } @@ -32,7 +32,7 @@ describe('xy_suggestions', () => { dataType: 'string', label: `Top 5 ${columnId}`, isBucketed: true, - isMetric: false, + scale: 'ordinal', }, }; } @@ -43,8 +43,8 @@ describe('xy_suggestions', () => { operation: { dataType: 'date', isBucketed: true, - isMetric: false, label: `${columnId} histogram`, + scale: 'interval', }, }; } @@ -74,24 +74,28 @@ describe('xy_suggestions', () => { isMultiRow: true, columns: [dateCol('a')], layerId: 'first', + changeType: 'unchanged', }, { datasourceSuggestionId: 1, isMultiRow: true, columns: [strCol('foo'), strCol('bar')], layerId: 'first', + changeType: 'unchanged', }, { datasourceSuggestionId: 2, isMultiRow: false, columns: [strCol('foo'), numCol('bar')], layerId: 'first', + changeType: 'unchanged', }, { datasourceSuggestionId: 3, isMultiRow: true, columns: [unknownCol(), numCol('bar')], layerId: 'first', + changeType: 'unchanged', }, ], }) @@ -107,23 +111,24 @@ describe('xy_suggestions', () => { isMultiRow: true, columns: [numCol('bytes'), dateCol('date')], layerId: 'first', + changeType: 'unchanged', }, ], }); expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "bar", - "splitAccessor": "aaa", - "x": "date", - "y": Array [ - "bytes", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "area", + "splitAccessor": "aaa", + "x": "date", + "y": Array [ + "bytes", + ], + }, + ] + `); }); test('does not suggest multiple splits', () => { @@ -140,6 +145,7 @@ describe('xy_suggestions', () => { strCol('city'), ], layerId: 'first', + changeType: 'unchanged', }, ], }); @@ -155,24 +161,166 @@ describe('xy_suggestions', () => { 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": "line", - "splitAccessor": "product", - "x": "date", - "y": Array [ - "price", - "quantity", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "area", + "splitAccessor": "product", + "x": "date", + "y": Array [ + "price", + "quantity", + ], + }, + ] + `); + }); + + test('uses datasource provided title if available', () => { + const [suggestion, ...rest] = getSuggestions({ + tables: [ + { + datasourceSuggestionId: 1, + 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({ + tables: [ + { + datasourceSuggestionId: 1, + 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({ + tables: [ + { + datasourceSuggestionId: 1, + 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({ + tables: [ + { + datasourceSuggestionId: 1, + 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({ + tables: [ + { + datasourceSuggestionId: 1, + 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('supports multiple suggestions', () => { @@ -184,41 +332,43 @@ describe('xy_suggestions', () => { isMultiRow: true, columns: [numCol('price'), dateCol('date')], layerId: 'first', + changeType: 'unchanged', }, { datasourceSuggestionId: 1, isMultiRow: true, columns: [numCol('count'), strCol('country')], layerId: 'first', + changeType: 'unchanged', }, ], }); expect(rest).toHaveLength(0); expect([suggestionSubset(s1), suggestionSubset(s2)]).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "seriesType": "bar", - "splitAccessor": "bbb", - "x": "date", - "y": Array [ - "price", - ], - }, - ], - Array [ - Object { - "seriesType": "bar", - "splitAccessor": "ccc", - "x": "country", - "y": Array [ - "count", - ], - }, - ], - ] - `); + Array [ + Array [ + Object { + "seriesType": "area", + "splitAccessor": "bbb", + "x": "date", + "y": Array [ + "price", + ], + }, + ], + Array [ + Object { + "seriesType": "bar", + "splitAccessor": "ccc", + "x": "country", + "y": Array [ + "count", + ], + }, + ], + ] + `); }); test('handles two numeric values', () => { @@ -230,22 +380,23 @@ describe('xy_suggestions', () => { 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", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "bar", + "splitAccessor": "ddd", + "x": "quantity", + "y": Array [ + "price", + ], + }, + ] + `); }); test('handles unbucketed suggestions', () => { @@ -261,29 +412,29 @@ describe('xy_suggestions', () => { columnId: 'mybool', operation: { dataType: 'boolean', - isMetric: false, 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", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "bar", + "splitAccessor": "eee", + "x": "mybool", + "y": Array [ + "num votes", + ], + }, + ] + `); }); test('adds a preview expression with disabled axes and legend', () => { @@ -294,6 +445,7 @@ describe('xy_suggestions', () => { isMultiRow: true, columns: [numCol('bytes'), dateCol('date')], layerId: 'first', + changeType: 'unchanged', }, ], }); 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 index 7ec9f9a68ad83..648a411a13413 100644 --- 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 @@ -7,14 +7,16 @@ 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 } from './types'; +import { State, SeriesType, XYState } from './types'; import { generateId } from '../id_generator'; import { buildExpression } from './to_expression'; @@ -25,6 +27,21 @@ const columnSortOrder = { 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. * @@ -62,20 +79,24 @@ function getSuggestionForColumns( return getSuggestion( table.datasourceSuggestionId, table.layerId, + table.changeType, x, values, splitBy, - currentState + currentState, + table.label ); } else if (buckets.length === 0) { const [x, ...yValues] = values; return getSuggestion( table.datasourceSuggestionId, table.layerId, + table.changeType, x, yValues, undefined, - currentState + currentState, + table.label ); } } @@ -92,11 +113,101 @@ function prioritizeColumns(columns: TableSuggestionColumn[]) { function getSuggestion( datasourceSuggestionId: number, layerId: string, + changeType: TableChangeType, xValue: TableSuggestionColumn, yValues: TableSuggestionColumn[], splitBy?: TableSuggestionColumn, - currentState?: State + 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, + datasourceSuggestionId, + 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( @@ -107,68 +218,117 @@ function getSuggestion( }) ); const xTitle = xValue.operation.label; - const isDate = xValue.operation.dataType === 'date'; + 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, + datasourceSuggestionId, + xValue, +}: { + currentState: XYState | undefined; + isHorizontal: boolean; + seriesType: SeriesType; + title: string; + yValues: TableSuggestionColumn[]; + xValue: TableSuggestionColumn; + splitBy: TableSuggestionColumn | undefined; + layerId: string; + changeType: string; + datasourceSuggestionId: number; +}) { + const newLayer = { + ...(getExistingLayer(currentState, layerId) || {}), + layerId, + seriesType, + xAccessor: xValue.columnId, + splitAccessor: splitBy ? splitBy.columnId : generateId(), + accessors: yValues.map(col => col.columnId), + }; - const title = isDate - ? 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 }, - }); - const seriesType: SeriesType = - (currentState && currentState.preferredSeriesType) || (splitBy && isDate ? 'line' : 'bar'); const state: State = { - isHorizontal: false, + isHorizontal, legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right }, preferredSeriesType: seriesType, layers: [ ...(currentState ? currentState.layers.filter(layer => layer.layerId !== layerId) : []), - { - layerId, - seriesType, - xAccessor: xValue.columnId, - splitAccessor: splitBy ? splitBy.columnId : generateId(), - accessors: yValues.map(col => col.columnId), - title: yTitle, - }, + newLayer, ], }; - const metadata: Record = {}; - - [xValue, ...yValues, splitBy].forEach(col => { - if (col) { - metadata[col.columnId] = col.operation; - } - }); - 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', datasourceSuggestionId, state, - previewIcon: isDate ? 'visLine' : 'visBar', - previewExpression: buildExpression( - { - ...state, - layers: state.layers - .filter(layer => layer.layerId === layerId) - .map(layer => ({ ...layer, hide: true })), - legend: { - ...state.legend, - isVisible: false, - }, - }, - { xTitle, yTitle }, - { [layerId]: metadata } - ), + 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 index 5fb41780838b6..8d9092f63f59b 100644 --- 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 @@ -24,7 +24,6 @@ function exampleState(): State { layerId: 'first', seriesType: 'area', splitAccessor: 'd', - title: 'Baz', xAccessor: 'a', accessors: ['b', 'c'], }, @@ -60,7 +59,6 @@ describe('xy_visualization', () => { "seriesType": "bar", "showGridlines": false, "splitAccessor": "test-id2", - "title": "", "xAccessor": "test-id3", }, ], 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 index f0b3c1a79ed2b..15a34abf12651 100644 --- 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 @@ -86,7 +86,6 @@ export const xyVisualization: Visualization = { seriesType: defaultSeriesType, showGridlines: false, splitAccessor: generateId(), - title: '', xAccessor: generateId(), }, ], From 426d9d771926185ecbee8e902a6dcc94ebccd7df Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 3 Sep 2019 15:13:22 -0400 Subject: [PATCH 091/105] [lens] Calculate existence of fields in datasource (#44422) * [lens] Calculate existence of fields in datasource * Fix route registration * Add page object and use existence in functional test * Simplify layout of filters for index pattern * Respond to review feedback * Update class names * Use new URL constant * Fix usage of base path * Fix lint errors --- src/fixtures/logstash_fields.js | 3 +- .../legacy/plugins/lens/common/constants.ts | 5 +- x-pack/legacy/plugins/lens/index.ts | 21 +- .../editor_frame/data_panel_wrapper.tsx | 7 +- .../editor_frame/editor_frame.tsx | 2 + .../indexpattern_plugin/datapanel.test.tsx | 323 ++++++++++--- .../public/indexpattern_plugin/datapanel.tsx | 451 +++++++++++++----- .../dimension_panel/_popover.scss | 4 + .../dimension_panel/dimension_panel.test.tsx | 7 + .../dimension_panel/field_select.tsx | 24 +- .../dimension_panel/popover_editor.tsx | 3 +- .../public/indexpattern_plugin/field_icon.tsx | 20 +- .../public/indexpattern_plugin/field_item.tsx | 26 +- .../indexpattern_plugin/indexpattern.scss | 7 +- .../indexpattern_plugin/indexpattern.test.ts | 6 + .../indexpattern_plugin/indexpattern.tsx | 28 +- .../indexpattern_suggestions.test.tsx | 6 + .../indexpattern_plugin/layerpanel.test.tsx | 1 + .../lens/public/indexpattern_plugin/loader.ts | 3 + .../definitions/date_histogram.test.tsx | 1 + .../definitions/filter_ratio.test.tsx | 1 + .../operations/definitions/terms.test.tsx | 1 + .../operations/operations.test.ts | 1 + .../indexpattern_plugin/state_helpers.test.ts | 6 + .../lens/public/register_vis_type_alias.ts | 5 +- x-pack/legacy/plugins/lens/public/types.ts | 2 + x-pack/legacy/plugins/lens/readme.md | 5 + x-pack/legacy/plugins/lens/server/index.ts | 11 + x-pack/legacy/plugins/lens/server/plugin.tsx | 24 + .../plugins/lens/server/routes/index.ts | 12 + .../lens/server/routes/index_stats.test.ts | 105 ++++ .../plugins/lens/server/routes/index_stats.ts | 166 +++++++ x-pack/test/api_integration/apis/index.js | 1 + .../test/api_integration/apis/lens/index.ts | 13 + .../api_integration/apis/lens/index_stats.ts | 110 +++++ x-pack/test/functional/apps/lens/index.ts | 5 + .../apps/lens/indexpattern_datapanel.ts | 10 +- x-pack/test/functional/page_objects/index.ts | 2 + x-pack/test/functional/page_objects/lens.ts | 25 + 39 files changed, 1214 insertions(+), 239 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/server/index.ts create mode 100644 x-pack/legacy/plugins/lens/server/plugin.tsx create mode 100644 x-pack/legacy/plugins/lens/server/routes/index.ts create mode 100644 x-pack/legacy/plugins/lens/server/routes/index_stats.test.ts create mode 100644 x-pack/legacy/plugins/lens/server/routes/index_stats.ts create mode 100644 x-pack/test/api_integration/apis/lens/index.ts create mode 100644 x-pack/test/api_integration/apis/lens/index_stats.ts create mode 100644 x-pack/test/functional/page_objects/lens.ts 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/x-pack/legacy/plugins/lens/common/constants.ts b/x-pack/legacy/plugins/lens/common/constants.ts index edcc34c58881a..787a348a788b8 100644 --- a/x-pack/legacy/plugins/lens/common/constants.ts +++ b/x-pack/legacy/plugins/lens/common/constants.ts @@ -6,8 +6,9 @@ export const PLUGIN_ID = 'lens'; -export const BASE_URL = 'app/lens'; +export const BASE_APP_URL = '/app/lens'; +export const BASE_API_URL = '/api/lens'; export function getEditPath(id: string) { - return `/${BASE_URL}#/edit/${encodeURIComponent(id)}`; + return `${BASE_APP_URL}#/edit/${encodeURIComponent(id)}`; } diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index f205b14af9dab..399a65041b664 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -5,11 +5,13 @@ */ import * as Joi from 'joi'; -import { Server } from 'hapi'; 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 } from './common'; +import { PLUGIN_ID, getEditPath, BASE_API_URL } from './common'; +import { lensServerPlugin } from './server'; const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; @@ -51,6 +53,8 @@ export const lens: LegacyPluginInitializer = kibana => { }, init(server: Server) { + const kbnServer = (server as unknown) as KbnServer; + server.plugins.xpack_main.registerFeature({ id: PLUGIN_ID, name: NOT_INTERNATIONALIZED_PRODUCT_NAME, @@ -77,6 +81,19 @@ export const lens: LegacyPluginInitializer = kibana => { }, }, }); + + // 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/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 index a013d9b1bceae..d3beddc0689c1 100644 --- 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 @@ -7,11 +7,12 @@ 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 } from '../../types'; +import { StateSetter, FramePublicAPI } from '../../types'; interface DataPanelWrapperProps { datasourceState: unknown; @@ -19,6 +20,8 @@ interface DataPanelWrapperProps { activeDatasource: string | null; datasourceIsLoading: boolean; dispatch: (action: Action) => void; + query: Query; + dateRange: FramePublicAPI['dateRange']; } export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { @@ -37,6 +40,8 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { dragDropContext: useContext(DragContext), state: props.datasourceState, setState: setDatasourceState, + query: props.query, + dateRange: props.dateRange, }; const [showDatasourceSwitcher, setDatasourceSwitcher] = useState(false); 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 index 927a1f1abf3dc..f7837bc02b9be 100644 --- 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 @@ -219,6 +219,8 @@ export function EditorFrame(props: EditorFrameProps) { : true } dispatch={dispatch} + query={props.query} + dateRange={props.dateRange} /> } configPanel={ 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 index dfd4adde48560..d6e7337a32d82 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -4,20 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; -import React, { ChangeEvent, ReactElement } from 'react'; -import { EuiComboBox, EuiFieldSearch, EuiContextMenuPanel } from '@elastic/eui'; +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 { npStart as npStartMock } from 'ui/new_platform'; jest.mock('ui/new_platform'); jest.mock('./loader'); +const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); + const initialState: IndexPatternPrivateState = { currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', @@ -203,6 +207,13 @@ describe('IndexPattern Data Panel', () => { showIndexPatternSwitcher: false, setShowIndexPatternSwitcher: jest.fn(), onChangeIndexPattern: jest.fn(), + dateRange: { + fromDate: 'now-7d', + toDate: 'now', + }, + query: { query: '', language: 'lucene' }, + showEmptyFields: false, + onToggleEmptyFields: jest.fn(), }; }); @@ -210,6 +221,7 @@ describe('IndexPattern Data Panel', () => { const setStateSpy = jest.fn(); const wrapper = shallow( { }; const wrapper = shallow( {} }} @@ -270,6 +283,7 @@ describe('IndexPattern Data Panel', () => { }; const wrapper = shallow( {} }} @@ -312,98 +326,259 @@ describe('IndexPattern Data Panel', () => { expect(defaultProps.onChangeIndexPattern).toHaveBeenCalledWith('2'); }); - it('should list all supported fields in the pattern sorted alphabetically', async () => { - const wrapper = shallow(); + describe('loading existence data', () => { + beforeEach(() => { + (npStartMock.core.http.post as jest.Mock).mockClear(); + }); - expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ - 'bytes', - 'memory', - 'source', - 'timestamp', - ]); - }); + it('loads existence data and updates the index pattern', async () => { + (npStartMock.core.http.post as jest.Mock).mockResolvedValue({ + timestamp: { + exists: true, + cardinality: 500, + count: 500, + }, + }); + const updateFields = jest.fn(); + mount(); - it('should filter down by name', async () => { - const wrapper = shallow(); + await waitForPromises(); - act(() => { - wrapper.find(EuiFieldSearch).prop('onChange')!({ target: { value: 'mem' } } as ChangeEvent< - HTMLInputElement - >); + expect(npStartMock.core.http.post as jest.Mock).toHaveBeenCalledWith( + `/api/lens/index_stats/my-fake-index-pattern`, + { + body: JSON.stringify({ + earliest: 'now-7d', + latest: '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 })), + ]); }); - expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ - 'memory', - ]); - }); + 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, + }, + }; - it('should filter down by type', async () => { - const wrapper = shallow(); + const props = { ...defaultProps, indexPatterns: newIndexPatterns }; - act(() => { - (wrapper - .find(EuiContextMenuPanel) - .prop('items')! - .find( - item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' - )! as ReactElement).props.onClick(); - }); + mount(); - expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ - 'bytes', - 'memory', - ]); + await waitForPromises(); + + expect(npStartMock.core.http.post as jest.Mock).not.toHaveBeenCalled(); + }); }); - it('should toggle type if clicked again', async () => { - const wrapper = shallow(); + 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', + ]); + }); - act(() => { - (wrapper - .find(EuiContextMenuPanel) - .prop('items')! - .find( - item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' - )! as ReactElement).props.onClick(); + 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', + ]); }); - act(() => { - (wrapper - .find(EuiContextMenuPanel) - .prop('items')! - .find( - item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' - )! as ReactElement).props.onClick(); + 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', + ]); }); - expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ - 'bytes', - 'memory', - 'source', - 'timestamp', - ]); + 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', + ]); + }); }); - it('should filter down by type and by name', async () => { - const wrapper = shallow(); + 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(), + }; + }); - act(() => { - wrapper.find(EuiFieldSearch).prop('onChange')!({ target: { value: 'mem' } } as ChangeEvent< - HTMLInputElement - >); + 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', + ]); }); - act(() => { - (wrapper - .find(EuiContextMenuPanel) - .prop('items')! - .find( - item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' - )! as ReactElement).props.onClick(); + 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', + ]); }); - 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 index 74c19ad2cf0e3..832dfaf47a950 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -4,27 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mapValues, uniq } from 'lodash'; +import { mapValues, uniq, indexBy } from 'lodash'; import React, { useState, useEffect, memo, useCallback } from 'react'; import { EuiComboBox, - EuiFieldSearch, + EuiLoadingSpinner, // @ts-ignore EuiHighlight, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiButtonEmpty, - EuiFilterGroup, - EuiFilterButton, 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 { npStart } from 'ui/new_platform'; +import { Query } from 'src/plugins/data/common'; import { DatasourceDataPanelProps, DataType } from '../types'; import { IndexPatternPrivateState, IndexPatternField, IndexPattern } from './indexpattern'; import { ChildDragDropProvider, DragContextState } from '../drag_drop'; @@ -60,6 +66,8 @@ export function IndexPatternDataPanel({ setState, state, dragDropContext, + query, + dateRange, }: DatasourceDataPanelProps) { const { indexPatterns, currentIndexPatternId } = state; const [showIndexPatternSwitcher, setShowIndexPatternSwitcher] = useState(false); @@ -79,64 +87,93 @@ export function IndexPatternDataPanel({ [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, +}: Partial & { currentIndexPatternId: string; indexPatterns: Record; + dateRange: DatasourceDataPanelProps['dateRange']; + query: Query; dragDropContext: DragContextState; showIndexPatternSwitcher: boolean; setShowIndexPatternSwitcher: (show: boolean) => void; + showEmptyFields: boolean; + onToggleEmptyFields: () => void; onChangeIndexPattern?: (newId: string) => void; + updateFieldsWithCounts?: (indexPatternId: string, fields: IndexPattern['fields']) => void; }) { - const [state, setState] = useState({ - nameFilter: '', - typeFilter: [] as DataType[], - isTypeFilterOpen: false, - }); - const [pageSize, setPageSize] = useState(PAGINATION_SIZE); - const [scrollContainer, setScrollContainer] = useState(undefined); - 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(); - } - }, [state.nameFilter, state.typeFilter, currentIndexPatternId]); - if (Object.keys(indexPatterns).length === 0) { return ( - field.name.toLowerCase().includes(state.nameFilter.toLowerCase()) && - supportedFieldTypes.includes(field.type) - ) - .slice(0, pageSize); - - const availableFieldTypes = uniq(filteredFields.map(({ type }) => type)); - const availableFilteredTypes = state.typeFilter.filter(type => - availableFieldTypes.includes(type) + 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 })); + + npStart.core.http + .post(`/api/lens/index_stats/${currentIndexPattern.title}`, { + body: JSON.stringify({ + earliest: dateRange.fromDate, + latest: 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 (

- {indexPatterns[currentIndexPatternId].title}{' '} + {currentIndexPattern.title}{' '}

{ onChangeIndexPattern!(choices[0].value as string); - setState({ - ...state, + setLocalState(s => ({ + ...s, nameFilter: '', typeFilter: [], - }); + })); setShowIndexPatternSwitcher(false); }} @@ -259,69 +404,114 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ responsive={false} > - { - setState({ ...state, nameFilter: e.target.value }); - }} - aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameAriaLabel', { - defaultMessage: 'Search fields', - })} - /> - - - - setState({ ...state, isTypeFilterOpen: false })} - button={ - - setState({ ...state, isTypeFilterOpen: !state.isTypeFilterOpen }) - } - iconType="arrowDown" - data-test-subj="indexPatternTypeFilterButton" - isSelected={state.isTypeFilterOpen} - numFilters={availableFieldTypes.length} - hasActiveFilters={availableFilteredTypes.length > 0} - numActiveFilters={availableFilteredTypes.length} - > - {i18n.translate('xpack.lens.indexPatterns.typeFilterLabel', { - defaultMessage: 'Types', - })} - - } - > - ( - - setState({ - ...state, - typeFilter: state.typeFilter.includes(type) - ? state.typeFilter.filter(t => t !== type) - : [...state.typeFilter, type], - }) - } + + 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', + } + )} > - {fieldTypeNames[type]} - - ))} - /> - - + + + } + > + + {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', + })} + /> +
- {filteredFields - .filter( - field => - state.typeFilter.length === 0 || - state.typeFilter.includes(field.type as DataType) - ) - .sort(sortFields) - .map(field => ( + {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 }, + })} + + )}
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 index b5701daf31d7e..42f9366bd1f1e 100644 --- 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 @@ -16,6 +16,10 @@ color: $euiColorLightShade; } +.lnsConfigPanel__fieldOption--nonExistant { + background-color: $euiColorLightestShade; +} + .lnsConfigPanel__operation { padding: $euiSizeXS; font-size: 0.875rem; 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 index 2ddfce6b7e0a5..1e801467f3f94 100644 --- 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 @@ -33,30 +33,35 @@ const expectedIndexPatterns = { 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, }, ], }, @@ -80,6 +85,7 @@ describe('IndexPatternDimensionPanel', () => { state = { indexPatterns: expectedIndexPatterns, currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', @@ -947,6 +953,7 @@ describe('IndexPatternDimensionPanel', () => { }, }, currentIndexPatternId: '1', + showEmptyFields: false, layers: { myLayer: { indexPatternId: 'foo', 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 index 2874449bcb4ff..16d57dc595117 100644 --- 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 @@ -24,6 +24,7 @@ export type FieldChoice = export interface FieldSelectProps { currentIndexPattern: IndexPattern; + showEmptyFields: boolean; fieldMap: Record; incompatibleSelectedOperationType: OperationType | null; selectedColumnOperationType?: OperationType; @@ -34,16 +35,18 @@ export interface FieldSelectProps { } export function FieldSelect({ + currentIndexPattern, + showEmptyFields, + fieldMap, incompatibleSelectedOperationType, selectedColumnOperationType, selectedColumnSourceField, operationFieldSupportMatrix, - currentIndexPattern, - fieldMap, onChoose, onDeleteColumn, }: FieldSelectProps) { const { operationByDocument, operationByField } = operationFieldSupportMatrix; + const memoizedFieldOptions = useMemo(() => { const fields = Object.keys(operationByField).sort(); @@ -97,25 +100,31 @@ export function FieldSelect({ ? selectedColumnOperationType : undefined, }, + exists: fieldMap[field].exists || false, compatible: isCompatibleWithCurrentOperation(field), })) - .sort(({ compatible: a }, { compatible: b }) => { - if (a && !b) { + .filter(field => showEmptyFields || field.exists) + .sort((a, b) => { + if (a.compatible && !b.compatible) { return -1; } - if (!a && b) { + if (!a.compatible && b.compatible) { return 1; } return 0; }) - .map(({ label, value, compatible }) => ({ + .map(({ label, value, compatible, exists }) => ({ label, value, - className: classNames({ 'lnsConfigPanel__fieldOption--incompatible': !compatible }), + className: classNames({ + 'lnsConfigPanel__fieldOption--incompatible': !compatible, + 'lnsConfigPanel__fieldOption--nonExistant': !exists, + }), 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${label}`, })), }); } + return fieldOptions; }, [ incompatibleSelectedOperationType, @@ -124,6 +133,7 @@ export function FieldSelect({ operationFieldSupportMatrix, currentIndexPattern, fieldMap, + showEmptyFields, ]); return ( 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 index 960e81b98699b..7eb03152b341f 100644 --- 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 @@ -258,8 +258,9 @@ export function PopoverEditor(props: PopoverEditorProps) { acc + ch.charCodeAt(0), 1); } -export type UnwrapArray = T extends Array ? P : T; - -export function FieldIcon({ type }: { type: DataType }) { - const icons: Partial>> = { +function getIconForDataType(dataType: string) { + const icons: Partial>> = { boolean: 'invert', date: 'calendar', }; + return icons[dataType] || ICON_TYPES.find(t => t === dataType) || 'empty'; +} - const iconType = icons[type] || ICON_TYPES.find(t => t === type) || '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 ; + 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 index 51d519da21693..403b76ed1c49c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx @@ -5,15 +5,16 @@ */ import React from 'react'; -import { IndexPatternField, DraggedField } from './indexpattern'; +import { IndexPattern, IndexPatternField, DraggedField } from './indexpattern'; import { DragDrop } from '../drag_drop'; import { FieldIcon } from './field_icon'; import { DataType } from '..'; export interface FieldItemProps { field: IndexPatternField; - indexPatternId: string; + indexPattern: IndexPattern; highlight?: string; + exists: boolean; } function wrapOnDot(str?: string) { @@ -23,8 +24,8 @@ function wrapOnDot(str?: string) { return str ? str.replace(/\./g, '.\u200B') : ''; } -export function FieldItem({ field, indexPatternId, highlight }: FieldItemProps) { - const wrappableName = wrapOnDot(field.name); +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()) @@ -42,15 +43,20 @@ export function FieldItem({ field, indexPatternId, highlight }: FieldItemProps) return ( - - - {wrappableHighlightableFieldName} - +
+ + + + {wrappableHighlightableFieldName} + +
); } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss index adbbcacaa8f30..dcc579dd05ec6 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss @@ -53,10 +53,7 @@ background: $euiColorEmptyShade; border-radius: $euiBorderRadius; padding: $euiSizeS; - display: flex; - align-items: center; margin-bottom: $euiSizeXS; - font-weight: $euiFontWeightMedium; transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance; &:hover { @@ -66,6 +63,10 @@ } } +.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 index 336deef6147a3..d69a3827a43c9 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts @@ -176,6 +176,7 @@ describe('IndexPattern Data Source', () => { currentIndexPatternId: '1', indexPatterns: expectedIndexPatterns, layers: {}, + showEmptyFields: false, }); }); @@ -184,6 +185,7 @@ describe('IndexPattern Data Source', () => { expect(state).toEqual({ ...persistedState, indexPatterns: expectedIndexPatterns, + showEmptyFields: false, }); }); }); @@ -263,6 +265,7 @@ describe('IndexPattern Data Source', () => { }, }, currentIndexPatternId: '1', + showEmptyFields: false, }; expect(indexPatternDatasource.insertLayer(state, 'newLayer')).toEqual({ ...state, @@ -281,6 +284,7 @@ describe('IndexPattern Data Source', () => { describe('#removeLayer', () => { it('should remove a layer', () => { const state = { + showEmptyFields: false, indexPatterns: expectedIndexPatterns, layers: { first: { @@ -313,6 +317,7 @@ describe('IndexPattern Data Source', () => { it('should list the current layers', () => { expect( indexPatternDatasource.getLayers({ + showEmptyFields: false, indexPatterns: expectedIndexPatterns, layers: { first: { @@ -336,6 +341,7 @@ describe('IndexPattern Data Source', () => { it('should return the title of the index patterns', () => { expect( indexPatternDatasource.getMetaData({ + showEmptyFields: false, indexPatterns: expectedIndexPatterns, layers: { first: { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 874ee486b3c6b..c21f6a68c0416 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -28,7 +28,7 @@ import { import { isDraggedField } from './utils'; import { LayerPanel } from './layerpanel'; import { IndexPatternColumn } from './operations'; -import { Datasource } from '..'; +import { Datasource, StateSetter } from '..'; export { OperationType, IndexPatternColumn } from './operations'; @@ -37,6 +37,16 @@ export interface IndexPattern { 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 { @@ -58,6 +68,11 @@ export interface IndexPatternField { } > >; + + // TODO: This is loaded separately, but should be combined into one API + exists?: boolean; + cardinality?: number; + count?: number; } export interface DraggedField { @@ -78,6 +93,7 @@ export interface IndexPatternPersistedState { export type IndexPatternPrivateState = IndexPatternPersistedState & { indexPatterns: Record; + showEmptyFields: boolean; }; export function columnToOperation(column: IndexPatternColumn): Operation { @@ -141,6 +157,8 @@ export function getIndexPatternDatasource({ // 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, toastNotifications); const indexPatterns: Record = {}; @@ -154,12 +172,14 @@ export function getIndexPatternDatasource({ return { ...state, indexPatterns, + showEmptyFields: false, }; } return { currentIndexPatternId: indexPatternObjects ? indexPatternObjects[0].id : '', indexPatterns, layers: {}, + showEmptyFields: false, }; }, @@ -222,7 +242,11 @@ export function getIndexPatternDatasource({ ); }, - getPublicAPI(state, setState, layerId) { + getPublicAPI( + state: IndexPatternPrivateState, + setState: StateSetter, + layerId: string + ) { return { getTableSpec: () => { return state.layers[layerId].columnOrder.map(colId => ({ columnId: colId })); 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 index d7c61e1f1c73d..ae26633a848ea 100644 --- 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 @@ -313,6 +313,7 @@ describe('IndexPattern Data Source suggestions', () => { 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', @@ -494,6 +495,7 @@ describe('IndexPattern Data Source suggestions', () => { 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', @@ -883,6 +885,7 @@ describe('IndexPattern Data Source suggestions', () => { it('returns no suggestions if there are no columns', () => { expect( indexPatternDatasource.getDatasourceSuggestionsFromCurrentState({ + showEmptyFields: false, indexPatterns: expectedIndexPatterns, layers: { first: { @@ -1168,6 +1171,7 @@ describe('IndexPattern Data Source suggestions', () => { ], }, }, + showEmptyFields: true, layers: { first: { ...persistedState.layers.first, @@ -1284,6 +1288,7 @@ describe('IndexPattern Data Source suggestions', () => { ], }, }, + showEmptyFields: true, layers: { first: { ...persistedState.layers.first, @@ -1334,6 +1339,7 @@ describe('IndexPattern Data Source suggestions', () => { ], }, }, + showEmptyFields: true, layers: { first: { ...persistedState.layers.first, 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 index 46e381d69741b..755ed02904e31 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx @@ -18,6 +18,7 @@ jest.mock('./state_helpers'); const initialState: IndexPatternPrivateState = { currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts index 8ed19d098134e..79fa208e16cf9 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts @@ -57,6 +57,9 @@ export const getIndexPatterns = (chrome: Chrome, toastNotifications: ToastNotifi typeMeta: attributes.typeMeta ? (JSON.parse(attributes.typeMeta) as SavedRestrictionsInfo) : undefined, + fieldFormatMap: attributes.fieldFormatMap + ? JSON.parse(attributes.fieldFormatMap) + : undefined, }; }); }) 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 index d77f9343fd124..4b8b556927052 100644 --- 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 @@ -23,6 +23,7 @@ describe('date_histogram', () => { beforeEach(() => { state = { currentIndexPatternId: '1', + showEmptyFields: false, indexPatterns: { 1: { id: '1', 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 index 8dd2f3944eee6..8864e959977a8 100644 --- 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 @@ -32,6 +32,7 @@ describe('filter_ratio', () => { }, }, currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', 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 index 18b4e5e754f19..56b15eaaa47db 100644 --- 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 @@ -24,6 +24,7 @@ describe('terms', () => { state = { indexPatterns: {}, currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', 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 index 878b1dda7b1ce..0a8e4b57521fe 100644 --- 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 @@ -146,6 +146,7 @@ describe('getOperationTypesForField', () => { describe('buildColumn', () => { const state: IndexPatternPrivateState = { currentIndexPatternId: '1', + showEmptyFields: false, indexPatterns: expectedIndexPatterns, layers: { first: { 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 index d093f50c1bea1..9023173ab95df 100644 --- 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 @@ -41,6 +41,7 @@ describe('state_helpers', () => { const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', @@ -86,6 +87,7 @@ describe('state_helpers', () => { const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', @@ -135,6 +137,7 @@ describe('state_helpers', () => { const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', @@ -166,6 +169,7 @@ describe('state_helpers', () => { const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', @@ -225,6 +229,7 @@ describe('state_helpers', () => { const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', @@ -300,6 +305,7 @@ describe('state_helpers', () => { const state: IndexPatternPrivateState = { indexPatterns: {}, currentIndexPatternId: '1', + showEmptyFields: false, layers: { first: { indexPatternId: '1', 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 index 78b22e0974a09..8f49f6f12ee16 100644 --- a/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts +++ b/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts @@ -6,11 +6,12 @@ import { i18n } from '@kbn/i18n'; import { visualizations } from '../../../../../src/legacy/core_plugins/visualizations/public'; +import { BASE_APP_URL, getEditPath } from '../common'; const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; visualizations.types.visTypeAliasRegistry.add({ - aliasUrl: '/app/lens', + aliasUrl: BASE_APP_URL, name: NOT_INTERNATIONALIZED_PRODUCT_NAME, title: i18n.translate('xpack.lens.visTypeAlias.title', { defaultMessage: 'Lens Visualizations', @@ -29,7 +30,7 @@ visualizations.types.visTypeAliasRegistry.add({ return { id, title, - editUrl: `/app/lens#/edit/${id}`, + editUrl: getEditPath(id), icon: 'faceHappy', isExperimental: true, savedObjectType: type, diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 00bb670839f81..affe077909947 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -162,6 +162,8 @@ export interface DatasourceDataPanelProps { state: T; dragDropContext: DragContextState; setState: StateSetter; + query: Query; + dateRange: FramePublicAPI['dateRange']; } // The only way a visualization has to restrict the query building diff --git a/x-pack/legacy/plugins/lens/readme.md b/x-pack/legacy/plugins/lens/readme.md index 2bc027121fed5..0ea0778dd17ef 100644 --- a/x-pack/legacy/plugins/lens/readme.md +++ b/x-pack/legacy/plugins/lens/readme.md @@ -2,8 +2,13 @@ ## 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..6918579428dc6 --- /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({ + earliest: schema.string(), + latest: 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 { earliest, latest, 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: earliest, + lte: latest, + 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/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index 99694c250ef3f..87d64126571b3 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -26,5 +26,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..286fe2f971621 --- /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({ + earliest: TEST_START_TIME, + latest: 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/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 085950b9f5f6b..352e7b2d5de80 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -20,6 +20,11 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { await esArchiver.loadIfNeeded('visualize/default'); }); + after(async () => { + await esArchiver.unload('logstash_functional'); + await esArchiver.unload('visualize/default'); + }); + describe('', function() { this.tags(['ciGroup4', 'skipFirefox']); diff --git a/x-pack/test/functional/apps/lens/indexpattern_datapanel.ts b/x-pack/test/functional/apps/lens/indexpattern_datapanel.ts index f0fc37a2c18d9..4c89ef4d51cc9 100644 --- a/x-pack/test/functional/apps/lens/indexpattern_datapanel.ts +++ b/x-pack/test/functional/apps/lens/indexpattern_datapanel.ts @@ -8,9 +8,8 @@ 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']); - const find = getService('find'); +export default function({ getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['header', 'common', 'lens']); describe('indexpattern_datapanel', () => { beforeEach(async () => { @@ -18,7 +17,10 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { }); it('should list the index pattern fields', async () => { - const fields = await find.allByCssSelector('[data-test-subj="lnsFieldListPanelField"]'); + await PageObjects.lens.openIndexPatternFiltersPopover(); + await PageObjects.lens.toggleExistenceFilter(); + + const fields = await PageObjects.lens.findAllFields(); const fieldText = await Promise.all(fields.map(field => field.getVisibleText())); expect(fieldText).to.eql([ '_score', diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 690a77ff00aa1..e22e36fcd73aa 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'; // 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.ts b/x-pack/test/functional/page_objects/lens.ts new file mode 100644 index 0000000000000..fa762dc86fd8d --- /dev/null +++ b/x-pack/test/functional/page_objects/lens.ts @@ -0,0 +1,25 @@ +/* + * 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 function LensPageProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + return { + async openIndexPatternFiltersPopover() { + await testSubjects.click('lnsIndexPatternFiltersToggle'); + }, + + async toggleExistenceFilter() { + await testSubjects.click('lnsEmptyFilter'); + }, + + async findAllFields() { + return await testSubjects.findAll('lnsFieldListPanelField'); + }, + }; +} From b443d3e6cc02912b7bb9827e646a7c0f450f9d92 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 4 Sep 2019 11:16:11 +0200 Subject: [PATCH 092/105] [Lens ] Preview metric (#43755) --- .../editor_frame/suggestion_panel.scss | 1 + .../auto_scale.test.tsx | 6 +- .../auto_scale.tsx | 18 +++--- .../metric_expression.test.tsx | 60 +++++++++++++++---- .../metric_expression.tsx | 13 +++- .../metric_suggestions.test.ts | 3 + .../metric_suggestions.ts | 1 + .../metric_visualization_plugin/types.ts | 1 + 8 files changed, 81 insertions(+), 22 deletions(-) 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 index add9df3f10dbd..c966bfcb80668 100644 --- 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 @@ -56,4 +56,5 @@ $lnsSuggestionWidth: 150px; .lnsSuggestionChartWrapper { height: $lnsSuggestionHeight - $euiSize; pointer-events: none; + margin: 0 $euiSizeS; } 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 index 60008d1237d82..722be9048e775 100644 --- 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 @@ -25,10 +25,14 @@ describe('AutoScale', () => { expect(computeScale(mockElement(2000, 2000), mockElement(1000, 1000))).toBe(1); }); - it('is never under 0.3', () => { + 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); 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 index 9ca58c1944803..37dc71b28b87f 100644 --- 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 @@ -10,6 +10,7 @@ import { EuiResizeObserver } from '@elastic/eui'; interface Props extends React.HTMLAttributes { children: React.ReactNode | React.ReactNode[]; + minScale?: number; } interface State { @@ -25,7 +26,7 @@ export class AutoScale extends React.Component { super(props); this.scale = _.throttle(() => { - const scale = computeScale(this.parent, this.child); + const scale = computeScale(this.parent, this.child, this.props.minScale); // Prevent an infinite render loop if (this.state.scale !== scale) { @@ -54,7 +55,7 @@ export class AutoScale extends React.Component { }; render() { - const { children } = this.props; + const { children, minScale, ...rest } = this.props; const { scale } = this.state; const style = this.props.style || {}; @@ -62,7 +63,7 @@ export class AutoScale extends React.Component { {resizeRef => (
{ this.setParent(el); resizeRef(el); @@ -97,17 +98,18 @@ interface ClientDimensionable { 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 + child: ClientDimensionable | null, + minScale: number = MIN_SCALE ) { - const MAX_SCALE = 1; - const MIN_SCALE = 0.3; - if (!parent || !child) { return 1; } @@ -115,5 +117,5 @@ export function computeScale( const scaleX = parent.clientWidth / child.clientWidth; const scaleY = parent.clientHeight / child.clientHeight; - return Math.max(Math.min(MAX_SCALE, Math.min(scaleX, scaleY)), MIN_SCALE); + return Math.max(Math.min(MAX_SCALE, Math.min(scaleX, scaleY)), minScale); } 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 index 69d91d4c97fe1..f7690b0ee8562 100644 --- 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 @@ -26,6 +26,7 @@ function sampleArgs() { accessor: 'a', layerId: 'l1', title: 'My fanci metric chart', + mode: 'full', }; return { data, args }; @@ -50,6 +51,52 @@ describe('metric_expression', () => { expect(shallow( x} />)) .toMatchInlineSnapshot(` +
+ +
+ 10110 +
+
+ My fanci metric chart +
+
+
+ `); + }); + + test('it does not render title in reduced mode', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + x} /> + ) + ).toMatchInlineSnapshot(`
{ } } > - +
{ > 10110
-
- My fanci metric chart -
`); 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 index daff873feb18c..d6c29abf70c4d 100644 --- 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 @@ -42,6 +42,13 @@ export const metricChart: ExpressionFunction< 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'], @@ -82,7 +89,7 @@ export function MetricChart({ args, formatFactory, }: MetricChartProps & { formatFactory: FormatFactory }) { - const { title, accessor } = args; + const { title, accessor, mode } = args; let value = '-'; const firstTable = Object.values(data.tables)[0]; @@ -109,9 +116,9 @@ export function MetricChart({ textAlign: 'center', }} > - +
{value}
-
{title}
+ {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 index a28e8dc7300f1..73767375465b5 100644 --- 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 @@ -115,6 +115,9 @@ describe('metric_suggestions', () => { "accessor": Array [ "bytes", ], + "mode": Array [ + "reduced", + ], "title": Array [ "", ], 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 index 9fc57fa3937d0..c078f4ba57752 100644 --- 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 @@ -46,6 +46,7 @@ function getSuggestion(table: TableSuggestion): VisualizationSuggestion { arguments: { title: [''], accessor: [col.columnId], + mode: ['reduced'], }, }, ], 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 index 89d41552639c4..6348d80b15e2f 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/types.ts @@ -11,6 +11,7 @@ export interface State { export interface MetricConfig extends State { title: string; + mode: 'reduced' | 'full'; } export type PersistableState = State; From 57d938067e19b0ce3ad551c1b598ab0fa49a44b7 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 4 Sep 2019 11:19:14 +0200 Subject: [PATCH 093/105] format filter ratio as percentage (#44625) --- .../indexpattern_plugin/filter_ratio.test.ts | 64 +++++++++++++++++-- .../indexpattern_plugin/filter_ratio.ts | 15 ++++- 2 files changed, 73 insertions(+), 6 deletions(-) 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 index 11102cf7a5a07..5b8ee486db09b 100644 --- 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 @@ -16,7 +16,15 @@ describe('calculate_filter_ratio', () => { }; expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ - columns: [{ id: 'bucket', name: 'A' }], + columns: [ + { + id: 'bucket', + name: 'A', + formatHint: { + id: 'percent', + }, + }, + ], rows: [{ bucket: 0.5 }], type: 'kibana_datatable', }); @@ -30,7 +38,15 @@ describe('calculate_filter_ratio', () => { }; expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ - columns: [{ id: 'bucket', name: 'A' }], + columns: [ + { + id: 'bucket', + name: 'A', + formatHint: { + id: 'percent', + }, + }, + ], rows: [{ bucket: 0 }], type: 'kibana_datatable', }); @@ -44,7 +60,15 @@ describe('calculate_filter_ratio', () => { }; expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ - columns: [{ id: 'bucket', name: 'A' }], + columns: [ + { + id: 'bucket', + name: 'A', + formatHint: { + id: 'percent', + }, + }, + ], rows: [{ bucket: 0 }], type: 'kibana_datatable', }); @@ -65,9 +89,39 @@ describe('calculate_filter_ratio', () => { }; expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ - columns: [{ id: 'bucket', name: 'A' }, { id: 'extra', name: 'C' }], - rows: [{ bucket: 0.5, extra: 'first' }], + 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 index 854284f98a8d0..d27da1833f04e 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts @@ -64,10 +64,23 @@ export const calculateFilterRatio: ExpressionFunction< 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: data.columns.filter(col => !col.id.includes('filter-ratio')), + columns: newColumns, }; }, }; From 32ddc97b275a19d50435a9430c78278b524af202 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 4 Sep 2019 14:57:58 +0200 Subject: [PATCH 094/105] [Lens] Remove datasource suggestion id (#44495) --- .../visualization.tsx | 90 +++-- .../editor_frame/chart_switch.test.tsx | 4 - .../editor_frame/editor_frame.test.tsx | 28 +- .../editor_frame/suggestion_helpers.test.ts | 84 ++--- .../editor_frame/suggestion_helpers.ts | 103 +++--- .../editor_frame/workspace_panel.test.tsx | 28 +- .../indexpattern_suggestions.test.tsx | 12 - .../indexpattern_suggestions.ts | 10 - .../metric_suggestions.test.ts | 89 +++-- .../metric_suggestions.ts | 34 +- x-pack/legacy/plugins/lens/public/types.ts | 31 +- .../xy_suggestions.test.ts | 312 +++++++----------- .../xy_visualization_plugin/xy_suggestions.ts | 46 +-- 13 files changed, 379 insertions(+), 492 deletions(-) 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 index 8f9e736499069..b3f3a389c4490 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -126,58 +126,54 @@ export const datatableVisualization: Visualization< getPersistableState: state => state, getSuggestions({ - tables, + table, state, }: SuggestionRequest): Array< VisualizationSuggestion > { - const maxColumnCount = Math.max.apply(undefined, tables.map(table => table.columns.length)); - return ( - tables - // don't suggest current table if visualization is active - .filter(({ changeType }) => !state || changeType !== 'unchanged') - .map(table => { - 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, - // largest possible table will have a score of 0.2, fewer columns reduce score - score: (table.columns.length / maxColumnCount) * 0.2, - datasourceSuggestionId: table.datasourceSuggestionId, - state: { - layers: [ - { - layerId: table.layerId, - columns: table.columns.map(col => col.columnId), - }, - ], + 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.', + }) + ), }, - previewIcon: 'visTable', - // dont show suggestions for reduced versions or single-line tables - hide: table.changeType === 'reduced' || !table.isMultiRow, - }; - }) - ); + }); + + 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) => 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 index 5624553d92ddf..298b25b5090c4 100644 --- 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 @@ -34,7 +34,6 @@ describe('chart_switch', () => { score: 1, title: '', state: `suggestion ${id}`, - datasourceSuggestionId: options.tables[0].datasourceSuggestionId, previewIcon: 'empty', }, ]; @@ -91,7 +90,6 @@ describe('chart_switch', () => { state: {}, table: { columns: [], - datasourceSuggestionId: 0, isMultiRow: true, layerId: 'a', changeType: 'unchanged', @@ -217,7 +215,6 @@ describe('chart_switch', () => { }, }, ], - datasourceSuggestionId: 0, layerId: 'first', isMultiRow: true, changeType: 'unchanged', @@ -446,7 +443,6 @@ describe('chart_switch', () => { }, }, ], - datasourceSuggestionId: 0, layerId: 'a', isMultiRow: true, changeType: 'unchanged', 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 index c7d1eb543d10d..2faa2ed902b5b 100644 --- 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 @@ -24,12 +24,11 @@ import { EuiPanel, EuiToolTip } from '@elastic/eui'; // datasources to be processed by its callers. const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); -function generateSuggestion(datasourceSuggestionId = 1, state = {}): DatasourceSuggestion { +function generateSuggestion(state = {}): DatasourceSuggestion { return { state, table: { columns: [], - datasourceSuggestionId, isMultiRow: true, layerId: 'first', changeType: 'unchanged', @@ -905,7 +904,6 @@ describe('editor_frame', () => { state: {}, table: { columns: [], - datasourceSuggestionId: 0, isMultiRow: true, layerId: 'first', changeType: 'unchanged', @@ -989,7 +987,6 @@ describe('editor_frame', () => { { title: 'Suggested vis', score: 1, - datasourceSuggestionId: 0, state: initialState, previewIcon: 'empty', }, @@ -1047,6 +1044,17 @@ describe('editor_frame', () => { }); it('should fetch suggestions of all visualizations', async () => { + mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { + state: {}, + table: { + changeType: 'unchanged', + columns: [], + isMultiRow: true, + layerId: 'first', + }, + }, + ]); mount( { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.1, state: {}, title: 'Suggestion6', previewIcon: 'empty', }, { - datasourceSuggestionId: 0, score: 0.5, state: {}, title: 'Suggestion3', previewIcon: 'empty', }, { - datasourceSuggestionId: 0, score: 0.7, state: {}, title: 'Suggestion2', previewIcon: 'empty', }, { - datasourceSuggestionId: 0, score: 0.8, state: {}, title: 'Suggestion1', @@ -1112,14 +1116,12 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.4, state: {}, title: 'Suggestion5', previewIcon: 'empty', }, { - datasourceSuggestionId: 0, score: 0.45, state: {}, title: 'Suggestion4', @@ -1163,7 +1165,6 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.8, state: suggestionVisState, title: 'Suggestion1', @@ -1222,14 +1223,12 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.2, state: {}, title: 'Suggestion1', previewIcon: 'empty', }, { - datasourceSuggestionId: 0, score: 0.8, state: suggestionVisState, title: 'Suggestion2', @@ -1279,14 +1278,12 @@ describe('editor_frame', () => { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.2, state: {}, title: 'Suggestion1', previewIcon: 'empty', }, { - datasourceSuggestionId: 0, score: 0.6, state: {}, title: 'Suggestion2', @@ -1298,7 +1295,6 @@ describe('editor_frame', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.8, state: suggestionVisState, title: 'Suggestion3', 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 index cb4a5bf79e817..7b3e4454a5e39 100644 --- 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 @@ -8,14 +8,9 @@ import { getSuggestions } from './suggestion_helpers'; import { createMockVisualization, createMockDatasource, DatasourceMock } from '../mocks'; import { TableSuggestion, DatasourceSuggestion } from '../../types'; -const generateSuggestion = ( - datasourceSuggestionId: number = 0, - state = {}, - layerId: string = 'first' -): DatasourceSuggestion => ({ +const generateSuggestion = (state = {}, layerId: string = 'first'): DatasourceSuggestion => ({ state, table: { - datasourceSuggestionId, columns: [], isMultiRow: false, layerId, @@ -58,7 +53,6 @@ describe('suggestion helpers', () => { ...mockVisualization, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.5, title: 'Test', state: suggestedState, @@ -88,14 +82,12 @@ describe('suggestion helpers', () => { ...mockVisualization1, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.5, title: 'Test', state: {}, previewIcon: 'empty', }, { - datasourceSuggestionId: 0, score: 0.5, title: 'Test2', state: {}, @@ -107,7 +99,6 @@ describe('suggestion helpers', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.5, title: 'Test3', state: {}, @@ -193,14 +184,12 @@ describe('suggestion helpers', () => { ...mockVisualization1, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.2, title: 'Test', state: {}, previewIcon: 'empty', }, { - datasourceSuggestionId: 0, score: 0.8, title: 'Test2', state: {}, @@ -212,7 +201,6 @@ describe('suggestion helpers', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.6, title: 'Test3', state: {}, @@ -235,14 +223,12 @@ describe('suggestion helpers', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); const table1: TableSuggestion = { - datasourceSuggestionId: 0, columns: [], isMultiRow: true, layerId: 'first', changeType: 'unchanged', }; const table2: TableSuggestion = { - datasourceSuggestionId: 1, columns: [], isMultiRow: true, layerId: 'first', @@ -262,10 +248,10 @@ describe('suggestion helpers', () => { datasourceMap, datasourceStates, }); - expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[0]).toEqual(table1); - expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[1]).toEqual(table2); - expect(mockVisualization2.getSuggestions.mock.calls[0][0].tables[0]).toEqual(table1); - expect(mockVisualization2.getSuggestions.mock.calls[0][0].tables[1]).toEqual(table2); + 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', () => { @@ -274,41 +260,45 @@ describe('suggestion helpers', () => { const tableState1 = {}; const tableState2 = {}; datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ - generateSuggestion(0, tableState1), - generateSuggestion(1, tableState2), + 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: () => [ - { - datasourceSuggestionId: 0, - score: 0.3, - title: 'Test', - state: {}, - previewIcon: 'empty', - }, - { - datasourceSuggestionId: 1, - score: 0.2, - title: 'Test2', - state: {}, - previewIcon: 'empty', - }, - ], + getSuggestions: vis1Suggestions, }, vis2: { ...mockVisualization2, - getSuggestions: () => [ - { - datasourceSuggestionId: 1, - score: 0.1, - title: 'Test3', - state: {}, - previewIcon: 'empty', - }, - ], + getSuggestions: vis2Suggestions, }, }, activeVisualizationId: 'vis1', @@ -367,7 +357,6 @@ describe('suggestion helpers', () => { ...mockVisualization1, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.8, title: 'Test2', state: {}, @@ -379,7 +368,6 @@ describe('suggestion helpers', () => { ...mockVisualization2, getSuggestions: () => [ { - datasourceSuggestionId: 0, score: 0.6, title: 'Test3', state: {}, 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 index b3aac25fd1fcf..270c279375088 100644 --- 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 @@ -6,7 +6,13 @@ import _ from 'lodash'; import { Ast } from '@kbn/interpreter/common'; -import { Visualization, Datasource, FramePublicAPI, TableChangeType } from '../../types'; +import { + Visualization, + Datasource, + FramePublicAPI, + TableChangeType, + TableSuggestion, +} from '../../types'; import { Action } from './state_management'; export interface Suggestion { @@ -63,52 +69,75 @@ export function getSuggestions({ ) ); + // 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) - ) - // TODO have the datasource in there by default - .map(suggestion => ({ ...suggestion, datasourceId })) - ); + return (field + ? datasource.getDatasourceSuggestionsForField(datasourceState, field) + : datasource.getDatasourceSuggestionsFromCurrentState(datasourceState) + ).map(suggestion => ({ ...suggestion, datasourceId })); }) - ).map((suggestion, index) => ({ - ...suggestion, - table: { ...suggestion.table, datasourceSuggestionId: index }, - })); - - const datasourceTables = datasourceTableSuggestions.map(({ table }) => table); + ); + // 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]) => { - return visualization - .getSuggestions({ - tables: datasourceTables, - state: visualizationId === activeVisualizationId ? visualizationState : undefined, - }) - .map(({ datasourceSuggestionId, state, ...suggestion }) => { - const datasourceSuggestion = datasourceTableSuggestions[datasourceSuggestionId]; - return { - ...suggestion, + 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, - visualizationState: state, - keptLayerIds: - visualizationId !== activeVisualizationId - ? [datasourceSuggestion.table.layerId] - : allLayerIds, - datasourceState: datasourceSuggestion.state, - datasourceId: datasourceSuggestion.datasourceId, - columns: datasourceSuggestion.table.columns.length, - changeType: datasourceSuggestion.table.changeType, - }; - }); - }) + 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, 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 index 04f173c75eab2..1e50e58ec993e 100644 --- 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 @@ -549,7 +549,6 @@ describe('workspace_panel', () => { it('should immediately transition if exactly one suggestion is returned', () => { const expectedTable: TableSuggestion = { - datasourceSuggestionId: 0, isMultiRow: true, layerId: '1', columns: [], @@ -566,7 +565,6 @@ describe('workspace_panel', () => { score: 0.5, title: 'my title', state: {}, - datasourceSuggestionId: 0, previewIcon: 'empty', }, ]); @@ -577,7 +575,7 @@ describe('workspace_panel', () => { expect(mockDatasource.getDatasourceSuggestionsForField).toHaveBeenCalledTimes(1); expect(mockVisualization.getSuggestions).toHaveBeenCalledWith( expect.objectContaining({ - tables: [expectedTable], + table: expectedTable, }) ); expect(mockDispatch).toHaveBeenCalledWith({ @@ -594,7 +592,6 @@ describe('workspace_panel', () => { { state: {}, table: { - datasourceSuggestionId: 0, isMultiRow: true, layerId: '1', columns: [], @@ -607,7 +604,6 @@ describe('workspace_panel', () => { score: 0.5, title: 'my title', state: {}, - datasourceSuggestionId: 0, previewIcon: 'empty', }, ]); @@ -622,7 +618,6 @@ describe('workspace_panel', () => { { state: {}, table: { - datasourceSuggestionId: 0, isMultiRow: true, layerId: '1', columns: [], @@ -635,7 +630,6 @@ describe('workspace_panel', () => { score: 0.5, title: 'my title', state: {}, - datasourceSuggestionId: 0, previewIcon: 'empty', }, ]); @@ -650,7 +644,6 @@ describe('workspace_panel', () => { { state: {}, table: { - datasourceSuggestionId: 0, isMultiRow: true, layerId: '1', columns: [], @@ -663,7 +656,6 @@ describe('workspace_panel', () => { score: 0.5, title: 'my title', state: {}, - datasourceSuggestionId: 0, previewIcon: 'empty', }, ]); @@ -681,7 +673,6 @@ describe('workspace_panel', () => { { state: {}, table: { - datasourceSuggestionId: 0, isMultiRow: true, columns: [], layerId: '1', @@ -691,7 +682,6 @@ describe('workspace_panel', () => { { state: {}, table: { - datasourceSuggestionId: 1, isMultiRow: true, columns: [], layerId: '1', @@ -699,6 +689,14 @@ describe('workspace_panel', () => { }, }, ]); + mockVisualization.getSuggestions.mockReturnValueOnce([ + { + score: 0.5, + title: 'second suggestion', + state: {}, + previewIcon: 'empty', + }, + ]); mockVisualization.getSuggestions.mockReturnValueOnce([ { score: 0.8, @@ -706,14 +704,6 @@ describe('workspace_panel', () => { state: { isFirst: true, }, - datasourceSuggestionId: 1, - previewIcon: 'empty', - }, - { - score: 0.5, - title: 'second suggestion', - state: {}, - datasourceSuggestionId: 0, previewIcon: 'empty', }, ]); 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 index ae26633a848ea..7201cd12fce2a 100644 --- 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 @@ -211,7 +211,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table).toEqual({ changeType: 'initial', label: undefined, - datasourceSuggestionId: 0, isMultiRow: true, columns: [ expect.objectContaining({ @@ -253,7 +252,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table).toEqual({ changeType: 'initial', label: undefined, - datasourceSuggestionId: 0, isMultiRow: true, columns: [ expect.objectContaining({ @@ -296,7 +294,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table).toEqual({ changeType: 'initial', label: undefined, - datasourceSuggestionId: 0, isMultiRow: true, columns: [ expect.objectContaining({ @@ -393,7 +390,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table).toEqual({ changeType: 'initial', label: undefined, - datasourceSuggestionId: 0, isMultiRow: true, columns: [ expect.objectContaining({ @@ -435,7 +431,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table).toEqual({ changeType: 'initial', label: undefined, - datasourceSuggestionId: 0, isMultiRow: true, columns: [ expect.objectContaining({ @@ -478,7 +473,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table).toEqual({ changeType: 'initial', label: undefined, - datasourceSuggestionId: 0, isMultiRow: true, columns: [ expect.objectContaining({ @@ -654,7 +648,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table).toEqual({ changeType: 'extended', label: undefined, - datasourceSuggestionId: 0, isMultiRow: true, columns: [ expect.objectContaining({ @@ -825,7 +818,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table).toEqual({ changeType: 'initial', label: undefined, - datasourceSuggestionId: 0, isMultiRow: true, columns: [ expect.objectContaining({ @@ -928,7 +920,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state)).toEqual([ expect.objectContaining({ table: { - datasourceSuggestionId: 0, isMultiRow: true, changeType: 'unchanged', label: undefined, @@ -948,7 +939,6 @@ describe('IndexPattern Data Source suggestions', () => { }), expect.objectContaining({ table: { - datasourceSuggestionId: 1, isMultiRow: true, changeType: 'unchanged', label: undefined, @@ -994,7 +984,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state)[0]).toEqual( expect.objectContaining({ table: { - datasourceSuggestionId: 0, isMultiRow: true, changeType: 'extended', label: 'Over time', @@ -1062,7 +1051,6 @@ describe('IndexPattern Data Source suggestions', () => { expect(indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state)[2]).toEqual( expect.objectContaining({ table: { - datasourceSuggestionId: 2, isMultiRow: true, changeType: 'extended', label: 'Over time', 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 index 5ed236ee73933..8185d18070d2d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts @@ -22,7 +22,6 @@ function buildSuggestion({ state, updatedLayer, layerId, - datasourceSuggestionId, label, changeType, }: { @@ -30,7 +29,6 @@ function buildSuggestion({ layerId: string; changeType: TableChangeType; updatedLayer?: IndexPatternLayer; - datasourceSuggestionId?: number; label?: string; }): DatasourceSuggestion { const columnOrder = (updatedLayer || state.layers[layerId]).columnOrder; @@ -55,7 +53,6 @@ function buildSuggestion({ operation: columnToOperation(columnMap[columnId]), })), isMultiRow, - datasourceSuggestionId: datasourceSuggestionId || 0, layerId, changeType, label, @@ -331,7 +328,6 @@ export function getDatasourceSuggestionsFromCurrentState( buildSuggestion({ state, layerId, - datasourceSuggestionId: index, changeType: 'unchanged', }) ); @@ -346,7 +342,6 @@ export function getDatasourceSuggestionsFromCurrentState( buildSuggestion({ state, layerId, - datasourceSuggestionId: index, changeType: 'unchanged', }) ); @@ -366,11 +361,6 @@ export function getDatasourceSuggestionsFromCurrentState( return suggestions; }) - ).map( - (suggestion, index): DatasourceSuggestion => ({ - ...suggestion, - table: { ...suggestion.table, datasourceSuggestionId: index }, - }) ); } 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 index 73767375465b5..8baa78987b756 100644 --- 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 @@ -5,7 +5,7 @@ */ import { getSuggestions } from './metric_suggestions'; -import { TableSuggestionColumn } from '..'; +import { TableSuggestionColumn, TableSuggestion } from '..'; describe('metric_suggestions', () => { function numCol(columnId: string): TableSuggestionColumn { @@ -49,65 +49,54 @@ describe('metric_suggestions', () => { }; expect( - getSuggestions({ - tables: [ - { - columns: [dateCol('a')], - datasourceSuggestionId: 0, - isMultiRow: true, - layerId: 'l1', - changeType: 'unchanged', - }, - { - columns: [strCol('foo'), strCol('bar')], - datasourceSuggestionId: 1, - isMultiRow: true, - layerId: 'l1', - changeType: 'unchanged', - }, - { - layerId: 'l1', - datasourceSuggestionId: 2, - isMultiRow: true, - columns: [numCol('bar')], - changeType: 'unchanged', - }, - { - columns: [unknownCol(), numCol('bar')], - datasourceSuggestionId: 3, - isMultiRow: true, - layerId: 'l1', - changeType: 'unchanged', - }, - { - columns: [numCol('bar'), numCol('baz')], - datasourceSuggestionId: 4, - isMultiRow: false, - layerId: 'l1', - changeType: 'unchanged', - }, - ], - }) - ).toEqual([]); - }); - - test('suggests a basic metric chart', () => { - const [suggestion, ...rest] = getSuggestions({ - tables: [ + ([ { - columns: [numCol('bytes')], - datasourceSuggestionId: 0, + 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 { - "datasourceSuggestionId": 0, "previewExpression": Object { "chain": Array [ Object { 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 index c078f4ba57752..9cf5133527183 100644 --- 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 @@ -12,20 +12,25 @@ import { State } from './types'; * * @param opts */ -export function getSuggestions( - opts: SuggestionRequest -): Array> { - return ( - opts.tables - .filter( - ({ isMultiRow, columns }) => - // We only render metric charts for single-row queries. We require a single, numeric column. - !isMultiRow && columns.length === 1 && columns[0].operation.dataType === 'number' - ) - // don't suggest current table if visualization is active - .filter(({ changeType }) => !opts.state || changeType !== 'unchanged') - .map(table => getSuggestion(table)) - ); +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 { @@ -35,7 +40,6 @@ function getSuggestion(table: TableSuggestion): VisualizationSuggestion { return { title, score: 0.5, - datasourceSuggestionId: table.datasourceSuggestionId, previewIcon: 'visMetric', previewExpression: { type: 'expression', diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index affe077909947..14fa018fa6e5d 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -53,11 +53,6 @@ export interface TableSuggestionColumn { * is possible, the visualization returns a `VisualizationSuggestion` object */ export interface TableSuggestion { - /** - * The id of this table. This id has to be included in the `VisualizationSuggestion` to map - * the visualization to the right table as there can be multiple tables in a single `SuggestionRequest`. - */ - datasourceSuggestionId: number; /** * Flag indicating whether the table will include more than one column. * This is not the case for example for a single metric aggregation @@ -222,10 +217,23 @@ export interface VisualizationProps { 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 { - // It is up to the Visualization to rank these tables - tables: TableSuggestion[]; - state?: T; // State is only passed if the visualization is active + /** + * A table configuration the datasource could provide. + */ + table: TableSuggestion; + /** + * State is only passed if the visualization is active. + */ + state?: T; } /** @@ -256,11 +264,6 @@ export interface VisualizationSuggestion { * The new state of the visualization if this suggestion is applied. */ state: T; - /** - * The id of the `TableSuggestion` object this visualization suggestion is based on. - * This is used to switch the datasource configuration to the right table. - */ - datasourceSuggestionId: number; /** * 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. @@ -316,5 +319,5 @@ export interface Visualization { // The frame will call this function on all visualizations when the table changes, or when // rendering additional ways of using the data - getSuggestions: (options: SuggestionRequest) => Array>; + getSuggestions: (context: SuggestionRequest) => Array>; } 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 index ceab87e9d9516..ececea6a1d99f 100644 --- 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 @@ -5,7 +5,12 @@ */ import { getSuggestions } from './xy_suggestions'; -import { TableSuggestionColumn, VisualizationSuggestion, DataType } from '../types'; +import { + TableSuggestionColumn, + VisualizationSuggestion, + DataType, + TableSuggestion, +} from '../types'; import { State, XYState } from './types'; import { generateId } from '../id_generator'; import { Ast } from '@kbn/interpreter/target/common'; @@ -67,53 +72,44 @@ describe('xy_suggestions', () => { }; expect( - getSuggestions({ - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: [dateCol('a')], - layerId: 'first', - changeType: 'unchanged', - }, - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [strCol('foo'), strCol('bar')], - layerId: 'first', - changeType: 'unchanged', - }, - { - datasourceSuggestionId: 2, - isMultiRow: false, - columns: [strCol('foo'), numCol('bar')], - layerId: 'first', - changeType: 'unchanged', - }, - { - datasourceSuggestionId: 3, - isMultiRow: true, - columns: [unknownCol(), numCol('bar')], - layerId: 'first', - changeType: 'unchanged', - }, - ], - }) - ).toEqual([]); - }); - - test('suggests a basic x y chart with date on x', () => { - (generateId as jest.Mock).mockReturnValueOnce('aaa'); - const [suggestion, ...rest] = getSuggestions({ - tables: [ + ([ { - datasourceSuggestionId: 0, isMultiRow: true, - columns: [numCol('bytes'), dateCol('date')], + 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); @@ -133,21 +129,18 @@ describe('xy_suggestions', () => { test('does not suggest multiple splits', () => { const suggestions = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [ - numCol('price'), - numCol('quantity'), - dateCol('date'), - strCol('product'), - strCol('city'), - ], - layerId: 'first', - changeType: 'unchanged', - }, - ], + table: { + isMultiRow: true, + columns: [ + numCol('price'), + numCol('quantity'), + dateCol('date'), + strCol('product'), + strCol('city'), + ], + layerId: 'first', + changeType: 'unchanged', + }, }); expect(suggestions).toHaveLength(0); @@ -155,15 +148,12 @@ describe('xy_suggestions', () => { test('suggests a split x y chart with date on x', () => { const [suggestion, ...rest] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], - layerId: 'first', - changeType: 'unchanged', - }, - ], + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, }); expect(rest).toHaveLength(0); @@ -184,16 +174,13 @@ describe('xy_suggestions', () => { test('uses datasource provided title if available', () => { const [suggestion, ...rest] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], - layerId: 'first', - changeType: 'unchanged', - label: 'Datasource title', - }, - ], + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + label: 'Datasource title', + }, }); expect(rest).toHaveLength(0); @@ -202,15 +189,12 @@ describe('xy_suggestions', () => { test('hides reduced suggestions if there is a current state', () => { const [suggestion, ...rest] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], - layerId: 'first', - changeType: 'reduced', - }, - ], + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'reduced', + }, state: { isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, @@ -233,15 +217,12 @@ describe('xy_suggestions', () => { test('does not hide reduced suggestions if xy visualization is not active', () => { const [suggestion, ...rest] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], - layerId: 'first', - changeType: 'reduced', - }, - ], + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'reduced', + }, }); expect(rest).toHaveLength(0); @@ -264,15 +245,12 @@ describe('xy_suggestions', () => { ], }; const [suggestion, ...rest] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], - layerId: 'first', - changeType: 'unchanged', - }, - ], + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, state: currentState, }); @@ -303,15 +281,12 @@ describe('xy_suggestions', () => { ], }; const [suggestion, ...rest] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('price'), numCol('quantity'), strCol('product')], - layerId: 'first', - changeType: 'unchanged', - }, - ], + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, state: currentState, }); @@ -323,66 +298,15 @@ describe('xy_suggestions', () => { expect(suggestion.title).toEqual('Flip'); }); - test('supports multiple suggestions', () => { - (generateId as jest.Mock).mockReturnValueOnce('bbb').mockReturnValueOnce('ccc'); - const [s1, s2, ...rest] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: [numCol('price'), dateCol('date')], - layerId: 'first', - changeType: 'unchanged', - }, - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('count'), strCol('country')], - layerId: 'first', - changeType: 'unchanged', - }, - ], - }); - - expect(rest).toHaveLength(0); - expect([suggestionSubset(s1), suggestionSubset(s2)]).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "seriesType": "area", - "splitAccessor": "bbb", - "x": "date", - "y": Array [ - "price", - ], - }, - ], - Array [ - Object { - "seriesType": "bar", - "splitAccessor": "ccc", - "x": "country", - "y": Array [ - "count", - ], - }, - ], - ] - `); - }); - test('handles two numeric values', () => { (generateId as jest.Mock).mockReturnValueOnce('ddd'); const [suggestion] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [numCol('quantity'), numCol('price')], - layerId: 'first', - changeType: 'unchanged', - }, - ], + table: { + isMultiRow: true, + columns: [numCol('quantity'), numCol('price')], + layerId: 'first', + changeType: 'unchanged', + }, }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` @@ -402,25 +326,22 @@ describe('xy_suggestions', () => { test('handles unbucketed suggestions', () => { (generateId as jest.Mock).mockReturnValueOnce('eee'); const [suggestion] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 1, - isMultiRow: true, - columns: [ - numCol('num votes'), - { - columnId: 'mybool', - operation: { - dataType: 'boolean', - isBucketed: false, - label: 'Yes / No', - }, + table: { + isMultiRow: true, + columns: [ + numCol('num votes'), + { + columnId: 'mybool', + operation: { + dataType: 'boolean', + isBucketed: false, + label: 'Yes / No', }, - ], - layerId: 'first', - changeType: 'unchanged', - }, - ], + }, + ], + layerId: 'first', + changeType: 'unchanged', + }, }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` @@ -439,15 +360,12 @@ describe('xy_suggestions', () => { test('adds a preview expression with disabled axes and legend', () => { const [suggestion] = getSuggestions({ - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: [numCol('bytes'), dateCol('date')], - layerId: 'first', - changeType: 'unchanged', - }, - ], + table: { + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + layerId: 'first', + changeType: 'unchanged', + }, }); const expression = suggestion.previewExpression! as Ast; 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 index 648a411a13413..3ffd15067e73c 100644 --- 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 @@ -47,22 +47,29 @@ function getIconForSeries(type: SeriesType): EuiIconType { * * @param opts */ -export function getSuggestions( - opts: SuggestionRequest -): Array> { - return opts.tables - .filter( - ({ isMultiRow, columns }) => - // 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. - isMultiRow && - columns.length > 1 && - columns.some(col => col.operation.dataType === 'number') && - !columns.some(col => !columnSortOrder.hasOwnProperty(col.operation.dataType)) - ) - .map(table => getSuggestionForColumns(table, opts.state)) - .filter((suggestion): suggestion is VisualizationSuggestion => suggestion !== undefined); +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( @@ -77,7 +84,6 @@ function getSuggestionForColumns( if (buckets.length === 1 || buckets.length === 2) { const [x, splitBy] = buckets; return getSuggestion( - table.datasourceSuggestionId, table.layerId, table.changeType, x, @@ -89,7 +95,6 @@ function getSuggestionForColumns( } else if (buckets.length === 0) { const [x, ...yValues] = values; return getSuggestion( - table.datasourceSuggestionId, table.layerId, table.changeType, x, @@ -111,7 +116,6 @@ function prioritizeColumns(columns: TableSuggestionColumn[]) { } function getSuggestion( - datasourceSuggestionId: number, layerId: string, changeType: TableChangeType, xValue: TableSuggestionColumn, @@ -133,7 +137,6 @@ function getSuggestion( yValues, splitBy, changeType, - datasourceSuggestionId, xValue, }; @@ -245,7 +248,6 @@ function buildSuggestion({ yValues, splitBy, changeType, - datasourceSuggestionId, xValue, }: { currentState: XYState | undefined; @@ -257,7 +259,6 @@ function buildSuggestion({ splitBy: TableSuggestionColumn | undefined; layerId: string; changeType: string; - datasourceSuggestionId: number; }) { const newLayer = { ...(getExistingLayer(currentState, layerId) || {}), @@ -284,7 +285,6 @@ function buildSuggestion({ 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', - datasourceSuggestionId, state, previewIcon: getIconForSeries(seriesType), previewExpression: buildPreviewExpression(state, layerId, xValue, yValues, splitBy), From c250c7d6c8a38bd57c6edcfcdba2e515a0cf5b77 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Wed, 4 Sep 2019 11:00:39 -0400 Subject: [PATCH 095/105] [Lens] Make breadcrumbs look and feel like Visualize (#44258) --- .../lens/public/app_plugin/app.test.tsx | 43 ++++++++++++++++++- .../plugins/lens/public/app_plugin/app.tsx | 17 ++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) 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 index 3b30c040985c4..b0dcdfa8e288f 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { App } from './app'; import { EditorFrameInstance } from '../types'; - import { Chrome } from 'ui/chrome'; import { toastNotifications } from 'ui/notify'; import { Storage } from 'ui/storage'; @@ -44,6 +43,8 @@ function makeDefaultArgs(): jest.Mocked<{ return ({ editorFrame: createMockFrame(), chrome: { + addBasePath: (s: string) => `/testbasepath/${s}`, + breadcrumbs: { set: jest.fn() }, getUiSettingsClient() { return { get: jest.fn(type => { @@ -114,6 +115,46 @@ describe('Lens App', () => { `); }); + it('sets breadcrumbs when the document title changes', async () => { + const mockSet = jest.fn(); + const defaultArgs = makeDefaultArgs(); + const args = { + ...defaultArgs, + chrome: ({ + ...defaultArgs.chrome, + addBasePath: jest.fn(s => `/testbasepath${s}`), + breadcrumbs: { + ...defaultArgs.chrome.breadcrumbs, + set: mockSet, + }, + } as unknown) as Chrome, + }; + + const instance = mount(); + + expect(mockSet).toHaveBeenCalledWith([ + { text: 'Visualize', href: '/testbasepath/app/kibana#/visualize' }, + { text: 'Create' }, + ]); + + (args.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(mockSet).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(); diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index 2b768e621c17d..243ee3df97980 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -87,6 +87,23 @@ export function App({ const lastKnownDocRef = useRef(undefined); + // Sync Kibana breadcrumbs any time the saved document's title changes + useEffect(() => { + chrome.breadcrumbs.set([ + { + href: chrome.addBasePath(`/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 }); From 9f36baf025216c9164a949d712a806fbc40fd901 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Thu, 5 Sep 2019 15:57:50 -0400 Subject: [PATCH 096/105] [lens] Fix breakage from app-arch movements (#44720) --- .../kbn-interpreter/src/common/index.d.ts | 2 +- .../expressions/expression_renderer.tsx | 2 +- .../public/expressions/expression_runner.ts | 84 ------------------- .../public/expressions/expressions_service.ts | 5 +- .../data/public/expressions/lib/_types.ts | 4 +- src/legacy/core_plugins/data/public/plugin.ts | 12 ++- .../lens/public/app_plugin/app.test.tsx | 5 ++ .../plugins/lens/public/app_plugin/app.tsx | 4 + .../plugins/lens/public/app_plugin/plugin.tsx | 29 +++++-- .../expression.tsx | 4 +- .../datatable_visualization_plugin/plugin.tsx | 25 ++---- .../editor_frame/expression_helpers.ts | 2 +- .../embeddable/embeddable.test.tsx | 7 +- .../embeddable/embeddable.tsx | 2 +- .../embeddable/embeddable_factory.ts | 2 +- .../embeddable/expression_wrapper.tsx | 4 +- .../lens/public/editor_frame_plugin/mocks.tsx | 45 ++++++---- .../editor_frame_plugin/plugin.test.tsx | 28 +++++-- .../public/editor_frame_plugin/plugin.tsx | 63 ++++++++------ x-pack/legacy/plugins/lens/public/index.ts | 5 +- .../dimension_panel/dimension_panel.test.tsx | 3 +- .../dimension_panel/dimension_panel.tsx | 3 +- .../dimension_panel/popover_editor.tsx | 1 + .../indexpattern_plugin/indexpattern.test.ts | 2 + .../indexpattern_plugin/indexpattern.tsx | 8 +- .../indexpattern_suggestions.test.tsx | 2 + .../definitions/date_histogram.test.tsx | 8 +- .../operations/definitions/date_histogram.tsx | 6 +- .../definitions/filter_ratio.test.tsx | 6 +- .../operations/definitions/filter_ratio.tsx | 12 ++- .../operations/definitions/index.ts | 3 +- .../operations/definitions/terms.test.tsx | 9 +- .../operations/definitions/terms.tsx | 6 +- .../public/indexpattern_plugin/plugin.tsx | 1 + .../plugins/lens/public/interpreter_types.ts | 32 ------- .../metric_expression.tsx | 4 +- .../metric_visualization_plugin/plugin.tsx | 48 ++--------- .../lens/public/register_vis_type_alias.ts | 4 +- x-pack/legacy/plugins/lens/public/types.ts | 5 +- .../public/xy_visualization_plugin/plugin.tsx | 37 ++++---- .../xy_visualization_plugin/xy_expression.tsx | 4 +- 41 files changed, 245 insertions(+), 293 deletions(-) delete mode 100644 src/legacy/core_plugins/data/public/expressions/expression_runner.ts delete mode 100644 x-pack/legacy/plugins/lens/public/interpreter_types.ts 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/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx b/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx index e5358acc1c05c..11921ca9cf269 100644 --- a/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx +++ b/src/legacy/core_plugins/data/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/data/public/expressions/expression_runner.ts b/src/legacy/core_plugins/data/public/expressions/expression_runner.ts deleted file mode 100644 index ac087e2f530f0..0000000000000 --- a/src/legacy/core_plugins/data/public/expressions/expression_runner.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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. - */ - -import { Ast, fromExpression } from '@kbn/interpreter/common'; - -import { RequestAdapter, DataAdapter } from 'ui/inspector/adapters'; -import { RenderFunctionsRegistry, Interpreter, Result } from './expressions_service'; - -export interface ExpressionRunnerOptions { - // TODO use the real types here once they are ready - context?: object; - getInitialContext?: () => object; - element?: Element; - /** - * If an element is specified, but the response of the expression run can't be rendered - * because it isn't a valid response or the specified renderer isn't available, - * this callback is called with the given result. - */ - onRenderFailure?: (result: Result) => void; -} - -export type ExpressionRunner = ( - expression: string | Ast, - options: ExpressionRunnerOptions -) => Promise; - -export const createRunFn = ( - renderersRegistry: RenderFunctionsRegistry, - interpreterPromise: Promise -): ExpressionRunner => async ( - expressionOrAst, - { element, context, getInitialContext, onRenderFailure } -) => { - // TODO: make interpreter initialization synchronous to avoid this - const interpreter = await interpreterPromise; - const ast = - typeof expressionOrAst === 'string' ? fromExpression(expressionOrAst) : expressionOrAst; - - const response = await interpreter.interpretAst(ast, context || { type: 'null' }, { - getInitialContext: getInitialContext || (() => ({})), - inspectorAdapters: { - // TODO connect real adapters - requests: new RequestAdapter(), - data: new DataAdapter(), - }, - }); - - if (response.type === 'error') { - throw response; - } - - if (element) { - if (response.type === 'render' && response.as && renderersRegistry.get(response.as) !== null) { - renderersRegistry.get(response.as).render(element, response.value, { - onDestroy: fn => { - // TODO implement - }, - done: () => { - // TODO implement - }, - }); - } else { - throw response; - } - } - - return response; -}; diff --git a/src/legacy/core_plugins/data/public/expressions/expressions_service.ts b/src/legacy/core_plugins/data/public/expressions/expressions_service.ts index a00512f04c1ac..2aff9cab67e82 100644 --- a/src/legacy/core_plugins/data/public/expressions/expressions_service.ts +++ b/src/legacy/core_plugins/data/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/data/public/expressions/lib/_types.ts b/src/legacy/core_plugins/data/public/expressions/lib/_types.ts index b3185fda2c178..e458260432eaa 100644 --- a/src/legacy/core_plugins/data/public/expressions/lib/_types.ts +++ b/src/legacy/core_plugins/data/public/expressions/lib/_types.ts @@ -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/data/public/plugin.ts b/src/legacy/core_plugins/data/public/plugin.ts index b62c8106bc168..ddbc663525f3b 100644 --- a/src/legacy/core_plugins/data/public/plugin.ts +++ b/src/legacy/core_plugins/data/public/plugin.ts @@ -79,6 +79,8 @@ export class DataPlugin implements Plugin ({ QueryBar: jest.fn(() => null), @@ -22,6 +23,7 @@ jest.mock('ui/new_platform'); jest.mock('ui/notify'); jest.mock('ui/chrome'); jest.mock('../persistence'); +jest.mock('src/core/public'); const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); @@ -39,6 +41,7 @@ function makeDefaultArgs(): jest.Mocked<{ docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + savedObjectsClient: SavedObjectsClientContract; }> { return ({ editorFrame: createMockFrame(), @@ -68,6 +71,7 @@ function makeDefaultArgs(): jest.Mocked<{ }, QueryBar: jest.fn(() =>
), redirectTo: jest.fn(id => {}), + savedObjectsClient: jest.fn(), } as unknown) as jest.Mocked<{ editorFrame: EditorFrameInstance; chrome: Chrome; @@ -75,6 +79,7 @@ function makeDefaultArgs(): jest.Mocked<{ docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + savedObjectsClient: SavedObjectsClientContract; }>; } diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index 243ee3df97980..82353f6550151 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -12,6 +12,7 @@ import { EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Storage } from 'ui/storage'; import { toastNotifications } from 'ui/notify'; import { Chrome } from 'ui/chrome'; +import { SavedObjectsClientContract } from 'src/core/public'; import { Query, QueryBar } from '../../../../../../src/legacy/core_plugins/data/public/query'; import { Document, SavedObjectStore } from '../persistence'; import { EditorFrameInstance } from '../types'; @@ -55,6 +56,7 @@ export function App({ docId, docStorage, redirectTo, + savedObjectsClient, }: { editorFrame: EditorFrameInstance; chrome: Chrome; @@ -62,6 +64,7 @@ export function App({ docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + savedObjectsClient: SavedObjectsClientContract; }) { const uiSettings = chrome.getUiSettingsClient(); const timeDefaults = uiSettings.get('timepicker:timeDefaults'); @@ -228,6 +231,7 @@ export function App({ state.localQueryBarState.dateRange && state.localQueryBarState.dateRange.to } uiSettings={uiSettings} + savedObjectsClient={savedObjectsClient} />
diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index 07bd55cbd4e93..f3580a59ae89d 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -9,7 +9,7 @@ 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 { editorFrameSetup, editorFrameStop } from '../editor_frame_plugin'; +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'; @@ -23,6 +23,7 @@ import { EditorFrameInstance } from '../types'; export class AppPlugin { private instance: EditorFrameInstance | null = null; + private store: SavedObjectIndexStore | null = null; constructor() {} @@ -33,15 +34,25 @@ export class AppPlugin { const datatableVisualization = datatableVisualizationSetup(); const xyVisualization = xyVisualizationSetup(); const metricVisualization = metricVisualizationSetup(); - const editorFrame = editorFrameSetup(); - const store = new SavedObjectIndexStore(chrome!.getSavedObjectsClient()); + const editorFrameSetupInterface = editorFrameSetup(); + this.store = new SavedObjectIndexStore(chrome!.getSavedObjectsClient()); - editorFrame.registerDatasource('indexpattern', indexPattern); - editorFrame.registerVisualization(xyVisualization); - editorFrame.registerVisualization(datatableVisualization); - editorFrame.registerVisualization(metricVisualization); + editorFrameSetupInterface.registerDatasource('indexpattern', indexPattern); + editorFrameSetupInterface.registerVisualization(xyVisualization); + editorFrameSetupInterface.registerVisualization(datatableVisualization); + editorFrameSetupInterface.registerVisualization(metricVisualization); + } + + start() { + if (this.store === null) { + throw new Error('Start lifecycle called before setup lifecycle'); + } + + const store = this.store; + + const editorFrameStartInterface = editorFrameStart(); - this.instance = editorFrame.createInstance({}); + this.instance = editorFrameStartInterface.createInstance({}); const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { return ( @@ -49,6 +60,7 @@ export class AppPlugin { editorFrame={this.instance!} chrome={chrome} store={new Storage(localStorage)} + savedObjectsClient={chrome.getSavedObjectsClient()} docId={routeProps.match.params.id} docStorage={store} redirectTo={id => { @@ -96,4 +108,5 @@ export class AppPlugin { const app = new AppPlugin(); export const appSetup = () => app.setup(); +export const appStart = () => app.start(); 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 index 0e53ee59761f5..353bb85dd32e1 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiBasicTable } from '@elastic/eui'; import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; import { LensMultiTable } from '../types'; -import { RenderFunction } from '../interpreter_types'; +import { IInterpreterRenderFunction } from '../../../../../../src/legacy/core_plugins/data/public/expressions/lib/_types'; import { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities'; export interface DatatableColumns { @@ -109,7 +109,7 @@ export const datatableColumns: ExpressionFunction< export const getDatatableRenderer = ( formatFactory: FormatFactory -): RenderFunction => ({ +): IInterpreterRenderFunction => ({ name: 'lens_datatable_renderer', displayName: i18n.translate('xpack.lens.datatable.visualizationName', { defaultMessage: 'Datatable', 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 index 52f4f99513e7a..9716b6b708b60 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx @@ -8,16 +8,12 @@ import { CoreSetup } from 'src/core/public'; import { getFormat, FormatFactory } from 'ui/visualize/loader/pipeline_helpers/utilities'; import { datatableVisualization } from './visualization'; - -import { - renderersRegistry, - functionsRegistry, -} from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; -import { InterpreterSetup, RenderFunction } from '../interpreter_types'; +import { ExpressionsSetup } from '../../../../../../src/legacy/core_plugins/data/public/expressions'; +import { setup as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; import { datatable, datatableColumns, getDatatableRenderer } from './expression'; export interface DatatableVisualizationPluginSetupPlugins { - interpreter: InterpreterSetup; + expressions: ExpressionsSetup; // TODO this is a simulated NP plugin. // Once field formatters are actually migrated, the actual shim can be used fieldFormat: { @@ -30,13 +26,11 @@ class DatatableVisualizationPlugin { setup( _core: CoreSetup | null, - { interpreter, fieldFormat }: DatatableVisualizationPluginSetupPlugins + { expressions, fieldFormat }: DatatableVisualizationPluginSetupPlugins ) { - interpreter.functionsRegistry.register(() => datatableColumns); - interpreter.functionsRegistry.register(() => datatable); - interpreter.renderersRegistry.register( - () => getDatatableRenderer(fieldFormat.formatFactory) as RenderFunction - ); + expressions.registerFunction(() => datatableColumns); + expressions.registerFunction(() => datatable); + expressions.registerRenderer(() => getDatatableRenderer(fieldFormat.formatFactory)); return datatableVisualization; } @@ -48,10 +42,7 @@ const plugin = new DatatableVisualizationPlugin(); export const datatableVisualizationSetup = () => plugin.setup(null, { - interpreter: { - renderersRegistry, - functionsRegistry, - }, + expressions: dataSetup.expressions, fieldFormat: { formatFactory: getFormat, }, 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 index 1b71f28260088..da7ddee67453e 100644 --- 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TimeRange } from 'ui/timefilter/time_history'; +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'; 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 index 2009eb232562b..375b29bd10d34 100644 --- 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 @@ -5,7 +5,7 @@ */ import { Embeddable } from './embeddable'; -import { TimeRange } from 'ui/timefilter/time_history'; +import { TimeRange } from 'src/plugins/data/public'; import { Query, ExpressionRendererProps } from 'src/legacy/core_plugins/data/public'; import { Filter } from '@kbn/es-query'; import { Document } from '../../persistence'; @@ -104,7 +104,7 @@ describe('embeddable', () => { expect(expressionRenderer).toHaveBeenCalledTimes(2); }); - it('should pass context in getInitialContext handler', () => { + 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 } }]; @@ -120,7 +120,8 @@ describe('embeddable', () => { ); embeddable.render(mountpoint); - expect(expressionRenderer.mock.calls[0][0].getInitialContext!()).toEqual({ + expect(expressionRenderer.mock.calls[0][0].searchContext).toEqual({ + type: 'kibana_context', timeRange, query, filters, 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 index f50ee70731642..8827e5a6397c4 100644 --- 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 @@ -8,7 +8,7 @@ import _ from 'lodash'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { TimeRange } from 'ui/timefilter/time_history'; +import { TimeRange } from 'src/plugins/data/public'; import { Query, StaticIndexPattern, ExpressionRenderer } from 'src/legacy/core_plugins/data/public'; import { Filter } from '@kbn/es-query'; import { Subscription } from 'rxjs'; 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 index edd219b64701b..98f831b7050e0 100644 --- 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 @@ -46,8 +46,8 @@ export class EmbeddableFactory extends AbstractEmbeddableFactory { }, }); this.chrome = chrome; - this.indexPatternService = indexPatternService; this.expressionRenderer = expressionRenderer; + this.indexPatternService = indexPatternService; } public isEditable() { 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 index d5ba7bcd39118..e5aabf159de63 100644 --- 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 @@ -10,7 +10,7 @@ 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 'ui/timefilter/time_history'; +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/data/public'; @@ -61,7 +61,7 @@ export function ExpressionWrapper({ onRenderFailure={(e: unknown) => { setExpressionError(e); }} - getInitialContext={() => context} + searchContext={{ ...context, type: 'kibana_context' }} /> )} 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 index a1bcc921104bc..582aa42051aca 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -6,11 +6,15 @@ import React from 'react'; import { ExpressionRendererProps } from 'src/legacy/core_plugins/data/public'; -import { setup as data } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; +import { + setup as dataSetup, + start as dataStart, +} from '../../../../../../src/legacy/core_plugins/data/public/legacy'; import { DatasourcePublicAPI, FramePublicAPI, Visualization, Datasource } from '../types'; -import { EditorFrameSetupPlugins } from './plugin'; +import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './plugin'; -type DataSetup = typeof data; +type DataSetup = typeof dataSetup; +type DataStart = typeof dataStart; export function createMockVisualization(): jest.Mocked { return { @@ -80,10 +84,14 @@ export function createMockFramePublicAPI(): FrameMock { type Omit = Pick>; -export type MockedDependencies = Omit & { +export type MockedSetupDependencies = Omit & { data: Omit & { expressions: jest.Mocked }; }; +export type MockedStartDependencies = Omit & { + data: Omit & { expressions: jest.Mocked }; +}; + export function createExpressionRendererMock(): jest.Mock< React.ReactElement, [ExpressionRendererProps] @@ -91,12 +99,25 @@ export function createExpressionRendererMock(): jest.Mock< return jest.fn(_ => ); } -export function createMockDependencies() { +export function createMockSetupDependencies() { return ({ data: { expressions: { - ExpressionRenderer: createExpressionRendererMock(), - run: jest.fn(_ => Promise.resolve({ type: 'render', as: 'test', value: undefined })), + registerFunction: jest.fn(), + registerRenderer: jest.fn(), + }, + }, + chrome: { + getSavedObjectsClient: () => {}, + }, + } as unknown) as MockedSetupDependencies; +} + +export function createMockStartDependencies() { + return ({ + data: { + expressions: { + ExpressionRenderer: jest.fn(() => null), }, indexPatterns: { indexPatterns: {}, @@ -105,13 +126,5 @@ export function createMockDependencies() { embeddables: { registerEmbeddableFactory: jest.fn(), }, - chrome: { - getSavedObjectsClient: () => {}, - }, - interpreter: { - functionsRegistry: { - register: jest.fn(), - }, - }, - } as unknown) as MockedDependencies; + } 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 index 09a22c61d0ccf..aa37b654f4511 100644 --- 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 @@ -5,7 +5,12 @@ */ import { EditorFramePlugin } from './plugin'; -import { createMockDependencies, MockedDependencies } from './mocks'; +import { + MockedSetupDependencies, + MockedStartDependencies, + createMockSetupDependencies, + createMockStartDependencies, +} from './mocks'; jest.mock('ui/chrome', () => ({ getSavedObjectsClient: jest.fn(), @@ -13,18 +18,25 @@ jest.mock('ui/chrome', () => ({ // 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', () => {}); -jest.mock('./embeddable/embeddable_factory', () => ({ EmbeddableFactory: class Mock {} })); +jest.mock('../../../../../../src/legacy/core_plugins/data/public/legacy', () => ({ + start: {}, + setup: {}, +})); +jest.mock('./embeddable/embeddable_factory', () => ({ + EmbeddableFactory: class Mock {}, +})); describe('editor_frame plugin', () => { let pluginInstance: EditorFramePlugin; let mountpoint: Element; - let pluginDependencies: MockedDependencies; + let pluginSetupDependencies: MockedSetupDependencies; + let pluginStartDependencies: MockedStartDependencies; beforeEach(() => { pluginInstance = new EditorFramePlugin(); mountpoint = document.createElement('div'); - pluginDependencies = createMockDependencies(); + pluginSetupDependencies = createMockSetupDependencies(); + pluginStartDependencies = createMockStartDependencies(); }); afterEach(() => { @@ -33,7 +45,8 @@ describe('editor_frame plugin', () => { it('should create an editor frame instance which mounts and unmounts', () => { expect(() => { - const publicAPI = pluginInstance.setup(null, pluginDependencies); + pluginInstance.setup(null, pluginSetupDependencies); + const publicAPI = pluginInstance.start(null, pluginStartDependencies); const instance = publicAPI.createInstance({}); instance.mount(mountpoint, { onError: jest.fn(), @@ -46,7 +59,8 @@ describe('editor_frame plugin', () => { }); it('should not have child nodes after unmount', () => { - const publicAPI = pluginInstance.setup(null, pluginDependencies); + pluginInstance.setup(null, pluginSetupDependencies); + const publicAPI = pluginInstance.start(null, pluginStartDependencies); const instance = publicAPI.createInstance({}); instance.mount(mountpoint, { onError: jest.fn(), 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 index 62339a4cc3afc..2b83f50924e8a 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -7,32 +7,34 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; -import { Registry } from '@kbn/interpreter/target/common'; -import { CoreSetup } from 'src/core/public'; +import { CoreSetup, CoreStart } from 'src/core/public'; import chrome, { Chrome } from 'ui/chrome'; import { Plugin as EmbeddablePlugin } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; -import { setup as embeddablePlugin } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; -import { setup as data } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; -import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/public'; -import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; -import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; +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 { + 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 data; - chrome: Chrome; - embeddables: ReturnType; - interpreter: InterpreterSetup; + data: typeof dataSetup; } -export interface InterpreterSetup { - functionsRegistry: Registry< - ExpressionFunction, - ExpressionFunction - >; +export interface EditorFrameStartPlugins { + data: typeof dataStart; + embeddables: ReturnType; + chrome: Chrome; } export class EditorFramePlugin { @@ -42,8 +44,19 @@ export class EditorFramePlugin { private readonly visualizations: Record = {}; public setup(_core: CoreSetup | null, plugins: EditorFrameSetupPlugins): EditorFrameSetup { - plugins.interpreter.functionsRegistry.register(() => mergeTables); + plugins.data.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 | null, plugins: EditorFrameStartPlugins): EditorFrameStart { plugins.embeddables.registerEmbeddableFactory( 'lens', new EmbeddableFactory( @@ -92,12 +105,6 @@ export class EditorFramePlugin { return { createInstance, - registerDatasource: (name, datasource) => { - this.datasources[name] = datasource as Datasource; - }, - registerVisualization: visualization => { - this.visualizations[visualization.id] = visualization as Visualization; - }, }; } @@ -110,12 +117,14 @@ const editorFrame = new EditorFramePlugin(); export const editorFrameSetup = () => editorFrame.setup(null, { - data, + data: dataSetup, + }); + +export const editorFrameStart = () => + editorFrame.start(null, { + data: dataStart, chrome, embeddables: embeddablePlugin, - interpreter: { - functionsRegistry, - }, }); export const editorFrameStop = () => editorFrame.stop(); diff --git a/x-pack/legacy/plugins/lens/public/index.ts b/x-pack/legacy/plugins/lens/public/index.ts index 8e232e878e91a..48a37fd5d8656 100644 --- a/x-pack/legacy/plugins/lens/public/index.ts +++ b/x-pack/legacy/plugins/lens/public/index.ts @@ -20,7 +20,7 @@ import 'uiExports/savedObjectTypes'; import { render, unmountComponentAtNode } from 'react-dom'; import { IScope } from 'angular'; import chrome from 'ui/chrome'; -import { appSetup, appStop } from './app_plugin'; +import { appStart, appSetup, appStop } from './app_plugin'; import { PLUGIN_ID } from '../common'; // TODO: Convert this to the "new platform" way of doing UI @@ -31,7 +31,8 @@ function Root($scope: IScope, $element: JQLite) { appStop(); }); - return render(appSetup(), el); + appSetup(); + return render(appStart(), el); } chrome.setRootController(PLUGIN_ID, Root); 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 index 1e801467f3f94..2351bd7952a3d 100644 --- 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 @@ -14,7 +14,7 @@ import { IndexPatternDimensionPanel, IndexPatternDimensionPanelProps } from './d import { DropHandler, DragContextState } from '../../drag_drop'; import { createMockedDragDropContext } from '../mocks'; import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; -import { UiSettingsClientContract } from 'src/core/public'; +import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; import { Storage } from 'ui/storage'; jest.mock('ui/new_platform'); @@ -121,6 +121,7 @@ describe('IndexPatternDimensionPanel', () => { filterOperations: () => true, storage: {} as Storage, uiSettings: {} as UiSettingsClientContract, + savedObjectsClient: {} as SavedObjectsClientContract, }; jest.clearAllMocks(); 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 index 0076a9f599bb9..9158806138c2e 100644 --- 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 @@ -9,7 +9,7 @@ import React, { memo, useMemo } from 'react'; import { EuiButtonIcon } from '@elastic/eui'; import { Storage } from 'ui/storage'; import { i18n } from '@kbn/i18n'; -import { UiSettingsClientContract } from 'src/core/public'; +import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; import { DatasourceDimensionPanelProps, StateSetter } from '../../types'; import { IndexPatternColumn, @@ -30,6 +30,7 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { dragDropContext: DragContextState; uiSettings: UiSettingsClientContract; storage: Storage; + savedObjectsClient: SavedObjectsClientContract; layerId: string; }; 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 index 7eb03152b341f..eaec3ea8b6c5b 100644 --- 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 @@ -355,6 +355,7 @@ export function PopoverEditor(props: PopoverEditorProps) { currentColumn={state.layers[layerId].columns[columnId]} storage={props.storage} uiSettings={props.uiSettings} + savedObjectsClient={props.savedObjectsClient} layerId={layerId} /> )} 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 index d69a3827a43c9..0aa21e867fc30 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts @@ -9,6 +9,7 @@ import { data as dataMock } from '../../../../../../src/legacy/core_plugins/data import { Storage } from 'ui/storage'; import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { toastNotifications as notificationsMock } from 'ui/notify'; +import { SavedObjectsClientContract } from 'src/core/public'; import { getIndexPatternDatasource, IndexPatternPersistedState, @@ -140,6 +141,7 @@ describe('IndexPattern Data Source', () => { interpreter: { functionsRegistry }, toastNotifications: notificationsMock, data: dataMock, + savedObjectsClient: {} as SavedObjectsClientContract, }); persistedState = { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index c21f6a68c0416..78174e8d96f63 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -8,6 +8,7 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; +import { SavedObjectsClientContract } from 'src/core/public'; import { Storage } from 'ui/storage'; import { DatasourceDimensionPanelProps, @@ -152,7 +153,11 @@ export function getIndexPatternDatasource({ chrome, toastNotifications, storage, -}: IndexPatternDatasourcePluginPlugins & { storage: Storage }) { + savedObjectsClient, +}: IndexPatternDatasourcePluginPlugins & { + storage: Storage; + savedObjectsClient: SavedObjectsClientContract; +}) { const uiSettings = chrome.getUiSettingsClient(); // Not stateful. State is persisted to the frame const indexPatternDatasource: Datasource = { @@ -267,6 +272,7 @@ export function getIndexPatternDatasource({ setState={setState} uiSettings={uiSettings} storage={storage} + savedObjectsClient={savedObjectsClient} layerId={props.layerId} {...props} /> 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 index 7201cd12fce2a..aa07a95a76091 100644 --- 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 @@ -8,6 +8,7 @@ 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 { toastNotifications as notificationsMock } from 'ui/notify'; +import { SavedObjectsClientContract } from 'src/core/public'; import { getIndexPatternDatasource, IndexPatternPersistedState, @@ -140,6 +141,7 @@ describe('IndexPattern Data Source suggestions', () => { interpreter: { functionsRegistry }, toastNotifications: notificationsMock, data: dataMock, + savedObjectsClient: {} as SavedObjectsClientContract, }); persistedState = { 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 index 4b8b556927052..76f67bcecbf47 100644 --- 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 @@ -10,7 +10,7 @@ import { dateHistogramOperation } from '.'; import { shallow } from 'enzyme'; import { IndexPatternPrivateState } from '../../indexpattern'; import { EuiRange, EuiSwitch } from '@elastic/eui'; -import { UiSettingsClientContract } from 'src/core/public'; +import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; import { Storage } from 'ui/storage'; import { createMockedIndexPattern } from '../../mocks'; @@ -323,6 +323,7 @@ describe('date_histogram', () => { currentColumn={state.layers.first.columns.col1 as DateHistogramIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -340,6 +341,7 @@ describe('date_histogram', () => { layerId="second" storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -356,6 +358,7 @@ describe('date_histogram', () => { layerId="third" storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); expect(instance.find(EuiRange).exists()).toBe(false); @@ -373,6 +376,7 @@ describe('date_histogram', () => { currentColumn={state.layers.third.columns.col1 as DateHistogramIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); instance.find(EuiSwitch).prop('onChange')!({ @@ -394,6 +398,7 @@ describe('date_histogram', () => { currentColumn={state.layers.first.columns.col1 as DateHistogramIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -452,6 +457,7 @@ describe('date_histogram', () => { currentColumn={state.layers.first.columns.col1 as DateHistogramIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); 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 index a90e4e7a3068d..9558a141ad7a0 100644 --- 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 @@ -228,14 +228,16 @@ export const dateHistogramOperation: OperationDefinition) => + onChange={( + e: React.ChangeEvent | React.MouseEvent + ) => setState( updateColumnParam({ state, layerId, currentColumn, paramName: 'interval', - value: numericToInterval(Number(e.target.value)), + value: numericToInterval(Number((e.target as HTMLInputElement).value)), }) ) } 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 index 8864e959977a8..e9da64c44fcbd 100644 --- 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 @@ -11,7 +11,7 @@ import { FilterRatioIndexPatternColumn } from './filter_ratio'; import { filterRatioOperation } from '.'; import { IndexPatternPrivateState } from '../../indexpattern'; import { Storage } from 'ui/storage'; -import { UiSettingsClientContract } from 'src/core/public'; +import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; import { QueryBarInput } from '../../../../../../../../src/legacy/core_plugins/data/public/query'; import { createMockedIndexPattern } from '../../mocks'; @@ -108,6 +108,7 @@ describe('filter_ratio', () => { currentColumn={state.layers.first.columns.col1 as FilterRatioIndexPatternColumn} storage={storageMock} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); }).not.toThrow(); @@ -123,6 +124,7 @@ describe('filter_ratio', () => { currentColumn={state.layers.first.columns.col1 as FilterRatioIndexPatternColumn} storage={storageMock} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -141,6 +143,7 @@ describe('filter_ratio', () => { currentColumn={state.layers.first.columns.col1 as FilterRatioIndexPatternColumn} storage={storageMock} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -180,6 +183,7 @@ describe('filter_ratio', () => { currentColumn={state.layers.first.columns.col1 as FilterRatioIndexPatternColumn} storage={storageMock} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); 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 index 0d50801708b3e..de6e34ec3d941 100644 --- 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 @@ -77,7 +77,15 @@ export const filterRatioOperation: OperationDefinition { + paramEditor: ({ + state, + setState, + currentColumn, + uiSettings, + storage, + layerId, + savedObjectsClient, + }) => { const [hasDenominator, setDenominator] = useState(false); return ( @@ -94,6 +102,7 @@ export const filterRatioOperation: OperationDefinition { setState( updateColumnParam({ @@ -121,6 +130,7 @@ export const filterRatioOperation: OperationDefinition { setState( updateColumnParam({ 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 index 13020605d3606..9f82d0f2a1763 100644 --- 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 @@ -5,7 +5,7 @@ */ import { Storage } from 'ui/storage'; -import { UiSettingsClientContract } from 'src/core/public'; +import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; import { termsOperation } from './terms'; import { minOperation, averageOperation, sumOperation, maxOperation } from './metrics'; import { dateHistogramOperation } from './date_histogram'; @@ -46,6 +46,7 @@ export interface ParamEditorProps { layerId: string; uiSettings: UiSettingsClientContract; storage: Storage; + savedObjectsClient: SavedObjectsClientContract; } interface BaseOperationDefinitionProps { 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 index 56b15eaaa47db..30b8bfb0ec5bd 100644 --- 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 @@ -8,7 +8,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { IndexPatternPrivateState } from '../../indexpattern'; import { EuiRange, EuiSelect } from '@elastic/eui'; -import { UiSettingsClientContract } from 'src/core/public'; +import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; import { Storage } from 'ui/storage'; import { createMockedIndexPattern } from '../../mocks'; import { TermsIndexPatternColumn } from './terms'; @@ -312,6 +312,7 @@ describe('terms', () => { layerId="first" storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -358,6 +359,7 @@ describe('terms', () => { currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -377,6 +379,7 @@ describe('terms', () => { layerId="first" storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -423,6 +426,7 @@ describe('terms', () => { currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -445,6 +449,7 @@ describe('terms', () => { currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -488,6 +493,7 @@ describe('terms', () => { currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); @@ -505,6 +511,7 @@ describe('terms', () => { currentColumn={state.layers.first.columns.col1 as TermsIndexPatternColumn} storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} + savedObjectsClient={{} as SavedObjectsClientContract} /> ); 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 index 6f1a1a3b5471c..52b27f85fb495 100644 --- 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 @@ -181,14 +181,16 @@ export const termsOperation: OperationDefinition = { step={1} value={currentColumn.params.size} showInput - onChange={(e: React.ChangeEvent) => + onChange={( + e: React.ChangeEvent | React.MouseEvent + ) => setState( updateColumnParam({ state, layerId, currentColumn, paramName: 'size', - value: Number(e.target.value), + value: Number((e.target as HTMLInputElement).value), }) ) } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx index 2f1c23b1bf9dc..63e9642636063 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx @@ -50,6 +50,7 @@ class IndexPatternDatasourcePlugin { toastNotifications: toast, data, storage: new Storage(localStorage), + savedObjectsClient: chrome.getSavedObjectsClient(), }); } diff --git a/x-pack/legacy/plugins/lens/public/interpreter_types.ts b/x-pack/legacy/plugins/lens/public/interpreter_types.ts deleted file mode 100644 index fe02ab11757cc..0000000000000 --- a/x-pack/legacy/plugins/lens/public/interpreter_types.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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 { ExpressionFunction } from '../../../../../src/legacy/core_plugins/interpreter/public'; - -// 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 -interface RenderHandlers { - done: () => void; - onDestroy: (fn: () => void) => void; -} -export interface RenderFunction { - name: string; - displayName: string; - help: string; - validate: () => void; - reuseDomNode: boolean; - render: (domNode: Element, data: T, handlers: RenderHandlers) => void; -} - -export interface InterpreterSetup { - renderersRegistry: Registry; - functionsRegistry: Registry< - ExpressionFunction, - ExpressionFunction - >; -} 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 index d6c29abf70c4d..0b7f6aeda05a9 100644 --- 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 @@ -8,9 +8,9 @@ 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/data/public/expressions/lib/_types'; import { MetricConfig } from './types'; import { LensMultiTable } from '../types'; -import { RenderFunction } from './plugin'; import { AutoScale } from './auto_scale'; export interface MetricChartProps { @@ -73,7 +73,7 @@ export const metricChart: ExpressionFunction< export const getMetricChartRenderer = ( formatFactory: FormatFactory -): RenderFunction => ({ +): IInterpreterRenderFunction => ({ name: 'lens_metric_chart_renderer', displayName: 'Metric Chart', help: 'Metric Chart Renderer', 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 index f8bfd15b49892..fc3fc9462a498 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/plugin.tsx @@ -4,44 +4,15 @@ * 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'; import { FormatFactory, getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; import { metricVisualization } from './metric_visualization'; -import { - renderersRegistry, - functionsRegistry, -} from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; -import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/public'; +import { ExpressionsSetup } from '../../../../../../src/legacy/core_plugins/data/public/expressions'; +import { setup as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; import { metricChart, getMetricChartRenderer } from './metric_expression'; -// 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 -interface RenderHandlers { - done: () => void; - onDestroy: (fn: () => void) => void; -} - -export interface RenderFunction { - name: string; - displayName: string; - help: string; - validate: () => void; - reuseDomNode: boolean; - render: (domNode: Element, data: T, handlers: RenderHandlers) => void; -} - -export interface InterpreterSetup { - renderersRegistry: Registry; - functionsRegistry: Registry< - ExpressionFunction, - ExpressionFunction - >; -} - export interface MetricVisualizationPluginSetupPlugins { - interpreter: InterpreterSetup; + expressions: ExpressionsSetup; // TODO this is a simulated NP plugin. // Once field formatters are actually migrated, the actual shim can be used fieldFormat: { @@ -54,13 +25,11 @@ class MetricVisualizationPlugin { setup( _core: CoreSetup | null, - { interpreter, fieldFormat }: MetricVisualizationPluginSetupPlugins + { expressions, fieldFormat }: MetricVisualizationPluginSetupPlugins ) { - interpreter.functionsRegistry.register(() => metricChart); + expressions.registerFunction(() => metricChart); - interpreter.renderersRegistry.register( - () => getMetricChartRenderer(fieldFormat.formatFactory) as RenderFunction - ); + expressions.registerRenderer(() => getMetricChartRenderer(fieldFormat.formatFactory)); return metricVisualization; } @@ -72,10 +41,7 @@ const plugin = new MetricVisualizationPlugin(); export const metricVisualizationSetup = () => plugin.setup(null, { - interpreter: { - renderersRegistry, - functionsRegistry, - }, + expressions: dataSetup.expressions, fieldFormat: { formatFactory: getFormat, }, 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 index 8f49f6f12ee16..19f313f2d56d6 100644 --- a/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts +++ b/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts @@ -5,12 +5,12 @@ */ import { i18n } from '@kbn/i18n'; -import { visualizations } from '../../../../../src/legacy/core_plugins/visualizations/public'; +import { setup } from '../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/legacy'; import { BASE_APP_URL, getEditPath } from '../common'; const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; -visualizations.types.visTypeAliasRegistry.add({ +setup.types.visTypeAliasRegistry.add({ aliasUrl: BASE_APP_URL, name: NOT_INTERNATIONALIZED_PRODUCT_NAME, title: i18n.translate('xpack.lens.visTypeAlias.title', { diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 14fa018fa6e5d..c047ad4fbfe7c 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -33,12 +33,15 @@ export interface EditorFrameInstance { } export interface EditorFrameSetup { - createInstance: (options: EditorFrameOptions) => EditorFrameInstance; // 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; 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 index 6692fc17f05ac..0f0e1e8534cc0 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -8,19 +8,14 @@ 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 { - renderersRegistry, - functionsRegistry, -} from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; - +import { ExpressionsSetup } from '../../../../../../src/legacy/core_plugins/data/public/expressions'; +import { setup as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; import { xyVisualization } from './xy_visualization'; -import { InterpreterSetup, RenderFunction } from '../interpreter_types'; import { xyChart, getXyChartRenderer } from './xy_expression'; import { legendConfig, xConfig, layerConfig } from './types'; export interface XyVisualizationPluginSetupPlugins { - interpreter: InterpreterSetup; + expressions: ExpressionsSetup; chrome: Chrome; // TODO this is a simulated NP plugin. // Once field formatters are actually migrated, the actual shim can be used @@ -44,22 +39,21 @@ class XyVisualizationPlugin { setup( _core: CoreSetup | null, { - interpreter, + expressions, fieldFormat: { formatFactory }, chrome: { getUiSettingsClient }, }: XyVisualizationPluginSetupPlugins ) { - interpreter.functionsRegistry.register(() => legendConfig); - interpreter.functionsRegistry.register(() => xConfig); - interpreter.functionsRegistry.register(() => layerConfig); - interpreter.functionsRegistry.register(() => xyChart); + expressions.registerFunction(() => legendConfig); + expressions.registerFunction(() => xConfig); + expressions.registerFunction(() => layerConfig); + expressions.registerFunction(() => xyChart); - interpreter.renderersRegistry.register( - () => - getXyChartRenderer({ - formatFactory, - timeZone: getTimeZone(getUiSettingsClient()), - }) as RenderFunction + expressions.registerRenderer(() => + getXyChartRenderer({ + formatFactory, + timeZone: getTimeZone(getUiSettingsClient()), + }) ); return xyVisualization; @@ -72,10 +66,7 @@ const plugin = new XyVisualizationPlugin(); export const xyVisualizationSetup = () => plugin.setup(null, { - interpreter: { - renderersRegistry, - functionsRegistry, - }, + expressions: dataSetup.expressions, fieldFormat: { formatFactory: getFormat, }, 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 index df69f510a8d21..3eeeee618fb88 100644 --- 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 @@ -23,9 +23,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, IconType } from '@elastic/ 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/data/public/expressions/lib/_types'; import { LensMultiTable } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; -import { RenderFunction } from '../interpreter_types'; export interface XYChartProps { data: LensMultiTable; @@ -93,7 +93,7 @@ export interface XYChartProps { export const getXyChartRenderer = (dependencies: { formatFactory: FormatFactory; timeZone: string; -}): RenderFunction => ({ +}): IInterpreterRenderFunction => ({ name: 'lens_xy_chart_renderer', displayName: 'XY Chart', help: i18n.translate('xpack.lens.xyChart.renderer.help', { From c40938e97f9d602789f6eebb6cbc0ec7238113a0 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Mon, 9 Sep 2019 15:37:39 -0400 Subject: [PATCH 097/105] [lens] Fix type error in test from merge --- .../metric_expression.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 index f7690b0ee8562..53c4a83cf9c2a 100644 --- 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 @@ -9,6 +9,7 @@ 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 = { @@ -49,7 +50,7 @@ describe('metric_expression', () => { test('it renders the title and value', () => { const { data, args } = sampleArgs(); - expect(shallow( x} />)) + expect(shallow( x as FieldFormat} />)) .toMatchInlineSnapshot(`
{ expect( shallow( - x} /> + x as FieldFormat} + /> ) ).toMatchInlineSnapshot(`
Date: Mon, 9 Sep 2019 16:53:58 -0400 Subject: [PATCH 098/105] [lens] Fix registration of embeddable (#45171) --- x-pack/legacy/plugins/lens/public/register_embeddable.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/lens/public/register_embeddable.ts b/x-pack/legacy/plugins/lens/public/register_embeddable.ts index e488f8e3d9aa3..f86cb91a0029e 100644 --- a/x-pack/legacy/plugins/lens/public/register_embeddable.ts +++ b/x-pack/legacy/plugins/lens/public/register_embeddable.ts @@ -6,7 +6,7 @@ import { indexPatternDatasourceSetup } from './indexpattern_plugin'; import { xyVisualizationSetup } from './xy_visualization_plugin'; -import { editorFrameSetup } from './editor_frame_plugin'; +import { editorFrameSetup, editorFrameStart } from './editor_frame_plugin'; import { datatableVisualizationSetup } from './datatable_visualization_plugin'; import { metricVisualizationSetup } from './metric_visualization_plugin'; @@ -17,3 +17,4 @@ datatableVisualizationSetup(); xyVisualizationSetup(); metricVisualizationSetup(); editorFrameSetup(); +editorFrameStart(); From 1abef4b392a26e015e21e85eeba2f0758f4e7d6a Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Tue, 10 Sep 2019 12:47:47 -0400 Subject: [PATCH 099/105] [Lens] Functional tests (#44814) Basic functional tests for Lens, by no means comprehensive. This is more of a smokescreen test of some normal use cases. --- .../expression.tsx | 1 + .../lens/public/drag_drop/drag_drop.tsx | 4 +- .../editor_frame/chart_switch.tsx | 14 +- .../editor_frame/editor_frame.test.tsx | 5 +- .../editor_frame/workspace_panel.tsx | 7 +- .../public/indexpattern_plugin/field_item.tsx | 2 +- .../indexpattern_suggestions.ts | 84 +- .../metric_expression.test.tsx | 79 +- .../metric_expression.tsx | 12 +- .../lens/public/register_vis_type_alias.ts | 4 +- .../xy_config_panel.tsx | 1 + x-pack/test/functional/apps/lens/index.ts | 4 +- .../apps/lens/indexpattern_datapanel.ts | 40 - .../test/functional/apps/lens/smokescreen.ts | 111 ++ .../es_archives/lens/basic/data.json.gz | Bin 0 -> 2957 bytes .../es_archives/lens/basic/mappings.json | 1155 +++++++++++++++++ x-pack/test/functional/page_objects/index.ts | 2 +- x-pack/test/functional/page_objects/lens.ts | 25 - .../test/functional/page_objects/lens_page.ts | 139 ++ .../functional/page_objects/log_wrapper.ts | 34 + 20 files changed, 1567 insertions(+), 156 deletions(-) delete mode 100644 x-pack/test/functional/apps/lens/indexpattern_datapanel.ts create mode 100644 x-pack/test/functional/apps/lens/smokescreen.ts create mode 100644 x-pack/test/functional/es_archives/lens/basic/data.json.gz create mode 100644 x-pack/test/functional/es_archives/lens/basic/mappings.json delete mode 100644 x-pack/test/functional/page_objects/lens.ts create mode 100644 x-pack/test/functional/page_objects/lens_page.ts create mode 100644 x-pack/test/functional/page_objects/log_wrapper.ts 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 index 353bb85dd32e1..aa39a09427008 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx @@ -133,6 +133,7 @@ function DatatableComponent(props: DatatableProps & { formatFactory: FormatFacto return ( { return { 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 index e1f765ae6e673..bf3f207a1d7d5 100644 --- a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx @@ -56,7 +56,7 @@ interface Props { /** * The optional test subject associated with this DOM element. */ - dataTestSubj?: string; + 'data-test-subj'?: string; } /** @@ -128,7 +128,7 @@ export function DragDrop(props: Props) { return (
); } + function getTopSuggestion( props: Props, visualizationId: string, newVisualization: Visualization ): Suggestion | undefined { - return getSuggestions({ + const suggestions = getSuggestions({ datasourceMap: props.datasourceMap, datasourceStates: props.datasourceStates, visualizationMap: { [visualizationId]: newVisualization }, @@ -251,5 +252,14 @@ function getTopSuggestion( // don't use extended versions of current data table on switching between visualizations // to avoid confusing the user. return suggestion.changeType !== 'extended'; - })[0]; + }); + + // 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/editor_frame.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index 2faa2ed902b5b..2884811e7faf8 100644 --- 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 @@ -1257,7 +1257,10 @@ describe('editor_frame', () => { instance.update(); act(() => { - instance.find('[data-test-subj="lnsDragDrop"]').simulate('drop'); + instance + .find('[data-test-subj="lnsWorkspace"]') + .last() + .simulate('drop'); }); expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( 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 index cc7b7148adae9..4db1997ccfd39 100644 --- 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 @@ -163,7 +163,12 @@ export function InnerWorkspacePanel({ } return ( - + {renderVisualization()} ); 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 index 403b76ed1c49c..0afc769688218 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx @@ -44,7 +44,7 @@ export function FieldItem({ field, indexPattern, highlight, exists }: FieldItemP return ( { - const columnOrder = (updatedLayer || state.layers[layerId]).columnOrder; - const columnMap = (updatedLayer || state.layers[layerId]).columns; + 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: updatedLayer - ? { - ...state, - layers: { - ...state.layers, - [layerId]: updatedLayer, - }, - } - : state, + state: { + ...updatedState, + layers, + }, table: { columns: columnOrder.map(columnId => ({ @@ -410,9 +430,11 @@ function createAlternativeMetricSuggestions( field, suggestedPriority: undefined, }); - const updatedLayer = buildLayerByColumnOrder({ ...layer, columns: { [newId]: newColumn } }, [ - newId, - ]); + const updatedLayer = { + ...layer, + columns: { [newId]: newColumn }, + columnOrder: [newId], + }; suggestions.push( buildSuggestion({ state, @@ -441,10 +463,11 @@ function createSuggestionWithDefaultDateHistogram( field: indexPattern.fields.find(({ name }) => name === indexPattern.timeFieldName), suggestedPriority: undefined, }); - const updatedLayer = buildLayerByColumnOrder( - { ...layer, columns: { ...layer.columns, [newId]: timeColumn } }, - [...buckets, newId, ...metrics] - ); + const updatedLayer = { + ...layer, + columns: { ...layer.columns, [newId]: timeColumn }, + columnOrder: [...buckets, newId, ...metrics], + }; return buildSuggestion({ state, layerId, @@ -465,15 +488,15 @@ function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layer availableBucketedColumns.map((_col, index) => { // build suggestions with fewer buckets const bucketedColumns = availableBucketedColumns.slice(0, index + 1); - const allMetricsSuggestion = buildLayerByColumnOrder(layer, [ - ...bucketedColumns, - ...availableMetricColumns, - ]); + const allMetricsSuggestion = { + ...layer, + columnOrder: [...bucketedColumns, ...availableMetricColumns], + }; if (availableMetricColumns.length > 1) { return [ allMetricsSuggestion, - buildLayerByColumnOrder(layer, [...bucketedColumns, availableMetricColumns[0]]), + { ...layer, columnOrder: [...bucketedColumns, availableMetricColumns[0]] }, ]; } else { return allMetricsSuggestion; @@ -483,7 +506,7 @@ function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layer .concat( availableMetricColumns.map(columnId => { // build suggestions with only metrics - return buildLayerByColumnOrder(layer, [columnId]); + return { ...layer, columnOrder: [columnId] }; }) ) .map(updatedLayer => { @@ -516,14 +539,3 @@ function getMetricSuggestionTitle(layer: IndexPatternLayer, onlyMetric: boolean) function separateBucketColumns(layer: IndexPatternLayer) { return partition(layer.columnOrder, columnId => layer.columns[columnId].isBucketed); } - -function buildLayerByColumnOrder( - layer: IndexPatternLayer, - columnOrder: string[] -): IndexPatternLayer { - return { - ...layer, - columns: _.pick(layer.columns, columnOrder), - columnOrder, - }; -} 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 index 53c4a83cf9c2a..f942206e4b70b 100644 --- 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 @@ -52,42 +52,44 @@ describe('metric_expression', () => { expect(shallow( x as FieldFormat} />)) .toMatchInlineSnapshot(` -
- -
- 10110 -
-
- My fanci metric chart -
-
-
- `); +
+ +
+ 10110 +
+
+ My fanci metric chart +
+
+
+ `); }); test('it does not render title in reduced mode', () => { @@ -115,10 +117,9 @@ describe('metric_expression', () => { } } > - +
- -
{value}
- {mode === 'full' &&
{title}
} + +
+ {value} +
+ {mode === 'full' && ( +
+ {title} +
+ )}
); 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 index 19f313f2d56d6..47ab38de7adf8 100644 --- a/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts +++ b/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts @@ -8,11 +8,9 @@ import { i18n } from '@kbn/i18n'; import { setup } from '../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/legacy'; import { BASE_APP_URL, getEditPath } from '../common'; -const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; - setup.types.visTypeAliasRegistry.add({ aliasUrl: BASE_APP_URL, - name: NOT_INTERNATIONALIZED_PRODUCT_NAME, + name: 'lens', title: i18n.translate('xpack.lens.visTypeAlias.title', { defaultMessage: 'Lens Visualizations', }), 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 index 07ce118673bd1..4d0c7b7044163 100644 --- 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 @@ -214,6 +214,7 @@ export function XYConfigPanel(props: VisualizationProps) { { @@ -28,7 +28,7 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { describe('', function() { this.tags(['ciGroup4', 'skipFirefox']); - loadTestFile(require.resolve('./indexpattern_datapanel')); + loadTestFile(require.resolve('./smokescreen')); }); }); } diff --git a/x-pack/test/functional/apps/lens/indexpattern_datapanel.ts b/x-pack/test/functional/apps/lens/indexpattern_datapanel.ts deleted file mode 100644 index 4c89ef4d51cc9..0000000000000 --- a/x-pack/test/functional/apps/lens/indexpattern_datapanel.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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({ getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['header', 'common', 'lens']); - - describe('indexpattern_datapanel', () => { - beforeEach(async () => { - await PageObjects.common.navigateToApp('lens'); - }); - - it('should list the index pattern fields', async () => { - await PageObjects.lens.openIndexPatternFiltersPopover(); - await PageObjects.lens.toggleExistenceFilter(); - - const fields = await PageObjects.lens.findAllFields(); - const fieldText = await Promise.all(fields.map(field => field.getVisibleText())); - expect(fieldText).to.eql([ - '_score', - '@timestamp', - 'bytes', - 'id', - 'machine.ram', - 'memory', - 'meta.user.lastname', - 'phpmemory', - 'relatedContent.article:modified_time', - 'relatedContent.article:published_time', - 'utc_time', - ]); - }); - }); -} 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/es_archives/lens/basic/data.json.gz b/x-pack/test/functional/es_archives/lens/basic/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..992bf7c85e9fd7046b0fe3a3cf63b0297070baea GIT binary patch literal 2957 zcmc)4_dgVl!vOFzLuBuHw(c7C&N}<-Y-g{ckdVD+sE%-Eb~t-vobgFHBhGf5nb9Fz z)^+*5p1_CGSS3+~cmn0+ z$c8Ky;;rDLmA1E+A`&AXd`ALig1-XDr-yd7@tcat$;sJ$_(RCd>o9xum4$P0`ag`1 zI!X0M3=gsF7p&T-92S9uC~`Mb(Wh;jmbT=6Zw{`QCuP|Zse|D_q_8%KR(!M- z!@zt|XIVY>vr9PSccxKq12#Q>m1Bru5S)-8dt;vK=v!BC_#oAgmin9Z`||3}gmOYH z6`Y|w&Px3JOjeMKQ0nt}j%+qzAR2#QoDw4tJH<`qovN&0r)M)rTj46)=mR^*s5E;+ zaZ_R0Rd4sot*LB$WM^yZ@C}GMo`d2O%g-Msr>vzh$c$*!A!lA&*vb%fH?t+}S;s8t#G$5?ucW>#ba6d-gVOGzDzq9WyK=*qDzZCcd4 zQ^%SFQs1Ahl1LH=i$6OL;)~iz6EIrwoK5yl|L@Nuh-L0Y>`zPOJaHSw)TiGzhAa8M zA$2tDUNdUPe61d6g-p$Su0q=wN=j_UYJKH7f?gt_WGVgJ#uis@6DZ_Y8+NmvV`>3Q z1>t6nw~2qlTFCiHbgR~xm(BH}(zI^LAG^fmg-sgsAp38}ZPw`$IWf4cfZ_+j2p(^W zvw*x`3YwhQKoP4h@T;eo0WtL#DZIBVyc)cplFB7Q{5SOk{ zR&a3ELbd&PRM6f{Q4*7XMN2lzenec0VJc~6j8N7`bA|>pN^}>*pc^e*UiDbdgIYrZ z8W;hh=b{;c3gisA@-QS(Gvt04>QStPtE*%(iniU)rFS5`X;Li;Vcpk!7}A(|Tp4h& zgmK#D-1cGsSg?prnw(L6r_wQuqLDz8YyqarXqh1M7?S(3z>ji^enK3h0g;N+d#ZS`-Y zJ*{Ct^EmQI=Dm{-Bz_Fn6n^*i9g++SGsN8X^}_P!;cXOsXyQ_6vvuMXb7$O??J+y; zu0%Av^mN*Ul;~G7@g%(t7%Y5l{|x6U?LB`ekUYM}S>=Jf=-R$!#+BH(;oLn}I@adq zR@RlLmY7RJP(`cDzRH8YG;L^|pK(T~fAx!Wz`IV13D|#h)g3Rf1Zm|pzvH%IEKfM~ z;{E%Gz5K>@aLwg!DYfWYd8BAl>@u2=)wc@@6YH>PC738 z7($D@L|(L0l0f%B@Svy_nkMT58;N|TZsUy%N<>ZR^-B551yT5&Z#$-lB+*Epxc0Hl z7ah9V+77bFV0ftuSMI)Ltz+|16UgcEG6??8)M>ySXNv2NJMOxieWJ0}iIgDZZR&Lr zG=FHAfztj3;<((57myXSkAN>Y8!T^%3#?x&Y$ldjq*1x}{C50ed>x6Nsm-e+xGMX} z^=Afv7D-RA+BWsT=X7R1DRx@3T&I^vpdjVd^|sUZG!@Tw1|mIWL#tuEB1nkqOgRH) z;0C0CUqBjMp&=sp1`6bqn!ajX&o&Rocr(WX@wDfAA$GnxFpb?P6w5Z4g)9>Y%5rbB zUqrR$%~=QIUNp_wI~$RKUO~xz#1Axq@tmtF>2%6eQ1+oi^8DDo1tQZc?cl`ohE0gA-n1M)v4CMnu+}uUqI~h-ajKihd!Sm9_k;53B z$Oplc%?s{tQHq?59E zSZg-IWqhI@p%xT@<2CU-D=uw^7nXm-sx~C8?$zk`bkOt^ghH&OBcsX zhNWM39ygq<8^w10skJPFHx2)*o@>#JYCTMPo`1Z;em7_Mft})B+@tru)g=3`k{NH0 ze9nw!PofJF8v<(n-i)#QWOYb$GcFTZXZsn#=u)P&KpMz!hq7tMVlb6NCT3SjhX=Zo z$=y>b-JZZ>RHxM68J{4gmTyXu<$pwqgnh)k&pr2mqkidghXUi;t&YW}HBQ5$R80qIK&MS1d}GrOFf-~IS{MMh1|>-*EhXb$vAyyuE6k}31`4> zeHGkZ#YjFI4)x5;%Q&NxW!cw$;d-W{)?hq;Nh!lF1@e5V!rW+c&(8`B>Ha!v3MvX! z!$~|27xl=O%+Rj>6)qxB@8+r+qRRpsGP0w)G>6ZmB1IYkdYZc z(ObR$-TMme4w!1r<=%r8;8B@Zb5#xU^0n#OwW?@;E1VPwF2^6ybLMFi<~Xn?UK2Yg zIPUMm6gO(9T|#z{Ku8TY7F=ZG2;=s<@xV`!cgy9G2(?x7o#iRW#qcImCTRE;{P#oV z*SfxJdW)_Phlja0HZhY^)48()Eu%usGkCXmn2Lu=Y=;F4Eab0qDW>MFS$o+nU*X7> zSbD+Mth$-@z{_E*_Y%x+E{kq=katZ|=EK6<<3!)agF(#pSoqG3`po!X$4Z*gJgWY` zOWf6#9*-rSKW$g7<#G?KLDZ#S?+r&5weKEJAbaI!?mSY9aK|lWZP{!cpdqKct;U%&x8H_DEHv0^dwqjCOUE2(W@E8fq|}bZbV2)^Ps6Su9p*& zxFN-giT-j3=w(rrml07TayEIJLu2}fZwwIs!m=r2XDlbS+L)v3UOFvz(+XVr518y* zpr;3}y{Kjv3Tw2UG_oHyYd-bw?(lI5xVMrU0X*ewrfW@MiTuy}(cYj>v%zue{-CaM zB6iJ{BXY^w`DjD`7NLe?V;psdm7;`44m@(4ykb&RH@(+aEdpzVg{lR6qR&@*0uf}& bewYwwv=ToCM%XC2yd^niP>i9=1OWaAFXgN? literal 0 HcmV?d00001 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 e22e36fcd73aa..4163e77911934 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -44,7 +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'; +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 diff --git a/x-pack/test/functional/page_objects/lens.ts b/x-pack/test/functional/page_objects/lens.ts deleted file mode 100644 index fa762dc86fd8d..0000000000000 --- a/x-pack/test/functional/page_objects/lens.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 function LensPageProvider({ getService }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - - return { - async openIndexPatternFiltersPopover() { - await testSubjects.click('lnsIndexPatternFiltersToggle'); - }, - - async toggleExistenceFilter() { - await testSubjects.click('lnsEmptyFilter'); - }, - - async findAllFields() { - return await testSubjects.findAll('lnsFieldListPanelField'); - }, - }; -} 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})`); +} From 3d1e668292930b1738aa79d20dac7f771246e233 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 10 Sep 2019 15:24:22 -0400 Subject: [PATCH 100/105] [lens] Add Lens to CODEOWNERS (#45296) --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) 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 From 4e766201e033dad8258647f3d8c2125fb90f6c8a Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 11 Sep 2019 10:48:41 -0400 Subject: [PATCH 101/105] [lens] Fix visualization alias registration --- x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 47ab38de7adf8..004f7ab1ce64f 100644 --- a/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts +++ b/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts @@ -5,10 +5,10 @@ */ import { i18n } from '@kbn/i18n'; -import { setup } from '../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/legacy'; +import { visualizations } from '../../../../../src/legacy/core_plugins/visualizations/public'; import { BASE_APP_URL, getEditPath } from '../common'; -setup.types.visTypeAliasRegistry.add({ +visualizations.types.visTypeAliasRegistry.add({ aliasUrl: BASE_APP_URL, name: 'lens', title: i18n.translate('xpack.lens.visTypeAlias.title', { From 234ffb6175324ee2284c044eaf60c69c8e3d2bd2 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 11 Sep 2019 15:11:51 -0400 Subject: [PATCH 102/105] [lens] Fix usage of EUI after typescript upgrade (#45404) * [lens] Fix usage of EUI after typescript upgrade * Use local fix instead of workaround --- .../editor_frame/suggestion_panel.tsx | 16 +++++++++++----- .../xy_config_panel.test.tsx | 4 ++-- 2 files changed, 13 insertions(+), 7 deletions(-) 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 index 8c63d9d03806d..fc07c2df37d8a 100644 --- 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 @@ -18,6 +18,10 @@ 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; @@ -55,15 +59,17 @@ const SuggestionPreview = ({ setExpressionError(false); }, [previewExpression]); + const clickHandler = () => { + switchToSuggestion(frame, dispatch, suggestion); + }; + return ( - { - switchToSuggestion(frame, dispatch, suggestion); - }} + onClick={clickHandler} > {expressionError ? (
@@ -94,7 +100,7 @@ const SuggestionPreview = ({
)} -
+
); }; 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 index 305cda47b3e19..ad08b8949f3b9 100644 --- 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 @@ -118,7 +118,7 @@ describe('XYConfigPanel', () => { .first() .prop('options') as EuiButtonGroupProps['options']; - expect(options.map(({ id }) => id)).toEqual([ + expect(options!.map(({ id }) => id)).toEqual([ 'bar', 'bar_stacked', 'line', @@ -126,7 +126,7 @@ describe('XYConfigPanel', () => { 'area_stacked', ]); - expect(options.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); + expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); }); test('the x dimension panel accepts only bucketed operations', () => { From 2bad5fe7cd9b93b25da84310002e1fb1c6f3db13 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Fri, 13 Sep 2019 14:52:19 -0400 Subject: [PATCH 103/105] [lens] Fix usage of expressions plugin (#45544) * [lens] Fix usage of expressions plugin * Use updated exports from #45538 * Fix imports and mocha tests * Use relative instead of absolute path to fix tests --- .../public/expressions/lib/_types.ts | 2 +- .../expression.tsx | 5 +-- .../datatable_visualization_plugin/plugin.tsx | 7 ++-- .../editor_frame/editor_frame.test.tsx | 2 +- .../editor_frame/editor_frame.tsx | 6 ++-- .../editor_frame/suggestion_panel.test.tsx | 2 +- .../editor_frame/suggestion_panel.tsx | 2 +- .../editor_frame/workspace_panel.test.tsx | 2 +- .../editor_frame/workspace_panel.tsx | 2 +- .../embeddable/embeddable.test.tsx | 3 +- .../embeddable/embeddable.tsx | 3 +- .../embeddable/embeddable_factory.ts | 7 ++-- .../embeddable/expression_wrapper.tsx | 2 +- .../editor_frame_plugin/merge_tables.test.ts | 2 +- .../editor_frame_plugin/merge_tables.ts | 3 +- .../lens/public/editor_frame_plugin/mocks.tsx | 33 ++++++++++--------- .../editor_frame_plugin/plugin.test.tsx | 4 +++ .../public/editor_frame_plugin/plugin.tsx | 14 ++++++-- .../indexpattern_plugin/filter_ratio.test.ts | 2 +- .../indexpattern_plugin/filter_ratio.ts | 2 +- .../rename_columns.test.ts | 2 +- .../indexpattern_plugin/rename_columns.ts | 2 +- .../metric_expression.tsx | 2 +- .../metric_visualization_plugin/plugin.tsx | 6 ++-- x-pack/legacy/plugins/lens/public/types.ts | 3 +- .../public/xy_visualization_plugin/plugin.tsx | 6 ++-- .../xy_visualization_plugin/xy_expression.tsx | 2 +- 27 files changed, 71 insertions(+), 57 deletions(-) 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 6306d685f3829..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'; 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 index aa39a09427008..5d4a534d5c262 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx @@ -8,9 +8,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { EuiBasicTable } from '@elastic/eui'; -import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; +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/data/public/expressions/lib/_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 { 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 index 9716b6b708b60..dc4d56e7b7de0 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx @@ -4,12 +4,11 @@ * 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'; import { getFormat, FormatFactory } from 'ui/visualize/loader/pipeline_helpers/utilities'; import { datatableVisualization } from './visualization'; -import { ExpressionsSetup } from '../../../../../../src/legacy/core_plugins/data/public/expressions'; -import { setup as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; +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 { @@ -42,7 +41,7 @@ const plugin = new DatatableVisualizationPlugin(); export const datatableVisualizationSetup = () => plugin.setup(null, { - expressions: dataSetup.expressions, + expressions: expressionsSetup, fieldFormat: { formatFactory: getFormat, }, 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 index 2884811e7faf8..6a51a6ec4ea9c 100644 --- 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 @@ -16,7 +16,7 @@ import { createExpressionRendererMock, DatasourceMock, } from '../mocks'; -import { ExpressionRenderer } from 'src/legacy/core_plugins/data/public'; +import { ExpressionRenderer } from 'src/legacy/core_plugins/expressions/public'; import { DragDrop } from '../../drag_drop'; import { EuiPanel, EuiToolTip } from '@elastic/eui'; 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 index f7837bc02b9be..5bead32178e53 100644 --- 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 @@ -5,10 +5,8 @@ */ import React, { useEffect, useReducer } from 'react'; -import { - ExpressionRenderer, - Query, -} from '../../../../../../../src/legacy/core_plugins/data/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'; 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 index 84b4f24bf0f9c..7302ea379eba8 100644 --- 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 @@ -14,7 +14,7 @@ import { DatasourceMock, createMockFramePublicAPI, } from '../mocks'; -import { ExpressionRenderer } from 'src/legacy/core_plugins/data/public'; +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'; 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 index fc07c2df37d8a..ad073913930cc 100644 --- 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 @@ -12,7 +12,7 @@ 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/data/public'; +import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; 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 index 1e50e58ec993e..86a0e5c8a833a 100644 --- 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 @@ -6,7 +6,7 @@ import React from 'react'; -import { ExpressionRendererProps } from '../../../../../../../src/legacy/core_plugins/data/public'; +import { ExpressionRendererProps } from '../../../../../../../src/legacy/core_plugins/expressions/public'; import { Visualization, FramePublicAPI, TableSuggestion } from '../../types'; import { createMockVisualization, 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 index 4db1997ccfd39..81777f3593dc0 100644 --- 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 @@ -8,7 +8,7 @@ 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/data/public'; +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'; 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 index 375b29bd10d34..96624764bb8ca 100644 --- 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 @@ -6,7 +6,8 @@ import { Embeddable } from './embeddable'; import { TimeRange } from 'src/plugins/data/public'; -import { Query, ExpressionRendererProps } from 'src/legacy/core_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'; 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 index 8827e5a6397c4..e815a1951bdb7 100644 --- 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 @@ -9,7 +9,8 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { TimeRange } from 'src/plugins/data/public'; -import { Query, StaticIndexPattern, ExpressionRenderer } from 'src/legacy/core_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 { 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 index 98f831b7050e0..c340342a31ff6 100644 --- 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 @@ -9,11 +9,8 @@ import { Chrome } from 'ui/chrome'; import { capabilities } from 'ui/capabilities'; import { i18n } from '@kbn/i18n'; -import { - IndexPatterns, - ExpressionRenderer, - IndexPattern, -} from 'src/legacy/core_plugins/data/public'; +import { IndexPatterns, IndexPattern } from 'src/legacy/core_plugins/data/public'; +import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; import { EmbeddableFactory as AbstractEmbeddableFactory, ErrorEmbeddable, 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 index e5aabf159de63..91d0c2ad34334 100644 --- 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 @@ -13,7 +13,7 @@ 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/data/public'; +import { ExpressionRenderer } from 'src/legacy/core_plugins/expressions/public'; export interface ExpressionWrapperProps { ExpressionRenderer: ExpressionRenderer; 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 index 2769f1e1201b4..3aa765f2910fd 100644 --- 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 @@ -5,7 +5,7 @@ */ import { mergeTables } from './merge_tables'; -import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; +import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public'; describe('lens_merge_tables', () => { it('should produce a row with the nested table as defined', () => { 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 index 2b7e35876bb63..887c1841ae15f 100644 --- 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 @@ -5,7 +5,8 @@ */ import { i18n } from '@kbn/i18n'; -import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; +import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; +import { KibanaDatatable } from '../../../../../../src/legacy/core_plugins/interpreter/public'; import { LensMultiTable } from '../types'; interface MergeTables { 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 index 582aa42051aca..c7fbce2256b68 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -5,16 +5,16 @@ */ import React from 'react'; -import { ExpressionRendererProps } from 'src/legacy/core_plugins/data/public'; +import { ExpressionRendererProps } from 'src/legacy/core_plugins/expressions/public'; +import { setup as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; import { - setup as dataSetup, - start as dataStart, -} from '../../../../../../src/legacy/core_plugins/data/public/legacy'; + ExpressionsSetup, + ExpressionsStart, +} from '../../../../../../src/legacy/core_plugins/expressions/public'; import { DatasourcePublicAPI, FramePublicAPI, Visualization, Datasource } from '../types'; import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './plugin'; type DataSetup = typeof dataSetup; -type DataStart = typeof dataStart; export function createMockVisualization(): jest.Mocked { return { @@ -85,11 +85,15 @@ export function createMockFramePublicAPI(): FrameMock { type Omit = Pick>; export type MockedSetupDependencies = Omit & { - data: Omit & { expressions: jest.Mocked }; + data: Omit & { + expressions: jest.Mocked; + }; }; export type MockedStartDependencies = Omit & { - data: Omit & { expressions: jest.Mocked }; + data: Omit & { + expressions: jest.Mocked; + }; }; export function createExpressionRendererMock(): jest.Mock< @@ -101,11 +105,10 @@ export function createExpressionRendererMock(): jest.Mock< export function createMockSetupDependencies() { return ({ - data: { - expressions: { - registerFunction: jest.fn(), - registerRenderer: jest.fn(), - }, + data: {}, + expressions: { + registerFunction: jest.fn(), + registerRenderer: jest.fn(), }, chrome: { getSavedObjectsClient: () => {}, @@ -116,13 +119,13 @@ export function createMockSetupDependencies() { export function createMockStartDependencies() { return ({ data: { - expressions: { - ExpressionRenderer: jest.fn(() => null), - }, indexPatterns: { indexPatterns: {}, }, }, + expressions: { + ExpressionRenderer: jest.fn(() => null), + }, embeddables: { registerEmbeddableFactory: jest.fn(), }, 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 index aa37b654f4511..632d95d3a619b 100644 --- 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 @@ -22,6 +22,10 @@ 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 {}, })); 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 index 2b83f50924e8a..92f7ae6fea57b 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -15,6 +15,10 @@ 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, @@ -29,10 +33,12 @@ 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; } @@ -44,7 +50,7 @@ export class EditorFramePlugin { private readonly visualizations: Record = {}; public setup(_core: CoreSetup | null, plugins: EditorFrameSetupPlugins): EditorFrameSetup { - plugins.data.expressions.registerFunction(() => mergeTables); + plugins.expressions.registerFunction(() => mergeTables); return { registerDatasource: (name, datasource) => { @@ -61,7 +67,7 @@ export class EditorFramePlugin { 'lens', new EmbeddableFactory( plugins.chrome, - plugins.data.expressions.ExpressionRenderer, + plugins.expressions.ExpressionRenderer, plugins.data.indexPatterns.indexPatterns ) ); @@ -85,7 +91,7 @@ export class EditorFramePlugin { initialVisualizationId={ (doc && doc.visualizationType) || firstVisualizationId || null } - ExpressionRenderer={plugins.data.expressions.ExpressionRenderer} + ExpressionRenderer={plugins.expressions.ExpressionRenderer} doc={doc} dateRange={dateRange} query={query} @@ -118,11 +124,13 @@ const editorFrame = new EditorFramePlugin(); export const editorFrameSetup = () => editorFrame.setup(null, { data: dataSetup, + expressions: expressionsSetup, }); export const editorFrameStart = () => editorFrame.start(null, { data: dataStart, + expressions: expressionsStart, chrome, embeddables: embeddablePlugin, }); 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 index 5b8ee486db09b..849e40e18f981 100644 --- 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 @@ -5,7 +5,7 @@ */ import { calculateFilterRatio } from './filter_ratio'; -import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; +import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public'; describe('calculate_filter_ratio', () => { it('should collapse two rows and columns into a single row and column', () => { 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 index d27da1833f04e..79dbe1dbe6fb3 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; +import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public'; interface FilterRatioKey { id: string; 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 index 5eb28af0ae3e4..cf0292d853567 100644 --- 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 @@ -5,7 +5,7 @@ */ import { renameColumns } from './rename_columns'; -import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; +import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public'; describe('rename_columns', () => { it('should rename columns of a given 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 index 5a508bf2a84f6..4a54bcad56163 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.ts @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; +import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public'; interface RemapArgs { idMap: string; 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 index 87a23d31373fc..f940e9cf5ece7 100644 --- 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 @@ -8,7 +8,7 @@ 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/data/public/expressions/lib/_types'; +import { IInterpreterRenderFunction } from '../../../../../../src/legacy/core_plugins/expressions/public/expressions'; import { MetricConfig } from './types'; import { LensMultiTable } from '../types'; import { AutoScale } from './auto_scale'; 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 index fc3fc9462a498..832efb6200ee5 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/plugin.tsx @@ -7,8 +7,8 @@ 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/data/public/expressions'; -import { setup as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; +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 { @@ -41,7 +41,7 @@ const plugin = new MetricVisualizationPlugin(); export const metricVisualizationSetup = () => plugin.setup(null, { - expressions: dataSetup.expressions, + expressions: expressionsSetup, fieldFormat: { formatFactory: getFormat, }, diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index c047ad4fbfe7c..a21dc5af8cd19 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -6,7 +6,8 @@ import { Ast } from '@kbn/interpreter/common'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; -import { Query, KibanaDatatable } from 'src/plugins/data/common'; +import { Query } from 'src/plugins/data/common'; +import { KibanaDatatable } from '../../../../../src/legacy/core_plugins/interpreter/public'; import { DragContextState } from './drag_drop'; import { Document } from './persistence'; 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 index 0f0e1e8534cc0..f5f7664b4d352 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -8,8 +8,8 @@ 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/data/public/expressions'; -import { setup as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; +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'; @@ -66,7 +66,7 @@ const plugin = new XyVisualizationPlugin(); export const xyVisualizationSetup = () => plugin.setup(null, { - expressions: dataSetup.expressions, + expressions: expressionsSetup, fieldFormat: { formatFactory: getFormat, }, 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 index 3eeeee618fb88..77853c38d075b 100644 --- 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 @@ -23,7 +23,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, IconType } from '@elastic/ 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/data/public/expressions/lib/_types'; +import { IInterpreterRenderFunction } from '../../../../../../src/legacy/core_plugins/expressions/public'; import { LensMultiTable } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; From 018f62899c9464bc75c3c66a5863a452ad7196c1 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Mon, 16 Sep 2019 10:03:50 -0400 Subject: [PATCH 104/105] [lens] More cleanup from QueryBar changes in master (#45687) --- .../lens/public/app_plugin/app.test.tsx | 18 +++++++++--------- .../plugins/lens/public/app_plugin/app.tsx | 5 +++-- .../public/editor_frame_plugin/merge_tables.ts | 4 ++-- .../lens/public/editor_frame_plugin/mocks.tsx | 15 ++++----------- .../indexpattern_plugin/filter_ratio.test.ts | 2 +- .../indexpattern_plugin/rename_columns.test.ts | 2 +- x-pack/legacy/plugins/lens/public/types.ts | 2 +- 7 files changed, 21 insertions(+), 27 deletions(-) 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 index f0a4cb5b5e451..d2aa3e1e3e809 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -12,11 +12,11 @@ import { toastNotifications } from 'ui/notify'; import { Storage } from 'ui/storage'; import { Document, SavedObjectStore } from '../persistence'; import { mount } from 'enzyme'; -import { QueryBar } from '../../../../../../src/legacy/core_plugins/data/public/query'; +import { QueryBarTopRow } from '../../../../../../src/legacy/core_plugins/data/public/query/query_bar'; import { SavedObjectsClientContract } from 'src/core/public'; -jest.mock('../../../../../../src/legacy/core_plugins/data/public/query', () => ({ - QueryBar: jest.fn(() => null), +jest.mock('../../../../../../src/legacy/core_plugins/data/public/query/query_bar', () => ({ + QueryBarTopRow: jest.fn(() => null), })); jest.mock('ui/new_platform'); @@ -69,7 +69,7 @@ function makeDefaultArgs(): jest.Mocked<{ load: jest.fn(), save: jest.fn(), }, - QueryBar: jest.fn(() =>
), + QueryBarTopRow: jest.fn(() =>
), redirectTo: jest.fn(id => {}), savedObjectsClient: jest.fn(), } as unknown) as jest.Mocked<{ @@ -186,7 +186,7 @@ describe('Lens App', () => { await waitForPromises(); expect(args.docStorage.load).toHaveBeenCalledWith('1234'); - expect(QueryBar).toHaveBeenCalledWith( + expect(QueryBarTopRow).toHaveBeenCalledWith( expect.objectContaining({ dateRangeFrom: 'now-7d', dateRangeTo: 'now', @@ -355,7 +355,7 @@ describe('Lens App', () => { mount(); - expect(QueryBar).toHaveBeenCalledWith( + expect(QueryBarTopRow).toHaveBeenCalledWith( expect.objectContaining({ dateRangeFrom: 'now-7d', dateRangeTo: 'now', @@ -378,7 +378,7 @@ describe('Lens App', () => { const instance = mount(); - expect(QueryBar).toHaveBeenCalledWith( + expect(QueryBarTopRow).toHaveBeenCalledWith( expect.objectContaining({ indexPatterns: [], }), @@ -393,7 +393,7 @@ describe('Lens App', () => { instance.update(); - expect(QueryBar).toHaveBeenCalledWith( + expect(QueryBarTopRow).toHaveBeenCalledWith( expect.objectContaining({ indexPatterns: ['newIndex'], }), @@ -417,7 +417,7 @@ describe('Lens App', () => { instance.update(); - expect(QueryBar).toHaveBeenCalledWith( + expect(QueryBarTopRow).toHaveBeenCalledWith( expect.objectContaining({ dateRangeFrom: 'now-14d', dateRangeTo: 'now-7d', diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index 82353f6550151..9978cac00d920 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -13,7 +13,8 @@ import { Storage } from 'ui/storage'; import { toastNotifications } from 'ui/notify'; import { Chrome } from 'ui/chrome'; import { SavedObjectsClientContract } from 'src/core/public'; -import { Query, QueryBar } from '../../../../../../src/legacy/core_plugins/data/public/query'; +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'; @@ -199,7 +200,7 @@ export function App({ - { 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 index 887c1841ae15f..9ddba447df293 100644 --- 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 @@ -5,8 +5,8 @@ */ import { i18n } from '@kbn/i18n'; -import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; -import { KibanaDatatable } from '../../../../../../src/legacy/core_plugins/interpreter/public'; +import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/types'; +import { KibanaDatatable } from '../../../../../../src/legacy/core_plugins/interpreter/common'; import { LensMultiTable } from '../types'; interface MergeTables { 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 index c7fbce2256b68..02e93e4631284 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { ExpressionRendererProps } from 'src/legacy/core_plugins/expressions/public'; -import { setup as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; import { ExpressionsSetup, ExpressionsStart, @@ -14,8 +13,6 @@ import { import { DatasourcePublicAPI, FramePublicAPI, Visualization, Datasource } from '../types'; import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './plugin'; -type DataSetup = typeof dataSetup; - export function createMockVisualization(): jest.Mocked { return { id: 'TEST_VIS', @@ -84,16 +81,12 @@ export function createMockFramePublicAPI(): FrameMock { type Omit = Pick>; -export type MockedSetupDependencies = Omit & { - data: Omit & { - expressions: jest.Mocked; - }; +export type MockedSetupDependencies = Omit & { + expressions: jest.Mocked; }; -export type MockedStartDependencies = Omit & { - data: Omit & { - expressions: jest.Mocked; - }; +export type MockedStartDependencies = Omit & { + expressions: jest.Mocked; }; export function createExpressionRendererMock(): jest.Mock< 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 index 849e40e18f981..d03370d0b8137 100644 --- 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 @@ -5,7 +5,7 @@ */ import { calculateFilterRatio } from './filter_ratio'; -import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public'; +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', () => { 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 index cf0292d853567..641b1ceb431fb 100644 --- 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 @@ -5,7 +5,7 @@ */ import { renameColumns } from './rename_columns'; -import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public'; +import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/common'; describe('rename_columns', () => { it('should rename columns of a given datatable', () => { diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index a21dc5af8cd19..4d5e8ce213646 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -7,7 +7,7 @@ import { Ast } from '@kbn/interpreter/common'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import { Query } from 'src/plugins/data/common'; -import { KibanaDatatable } from '../../../../../src/legacy/core_plugins/interpreter/public'; +import { KibanaDatatable } from '../../../../../src/legacy/core_plugins/interpreter/common'; import { DragContextState } from './drag_drop'; import { Document } from './persistence'; From 1dee1b4b153b9ec52b76e3f0a95cd850c517eb2d Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 17 Sep 2019 12:03:18 -0400 Subject: [PATCH 105/105] [lens] Fix build and use new platform from entry points (#45834) * [lens] Fix build and use new platform from entry points * Fix params for existence route --- .../lens/public/app_plugin/app.test.tsx | 123 ++++++++---------- .../plugins/lens/public/app_plugin/app.tsx | 27 ++-- .../plugins/lens/public/app_plugin/plugin.tsx | 12 +- .../datatable_visualization_plugin/plugin.tsx | 3 +- .../editor_frame/data_panel_wrapper.tsx | 2 + .../editor_frame/editor_frame.test.tsx | 2 + .../editor_frame/editor_frame.tsx | 4 +- .../editor_frame/state_management.test.ts | 2 + .../editor_frame_plugin/plugin.test.tsx | 10 +- .../public/editor_frame_plugin/plugin.tsx | 10 +- .../indexpattern_plugin/datapanel.test.tsx | 75 +++++------ .../public/indexpattern_plugin/datapanel.tsx | 11 +- .../dimension_panel/dimension_panel.test.tsx | 7 +- .../dimension_panel/dimension_panel.tsx | 7 +- .../dimension_panel/popover_editor.tsx | 1 + .../indexpattern_plugin/indexpattern.test.ts | 5 +- .../indexpattern_plugin/indexpattern.tsx | 8 +- .../indexpattern_suggestions.test.tsx | 5 +- .../lens/public/indexpattern_plugin/loader.ts | 6 +- .../definitions/date_histogram.test.tsx | 12 +- .../definitions/filter_ratio.test.tsx | 10 +- .../operations/definitions/filter_ratio.tsx | 3 + .../operations/definitions/index.ts | 7 +- .../operations/definitions/terms.test.tsx | 13 +- .../public/indexpattern_plugin/plugin.tsx | 13 +- x-pack/legacy/plugins/lens/public/types.ts | 2 + .../plugins/lens/server/routes/index_stats.ts | 10 +- .../api_integration/apis/lens/index_stats.ts | 4 +- 28 files changed, 221 insertions(+), 173 deletions(-) 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 index d2aa3e1e3e809..c2cd20f702f2b 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -7,21 +7,18 @@ import React from 'react'; import { App } from './app'; import { EditorFrameInstance } from '../types'; -import { Chrome } from 'ui/chrome'; -import { toastNotifications } from 'ui/notify'; 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('ui/notify'); -jest.mock('ui/chrome'); jest.mock('../persistence'); jest.mock('src/core/public'); @@ -34,60 +31,61 @@ function createMockFrame(): jest.Mocked { }; } -function makeDefaultArgs(): jest.Mocked<{ - editorFrame: EditorFrameInstance; - chrome: Chrome; - store: Storage; - docId?: string; - docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; - savedObjectsClient: SavedObjectsClientContract; -}> { - return ({ - editorFrame: createMockFrame(), - chrome: { - addBasePath: (s: string) => `/testbasepath/${s}`, - breadcrumbs: { set: jest.fn() }, - getUiSettingsClient() { - return { - get: jest.fn(type => { - if (type === 'timepicker:timeDefaults') { - return { from: 'now-7d', to: 'now' }; - } else if (type === 'search:queryLanguage') { - return 'kuery'; - } else { - return []; - } - }), - }; - }, - }, - 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<{ +describe('Lens App', () => { + let frame: jest.Mocked; + let core: ReturnType; + + function makeDefaultArgs(): jest.Mocked<{ editorFrame: EditorFrameInstance; - chrome: Chrome; + core: typeof core; store: Storage; docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; savedObjectsClient: SavedObjectsClientContract; - }>; -} - -describe('Lens App', () => { - let frame: jest.Mocked; + }> { + 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', () => { @@ -121,28 +119,15 @@ describe('Lens App', () => { }); it('sets breadcrumbs when the document title changes', async () => { - const mockSet = jest.fn(); const defaultArgs = makeDefaultArgs(); - const args = { - ...defaultArgs, - chrome: ({ - ...defaultArgs.chrome, - addBasePath: jest.fn(s => `/testbasepath${s}`), - breadcrumbs: { - ...defaultArgs.chrome.breadcrumbs, - set: mockSet, - }, - } as unknown) as Chrome, - }; - - const instance = mount(); + const instance = mount(); - expect(mockSet).toHaveBeenCalledWith([ + expect(core.chrome.setBreadcrumbs).toHaveBeenCalledWith([ { text: 'Visualize', href: '/testbasepath/app/kibana#/visualize' }, { text: 'Create' }, ]); - (args.docStorage.load as jest.Mock).mockResolvedValue({ + (defaultArgs.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234', title: 'Daaaaaaadaumching!', state: { @@ -154,7 +139,7 @@ describe('Lens App', () => { instance.setProps({ docId: '1234' }); await waitForPromises(); - expect(mockSet).toHaveBeenCalledWith([ + expect(defaultArgs.core.chrome.setBreadcrumbs).toHaveBeenCalledWith([ { text: 'Visualize', href: '/testbasepath/app/kibana#/visualize' }, { text: 'Daaaaaaadaumching!' }, ]); @@ -240,7 +225,7 @@ describe('Lens App', () => { await waitForPromises(); expect(args.docStorage.load).toHaveBeenCalledWith('1234'); - expect(toastNotifications.addDanger).toHaveBeenCalled(); + expect(args.core.notifications.toasts.addDanger).toHaveBeenCalled(); expect(args.redirectTo).toHaveBeenCalled(); }); @@ -334,7 +319,7 @@ describe('Lens App', () => { await waitForPromises(); - expect(toastNotifications.addDanger).toHaveBeenCalled(); + expect(args.core.notifications.toasts.addDanger).toHaveBeenCalled(); expect(args.redirectTo).not.toHaveBeenCalled(); await waitForPromises(); @@ -446,6 +431,6 @@ describe('Lens App', () => { instance.update(); - expect(toastNotifications.addDanger).toHaveBeenCalled(); + 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 index 9978cac00d920..acbb8c51bac12 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -10,9 +10,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Storage } from 'ui/storage'; -import { toastNotifications } from 'ui/notify'; -import { Chrome } from 'ui/chrome'; -import { SavedObjectsClientContract } from 'src/core/public'; +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'; @@ -52,24 +50,24 @@ function isLocalStateDirty( export function App({ editorFrame, + core, store, - chrome, docId, docStorage, redirectTo, savedObjectsClient, }: { editorFrame: EditorFrameInstance; - chrome: Chrome; + core: CoreStart; store: Storage; docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; savedObjectsClient: SavedObjectsClientContract; }) { - const uiSettings = chrome.getUiSettingsClient(); - const timeDefaults = uiSettings.get('timepicker:timeDefaults'); - const language = store.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'); + const timeDefaults = core.uiSettings.get('timepicker:timeDefaults'); + const language = + store.get('kibana.userQueryLanguage') || core.uiSettings.get('search:queryLanguage'); const [state, setState] = useState({ isLoading: !!docId, @@ -93,9 +91,9 @@ export function App({ // Sync Kibana breadcrumbs any time the saved document's title changes useEffect(() => { - chrome.breadcrumbs.set([ + core.chrome.setBreadcrumbs([ { - href: chrome.addBasePath(`/app/kibana#/visualize`), + href: core.http.basePath.prepend(`/app/kibana#/visualize`), text: i18n.translate('xpack.lens.breadcrumbsTitle', { defaultMessage: 'Visualize', }), @@ -131,7 +129,7 @@ export function App({ .catch(() => { setState({ ...state, isLoading: false }); - toastNotifications.addDanger( + core.notifications.toasts.addDanger( i18n.translate('xpack.lens.editorFrame.docLoadingError', { defaultMessage: 'Error loading saved document', }) @@ -149,7 +147,7 @@ export function App({ const onError = useCallback( (e: { message: string }) => - toastNotifications.addDanger({ + core.notifications.toasts.addDanger({ title: e.message, }), [] @@ -181,7 +179,7 @@ export function App({ } }) .catch(reason => { - toastNotifications.addDanger( + core.notifications.toasts.addDanger( i18n.translate('xpack.lens.editorFrame.docSavingError', { defaultMessage: 'Error saving document {reason}', values: { reason }, @@ -231,8 +229,9 @@ export function App({ dateRangeTo={ state.localQueryBarState.dateRange && state.localQueryBarState.dateRange.to } - uiSettings={uiSettings} + uiSettings={core.uiSettings} savedObjectsClient={savedObjectsClient} + http={core.http} />
diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index f3580a59ae89d..a29356a084063 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -9,6 +9,8 @@ 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'; @@ -27,7 +29,7 @@ export class AppPlugin { constructor() {} - setup() { + 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(); @@ -43,7 +45,7 @@ export class AppPlugin { editorFrameSetupInterface.registerVisualization(metricVisualization); } - start() { + start(core: CoreStart) { if (this.store === null) { throw new Error('Start lifecycle called before setup lifecycle'); } @@ -57,8 +59,8 @@ export class AppPlugin { const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { return ( app.setup(); -export const appStart = () => app.start(); +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/plugin.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx index dc4d56e7b7de0..88f029bd8b179 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx @@ -6,6 +6,7 @@ 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'; @@ -40,7 +41,7 @@ class DatatableVisualizationPlugin { const plugin = new DatatableVisualizationPlugin(); export const datatableVisualizationSetup = () => - plugin.setup(null, { + plugin.setup(npSetup.core, { expressions: expressionsSetup, fieldFormat: { formatFactory: getFormat, 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 index d3beddc0689c1..ea4c909d75cbe 100644 --- 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 @@ -20,6 +20,7 @@ interface DataPanelWrapperProps { activeDatasource: string | null; datasourceIsLoading: boolean; dispatch: (action: Action) => void; + core: DatasourceDataPanelProps['core']; query: Query; dateRange: FramePublicAPI['dateRange']; } @@ -40,6 +41,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { dragDropContext: useContext(DragContext), state: props.datasourceState, setState: setDatasourceState, + core: props.core, query: props.query, dateRange: props.dateRange, }; 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 index 6a51a6ec4ea9c..23ae8d4ad08e4 100644 --- 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 @@ -10,6 +10,7 @@ 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, @@ -47,6 +48,7 @@ function getDefaultProps() { onChange: jest.fn(), dateRange: { fromDate: '', toDate: '' }, query: { query: '', language: 'lucene' }, + core: coreMock.createSetup(), }; } 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 index 5bead32178e53..9d658cd2967d7 100644 --- 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 @@ -5,6 +5,7 @@ */ 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'; @@ -27,7 +28,7 @@ export interface EditorFrameProps { initialVisualizationId: string | null; ExpressionRenderer: ExpressionRenderer; onError: (e: { message: string }) => void; - + core: CoreSetup | CoreStart; dateRange: { fromDate: string; toDate: string; @@ -217,6 +218,7 @@ export function EditorFrame(props: EditorFrameProps) { : true } dispatch={dispatch} + core={props.core} query={props.query} dateRange={props.dateRange} /> 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 index a4a58b5398132..aa6d7ded87ed9 100644 --- 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 @@ -8,6 +8,7 @@ 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', () => { @@ -22,6 +23,7 @@ describe('editor_frame state management', () => { initialVisualizationId: 'testVis', ExpressionRenderer: createExpressionRendererMock(), onChange: jest.fn(), + core: coreMock.createSetup(), dateRange: { fromDate: 'now-7d', toDate: 'now' }, query: { query: '', language: 'lucene' }, }; 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 index 632d95d3a619b..7b21ec0cac1c2 100644 --- 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 @@ -5,6 +5,7 @@ */ import { EditorFramePlugin } from './plugin'; +import { coreMock } from 'src/core/public/mocks'; import { MockedSetupDependencies, MockedStartDependencies, @@ -12,6 +13,7 @@ import { createMockStartDependencies, } from './mocks'; +jest.mock('ui/new_platform'); jest.mock('ui/chrome', () => ({ getSavedObjectsClient: jest.fn(), })); @@ -49,8 +51,8 @@ describe('editor_frame plugin', () => { it('should create an editor frame instance which mounts and unmounts', () => { expect(() => { - pluginInstance.setup(null, pluginSetupDependencies); - const publicAPI = pluginInstance.start(null, pluginStartDependencies); + pluginInstance.setup(coreMock.createSetup(), pluginSetupDependencies); + const publicAPI = pluginInstance.start(coreMock.createStart(), pluginStartDependencies); const instance = publicAPI.createInstance({}); instance.mount(mountpoint, { onError: jest.fn(), @@ -63,8 +65,8 @@ describe('editor_frame plugin', () => { }); it('should not have child nodes after unmount', () => { - pluginInstance.setup(null, pluginSetupDependencies); - const publicAPI = pluginInstance.start(null, pluginStartDependencies); + pluginInstance.setup(coreMock.createSetup(), pluginSetupDependencies); + const publicAPI = pluginInstance.start(coreMock.createStart(), pluginStartDependencies); const instance = publicAPI.createInstance({}); instance.mount(mountpoint, { onError: jest.fn(), 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 index 92f7ae6fea57b..e27c2e54500cf 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -9,6 +9,7 @@ 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 { @@ -49,7 +50,7 @@ export class EditorFramePlugin { private readonly datasources: Record = {}; private readonly visualizations: Record = {}; - public setup(_core: CoreSetup | null, plugins: EditorFrameSetupPlugins): EditorFrameSetup { + public setup(core: CoreSetup, plugins: EditorFrameSetupPlugins): EditorFrameSetup { plugins.expressions.registerFunction(() => mergeTables); return { @@ -62,7 +63,7 @@ export class EditorFramePlugin { }; } - public start(_core: CoreStart | null, plugins: EditorFrameStartPlugins): EditorFrameStart { + public start(core: CoreStart, plugins: EditorFrameStartPlugins): EditorFrameStart { plugins.embeddables.registerEmbeddableFactory( 'lens', new EmbeddableFactory( @@ -91,6 +92,7 @@ export class EditorFramePlugin { initialVisualizationId={ (doc && doc.visualizationType) || firstVisualizationId || null } + core={core} ExpressionRenderer={plugins.expressions.ExpressionRenderer} doc={doc} dateRange={dateRange} @@ -122,13 +124,13 @@ export class EditorFramePlugin { const editorFrame = new EditorFramePlugin(); export const editorFrameSetup = () => - editorFrame.setup(null, { + editorFrame.setup(npSetup.core, { data: dataSetup, expressions: expressionsSetup, }); export const editorFrameStart = () => - editorFrame.start(null, { + editorFrame.start(npStart.core, { data: dataStart, expressions: expressionsStart, chrome, 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 index d6e7337a32d82..b8ef8b7689627 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -12,10 +12,11 @@ import { createMockedDragDropContext } from './mocks'; import { InnerIndexPatternDataPanel, IndexPatternDataPanel, MemoizedDataPanel } from './datapanel'; import { FieldItem } from './field_item'; import { act } from 'react-dom/test-utils'; -import { npStart as npStartMock } from 'ui/new_platform'; +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)); @@ -198,8 +199,10 @@ const initialState: IndexPatternPrivateState = { }; describe('IndexPattern Data Panel', () => { let defaultProps: Parameters[0]; + let core: ReturnType; beforeEach(() => { + core = coreMock.createSetup(); defaultProps = { dragDropContext: createMockedDragDropContext(), currentIndexPatternId: '1', @@ -207,6 +210,7 @@ describe('IndexPattern Data Panel', () => { showIndexPatternSwitcher: false, setShowIndexPatternSwitcher: jest.fn(), onChangeIndexPattern: jest.fn(), + core, dateRange: { fromDate: 'now-7d', toDate: 'now', @@ -328,11 +332,11 @@ describe('IndexPattern Data Panel', () => { describe('loading existence data', () => { beforeEach(() => { - (npStartMock.core.http.post as jest.Mock).mockClear(); + core.http.post.mockClear(); }); it('loads existence data and updates the index pattern', async () => { - (npStartMock.core.http.post as jest.Mock).mockResolvedValue({ + core.http.post.mockResolvedValue({ timestamp: { exists: true, cardinality: 500, @@ -344,39 +348,36 @@ describe('IndexPattern Data Panel', () => { await waitForPromises(); - expect(npStartMock.core.http.post as jest.Mock).toHaveBeenCalledWith( - `/api/lens/index_stats/my-fake-index-pattern`, - { - body: JSON.stringify({ - earliest: 'now-7d', - latest: '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(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', [ { @@ -410,7 +411,7 @@ describe('IndexPattern Data Panel', () => { await waitForPromises(); - expect(npStartMock.core.http.post as jest.Mock).not.toHaveBeenCalled(); + expect(core.http.post).not.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 index 832dfaf47a950..b397479bf32fd 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -29,7 +29,6 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { npStart } from 'ui/new_platform'; import { Query } from 'src/plugins/data/common'; import { DatasourceDataPanelProps, DataType } from '../types'; import { IndexPatternPrivateState, IndexPatternField, IndexPattern } from './indexpattern'; @@ -66,6 +65,7 @@ export function IndexPatternDataPanel({ setState, state, dragDropContext, + core, query, dateRange, }: DatasourceDataPanelProps) { @@ -121,6 +121,7 @@ export function IndexPatternDataPanel({ dragDropContext={dragDropContext} showEmptyFields={state.showEmptyFields} onToggleEmptyFields={onToggleEmptyFields} + core={core} // only pass in the state change callback if it's actually needed to avoid re-renders onChangeIndexPattern={showIndexPatternSwitcher ? onChangeIndexPattern : undefined} updateFieldsWithCounts={ @@ -157,11 +158,13 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ 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; @@ -275,11 +278,11 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ setLocalState(s => ({ ...s, isLoading: true })); - npStart.core.http + core.http .post(`/api/lens/index_stats/${currentIndexPattern.title}`, { body: JSON.stringify({ - earliest: dateRange.fromDate, - latest: dateRange.toDate, + fromDate: dateRange.fromDate, + toDate: dateRange.toDate, size: 500, timeFieldName: currentIndexPattern.timeFieldName, fields: allFields 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 index 2351bd7952a3d..a96a2a9f0d831 100644 --- 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 @@ -14,7 +14,11 @@ import { IndexPatternDimensionPanel, IndexPatternDimensionPanelProps } from './d 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 } from 'src/core/public'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; import { Storage } from 'ui/storage'; jest.mock('ui/new_platform'); @@ -122,6 +126,7 @@ describe('IndexPatternDimensionPanel', () => { storage: {} as Storage, uiSettings: {} as UiSettingsClientContract, savedObjectsClient: {} as SavedObjectsClientContract, + http: {} as HttpServiceBase, }; jest.clearAllMocks(); 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 index 9158806138c2e..3e8c90b80ad82 100644 --- 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 @@ -9,7 +9,11 @@ import React, { memo, useMemo } from 'react'; import { EuiButtonIcon } from '@elastic/eui'; import { Storage } from 'ui/storage'; import { i18n } from '@kbn/i18n'; -import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; import { DatasourceDimensionPanelProps, StateSetter } from '../../types'; import { IndexPatternColumn, @@ -32,6 +36,7 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { storage: Storage; savedObjectsClient: SavedObjectsClientContract; layerId: string; + http: HttpServiceBase; }; export interface OperationFieldSupportMatrix { 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 index eaec3ea8b6c5b..1ba7bf9dd607a 100644 --- 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 @@ -357,6 +357,7 @@ export function PopoverEditor(props: PopoverEditorProps) { uiSettings={props.uiSettings} savedObjectsClient={props.savedObjectsClient} layerId={layerId} + http={props.http} /> )} {!incompatibleSelectedOperationType && selectedColumn && ( 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 index 0aa21e867fc30..dbbdb368b44e6 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts @@ -8,7 +8,6 @@ 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 { toastNotifications as notificationsMock } from 'ui/notify'; import { SavedObjectsClientContract } from 'src/core/public'; import { getIndexPatternDatasource, @@ -17,12 +16,12 @@ import { 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'); -jest.mock('ui/notify'); // 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: {} } } })); @@ -139,7 +138,7 @@ describe('IndexPattern Data Source', () => { chrome: chromeMock, storage: {} as Storage, interpreter: { functionsRegistry }, - toastNotifications: notificationsMock, + core: coreMock.createSetup(), data: dataMock, savedObjectsClient: {} as SavedObjectsClientContract, }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 78174e8d96f63..92c0f6e89aecb 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -8,7 +8,7 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; -import { SavedObjectsClientContract } from 'src/core/public'; +import { CoreSetup, SavedObjectsClientContract } from 'src/core/public'; import { Storage } from 'ui/storage'; import { DatasourceDimensionPanelProps, @@ -150,11 +150,12 @@ function removeProperty(prop: string, object: Record): Record = {}; if (indexPatternObjects) { @@ -274,6 +275,7 @@ export function getIndexPatternDatasource({ storage={storage} savedObjectsClient={savedObjectsClient} layerId={props.layerId} + http={core.http} {...props} /> , 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 index aa07a95a76091..c7e33640dee87 100644 --- 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 @@ -7,7 +7,6 @@ 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 { toastNotifications as notificationsMock } from 'ui/notify'; import { SavedObjectsClientContract } from 'src/core/public'; import { getIndexPatternDatasource, @@ -17,12 +16,12 @@ import { 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'); -jest.mock('ui/notify'); // 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: {} } } })); @@ -136,10 +135,10 @@ describe('IndexPattern Data Source suggestions', () => { beforeEach(() => { indexPatternDatasource = getIndexPatternDatasource({ + core: coreMock.createSetup(), chrome: chromeMock, storage: {} as Storage, interpreter: { functionsRegistry }, - toastNotifications: notificationsMock, data: dataMock, savedObjectsClient: {} as SavedObjectsClientContract, }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts index 79fa208e16cf9..0f6c14415ccf5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts @@ -5,9 +5,9 @@ */ import { Chrome } from 'ui/chrome'; -import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; // 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 { @@ -35,7 +35,7 @@ interface SavedRestrictionsObject { } type SavedRestrictionsInfo = SavedRestrictionsObject | undefined; -export const getIndexPatterns = (chrome: Chrome, toastNotifications: ToastNotifications) => { +export const getIndexPatterns = (chrome: Chrome, notifications: NotificationsSetup) => { const savedObjectsClient = chrome.getSavedObjectsClient(); return savedObjectsClient .find({ @@ -64,6 +64,6 @@ export const getIndexPatterns = (chrome: Chrome, toastNotifications: ToastNotifi }); }) .catch(err => { - toastNotifications.addDanger('Failed to load index patterns'); + notifications.toasts.addDanger('Failed to load index patterns'); }); }; 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 index 76f67bcecbf47..ac9c9f48c7acb 100644 --- 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 @@ -10,7 +10,11 @@ import { dateHistogramOperation } from '.'; import { shallow } from 'enzyme'; import { IndexPatternPrivateState } from '../../indexpattern'; import { EuiRange, EuiSwitch } from '@elastic/eui'; -import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; import { Storage } from 'ui/storage'; import { createMockedIndexPattern } from '../../mocks'; @@ -324,6 +328,7 @@ describe('date_histogram', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -342,6 +347,7 @@ describe('date_histogram', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -359,6 +365,7 @@ describe('date_histogram', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); expect(instance.find(EuiRange).exists()).toBe(false); @@ -377,6 +384,7 @@ describe('date_histogram', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); instance.find(EuiSwitch).prop('onChange')!({ @@ -399,6 +407,7 @@ describe('date_histogram', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -458,6 +467,7 @@ describe('date_histogram', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); 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 index e9da64c44fcbd..8145ae0d5be03 100644 --- 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 @@ -11,7 +11,11 @@ import { FilterRatioIndexPatternColumn } from './filter_ratio'; import { filterRatioOperation } from '.'; import { IndexPatternPrivateState } from '../../indexpattern'; import { Storage } from 'ui/storage'; -import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; import { QueryBarInput } from '../../../../../../../../src/legacy/core_plugins/data/public/query'; import { createMockedIndexPattern } from '../../mocks'; @@ -109,6 +113,7 @@ describe('filter_ratio', () => { storage={storageMock} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); }).not.toThrow(); @@ -125,6 +130,7 @@ describe('filter_ratio', () => { storage={storageMock} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -144,6 +150,7 @@ describe('filter_ratio', () => { storage={storageMock} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -184,6 +191,7 @@ describe('filter_ratio', () => { storage={storageMock} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); 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 index de6e34ec3d941..90e959523b328 100644 --- 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 @@ -85,6 +85,7 @@ export const filterRatioOperation: OperationDefinition { const [hasDenominator, setDenominator] = useState(false); @@ -103,6 +104,7 @@ export const filterRatioOperation: OperationDefinition { setState( updateColumnParam({ @@ -131,6 +133,7 @@ export const filterRatioOperation: OperationDefinition { setState( updateColumnParam({ 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 index 9f82d0f2a1763..93d9dd68d1c61 100644 --- 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 @@ -5,7 +5,11 @@ */ import { Storage } from 'ui/storage'; -import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; import { termsOperation } from './terms'; import { minOperation, averageOperation, sumOperation, maxOperation } from './metrics'; import { dateHistogramOperation } from './date_histogram'; @@ -47,6 +51,7 @@ export interface ParamEditorProps { uiSettings: UiSettingsClientContract; storage: Storage; savedObjectsClient: SavedObjectsClientContract; + http: HttpServiceBase; } interface BaseOperationDefinitionProps { 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 index 30b8bfb0ec5bd..a0c7cdd69ff0d 100644 --- 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 @@ -8,7 +8,11 @@ import React from 'react'; import { shallow } from 'enzyme'; import { IndexPatternPrivateState } from '../../indexpattern'; import { EuiRange, EuiSelect } from '@elastic/eui'; -import { UiSettingsClientContract, SavedObjectsClientContract } from 'src/core/public'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; import { Storage } from 'ui/storage'; import { createMockedIndexPattern } from '../../mocks'; import { TermsIndexPatternColumn } from './terms'; @@ -313,6 +317,7 @@ describe('terms', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -360,6 +365,7 @@ describe('terms', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -380,6 +386,7 @@ describe('terms', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -427,6 +434,7 @@ describe('terms', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -450,6 +458,7 @@ describe('terms', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -494,6 +503,7 @@ describe('terms', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); @@ -512,6 +522,7 @@ describe('terms', () => { storage={{} as Storage} uiSettings={{} as UiSettingsClientContract} savedObjectsClient={{} as SavedObjectsClientContract} + http={{} as HttpServiceBase} /> ); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx index 63e9642636063..581c08f832b67 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx @@ -8,8 +8,8 @@ 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 { toastNotifications } from 'ui/notify'; 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'; @@ -25,7 +25,6 @@ export interface IndexPatternDatasourcePluginPlugins { chrome: Chrome; interpreter: InterpreterSetup; data: typeof dataSetup; - toastNotifications: typeof toastNotifications; } export interface InterpreterSetup { @@ -38,16 +37,13 @@ export interface InterpreterSetup { class IndexPatternDatasourcePlugin { constructor() {} - setup( - _core: CoreSetup | null, - { interpreter, data, toastNotifications: toast }: IndexPatternDatasourcePluginPlugins - ) { + setup(core: CoreSetup, { interpreter, data }: IndexPatternDatasourcePluginPlugins) { interpreter.functionsRegistry.register(() => renameColumns); interpreter.functionsRegistry.register(() => calculateFilterRatio); return getIndexPatternDatasource({ + core, chrome, interpreter, - toastNotifications: toast, data, storage: new Storage(localStorage), savedObjectsClient: chrome.getSavedObjectsClient(), @@ -60,12 +56,11 @@ class IndexPatternDatasourcePlugin { const plugin = new IndexPatternDatasourcePlugin(); export const indexPatternDatasourceSetup = () => - plugin.setup(null, { + plugin.setup(npSetup.core, { chrome, interpreter: { functionsRegistry, }, data: dataSetup, - toastNotifications, }); export const indexPatternDatasourceStop = () => plugin.stop(); diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 4d5e8ce213646..2b2c46bf5cd2a 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -6,6 +6,7 @@ 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'; @@ -161,6 +162,7 @@ export interface DatasourceDataPanelProps { state: T; dragDropContext: DragContextState; setState: StateSetter; + core: Pick; query: Query; dateRange: FramePublicAPI['dateRange']; } diff --git a/x-pack/legacy/plugins/lens/server/routes/index_stats.ts b/x-pack/legacy/plugins/lens/server/routes/index_stats.ts index 6918579428dc6..aeb213e356786 100644 --- a/x-pack/legacy/plugins/lens/server/routes/index_stats.ts +++ b/x-pack/legacy/plugins/lens/server/routes/index_stats.ts @@ -28,8 +28,8 @@ export async function initStatsRoute(setup: CoreSetup) { indexPatternTitle: schema.string(), }), body: schema.object({ - earliest: schema.string(), - latest: schema.string(), + fromDate: schema.string(), + toDate: schema.string(), timeZone: schema.maybe(schema.string()), timeFieldName: schema.string(), size: schema.number(), @@ -47,7 +47,7 @@ export async function initStatsRoute(setup: CoreSetup) { const indexPatternsService = new IndexPatternsService(requestClient.callAsCurrentUser); - const { earliest, latest, timeZone, timeFieldName, fields, size } = req.body; + const { fromDate, toDate, timeZone, timeFieldName, fields, size } = req.body; try { const indexPattern = await indexPatternsService.getFieldsForWildcard({ @@ -65,8 +65,8 @@ export async function initStatsRoute(setup: CoreSetup) { { range: { [timeFieldName]: { - gte: earliest, - lte: latest, + gte: fromDate, + lte: toDate, time_zone: timeZone, }, }, diff --git a/x-pack/test/api_integration/apis/lens/index_stats.ts b/x-pack/test/api_integration/apis/lens/index_stats.ts index 286fe2f971621..8dc181fa9b601 100644 --- a/x-pack/test/api_integration/apis/lens/index_stats.ts +++ b/x-pack/test/api_integration/apis/lens/index_stats.ts @@ -95,8 +95,8 @@ export default ({ getService }: FtrProviderContext) => { .post('/api/lens/index_stats/logstash-2015.09.22') .set(COMMON_HEADERS) .send({ - earliest: TEST_START_TIME, - latest: TEST_END_TIME, + fromDate: TEST_START_TIME, + toDate: TEST_END_TIME, timeFieldName: '@timestamp', size: 500, fields: fieldsWithData.concat(fieldsNotInDocuments, fieldsNotInPattern),