mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
New visualization editor Lens (#36437)
* [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
* [lens] Initial state for IndexPatternDatasource (#36052)
* [lens] Add first tests to indexpattern data source
* Respond to review comments
* Fix type definitions
* [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
* [lens][draft] Lens/drag drop (#36268)
Add basic drag / drop component to Lens
* remove local package (#36456)
* [lens] Native renderer (#36165)
* Add nativerenderer component
* Use native renderer in app and editor frame
* [Lens] No explicit any (#36515)
* [Lens] Implement basic editor frame state handling (#36443)
* [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
* [draft] Lens/line chart renderer (#36827)
Expression logic for the Lens xy chart.
* [lens] Index pattern data panel (initial) (#37015)
* [lens] Index pattern switcher
* Respond to review comments
* [Lens] Editor state 2 (#36513)
* [lens] Dimension panel that generates columns (#37117)
* [lens] Dimension panel that generates columns
* Update from review comments
* [lens] Generate esdocs queries from index pattern (#37361)
* [lens] Generate esdocs queries from index pattern
* Remove unused code
* Update yarn.lock from yarn kbn bootstrap
* [Lens] Add basic Lens xy chart suggestions (#37030)
Basic xy chart suggestions
* [Lens] Expression rendering (#37648)
* [Lens] Expression handling (#37876)
* [Lens] Lens/xy config panel (#37391)
Basic xy chart configuration panel
* [Lens] Xy expression building (#37967)
* [Lens] Initialize visualization with datasource api (#38142)
* [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
* [Lens] Rename columns (#38278)
* [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] 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 604c6ed68c
.
* Fix type errors
* [lens] Suggest on drop (#38848)
* [lens] Index pattern suggest on drop
* Add test for suggestion without date field
* fix merge
* [Lens] Parameter configurations and new dimension config flow (#38863)
* fix eslint failure
* [lens] Fix build by updating saved objects and i18n (#39391)
* [lens] Update location of saved objects code
* Update internatationalization
* Remove added file
* [lens] Fix arguments to esaggs using booleans (#39462)
* [lens] Datatable visualization plugin (#39390)
* [lens] Datatable visualization plugin
* Fix merge issues and add tests
* Update from review
* Fix file locations
* [lens] Use first suggestion when switching visualizations (#39377)
* [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
* [Lens] Suggestion preview rendering (#39576)
* [Lens] Popover configs (#39565)
* [Lens] Basic layouting (#39587)
* remove datasource public API in suggestions (#39772)
* [Lens] Basic save / load (#39257)
Add basic routing, save, and load to Lens
* [lens] Fix lint error
* [lens] Use node scripts/eslint.js --fix to fix errors
* [lens] Include link to lens from Visualize (#40542)
* [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
* [Lens] Add xy split series support (#39726)
* Add split series to lens xy chart
* [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
* [Lens] Terms order direction (#39884)
* fix types
* [Lens] Data panel styling and optimizations (#40787)
Style the data panel (mostly Joe Reuter's doing). Optimize a bunch of the Lens stack.
* [Lens] Optimize dimension panel flow (#41114)
* [Lens] re-introduce no-explicit-any (#41454)
* [Lens] No results marker (#41450)
* [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
* [Lens] Embeddable (#41361)
* [lens] Move XY chart config into popover and fix layering (#41927)
* [lens] Move XY chart config into popover and fix layering
* Fix tests
* Update style
* Change wrapping of layer settings popover
* [Lens] Fix bugs in date_histogram and filter ratio (#42046)
* [Lens] Performance improvements (#41784)
* fix type error
* switch default size of terms operation to 3 (#42334)
* [lens] Improve suggestions for split series (#42052)
* [lens] Add chart switcher (#42093)
* solve merge conflicts
* fix test case
* [Lens] Allow only current visualization on field drop in workspace (#42344)
* [Lens] Remove indexpattern id on column (#42429)
* [lens] Implement app-level filtering and time picker (#42031)
* [lens] Implement app-level filtering and time picker
* More integration with filter bar
* Clean up test code and type errors
* Add frame level tests for syncing with app
* Add test coverage for app logic
* Simplify state management from app down
* Fix import errors
* Clarify whether properties are ids or titles for index pattern
* pass new saved object by ref
* add dirty state checking
* Fix tests
* [Lens] Add some tests around document handling in dimension panel (#42670)
* [Lens] Terms operation boolean support (#42817)
* [lens] Minor UX/UI improvements in Lens (#42852)
* Make dimension popover toggle when clicking button
* Without suggestions hide suggestion panel
* Add missing translations (#42921)
* [Lens] Config panel design (#42980)
* Fix up design of config panel
Does not include config popover
* Remove a couple of non-null assertions (#43013)
* Remove a couple of non-null assertions
* Remove orphaned import
* [Lens] Switch indexpattern manually (#42599)
* [Lens] Update frame to put suggestions at the bottom (#42997)
* fix type errors
* switch indexpattern on layer if there is only a single empty one (#43079)
* [Lens] Suggest reduced versions of current data table (#42537)
* [Lens] Field formatter support (#38874)
* Fix bugs
* [Lens] Add bucket nesting editor to indexpattern (#42869)
* [Lens] Remove unnecessary fields and indexing from mappings (#43285)
* [Lens] Xy scale type (#42142)
* [lens] Allow updater function to be used for updating state (#43373)
* [Lens] Lens metric visualization (#39364)
* Fix axis rotation (#43792)
* [Lens] Auto date histogram (#43775)
* Add auto date histogram
* Improve documentation and cleanup
* Add tests
* Change test name
* [Lens] Fix query bar integration (#43865)
* [Lens] Clean up operations code (#43784)
* [Lens] Functional tests (#44279)
Foundational layer for lens functional tests. Lens-specific page objects are not in this PR.
* [Lens] Add Lens visualizations to Visualize list (#43398)
* [Lens] Suggestion improvements (#43688)
* [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
* [Lens ] Preview metric (#43755)
* format filter ratio as percentage (#44625)
* [Lens] Remove datasource suggestion id (#44495)
* [Lens] Make breadcrumbs look and feel like Visualize (#44258)
* [lens] Fix breakage from app-arch movements (#44720)
* [lens] Fix type error in test from merge
* [lens] Fix registration of embeddable (#45171)
* [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.
* [lens] Add Lens to CODEOWNERS (#45296)
* [lens] Fix visualization alias registration
* [lens] Fix usage of EUI after typescript upgrade (#45404)
* [lens] Fix usage of EUI after typescript upgrade
* Use local fix instead of workaround
* [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
* [lens] More cleanup from QueryBar changes in master (#45687)
* [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
This commit is contained in:
parent
c1d6a4701a
commit
eed848ab2e
192 changed files with 27950 additions and 15 deletions
10
.eslintrc.js
10
.eslintrc.js
|
@ -636,6 +636,16 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Lens overrides
|
||||
*/
|
||||
{
|
||||
files: ['x-pack/legacy/plugins/lens/**/*.ts', 'x-pack/legacy/plugins/lens/**/*.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* disable jsx-a11y for kbn-ui-framework
|
||||
*/
|
||||
|
|
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -19,4 +19,4 @@
|
|||
|
||||
export { Registry } from './lib/registry';
|
||||
|
||||
export { fromExpression, toExpression, Ast } from './lib/ast';
|
||||
export { fromExpression, toExpression, Ast, ExpressionFunctionAST } from './lib/ast';
|
||||
|
|
15
packages/kbn-interpreter/src/common/lib/ast.d.ts
vendored
15
packages/kbn-interpreter/src/common/lib/ast.d.ts
vendored
|
@ -17,7 +17,20 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
export type Ast = unknown;
|
||||
export type ExpressionArgAST = string | boolean | number | Ast;
|
||||
|
||||
export interface ExpressionFunctionAST {
|
||||
type: 'function';
|
||||
function: string;
|
||||
arguments: {
|
||||
[key: string]: ExpressionArgAST[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Ast {
|
||||
type: 'expression';
|
||||
chain: ExpressionFunctionAST[];
|
||||
}
|
||||
|
||||
export declare function fromExpression(expression: string): Ast;
|
||||
export declare function toExpression(astObj: Ast, type?: string): string;
|
||||
|
|
|
@ -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 ],
|
||||
|
|
|
@ -359,5 +359,4 @@ export class QueryBarTopRowUI extends Component<Props, State> {
|
|||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export const QueryBarTopRow = injectI18n(QueryBarTopRowUI);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -17,8 +17,8 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { TimeRange } from 'src/plugins/data/public';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { TimeRange } from '../../../../../../plugins/data/public';
|
||||
import { Adapters } from '../../../../../../plugins/inspector/public';
|
||||
import { Query } from '../../../../../../plugins/data/public';
|
||||
import { ExpressionAST } from '../../../../../../plugins/expressions/common';
|
||||
|
@ -68,13 +68,13 @@ export interface IInterpreterRenderHandlers {
|
|||
event: (event: event) => void;
|
||||
}
|
||||
|
||||
export interface IInterpreterRenderFunction {
|
||||
export interface IInterpreterRenderFunction<T = unknown> {
|
||||
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<void>;
|
||||
}
|
||||
|
||||
export interface IInterpreter {
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -58,6 +58,7 @@ export interface LegacyPluginOptions {
|
|||
icon: string;
|
||||
euiIconType: string;
|
||||
order: number;
|
||||
listed: boolean;
|
||||
}>;
|
||||
apps: any;
|
||||
hacks: string[];
|
||||
|
|
20
src/plugins/data/common/query/index.ts
Normal file
20
src/plugins/data/common/query/index.ts
Normal file
|
@ -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';
|
|
@ -18,6 +18,7 @@
|
|||
"xpack.indexLifecycleMgmt": "legacy/plugins/index_lifecycle_management",
|
||||
"xpack.infra": "legacy/plugins/infra",
|
||||
"xpack.kueryAutocomplete": "legacy/plugins/kuery_autocomplete",
|
||||
"xpack.lens": "legacy/plugins/lens",
|
||||
"xpack.licensing": "plugins/licensing",
|
||||
"xpack.licenseMgmt": "legacy/plugins/license_management",
|
||||
"xpack.maps": "legacy/plugins/maps",
|
||||
|
|
|
@ -44,6 +44,7 @@ import { snapshotRestore } from './legacy/plugins/snapshot_restore';
|
|||
import { actions } from './legacy/plugins/actions';
|
||||
import { alerting } from './legacy/plugins/alerting';
|
||||
import { advancedUiActions } from './legacy/plugins/advanced_ui_actions';
|
||||
import { lens } from './legacy/plugins/lens';
|
||||
|
||||
module.exports = function (kibana) {
|
||||
return [
|
||||
|
@ -83,6 +84,7 @@ module.exports = function (kibana) {
|
|||
ossTelemetry(kibana),
|
||||
fileUpload(kibana),
|
||||
encryptedSavedObjects(kibana),
|
||||
lens(kibana),
|
||||
snapshotRestore(kibana),
|
||||
actions(kibana),
|
||||
alerting(kibana),
|
||||
|
|
|
@ -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<Config> = () => ({
|
|||
if (commitValue === '%%CANVAS_MATCH_ALL%%') {
|
||||
handlers.setFilter('');
|
||||
} else {
|
||||
const newFilterAST = {
|
||||
const newFilterAST: Ast = {
|
||||
type: 'expression',
|
||||
chain: [
|
||||
{
|
||||
|
|
14
x-pack/legacy/plugins/lens/common/constants.ts
Normal file
14
x-pack/legacy/plugins/lens/common/constants.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export const PLUGIN_ID = 'lens';
|
||||
|
||||
export const BASE_APP_URL = '/app/lens';
|
||||
export const BASE_API_URL = '/api/lens';
|
||||
|
||||
export function getEditPath(id: string) {
|
||||
return `${BASE_APP_URL}#/edit/${encodeURIComponent(id)}`;
|
||||
}
|
7
x-pack/legacy/plugins/lens/common/index.ts
Normal file
7
x-pack/legacy/plugins/lens/common/index.ts
Normal file
|
@ -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';
|
99
x-pack/legacy/plugins/lens/index.ts
Normal file
99
x-pack/legacy/plugins/lens/index.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import * as Joi from 'joi';
|
||||
import { resolve } from 'path';
|
||||
import { LegacyPluginInitializer } from 'src/legacy/types';
|
||||
import KbnServer, { Server } from 'src/legacy/server/kbn_server';
|
||||
import { CoreSetup } from 'src/core/server';
|
||||
import mappings from './mappings.json';
|
||||
import { PLUGIN_ID, getEditPath, BASE_API_URL } from './common';
|
||||
import { lensServerPlugin } from './server';
|
||||
|
||||
const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations';
|
||||
|
||||
export const lens: LegacyPluginInitializer = kibana => {
|
||||
return new kibana.Plugin({
|
||||
id: PLUGIN_ID,
|
||||
configPrefix: `xpack.${PLUGIN_ID}`,
|
||||
require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter', 'data'],
|
||||
publicDir: resolve(__dirname, 'public'),
|
||||
|
||||
uiExports: {
|
||||
app: {
|
||||
title: NOT_INTERNATIONALIZED_PRODUCT_NAME,
|
||||
description: 'Explore and visualize data.',
|
||||
main: `plugins/${PLUGIN_ID}/index`,
|
||||
listed: false,
|
||||
},
|
||||
embeddableFactories: ['plugins/lens/register_embeddable'],
|
||||
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
|
||||
mappings,
|
||||
visTypes: ['plugins/lens/register_vis_type_alias'],
|
||||
savedObjectsManagement: {
|
||||
lens: {
|
||||
defaultSearchField: 'title',
|
||||
isImportableAndExportable: true,
|
||||
getTitle: (obj: { attributes: { title: string } }) => obj.attributes.title,
|
||||
getInAppUrl: (obj: { id: string }) => ({
|
||||
path: getEditPath(obj.id),
|
||||
uiCapabilitiesPath: 'lens.show',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
config: () => {
|
||||
return Joi.object({
|
||||
enabled: Joi.boolean().default(true),
|
||||
}).default();
|
||||
},
|
||||
|
||||
init(server: Server) {
|
||||
const kbnServer = (server as unknown) as KbnServer;
|
||||
|
||||
server.plugins.xpack_main.registerFeature({
|
||||
id: PLUGIN_ID,
|
||||
name: NOT_INTERNATIONALIZED_PRODUCT_NAME,
|
||||
app: [PLUGIN_ID, 'kibana'],
|
||||
catalogue: [PLUGIN_ID],
|
||||
privileges: {
|
||||
all: {
|
||||
api: [PLUGIN_ID],
|
||||
catalogue: [PLUGIN_ID],
|
||||
savedObject: {
|
||||
all: [],
|
||||
read: [],
|
||||
},
|
||||
ui: ['save', 'show'],
|
||||
},
|
||||
read: {
|
||||
api: [PLUGIN_ID],
|
||||
catalogue: [PLUGIN_ID],
|
||||
savedObject: {
|
||||
all: [],
|
||||
read: [],
|
||||
},
|
||||
ui: ['show'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Set up with the new platform plugin lifecycle API.
|
||||
const plugin = lensServerPlugin();
|
||||
plugin.setup(({
|
||||
http: {
|
||||
...kbnServer.newPlatform.setup.core.http,
|
||||
createRouter: () => kbnServer.newPlatform.setup.core.http.createRouter(BASE_API_URL),
|
||||
},
|
||||
} as unknown) as CoreSetup);
|
||||
|
||||
server.events.on('stop', () => {
|
||||
plugin.stop();
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
20
x-pack/legacy/plugins/lens/mappings.json
Normal file
20
x-pack/legacy/plugins/lens/mappings.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"lens": {
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "text"
|
||||
},
|
||||
"visualizationType": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"state": {
|
||||
"enabled": false,
|
||||
"type": "object"
|
||||
},
|
||||
"expression": {
|
||||
"index": false,
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
436
x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx
Normal file
436
x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx
Normal file
|
@ -0,0 +1,436 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { App } from './app';
|
||||
import { EditorFrameInstance } from '../types';
|
||||
import { Storage } from 'ui/storage';
|
||||
import { Document, SavedObjectStore } from '../persistence';
|
||||
import { mount } from 'enzyme';
|
||||
import { QueryBarTopRow } from '../../../../../../src/legacy/core_plugins/data/public/query/query_bar';
|
||||
import { SavedObjectsClientContract } from 'src/core/public';
|
||||
import { coreMock } from 'src/core/public/mocks';
|
||||
|
||||
jest.mock('../../../../../../src/legacy/core_plugins/data/public/query/query_bar', () => ({
|
||||
QueryBarTopRow: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('ui/new_platform');
|
||||
jest.mock('../persistence');
|
||||
jest.mock('src/core/public');
|
||||
|
||||
const waitForPromises = () => new Promise(resolve => setTimeout(resolve));
|
||||
|
||||
function createMockFrame(): jest.Mocked<EditorFrameInstance> {
|
||||
return {
|
||||
mount: jest.fn((el, props) => {}),
|
||||
unmount: jest.fn(() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Lens App', () => {
|
||||
let frame: jest.Mocked<EditorFrameInstance>;
|
||||
let core: ReturnType<typeof coreMock['createStart']>;
|
||||
|
||||
function makeDefaultArgs(): jest.Mocked<{
|
||||
editorFrame: EditorFrameInstance;
|
||||
core: typeof core;
|
||||
store: Storage;
|
||||
docId?: string;
|
||||
docStorage: SavedObjectStore;
|
||||
redirectTo: (id?: string) => void;
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
}> {
|
||||
return ({
|
||||
editorFrame: createMockFrame(),
|
||||
core,
|
||||
store: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
docStorage: {
|
||||
load: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
QueryBarTopRow: jest.fn(() => <div />),
|
||||
redirectTo: jest.fn(id => {}),
|
||||
savedObjectsClient: jest.fn(),
|
||||
} as unknown) as jest.Mocked<{
|
||||
editorFrame: EditorFrameInstance;
|
||||
core: typeof core;
|
||||
store: Storage;
|
||||
docId?: string;
|
||||
docStorage: SavedObjectStore;
|
||||
redirectTo: (id?: string) => void;
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
}>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
frame = createMockFrame();
|
||||
core = coreMock.createStart();
|
||||
|
||||
core.uiSettings.get.mockImplementation(
|
||||
jest.fn(type => {
|
||||
if (type === 'timepicker:timeDefaults') {
|
||||
return { from: 'now-7d', to: 'now' };
|
||||
} else if (type === 'search:queryLanguage') {
|
||||
return 'kuery';
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
(core.http.basePath.get as jest.Mock).mockReturnValue(`/testbasepath`);
|
||||
(core.http.basePath.prepend as jest.Mock).mockImplementation(s => `/testbasepath${s}`);
|
||||
});
|
||||
|
||||
it('renders the editor frame', () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
|
||||
mount(<App {...args} />);
|
||||
|
||||
expect(frame.mount.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
<div
|
||||
class="lnsAppFrame"
|
||||
/>,
|
||||
Object {
|
||||
"dateRange": Object {
|
||||
"fromDate": "now-7d",
|
||||
"toDate": "now",
|
||||
},
|
||||
"doc": undefined,
|
||||
"onChange": [Function],
|
||||
"onError": [Function],
|
||||
"query": Object {
|
||||
"language": "kuery",
|
||||
"query": "",
|
||||
},
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('sets breadcrumbs when the document title changes', async () => {
|
||||
const defaultArgs = makeDefaultArgs();
|
||||
const instance = mount(<App {...defaultArgs} />);
|
||||
|
||||
expect(core.chrome.setBreadcrumbs).toHaveBeenCalledWith([
|
||||
{ text: 'Visualize', href: '/testbasepath/app/kibana#/visualize' },
|
||||
{ text: 'Create' },
|
||||
]);
|
||||
|
||||
(defaultArgs.docStorage.load as jest.Mock).mockResolvedValue({
|
||||
id: '1234',
|
||||
title: 'Daaaaaaadaumching!',
|
||||
state: {
|
||||
query: 'fake query',
|
||||
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
|
||||
},
|
||||
});
|
||||
|
||||
instance.setProps({ docId: '1234' });
|
||||
await waitForPromises();
|
||||
|
||||
expect(defaultArgs.core.chrome.setBreadcrumbs).toHaveBeenCalledWith([
|
||||
{ text: 'Visualize', href: '/testbasepath/app/kibana#/visualize' },
|
||||
{ text: 'Daaaaaaadaumching!' },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('does not load a document if there is no document id', () => {
|
||||
const args = makeDefaultArgs();
|
||||
|
||||
mount(<App {...args} />);
|
||||
|
||||
expect(args.docStorage.load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads a document and uses query if there is a document id', async () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
(args.docStorage.load as jest.Mock).mockResolvedValue({
|
||||
id: '1234',
|
||||
state: {
|
||||
query: 'fake query',
|
||||
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
|
||||
},
|
||||
});
|
||||
|
||||
const instance = mount(<App {...args} />);
|
||||
|
||||
instance.setProps({ docId: '1234' });
|
||||
await waitForPromises();
|
||||
|
||||
expect(args.docStorage.load).toHaveBeenCalledWith('1234');
|
||||
expect(QueryBarTopRow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dateRangeFrom: 'now-7d',
|
||||
dateRangeTo: 'now',
|
||||
query: 'fake query',
|
||||
indexPatterns: ['saved'],
|
||||
}),
|
||||
{}
|
||||
);
|
||||
expect(frame.mount).toHaveBeenCalledWith(
|
||||
expect.any(Element),
|
||||
expect.objectContaining({
|
||||
doc: {
|
||||
id: '1234',
|
||||
state: {
|
||||
query: 'fake query',
|
||||
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not load documents on sequential renders unless the id changes', async () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
(args.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234' });
|
||||
|
||||
const instance = mount(<App {...args} />);
|
||||
|
||||
instance.setProps({ docId: '1234' });
|
||||
await waitForPromises();
|
||||
instance.setProps({ docId: '1234' });
|
||||
await waitForPromises();
|
||||
|
||||
expect(args.docStorage.load).toHaveBeenCalledTimes(1);
|
||||
|
||||
instance.setProps({ docId: '9876' });
|
||||
await waitForPromises();
|
||||
|
||||
expect(args.docStorage.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles document load errors', async () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
(args.docStorage.load as jest.Mock).mockRejectedValue('failed to load');
|
||||
|
||||
const instance = mount(<App {...args} />);
|
||||
|
||||
instance.setProps({ docId: '1234' });
|
||||
await waitForPromises();
|
||||
|
||||
expect(args.docStorage.load).toHaveBeenCalledWith('1234');
|
||||
expect(args.core.notifications.toasts.addDanger).toHaveBeenCalled();
|
||||
expect(args.redirectTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('save button', () => {
|
||||
it('shows a save button that is enabled when the frame has provided its state', () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
|
||||
const instance = mount(<App {...args} />);
|
||||
|
||||
expect(
|
||||
instance
|
||||
.find('[data-test-subj="lnsApp_saveButton"]')
|
||||
.first()
|
||||
.prop('disabled')
|
||||
).toEqual(true);
|
||||
|
||||
const onChange = frame.mount.mock.calls[0][1].onChange;
|
||||
onChange({ indexPatternTitles: [], doc: ('will save this' as unknown) as Document });
|
||||
|
||||
instance.update();
|
||||
|
||||
expect(
|
||||
instance
|
||||
.find('[data-test-subj="lnsApp_saveButton"]')
|
||||
.first()
|
||||
.prop('disabled')
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
it('saves the latest doc and then prevents more saving', async () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
(args.docStorage.save as jest.Mock).mockResolvedValue({ id: '1234' });
|
||||
|
||||
const instance = mount(<App {...args} />);
|
||||
|
||||
expect(frame.mount).toHaveBeenCalledTimes(1);
|
||||
|
||||
const onChange = frame.mount.mock.calls[0][1].onChange;
|
||||
onChange({ indexPatternTitles: [], doc: ({ id: undefined } as unknown) as Document });
|
||||
|
||||
instance.update();
|
||||
|
||||
expect(
|
||||
instance
|
||||
.find('[data-test-subj="lnsApp_saveButton"]')
|
||||
.first()
|
||||
.prop('disabled')
|
||||
).toEqual(false);
|
||||
|
||||
instance
|
||||
.find('[data-test-subj="lnsApp_saveButton"]')
|
||||
.first()
|
||||
.prop('onClick')!({} as React.MouseEvent);
|
||||
|
||||
expect(args.docStorage.save).toHaveBeenCalledWith({ id: undefined });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(args.redirectTo).toHaveBeenCalledWith('1234');
|
||||
|
||||
instance.setProps({ docId: '1234' });
|
||||
|
||||
expect(args.docStorage.load).not.toHaveBeenCalled();
|
||||
|
||||
expect(
|
||||
instance
|
||||
.find('[data-test-subj="lnsApp_saveButton"]')
|
||||
.first()
|
||||
.prop('disabled')
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
it('handles save failure by showing a warning, but still allows another save', async () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
(args.docStorage.save as jest.Mock).mockRejectedValue({ message: 'failed' });
|
||||
|
||||
const instance = mount(<App {...args} />);
|
||||
|
||||
const onChange = frame.mount.mock.calls[0][1].onChange;
|
||||
onChange({ indexPatternTitles: [], doc: ({ id: undefined } as unknown) as Document });
|
||||
|
||||
instance.update();
|
||||
|
||||
instance
|
||||
.find('[data-test-subj="lnsApp_saveButton"]')
|
||||
.first()
|
||||
.prop('onClick')!({} as React.MouseEvent);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(args.core.notifications.toasts.addDanger).toHaveBeenCalled();
|
||||
expect(args.redirectTo).not.toHaveBeenCalled();
|
||||
await waitForPromises();
|
||||
|
||||
expect(
|
||||
instance
|
||||
.find('[data-test-subj="lnsApp_saveButton"]')
|
||||
.first()
|
||||
.prop('disabled')
|
||||
).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('query bar state management', () => {
|
||||
it('uses the default time and query language settings', () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
|
||||
mount(<App {...args} />);
|
||||
|
||||
expect(QueryBarTopRow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dateRangeFrom: 'now-7d',
|
||||
dateRangeTo: 'now',
|
||||
query: { query: '', language: 'kuery' },
|
||||
}),
|
||||
{}
|
||||
);
|
||||
expect(frame.mount).toHaveBeenCalledWith(
|
||||
expect.any(Element),
|
||||
expect.objectContaining({
|
||||
dateRange: { fromDate: 'now-7d', toDate: 'now' },
|
||||
query: { query: '', language: 'kuery' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('updates the index patterns when the editor frame is changed', () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
|
||||
const instance = mount(<App {...args} />);
|
||||
|
||||
expect(QueryBarTopRow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
indexPatterns: [],
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
const onChange = frame.mount.mock.calls[0][1].onChange;
|
||||
onChange({
|
||||
indexPatternTitles: ['newIndex'],
|
||||
doc: ({ id: undefined } as unknown) as Document,
|
||||
});
|
||||
|
||||
instance.update();
|
||||
|
||||
expect(QueryBarTopRow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
indexPatterns: ['newIndex'],
|
||||
}),
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('updates the editor frame when the user changes query or time', () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
|
||||
const instance = mount(<App {...args} />);
|
||||
|
||||
instance
|
||||
.find('[data-test-subj="lnsApp_queryBar"]')
|
||||
.first()
|
||||
.prop('onSubmit')!(({
|
||||
dateRange: { from: 'now-14d', to: 'now-7d' },
|
||||
query: { query: 'new', language: 'lucene' },
|
||||
} as unknown) as React.FormEvent);
|
||||
|
||||
instance.update();
|
||||
|
||||
expect(QueryBarTopRow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dateRangeFrom: 'now-14d',
|
||||
dateRangeTo: 'now-7d',
|
||||
query: { query: 'new', language: 'lucene' },
|
||||
}),
|
||||
{}
|
||||
);
|
||||
expect(frame.mount).toHaveBeenCalledWith(
|
||||
expect.any(Element),
|
||||
expect.objectContaining({
|
||||
dateRange: { fromDate: 'now-14d', toDate: 'now-7d' },
|
||||
query: { query: 'new', language: 'lucene' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays errors from the frame in a toast', () => {
|
||||
const args = makeDefaultArgs();
|
||||
args.editorFrame = frame;
|
||||
|
||||
const instance = mount(<App {...args} />);
|
||||
|
||||
const onError = frame.mount.mock.calls[0][1].onError;
|
||||
onError({ message: 'error' });
|
||||
|
||||
instance.update();
|
||||
|
||||
expect(args.core.notifications.toasts.addDanger).toHaveBeenCalled();
|
||||
});
|
||||
});
|
265
x-pack/legacy/plugins/lens/public/app_plugin/app.tsx
Normal file
265
x-pack/legacy/plugins/lens/public/app_plugin/app.tsx
Normal file
|
@ -0,0 +1,265 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { Storage } from 'ui/storage';
|
||||
import { CoreStart, SavedObjectsClientContract } from 'src/core/public';
|
||||
import { Query } from '../../../../../../src/legacy/core_plugins/data/public/query';
|
||||
import { QueryBarTopRow } from '../../../../../../src/legacy/core_plugins/data/public/query/query_bar';
|
||||
import { Document, SavedObjectStore } from '../persistence';
|
||||
import { EditorFrameInstance } from '../types';
|
||||
import { NativeRenderer } from '../native_renderer';
|
||||
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
isDirty: boolean;
|
||||
dateRange: {
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
};
|
||||
query: Query;
|
||||
indexPatternTitles: string[];
|
||||
persistedDoc?: Document;
|
||||
localQueryBarState: {
|
||||
query?: Query;
|
||||
dateRange?: {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function isLocalStateDirty(
|
||||
localState: State['localQueryBarState'],
|
||||
query: Query,
|
||||
dateRange: State['dateRange']
|
||||
) {
|
||||
return Boolean(
|
||||
(localState.query && query && localState.query.query !== query.query) ||
|
||||
(localState.dateRange && dateRange.fromDate !== localState.dateRange.from) ||
|
||||
(localState.dateRange && dateRange.toDate !== localState.dateRange.to)
|
||||
);
|
||||
}
|
||||
|
||||
export function App({
|
||||
editorFrame,
|
||||
core,
|
||||
store,
|
||||
docId,
|
||||
docStorage,
|
||||
redirectTo,
|
||||
savedObjectsClient,
|
||||
}: {
|
||||
editorFrame: EditorFrameInstance;
|
||||
core: CoreStart;
|
||||
store: Storage;
|
||||
docId?: string;
|
||||
docStorage: SavedObjectStore;
|
||||
redirectTo: (id?: string) => void;
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
}) {
|
||||
const timeDefaults = core.uiSettings.get('timepicker:timeDefaults');
|
||||
const language =
|
||||
store.get('kibana.userQueryLanguage') || core.uiSettings.get('search:queryLanguage');
|
||||
|
||||
const [state, setState] = useState<State>({
|
||||
isLoading: !!docId,
|
||||
isDirty: false,
|
||||
query: { query: '', language },
|
||||
dateRange: {
|
||||
fromDate: timeDefaults.from,
|
||||
toDate: timeDefaults.to,
|
||||
},
|
||||
indexPatternTitles: [],
|
||||
localQueryBarState: {
|
||||
query: { query: '', language },
|
||||
dateRange: {
|
||||
from: timeDefaults.from,
|
||||
to: timeDefaults.to,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lastKnownDocRef = useRef<Document | undefined>(undefined);
|
||||
|
||||
// Sync Kibana breadcrumbs any time the saved document's title changes
|
||||
useEffect(() => {
|
||||
core.chrome.setBreadcrumbs([
|
||||
{
|
||||
href: core.http.basePath.prepend(`/app/kibana#/visualize`),
|
||||
text: i18n.translate('xpack.lens.breadcrumbsTitle', {
|
||||
defaultMessage: 'Visualize',
|
||||
}),
|
||||
},
|
||||
{
|
||||
text: state.persistedDoc
|
||||
? state.persistedDoc.title
|
||||
: i18n.translate('xpack.lens.breadcrumbsCreate', { defaultMessage: 'Create' }),
|
||||
},
|
||||
]);
|
||||
}, [state.persistedDoc && state.persistedDoc.title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (docId && (!state.persistedDoc || state.persistedDoc.id !== docId)) {
|
||||
setState({ ...state, isLoading: true });
|
||||
docStorage
|
||||
.load(docId)
|
||||
.then(doc => {
|
||||
setState({
|
||||
...state,
|
||||
isLoading: false,
|
||||
persistedDoc: doc,
|
||||
query: doc.state.query,
|
||||
localQueryBarState: {
|
||||
...state.localQueryBarState,
|
||||
query: doc.state.query,
|
||||
},
|
||||
indexPatternTitles: doc.state.datasourceMetaData.filterableIndexPatterns.map(
|
||||
({ title }) => title
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setState({ ...state, isLoading: false });
|
||||
|
||||
core.notifications.toasts.addDanger(
|
||||
i18n.translate('xpack.lens.editorFrame.docLoadingError', {
|
||||
defaultMessage: 'Error loading saved document',
|
||||
})
|
||||
);
|
||||
|
||||
redirectTo();
|
||||
});
|
||||
}
|
||||
}, [docId]);
|
||||
|
||||
// Can save if the frame has told us what it has, and there is either:
|
||||
// a) No saved doc
|
||||
// b) A saved doc that differs from the frame state
|
||||
const isSaveable = state.isDirty;
|
||||
|
||||
const onError = useCallback(
|
||||
(e: { message: string }) =>
|
||||
core.notifications.toasts.addDanger({
|
||||
title: e.message,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<div className="lnsApp">
|
||||
<div className="lnsAppHeader">
|
||||
<nav>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLink
|
||||
data-test-subj="lnsApp_saveButton"
|
||||
onClick={() => {
|
||||
if (isSaveable && lastKnownDocRef.current) {
|
||||
docStorage
|
||||
.save(lastKnownDocRef.current)
|
||||
.then(({ id }) => {
|
||||
// Prevents unnecessary network request and disables save button
|
||||
const newDoc = { ...lastKnownDocRef.current!, id };
|
||||
setState({
|
||||
...state,
|
||||
isDirty: false,
|
||||
persistedDoc: newDoc,
|
||||
});
|
||||
if (docId !== id) {
|
||||
redirectTo(id);
|
||||
}
|
||||
})
|
||||
.catch(reason => {
|
||||
core.notifications.toasts.addDanger(
|
||||
i18n.translate('xpack.lens.editorFrame.docSavingError', {
|
||||
defaultMessage: 'Error saving document {reason}',
|
||||
values: { reason },
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}}
|
||||
color={isSaveable ? 'primary' : 'subdued'}
|
||||
disabled={!isSaveable}
|
||||
>
|
||||
{i18n.translate('xpack.lens.editorFrame.save', {
|
||||
defaultMessage: 'Save',
|
||||
})}
|
||||
</EuiLink>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</nav>
|
||||
<QueryBarTopRow
|
||||
data-test-subj="lnsApp_queryBar"
|
||||
screenTitle={'lens'}
|
||||
onSubmit={payload => {
|
||||
const { dateRange, query } = payload;
|
||||
setState({
|
||||
...state,
|
||||
dateRange: {
|
||||
fromDate: dateRange.from,
|
||||
toDate: dateRange.to,
|
||||
},
|
||||
query: query || state.query,
|
||||
localQueryBarState: payload,
|
||||
});
|
||||
}}
|
||||
onChange={localQueryBarState => {
|
||||
setState({ ...state, localQueryBarState });
|
||||
}}
|
||||
isDirty={isLocalStateDirty(state.localQueryBarState, state.query, state.dateRange)}
|
||||
appName={'lens'}
|
||||
indexPatterns={state.indexPatternTitles}
|
||||
store={store}
|
||||
showDatePicker={true}
|
||||
showQueryInput={true}
|
||||
query={state.localQueryBarState.query}
|
||||
dateRangeFrom={
|
||||
state.localQueryBarState.dateRange && state.localQueryBarState.dateRange.from
|
||||
}
|
||||
dateRangeTo={
|
||||
state.localQueryBarState.dateRange && state.localQueryBarState.dateRange.to
|
||||
}
|
||||
uiSettings={core.uiSettings}
|
||||
savedObjectsClient={savedObjectsClient}
|
||||
http={core.http}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(!state.isLoading || state.persistedDoc) && (
|
||||
<NativeRenderer
|
||||
className="lnsAppFrame"
|
||||
render={editorFrame.mount}
|
||||
nativeProps={{
|
||||
dateRange: state.dateRange,
|
||||
query: state.query,
|
||||
doc: state.persistedDoc,
|
||||
onError,
|
||||
onChange: ({ indexPatternTitles, doc }) => {
|
||||
const indexPatternChange = !_.isEqual(state.indexPatternTitles, indexPatternTitles);
|
||||
const docChange = !_.isEqual(state.persistedDoc, doc);
|
||||
if (indexPatternChange || docChange) {
|
||||
setState({
|
||||
...state,
|
||||
indexPatternTitles,
|
||||
isDirty: docChange,
|
||||
});
|
||||
}
|
||||
lastKnownDocRef.current = doc;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
23
x-pack/legacy/plugins/lens/public/app_plugin/index.scss
Normal file
23
x-pack/legacy/plugins/lens/public/app_plugin/index.scss
Normal file
|
@ -0,0 +1,23 @@
|
|||
.lnsApp {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lnsAppHeader {
|
||||
padding: $euiSize;
|
||||
border-bottom: $euiBorderThin;
|
||||
}
|
||||
|
||||
.lnsAppFrame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
7
x-pack/legacy/plugins/lens/public/app_plugin/index.ts
Normal file
7
x-pack/legacy/plugins/lens/public/app_plugin/index.ts
Normal file
|
@ -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';
|
114
x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx
Normal file
114
x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { I18nProvider, FormattedMessage } from '@kbn/i18n/react';
|
||||
import { HashRouter, Switch, Route, RouteComponentProps } from 'react-router-dom';
|
||||
import chrome from 'ui/chrome';
|
||||
import { Storage } from 'ui/storage';
|
||||
import { CoreSetup, CoreStart } from 'src/core/public';
|
||||
import { npSetup, npStart } from 'ui/new_platform';
|
||||
import { editorFrameSetup, editorFrameStart, editorFrameStop } from '../editor_frame_plugin';
|
||||
import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin';
|
||||
import { SavedObjectIndexStore } from '../persistence';
|
||||
import { xyVisualizationSetup, xyVisualizationStop } from '../xy_visualization_plugin';
|
||||
import { metricVisualizationSetup, metricVisualizationStop } from '../metric_visualization_plugin';
|
||||
import {
|
||||
datatableVisualizationSetup,
|
||||
datatableVisualizationStop,
|
||||
} from '../datatable_visualization_plugin';
|
||||
import { App } from './app';
|
||||
import { EditorFrameInstance } from '../types';
|
||||
|
||||
export class AppPlugin {
|
||||
private instance: EditorFrameInstance | null = null;
|
||||
private store: SavedObjectIndexStore | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
setup(core: CoreSetup) {
|
||||
// TODO: These plugins should not be called from the top level, but since this is the
|
||||
// entry point to the app we have no choice until the new platform is ready
|
||||
const indexPattern = indexPatternDatasourceSetup();
|
||||
const datatableVisualization = datatableVisualizationSetup();
|
||||
const xyVisualization = xyVisualizationSetup();
|
||||
const metricVisualization = metricVisualizationSetup();
|
||||
const editorFrameSetupInterface = editorFrameSetup();
|
||||
this.store = new SavedObjectIndexStore(chrome!.getSavedObjectsClient());
|
||||
|
||||
editorFrameSetupInterface.registerDatasource('indexpattern', indexPattern);
|
||||
editorFrameSetupInterface.registerVisualization(xyVisualization);
|
||||
editorFrameSetupInterface.registerVisualization(datatableVisualization);
|
||||
editorFrameSetupInterface.registerVisualization(metricVisualization);
|
||||
}
|
||||
|
||||
start(core: CoreStart) {
|
||||
if (this.store === null) {
|
||||
throw new Error('Start lifecycle called before setup lifecycle');
|
||||
}
|
||||
|
||||
const store = this.store;
|
||||
|
||||
const editorFrameStartInterface = editorFrameStart();
|
||||
|
||||
this.instance = editorFrameStartInterface.createInstance({});
|
||||
|
||||
const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => {
|
||||
return (
|
||||
<App
|
||||
core={core}
|
||||
editorFrame={this.instance!}
|
||||
store={new Storage(localStorage)}
|
||||
savedObjectsClient={chrome.getSavedObjectsClient()}
|
||||
docId={routeProps.match.params.id}
|
||||
docStorage={store}
|
||||
redirectTo={id => {
|
||||
if (!id) {
|
||||
routeProps.history.push('/');
|
||||
} else {
|
||||
routeProps.history.push(`/edit/${id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function NotFound() {
|
||||
return <FormattedMessage id="xpack.lens.app404" defaultMessage="404 Not Found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<Route exact path="/edit/:id" render={renderEditor} />
|
||||
<Route exact path="/" render={renderEditor} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.instance) {
|
||||
this.instance.unmount();
|
||||
}
|
||||
|
||||
// TODO this will be handled by the plugin platform itself
|
||||
indexPatternDatasourceStop();
|
||||
xyVisualizationStop();
|
||||
metricVisualizationStop();
|
||||
datatableVisualizationStop();
|
||||
editorFrameStop();
|
||||
}
|
||||
}
|
||||
|
||||
const app = new AppPlugin();
|
||||
|
||||
export const appSetup = () => app.setup(npSetup.core);
|
||||
export const appStart = () => app.start(npStart.core);
|
||||
export const appStop = () => app.stop();
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiBasicTable } from '@elastic/eui';
|
||||
import { ExpressionFunction } from '../../../../../../src/plugins/expressions/common';
|
||||
import { KibanaDatatable } from '../../../../../../src/legacy/core_plugins/interpreter/public';
|
||||
import { LensMultiTable } from '../types';
|
||||
import { IInterpreterRenderFunction } from '../../../../../../src/legacy/core_plugins/expressions/public';
|
||||
import { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities';
|
||||
|
||||
export interface DatatableColumns {
|
||||
columnIds: string[];
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
interface Args {
|
||||
columns: DatatableColumns;
|
||||
}
|
||||
|
||||
export interface DatatableProps {
|
||||
data: LensMultiTable;
|
||||
args: Args;
|
||||
}
|
||||
|
||||
export interface DatatableRender {
|
||||
type: 'render';
|
||||
as: 'lens_datatable_renderer';
|
||||
value: DatatableProps;
|
||||
}
|
||||
|
||||
export const datatable: ExpressionFunction<
|
||||
'lens_datatable',
|
||||
KibanaDatatable,
|
||||
Args,
|
||||
DatatableRender
|
||||
> = ({
|
||||
name: 'lens_datatable',
|
||||
type: 'render',
|
||||
help: i18n.translate('xpack.lens.datatable.expressionHelpLabel', {
|
||||
defaultMessage: 'Datatable renderer',
|
||||
}),
|
||||
args: {
|
||||
title: {
|
||||
types: ['string'],
|
||||
help: i18n.translate('xpack.lens.datatable.titleLabel', {
|
||||
defaultMessage: 'Title',
|
||||
}),
|
||||
},
|
||||
columns: {
|
||||
types: ['lens_datatable_columns'],
|
||||
help: '',
|
||||
},
|
||||
},
|
||||
context: {
|
||||
types: ['lens_multitable'],
|
||||
},
|
||||
fn(data: KibanaDatatable, args: Args) {
|
||||
return {
|
||||
type: 'render',
|
||||
as: 'lens_datatable_renderer',
|
||||
value: {
|
||||
data,
|
||||
args,
|
||||
},
|
||||
};
|
||||
},
|
||||
// TODO the typings currently don't support custom type args. As soon as they do, this can be removed
|
||||
} as unknown) as ExpressionFunction<'lens_datatable', KibanaDatatable, Args, DatatableRender>;
|
||||
|
||||
type DatatableColumnsResult = DatatableColumns & { type: 'lens_datatable_columns' };
|
||||
|
||||
export const datatableColumns: ExpressionFunction<
|
||||
'lens_datatable_columns',
|
||||
null,
|
||||
DatatableColumns,
|
||||
DatatableColumnsResult
|
||||
> = {
|
||||
name: 'lens_datatable_columns',
|
||||
aliases: [],
|
||||
type: 'lens_datatable_columns',
|
||||
help: '',
|
||||
context: {
|
||||
types: ['null'],
|
||||
},
|
||||
args: {
|
||||
columnIds: {
|
||||
types: ['string'],
|
||||
multi: true,
|
||||
help: '',
|
||||
},
|
||||
labels: {
|
||||
types: ['string'],
|
||||
multi: true,
|
||||
help: '',
|
||||
},
|
||||
},
|
||||
fn: function fn(_context: unknown, args: DatatableColumns) {
|
||||
return {
|
||||
type: 'lens_datatable_columns',
|
||||
...args,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const getDatatableRenderer = (
|
||||
formatFactory: FormatFactory
|
||||
): IInterpreterRenderFunction<DatatableProps> => ({
|
||||
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(<DatatableComponent {...config} formatFactory={formatFactory} />, domNode);
|
||||
},
|
||||
});
|
||||
|
||||
function DatatableComponent(props: DatatableProps & { formatFactory: FormatFactory }) {
|
||||
const [firstTable] = Object.values(props.data.tables);
|
||||
const formatters: Record<string, ReturnType<FormatFactory>> = {};
|
||||
|
||||
firstTable.columns.forEach(column => {
|
||||
formatters[column.id] = props.formatFactory(column.formatHint);
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiBasicTable
|
||||
className="lnsDataTable"
|
||||
data-test-subj="lnsDataTable"
|
||||
columns={props.args.columns.columnIds
|
||||
.map((field, index) => {
|
||||
return {
|
||||
field,
|
||||
name: props.args.columns.labels[index],
|
||||
};
|
||||
})
|
||||
.filter(({ field }) => !!field)}
|
||||
items={
|
||||
firstTable
|
||||
? firstTable.rows.map(row => {
|
||||
const formattedRow: Record<string, unknown> = {};
|
||||
Object.entries(formatters).forEach(([columnId, formatter]) => {
|
||||
formattedRow[columnId] = formatter.convert(row[columnId]);
|
||||
});
|
||||
return formattedRow;
|
||||
})
|
||||
: []
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.lnsDataTable {
|
||||
align-self: flex-start;
|
||||
}
|
|
@ -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';
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { CoreSetup } from 'src/core/public';
|
||||
import { getFormat, FormatFactory } from 'ui/visualize/loader/pipeline_helpers/utilities';
|
||||
import { npSetup } from 'ui/new_platform';
|
||||
import { datatableVisualization } from './visualization';
|
||||
import { ExpressionsSetup } from '../../../../../../src/legacy/core_plugins/expressions/public';
|
||||
import { setup as expressionsSetup } from '../../../../../../src/legacy/core_plugins/expressions/public/legacy';
|
||||
import { datatable, datatableColumns, getDatatableRenderer } from './expression';
|
||||
|
||||
export interface DatatableVisualizationPluginSetupPlugins {
|
||||
expressions: ExpressionsSetup;
|
||||
// TODO this is a simulated NP plugin.
|
||||
// Once field formatters are actually migrated, the actual shim can be used
|
||||
fieldFormat: {
|
||||
formatFactory: FormatFactory;
|
||||
};
|
||||
}
|
||||
|
||||
class DatatableVisualizationPlugin {
|
||||
constructor() {}
|
||||
|
||||
setup(
|
||||
_core: CoreSetup | null,
|
||||
{ expressions, fieldFormat }: DatatableVisualizationPluginSetupPlugins
|
||||
) {
|
||||
expressions.registerFunction(() => datatableColumns);
|
||||
expressions.registerFunction(() => datatable);
|
||||
expressions.registerRenderer(() => getDatatableRenderer(fieldFormat.formatFactory));
|
||||
|
||||
return datatableVisualization;
|
||||
}
|
||||
|
||||
stop() {}
|
||||
}
|
||||
|
||||
const plugin = new DatatableVisualizationPlugin();
|
||||
|
||||
export const datatableVisualizationSetup = () =>
|
||||
plugin.setup(npSetup.core, {
|
||||
expressions: expressionsSetup,
|
||||
fieldFormat: {
|
||||
formatFactory: getFormat,
|
||||
},
|
||||
});
|
||||
export const datatableVisualizationStop = () => plugin.stop();
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { createMockDatasource } from '../editor_frame_plugin/mocks';
|
||||
import {
|
||||
DatatableVisualizationState,
|
||||
datatableVisualization,
|
||||
DataTableLayer,
|
||||
} from './visualization';
|
||||
import { mount } from 'enzyme';
|
||||
import { Operation, DataType, FramePublicAPI } from '../types';
|
||||
import { generateId } from '../id_generator';
|
||||
|
||||
jest.mock('../id_generator');
|
||||
|
||||
function mockFrame(): FramePublicAPI {
|
||||
return {
|
||||
addNewLayer: () => 'aaa',
|
||||
removeLayers: () => {},
|
||||
datasourceLayers: {},
|
||||
query: { query: '', language: 'lucene' },
|
||||
dateRange: {
|
||||
fromDate: 'now-7d',
|
||||
toDate: 'now',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Datatable Visualization', () => {
|
||||
describe('#initialize', () => {
|
||||
it('should initialize from the empty state', () => {
|
||||
(generateId as jest.Mock).mockReturnValueOnce('id');
|
||||
expect(datatableVisualization.initialize(mockFrame(), undefined)).toEqual({
|
||||
layers: [
|
||||
{
|
||||
layerId: 'aaa',
|
||||
columns: ['id'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize from a persisted state', () => {
|
||||
const expectedState: DatatableVisualizationState = {
|
||||
layers: [
|
||||
{
|
||||
layerId: 'foo',
|
||||
columns: ['saved'],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(datatableVisualization.initialize(mockFrame(), expectedState)).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getPersistableState', () => {
|
||||
it('should persist the internal state', () => {
|
||||
const expectedState: DatatableVisualizationState = {
|
||||
layers: [
|
||||
{
|
||||
layerId: 'baz',
|
||||
columns: ['a', 'b', 'c'],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(datatableVisualization.getPersistableState(expectedState)).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataTableLayer', () => {
|
||||
it('allows all kinds of operations', () => {
|
||||
const setState = jest.fn();
|
||||
const datasource = createMockDatasource();
|
||||
const layer = { layerId: 'a', columns: ['b', 'c'] };
|
||||
const frame = mockFrame();
|
||||
frame.datasourceLayers = { a: datasource.publicAPIMock };
|
||||
|
||||
mount(
|
||||
<DataTableLayer
|
||||
dragDropContext={{ dragging: undefined, setDragging: () => {} }}
|
||||
frame={frame}
|
||||
layer={layer}
|
||||
setState={setState}
|
||||
state={{ layers: [layer] }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(datasource.publicAPIMock.renderDimensionPanel).toHaveBeenCalled();
|
||||
|
||||
const filterOperations =
|
||||
datasource.publicAPIMock.renderDimensionPanel.mock.calls[0][1].filterOperations;
|
||||
|
||||
const baseOperation: Operation = {
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
label: '',
|
||||
};
|
||||
expect(filterOperations({ ...baseOperation })).toEqual(true);
|
||||
expect(filterOperations({ ...baseOperation, dataType: 'number' })).toEqual(true);
|
||||
expect(filterOperations({ ...baseOperation, dataType: 'date' })).toEqual(true);
|
||||
expect(filterOperations({ ...baseOperation, dataType: 'boolean' })).toEqual(true);
|
||||
expect(filterOperations({ ...baseOperation, dataType: 'other' as DataType })).toEqual(true);
|
||||
expect(filterOperations({ ...baseOperation, dataType: 'date', isBucketed: false })).toEqual(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('allows columns to be removed', () => {
|
||||
const setState = jest.fn();
|
||||
const datasource = createMockDatasource();
|
||||
const layer = { layerId: 'a', columns: ['b', 'c'] };
|
||||
const frame = mockFrame();
|
||||
frame.datasourceLayers = { a: datasource.publicAPIMock };
|
||||
const component = mount(
|
||||
<DataTableLayer
|
||||
dragDropContext={{ dragging: undefined, setDragging: () => {} }}
|
||||
frame={frame}
|
||||
layer={layer}
|
||||
setState={setState}
|
||||
state={{ layers: [layer] }}
|
||||
/>
|
||||
);
|
||||
|
||||
const onRemove = component
|
||||
.find('[data-test-subj="datatable_multicolumnEditor"]')
|
||||
.first()
|
||||
.prop('onRemove') as (k: string) => {};
|
||||
|
||||
onRemove('b');
|
||||
|
||||
expect(setState).toHaveBeenCalledWith({
|
||||
layers: [
|
||||
{
|
||||
layerId: 'a',
|
||||
columns: ['c'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows columns to be added', () => {
|
||||
(generateId as jest.Mock).mockReturnValueOnce('d');
|
||||
const setState = jest.fn();
|
||||
const datasource = createMockDatasource();
|
||||
const layer = { layerId: 'a', columns: ['b', 'c'] };
|
||||
const frame = mockFrame();
|
||||
frame.datasourceLayers = { a: datasource.publicAPIMock };
|
||||
const component = mount(
|
||||
<DataTableLayer
|
||||
dragDropContext={{ dragging: undefined, setDragging: () => {} }}
|
||||
frame={frame}
|
||||
layer={layer}
|
||||
setState={setState}
|
||||
state={{ layers: [layer] }}
|
||||
/>
|
||||
);
|
||||
|
||||
const onAdd = component
|
||||
.find('[data-test-subj="datatable_multicolumnEditor"]')
|
||||
.first()
|
||||
.prop('onAdd') as () => {};
|
||||
|
||||
onAdd();
|
||||
|
||||
expect(setState).toHaveBeenCalledWith({
|
||||
layers: [
|
||||
{
|
||||
layerId: 'a',
|
||||
columns: ['b', 'c', 'd'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,231 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { EuiForm, EuiFormRow, EuiPanel, EuiSpacer } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import { MultiColumnEditor } from '../multi_column_editor';
|
||||
import {
|
||||
SuggestionRequest,
|
||||
Visualization,
|
||||
VisualizationProps,
|
||||
VisualizationSuggestion,
|
||||
Operation,
|
||||
} from '../types';
|
||||
import { generateId } from '../id_generator';
|
||||
import { NativeRenderer } from '../native_renderer';
|
||||
|
||||
export interface LayerState {
|
||||
layerId: string;
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
export interface DatatableVisualizationState {
|
||||
layers: LayerState[];
|
||||
}
|
||||
|
||||
function newLayerState(layerId: string): LayerState {
|
||||
return {
|
||||
layerId,
|
||||
columns: [generateId()],
|
||||
};
|
||||
}
|
||||
|
||||
function updateColumns(
|
||||
state: DatatableVisualizationState,
|
||||
layer: LayerState,
|
||||
fn: (columns: string[]) => string[]
|
||||
) {
|
||||
const columns = fn(layer.columns);
|
||||
const updatedLayer = { ...layer, columns };
|
||||
const layers = state.layers.map(l => (l.layerId === layer.layerId ? updatedLayer : l));
|
||||
return { ...state, layers };
|
||||
}
|
||||
|
||||
const allOperations = () => true;
|
||||
|
||||
export function DataTableLayer({
|
||||
layer,
|
||||
frame,
|
||||
state,
|
||||
setState,
|
||||
dragDropContext,
|
||||
}: { layer: LayerState } & VisualizationProps<DatatableVisualizationState>) {
|
||||
const datasource = frame.datasourceLayers[layer.layerId];
|
||||
return (
|
||||
<EuiPanel className="lnsConfigPanel__panel" paddingSize="s">
|
||||
<NativeRenderer
|
||||
render={datasource.renderLayerPanel}
|
||||
nativeProps={{ layerId: layer.layerId }}
|
||||
/>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFormRow
|
||||
className="lnsConfigPanel__axis"
|
||||
label={i18n.translate('xpack.lens.datatable.columns', { defaultMessage: 'Columns' })}
|
||||
>
|
||||
<MultiColumnEditor
|
||||
accessors={layer.columns}
|
||||
datasource={datasource}
|
||||
dragDropContext={dragDropContext}
|
||||
filterOperations={allOperations}
|
||||
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"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export const datatableVisualization: Visualization<
|
||||
DatatableVisualizationState,
|
||||
DatatableVisualizationState
|
||||
> = {
|
||||
id: 'lnsDatatable',
|
||||
|
||||
visualizationTypes: [
|
||||
{
|
||||
id: 'lnsDatatable',
|
||||
icon: 'visTable',
|
||||
label: i18n.translate('xpack.lens.datatable.label', {
|
||||
defaultMessage: 'Datatable',
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
getDescription(state) {
|
||||
return {
|
||||
icon: 'visTable',
|
||||
label: i18n.translate('xpack.lens.datatable.label', {
|
||||
defaultMessage: 'Datatable',
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
switchVisualizationType: (_, state) => state,
|
||||
|
||||
initialize(frame, state) {
|
||||
const layerId = Object.keys(frame.datasourceLayers)[0] || frame.addNewLayer();
|
||||
return (
|
||||
state || {
|
||||
layers: [newLayerState(layerId)],
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
getPersistableState: state => state,
|
||||
|
||||
getSuggestions({
|
||||
table,
|
||||
state,
|
||||
}: SuggestionRequest<DatatableVisualizationState>): Array<
|
||||
VisualizationSuggestion<DatatableVisualizationState>
|
||||
> {
|
||||
if (state && table.changeType === 'unchanged') {
|
||||
return [];
|
||||
}
|
||||
const title =
|
||||
table.changeType === 'unchanged'
|
||||
? i18n.translate('xpack.lens.datatable.suggestionLabel', {
|
||||
defaultMessage: 'As table',
|
||||
})
|
||||
: i18n.translate('xpack.lens.datatable.visualizationOf', {
|
||||
defaultMessage: 'Table {operations}',
|
||||
values: {
|
||||
operations:
|
||||
table.label ||
|
||||
table.columns
|
||||
.map(col => col.operation.label)
|
||||
.join(
|
||||
i18n.translate('xpack.lens.datatable.conjunctionSign', {
|
||||
defaultMessage: ' & ',
|
||||
description:
|
||||
'A character that can be used for conjunction of multiple enumarated items. Make sure to include spaces around it if needed.',
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
title,
|
||||
// table with >= 10 columns will have a score of 0.6, fewer columns reduce score
|
||||
score: (Math.min(table.columns.length, 10) / 10) * 0.6,
|
||||
state: {
|
||||
layers: [
|
||||
{
|
||||
layerId: table.layerId,
|
||||
columns: table.columns.map(col => col.columnId),
|
||||
},
|
||||
],
|
||||
},
|
||||
previewIcon: 'visTable',
|
||||
// dont show suggestions for reduced versions or single-line tables
|
||||
hide: table.changeType === 'reduced' || !table.isMultiRow,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderConfigPanel: (domElement, props) =>
|
||||
render(
|
||||
<I18nProvider>
|
||||
<EuiForm className="lnsConfigPanel">
|
||||
{props.state.layers.map(layer => (
|
||||
<DataTableLayer key={layer.layerId} layer={layer} {...props} />
|
||||
))}
|
||||
</EuiForm>
|
||||
</I18nProvider>,
|
||||
domElement
|
||||
),
|
||||
|
||||
toExpression(state, frame) {
|
||||
const layer = state.layers[0];
|
||||
const datasource = frame.datasourceLayers[layer.layerId];
|
||||
const operations = layer.columns
|
||||
.map(columnId => ({ columnId, operation: datasource.getOperationForColumnId(columnId) }))
|
||||
.filter((o): o is { columnId: string; operation: Operation } => !!o.operation);
|
||||
|
||||
return {
|
||||
type: 'expression',
|
||||
chain: [
|
||||
{
|
||||
type: 'function',
|
||||
function: 'lens_datatable',
|
||||
arguments: {
|
||||
columns: [
|
||||
{
|
||||
type: 'expression',
|
||||
chain: [
|
||||
{
|
||||
type: 'function',
|
||||
function: 'lens_datatable_columns',
|
||||
arguments: {
|
||||
columnIds: operations.map(o => o.columnId),
|
||||
labels: operations.map(
|
||||
o =>
|
||||
o.operation.label ||
|
||||
i18n.translate('xpack.lens.datatable.na', {
|
||||
defaultMessage: 'N/A',
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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 <h1>{title}</h1>;
|
||||
});
|
||||
expect(mount(<TestComponent title="hoi" />).html()).toMatchInlineSnapshot(`"<h1>hoi</h1>"`);
|
||||
});
|
||||
|
||||
test('debounces changes', async () => {
|
||||
const TestComponent = debouncedComponent(({ title }: { title: string }) => {
|
||||
return <h1>{title}</h1>;
|
||||
}, 1);
|
||||
const component = mount(<TestComponent title="there" />);
|
||||
component.setProps({ title: 'yall' });
|
||||
expect(component.text()).toEqual('there');
|
||||
await new Promise(r => setTimeout(r, 1));
|
||||
expect(component.text()).toEqual('yall');
|
||||
});
|
||||
});
|
|
@ -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<TProps>(component: FunctionComponent<TProps>, delay = 256) {
|
||||
const MemoizedComponent = (memo(component) as unknown) as FunctionComponent<TProps>;
|
||||
|
||||
return (props: TProps) => {
|
||||
const [cachedProps, setCachedProps] = useState(props);
|
||||
const delayRender = useMemo(() => debounce(setCachedProps, delay), []);
|
||||
|
||||
delayRender(props);
|
||||
|
||||
return React.createElement(MemoizedComponent, cachedProps);
|
||||
};
|
||||
}
|
|
@ -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';
|
20
x-pack/legacy/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap
generated
Normal file
20
x-pack/legacy/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap
generated
Normal file
|
@ -0,0 +1,20 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`DragDrop droppable is reflected in the className 1`] = `
|
||||
<div
|
||||
class="lnsDragDrop lnsDragDrop-isDropTarget"
|
||||
data-test-subj="lnsDragDrop"
|
||||
>
|
||||
Hello!
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`DragDrop renders if nothing is being dragged 1`] = `
|
||||
<div
|
||||
class="lnsDragDrop"
|
||||
data-test-subj="lnsDragDrop"
|
||||
draggable="true"
|
||||
>
|
||||
Hello!
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,7 @@
|
|||
.lnsDragDrop-isDropTarget {
|
||||
background-color: transparentize($euiColorSecondary, .9);
|
||||
}
|
||||
|
||||
.lnsDragDrop-isActiveDropTarget {
|
||||
background-color: transparentize($euiColorSecondary, .75);
|
||||
}
|
104
x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx
Normal file
104
x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx
Normal file
|
@ -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(
|
||||
<DragDrop value="hello" draggable>
|
||||
Hello!
|
||||
</DragDrop>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('dragover calls preventDefault if droppable is true', () => {
|
||||
const preventDefault = jest.fn();
|
||||
const component = shallow(<DragDrop droppable>Hello!</DragDrop>);
|
||||
|
||||
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(<DragDrop>Hello!</DragDrop>);
|
||||
|
||||
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(
|
||||
<ChildDragDropProvider dragging={undefined} setDragging={setDragging}>
|
||||
<DragDrop value={value}>Hello!</DragDrop>
|
||||
</ChildDragDropProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<ChildDragDropProvider dragging="hola" setDragging={setDragging}>
|
||||
<DragDrop onDrop={onDrop} value={value}>
|
||||
Hello!
|
||||
</DragDrop>
|
||||
</ChildDragDropProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<DragDrop
|
||||
onDrop={(x: unknown) => {
|
||||
throw x;
|
||||
}}
|
||||
droppable
|
||||
>
|
||||
Hello!
|
||||
</DragDrop>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
143
x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx
Normal file
143
x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx
Normal file
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useState, useContext } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { DragContext } from './providers';
|
||||
|
||||
type DroppableEvent = React.DragEvent<HTMLElement>;
|
||||
|
||||
/**
|
||||
* A function that handles a drop event.
|
||||
*/
|
||||
export type DropHandler = (item: unknown) => void;
|
||||
|
||||
/**
|
||||
* The argument to the DragDrop component.
|
||||
*/
|
||||
interface Props {
|
||||
/**
|
||||
* The CSS class(es) for the root element.
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* The event handler that fires when an item
|
||||
* is dropped onto this DragDrop component.
|
||||
*/
|
||||
onDrop?: DropHandler;
|
||||
|
||||
/**
|
||||
* The value associated with this item, if it is draggable.
|
||||
* If this component is dragged, this will be the value of
|
||||
* "dragging" in the root drag/drop context.
|
||||
*/
|
||||
value?: unknown;
|
||||
|
||||
/**
|
||||
* The React children.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the currently dragged item
|
||||
* can be dropped onto this component.
|
||||
*/
|
||||
droppable?: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether or not this component is draggable.
|
||||
*/
|
||||
draggable?: boolean;
|
||||
|
||||
/**
|
||||
* The optional test subject associated with this DOM element.
|
||||
*/
|
||||
'data-test-subj'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A draggable / droppable item. Items can be both draggable and droppable at
|
||||
* the same time.
|
||||
*
|
||||
* @param props
|
||||
*/
|
||||
export function DragDrop(props: Props) {
|
||||
const { dragging, setDragging } = useContext(DragContext);
|
||||
const [state, setState] = useState({ isActive: false });
|
||||
const { className, onDrop, value, children, droppable, draggable } = props;
|
||||
const isDragging = draggable && value === dragging;
|
||||
|
||||
const classes = classNames('lnsDragDrop', className, {
|
||||
'lnsDragDrop-isDropTarget': droppable,
|
||||
'lnsDragDrop-isActiveDropTarget': droppable && state.isActive,
|
||||
'lnsDragDrop-isDragging': isDragging,
|
||||
});
|
||||
|
||||
const dragStart = (e: DroppableEvent) => {
|
||||
// Setting stopPropgagation causes Chrome failures, so
|
||||
// we are manually checking if we've already handled this
|
||||
// in a nested child, and doing nothing if so...
|
||||
if (e.dataTransfer.getData('text')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.setData('text', 'dragging');
|
||||
|
||||
// Chrome causes issues if you try to render from within a
|
||||
// dragStart event, so we drop a setTimeout to avoid that.
|
||||
setTimeout(() => setDragging(value));
|
||||
};
|
||||
|
||||
const dragEnd = (e: DroppableEvent) => {
|
||||
e.stopPropagation();
|
||||
setDragging(undefined);
|
||||
};
|
||||
|
||||
const dragOver = (e: DroppableEvent) => {
|
||||
if (!droppable) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// An optimization to prevent a bunch of React churn.
|
||||
if (!state.isActive) {
|
||||
setState({ ...state, isActive: true });
|
||||
}
|
||||
};
|
||||
|
||||
const dragLeave = () => {
|
||||
setState({ ...state, isActive: false });
|
||||
};
|
||||
|
||||
const drop = (e: DroppableEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setState({ ...state, isActive: false });
|
||||
setDragging(undefined);
|
||||
|
||||
if (onDrop) {
|
||||
onDrop(dragging);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test-subj={props['data-test-subj'] || 'lnsDragDrop'}
|
||||
className={classes}
|
||||
onDragOver={dragOver}
|
||||
onDragLeave={dragLeave}
|
||||
onDrop={drop}
|
||||
draggable={draggable}
|
||||
onDragEnd={dragEnd}
|
||||
onDragStart={dragStart}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
8
x-pack/legacy/plugins/lens/public/drag_drop/index.ts
Normal file
8
x-pack/legacy/plugins/lens/public/drag_drop/index.ts
Normal file
|
@ -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';
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { RootDragDropProvider, DragContext } from './providers';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('RootDragDropProvider', () => {
|
||||
test('reuses contexts for each render', () => {
|
||||
const contexts: Array<{}> = [];
|
||||
const TestComponent = ({ name }: { name: string }) => {
|
||||
const context = useContext(DragContext);
|
||||
contexts.push(context);
|
||||
return (
|
||||
<div data-test-subj="test-component">
|
||||
{name} {!!context.dragging}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RootComponent = ({ name }: { name: string }) => (
|
||||
<RootDragDropProvider>
|
||||
<TestComponent name={name} />
|
||||
</RootDragDropProvider>
|
||||
);
|
||||
|
||||
const component = mount(<RootComponent name="aaaa" />);
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
86
x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx
Normal file
86
x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* The shape of the drag / drop context.
|
||||
*/
|
||||
export interface DragContextState {
|
||||
/**
|
||||
* The item being dragged or undefined.
|
||||
*/
|
||||
dragging: unknown;
|
||||
|
||||
/**
|
||||
* Set the item being dragged.
|
||||
*/
|
||||
setDragging: (dragging: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The drag / drop context singleton, used like so:
|
||||
*
|
||||
* const { dragging, setDragging } = useContext(DragContext);
|
||||
*/
|
||||
export const DragContext = React.createContext<DragContextState>({
|
||||
dragging: undefined,
|
||||
setDragging: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* The argument to DragDropProvider.
|
||||
*/
|
||||
export interface ProviderProps {
|
||||
/**
|
||||
* The item being dragged. If unspecified, the provider will
|
||||
* behave as if it is the root provider.
|
||||
*/
|
||||
dragging: unknown;
|
||||
|
||||
/**
|
||||
* Sets the item being dragged. If unspecified, the provider
|
||||
* will behave as if it is the root provider.
|
||||
*/
|
||||
setDragging: (dragging: unknown) => void;
|
||||
|
||||
/**
|
||||
* The React children.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React provider that tracks the dragging state. This should
|
||||
* be placed at the root of any React application that supports
|
||||
* drag / drop.
|
||||
*
|
||||
* @param props
|
||||
*/
|
||||
export function RootDragDropProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<{ dragging: unknown }>({
|
||||
dragging: undefined,
|
||||
});
|
||||
const setDragging = useMemo(() => (dragging: unknown) => setState({ dragging }), [setState]);
|
||||
|
||||
return (
|
||||
<ChildDragDropProvider dragging={state.dragging} setDragging={setDragging}>
|
||||
{children}
|
||||
</ChildDragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A React drag / drop provider that derives its state from a RootDragDropProvider. If
|
||||
* part of a React application is rendered separately from the root, this provider can
|
||||
* be used to enable drag / drop functionality within the disconnected part.
|
||||
*
|
||||
* @param props
|
||||
*/
|
||||
export function ChildDragDropProvider({ dragging, setDragging, children }: ProviderProps) {
|
||||
const value = useMemo(() => ({ dragging, setDragging }), [setDragging, dragging]);
|
||||
return <DragContext.Provider value={value}>{children}</DragContext.Provider>;
|
||||
}
|
69
x-pack/legacy/plugins/lens/public/drag_drop/readme.md
Normal file
69
x-pack/legacy/plugins/lens/public/drag_drop/readme.md
Normal file
|
@ -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
|
||||
<RootDragDropProvider>
|
||||
... your app here ...
|
||||
</RootDragDropProvider>
|
||||
```
|
||||
|
||||
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
|
||||
<ChildDragDropProvider {...context}>
|
||||
... your child app here ...
|
||||
</ChildDragDropProvider>
|
||||
```
|
||||
|
||||
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
|
||||
<div className="field-list">
|
||||
{fields.map(f => (
|
||||
<DragDrop key={f.id} className="field-list-item" value={f} draggable>
|
||||
{f.name}
|
||||
</DragDrop>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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 (
|
||||
<DragDrop
|
||||
className="axis"
|
||||
droppable={dragging && canHandleDrop(dragging)}
|
||||
onDrop={item => onChange([...items, item])}
|
||||
>
|
||||
{items.map(x => <div>{x.name}</div>)}
|
||||
</DragDrop>
|
||||
);
|
||||
```
|
||||
|
||||
## 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.
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
const actual = jest.requireActual('../suggestion_helpers');
|
||||
|
||||
jest.spyOn(actual, 'getSuggestions');
|
||||
|
||||
export const { getSuggestions, switchToSuggestion } = actual;
|
|
@ -0,0 +1,529 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { createMockVisualization, createMockFramePublicAPI, createMockDatasource } from '../mocks';
|
||||
import { mountWithIntl as mount } from 'test_utils/enzyme_helpers';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { ChartSwitch } from './chart_switch';
|
||||
import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types';
|
||||
import { EuiKeyPadMenuItemButton } from '@elastic/eui';
|
||||
import { Action } from './state_management';
|
||||
|
||||
describe('chart_switch', () => {
|
||||
function generateVisualization(id: string): jest.Mocked<Visualization> {
|
||||
return {
|
||||
...createMockVisualization(),
|
||||
id,
|
||||
visualizationTypes: [
|
||||
{
|
||||
icon: 'empty',
|
||||
id: `sub${id}`,
|
||||
label: `Label ${id}`,
|
||||
},
|
||||
],
|
||||
initialize: jest.fn((_frame, state?: unknown) => {
|
||||
return state || `${id} initial state`;
|
||||
}),
|
||||
getSuggestions: jest.fn(options => {
|
||||
return [
|
||||
{
|
||||
score: 1,
|
||||
title: '',
|
||||
state: `suggestion ${id}`,
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockVisualizations() {
|
||||
return {
|
||||
visA: generateVisualization('visA'),
|
||||
visB: generateVisualization('visB'),
|
||||
visC: {
|
||||
...generateVisualization('visC'),
|
||||
visualizationTypes: [
|
||||
{
|
||||
icon: 'empty',
|
||||
id: 'subvisC1',
|
||||
label: 'C1',
|
||||
},
|
||||
{
|
||||
icon: 'empty',
|
||||
id: 'subvisC2',
|
||||
label: 'C2',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockFrame(layers: string[]) {
|
||||
return {
|
||||
...createMockFramePublicAPI(),
|
||||
datasourceLayers: layers.reduce(
|
||||
(acc, layerId) => ({
|
||||
...acc,
|
||||
[layerId]: ({
|
||||
getTableSpec: jest.fn(() => {
|
||||
return [{ columnId: 2 }];
|
||||
}),
|
||||
getOperationForColumnId() {
|
||||
return {};
|
||||
},
|
||||
} as unknown) as DatasourcePublicAPI,
|
||||
}),
|
||||
{} as Record<string, unknown>
|
||||
),
|
||||
} as FramePublicAPI;
|
||||
}
|
||||
|
||||
function mockDatasourceMap() {
|
||||
const datasource = createMockDatasource();
|
||||
datasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
|
||||
{
|
||||
state: {},
|
||||
table: {
|
||||
columns: [],
|
||||
isMultiRow: true,
|
||||
layerId: 'a',
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
},
|
||||
]);
|
||||
return {
|
||||
testDatasource: datasource,
|
||||
};
|
||||
}
|
||||
|
||||
function mockDatasourceStates() {
|
||||
return {
|
||||
testDatasource: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function showFlyout(component: ReactWrapper) {
|
||||
component
|
||||
.find('[data-test-subj="lnsChartSwitchPopover"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
}
|
||||
|
||||
function switchTo(subType: string, component: ReactWrapper) {
|
||||
showFlyout(component);
|
||||
component
|
||||
.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`)
|
||||
.first()
|
||||
.simulate('click');
|
||||
}
|
||||
|
||||
function getMenuItem(subType: string, component: ReactWrapper) {
|
||||
showFlyout(component);
|
||||
return component
|
||||
.find(EuiKeyPadMenuItemButton)
|
||||
.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`)
|
||||
.first();
|
||||
}
|
||||
|
||||
it('should use suggested state if there is a suggestion from the target visualization', () => {
|
||||
const dispatch = jest.fn();
|
||||
const visualizations = mockVisualizations();
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={visualizations}
|
||||
dispatch={dispatch}
|
||||
framePublicAPI={mockFrame(['a'])}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
switchTo('subvisB', component);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
initialState: 'suggestion visB',
|
||||
newVisualizationId: 'visB',
|
||||
type: 'SWITCH_VISUALIZATION',
|
||||
datasourceId: 'testDatasource',
|
||||
datasourceState: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should use initial state if there is no suggestion from the target visualization', () => {
|
||||
const dispatch = jest.fn();
|
||||
const visualizations = mockVisualizations();
|
||||
visualizations.visB.getSuggestions.mockReturnValueOnce([]);
|
||||
const frame = mockFrame(['a']);
|
||||
(frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]);
|
||||
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={visualizations}
|
||||
dispatch={dispatch}
|
||||
framePublicAPI={frame}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
switchTo('subvisB', component);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
initialState: 'visB initial state',
|
||||
newVisualizationId: 'visB',
|
||||
type: 'SWITCH_VISUALIZATION',
|
||||
});
|
||||
});
|
||||
|
||||
it('should indicate data loss if not all columns will be used', () => {
|
||||
const dispatch = jest.fn();
|
||||
const visualizations = mockVisualizations();
|
||||
const frame = mockFrame(['a']);
|
||||
|
||||
const datasourceMap = mockDatasourceMap();
|
||||
datasourceMap.testDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
|
||||
{
|
||||
state: {},
|
||||
table: {
|
||||
columns: [
|
||||
{
|
||||
columnId: 'col1',
|
||||
operation: {
|
||||
label: '',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'col2',
|
||||
operation: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
layerId: 'first',
|
||||
isMultiRow: true,
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
},
|
||||
]);
|
||||
datasourceMap.testDatasource.publicAPIMock.getTableSpec.mockReturnValue([
|
||||
{ columnId: 'col1' },
|
||||
{ columnId: 'col2' },
|
||||
{ columnId: 'col3' },
|
||||
]);
|
||||
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={visualizations}
|
||||
dispatch={dispatch}
|
||||
framePublicAPI={frame}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert');
|
||||
});
|
||||
|
||||
it('should indicate data loss if not all layers will be used', () => {
|
||||
const dispatch = jest.fn();
|
||||
const visualizations = mockVisualizations();
|
||||
const frame = mockFrame(['a', 'b']);
|
||||
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={visualizations}
|
||||
dispatch={dispatch}
|
||||
framePublicAPI={frame}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert');
|
||||
});
|
||||
|
||||
it('should indicate data loss if no data will be used', () => {
|
||||
const dispatch = jest.fn();
|
||||
const visualizations = mockVisualizations();
|
||||
visualizations.visB.getSuggestions.mockReturnValueOnce([]);
|
||||
const frame = mockFrame(['a']);
|
||||
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={visualizations}
|
||||
dispatch={dispatch}
|
||||
framePublicAPI={frame}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert');
|
||||
});
|
||||
|
||||
it('should not indicate data loss if there is no data', () => {
|
||||
const dispatch = jest.fn();
|
||||
const visualizations = mockVisualizations();
|
||||
visualizations.visB.getSuggestions.mockReturnValueOnce([]);
|
||||
const frame = mockFrame(['a']);
|
||||
(frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]);
|
||||
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={visualizations}
|
||||
dispatch={dispatch}
|
||||
framePublicAPI={frame}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not indicate data loss if visualization is not changed', () => {
|
||||
const dispatch = jest.fn();
|
||||
const removeLayers = jest.fn();
|
||||
const frame = {
|
||||
...mockFrame(['a', 'b', 'c']),
|
||||
removeLayers,
|
||||
};
|
||||
const visualizations = mockVisualizations();
|
||||
const switchVisualizationType = jest.fn(() => 'therebedragons');
|
||||
|
||||
visualizations.visC.switchVisualizationType = switchVisualizationType;
|
||||
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visC"
|
||||
visualizationState={'therebegriffins'}
|
||||
visualizationMap={visualizations}
|
||||
dispatch={dispatch}
|
||||
framePublicAPI={frame}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getMenuItem('subvisC2', component).prop('betaBadgeIconType')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove unused layers', () => {
|
||||
const removeLayers = jest.fn();
|
||||
const frame = {
|
||||
...mockFrame(['a', 'b', 'c']),
|
||||
removeLayers,
|
||||
};
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={mockVisualizations()}
|
||||
dispatch={jest.fn()}
|
||||
framePublicAPI={frame}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
switchTo('subvisB', component);
|
||||
|
||||
expect(removeLayers).toHaveBeenCalledTimes(1);
|
||||
expect(removeLayers).toHaveBeenCalledWith(['b', 'c']);
|
||||
});
|
||||
|
||||
it('should remove all layers if there is no suggestion', () => {
|
||||
const dispatch = jest.fn();
|
||||
const visualizations = mockVisualizations();
|
||||
visualizations.visB.getSuggestions.mockReturnValueOnce([]);
|
||||
const frame = mockFrame(['a', 'b', 'c']);
|
||||
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={visualizations}
|
||||
dispatch={dispatch}
|
||||
framePublicAPI={frame}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
switchTo('subvisB', component);
|
||||
|
||||
expect(frame.removeLayers).toHaveBeenCalledTimes(1);
|
||||
expect(frame.removeLayers).toHaveBeenCalledWith(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should not remove layers if the visualization is not changing', () => {
|
||||
const dispatch = jest.fn();
|
||||
const removeLayers = jest.fn();
|
||||
const frame = {
|
||||
...mockFrame(['a', 'b', 'c']),
|
||||
removeLayers,
|
||||
};
|
||||
const visualizations = mockVisualizations();
|
||||
const switchVisualizationType = jest.fn(() => 'therebedragons');
|
||||
|
||||
visualizations.visC.switchVisualizationType = switchVisualizationType;
|
||||
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visC"
|
||||
visualizationState={'therebegriffins'}
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
layerId: 'a',
|
||||
isMultiRow: true,
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={visualizations}
|
||||
dispatch={dispatch}
|
||||
framePublicAPI={frame}
|
||||
datasourceMap={datasourceMap}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
switchTo('subvisB', component);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SWITCH_VISUALIZATION',
|
||||
newVisualizationId: 'visB',
|
||||
datasourceId: 'testDatasource',
|
||||
datasourceState: 'testDatasource suggestion',
|
||||
initialState: 'suggestion visB',
|
||||
} as Action);
|
||||
});
|
||||
|
||||
it('should ensure the new visualization has the proper subtype', () => {
|
||||
const dispatch = jest.fn();
|
||||
const visualizations = mockVisualizations();
|
||||
const switchVisualizationType = jest.fn(
|
||||
(visualizationType, state) => `${state} ${visualizationType}`
|
||||
);
|
||||
|
||||
visualizations.visB.switchVisualizationType = switchVisualizationType;
|
||||
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={visualizations}
|
||||
dispatch={dispatch}
|
||||
framePublicAPI={mockFrame(['a'])}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
switchTo('subvisB', component);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
initialState: 'suggestion visB subvisB',
|
||||
newVisualizationId: 'visB',
|
||||
type: 'SWITCH_VISUALIZATION',
|
||||
datasourceId: 'testDatasource',
|
||||
datasourceState: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show all visualization types', () => {
|
||||
const component = mount(
|
||||
<ChartSwitch
|
||||
visualizationId="visA"
|
||||
visualizationState={{}}
|
||||
visualizationMap={mockVisualizations()}
|
||||
dispatch={jest.fn()}
|
||||
framePublicAPI={mockFrame(['a', 'b'])}
|
||||
datasourceMap={mockDatasourceMap()}
|
||||
datasourceStates={mockDatasourceStates()}
|
||||
/>
|
||||
);
|
||||
|
||||
showFlyout(component);
|
||||
|
||||
const allDisplayed = ['subvisA', 'subvisB', 'subvisC1', 'subvisC2'].every(
|
||||
subType => component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).length > 0
|
||||
);
|
||||
|
||||
expect(allDisplayed).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,265 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
EuiIcon,
|
||||
EuiPopover,
|
||||
EuiPopoverTitle,
|
||||
EuiKeyPadMenu,
|
||||
EuiKeyPadMenuItemButton,
|
||||
EuiButtonEmpty,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { flatten } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Visualization, FramePublicAPI, Datasource } from '../../types';
|
||||
import { Action } from './state_management';
|
||||
import { getSuggestions, switchToSuggestion, Suggestion } from './suggestion_helpers';
|
||||
|
||||
interface VisualizationSelection {
|
||||
visualizationId: string;
|
||||
subVisualizationId: string;
|
||||
getVisualizationState: () => unknown;
|
||||
keptLayerIds: string[];
|
||||
dataLoss: 'nothing' | 'layers' | 'everything' | 'columns';
|
||||
datasourceId?: string;
|
||||
datasourceState?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dispatch: (action: Action) => void;
|
||||
visualizationMap: Record<string, Visualization>;
|
||||
visualizationId: string | null;
|
||||
visualizationState: unknown;
|
||||
framePublicAPI: FramePublicAPI;
|
||||
datasourceMap: Record<string, Datasource>;
|
||||
datasourceStates: Record<
|
||||
string,
|
||||
{
|
||||
isLoading: boolean;
|
||||
state: unknown;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
function VisualizationSummary(props: Props) {
|
||||
const visualization = props.visualizationMap[props.visualizationId || ''];
|
||||
|
||||
if (!visualization) {
|
||||
return (
|
||||
<>
|
||||
{i18n.translate('xpack.lens.configPanel.chooseVisualization', {
|
||||
defaultMessage: 'Choose a visualization',
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const description = visualization.getDescription(props.visualizationState);
|
||||
|
||||
return (
|
||||
<>
|
||||
{description.icon && (
|
||||
<EuiIcon className="lnsChartSwitch__summaryIcon" type={description.icon} />
|
||||
)}
|
||||
{description.label}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartSwitch(props: Props) {
|
||||
const [flyoutOpen, setFlyoutOpen] = useState<boolean>(false);
|
||||
|
||||
const commitSelection = (selection: VisualizationSelection) => {
|
||||
setFlyoutOpen(false);
|
||||
|
||||
switchToSuggestion(props.framePublicAPI, props.dispatch, {
|
||||
...selection,
|
||||
visualizationState: selection.getVisualizationState(),
|
||||
});
|
||||
};
|
||||
|
||||
function getSelection(
|
||||
visualizationId: string,
|
||||
subVisualizationId: string
|
||||
): VisualizationSelection {
|
||||
const newVisualization = props.visualizationMap[visualizationId];
|
||||
const switchVisType =
|
||||
props.visualizationMap[visualizationId].switchVisualizationType ||
|
||||
((_type: string, initialState: unknown) => initialState);
|
||||
if (props.visualizationId === visualizationId) {
|
||||
return {
|
||||
visualizationId,
|
||||
subVisualizationId,
|
||||
dataLoss: 'nothing',
|
||||
keptLayerIds: Object.keys(props.framePublicAPI.datasourceLayers),
|
||||
getVisualizationState: () => switchVisType(subVisualizationId, props.visualizationState),
|
||||
};
|
||||
}
|
||||
|
||||
const layers = Object.entries(props.framePublicAPI.datasourceLayers);
|
||||
const containsData = layers.some(
|
||||
([_layerId, datasource]) => datasource.getTableSpec().length > 0
|
||||
);
|
||||
|
||||
const topSuggestion = getTopSuggestion(props, visualizationId, newVisualization);
|
||||
|
||||
let dataLoss: VisualizationSelection['dataLoss'];
|
||||
|
||||
if (!containsData) {
|
||||
dataLoss = 'nothing';
|
||||
} else if (!topSuggestion) {
|
||||
dataLoss = 'everything';
|
||||
} else if (layers.length > 1) {
|
||||
dataLoss = 'layers';
|
||||
} else if (topSuggestion.columns !== layers[0][1].getTableSpec().length) {
|
||||
dataLoss = 'columns';
|
||||
} else {
|
||||
dataLoss = 'nothing';
|
||||
}
|
||||
|
||||
return {
|
||||
visualizationId,
|
||||
subVisualizationId,
|
||||
dataLoss,
|
||||
getVisualizationState: topSuggestion
|
||||
? () =>
|
||||
switchVisType(
|
||||
subVisualizationId,
|
||||
newVisualization.initialize(props.framePublicAPI, topSuggestion.visualizationState)
|
||||
)
|
||||
: () => {
|
||||
return switchVisType(
|
||||
subVisualizationId,
|
||||
newVisualization.initialize(props.framePublicAPI)
|
||||
);
|
||||
},
|
||||
keptLayerIds: topSuggestion ? topSuggestion.keptLayerIds : [],
|
||||
datasourceState: topSuggestion ? topSuggestion.datasourceState : undefined,
|
||||
datasourceId: topSuggestion ? topSuggestion.datasourceId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const visualizationTypes = useMemo(
|
||||
() =>
|
||||
flyoutOpen &&
|
||||
flatten(
|
||||
Object.values(props.visualizationMap).map(v =>
|
||||
v.visualizationTypes.map(t => ({
|
||||
visualizationId: v.id,
|
||||
...t,
|
||||
}))
|
||||
)
|
||||
).map(visualizationType => ({
|
||||
...visualizationType,
|
||||
selection: getSelection(visualizationType.visualizationId, visualizationType.id),
|
||||
})),
|
||||
[
|
||||
flyoutOpen,
|
||||
props.visualizationMap,
|
||||
props.framePublicAPI,
|
||||
props.visualizationId,
|
||||
props.visualizationState,
|
||||
]
|
||||
);
|
||||
|
||||
const popover = (
|
||||
<EuiPopover
|
||||
id="lnsChartSwitchPopover"
|
||||
ownFocus
|
||||
initialFocus=".lnsChartSwitchPopoverPanel"
|
||||
panelClassName="lnsChartSwitchPopoverPanel"
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
onClick={() => setFlyoutOpen(!flyoutOpen)}
|
||||
data-test-subj="lnsChartSwitchPopover"
|
||||
>
|
||||
(
|
||||
{i18n.translate('xpack.lens.configPanel.changeVisualization', {
|
||||
defaultMessage: 'change',
|
||||
})}
|
||||
)
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
isOpen={flyoutOpen}
|
||||
closePopover={() => setFlyoutOpen(false)}
|
||||
anchorPosition="leftUp"
|
||||
>
|
||||
<EuiPopoverTitle>
|
||||
{i18n.translate('xpack.lens.configPanel.chooseVisualization', {
|
||||
defaultMessage: 'Choose a visualization',
|
||||
})}
|
||||
</EuiPopoverTitle>
|
||||
<EuiKeyPadMenu>
|
||||
{(visualizationTypes || []).map(v => (
|
||||
<EuiKeyPadMenuItemButton
|
||||
key={`${v.visualizationId}:${v.id}`}
|
||||
label={<span data-test-subj="visTypeTitle">{v.label}</span>}
|
||||
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}
|
||||
>
|
||||
<EuiIcon type={v.icon || 'empty'} />
|
||||
</EuiKeyPadMenuItemButton>
|
||||
))}
|
||||
</EuiKeyPadMenu>
|
||||
</EuiPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="lnsSidebar__header">
|
||||
<EuiTitle size="xs">
|
||||
<h3>
|
||||
<VisualizationSummary {...props} /> {popover}
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTopSuggestion(
|
||||
props: Props,
|
||||
visualizationId: string,
|
||||
newVisualization: Visualization<unknown, unknown>
|
||||
): Suggestion | undefined {
|
||||
const suggestions = getSuggestions({
|
||||
datasourceMap: props.datasourceMap,
|
||||
datasourceStates: props.datasourceStates,
|
||||
visualizationMap: { [visualizationId]: newVisualization },
|
||||
activeVisualizationId: props.visualizationId,
|
||||
visualizationState: props.visualizationState,
|
||||
}).filter(suggestion => {
|
||||
// don't use extended versions of current data table on switching between visualizations
|
||||
// to avoid confusing the user.
|
||||
return suggestion.changeType !== 'extended';
|
||||
});
|
||||
|
||||
// We prefer unchanged or reduced suggestions when switching
|
||||
// charts since that allows you to switch from A to B and back
|
||||
// to A with the greatest chance of preserving your original state.
|
||||
return (
|
||||
suggestions.find(s => s.changeType === 'unchanged') ||
|
||||
suggestions.find(s => s.changeType === 'reduced') ||
|
||||
suggestions[0]
|
||||
);
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useContext, memo } from 'react';
|
||||
import { NativeRenderer } from '../../native_renderer';
|
||||
import { Action } from './state_management';
|
||||
import { Visualization, FramePublicAPI, Datasource } from '../../types';
|
||||
import { DragContext } from '../../drag_drop';
|
||||
import { ChartSwitch } from './chart_switch';
|
||||
|
||||
interface ConfigPanelWrapperProps {
|
||||
visualizationState: unknown;
|
||||
visualizationMap: Record<string, Visualization>;
|
||||
activeVisualizationId: string | null;
|
||||
dispatch: (action: Action) => void;
|
||||
framePublicAPI: FramePublicAPI;
|
||||
datasourceMap: Record<string, Datasource>;
|
||||
datasourceStates: Record<
|
||||
string,
|
||||
{
|
||||
isLoading: boolean;
|
||||
state: unknown;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) {
|
||||
const context = useContext(DragContext);
|
||||
const setVisualizationState = useMemo(
|
||||
() => (newState: unknown) => {
|
||||
props.dispatch({
|
||||
type: 'UPDATE_VISUALIZATION_STATE',
|
||||
newState,
|
||||
});
|
||||
},
|
||||
[props.dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChartSwitch
|
||||
data-test-subj="lnsChartSwitcher"
|
||||
visualizationMap={props.visualizationMap}
|
||||
visualizationId={props.activeVisualizationId}
|
||||
visualizationState={props.visualizationState}
|
||||
datasourceMap={props.datasourceMap}
|
||||
datasourceStates={props.datasourceStates}
|
||||
dispatch={props.dispatch}
|
||||
framePublicAPI={props.framePublicAPI}
|
||||
/>
|
||||
{props.activeVisualizationId && props.visualizationState !== null && (
|
||||
<div className="lnsConfigPanelWrapper">
|
||||
<NativeRenderer
|
||||
render={props.visualizationMap[props.activeVisualizationId].renderConfigPanel}
|
||||
nativeProps={{
|
||||
dragDropContext: context,
|
||||
state: props.visualizationState,
|
||||
setState: setVisualizationState,
|
||||
frame: props.framePublicAPI,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useMemo, memo, useContext, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
|
||||
import { Query } from 'src/plugins/data/common';
|
||||
import { DatasourceDataPanelProps, Datasource } from '../../../public';
|
||||
import { NativeRenderer } from '../../native_renderer';
|
||||
import { Action } from './state_management';
|
||||
import { DragContext } from '../../drag_drop';
|
||||
import { StateSetter, FramePublicAPI } from '../../types';
|
||||
|
||||
interface DataPanelWrapperProps {
|
||||
datasourceState: unknown;
|
||||
datasourceMap: Record<string, Datasource>;
|
||||
activeDatasource: string | null;
|
||||
datasourceIsLoading: boolean;
|
||||
dispatch: (action: Action) => void;
|
||||
core: DatasourceDataPanelProps['core'];
|
||||
query: Query;
|
||||
dateRange: FramePublicAPI['dateRange'];
|
||||
}
|
||||
|
||||
export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
|
||||
const setDatasourceState: StateSetter<unknown> = useMemo(
|
||||
() => updater => {
|
||||
props.dispatch({
|
||||
type: 'UPDATE_DATASOURCE_STATE',
|
||||
updater,
|
||||
datasourceId: props.activeDatasource!,
|
||||
});
|
||||
},
|
||||
[props.dispatch, props.activeDatasource]
|
||||
);
|
||||
|
||||
const datasourceProps: DatasourceDataPanelProps = {
|
||||
dragDropContext: useContext(DragContext),
|
||||
state: props.datasourceState,
|
||||
setState: setDatasourceState,
|
||||
core: props.core,
|
||||
query: props.query,
|
||||
dateRange: props.dateRange,
|
||||
};
|
||||
|
||||
const [showDatasourceSwitcher, setDatasourceSwitcher] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.keys(props.datasourceMap).length > 1 && (
|
||||
<EuiPopover
|
||||
id="datasource-switch"
|
||||
className="lnsDatasourceSwitch"
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate('xpack.lens.dataPanelWrapper.switchDatasource', {
|
||||
defaultMessage: 'Switch to datasource',
|
||||
})}
|
||||
title={i18n.translate('xpack.lens.dataPanelWrapper.switchDatasource', {
|
||||
defaultMessage: 'Switch to datasource',
|
||||
})}
|
||||
data-test-subj="datasource-switch"
|
||||
onClick={() => setDatasourceSwitcher(true)}
|
||||
iconType="gear"
|
||||
/>
|
||||
}
|
||||
isOpen={showDatasourceSwitcher}
|
||||
closePopover={() => setDatasourceSwitcher(false)}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="rightUp"
|
||||
>
|
||||
<EuiContextMenuPanel
|
||||
title={i18n.translate('xpack.lens.dataPanelWrapper.switchDatasource', {
|
||||
defaultMessage: 'Switch to datasource',
|
||||
})}
|
||||
items={Object.keys(props.datasourceMap).map(datasourceId => (
|
||||
<EuiContextMenuItem
|
||||
key={datasourceId}
|
||||
data-test-subj={`datasource-switch-${datasourceId}`}
|
||||
icon={props.activeDatasource === datasourceId ? 'check' : 'empty'}
|
||||
onClick={() => {
|
||||
setDatasourceSwitcher(false);
|
||||
props.dispatch({
|
||||
type: 'SWITCH_DATASOURCE',
|
||||
newDatasourceId: datasourceId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{datasourceId}
|
||||
</EuiContextMenuItem>
|
||||
))}
|
||||
/>
|
||||
</EuiPopover>
|
||||
)}
|
||||
{props.activeDatasource && !props.datasourceIsLoading && (
|
||||
<NativeRenderer
|
||||
className="lnsSidebarContainer"
|
||||
render={props.datasourceMap[props.activeDatasource].renderDataPanel}
|
||||
nativeProps={datasourceProps}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,273 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
import { CoreSetup, CoreStart } from 'src/core/public';
|
||||
import { Query } from '../../../../../../../src/legacy/core_plugins/data/public';
|
||||
import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public';
|
||||
import { Datasource, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../../types';
|
||||
import { reducer, getInitialState } from './state_management';
|
||||
import { DataPanelWrapper } from './data_panel_wrapper';
|
||||
import { ConfigPanelWrapper } from './config_panel_wrapper';
|
||||
import { FrameLayout } from './frame_layout';
|
||||
import { SuggestionPanel } from './suggestion_panel';
|
||||
import { WorkspacePanel } from './workspace_panel';
|
||||
import { Document } from '../../persistence/saved_object_store';
|
||||
import { getSavedObjectFormat } from './save';
|
||||
import { WorkspacePanelWrapper } from './workspace_panel_wrapper';
|
||||
import { generateId } from '../../id_generator';
|
||||
|
||||
export interface EditorFrameProps {
|
||||
doc?: Document;
|
||||
datasourceMap: Record<string, Datasource>;
|
||||
visualizationMap: Record<string, Visualization>;
|
||||
initialDatasourceId: string | null;
|
||||
initialVisualizationId: string | null;
|
||||
ExpressionRenderer: ExpressionRenderer;
|
||||
onError: (e: { message: string }) => void;
|
||||
core: CoreSetup | CoreStart;
|
||||
dateRange: {
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
};
|
||||
query: Query;
|
||||
onChange: (arg: { indexPatternTitles: string[]; doc: Document }) => void;
|
||||
}
|
||||
|
||||
export function EditorFrame(props: EditorFrameProps) {
|
||||
const [state, dispatch] = useReducer(reducer, props, getInitialState);
|
||||
const { onError } = props;
|
||||
|
||||
const allLoaded = Object.values(state.datasourceStates).every(
|
||||
({ isLoading }) => typeof isLoading === 'boolean' && !isLoading
|
||||
);
|
||||
|
||||
// Initialize current datasource and all active datasources
|
||||
useEffect(() => {
|
||||
if (!allLoaded) {
|
||||
Object.entries(props.datasourceMap).forEach(([datasourceId, datasource]) => {
|
||||
if (
|
||||
state.datasourceStates[datasourceId] &&
|
||||
state.datasourceStates[datasourceId].isLoading
|
||||
) {
|
||||
datasource
|
||||
.initialize(state.datasourceStates[datasourceId].state || undefined)
|
||||
.then(datasourceState => {
|
||||
dispatch({
|
||||
type: 'UPDATE_DATASOURCE_STATE',
|
||||
updater: datasourceState,
|
||||
datasourceId,
|
||||
});
|
||||
})
|
||||
.catch(onError);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [allLoaded]);
|
||||
|
||||
const datasourceLayers: Record<string, DatasourcePublicAPI> = {};
|
||||
Object.keys(props.datasourceMap)
|
||||
.filter(id => state.datasourceStates[id] && !state.datasourceStates[id].isLoading)
|
||||
.forEach(id => {
|
||||
const datasourceState = state.datasourceStates[id].state;
|
||||
const datasource = props.datasourceMap[id];
|
||||
|
||||
const layers = datasource.getLayers(datasourceState);
|
||||
layers.forEach(layer => {
|
||||
const publicAPI = props.datasourceMap[id].getPublicAPI(
|
||||
datasourceState,
|
||||
(newState: unknown) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_DATASOURCE_STATE',
|
||||
datasourceId: id,
|
||||
updater: newState,
|
||||
});
|
||||
},
|
||||
layer
|
||||
);
|
||||
|
||||
datasourceLayers[layer] = publicAPI;
|
||||
});
|
||||
});
|
||||
|
||||
const framePublicAPI: FramePublicAPI = {
|
||||
datasourceLayers,
|
||||
dateRange: props.dateRange,
|
||||
query: props.query,
|
||||
|
||||
addNewLayer() {
|
||||
const newLayerId = generateId();
|
||||
|
||||
dispatch({
|
||||
type: 'UPDATE_LAYER',
|
||||
datasourceId: state.activeDatasourceId!,
|
||||
layerId: newLayerId,
|
||||
updater: props.datasourceMap[state.activeDatasourceId!].insertLayer,
|
||||
});
|
||||
|
||||
return newLayerId;
|
||||
},
|
||||
removeLayers: (layerIds: string[]) => {
|
||||
layerIds.forEach(layerId => {
|
||||
const layerDatasourceId = Object.entries(props.datasourceMap).find(
|
||||
([datasourceId, datasource]) =>
|
||||
state.datasourceStates[datasourceId] &&
|
||||
datasource.getLayers(state.datasourceStates[datasourceId].state).includes(layerId)
|
||||
)![0];
|
||||
dispatch({
|
||||
type: 'UPDATE_LAYER',
|
||||
layerId,
|
||||
datasourceId: layerDatasourceId,
|
||||
updater: props.datasourceMap[layerDatasourceId].removeLayer,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.doc) {
|
||||
dispatch({
|
||||
type: 'VISUALIZATION_LOADED',
|
||||
doc: props.doc,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'RESET',
|
||||
state: getInitialState(props),
|
||||
});
|
||||
}
|
||||
}, [props.doc]);
|
||||
|
||||
// Initialize visualization as soon as all datasources are ready
|
||||
useEffect(() => {
|
||||
if (allLoaded && state.visualization.state === null && state.visualization.activeId !== null) {
|
||||
const initialVisualizationState = props.visualizationMap[
|
||||
state.visualization.activeId
|
||||
].initialize(framePublicAPI);
|
||||
dispatch({
|
||||
type: 'UPDATE_VISUALIZATION_STATE',
|
||||
newState: initialVisualizationState,
|
||||
});
|
||||
}
|
||||
}, [allLoaded, state.visualization.activeId, state.visualization.state]);
|
||||
|
||||
// The frame needs to call onChange every time its internal state changes
|
||||
useEffect(() => {
|
||||
const activeDatasource =
|
||||
state.activeDatasourceId && !state.datasourceStates[state.activeDatasourceId].isLoading
|
||||
? props.datasourceMap[state.activeDatasourceId]
|
||||
: undefined;
|
||||
|
||||
const visualization = state.visualization.activeId
|
||||
? props.visualizationMap[state.visualization.activeId]
|
||||
: undefined;
|
||||
|
||||
if (!activeDatasource || !visualization) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexPatternTitles: string[] = [];
|
||||
Object.entries(props.datasourceMap)
|
||||
.filter(([id, datasource]) => {
|
||||
const stateWrapper = state.datasourceStates[id];
|
||||
return (
|
||||
stateWrapper &&
|
||||
!stateWrapper.isLoading &&
|
||||
datasource.getLayers(stateWrapper.state).length > 0
|
||||
);
|
||||
})
|
||||
.forEach(([id, datasource]) => {
|
||||
indexPatternTitles.push(
|
||||
...datasource
|
||||
.getMetaData(state.datasourceStates[id].state)
|
||||
.filterableIndexPatterns.map(pattern => pattern.title)
|
||||
);
|
||||
});
|
||||
|
||||
const doc = getSavedObjectFormat({
|
||||
activeDatasources: Object.keys(state.datasourceStates).reduce(
|
||||
(datasourceMap, datasourceId) => ({
|
||||
...datasourceMap,
|
||||
[datasourceId]: props.datasourceMap[datasourceId],
|
||||
}),
|
||||
{}
|
||||
),
|
||||
visualization,
|
||||
state,
|
||||
framePublicAPI,
|
||||
});
|
||||
|
||||
props.onChange({ indexPatternTitles, doc });
|
||||
}, [state.datasourceStates, state.visualization, props.query, props.dateRange, state.title]);
|
||||
|
||||
return (
|
||||
<FrameLayout
|
||||
dataPanel={
|
||||
<DataPanelWrapper
|
||||
datasourceMap={props.datasourceMap}
|
||||
activeDatasource={state.activeDatasourceId}
|
||||
datasourceState={
|
||||
state.activeDatasourceId ? state.datasourceStates[state.activeDatasourceId].state : null
|
||||
}
|
||||
datasourceIsLoading={
|
||||
state.activeDatasourceId
|
||||
? state.datasourceStates[state.activeDatasourceId].isLoading
|
||||
: true
|
||||
}
|
||||
dispatch={dispatch}
|
||||
core={props.core}
|
||||
query={props.query}
|
||||
dateRange={props.dateRange}
|
||||
/>
|
||||
}
|
||||
configPanel={
|
||||
allLoaded && (
|
||||
<ConfigPanelWrapper
|
||||
datasourceMap={props.datasourceMap}
|
||||
datasourceStates={state.datasourceStates}
|
||||
visualizationMap={props.visualizationMap}
|
||||
activeVisualizationId={state.visualization.activeId}
|
||||
dispatch={dispatch}
|
||||
visualizationState={state.visualization.state}
|
||||
framePublicAPI={framePublicAPI}
|
||||
/>
|
||||
)
|
||||
}
|
||||
workspacePanel={
|
||||
allLoaded && (
|
||||
<WorkspacePanelWrapper title={state.title} dispatch={dispatch}>
|
||||
<WorkspacePanel
|
||||
activeDatasourceId={state.activeDatasourceId}
|
||||
activeVisualizationId={state.visualization.activeId}
|
||||
datasourceMap={props.datasourceMap}
|
||||
datasourceStates={state.datasourceStates}
|
||||
framePublicAPI={framePublicAPI}
|
||||
visualizationState={state.visualization.state}
|
||||
visualizationMap={props.visualizationMap}
|
||||
dispatch={dispatch}
|
||||
ExpressionRenderer={props.ExpressionRenderer}
|
||||
/>
|
||||
</WorkspacePanelWrapper>
|
||||
)
|
||||
}
|
||||
suggestionsPanel={
|
||||
allLoaded && (
|
||||
<SuggestionPanel
|
||||
frame={framePublicAPI}
|
||||
activeDatasourceId={state.activeDatasourceId}
|
||||
activeVisualizationId={state.visualization.activeId}
|
||||
datasourceMap={props.datasourceMap}
|
||||
datasourceStates={state.datasourceStates}
|
||||
visualizationState={state.visualization.state}
|
||||
visualizationMap={props.visualizationMap}
|
||||
dispatch={dispatch}
|
||||
ExpressionRenderer={props.ExpressionRenderer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { TimeRange } from 'src/plugins/data/public';
|
||||
import { Query } from 'src/legacy/core_plugins/data/public';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { Ast, fromExpression, ExpressionFunctionAST } from '@kbn/interpreter/common';
|
||||
import { Visualization, Datasource, FramePublicAPI } from '../../types';
|
||||
|
||||
export function prependDatasourceExpression(
|
||||
visualizationExpression: Ast | string | null,
|
||||
datasourceMap: Record<string, Datasource>,
|
||||
datasourceStates: Record<
|
||||
string,
|
||||
{
|
||||
isLoading: boolean;
|
||||
state: unknown;
|
||||
}
|
||||
>
|
||||
): Ast | null {
|
||||
const datasourceExpressions: Array<[string, Ast | string]> = [];
|
||||
|
||||
Object.entries(datasourceMap).forEach(([datasourceId, datasource]) => {
|
||||
const state = datasourceStates[datasourceId].state;
|
||||
const layers = datasource.getLayers(datasourceStates[datasourceId].state);
|
||||
|
||||
layers.forEach(layerId => {
|
||||
const result = datasource.toExpression(state, layerId);
|
||||
if (result) {
|
||||
datasourceExpressions.push([layerId, result]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (datasourceExpressions.length === 0 || visualizationExpression === null) {
|
||||
return null;
|
||||
}
|
||||
const parsedDatasourceExpressions: Array<[string, Ast]> = datasourceExpressions.map(
|
||||
([layerId, expr]) => [layerId, typeof expr === 'string' ? fromExpression(expr) : expr]
|
||||
);
|
||||
|
||||
const datafetchExpression: ExpressionFunctionAST = {
|
||||
type: 'function',
|
||||
function: 'lens_merge_tables',
|
||||
arguments: {
|
||||
layerIds: parsedDatasourceExpressions.map(([id]) => id),
|
||||
tables: parsedDatasourceExpressions.map(([id, expr]) => expr),
|
||||
},
|
||||
};
|
||||
|
||||
const parsedVisualizationExpression =
|
||||
typeof visualizationExpression === 'string'
|
||||
? fromExpression(visualizationExpression)
|
||||
: visualizationExpression;
|
||||
|
||||
return {
|
||||
type: 'expression',
|
||||
chain: [datafetchExpression, ...parsedVisualizationExpression.chain],
|
||||
};
|
||||
}
|
||||
|
||||
export function prependKibanaContext(
|
||||
expression: Ast | string,
|
||||
{
|
||||
timeRange,
|
||||
query,
|
||||
filters,
|
||||
}: {
|
||||
timeRange?: TimeRange;
|
||||
query?: Query;
|
||||
filters?: Filter[];
|
||||
}
|
||||
): Ast {
|
||||
const parsedExpression = typeof expression === 'string' ? fromExpression(expression) : expression;
|
||||
|
||||
return {
|
||||
type: 'expression',
|
||||
chain: [
|
||||
{ type: 'function', function: 'kibana', arguments: {} },
|
||||
{
|
||||
type: 'function',
|
||||
function: 'kibana_context',
|
||||
arguments: {
|
||||
timeRange: timeRange ? [JSON.stringify(timeRange)] : [],
|
||||
query: query ? [JSON.stringify(query)] : [],
|
||||
filters: filters ? [JSON.stringify(filters)] : [],
|
||||
},
|
||||
},
|
||||
...parsedExpression.chain,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExpression({
|
||||
visualization,
|
||||
visualizationState,
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
framePublicAPI,
|
||||
removeDateRange,
|
||||
}: {
|
||||
visualization: Visualization | null;
|
||||
visualizationState: unknown;
|
||||
datasourceMap: Record<string, Datasource>;
|
||||
datasourceStates: Record<
|
||||
string,
|
||||
{
|
||||
isLoading: boolean;
|
||||
state: unknown;
|
||||
}
|
||||
>;
|
||||
framePublicAPI: FramePublicAPI;
|
||||
removeDateRange?: boolean;
|
||||
}): Ast | null {
|
||||
if (visualization === null) {
|
||||
return null;
|
||||
}
|
||||
const visualizationExpression = visualization.toExpression(visualizationState, framePublicAPI);
|
||||
|
||||
const expressionContext = removeDateRange
|
||||
? { query: framePublicAPI.query }
|
||||
: {
|
||||
query: framePublicAPI.query,
|
||||
timeRange: {
|
||||
from: framePublicAPI.dateRange.fromDate,
|
||||
to: framePublicAPI.dateRange.toDate,
|
||||
},
|
||||
};
|
||||
|
||||
const completeExpression = prependDatasourceExpression(
|
||||
visualizationExpression,
|
||||
datasourceMap,
|
||||
datasourceStates
|
||||
);
|
||||
|
||||
if (completeExpression) {
|
||||
return prependKibanaContext(completeExpression, expressionContext);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiPage, EuiPageSideBar, EuiPageBody } from '@elastic/eui';
|
||||
import { RootDragDropProvider } from '../../drag_drop';
|
||||
|
||||
export interface FrameLayoutProps {
|
||||
dataPanel: React.ReactNode;
|
||||
configPanel?: React.ReactNode;
|
||||
suggestionsPanel?: React.ReactNode;
|
||||
workspacePanel?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FrameLayout(props: FrameLayoutProps) {
|
||||
return (
|
||||
<RootDragDropProvider>
|
||||
<EuiPage className="lnsPage">
|
||||
<div className="lnsPageMainContent">
|
||||
<EuiPageSideBar className="lnsSidebar">{props.dataPanel}</EuiPageSideBar>
|
||||
<EuiPageBody className="lnsPageBody" restrictWidth={false}>
|
||||
{props.workspacePanel}
|
||||
{props.suggestionsPanel}
|
||||
</EuiPageBody>
|
||||
<EuiPageSideBar className="lnsSidebar lnsSidebar--right">
|
||||
{props.configPanel}
|
||||
</EuiPageSideBar>
|
||||
</div>
|
||||
</EuiPage>
|
||||
</RootDragDropProvider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
$lnsPanelMinWidth: $euiSize * 18;
|
||||
|
||||
.lnsPage {
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lnsHeader {
|
||||
padding: $euiSize;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.lnsPageMainContent {
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.lnsSidebar {
|
||||
margin: 0;
|
||||
flex: 1 0 18%;
|
||||
min-width: $lnsPanelMinWidth + $euiSize;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lnsSidebar__header {
|
||||
padding: $euiSizeS 0;
|
||||
|
||||
> * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.lnsChartSwitch__summaryIcon {
|
||||
margin-right: $euiSizeS;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.lnsSidebar--right {
|
||||
@include euiScrollBar;
|
||||
min-width: $lnsPanelMinWidth + $euiSize;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding-top: $euiSize;
|
||||
padding-right: $euiSize;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.lnsSidebarContainer {
|
||||
flex: 1 0 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lnsDatasourceSwitch {
|
||||
position: absolute;
|
||||
right: $euiSize + $euiSizeXS;
|
||||
top: $euiSize + $euiSizeXS;
|
||||
}
|
||||
|
||||
.lnsPageBody {
|
||||
@include euiScrollBar;
|
||||
min-width: $lnsPanelMinWidth + $euiSizeXL;
|
||||
overflow: hidden;
|
||||
// Leave out bottom padding so the suggestions scrollbar stays flush to window edge
|
||||
// This also means needing to add same amount of margin to page content and suggestion items
|
||||
padding: $euiSize $euiSize 0;
|
||||
|
||||
&:first-child {
|
||||
padding-left: $euiSize;
|
||||
}
|
||||
|
||||
.lnsPageContent {
|
||||
@include euiScrollBar;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin-bottom: $euiSize;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.lnsPageContentHeader {
|
||||
padding: $euiSizeS;
|
||||
border-bottom: $euiBorderThin;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.lnsPageContentBody {
|
||||
@include euiScrollBar;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
overflow: auto;
|
||||
|
||||
> * {
|
||||
flex: 1 1 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lnsExpressionOutput {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow-x: hidden;
|
||||
padding: $euiSize;
|
||||
}
|
||||
|
||||
.lnsExpressionOutput > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lnsTitleInput {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
@import './suggestion_panel.scss';
|
|
@ -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';
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getSavedObjectFormat, Props } from './save';
|
||||
import { createMockDatasource, createMockVisualization } from '../mocks';
|
||||
|
||||
describe('save editor frame state', () => {
|
||||
const mockVisualization = createMockVisualization();
|
||||
mockVisualization.getPersistableState.mockImplementation(x => x);
|
||||
const mockDatasource = createMockDatasource();
|
||||
mockDatasource.getPersistableState.mockImplementation(x => x);
|
||||
const saveArgs: Props = {
|
||||
activeDatasources: {
|
||||
indexpattern: mockDatasource,
|
||||
},
|
||||
visualization: mockVisualization,
|
||||
state: {
|
||||
title: 'aaa',
|
||||
datasourceStates: {
|
||||
indexpattern: {
|
||||
state: 'hello',
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'indexpattern',
|
||||
visualization: { activeId: '2', state: {} },
|
||||
},
|
||||
framePublicAPI: {
|
||||
addNewLayer: jest.fn(),
|
||||
removeLayers: jest.fn(),
|
||||
datasourceLayers: {
|
||||
first: mockDatasource.publicAPIMock,
|
||||
},
|
||||
query: { query: '', language: 'lucene' },
|
||||
dateRange: { fromDate: 'now-7d', toDate: 'now' },
|
||||
},
|
||||
};
|
||||
|
||||
it('transforms from internal state to persisted doc format', async () => {
|
||||
const datasource = createMockDatasource();
|
||||
datasource.getPersistableState.mockImplementation(state => ({
|
||||
stuff: `${state}_datasource_persisted`,
|
||||
}));
|
||||
|
||||
const visualization = createMockVisualization();
|
||||
visualization.getPersistableState.mockImplementation(state => ({
|
||||
things: `${state}_vis_persisted`,
|
||||
}));
|
||||
|
||||
const doc = await getSavedObjectFormat({
|
||||
...saveArgs,
|
||||
activeDatasources: {
|
||||
indexpattern: datasource,
|
||||
},
|
||||
state: {
|
||||
title: 'bbb',
|
||||
datasourceStates: {
|
||||
indexpattern: {
|
||||
state: '2',
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'indexpattern',
|
||||
visualization: { activeId: '3', state: '4' },
|
||||
},
|
||||
visualization,
|
||||
});
|
||||
|
||||
expect(doc).toEqual({
|
||||
id: undefined,
|
||||
expression: '',
|
||||
state: {
|
||||
datasourceMetaData: {
|
||||
filterableIndexPatterns: [],
|
||||
},
|
||||
datasourceStates: {
|
||||
indexpattern: {
|
||||
stuff: '2_datasource_persisted',
|
||||
},
|
||||
},
|
||||
visualization: { things: '4_vis_persisted' },
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
},
|
||||
title: 'bbb',
|
||||
type: 'lens',
|
||||
visualizationType: '3',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import { toExpression } from '@kbn/interpreter/target/common';
|
||||
import { EditorFrameState } from './state_management';
|
||||
import { Document } from '../../persistence/saved_object_store';
|
||||
import { buildExpression } from './expression_helpers';
|
||||
import { Datasource, Visualization, FramePublicAPI } from '../../types';
|
||||
|
||||
export interface Props {
|
||||
activeDatasources: Record<string, Datasource>;
|
||||
state: EditorFrameState;
|
||||
visualization: Visualization;
|
||||
framePublicAPI: FramePublicAPI;
|
||||
}
|
||||
|
||||
export function getSavedObjectFormat({
|
||||
activeDatasources,
|
||||
state,
|
||||
visualization,
|
||||
framePublicAPI,
|
||||
}: Props): Document {
|
||||
const expression = buildExpression({
|
||||
visualization,
|
||||
visualizationState: state.visualization.state,
|
||||
datasourceMap: activeDatasources,
|
||||
datasourceStates: state.datasourceStates,
|
||||
framePublicAPI,
|
||||
removeDateRange: true,
|
||||
});
|
||||
|
||||
const datasourceStates: Record<string, unknown> = {};
|
||||
Object.entries(activeDatasources).forEach(([id, datasource]) => {
|
||||
datasourceStates[id] = datasource.getPersistableState(state.datasourceStates[id].state);
|
||||
});
|
||||
|
||||
const filterableIndexPatterns: Array<{ id: string; title: string }> = [];
|
||||
Object.entries(activeDatasources).forEach(([id, datasource]) => {
|
||||
filterableIndexPatterns.push(
|
||||
...datasource.getMetaData(state.datasourceStates[id].state).filterableIndexPatterns
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
id: state.persistedId,
|
||||
title: state.title,
|
||||
type: 'lens',
|
||||
visualizationType: state.visualization.activeId,
|
||||
expression: expression ? toExpression(expression) : '',
|
||||
state: {
|
||||
datasourceStates,
|
||||
datasourceMetaData: {
|
||||
filterableIndexPatterns: _.uniq(filterableIndexPatterns, 'id'),
|
||||
},
|
||||
visualization: visualization.getPersistableState(state.visualization.state),
|
||||
query: framePublicAPI.query,
|
||||
filters: [], // TODO: Support filters
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,408 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getInitialState, reducer } from './state_management';
|
||||
import { EditorFrameProps } from '.';
|
||||
import { Datasource, Visualization } from '../../types';
|
||||
import { createExpressionRendererMock } from '../mocks';
|
||||
import { coreMock } from 'src/core/public/mocks';
|
||||
|
||||
describe('editor_frame state management', () => {
|
||||
describe('initialization', () => {
|
||||
let props: EditorFrameProps;
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
onError: jest.fn(),
|
||||
datasourceMap: { testDatasource: ({} as unknown) as Datasource },
|
||||
visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization },
|
||||
initialDatasourceId: 'testDatasource',
|
||||
initialVisualizationId: 'testVis',
|
||||
ExpressionRenderer: createExpressionRendererMock(),
|
||||
onChange: jest.fn(),
|
||||
core: coreMock.createSetup(),
|
||||
dateRange: { fromDate: 'now-7d', toDate: 'now' },
|
||||
query: { query: '', language: 'lucene' },
|
||||
};
|
||||
});
|
||||
|
||||
it('should store initial datasource and visualization', () => {
|
||||
const initialState = getInitialState(props);
|
||||
expect(initialState.activeDatasourceId).toEqual('testDatasource');
|
||||
expect(initialState.visualization.activeId).toEqual('testVis');
|
||||
});
|
||||
|
||||
it('should not initialize visualization but set active id', () => {
|
||||
const initialState = getInitialState(props);
|
||||
|
||||
expect(initialState.visualization.state).toBe(null);
|
||||
expect(initialState.visualization.activeId).toBe('testVis');
|
||||
expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefill state if doc is passed in', () => {
|
||||
const initialState = getInitialState({
|
||||
...props,
|
||||
doc: {
|
||||
expression: '',
|
||||
state: {
|
||||
datasourceStates: {
|
||||
testDatasource: { internalState1: '' },
|
||||
testDatasource2: { internalState2: '' },
|
||||
},
|
||||
visualization: {},
|
||||
datasourceMetaData: {
|
||||
filterableIndexPatterns: [],
|
||||
},
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
},
|
||||
title: '',
|
||||
visualizationType: 'testVis',
|
||||
},
|
||||
});
|
||||
|
||||
expect(initialState.datasourceStates).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"testDatasource": Object {
|
||||
"isLoading": true,
|
||||
"state": Object {
|
||||
"internalState1": "",
|
||||
},
|
||||
},
|
||||
"testDatasource2": Object {
|
||||
"isLoading": true,
|
||||
"state": Object {
|
||||
"internalState2": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(initialState.visualization).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"activeId": "testVis",
|
||||
"state": null,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not set active id if no initial visualization is passed in', () => {
|
||||
const initialState = getInitialState({ ...props, initialVisualizationId: null });
|
||||
|
||||
expect(initialState.visualization.state).toEqual(null);
|
||||
expect(initialState.visualization.activeId).toEqual(null);
|
||||
expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state update', () => {
|
||||
it('should update the corresponding visualization state on update', () => {
|
||||
const newVisState = {};
|
||||
const newState = reducer(
|
||||
{
|
||||
datasourceStates: {
|
||||
testDatasource: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'testDatasource',
|
||||
title: 'aaa',
|
||||
visualization: {
|
||||
activeId: 'testVis',
|
||||
state: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'UPDATE_VISUALIZATION_STATE',
|
||||
newState: newVisState,
|
||||
}
|
||||
);
|
||||
|
||||
expect(newState.visualization.state).toBe(newVisState);
|
||||
});
|
||||
|
||||
it('should update the datasource state with passed in reducer', () => {
|
||||
const datasourceReducer = jest.fn(() => ({ changed: true }));
|
||||
const newState = reducer(
|
||||
{
|
||||
datasourceStates: {
|
||||
testDatasource: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'testDatasource',
|
||||
title: 'bbb',
|
||||
visualization: {
|
||||
activeId: 'testVis',
|
||||
state: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'UPDATE_DATASOURCE_STATE',
|
||||
updater: datasourceReducer,
|
||||
datasourceId: 'testDatasource',
|
||||
}
|
||||
);
|
||||
|
||||
expect(newState.datasourceStates.testDatasource.state).toEqual({ changed: true });
|
||||
expect(datasourceReducer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should update the layer state with passed in reducer', () => {
|
||||
const newDatasourceState = {};
|
||||
const newState = reducer(
|
||||
{
|
||||
datasourceStates: {
|
||||
testDatasource: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'testDatasource',
|
||||
title: 'bbb',
|
||||
visualization: {
|
||||
activeId: 'testVis',
|
||||
state: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'UPDATE_DATASOURCE_STATE',
|
||||
updater: newDatasourceState,
|
||||
datasourceId: 'testDatasource',
|
||||
}
|
||||
);
|
||||
|
||||
expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState);
|
||||
});
|
||||
|
||||
it('should should switch active visualization', () => {
|
||||
const testVisState = {};
|
||||
const newVisState = {};
|
||||
const newState = reducer(
|
||||
{
|
||||
datasourceStates: {
|
||||
testDatasource: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'testDatasource',
|
||||
title: 'ccc',
|
||||
visualization: {
|
||||
activeId: 'testVis',
|
||||
state: testVisState,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'SWITCH_VISUALIZATION',
|
||||
newVisualizationId: 'testVis2',
|
||||
initialState: newVisState,
|
||||
}
|
||||
);
|
||||
|
||||
expect(newState.visualization.state).toBe(newVisState);
|
||||
});
|
||||
|
||||
it('should should switch active visualization and update datasource state', () => {
|
||||
const testVisState = {};
|
||||
const newVisState = {};
|
||||
const newDatasourceState = {};
|
||||
const newState = reducer(
|
||||
{
|
||||
datasourceStates: {
|
||||
testDatasource: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'testDatasource',
|
||||
title: 'ddd',
|
||||
visualization: {
|
||||
activeId: 'testVis',
|
||||
state: testVisState,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'SWITCH_VISUALIZATION',
|
||||
newVisualizationId: 'testVis2',
|
||||
initialState: newVisState,
|
||||
datasourceState: newDatasourceState,
|
||||
datasourceId: 'testDatasource',
|
||||
}
|
||||
);
|
||||
|
||||
expect(newState.visualization.state).toBe(newVisState);
|
||||
expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState);
|
||||
});
|
||||
|
||||
it('should should switch active datasource and initialize new state', () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
datasourceStates: {
|
||||
testDatasource: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'testDatasource',
|
||||
title: 'eee',
|
||||
visualization: {
|
||||
activeId: 'testVis',
|
||||
state: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'SWITCH_DATASOURCE',
|
||||
newDatasourceId: 'testDatasource2',
|
||||
}
|
||||
);
|
||||
|
||||
expect(newState.activeDatasourceId).toEqual('testDatasource2');
|
||||
expect(newState.datasourceStates.testDatasource2.isLoading).toEqual(true);
|
||||
});
|
||||
|
||||
it('not initialize already initialized datasource on switch', () => {
|
||||
const datasource2State = {};
|
||||
const newState = reducer(
|
||||
{
|
||||
datasourceStates: {
|
||||
testDatasource: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
testDatasource2: {
|
||||
state: datasource2State,
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'testDatasource',
|
||||
title: 'eee',
|
||||
visualization: {
|
||||
activeId: 'testVis',
|
||||
state: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'SWITCH_DATASOURCE',
|
||||
newDatasourceId: 'testDatasource2',
|
||||
}
|
||||
);
|
||||
|
||||
expect(newState.activeDatasourceId).toEqual('testDatasource2');
|
||||
expect(newState.datasourceStates.testDatasource2.state).toBe(datasource2State);
|
||||
});
|
||||
|
||||
it('should reset the state', () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
datasourceStates: {
|
||||
a: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'a',
|
||||
title: 'jjj',
|
||||
visualization: {
|
||||
activeId: 'b',
|
||||
state: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'RESET',
|
||||
state: {
|
||||
datasourceStates: {
|
||||
z: {
|
||||
isLoading: false,
|
||||
state: { hola: 'muchacho' },
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'z',
|
||||
persistedId: 'bar',
|
||||
title: 'lll',
|
||||
visualization: {
|
||||
activeId: 'q',
|
||||
state: { my: 'viz' },
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(newState).toMatchObject({
|
||||
datasourceStates: {
|
||||
z: {
|
||||
isLoading: false,
|
||||
state: { hola: 'muchacho' },
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'z',
|
||||
persistedId: 'bar',
|
||||
visualization: {
|
||||
activeId: 'q',
|
||||
state: { my: 'viz' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should load the state from the doc', () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
datasourceStates: {
|
||||
a: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: 'a',
|
||||
title: 'mmm',
|
||||
visualization: {
|
||||
activeId: 'b',
|
||||
state: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'VISUALIZATION_LOADED',
|
||||
doc: {
|
||||
id: 'b',
|
||||
expression: '',
|
||||
state: {
|
||||
datasourceMetaData: { filterableIndexPatterns: [] },
|
||||
datasourceStates: { a: { foo: 'c' } },
|
||||
visualization: { bar: 'd' },
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
},
|
||||
title: 'heyo!',
|
||||
type: 'lens',
|
||||
visualizationType: 'line',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(newState).toEqual({
|
||||
activeDatasourceId: 'a',
|
||||
datasourceStates: {
|
||||
a: {
|
||||
isLoading: true,
|
||||
state: {
|
||||
foo: 'c',
|
||||
},
|
||||
},
|
||||
},
|
||||
persistedId: 'b',
|
||||
title: 'heyo!',
|
||||
visualization: {
|
||||
activeId: 'line',
|
||||
state: {
|
||||
bar: 'd',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,208 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EditorFrameProps } from '../editor_frame';
|
||||
import { Document } from '../../persistence/saved_object_store';
|
||||
|
||||
export interface EditorFrameState {
|
||||
persistedId?: string;
|
||||
title: string;
|
||||
visualization: {
|
||||
activeId: string | null;
|
||||
state: unknown;
|
||||
};
|
||||
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>;
|
||||
activeDatasourceId: string | null;
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
type: 'RESET';
|
||||
state: EditorFrameState;
|
||||
}
|
||||
| {
|
||||
type: 'UPDATE_TITLE';
|
||||
title: string;
|
||||
}
|
||||
| {
|
||||
type: 'UPDATE_DATASOURCE_STATE';
|
||||
updater: unknown | ((prevState: unknown) => unknown);
|
||||
datasourceId: string;
|
||||
}
|
||||
| {
|
||||
type: 'UPDATE_VISUALIZATION_STATE';
|
||||
newState: unknown;
|
||||
}
|
||||
| {
|
||||
type: 'UPDATE_LAYER';
|
||||
layerId: string;
|
||||
datasourceId: string;
|
||||
updater: (state: unknown, layerId: string) => unknown;
|
||||
}
|
||||
| {
|
||||
type: 'VISUALIZATION_LOADED';
|
||||
doc: Document;
|
||||
}
|
||||
| {
|
||||
type: 'SWITCH_VISUALIZATION';
|
||||
newVisualizationId: string;
|
||||
initialState: unknown;
|
||||
}
|
||||
| {
|
||||
type: 'SWITCH_VISUALIZATION';
|
||||
newVisualizationId: string;
|
||||
initialState: unknown;
|
||||
datasourceState: unknown;
|
||||
datasourceId: string;
|
||||
}
|
||||
| {
|
||||
type: 'SWITCH_DATASOURCE';
|
||||
newDatasourceId: string;
|
||||
};
|
||||
|
||||
export function getActiveDatasourceIdFromDoc(doc?: Document) {
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [initialDatasourceId] = Object.keys(doc.state.datasourceStates);
|
||||
return initialDatasourceId || null;
|
||||
}
|
||||
|
||||
function getInitialDatasourceId(props: EditorFrameProps) {
|
||||
return props.initialDatasourceId
|
||||
? props.initialDatasourceId
|
||||
: getActiveDatasourceIdFromDoc(props.doc);
|
||||
}
|
||||
|
||||
export const getInitialState = (props: EditorFrameProps): EditorFrameState => {
|
||||
const datasourceStates: EditorFrameState['datasourceStates'] = {};
|
||||
|
||||
if (props.doc) {
|
||||
Object.entries(props.doc.state.datasourceStates).forEach(([datasourceId, state]) => {
|
||||
datasourceStates[datasourceId] = { isLoading: true, state };
|
||||
});
|
||||
} else if (props.initialDatasourceId) {
|
||||
datasourceStates[props.initialDatasourceId] = {
|
||||
state: null,
|
||||
isLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: i18n.translate('xpack.lens.chartTitle', { defaultMessage: 'New visualization' }),
|
||||
datasourceStates,
|
||||
activeDatasourceId: getInitialDatasourceId(props),
|
||||
visualization: {
|
||||
state: null,
|
||||
activeId: props.initialVisualizationId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const reducer = (state: EditorFrameState, action: Action): EditorFrameState => {
|
||||
switch (action.type) {
|
||||
case 'RESET':
|
||||
return action.state;
|
||||
case 'UPDATE_TITLE':
|
||||
return { ...state, title: action.title };
|
||||
case 'UPDATE_LAYER':
|
||||
return {
|
||||
...state,
|
||||
datasourceStates: {
|
||||
...state.datasourceStates,
|
||||
[action.datasourceId]: {
|
||||
...state.datasourceStates[action.datasourceId],
|
||||
state: action.updater(
|
||||
state.datasourceStates[action.datasourceId].state,
|
||||
action.layerId
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'VISUALIZATION_LOADED':
|
||||
return {
|
||||
...state,
|
||||
persistedId: action.doc.id,
|
||||
title: action.doc.title,
|
||||
datasourceStates: Object.entries(action.doc.state.datasourceStates).reduce(
|
||||
(stateMap, [datasourceId, datasourceState]) => ({
|
||||
...stateMap,
|
||||
[datasourceId]: {
|
||||
isLoading: true,
|
||||
state: datasourceState,
|
||||
},
|
||||
}),
|
||||
{}
|
||||
),
|
||||
activeDatasourceId: getActiveDatasourceIdFromDoc(action.doc),
|
||||
visualization: {
|
||||
...state.visualization,
|
||||
activeId: action.doc.visualizationType,
|
||||
state: action.doc.state.visualization,
|
||||
},
|
||||
};
|
||||
case 'SWITCH_DATASOURCE':
|
||||
return {
|
||||
...state,
|
||||
datasourceStates: {
|
||||
...state.datasourceStates,
|
||||
[action.newDatasourceId]: state.datasourceStates[action.newDatasourceId] || {
|
||||
state: null,
|
||||
isLoading: true,
|
||||
},
|
||||
},
|
||||
activeDatasourceId: action.newDatasourceId,
|
||||
};
|
||||
case 'SWITCH_VISUALIZATION':
|
||||
return {
|
||||
...state,
|
||||
datasourceStates:
|
||||
'datasourceId' in action && action.datasourceId
|
||||
? {
|
||||
...state.datasourceStates,
|
||||
[action.datasourceId]: {
|
||||
...state.datasourceStates[action.datasourceId],
|
||||
state: action.datasourceState,
|
||||
},
|
||||
}
|
||||
: state.datasourceStates,
|
||||
visualization: {
|
||||
...state.visualization,
|
||||
activeId: action.newVisualizationId,
|
||||
state: action.initialState,
|
||||
},
|
||||
};
|
||||
case 'UPDATE_DATASOURCE_STATE':
|
||||
return {
|
||||
...state,
|
||||
datasourceStates: {
|
||||
...state.datasourceStates,
|
||||
[action.datasourceId]: {
|
||||
state:
|
||||
typeof action.updater === 'function'
|
||||
? action.updater(state.datasourceStates[action.datasourceId].state)
|
||||
: action.updater,
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'UPDATE_VISUALIZATION_STATE':
|
||||
if (!state.visualization.activeId) {
|
||||
throw new Error('Invariant: visualization state got updated without active visualization');
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
visualization: {
|
||||
...state.visualization,
|
||||
state: action.newState,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,387 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getSuggestions } from './suggestion_helpers';
|
||||
import { createMockVisualization, createMockDatasource, DatasourceMock } from '../mocks';
|
||||
import { TableSuggestion, DatasourceSuggestion } from '../../types';
|
||||
|
||||
const generateSuggestion = (state = {}, layerId: string = 'first'): DatasourceSuggestion => ({
|
||||
state,
|
||||
table: {
|
||||
columns: [],
|
||||
isMultiRow: false,
|
||||
layerId,
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
});
|
||||
|
||||
let datasourceMap: Record<string, DatasourceMock>;
|
||||
let datasourceStates: Record<
|
||||
string,
|
||||
{
|
||||
isLoading: boolean;
|
||||
state: unknown;
|
||||
}
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
datasourceMap = {
|
||||
mock: createMockDatasource(),
|
||||
};
|
||||
|
||||
datasourceStates = {
|
||||
mock: {
|
||||
isLoading: false,
|
||||
state: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('suggestion helpers', () => {
|
||||
it('should return suggestions array', () => {
|
||||
const mockVisualization = createMockVisualization();
|
||||
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
|
||||
generateSuggestion(),
|
||||
]);
|
||||
const suggestedState = {};
|
||||
const suggestions = getSuggestions({
|
||||
visualizationMap: {
|
||||
vis1: {
|
||||
...mockVisualization,
|
||||
getSuggestions: () => [
|
||||
{
|
||||
score: 0.5,
|
||||
title: 'Test',
|
||||
state: suggestedState,
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
activeVisualizationId: 'vis1',
|
||||
visualizationState: {},
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
});
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0].visualizationState).toBe(suggestedState);
|
||||
});
|
||||
|
||||
it('should concatenate suggestions from all visualizations', () => {
|
||||
const mockVisualization1 = createMockVisualization();
|
||||
const mockVisualization2 = createMockVisualization();
|
||||
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
|
||||
generateSuggestion(),
|
||||
]);
|
||||
const suggestions = getSuggestions({
|
||||
visualizationMap: {
|
||||
vis1: {
|
||||
...mockVisualization1,
|
||||
getSuggestions: () => [
|
||||
{
|
||||
score: 0.5,
|
||||
title: 'Test',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
{
|
||||
score: 0.5,
|
||||
title: 'Test2',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
],
|
||||
},
|
||||
vis2: {
|
||||
...mockVisualization2,
|
||||
getSuggestions: () => [
|
||||
{
|
||||
score: 0.5,
|
||||
title: 'Test3',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
activeVisualizationId: 'vis1',
|
||||
visualizationState: {},
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
});
|
||||
expect(suggestions).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should call getDatasourceSuggestionsForField when a field is passed', () => {
|
||||
datasourceMap.mock.getDatasourceSuggestionsForField.mockReturnValue([generateSuggestion()]);
|
||||
const droppedField = {};
|
||||
getSuggestions({
|
||||
visualizationMap: {
|
||||
vis1: createMockVisualization(),
|
||||
},
|
||||
activeVisualizationId: 'vis1',
|
||||
visualizationState: {},
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
field: droppedField,
|
||||
});
|
||||
expect(datasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith(
|
||||
datasourceStates.mock.state,
|
||||
droppedField
|
||||
);
|
||||
});
|
||||
|
||||
it('should call getDatasourceSuggestionsForField from all datasources with a state', () => {
|
||||
const multiDatasourceStates = {
|
||||
mock: {
|
||||
isLoading: false,
|
||||
state: {},
|
||||
},
|
||||
mock2: {
|
||||
isLoading: false,
|
||||
state: {},
|
||||
},
|
||||
};
|
||||
const multiDatasourceMap = {
|
||||
mock: createMockDatasource(),
|
||||
mock2: createMockDatasource(),
|
||||
mock3: createMockDatasource(),
|
||||
};
|
||||
const droppedField = {};
|
||||
getSuggestions({
|
||||
visualizationMap: {
|
||||
vis1: createMockVisualization(),
|
||||
},
|
||||
activeVisualizationId: 'vis1',
|
||||
visualizationState: {},
|
||||
datasourceMap: multiDatasourceMap,
|
||||
datasourceStates: multiDatasourceStates,
|
||||
field: droppedField,
|
||||
});
|
||||
expect(multiDatasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith(
|
||||
multiDatasourceStates.mock.state,
|
||||
droppedField
|
||||
);
|
||||
expect(multiDatasourceMap.mock2.getDatasourceSuggestionsForField).toHaveBeenCalledWith(
|
||||
multiDatasourceStates.mock2.state,
|
||||
droppedField
|
||||
);
|
||||
expect(multiDatasourceMap.mock3.getDatasourceSuggestionsForField).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should rank the visualizations by score', () => {
|
||||
const mockVisualization1 = createMockVisualization();
|
||||
const mockVisualization2 = createMockVisualization();
|
||||
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
|
||||
generateSuggestion(),
|
||||
]);
|
||||
const suggestions = getSuggestions({
|
||||
visualizationMap: {
|
||||
vis1: {
|
||||
...mockVisualization1,
|
||||
getSuggestions: () => [
|
||||
{
|
||||
score: 0.2,
|
||||
title: 'Test',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
{
|
||||
score: 0.8,
|
||||
title: 'Test2',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
],
|
||||
},
|
||||
vis2: {
|
||||
...mockVisualization2,
|
||||
getSuggestions: () => [
|
||||
{
|
||||
score: 0.6,
|
||||
title: 'Test3',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
activeVisualizationId: 'vis1',
|
||||
visualizationState: {},
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
});
|
||||
expect(suggestions[0].score).toBe(0.8);
|
||||
expect(suggestions[1].score).toBe(0.6);
|
||||
expect(suggestions[2].score).toBe(0.2);
|
||||
});
|
||||
|
||||
it('should call all suggestion getters with all available data tables', () => {
|
||||
const mockVisualization1 = createMockVisualization();
|
||||
const mockVisualization2 = createMockVisualization();
|
||||
const table1: TableSuggestion = {
|
||||
columns: [],
|
||||
isMultiRow: true,
|
||||
layerId: 'first',
|
||||
changeType: 'unchanged',
|
||||
};
|
||||
const table2: TableSuggestion = {
|
||||
columns: [],
|
||||
isMultiRow: true,
|
||||
layerId: 'first',
|
||||
changeType: 'unchanged',
|
||||
};
|
||||
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
|
||||
{ state: {}, table: table1 },
|
||||
{ state: {}, table: table2 },
|
||||
]);
|
||||
getSuggestions({
|
||||
visualizationMap: {
|
||||
vis1: mockVisualization1,
|
||||
vis2: mockVisualization2,
|
||||
},
|
||||
activeVisualizationId: 'vis1',
|
||||
visualizationState: {},
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
});
|
||||
expect(mockVisualization1.getSuggestions.mock.calls[0][0].table).toEqual(table1);
|
||||
expect(mockVisualization1.getSuggestions.mock.calls[1][0].table).toEqual(table2);
|
||||
expect(mockVisualization2.getSuggestions.mock.calls[0][0].table).toEqual(table1);
|
||||
expect(mockVisualization2.getSuggestions.mock.calls[1][0].table).toEqual(table2);
|
||||
});
|
||||
|
||||
it('should map the suggestion ids back to the correct datasource ids and states', () => {
|
||||
const mockVisualization1 = createMockVisualization();
|
||||
const mockVisualization2 = createMockVisualization();
|
||||
const tableState1 = {};
|
||||
const tableState2 = {};
|
||||
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
|
||||
generateSuggestion(tableState1),
|
||||
generateSuggestion(tableState2),
|
||||
]);
|
||||
const vis1Suggestions = jest.fn();
|
||||
vis1Suggestions.mockReturnValueOnce([
|
||||
{
|
||||
score: 0.3,
|
||||
title: 'Test',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
]);
|
||||
vis1Suggestions.mockReturnValueOnce([
|
||||
{
|
||||
score: 0.2,
|
||||
title: 'Test2',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
]);
|
||||
const vis2Suggestions = jest.fn();
|
||||
vis2Suggestions.mockReturnValueOnce([]);
|
||||
vis2Suggestions.mockReturnValueOnce([
|
||||
{
|
||||
score: 0.1,
|
||||
title: 'Test3',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
]);
|
||||
const suggestions = getSuggestions({
|
||||
visualizationMap: {
|
||||
vis1: {
|
||||
...mockVisualization1,
|
||||
getSuggestions: vis1Suggestions,
|
||||
},
|
||||
vis2: {
|
||||
...mockVisualization2,
|
||||
getSuggestions: vis2Suggestions,
|
||||
},
|
||||
},
|
||||
activeVisualizationId: 'vis1',
|
||||
visualizationState: {},
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
});
|
||||
expect(suggestions[0].datasourceState).toBe(tableState1);
|
||||
expect(suggestions[0].datasourceId).toBe('mock');
|
||||
expect(suggestions[1].datasourceState).toBe(tableState2);
|
||||
expect(suggestions[1].datasourceId).toBe('mock');
|
||||
expect(suggestions[2].datasourceState).toBe(tableState2);
|
||||
expect(suggestions[2].datasourceId).toBe('mock');
|
||||
});
|
||||
|
||||
it('should pass the state of the currently active visualization to getSuggestions', () => {
|
||||
const mockVisualization1 = createMockVisualization();
|
||||
const mockVisualization2 = createMockVisualization();
|
||||
const currentState = {};
|
||||
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
|
||||
generateSuggestion(0),
|
||||
generateSuggestion(1),
|
||||
]);
|
||||
getSuggestions({
|
||||
visualizationMap: {
|
||||
vis1: mockVisualization1,
|
||||
vis2: mockVisualization2,
|
||||
},
|
||||
activeVisualizationId: 'vis1',
|
||||
visualizationState: {},
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
});
|
||||
expect(mockVisualization1.getSuggestions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
state: currentState,
|
||||
})
|
||||
);
|
||||
expect(mockVisualization2.getSuggestions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
state: currentState,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should drop other layers only on visualization switch', () => {
|
||||
const mockVisualization1 = createMockVisualization();
|
||||
const mockVisualization2 = createMockVisualization();
|
||||
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
|
||||
generateSuggestion(),
|
||||
]);
|
||||
datasourceMap.mock.getLayers.mockReturnValue(['first', 'second']);
|
||||
const suggestions = getSuggestions({
|
||||
visualizationMap: {
|
||||
vis1: {
|
||||
...mockVisualization1,
|
||||
getSuggestions: () => [
|
||||
{
|
||||
score: 0.8,
|
||||
title: 'Test2',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
],
|
||||
},
|
||||
vis2: {
|
||||
...mockVisualization2,
|
||||
getSuggestions: () => [
|
||||
{
|
||||
score: 0.6,
|
||||
title: 'Test3',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
activeVisualizationId: 'vis1',
|
||||
visualizationState: {},
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
});
|
||||
expect(suggestions[0].keptLayerIds).toEqual(['first', 'second']);
|
||||
expect(suggestions[1].keptLayerIds).toEqual(['first']);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import { Ast } from '@kbn/interpreter/common';
|
||||
import {
|
||||
Visualization,
|
||||
Datasource,
|
||||
FramePublicAPI,
|
||||
TableChangeType,
|
||||
TableSuggestion,
|
||||
} from '../../types';
|
||||
import { Action } from './state_management';
|
||||
|
||||
export interface Suggestion {
|
||||
visualizationId: string;
|
||||
datasourceState?: unknown;
|
||||
datasourceId?: string;
|
||||
keptLayerIds: string[];
|
||||
columns: number;
|
||||
score: number;
|
||||
title: string;
|
||||
visualizationState: unknown;
|
||||
previewExpression?: Ast | string;
|
||||
previewIcon: string;
|
||||
hide?: boolean;
|
||||
changeType: TableChangeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes a list of available data tables and a list of visualization
|
||||
* extensions and creates a ranked list of suggestions which contain a pair of a data table
|
||||
* and a visualization.
|
||||
*
|
||||
* Each suggestion represents a valid state of the editor and can be applied by creating an
|
||||
* action with `toSwitchAction` and dispatching it
|
||||
*/
|
||||
export function getSuggestions({
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
visualizationMap,
|
||||
activeVisualizationId,
|
||||
visualizationState,
|
||||
field,
|
||||
}: {
|
||||
datasourceMap: Record<string, Datasource>;
|
||||
datasourceStates: Record<
|
||||
string,
|
||||
{
|
||||
isLoading: boolean;
|
||||
state: unknown;
|
||||
}
|
||||
>;
|
||||
visualizationMap: Record<string, Visualization>;
|
||||
activeVisualizationId: string | null;
|
||||
visualizationState: unknown;
|
||||
field?: unknown;
|
||||
}): Suggestion[] {
|
||||
const datasources = Object.entries(datasourceMap).filter(
|
||||
([datasourceId]) => datasourceStates[datasourceId] && !datasourceStates[datasourceId].isLoading
|
||||
);
|
||||
|
||||
const allLayerIds = _.flatten(
|
||||
datasources.map(([datasourceId, datasource]) =>
|
||||
datasource.getLayers(datasourceStates[datasourceId].state)
|
||||
)
|
||||
);
|
||||
|
||||
// Collect all table suggestions from available datasources
|
||||
const datasourceTableSuggestions = _.flatten(
|
||||
datasources.map(([datasourceId, datasource]) => {
|
||||
const datasourceState = datasourceStates[datasourceId].state;
|
||||
return (field
|
||||
? datasource.getDatasourceSuggestionsForField(datasourceState, field)
|
||||
: datasource.getDatasourceSuggestionsFromCurrentState(datasourceState)
|
||||
).map(suggestion => ({ ...suggestion, datasourceId }));
|
||||
})
|
||||
);
|
||||
|
||||
// Pass all table suggestions to all visualization extensions to get visualization suggestions
|
||||
// and rank them by score
|
||||
return _.flatten(
|
||||
Object.entries(visualizationMap).map(([visualizationId, visualization]) =>
|
||||
_.flatten(
|
||||
datasourceTableSuggestions.map(datasourceSuggestion => {
|
||||
const table = datasourceSuggestion.table;
|
||||
const currentVisualizationState =
|
||||
visualizationId === activeVisualizationId ? visualizationState : undefined;
|
||||
const keptLayerIds =
|
||||
visualizationId !== activeVisualizationId
|
||||
? [datasourceSuggestion.table.layerId]
|
||||
: allLayerIds;
|
||||
return getVisualizationSuggestions(
|
||||
visualization,
|
||||
table,
|
||||
visualizationId,
|
||||
datasourceSuggestion,
|
||||
currentVisualizationState,
|
||||
keptLayerIds
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
).sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries a single visualization extensions for a single datasource suggestion and
|
||||
* creates an array of complete suggestions containing both the target datasource
|
||||
* state and target visualization state along with suggestion meta data like score,
|
||||
* title and preview expression.
|
||||
*/
|
||||
function getVisualizationSuggestions(
|
||||
visualization: Visualization<unknown, unknown>,
|
||||
table: TableSuggestion,
|
||||
visualizationId: string,
|
||||
datasourceSuggestion: { datasourceId: string; state: unknown; table: TableSuggestion },
|
||||
currentVisualizationState: unknown,
|
||||
keptLayerIds: string[]
|
||||
) {
|
||||
return visualization
|
||||
.getSuggestions({
|
||||
table,
|
||||
state: currentVisualizationState,
|
||||
})
|
||||
.map(({ state, ...visualizationSuggestion }) => ({
|
||||
...visualizationSuggestion,
|
||||
visualizationId,
|
||||
visualizationState: state,
|
||||
keptLayerIds,
|
||||
datasourceState: datasourceSuggestion.state,
|
||||
datasourceId: datasourceSuggestion.datasourceId,
|
||||
columns: table.columns.length,
|
||||
changeType: table.changeType,
|
||||
}));
|
||||
}
|
||||
|
||||
export function switchToSuggestion(
|
||||
frame: FramePublicAPI,
|
||||
dispatch: (action: Action) => void,
|
||||
suggestion: Pick<
|
||||
Suggestion,
|
||||
'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId' | 'keptLayerIds'
|
||||
>
|
||||
) {
|
||||
const action: Action = {
|
||||
type: 'SWITCH_VISUALIZATION',
|
||||
newVisualizationId: suggestion.visualizationId,
|
||||
initialState: suggestion.visualizationState,
|
||||
datasourceState: suggestion.datasourceState,
|
||||
datasourceId: suggestion.datasourceId,
|
||||
};
|
||||
dispatch(action);
|
||||
const layerIds = Object.keys(frame.datasourceLayers).filter(id => {
|
||||
return !suggestion.keptLayerIds.includes(id);
|
||||
});
|
||||
if (layerIds.length > 0) {
|
||||
frame.removeLayers(layerIds);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
// SASSTODO: Create this in EUI
|
||||
@mixin lnsOverflowShadowHorizontal {
|
||||
$hideHeight: $euiScrollBarCorner * 1.25;
|
||||
mask-image: linear-gradient(to right,
|
||||
transparentize(red, .9) 0%,
|
||||
transparentize(red, 0) $hideHeight,
|
||||
transparentize(red, 0) calc(100% - #{$hideHeight}),
|
||||
transparentize(red, .9) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.lnsSuggestionsPanel__title {
|
||||
margin: $euiSizeS 0 $euiSizeXS;
|
||||
}
|
||||
|
||||
.lnsSuggestionsPanel__suggestions {
|
||||
@include euiScrollBar;
|
||||
@include lnsOverflowShadowHorizontal;
|
||||
padding-top: $euiSizeXS;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
|
||||
// Padding / negative margins to make room for overflow shadow
|
||||
padding-left: $euiSizeXS;
|
||||
margin-left: -$euiSizeXS;
|
||||
|
||||
// Add margin to the next of the same type
|
||||
> * + * {
|
||||
margin-left: $euiSizeS;
|
||||
}
|
||||
}
|
||||
|
||||
// These sizes also match canvas' page thumbnails for consistency
|
||||
$lnsSuggestionHeight: 100px;
|
||||
$lnsSuggestionWidth: 150px;
|
||||
|
||||
.lnsSuggestionPanel__button {
|
||||
flex: 0 0 auto;
|
||||
width: $lnsSuggestionWidth !important;
|
||||
height: $lnsSuggestionHeight;
|
||||
// Allows the scrollbar to stay flush to window
|
||||
margin-bottom: $euiSize;
|
||||
}
|
||||
|
||||
.lnsSidebar__suggestionIcon {
|
||||
color: $euiColorDarkShade;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $euiSizeS;
|
||||
}
|
||||
|
||||
.lnsSuggestionChartWrapper {
|
||||
height: $lnsSuggestionHeight - $euiSize;
|
||||
pointer-events: none;
|
||||
margin: 0 $euiSizeS;
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mountWithIntl as mount } from 'test_utils/enzyme_helpers';
|
||||
import { Visualization } from '../../types';
|
||||
import {
|
||||
createMockVisualization,
|
||||
createMockDatasource,
|
||||
createExpressionRendererMock,
|
||||
DatasourceMock,
|
||||
createMockFramePublicAPI,
|
||||
} from '../mocks';
|
||||
import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public';
|
||||
import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel';
|
||||
import { getSuggestions, Suggestion } from './suggestion_helpers';
|
||||
import { fromExpression } from '@kbn/interpreter/target/common';
|
||||
import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui';
|
||||
|
||||
jest.mock('./suggestion_helpers');
|
||||
|
||||
describe('suggestion_panel', () => {
|
||||
let mockVisualization: Visualization;
|
||||
let mockDatasource: DatasourceMock;
|
||||
|
||||
let expressionRendererMock: ExpressionRenderer;
|
||||
let dispatchMock: jest.Mock;
|
||||
|
||||
const suggestion1State = { suggestion1: true };
|
||||
const suggestion2State = { suggestion2: true };
|
||||
|
||||
let defaultProps: SuggestionPanelProps;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVisualization = createMockVisualization();
|
||||
mockDatasource = createMockDatasource();
|
||||
expressionRendererMock = createExpressionRendererMock();
|
||||
dispatchMock = jest.fn();
|
||||
|
||||
(getSuggestions as jest.Mock).mockReturnValue([
|
||||
{
|
||||
datasourceState: {},
|
||||
previewIcon: 'empty',
|
||||
score: 0.5,
|
||||
visualizationState: suggestion1State,
|
||||
visualizationId: 'vis',
|
||||
title: 'Suggestion1',
|
||||
keptLayerIds: ['a'],
|
||||
},
|
||||
{
|
||||
datasourceState: {},
|
||||
previewIcon: 'empty',
|
||||
score: 0.5,
|
||||
visualizationState: suggestion2State,
|
||||
visualizationId: 'vis',
|
||||
title: 'Suggestion2',
|
||||
keptLayerIds: ['a'],
|
||||
},
|
||||
] as Suggestion[]);
|
||||
|
||||
defaultProps = {
|
||||
activeDatasourceId: 'mock',
|
||||
datasourceMap: {
|
||||
mock: mockDatasource,
|
||||
},
|
||||
datasourceStates: {
|
||||
mock: {
|
||||
isLoading: false,
|
||||
state: {},
|
||||
},
|
||||
},
|
||||
activeVisualizationId: 'vis',
|
||||
visualizationMap: {
|
||||
vis: mockVisualization,
|
||||
},
|
||||
visualizationState: {},
|
||||
dispatch: dispatchMock,
|
||||
ExpressionRenderer: expressionRendererMock,
|
||||
frame: createMockFramePublicAPI(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should list passed in suggestions', () => {
|
||||
const wrapper = mount(<SuggestionPanel {...defaultProps} />);
|
||||
|
||||
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(<SuggestionPanel {...defaultProps} />);
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="lnsSuggestion"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
|
||||
expect(dispatchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'SWITCH_VISUALIZATION',
|
||||
initialState: suggestion1State,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove unused layers if suggestion is clicked', () => {
|
||||
defaultProps.frame.datasourceLayers.a = mockDatasource.publicAPIMock;
|
||||
defaultProps.frame.datasourceLayers.b = mockDatasource.publicAPIMock;
|
||||
const wrapper = mount(<SuggestionPanel {...defaultProps} activeVisualizationId="vis2" />);
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="lnsSuggestion"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
|
||||
expect(defaultProps.frame.removeLayers).toHaveBeenCalledWith(['b']);
|
||||
});
|
||||
|
||||
it('should render preview expression if there is one', () => {
|
||||
mockDatasource.getLayers.mockReturnValue(['first']);
|
||||
(getSuggestions as jest.Mock).mockReturnValue([
|
||||
{
|
||||
datasourceState: {},
|
||||
previewIcon: 'empty',
|
||||
score: 0.5,
|
||||
visualizationState: suggestion1State,
|
||||
visualizationId: 'vis',
|
||||
title: 'Suggestion1',
|
||||
},
|
||||
{
|
||||
datasourceState: {},
|
||||
previewIcon: 'empty',
|
||||
score: 0.5,
|
||||
visualizationState: suggestion2State,
|
||||
visualizationId: 'vis',
|
||||
title: 'Suggestion2',
|
||||
previewExpression: 'test | expression',
|
||||
},
|
||||
] as Suggestion[]);
|
||||
|
||||
mockDatasource.toExpression.mockReturnValue('datasource_expression');
|
||||
|
||||
mount(<SuggestionPanel {...defaultProps} />);
|
||||
|
||||
expect(expressionRendererMock).toHaveBeenCalledTimes(1);
|
||||
const passedExpression = fromExpression(
|
||||
(expressionRendererMock as jest.Mock).mock.calls[0][0].expression
|
||||
);
|
||||
expect(passedExpression).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"chain": Array [
|
||||
Object {
|
||||
"arguments": Object {},
|
||||
"function": "kibana",
|
||||
"type": "function",
|
||||
},
|
||||
Object {
|
||||
"arguments": Object {
|
||||
"query": Array [
|
||||
"{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}",
|
||||
],
|
||||
"timeRange": Array [
|
||||
"{\\"from\\":\\"now-7d\\",\\"to\\":\\"now\\"}",
|
||||
],
|
||||
},
|
||||
"function": "kibana_context",
|
||||
"type": "function",
|
||||
},
|
||||
Object {
|
||||
"arguments": Object {
|
||||
"layerIds": Array [
|
||||
"first",
|
||||
],
|
||||
"tables": Array [
|
||||
Object {
|
||||
"chain": Array [
|
||||
Object {
|
||||
"arguments": Object {},
|
||||
"function": "datasource_expression",
|
||||
"type": "function",
|
||||
},
|
||||
],
|
||||
"type": "expression",
|
||||
},
|
||||
],
|
||||
},
|
||||
"function": "lens_merge_tables",
|
||||
"type": "function",
|
||||
},
|
||||
Object {
|
||||
"arguments": Object {},
|
||||
"function": "test",
|
||||
"type": "function",
|
||||
},
|
||||
Object {
|
||||
"arguments": Object {},
|
||||
"function": "expression",
|
||||
"type": "function",
|
||||
},
|
||||
],
|
||||
"type": "expression",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should render render icon if there is no preview expression', () => {
|
||||
mockDatasource.getLayers.mockReturnValue(['first']);
|
||||
(getSuggestions as jest.Mock).mockReturnValue([
|
||||
{
|
||||
datasourceState: {},
|
||||
previewIcon: 'visTable',
|
||||
score: 0.5,
|
||||
visualizationState: suggestion1State,
|
||||
visualizationId: 'vis',
|
||||
title: 'Suggestion1',
|
||||
},
|
||||
{
|
||||
datasourceState: {},
|
||||
previewIcon: 'empty',
|
||||
score: 0.5,
|
||||
visualizationState: suggestion2State,
|
||||
visualizationId: 'vis',
|
||||
title: 'Suggestion2',
|
||||
previewExpression: 'test | expression',
|
||||
},
|
||||
] as Suggestion[]);
|
||||
|
||||
mockDatasource.toExpression.mockReturnValue('datasource_expression');
|
||||
|
||||
const wrapper = mount(<SuggestionPanel {...defaultProps} />);
|
||||
|
||||
expect(wrapper.find(EuiIcon)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiIcon).prop('type')).toEqual('visTable');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,209 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip, EuiToolTip } from '@elastic/eui';
|
||||
import { toExpression, Ast } from '@kbn/interpreter/common';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Action } from './state_management';
|
||||
import { Datasource, Visualization, FramePublicAPI } from '../../types';
|
||||
import { getSuggestions, Suggestion, switchToSuggestion } from './suggestion_helpers';
|
||||
import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public';
|
||||
import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers';
|
||||
import { debouncedComponent } from '../../debounced_component';
|
||||
|
||||
const MAX_SUGGESTIONS_DISPLAYED = 5;
|
||||
|
||||
// TODO: Remove this <any> when upstream fix is merged https://github.com/elastic/eui/issues/2329
|
||||
// eslint-disable-next-line
|
||||
const EuiPanelFixed = EuiPanel as React.ComponentType<any>;
|
||||
|
||||
export interface SuggestionPanelProps {
|
||||
activeDatasourceId: string | null;
|
||||
datasourceMap: Record<string, Datasource>;
|
||||
datasourceStates: Record<
|
||||
string,
|
||||
{
|
||||
isLoading: boolean;
|
||||
state: unknown;
|
||||
}
|
||||
>;
|
||||
activeVisualizationId: string | null;
|
||||
visualizationMap: Record<string, Visualization>;
|
||||
visualizationState: unknown;
|
||||
dispatch: (action: Action) => void;
|
||||
ExpressionRenderer: ExpressionRenderer;
|
||||
frame: FramePublicAPI;
|
||||
}
|
||||
|
||||
const SuggestionPreview = ({
|
||||
suggestion,
|
||||
dispatch,
|
||||
frame,
|
||||
previewExpression,
|
||||
ExpressionRenderer: ExpressionRendererComponent,
|
||||
}: {
|
||||
suggestion: Suggestion;
|
||||
dispatch: (action: Action) => void;
|
||||
frame: FramePublicAPI;
|
||||
ExpressionRenderer: ExpressionRenderer;
|
||||
previewExpression?: string;
|
||||
}) => {
|
||||
const [expressionError, setExpressionError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setExpressionError(false);
|
||||
}, [previewExpression]);
|
||||
|
||||
const clickHandler = () => {
|
||||
switchToSuggestion(frame, dispatch, suggestion);
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiToolTip content={suggestion.title}>
|
||||
<EuiPanelFixed
|
||||
className="lnsSuggestionPanel__button"
|
||||
paddingSize="none"
|
||||
data-test-subj="lnsSuggestion"
|
||||
onClick={clickHandler}
|
||||
>
|
||||
{expressionError ? (
|
||||
<div className="lnsSidebar__suggestionIcon">
|
||||
<EuiIconTip
|
||||
size="xxl"
|
||||
color="danger"
|
||||
type="cross"
|
||||
aria-label={i18n.translate('xpack.lens.editorFrame.previewErrorLabel', {
|
||||
defaultMessage: 'Preview rendering failed',
|
||||
})}
|
||||
content={i18n.translate('xpack.lens.editorFrame.previewErrorTooltip', {
|
||||
defaultMessage: 'Preview rendering failed',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : previewExpression ? (
|
||||
<ExpressionRendererComponent
|
||||
className="lnsSuggestionChartWrapper"
|
||||
expression={previewExpression}
|
||||
onRenderFailure={(e: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to render preview: `, e);
|
||||
setExpressionError(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="lnsSidebar__suggestionIcon">
|
||||
<EuiIcon size="xxl" type={suggestion.previewIcon} />
|
||||
</div>
|
||||
)}
|
||||
</EuiPanelFixed>
|
||||
</EuiToolTip>
|
||||
);
|
||||
};
|
||||
|
||||
export const SuggestionPanel = debouncedComponent(InnerSuggestionPanel, 2000);
|
||||
|
||||
function InnerSuggestionPanel({
|
||||
activeDatasourceId,
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
activeVisualizationId,
|
||||
visualizationMap,
|
||||
visualizationState,
|
||||
dispatch,
|
||||
frame,
|
||||
ExpressionRenderer: ExpressionRendererComponent,
|
||||
}: SuggestionPanelProps) {
|
||||
if (!activeDatasourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suggestions = getSuggestions({
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
visualizationMap,
|
||||
activeVisualizationId,
|
||||
visualizationState,
|
||||
})
|
||||
.filter(suggestion => !suggestion.hide)
|
||||
.slice(0, MAX_SUGGESTIONS_DISPLAYED);
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lnsSuggestionsPanel">
|
||||
<EuiTitle className="lnsSuggestionsPanel__title" size="xxs">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.lens.editorFrame.suggestionPanelTitle"
|
||||
defaultMessage="Suggestions"
|
||||
/>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
<div className="lnsSuggestionsPanel__suggestions">
|
||||
{suggestions.map((suggestion: Suggestion) => (
|
||||
<SuggestionPreview
|
||||
suggestion={suggestion}
|
||||
dispatch={dispatch}
|
||||
frame={frame}
|
||||
ExpressionRenderer={ExpressionRendererComponent}
|
||||
previewExpression={
|
||||
suggestion.previewExpression
|
||||
? preparePreviewExpression(
|
||||
suggestion.previewExpression,
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
frame,
|
||||
suggestion.datasourceId,
|
||||
suggestion.datasourceState
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
key={`${suggestion.visualizationId}-${suggestion.title}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function preparePreviewExpression(
|
||||
expression: string | Ast,
|
||||
datasourceMap: Record<string, Datasource<unknown, unknown>>,
|
||||
datasourceStates: Record<string, { isLoading: boolean; state: unknown }>,
|
||||
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;
|
||||
}
|
|
@ -0,0 +1,725 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ExpressionRendererProps } from '../../../../../../../src/legacy/core_plugins/expressions/public';
|
||||
import { Visualization, FramePublicAPI, TableSuggestion } from '../../types';
|
||||
import {
|
||||
createMockVisualization,
|
||||
createMockDatasource,
|
||||
createExpressionRendererMock,
|
||||
DatasourceMock,
|
||||
createMockFramePublicAPI,
|
||||
} from '../mocks';
|
||||
import { InnerWorkspacePanel, WorkspacePanelProps } from './workspace_panel';
|
||||
import { mountWithIntl as mount } from 'test_utils/enzyme_helpers';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { DragDrop, ChildDragDropProvider } from '../../drag_drop';
|
||||
import { Ast } from '@kbn/interpreter/common';
|
||||
|
||||
const waitForPromises = () => new Promise(resolve => setTimeout(resolve));
|
||||
|
||||
describe('workspace_panel', () => {
|
||||
let mockVisualization: jest.Mocked<Visualization>;
|
||||
let mockVisualization2: jest.Mocked<Visualization>;
|
||||
let mockDatasource: DatasourceMock;
|
||||
|
||||
let expressionRendererMock: jest.Mock<React.ReactElement, [ExpressionRendererProps]>;
|
||||
|
||||
let instance: ReactWrapper<WorkspacePanelProps>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVisualization = createMockVisualization();
|
||||
mockVisualization2 = createMockVisualization();
|
||||
|
||||
mockDatasource = createMockDatasource();
|
||||
|
||||
expressionRendererMock = createExpressionRendererMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it('should render an explanatory text if no visualization is active', () => {
|
||||
instance = mount(
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{}}
|
||||
datasourceMap={{}}
|
||||
framePublicAPI={createMockFramePublicAPI()}
|
||||
activeVisualizationId={null}
|
||||
visualizationMap={{
|
||||
vis: mockVisualization,
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={() => {}}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1);
|
||||
expect(instance.find(expressionRendererMock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should render an explanatory text if the visualization does not produce an expression', () => {
|
||||
instance = mount(
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{}}
|
||||
datasourceMap={{}}
|
||||
framePublicAPI={createMockFramePublicAPI()}
|
||||
activeVisualizationId="vis"
|
||||
visualizationMap={{
|
||||
vis: { ...mockVisualization, toExpression: () => null },
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={() => {}}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1);
|
||||
expect(instance.find(expressionRendererMock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should render an explanatory text if the datasource does not produce an expression', () => {
|
||||
instance = mount(
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{}}
|
||||
datasourceMap={{}}
|
||||
framePublicAPI={createMockFramePublicAPI()}
|
||||
activeVisualizationId="vis"
|
||||
visualizationMap={{
|
||||
vis: { ...mockVisualization, toExpression: () => 'vis' },
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={() => {}}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1);
|
||||
expect(instance.find(expressionRendererMock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should render the resulting expression using the expression renderer', () => {
|
||||
const framePublicAPI = createMockFramePublicAPI();
|
||||
framePublicAPI.datasourceLayers = {
|
||||
first: mockDatasource.publicAPIMock,
|
||||
};
|
||||
mockDatasource.toExpression.mockReturnValue('datasource');
|
||||
mockDatasource.getLayers.mockReturnValue(['first']);
|
||||
|
||||
instance = mount(
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{
|
||||
mock: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
}}
|
||||
datasourceMap={{
|
||||
mock: mockDatasource,
|
||||
}}
|
||||
framePublicAPI={framePublicAPI}
|
||||
activeVisualizationId="vis"
|
||||
visualizationMap={{
|
||||
vis: { ...mockVisualization, toExpression: () => 'vis' },
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={() => {}}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"chain": Array [
|
||||
Object {
|
||||
"arguments": Object {},
|
||||
"function": "kibana",
|
||||
"type": "function",
|
||||
},
|
||||
Object {
|
||||
"arguments": Object {
|
||||
"filters": Array [],
|
||||
"query": Array [
|
||||
"{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}",
|
||||
],
|
||||
"timeRange": Array [
|
||||
"{\\"from\\":\\"now-7d\\",\\"to\\":\\"now\\"}",
|
||||
],
|
||||
},
|
||||
"function": "kibana_context",
|
||||
"type": "function",
|
||||
},
|
||||
Object {
|
||||
"arguments": Object {
|
||||
"layerIds": Array [
|
||||
"first",
|
||||
],
|
||||
"tables": Array [
|
||||
Object {
|
||||
"chain": Array [
|
||||
Object {
|
||||
"arguments": Object {},
|
||||
"function": "datasource",
|
||||
"type": "function",
|
||||
},
|
||||
],
|
||||
"type": "expression",
|
||||
},
|
||||
],
|
||||
},
|
||||
"function": "lens_merge_tables",
|
||||
"type": "function",
|
||||
},
|
||||
Object {
|
||||
"arguments": Object {},
|
||||
"function": "vis",
|
||||
"type": "function",
|
||||
},
|
||||
],
|
||||
"type": "expression",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should include data fetching for each layer in the expression', () => {
|
||||
const mockDatasource2 = createMockDatasource();
|
||||
const framePublicAPI = createMockFramePublicAPI();
|
||||
framePublicAPI.datasourceLayers = {
|
||||
first: mockDatasource.publicAPIMock,
|
||||
second: mockDatasource2.publicAPIMock,
|
||||
};
|
||||
mockDatasource.toExpression.mockReturnValue('datasource');
|
||||
mockDatasource.getLayers.mockReturnValue(['first']);
|
||||
|
||||
mockDatasource2.toExpression.mockReturnValue('datasource2');
|
||||
mockDatasource2.getLayers.mockReturnValue(['second', 'third']);
|
||||
|
||||
instance = mount(
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{
|
||||
mock: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
mock2: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
}}
|
||||
datasourceMap={{
|
||||
mock: mockDatasource,
|
||||
mock2: mockDatasource2,
|
||||
}}
|
||||
framePublicAPI={framePublicAPI}
|
||||
activeVisualizationId="vis"
|
||||
visualizationMap={{
|
||||
vis: { ...mockVisualization, toExpression: () => 'vis' },
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={() => {}}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
(instance.find(expressionRendererMock).prop('expression') as Ast).chain[2].arguments.layerIds
|
||||
).toEqual(['first', 'second', 'third']);
|
||||
expect(
|
||||
(instance.find(expressionRendererMock).prop('expression') as Ast).chain[2].arguments.tables
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"chain": Array [
|
||||
Object {
|
||||
"arguments": Object {},
|
||||
"function": "datasource",
|
||||
"type": "function",
|
||||
},
|
||||
],
|
||||
"type": "expression",
|
||||
},
|
||||
Object {
|
||||
"chain": Array [
|
||||
Object {
|
||||
"arguments": Object {},
|
||||
"function": "datasource2",
|
||||
"type": "function",
|
||||
},
|
||||
],
|
||||
"type": "expression",
|
||||
},
|
||||
Object {
|
||||
"chain": Array [
|
||||
Object {
|
||||
"arguments": Object {},
|
||||
"function": "datasource2",
|
||||
"type": "function",
|
||||
},
|
||||
],
|
||||
"type": "expression",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should run the expression again if the date range changes', async () => {
|
||||
const framePublicAPI = createMockFramePublicAPI();
|
||||
framePublicAPI.datasourceLayers = {
|
||||
first: mockDatasource.publicAPIMock,
|
||||
};
|
||||
mockDatasource.getLayers.mockReturnValue(['first']);
|
||||
|
||||
mockDatasource.toExpression
|
||||
.mockReturnValueOnce('datasource')
|
||||
.mockReturnValueOnce('datasource second');
|
||||
|
||||
expressionRendererMock = jest.fn(_arg => <span />);
|
||||
|
||||
instance = mount(
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{
|
||||
mock: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
}}
|
||||
datasourceMap={{
|
||||
mock: mockDatasource,
|
||||
}}
|
||||
framePublicAPI={framePublicAPI}
|
||||
activeVisualizationId="vis"
|
||||
visualizationMap={{
|
||||
vis: { ...mockVisualization, toExpression: () => 'vis' },
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={() => {}}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
);
|
||||
|
||||
// "wait" for the expression to execute
|
||||
await waitForPromises();
|
||||
instance.update();
|
||||
|
||||
expect(expressionRendererMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
instance.setProps({
|
||||
framePublicAPI: { ...framePublicAPI, dateRange: { fromDate: 'now-90d', toDate: 'now-30d' } },
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
instance.update();
|
||||
|
||||
expect(expressionRendererMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('expression failures', () => {
|
||||
it('should show an error message if the expression fails to parse', () => {
|
||||
mockDatasource.toExpression.mockReturnValue('|||');
|
||||
mockDatasource.getLayers.mockReturnValue(['first']);
|
||||
const framePublicAPI = createMockFramePublicAPI();
|
||||
framePublicAPI.datasourceLayers = {
|
||||
first: mockDatasource.publicAPIMock,
|
||||
};
|
||||
|
||||
instance = mount(
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{
|
||||
mock: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
}}
|
||||
datasourceMap={{
|
||||
mock: mockDatasource,
|
||||
}}
|
||||
framePublicAPI={framePublicAPI}
|
||||
activeVisualizationId="vis"
|
||||
visualizationMap={{
|
||||
vis: { ...mockVisualization, toExpression: () => 'vis' },
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={() => {}}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(instance.find('[data-test-subj="expression-failure"]').first()).toBeTruthy();
|
||||
expect(instance.find(expressionRendererMock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should show an error message if the expression fails to render', async () => {
|
||||
mockDatasource.toExpression.mockReturnValue('datasource');
|
||||
mockDatasource.getLayers.mockReturnValue(['first']);
|
||||
const framePublicAPI = createMockFramePublicAPI();
|
||||
framePublicAPI.datasourceLayers = {
|
||||
first: mockDatasource.publicAPIMock,
|
||||
};
|
||||
expressionRendererMock = jest.fn(({ onRenderFailure }) => {
|
||||
Promise.resolve().then(() => onRenderFailure!({ type: 'error' }));
|
||||
return <span />;
|
||||
});
|
||||
|
||||
instance = mount(
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{
|
||||
mock: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
}}
|
||||
datasourceMap={{
|
||||
mock: mockDatasource,
|
||||
}}
|
||||
framePublicAPI={framePublicAPI}
|
||||
activeVisualizationId="vis"
|
||||
visualizationMap={{
|
||||
vis: { ...mockVisualization, toExpression: () => 'vis' },
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={() => {}}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
);
|
||||
|
||||
// "wait" for the expression to execute
|
||||
await waitForPromises();
|
||||
|
||||
instance.update();
|
||||
|
||||
expect(instance.find('EuiFlexItem[data-test-subj="expression-failure"]')).toHaveLength(1);
|
||||
expect(instance.find(expressionRendererMock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not attempt to run the expression again if it does not change', async () => {
|
||||
mockDatasource.toExpression.mockReturnValue('datasource');
|
||||
mockDatasource.getLayers.mockReturnValue(['first']);
|
||||
const framePublicAPI = createMockFramePublicAPI();
|
||||
framePublicAPI.datasourceLayers = {
|
||||
first: mockDatasource.publicAPIMock,
|
||||
};
|
||||
expressionRendererMock = jest.fn(({ onRenderFailure }) => {
|
||||
Promise.resolve().then(() => onRenderFailure!({ type: 'error' }));
|
||||
return <span />;
|
||||
});
|
||||
|
||||
instance = mount(
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{
|
||||
mock: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
}}
|
||||
datasourceMap={{
|
||||
mock: mockDatasource,
|
||||
}}
|
||||
framePublicAPI={framePublicAPI}
|
||||
activeVisualizationId="vis"
|
||||
visualizationMap={{
|
||||
vis: { ...mockVisualization, toExpression: () => 'vis' },
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={() => {}}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
);
|
||||
|
||||
// "wait" for the expression to execute
|
||||
await waitForPromises();
|
||||
|
||||
instance.update();
|
||||
|
||||
expect(expressionRendererMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
instance.update();
|
||||
|
||||
expect(expressionRendererMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should attempt to run the expression again if changes after an error', async () => {
|
||||
mockDatasource.toExpression.mockReturnValue('datasource');
|
||||
mockDatasource.getLayers.mockReturnValue(['first']);
|
||||
const framePublicAPI = createMockFramePublicAPI();
|
||||
framePublicAPI.datasourceLayers = {
|
||||
first: mockDatasource.publicAPIMock,
|
||||
};
|
||||
expressionRendererMock = jest.fn(({ onRenderFailure }) => {
|
||||
Promise.resolve().then(() => onRenderFailure!({ type: 'error' }));
|
||||
return <span />;
|
||||
});
|
||||
|
||||
instance = mount(
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{
|
||||
mock: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
}}
|
||||
datasourceMap={{
|
||||
mock: mockDatasource,
|
||||
}}
|
||||
framePublicAPI={framePublicAPI}
|
||||
activeVisualizationId="vis"
|
||||
visualizationMap={{
|
||||
vis: { ...mockVisualization, toExpression: () => 'vis' },
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={() => {}}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
);
|
||||
|
||||
// "wait" for the expression to execute
|
||||
await waitForPromises();
|
||||
|
||||
instance.update();
|
||||
|
||||
expect(expressionRendererMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expressionRendererMock.mockImplementation(_ => {
|
||||
return <span />;
|
||||
});
|
||||
|
||||
instance.setProps({ visualizationState: {} });
|
||||
instance.update();
|
||||
|
||||
expect(expressionRendererMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(instance.find(expressionRendererMock)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestions from dropping in workspace panel', () => {
|
||||
let mockDispatch: jest.Mock;
|
||||
let frame: jest.Mocked<FramePublicAPI>;
|
||||
|
||||
const draggedField: unknown = {};
|
||||
|
||||
beforeEach(() => {
|
||||
frame = createMockFramePublicAPI();
|
||||
mockDispatch = jest.fn();
|
||||
});
|
||||
|
||||
function initComponent(draggingContext: unknown = draggedField) {
|
||||
instance = mount(
|
||||
<ChildDragDropProvider dragging={draggingContext} setDragging={() => {}}>
|
||||
<InnerWorkspacePanel
|
||||
activeDatasourceId={'mock'}
|
||||
datasourceStates={{
|
||||
mock: {
|
||||
state: {},
|
||||
isLoading: false,
|
||||
},
|
||||
}}
|
||||
datasourceMap={{
|
||||
mock: mockDatasource,
|
||||
}}
|
||||
framePublicAPI={frame}
|
||||
activeVisualizationId={'vis'}
|
||||
visualizationMap={{
|
||||
vis: mockVisualization,
|
||||
vis2: mockVisualization2,
|
||||
}}
|
||||
visualizationState={{}}
|
||||
dispatch={mockDispatch}
|
||||
ExpressionRenderer={expressionRendererMock}
|
||||
/>
|
||||
</ChildDragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
it('should immediately transition if exactly one suggestion is returned', () => {
|
||||
const expectedTable: TableSuggestion = {
|
||||
isMultiRow: true,
|
||||
layerId: '1',
|
||||
columns: [],
|
||||
changeType: 'unchanged',
|
||||
};
|
||||
mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([
|
||||
{
|
||||
state: {},
|
||||
table: expectedTable,
|
||||
},
|
||||
]);
|
||||
mockVisualization.getSuggestions.mockReturnValueOnce([
|
||||
{
|
||||
score: 0.5,
|
||||
title: 'my title',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
]);
|
||||
initComponent();
|
||||
|
||||
instance.find(DragDrop).prop('onDrop')!(draggedField);
|
||||
|
||||
expect(mockDatasource.getDatasourceSuggestionsForField).toHaveBeenCalledTimes(1);
|
||||
expect(mockVisualization.getSuggestions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
table: expectedTable,
|
||||
})
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SWITCH_VISUALIZATION',
|
||||
newVisualizationId: 'vis',
|
||||
initialState: {},
|
||||
datasourceState: {},
|
||||
datasourceId: 'mock',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow to drop if there are suggestions', () => {
|
||||
mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([
|
||||
{
|
||||
state: {},
|
||||
table: {
|
||||
isMultiRow: true,
|
||||
layerId: '1',
|
||||
columns: [],
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockVisualization.getSuggestions.mockReturnValueOnce([
|
||||
{
|
||||
score: 0.5,
|
||||
title: 'my title',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
]);
|
||||
initComponent();
|
||||
expect(instance.find(DragDrop).prop('droppable')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should refuse to drop if there only suggestions from other visualizations if there are data tables', () => {
|
||||
frame.datasourceLayers.a = mockDatasource.publicAPIMock;
|
||||
mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'a' }]);
|
||||
mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([
|
||||
{
|
||||
state: {},
|
||||
table: {
|
||||
isMultiRow: true,
|
||||
layerId: '1',
|
||||
columns: [],
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockVisualization2.getSuggestions.mockReturnValueOnce([
|
||||
{
|
||||
score: 0.5,
|
||||
title: 'my title',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
]);
|
||||
initComponent();
|
||||
expect(instance.find(DragDrop).prop('droppable')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should allow to drop if there are suggestions from active visualization even if there are data tables', () => {
|
||||
frame.datasourceLayers.a = mockDatasource.publicAPIMock;
|
||||
mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'a' }]);
|
||||
mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([
|
||||
{
|
||||
state: {},
|
||||
table: {
|
||||
isMultiRow: true,
|
||||
layerId: '1',
|
||||
columns: [],
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockVisualization.getSuggestions.mockReturnValueOnce([
|
||||
{
|
||||
score: 0.5,
|
||||
title: 'my title',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
]);
|
||||
initComponent();
|
||||
expect(instance.find(DragDrop).prop('droppable')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should refuse to drop if there are no suggestions', () => {
|
||||
initComponent();
|
||||
expect(instance.find(DragDrop).prop('droppable')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should immediately transition to the first suggestion if there are multiple', () => {
|
||||
mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([
|
||||
{
|
||||
state: {},
|
||||
table: {
|
||||
isMultiRow: true,
|
||||
columns: [],
|
||||
layerId: '1',
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
},
|
||||
{
|
||||
state: {},
|
||||
table: {
|
||||
isMultiRow: true,
|
||||
columns: [],
|
||||
layerId: '1',
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockVisualization.getSuggestions.mockReturnValueOnce([
|
||||
{
|
||||
score: 0.5,
|
||||
title: 'second suggestion',
|
||||
state: {},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
]);
|
||||
mockVisualization.getSuggestions.mockReturnValueOnce([
|
||||
{
|
||||
score: 0.8,
|
||||
title: 'first suggestion',
|
||||
state: {
|
||||
isFirst: true,
|
||||
},
|
||||
previewIcon: 'empty',
|
||||
},
|
||||
]);
|
||||
|
||||
initComponent();
|
||||
instance.find(DragDrop).prop('onDrop')!(draggedField);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SWITCH_VISUALIZATION',
|
||||
newVisualizationId: 'vis',
|
||||
initialState: {
|
||||
isFirst: true,
|
||||
},
|
||||
datasourceState: {},
|
||||
datasourceId: 'mock',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useContext } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { toExpression } from '@kbn/interpreter/common';
|
||||
import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public';
|
||||
import { Action } from './state_management';
|
||||
import { Datasource, Visualization, FramePublicAPI } from '../../types';
|
||||
import { DragDrop, DragContext } from '../../drag_drop';
|
||||
import { getSuggestions, switchToSuggestion } from './suggestion_helpers';
|
||||
import { buildExpression } from './expression_helpers';
|
||||
import { debouncedComponent } from '../../debounced_component';
|
||||
|
||||
export interface WorkspacePanelProps {
|
||||
activeVisualizationId: string | null;
|
||||
visualizationMap: Record<string, Visualization>;
|
||||
visualizationState: unknown;
|
||||
activeDatasourceId: string | null;
|
||||
datasourceMap: Record<string, Datasource>;
|
||||
datasourceStates: Record<
|
||||
string,
|
||||
{
|
||||
state: unknown;
|
||||
isLoading: boolean;
|
||||
}
|
||||
>;
|
||||
framePublicAPI: FramePublicAPI;
|
||||
dispatch: (action: Action) => void;
|
||||
ExpressionRenderer: ExpressionRenderer;
|
||||
}
|
||||
|
||||
export const WorkspacePanel = debouncedComponent(InnerWorkspacePanel);
|
||||
|
||||
// Exported for testing purposes only.
|
||||
export function InnerWorkspacePanel({
|
||||
activeDatasourceId,
|
||||
activeVisualizationId,
|
||||
visualizationMap,
|
||||
visualizationState,
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
framePublicAPI,
|
||||
dispatch,
|
||||
ExpressionRenderer: ExpressionRendererComponent,
|
||||
}: WorkspacePanelProps) {
|
||||
const dragDropContext = useContext(DragContext);
|
||||
|
||||
const suggestionForDraggedField = useMemo(() => {
|
||||
if (!dragDropContext.dragging || !activeDatasourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasData = Object.values(framePublicAPI.datasourceLayers).some(
|
||||
datasource => datasource.getTableSpec().length > 0
|
||||
);
|
||||
|
||||
const suggestions = getSuggestions({
|
||||
datasourceMap: { [activeDatasourceId]: datasourceMap[activeDatasourceId] },
|
||||
datasourceStates,
|
||||
visualizationMap:
|
||||
hasData && activeVisualizationId
|
||||
? { [activeVisualizationId]: visualizationMap[activeVisualizationId] }
|
||||
: visualizationMap,
|
||||
activeVisualizationId,
|
||||
visualizationState,
|
||||
field: dragDropContext.dragging,
|
||||
});
|
||||
|
||||
return suggestions[0];
|
||||
}, [dragDropContext.dragging]);
|
||||
|
||||
function onDrop() {
|
||||
if (suggestionForDraggedField) {
|
||||
switchToSuggestion(framePublicAPI, dispatch, suggestionForDraggedField);
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmptyWorkspace() {
|
||||
return (
|
||||
<p data-test-subj="empty-workspace">
|
||||
<FormattedMessage
|
||||
id="xpack.lens.editorFrame.emptyWorkspace"
|
||||
defaultMessage="This is the workspace panel. Drop fields here"
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function renderVisualization() {
|
||||
const [expressionError, setExpressionError] = useState<unknown>(undefined);
|
||||
|
||||
const activeVisualization = activeVisualizationId
|
||||
? visualizationMap[activeVisualizationId]
|
||||
: null;
|
||||
const expression = useMemo(() => {
|
||||
try {
|
||||
return buildExpression({
|
||||
visualization: activeVisualization,
|
||||
visualizationState,
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
framePublicAPI,
|
||||
});
|
||||
} catch (e) {
|
||||
setExpressionError(e.toString());
|
||||
}
|
||||
}, [
|
||||
activeVisualization,
|
||||
visualizationState,
|
||||
datasourceMap,
|
||||
datasourceStates,
|
||||
framePublicAPI.dateRange,
|
||||
framePublicAPI.query,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// reset expression error if component attempts to run it again
|
||||
if (expressionError) {
|
||||
setExpressionError(undefined);
|
||||
}
|
||||
}, [expression]);
|
||||
|
||||
if (expression === null) {
|
||||
return renderEmptyWorkspace();
|
||||
}
|
||||
|
||||
if (expressionError) {
|
||||
return (
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem data-test-subj="expression-failure">
|
||||
{/* TODO word this differently because expressions should not be exposed at this level */}
|
||||
<FormattedMessage
|
||||
id="xpack.lens.editorFrame.expressionFailure"
|
||||
defaultMessage="Expression could not be executed successfully"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{expression && (
|
||||
<EuiFlexItem>
|
||||
<EuiCodeBlock>{toExpression(expression)}</EuiCodeBlock>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<EuiCodeBlock>{JSON.stringify(expressionError, null, 2)}</EuiCodeBlock>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ExpressionRendererComponent
|
||||
className="lnsExpressionOutput"
|
||||
expression={expression!}
|
||||
onRenderFailure={(e: unknown) => {
|
||||
setExpressionError(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDrop
|
||||
data-test-subj="lnsWorkspace"
|
||||
draggable={false}
|
||||
droppable={Boolean(suggestionForDraggedField)}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{renderVisualization()}
|
||||
</DragDrop>
|
||||
);
|
||||
}
|
|
@ -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<Action>;
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
}
|
||||
|
||||
export function WorkspacePanelWrapper({ children, title, dispatch }: Props) {
|
||||
return (
|
||||
<EuiPageContent className="lnsPageContent">
|
||||
<EuiPageContentHeader className="lnsPageContentHeader">
|
||||
<input
|
||||
type="text"
|
||||
className="euiFieldText lnsTitleInput"
|
||||
placeholder={i18n.translate('xpack.lens.chartTitlePlaceholder', {
|
||||
defaultMessage: 'Title',
|
||||
})}
|
||||
data-test-subj="lns_ChartTitle"
|
||||
value={title}
|
||||
onChange={e => dispatch({ type: 'UPDATE_TITLE', title: e.target.value })}
|
||||
aria-label={i18n.translate('xpack.lens.chartTitleAriaLabel', {
|
||||
defaultMessage: 'Title',
|
||||
})}
|
||||
/>
|
||||
</EuiPageContentHeader>
|
||||
<EuiPageContentBody className="lnsPageContentBody">{children}</EuiPageContentBody>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Embeddable } from './embeddable';
|
||||
import { TimeRange } from 'src/plugins/data/public';
|
||||
import { Query } from 'src/legacy/core_plugins/data/public';
|
||||
import { ExpressionRendererProps } from 'src/legacy/core_plugins/expressions/public';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { Document } from '../../persistence';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
jest.mock('../../../../../../../src/legacy/ui/public/inspector', () => ({
|
||||
isAvailable: false,
|
||||
open: false,
|
||||
}));
|
||||
|
||||
const savedVis: Document = {
|
||||
expression: 'my | expression',
|
||||
state: {
|
||||
visualization: {},
|
||||
datasourceStates: {},
|
||||
datasourceMetaData: {
|
||||
filterableIndexPatterns: [],
|
||||
},
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
},
|
||||
title: 'My title',
|
||||
visualizationType: '',
|
||||
};
|
||||
|
||||
describe('embeddable', () => {
|
||||
let mountpoint: HTMLDivElement;
|
||||
let expressionRenderer: jest.Mock<null, [ExpressionRendererProps]>;
|
||||
|
||||
beforeEach(() => {
|
||||
mountpoint = document.createElement('div');
|
||||
expressionRenderer = jest.fn(_props => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mountpoint.remove();
|
||||
});
|
||||
|
||||
it('should render expression with expression renderer', () => {
|
||||
const embeddable = new Embeddable(
|
||||
expressionRenderer,
|
||||
{
|
||||
editUrl: '',
|
||||
editable: true,
|
||||
savedVis,
|
||||
},
|
||||
{ id: '123' }
|
||||
);
|
||||
embeddable.render(mountpoint);
|
||||
|
||||
expect(expressionRenderer).toHaveBeenCalledTimes(1);
|
||||
expect(expressionRenderer.mock.calls[0][0]!.expression).toEqual(savedVis.expression);
|
||||
});
|
||||
|
||||
it('should display error if expression renderering fails', () => {
|
||||
const embeddable = new Embeddable(
|
||||
expressionRenderer,
|
||||
{
|
||||
editUrl: '',
|
||||
editable: true,
|
||||
savedVis,
|
||||
},
|
||||
{ id: '123' }
|
||||
);
|
||||
embeddable.render(mountpoint);
|
||||
|
||||
act(() => {
|
||||
expressionRenderer.mock.calls[0][0]!.onRenderFailure!({ type: 'error' });
|
||||
});
|
||||
|
||||
expect(mountpoint.innerHTML).toContain("Visualization couldn't be displayed");
|
||||
});
|
||||
|
||||
it('should re-render if new input is pushed', () => {
|
||||
const timeRange: TimeRange = { from: 'now-15d', to: 'now' };
|
||||
const query: Query = { language: 'kquery', query: '' };
|
||||
const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }];
|
||||
|
||||
const embeddable = new Embeddable(
|
||||
expressionRenderer,
|
||||
{
|
||||
editUrl: '',
|
||||
editable: true,
|
||||
savedVis,
|
||||
},
|
||||
{ id: '123' }
|
||||
);
|
||||
embeddable.render(mountpoint);
|
||||
|
||||
embeddable.updateInput({
|
||||
timeRange,
|
||||
query,
|
||||
filters,
|
||||
});
|
||||
|
||||
expect(expressionRenderer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should pass context to embeddable', () => {
|
||||
const timeRange: TimeRange = { from: 'now-15d', to: 'now' };
|
||||
const query: Query = { language: 'kquery', query: '' };
|
||||
const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }];
|
||||
|
||||
const embeddable = new Embeddable(
|
||||
expressionRenderer,
|
||||
{
|
||||
editUrl: '',
|
||||
editable: true,
|
||||
savedVis,
|
||||
},
|
||||
{ id: '123', timeRange, query, filters }
|
||||
);
|
||||
embeddable.render(mountpoint);
|
||||
|
||||
expect(expressionRenderer.mock.calls[0][0].searchContext).toEqual({
|
||||
type: 'kibana_context',
|
||||
timeRange,
|
||||
query,
|
||||
filters,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not re-render if only change is in disabled filter', () => {
|
||||
const timeRange: TimeRange = { from: 'now-15d', to: 'now' };
|
||||
const query: Query = { language: 'kquery', query: '' };
|
||||
const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: true } }];
|
||||
|
||||
const embeddable = new Embeddable(
|
||||
expressionRenderer,
|
||||
{
|
||||
editUrl: '',
|
||||
editable: true,
|
||||
savedVis,
|
||||
},
|
||||
{ id: '123', timeRange, query, filters }
|
||||
);
|
||||
embeddable.render(mountpoint);
|
||||
|
||||
embeddable.updateInput({
|
||||
timeRange,
|
||||
query,
|
||||
filters: [{ meta: { alias: 'test', negate: true, disabled: true } }],
|
||||
});
|
||||
|
||||
expect(expressionRenderer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import { render, unmountComponentAtNode } from 'react-dom';
|
||||
|
||||
import { TimeRange } from 'src/plugins/data/public';
|
||||
import { Query, StaticIndexPattern } from 'src/legacy/core_plugins/data/public';
|
||||
import { ExpressionRenderer } from 'src/legacy/core_plugins/expressions/public';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { Subscription } from 'rxjs';
|
||||
import {
|
||||
Embeddable as AbstractEmbeddable,
|
||||
EmbeddableOutput,
|
||||
IContainer,
|
||||
EmbeddableInput,
|
||||
} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public';
|
||||
import { Document, DOC_TYPE } from '../../persistence';
|
||||
import { ExpressionWrapper } from './expression_wrapper';
|
||||
|
||||
export interface LensEmbeddableConfiguration {
|
||||
savedVis: Document;
|
||||
editUrl: string;
|
||||
editable: boolean;
|
||||
indexPatterns?: StaticIndexPattern[];
|
||||
}
|
||||
|
||||
export interface LensEmbeddableInput extends EmbeddableInput {
|
||||
timeRange?: TimeRange;
|
||||
query?: Query;
|
||||
filters?: Filter[];
|
||||
}
|
||||
|
||||
export interface LensEmbeddableOutput extends EmbeddableOutput {
|
||||
indexPatterns?: StaticIndexPattern[];
|
||||
}
|
||||
|
||||
export class Embeddable extends AbstractEmbeddable<LensEmbeddableInput, LensEmbeddableOutput> {
|
||||
type = DOC_TYPE;
|
||||
|
||||
private expressionRenderer: ExpressionRenderer;
|
||||
private savedVis: Document;
|
||||
private domNode: HTMLElement | Element | undefined;
|
||||
private subscription: Subscription;
|
||||
|
||||
private currentContext: {
|
||||
timeRange?: TimeRange;
|
||||
query?: Query;
|
||||
filters?: Filter[];
|
||||
lastReloadRequestTime?: number;
|
||||
} = {};
|
||||
|
||||
constructor(
|
||||
expressionRenderer: ExpressionRenderer,
|
||||
{ savedVis, editUrl, editable, indexPatterns }: LensEmbeddableConfiguration,
|
||||
initialInput: LensEmbeddableInput,
|
||||
parent?: IContainer
|
||||
) {
|
||||
super(
|
||||
initialInput,
|
||||
{
|
||||
defaultTitle: savedVis.title,
|
||||
savedObjectId: savedVis.id,
|
||||
editable,
|
||||
// passing edit url and index patterns to the output of this embeddable for
|
||||
// the container to pick them up and use them to configure filter bar and
|
||||
// config dropdown correctly.
|
||||
editUrl,
|
||||
indexPatterns,
|
||||
},
|
||||
parent
|
||||
);
|
||||
|
||||
this.expressionRenderer = expressionRenderer;
|
||||
this.savedVis = savedVis;
|
||||
this.subscription = this.getInput$().subscribe(input => this.onContainerStateChanged(input));
|
||||
this.onContainerStateChanged(initialInput);
|
||||
}
|
||||
|
||||
onContainerStateChanged(containerState: LensEmbeddableInput) {
|
||||
const cleanedFilters = containerState.filters
|
||||
? containerState.filters.filter(filter => !filter.meta.disabled)
|
||||
: undefined;
|
||||
if (
|
||||
!_.isEqual(containerState.timeRange, this.currentContext.timeRange) ||
|
||||
!_.isEqual(containerState.query, this.currentContext.query) ||
|
||||
!_.isEqual(cleanedFilters, this.currentContext.filters)
|
||||
) {
|
||||
this.currentContext = {
|
||||
timeRange: containerState.timeRange,
|
||||
query: containerState.query,
|
||||
lastReloadRequestTime: this.currentContext.lastReloadRequestTime,
|
||||
filters: cleanedFilters,
|
||||
};
|
||||
|
||||
if (this.domNode) {
|
||||
this.render(this.domNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} domNode
|
||||
* @param {ContainerState} containerState
|
||||
*/
|
||||
render(domNode: HTMLElement | Element) {
|
||||
this.domNode = domNode;
|
||||
render(
|
||||
<ExpressionWrapper
|
||||
ExpressionRenderer={this.expressionRenderer}
|
||||
expression={this.savedVis.expression}
|
||||
context={this.currentContext}
|
||||
/>,
|
||||
domNode
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
if (this.domNode) {
|
||||
unmountComponentAtNode(this.domNode);
|
||||
}
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
reload() {
|
||||
const currentTime = Date.now();
|
||||
if (this.currentContext.lastReloadRequestTime !== currentTime) {
|
||||
this.currentContext = {
|
||||
...this.currentContext,
|
||||
lastReloadRequestTime: currentTime,
|
||||
};
|
||||
|
||||
if (this.domNode) {
|
||||
this.render(this.domNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import { Chrome } from 'ui/chrome';
|
||||
|
||||
import { capabilities } from 'ui/capabilities';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { IndexPatterns, IndexPattern } from 'src/legacy/core_plugins/data/public';
|
||||
import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public';
|
||||
import {
|
||||
EmbeddableFactory as AbstractEmbeddableFactory,
|
||||
ErrorEmbeddable,
|
||||
EmbeddableInput,
|
||||
IContainer,
|
||||
} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public';
|
||||
import { Embeddable } from './embeddable';
|
||||
import { SavedObjectIndexStore, DOC_TYPE } from '../../persistence';
|
||||
import { getEditPath } from '../../../common';
|
||||
|
||||
export class EmbeddableFactory extends AbstractEmbeddableFactory {
|
||||
type = DOC_TYPE;
|
||||
|
||||
private chrome: Chrome;
|
||||
private indexPatternService: IndexPatterns;
|
||||
private expressionRenderer: ExpressionRenderer;
|
||||
|
||||
constructor(
|
||||
chrome: Chrome,
|
||||
expressionRenderer: ExpressionRenderer,
|
||||
indexPatternService: IndexPatterns
|
||||
) {
|
||||
super({
|
||||
savedObjectMetaData: {
|
||||
name: i18n.translate('xpack.lens.lensSavedObjectLabel', {
|
||||
defaultMessage: 'Lens Visualization',
|
||||
}),
|
||||
type: DOC_TYPE,
|
||||
getIconForSavedObject: () => 'faceHappy',
|
||||
},
|
||||
});
|
||||
this.chrome = chrome;
|
||||
this.expressionRenderer = expressionRenderer;
|
||||
this.indexPatternService = indexPatternService;
|
||||
}
|
||||
|
||||
public isEditable() {
|
||||
return capabilities.get().lens.save as boolean;
|
||||
}
|
||||
|
||||
canCreateNew() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getDisplayName() {
|
||||
return i18n.translate('xpack.lens.embeddableDisplayName', {
|
||||
defaultMessage: 'lens',
|
||||
});
|
||||
}
|
||||
|
||||
async createFromSavedObject(
|
||||
savedObjectId: string,
|
||||
input: Partial<EmbeddableInput> & { id: string },
|
||||
parent?: IContainer
|
||||
) {
|
||||
const store = new SavedObjectIndexStore(this.chrome.getSavedObjectsClient());
|
||||
const savedVis = await store.load(savedObjectId);
|
||||
|
||||
const promises = savedVis.state.datasourceMetaData.filterableIndexPatterns.map(
|
||||
async ({ id }) => {
|
||||
try {
|
||||
return await this.indexPatternService.get(id);
|
||||
} catch (error) {
|
||||
// Unable to load index pattern, ignore error as the index patterns are only used to
|
||||
// configure the filter and query bar - there is still a good chance to get the visualization
|
||||
// to show.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
const indexPatterns = (await Promise.all(promises)).filter(
|
||||
(indexPattern: IndexPattern | null): indexPattern is IndexPattern => Boolean(indexPattern)
|
||||
);
|
||||
|
||||
return new Embeddable(
|
||||
this.expressionRenderer,
|
||||
{
|
||||
savedVis,
|
||||
editUrl: this.chrome.addBasePath(getEditPath(savedObjectId)),
|
||||
editable: this.isEditable(),
|
||||
indexPatterns,
|
||||
},
|
||||
input,
|
||||
parent
|
||||
);
|
||||
}
|
||||
|
||||
async create(input: EmbeddableInput) {
|
||||
return new ErrorEmbeddable('Lens can only be created from a saved object', input);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon } from '@elastic/eui';
|
||||
import { TimeRange } from 'src/plugins/data/public';
|
||||
import { Query } from 'src/legacy/core_plugins/data/public';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { ExpressionRenderer } from 'src/legacy/core_plugins/expressions/public';
|
||||
|
||||
export interface ExpressionWrapperProps {
|
||||
ExpressionRenderer: ExpressionRenderer;
|
||||
expression: string;
|
||||
context: {
|
||||
timeRange?: TimeRange;
|
||||
query?: Query;
|
||||
filters?: Filter[];
|
||||
lastReloadRequestTime?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function ExpressionWrapper({
|
||||
ExpressionRenderer: ExpressionRendererComponent,
|
||||
expression,
|
||||
context,
|
||||
}: ExpressionWrapperProps) {
|
||||
const [expressionError, setExpressionError] = useState<unknown>(undefined);
|
||||
useEffect(() => {
|
||||
// reset expression error if component attempts to run it again
|
||||
if (expressionError) {
|
||||
setExpressionError(undefined);
|
||||
}
|
||||
}, [expression, context]);
|
||||
return (
|
||||
<I18nProvider>
|
||||
{expression === '' || expressionError ? (
|
||||
<EuiFlexGroup direction="column" alignItems="center" justifyContent="center">
|
||||
<EuiFlexItem>
|
||||
<EuiIcon type="alert" color="danger" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.lens.embeddable.failure"
|
||||
defaultMessage="Visualization couldn't be displayed"
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
<ExpressionRendererComponent
|
||||
className="lnsExpressionOutput"
|
||||
expression={expression}
|
||||
onRenderFailure={(e: unknown) => {
|
||||
setExpressionError(e);
|
||||
}}
|
||||
searchContext={{ ...context, type: 'kibana_context' }}
|
||||
/>
|
||||
)}
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { mergeTables } from './merge_tables';
|
||||
import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public';
|
||||
|
||||
describe('lens_merge_tables', () => {
|
||||
it('should produce a row with the nested table as defined', () => {
|
||||
const sampleTable1: KibanaDatatable = {
|
||||
type: 'kibana_datatable',
|
||||
columns: [{ id: 'bucket', name: 'A' }, { id: 'count', name: 'Count' }],
|
||||
rows: [{ bucket: 'a', count: 5 }, { bucket: 'b', count: 10 }],
|
||||
};
|
||||
|
||||
const sampleTable2: KibanaDatatable = {
|
||||
type: 'kibana_datatable',
|
||||
columns: [{ id: 'bucket', name: 'C' }, { id: 'avg', name: 'Average' }],
|
||||
rows: [{ bucket: 'a', avg: 2.5 }, { bucket: 'b', avg: 9 }],
|
||||
};
|
||||
|
||||
expect(
|
||||
mergeTables.fn(
|
||||
null,
|
||||
{ layerIds: ['first', 'second'], tables: [sampleTable1, sampleTable2] },
|
||||
{}
|
||||
)
|
||||
).toEqual({
|
||||
tables: { first: sampleTable1, second: sampleTable2 },
|
||||
type: 'lens_multitable',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/types';
|
||||
import { KibanaDatatable } from '../../../../../../src/legacy/core_plugins/interpreter/common';
|
||||
import { LensMultiTable } from '../types';
|
||||
|
||||
interface MergeTables {
|
||||
layerIds: string[];
|
||||
tables: KibanaDatatable[];
|
||||
}
|
||||
|
||||
export const mergeTables: ExpressionFunction<
|
||||
'lens_merge_tables',
|
||||
null,
|
||||
MergeTables,
|
||||
LensMultiTable
|
||||
> = {
|
||||
name: 'lens_merge_tables',
|
||||
type: 'lens_multitable',
|
||||
help: i18n.translate('xpack.lens.functions.mergeTables.help', {
|
||||
defaultMessage: 'A helper to merge any number of kibana tables into a single table',
|
||||
}),
|
||||
args: {
|
||||
layerIds: {
|
||||
types: ['string'],
|
||||
help: '',
|
||||
multi: true,
|
||||
},
|
||||
tables: {
|
||||
types: ['kibana_datatable'],
|
||||
help: '',
|
||||
multi: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
types: ['null'],
|
||||
},
|
||||
fn(_ctx, { layerIds, tables }: MergeTables) {
|
||||
const resultTables: Record<string, KibanaDatatable> = {};
|
||||
tables.forEach((table, index) => {
|
||||
resultTables[layerIds[index]] = table;
|
||||
});
|
||||
return {
|
||||
type: 'lens_multitable',
|
||||
tables: resultTables,
|
||||
};
|
||||
},
|
||||
};
|
126
x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx
Normal file
126
x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx
Normal file
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ExpressionRendererProps } from 'src/legacy/core_plugins/expressions/public';
|
||||
import {
|
||||
ExpressionsSetup,
|
||||
ExpressionsStart,
|
||||
} from '../../../../../../src/legacy/core_plugins/expressions/public';
|
||||
import { DatasourcePublicAPI, FramePublicAPI, Visualization, Datasource } from '../types';
|
||||
import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './plugin';
|
||||
|
||||
export function createMockVisualization(): jest.Mocked<Visualization> {
|
||||
return {
|
||||
id: 'TEST_VIS',
|
||||
visualizationTypes: [
|
||||
{
|
||||
icon: 'empty',
|
||||
id: 'TEST_VIS',
|
||||
label: 'TEST',
|
||||
},
|
||||
],
|
||||
getDescription: jest.fn(_state => ({ label: '' })),
|
||||
switchVisualizationType: jest.fn((_, x) => x),
|
||||
getPersistableState: jest.fn(_state => _state),
|
||||
getSuggestions: jest.fn(_options => []),
|
||||
initialize: jest.fn((_frame, _state?) => ({})),
|
||||
renderConfigPanel: jest.fn(),
|
||||
toExpression: jest.fn((_state, _frame) => null),
|
||||
};
|
||||
}
|
||||
|
||||
export type DatasourceMock = jest.Mocked<Datasource> & {
|
||||
publicAPIMock: jest.Mocked<DatasourcePublicAPI>;
|
||||
};
|
||||
|
||||
export function createMockDatasource(): DatasourceMock {
|
||||
const publicAPIMock: jest.Mocked<DatasourcePublicAPI> = {
|
||||
getTableSpec: jest.fn(() => []),
|
||||
getOperationForColumnId: jest.fn(),
|
||||
renderDimensionPanel: jest.fn(),
|
||||
renderLayerPanel: jest.fn(),
|
||||
removeColumnInTableSpec: jest.fn(),
|
||||
moveColumnTo: jest.fn(),
|
||||
duplicateColumn: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
getDatasourceSuggestionsForField: jest.fn((_state, item) => []),
|
||||
getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []),
|
||||
getPersistableState: jest.fn(),
|
||||
getPublicAPI: jest.fn((_state, _setState, _layerId) => publicAPIMock),
|
||||
initialize: jest.fn((_state?) => Promise.resolve()),
|
||||
renderDataPanel: jest.fn(),
|
||||
toExpression: jest.fn((_frame, _state) => null),
|
||||
insertLayer: jest.fn((_state, _newLayerId) => {}),
|
||||
removeLayer: jest.fn((_state, _layerId) => {}),
|
||||
getLayers: jest.fn(_state => []),
|
||||
getMetaData: jest.fn(_state => ({ filterableIndexPatterns: [] })),
|
||||
|
||||
// this is an additional property which doesn't exist on real datasources
|
||||
// but can be used to validate whether specific API mock functions are called
|
||||
publicAPIMock,
|
||||
};
|
||||
}
|
||||
|
||||
export type FrameMock = jest.Mocked<FramePublicAPI>;
|
||||
|
||||
export function createMockFramePublicAPI(): FrameMock {
|
||||
return {
|
||||
datasourceLayers: {},
|
||||
addNewLayer: jest.fn(() => ''),
|
||||
removeLayers: jest.fn(),
|
||||
dateRange: { fromDate: 'now-7d', toDate: 'now' },
|
||||
query: { query: '', language: 'lucene' },
|
||||
};
|
||||
}
|
||||
|
||||
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type MockedSetupDependencies = Omit<EditorFrameSetupPlugins, 'expressions'> & {
|
||||
expressions: jest.Mocked<ExpressionsSetup>;
|
||||
};
|
||||
|
||||
export type MockedStartDependencies = Omit<EditorFrameStartPlugins, 'expressions'> & {
|
||||
expressions: jest.Mocked<ExpressionsStart>;
|
||||
};
|
||||
|
||||
export function createExpressionRendererMock(): jest.Mock<
|
||||
React.ReactElement,
|
||||
[ExpressionRendererProps]
|
||||
> {
|
||||
return jest.fn(_ => <span />);
|
||||
}
|
||||
|
||||
export function createMockSetupDependencies() {
|
||||
return ({
|
||||
data: {},
|
||||
expressions: {
|
||||
registerFunction: jest.fn(),
|
||||
registerRenderer: jest.fn(),
|
||||
},
|
||||
chrome: {
|
||||
getSavedObjectsClient: () => {},
|
||||
},
|
||||
} as unknown) as MockedSetupDependencies;
|
||||
}
|
||||
|
||||
export function createMockStartDependencies() {
|
||||
return ({
|
||||
data: {
|
||||
indexPatterns: {
|
||||
indexPatterns: {},
|
||||
},
|
||||
},
|
||||
expressions: {
|
||||
ExpressionRenderer: jest.fn(() => null),
|
||||
},
|
||||
embeddables: {
|
||||
registerEmbeddableFactory: jest.fn(),
|
||||
},
|
||||
} as unknown) as MockedStartDependencies;
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EditorFramePlugin } from './plugin';
|
||||
import { coreMock } from 'src/core/public/mocks';
|
||||
import {
|
||||
MockedSetupDependencies,
|
||||
MockedStartDependencies,
|
||||
createMockSetupDependencies,
|
||||
createMockStartDependencies,
|
||||
} from './mocks';
|
||||
|
||||
jest.mock('ui/new_platform');
|
||||
jest.mock('ui/chrome', () => ({
|
||||
getSavedObjectsClient: jest.fn(),
|
||||
}));
|
||||
|
||||
// mock away actual dependencies to prevent all of it being loaded
|
||||
jest.mock('../../../../../../src/legacy/core_plugins/interpreter/public/registries', () => {});
|
||||
jest.mock('../../../../../../src/legacy/core_plugins/data/public/legacy', () => ({
|
||||
start: {},
|
||||
setup: {},
|
||||
}));
|
||||
jest.mock('../../../../../../src/legacy/core_plugins/expressions/public/legacy', () => ({
|
||||
start: {},
|
||||
setup: {},
|
||||
}));
|
||||
jest.mock('./embeddable/embeddable_factory', () => ({
|
||||
EmbeddableFactory: class Mock {},
|
||||
}));
|
||||
|
||||
describe('editor_frame plugin', () => {
|
||||
let pluginInstance: EditorFramePlugin;
|
||||
let mountpoint: Element;
|
||||
let pluginSetupDependencies: MockedSetupDependencies;
|
||||
let pluginStartDependencies: MockedStartDependencies;
|
||||
|
||||
beforeEach(() => {
|
||||
pluginInstance = new EditorFramePlugin();
|
||||
mountpoint = document.createElement('div');
|
||||
pluginSetupDependencies = createMockSetupDependencies();
|
||||
pluginStartDependencies = createMockStartDependencies();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mountpoint.remove();
|
||||
});
|
||||
|
||||
it('should create an editor frame instance which mounts and unmounts', () => {
|
||||
expect(() => {
|
||||
pluginInstance.setup(coreMock.createSetup(), pluginSetupDependencies);
|
||||
const publicAPI = pluginInstance.start(coreMock.createStart(), pluginStartDependencies);
|
||||
const instance = publicAPI.createInstance({});
|
||||
instance.mount(mountpoint, {
|
||||
onError: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
dateRange: { fromDate: '', toDate: '' },
|
||||
query: { query: '', language: 'lucene' },
|
||||
});
|
||||
instance.unmount();
|
||||
}).not.toThrowError();
|
||||
});
|
||||
|
||||
it('should not have child nodes after unmount', () => {
|
||||
pluginInstance.setup(coreMock.createSetup(), pluginSetupDependencies);
|
||||
const publicAPI = pluginInstance.start(coreMock.createStart(), pluginStartDependencies);
|
||||
const instance = publicAPI.createInstance({});
|
||||
instance.mount(mountpoint, {
|
||||
onError: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
dateRange: { fromDate: '', toDate: '' },
|
||||
query: { query: '', language: 'lucene' },
|
||||
});
|
||||
instance.unmount();
|
||||
|
||||
expect(mountpoint.hasChildNodes()).toBe(false);
|
||||
});
|
||||
});
|
140
x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx
Normal file
140
x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, unmountComponentAtNode } from 'react-dom';
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import { CoreSetup, CoreStart } from 'src/core/public';
|
||||
import chrome, { Chrome } from 'ui/chrome';
|
||||
import { npSetup, npStart } from 'ui/new_platform';
|
||||
import { Plugin as EmbeddablePlugin } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public';
|
||||
import { start as embeddablePlugin } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy';
|
||||
import {
|
||||
setup as dataSetup,
|
||||
start as dataStart,
|
||||
} from '../../../../../../src/legacy/core_plugins/data/public/legacy';
|
||||
import {
|
||||
setup as expressionsSetup,
|
||||
start as expressionsStart,
|
||||
} from '../../../../../../src/legacy/core_plugins/expressions/public/legacy';
|
||||
import {
|
||||
Datasource,
|
||||
Visualization,
|
||||
EditorFrameSetup,
|
||||
EditorFrameInstance,
|
||||
EditorFrameStart,
|
||||
} from '../types';
|
||||
import { EditorFrame } from './editor_frame';
|
||||
import { mergeTables } from './merge_tables';
|
||||
import { EmbeddableFactory } from './embeddable/embeddable_factory';
|
||||
import { getActiveDatasourceIdFromDoc } from './editor_frame/state_management';
|
||||
|
||||
export interface EditorFrameSetupPlugins {
|
||||
data: typeof dataSetup;
|
||||
expressions: typeof expressionsSetup;
|
||||
}
|
||||
|
||||
export interface EditorFrameStartPlugins {
|
||||
data: typeof dataStart;
|
||||
expressions: typeof expressionsStart;
|
||||
embeddables: ReturnType<EmbeddablePlugin['start']>;
|
||||
chrome: Chrome;
|
||||
}
|
||||
|
||||
export class EditorFramePlugin {
|
||||
constructor() {}
|
||||
|
||||
private readonly datasources: Record<string, Datasource> = {};
|
||||
private readonly visualizations: Record<string, Visualization> = {};
|
||||
|
||||
public setup(core: CoreSetup, plugins: EditorFrameSetupPlugins): EditorFrameSetup {
|
||||
plugins.expressions.registerFunction(() => mergeTables);
|
||||
|
||||
return {
|
||||
registerDatasource: (name, datasource) => {
|
||||
this.datasources[name] = datasource as Datasource<unknown, unknown>;
|
||||
},
|
||||
registerVisualization: visualization => {
|
||||
this.visualizations[visualization.id] = visualization as Visualization<unknown, unknown>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public start(core: CoreStart, plugins: EditorFrameStartPlugins): EditorFrameStart {
|
||||
plugins.embeddables.registerEmbeddableFactory(
|
||||
'lens',
|
||||
new EmbeddableFactory(
|
||||
plugins.chrome,
|
||||
plugins.expressions.ExpressionRenderer,
|
||||
plugins.data.indexPatterns.indexPatterns
|
||||
)
|
||||
);
|
||||
|
||||
const createInstance = (): EditorFrameInstance => {
|
||||
let domElement: Element;
|
||||
return {
|
||||
mount: (element, { doc, onError, dateRange, query, onChange }) => {
|
||||
domElement = element;
|
||||
const firstDatasourceId = Object.keys(this.datasources)[0];
|
||||
const firstVisualizationId = Object.keys(this.visualizations)[0];
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<EditorFrame
|
||||
data-test-subj="lnsEditorFrame"
|
||||
onError={onError}
|
||||
datasourceMap={this.datasources}
|
||||
visualizationMap={this.visualizations}
|
||||
initialDatasourceId={getActiveDatasourceIdFromDoc(doc) || firstDatasourceId || null}
|
||||
initialVisualizationId={
|
||||
(doc && doc.visualizationType) || firstVisualizationId || null
|
||||
}
|
||||
core={core}
|
||||
ExpressionRenderer={plugins.expressions.ExpressionRenderer}
|
||||
doc={doc}
|
||||
dateRange={dateRange}
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
domElement
|
||||
);
|
||||
},
|
||||
unmount() {
|
||||
if (domElement) {
|
||||
unmountComponentAtNode(domElement);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
createInstance,
|
||||
};
|
||||
}
|
||||
|
||||
public stop() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const editorFrame = new EditorFramePlugin();
|
||||
|
||||
export const editorFrameSetup = () =>
|
||||
editorFrame.setup(npSetup.core, {
|
||||
data: dataSetup,
|
||||
expressions: expressionsSetup,
|
||||
});
|
||||
|
||||
export const editorFrameStart = () =>
|
||||
editorFrame.start(npStart.core, {
|
||||
data: dataStart,
|
||||
expressions: expressionsStart,
|
||||
chrome,
|
||||
embeddables: embeddablePlugin,
|
||||
});
|
||||
|
||||
export const editorFrameStop = () => editorFrame.stop();
|
|
@ -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());
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
7
x-pack/legacy/plugins/lens/public/id_generator/index.ts
Normal file
7
x-pack/legacy/plugins/lens/public/id_generator/index.ts
Normal file
|
@ -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';
|
10
x-pack/legacy/plugins/lens/public/index.scss
Normal file
10
x-pack/legacy/plugins/lens/public/index.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Import the EUI global scope so we can use EUI constants
|
||||
@import 'src/legacy/ui/public/styles/_styling_constants';
|
||||
|
||||
@import "./app_plugin/index";
|
||||
@import './xy_visualization_plugin/index';
|
||||
@import './datatable_visualization_plugin/index';
|
||||
@import './xy_visualization_plugin/xy_expression.scss';
|
||||
@import './indexpattern_plugin/indexpattern';
|
||||
@import './drag_drop/drag_drop.scss';
|
||||
@import './editor_frame_plugin/editor_frame/index';
|
38
x-pack/legacy/plugins/lens/public/index.ts
Normal file
38
x-pack/legacy/plugins/lens/public/index.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
|
||||
import 'ui/autoload/all';
|
||||
// Used for kuery autocomplete
|
||||
import 'uiExports/autocompleteProviders';
|
||||
// Used to run esaggs queries
|
||||
import 'uiExports/fieldFormats';
|
||||
import 'uiExports/search';
|
||||
import 'uiExports/visRequestHandlers';
|
||||
import 'uiExports/visResponseHandlers';
|
||||
// Used for kibana_context function
|
||||
import 'uiExports/savedObjectTypes';
|
||||
|
||||
import { render, unmountComponentAtNode } from 'react-dom';
|
||||
import { IScope } from 'angular';
|
||||
import chrome from 'ui/chrome';
|
||||
import { appStart, appSetup, appStop } from './app_plugin';
|
||||
import { PLUGIN_ID } from '../common';
|
||||
|
||||
// TODO: Convert this to the "new platform" way of doing UI
|
||||
function Root($scope: IScope, $element: JQLite) {
|
||||
const el = $element[0];
|
||||
$scope.$on('$destroy', () => {
|
||||
unmountComponentAtNode(el);
|
||||
appStop();
|
||||
});
|
||||
|
||||
appSetup();
|
||||
return render(appStart(), el);
|
||||
}
|
||||
|
||||
chrome.setRootController(PLUGIN_ID, Root);
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { createMockedIndexPattern, createMockedRestrictedIndexPattern } from '../mocks';
|
||||
|
||||
export function getIndexPatterns() {
|
||||
return new Promise(resolve => {
|
||||
resolve([createMockedIndexPattern(), createMockedRestrictedIndexPattern()]);
|
||||
});
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
const actual = jest.requireActual('../state_helpers');
|
||||
|
||||
jest.spyOn(actual, 'changeColumn');
|
||||
jest.spyOn(actual, 'updateLayerIndexPattern');
|
||||
|
||||
export const {
|
||||
getColumnOrder,
|
||||
changeColumn,
|
||||
deleteColumn,
|
||||
updateColumnParam,
|
||||
sortByField,
|
||||
hasField,
|
||||
updateLayerIndexPattern,
|
||||
isLayerTransferable,
|
||||
} = actual;
|
|
@ -0,0 +1,585 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { EuiComboBox } from '@elastic/eui';
|
||||
import { IndexPatternPrivateState, IndexPatternColumn } from './indexpattern';
|
||||
import { createMockedDragDropContext } from './mocks';
|
||||
import { InnerIndexPatternDataPanel, IndexPatternDataPanel, MemoizedDataPanel } from './datapanel';
|
||||
import { FieldItem } from './field_item';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { coreMock } from 'src/core/public/mocks';
|
||||
|
||||
jest.mock('ui/new_platform');
|
||||
jest.mock('./loader');
|
||||
jest.mock('../../../../../../src/legacy/ui/public/registry/field_formats');
|
||||
|
||||
const waitForPromises = () => new Promise(resolve => setTimeout(resolve));
|
||||
|
||||
const initialState: IndexPatternPrivateState = {
|
||||
currentIndexPatternId: '1',
|
||||
showEmptyFields: false,
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'My Op',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
operationType: 'terms',
|
||||
sourceField: 'source',
|
||||
params: {
|
||||
size: 5,
|
||||
orderDirection: 'asc',
|
||||
orderBy: {
|
||||
type: 'alphabetical',
|
||||
},
|
||||
},
|
||||
},
|
||||
col2: {
|
||||
label: 'My Op',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
sourceField: 'memory',
|
||||
},
|
||||
},
|
||||
},
|
||||
second: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'My Op',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
operationType: 'terms',
|
||||
sourceField: 'source',
|
||||
params: {
|
||||
size: 5,
|
||||
orderDirection: 'asc',
|
||||
orderBy: {
|
||||
type: 'alphabetical',
|
||||
},
|
||||
},
|
||||
},
|
||||
col2: {
|
||||
label: 'My Op',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
indexPatterns: {
|
||||
'1': {
|
||||
id: '1',
|
||||
title: 'my-fake-index-pattern',
|
||||
timeFieldName: 'timestamp',
|
||||
fields: [
|
||||
{
|
||||
name: 'timestamp',
|
||||
type: 'date',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'bytes',
|
||||
type: 'number',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
type: 'number',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'unsupported',
|
||||
type: 'geo',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
type: 'string',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
title: 'my-fake-restricted-pattern',
|
||||
timeFieldName: 'timestamp',
|
||||
fields: [
|
||||
{
|
||||
name: 'timestamp',
|
||||
type: 'date',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
aggregationRestrictions: {
|
||||
date_histogram: {
|
||||
agg: 'date_histogram',
|
||||
fixed_interval: '1d',
|
||||
delay: '7d',
|
||||
time_zone: 'UTC',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'bytes',
|
||||
type: 'number',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
aggregationRestrictions: {
|
||||
histogram: {
|
||||
agg: 'histogram',
|
||||
interval: 1000,
|
||||
},
|
||||
max: {
|
||||
agg: 'max',
|
||||
},
|
||||
min: {
|
||||
agg: 'min',
|
||||
},
|
||||
sum: {
|
||||
agg: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
type: 'string',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
aggregationRestrictions: {
|
||||
terms: {
|
||||
agg: 'terms',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'3': {
|
||||
id: '3',
|
||||
title: 'my-compatible-pattern',
|
||||
timeFieldName: 'timestamp',
|
||||
fields: [
|
||||
{
|
||||
name: 'timestamp',
|
||||
type: 'date',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'bytes',
|
||||
type: 'number',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
type: 'string',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
describe('IndexPattern Data Panel', () => {
|
||||
let defaultProps: Parameters<typeof InnerIndexPatternDataPanel>[0];
|
||||
let core: ReturnType<typeof coreMock['createSetup']>;
|
||||
|
||||
beforeEach(() => {
|
||||
core = coreMock.createSetup();
|
||||
defaultProps = {
|
||||
dragDropContext: createMockedDragDropContext(),
|
||||
currentIndexPatternId: '1',
|
||||
indexPatterns: initialState.indexPatterns,
|
||||
showIndexPatternSwitcher: false,
|
||||
setShowIndexPatternSwitcher: jest.fn(),
|
||||
onChangeIndexPattern: jest.fn(),
|
||||
core,
|
||||
dateRange: {
|
||||
fromDate: 'now-7d',
|
||||
toDate: 'now',
|
||||
},
|
||||
query: { query: '', language: 'lucene' },
|
||||
showEmptyFields: false,
|
||||
onToggleEmptyFields: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should update index pattern of layer on switch if it is a single empty one', async () => {
|
||||
const setStateSpy = jest.fn();
|
||||
const wrapper = shallow(
|
||||
<IndexPatternDataPanel
|
||||
{...defaultProps}
|
||||
state={{
|
||||
...initialState,
|
||||
layers: { first: { indexPatternId: '1', columnOrder: [], columns: {} } },
|
||||
}}
|
||||
setState={setStateSpy}
|
||||
dragDropContext={{ dragging: {}, setDragging: () => {} }}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<IndexPatternDataPanel
|
||||
{...defaultProps}
|
||||
state={state}
|
||||
setState={setStateSpy}
|
||||
dragDropContext={{ dragging: {}, setDragging: () => {} }}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<IndexPatternDataPanel
|
||||
{...defaultProps}
|
||||
state={state}
|
||||
setState={setStateSpy}
|
||||
dragDropContext={{ dragging: {}, setDragging: () => {} }}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<InnerIndexPatternDataPanel {...defaultProps} currentIndexPatternId="" indexPatterns={{}} />
|
||||
);
|
||||
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(<InnerIndexPatternDataPanel {...defaultProps} />);
|
||||
|
||||
wrapper.find('[data-test-subj="indexPattern-switch-link"]').simulate('click');
|
||||
|
||||
expect(defaultProps.setShowIndexPatternSwitcher).toHaveBeenCalledWith(true);
|
||||
|
||||
wrapper.setProps({ showIndexPatternSwitcher: true });
|
||||
|
||||
const comboBox = wrapper.find(EuiComboBox);
|
||||
|
||||
comboBox.prop('onChange')!([
|
||||
{
|
||||
label: initialState.indexPatterns['2'].title,
|
||||
value: '2',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(defaultProps.onChangeIndexPattern).toHaveBeenCalledWith('2');
|
||||
});
|
||||
|
||||
describe('loading existence data', () => {
|
||||
beforeEach(() => {
|
||||
core.http.post.mockClear();
|
||||
});
|
||||
|
||||
it('loads existence data and updates the index pattern', async () => {
|
||||
core.http.post.mockResolvedValue({
|
||||
timestamp: {
|
||||
exists: true,
|
||||
cardinality: 500,
|
||||
count: 500,
|
||||
},
|
||||
});
|
||||
const updateFields = jest.fn();
|
||||
mount(<InnerIndexPatternDataPanel {...defaultProps} updateFieldsWithCounts={updateFields} />);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(core.http.post).toHaveBeenCalledWith(`/api/lens/index_stats/my-fake-index-pattern`, {
|
||||
body: JSON.stringify({
|
||||
fromDate: 'now-7d',
|
||||
toDate: 'now',
|
||||
size: 500,
|
||||
timeFieldName: 'timestamp',
|
||||
fields: [
|
||||
{
|
||||
name: 'timestamp',
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
name: 'bytes',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'unsupported',
|
||||
type: 'geo',
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(updateFields).toHaveBeenCalledWith('1', [
|
||||
{
|
||||
name: 'timestamp',
|
||||
type: 'date',
|
||||
exists: true,
|
||||
cardinality: 500,
|
||||
count: 500,
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
...defaultProps.indexPatterns['1'].fields
|
||||
.slice(1)
|
||||
.map(field => ({ ...field, exists: false })),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not attempt to load existence data if the index pattern has it', async () => {
|
||||
const updateFields = jest.fn();
|
||||
const newIndexPatterns = {
|
||||
...defaultProps.indexPatterns,
|
||||
'1': {
|
||||
...defaultProps.indexPatterns['1'],
|
||||
hasExistence: true,
|
||||
},
|
||||
};
|
||||
|
||||
const props = { ...defaultProps, indexPatterns: newIndexPatterns };
|
||||
|
||||
mount(<InnerIndexPatternDataPanel {...props} updateFieldsWithCounts={updateFields} />);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(core.http.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('while showing empty fields', () => {
|
||||
it('should list all supported fields in the pattern sorted alphabetically', async () => {
|
||||
const wrapper = shallow(
|
||||
<InnerIndexPatternDataPanel {...defaultProps} showEmptyFields={true} />
|
||||
);
|
||||
|
||||
expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([
|
||||
'bytes',
|
||||
'memory',
|
||||
'source',
|
||||
'timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter down by name', () => {
|
||||
const wrapper = shallow(
|
||||
<InnerIndexPatternDataPanel {...defaultProps} showEmptyFields={true} />
|
||||
);
|
||||
|
||||
act(() => {
|
||||
wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').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', () => {
|
||||
const wrapper = mount(
|
||||
<InnerIndexPatternDataPanel {...defaultProps} showEmptyFields={true} />
|
||||
);
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="lnsIndexPatternFiltersToggle"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="typeFilter-number"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
|
||||
expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([
|
||||
'bytes',
|
||||
'memory',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should toggle type if clicked again', () => {
|
||||
const wrapper = mount(
|
||||
<InnerIndexPatternDataPanel {...defaultProps} showEmptyFields={true} />
|
||||
);
|
||||
|
||||
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(
|
||||
<InnerIndexPatternDataPanel {...defaultProps} showEmptyFields={true} />
|
||||
);
|
||||
|
||||
act(() => {
|
||||
wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').prop('onChange')!({
|
||||
target: { value: 'mem' },
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="lnsIndexPatternFiltersToggle"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="typeFilter-number"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
|
||||
expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([
|
||||
'memory',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering out empty fields', () => {
|
||||
let emptyFieldsTestProps: typeof defaultProps;
|
||||
|
||||
beforeEach(() => {
|
||||
emptyFieldsTestProps = {
|
||||
...defaultProps,
|
||||
indexPatterns: {
|
||||
...defaultProps.indexPatterns,
|
||||
'1': {
|
||||
...defaultProps.indexPatterns['1'],
|
||||
hasExistence: true,
|
||||
fields: defaultProps.indexPatterns['1'].fields.map(field => ({
|
||||
...field,
|
||||
exists: field.type === 'number',
|
||||
})),
|
||||
},
|
||||
},
|
||||
onToggleEmptyFields: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should list all supported fields in the pattern sorted alphabetically', async () => {
|
||||
const wrapper = shallow(<InnerIndexPatternDataPanel {...emptyFieldsTestProps} />);
|
||||
|
||||
expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([
|
||||
'bytes',
|
||||
'memory',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter down by name', () => {
|
||||
const wrapper = shallow(
|
||||
<InnerIndexPatternDataPanel {...emptyFieldsTestProps} showEmptyFields={true} />
|
||||
);
|
||||
|
||||
act(() => {
|
||||
wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').prop('onChange')!({
|
||||
target: { value: 'mem' },
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([
|
||||
'memory',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should allow removing the filter for data', () => {
|
||||
const wrapper = mount(<InnerIndexPatternDataPanel {...emptyFieldsTestProps} />);
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="lnsIndexPatternFiltersToggle"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="lnsEmptyFilter"]')
|
||||
.first()
|
||||
.prop('onChange')!({} as ChangeEvent);
|
||||
|
||||
expect(emptyFieldsTestProps.onToggleEmptyFields).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,567 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { mapValues, uniq, indexBy } from 'lodash';
|
||||
import React, { useState, useEffect, memo, useCallback } from 'react';
|
||||
import {
|
||||
EuiComboBox,
|
||||
EuiLoadingSpinner,
|
||||
// @ts-ignore
|
||||
EuiHighlight,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiTitle,
|
||||
EuiButtonEmpty,
|
||||
EuiContextMenuPanel,
|
||||
EuiContextMenuItem,
|
||||
EuiContextMenuPanelProps,
|
||||
EuiPopover,
|
||||
EuiPopoverTitle,
|
||||
EuiPopoverFooter,
|
||||
EuiCallOut,
|
||||
EuiText,
|
||||
EuiFormControlLayout,
|
||||
EuiSwitch,
|
||||
EuiIcon,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { Query } from 'src/plugins/data/common';
|
||||
import { DatasourceDataPanelProps, DataType } from '../types';
|
||||
import { IndexPatternPrivateState, IndexPatternField, IndexPattern } from './indexpattern';
|
||||
import { ChildDragDropProvider, DragContextState } from '../drag_drop';
|
||||
import { FieldItem } from './field_item';
|
||||
import { FieldIcon } from './field_icon';
|
||||
import { updateLayerIndexPattern } from './state_helpers';
|
||||
|
||||
// TODO the typings for EuiContextMenuPanel are incorrect - watchedItemProps is missing. This can be removed when the types are adjusted
|
||||
const FixedEuiContextMenuPanel = (EuiContextMenuPanel as unknown) as React.FunctionComponent<
|
||||
EuiContextMenuPanelProps & { watchedItemProps: string[] }
|
||||
>;
|
||||
|
||||
function sortFields(fieldA: IndexPatternField, fieldB: IndexPatternField) {
|
||||
return fieldA.name.localeCompare(fieldB.name, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
const supportedFieldTypes = ['string', 'number', 'boolean', 'date'];
|
||||
const PAGINATION_SIZE = 50;
|
||||
|
||||
const fieldTypeNames: Record<DataType, string> = {
|
||||
string: i18n.translate('xpack.lens.datatypes.string', { defaultMessage: 'string' }),
|
||||
number: i18n.translate('xpack.lens.datatypes.number', { defaultMessage: 'number' }),
|
||||
boolean: i18n.translate('xpack.lens.datatypes.boolean', { defaultMessage: 'boolean' }),
|
||||
date: i18n.translate('xpack.lens.datatypes.date', { defaultMessage: 'date' }),
|
||||
};
|
||||
|
||||
function isSingleEmptyLayer(layerMap: IndexPatternPrivateState['layers']) {
|
||||
const layers = Object.values(layerMap);
|
||||
return layers.length === 1 && layers[0].columnOrder.length === 0;
|
||||
}
|
||||
|
||||
export function IndexPatternDataPanel({
|
||||
setState,
|
||||
state,
|
||||
dragDropContext,
|
||||
core,
|
||||
query,
|
||||
dateRange,
|
||||
}: DatasourceDataPanelProps<IndexPatternPrivateState>) {
|
||||
const { indexPatterns, currentIndexPatternId } = state;
|
||||
const [showIndexPatternSwitcher, setShowIndexPatternSwitcher] = useState(false);
|
||||
|
||||
const onChangeIndexPattern = useCallback(
|
||||
(newIndexPattern: string) => {
|
||||
setState({
|
||||
...state,
|
||||
layers: isSingleEmptyLayer(state.layers)
|
||||
? mapValues(state.layers, layer =>
|
||||
updateLayerIndexPattern(layer, indexPatterns[newIndexPattern])
|
||||
)
|
||||
: state.layers,
|
||||
currentIndexPatternId: newIndexPattern,
|
||||
});
|
||||
},
|
||||
[state, setState]
|
||||
);
|
||||
|
||||
const updateFieldsWithCounts = useCallback(
|
||||
(indexPatternId: string, allFields: IndexPattern['fields']) => {
|
||||
setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
indexPatterns: {
|
||||
...prevState.indexPatterns,
|
||||
[indexPatternId]: {
|
||||
...prevState.indexPatterns[indexPatternId],
|
||||
hasExistence: true,
|
||||
fields: allFields,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[currentIndexPatternId, indexPatterns[currentIndexPatternId]]
|
||||
);
|
||||
|
||||
const onToggleEmptyFields = useCallback(() => {
|
||||
setState(prevState => ({ ...prevState, showEmptyFields: !prevState.showEmptyFields }));
|
||||
}, [state, setState]);
|
||||
|
||||
return (
|
||||
<MemoizedDataPanel
|
||||
showIndexPatternSwitcher={showIndexPatternSwitcher}
|
||||
setShowIndexPatternSwitcher={setShowIndexPatternSwitcher}
|
||||
currentIndexPatternId={currentIndexPatternId}
|
||||
indexPatterns={indexPatterns}
|
||||
query={query}
|
||||
dateRange={dateRange}
|
||||
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={
|
||||
!indexPatterns[currentIndexPatternId].hasExistence ? updateFieldsWithCounts : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type OverallFields = Record<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
cardinality: number;
|
||||
}
|
||||
>;
|
||||
|
||||
interface DataPanelState {
|
||||
isLoading: boolean;
|
||||
nameFilter: string;
|
||||
typeFilter: DataType[];
|
||||
isTypeFilterOpen: boolean;
|
||||
}
|
||||
|
||||
export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
|
||||
currentIndexPatternId,
|
||||
indexPatterns,
|
||||
query,
|
||||
dateRange,
|
||||
dragDropContext,
|
||||
showIndexPatternSwitcher,
|
||||
setShowIndexPatternSwitcher,
|
||||
onChangeIndexPattern,
|
||||
updateFieldsWithCounts,
|
||||
showEmptyFields,
|
||||
onToggleEmptyFields,
|
||||
core,
|
||||
}: Partial<DatasourceDataPanelProps> & {
|
||||
currentIndexPatternId: string;
|
||||
indexPatterns: Record<string, IndexPattern>;
|
||||
dateRange: DatasourceDataPanelProps['dateRange'];
|
||||
query: Query;
|
||||
core: DatasourceDataPanelProps['core'];
|
||||
dragDropContext: DragContextState;
|
||||
showIndexPatternSwitcher: boolean;
|
||||
setShowIndexPatternSwitcher: (show: boolean) => void;
|
||||
showEmptyFields: boolean;
|
||||
onToggleEmptyFields: () => void;
|
||||
onChangeIndexPattern?: (newId: string) => void;
|
||||
updateFieldsWithCounts?: (indexPatternId: string, fields: IndexPattern['fields']) => void;
|
||||
}) {
|
||||
if (Object.keys(indexPatterns).length === 0) {
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
gutterSize="m"
|
||||
className="lnsIndexPatternDataPanel"
|
||||
direction="column"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={null}>
|
||||
<EuiCallOut
|
||||
data-test-subj="indexPattern-no-indexpatterns"
|
||||
title={i18n.translate('xpack.lens.indexPattern.noPatternsLabel', {
|
||||
defaultMessage: 'No index patterns',
|
||||
})}
|
||||
color="warning"
|
||||
iconType="alert"
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.lens.indexPattern.noPatternsDescription"
|
||||
defaultMessage="Please create an index pattern or switch to another data source"
|
||||
/>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const [localState, setLocalState] = useState<DataPanelState>({
|
||||
isLoading: false,
|
||||
nameFilter: '',
|
||||
typeFilter: [],
|
||||
isTypeFilterOpen: false,
|
||||
});
|
||||
const [pageSize, setPageSize] = useState(PAGINATION_SIZE);
|
||||
const [scrollContainer, setScrollContainer] = useState<Element | undefined>(undefined);
|
||||
|
||||
const currentIndexPattern = indexPatterns[currentIndexPatternId];
|
||||
const allFields = currentIndexPattern.fields;
|
||||
const fieldByName = indexBy(allFields, 'name');
|
||||
|
||||
const lazyScroll = () => {
|
||||
if (scrollContainer) {
|
||||
const nearBottom =
|
||||
scrollContainer.scrollTop + scrollContainer.clientHeight >
|
||||
scrollContainer.scrollHeight * 0.9;
|
||||
if (nearBottom) {
|
||||
setPageSize(Math.min(pageSize * 1.5, allFields.length));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
setPageSize(PAGINATION_SIZE);
|
||||
lazyScroll();
|
||||
}
|
||||
}, [localState.nameFilter, localState.typeFilter, currentIndexPatternId, showEmptyFields]);
|
||||
|
||||
const availableFieldTypes = uniq(allFields.map(({ type }) => type)).filter(
|
||||
type => type in fieldTypeNames
|
||||
);
|
||||
|
||||
const displayedFields = allFields.filter(field => {
|
||||
if (!supportedFieldTypes.includes(field.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
localState.nameFilter.length &&
|
||||
!field.name.toLowerCase().includes(localState.nameFilter.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!showEmptyFields) {
|
||||
const indexField =
|
||||
currentIndexPattern && currentIndexPattern.hasExistence && fieldByName[field.name];
|
||||
if (localState.typeFilter.length > 0) {
|
||||
return (
|
||||
indexField && indexField.exists && localState.typeFilter.includes(field.type as DataType)
|
||||
);
|
||||
}
|
||||
return indexField && indexField.exists;
|
||||
}
|
||||
|
||||
if (localState.typeFilter.length > 0) {
|
||||
return localState.typeFilter.includes(field.type as DataType);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const paginatedFields = displayedFields.sort(sortFields).slice(0, pageSize);
|
||||
|
||||
// Side effect: Fetch field existence data when the index pattern is switched
|
||||
useEffect(() => {
|
||||
if (localState.isLoading || currentIndexPattern.hasExistence || !updateFieldsWithCounts) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalState(s => ({ ...s, isLoading: true }));
|
||||
|
||||
core.http
|
||||
.post(`/api/lens/index_stats/${currentIndexPattern.title}`, {
|
||||
body: JSON.stringify({
|
||||
fromDate: dateRange.fromDate,
|
||||
toDate: dateRange.toDate,
|
||||
size: 500,
|
||||
timeFieldName: currentIndexPattern.timeFieldName,
|
||||
fields: allFields
|
||||
.filter(field => field.aggregatable)
|
||||
.map(field => ({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
.then((results: OverallFields) => {
|
||||
setLocalState(s => ({
|
||||
...s,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
if (!updateFieldsWithCounts) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateFieldsWithCounts(
|
||||
currentIndexPatternId,
|
||||
allFields.map(field => {
|
||||
const matching = results[field.name];
|
||||
if (!matching) {
|
||||
return { ...field, exists: false };
|
||||
}
|
||||
return {
|
||||
...field,
|
||||
exists: true,
|
||||
cardinality: matching.cardinality,
|
||||
count: matching.count,
|
||||
};
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setLocalState(s => ({ ...s, isLoading: false }));
|
||||
});
|
||||
}, [currentIndexPatternId]);
|
||||
|
||||
return (
|
||||
<ChildDragDropProvider {...dragDropContext}>
|
||||
<EuiFlexGroup
|
||||
gutterSize="none"
|
||||
className="lnsIndexPatternDataPanel"
|
||||
direction="column"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={null}>
|
||||
<div className="lnsIndexPatternDataPanel__header">
|
||||
{!showIndexPatternSwitcher ? (
|
||||
<>
|
||||
<EuiTitle size="xxs">
|
||||
<h4
|
||||
className="lnsIndexPatternDataPanel__header"
|
||||
title={currentIndexPattern.title}
|
||||
>
|
||||
{currentIndexPattern.title}{' '}
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="indexPattern-switch-link"
|
||||
className="lnsIndexPatternDataPanel__changeLink"
|
||||
onClick={() => setShowIndexPatternSwitcher(true)}
|
||||
size="xs"
|
||||
>
|
||||
(
|
||||
<FormattedMessage
|
||||
id="xpack.lens.indexPatterns.changePatternLabel"
|
||||
defaultMessage="change"
|
||||
/>
|
||||
)
|
||||
</EuiButtonEmpty>
|
||||
</>
|
||||
) : (
|
||||
<EuiComboBox
|
||||
data-test-subj="indexPattern-switcher"
|
||||
options={Object.values(indexPatterns).map(({ title, id }) => ({
|
||||
label: title,
|
||||
value: id,
|
||||
}))}
|
||||
inputRef={el => {
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}}
|
||||
selectedOptions={
|
||||
currentIndexPatternId
|
||||
? [
|
||||
{
|
||||
label: currentIndexPattern.title,
|
||||
value: currentIndexPattern.id,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
isClearable={false}
|
||||
onBlur={() => {
|
||||
setShowIndexPatternSwitcher(false);
|
||||
}}
|
||||
onChange={choices => {
|
||||
onChangeIndexPattern!(choices[0].value as string);
|
||||
|
||||
setLocalState(s => ({
|
||||
...s,
|
||||
nameFilter: '',
|
||||
typeFilter: [],
|
||||
}));
|
||||
|
||||
setShowIndexPatternSwitcher(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup
|
||||
gutterSize="s"
|
||||
className="lnsIndexPatternDataPanel__filter-wrapper"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiFormControlLayout
|
||||
prepend={
|
||||
<EuiPopover
|
||||
id="dataPanelTypeFilter"
|
||||
panelClassName="euiFilterGroup__popoverPanel"
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
isOpen={localState.isTypeFilterOpen}
|
||||
closePopover={() =>
|
||||
setLocalState(s => ({ ...localState, isTypeFilterOpen: false }))
|
||||
}
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
onClick={() => {
|
||||
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',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiIcon type="filter" size="m" />
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
>
|
||||
<EuiPopoverTitle>
|
||||
{i18n.translate('xpack.lens.indexPatterns.filterByTypeLabel', {
|
||||
defaultMessage: 'Filter by type',
|
||||
})}
|
||||
</EuiPopoverTitle>
|
||||
<FixedEuiContextMenuPanel
|
||||
watchedItemProps={['icon', 'disabled']}
|
||||
data-test-subj="lnsIndexPatternTypeFilterOptions"
|
||||
items={(availableFieldTypes as DataType[]).map(type => (
|
||||
<EuiContextMenuItem
|
||||
key={type}
|
||||
icon={localState.typeFilter.includes(type) ? 'check' : 'empty'}
|
||||
data-test-subj={`typeFilter-${type}`}
|
||||
onClick={() =>
|
||||
setLocalState(s => ({
|
||||
...s,
|
||||
typeFilter: localState.typeFilter.includes(type)
|
||||
? localState.typeFilter.filter(t => t !== type)
|
||||
: [...localState.typeFilter, type],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<FieldIcon type={type} /> {fieldTypeNames[type]}
|
||||
</EuiContextMenuItem>
|
||||
))}
|
||||
/>
|
||||
<EuiPopoverFooter>
|
||||
<EuiSwitch
|
||||
checked={!showEmptyFields}
|
||||
onChange={() => {
|
||||
onToggleEmptyFields();
|
||||
}}
|
||||
label={i18n.translate('xpack.lens.indexPatterns.toggleEmptyFieldsSwitch', {
|
||||
defaultMessage: 'Only show fields with data',
|
||||
})}
|
||||
data-test-subj="lnsEmptyFilter"
|
||||
/>
|
||||
</EuiPopoverFooter>
|
||||
</EuiPopover>
|
||||
}
|
||||
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: [],
|
||||
}));
|
||||
},
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="euiFieldText euiFieldText--inGroup"
|
||||
data-test-subj="lnsIndexPatternFieldSearch"
|
||||
placeholder={i18n.translate('xpack.lens.indexPatterns.filterByNameLabel', {
|
||||
defaultMessage: 'Search fields',
|
||||
description:
|
||||
'Search the list of fields in the index pattern for the provided text',
|
||||
})}
|
||||
value={localState.nameFilter}
|
||||
onChange={e => {
|
||||
setLocalState({ ...localState, nameFilter: e.target.value });
|
||||
}}
|
||||
aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameAriaLabel', {
|
||||
defaultMessage: 'Search fields',
|
||||
})}
|
||||
/>
|
||||
</EuiFormControlLayout>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<div
|
||||
className="lnsFieldListPanel__list-wrapper"
|
||||
ref={el => {
|
||||
if (el && !el.dataset.dynamicScroll) {
|
||||
el.dataset.dynamicScroll = 'true';
|
||||
setScrollContainer(el);
|
||||
}
|
||||
}}
|
||||
onScroll={lazyScroll}
|
||||
>
|
||||
<div className="lnsFieldListPanel__list">
|
||||
{localState.isLoading && <EuiLoadingSpinner />}
|
||||
|
||||
{paginatedFields.map(field => {
|
||||
const overallField = fieldByName[field.name];
|
||||
return (
|
||||
<FieldItem
|
||||
indexPattern={currentIndexPattern}
|
||||
key={field.name}
|
||||
field={field}
|
||||
highlight={localState.nameFilter.toLowerCase()}
|
||||
exists={overallField ? !!overallField.exists : false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!localState.isLoading && paginatedFields.length === 0 && (
|
||||
<EuiText>
|
||||
{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 },
|
||||
})}
|
||||
</EuiText>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</ChildDragDropProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const MemoizedDataPanel = memo(InnerIndexPatternDataPanel);
|
|
@ -0,0 +1,2 @@
|
|||
@import './popover';
|
||||
@import './summary';
|
|
@ -0,0 +1,34 @@
|
|||
.lnsConfigPanel__summaryPopoverLeft,
|
||||
.lnsConfigPanel__summaryPopoverRight {
|
||||
padding: $euiSizeS;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__summaryPopoverLeft {
|
||||
padding-top: 0;
|
||||
background-color: $euiColorLightestShade;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__summaryPopoverRight {
|
||||
width: $euiSize * 20;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__fieldOption--incompatible {
|
||||
color: $euiColorLightShade;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__fieldOption--nonExistant {
|
||||
background-color: $euiColorLightestShade;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__operation {
|
||||
padding: $euiSizeXS;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__operation--selected {
|
||||
background-color: $euiColorLightShade;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__operation--incompatible {
|
||||
opacity: 0.7;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
.lnsConfigPanel__summary {
|
||||
@include euiFontSizeS;
|
||||
background: $euiColorEmptyShade;
|
||||
border-radius: $euiBorderRadius;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: $euiSizeXS;
|
||||
padding: $euiSizeS;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__summaryPopover {
|
||||
flex-grow: 1;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__summaryPopoverAnchor {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__summaryIcon {
|
||||
margin-right: $euiSizeXS;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__summaryLink {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lnsConfigPanel__summaryField {
|
||||
color: $euiColorPrimary;
|
||||
}
|
|
@ -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> = {}): 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(
|
||||
<BucketNestingEditor
|
||||
columnId="a"
|
||||
layer={{
|
||||
columnOrder: ['a', 'b', 'c'],
|
||||
columns: {
|
||||
a: mockCol({ suggestedPriority: 0 }),
|
||||
b: mockCol({ suggestedPriority: 1 }),
|
||||
c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: false }),
|
||||
},
|
||||
indexPatternId: 'foo',
|
||||
}}
|
||||
setColumns={jest.fn()}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<BucketNestingEditor
|
||||
columnId="a"
|
||||
layer={{
|
||||
columnOrder: ['b', 'a', 'c'],
|
||||
columns: {
|
||||
a: mockCol({ suggestedPriority: 0 }),
|
||||
b: mockCol({ suggestedPriority: 1 }),
|
||||
c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: false }),
|
||||
},
|
||||
indexPatternId: 'foo',
|
||||
}}
|
||||
setColumns={jest.fn()}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<BucketNestingEditor
|
||||
columnId="a"
|
||||
layer={{
|
||||
columnOrder: ['b', 'a', 'c'],
|
||||
columns: {
|
||||
a: mockCol({ suggestedPriority: 0 }),
|
||||
b: mockCol({ suggestedPriority: 1 }),
|
||||
c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: false }),
|
||||
},
|
||||
indexPatternId: 'foo',
|
||||
}}
|
||||
setColumns={setColumns}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<BucketNestingEditor
|
||||
columnId="a"
|
||||
layer={{
|
||||
columnOrder: ['a', 'b', 'c'],
|
||||
columns: {
|
||||
a: mockCol({ suggestedPriority: 0, operationType: 'avg', isBucketed: false }),
|
||||
b: mockCol({ suggestedPriority: 1, operationType: 'max', isBucketed: false }),
|
||||
c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: false }),
|
||||
},
|
||||
indexPatternId: 'foo',
|
||||
}}
|
||||
setColumns={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component.children().length).toBe(0);
|
||||
});
|
||||
|
||||
it('should display nothing if there is one bucket', () => {
|
||||
const component = mount(
|
||||
<BucketNestingEditor
|
||||
columnId="a"
|
||||
layer={{
|
||||
columnOrder: ['a', 'b', 'c'],
|
||||
columns: {
|
||||
a: mockCol({ suggestedPriority: 0 }),
|
||||
b: mockCol({ suggestedPriority: 1, operationType: 'max', isBucketed: false }),
|
||||
c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: false }),
|
||||
},
|
||||
indexPatternId: 'foo',
|
||||
}}
|
||||
setColumns={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component.children().length).toBe(0);
|
||||
});
|
||||
|
||||
it('should display a dropdown with the parent column selected if 3+ buckets', () => {
|
||||
const component = mount(
|
||||
<BucketNestingEditor
|
||||
columnId="a"
|
||||
layer={{
|
||||
columnOrder: ['c', 'a', 'b'],
|
||||
columns: {
|
||||
a: mockCol({ suggestedPriority: 0, operationType: 'count', isBucketed: true }),
|
||||
b: mockCol({ suggestedPriority: 1, operationType: 'max', isBucketed: true }),
|
||||
c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: true }),
|
||||
},
|
||||
indexPatternId: 'foo',
|
||||
}}
|
||||
setColumns={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<BucketNestingEditor
|
||||
columnId="a"
|
||||
layer={{
|
||||
columnOrder: ['c', 'a', 'b'],
|
||||
columns: {
|
||||
a: mockCol({ suggestedPriority: 0, operationType: 'count', isBucketed: true }),
|
||||
b: mockCol({ suggestedPriority: 1, operationType: 'max', isBucketed: true }),
|
||||
c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: true }),
|
||||
},
|
||||
indexPatternId: 'foo',
|
||||
}}
|
||||
setColumns={setColumns}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<BucketNestingEditor
|
||||
columnId="a"
|
||||
layer={{
|
||||
columnOrder: ['c', 'a', 'b'],
|
||||
columns: {
|
||||
a: mockCol({ suggestedPriority: 0, operationType: 'count', isBucketed: true }),
|
||||
b: mockCol({ suggestedPriority: 1, operationType: 'max', isBucketed: true }),
|
||||
c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: true }),
|
||||
},
|
||||
indexPatternId: 'foo',
|
||||
}}
|
||||
setColumns={setColumns}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<BucketNestingEditor
|
||||
columnId="b"
|
||||
layer={{
|
||||
columnOrder: ['c', 'a', 'b'],
|
||||
columns: {
|
||||
a: mockCol({ suggestedPriority: 0, operationType: 'count', isBucketed: true }),
|
||||
b: mockCol({ suggestedPriority: 1, operationType: 'max', isBucketed: true }),
|
||||
c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: true }),
|
||||
},
|
||||
indexPatternId: 'foo',
|
||||
}}
|
||||
setColumns={setColumns}
|
||||
/>
|
||||
);
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
<EuiFormRow>
|
||||
<>
|
||||
<EuiHorizontalRule margin="m" />
|
||||
<EuiSwitch
|
||||
data-test-subj="indexPattern-nesting-switch"
|
||||
label={i18n.translate('xpack.lens.xyChart.nestUnderTarget', {
|
||||
defaultMessage: 'Nest under {target}',
|
||||
values: { target: target.text },
|
||||
})}
|
||||
checked={!!prevColumn}
|
||||
onChange={() => {
|
||||
if (prevColumn) {
|
||||
setColumns(nestColumn(layer.columnOrder, columnId, target.value));
|
||||
} else {
|
||||
setColumns(nestColumn(layer.columnOrder, target.value, columnId));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFormRow>
|
||||
<>
|
||||
<EuiHorizontalRule margin="m" />
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.lens.xyChart.nestUnder', {
|
||||
defaultMessage: 'Nest under',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
<EuiSelect
|
||||
data-test-subj="indexPattern-nesting-select"
|
||||
options={[
|
||||
{
|
||||
value: '',
|
||||
text: i18n.translate('xpack.lens.xyChart.nestUnderRoot', {
|
||||
defaultMessage: 'Top level',
|
||||
}),
|
||||
},
|
||||
...aggColumns,
|
||||
]}
|
||||
value={prevColumn}
|
||||
onChange={e => setColumns(nestColumn(layer.columnOrder, e.target.value, columnId))}
|
||||
/>
|
||||
</>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { EuiButtonIcon } from '@elastic/eui';
|
||||
import { Storage } from 'ui/storage';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
UiSettingsClientContract,
|
||||
SavedObjectsClientContract,
|
||||
HttpServiceBase,
|
||||
} from 'src/core/public';
|
||||
import { DatasourceDimensionPanelProps, StateSetter } from '../../types';
|
||||
import {
|
||||
IndexPatternColumn,
|
||||
IndexPatternPrivateState,
|
||||
IndexPatternField,
|
||||
OperationType,
|
||||
} from '../indexpattern';
|
||||
|
||||
import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations';
|
||||
import { PopoverEditor } from './popover_editor';
|
||||
import { DragContextState, ChildDragDropProvider, DragDrop } from '../../drag_drop';
|
||||
import { changeColumn, deleteColumn } from '../state_helpers';
|
||||
import { isDraggedField, hasField } from '../utils';
|
||||
|
||||
export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & {
|
||||
state: IndexPatternPrivateState;
|
||||
setState: StateSetter<IndexPatternPrivateState>;
|
||||
dragDropContext: DragContextState;
|
||||
uiSettings: UiSettingsClientContract;
|
||||
storage: Storage;
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
layerId: string;
|
||||
http: HttpServiceBase;
|
||||
};
|
||||
|
||||
export interface OperationFieldSupportMatrix {
|
||||
operationByField: Partial<Record<string, OperationType[]>>;
|
||||
fieldByOperation: Partial<Record<OperationType, string[]>>;
|
||||
operationByDocument: OperationType[];
|
||||
}
|
||||
|
||||
export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPanel(
|
||||
props: IndexPatternDimensionPanelProps
|
||||
) {
|
||||
const layerId = props.layerId;
|
||||
const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId];
|
||||
|
||||
const operationFieldSupportMatrix = useMemo(() => {
|
||||
const filteredOperationsByMetadata = getAvailableOperationsByMetadata(
|
||||
currentIndexPattern
|
||||
).filter(operation => props.filterOperations(operation.operationMetaData));
|
||||
|
||||
const supportedOperationsByField: Partial<Record<string, OperationType[]>> = {};
|
||||
const supportedFieldsByOperation: Partial<Record<OperationType, string[]>> = {};
|
||||
const supportedOperationsByDocument: OperationType[] = [];
|
||||
filteredOperationsByMetadata.forEach(({ operations }) => {
|
||||
operations.forEach(operation => {
|
||||
if (operation.type === 'field') {
|
||||
if (supportedOperationsByField[operation.field]) {
|
||||
supportedOperationsByField[operation.field]!.push(operation.operationType);
|
||||
} else {
|
||||
supportedOperationsByField[operation.field] = [operation.operationType];
|
||||
}
|
||||
|
||||
if (supportedFieldsByOperation[operation.operationType]) {
|
||||
supportedFieldsByOperation[operation.operationType]!.push(operation.field);
|
||||
} else {
|
||||
supportedFieldsByOperation[operation.operationType] = [operation.field];
|
||||
}
|
||||
} else {
|
||||
supportedOperationsByDocument.push(operation.operationType);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
operationByField: _.mapValues(supportedOperationsByField, _.uniq),
|
||||
fieldByOperation: _.mapValues(supportedFieldsByOperation, _.uniq),
|
||||
operationByDocument: _.uniq(supportedOperationsByDocument),
|
||||
};
|
||||
}, [currentIndexPattern, props.filterOperations]);
|
||||
|
||||
const selectedColumn: IndexPatternColumn | null =
|
||||
props.state.layers[layerId].columns[props.columnId] || null;
|
||||
|
||||
function hasOperationForField(field: IndexPatternField) {
|
||||
return Boolean(operationFieldSupportMatrix.operationByField[field.name]);
|
||||
}
|
||||
|
||||
function canHandleDrop() {
|
||||
const { dragging } = props.dragDropContext;
|
||||
const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId;
|
||||
|
||||
return (
|
||||
isDraggedField(dragging) &&
|
||||
layerIndexPatternId === dragging.indexPatternId &&
|
||||
Boolean(hasOperationForField(dragging.field))
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChildDragDropProvider {...props.dragDropContext}>
|
||||
<DragDrop
|
||||
className="lnsConfigPanel__summary"
|
||||
data-test-subj="indexPattern-dropTarget"
|
||||
droppable={canHandleDrop()}
|
||||
onDrop={droppedItem => {
|
||||
if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) {
|
||||
// TODO: What do we do if we couldn't find a column?
|
||||
return;
|
||||
}
|
||||
|
||||
const operationsForNewField =
|
||||
operationFieldSupportMatrix.operationByField[droppedItem.field.name];
|
||||
|
||||
// We need to check if dragging in a new field, was just a field change on the same
|
||||
// index pattern and on the same operations (therefore checking if the new field supports
|
||||
// our previous operation)
|
||||
const hasFieldChanged =
|
||||
selectedColumn &&
|
||||
hasField(selectedColumn) &&
|
||||
selectedColumn.sourceField !== droppedItem.field.name &&
|
||||
operationsForNewField &&
|
||||
operationsForNewField.includes(selectedColumn.operationType);
|
||||
|
||||
// If only the field has changed use the onFieldChange method on the operation to get the
|
||||
// new column, otherwise use the regular buildColumn to get a new column.
|
||||
const newColumn = hasFieldChanged
|
||||
? changeField(selectedColumn, currentIndexPattern, droppedItem.field)
|
||||
: buildColumn({
|
||||
columns: props.state.layers[props.layerId].columns,
|
||||
indexPattern: currentIndexPattern,
|
||||
layerId,
|
||||
suggestedPriority: props.suggestedPriority,
|
||||
field: droppedItem.field,
|
||||
});
|
||||
|
||||
props.setState(
|
||||
changeColumn({
|
||||
state: props.state,
|
||||
layerId,
|
||||
columnId: props.columnId,
|
||||
newColumn,
|
||||
// If the field has changed, the onFieldChange method needs to take care of everything including moving
|
||||
// over params. If we create a new column above we want changeColumn to move over params.
|
||||
keepParams: !hasFieldChanged,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<PopoverEditor
|
||||
{...props}
|
||||
currentIndexPattern={currentIndexPattern}
|
||||
selectedColumn={selectedColumn}
|
||||
operationFieldSupportMatrix={operationFieldSupportMatrix}
|
||||
/>
|
||||
{selectedColumn && (
|
||||
<EuiButtonIcon
|
||||
data-test-subj="indexPattern-dimensionPopover-remove"
|
||||
iconType="cross"
|
||||
iconSize="s"
|
||||
size="s"
|
||||
color="danger"
|
||||
aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', {
|
||||
defaultMessage: 'Remove configuration',
|
||||
})}
|
||||
title={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', {
|
||||
defaultMessage: 'Remove configuration',
|
||||
})}
|
||||
onClick={() => {
|
||||
props.setState(
|
||||
deleteColumn({
|
||||
state: props.state,
|
||||
layerId,
|
||||
columnId: props.columnId,
|
||||
})
|
||||
);
|
||||
if (props.onRemove) {
|
||||
props.onRemove(props.columnId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DragDrop>
|
||||
</ChildDragDropProvider>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import React, { useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiComboBoxOptionProps } from '@elastic/eui';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
// @ts-ignore
|
||||
EuiHighlight,
|
||||
} from '@elastic/eui';
|
||||
import { OperationType, IndexPattern, IndexPatternField } from '../indexpattern';
|
||||
import { FieldIcon } from '../field_icon';
|
||||
import { DataType } from '../../types';
|
||||
import { OperationFieldSupportMatrix } from './dimension_panel';
|
||||
|
||||
export type FieldChoice =
|
||||
| { type: 'field'; field: string; operationType?: OperationType }
|
||||
| { type: 'document' };
|
||||
|
||||
export interface FieldSelectProps {
|
||||
currentIndexPattern: IndexPattern;
|
||||
showEmptyFields: boolean;
|
||||
fieldMap: Record<string, IndexPatternField>;
|
||||
incompatibleSelectedOperationType: OperationType | null;
|
||||
selectedColumnOperationType?: OperationType;
|
||||
selectedColumnSourceField?: string;
|
||||
operationFieldSupportMatrix: OperationFieldSupportMatrix;
|
||||
onChoose: (choice: FieldChoice) => void;
|
||||
onDeleteColumn: () => void;
|
||||
}
|
||||
|
||||
export function FieldSelect({
|
||||
currentIndexPattern,
|
||||
showEmptyFields,
|
||||
fieldMap,
|
||||
incompatibleSelectedOperationType,
|
||||
selectedColumnOperationType,
|
||||
selectedColumnSourceField,
|
||||
operationFieldSupportMatrix,
|
||||
onChoose,
|
||||
onDeleteColumn,
|
||||
}: FieldSelectProps) {
|
||||
const { operationByDocument, operationByField } = operationFieldSupportMatrix;
|
||||
|
||||
const memoizedFieldOptions = useMemo(() => {
|
||||
const fields = Object.keys(operationByField).sort();
|
||||
|
||||
function isCompatibleWithCurrentOperation(fieldName: string) {
|
||||
if (incompatibleSelectedOperationType) {
|
||||
return operationByField[fieldName]!.includes(incompatibleSelectedOperationType);
|
||||
}
|
||||
return (
|
||||
!selectedColumnOperationType ||
|
||||
operationByField[fieldName]!.includes(selectedColumnOperationType)
|
||||
);
|
||||
}
|
||||
|
||||
const isCurrentOperationApplicableWithoutField =
|
||||
(!selectedColumnOperationType && !incompatibleSelectedOperationType) ||
|
||||
operationByDocument.includes(
|
||||
incompatibleSelectedOperationType || selectedColumnOperationType!
|
||||
);
|
||||
|
||||
const fieldOptions = [];
|
||||
|
||||
if (operationByDocument.length > 0) {
|
||||
fieldOptions.push({
|
||||
label: i18n.translate('xpack.lens.indexPattern.documentField', {
|
||||
defaultMessage: 'Document',
|
||||
}),
|
||||
value: { type: 'document' },
|
||||
className: classNames({
|
||||
'lnsConfigPanel__fieldOption--incompatible': !isCurrentOperationApplicableWithoutField,
|
||||
}),
|
||||
'data-test-subj': `lns-documentOption${
|
||||
isCurrentOperationApplicableWithoutField ? '' : 'Incompatible'
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
fieldOptions.push({
|
||||
label: i18n.translate('xpack.lens.indexPattern.individualFieldsLabel', {
|
||||
defaultMessage: 'Individual fields',
|
||||
}),
|
||||
options: fields
|
||||
.map(field => ({
|
||||
label: field,
|
||||
value: {
|
||||
type: 'field',
|
||||
field,
|
||||
dataType: fieldMap[field].type,
|
||||
operationType:
|
||||
selectedColumnOperationType && isCompatibleWithCurrentOperation(field)
|
||||
? selectedColumnOperationType
|
||||
: undefined,
|
||||
},
|
||||
exists: fieldMap[field].exists || false,
|
||||
compatible: isCompatibleWithCurrentOperation(field),
|
||||
}))
|
||||
.filter(field => showEmptyFields || field.exists)
|
||||
.sort((a, b) => {
|
||||
if (a.compatible && !b.compatible) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.compatible && b.compatible) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
.map(({ label, value, compatible, exists }) => ({
|
||||
label,
|
||||
value,
|
||||
className: classNames({
|
||||
'lnsConfigPanel__fieldOption--incompatible': !compatible,
|
||||
'lnsConfigPanel__fieldOption--nonExistant': !exists,
|
||||
}),
|
||||
'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${label}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return fieldOptions;
|
||||
}, [
|
||||
incompatibleSelectedOperationType,
|
||||
selectedColumnOperationType,
|
||||
selectedColumnSourceField,
|
||||
operationFieldSupportMatrix,
|
||||
currentIndexPattern,
|
||||
fieldMap,
|
||||
showEmptyFields,
|
||||
]);
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
data-test-subj="indexPattern-dimension-field"
|
||||
placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', {
|
||||
defaultMessage: 'Field',
|
||||
})}
|
||||
options={(memoizedFieldOptions as unknown) as EuiComboBoxOptionProps[]}
|
||||
isInvalid={Boolean(incompatibleSelectedOperationType && selectedColumnOperationType)}
|
||||
selectedOptions={
|
||||
selectedColumnOperationType
|
||||
? selectedColumnSourceField
|
||||
? [
|
||||
{
|
||||
label: selectedColumnSourceField,
|
||||
value: { type: 'field', field: selectedColumnSourceField },
|
||||
},
|
||||
]
|
||||
: [memoizedFieldOptions[0]]
|
||||
: []
|
||||
}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
onChange={choices => {
|
||||
if (choices.length === 0) {
|
||||
onDeleteColumn();
|
||||
return;
|
||||
}
|
||||
|
||||
onChoose((choices[0].value as unknown) as FieldChoice);
|
||||
}}
|
||||
renderOption={(option, searchValue) => {
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={null}>
|
||||
<FieldIcon type={((option.value as unknown) as { dataType: DataType }).dataType} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiHighlight search={searchValue}>{option.label}</EuiHighlight>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -0,0 +1,412 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import {
|
||||
EuiPopover,
|
||||
EuiFlexItem,
|
||||
EuiFlexGroup,
|
||||
EuiSideNav,
|
||||
EuiCallOut,
|
||||
EuiFormRow,
|
||||
EuiFieldText,
|
||||
EuiLink,
|
||||
EuiButtonIcon,
|
||||
EuiTextColor,
|
||||
} from '@elastic/eui';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
IndexPatternColumn,
|
||||
OperationType,
|
||||
IndexPattern,
|
||||
IndexPatternField,
|
||||
} from '../indexpattern';
|
||||
import { IndexPatternDimensionPanelProps, OperationFieldSupportMatrix } from './dimension_panel';
|
||||
import {
|
||||
operationDefinitionMap,
|
||||
getOperationDisplay,
|
||||
buildColumn,
|
||||
changeField,
|
||||
} from '../operations';
|
||||
import { deleteColumn, changeColumn } from '../state_helpers';
|
||||
import { FieldSelect } from './field_select';
|
||||
import { hasField } from '../utils';
|
||||
import { BucketNestingEditor } from './bucket_nesting_editor';
|
||||
|
||||
const operationPanels = getOperationDisplay();
|
||||
|
||||
export function asOperationOptions(
|
||||
operationTypes: OperationType[],
|
||||
compatibleWithCurrentField: boolean
|
||||
) {
|
||||
return [...operationTypes]
|
||||
.sort((opType1, opType2) => {
|
||||
return operationPanels[opType1].displayName.localeCompare(
|
||||
operationPanels[opType2].displayName
|
||||
);
|
||||
})
|
||||
.map(operationType => ({
|
||||
operationType,
|
||||
compatibleWithCurrentField,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface PopoverEditorProps extends IndexPatternDimensionPanelProps {
|
||||
selectedColumn?: IndexPatternColumn;
|
||||
operationFieldSupportMatrix: OperationFieldSupportMatrix;
|
||||
currentIndexPattern: IndexPattern;
|
||||
}
|
||||
|
||||
export function PopoverEditor(props: PopoverEditorProps) {
|
||||
const {
|
||||
selectedColumn,
|
||||
operationFieldSupportMatrix,
|
||||
state,
|
||||
columnId,
|
||||
setState,
|
||||
layerId,
|
||||
currentIndexPattern,
|
||||
} = props;
|
||||
const { operationByDocument, operationByField, fieldByOperation } = operationFieldSupportMatrix;
|
||||
const [isPopoverOpen, setPopoverOpen] = useState(false);
|
||||
const [
|
||||
incompatibleSelectedOperationType,
|
||||
setInvalidOperationType,
|
||||
] = useState<OperationType | null>(null);
|
||||
|
||||
const ParamEditor =
|
||||
selectedColumn && operationDefinitionMap[selectedColumn.operationType].paramEditor;
|
||||
|
||||
const fieldMap: Record<string, IndexPatternField> = useMemo(() => {
|
||||
const fields: Record<string, IndexPatternField> = {};
|
||||
currentIndexPattern.fields.forEach(field => {
|
||||
fields[field.name] = field;
|
||||
});
|
||||
|
||||
return fields;
|
||||
}, [currentIndexPattern]);
|
||||
|
||||
function getOperationTypes() {
|
||||
const possibleOperationTypes = Object.keys(fieldByOperation).concat(
|
||||
operationByDocument
|
||||
) as OperationType[];
|
||||
|
||||
const validOperationTypes: OperationType[] = [];
|
||||
if (!selectedColumn || !hasField(selectedColumn)) {
|
||||
validOperationTypes.push(...operationByDocument);
|
||||
}
|
||||
|
||||
if (!selectedColumn) {
|
||||
validOperationTypes.push(...(Object.keys(fieldByOperation) as OperationType[]));
|
||||
} else if (hasField(selectedColumn) && operationByField[selectedColumn.sourceField]) {
|
||||
validOperationTypes.push(...operationByField[selectedColumn.sourceField]!);
|
||||
}
|
||||
|
||||
return _.uniq(
|
||||
[
|
||||
...asOperationOptions(validOperationTypes, true),
|
||||
...asOperationOptions(possibleOperationTypes, false),
|
||||
],
|
||||
'operationType'
|
||||
);
|
||||
}
|
||||
|
||||
function getSideNavItems() {
|
||||
return [
|
||||
{
|
||||
name: '',
|
||||
id: '0',
|
||||
items: getOperationTypes().map(({ operationType, compatibleWithCurrentField }) => ({
|
||||
name: operationPanels[operationType].displayName,
|
||||
id: operationType as string,
|
||||
className: classNames('lnsConfigPanel__operation', {
|
||||
'lnsConfigPanel__operation--selected': Boolean(
|
||||
incompatibleSelectedOperationType === operationType ||
|
||||
(!incompatibleSelectedOperationType &&
|
||||
selectedColumn &&
|
||||
selectedColumn.operationType === operationType)
|
||||
),
|
||||
'lnsConfigPanel__operation--incompatible': !compatibleWithCurrentField,
|
||||
}),
|
||||
'data-test-subj': `lns-indexPatternDimension${
|
||||
compatibleWithCurrentField ? '' : 'Incompatible'
|
||||
}-${operationType}`,
|
||||
onClick() {
|
||||
if (!selectedColumn) {
|
||||
const possibleFields = fieldByOperation[operationType] || [];
|
||||
const isFieldlessPossible = operationByDocument.includes(operationType);
|
||||
|
||||
if (
|
||||
possibleFields.length === 1 ||
|
||||
(possibleFields.length === 0 && isFieldlessPossible)
|
||||
) {
|
||||
setState(
|
||||
changeColumn({
|
||||
state,
|
||||
layerId,
|
||||
columnId,
|
||||
newColumn: buildColumn({
|
||||
columns: props.state.layers[props.layerId].columns,
|
||||
suggestedPriority: props.suggestedPriority,
|
||||
layerId: props.layerId,
|
||||
op: operationType,
|
||||
indexPattern: currentIndexPattern,
|
||||
field: possibleFields.length === 1 ? fieldMap[possibleFields[0]] : undefined,
|
||||
asDocumentOperation: possibleFields.length === 0,
|
||||
}),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setInvalidOperationType(operationType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!compatibleWithCurrentField) {
|
||||
setInvalidOperationType(operationType);
|
||||
return;
|
||||
}
|
||||
if (incompatibleSelectedOperationType) {
|
||||
setInvalidOperationType(null);
|
||||
}
|
||||
if (selectedColumn.operationType === operationType) {
|
||||
return;
|
||||
}
|
||||
const newColumn: IndexPatternColumn = buildColumn({
|
||||
columns: props.state.layers[props.layerId].columns,
|
||||
suggestedPriority: props.suggestedPriority,
|
||||
layerId: props.layerId,
|
||||
op: operationType,
|
||||
indexPattern: currentIndexPattern,
|
||||
field: hasField(selectedColumn) ? fieldMap[selectedColumn.sourceField] : undefined,
|
||||
});
|
||||
setState(
|
||||
changeColumn({
|
||||
state,
|
||||
layerId,
|
||||
columnId,
|
||||
newColumn,
|
||||
})
|
||||
);
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
id={columnId}
|
||||
className="lnsConfigPanel__summaryPopover"
|
||||
anchorClassName={
|
||||
selectedColumn ? 'lnsConfigPanel__summaryPopoverAnchor' : 'lnsConfigPanel__summaryLink'
|
||||
}
|
||||
button={
|
||||
selectedColumn ? (
|
||||
<EuiLink
|
||||
className="lnsConfigPanel__summaryLink"
|
||||
onClick={() => {
|
||||
setPopoverOpen(!isPopoverOpen);
|
||||
}}
|
||||
data-test-subj="indexPattern-configure-dimension"
|
||||
aria-label={i18n.translate('xpack.lens.configure.editConfig', {
|
||||
defaultMessage: 'Edit configuration',
|
||||
})}
|
||||
title={i18n.translate('xpack.lens.configure.editConfig', {
|
||||
defaultMessage: 'Edit configuration',
|
||||
})}
|
||||
>
|
||||
{selectedColumn.label}
|
||||
</EuiLink>
|
||||
) : (
|
||||
<>
|
||||
<EuiButtonIcon
|
||||
iconType="plusInCircleFilled"
|
||||
data-test-subj="indexPattern-configure-dimension"
|
||||
aria-label={i18n.translate('xpack.lens.configure.addConfig', {
|
||||
defaultMessage: 'Add a configuration',
|
||||
})}
|
||||
title={i18n.translate('xpack.lens.configure.addConfig', {
|
||||
defaultMessage: 'Add a configuration',
|
||||
})}
|
||||
onClick={() => setPopoverOpen(!isPopoverOpen)}
|
||||
/>{' '}
|
||||
<EuiTextColor color="subdued">
|
||||
<FormattedMessage
|
||||
id="xpack.lens.configure.emptyConfig"
|
||||
defaultMessage="Drop a field here"
|
||||
/>
|
||||
</EuiTextColor>
|
||||
</>
|
||||
)
|
||||
}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={() => {
|
||||
setPopoverOpen(false);
|
||||
setInvalidOperationType(null);
|
||||
}}
|
||||
anchorPosition="leftUp"
|
||||
withTitle
|
||||
panelPaddingSize="s"
|
||||
>
|
||||
{isPopoverOpen && (
|
||||
<EuiFlexGroup gutterSize="s" direction="column">
|
||||
<EuiFlexItem>
|
||||
<FieldSelect
|
||||
currentIndexPattern={currentIndexPattern}
|
||||
showEmptyFields={state.showEmptyFields}
|
||||
fieldMap={fieldMap}
|
||||
operationFieldSupportMatrix={operationFieldSupportMatrix}
|
||||
selectedColumnOperationType={selectedColumn && selectedColumn.operationType}
|
||||
selectedColumnSourceField={
|
||||
selectedColumn && hasField(selectedColumn) ? selectedColumn.sourceField : undefined
|
||||
}
|
||||
incompatibleSelectedOperationType={incompatibleSelectedOperationType}
|
||||
onDeleteColumn={() => {
|
||||
setState(
|
||||
deleteColumn({
|
||||
state,
|
||||
layerId,
|
||||
columnId,
|
||||
})
|
||||
);
|
||||
}}
|
||||
onChoose={choice => {
|
||||
let column: IndexPatternColumn;
|
||||
if (
|
||||
!incompatibleSelectedOperationType &&
|
||||
selectedColumn &&
|
||||
('field' in choice && choice.operationType === selectedColumn.operationType)
|
||||
) {
|
||||
// If we just changed the field are not in an error state and the operation didn't change,
|
||||
// we use the operations onFieldChange method to calculate the new column.
|
||||
column = changeField(selectedColumn, currentIndexPattern, fieldMap[choice.field]);
|
||||
} else {
|
||||
// Otherwise we'll use the buildColumn method to calculate a new column
|
||||
column = buildColumn({
|
||||
columns: props.state.layers[props.layerId].columns,
|
||||
field: 'field' in choice ? fieldMap[choice.field] : undefined,
|
||||
indexPattern: currentIndexPattern,
|
||||
layerId: props.layerId,
|
||||
suggestedPriority: props.suggestedPriority,
|
||||
op:
|
||||
incompatibleSelectedOperationType ||
|
||||
('field' in choice ? choice.operationType : undefined),
|
||||
asDocumentOperation: choice.type === 'document',
|
||||
});
|
||||
}
|
||||
|
||||
setState(
|
||||
changeColumn({
|
||||
state,
|
||||
layerId,
|
||||
columnId,
|
||||
newColumn: column,
|
||||
keepParams: false,
|
||||
})
|
||||
);
|
||||
setInvalidOperationType(null);
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={null} className={classNames('lnsConfigPanel__summaryPopoverLeft')}>
|
||||
<EuiSideNav items={getSideNavItems()} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true} className="lnsConfigPanel__summaryPopoverRight">
|
||||
{incompatibleSelectedOperationType && selectedColumn && (
|
||||
<EuiCallOut
|
||||
data-test-subj="indexPattern-invalid-operation"
|
||||
title={i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
|
||||
defaultMessage: 'Operation not applicable to field',
|
||||
})}
|
||||
color="danger"
|
||||
iconType="cross"
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.lens.indexPattern.invalidOperationDescription"
|
||||
defaultMessage="Please choose another field"
|
||||
/>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
)}
|
||||
{incompatibleSelectedOperationType && !selectedColumn && (
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
data-test-subj="indexPattern-fieldless-operation"
|
||||
title={i18n.translate('xpack.lens.indexPattern.fieldlessOperationLabel', {
|
||||
defaultMessage: 'Choose a field the operation is applied to',
|
||||
})}
|
||||
iconType="alert"
|
||||
></EuiCallOut>
|
||||
)}
|
||||
{!incompatibleSelectedOperationType && ParamEditor && (
|
||||
<ParamEditor
|
||||
state={state}
|
||||
setState={setState}
|
||||
columnId={columnId}
|
||||
currentColumn={state.layers[layerId].columns[columnId]}
|
||||
storage={props.storage}
|
||||
uiSettings={props.uiSettings}
|
||||
savedObjectsClient={props.savedObjectsClient}
|
||||
layerId={layerId}
|
||||
http={props.http}
|
||||
/>
|
||||
)}
|
||||
{!incompatibleSelectedOperationType && selectedColumn && (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.lens.indexPattern.columnLabel', {
|
||||
defaultMessage: 'Label',
|
||||
description: 'Label of a column of data',
|
||||
})}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="indexPattern-label-edit"
|
||||
value={selectedColumn.label}
|
||||
onChange={e => {
|
||||
setState(
|
||||
changeColumn({
|
||||
state,
|
||||
layerId,
|
||||
columnId,
|
||||
newColumn: {
|
||||
...selectedColumn,
|
||||
label: e.target.value,
|
||||
},
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
<BucketNestingEditor
|
||||
layer={state.layers[props.layerId]}
|
||||
columnId={props.columnId}
|
||||
setColumns={columnOrder => {
|
||||
setState({
|
||||
...state,
|
||||
layers: {
|
||||
...state.layers,
|
||||
[props.layerId]: {
|
||||
...state.layers[props.layerId],
|
||||
columnOrder,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</EuiPopover>
|
||||
);
|
||||
}
|
|
@ -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(<FieldIcon type="boolean" />)).toMatchInlineSnapshot(`
|
||||
<EuiIcon
|
||||
className="lnsFieldListPanel__fieldIcon lnsFieldListPanel__fieldIcon--boolean"
|
||||
color="#F37020"
|
||||
type="invert"
|
||||
/>
|
||||
`);
|
||||
expect(shallow(<FieldIcon type="date" />)).toMatchInlineSnapshot(`
|
||||
<EuiIcon
|
||||
className="lnsFieldListPanel__fieldIcon lnsFieldListPanel__fieldIcon--date"
|
||||
color="#B0916F"
|
||||
type="calendar"
|
||||
/>
|
||||
`);
|
||||
expect(shallow(<FieldIcon type="number" />)).toMatchInlineSnapshot(`
|
||||
<EuiIcon
|
||||
className="lnsFieldListPanel__fieldIcon lnsFieldListPanel__fieldIcon--number"
|
||||
color="#1EA593"
|
||||
type="number"
|
||||
/>
|
||||
`);
|
||||
expect(shallow(<FieldIcon type="string" />)).toMatchInlineSnapshot(`
|
||||
<EuiIcon
|
||||
className="lnsFieldListPanel__fieldIcon lnsFieldListPanel__fieldIcon--string"
|
||||
color="#FCA5D3"
|
||||
type="string"
|
||||
/>
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ICON_TYPES, palettes, EuiIcon } from '@elastic/eui';
|
||||
import classNames from 'classnames';
|
||||
import { DataType } from '../types';
|
||||
|
||||
function stringToNum(s: string) {
|
||||
return Array.from(s).reduce((acc, ch) => acc + ch.charCodeAt(0), 1);
|
||||
}
|
||||
|
||||
function getIconForDataType(dataType: string) {
|
||||
const icons: Partial<Record<string, UnwrapArray<typeof ICON_TYPES>>> = {
|
||||
boolean: 'invert',
|
||||
date: 'calendar',
|
||||
};
|
||||
return icons[dataType] || ICON_TYPES.find(t => t === dataType) || 'empty';
|
||||
}
|
||||
|
||||
export function getColorForDataType(type: string) {
|
||||
const iconType = getIconForDataType(type);
|
||||
const { colors } = palettes.euiPaletteColorBlind;
|
||||
const colorIndex = stringToNum(iconType) % colors.length;
|
||||
return colors[colorIndex];
|
||||
}
|
||||
|
||||
export type UnwrapArray<T> = T extends Array<infer P> ? P : T;
|
||||
|
||||
export function FieldIcon({ type }: { type: DataType }) {
|
||||
const iconType = getIconForDataType(type);
|
||||
|
||||
const classes = classNames(
|
||||
'lnsFieldListPanel__fieldIcon',
|
||||
`lnsFieldListPanel__fieldIcon--${type}`
|
||||
);
|
||||
|
||||
return <EuiIcon type={iconType} color={getColorForDataType(type)} className={classes} />;
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { IndexPattern, IndexPatternField, DraggedField } from './indexpattern';
|
||||
import { DragDrop } from '../drag_drop';
|
||||
import { FieldIcon } from './field_icon';
|
||||
import { DataType } from '..';
|
||||
|
||||
export interface FieldItemProps {
|
||||
field: IndexPatternField;
|
||||
indexPattern: IndexPattern;
|
||||
highlight?: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
function wrapOnDot(str?: string) {
|
||||
// u200B is a non-width white-space character, which allows
|
||||
// the browser to efficiently word-wrap right after the dot
|
||||
// without us having to draw a lot of extra DOM elements, etc
|
||||
return str ? str.replace(/\./g, '.\u200B') : '';
|
||||
}
|
||||
|
||||
export function FieldItem({ field, indexPattern, highlight, exists }: FieldItemProps) {
|
||||
const wrappableName = wrapOnDot(field.name)!;
|
||||
const wrappableHighlight = wrapOnDot(highlight);
|
||||
const highlightIndex = wrappableHighlight
|
||||
? wrappableName.toLowerCase().indexOf(wrappableHighlight.toLowerCase())
|
||||
: -1;
|
||||
const wrappableHighlightableFieldName =
|
||||
highlightIndex < 0 ? (
|
||||
wrappableName
|
||||
) : (
|
||||
<span>
|
||||
<span>{wrappableName.substr(0, highlightIndex)}</span>
|
||||
<strong>{wrappableName.substr(highlightIndex, wrappableHighlight.length)}</strong>
|
||||
<span>{wrappableName.substr(highlightIndex + wrappableHighlight.length)}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<DragDrop
|
||||
value={{ field, indexPatternId: indexPattern.id } as DraggedField}
|
||||
data-test-subj="lnsFieldListPanelField"
|
||||
draggable
|
||||
className={`lnsFieldListPanel__field lnsFieldListPanel__field-btn-${
|
||||
field.type
|
||||
} lnsFieldListPanel__field--${exists ? 'exists' : 'missing'}`}
|
||||
>
|
||||
<div className="lnsFieldListPanel__fieldInfo">
|
||||
<FieldIcon type={field.type as DataType} />
|
||||
|
||||
<span className="lnsFieldListPanel__fieldName" title={field.name}>
|
||||
{wrappableHighlightableFieldName}
|
||||
</span>
|
||||
</div>
|
||||
</DragDrop>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { calculateFilterRatio } from './filter_ratio';
|
||||
import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/common';
|
||||
|
||||
describe('calculate_filter_ratio', () => {
|
||||
it('should collapse two rows and columns into a single row and column', () => {
|
||||
const input: KibanaDatatable = {
|
||||
type: 'kibana_datatable',
|
||||
columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }],
|
||||
rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b', 'filter-ratio': 10 }],
|
||||
};
|
||||
|
||||
expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({
|
||||
columns: [
|
||||
{
|
||||
id: 'bucket',
|
||||
name: 'A',
|
||||
formatHint: {
|
||||
id: 'percent',
|
||||
},
|
||||
},
|
||||
],
|
||||
rows: [{ bucket: 0.5 }],
|
||||
type: 'kibana_datatable',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 0 when the denominator is undefined', () => {
|
||||
const input: KibanaDatatable = {
|
||||
type: 'kibana_datatable',
|
||||
columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }],
|
||||
rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b' }],
|
||||
};
|
||||
|
||||
expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({
|
||||
columns: [
|
||||
{
|
||||
id: 'bucket',
|
||||
name: 'A',
|
||||
formatHint: {
|
||||
id: 'percent',
|
||||
},
|
||||
},
|
||||
],
|
||||
rows: [{ bucket: 0 }],
|
||||
type: 'kibana_datatable',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 0 when the denominator is 0', () => {
|
||||
const input: KibanaDatatable = {
|
||||
type: 'kibana_datatable',
|
||||
columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }],
|
||||
rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b', 'filter-ratio': 0 }],
|
||||
};
|
||||
|
||||
expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({
|
||||
columns: [
|
||||
{
|
||||
id: 'bucket',
|
||||
name: 'A',
|
||||
formatHint: {
|
||||
id: 'percent',
|
||||
},
|
||||
},
|
||||
],
|
||||
rows: [{ bucket: 0 }],
|
||||
type: 'kibana_datatable',
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep columns which are not mapped', () => {
|
||||
const input: KibanaDatatable = {
|
||||
type: 'kibana_datatable',
|
||||
columns: [
|
||||
{ id: 'bucket', name: 'A' },
|
||||
{ id: 'filter-ratio', name: 'B' },
|
||||
{ id: 'extra', name: 'C' },
|
||||
],
|
||||
rows: [
|
||||
{ bucket: 'a', 'filter-ratio': 5, extra: 'first' },
|
||||
{ bucket: 'b', 'filter-ratio': 10, extra: 'second' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({
|
||||
columns: [
|
||||
{
|
||||
id: 'bucket',
|
||||
name: 'A',
|
||||
formatHint: {
|
||||
id: 'percent',
|
||||
},
|
||||
},
|
||||
{ id: 'extra', name: 'C' },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
bucket: 0.5,
|
||||
extra: 'first',
|
||||
},
|
||||
],
|
||||
type: 'kibana_datatable',
|
||||
});
|
||||
});
|
||||
|
||||
it('should attach a percentage format hint to the ratio column', () => {
|
||||
const input: KibanaDatatable = {
|
||||
type: 'kibana_datatable',
|
||||
columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }],
|
||||
rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b', 'filter-ratio': 10 }],
|
||||
};
|
||||
|
||||
expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {}).columns[0]).toEqual({
|
||||
id: 'bucket',
|
||||
name: 'A',
|
||||
formatHint: {
|
||||
id: 'percent',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public';
|
||||
|
||||
interface FilterRatioKey {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const calculateFilterRatio: ExpressionFunction<
|
||||
'lens_calculate_filter_ratio',
|
||||
KibanaDatatable,
|
||||
FilterRatioKey,
|
||||
KibanaDatatable
|
||||
> = {
|
||||
name: 'lens_calculate_filter_ratio',
|
||||
type: 'kibana_datatable',
|
||||
help: i18n.translate('xpack.lens.functions.calculateFilterRatio.help', {
|
||||
defaultMessage: 'A helper to collapse two filter ratio rows into a single row',
|
||||
}),
|
||||
args: {
|
||||
id: {
|
||||
types: ['string'],
|
||||
help: i18n.translate('xpack.lens.functions.calculateFilterRatio.id.help', {
|
||||
defaultMessage: 'The column ID which has the filter ratio',
|
||||
}),
|
||||
},
|
||||
},
|
||||
context: {
|
||||
types: ['kibana_datatable'],
|
||||
},
|
||||
fn(data: KibanaDatatable, { id }: FilterRatioKey) {
|
||||
const newRows: KibanaDatatable['rows'] = [];
|
||||
|
||||
if (data.rows.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data.rows.length % 2 === 1) {
|
||||
throw new Error('Cannot divide an odd number of rows');
|
||||
}
|
||||
|
||||
const [[valueKey]] = Object.entries(data.rows[0]).filter(([key]) =>
|
||||
key.includes('filter-ratio')
|
||||
);
|
||||
|
||||
for (let i = 0; i < data.rows.length; i += 2) {
|
||||
const row1 = data.rows[i];
|
||||
const row2 = data.rows[i + 1];
|
||||
|
||||
const calculatedRatio = row2[valueKey]
|
||||
? (row1[valueKey] as number) / (row2[valueKey] as number)
|
||||
: 0;
|
||||
|
||||
const result = { ...row1 };
|
||||
delete result[valueKey];
|
||||
|
||||
result[id] = calculatedRatio;
|
||||
|
||||
newRows.push(result);
|
||||
}
|
||||
|
||||
const newColumns = data.columns
|
||||
.filter(col => !col.id.includes('filter-ratio'))
|
||||
.map(col =>
|
||||
col.id === id
|
||||
? {
|
||||
...col,
|
||||
formatHint: {
|
||||
id: 'percent',
|
||||
},
|
||||
}
|
||||
: col
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'kibana_datatable',
|
||||
rows: newRows,
|
||||
columns: newColumns,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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';
|
|
@ -0,0 +1,72 @@
|
|||
@import './dimension_panel/index';
|
||||
|
||||
|
||||
.lnsIndexPatternDataPanel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: $euiSize 0 0 $euiSize;
|
||||
}
|
||||
|
||||
.lnsIndexPatternDataPanel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: $euiSize * 3;
|
||||
margin-top: -$euiSizeS;
|
||||
|
||||
|
||||
& > .lnsIndexPatternDataPanel__changeLink {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 $euiSize;
|
||||
}
|
||||
}
|
||||
|
||||
.lnsIndexPatternDataPanel__filter-wrapper {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.lnsIndexPatternDataPanel__header-text {
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lnsFieldListPanel__list-wrapper {
|
||||
@include euiOverflowShadow;
|
||||
@include euiScrollBar;
|
||||
margin-top: 2px; // form control shadow
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.lnsFieldListPanel__list {
|
||||
padding-top: $euiSizeS;
|
||||
scrollbar-width: thin;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.lnsFieldListPanel__field {
|
||||
@include euiFontSizeS;
|
||||
background: $euiColorEmptyShade;
|
||||
border-radius: $euiBorderRadius;
|
||||
padding: $euiSizeS;
|
||||
margin-bottom: $euiSizeXS;
|
||||
transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance;
|
||||
|
||||
&:hover {
|
||||
@include euiBottomShadowMedium;
|
||||
z-index: 2;
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
|
||||
.lnsFieldListPanel__field--missing {
|
||||
background: $euiColorLightestShade;
|
||||
}
|
||||
|
||||
.lnsFieldListPanel__fieldName {
|
||||
margin-left: $euiSizeXS;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue