From 0549ae215bd8b7f08971e75b38620e944f5e4eeb Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Fri, 4 Feb 2022 18:15:06 +0100 Subject: [PATCH 01/12] [Expressions] Refactor Expression Renderer React component (#124050) * Extract expression renderer logic to a custom React hook * Fix expressions loader loading state observable --- src/plugins/expressions/public/index.ts | 1 + src/plugins/expressions/public/loader.ts | 3 +- .../public/react_expression_renderer.tsx | 243 ------------------ .../public/react_expression_renderer/index.ts | 10 + .../react_expression_renderer.test.tsx | 26 +- .../react_expression_renderer.tsx | 65 +++++ .../shallow_equal.d.ts | 14 + .../use_debounced_value.test.ts | 82 ++++++ .../use_debounced_value.ts | 38 +++ .../use_expression_renderer.test.ts | 215 ++++++++++++++++ .../use_expression_renderer.ts | 167 ++++++++++++ .../use_shallow_memo.test.ts | 37 +++ .../use_shallow_memo.ts | 23 ++ .../react_expression_renderer_wrapper.tsx | 6 +- 14 files changed, 672 insertions(+), 258 deletions(-) delete mode 100644 src/plugins/expressions/public/react_expression_renderer.tsx create mode 100644 src/plugins/expressions/public/react_expression_renderer/index.ts rename src/plugins/expressions/public/{ => react_expression_renderer}/react_expression_renderer.test.tsx (95%) create mode 100644 src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx create mode 100644 src/plugins/expressions/public/react_expression_renderer/shallow_equal.d.ts create mode 100644 src/plugins/expressions/public/react_expression_renderer/use_debounced_value.test.ts create mode 100644 src/plugins/expressions/public/react_expression_renderer/use_debounced_value.ts create mode 100644 src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.test.ts create mode 100644 src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts create mode 100644 src/plugins/expressions/public/react_expression_renderer/use_shallow_memo.test.ts create mode 100644 src/plugins/expressions/public/react_expression_renderer/use_shallow_memo.ts diff --git a/src/plugins/expressions/public/index.ts b/src/plugins/expressions/public/index.ts index 3746d4d61a7bc..24116025267ba 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -34,6 +34,7 @@ export type { ExpressionRendererComponent, ReactExpressionRendererProps, ReactExpressionRendererType, + useExpressionRenderer, } from './react_expression_renderer'; export type { AnyExpressionFunctionDefinition, diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 64384ebbfc852..f0571d82f427e 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -7,7 +7,7 @@ */ import { BehaviorSubject, Observable, Subject, Subscription, asyncScheduler, identity } from 'rxjs'; -import { filter, map, delay, throttleTime } from 'rxjs/operators'; +import { filter, map, delay, shareReplay, throttleTime } from 'rxjs/operators'; import { defaults } from 'lodash'; import { SerializableRecord, UnwrapObservable } from '@kbn/utility-types'; import { Adapters } from '../../inspector/public'; @@ -48,6 +48,7 @@ export class ExpressionLoader { // as loading$ could emit straight away in the constructor // and we want to notify subscribers about it, but all subscriptions will happen later this.loading$ = this.loadingSubject.asObservable().pipe( + shareReplay(1), filter((_) => _ === true), map(() => void 0) ); diff --git a/src/plugins/expressions/public/react_expression_renderer.tsx b/src/plugins/expressions/public/react_expression_renderer.tsx deleted file mode 100644 index 897d69f356035..0000000000000 --- a/src/plugins/expressions/public/react_expression_renderer.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { useRef, useEffect, useState, useLayoutEffect } from 'react'; -import classNames from 'classnames'; -import { Observable, Subscription } from 'rxjs'; -import { filter } from 'rxjs/operators'; -import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect'; -import { EuiLoadingChart, EuiProgress } from '@elastic/eui'; -import { euiLightVars as theme } from '@kbn/ui-theme'; -import { IExpressionLoaderParams, ExpressionRenderError, ExpressionRendererEvent } from './types'; -import { ExpressionAstExpression, IInterpreterRenderHandlers } from '../common'; -import { ExpressionLoader } from './loader'; - -// Accept all options of the runner as props except for the -// dom element which is provided by the component itself -export interface ReactExpressionRendererProps extends IExpressionLoaderParams { - className?: string; - dataAttrs?: string[]; - expression: string | ExpressionAstExpression; - renderError?: ( - message?: string | null, - error?: ExpressionRenderError | null - ) => React.ReactElement | React.ReactElement[]; - padding?: 'xs' | 's' | 'm' | 'l' | 'xl'; - onEvent?: (event: ExpressionRendererEvent) => void; - onData$?: ( - data: TData, - adapters?: TInspectorAdapters, - partial?: boolean - ) => void; - /** - * An observable which can be used to re-run the expression without destroying the component - */ - reload$?: Observable; - onRender$?: (item: number) => void; - debounce?: number; -} - -export type ReactExpressionRendererType = React.ComponentType; - -interface State { - isEmpty: boolean; - isLoading: boolean; - error: null | ExpressionRenderError; -} - -export type ExpressionRendererComponent = React.FC; - -const defaultState: State = { - isEmpty: true, - isLoading: false, - error: null, -}; - -// eslint-disable-next-line import/no-default-export -export default function ReactExpressionRenderer({ - className, - dataAttrs, - padding, - renderError, - expression, - onEvent, - onData$, - onRender$, - reload$, - debounce, - ...expressionLoaderOptions -}: ReactExpressionRendererProps) { - const mountpoint: React.MutableRefObject = useRef(null); - const [state, setState] = useState({ ...defaultState }); - const hasCustomRenderErrorHandler = !!renderError; - const expressionLoaderRef: React.MutableRefObject = useRef(null); - // flag to skip next render$ notification, - // because of just handled error - const hasHandledErrorRef = useRef(false); - - // will call done() in LayoutEffect when done with rendering custom error state - const errorRenderHandlerRef: React.MutableRefObject = - useRef(null); - const [debouncedExpression, setDebouncedExpression] = useState(expression); - const [waitingForDebounceToComplete, setDebouncePending] = useState(false); - const firstRender = useRef(true); - useShallowCompareEffect(() => { - if (firstRender.current) { - firstRender.current = false; - return; - } - if (debounce === undefined) { - return; - } - setDebouncePending(true); - const handler = setTimeout(() => { - setDebouncedExpression(expression); - setDebouncePending(false); - }, debounce); - - return () => { - clearTimeout(handler); - }; - }, [expression, expressionLoaderOptions, debounce]); - - const activeExpression = debounce !== undefined ? debouncedExpression : expression; - - /* eslint-disable react-hooks/exhaustive-deps */ - // OK to ignore react-hooks/exhaustive-deps because options update is handled by calling .update() - useEffect(() => { - const subs: Subscription[] = []; - expressionLoaderRef.current = new ExpressionLoader(mountpoint.current!, activeExpression, { - ...expressionLoaderOptions, - // react component wrapper provides different - // error handling api which is easier to work with from react - // if custom renderError is not provided then we fallback to default error handling from ExpressionLoader - onRenderError: hasCustomRenderErrorHandler - ? (domNode, error, handlers) => { - errorRenderHandlerRef.current = handlers; - setState(() => ({ - ...defaultState, - isEmpty: false, - error, - })); - - if (expressionLoaderOptions.onRenderError) { - expressionLoaderOptions.onRenderError(domNode, error, handlers); - } - } - : expressionLoaderOptions.onRenderError, - }); - if (onEvent) { - subs.push( - expressionLoaderRef.current.events$.subscribe((event) => { - onEvent(event); - }) - ); - } - if (onData$) { - subs.push( - expressionLoaderRef.current.data$.subscribe(({ partial, result }) => { - onData$(result, expressionLoaderRef.current?.inspect(), partial); - }) - ); - } - subs.push( - expressionLoaderRef.current.loading$.subscribe(() => { - hasHandledErrorRef.current = false; - setState((prevState) => ({ ...prevState, isLoading: true })); - }), - expressionLoaderRef.current.render$ - .pipe(filter(() => !hasHandledErrorRef.current)) - .subscribe((item) => { - setState(() => ({ - ...defaultState, - isEmpty: false, - })); - onRender$?.(item); - }) - ); - - return () => { - subs.forEach((s) => s.unsubscribe()); - if (expressionLoaderRef.current) { - expressionLoaderRef.current.destroy(); - expressionLoaderRef.current = null; - } - - errorRenderHandlerRef.current = null; - }; - }, [ - hasCustomRenderErrorHandler, - onEvent, - expressionLoaderOptions.interactive, - expressionLoaderOptions.renderMode, - expressionLoaderOptions.syncColors, - ]); - - useEffect(() => { - const subscription = reload$?.subscribe(() => { - if (expressionLoaderRef.current) { - expressionLoaderRef.current.update(activeExpression, expressionLoaderOptions); - } - }); - return () => subscription?.unsubscribe(); - }, [reload$, activeExpression, ...Object.values(expressionLoaderOptions)]); - - // Re-fetch data automatically when the inputs change - useShallowCompareEffect( - () => { - // only update the loader if the debounce period is over - if (expressionLoaderRef.current && !waitingForDebounceToComplete) { - expressionLoaderRef.current.update(activeExpression, expressionLoaderOptions); - } - }, - // when debounced, wait for debounce status to change to update loader. - // Otherwise, update when expression is changed by reference and when any other loaderOption is changed by reference - debounce === undefined - ? [{ activeExpression, ...expressionLoaderOptions }] - : [{ waitingForDebounceToComplete }] - ); - - /* eslint-enable react-hooks/exhaustive-deps */ - // call expression loader's done() handler when finished rendering custom error state - useLayoutEffect(() => { - if (state.error && errorRenderHandlerRef.current) { - hasHandledErrorRef.current = true; - errorRenderHandlerRef.current.done(); - errorRenderHandlerRef.current = null; - } - }, [state.error]); - - const classes = classNames('expExpressionRenderer', className, { - 'expExpressionRenderer-isEmpty': state.isEmpty, - 'expExpressionRenderer-hasError': !!state.error, - }); - - const expressionStyles: React.CSSProperties = {}; - - if (padding) { - expressionStyles.padding = theme.paddingSizes[padding]; - } - - return ( -
- {state.isEmpty && } - {(state.isLoading || waitingForDebounceToComplete) && ( - - )} - {!state.isLoading && - state.error && - renderError && - renderError(state.error.message, state.error)} -
-
- ); -} diff --git a/src/plugins/expressions/public/react_expression_renderer/index.ts b/src/plugins/expressions/public/react_expression_renderer/index.ts new file mode 100644 index 0000000000000..75d982ef26526 --- /dev/null +++ b/src/plugins/expressions/public/react_expression_renderer/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './react_expression_renderer'; +export * from './use_expression_renderer'; diff --git a/src/plugins/expressions/public/react_expression_renderer.test.tsx b/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.test.tsx similarity index 95% rename from src/plugins/expressions/public/react_expression_renderer.test.tsx rename to src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.test.tsx index cf19b333fed45..d7e08b0f7fc93 100644 --- a/src/plugins/expressions/public/react_expression_renderer.test.tsx +++ b/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.test.tsx @@ -10,14 +10,14 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { Subject } from 'rxjs'; import { share } from 'rxjs/operators'; -import { default as ReactExpressionRenderer } from './react_expression_renderer'; -import { ExpressionLoader } from './loader'; +import { ReactExpressionRenderer } from './react_expression_renderer'; import { mount } from 'enzyme'; import { EuiProgress } from '@elastic/eui'; -import { IInterpreterRenderHandlers } from '../common'; -import { RenderErrorHandlerFnType, ExpressionRendererEvent } from './types'; +import { IInterpreterRenderHandlers } from '../../common'; +import { ExpressionLoader } from '../loader'; +import { RenderErrorHandlerFnType, ExpressionRendererEvent } from '../types'; -jest.mock('./loader', () => { +jest.mock('../loader', () => { return { ExpressionLoader: jest.fn().mockImplementation(() => { return {}; @@ -103,7 +103,7 @@ describe('ExpressionRenderer', () => { }); it('waits for debounce period if specified', () => { - jest.useFakeTimers(); + jest.useFakeTimers('modern'); const refreshSubject = new Subject(); const loaderUpdate = jest.fn(); @@ -124,19 +124,19 @@ describe('ExpressionRenderer', () => { instance.setProps({ expression: 'abc' }); - expect(loaderUpdate).toHaveBeenCalledTimes(1); + expect(loaderUpdate).not.toHaveBeenCalled(); act(() => { jest.runAllTimers(); }); - expect(loaderUpdate).toHaveBeenCalledTimes(2); + expect(loaderUpdate).toHaveBeenCalledTimes(1); instance.unmount(); }); it('should not update twice immediately after rendering', () => { - jest.useFakeTimers(); + jest.useFakeTimers('modern'); const refreshSubject = new Subject(); const loaderUpdate = jest.fn(); @@ -159,13 +159,13 @@ describe('ExpressionRenderer', () => { jest.runAllTimers(); }); - expect(loaderUpdate).toHaveBeenCalledTimes(1); + expect(loaderUpdate).not.toHaveBeenCalled(); instance.unmount(); }); it('waits for debounce period on other loader option change if specified', () => { - jest.useFakeTimers(); + jest.useFakeTimers('modern'); const refreshSubject = new Subject(); const loaderUpdate = jest.fn(); @@ -191,13 +191,13 @@ describe('ExpressionRenderer', () => { instance.setProps({ searchContext: { from: 'now-30m', to: 'now' } }); - expect(loaderUpdate).toHaveBeenCalledTimes(1); + expect(loaderUpdate).not.toHaveBeenCalled(); act(() => { jest.runAllTimers(); }); - expect(loaderUpdate).toHaveBeenCalledTimes(2); + expect(loaderUpdate).toHaveBeenCalledTimes(1); instance.unmount(); }); diff --git a/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx b/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx new file mode 100644 index 0000000000000..000c68af26c5d --- /dev/null +++ b/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useRef } from 'react'; +import classNames from 'classnames'; +import { EuiLoadingChart, EuiProgress } from '@elastic/eui'; +import { euiLightVars as theme } from '@kbn/ui-theme'; +import { ExpressionRenderError } from '../types'; +import type { ExpressionRendererParams } from './use_expression_renderer'; +import { useExpressionRenderer } from './use_expression_renderer'; + +// Accept all options of the runner as props except for the +// dom element which is provided by the component itself +export interface ReactExpressionRendererProps + extends Omit { + className?: string; + dataAttrs?: string[]; + renderError?: ( + message?: string | null, + error?: ExpressionRenderError | null + ) => React.ReactElement | React.ReactElement[]; + padding?: 'xs' | 's' | 'm' | 'l' | 'xl'; +} + +export type ReactExpressionRendererType = React.ComponentType; +export type ExpressionRendererComponent = React.FC; + +export function ReactExpressionRenderer({ + className, + dataAttrs, + padding, + renderError, + ...expressionRendererOptions +}: ReactExpressionRendererProps) { + const nodeRef = useRef(null); + const { error, isEmpty, isLoading } = useExpressionRenderer(nodeRef, { + ...expressionRendererOptions, + hasCustomErrorRenderer: !!renderError, + }); + + const classes = classNames('expExpressionRenderer', className, { + 'expExpressionRenderer-isEmpty': isEmpty, + 'expExpressionRenderer-hasError': !!error, + }); + + const expressionStyles: React.CSSProperties = {}; + + if (padding) { + expressionStyles.padding = theme.paddingSizes[padding]; + } + + return ( +
+ {isEmpty && } + {isLoading && } + {!isLoading && error && renderError?.(error.message, error)} +
+
+ ); +} diff --git a/src/plugins/expressions/public/react_expression_renderer/shallow_equal.d.ts b/src/plugins/expressions/public/react_expression_renderer/shallow_equal.d.ts new file mode 100644 index 0000000000000..f18838cf23329 --- /dev/null +++ b/src/plugins/expressions/public/react_expression_renderer/shallow_equal.d.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +declare module 'react-redux/lib/utils/shallowEqual' { + const shallowEqual: typeof import('react-redux').shallowEqual; + + // eslint-disable-next-line import/no-default-export + export default shallowEqual; +} diff --git a/src/plugins/expressions/public/react_expression_renderer/use_debounced_value.test.ts b/src/plugins/expressions/public/react_expression_renderer/use_debounced_value.test.ts new file mode 100644 index 0000000000000..be0c3717a9e35 --- /dev/null +++ b/src/plugins/expressions/public/react_expression_renderer/use_debounced_value.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { useDebouncedValue } from './use_debounced_value'; + +describe('useDebouncedValue', () => { + beforeEach(() => { + jest.useFakeTimers('modern'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return the initial value', () => { + const { result } = renderHook(() => useDebouncedValue('something', 1000)); + + const [value] = result.current; + expect(value).toBe('something'); + }); + + it('should debounce value update', () => { + const hook = renderHook((value) => useDebouncedValue(value, 1000), { + initialProps: 'something', + }); + hook.rerender('something else'); + + const [value, isPending] = hook.result.current; + expect(value).toBe('something'); + expect(isPending).toBe(true); + }); + + it('should update value after a timeout', () => { + const hook = renderHook((value) => useDebouncedValue(value, 1000), { + initialProps: 'something', + }); + + hook.rerender('something else'); + act(() => { + jest.advanceTimersByTime(1000); + }); + + const [value, isPending] = hook.result.current; + expect(value).toBe('something else'); + expect(isPending).toBe(false); + }); + + it('should throttle multiple value updates', () => { + const hook = renderHook((value) => useDebouncedValue(value, 1000), { + initialProps: 'something', + }); + + hook.rerender('something else'); + act(() => { + jest.advanceTimersByTime(500); + }); + + hook.rerender('another value'); + act(() => { + jest.advanceTimersByTime(1000); + }); + + const [value] = hook.result.current; + expect(value).toBe('another value'); + }); + + it('should update value immediately if there is no timeout', () => { + const hook = renderHook((value) => useDebouncedValue(value), { initialProps: 'something' }); + + hook.rerender('something else'); + + const [value, isPending] = hook.result.current; + expect(value).toBe('something else'); + expect(isPending).toBe(false); + }); +}); diff --git a/src/plugins/expressions/public/react_expression_renderer/use_debounced_value.ts b/src/plugins/expressions/public/react_expression_renderer/use_debounced_value.ts new file mode 100644 index 0000000000000..07ae13590174f --- /dev/null +++ b/src/plugins/expressions/public/react_expression_renderer/use_debounced_value.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { debounce } from 'lodash'; +import type { Cancelable } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; + +export function useDebouncedValue(value: T, timeout?: number): [T, boolean] { + const [storedValue, setStoredValue] = useState(value); + const [isPending, setPending] = useState(false); + const setValue = useCallback( + (newValue: T) => { + setStoredValue(newValue); + setPending(false); + }, + [setStoredValue, setPending] + ); + const setDebouncedValue = useMemo>( + () => (timeout ? debounce(setValue, timeout) : setValue), + [setValue, timeout] + ); + + useEffect(() => () => setDebouncedValue.cancel?.(), [setDebouncedValue]); + useUpdateEffect(() => { + setPending(true); + setDebouncedValue(value); + + return () => setDebouncedValue.cancel?.(); + }, [value]); + + return [storedValue, isPending]; +} diff --git a/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.test.ts b/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.test.ts new file mode 100644 index 0000000000000..581fb4b182b41 --- /dev/null +++ b/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.test.ts @@ -0,0 +1,215 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { RefObject } from 'react'; +import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { Subject } from 'rxjs'; +import type { IInterpreterRenderHandlers } from '../../common'; +import { ExpressionRendererParams, useExpressionRenderer } from './use_expression_renderer'; +import * as loader from '../loader'; + +describe('useExpressionRenderer', () => { + const expressionLoaderSpy = jest.spyOn(loader, 'ExpressionLoader'); + let nodeRef: RefObject; + let expressionLoader: jest.Mocked & { + data$: Subject; + events$: Subject; + loading$: Subject; + render$: Subject; + }; + let hook: RenderHookResult>; + + beforeEach(() => { + nodeRef = { current: document.createElement('div') }; + expressionLoader = { + data$: new Subject(), + events$: new Subject(), + loading$: new Subject(), + render$: new Subject(), + destroy: jest.fn(), + inspect: jest.fn(), + update: jest.fn(), + } as unknown as typeof expressionLoader; + + expressionLoaderSpy.mockImplementation(() => expressionLoader); + hook = renderHook( + (params: ExpressionRendererParams) => useExpressionRenderer(nodeRef, params), + { initialProps: { expression: 'something' } } + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should return default state', () => { + expect(hook.result.current).toEqual({ + isEmpty: true, + isLoading: false, + error: null, + }); + }); + + it('should update loader options on properties change', () => { + expect(expressionLoader.update).not.toHaveBeenCalled(); + + hook.rerender({ expression: 'something else', partial: true }); + + expect(expressionLoader.update).toHaveBeenCalledWith('something else', { partial: true }); + }); + + it('should debounce property changes', () => { + jest.useFakeTimers('modern'); + + hook.rerender({ debounce: 1000, expression: 'something else' }); + expect(expressionLoader.update).not.toHaveBeenCalled(); + + expect(hook.result.current).toEqual(expect.objectContaining({ isLoading: true })); + + act(() => void jest.advanceTimersByTime(1000)); + expect(hook.result.current).toEqual(expect.objectContaining({ isLoading: false })); + expect(expressionLoader.update).toHaveBeenCalledWith('something else', {}); + + jest.useRealTimers(); + }); + + it('should not debounce if loader optaions are not changed', () => { + jest.useFakeTimers('modern'); + + hook.rerender({ expression: 'something else', partial: true }); + hook.rerender({ + expression: 'something else', + debounce: 1000, + hasCustomErrorRenderer: true, + partial: true, + }); + + expect(hook.result.current).toEqual(expect.objectContaining({ isLoading: false })); + expect(expressionLoader.update).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); + + it('should handle rendering errors', () => { + expressionLoaderSpy.mockClear(); + const onRenderError = jest.fn(); + const done = jest.fn(); + hook.rerender({ onRenderError, expression: 'something' }); + + expect(expressionLoaderSpy).toHaveBeenCalledTimes(1); + + const [[, , loaderParams]] = expressionLoaderSpy.mock.calls; + act(() => + loaderParams?.onRenderError?.(document.createElement('div'), new Error('something'), { + done, + } as unknown as IInterpreterRenderHandlers) + ); + + expect(hook.result.current).toEqual({ + isEmpty: false, + isLoading: false, + error: new Error('something'), + }); + expect(onRenderError).toHaveBeenCalled(); + expect(done).not.toHaveBeenCalled(); + }); + + it('should notify loader handlers on custom error rendering', () => { + const done = jest.fn(); + hook.rerender({ expression: 'something', hasCustomErrorRenderer: true }); + + expect(expressionLoaderSpy).toHaveBeenCalledTimes(1); + + const [[, , loaderParams]] = expressionLoaderSpy.mock.calls; + act(() => + loaderParams?.onRenderError?.(document.createElement('div'), new Error('something'), { + done, + } as unknown as IInterpreterRenderHandlers) + ); + + expect(done).toHaveBeenCalled(); + }); + + it('should update loading state', () => { + expect(hook.result.current).toHaveProperty('isLoading', false); + act(() => expressionLoader.loading$.next()); + expect(hook.result.current).toHaveProperty('isLoading', true); + }); + + it('should call the event handler', () => { + const onEvent = jest.fn(); + hook.rerender({ onEvent, expression: 'something' }); + act(() => expressionLoader.events$.next('event')); + + expect(onEvent).toHaveBeenCalledWith('event'); + }); + + it('should call the data handler', () => { + const adapters = {}; + const onData$ = jest.fn(); + hook.rerender({ onData$, expression: 'something' }); + expressionLoader.inspect.mockReturnValueOnce(adapters); + act(() => expressionLoader.data$.next({ partial: true, result: 'something' })); + + expect(onData$).toHaveBeenCalledWith('something', adapters, true); + }); + + it('should update on loader options changes', () => { + const adapters = {}; + const onData$ = jest.fn(); + hook.rerender({ onData$, expression: 'something' }); + expressionLoader.inspect.mockReturnValueOnce(adapters); + act(() => expressionLoader.data$.next({ partial: true, result: 'something' })); + + expect(onData$).toHaveBeenCalledWith('something', adapters, true); + }); + + it('should call the render handler', () => { + const onRender$ = jest.fn(); + hook.rerender({ onRender$, expression: 'something' }); + act(() => expressionLoader.render$.next(1)); + + expect(hook.result.current).toEqual({ + isEmpty: false, + isLoading: false, + error: null, + }); + expect(onRender$).toHaveBeenCalledWith(1); + }); + + it('should not call the render handler when there is a custom error renderer', () => { + const onRender$ = jest.fn(); + hook.rerender({ onRender$, expression: 'something', hasCustomErrorRenderer: true }); + + expect(expressionLoaderSpy).toHaveBeenCalledTimes(1); + + const [[, , loaderParams]] = expressionLoaderSpy.mock.calls; + act(() => + loaderParams?.onRenderError?.(document.createElement('div'), new Error('something'), { + done: jest.fn(), + } as unknown as IInterpreterRenderHandlers) + ); + act(() => expressionLoader.render$.next(1)); + + expect(hook.result.current).toEqual({ + isEmpty: false, + isLoading: false, + error: new Error('something'), + }); + expect(onRender$).not.toHaveBeenCalled(); + }); + + it('should update on reload', () => { + const reload$ = new Subject(); + hook.rerender({ reload$, expression: 'something' }); + + expect(expressionLoader.update).not.toHaveBeenCalled(); + reload$.next(); + expect(expressionLoader.update).toHaveBeenCalledWith('something', {}); + }); +}); diff --git a/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts b/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts new file mode 100644 index 0000000000000..49af9f19b3b88 --- /dev/null +++ b/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { RefObject } from 'react'; +import { useRef, useEffect, useState, useLayoutEffect } from 'react'; +import { Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; +import { ExpressionAstExpression, IInterpreterRenderHandlers } from '../../common'; +import { ExpressionLoader } from '../loader'; +import { IExpressionLoaderParams, ExpressionRenderError, ExpressionRendererEvent } from '../types'; +import { useDebouncedValue } from './use_debounced_value'; +import { useShallowMemo } from './use_shallow_memo'; + +export interface ExpressionRendererParams extends IExpressionLoaderParams { + debounce?: number; + expression: string | ExpressionAstExpression; + hasCustomErrorRenderer?: boolean; + onData$?( + data: TData, + adapters?: TInspectorAdapters, + partial?: boolean + ): void; + onEvent?(event: ExpressionRendererEvent): void; + onRender$?(item: number): void; + /** + * An observable which can be used to re-run the expression without destroying the component + */ + reload$?: Observable; +} + +interface ExpressionRendererState { + isEmpty: boolean; + isLoading: boolean; + error: null | ExpressionRenderError; +} + +export function useExpressionRenderer( + nodeRef: RefObject, + { + debounce, + expression, + hasCustomErrorRenderer, + onData$, + onEvent, + onRender$, + reload$, + ...loaderParams + }: ExpressionRendererParams +): ExpressionRendererState { + const [isEmpty, setEmpty] = useState(true); + const [isLoading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const memoizedOptions = useShallowMemo({ expression, params: useShallowMemo(loaderParams) }); + const [{ expression: debouncedExpression, params: debouncedLoaderParams }, isDebounced] = + useDebouncedValue(memoizedOptions, debounce); + + const expressionLoaderRef = useRef(null); + + // flag to skip next render$ notification, + // because of just handled error + const hasHandledErrorRef = useRef(false); + // will call done() in LayoutEffect when done with rendering custom error state + const errorRenderHandlerRef = useRef(null); + + /* eslint-disable react-hooks/exhaustive-deps */ + // OK to ignore react-hooks/exhaustive-deps because options update is handled by calling .update() + useEffect(() => { + expressionLoaderRef.current = + nodeRef.current && + new ExpressionLoader(nodeRef.current, debouncedExpression, { + ...debouncedLoaderParams, + // react component wrapper provides different + // error handling api which is easier to work with from react + // if custom renderError is not provided then we fallback to default error handling from ExpressionLoader + onRenderError: (domNode, newError, handlers) => { + errorRenderHandlerRef.current = handlers; + setEmpty(false); + setError(newError); + setLoading(false); + + return debouncedLoaderParams.onRenderError?.(domNode, newError, handlers); + }, + }); + + const subscription = expressionLoaderRef.current?.loading$.subscribe(() => { + hasHandledErrorRef.current = false; + setLoading(true); + }); + + return () => { + subscription?.unsubscribe(); + expressionLoaderRef.current?.destroy(); + expressionLoaderRef.current = null; + errorRenderHandlerRef.current = null; + }; + }, [ + debouncedLoaderParams.onRenderError, + debouncedLoaderParams.interactive, + debouncedLoaderParams.renderMode, + debouncedLoaderParams.syncColors, + ]); + + useEffect(() => { + const subscription = onEvent && expressionLoaderRef.current?.events$.subscribe(onEvent); + + return () => subscription?.unsubscribe(); + }, [expressionLoaderRef.current, onEvent]); + + useEffect(() => { + const subscription = + onData$ && + expressionLoaderRef.current?.data$.subscribe(({ partial, result }) => { + onData$(result, expressionLoaderRef.current?.inspect(), partial); + }); + + return () => subscription?.unsubscribe(); + }, [expressionLoaderRef.current, onData$]); + + useEffect(() => { + const subscription = expressionLoaderRef.current?.render$ + .pipe(filter(() => !hasHandledErrorRef.current)) + .subscribe((item) => { + setEmpty(false); + setError(null); + setLoading(false); + onRender$?.(item); + }); + + return () => subscription?.unsubscribe(); + }, [expressionLoaderRef.current, onRender$]); + /* eslint-enable react-hooks/exhaustive-deps */ + + useEffect(() => { + const subscription = reload$?.subscribe(() => { + expressionLoaderRef.current?.update(debouncedExpression, debouncedLoaderParams); + }); + + return () => subscription?.unsubscribe(); + }, [reload$, debouncedExpression, debouncedLoaderParams]); + + useUpdateEffect(() => { + expressionLoaderRef.current?.update(debouncedExpression, debouncedLoaderParams); + }, [debouncedExpression, debouncedLoaderParams]); + + // call expression loader's done() handler when finished rendering custom error state + useLayoutEffect(() => { + if (error && hasCustomErrorRenderer) { + hasHandledErrorRef.current = true; + errorRenderHandlerRef.current?.done(); + } + + errorRenderHandlerRef.current = null; + }, [error, hasCustomErrorRenderer]); + + return { + error, + isEmpty, + isLoading: isLoading || isDebounced, + }; +} diff --git a/src/plugins/expressions/public/react_expression_renderer/use_shallow_memo.test.ts b/src/plugins/expressions/public/react_expression_renderer/use_shallow_memo.test.ts new file mode 100644 index 0000000000000..57ffe707befa0 --- /dev/null +++ b/src/plugins/expressions/public/react_expression_renderer/use_shallow_memo.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useShallowMemo } from './use_shallow_memo'; + +describe('useShallowMemo', () => { + it('should return the initial value', () => { + const value = { a: 'b' }; + const { result } = renderHook(useShallowMemo, { initialProps: value }); + + expect(result.current).toBe(value); + }); + + it('should return the same value for a shallow copy', () => { + const value = { a: 'b', c: 'd' }; + const newValue = { a: 'b', c: 'd' }; + const hook = renderHook(useShallowMemo, { initialProps: value }); + hook.rerender(newValue); + + expect(hook.result.current).toBe(value); + }); + + it('should return the updated value', () => { + const value = { a: { b: 'c' } }; + const newValue = { a: { b: 'c' } }; + const hook = renderHook(useShallowMemo, { initialProps: value }); + hook.rerender(newValue); + + expect(hook.result.current).toBe(newValue); + }); +}); diff --git a/src/plugins/expressions/public/react_expression_renderer/use_shallow_memo.ts b/src/plugins/expressions/public/react_expression_renderer/use_shallow_memo.ts new file mode 100644 index 0000000000000..97b5540232c02 --- /dev/null +++ b/src/plugins/expressions/public/react_expression_renderer/use_shallow_memo.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line @typescript-eslint/triple-slash-reference, spaced-comment +/// + +import { useRef } from 'react'; +import shallowEqual from 'react-redux/lib/utils/shallowEqual'; + +export function useShallowMemo(value: T): T { + const previousRef = useRef(value); + + if (!shallowEqual(previousRef.current, value)) { + previousRef.current = value; + } + + return previousRef.current; +} diff --git a/src/plugins/expressions/public/react_expression_renderer_wrapper.tsx b/src/plugins/expressions/public/react_expression_renderer_wrapper.tsx index 45295da0a9ae8..fe0bfcc42d602 100644 --- a/src/plugins/expressions/public/react_expression_renderer_wrapper.tsx +++ b/src/plugins/expressions/public/react_expression_renderer_wrapper.tsx @@ -10,7 +10,11 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; import type { ReactExpressionRendererProps } from './react_expression_renderer'; -const ReactExpressionRendererComponent = lazy(() => import('./react_expression_renderer')); +const ReactExpressionRendererComponent = lazy(async () => { + const { ReactExpressionRenderer } = await import('./react_expression_renderer'); + + return { default: ReactExpressionRenderer }; +}); export const ReactExpressionRenderer = (props: ReactExpressionRendererProps) => ( }> From 96efcb2f30a4fc1a54c0580705b29a445ff46c9b Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Fri, 4 Feb 2022 12:15:27 -0500 Subject: [PATCH 02/12] String replace from "experimental" to "technical preview" (#124311) * change experimental mentions to technical preview * fix table * revert yml manifest change * fix jest tests * fix translation * feedback and tests * feedback and typos. ty Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../connectors/action-types/index.asciidoc | 2 +- docs/settings/task-manager-settings.asciidoc | 2 +- packages/elastic-apm-synthtrace/README.md | 31 ++++++++++--------- .../__snapshots__/call_outs.test.tsx.snap | 2 +- .../components/call_outs/call_outs.tsx | 2 +- src/plugins/dashboard/server/ui_settings.ts | 2 +- .../presentation_util/public/i18n/labs.tsx | 2 +- .../vis_types/timelion/server/ui_settings.ts | 2 +- .../components/experimental_map_vis_info.tsx | 4 ++- .../components/experimental_vis_info.tsx | 4 ++- .../visualize_app/utils/get_table_columns.tsx | 4 +-- .../group_selection/group_selection.tsx | 4 +-- src/plugins/visualizations/server/plugin.ts | 6 ++-- .../templates/apm_service_template/index.tsx | 4 +-- .../helper/get_alert_annotations.test.tsx | 6 ++-- .../charts/helper/get_alert_annotations.tsx | 2 +- x-pack/plugins/canvas/server/ui_settings.ts | 2 +- .../sections/epm/components/release_badge.ts | 5 +-- .../workspace_panel/chart_switch.tsx | 2 +- .../routes/trained_models/models_list.tsx | 4 +-- .../routes/trained_models/nodes_list.tsx | 4 +-- .../components/shared/experimental_badge.tsx | 4 +-- .../alerts/components/alerts_disclaimer.tsx | 5 ++- .../common/experimental_features_service.ts | 2 +- .../mock/endpoint/app_context_render.tsx | 4 +-- .../rules/details/translations.ts | 2 +- .../detection_engine/rules/translations.ts | 6 ++-- .../signals/get_input_output_index.test.ts | 2 +- .../experimental/0.1.0/manifest.yml | 4 +-- 29 files changed, 65 insertions(+), 60 deletions(-) diff --git a/docs/management/connectors/action-types/index.asciidoc b/docs/management/connectors/action-types/index.asciidoc index 98f7dac4de81d..01e9e3b22e2c2 100644 --- a/docs/management/connectors/action-types/index.asciidoc +++ b/docs/management/connectors/action-types/index.asciidoc @@ -105,7 +105,7 @@ experimental[] {kib} offers a preconfigured index connector to facilitate indexi [WARNING] ================================================== -This functionality is experimental and may be changed or removed completely in a future release. +This functionality is in technical preview and may be changed or removed completely in a future release. ================================================== To use this connector, set the <> configuration to `true`. diff --git a/docs/settings/task-manager-settings.asciidoc b/docs/settings/task-manager-settings.asciidoc index 286bb71542b3a..b7423d7c37b31 100644 --- a/docs/settings/task-manager-settings.asciidoc +++ b/docs/settings/task-manager-settings.asciidoc @@ -33,7 +33,7 @@ This flag will enable automatic warn and error logging if task manager self dete The amount of seconds we allow a task to delay before printing a warning server log. Defaults to 60. `xpack.task_manager.ephemeral_tasks.enabled`:: -Enables an experimental feature that executes a limited (and configurable) number of actions in the same task as the alert which triggered them. +Enables a technical preview feature that executes a limited (and configurable) number of actions in the same task as the alert which triggered them. These action tasks will reduce the latency of the time it takes an action to run after it's triggered, but are not persisted as SavedObjects. These non-persisted action tasks have a risk that they won't be run at all if the Kibana instance running them exits unexpectedly. Defaults to false. diff --git a/packages/elastic-apm-synthtrace/README.md b/packages/elastic-apm-synthtrace/README.md index cdbd536831676..bf497fecd81c8 100644 --- a/packages/elastic-apm-synthtrace/README.md +++ b/packages/elastic-apm-synthtrace/README.md @@ -1,6 +1,6 @@ # @elastic/apm-synthtrace -`@elastic/apm-synthtrace` is an experimental tool to generate synthetic APM data. It is intended to be used for development and testing of the Elastic APM app in Kibana. +`@elastic/apm-synthtrace` is a tool in technical preview to generate synthetic APM data. It is intended to be used for development and testing of the Elastic APM app in Kibana. At a high-level, the module works by modeling APM events/metricsets with [a fluent API](https://en.wikipedia.org/wiki/Fluent_interface). The models can then be serialized and converted to Elasticsearch documents. In the future we might support APM Server as an output as well. @@ -98,19 +98,20 @@ Via the CLI, you can upload scenarios, either using a fixed time range or contin For a fixed time window: `$ node packages/elastic-apm-synthtrace/src/scripts/run packages/elastic-apm-synthtrace/src/scripts/examples/01_simple_trace.ts --target=http://admin:changeme@localhost:9200 --from=now-24h --to=now` -The script will try to automatically find bootstrapped APM indices. __If these indices do not exist, the script will exit with an error. It will not bootstrap the indices itself.__ +The script will try to automatically find bootstrapped APM indices. **If these indices do not exist, the script will exit with an error. It will not bootstrap the indices itself.** The following options are supported: -| Option | Description | Default | -| ------------------| ------------------------------------------------------- | ------------ | -| `--target` | Elasticsearch target, including username/password. | **Required** | -| `--from` | The start of the time window. | `now - 15m` | -| `--to` | The end of the time window. | `now` | -| `--live` | Continously ingest data | `false` | -| `--clean` | Clean APM indices before indexing new data. | `false` | -| `--workers` | Amount of Node.js worker threads | `5` | -| `--bucketSize` | Size of bucket for which to generate data. | `15m` | -| `--interval` | The interval at which to index data. | `10s` | -| `--clientWorkers` | Number of simultaneously connected ES clients | `5` | -| `--batchSize` | Number of documents per bulk index request | `1000` | -| `--logLevel` | Log level. | `info` | + +| Option | Description | Default | +| ----------------- | -------------------------------------------------- | ------------ | +| `--target` | Elasticsearch target, including username/password. | **Required** | +| `--from` | The start of the time window. | `now - 15m` | +| `--to` | The end of the time window. | `now` | +| `--live` | Continously ingest data | `false` | +| `--clean` | Clean APM indices before indexing new data. | `false` | +| `--workers` | Amount of Node.js worker threads | `5` | +| `--bucketSize` | Size of bucket for which to generate data. | `15m` | +| `--interval` | The interval at which to index data. | `10s` | +| `--clientWorkers` | Number of simultaneously connected ES clients | `5` | +| `--batchSize` | Number of documents per bulk index request | `1000` | +| `--logLevel` | Log level. | `info` | diff --git a/src/plugins/advanced_settings/public/management_app/components/call_outs/__snapshots__/call_outs.test.tsx.snap b/src/plugins/advanced_settings/public/management_app/components/call_outs/__snapshots__/call_outs.test.tsx.snap index b3863b5452d90..7da96ad98f1bf 100644 --- a/src/plugins/advanced_settings/public/management_app/components/call_outs/__snapshots__/call_outs.test.tsx.snap +++ b/src/plugins/advanced_settings/public/management_app/components/call_outs/__snapshots__/call_outs.test.tsx.snap @@ -15,7 +15,7 @@ exports[`CallOuts should render normally 1`] = ` >

diff --git a/src/plugins/advanced_settings/public/management_app/components/call_outs/call_outs.tsx b/src/plugins/advanced_settings/public/management_app/components/call_outs/call_outs.tsx index b4eea59249c63..dabf44e2ba948 100644 --- a/src/plugins/advanced_settings/public/management_app/components/call_outs/call_outs.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/call_outs/call_outs.tsx @@ -29,7 +29,7 @@ export const CallOuts = () => { id="advancedSettings.callOutCautionDescription" defaultMessage="Be careful in here, these settings are for very advanced users only. Tweaks you make here can break large portions of Kibana. - Some of these settings may be undocumented, unsupported or experimental. + Some of these settings may be undocumented, unsupported or in technical preview. If a field has a default value, blanking the field will reset it to its default which may be unacceptable given other configuration directives. Deleting a custom setting will permanently remove it from Kibana's config." diff --git a/src/plugins/dashboard/server/ui_settings.ts b/src/plugins/dashboard/server/ui_settings.ts index 99eb29a27deaa..6efd68da25f2d 100644 --- a/src/plugins/dashboard/server/ui_settings.ts +++ b/src/plugins/dashboard/server/ui_settings.ts @@ -22,7 +22,7 @@ export const getUISettings = (): Record> => ({ }), description: i18n.translate('dashboard.labs.enableLabsDescription', { defaultMessage: - 'This flag determines if the viewer has access to the Labs button, a quick way to enable and disable experimental features in Dashboard.', + 'This flag determines if the viewer has access to the Labs button, a quick way to enable and disable technical preview features in Dashboard.', }), value: false, type: 'boolean', diff --git a/src/plugins/presentation_util/public/i18n/labs.tsx b/src/plugins/presentation_util/public/i18n/labs.tsx index ee8f15f421487..c7fafcc89f060 100644 --- a/src/plugins/presentation_util/public/i18n/labs.tsx +++ b/src/plugins/presentation_util/public/i18n/labs.tsx @@ -89,7 +89,7 @@ export const LabsStrings = { }), getDescriptionMessage: () => i18n.translate('presentationUtil.labs.components.descriptionMessage', { - defaultMessage: 'Try out our features that are in progress or experimental.', + defaultMessage: 'Try out features that are in progress or in technical preview.', }), getResetToDefaultLabel: () => i18n.translate('presentationUtil.labs.components.resetToDefaultLabel', { diff --git a/src/plugins/vis_types/timelion/server/ui_settings.ts b/src/plugins/vis_types/timelion/server/ui_settings.ts index 40907b0271487..2dff4f013c25c 100644 --- a/src/plugins/vis_types/timelion/server/ui_settings.ts +++ b/src/plugins/vis_types/timelion/server/ui_settings.ts @@ -14,7 +14,7 @@ import { UI_SETTINGS } from '../common/constants'; import { configSchema } from '../config'; const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel', { - defaultMessage: 'experimental', + defaultMessage: 'technical preview', }); export function getUiSettings( diff --git a/src/plugins/vis_types/vega/public/components/experimental_map_vis_info.tsx b/src/plugins/vis_types/vega/public/components/experimental_map_vis_info.tsx index 6d6ed0a6c1cc8..e98e0d65bd796 100644 --- a/src/plugins/vis_types/vega/public/components/experimental_map_vis_info.tsx +++ b/src/plugins/vis_types/vega/public/components/experimental_map_vis_info.tsx @@ -20,7 +20,9 @@ export const ExperimentalMapLayerInfo = () => ( title={ { <> { className="visListingTable__experimentalIcon" label="E" title={i18n.translate('visualizations.listing.experimentalTitle', { - defaultMessage: 'Experimental', + defaultMessage: 'Technical preview', })} tooltipContent={i18n.translate('visualizations.listing.experimentalTooltip', { defaultMessage: - 'This visualization might be changed or removed in a future release and is not subject to the support SLA.', + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', })} /> ); diff --git a/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx b/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx index a8080cac2c06c..bd6e64597ec8f 100644 --- a/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx +++ b/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx @@ -238,10 +238,10 @@ const ToolsGroup = ({ visType, onVisTypeSelected, showExperimental }: VisCardPro iconType="beaker" tooltipContent={i18n.translate('visualizations.newVisWizard.experimentalTooltip', { defaultMessage: - 'This visualization might be changed or removed in a future release and is not subject to the support SLA.', + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', })} label={i18n.translate('visualizations.newVisWizard.experimentalTitle', { - defaultMessage: 'Experimental', + defaultMessage: 'Technical preview', })} /> diff --git a/src/plugins/visualizations/server/plugin.ts b/src/plugins/visualizations/server/plugin.ts index 97c3e4bc19387..7ad8a44200bc1 100644 --- a/src/plugins/visualizations/server/plugin.ts +++ b/src/plugins/visualizations/server/plugin.ts @@ -55,12 +55,12 @@ export class VisualizationsPlugin core.uiSettings.register({ [VISUALIZE_ENABLE_LABS_SETTING]: { name: i18n.translate('visualizations.advancedSettings.visualizeEnableLabsTitle', { - defaultMessage: 'Enable experimental visualizations', + defaultMessage: 'Enable technical preview visualizations', }), value: true, description: i18n.translate('visualizations.advancedSettings.visualizeEnableLabsText', { - defaultMessage: `Allows users to create, view, and edit experimental visualizations. If disabled, - only visualizations that are considered production-ready are available to the user.`, + defaultMessage: `Allows users to create, view, and edit visualizations that are in technical preview. + If disabled, only visualizations that are considered production-ready are available to the user.`, }), category: ['visualization'], schema: schema.boolean(), diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx index 20f907e03fc37..8a2d837857060 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx @@ -302,14 +302,14 @@ function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { label={i18n.translate( 'xpack.apm.serviceDetails.profilingTabExperimentalLabel', { - defaultMessage: 'Experimental', + defaultMessage: 'Technical preview', } )} tooltipContent={i18n.translate( 'xpack.apm.serviceDetails.profilingTabExperimentalDescription', { defaultMessage: - 'Profiling is highly experimental and for internal use only.', + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', } )} /> diff --git a/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.test.tsx index b9f935caa98c0..6052edfd9ee77 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.test.tsx @@ -110,7 +110,7 @@ describe('getAlertAnnotations', () => { setSelectedAlertId, theme, })![0].props.dataValues[0].header - ).toEqual('Alert - Experimental'); + ).toEqual('Alert - Technical preview'); }); it('uses the reason in the annotation details', () => { @@ -191,7 +191,7 @@ describe('getAlertAnnotations', () => { setSelectedAlertId, theme, })![0].props.dataValues[0].header - ).toEqual('Warning Alert - Experimental'); + ).toEqual('Warning Alert - Technical preview'); }); }); @@ -224,7 +224,7 @@ describe('getAlertAnnotations', () => { setSelectedAlertId, theme, })![0].props.dataValues[0].header - ).toEqual('Critical Alert - Experimental'); + ).toEqual('Critical Alert - Technical preview'); }); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.tsx b/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.tsx index ca2d0351c8135..b4cd13d4615ed 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/helper/get_alert_annotations.tsx @@ -107,7 +107,7 @@ export function getAlertAnnotations({ const color = getAlertColor({ severityLevel, theme }); const experimentalLabel = i18n.translate( 'xpack.apm.alertAnnotationTooltipExperimentalText', - { defaultMessage: 'Experimental' } + { defaultMessage: 'Technical preview' } ); const header = `${getAlertHeader({ severityLevel, diff --git a/x-pack/plugins/canvas/server/ui_settings.ts b/x-pack/plugins/canvas/server/ui_settings.ts index 8c7dc9a095872..3e7de1dbb7d79 100644 --- a/x-pack/plugins/canvas/server/ui_settings.ts +++ b/x-pack/plugins/canvas/server/ui_settings.ts @@ -21,7 +21,7 @@ export const getUISettings = (): Record> => ({ }), description: i18n.translate('xpack.canvas.labs.enableLabsDescription', { defaultMessage: - 'This flag determines if the viewer has access to the Labs button, a quick way to enable and disable experimental features in Canvas.', + 'This flag determines if the viewer has access to the Labs button, a quick way to enable and disable technical preview features in Canvas.', }), value: false, type: 'boolean', diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/release_badge.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/release_badge.ts index 547d920045b90..b08e43ec3d4c5 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/release_badge.ts +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/release_badge.ts @@ -14,7 +14,7 @@ export const RELEASE_BADGE_LABEL: { [key in Exclude]: str defaultMessage: 'Beta', }), experimental: i18n.translate('xpack.fleet.epm.releaseBadge.experimentalLabel', { - defaultMessage: 'Experimental', + defaultMessage: 'Technical preview', }), }; @@ -23,6 +23,7 @@ export const RELEASE_BADGE_DESCRIPTION: { [key in Exclude defaultMessage: 'This integration is not recommended for use in production environments.', }), experimental: i18n.translate('xpack.fleet.epm.releaseBadge.experimentalDescription', { - defaultMessage: 'This integration may have breaking changes or be removed in a future release.', + defaultMessage: + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', }), }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index fe054edfb2917..4e75981f6f45e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -366,7 +366,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { diff --git a/x-pack/plugins/ml/public/application/routing/routes/trained_models/models_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/trained_models/models_list.tsx index 21d30ef76c458..a8f92e8ae9f71 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/trained_models/models_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/trained_models/models_list.tsx @@ -62,7 +62,7 @@ const PageWrapper: FC = ({ location, deps }) => { = ({ location, deps }) => { 'xpack.ml.navMenu.trainedModelsTabBetaTooltipContent', { defaultMessage: - "Model Management is an experimental feature and subject to change. We'd love to hear your feedback.", + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', } )} tooltipPosition={'right'} diff --git a/x-pack/plugins/ml/public/application/routing/routes/trained_models/nodes_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/trained_models/nodes_list.tsx index 88df1e0b07f58..33791f1e2aa81 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/trained_models/nodes_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/trained_models/nodes_list.tsx @@ -60,7 +60,7 @@ const PageWrapper: FC = ({ location, deps }) => { = ({ location, deps }) => { 'xpack.ml.navMenu.trainedModelsTabBetaTooltipContent', { defaultMessage: - "Model Management is an experimental feature and subject to change. We'd love to hear your feedback.", + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', } )} tooltipPosition={'right'} diff --git a/x-pack/plugins/observability/public/components/shared/experimental_badge.tsx b/x-pack/plugins/observability/public/components/shared/experimental_badge.tsx index a99187271806a..eb755200949c3 100644 --- a/x-pack/plugins/observability/public/components/shared/experimental_badge.tsx +++ b/x-pack/plugins/observability/public/components/shared/experimental_badge.tsx @@ -13,11 +13,11 @@ export function ExperimentalBadge() { return ( ); diff --git a/x-pack/plugins/observability/public/pages/alerts/components/alerts_disclaimer.tsx b/x-pack/plugins/observability/public/pages/alerts/components/alerts_disclaimer.tsx index 4b465a1091965..9a07c1b3cd605 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/alerts_disclaimer.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/alerts_disclaimer.tsx @@ -35,14 +35,13 @@ export function AlertsDisclaimer() { diff --git a/x-pack/plugins/security_solution/public/common/experimental_features_service.ts b/x-pack/plugins/security_solution/public/common/experimental_features_service.ts index 813341f175408..bb03fb59bf7a5 100644 --- a/x-pack/plugins/security_solution/public/common/experimental_features_service.ts +++ b/x-pack/plugins/security_solution/public/common/experimental_features_service.ts @@ -24,7 +24,7 @@ export class ExperimentalFeaturesService { private static throwUninitializedError(): never { throw new Error( - 'Experimental features services not initialized - are you trying to import this module from outside of the Security Solution app?' + 'Technical preview features services not initialized - are you trying to import this module from outside of the Security Solution app?' ); } } diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index 6d5d2dcbc7b4d..161cc62d6e731 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -62,7 +62,7 @@ export interface AppContextTestRender { render: UiRender; /** - * Set experimental features on/off. Calling this method updates the Store with the new values + * Set technical preview features on/off. Calling this method updates the Store with the new values * for the given feature flags * @param flags */ @@ -70,7 +70,7 @@ export interface AppContextTestRender { } // Defined a private custom reducer that reacts to an action that enables us to updat the -// store with new values for experimental features/flags. Because the `action.type` is a `Symbol`, +// store with new values for technical preview features/flags. Because the `action.type` is a `Symbol`, // and its not exported the action can only be `dispatch`'d from this module const UpdateExperimentalFeaturesTestActionType = Symbol('updateExperimentalFeaturesTestAction'); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts index 2ea37ccfd343b..32745f39d27a8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts @@ -24,7 +24,7 @@ export const BACK_TO_RULES = i18n.translate( export const EXPERIMENTAL = i18n.translate( 'xpack.securitySolution.detectionEngine.ruleDetails.experimentalDescription', { - defaultMessage: 'Experimental', + defaultMessage: 'Technical preview', } ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 7972eb90310c1..24d842eb930a8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -55,7 +55,7 @@ export const PAGE_TITLE = i18n.translate('xpack.securitySolution.detectionEngine export const EXPERIMENTAL_ON = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.experimentalOn', { - defaultMessage: 'Experimental: On', + defaultMessage: 'Technical preview: On', } ); @@ -63,14 +63,14 @@ export const EXPERIMENTAL_DESCRIPTION = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.experimentalDescription', { defaultMessage: - 'The experimental rules table view allows for advanced sorting capabilities. If you experience performance issues when working with the table, you can turn this setting off.', + 'The experimental rules table view is in technical preview and allows for advanced sorting capabilities. If you experience performance issues when working with the table, you can turn this setting off.', } ); export const EXPERIMENTAL_OFF = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.experimentalOff', { - defaultMessage: 'Experimental: Off', + defaultMessage: 'Technical preview: Off', } ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts index 787c26871d869..9168e25edfb37 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts @@ -99,7 +99,7 @@ describe('get_input_output_index', () => { expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); }); - test('Returns a saved object inputIndex default along with experimental features when uebaEnabled=true', async () => { + test('Returns a saved object inputIndex default along with technical preview features when uebaEnabled=true', async () => { servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ id, type, diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/experimental/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/experimental/0.1.0/manifest.yml index 9c83569a69cbe..62f605c5828f8 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/experimental/0.1.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/experimental/0.1.0/manifest.yml @@ -1,7 +1,7 @@ format_version: 1.0.0 -name: experimental +name: experimental title: experimental integration -description: This is a test package for testing experimental packages +description: This is a test package for testing technical preview packages version: 0.1.0 categories: [] release: experimental From cd7618eecaf28cc578c26943ece82daba8aa500b Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 4 Feb 2022 10:17:11 -0700 Subject: [PATCH 03/12] [maps] fix Can not style by date time with vector tiles (#124530) * [maps] fix Can not style by date time with vector tiles * eslint Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/maps/server/mvt/get_tile.ts | 13 ++++-- .../plugins/maps/server/mvt/merge_fields.ts | 40 +++++++++++++++++++ .../api_integration/apis/maps/get_tile.js | 3 +- 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/maps/server/mvt/merge_fields.ts diff --git a/x-pack/plugins/maps/server/mvt/get_tile.ts b/x-pack/plugins/maps/server/mvt/get_tile.ts index 35c3ad044216c..50b21433ebf2c 100644 --- a/x-pack/plugins/maps/server/mvt/get_tile.ts +++ b/x-pack/plugins/maps/server/mvt/get_tile.ts @@ -5,12 +5,12 @@ * 2.0. */ -import _ from 'lodash'; import { CoreStart, Logger } from 'src/core/server'; import type { DataRequestHandlerContext } from 'src/plugins/data/server'; import { Stream } from 'stream'; import { isAbortError } from './util'; import { makeExecutionContext } from '../../common/execution_context'; +import { Field, mergeFields } from './merge_fields'; export async function getEsTile({ url, @@ -39,14 +39,19 @@ export async function getEsTile({ }): Promise { try { const path = `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`; - let fields = _.uniq(requestBody.docvalue_fields.concat(requestBody.stored_fields)); - fields = fields.filter((f) => f !== geometryFieldName); + const body = { grid_precision: 0, // no aggs exact_bounds: true, extent: 4096, // full resolution, query: requestBody.query, - fields, + fields: mergeFields( + [ + requestBody.docvalue_fields as Field[] | undefined, + requestBody.stored_fields as Field[] | undefined, + ], + [geometryFieldName] + ), runtime_mappings: requestBody.runtime_mappings, track_total_hits: requestBody.size + 1, }; diff --git a/x-pack/plugins/maps/server/mvt/merge_fields.ts b/x-pack/plugins/maps/server/mvt/merge_fields.ts new file mode 100644 index 0000000000000..e371f3ff0715b --- /dev/null +++ b/x-pack/plugins/maps/server/mvt/merge_fields.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// can not use "import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey" +// SearchRequest is incorrectly typed and does not support Field as object +// https://github.com/elastic/elasticsearch-js/issues/1615 +export type Field = + | string + | { + field: string; + format: string; + }; + +export function mergeFields( + fieldsList: Array, + excludeNames: string[] +): Field[] { + const fieldNames: string[] = []; + const mergedFields: Field[] = []; + + fieldsList.forEach((fields) => { + if (!fields) { + return; + } + + fields.forEach((field) => { + const fieldName = typeof field === 'string' ? field : field.field; + if (!excludeNames.includes(fieldName) && !fieldNames.includes(fieldName)) { + fieldNames.push(fieldName); + mergedFields.push(field); + } + }); + }); + + return mergedFields; +} diff --git a/x-pack/test/api_integration/apis/maps/get_tile.js b/x-pack/test/api_integration/apis/maps/get_tile.js index 6606c9a6aa420..dd85fd094a804 100644 --- a/x-pack/test/api_integration/apis/maps/get_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_tile.js @@ -28,7 +28,7 @@ export default function ({ getService }) { `/api/maps/mvt/getTile/2/1/1.pbf\ ?geometryFieldName=geo.coordinates\ &index=logstash-*\ -&requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw))` +&requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))` ) .set('kbn-xsrf', 'kibana') .responseType('blob') @@ -53,6 +53,7 @@ export default function ({ getService }) { expect(feature.extent).to.be(4096); expect(feature.id).to.be(undefined); expect(feature.properties).to.eql({ + '@timestamp': '1442709961071', _id: 'AU_x3_BsGFA8no6Qjjug', _index: 'logstash-2015.09.20', bytes: 9252, From 490bcc440474cc91b857a9df01bcdb1c5eb80a9d Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 4 Feb 2022 10:18:05 -0700 Subject: [PATCH 04/12] [maps] unskip flacky shapefile test (#124539) --- x-pack/test/functional/apps/maps/file_upload/shapefile.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/maps/file_upload/shapefile.js b/x-pack/test/functional/apps/maps/file_upload/shapefile.js index 30d3aa1ae3b02..059e32861278a 100644 --- a/x-pack/test/functional/apps/maps/file_upload/shapefile.js +++ b/x-pack/test/functional/apps/maps/file_upload/shapefile.js @@ -14,8 +14,7 @@ export default function ({ getPageObjects, getService }) { const security = getService('security'); const retry = getService('retry'); - // FLAKY: https://github.com/elastic/kibana/issues/124334 - describe.skip('shapefile upload', () => { + describe('shapefile upload', () => { let indexName = ''; before(async () => { await security.testUser.setRoles([ @@ -41,10 +40,14 @@ export default function ({ getPageObjects, getService }) { const numberOfLayers = await PageObjects.maps.getNumberOfLayers(); expect(numberOfLayers).to.be(2); + // preview text is inconsistent. Skip expect for now + // https://github.com/elastic/kibana/issues/124334 + /* const tooltipText = await PageObjects.maps.getLayerTocTooltipMsg('cb_2018_us_csa_500k'); expect(tooltipText).to.be( 'cb_2018_us_csa_500k\nResults limited to 141 features, 81% of file.' ); + */ }); it('should import shapefile', async () => { From ce8efdfd94747ce7d8b1e9689638f61369316cd4 Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Fri, 4 Feb 2022 18:29:23 +0100 Subject: [PATCH 05/12] Add integration test for Fleet setup with HA kibana deployment (#122349) --- .../server/integration_tests/ha_setup.test.ts | 306 ++++++++++++++++++ .../server/integration_tests/router.test.ts | 208 ------------ .../plugins/fleet/server/telemetry/sender.ts | 2 +- 3 files changed, 307 insertions(+), 209 deletions(-) create mode 100644 x-pack/plugins/fleet/server/integration_tests/ha_setup.test.ts delete mode 100644 x-pack/plugins/fleet/server/integration_tests/router.test.ts diff --git a/x-pack/plugins/fleet/server/integration_tests/ha_setup.test.ts b/x-pack/plugins/fleet/server/integration_tests/ha_setup.test.ts new file mode 100644 index 0000000000000..8907399adb628 --- /dev/null +++ b/x-pack/plugins/fleet/server/integration_tests/ha_setup.test.ts @@ -0,0 +1,306 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Path from 'path'; + +import { range } from 'lodash'; + +import type { ISavedObjectsRepository } from 'src/core/server'; +import * as kbnTestServer from 'src/core/test_helpers/kbn_server'; + +import type { + AgentPolicySOAttributes, + Installation, + OutputSOAttributes, + PackagePolicySOAttributes, +} from '../types'; + +import { useDockerRegistry } from './docker_registry_helper'; + +const logFilePath = Path.join(__dirname, 'logs.log'); + +type Root = ReturnType; + +const startAndWaitForFleetSetup = async (root: Root) => { + const start = await root.start(); + + const isFleetSetupRunning = async () => { + const statusApi = kbnTestServer.getSupertest(root, 'get', '/api/status'); + const resp = await statusApi.send(); + const fleetStatus = resp.body?.status?.plugins?.fleet; + if (fleetStatus?.meta?.error) { + throw new Error(`Setup failed: ${JSON.stringify(fleetStatus)}`); + } + + return !fleetStatus || fleetStatus?.summary === 'Fleet is setting up'; + }; + + while (await isFleetSetupRunning()) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + return start; +}; + +const createAndSetupRoot = async (config?: object) => { + const root = kbnTestServer.createRootWithCorePlugins( + { + xpack: { + fleet: config, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + { + name: 'plugins.fleet', + level: 'all', + }, + ], + }, + }, + { oss: false } + ); + + await root.preboot(); + await root.setup(); + return root; +}; + +/** + * Verifies that multiple Kibana instances running in parallel will not create duplicate preconfiguration objects. + */ +describe('Fleet setup preconfiguration with multiple instances Kibana', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + // let esClient: Client; + let roots: Root[] = []; + + const registryUrl = useDockerRegistry(); + + const startServers = async () => { + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + }, + }, + }); + + esServer = await startES(); + }; + + const addRoots = async (n: number) => { + const newRoots = await Promise.all(range(n).map(() => createAndSetupRoot(preconfiguration))); + newRoots.forEach((r) => roots.push(r)); + return newRoots; + }; + + const startRoots = async () => { + return await Promise.all(roots.map(startAndWaitForFleetSetup)); + }; + + const stopServers = async () => { + for (const root of roots) { + await root.shutdown(); + } + roots = []; + + if (esServer) { + await esServer.stop(); + } + + await new Promise((res) => setTimeout(res, 10000)); + }; + + beforeEach(async () => { + await startServers(); + }); + + afterEach(async () => { + await stopServers(); + }); + + describe('preconfiguration setup', () => { + it('sets up Fleet correctly with single Kibana instance', async () => { + await addRoots(1); + const [root1Start] = await startRoots(); + const soClient = root1Start.savedObjects.createInternalRepository(); + await expectFleetSetupState(soClient); + }); + + it('sets up Fleet correctly when multiple Kibana instances are started at the same time', async () => { + await addRoots(3); + const [root1Start] = await startRoots(); + const soClient = root1Start.savedObjects.createInternalRepository(); + await expectFleetSetupState(soClient); + }); + + it('sets up Fleet correctly when multiple Kibana instaces are started in serial', async () => { + const [root1] = await addRoots(1); + const root1Start = await startAndWaitForFleetSetup(root1); + const soClient = root1Start.savedObjects.createInternalRepository(); + await expectFleetSetupState(soClient); + + const [root2] = await addRoots(1); + await startAndWaitForFleetSetup(root2); + await expectFleetSetupState(soClient); + + const [root3] = await addRoots(1); + await startAndWaitForFleetSetup(root3); + await expectFleetSetupState(soClient); + }); + }); + + const preconfiguration = { + registryUrl, + packages: [ + { + name: 'fleet_server', + version: 'latest', + }, + { + name: 'apm', + version: 'latest', + }, + ], + outputs: [ + { + name: 'Preconfigured output', + id: 'preconfigured-output', + type: 'elasticsearch', + hosts: ['http://127.0.0.1:9200'], + }, + ], + agentPolicies: [ + { + name: 'managed-test', + id: 'managed-policy-test', + data_output_id: 'preconfigured-output', + monitoring_output_id: 'preconfigured-output', + is_managed: true, + is_default_fleet_server: true, + package_policies: [ + { + name: 'fleet-server-123', + package: { + name: 'fleet_server', + }, + inputs: [ + { + type: 'fleet-server', + keep_enabled: true, + vars: [{ name: 'host', value: '127.0.0.1:8220', frozen: true }], + }, + ], + }, + ], + }, + { + name: 'nonmanaged-test', + id: 'nonmanaged-policy-test', + is_managed: false, + package_policies: [ + { + name: 'apm-123', + package: { + name: 'apm', + }, + inputs: [ + { + type: 'apm', + keep_enabled: true, + vars: [ + { name: 'api_key_enabled', value: true }, + { name: 'host', value: '0.0.0.0:8200', frozen: true }, + ], + }, + ], + }, + ], + }, + ], + }; + + async function expectFleetSetupState(soClient: ISavedObjectsRepository) { + // Assert setup state + const agentPolicies = await soClient.find({ + type: 'ingest-agent-policies', + perPage: 10000, + }); + expect(agentPolicies.saved_objects).toHaveLength(2); + expect(agentPolicies.saved_objects.map((ap) => ap.attributes)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'managed-test', + is_managed: true, + is_default_fleet_server: true, + data_output_id: 'preconfigured-output', + }), + expect.objectContaining({ + name: 'nonmanaged-test', + is_managed: false, + }), + ]) + ); + + const packagePolicies = await soClient.find({ + type: 'ingest-package-policies', + perPage: 10000, + }); + expect(packagePolicies.saved_objects).toHaveLength(2); + expect(packagePolicies.saved_objects.map((pp) => pp.attributes.name)).toEqual( + expect.arrayContaining(['apm-123', 'fleet-server-123']) + ); + + const outputs = await soClient.find({ + type: 'ingest-outputs', + perPage: 10000, + }); + expect(outputs.saved_objects).toHaveLength(2); + expect(outputs.saved_objects.map((o) => o.attributes)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'default', + is_default: true, + is_default_monitoring: true, + type: 'elasticsearch', + output_id: 'fleet-default-output', + hosts: ['http://localhost:9200'], + }), + expect.objectContaining({ + name: 'Preconfigured output', + is_default: false, + is_default_monitoring: false, + type: 'elasticsearch', + output_id: 'preconfigured-output', + hosts: ['http://127.0.0.1:9200'], + }), + ]) + ); + + const packages = await soClient.find({ + type: 'epm-packages', + perPage: 10000, + }); + expect(packages.saved_objects).toHaveLength(2); + expect(packages.saved_objects.map((p) => p.attributes.name)).toEqual( + expect.arrayContaining(['fleet_server', 'apm']) + ); + } +}); diff --git a/x-pack/plugins/fleet/server/integration_tests/router.test.ts b/x-pack/plugins/fleet/server/integration_tests/router.test.ts deleted file mode 100644 index eb002f5d731d8..0000000000000 --- a/x-pack/plugins/fleet/server/integration_tests/router.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -test.skip('requires one test', () => {}); - -/** - * skipped due to all being flaky: https://github.com/elastic/kibana/issues/58954 - * - * commented out due to hooks being called regardless of skip - * https://github.com/facebook/jest/issues/8379 - -import { resolve } from 'path'; -import * as kbnTestServer from '../../../../../src/test_utils/kbn_server'; - -function createXPackRoot(config: {} = {}) { - return kbnTestServer.createRoot({ - plugins: { - paths: [ - resolve(__dirname, '../../../../../x-pack/plugins/encrypted_saved_objects'), - resolve(__dirname, '../../../../../x-pack/plugins/fleet'), - resolve(__dirname, '../../../../../x-pack/plugins/licensing'), - ], - }, - migrations: { skip: true }, - xpack: config, - }); -} - -describe('fleet', () => { - describe('default. manager, EPM, and Fleet all disabled', () => { - let root: ReturnType; - - beforeAll(async () => { - root = createXPackRoot(); - await root.preboot(); - await root.setup(); - await root.start(); - }, 30000); - - afterAll(async () => await root.shutdown()); - - it('does not have agent policy api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/agent_policies').expect(404); - }); - - it('does not have package policies api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/package_policies').expect(404); - }); - - it('does not have EPM api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/epm/packages').expect(404); - }); - - it('does not have Fleet api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/agents/setup').expect(404); - }); - }); - - describe('manager only (no EPM, no Fleet)', () => { - let root: ReturnType; - - beforeAll(async () => { - const fleetConfig = { - enabled: true, - }; - root = createXPackRoot({ - fleet: fleetConfig, - }); - await root.preboot(); - await root.setup(); - await root.start(); - }, 30000); - - afterAll(async () => await root.shutdown()); - - it('has agent policy api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/agent_policies').expect(200); - }); - - it('has package policies api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/package_policies').expect(200); - }); - - it('does not have EPM api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/epm/packages').expect(404); - }); - - it('does not have Fleet api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/agents/setup').expect(404); - }); - }); - - // For now, only the manager routes (/agent_policies & /package_policies) are added - // EPM and ingest will be conditionally added when we enable these lines - // https://github.com/jfsiii/kibana/blob/f73b54ebb7e0f6fc00efd8a6800a01eb2d9fb772/x-pack/plugins/fleet/server/plugin.ts#L84 - // adding tests to confirm the Fleet & EPM routes are never added - - describe('manager and EPM; no Fleet', () => { - let root: ReturnType; - - beforeAll(async () => { - const fleetConfig = { - enabled: true, - epm: { enabled: true }, - }; - root = createXPackRoot({ - fleet: fleetConfig, - }); - await root.preboot(); - await root.setup(); - await root.start(); - }, 30000); - - afterAll(async () => await root.shutdown()); - - it('has agent policy api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/agent_policies').expect(200); - }); - - it('has package policies api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/package_policies').expect(200); - }); - - it('does have EPM api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/epm/packages').expect(500); - }); - - it('does not have Fleet api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/agents/setup').expect(404); - }); - }); - - describe('manager and Fleet; no EPM)', () => { - let root: ReturnType; - - beforeAll(async () => { - const fleetConfig = { - enabled: true, - fleet: { enabled: true }, - }; - root = createXPackRoot({ - fleet: fleetConfig, - }); - await root.preboot(); - await root.setup(); - await root.start(); - }, 30000); - - afterAll(async () => await root.shutdown()); - - it('has agent policy api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/agent_policies').expect(200); - }); - - it('has package policies api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/package_policies').expect(200); - }); - - it('does not have EPM api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/epm/packages').expect(404); - }); - - it('does have Fleet api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/agents/setup').expect(200); - }); - }); - - describe('all flags enabled: manager, EPM, and Fleet)', () => { - let root: ReturnType; - - beforeAll(async () => { - const fleetConfig = { - enabled: true, - epm: { enabled: true }, - fleet: { enabled: true }, - }; - root = createXPackRoot({ - fleet: fleetConfig, - }); - await root.preboot(); - await root.setup(); - await root.start(); - }, 30000); - - afterAll(async () => await root.shutdown()); - - it('has agent policy api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/agent_policies').expect(200); - }); - - it('has package policies api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/package_policies').expect(200); - }); - - it('does have EPM api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/epm/packages').expect(500); - }); - - it('does have Fleet api', async () => { - await kbnTestServer.request.get(root, '/api/fleet/agents/setup').expect(200); - }); - }); -}); -*/ diff --git a/x-pack/plugins/fleet/server/telemetry/sender.ts b/x-pack/plugins/fleet/server/telemetry/sender.ts index 473ff470842bf..2377f5e016deb 100644 --- a/x-pack/plugins/fleet/server/telemetry/sender.ts +++ b/x-pack/plugins/fleet/server/telemetry/sender.ts @@ -176,7 +176,7 @@ export class TelemetryEventsSender { this.logger.debug(`Events sent!. Response: ${resp.status} ${JSON.stringify(resp.data)}`); } catch (err) { this.logger.debug( - `Error sending events: ${err.response.status} ${JSON.stringify(err.response.data)}` + `Error sending events: ${err?.response?.status} ${JSON.stringify(err.response.data)}` ); } } From fca467d79da6812acd32f0cd8fcedf818b31586c Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Fri, 4 Feb 2022 18:37:46 +0100 Subject: [PATCH 06/12] :bug: Move up the hooks to not break the react component (#124703) --- .../public/components/heatmap_component.tsx | 222 +++++++++--------- 1 file changed, 111 insertions(+), 111 deletions(-) diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx index 17fa5b16bb574..3c751956c0ea2 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx @@ -194,6 +194,117 @@ export const HeatmapComponent: FC = memo( const xAxisColumn = table.columns[xAxisColumnIndex]; const yAxisColumn = table.columns[yAxisColumnIndex]; const valueColumn = table.columns.find((v) => v.id === valueAccessor); + const xAxisMeta = xAxisColumn?.meta; + const isTimeBasedSwimLane = xAxisMeta?.type === 'date'; + + const onElementClick = useCallback( + (e: HeatmapElementEvent[]) => { + const cell = e[0][0]; + const { x, y } = cell.datum; + + const xAxisFieldName = xAxisColumn?.meta?.field; + const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : ''; + + const points = [ + { + row: table.rows.findIndex((r) => r[xAxisColumn.id] === x), + column: xAxisColumnIndex, + value: x, + }, + ...(yAxisColumn + ? [ + { + row: table.rows.findIndex((r) => r[yAxisColumn.id] === y), + column: yAxisColumnIndex, + value: y, + }, + ] + : []), + ]; + + const context: FilterEvent['data'] = { + data: points.map((point) => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + timeFieldName, + }; + onClickValue(context); + }, + [ + isTimeBasedSwimLane, + onClickValue, + table, + xAxisColumn?.id, + xAxisColumn?.meta?.field, + xAxisColumnIndex, + yAxisColumn, + yAxisColumnIndex, + ] + ); + + const onBrushEnd = useCallback( + (e: HeatmapBrushEvent) => { + const { x, y } = e; + + const xAxisFieldName = xAxisColumn?.meta?.field; + const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : ''; + + if (isTimeBasedSwimLane) { + const context: BrushEvent['data'] = { + range: x as number[], + table, + column: xAxisColumnIndex, + timeFieldName, + }; + onSelectRange(context); + } else { + const points: Array<{ row: number; column: number; value: string | number }> = []; + + if (yAxisColumn) { + (y as string[]).forEach((v) => { + points.push({ + row: table.rows.findIndex((r) => r[yAxisColumn.id] === v), + column: yAxisColumnIndex, + value: v, + }); + }); + } + if (xAxisColumn) { + (x as string[]).forEach((v) => { + points.push({ + row: table.rows.findIndex((r) => r[xAxisColumn.id] === v), + column: xAxisColumnIndex, + value: v, + }); + }); + } + + const context: FilterEvent['data'] = { + data: points.map((point) => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + timeFieldName, + }; + onClickValue(context); + } + }, + [ + isTimeBasedSwimLane, + onClickValue, + onSelectRange, + table, + xAxisColumn, + xAxisColumnIndex, + yAxisColumn, + yAxisColumnIndex, + ] + ); if (!valueColumn) { // Chart is not ready @@ -216,12 +327,10 @@ export const HeatmapComponent: FC = memo( } const { min, max } = minMaxByColumnId[valueAccessor!]; // formatters - const xAxisMeta = xAxisColumn?.meta; const xValuesFormatter = formatFactory(xAxisMeta?.params); const metricFormatter = formatFactory( typeof args.valueAccessor === 'string' ? valueColumn.meta.params : args?.valueAccessor?.format ); - const isTimeBasedSwimLane = xAxisMeta?.type === 'date'; const dateHistogramMeta = xAxisColumn ? search.aggs.getDateHistogramMetaDataByDatatableColumn(xAxisColumn) : undefined; @@ -316,115 +425,6 @@ export const HeatmapComponent: FC = memo( }; }); - const onElementClick = useCallback( - (e: HeatmapElementEvent[]) => { - const cell = e[0][0]; - const { x, y } = cell.datum; - - const xAxisFieldName = xAxisColumn?.meta?.field; - const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : ''; - - const points = [ - { - row: table.rows.findIndex((r) => r[xAxisColumn.id] === x), - column: xAxisColumnIndex, - value: x, - }, - ...(yAxisColumn - ? [ - { - row: table.rows.findIndex((r) => r[yAxisColumn.id] === y), - column: yAxisColumnIndex, - value: y, - }, - ] - : []), - ]; - - const context: FilterEvent['data'] = { - data: points.map((point) => ({ - row: point.row, - column: point.column, - value: point.value, - table, - })), - timeFieldName, - }; - onClickValue(context); - }, - [ - isTimeBasedSwimLane, - onClickValue, - table, - xAxisColumn?.id, - xAxisColumn?.meta?.field, - xAxisColumnIndex, - yAxisColumn, - yAxisColumnIndex, - ] - ); - - const onBrushEnd = useCallback( - (e: HeatmapBrushEvent) => { - const { x, y } = e; - - const xAxisFieldName = xAxisColumn?.meta?.field; - const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : ''; - - if (isTimeBasedSwimLane) { - const context: BrushEvent['data'] = { - range: x as number[], - table, - column: xAxisColumnIndex, - timeFieldName, - }; - onSelectRange(context); - } else { - const points: Array<{ row: number; column: number; value: string | number }> = []; - - if (yAxisColumn) { - (y as string[]).forEach((v) => { - points.push({ - row: table.rows.findIndex((r) => r[yAxisColumn.id] === v), - column: yAxisColumnIndex, - value: v, - }); - }); - } - if (xAxisColumn) { - (x as string[]).forEach((v) => { - points.push({ - row: table.rows.findIndex((r) => r[xAxisColumn.id] === v), - column: xAxisColumnIndex, - value: v, - }); - }); - } - - const context: FilterEvent['data'] = { - data: points.map((point) => ({ - row: point.row, - column: point.column, - value: point.value, - table, - })), - timeFieldName, - }; - onClickValue(context); - } - }, - [ - isTimeBasedSwimLane, - onClickValue, - onSelectRange, - table, - xAxisColumn, - xAxisColumnIndex, - yAxisColumn, - yAxisColumnIndex, - ] - ); - const themeOverrides: PartialTheme = { legend: { labelOptions: { From 17409c34fe66d91027fdda2ef252a8d69842d436 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Fri, 4 Feb 2022 11:57:50 -0600 Subject: [PATCH 07/12] [ML] Transforms: Fix sort on field names containing dots not applied in wizard preview grid (#124587) * Fix field name containing dot in paths * Fix field name containing dot in paths * Fix find Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/data_grid/common.test.ts | 18 ++++----- .../components/data_grid/common.ts | 40 ++++++++++++------- .../application/components/data_grid/index.ts | 1 + .../components/data_grid/use_data_grid.tsx | 6 +-- .../public/app/hooks/use_pivot_data.ts | 8 +++- .../transform_list/use_table_settings.ts | 1 - 6 files changed, 44 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.test.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.test.ts index 1c249ee1b5037..2b3a4a2dcbb50 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.test.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.test.ts @@ -5,9 +5,7 @@ * 2.0. */ -import { EuiDataGridSorting } from '@elastic/eui'; - -import { multiColumnSortFactory } from './common'; +import { MultiColumnSorter, multiColumnSortFactory } from './common'; describe('Data Frame Analytics: Data Grid Common', () => { test('multiColumnSortFactory()', () => { @@ -18,7 +16,7 @@ describe('Data Frame Analytics: Data Grid Common', () => { { s: 'b', n: 4 }, ]; - const sortingColumns1: EuiDataGridSorting['columns'] = [{ id: 's', direction: 'desc' }]; + const sortingColumns1: MultiColumnSorter[] = [{ id: 's', direction: 'desc', type: 'number' }]; const multiColumnSort1 = multiColumnSortFactory(sortingColumns1); data.sort(multiColumnSort1); @@ -29,9 +27,9 @@ describe('Data Frame Analytics: Data Grid Common', () => { { s: 'a', n: 2 }, ]); - const sortingColumns2: EuiDataGridSorting['columns'] = [ - { id: 's', direction: 'asc' }, - { id: 'n', direction: 'desc' }, + const sortingColumns2: MultiColumnSorter[] = [ + { id: 's', direction: 'asc', type: 'number' }, + { id: 'n', direction: 'desc', type: 'number' }, ]; const multiColumnSort2 = multiColumnSortFactory(sortingColumns2); data.sort(multiColumnSort2); @@ -43,9 +41,9 @@ describe('Data Frame Analytics: Data Grid Common', () => { { s: 'b', n: 3 }, ]); - const sortingColumns3: EuiDataGridSorting['columns'] = [ - { id: 'n', direction: 'desc' }, - { id: 's', direction: 'desc' }, + const sortingColumns3: MultiColumnSorter[] = [ + { id: 'n', direction: 'desc', type: 'number' }, + { id: 's', direction: 'desc', type: 'number' }, ]; const multiColumnSort3 = multiColumnSortFactory(sortingColumns3); data.sort(multiColumnSort3); diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts index d49442b9864d4..31979000f4a60 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts @@ -9,11 +9,7 @@ import moment from 'moment-timezone'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { useEffect, useMemo } from 'react'; -import { - EuiDataGridCellValueElementProps, - EuiDataGridSorting, - EuiDataGridStyle, -} from '@elastic/eui'; +import { EuiDataGridCellValueElementProps, EuiDataGridStyle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -178,7 +174,7 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results export const NON_AGGREGATABLE = 'non-aggregatable'; export const getDataGridSchemaFromESFieldType = ( - fieldType: ES_FIELD_TYPES | undefined | estypes.MappingRuntimeField['type'] + fieldType: ES_FIELD_TYPES | undefined | estypes.MappingRuntimeField['type'] | 'number' ): string | undefined => { // Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json'] // To fall back to the default string schema it needs to be undefined. @@ -204,6 +200,7 @@ export const getDataGridSchemaFromESFieldType = ( case ES_FIELD_TYPES.LONG: case ES_FIELD_TYPES.SCALED_FLOAT: case ES_FIELD_TYPES.SHORT: + case 'number': schema = 'numeric'; break; // keep schema undefined for text based columns @@ -417,6 +414,16 @@ export const useRenderCellValue = ( return renderCellValue; }; +// Value can be nested or the fieldName itself might contain other special characters like `.` +export const getNestedOrEscapedVal = (obj: any, sortId: string) => + getNestedProperty(obj, sortId, null) ?? obj[sortId]; + +export interface MultiColumnSorter { + id: string; + direction: 'asc' | 'desc'; + type: string; +} + /** * Helper to sort an array of objects based on an EuiDataGrid sorting configuration. * `sortFn()` is recursive to support sorting on multiple columns. @@ -424,17 +431,17 @@ export const useRenderCellValue = ( * @param sortingColumns - The EUI data grid sorting configuration * @returns The sorting function which can be used with an array's sort() function. */ -export const multiColumnSortFactory = (sortingColumns: EuiDataGridSorting['columns']) => { - const isString = (arg: any): arg is string => { - return typeof arg === 'string'; - }; - +export const multiColumnSortFactory = (sortingColumns: MultiColumnSorter[]) => { const sortFn = (a: any, b: any, sortingColumnIndex = 0): number => { const sort = sortingColumns[sortingColumnIndex]; - const aValue = getNestedProperty(a, sort.id, null); - const bValue = getNestedProperty(b, sort.id, null); - if (typeof aValue === 'number' && typeof bValue === 'number') { + // Value can be nested or the fieldName itself might contain `.` + let aValue = getNestedOrEscapedVal(a, sort.id); + let bValue = getNestedOrEscapedVal(b, sort.id); + + if (sort.type === 'number') { + aValue = aValue ?? 0; + bValue = bValue ?? 0; if (aValue < bValue) { return sort.direction === 'asc' ? -1 : 1; } @@ -443,7 +450,10 @@ export const multiColumnSortFactory = (sortingColumns: EuiDataGridSorting['colum } } - if (isString(aValue) && isString(bValue)) { + if (sort.type === 'string') { + aValue = aValue ?? ''; + bValue = bValue ?? ''; + if (aValue.localeCompare(bValue) === -1) { return sort.direction === 'asc' ? -1 : 1; } diff --git a/x-pack/plugins/ml/public/application/components/data_grid/index.ts b/x-pack/plugins/ml/public/application/components/data_grid/index.ts index b2bd1ff228923..8b09617aa817e 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/index.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/index.ts @@ -12,6 +12,7 @@ export { getFieldsFromKibanaIndexPattern, getCombinedRuntimeMappings, multiColumnSortFactory, + getNestedOrEscapedVal, showDataGridColumnChartErrorMessageToast, useRenderCellValue, getProcessedFields, diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx index 633c3d9aab002..55c12338b8fd4 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx @@ -87,15 +87,15 @@ export const useDataGrid = ( const onSort: OnSort = useCallback( (sc) => { // Check if an unsupported column type for sorting was selected. - const updatedInvalidSortingColumnns = sc.reduce((arr, current) => { + const updatedInvalidSortingColumns = sc.reduce((arr, current) => { const columnType = columns.find((dgc) => dgc.id === current.id); if (columnType?.schema === 'json') { arr.push(current.id); } return arr; }, []); - setInvalidSortingColumnns(updatedInvalidSortingColumnns); - if (updatedInvalidSortingColumnns.length === 0) { + setInvalidSortingColumnns(updatedInvalidSortingColumns); + if (updatedInvalidSortingColumns.length === 0) { setSortingColumns(sc); } }, diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index 300c9c84993a1..01cb39ac87fa8 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -110,6 +110,7 @@ export const usePivotData = ( getDataGridSchemaFromESFieldType, formatHumanReadableDateTimeSeconds, multiColumnSortFactory, + getNestedOrEscapedVal, useDataGrid, INDEX_STATUS, }, @@ -235,7 +236,12 @@ export const usePivotData = ( }, [indexPatternTitle, JSON.stringify([requestPayload, query, combinedRuntimeMappings])]); if (sortingColumns.length > 0) { - tableItems.sort(multiColumnSortFactory(sortingColumns)); + const sortingColumnsWithTypes = sortingColumns.map((c) => ({ + ...c, + // Since items might contain undefined/null values, we want to accurate find the data type + type: typeof tableItems.find((item) => getNestedOrEscapedVal(item, c.id) !== undefined), + })); + tableItems.sort(multiColumnSortFactory(sortingColumnsWithTypes)); } const pageData = tableItems.slice( diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_table_settings.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_table_settings.ts index c627984e0214f..ca073bc82cbc7 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_table_settings.ts +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_table_settings.ts @@ -89,6 +89,5 @@ export function useTableSettings( direction: sortDirection, }, }; - return { onTableChange, pagination, sorting }; } From f7661c007be3033dd6da74ad013bc37c08ba3fc9 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Fri, 4 Feb 2022 11:58:22 -0600 Subject: [PATCH 08/12] [ML] Assert Lens show chart after navigation from Index data visualizer (#124579) * Assert Lens show chart after navigation from data viz * Fix tests --- .../data_visualizer/index_data_visualizer.ts | 47 +++++++++++++++++++ .../services/ml/data_visualizer_table.ts | 11 +++++ .../test/functional/services/ml/navigation.ts | 8 +++- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts index a5a0d6d2de6d7..a20962e607af2 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts @@ -14,6 +14,7 @@ import { farequoteLuceneSearchTestData, sampleLogTestData, } from './index_test_data'; +import { ML_JOB_FIELD_TYPES } from '../../../../../plugins/ml/common/constants/field_types'; export default function ({ getPageObject, getService }: FtrProviderContext) { const headerPage = getPageObject('header'); @@ -220,5 +221,51 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { }); runTests(sampleLogTestData); }); + + describe('with view in lens action ', function () { + const testData = farequoteDataViewTestData; + // Run tests on full ft_module_sample_logs index. + it(`${testData.suiteTitle} loads the data visualizer selector page`, async () => { + // Start navigation from the base of the ML app. + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataVisualizer(); + }); + + it(`${testData.suiteTitle} loads lens charts`, async () => { + await ml.testExecution.logTestStep( + `${testData.suiteTitle} loads the saved search selection page` + ); + await ml.dataVisualizer.navigateToIndexPatternSelection(); + + await ml.testExecution.logTestStep( + `${testData.suiteTitle} loads the index data visualizer page` + ); + await ml.jobSourceSelection.selectSourceForIndexBasedDataVisualizer( + testData.sourceIndexOrSavedSearch + ); + + await ml.testExecution.logTestStep(`${testData.suiteTitle} loads data for full time range`); + await ml.dataVisualizerIndexBased.clickUseFullDataButton( + testData.expected.totalDocCountFormatted + ); + await headerPage.waitUntilLoadingHasFinished(); + + await ml.testExecution.logTestStep('navigate to Lens'); + const lensMetricField = testData.expected.metricFields![0]; + + if (lensMetricField) { + await ml.dataVisualizerTable.assertLensActionShowChart(lensMetricField.fieldName); + await ml.navigation.browserBackTo('dataVisualizerTable'); + } + const lensNonMetricField = testData.expected.nonMetricFields?.find( + (f) => f.type === ML_JOB_FIELD_TYPES.KEYWORD + ); + + if (lensNonMetricField) { + await ml.dataVisualizerTable.assertLensActionShowChart(lensNonMetricField.fieldName); + await ml.navigation.browserBackTo('dataVisualizerTable'); + } + }); + }); }); } diff --git a/x-pack/test/functional/services/ml/data_visualizer_table.ts b/x-pack/test/functional/services/ml/data_visualizer_table.ts index 24563fe05f6ff..cf9b1f8fa35a5 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_table.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_table.ts @@ -565,6 +565,17 @@ export function MachineLearningDataVisualizerTableProvider( } } + public async assertLensActionShowChart(fieldName: string) { + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.clickWhenNotDisabled( + this.rowSelector(fieldName, 'dataVisualizerActionViewInLensButton') + ); + await testSubjects.existOrFail('lnsVisualizationContainer', { + timeout: 15 * 1000, + }); + }); + } + public async ensureNumRowsPerPage(n: 10 | 25 | 50) { const paginationButton = 'dataVisualizerTable > tablePaginationPopoverButton'; await retry.tryForTime(10000, async () => { diff --git a/x-pack/test/functional/services/ml/navigation.ts b/x-pack/test/functional/services/ml/navigation.ts index 4f11f082eb152..c11721453d10f 100644 --- a/x-pack/test/functional/services/ml/navigation.ts +++ b/x-pack/test/functional/services/ml/navigation.ts @@ -17,7 +17,7 @@ export function MachineLearningNavigationProvider({ const browser = getService('browser'); const retry = getService('retry'); const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['common']); + const PageObjects = getPageObjects(['common', 'header']); return { async navigateToMl() { @@ -264,5 +264,11 @@ export function MachineLearningNavigationProvider({ `Expected the current URL "${currentUrl}" to not include ${expectedUrlPart}` ); }, + + async browserBackTo(backTestSubj: string) { + await browser.goBack(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail(backTestSubj, { timeout: 10 * 1000 }); + }, }; } From d9aa72c7f8b956546cc4d1f8fb185871eb695378 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Fri, 4 Feb 2022 20:14:34 +0200 Subject: [PATCH 09/12] [PieVis] Lens adaptation. (#122420) * Added config for mosaic/pie/donut/treemap/waffle. * Added sortPredicate functionality for waffle/mosaic/treemap/pie/donut * Added Donut handling. * Refactored get_color. * Merged color computation for lens and vis_types. * Added isFlatLegend support. * Added showValuesInLegend for waffle and fixed tests. * Removed not used position, which is equivalent to labels.show = false. * legendDisplay added. * Added migrations for pieVis addLegend argument. * Added startFromSecondLargestSlice and support of correct formatters. * Updated docs. * Added functionality for truncate. * Added unit tests for pie and partial for donut/waffle. * Addressed issue with label truncation by default. * Addressed issue with formatters. * Added tests for accessor.test.ts * Added support of formatter by meta data from columns at splitChartAccessors. * Added tests for filterOutConfig. * Added tests for getFormatters. * Added tests for getAvailableFormatter. * Added tests for getFormatter. * Added tests for get_split_dimension_accessor. * Add is legend scenario. * Added tests for legend. * Replaced sortPredicate, relying on the internal terms params, with the mosaic one. * Fixed pie snapshot and added new snapshot for treemap. * Added snapshots for mosaicVis. * Added snapshot to waffleVis. * Updated unit tests for *_vis_function's. * Added storybook. * Added snapshots for partition vis component. * Added expression error on providing both, splitColumn && splitRow. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 2 +- .i18nrc.json | 2 +- docs/developer/plugin-list.asciidoc | 4 +- packages/kbn-optimizer/limits.yml | 2 +- src/dev/storybook/aliases.ts | 2 +- .../.storybook/main.js | 0 .../expression_partition_vis/README.md | 9 + .../common/constants.ts | 20 + .../mosaic_vis_function.test.ts.snap | 188 ++ .../pie_vis_function.test.ts.snap | 288 +++ .../treemap_vis_function.test.ts.snap | 188 ++ .../waffle_vis_function.test.ts.snap | 168 ++ .../common/expression_functions/i18n.ts | 126 ++ .../common/expression_functions/index.ts | 13 + .../mosaic_vis_function.test.ts | 145 ++ .../mosaic_vis_function.ts | 131 ++ .../partition_labels_function.ts | 105 + .../pie_vis_function.test.ts | 69 +- .../expression_functions/pie_vis_function.ts | 153 ++ .../treemap_vis_function.test.ts | 145 ++ .../treemap_vis_function.ts | 131 ++ .../waffle_vis_function.test.ts | 116 + .../waffle_vis_function.ts | 126 ++ .../expression_partition_vis/common/index.ts | 52 + .../common/types/expression_functions.ts | 93 + .../common/types/expression_renderers.ts | 73 +- .../common/types/index.ts | 0 .../jest.config.js | 6 +- .../kibana.json | 4 +- .../public/__mocks__/format_service.ts | 2 +- .../public/__mocks__/index.ts | 0 .../public/__mocks__/palettes.ts | 7 +- .../public/__mocks__/start_deps.ts | 0 .../public/__mocks__/theme.ts | 6 +- .../public/__mocks__/ui_settings.ts | 31 + .../mosaic_vis_renderer.stories.tsx | 51 + .../__stories__/pie_vis_renderer.stories.tsx | 51 + .../public/__stories__/shared/arg_types.ts | 216 ++ .../public/__stories__/shared/config.ts | 129 ++ .../public/__stories__/shared/data.ts | 207 ++ .../public/__stories__/shared/index.ts | 17 + .../treemap_vis_renderer.stories.tsx | 51 + .../waffle_vis_renderer.stories.tsx | 51 + .../partition_vis_component.test.tsx.snap | 1993 +++++++++++++++++ .../public/components/chart_split.tsx | 0 .../public/components}/index.ts | 2 +- .../partition_vis_component.styles.ts} | 7 +- .../partition_vis_component.test.tsx} | 113 +- .../components/partition_vis_component.tsx} | 170 +- .../components/visualization_noresults.tsx | 4 +- .../public/expression_renderers}/index.ts | 3 +- .../partition_vis_renderer.tsx} | 19 +- .../public}/index.ts | 6 +- .../public/mocks.ts | 89 +- .../public/plugin.ts | 30 +- .../public/types.ts | 4 +- .../public/utils/accessor.test.ts | 50 + .../public/utils/accessor.ts | 0 .../public/utils/filter_helpers.test.ts | 0 .../public/utils/filter_helpers.ts | 0 .../public/utils/filter_out_config.test.ts | 47 + .../public/utils/filter_out_config.ts | 22 + .../public/utils/formatters.test.ts | 186 ++ .../public/utils/formatters.ts | 52 + .../public/utils/get_color_picker.test.tsx | 0 .../public/utils/get_color_picker.tsx | 0 .../public/utils/get_columns.test.ts | 57 +- .../public/utils/get_columns.ts | 40 +- .../public/utils/get_distinct_series.test.ts | 0 .../public/utils/get_distinct_series.ts | 15 +- .../public/utils/get_legend_actions.tsx | 10 +- .../public/utils/get_partition_theme.test.ts | 496 ++++ .../public/utils/get_partition_theme.ts | 165 ++ .../public/utils/get_partition_type.ts | 19 + .../get_split_dimension_accessor.test.ts | 166 ++ .../utils/get_split_dimension_accessor.ts | 36 + .../public/utils/index.ts | 5 +- .../public/utils/layers/get_color.ts | 237 ++ .../public/utils/layers}/get_layers.test.ts | 35 +- .../public/utils/layers/get_layers.ts | 84 + .../public/utils/layers/get_node_labels.ts | 25 + .../public/utils/layers}/index.ts | 2 +- .../public/utils/layers/sort_predicate.ts | 77 + .../public/utils/legend.test.ts | 140 ++ .../public/utils/legend.ts | 45 + .../server}/index.ts | 6 +- .../expression_partition_vis/server/plugin.ts | 43 + .../server/types.ts | 4 +- .../tsconfig.json | 0 .../expression_pie/README.md | 9 - .../expression_pie/common/constants.ts | 16 - .../pie_vis_function.test.ts.snap | 98 - .../pie_labels_function.ts | 88 - .../expression_functions/pie_vis_function.ts | 183 -- .../expression_pie/common/index.ts | 32 - .../common/types/expression_functions.ts | 46 - .../__stories__/pie_renderer.stories.tsx | 115 - .../expression_pie/public/utils/get_layers.ts | 201 -- .../public/utils/get_partition_theme.test.ts | 72 - .../public/utils/get_partition_theme.ts | 85 - .../utils/get_split_dimension_accessor.ts | 31 - .../expression_pie/server/plugin.ts | 23 - src/plugins/vis_types/pie/kibana.json | 2 +- .../public/__snapshots__/to_ast.test.ts.snap | 18 +- .../pie/public/editor/collections.ts | 2 +- .../pie/public/editor/components/index.tsx | 4 +- .../pie/public/editor/components/pie.tsx | 46 +- .../pie/public/sample_vis.test.mocks.ts | 6 +- .../vis_types/pie/public/to_ast.test.ts | 4 +- src/plugins/vis_types/pie/public/to_ast.ts | 19 +- .../vis_types/pie/public/to_ast_esaggs.ts | 4 +- .../vis_types/pie/public/vis_type/pie.ts | 9 +- src/plugins/vis_types/pie/tsconfig.json | 2 +- .../public/__snapshots__/pie_fn.test.ts.snap | 2 +- .../__snapshots__/to_ast_pie.test.ts.snap | 2 +- src/plugins/vis_types/vislib/public/pie.ts | 11 +- .../vis_types/vislib/public/pie_fn.test.ts | 2 +- .../vislib/public/vis_controller.tsx | 22 +- .../vislib/visualizations/pie_chart.test.js | 4 +- .../make_visualize_embeddable_factory.ts | 7 + .../visualization_common_migrations.ts | 16 + ...ualization_saved_object_migrations.test.ts | 70 + .../visualization_saved_object_migrations.ts | 26 + .../translations/translations/ja-JP.json | 30 +- .../translations/translations/zh-CN.json | 277 +-- 125 files changed, 7715 insertions(+), 1553 deletions(-) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/.storybook/main.js (100%) create mode 100755 src/plugins/chart_expressions/expression_partition_vis/README.md create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/constants.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/index.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/partition_labels_function.ts rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/common/expression_functions/pie_vis_function.test.ts (56%) create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts create mode 100755 src/plugins/chart_expressions/expression_partition_vis/common/index.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/common/types/expression_functions.ts rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/common/types/expression_renderers.ts (59%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/common/types/index.ts (100%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/jest.config.js (72%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/kibana.json (55%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/__mocks__/format_service.ts (88%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/__mocks__/index.ts (100%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/__mocks__/palettes.ts (87%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/__mocks__/start_deps.ts (100%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/__mocks__/theme.ts (79%) create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/ui_settings.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/__stories__/mosaic_vis_renderer.stories.tsx create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/__stories__/pie_vis_renderer.stories.tsx create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/config.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/data.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/index.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/__stories__/treemap_vis_renderer.stories.tsx create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/__stories__/waffle_vis_renderer.stories.tsx create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/components/chart_split.tsx (100%) rename src/plugins/chart_expressions/{expression_pie/public/expression_renderers => expression_partition_vis/public/components}/index.ts (86%) rename src/plugins/chart_expressions/{expression_pie/public/components/pie_vis_component.styles.ts => expression_partition_vis/public/components/partition_vis_component.styles.ts} (78%) rename src/plugins/chart_expressions/{expression_pie/public/components/pie_vis_component.test.tsx => expression_partition_vis/public/components/partition_vis_component.test.tsx} (65%) rename src/plugins/chart_expressions/{expression_pie/public/components/pie_vis_component.tsx => expression_partition_vis/public/components/partition_vis_component.tsx} (71%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/components/visualization_noresults.tsx (86%) rename src/plugins/chart_expressions/{expression_pie/common/expression_functions => expression_partition_vis/public/expression_renderers}/index.ts (76%) rename src/plugins/chart_expressions/{expression_pie/public/expression_renderers/pie_vis_renderer.tsx => expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx} (79%) rename src/plugins/chart_expressions/{expression_pie/server => expression_partition_vis/public}/index.ts (65%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/mocks.ts (84%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/plugin.ts (64%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/types.ts (86%) create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.test.ts rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/accessor.ts (100%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/filter_helpers.test.ts (100%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/filter_helpers.ts (100%) create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_out_config.test.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_out_config.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.test.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.ts rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/get_color_picker.test.tsx (100%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/get_color_picker.tsx (100%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/get_columns.test.ts (85%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/get_columns.ts (50%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/get_distinct_series.test.ts (100%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/get_distinct_series.ts (78%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/get_legend_actions.tsx (91%) create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_theme.test.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_theme.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_type.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.test.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.ts rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/public/utils/index.ts (78%) create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts rename src/plugins/chart_expressions/{expression_pie/public/utils => expression_partition_vis/public/utils/layers}/get_layers.test.ts (84%) create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_node_labels.ts rename src/plugins/chart_expressions/{expression_pie/public/components => expression_partition_vis/public/utils/layers}/index.ts (89%) create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/sort_predicate.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/legend.test.ts create mode 100644 src/plugins/chart_expressions/expression_partition_vis/public/utils/legend.ts rename src/plugins/chart_expressions/{expression_pie/public => expression_partition_vis/server}/index.ts (65%) create mode 100755 src/plugins/chart_expressions/expression_partition_vis/server/plugin.ts rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/server/types.ts (84%) rename src/plugins/chart_expressions/{expression_pie => expression_partition_vis}/tsconfig.json (100%) delete mode 100755 src/plugins/chart_expressions/expression_pie/README.md delete mode 100644 src/plugins/chart_expressions/expression_pie/common/constants.ts delete mode 100644 src/plugins/chart_expressions/expression_pie/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap delete mode 100644 src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_labels_function.ts delete mode 100644 src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_vis_function.ts delete mode 100755 src/plugins/chart_expressions/expression_pie/common/index.ts delete mode 100644 src/plugins/chart_expressions/expression_pie/common/types/expression_functions.ts delete mode 100644 src/plugins/chart_expressions/expression_pie/public/__stories__/pie_renderer.stories.tsx delete mode 100644 src/plugins/chart_expressions/expression_pie/public/utils/get_layers.ts delete mode 100644 src/plugins/chart_expressions/expression_pie/public/utils/get_partition_theme.test.ts delete mode 100644 src/plugins/chart_expressions/expression_pie/public/utils/get_partition_theme.ts delete mode 100644 src/plugins/chart_expressions/expression_pie/public/utils/get_split_dimension_accessor.ts delete mode 100755 src/plugins/chart_expressions/expression_pie/server/plugin.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7035660b1b46a..604179ec75706 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -40,7 +40,7 @@ /src/plugins/chart_expressions/expression_metric/ @elastic/kibana-vis-editors /src/plugins/chart_expressions/expression_heatmap/ @elastic/kibana-vis-editors /src/plugins/chart_expressions/expression_gauge/ @elastic/kibana-vis-editors -/src/plugins/chart_expressions/expression_pie/ @elastic/kibana-vis-editors +/src/plugins/chart_expressions/expression_partition_vis/ @elastic/kibana-vis-editors /src/plugins/url_forwarding/ @elastic/kibana-vis-editors /packages/kbn-tinymath/ @elastic/kibana-vis-editors /x-pack/test/functional/apps/lens @elastic/kibana-vis-editors diff --git a/.i18nrc.json b/.i18nrc.json index 043e1e28a0e9d..5c362908a1876 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -25,7 +25,7 @@ "expressionImage": "src/plugins/expression_image", "expressionMetric": "src/plugins/expression_metric", "expressionMetricVis": "src/plugins/chart_expressions/expression_metric", - "expressionPie": "src/plugins/chart_expressions/expression_pie", + "expressionPartitionVis": "src/plugins/chart_expressions/expression_partition_vis", "expressionRepeatImage": "src/plugins/expression_repeat_image", "expressionRevealImage": "src/plugins/expression_reveal_image", "expressions": "src/plugins/expressions", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index d38775fc608d5..ea7ea24690886 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -118,8 +118,8 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a |Expression MetricVis plugin adds a metric renderer and function to the expression plugin. The renderer will display the metric chart. -|{kib-repo}blob/{branch}/src/plugins/chart_expressions/expression_pie/README.md[expressionPie] -|Expression Pie plugin adds a pie renderer and function to the expression plugin. The renderer will display the Pie chart. +|{kib-repo}blob/{branch}/src/plugins/chart_expressions/expression_partition_vis/README.md[expressionPartitionVis] +|Expression Partition Visualization plugin adds a partitionVis renderer and pieVis, mosaicVis, treemapVis, waffleVis functions to the expression plugin. The renderer will display the pie, waffle, treemap and mosaic charts. |{kib-repo}blob/{branch}/src/plugins/expression_repeat_image/README.md[expressionRepeatImage] diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index bbd7f25ca9c02..3a7c50feb38b7 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -119,6 +119,6 @@ pageLoadAssetSize: screenshotting: 17017 expressionGauge: 25000 controls: 34788 - expressionPie: 26338 + expressionPartitionVis: 26338 sharedUX: 16225 ux: 20784 diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index d6526781e7373..542acf7b0fa8f 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -24,7 +24,7 @@ export const storybookAliases = { expression_image: 'src/plugins/expression_image/.storybook', expression_metric_vis: 'src/plugins/chart_expressions/expression_metric/.storybook', expression_metric: 'src/plugins/expression_metric/.storybook', - expression_pie: 'src/plugins/chart_expressions/expression_pie/.storybook', + expression_partition_vis: 'src/plugins/chart_expressions/expression_partition_vis/.storybook', expression_repeat_image: 'src/plugins/expression_repeat_image/.storybook', expression_reveal_image: 'src/plugins/expression_reveal_image/.storybook', expression_shape: 'src/plugins/expression_shape/.storybook', diff --git a/src/plugins/chart_expressions/expression_pie/.storybook/main.js b/src/plugins/chart_expressions/expression_partition_vis/.storybook/main.js similarity index 100% rename from src/plugins/chart_expressions/expression_pie/.storybook/main.js rename to src/plugins/chart_expressions/expression_partition_vis/.storybook/main.js diff --git a/src/plugins/chart_expressions/expression_partition_vis/README.md b/src/plugins/chart_expressions/expression_partition_vis/README.md new file mode 100755 index 0000000000000..9a499280236e4 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/README.md @@ -0,0 +1,9 @@ +# expressionPartitionVis + +Expression Partition Visualization plugin adds a `partitionVis` renderer and `pieVis`, `mosaicVis`, `treemapVis`, `waffleVis` functions to the expression plugin. The renderer will display the `pie`, `waffle`, `treemap` and `mosaic` charts. + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/constants.ts b/src/plugins/chart_expressions/expression_partition_vis/common/constants.ts new file mode 100644 index 0000000000000..ffa549f7b7679 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/constants.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const PLUGIN_ID = 'expressionPartitionVis'; +export const PLUGIN_NAME = 'expressionPartitionVis'; + +export const PIE_VIS_EXPRESSION_NAME = 'pieVis'; +export const TREEMAP_VIS_EXPRESSION_NAME = 'treemapVis'; +export const MOSAIC_VIS_EXPRESSION_NAME = 'mosaicVis'; +export const WAFFLE_VIS_EXPRESSION_NAME = 'waffleVis'; +export const PARTITION_VIS_RENDERER_NAME = 'partitionVis'; +export const PARTITION_LABELS_VALUE = 'partitionLabelsValue'; +export const PARTITION_LABELS_FUNCTION = 'partitionLabels'; + +export const DEFAULT_PERCENT_DECIMALS = 2; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap new file mode 100644 index 0000000000000..de064a44058cc --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap @@ -0,0 +1,188 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`interpreter/functions#mosaicVis logs correct datatable to inspector 1`] = ` +Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "dimensionName": "Slice size", + "type": "number", + }, + "name": "Field 1", + }, + Object { + "id": "col-0-2", + "meta": Object { + "dimensionName": "Slice", + "type": "number", + }, + "name": "Field 2", + }, + Object { + "id": "col-0-3", + "meta": Object { + "dimensionName": "Slice", + "type": "number", + }, + "name": "Field 3", + }, + Object { + "id": "col-0-4", + "meta": Object { + "dimensionName": undefined, + "type": "number", + }, + "name": "Field 4", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + "col-0-2": 0, + "col-0-3": 0, + "col-0-4": 0, + }, + ], + "type": "datatable", +} +`; + +exports[`interpreter/functions#mosaicVis returns an object with the correct structure 1`] = ` +Object { + "as": "partitionVis", + "type": "render", + "value": Object { + "params": Object { + "listenOnChange": true, + }, + "syncColors": false, + "visConfig": Object { + "addTooltip": true, + "buckets": Array [ + Object { + "accessor": 1, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 2, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + ], + "dimensions": Object { + "buckets": Array [ + Object { + "accessor": 1, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 2, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + ], + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "labels": Object { + "last_level": false, + "percentDecimals": 2, + "position": "default", + "show": false, + "truncate": 100, + "type": "partitionLabelsValue", + "values": true, + "valuesFormat": "percent", + }, + "legendDisplay": "show", + "legendPosition": "right", + "maxLegendLines": 2, + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "nestedLegend": true, + "palette": Object { + "name": "kibana_palette", + "type": "system_palette", + }, + "splitColumn": undefined, + "splitRow": undefined, + "truncateLegend": true, + }, + "visData": Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "type": "number", + }, + "name": "Field 1", + }, + Object { + "id": "col-0-2", + "meta": Object { + "type": "number", + }, + "name": "Field 2", + }, + Object { + "id": "col-0-3", + "meta": Object { + "type": "number", + }, + "name": "Field 3", + }, + Object { + "id": "col-0-4", + "meta": Object { + "type": "number", + }, + "name": "Field 4", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + "col-0-2": 0, + "col-0-3": 0, + "col-0-4": 0, + }, + ], + "type": "datatable", + }, + "visType": "mosaic", + }, +} +`; + +exports[`interpreter/functions#mosaicVis throws error if provided more than 2 buckets 1`] = `"More than 2 buckets are not supported"`; + +exports[`interpreter/functions#mosaicVis throws error if provided split row and split column at once 1`] = `"A split row and column are specified. Expression is supporting only one of them at once."`; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap new file mode 100644 index 0000000000000..95b8df13882d9 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap @@ -0,0 +1,288 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`interpreter/functions#pieVis logs correct datatable to inspector 1`] = ` +Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "dimensionName": "Slice size", + "type": "number", + }, + "name": "Count", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + }, + ], + "type": "datatable", +} +`; + +exports[`interpreter/functions#pieVis returns an object with the correct structure for donut 1`] = ` +Object { + "as": "partitionVis", + "type": "render", + "value": Object { + "params": Object { + "listenOnChange": true, + }, + "syncColors": false, + "visConfig": Object { + "addTooltip": true, + "buckets": Array [ + Object { + "accessor": 1, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 2, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 3, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + ], + "dimensions": Object { + "buckets": Array [ + Object { + "accessor": 1, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 2, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 3, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + ], + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "distinctColors": false, + "emptySizeRatio": 0.3, + "isDonut": true, + "labels": Object { + "last_level": false, + "percentDecimals": 2, + "position": "default", + "show": false, + "truncate": 100, + "type": "partitionLabelsValue", + "values": true, + "valuesFormat": "percent", + }, + "legendDisplay": "show", + "legendPosition": "right", + "maxLegendLines": 2, + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "nestedLegend": true, + "palette": Object { + "name": "kibana_palette", + "type": "system_palette", + }, + "respectSourceOrder": true, + "splitColumn": undefined, + "splitRow": undefined, + "startFromSecondLargestSlice": true, + "truncateLegend": true, + }, + "visData": Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "type": "number", + }, + "name": "Count", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + }, + ], + "type": "datatable", + }, + "visType": "donut", + }, +} +`; + +exports[`interpreter/functions#pieVis returns an object with the correct structure for pie 1`] = ` +Object { + "as": "partitionVis", + "type": "render", + "value": Object { + "params": Object { + "listenOnChange": true, + }, + "syncColors": false, + "visConfig": Object { + "addTooltip": true, + "buckets": Array [ + Object { + "accessor": 1, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 2, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 3, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + ], + "dimensions": Object { + "buckets": Array [ + Object { + "accessor": 1, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 2, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 3, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + ], + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "distinctColors": false, + "emptySizeRatio": 0.3, + "isDonut": false, + "labels": Object { + "last_level": false, + "percentDecimals": 2, + "position": "default", + "show": false, + "truncate": 100, + "type": "partitionLabelsValue", + "values": true, + "valuesFormat": "percent", + }, + "legendDisplay": "show", + "legendPosition": "right", + "maxLegendLines": 2, + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "nestedLegend": true, + "palette": Object { + "name": "kibana_palette", + "type": "system_palette", + }, + "respectSourceOrder": true, + "splitColumn": undefined, + "splitRow": undefined, + "startFromSecondLargestSlice": true, + "truncateLegend": true, + }, + "visData": Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "type": "number", + }, + "name": "Count", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + }, + ], + "type": "datatable", + }, + "visType": "pie", + }, +} +`; + +exports[`interpreter/functions#pieVis throws error if provided split row and split column at once 1`] = `"A split row and column are specified. Expression is supporting only one of them at once."`; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap new file mode 100644 index 0000000000000..d18dca573606a --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap @@ -0,0 +1,188 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`interpreter/functions#treemapVis logs correct datatable to inspector 1`] = ` +Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "dimensionName": "Slice size", + "type": "number", + }, + "name": "Field 1", + }, + Object { + "id": "col-0-2", + "meta": Object { + "dimensionName": "Slice", + "type": "number", + }, + "name": "Field 2", + }, + Object { + "id": "col-0-3", + "meta": Object { + "dimensionName": "Slice", + "type": "number", + }, + "name": "Field 3", + }, + Object { + "id": "col-0-4", + "meta": Object { + "dimensionName": undefined, + "type": "number", + }, + "name": "Field 4", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + "col-0-2": 0, + "col-0-3": 0, + "col-0-4": 0, + }, + ], + "type": "datatable", +} +`; + +exports[`interpreter/functions#treemapVis returns an object with the correct structure 1`] = ` +Object { + "as": "partitionVis", + "type": "render", + "value": Object { + "params": Object { + "listenOnChange": true, + }, + "syncColors": false, + "visConfig": Object { + "addTooltip": true, + "buckets": Array [ + Object { + "accessor": 1, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 2, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + ], + "dimensions": Object { + "buckets": Array [ + Object { + "accessor": 1, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + Object { + "accessor": 2, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + ], + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "labels": Object { + "last_level": false, + "percentDecimals": 2, + "position": "default", + "show": false, + "truncate": 100, + "type": "partitionLabelsValue", + "values": true, + "valuesFormat": "percent", + }, + "legendDisplay": "show", + "legendPosition": "right", + "maxLegendLines": 2, + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "nestedLegend": true, + "palette": Object { + "name": "kibana_palette", + "type": "system_palette", + }, + "splitColumn": undefined, + "splitRow": undefined, + "truncateLegend": true, + }, + "visData": Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "type": "number", + }, + "name": "Field 1", + }, + Object { + "id": "col-0-2", + "meta": Object { + "type": "number", + }, + "name": "Field 2", + }, + Object { + "id": "col-0-3", + "meta": Object { + "type": "number", + }, + "name": "Field 3", + }, + Object { + "id": "col-0-4", + "meta": Object { + "type": "number", + }, + "name": "Field 4", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + "col-0-2": 0, + "col-0-3": 0, + "col-0-4": 0, + }, + ], + "type": "datatable", + }, + "visType": "treemap", + }, +} +`; + +exports[`interpreter/functions#treemapVis throws error if provided more than 2 buckets 1`] = `"More than 2 buckets are not supported"`; + +exports[`interpreter/functions#treemapVis throws error if provided split row and split column at once 1`] = `"A split row and column are specified. Expression is supporting only one of them at once."`; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap new file mode 100644 index 0000000000000..54ead941c7548 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap @@ -0,0 +1,168 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`interpreter/functions#waffleVis logs correct datatable to inspector 1`] = ` +Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "dimensionName": "Slice size", + "type": "number", + }, + "name": "Field 1", + }, + Object { + "id": "col-0-2", + "meta": Object { + "dimensionName": "Slice", + "type": "number", + }, + "name": "Field 2", + }, + Object { + "id": "col-0-3", + "meta": Object { + "dimensionName": undefined, + "type": "number", + }, + "name": "Field 3", + }, + Object { + "id": "col-0-4", + "meta": Object { + "dimensionName": undefined, + "type": "number", + }, + "name": "Field 4", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + "col-0-2": 0, + "col-0-3": 0, + "col-0-4": 0, + }, + ], + "type": "datatable", +} +`; + +exports[`interpreter/functions#waffleVis returns an object with the correct structure 1`] = ` +Object { + "as": "partitionVis", + "type": "render", + "value": Object { + "params": Object { + "listenOnChange": true, + }, + "syncColors": false, + "visConfig": Object { + "addTooltip": true, + "bucket": Object { + "accessor": 1, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "dimensions": Object { + "buckets": Array [ + Object { + "accessor": 1, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + ], + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "labels": Object { + "last_level": false, + "percentDecimals": 2, + "position": "default", + "show": false, + "truncate": 100, + "type": "partitionLabelsValue", + "values": true, + "valuesFormat": "percent", + }, + "legendDisplay": "show", + "legendPosition": "right", + "maxLegendLines": 2, + "metric": Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", + }, + "palette": Object { + "name": "kibana_palette", + "type": "system_palette", + }, + "showValuesInLegend": true, + "splitColumn": undefined, + "splitRow": undefined, + "truncateLegend": true, + }, + "visData": Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "type": "number", + }, + "name": "Field 1", + }, + Object { + "id": "col-0-2", + "meta": Object { + "type": "number", + }, + "name": "Field 2", + }, + Object { + "id": "col-0-3", + "meta": Object { + "type": "number", + }, + "name": "Field 3", + }, + Object { + "id": "col-0-4", + "meta": Object { + "type": "number", + }, + "name": "Field 4", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + "col-0-2": 0, + "col-0-3": 0, + "col-0-4": 0, + }, + ], + "type": "datatable", + }, + "visType": "waffle", + }, +} +`; + +exports[`interpreter/functions#waffleVis throws error if provided split row and split column at once 1`] = `"A split row and column are specified. Expression is supporting only one of them at once."`; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts new file mode 100644 index 0000000000000..0be470121ecb4 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const strings = { + getPieVisFunctionName: () => + i18n.translate('expressionPartitionVis.pieVis.function.help', { + defaultMessage: 'Pie visualization', + }), + getMetricArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.metricHelpText', { + defaultMessage: 'Metric dimensions config', + }), + getBucketsArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.bucketsHelpText', { + defaultMessage: 'Buckets dimensions config', + }), + getBucketArgHelp: () => + i18n.translate('expressionPartitionVis.waffle.function.args.bucketHelpText', { + defaultMessage: 'Bucket dimensions config', + }), + getSplitColumnArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.splitColumnHelpText', { + defaultMessage: 'Split by column dimension config', + }), + getSplitRowArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.splitRowHelpText', { + defaultMessage: 'Split by row dimension config', + }), + getAddTooltipArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.addTooltipHelpText', { + defaultMessage: 'Show tooltip on slice hover', + }), + getLegendDisplayArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.legendDisplayHelpText', { + defaultMessage: 'Show legend chart legend', + }), + getLegendPositionArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.legendPositionHelpText', { + defaultMessage: 'Position the legend on top, bottom, left, right of the chart', + }), + getNestedLegendArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.nestedLegendHelpText', { + defaultMessage: 'Show a more detailed legend', + }), + getTruncateLegendArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.truncateLegendHelpText', { + defaultMessage: 'Defines if the legend items will be truncated or not', + }), + getMaxLegendLinesArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.maxLegendLinesHelpText', { + defaultMessage: 'Defines the number of lines per legend item', + }), + getDistinctColorsArgHelp: () => + i18n.translate('expressionPartitionVis.pieVis.function.args.distinctColorsHelpText', { + defaultMessage: + 'Maps different color per slice. Slices with the same value have the same color', + }), + getIsDonutArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.isDonutHelpText', { + defaultMessage: 'Displays the pie chart as donut', + }), + getRespectSourceOrderArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.respectSourceOrderHelpText', { + defaultMessage: 'Keeps an order of the elements, returned from the datasource', + }), + getStartFromSecondLargestSliceArgHelp: () => + i18n.translate( + 'expressionPartitionVis.reusable.function.args.startPlacementWithSecondLargestSliceHelpText', + { + defaultMessage: 'Starts placement with the second largest slice', + } + ), + getEmptySizeRatioArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.emptySizeRatioHelpText', { + defaultMessage: 'Defines donut inner empty area size', + }), + getPaletteArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.paletteHelpText', { + defaultMessage: 'Defines the chart palette name', + }), + getLabelsArgHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.args.labelsHelpText', { + defaultMessage: 'Pie labels config', + }), + getShowValuesInLegendArgHelp: () => + i18n.translate('expressionPartitionVis.waffle.function.args.showValuesInLegendHelpText', { + defaultMessage: 'Show values in legend', + }), + + getSliceSizeHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.dimension.metric', { + defaultMessage: 'Slice size', + }), + getSliceHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.dimension.buckets', { + defaultMessage: 'Slice', + }), + getColumnSplitHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.dimension.splitcolumn', { + defaultMessage: 'Column split', + }), + getRowSplitHelp: () => + i18n.translate('expressionPartitionVis.reusable.function.dimension.splitrow', { + defaultMessage: 'Row split', + }), +}; + +export const errors = { + moreThanNBucketsAreNotSupportedError: (maxLength: number) => + i18n.translate('expressionPartitionVis.reusable.function.errors.moreThenNumberBuckets', { + defaultMessage: 'More than {maxLength} buckets are not supported', + values: { maxLength }, + }), + splitRowAndSplitColumnAreSpecifiedError: () => + i18n.translate('expressionPartitionVis.reusable.function.errors.splitRowAndColumnSpecified', { + defaultMessage: + 'A split row and column are specified. Expression is supporting only one of them at once.', + }), +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/index.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/index.ts new file mode 100644 index 0000000000000..6117b53a8b2a8 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { pieVisFunction } from './pie_vis_function'; +export { treemapVisFunction } from './treemap_vis_function'; +export { mosaicVisFunction } from './mosaic_vis_function'; +export { waffleVisFunction } from './waffle_vis_function'; +export { partitionLabelsFunction } from './partition_labels_function'; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts new file mode 100644 index 0000000000000..58f765899f5e3 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { functionWrapper } from '../../../../expressions/common/expression_functions/specs/tests/utils'; +import { + MosaicVisConfig, + LabelPositions, + ValueFormats, + LegendDisplay, +} from '../types/expression_renderers'; +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; +import { Datatable } from '../../../../expressions/common/expression_types/specs'; +import { mosaicVisFunction } from './mosaic_vis_function'; +import { PARTITION_LABELS_VALUE } from '../constants'; + +describe('interpreter/functions#mosaicVis', () => { + const fn = functionWrapper(mosaicVisFunction()); + const context: Datatable = { + type: 'datatable', + rows: [{ 'col-0-1': 0, 'col-0-2': 0, 'col-0-3': 0, 'col-0-4': 0 }], + columns: [ + { id: 'col-0-1', name: 'Field 1', meta: { type: 'number' } }, + { id: 'col-0-2', name: 'Field 2', meta: { type: 'number' } }, + { id: 'col-0-3', name: 'Field 3', meta: { type: 'number' } }, + { id: 'col-0-4', name: 'Field 4', meta: { type: 'number' } }, + ], + }; + + const visConfig: MosaicVisConfig = { + addTooltip: true, + legendDisplay: LegendDisplay.SHOW, + legendPosition: 'right', + nestedLegend: true, + truncateLegend: true, + maxLegendLines: 2, + palette: { + type: 'system_palette', + name: 'kibana_palette', + }, + labels: { + type: PARTITION_LABELS_VALUE, + show: false, + values: true, + position: LabelPositions.DEFAULT, + valuesFormat: ValueFormats.PERCENT, + percentDecimals: 2, + truncate: 100, + last_level: false, + }, + metric: { + type: 'vis_dimension', + accessor: 0, + format: { + id: 'number', + params: {}, + }, + }, + buckets: [ + { + type: 'vis_dimension', + accessor: 1, + format: { + id: 'number', + params: {}, + }, + }, + { + type: 'vis_dimension', + accessor: 2, + format: { + id: 'number', + params: {}, + }, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an object with the correct structure', async () => { + const actual = await fn(context, visConfig); + expect(actual).toMatchSnapshot(); + }); + + it('throws error if provided more than 2 buckets', async () => { + expect(() => + fn(context, { + ...visConfig, + buckets: [ + ...(visConfig.buckets ?? []), + { + type: 'vis_dimension', + accessor: 3, + format: { + id: 'number', + params: {}, + }, + }, + ], + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('throws error if provided split row and split column at once', async () => { + const splitDimension: ExpressionValueVisDimension = { + type: 'vis_dimension', + accessor: 3, + format: { + id: 'number', + params: {}, + }, + }; + + expect(() => + fn(context, { + ...visConfig, + splitColumn: [splitDimension], + splitRow: [splitDimension], + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('logs correct datatable to inspector', async () => { + let loggedTable: Datatable; + const handlers = { + inspectorAdapters: { + tables: { + logDatatable: (name: string, datatable: Datatable) => { + loggedTable = datatable; + }, + }, + }, + }; + await fn(context, visConfig, handlers as any); + + expect(loggedTable!).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts new file mode 100644 index 0000000000000..388b0741d23d3 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; +import { prepareLogTable } from '../../../../visualizations/common/prepare_log_table'; +import { ChartTypes, MosaicVisExpressionFunctionDefinition } from '../types'; +import { + PARTITION_LABELS_FUNCTION, + PARTITION_LABELS_VALUE, + PARTITION_VIS_RENDERER_NAME, + MOSAIC_VIS_EXPRESSION_NAME, +} from '../constants'; +import { errors, strings } from './i18n'; + +export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ + name: MOSAIC_VIS_EXPRESSION_NAME, + type: 'render', + inputTypes: ['datatable'], + help: strings.getPieVisFunctionName(), + args: { + metric: { + types: ['vis_dimension'], + help: strings.getMetricArgHelp(), + required: true, + }, + buckets: { + types: ['vis_dimension'], + help: strings.getBucketsArgHelp(), + multi: true, + }, + splitColumn: { + types: ['vis_dimension'], + help: strings.getSplitColumnArgHelp(), + multi: true, + }, + splitRow: { + types: ['vis_dimension'], + help: strings.getSplitRowArgHelp(), + multi: true, + }, + addTooltip: { + types: ['boolean'], + help: strings.getAddTooltipArgHelp(), + default: true, + }, + legendDisplay: { + types: ['string'], + help: strings.getLegendDisplayArgHelp(), + options: [LegendDisplay.SHOW, LegendDisplay.HIDE, LegendDisplay.DEFAULT], + default: LegendDisplay.HIDE, + }, + legendPosition: { + types: ['string'], + help: strings.getLegendPositionArgHelp(), + }, + nestedLegend: { + types: ['boolean'], + help: strings.getNestedLegendArgHelp(), + default: false, + }, + truncateLegend: { + types: ['boolean'], + help: strings.getTruncateLegendArgHelp(), + default: true, + }, + maxLegendLines: { + types: ['number'], + help: strings.getMaxLegendLinesArgHelp(), + }, + palette: { + types: ['palette', 'system_palette'], + help: strings.getPaletteArgHelp(), + default: '{palette}', + }, + labels: { + types: [PARTITION_LABELS_VALUE], + help: strings.getLabelsArgHelp(), + default: `{${PARTITION_LABELS_FUNCTION}}`, + }, + }, + fn(context, args, handlers) { + const maxSupportedBuckets = 2; + if ((args.buckets ?? []).length > maxSupportedBuckets) { + throw new Error(errors.moreThanNBucketsAreNotSupportedError(maxSupportedBuckets)); + } + + if (args.splitColumn && args.splitRow) { + throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); + } + + const visConfig: PartitionVisParams = { + ...args, + palette: args.palette, + dimensions: { + metric: args.metric, + buckets: args.buckets, + splitColumn: args.splitColumn, + splitRow: args.splitRow, + }, + }; + + if (handlers?.inspectorAdapters?.tables) { + const logTable = prepareLogTable(context, [ + [[args.metric], strings.getSliceSizeHelp()], + [args.buckets, strings.getSliceHelp()], + [args.splitColumn, strings.getColumnSplitHelp()], + [args.splitRow, strings.getRowSplitHelp()], + ]); + handlers.inspectorAdapters.tables.logDatatable('default', logTable); + } + + return { + type: 'render', + as: PARTITION_VIS_RENDERER_NAME, + value: { + visData: context, + visConfig, + syncColors: handlers?.isSyncColorsEnabled?.() ?? false, + visType: ChartTypes.MOSAIC, + params: { + listenOnChange: true, + }, + }, + }; + }, +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/partition_labels_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/partition_labels_function.ts new file mode 100644 index 0000000000000..900927a35983c --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/partition_labels_function.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition, Datatable } from '../../../../expressions/common'; +import { PARTITION_LABELS_FUNCTION, PARTITION_LABELS_VALUE } from '../constants'; +import { + ExpressionValuePartitionLabels, + LabelPositions, + PartitionLabelsArguments, + ValueFormats, +} from '../types'; + +export const partitionLabelsFunction = (): ExpressionFunctionDefinition< + typeof PARTITION_LABELS_FUNCTION, + Datatable | null, + PartitionLabelsArguments, + ExpressionValuePartitionLabels +> => ({ + name: PARTITION_LABELS_FUNCTION, + help: i18n.translate('expressionPartitionVis.partitionLabels.function.help', { + defaultMessage: 'Generates the partition labels object', + }), + type: PARTITION_LABELS_VALUE, + args: { + show: { + types: ['boolean'], + help: i18n.translate('expressionPartitionVis.partitionLabels.function.args.show.help', { + defaultMessage: 'Displays the partition chart labels', + }), + default: true, + }, + position: { + types: ['string'], + default: 'default', + help: i18n.translate('expressionPartitionVis.partitionLabels.function.args.position.help', { + defaultMessage: 'Defines the label position', + }), + options: [LabelPositions.DEFAULT, LabelPositions.INSIDE], + }, + values: { + types: ['boolean'], + help: i18n.translate('expressionPartitionVis.partitionLabels.function.args.values.help', { + defaultMessage: 'Displays the values inside the slices', + }), + default: true, + }, + percentDecimals: { + types: ['number'], + help: i18n.translate( + 'expressionPartitionVis.partitionLabels.function.args.percentDecimals.help', + { + defaultMessage: + 'Defines the number of decimals that will appear on the values as percent', + } + ), + default: 2, + }, + // Deprecated + last_level: { + types: ['boolean'], + help: i18n.translate('expressionPartitionVis.partitionLabels.function.args.last_level.help', { + defaultMessage: 'Show top level labels only for multilayer pie/donut charts', + }), + default: false, + }, + // Deprecated + truncate: { + types: ['number', 'null'], + help: i18n.translate('expressionPartitionVis.partitionLabels.function.args.truncate.help', { + defaultMessage: + 'Defines the number of characters that the slice value will display only for multilayer pie/donut charts', + }), + default: null, + }, + valuesFormat: { + types: ['string'], + default: 'percent', + help: i18n.translate( + 'expressionPartitionVis.partitionLabels.function.args.valuesFormat.help', + { + defaultMessage: 'Defines the format of the values', + } + ), + options: [ValueFormats.PERCENT, ValueFormats.VALUE], + }, + }, + fn: (context, args) => { + return { + type: PARTITION_LABELS_VALUE, + show: args.show, + position: args.position, + percentDecimals: args.percentDecimals, + values: args.values, + truncate: args.truncate, + valuesFormat: args.valuesFormat, + last_level: args.last_level, + }; + }, +}); diff --git a/src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts similarity index 56% rename from src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_vis_function.test.ts rename to src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts index 56807b4aa6a59..366683ce80ddb 100644 --- a/src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts @@ -12,21 +12,24 @@ import { EmptySizeRatios, LabelPositions, ValueFormats, + LegendDisplay, } from '../types/expression_renderers'; -import { pieVisFunction } from './pie_vis_function'; +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; import { Datatable } from '../../../../expressions/common/expression_types/specs'; +import { pieVisFunction } from './pie_vis_function'; +import { PARTITION_LABELS_VALUE } from '../constants'; -describe('interpreter/functions#pie', () => { +describe('interpreter/functions#pieVis', () => { const fn = functionWrapper(pieVisFunction()); - const context = { + const context: Datatable = { type: 'datatable', rows: [{ 'col-0-1': 0 }], - columns: [{ id: 'col-0-1', name: 'Count' }], - } as unknown as Datatable; + columns: [{ id: 'col-0-1', name: 'Count', meta: { type: 'number' } }], + }; const visConfig: PieVisConfig = { addTooltip: true, - addLegend: true, + legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', isDonut: true, emptySizeRatio: EmptySizeRatios.SMALL, @@ -39,7 +42,7 @@ describe('interpreter/functions#pie', () => { name: 'kibana_palette', }, labels: { - type: 'pie_labels_value', + type: PARTITION_LABELS_VALUE, show: false, values: true, position: LabelPositions.DEFAULT, @@ -56,17 +59,67 @@ describe('interpreter/functions#pie', () => { params: {}, }, }, + buckets: [ + { + type: 'vis_dimension', + accessor: 1, + format: { + id: 'number', + params: {}, + }, + }, + { + type: 'vis_dimension', + accessor: 2, + format: { + id: 'number', + params: {}, + }, + }, + { + type: 'vis_dimension', + accessor: 3, + format: { + id: 'number', + params: {}, + }, + }, + ], }; beforeEach(() => { jest.clearAllMocks(); }); - it('returns an object with the correct structure', async () => { + it('returns an object with the correct structure for pie', async () => { + const actual = await fn(context, { ...visConfig, isDonut: false }); + expect(actual).toMatchSnapshot(); + }); + + it('returns an object with the correct structure for donut', async () => { const actual = await fn(context, visConfig); expect(actual).toMatchSnapshot(); }); + it('throws error if provided split row and split column at once', async () => { + const splitDimension: ExpressionValueVisDimension = { + type: 'vis_dimension', + accessor: 3, + format: { + id: 'number', + params: {}, + }, + }; + + expect(() => + fn(context, { + ...visConfig, + splitColumn: [splitDimension], + splitRow: [splitDimension], + }) + ).toThrowErrorMatchingSnapshot(); + }); + it('logs correct datatable to inspector', async () => { let loggedTable: Datatable; const handlers = { diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts new file mode 100644 index 0000000000000..c054d572538ce --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Position } from '@elastic/charts'; +import { EmptySizeRatios, LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; +import { prepareLogTable } from '../../../../visualizations/common/prepare_log_table'; +import { ChartTypes, PieVisExpressionFunctionDefinition } from '../types'; +import { + PARTITION_LABELS_FUNCTION, + PARTITION_LABELS_VALUE, + PIE_VIS_EXPRESSION_NAME, + PARTITION_VIS_RENDERER_NAME, +} from '../constants'; +import { errors, strings } from './i18n'; + +export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ + name: PIE_VIS_EXPRESSION_NAME, + type: 'render', + inputTypes: ['datatable'], + help: strings.getPieVisFunctionName(), + args: { + metric: { + types: ['vis_dimension'], + help: strings.getMetricArgHelp(), + required: true, + }, + buckets: { + types: ['vis_dimension'], + help: strings.getBucketsArgHelp(), + multi: true, + }, + splitColumn: { + types: ['vis_dimension'], + help: strings.getSplitColumnArgHelp(), + multi: true, + }, + splitRow: { + types: ['vis_dimension'], + help: strings.getSplitRowArgHelp(), + multi: true, + }, + addTooltip: { + types: ['boolean'], + help: strings.getAddTooltipArgHelp(), + default: true, + }, + legendDisplay: { + types: ['string'], + help: strings.getLegendDisplayArgHelp(), + options: [LegendDisplay.SHOW, LegendDisplay.HIDE, LegendDisplay.DEFAULT], + default: LegendDisplay.HIDE, + }, + legendPosition: { + types: ['string'], + help: strings.getLegendPositionArgHelp(), + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + }, + nestedLegend: { + types: ['boolean'], + help: strings.getNestedLegendArgHelp(), + default: false, + }, + truncateLegend: { + types: ['boolean'], + help: strings.getTruncateLegendArgHelp(), + default: true, + }, + maxLegendLines: { + types: ['number'], + help: strings.getMaxLegendLinesArgHelp(), + }, + distinctColors: { + types: ['boolean'], + help: strings.getDistinctColorsArgHelp(), + default: false, + }, + respectSourceOrder: { + types: ['boolean'], + help: strings.getRespectSourceOrderArgHelp(), + default: true, + }, + isDonut: { + types: ['boolean'], + help: strings.getIsDonutArgHelp(), + default: false, + }, + emptySizeRatio: { + types: ['number'], + help: strings.getEmptySizeRatioArgHelp(), + default: EmptySizeRatios.SMALL, + }, + palette: { + types: ['palette', 'system_palette'], + help: strings.getPaletteArgHelp(), + default: '{palette}', + }, + labels: { + types: [PARTITION_LABELS_VALUE], + help: strings.getLabelsArgHelp(), + default: `{${PARTITION_LABELS_FUNCTION}}`, + }, + startFromSecondLargestSlice: { + types: ['boolean'], + help: strings.getStartFromSecondLargestSliceArgHelp(), + default: true, + }, + }, + fn(context, args, handlers) { + if (args.splitColumn && args.splitRow) { + throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); + } + + const visConfig: PartitionVisParams = { + ...args, + palette: args.palette, + dimensions: { + metric: args.metric, + buckets: args.buckets, + splitColumn: args.splitColumn, + splitRow: args.splitRow, + }, + }; + + if (handlers?.inspectorAdapters?.tables) { + const logTable = prepareLogTable(context, [ + [[args.metric], strings.getSliceSizeHelp()], + [args.buckets, strings.getSliceHelp()], + [args.splitColumn, strings.getColumnSplitHelp()], + [args.splitRow, strings.getRowSplitHelp()], + ]); + handlers.inspectorAdapters.tables.logDatatable('default', logTable); + } + + return { + type: 'render', + as: PARTITION_VIS_RENDERER_NAME, + value: { + visData: context, + visConfig, + syncColors: handlers?.isSyncColorsEnabled?.() ?? false, + visType: args.isDonut ? ChartTypes.DONUT : ChartTypes.PIE, + params: { + listenOnChange: true, + }, + }, + }; + }, +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts new file mode 100644 index 0000000000000..1d65ba35a5e0c --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { functionWrapper } from '../../../../expressions/common/expression_functions/specs/tests/utils'; +import { + TreemapVisConfig, + LabelPositions, + ValueFormats, + LegendDisplay, +} from '../types/expression_renderers'; +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; +import { Datatable } from '../../../../expressions/common/expression_types/specs'; +import { treemapVisFunction } from './treemap_vis_function'; +import { PARTITION_LABELS_VALUE } from '../constants'; + +describe('interpreter/functions#treemapVis', () => { + const fn = functionWrapper(treemapVisFunction()); + const context: Datatable = { + type: 'datatable', + rows: [{ 'col-0-1': 0, 'col-0-2': 0, 'col-0-3': 0, 'col-0-4': 0 }], + columns: [ + { id: 'col-0-1', name: 'Field 1', meta: { type: 'number' } }, + { id: 'col-0-2', name: 'Field 2', meta: { type: 'number' } }, + { id: 'col-0-3', name: 'Field 3', meta: { type: 'number' } }, + { id: 'col-0-4', name: 'Field 4', meta: { type: 'number' } }, + ], + }; + + const visConfig: TreemapVisConfig = { + addTooltip: true, + legendDisplay: LegendDisplay.SHOW, + legendPosition: 'right', + nestedLegend: true, + truncateLegend: true, + maxLegendLines: 2, + palette: { + type: 'system_palette', + name: 'kibana_palette', + }, + labels: { + type: PARTITION_LABELS_VALUE, + show: false, + values: true, + position: LabelPositions.DEFAULT, + valuesFormat: ValueFormats.PERCENT, + percentDecimals: 2, + truncate: 100, + last_level: false, + }, + metric: { + type: 'vis_dimension', + accessor: 0, + format: { + id: 'number', + params: {}, + }, + }, + buckets: [ + { + type: 'vis_dimension', + accessor: 1, + format: { + id: 'number', + params: {}, + }, + }, + { + type: 'vis_dimension', + accessor: 2, + format: { + id: 'number', + params: {}, + }, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an object with the correct structure', async () => { + const actual = await fn(context, visConfig); + expect(actual).toMatchSnapshot(); + }); + + it('throws error if provided more than 2 buckets', async () => { + expect(() => + fn(context, { + ...visConfig, + buckets: [ + ...(visConfig.buckets ?? []), + { + type: 'vis_dimension', + accessor: 3, + format: { + id: 'number', + params: {}, + }, + }, + ], + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('throws error if provided split row and split column at once', async () => { + const splitDimension: ExpressionValueVisDimension = { + type: 'vis_dimension', + accessor: 3, + format: { + id: 'number', + params: {}, + }, + }; + + expect(() => + fn(context, { + ...visConfig, + splitColumn: [splitDimension], + splitRow: [splitDimension], + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('logs correct datatable to inspector', async () => { + let loggedTable: Datatable; + const handlers = { + inspectorAdapters: { + tables: { + logDatatable: (name: string, datatable: Datatable) => { + loggedTable = datatable; + }, + }, + }, + }; + await fn(context, visConfig, handlers as any); + + expect(loggedTable!).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts new file mode 100644 index 0000000000000..d0ae42b4b7942 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; +import { prepareLogTable } from '../../../../visualizations/common/prepare_log_table'; +import { ChartTypes, TreemapVisExpressionFunctionDefinition } from '../types'; +import { + PARTITION_LABELS_FUNCTION, + PARTITION_LABELS_VALUE, + PARTITION_VIS_RENDERER_NAME, + TREEMAP_VIS_EXPRESSION_NAME, +} from '../constants'; +import { errors, strings } from './i18n'; + +export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => ({ + name: TREEMAP_VIS_EXPRESSION_NAME, + type: 'render', + inputTypes: ['datatable'], + help: strings.getPieVisFunctionName(), + args: { + metric: { + types: ['vis_dimension'], + help: strings.getMetricArgHelp(), + required: true, + }, + buckets: { + types: ['vis_dimension'], + help: strings.getBucketsArgHelp(), + multi: true, + }, + splitColumn: { + types: ['vis_dimension'], + help: strings.getSplitColumnArgHelp(), + multi: true, + }, + splitRow: { + types: ['vis_dimension'], + help: strings.getSplitRowArgHelp(), + multi: true, + }, + addTooltip: { + types: ['boolean'], + help: strings.getAddTooltipArgHelp(), + default: true, + }, + legendDisplay: { + types: ['string'], + help: strings.getLegendDisplayArgHelp(), + options: [LegendDisplay.SHOW, LegendDisplay.HIDE, LegendDisplay.DEFAULT], + default: LegendDisplay.HIDE, + }, + legendPosition: { + types: ['string'], + help: strings.getLegendPositionArgHelp(), + }, + nestedLegend: { + types: ['boolean'], + help: strings.getNestedLegendArgHelp(), + default: false, + }, + truncateLegend: { + types: ['boolean'], + help: strings.getTruncateLegendArgHelp(), + default: true, + }, + maxLegendLines: { + types: ['number'], + help: strings.getMaxLegendLinesArgHelp(), + }, + palette: { + types: ['palette', 'system_palette'], + help: strings.getPaletteArgHelp(), + default: '{palette}', + }, + labels: { + types: [PARTITION_LABELS_VALUE], + help: strings.getLabelsArgHelp(), + default: `{${PARTITION_LABELS_FUNCTION}}`, + }, + }, + fn(context, args, handlers) { + const maxSupportedBuckets = 2; + if ((args.buckets ?? []).length > maxSupportedBuckets) { + throw new Error(errors.moreThanNBucketsAreNotSupportedError(maxSupportedBuckets)); + } + + if (args.splitColumn && args.splitRow) { + throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); + } + + const visConfig: PartitionVisParams = { + ...args, + palette: args.palette, + dimensions: { + metric: args.metric, + buckets: args.buckets, + splitColumn: args.splitColumn, + splitRow: args.splitRow, + }, + }; + + if (handlers?.inspectorAdapters?.tables) { + const logTable = prepareLogTable(context, [ + [[args.metric], strings.getSliceSizeHelp()], + [args.buckets, strings.getSliceHelp()], + [args.splitColumn, strings.getColumnSplitHelp()], + [args.splitRow, strings.getRowSplitHelp()], + ]); + handlers.inspectorAdapters.tables.logDatatable('default', logTable); + } + + return { + type: 'render', + as: PARTITION_VIS_RENDERER_NAME, + value: { + visData: context, + visConfig, + syncColors: handlers?.isSyncColorsEnabled?.() ?? false, + visType: ChartTypes.TREEMAP, + params: { + listenOnChange: true, + }, + }, + }; + }, +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts new file mode 100644 index 0000000000000..ead4d97a8f8e0 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { functionWrapper } from '../../../../expressions/common/expression_functions/specs/tests/utils'; +import { + WaffleVisConfig, + LabelPositions, + ValueFormats, + LegendDisplay, +} from '../types/expression_renderers'; +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; +import { Datatable } from '../../../../expressions/common/expression_types/specs'; +import { waffleVisFunction } from './waffle_vis_function'; +import { PARTITION_LABELS_VALUE } from '../constants'; + +describe('interpreter/functions#waffleVis', () => { + const fn = functionWrapper(waffleVisFunction()); + const context: Datatable = { + type: 'datatable', + rows: [{ 'col-0-1': 0, 'col-0-2': 0, 'col-0-3': 0, 'col-0-4': 0 }], + columns: [ + { id: 'col-0-1', name: 'Field 1', meta: { type: 'number' } }, + { id: 'col-0-2', name: 'Field 2', meta: { type: 'number' } }, + { id: 'col-0-3', name: 'Field 3', meta: { type: 'number' } }, + { id: 'col-0-4', name: 'Field 4', meta: { type: 'number' } }, + ], + }; + + const visConfig: WaffleVisConfig = { + addTooltip: true, + showValuesInLegend: true, + legendDisplay: LegendDisplay.SHOW, + legendPosition: 'right', + truncateLegend: true, + maxLegendLines: 2, + palette: { + type: 'system_palette', + name: 'kibana_palette', + }, + labels: { + type: PARTITION_LABELS_VALUE, + show: false, + values: true, + position: LabelPositions.DEFAULT, + valuesFormat: ValueFormats.PERCENT, + percentDecimals: 2, + truncate: 100, + last_level: false, + }, + metric: { + type: 'vis_dimension', + accessor: 0, + format: { + id: 'number', + params: {}, + }, + }, + bucket: { + type: 'vis_dimension', + accessor: 1, + format: { + id: 'number', + params: {}, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an object with the correct structure', async () => { + const actual = await fn(context, visConfig); + expect(actual).toMatchSnapshot(); + }); + + it('throws error if provided split row and split column at once', async () => { + const splitDimension: ExpressionValueVisDimension = { + type: 'vis_dimension', + accessor: 3, + format: { + id: 'number', + params: {}, + }, + }; + + expect(() => + fn(context, { + ...visConfig, + splitColumn: [splitDimension], + splitRow: [splitDimension], + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('logs correct datatable to inspector', async () => { + let loggedTable: Datatable; + const handlers = { + inspectorAdapters: { + tables: { + logDatatable: (name: string, datatable: Datatable) => { + loggedTable = datatable; + }, + }, + }, + }; + await fn(context, visConfig, handlers as any); + + expect(loggedTable!).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts new file mode 100644 index 0000000000000..ade524aad59c8 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; +import { prepareLogTable } from '../../../../visualizations/common/prepare_log_table'; +import { ChartTypes, WaffleVisExpressionFunctionDefinition } from '../types'; +import { + PARTITION_LABELS_FUNCTION, + PARTITION_LABELS_VALUE, + PARTITION_VIS_RENDERER_NAME, + WAFFLE_VIS_EXPRESSION_NAME, +} from '../constants'; +import { errors, strings } from './i18n'; + +export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ + name: WAFFLE_VIS_EXPRESSION_NAME, + type: 'render', + inputTypes: ['datatable'], + help: strings.getPieVisFunctionName(), + args: { + metric: { + types: ['vis_dimension'], + help: strings.getMetricArgHelp(), + required: true, + }, + bucket: { + types: ['vis_dimension'], + help: strings.getBucketArgHelp(), + }, + splitColumn: { + types: ['vis_dimension'], + help: strings.getSplitColumnArgHelp(), + multi: true, + }, + splitRow: { + types: ['vis_dimension'], + help: strings.getSplitRowArgHelp(), + multi: true, + }, + addTooltip: { + types: ['boolean'], + help: strings.getAddTooltipArgHelp(), + default: true, + }, + legendDisplay: { + types: ['string'], + help: strings.getLegendDisplayArgHelp(), + options: [LegendDisplay.SHOW, LegendDisplay.HIDE, LegendDisplay.DEFAULT], + default: LegendDisplay.HIDE, + }, + legendPosition: { + types: ['string'], + help: strings.getLegendPositionArgHelp(), + }, + truncateLegend: { + types: ['boolean'], + help: strings.getTruncateLegendArgHelp(), + default: true, + }, + maxLegendLines: { + types: ['number'], + help: strings.getMaxLegendLinesArgHelp(), + }, + palette: { + types: ['palette', 'system_palette'], + help: strings.getPaletteArgHelp(), + default: '{palette}', + }, + labels: { + types: [PARTITION_LABELS_VALUE], + help: strings.getLabelsArgHelp(), + default: `{${PARTITION_LABELS_FUNCTION}}`, + }, + showValuesInLegend: { + types: ['boolean'], + help: strings.getShowValuesInLegendArgHelp(), + default: false, + }, + }, + fn(context, args, handlers) { + if (args.splitColumn && args.splitRow) { + throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); + } + + const buckets = args.bucket ? [args.bucket] : []; + const visConfig: PartitionVisParams = { + ...args, + palette: args.palette, + dimensions: { + metric: args.metric, + buckets, + splitColumn: args.splitColumn, + splitRow: args.splitRow, + }, + }; + + if (handlers?.inspectorAdapters?.tables) { + const logTable = prepareLogTable(context, [ + [[args.metric], strings.getSliceSizeHelp()], + [buckets, strings.getSliceHelp()], + [args.splitColumn, strings.getColumnSplitHelp()], + [args.splitRow, strings.getRowSplitHelp()], + ]); + handlers.inspectorAdapters.tables.logDatatable('default', logTable); + } + + return { + type: 'render', + as: PARTITION_VIS_RENDERER_NAME, + value: { + visData: context, + visConfig, + syncColors: handlers?.isSyncColorsEnabled?.() ?? false, + visType: ChartTypes.WAFFLE, + params: { + listenOnChange: true, + }, + }, + }; + }, +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/index.ts b/src/plugins/chart_expressions/expression_partition_vis/common/index.ts new file mode 100755 index 0000000000000..559d597caf90c --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { + PLUGIN_ID, + PLUGIN_NAME, + PIE_VIS_EXPRESSION_NAME, + TREEMAP_VIS_EXPRESSION_NAME, + MOSAIC_VIS_EXPRESSION_NAME, + WAFFLE_VIS_EXPRESSION_NAME, + PARTITION_LABELS_VALUE, + PARTITION_LABELS_FUNCTION, +} from './constants'; + +export { + pieVisFunction, + treemapVisFunction, + waffleVisFunction, + mosaicVisFunction, + partitionLabelsFunction, +} from './expression_functions'; + +export type { + ExpressionValuePartitionLabels, + PieVisExpressionFunctionDefinition, + TreemapVisExpressionFunctionDefinition, + MosaicVisExpressionFunctionDefinition, + WaffleVisExpressionFunctionDefinition, +} from './types/expression_functions'; + +export type { + PartitionVisParams, + PieVisConfig, + TreemapVisConfig, + MosaicVisConfig, + WaffleVisConfig, + LabelsParams, + Dimension, + Dimensions, +} from './types/expression_renderers'; + +export { + ValueFormats, + LabelPositions, + EmptySizeRatios, + LegendDisplay, +} from './types/expression_renderers'; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_functions.ts new file mode 100644 index 0000000000000..bc623fd621345 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_functions.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + PARTITION_LABELS_VALUE, + PIE_VIS_EXPRESSION_NAME, + TREEMAP_VIS_EXPRESSION_NAME, + MOSAIC_VIS_EXPRESSION_NAME, + WAFFLE_VIS_EXPRESSION_NAME, +} from '../constants'; +import { + ExpressionFunctionDefinition, + Datatable, + ExpressionValueRender, + ExpressionValueBoxed, +} from '../../../../expressions/common'; +import { + RenderValue, + PieVisConfig, + LabelPositions, + ValueFormats, + TreemapVisConfig, + MosaicVisConfig, + WaffleVisConfig, +} from './expression_renderers'; + +export interface PartitionLabelsArguments { + show: boolean; + position: LabelPositions; + values: boolean; + valuesFormat: ValueFormats; + percentDecimals: number; + /** @deprecated This field is deprecated and going to be removed in the futher release versions. */ + truncate?: number | null; + /** @deprecated This field is deprecated and going to be removed in the futher release versions. */ + last_level?: boolean; +} + +export type ExpressionValuePartitionLabels = ExpressionValueBoxed< + typeof PARTITION_LABELS_VALUE, + { + show: boolean; + position: LabelPositions; + values: boolean; + valuesFormat: ValueFormats; + percentDecimals: number; + /** @deprecated This field is deprecated and going to be removed in the futher release versions. */ + truncate?: number | null; + /** @deprecated This field is deprecated and going to be removed in the futher release versions. */ + last_level?: boolean; + } +>; + +export type PieVisExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof PIE_VIS_EXPRESSION_NAME, + Datatable, + PieVisConfig, + ExpressionValueRender +>; + +export type TreemapVisExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof TREEMAP_VIS_EXPRESSION_NAME, + Datatable, + TreemapVisConfig, + ExpressionValueRender +>; + +export type MosaicVisExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof MOSAIC_VIS_EXPRESSION_NAME, + Datatable, + MosaicVisConfig, + ExpressionValueRender +>; + +export type WaffleVisExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof WAFFLE_VIS_EXPRESSION_NAME, + Datatable, + WaffleVisConfig, + ExpressionValueRender +>; + +export enum ChartTypes { + PIE = 'pie', + DONUT = 'donut', + TREEMAP = 'treemap', + MOSAIC = 'mosaic', + WAFFLE = 'waffle', +} diff --git a/src/plugins/chart_expressions/expression_pie/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts similarity index 59% rename from src/plugins/chart_expressions/expression_pie/common/types/expression_renderers.ts rename to src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts index dd7bfacc0b9c1..87358d5dbe659 100644 --- a/src/plugins/chart_expressions/expression_pie/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts @@ -11,7 +11,7 @@ import { Datatable, DatatableColumn } from '../../../../expressions/common'; import { SerializedFieldFormat } from '../../../../field_formats/common'; import { ExpressionValueVisDimension } from '../../../../visualizations/common'; import { PaletteOutput } from '../../../../charts/common'; -import { ExpressionValuePieLabels } from './expression_functions'; +import { ChartTypes, ExpressionValuePartitionLabels } from './expression_functions'; export enum EmptySizeRatios { SMALL = 0.3, @@ -28,7 +28,7 @@ export interface Dimension { } export interface Dimensions { - metric: ExpressionValueVisDimension; + metric?: ExpressionValueVisDimension; buckets?: ExpressionValueVisDimension[]; splitRow?: ExpressionValueVisDimension[]; splitColumn?: ExpressionValueVisDimension[]; @@ -36,45 +36,74 @@ export interface Dimensions { export interface LabelsParams { show: boolean; - last_level: boolean; position: LabelPositions; values: boolean; - truncate: number | null; valuesFormat: ValueFormats; percentDecimals: number; + /** @deprecated This field is deprecated and going to be removed in the futher release versions. */ + truncate?: number | null; + /** @deprecated This field is deprecated and going to be removed in the futher release versions. */ + last_level?: boolean; } -interface PieCommonParams { +interface VisCommonParams { addTooltip: boolean; - addLegend: boolean; + legendDisplay: LegendDisplay; legendPosition: Position; - nestedLegend: boolean; truncateLegend: boolean; maxLegendLines: number; - distinctColors: boolean; - isDonut: boolean; - emptySizeRatio?: EmptySizeRatios; } -export interface PieVisParams extends PieCommonParams { +interface VisCommonConfig extends VisCommonParams { + metric: ExpressionValueVisDimension; + splitColumn?: ExpressionValueVisDimension[]; + splitRow?: ExpressionValueVisDimension[]; + labels: ExpressionValuePartitionLabels; + palette: PaletteOutput; +} + +export interface PartitionVisParams extends VisCommonParams { dimensions: Dimensions; labels: LabelsParams; palette: PaletteOutput; + isDonut?: boolean; + showValuesInLegend?: boolean; + respectSourceOrder?: boolean; + emptySizeRatio?: EmptySizeRatios; + startFromSecondLargestSlice?: boolean; + distinctColors?: boolean; + nestedLegend?: boolean; } -export interface PieVisConfig extends PieCommonParams { +export interface PieVisConfig extends VisCommonConfig { buckets?: ExpressionValueVisDimension[]; - metric: ExpressionValueVisDimension; - splitColumn?: ExpressionValueVisDimension[]; - splitRow?: ExpressionValueVisDimension[]; - labels: ExpressionValuePieLabels; - palette: PaletteOutput; + isDonut: boolean; + emptySizeRatio?: EmptySizeRatios; + respectSourceOrder?: boolean; + startFromSecondLargestSlice?: boolean; + distinctColors?: boolean; + nestedLegend: boolean; +} + +export interface TreemapVisConfig extends VisCommonConfig { + buckets?: ExpressionValueVisDimension[]; + nestedLegend: boolean; +} + +export interface MosaicVisConfig extends VisCommonConfig { + buckets?: ExpressionValueVisDimension[]; + nestedLegend: boolean; +} + +export interface WaffleVisConfig extends VisCommonConfig { + bucket?: ExpressionValueVisDimension; + showValuesInLegend: boolean; } export interface RenderValue { visData: Datatable; - visType: string; - visConfig: PieVisParams; + visType: ChartTypes; + visConfig: PartitionVisParams; syncColors: boolean; } @@ -88,6 +117,12 @@ export enum ValueFormats { VALUE = 'value', } +export enum LegendDisplay { + SHOW = 'show', + HIDE = 'hide', + DEFAULT = 'default', +} + export interface BucketColumns extends DatatableColumn { format?: { id?: string; diff --git a/src/plugins/chart_expressions/expression_pie/common/types/index.ts b/src/plugins/chart_expressions/expression_partition_vis/common/types/index.ts similarity index 100% rename from src/plugins/chart_expressions/expression_pie/common/types/index.ts rename to src/plugins/chart_expressions/expression_partition_vis/common/types/index.ts diff --git a/src/plugins/chart_expressions/expression_pie/jest.config.js b/src/plugins/chart_expressions/expression_partition_vis/jest.config.js similarity index 72% rename from src/plugins/chart_expressions/expression_pie/jest.config.js rename to src/plugins/chart_expressions/expression_partition_vis/jest.config.js index d8dd288fab086..c449f1e1f2453 100644 --- a/src/plugins/chart_expressions/expression_pie/jest.config.js +++ b/src/plugins/chart_expressions/expression_partition_vis/jest.config.js @@ -9,11 +9,11 @@ module.exports = { preset: '@kbn/test', rootDir: '../../../../', - roots: ['/src/plugins/chart_expressions/expression_pie'], + roots: ['/src/plugins/chart_expressions/expression_partition_vis'], coverageDirectory: - '/target/kibana-coverage/jest/src/plugins/chart_expressions/expression_pie', + '/target/kibana-coverage/jest/src/plugins/chart_expressions/expression_partition_vis', coverageReporters: ['text', 'html'], collectCoverageFrom: [ - '/src/plugins/chart_expressions/expression_pie/{common,public,server}/**/*.{ts,tsx}', + '/src/plugins/chart_expressions/expression_partition_vis/{common,public,server}/**/*.{ts,tsx}', ], }; diff --git a/src/plugins/chart_expressions/expression_pie/kibana.json b/src/plugins/chart_expressions/expression_partition_vis/kibana.json similarity index 55% rename from src/plugins/chart_expressions/expression_pie/kibana.json rename to src/plugins/chart_expressions/expression_partition_vis/kibana.json index a681ca1ed00ac..226d1681cd3fc 100755 --- a/src/plugins/chart_expressions/expression_pie/kibana.json +++ b/src/plugins/chart_expressions/expression_partition_vis/kibana.json @@ -1,12 +1,12 @@ { - "id": "expressionPie", + "id": "expressionPartitionVis", "version": "1.0.0", "kibanaVersion": "kibana", "owner": { "name": "Vis Editors", "githubTeam": "kibana-vis-editors" }, - "description": "Expression Pie plugin adds a `pie` renderer and function to the expression plugin. The renderer will display the `pie` chart.", + "description": "Expression Partition Visualization plugin adds a `partitionVis` renderer and `pieVis`, `mosaicVis`, `treemapVis`, `waffleVis` functions to the expression plugin. The renderer will display the `pie`, `waffle`, `treemap` and `mosaic` charts.", "server": true, "ui": true, "extraPublicDirs": [ diff --git a/src/plugins/chart_expressions/expression_pie/public/__mocks__/format_service.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/format_service.ts similarity index 88% rename from src/plugins/chart_expressions/expression_pie/public/__mocks__/format_service.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/format_service.ts index 77f6d8eb0bf37..84ad7f4f0f5bd 100644 --- a/src/plugins/chart_expressions/expression_pie/public/__mocks__/format_service.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/format_service.ts @@ -8,6 +8,6 @@ export const getFormatService = () => ({ deserialize: (target: any) => ({ - convert: (text: string, format: string) => text, + convert: (text: string, format: string) => `${text}`, }), }); diff --git a/src/plugins/chart_expressions/expression_pie/public/__mocks__/index.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/index.ts similarity index 100% rename from src/plugins/chart_expressions/expression_pie/public/__mocks__/index.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/index.ts diff --git a/src/plugins/chart_expressions/expression_pie/public/__mocks__/palettes.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/palettes.ts similarity index 87% rename from src/plugins/chart_expressions/expression_pie/public/__mocks__/palettes.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/palettes.ts index f418a7e561606..5637acfdbee10 100644 --- a/src/plugins/chart_expressions/expression_pie/public/__mocks__/palettes.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/palettes.ts @@ -21,10 +21,15 @@ export const getPaletteRegistry = () => { '#AA6556', '#E7664C', ]; + let counter = 0; const mockPalette: PaletteDefinition = { id: 'default', title: 'My Palette', - getCategoricalColor: (_: SeriesLayer[]) => colors[0], + getCategoricalColor: (_: SeriesLayer[]) => { + counter++; + if (counter > colors.length - 1) counter = 0; + return colors[counter]; + }, getCategoricalColors: (num: number) => colors, toExpression: () => ({ type: 'expression', diff --git a/src/plugins/chart_expressions/expression_pie/public/__mocks__/start_deps.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/start_deps.ts similarity index 100% rename from src/plugins/chart_expressions/expression_pie/public/__mocks__/start_deps.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/start_deps.ts diff --git a/src/plugins/chart_expressions/expression_pie/public/__mocks__/theme.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/theme.ts similarity index 79% rename from src/plugins/chart_expressions/expression_pie/public/__mocks__/theme.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/theme.ts index b5af8dc496608..178b8db605e1a 100644 --- a/src/plugins/chart_expressions/expression_pie/public/__mocks__/theme.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/theme.ts @@ -8,5 +8,9 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ThemeService } from '../../../../charts/public/services'; +import { uiSettings } from './ui_settings'; -export const theme = new ThemeService(); +const theme = new ThemeService(); +theme.init(uiSettings); + +export { theme }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/ui_settings.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/ui_settings.ts new file mode 100644 index 0000000000000..c5838d82867fb --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__mocks__/ui_settings.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IUiSettingsClient, PublicUiSettingsParams, UserProvidedValues } from 'kibana/public'; +import { Observable } from 'rxjs'; + +export const uiSettings: IUiSettingsClient = { + set: (key: string, value: any) => Promise.resolve(true), + remove: (key: string) => Promise.resolve(true), + isCustom: (key: string) => false, + isOverridden: (key: string) => Boolean(uiSettings.getAll()[key].isOverridden), + getUpdate$: () => + new Observable<{ + key: string; + newValue: any; + oldValue: any; + }>(), + isDeclared: (key: string) => true, + isDefault: (key: string) => true, + getUpdateErrors$: () => new Observable(), + get: (key: string, defaultOverride?: any): any => uiSettings.getAll()[key] || defaultOverride, + get$: (key: string) => new Observable(uiSettings.get(key)), + getAll: (): Readonly> => { + return {}; + }, +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/mosaic_vis_renderer.stories.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/mosaic_vis_renderer.stories.tsx new file mode 100644 index 0000000000000..bba644f721038 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/mosaic_vis_renderer.stories.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { ComponentStory } from '@storybook/react'; +import { Render } from '../../../../presentation_util/public/__stories__'; +import { getPartitionVisRenderer } from '../expression_renderers'; +import { ChartTypes, RenderValue } from '../../common/types'; +import { palettes, theme, getStartDeps } from '../__mocks__'; +import { mosaicArgTypes, treemapMosaicConfig, data } from './shared'; + +const containerSize = { + width: '700px', + height: '700px', +}; + +const PartitionVisRenderer = () => getPartitionVisRenderer({ palettes, theme, getStartDeps }); + +type Props = { + visType: RenderValue['visType']; + syncColors: RenderValue['syncColors']; +} & RenderValue['visConfig']; + +const PartitionVis: ComponentStory> = ({ + visType, + syncColors, + children, + ...visConfig +}) => ( + +); + +export default { + title: 'renderers/mosaicVis', + component: PartitionVis, + argTypes: mosaicArgTypes, +}; + +const Default = PartitionVis.bind({}); +Default.args = { ...treemapMosaicConfig, visType: ChartTypes.MOSAIC, syncColors: false }; + +export { Default }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/pie_vis_renderer.stories.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/pie_vis_renderer.stories.tsx new file mode 100644 index 0000000000000..b6d6e9055e692 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/pie_vis_renderer.stories.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { ComponentStory } from '@storybook/react'; +import { Render } from '../../../../presentation_util/public/__stories__'; +import { getPartitionVisRenderer } from '../expression_renderers'; +import { ChartTypes, RenderValue } from '../../common/types'; +import { palettes, theme, getStartDeps } from '../__mocks__'; +import { pieDonutArgTypes, pieConfig, data } from './shared'; + +const containerSize = { + width: '700px', + height: '700px', +}; + +const PartitionVisRenderer = () => getPartitionVisRenderer({ palettes, theme, getStartDeps }); + +type Props = { + visType: RenderValue['visType']; + syncColors: RenderValue['syncColors']; +} & RenderValue['visConfig']; + +const PartitionVis: ComponentStory> = ({ + visType, + syncColors, + children, + ...visConfig +}) => ( + +); + +export default { + title: 'renderers/pieVis', + component: PartitionVis, + argTypes: pieDonutArgTypes, +}; + +const Default = PartitionVis.bind({}); +Default.args = { ...pieConfig, visType: ChartTypes.PIE, syncColors: false }; + +export { Default }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts new file mode 100644 index 0000000000000..1a18c905548d4 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Position } from '@elastic/charts'; +import { ArgTypes } from '@storybook/addons'; +import { EmptySizeRatios, LegendDisplay } from '../../../common'; +import { ChartTypes } from '../../../common/types'; + +const visConfigName = 'visConfig'; + +export const argTypes: ArgTypes = { + addTooltip: { + name: `${visConfigName}.addTooltip`, + description: 'Add tooltip on hover', + type: { name: 'boolean', required: false }, + table: { type: { summary: 'boolean' }, defaultValue: { summary: true } }, + control: { type: 'boolean' }, + }, + legendDisplay: { + name: `${visConfigName}.legendDisplay`, + description: 'Legend mode of displaying', + type: { name: 'string', required: false }, + table: { type: { summary: 'string' }, defaultValue: { summary: LegendDisplay.HIDE } }, + options: Object.values(LegendDisplay), + control: { type: 'select' }, + }, + legendPosition: { + name: `${visConfigName}.legendPosition`, + description: 'Legend position', + type: { name: 'string', required: false }, + table: { type: { summary: 'string' }, defaultValue: { summary: Position.Bottom } }, + options: Object.values(Position), + control: { type: 'select' }, + }, + truncateLegend: { + name: `${visConfigName}.truncateLegend`, + description: 'Truncate too long legend', + type: { name: 'boolean', required: false }, + table: { type: { summary: 'boolean' }, defaultValue: { summary: true } }, + control: { type: 'boolean' }, + }, + maxLegendLines: { + name: `${visConfigName}.maxLegendLines`, + description: 'Legend maximum number of lines', + type: { name: 'number', required: false }, + table: { type: { summary: 'number' } }, + control: { type: 'number' }, + }, + palette: { + name: `${visConfigName}.palette`, + description: 'Palette', + type: { name: 'palette', required: false }, + table: { type: { summary: 'object' } }, + control: { type: 'object' }, + }, + labels: { + name: `${visConfigName}.labels`, + description: 'Labels configuration', + type: { name: 'object', required: false }, + table: { + type: { + summary: 'object', + detail: `Labels configuration consists of further fields: + - show: boolean. Default: true. + - position: string. Options: 'default', 'inside'. Default: 'default'. + - values: boolean. Default: true. + - percentDecimals: number. Default: 2. + - last_level: boolean. Default: false. DEPRECATED. + - truncate: number. Default: null. + - valuesFormat: string. Options: 'percent', 'value'. Default: percent. + `, + }, + }, + control: { type: 'object' }, + }, + dimensions: { + name: `${visConfigName}.dimensions`, + description: 'dimensions configuration', + type: { name: 'object', required: false }, + table: { + type: { + summary: 'object', + detail: `Dimensions configuration consists of two fields: + - metric: visdimension. + - buckets: visdimension[]. + `, + }, + }, + control: { type: 'object' }, + }, +}; + +export const pieDonutArgTypes: ArgTypes = { + ...argTypes, + visType: { + name: `visType`, + description: 'Type of the chart', + type: { name: 'string', required: false }, + table: { + type: { summary: 'string' }, + defaultValue: { summary: `${ChartTypes.PIE} | ${ChartTypes.DONUT}` }, + }, + control: { type: 'text', disable: true }, + }, + isDonut: { + name: `${visConfigName}.isDonut`, + description: 'Render a donut chart', + type: { name: 'boolean', required: false }, + table: { type: { summary: 'boolean' }, defaultValue: { summary: false } }, + control: { type: 'boolean' }, + }, + emptySizeRatio: { + name: `${visConfigName}.emptySizeRatio`, + description: 'The hole size of the donut chart', + type: { name: 'number', required: false }, + table: { type: { summary: 'number' }, defaultValue: { summary: EmptySizeRatios.SMALL } }, + options: [EmptySizeRatios.SMALL, EmptySizeRatios.MEDIUM, EmptySizeRatios.LARGE], + control: { type: 'select' }, + }, + distinctColors: { + name: `${visConfigName}.distinctColors`, + description: 'Enable distinct colors', + type: { name: 'boolean', required: false }, + table: { type: { summary: 'boolean' }, defaultValue: { summary: false } }, + control: { type: 'boolean' }, + }, + respectSourceOrder: { + name: `${visConfigName}.respectSourceOrder`, + description: 'Save default order of the incomming data', + type: { name: 'boolean', required: false }, + table: { type: { summary: 'boolean' }, defaultValue: { summary: true } }, + control: { type: 'boolean' }, + }, + startFromSecondLargestSlice: { + name: `${visConfigName}.startFromSecondLargestSlice`, + description: 'Start placement of slices from the second largest slice', + type: { name: 'boolean', required: false }, + table: { type: { summary: 'boolean' }, defaultValue: { summary: true } }, + control: { type: 'boolean' }, + }, + nestedLegend: { + name: `${visConfigName}.nestedLegend`, + description: 'Enable nested legend', + type: { name: 'boolean', required: false }, + table: { type: { summary: 'boolean' }, defaultValue: { summary: false } }, + control: { type: 'boolean' }, + }, +}; + +export const treemapArgTypes: ArgTypes = { + visType: { + name: `visType`, + description: 'Type of the chart', + type: { name: 'string', required: false }, + table: { + type: { summary: 'string' }, + defaultValue: { summary: `${ChartTypes.TREEMAP}` }, + }, + control: { type: 'text', disable: true }, + }, + ...argTypes, + nestedLegend: { + name: `${visConfigName}.nestedLegend`, + description: 'Enable nested legend', + type: { name: 'boolean', required: false }, + table: { type: { summary: 'boolean' }, defaultValue: { summary: false } }, + control: { type: 'boolean' }, + }, +}; + +export const mosaicArgTypes: ArgTypes = { + visType: { + name: `visType`, + description: 'Type of the chart', + type: { name: 'string', required: false }, + table: { + type: { summary: 'string' }, + defaultValue: { summary: `${ChartTypes.MOSAIC}` }, + }, + control: { type: 'text', disable: true }, + }, + ...argTypes, + nestedLegend: { + name: `${visConfigName}.nestedLegend`, + description: 'Enable nested legend', + type: { name: 'boolean', required: false }, + table: { type: { summary: 'boolean' }, defaultValue: { summary: false } }, + control: { type: 'boolean' }, + }, +}; + +export const waffleArgTypes: ArgTypes = { + visType: { + name: `visType`, + description: 'Type of the chart', + type: { name: 'string', required: false }, + table: { + type: { summary: 'string' }, + defaultValue: { summary: `${ChartTypes.WAFFLE}` }, + }, + control: { type: 'text', disable: true }, + }, + ...argTypes, + showValuesInLegend: { + name: `${visConfigName}.nestedLegend`, + description: 'Enable displaying values in the legend', + type: { name: 'boolean', required: false }, + table: { type: { summary: 'boolean' }, defaultValue: { summary: false } }, + control: { type: 'boolean' }, + }, +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/config.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/config.ts new file mode 100644 index 0000000000000..d16802518cce4 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/config.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { Position } from '@elastic/charts'; +import { + LabelPositions, + LegendDisplay, + RenderValue, + PartitionVisParams, + ValueFormats, +} from '../../../common/types'; + +export const config: RenderValue['visConfig'] = { + addTooltip: true, + legendDisplay: LegendDisplay.HIDE, + truncateLegend: true, + respectSourceOrder: true, + legendPosition: Position.Bottom, + maxLegendLines: 1, + palette: { + type: 'palette', + name: 'system_palette', + }, + labels: { + show: true, + position: LabelPositions.DEFAULT, + percentDecimals: 2, + values: true, + truncate: 0, + valuesFormat: ValueFormats.PERCENT, + last_level: false, + }, + dimensions: { + metric: { + type: 'vis_dimension', + accessor: { + id: 'percent_uptime', + name: 'percent_uptime', + meta: { + type: 'number', + }, + }, + format: { + id: 'string', + params: {}, + }, + }, + }, +}; + +export const pieConfig: PartitionVisParams = { + ...config, + isDonut: false, + emptySizeRatio: 0, + distinctColors: false, + nestedLegend: false, + dimensions: { + ...config.dimensions, + buckets: [ + { + type: 'vis_dimension', + accessor: { + id: 'project', + name: 'project', + meta: { + type: 'string', + }, + }, + format: { + id: 'string', + params: {}, + }, + }, + ], + }, + startFromSecondLargestSlice: true, +}; + +export const treemapMosaicConfig: PartitionVisParams = { + ...config, + nestedLegend: false, + dimensions: { + ...config.dimensions, + buckets: [ + { + type: 'vis_dimension', + accessor: { + id: 'project', + name: 'project', + meta: { + type: 'string', + }, + }, + format: { + id: 'string', + params: {}, + }, + }, + ], + }, +}; + +export const waffleConfig: PartitionVisParams = { + ...config, + dimensions: { + ...config.dimensions, + buckets: [ + { + type: 'vis_dimension', + accessor: { + id: 'project', + name: 'project', + meta: { + type: 'string', + }, + }, + format: { + id: 'string', + params: {}, + }, + }, + ], + }, + showValuesInLegend: false, +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/data.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/data.ts new file mode 100644 index 0000000000000..e02f090b5f7fa --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/data.ts @@ -0,0 +1,207 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RenderValue } from '../../../common/types'; + +export const data: RenderValue['visData'] = { + type: 'datatable', + columns: [ + { + id: '@timestamp', + name: '@timestamp', + meta: { + type: 'date', + }, + }, + { + id: 'time', + name: 'time', + meta: { + type: 'date', + }, + }, + { + id: 'cost', + name: 'cost', + meta: { + type: 'number', + }, + }, + { + id: 'username', + name: 'username', + meta: { + type: 'string', + }, + }, + { + id: 'price', + name: 'price', + meta: { + type: 'number', + }, + }, + { + id: 'age', + name: 'age', + meta: { + type: 'number', + }, + }, + { + id: 'country', + name: 'country', + meta: { + type: 'string', + }, + }, + { + id: 'state', + name: 'state', + meta: { + type: 'string', + }, + }, + { + id: 'project', + name: 'project', + meta: { + type: 'string', + }, + }, + { + id: 'percent_uptime', + name: 'percent_uptime', + meta: { + type: 'number', + }, + }, + ], + rows: [ + { + age: 63, + cost: 32.15, + country: 'US', + price: 53, + project: 'elasticsearch', + state: 'running', + time: 1546334211208, + '@timestamp': 1546334211208, + username: 'aevans2e', + percent_uptime: 0.83, + }, + { + age: 68, + cost: 20.52, + country: 'JP', + price: 33, + project: 'beats', + state: 'done', + time: 1546351551031, + '@timestamp': 1546351551031, + username: 'aking2c', + percent_uptime: 0.9, + }, + { + age: 57, + cost: 21.15, + country: 'UK', + price: 59, + project: 'apm', + state: 'running', + time: 1546352631083, + '@timestamp': 1546352631083, + username: 'mmoore2o', + percent_uptime: 0.96, + }, + { + age: 73, + cost: 35.64, + country: 'CN', + price: 71, + project: 'machine-learning', + state: 'start', + time: 1546402490956, + '@timestamp': 1546402490956, + username: 'wrodriguez1r', + percent_uptime: 0.61, + }, + { + age: 38, + cost: 27.19, + country: 'TZ', + price: 36, + project: 'kibana', + state: 'done', + time: 1546467111351, + '@timestamp': 1546467111351, + username: 'wrodriguez1r', + percent_uptime: 0.72, + }, + { + age: 61, + cost: 49.95, + country: 'NL', + price: 65, + project: 'machine-learning', + state: 'start', + time: 1546473771019, + '@timestamp': 1546473771019, + username: 'mmoore2o', + percent_uptime: 0.72, + }, + { + age: 53, + cost: 27.36, + country: 'JP', + price: 60, + project: 'x-pack', + state: 'running', + time: 1546482171310, + '@timestamp': 1546482171310, + username: 'hcrawford2h', + percent_uptime: 0.65, + }, + { + age: 31, + cost: 33.77, + country: 'AZ', + price: 77, + project: 'kibana', + state: 'start', + time: 1546493451206, + '@timestamp': 1546493451206, + username: 'aking2c', + percent_uptime: 0.92, + }, + { + age: 71, + cost: 20.2, + country: 'TZ', + price: 57, + project: 'swiftype', + state: 'running', + time: 1546494651235, + '@timestamp': 1546494651235, + username: 'jlawson2p', + percent_uptime: 0.59, + }, + { + age: 54, + cost: 36.65, + country: 'TZ', + price: 72, + project: 'apm', + state: 'done', + time: 1546498431195, + '@timestamp': 1546498431195, + username: 'aking2c', + percent_uptime: 1, + }, + ], +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/index.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/index.ts new file mode 100644 index 0000000000000..10d31b77d6973 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { data } from './data'; +export { config, pieConfig, treemapMosaicConfig, waffleConfig } from './config'; +export { + argTypes, + pieDonutArgTypes, + treemapArgTypes, + mosaicArgTypes, + waffleArgTypes, +} from './arg_types'; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/treemap_vis_renderer.stories.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/treemap_vis_renderer.stories.tsx new file mode 100644 index 0000000000000..a8f9010ade4ab --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/treemap_vis_renderer.stories.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { ComponentStory } from '@storybook/react'; +import { Render } from '../../../../presentation_util/public/__stories__'; +import { getPartitionVisRenderer } from '../expression_renderers'; +import { ChartTypes, RenderValue } from '../../common/types'; +import { palettes, theme, getStartDeps } from '../__mocks__'; +import { treemapArgTypes, treemapMosaicConfig, data } from './shared'; + +const containerSize = { + width: '700px', + height: '700px', +}; + +const PartitionVisRenderer = () => getPartitionVisRenderer({ palettes, theme, getStartDeps }); + +type Props = { + visType: RenderValue['visType']; + syncColors: RenderValue['syncColors']; +} & RenderValue['visConfig']; + +const PartitionVis: ComponentStory> = ({ + visType, + syncColors, + children, + ...visConfig +}) => ( + +); + +export default { + title: 'renderers/treemapVis', + component: PartitionVis, + argTypes: treemapArgTypes, +}; + +const Default = PartitionVis.bind({}); +Default.args = { ...treemapMosaicConfig, visType: ChartTypes.TREEMAP, syncColors: false }; + +export { Default }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/waffle_vis_renderer.stories.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/waffle_vis_renderer.stories.tsx new file mode 100644 index 0000000000000..a97efdabef892 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/waffle_vis_renderer.stories.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { ComponentStory } from '@storybook/react'; +import { Render } from '../../../../presentation_util/public/__stories__'; +import { getPartitionVisRenderer } from '../expression_renderers'; +import { ChartTypes, RenderValue } from '../../common/types'; +import { palettes, theme, getStartDeps } from '../__mocks__'; +import { waffleArgTypes, waffleConfig, data } from './shared'; + +const containerSize = { + width: '700px', + height: '700px', +}; + +const PartitionVisRenderer = () => getPartitionVisRenderer({ palettes, theme, getStartDeps }); + +type Props = { + visType: RenderValue['visType']; + syncColors: RenderValue['syncColors']; +} & RenderValue['visConfig']; + +const PartitionVis: ComponentStory> = ({ + visType, + syncColors, + children, + ...visConfig +}) => ( + +); + +export default { + title: 'renderers/waffleVis', + component: PartitionVis, + argTypes: waffleArgTypes, +}; + +const Default = PartitionVis.bind({}); +Default.args = { ...waffleConfig, visType: ChartTypes.WAFFLE, syncColors: false }; + +export { Default }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap new file mode 100644 index 0000000000000..4e56d2c5efa4c --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap @@ -0,0 +1,1993 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PartitionVisComponent should render correct structure for donut 1`] = ` +

+
+ + + + + + + + +
+
+`; + +exports[`PartitionVisComponent should render correct structure for mosaic 1`] = ` +
+
+ + + + + + + + +
+
+`; + +exports[`PartitionVisComponent should render correct structure for pie 1`] = ` +
+
+ + + + + + + + +
+
+`; + +exports[`PartitionVisComponent should render correct structure for treemap 1`] = ` +
+
+ + + + + + + + +
+
+`; + +exports[`PartitionVisComponent should render correct structure for waffle 1`] = ` +
+
+ + + + + + + + +
+
+`; diff --git a/src/plugins/chart_expressions/expression_pie/public/components/chart_split.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/chart_split.tsx similarity index 100% rename from src/plugins/chart_expressions/expression_pie/public/components/chart_split.tsx rename to src/plugins/chart_expressions/expression_partition_vis/public/components/chart_split.tsx diff --git a/src/plugins/chart_expressions/expression_pie/public/expression_renderers/index.ts b/src/plugins/chart_expressions/expression_partition_vis/public/components/index.ts similarity index 86% rename from src/plugins/chart_expressions/expression_pie/public/expression_renderers/index.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/components/index.ts index 3f370b63b4579..14a49bafb689c 100644 --- a/src/plugins/chart_expressions/expression_pie/public/expression_renderers/index.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { getPieVisRenderer } from './pie_vis_renderer'; +export * from './partition_vis_component'; diff --git a/src/plugins/chart_expressions/expression_pie/public/components/pie_vis_component.styles.ts b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.styles.ts similarity index 78% rename from src/plugins/chart_expressions/expression_pie/public/components/pie_vis_component.styles.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.styles.ts index 678bdd1a73c8e..b713b3b22964a 100644 --- a/src/plugins/chart_expressions/expression_pie/public/components/pie_vis_component.styles.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.styles.ts @@ -9,15 +9,15 @@ import { EuiThemeComputed } from '@elastic/eui'; import { css } from '@emotion/react'; -export const pieChartWrapperStyle = css({ +export const partitionVisWrapperStyle = css({ display: 'flex', flex: '1 1 auto', minHeight: 0, minWidth: 0, }); -export const pieChartContainerStyleFactory = (theme: EuiThemeComputed) => css` - ${pieChartWrapperStyle}; +export const partitionVisContainerStyleFactory = (theme: EuiThemeComputed) => css` + ${partitionVisWrapperStyle}; position: absolute; top: 0; @@ -27,4 +27,5 @@ export const pieChartContainerStyleFactory = (theme: EuiThemeComputed) => css` padding: ${theme.size.s}; margin-left: auto; margin-right: auto; + overflow: hidden; `; diff --git a/src/plugins/chart_expressions/expression_pie/public/components/pie_vis_component.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx similarity index 65% rename from src/plugins/chart_expressions/expression_pie/public/components/pie_vis_component.test.tsx rename to src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx index 0e0a26483ef6d..ddade06c2c7e0 100644 --- a/src/plugins/chart_expressions/expression_pie/public/components/pie_vis_component.test.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx @@ -15,8 +15,15 @@ import type { Datatable } from '../../../../expressions/public'; import { shallow, mount } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import { act } from 'react-dom/test-utils'; -import PieComponent, { PieComponentProps } from './pie_vis_component'; -import { createMockPieParams, createMockVisData } from '../mocks'; +import PartitionVisComponent, { PartitionVisComponentProps } from './partition_vis_component'; +import { + createMockDonutParams, + createMockPieParams, + createMockTreemapMosaicParams, + createMockVisData, + createMockWaffleParams, +} from '../mocks'; +import { ChartTypes } from '../../common/types'; jest.mock('@elastic/charts', () => { const original = jest.requireActual('@elastic/charts'); @@ -42,8 +49,8 @@ const uiState = { setSilent: jest.fn(), } as any; -describe('PieComponent', function () { - let wrapperProps: PieComponentProps; +describe('PartitionVisComponent', function () { + let wrapperProps: PartitionVisComponentProps; beforeAll(() => { wrapperProps = { @@ -51,6 +58,7 @@ describe('PieComponent', function () { palettesRegistry, visParams, visData, + visType: ChartTypes.PIE, uiState, syncColors: false, fireEvent: jest.fn(), @@ -62,20 +70,81 @@ describe('PieComponent', function () { }; }); + it('should render correct structure for pie', function () { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('should render correct structure for donut', function () { + const donutVisParams = createMockDonutParams(); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + it('should render correct structure for treemap', function () { + const treemapVisParams = createMockTreemapMosaicParams(); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + it('should render correct structure for mosaic', function () { + const mosaicVisParams = createMockTreemapMosaicParams(); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + it('should render correct structure for waffle', function () { + const waffleVisParams = createMockWaffleParams(); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + it('renders the legend on the correct position', () => { - const component = shallow(); + const component = shallow(); expect(component.find(Settings).prop('legendPosition')).toEqual('right'); }); it('renders the legend toggle component', async () => { - const component = mount(); + const component = mount(); await act(async () => { expect(findTestSubject(component, 'vislibToggleLegend').length).toBe(1); }); }); it('hides the legend if the legend toggle is clicked', async () => { - const component = mount(); + const component = mount(); findTestSubject(component, 'vislibToggleLegend').simulate('click'); await act(async () => { expect(component.find(Settings).prop('showLegend')).toEqual(false); @@ -83,31 +152,31 @@ describe('PieComponent', function () { }); it('defaults on showing the legend for the inner cicle', () => { - const component = shallow(); + const component = shallow(); expect(component.find(Settings).prop('legendMaxDepth')).toBe(1); }); it('shows the nested legend when the user requests it', () => { const newParams = { ...visParams, nestedLegend: true }; const newProps = { ...wrapperProps, visParams: newParams }; - const component = shallow(); + const component = shallow(); expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined(); }); it('defaults on displaying the tooltip', () => { - const component = shallow(); + const component = shallow(); expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.Follow }); }); it('doesnt show the tooltip when the user requests it', () => { const newParams = { ...visParams, addTooltip: false }; const newProps = { ...wrapperProps, visParams: newParams }; - const component = shallow(); + const component = shallow(); expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.None }); }); it('calls filter callback', () => { - const component = shallow(); + const component = shallow(); component.find(Settings).first().prop('onElementClick')!([ [ [ @@ -130,14 +199,14 @@ describe('PieComponent', function () { const newVisData = { type: 'datatable', columns: [ - { - id: 'col-1-1', - name: 'Count', - }, { id: 'col-0-2', name: 'filters', }, + { + id: 'col-1-1', + name: 'Count', + }, ], rows: [ { @@ -151,7 +220,7 @@ describe('PieComponent', function () { ], } as unknown as Datatable; const newProps = { ...wrapperProps, visData: newVisData }; - const component = mount(); + const component = mount(); expect(findTestSubject(component, 'pieVisualizationError').text()).toEqual('No results found'); }); @@ -159,14 +228,14 @@ describe('PieComponent', function () { const newVisData = { type: 'datatable', columns: [ - { - id: 'col-1-1', - name: 'Count', - }, { id: 'col-0-2', name: 'filters', }, + { + id: 'col-1-1', + name: 'Count', + }, ], rows: [ { @@ -180,7 +249,7 @@ describe('PieComponent', function () { ], } as unknown as Datatable; const newProps = { ...wrapperProps, visData: newVisData }; - const component = mount(); + const component = mount(); expect(findTestSubject(component, 'pieVisualizationError').text()).toEqual( "Pie/donut charts can't render with negative values." ); diff --git a/src/plugins/chart_expressions/expression_pie/public/components/pie_vis_component.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx similarity index 71% rename from src/plugins/chart_expressions/expression_pie/public/components/pie_vis_component.tsx rename to src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx index 82d0a68f3d1b1..834a0c9c9547b 100644 --- a/src/plugins/chart_expressions/expression_pie/public/components/pie_vis_component.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx @@ -18,7 +18,6 @@ import { TooltipProps, TooltipType, SeriesIdentifier, - PartitionLayout, } from '@elastic/charts'; import { useEuiTheme } from '@elastic/eui'; import { @@ -36,7 +35,7 @@ import { import type { FieldFormat } from '../../../../field_formats/common'; import { DEFAULT_PERCENT_DECIMALS } from '../../common/constants'; import { - PieVisParams, + PartitionVisParams, BucketColumns, ValueFormats, PieContainerDimensions, @@ -53,11 +52,21 @@ import { getColumns, getSplitDimensionAccessor, getColumnByAccessor, + isLegendFlat, + shouldShowLegend, + generateFormatters, + getFormatter, + getPartitionType, } from '../utils'; import { ChartSplit, SMALL_MULTIPLES_ID } from './chart_split'; import { VisualizationNoResults } from './visualization_noresults'; import { VisTypePiePluginStartDependencies } from '../plugin'; -import { pieChartWrapperStyle, pieChartContainerStyleFactory } from './pie_vis_component.styles'; +import { + partitionVisWrapperStyle, + partitionVisContainerStyleFactory, +} from './partition_vis_component.styles'; +import { ChartTypes } from '../../common/types'; +import { filterOutConfig } from '../utils/filter_out_config'; declare global { interface Window { @@ -67,9 +76,10 @@ declare global { _echDebugStateFlag?: boolean; } } -export interface PieComponentProps { - visParams: PieVisParams; +export interface PartitionVisComponentProps { + visParams: PartitionVisParams; visData: Datatable; + visType: ChartTypes; uiState: PersistedState; fireEvent: IInterpreterRenderHandlers['event']; renderComplete: IInterpreterRenderHandlers['done']; @@ -79,15 +89,31 @@ export interface PieComponentProps { syncColors: boolean; } -const PieComponent = (props: PieComponentProps) => { +const PartitionVisComponent = (props: PartitionVisComponentProps) => { + const { visData, visParams: preVisParams, visType, services, syncColors } = props; + const visParams = useMemo(() => filterOutConfig(visType, preVisParams), [preVisParams, visType]); + const theme = useEuiTheme(); const chartTheme = props.chartsThemeService.useChartsTheme(); const chartBaseTheme = props.chartsThemeService.useChartsBaseTheme(); - const [showLegend, setShowLegend] = useState(() => { - const bwcLegendStateDefault = - props.visParams.addLegend == null ? false : props.visParams.addLegend; - return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) ?? bwcLegendStateDefault; - }); + + const { bucketColumns, metricColumn } = useMemo( + () => getColumns(props.visParams, props.visData), + [props.visData, props.visParams] + ); + + const formatters = useMemo( + () => generateFormatters(visParams, visData, services.fieldFormats.deserialize), + [services.fieldFormats.deserialize, visData, visParams] + ); + + const showLegendDefault = useCallback(() => { + const showLegendDef = shouldShowLegend(visType, visParams.legendDisplay, bucketColumns); + return props.uiState?.get('vis.legendOpen', showLegendDef) ?? showLegendDef; + }, [bucketColumns, props.uiState, visParams.legendDisplay, visType]); + + const [showLegend, setShowLegend] = useState(() => showLegendDefault()); + const [dimensions, setDimensions] = useState(); const parentRef = useRef(null); @@ -100,6 +126,12 @@ const PieComponent = (props: PieComponentProps) => { } }, [parentRef]); + useEffect(() => { + const legendShow = showLegendDefault(); + setShowLegend(legendShow); + props.uiState?.set('vis.legendOpen', legendShow); + }, [showLegendDefault, props.uiState]); + const onRenderChange = useCallback( (isRendered) => { if (isRendered) { @@ -113,15 +145,15 @@ const PieComponent = (props: PieComponentProps) => { const handleSliceClick = useCallback( ( clickedLayers: LayerValue[], - bucketColumns: Array>, - visData: Datatable, + buckets: Array>, + vData: Datatable, splitChartDimension?: DatatableColumn, splitChartFormatter?: FieldFormat ): void => { const data = getFilterClickData( clickedLayers, - bucketColumns, - visData, + buckets, + vData, splitChartDimension, splitChartFormatter ); @@ -136,9 +168,9 @@ const PieComponent = (props: PieComponentProps) => { // handles legend action event data const getLegendActionEventData = useCallback( - (visData: Datatable) => + (vData: Datatable) => (series: SeriesIdentifier): ClickTriggerEvent | null => { - const data = getFilterEventData(visData, series); + const data = getFilterEventData(vData, series); return { name: 'filterBucket', @@ -172,11 +204,6 @@ const PieComponent = (props: PieComponentProps) => { }); }, [props.uiState]); - useEffect(() => { - setShowLegend(props.visParams.addLegend); - props.uiState?.set('vis.legendOpen', props.visParams.addLegend); - }, [props.uiState, props.visParams.addLegend]); - const setColor = useCallback( (newColor: string | null, seriesLabel: string | number) => { const colors = props.uiState?.get('vis.colors') || {}; @@ -192,22 +219,22 @@ const PieComponent = (props: PieComponentProps) => { [props.uiState] ); - const { visData, visParams, services, syncColors } = props; - - function getSliceValue(d: Datum, metricColumn: DatatableColumn) { - const value = d[metricColumn.id]; + const getSliceValue = useCallback((d: Datum, metric: DatatableColumn) => { + const value = d[metric.id]; return Number.isFinite(value) && value >= 0 ? value : 0; - } + }, []); + const defaultFormatter = services.fieldFormats.deserialize; // formatters - const metricFieldFormatter = services.fieldFormats.deserialize( - visParams.dimensions.metric.format - ); - const splitChartFormatter = visParams.dimensions.splitColumn - ? services.fieldFormats.deserialize(visParams.dimensions.splitColumn[0].format) - : visParams.dimensions.splitRow - ? services.fieldFormats.deserialize(visParams.dimensions.splitRow[0].format) + const metricFieldFormatter = getFormatter(metricColumn, formatters, defaultFormatter); + const { splitColumn, splitRow } = visParams.dimensions; + + const splitChartFormatter = splitColumn + ? getFormatter(splitColumn[0], formatters, defaultFormatter) + : splitRow + ? getFormatter(splitRow[0], formatters, defaultFormatter) : undefined; + const percentFormatter = services.fieldFormats.deserialize({ id: 'percent', params: { @@ -215,70 +242,71 @@ const PieComponent = (props: PieComponentProps) => { }, }); - const { bucketColumns, metricColumn } = useMemo( - () => getColumns(visParams, visData), - [visData, visParams] - ); - + const isDarkMode = props.chartsThemeService.useDarkMode(); const layers = useMemo( () => getLayers( + visType, bucketColumns, visParams, + visData, props.uiState?.get('vis.colors', {}), visData.rows, props.palettesRegistry, + formatters, services.fieldFormats, - syncColors + syncColors, + isDarkMode ), [ + visType, bucketColumns, visParams, + visData, props.uiState, props.palettesRegistry, - visData.rows, + formatters, services.fieldFormats, syncColors, + isDarkMode, ] ); const rescaleFactor = useMemo(() => { const overallSum = visData.rows.reduce((sum, row) => sum + row[metricColumn.id], 0); const slices = visData.rows.map((row) => row[metricColumn.id] / overallSum); - const smallSlices = slices.filter((value) => value < 0.02).length; - if (smallSlices) { + const smallSlices = slices.filter((value) => value < 0.02) ?? []; + if (smallSlices.length) { // shrink up to 20% to give some room for the linked values - return 1 / (1 + Math.min(smallSlices * 0.05, 0.2)); + return 1 / (1 + Math.min(smallSlices.length * 0.05, 0.2)); } return 1; }, [visData.rows, metricColumn]); const themeOverrides = useMemo( - () => getPartitionTheme(visParams, chartTheme, dimensions, rescaleFactor), - [chartTheme, visParams, dimensions, rescaleFactor] + () => getPartitionTheme(visType, visParams, chartTheme, dimensions, rescaleFactor), + [visType, visParams, chartTheme, dimensions, rescaleFactor] ); + + const fixedViewPort = document.getElementById('app-fixed-viewport'); const tooltip: TooltipProps = { + ...(fixedViewPort ? { boundary: fixedViewPort } : {}), type: visParams.addTooltip ? TooltipType.Follow : TooltipType.None, }; const legendPosition = visParams.legendPosition ?? Position.Right; - const splitChartColumnAccessor = visParams.dimensions.splitColumn - ? getSplitDimensionAccessor( - services.fieldFormats, - visData.columns - )(visParams.dimensions.splitColumn[0]) + const splitChartColumnAccessor = splitColumn + ? getSplitDimensionAccessor(visData.columns, splitColumn[0], formatters, defaultFormatter) : undefined; - const splitChartRowAccessor = visParams.dimensions.splitRow - ? getSplitDimensionAccessor( - services.fieldFormats, - visData.columns - )(visParams.dimensions.splitRow[0]) + + const splitChartRowAccessor = splitRow + ? getSplitDimensionAccessor(visData.columns, splitRow[0], formatters, defaultFormatter) : undefined; - const splitChartDimension = visParams.dimensions.splitColumn - ? getColumnByAccessor(visParams.dimensions.splitColumn[0].accessor, visData.columns) - : visParams.dimensions.splitRow - ? getColumnByAccessor(visParams.dimensions.splitRow[0].accessor, visData.columns) + const splitChartDimension = splitColumn + ? getColumnByAccessor(splitColumn[0].accessor, visData.columns) + : splitRow + ? getColumnByAccessor(splitRow[0].accessor, visData.columns) : undefined; /** @@ -302,15 +330,16 @@ const PieComponent = (props: PieComponentProps) => { }), [visData.rows, metricColumn] ); - + const flatLegend = isLegendFlat(visType, splitChartDimension); const canShowPieChart = !isAllZeros && !hasNegative; + const partitionType = getPartitionType(visType); return ( -
+
{!canShowPieChart ? ( ) : ( -
+
{ palette: visParams.palette.name, data: visData.rows, uiState: props.uiState, - distinctColors: visParams.distinctColors, + distinctColors: visParams.distinctColors ?? false, }} > { legendPosition={legendPosition} legendMaxDepth={visParams.nestedLegend ? undefined : 1} legendColorPicker={props.uiState ? LegendColorPickerWrapper : undefined} - flatLegend={Boolean(splitChartDimension)} + flatLegend={flatLegend} tooltip={tooltip} + showLegendExtra={visParams.showValuesInLegend} onElementClick={(args) => { handleSliceClick( args[0][0] as LayerValue[], @@ -374,11 +404,11 @@ const PieComponent = (props: PieComponentProps) => { onRenderChange={onRenderChange} /> getSliceValue(d, metricColumn)} percentFormatter={(d: number) => percentFormatter.convert(d / 100)} valueGetter={ @@ -386,7 +416,7 @@ const PieComponent = (props: PieComponentProps) => { visParams.labels.valuesFormat === ValueFormats.VALUE || !visParams.labels.values ? undefined - : 'percent' + : ValueFormats.PERCENT } valueFormatter={(d: number) => !visParams.labels.show || !visParams.labels.values @@ -405,4 +435,4 @@ const PieComponent = (props: PieComponentProps) => { }; // eslint-disable-next-line import/no-default-export -export default memo(PieComponent); +export default memo(PartitionVisComponent); diff --git a/src/plugins/chart_expressions/expression_pie/public/components/visualization_noresults.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/visualization_noresults.tsx similarity index 86% rename from src/plugins/chart_expressions/expression_pie/public/components/visualization_noresults.tsx rename to src/plugins/chart_expressions/expression_partition_vis/public/components/visualization_noresults.tsx index 46478556f5f9b..8362d17920bdd 100644 --- a/src/plugins/chart_expressions/expression_pie/public/components/visualization_noresults.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/visualization_noresults.tsx @@ -19,10 +19,10 @@ export const VisualizationNoResults = ({ hasNegativeValues = false }) => { body={ {hasNegativeValues - ? i18n.translate('expressionPie.negativeValuesFound', { + ? i18n.translate('expressionPartitionVis.negativeValuesFound', { defaultMessage: "Pie/donut charts can't render with negative values.", }) - : i18n.translate('expressionPie.noResultsFoundTitle', { + : i18n.translate('expressionPartitionVis.noResultsFoundTitle', { defaultMessage: 'No results found', })} diff --git a/src/plugins/chart_expressions/expression_pie/common/expression_functions/index.ts b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/index.ts similarity index 76% rename from src/plugins/chart_expressions/expression_pie/common/expression_functions/index.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/index.ts index ee8f0ec06d436..17a103370e9f4 100644 --- a/src/plugins/chart_expressions/expression_pie/common/expression_functions/index.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export { pieVisFunction } from './pie_vis_function'; -export { pieLabelsFunction } from './pie_labels_function'; +export { getPartitionVisRenderer } from './partition_vis_renderer'; diff --git a/src/plugins/chart_expressions/expression_pie/public/expression_renderers/pie_vis_renderer.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx similarity index 79% rename from src/plugins/chart_expressions/expression_pie/public/expression_renderers/pie_vis_renderer.tsx rename to src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx index b65e8696f7a57..c3521c7346a81 100644 --- a/src/plugins/chart_expressions/expression_pie/public/expression_renderers/pie_vis_renderer.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx @@ -15,23 +15,23 @@ import { VisualizationContainer } from '../../../../visualizations/public'; import type { PersistedState } from '../../../../visualizations/public'; import { KibanaThemeProvider } from '../../../../kibana_react/public'; -import { PIE_VIS_EXPRESSION_NAME } from '../../common/constants'; -import { RenderValue } from '../../common/types'; +import { PARTITION_VIS_RENDERER_NAME } from '../../common/constants'; +import { ChartTypes, RenderValue } from '../../common/types'; import { VisTypePieDependencies } from '../plugin'; export const strings = { getDisplayName: () => - i18n.translate('expressionPie.renderer.pieVis.displayName', { + i18n.translate('expressionPartitionVis.renderer.pieVis.displayName', { defaultMessage: 'Pie visualization', }), getHelpDescription: () => - i18n.translate('expressionPie.renderer.pieVis.helpDescription', { + i18n.translate('expressionPartitionVis.renderer.pieVis.helpDescription', { defaultMessage: 'Render a pie', }), }; -const PieComponent = lazy(() => import('../components/pie_vis_component')); +const PartitionVisComponent = lazy(() => import('../components/partition_vis_component')); function shouldShowNoResultsMessage(visData: Datatable | undefined): boolean { const rows: object[] | undefined = visData?.rows; @@ -40,14 +40,14 @@ function shouldShowNoResultsMessage(visData: Datatable | undefined): boolean { return Boolean(isZeroHits); } -export const getPieVisRenderer: ( +export const getPartitionVisRenderer: ( deps: VisTypePieDependencies ) => ExpressionRenderDefinition = ({ theme, palettes, getStartDeps }) => ({ - name: PIE_VIS_EXPRESSION_NAME, + name: PARTITION_VIS_RENDERER_NAME, displayName: strings.getDisplayName(), help: strings.getHelpDescription(), reuseDomNode: true, - render: async (domNode, { visConfig, visData, syncColors }, handlers) => { + render: async (domNode, { visConfig, visData, visType, syncColors }, handlers) => { const showNoResult = shouldShowNoResultsMessage(visData); handlers.onDestroy(() => { @@ -61,11 +61,12 @@ export const getPieVisRenderer: ( - { return [ @@ -107,50 +108,50 @@ export const createMockVisData = (): Datatable => { rows: [ { 'col-0-2': 'Logstash Airways', - 'col-2-3': 0, 'col-1-1': 797, + 'col-2-3': 0, 'col-3-1': 689, }, { 'col-0-2': 'Logstash Airways', - 'col-2-3': 1, 'col-1-1': 797, + 'col-2-3': 1, 'col-3-1': 108, }, { 'col-0-2': 'JetBeats', - 'col-2-3': 0, 'col-1-1': 766, + 'col-2-3': 0, 'col-3-1': 654, }, { 'col-0-2': 'JetBeats', - 'col-2-3': 1, 'col-1-1': 766, + 'col-2-3': 1, 'col-3-1': 112, }, { 'col-0-2': 'ES-Air', - 'col-2-3': 0, 'col-1-1': 744, + 'col-2-3': 0, 'col-3-1': 665, }, { 'col-0-2': 'ES-Air', - 'col-2-3': 1, 'col-1-1': 744, + 'col-2-3': 1, 'col-3-1': 79, }, { 'col-0-2': 'Kibana Airlines', - 'col-2-3': 0, 'col-1-1': 731, + 'col-2-3': 0, 'col-3-1': 655, }, { 'col-0-2': 'Kibana Airlines', - 'col-2-3': 1, 'col-1-1': 731, + 'col-2-3': 1, 'col-3-1': 76, }, ], @@ -269,9 +270,9 @@ export const createMockVisData = (): Datatable => { }; }; -export const createMockPieParams = (): PieVisParams => { +export const createMockPartitionVisParams = (): PartitionVisParams => { return { - addLegend: true, + legendDisplay: LegendDisplay.SHOW, addTooltip: true, isDonut: true, labels: { @@ -291,19 +292,20 @@ export const createMockPieParams = (): PieVisParams => { name: 'default', type: 'palette', }, - type: 'pie', dimensions: { metric: { + type: 'vis_dimension', accessor: 1, format: { id: 'number', + params: { + id: 'number', + }, }, - params: {}, - label: 'Count', - aggType: 'count', }, buckets: [ { + type: 'vis_dimension', accessor: 0, format: { id: 'terms', @@ -313,10 +315,9 @@ export const createMockPieParams = (): PieVisParams => { missingBucketLabel: 'Missing', }, }, - label: 'Carrier: Descending', - aggType: 'terms', }, { + type: 'vis_dimension', accessor: 2, format: { id: 'terms', @@ -326,10 +327,56 @@ export const createMockPieParams = (): PieVisParams => { missingBucketLabel: 'Missing', }, }, - label: 'Cancelled: Descending', - aggType: 'terms', }, ], }, - } as unknown as PieVisParams; + }; +}; + +export const createMockPieParams = (): PartitionVisParams => { + return { + ...createMockPartitionVisParams(), + isDonut: false, + distinctColors: false, + }; +}; + +export const createMockDonutParams = (): PartitionVisParams => { + return { + ...createMockPartitionVisParams(), + isDonut: true, + emptySizeRatio: 0.3, + }; +}; + +export const createMockTreemapMosaicParams = (): PartitionVisParams => { + return { + ...createMockPartitionVisParams(), + nestedLegend: true, + }; +}; + +export const createMockWaffleParams = (): PartitionVisParams => { + const visParams = createMockPartitionVisParams(); + return { + ...visParams, + dimensions: { + ...visParams.dimensions, + buckets: [ + { + type: 'vis_dimension', + accessor: 0, + format: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + }, + ], + }, + showValuesInLegend: true, + }; }; diff --git a/src/plugins/chart_expressions/expression_pie/public/plugin.ts b/src/plugins/chart_expressions/expression_partition_vis/public/plugin.ts similarity index 64% rename from src/plugins/chart_expressions/expression_pie/public/plugin.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/plugin.ts index 2c141027c65fb..3bc3cdb31f9b1 100755 --- a/src/plugins/chart_expressions/expression_pie/public/plugin.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/plugin.ts @@ -10,9 +10,20 @@ import { FieldFormatsStart } from '../../../field_formats/public'; import { CoreSetup, CoreStart, ThemeServiceStart } from '../../../../core/public'; import { ChartsPluginSetup } from '../../../charts/public'; import { DataPublicPluginStart } from '../../../data/public'; -import { pieLabelsFunction, pieVisFunction } from '../common'; -import { getPieVisRenderer } from './expression_renderers'; -import { ExpressionPiePluginSetup, ExpressionPiePluginStart, SetupDeps, StartDeps } from './types'; +import { + partitionLabelsFunction, + pieVisFunction, + treemapVisFunction, + mosaicVisFunction, + waffleVisFunction, +} from '../common'; +import { getPartitionVisRenderer } from './expression_renderers'; +import { + ExpressionPartitionVisPluginSetup, + ExpressionPartitionVisPluginStart, + SetupDeps, + StartDeps, +} from './types'; /** @internal */ export interface VisTypePieDependencies { @@ -30,13 +41,16 @@ export interface VisTypePiePluginStartDependencies { fieldFormats: FieldFormatsStart; } -export class ExpressionPiePlugin { +export class ExpressionPartitionVisPlugin { public setup( core: CoreSetup, { expressions, charts }: SetupDeps - ): ExpressionPiePluginSetup { - expressions.registerFunction(pieLabelsFunction); + ): ExpressionPartitionVisPluginSetup { + expressions.registerFunction(partitionLabelsFunction); expressions.registerFunction(pieVisFunction); + expressions.registerFunction(treemapVisFunction); + expressions.registerFunction(mosaicVisFunction); + expressions.registerFunction(waffleVisFunction); const getStartDeps = async () => { const [coreStart, deps] = await core.getStartServices(); @@ -46,11 +60,11 @@ export class ExpressionPiePlugin { }; expressions.registerRenderer( - getPieVisRenderer({ theme: charts.theme, palettes: charts.palettes, getStartDeps }) + getPartitionVisRenderer({ theme: charts.theme, palettes: charts.palettes, getStartDeps }) ); } - public start(core: CoreStart, deps: StartDeps): ExpressionPiePluginStart {} + public start(core: CoreStart, deps: StartDeps): ExpressionPartitionVisPluginStart {} public stop() {} } diff --git a/src/plugins/chart_expressions/expression_pie/public/types.ts b/src/plugins/chart_expressions/expression_partition_vis/public/types.ts similarity index 86% rename from src/plugins/chart_expressions/expression_pie/public/types.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/types.ts index 32f2e83bad512..64e132d2ddadb 100755 --- a/src/plugins/chart_expressions/expression_pie/public/types.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/types.ts @@ -8,8 +8,8 @@ import { ChartsPluginSetup } from '../../../charts/public'; import { ExpressionsPublicPlugin, ExpressionsServiceStart } from '../../../expressions/public'; -export type ExpressionPiePluginSetup = void; -export type ExpressionPiePluginStart = void; +export type ExpressionPartitionVisPluginSetup = void; +export type ExpressionPartitionVisPluginStart = void; export interface SetupDeps { expressions: ReturnType; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.test.ts new file mode 100644 index 0000000000000..f1023d478d40e --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; +import { createMockVisData } from '../mocks'; +import { getColumnByAccessor } from './accessor'; + +const visData = createMockVisData(); + +describe('getColumnByAccessor', () => { + it('returns column by the index', () => { + const index = 1; + const column = getColumnByAccessor(index, visData.columns); + expect(column).toEqual(visData.columns[index]); + }); + + it('returns undefiend if the index is higher then amount of columns', () => { + const index = visData.columns.length; + const column = getColumnByAccessor(index, visData.columns); + expect(column).toBeUndefined(); + }); + + it('returns column by id', () => { + const column = visData.columns[1]; + const accessor: ExpressionValueVisDimension['accessor'] = { + id: column.id, + name: '', + meta: { type: column.meta.type }, + }; + + const foundColumn = getColumnByAccessor(accessor, visData.columns); + expect(foundColumn).toEqual(column); + }); + + it('returns undefined for the accessor to non-existent column', () => { + const accessor: ExpressionValueVisDimension['accessor'] = { + id: 'non-existent-column', + name: '', + meta: { type: 'number' }, + }; + + const column = getColumnByAccessor(accessor, visData.columns); + expect(column).toBeUndefined(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/accessor.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.ts similarity index 100% rename from src/plugins/chart_expressions/expression_pie/public/utils/accessor.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/accessor.ts diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/filter_helpers.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.test.ts similarity index 100% rename from src/plugins/chart_expressions/expression_pie/public/utils/filter_helpers.test.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.test.ts diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/filter_helpers.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts similarity index 100% rename from src/plugins/chart_expressions/expression_pie/public/utils/filter_helpers.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_out_config.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_out_config.test.ts new file mode 100644 index 0000000000000..eec33cac05e3a --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_out_config.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ChartTypes } from '../../common/types'; +import { createMockDonutParams, createMockPieParams } from '../mocks'; +import { filterOutConfig } from './filter_out_config'; + +describe('filterOutConfig', () => { + const config = createMockPieParams(); + const { last_level: lastLevel, truncate, ...restLabels } = config.labels; + const configWithoutTruncateAndLastLevel = { ...config, labels: restLabels }; + + it('returns full configuration for pie visualization', () => { + const fullConfig = filterOutConfig(ChartTypes.PIE, config); + + expect(fullConfig).toEqual(config); + }); + + it('returns full configuration for donut visualization', () => { + const donutConfig = createMockDonutParams(); + const fullDonutConfig = filterOutConfig(ChartTypes.DONUT, donutConfig); + + expect(fullDonutConfig).toEqual(donutConfig); + }); + + it('excludes truncate and last_level from labels for treemap', () => { + const filteredOutConfig = filterOutConfig(ChartTypes.TREEMAP, config); + + expect(filteredOutConfig).toEqual(configWithoutTruncateAndLastLevel); + }); + + it('excludes truncate and last_level from labels for mosaic', () => { + const filteredOutConfig = filterOutConfig(ChartTypes.MOSAIC, config); + + expect(filteredOutConfig).toEqual(configWithoutTruncateAndLastLevel); + }); + + it('excludes truncate and last_level from labels for waffle', () => { + const filteredOutConfig = filterOutConfig(ChartTypes.WAFFLE, config); + + expect(filteredOutConfig).toEqual(configWithoutTruncateAndLastLevel); + }); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_out_config.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_out_config.ts new file mode 100644 index 0000000000000..2b118cd0903c1 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_out_config.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PartitionVisParams, ChartTypes } from '../../common/types'; + +export const filterOutConfig = (visType: ChartTypes, visConfig: PartitionVisParams) => { + if ([ChartTypes.PIE, ChartTypes.DONUT].includes(visType)) { + return visConfig; + } + + const { last_level: lastLevel, truncate, ...restLabelsConfig } = visConfig.labels; + + return { + ...visConfig, + labels: restLabelsConfig, + }; +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.test.ts new file mode 100644 index 0000000000000..69443dcfea5fb --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { fieldFormatsMock } from '../../../../field_formats/common/mocks'; +import { Datatable } from '../../../../expressions'; +import { createMockPieParams, createMockVisData } from '../mocks'; +import { generateFormatters, getAvailableFormatter, getFormatter } from './formatters'; +import { BucketColumns } from '../../common/types'; + +describe('generateFormatters', () => { + const visParams = createMockPieParams(); + const visData = createMockVisData(); + const defaultFormatter = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + beforeEach(() => { + defaultFormatter.mockClear(); + }); + + it('returns empty object, if labels should not be should ', () => { + const formatters = generateFormatters( + { ...visParams, labels: { ...visParams.labels, show: false } }, + visData, + defaultFormatter + ); + + expect(formatters).toEqual({}); + expect(defaultFormatter).toHaveBeenCalledTimes(0); + }); + + it('returns formatters, if columns have meta parameters', () => { + const formatters = generateFormatters(visParams, visData, defaultFormatter); + const formattingResult = fieldFormatsMock.deserialize(); + + const serializedFormatters = Object.keys(formatters).reduce( + (serialized, formatterId) => ({ + ...serialized, + [formatterId]: formatters[formatterId]?.toJSON(), + }), + {} + ); + + expect(serializedFormatters).toEqual({ + 'col-0-2': formattingResult.toJSON(), + 'col-1-1': formattingResult.toJSON(), + 'col-2-3': formattingResult.toJSON(), + 'col-3-1': formattingResult.toJSON(), + }); + + expect(defaultFormatter).toHaveBeenCalledTimes(visData.columns.length); + visData.columns.forEach((col) => { + expect(defaultFormatter).toHaveBeenCalledWith(col.meta.params); + }); + }); + + it('returns undefined formatters for columns without meta parameters', () => { + const newVisData: Datatable = { + ...visData, + columns: visData.columns.map(({ meta, ...col }) => ({ ...col, meta: { type: 'string' } })), + }; + + const formatters = generateFormatters(visParams, newVisData, defaultFormatter); + + expect(formatters).toEqual({ + 'col-0-2': undefined, + 'col-1-1': undefined, + 'col-2-3': undefined, + 'col-3-1': undefined, + }); + expect(defaultFormatter).toHaveBeenCalledTimes(0); + }); +}); + +describe('getAvailableFormatter', () => { + const visData = createMockVisData(); + + const preparedFormatter1 = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + const preparedFormatter2 = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + const defaultFormatter = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + + beforeEach(() => { + defaultFormatter.mockClear(); + preparedFormatter1.mockClear(); + preparedFormatter2.mockClear(); + }); + + const formatters: Record = { + [visData.columns[0].id]: preparedFormatter1(), + [visData.columns[1].id]: preparedFormatter2(), + }; + + it('returns formatter from formatters, if meta.params are present ', () => { + const formatter = getAvailableFormatter(visData.columns[1], formatters, defaultFormatter); + + expect(formatter).toEqual(formatters[visData.columns[1].id]); + expect(defaultFormatter).toHaveBeenCalledTimes(0); + }); + + it('returns formatter from defaultFormatter factory, if meta.params are not present and format is present at column', () => { + const column: Partial = { + ...visData.columns[1], + meta: { type: 'string' }, + format: { + id: 'string', + params: {}, + }, + }; + const formatter = getAvailableFormatter(column, formatters, defaultFormatter); + + expect(formatter).not.toBeNull(); + expect(typeof formatter).toBe('object'); + expect(defaultFormatter).toHaveBeenCalledTimes(1); + expect(defaultFormatter).toHaveBeenCalledWith(column.format); + }); + + it('returns undefined, if meta.params and format are not present', () => { + const column: Partial = { + ...visData.columns[1], + meta: { type: 'string' }, + }; + const formatter = getAvailableFormatter(column, formatters, defaultFormatter); + + expect(formatter).toBeUndefined(); + expect(defaultFormatter).toHaveBeenCalledTimes(0); + }); +}); + +describe('getFormatter', () => { + const visData = createMockVisData(); + + const preparedFormatter1 = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + const preparedFormatter2 = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + const defaultFormatter = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + + beforeEach(() => { + defaultFormatter.mockClear(); + preparedFormatter1.mockClear(); + preparedFormatter2.mockClear(); + }); + + const formatters: Record = { + [visData.columns[0].id]: preparedFormatter1(), + [visData.columns[1].id]: preparedFormatter2(), + }; + + it('returns formatter from formatters, if meta.params are present ', () => { + const formatter = getFormatter(visData.columns[1], formatters, defaultFormatter); + + expect(formatter).toEqual(formatters[visData.columns[1].id]); + expect(defaultFormatter).toHaveBeenCalledTimes(0); + }); + + it('returns formatter from defaultFormatter factory, if meta.params are not present and format is present at column', () => { + const column: Partial = { + ...visData.columns[1], + meta: { type: 'string' }, + format: { + id: 'string', + params: {}, + }, + }; + const formatter = getFormatter(column, formatters, defaultFormatter); + + expect(formatter).not.toBeNull(); + expect(typeof formatter).toBe('object'); + expect(defaultFormatter).toHaveBeenCalledTimes(1); + expect(defaultFormatter).toHaveBeenCalledWith(column.format); + }); + + it('returns defaultFormatter, if meta.params and format are not present', () => { + const column: Partial = { + ...visData.columns[1], + meta: { type: 'string' }, + }; + + const formatter = getFormatter(column, formatters, defaultFormatter); + + expect(formatter).not.toBeNull(); + expect(typeof formatter).toBe('object'); + expect(defaultFormatter).toHaveBeenCalledTimes(1); + expect(defaultFormatter).toHaveBeenCalledWith(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.ts new file mode 100644 index 0000000000000..59574dd248518 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FieldFormat, FormatFactory } from '../../../../field_formats/common'; +import type { Datatable } from '../../../../expressions/public'; +import { BucketColumns, PartitionVisParams } from '../../common/types'; + +export const generateFormatters = ( + visParams: PartitionVisParams, + visData: Datatable, + formatFactory: FormatFactory +) => { + if (!visParams.labels.show) { + return {}; + } + + return visData.columns.reduce | undefined>>( + (newFormatters, column) => ({ + ...newFormatters, + [column.id]: column?.meta?.params ? formatFactory(column.meta.params) : undefined, + }), + {} + ); +}; + +export const getAvailableFormatter = ( + column: Partial, + formatters: Record, + defaultFormatFactory: FormatFactory +) => { + if (column?.meta?.params) { + const formatter = column?.id ? formatters[column?.id] : undefined; + if (formatter) { + return formatter; + } + } + + if (column?.format) { + return defaultFormatFactory(column.format); + } +}; + +export const getFormatter = ( + column: Partial, + formatters: Record, + defaultFormatFactory: FormatFactory +) => getAvailableFormatter(column, formatters, defaultFormatFactory) ?? defaultFormatFactory(); diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_color_picker.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_color_picker.test.tsx similarity index 100% rename from src/plugins/chart_expressions/expression_pie/public/utils/get_color_picker.test.tsx rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/get_color_picker.test.tsx diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_color_picker.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_color_picker.tsx similarity index 100% rename from src/plugins/chart_expressions/expression_pie/public/utils/get_color_picker.tsx rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/get_color_picker.tsx diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_columns.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.test.ts similarity index 85% rename from src/plugins/chart_expressions/expression_pie/public/utils/get_columns.test.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.test.ts index 57dc4367c7e1e..157336599a26e 100644 --- a/src/plugins/chart_expressions/expression_pie/public/utils/get_columns.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.test.ts @@ -7,7 +7,12 @@ */ import { getColumns } from './get_columns'; -import { PieVisParams } from '../../common/types'; +import { + LabelPositions, + LegendDisplay, + PartitionVisParams, + ValueFormats, +} from '../../common/types'; import { createMockPieParams, createMockVisData } from '../mocks'; const visParams = createMockPieParams(); @@ -108,7 +113,16 @@ describe('getColumns', () => { }); it('should return the correct metric column if visParams returns dimensions', () => { - const { metricColumn } = getColumns(visParams, visData); + const { metricColumn } = getColumns( + { + ...visParams, + dimensions: { + ...visParams.dimensions, + metric: undefined, + }, + }, + visData + ); expect(metricColumn).toEqual({ id: 'col-3-1', meta: { @@ -130,39 +144,38 @@ describe('getColumns', () => { }); it('should return the first data column if no buckets specified', () => { - const visParamsOnlyMetric = { - addLegend: true, + const visParamsOnlyMetric: PartitionVisParams = { + legendDisplay: LegendDisplay.SHOW, addTooltip: true, - isDonut: true, labels: { - position: 'default', + position: LabelPositions.DEFAULT, show: true, truncate: 100, values: true, - valuesFormat: 'percent', + valuesFormat: ValueFormats.PERCENT, percentDecimals: 2, + last_level: false, }, legendPosition: 'right', nestedLegend: false, maxLegendLines: 1, truncateLegend: false, + distinctColors: false, palette: { name: 'default', type: 'palette', }, - type: 'pie', dimensions: { metric: { + type: 'vis_dimension', accessor: 1, format: { id: 'number', + params: {}, }, - params: {}, - label: 'Count', - aggType: 'count', }, }, - } as unknown as PieVisParams; + }; const { metricColumn } = getColumns(visParamsOnlyMetric, visData); expect(metricColumn).toEqual({ id: 'col-1-1', @@ -187,37 +200,39 @@ describe('getColumns', () => { }); it('should return an object with the name of the metric if no buckets specified', () => { - const visParamsOnlyMetric = { - addLegend: true, + const visParamsOnlyMetric: PartitionVisParams = { + legendDisplay: LegendDisplay.SHOW, addTooltip: true, isDonut: true, labels: { - position: 'default', + position: LabelPositions.DEFAULT, show: true, truncate: 100, values: true, - valuesFormat: 'percent', + valuesFormat: ValueFormats.PERCENT, percentDecimals: 2, + last_level: false, }, + truncateLegend: false, + maxLegendLines: 100, + distinctColors: false, legendPosition: 'right', nestedLegend: false, palette: { name: 'default', type: 'palette', }, - type: 'pie', dimensions: { metric: { + type: 'vis_dimension', accessor: 1, format: { id: 'number', + params: {}, }, - params: {}, - label: 'Count', - aggType: 'count', }, }, - } as unknown as PieVisParams; + }; const { bucketColumns, metricColumn } = getColumns(visParamsOnlyMetric, visData); expect(bucketColumns).toEqual([{ name: metricColumn.name }]); }); diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_columns.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts similarity index 50% rename from src/plugins/chart_expressions/expression_pie/public/utils/get_columns.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts index 2fce86f365ffa..063315e3aab94 100644 --- a/src/plugins/chart_expressions/expression_pie/public/utils/get_columns.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts @@ -6,39 +6,43 @@ * Side Public License, v 1. */ -import { getColumnByAccessor } from './accessor'; +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; import { DatatableColumn, Datatable } from '../../../../expressions/public'; -import { BucketColumns, PieVisParams } from '../../common/types'; +import { BucketColumns, PartitionVisParams } from '../../common/types'; +import { getColumnByAccessor } from './accessor'; + +const getMetricColumn = ( + metricAccessor: ExpressionValueVisDimension['accessor'], + visData: Datatable +) => { + return getColumnByAccessor(metricAccessor, visData.columns); +}; export const getColumns = ( - visParams: PieVisParams, + visParams: PartitionVisParams, visData: Datatable ): { metricColumn: DatatableColumn; bucketColumns: Array>; } => { - if (visParams.dimensions.buckets && visParams.dimensions.buckets.length > 0) { - const bucketColumns: Array> = visParams.dimensions.buckets.map( - ({ accessor, format }) => ({ - ...getColumnByAccessor(accessor, visData.columns), - format, - }) - ); + const { metric, buckets } = visParams.dimensions; + if (buckets && buckets.length > 0) { + const bucketColumns: Array> = buckets.map(({ accessor, format }) => ({ + ...getColumnByAccessor(accessor, visData.columns), + format, + })); + const lastBucketId = bucketColumns[bucketColumns.length - 1].id; const matchingIndex = visData.columns.findIndex((col) => col.id === lastBucketId); + return { bucketColumns, - metricColumn: visData.columns[matchingIndex + 1], + metricColumn: getMetricColumn(metric?.accessor ?? matchingIndex + 1, visData), }; } - const metricAccessor = visParams?.dimensions?.metric.accessor ?? 0; - const metricColumn = getColumnByAccessor(metricAccessor, visData.columns); + const metricColumn = getMetricColumn(metric?.accessor ?? 0, visData); return { metricColumn, - bucketColumns: [ - { - name: metricColumn.name, - }, - ], + bucketColumns: [{ name: metricColumn.name }], }; }; diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_distinct_series.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_distinct_series.test.ts similarity index 100% rename from src/plugins/chart_expressions/expression_pie/public/utils/get_distinct_series.test.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/get_distinct_series.test.ts diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_distinct_series.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_distinct_series.ts similarity index 78% rename from src/plugins/chart_expressions/expression_pie/public/utils/get_distinct_series.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/get_distinct_series.ts index d5014689f331f..cb432bf7b2580 100644 --- a/src/plugins/chart_expressions/expression_pie/public/utils/get_distinct_series.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_distinct_series.ts @@ -8,7 +8,15 @@ import { DatatableRow } from '../../../../expressions/public'; import { BucketColumns } from '../../common/types'; -export const getDistinctSeries = (rows: DatatableRow[], buckets: Array>) => { +export interface DistinctSeries { + allSeries: string[]; + parentSeries: string[]; +} + +export const getDistinctSeries = ( + rows: DatatableRow[], + buckets: Array> +): DistinctSeries => { const parentBucketId = buckets[0].id; const parentSeries: string[] = []; const allSeries: string[] = []; @@ -24,8 +32,5 @@ export const getDistinctSeries = (rows: DatatableRow[], buckets: Array Promise, getFilterEventData: (series: SeriesIdentifier) => ClickTriggerEvent | null, onFilter: (data: ClickTriggerEvent, negate?: any) => void, - visParams: PieVisParams, + visParams: PartitionVisParams, actions: DataPublicPluginStart['actions'], formatter: FieldFormatsStart ): LegendAction => { @@ -56,7 +56,7 @@ export const getLegendActions = ( title: `${title}`, items: [ { - name: i18n.translate('expressionPie.legend.filterForValueButtonAriaLabel', { + name: i18n.translate('expressionPartitionVis.legend.filterForValueButtonAriaLabel', { defaultMessage: 'Filter for value', }), 'data-test-subj': `legend-${title}-filterIn`, @@ -67,7 +67,7 @@ export const getLegendActions = ( }, }, { - name: i18n.translate('expressionPie.legend.filterOutValueButtonAriaLabel', { + name: i18n.translate('expressionPartitionVis.legend.filterOutValueButtonAriaLabel', { defaultMessage: 'Filter out value', }), 'data-test-subj': `legend-${title}-filterOut`, @@ -114,7 +114,7 @@ export const getLegendActions = ( }} panelPaddingSize="none" anchorPosition="upLeft" - title={i18n.translate('expressionPie.legend.filterOptionsLegend', { + title={i18n.translate('expressionPartitionVis.legend.filterOptionsLegend', { defaultMessage: '{legendDataLabel}, filter options', values: { legendDataLabel: title }, })} diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_theme.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_theme.test.ts new file mode 100644 index 0000000000000..11838c7ce0140 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_theme.test.ts @@ -0,0 +1,496 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; +import { getPartitionTheme } from './get_partition_theme'; +import { createMockPieParams, createMockDonutParams, createMockPartitionVisParams } from '../mocks'; +import { ChartTypes, LabelPositions, PartitionVisParams } from '../../common/types'; +import { RecursivePartial } from '@elastic/eui'; +import { Theme } from '@elastic/charts'; + +const column: ExpressionValueVisDimension = { + type: 'vis_dimension', + accessor: { id: 'col-1-1', name: 'Count', meta: { type: 'number' } }, + format: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, +}; + +const splitRows = [column]; +const splitColumns = [column]; +const chartTheme: RecursivePartial = { + barSeriesStyle: { displayValue: { fontFamily: 'Arial' } }, + lineSeriesStyle: { point: { fill: '#fff' } }, + axes: { axisTitle: { fill: '#000' } }, +}; + +const linkLabelWithEnoughSpace = (visParams: PartitionVisParams) => ({ + maxCount: Number.POSITIVE_INFINITY, + maximumSection: Number.POSITIVE_INFINITY, + maxTextLength: visParams.labels.truncate ?? undefined, +}); + +const linkLabelsWithoutSpaceForOuterLabels = { maxCount: 0 }; + +const linkLabelsWithoutSpaceForLabels = { + maxCount: 0, + maximumSection: Number.POSITIVE_INFINITY, +}; + +const getStaticThemePartition = ( + theme: RecursivePartial, + visParams: PartitionVisParams +) => ({ + fontFamily: theme.barSeriesStyle?.displayValue?.fontFamily, + outerSizeRatio: 1, + minFontSize: 10, + maxFontSize: 16, + emptySizeRatio: visParams.emptySizeRatio ?? 0, + sectorLineStroke: theme.lineSeriesStyle?.point?.fill, + sectorLineWidth: 1.5, + circlePadding: 4, +}); + +const getStaticThemeOptions = (theme: RecursivePartial, visParams: PartitionVisParams) => ({ + partition: getStaticThemePartition(theme, visParams), + chartMargins: { top: 0, left: 0, bottom: 0, right: 0 }, +}); + +const getDefaultLinkLabel = (visParams: PartitionVisParams, theme: RecursivePartial) => ({ + maxCount: 5, + fontSize: 11, + textColor: theme.axes?.axisTitle?.fill, + maxTextLength: visParams.labels.truncate ?? undefined, +}); + +const dimensions = undefined; + +const runPieDonutWaffleTestSuites = (chartType: ChartTypes, visParams: PartitionVisParams) => { + const vParamsSplitRows = { + ...visParams, + dimensions: { ...visParams.dimensions, splitRow: splitRows }, + }; + const vParamsSplitColumns = { + ...visParams, + dimensions: { ...visParams.dimensions, splitColumn: splitColumns }, + }; + + it('should return correct default theme options', () => { + const theme = getPartitionTheme(chartType, visParams, chartTheme, dimensions); + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + partition: { + ...getStaticThemePartition(chartTheme, visParams), + outerSizeRatio: undefined, + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + }, + }); + }); + + it('should not return padding settings if dimensions are not specified', () => { + const theme = getPartitionTheme(chartType, visParams, chartTheme, dimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + partition: { + ...getStaticThemePartition(chartTheme, visParams), + outerSizeRatio: undefined, + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + }, + }); + }); + + it('should not return padding settings if split column or row are specified', () => { + const themeForSplitColumns = getPartitionTheme( + chartType, + vParamsSplitColumns, + chartTheme, + dimensions + ); + + expect(themeForSplitColumns).toEqual({ + ...getStaticThemeOptions(chartTheme, vParamsSplitColumns), + partition: { + ...getStaticThemePartition(chartTheme, vParamsSplitColumns), + outerSizeRatio: undefined, + linkLabel: linkLabelsWithoutSpaceForOuterLabels, + }, + }); + + const themeForSplitRows = getPartitionTheme( + chartType, + vParamsSplitRows, + chartTheme, + dimensions + ); + + expect(themeForSplitRows).toEqual({ + ...getStaticThemeOptions(chartTheme, vParamsSplitRows), + partition: { + ...getStaticThemePartition(chartTheme, vParamsSplitRows), + outerSizeRatio: undefined, + linkLabel: linkLabelsWithoutSpaceForOuterLabels, + }, + }); + }); + + it('should return adjusted padding settings if dimensions are specified', () => { + const specifiedDimensions = { width: 2000, height: 2000 }; + const theme = getPartitionTheme(chartType, visParams, chartTheme, specifiedDimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + chartPaddings: { top: 500, bottom: 500, left: 500, right: 500 }, + partition: { + ...getStaticThemePartition(chartTheme, visParams), + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + }, + }); + }); + + it('should return right settings for the theme related fields', () => { + const theme = getPartitionTheme(chartType, visParams, chartTheme, dimensions); + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + partition: { + ...getStaticThemePartition(chartTheme, visParams), + outerSizeRatio: undefined, + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + }, + }); + }); + + it('should return undefined outerSizeRatio for split chart and show labels', () => { + const specifiedDimensions = { width: 2000, height: 2000 }; + const theme = getPartitionTheme(chartType, vParamsSplitRows, chartTheme, specifiedDimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, vParamsSplitRows), + partition: { + ...getStaticThemePartition(chartTheme, vParamsSplitRows), + outerSizeRatio: undefined, + linkLabel: linkLabelsWithoutSpaceForOuterLabels, + }, + }); + + const themeForSplitColumns = getPartitionTheme( + chartType, + vParamsSplitColumns, + chartTheme, + specifiedDimensions + ); + + expect(themeForSplitColumns).toEqual({ + ...getStaticThemeOptions(chartTheme, vParamsSplitColumns), + partition: { + ...getStaticThemePartition(chartTheme, vParamsSplitColumns), + outerSizeRatio: undefined, + linkLabel: linkLabelsWithoutSpaceForOuterLabels, + }, + }); + }); + + it( + 'should return undefined outerSizeRatio for not specified dimensions, visible labels,' + + 'and default labels position and not split chart', + () => { + const theme = getPartitionTheme(chartType, visParams, chartTheme, dimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + partition: { + ...getStaticThemePartition(chartTheme, visParams), + outerSizeRatio: undefined, + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + }, + }); + } + ); + + it( + 'should return rescaleFactor value for outerSizeRatio if dimensions are specified,' + + ' is not split chart, labels are shown and labels position is not `inside`', + () => { + const specifiedDimensions = { width: 2000, height: 2000 }; + const rescaleFactor = 2; + const theme = getPartitionTheme( + chartType, + visParams, + chartTheme, + specifiedDimensions, + rescaleFactor + ); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + chartPaddings: { top: 500, bottom: 500, left: 500, right: 500 }, + partition: { + ...getStaticThemePartition(chartTheme, visParams), + outerSizeRatio: rescaleFactor, + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + }, + }); + } + ); + it( + 'should return adjusted rescaleFactor for outerSizeRatio if dimensions are specified,' + + ' is not split chart, labels position is `inside` and labels are shown', + () => { + const specifiedDimensions = { width: 2000, height: 2000 }; + const rescaleFactor = 1; + const vParams = { + ...visParams, + labels: { ...visParams.labels, position: LabelPositions.INSIDE }, + }; + + const theme = getPartitionTheme( + chartType, + vParams, + chartTheme, + specifiedDimensions, + rescaleFactor + ); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, vParams), + chartPaddings: { top: 500, bottom: 500, left: 500, right: 500 }, + partition: { + ...getStaticThemePartition(chartTheme, vParams), + outerSizeRatio: 0.5, + linkLabel: linkLabelsWithoutSpaceForOuterLabels, + }, + }); + } + ); + it( + 'should return linkLabel with enough space if labels are shown,' + + ' labels position is `default` and need to show the last level only.', + () => { + const specifiedDimensions = { width: 2000, height: 2000 }; + const vParams = { + ...visParams, + labels: { ...visParams.labels, last_level: true }, + }; + const theme = getPartitionTheme(chartType, vParams, chartTheme, specifiedDimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, vParams), + chartPaddings: { top: 500, bottom: 500, left: 500, right: 500 }, + partition: { + ...getStaticThemePartition(chartTheme, vParams), + linkLabel: linkLabelWithEnoughSpace(vParams), + }, + }); + } + ); + + it('should hide links if position is `inside` or is split chart, and labels are shown', () => { + const vParams = { + ...visParams, + labels: { ...visParams.labels, position: LabelPositions.INSIDE }, + }; + const theme = getPartitionTheme(chartType, vParams, chartTheme, dimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, vParams), + partition: { + ...getStaticThemePartition(chartTheme, vParams), + outerSizeRatio: undefined, + linkLabel: linkLabelsWithoutSpaceForOuterLabels, + }, + }); + + const themeSplitColumns = getPartitionTheme( + chartType, + vParamsSplitColumns, + chartTheme, + dimensions + ); + + expect(themeSplitColumns).toEqual({ + ...getStaticThemeOptions(chartTheme, vParamsSplitColumns), + partition: { + ...getStaticThemePartition(chartTheme, vParamsSplitColumns), + outerSizeRatio: undefined, + linkLabel: linkLabelsWithoutSpaceForOuterLabels, + }, + }); + + const themeSplitRows = getPartitionTheme(chartType, vParamsSplitRows, chartTheme, dimensions); + + expect(themeSplitRows).toEqual({ + ...getStaticThemeOptions(chartTheme, vParamsSplitRows), + partition: { + ...getStaticThemePartition(chartTheme, vParamsSplitRows), + outerSizeRatio: undefined, + linkLabel: linkLabelsWithoutSpaceForOuterLabels, + }, + }); + }); + + it('should hide links if labels are not shown', () => { + const vParams = { ...visParams, labels: { ...visParams.labels, show: false } }; + const theme = getPartitionTheme(chartType, vParams, chartTheme, dimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, vParams), + partition: { + ...getStaticThemePartition(chartTheme, vParams), + outerSizeRatio: undefined, + linkLabel: linkLabelsWithoutSpaceForLabels, + }, + }); + }); +}; + +const runTreemapMosaicTestSuites = (chartType: ChartTypes, visParams: PartitionVisParams) => { + const vParamsSplitRows = { + ...visParams, + dimensions: { ...visParams.dimensions, splitRow: splitRows }, + }; + const vParamsSplitColumns = { + ...visParams, + dimensions: { ...visParams.dimensions, splitColumn: splitColumns }, + }; + + it('should return correct theme options', () => { + const theme = getPartitionTheme(chartType, visParams, chartTheme, dimensions); + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + partition: { + ...getStaticThemePartition(chartTheme, visParams), + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + }, + }); + }); + + it('should return empty padding settings if dimensions are not specified', () => { + const theme = getPartitionTheme(chartType, visParams, chartTheme, dimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + partition: { + ...getStaticThemePartition(chartTheme, visParams), + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + }, + }); + }); + + it('should return padding settings if split column or row are specified', () => { + const themeForSplitColumns = getPartitionTheme( + chartType, + vParamsSplitColumns, + chartTheme, + dimensions + ); + + expect(themeForSplitColumns).toEqual({ + ...getStaticThemeOptions(chartTheme, vParamsSplitColumns), + partition: { + ...getStaticThemePartition(chartTheme, vParamsSplitColumns), + linkLabel: getDefaultLinkLabel(vParamsSplitColumns, chartTheme), + }, + }); + + const themeForSplitRows = getPartitionTheme( + chartType, + vParamsSplitRows, + chartTheme, + dimensions + ); + + expect(themeForSplitRows).toEqual({ + ...getStaticThemeOptions(chartTheme, vParamsSplitRows), + partition: { + ...getStaticThemePartition(chartTheme, vParamsSplitRows), + linkLabel: getDefaultLinkLabel(vParamsSplitRows, chartTheme), + }, + }); + }); + + it('should return fullfilled padding settings if dimensions are specified', () => { + const specifiedDimensions = { width: 2000, height: 2000 }; + const theme = getPartitionTheme(chartType, visParams, chartTheme, specifiedDimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + chartPaddings: { top: 500, bottom: 500, left: 500, right: 500 }, + partition: { + ...getStaticThemePartition(chartTheme, visParams), + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + }, + }); + }); + + it('should return settings for the theme related fields', () => { + const theme = getPartitionTheme(chartType, visParams, chartTheme, dimensions); + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + partition: { + ...getStaticThemePartition(chartTheme, visParams), + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + }, + }); + }); + + it('should make color transparent if labels are hidden', () => { + const vParams = { ...visParams, labels: { ...visParams.labels, show: false } }; + const theme = getPartitionTheme(chartType, vParams, chartTheme, dimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, vParams), + partition: { + ...getStaticThemePartition(chartTheme, vParams), + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + fillLabel: { textColor: 'rgba(0,0,0,0)' }, + }, + }); + }); +}; + +describe('Pie getPartitionTheme', () => { + runPieDonutWaffleTestSuites(ChartTypes.PIE, createMockPieParams()); +}); + +describe('Donut getPartitionTheme', () => { + const visParams = createMockDonutParams(); + const chartType = ChartTypes.DONUT; + + runPieDonutWaffleTestSuites(chartType, visParams); + + it('should return correct empty size ratio and partitionLayout', () => { + const theme = getPartitionTheme(ChartTypes.DONUT, visParams, chartTheme, dimensions); + + expect(theme).toEqual({ + ...getStaticThemeOptions(chartTheme, visParams), + outerSizeRatio: undefined, + partition: { + ...getStaticThemePartition(chartTheme, visParams), + linkLabel: getDefaultLinkLabel(visParams, chartTheme), + outerSizeRatio: undefined, + }, + }); + }); +}); + +describe('Waffle getPartitionTheme', () => { + runPieDonutWaffleTestSuites(ChartTypes.WAFFLE, createMockPartitionVisParams()); +}); + +describe('Mosaic getPartitionTheme', () => { + runTreemapMosaicTestSuites(ChartTypes.MOSAIC, createMockPartitionVisParams()); +}); + +describe('Treemap getPartitionTheme', () => { + runTreemapMosaicTestSuites(ChartTypes.TREEMAP, createMockPartitionVisParams()); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_theme.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_theme.ts new file mode 100644 index 0000000000000..edb1aaea64aad --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_theme.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RecursivePartial, Theme, PartialTheme } from '@elastic/charts'; +import { + ChartTypes, + LabelPositions, + PartitionVisParams, + PieContainerDimensions, +} from '../../common/types'; + +type GetThemeByTypeFn = ( + chartType: ChartTypes, + visParams: PartitionVisParams, + dimensions?: PieContainerDimensions, + rescaleFactor?: number +) => PartialTheme; + +type GetThemeFn = ( + chartType: ChartTypes, + visParams: PartitionVisParams, + chartTheme: RecursivePartial, + dimensions?: PieContainerDimensions, + rescaleFactor?: number +) => PartialTheme; + +type GetPieDonutWaffleThemeFn = ( + visParams: PartitionVisParams, + dimensions?: PieContainerDimensions, + rescaleFactor?: number +) => PartialTheme; + +type GetTreemapMosaicThemeFn = (visParams: PartitionVisParams) => PartialTheme; + +const MAX_SIZE = 1000; + +const getPieDonutWaffleCommonTheme: GetPieDonutWaffleThemeFn = ( + visParams, + dimensions, + rescaleFactor = 1 +) => { + const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); + const preventLinksFromShowing = + (visParams.labels.position === LabelPositions.INSIDE || isSplitChart) && visParams.labels.show; + + const usingOuterSizeRatio = + dimensions && !isSplitChart + ? { + outerSizeRatio: + // Cap the ratio to 1 and then rescale + rescaleFactor * Math.min(MAX_SIZE / Math.min(dimensions?.width, dimensions?.height), 1), + } + : { outerSizeRatio: undefined }; + + const theme: PartialTheme = {}; + theme.partition = { ...(usingOuterSizeRatio ?? {}) }; + + if ( + visParams.labels.show && + visParams.labels.position === LabelPositions.DEFAULT && + visParams.labels.last_level + ) { + theme.partition.linkLabel = { + maxCount: Number.POSITIVE_INFINITY, + maximumSection: Number.POSITIVE_INFINITY, + maxTextLength: visParams.labels.truncate ?? undefined, + }; + } + + if (preventLinksFromShowing || !visParams.labels.show) { + // Prevent links from showing + theme.partition.linkLabel = { + maxCount: 0, + ...(!visParams.labels.show ? { maximumSection: Number.POSITIVE_INFINITY } : {}), + }; + } + + if (!preventLinksFromShowing && dimensions && !isSplitChart) { + // shrink up to 20% to give some room for the linked values + theme.partition.outerSizeRatio = rescaleFactor; + } + + return theme; +}; + +const getDonutSpecificTheme: GetPieDonutWaffleThemeFn = (visParams, ...args) => { + const { partition, ...restTheme } = getPieDonutWaffleCommonTheme(visParams, ...args); + return { ...restTheme, partition: { ...partition, emptySizeRatio: visParams.emptySizeRatio } }; +}; + +const getTreemapMosaicCommonTheme: GetTreemapMosaicThemeFn = (visParams) => { + if (!visParams.labels.show) { + return { + partition: { + fillLabel: { textColor: 'rgba(0,0,0,0)' }, + }, + }; + } + return {}; +}; + +const getSpecificTheme: GetThemeByTypeFn = (chartType, visParams, dimensions, rescaleFactor) => + ({ + [ChartTypes.PIE]: () => getPieDonutWaffleCommonTheme(visParams, dimensions, rescaleFactor), + [ChartTypes.DONUT]: () => getDonutSpecificTheme(visParams, dimensions, rescaleFactor), + [ChartTypes.TREEMAP]: () => getTreemapMosaicCommonTheme(visParams), + [ChartTypes.MOSAIC]: () => getTreemapMosaicCommonTheme(visParams), + [ChartTypes.WAFFLE]: () => getPieDonutWaffleCommonTheme(visParams, dimensions, rescaleFactor), + }[chartType]()); + +export const getPartitionTheme: GetThemeFn = ( + chartType, + visParams, + chartTheme, + dimensions, + rescaleFactor = 1 +) => { + // On small multiples we want the labels to only appear inside + const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); + const paddingProps: PartialTheme | null = + dimensions && !isSplitChart + ? { + chartPaddings: { + top: ((1 - Math.min(1, MAX_SIZE / dimensions?.height)) / 2) * dimensions?.height, + bottom: ((1 - Math.min(1, MAX_SIZE / dimensions?.height)) / 2) * dimensions?.height, + left: ((1 - Math.min(1, MAX_SIZE / dimensions?.width)) / 2) * dimensions?.height, + right: ((1 - Math.min(1, MAX_SIZE / dimensions?.width)) / 2) * dimensions?.height, + }, + } + : null; + const partition = { + fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily, + outerSizeRatio: 1, + minFontSize: 10, + maxFontSize: 16, + emptySizeRatio: 0, + sectorLineStroke: chartTheme.lineSeriesStyle?.point?.fill, + sectorLineWidth: 1.5, + circlePadding: 4, + linkLabel: { + maxCount: 5, + fontSize: 11, + textColor: chartTheme.axes?.axisTitle?.fill, + maxTextLength: visParams.labels.truncate ?? undefined, + }, + }; + const { partition: specificPartition = {}, ...restSpecificTheme } = getSpecificTheme( + chartType, + visParams, + dimensions, + rescaleFactor + ); + + return { + partition: { ...partition, ...specificPartition }, + chartMargins: { top: 0, bottom: 0, left: 0, right: 0 }, + ...(paddingProps ?? {}), + ...restSpecificTheme, + }; +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_type.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_type.ts new file mode 100644 index 0000000000000..842c4f49b42c9 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_partition_type.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PartitionLayout } from '@elastic/charts'; +import { ChartTypes } from '../../common/types'; + +export const getPartitionType = (chartType: ChartTypes) => + ({ + [ChartTypes.PIE]: PartitionLayout.sunburst, + [ChartTypes.DONUT]: PartitionLayout.sunburst, + [ChartTypes.TREEMAP]: PartitionLayout.treemap, + [ChartTypes.MOSAIC]: PartitionLayout.mosaic, + [ChartTypes.WAFFLE]: PartitionLayout.waffle, + }[chartType]); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.test.ts new file mode 100644 index 0000000000000..5e0f58a384785 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.test.ts @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { fieldFormatsMock } from '../../../../field_formats/common/mocks'; +import { DatatableColumn } from '../../../../expressions'; +import { createMockVisData } from '../mocks'; +import { getSplitDimensionAccessor } from './get_split_dimension_accessor'; +import { BucketColumns } from '../../common/types'; +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; + +describe('getSplitDimensionAccessor', () => { + const visData = createMockVisData(); + + const preparedFormatter1 = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + const preparedFormatter2 = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + const defaultFormatter = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + + beforeEach(() => { + defaultFormatter.mockClear(); + preparedFormatter1.mockClear(); + preparedFormatter2.mockClear(); + }); + + const formatters: Record = { + [visData.columns[0].id]: preparedFormatter1(), + [visData.columns[1].id]: preparedFormatter2(), + }; + + const splitDimension: ExpressionValueVisDimension = { + type: 'vis_dimension', + accessor: { + id: visData.columns[1].id, + name: visData.columns[1].name, + meta: visData.columns[1].meta, + }, + format: { + params: {}, + }, + }; + + it('returns accessor which is using formatter from formatters, if meta.params are present at accessing column', () => { + const accessor = getSplitDimensionAccessor( + visData.columns, + splitDimension, + formatters, + defaultFormatter + ); + const formatter = formatters[visData.columns[1].id]; + const spyOnFormatterConvert = jest.spyOn(formatter, 'convert'); + + expect(defaultFormatter).toHaveBeenCalledTimes(0); + expect(typeof accessor).toBe('function'); + accessor(visData.rows[0]); + expect(spyOnFormatterConvert).toHaveBeenCalledTimes(1); + }); + + it('returns accessor which is using default formatter, if meta.params are not present and format is present at accessing column', () => { + const column: Partial = { + ...visData.columns[1], + meta: { type: 'string' }, + format: { + id: 'string', + params: {}, + }, + }; + const columns = [visData.columns[0], column, visData.columns[2]] as DatatableColumn[]; + const defaultFormatterReturnedVal = fieldFormatsMock.deserialize(); + const spyOnDefaultFormatterConvert = jest.spyOn(defaultFormatterReturnedVal, 'convert'); + + defaultFormatter.mockReturnValueOnce(defaultFormatterReturnedVal); + + const accessor = getSplitDimensionAccessor( + columns, + splitDimension, + formatters, + defaultFormatter + ); + + expect(defaultFormatter).toHaveBeenCalledTimes(1); + expect(defaultFormatter).toHaveBeenCalledWith(column.format); + + expect(typeof accessor).toBe('function'); + accessor(visData.rows[0]); + expect(spyOnDefaultFormatterConvert).toHaveBeenCalledTimes(1); + }); + + it('returns accessor which is using default formatter, if meta.params and format are not present', () => { + const column: Partial = { + ...visData.columns[1], + meta: { type: 'string' }, + }; + const columns = [visData.columns[0], column, visData.columns[2]] as DatatableColumn[]; + const defaultFormatterReturnedVal = fieldFormatsMock.deserialize(); + const spyOnDefaultFormatterConvert = jest.spyOn(defaultFormatterReturnedVal, 'convert'); + + defaultFormatter.mockReturnValueOnce(defaultFormatterReturnedVal); + + const accessor = getSplitDimensionAccessor( + columns, + splitDimension, + formatters, + defaultFormatter + ); + + expect(defaultFormatter).toHaveBeenCalledTimes(1); + expect(defaultFormatter).toHaveBeenCalledWith(); + + expect(typeof accessor).toBe('function'); + accessor(visData.rows[0]); + expect(spyOnDefaultFormatterConvert).toHaveBeenCalledTimes(1); + }); + + it('returns accessor which returns undefined, if such column is not present', () => { + const accessor1 = getSplitDimensionAccessor( + visData.columns, + splitDimension, + formatters, + defaultFormatter + ); + + expect(typeof accessor1).toBe('function'); + const result1 = accessor1({}); + expect(result1).toBeUndefined(); + + const column2: Partial = { + ...visData.columns[1], + meta: { type: 'string' }, + }; + const columns2 = [visData.columns[0], column2, visData.columns[2]] as DatatableColumn[]; + const accessor2 = getSplitDimensionAccessor( + columns2, + splitDimension, + formatters, + defaultFormatter + ); + + expect(typeof accessor2).toBe('function'); + const result2 = accessor1({}); + expect(result2).toBeUndefined(); + + const column3: Partial = { + ...visData.columns[1], + meta: { type: 'string' }, + format: { + id: 'string', + params: {}, + }, + }; + const columns3 = [visData.columns[0], column3, visData.columns[2]] as DatatableColumn[]; + + const accessor3 = getSplitDimensionAccessor( + columns3, + splitDimension, + formatters, + defaultFormatter + ); + expect(typeof accessor3).toBe('function'); + const result3 = accessor3({}); + expect(result3).toBeUndefined(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.ts new file mode 100644 index 0000000000000..1a18a1134bafe --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_split_dimension_accessor.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { AccessorFn } from '@elastic/charts'; +import { getColumnByAccessor } from './accessor'; +import { DatatableColumn } from '../../../../expressions/public'; +import { FieldFormat, FormatFactory } from '../../../../field_formats/common'; +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; +import { getFormatter } from './formatters'; + +export const getSplitDimensionAccessor = ( + columns: DatatableColumn[], + splitDimension: ExpressionValueVisDimension, + formatters: Record, + defaultFormatFactory: FormatFactory +): AccessorFn => { + const splitChartColumn = getColumnByAccessor(splitDimension.accessor, columns); + const accessor = splitChartColumn.id; + const formatter = getFormatter(splitChartColumn, formatters, defaultFormatFactory); + + const fn: AccessorFn = (d) => { + const v = d[accessor]; + if (v === undefined) { + return; + } + + const f = formatter.convert(v); + return f; + }; + + return fn; +}; diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/index.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts similarity index 78% rename from src/plugins/chart_expressions/expression_pie/public/utils/index.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts index 7fe499e7f4ab7..afa0b82a87eb1 100644 --- a/src/plugins/chart_expressions/expression_pie/public/utils/index.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export { getLayers } from './get_layers'; +export { getLayers } from './layers'; export { LegendColorPickerWrapper, LegendColorPickerWrapperContext } from './get_color_picker'; export { getLegendActions } from './get_legend_actions'; export { canFilter, getFilterClickData, getFilterEventData } from './filter_helpers'; @@ -15,3 +15,6 @@ export { getColumns } from './get_columns'; export { getSplitDimensionAccessor } from './get_split_dimension_accessor'; export { getDistinctSeries } from './get_distinct_series'; export { getColumnByAccessor } from './accessor'; +export { isLegendFlat, shouldShowLegend } from './legend'; +export { generateFormatters, getAvailableFormatter, getFormatter } from './formatters'; +export { getPartitionType } from './get_partition_type'; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts new file mode 100644 index 0000000000000..d381c9cd3f0be --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ShapeTreeNode } from '@elastic/charts'; +import { isEqual } from 'lodash'; +import type { FieldFormatsStart } from '../../../../../field_formats/public'; +import { + SeriesLayer, + PaletteRegistry, + lightenColor, + PaletteDefinition, + PaletteOutput, +} from '../../../../../charts/public'; +import type { Datatable, DatatableRow } from '../../../../../expressions/public'; +import { BucketColumns, ChartTypes, PartitionVisParams } from '../../../common/types'; +import { DistinctSeries, getDistinctSeries } from '../get_distinct_series'; + +const isTreemapOrMosaicChart = (shape: ChartTypes) => + [ChartTypes.MOSAIC, ChartTypes.TREEMAP].includes(shape); + +export const byDataColorPaletteMap = ( + rows: Datatable['rows'], + columnId: string, + paletteDefinition: PaletteDefinition, + { params }: PaletteOutput +) => { + const colorMap = new Map( + rows.map((item) => [String(item[columnId]), undefined]) + ); + let rankAtDepth = 0; + + return { + getColor: (item: unknown) => { + const key = String(item); + if (!colorMap.has(key)) return; + + let color = colorMap.get(key); + if (color) { + return color; + } + color = + paletteDefinition.getCategoricalColor( + [ + { + name: key, + totalSeriesAtDepth: colorMap.size, + rankAtDepth: rankAtDepth++, + }, + ], + { behindText: false }, + params + ) || undefined; + + colorMap.set(key, color); + return color; + }, + }; +}; + +const getDistinctColor = ( + d: ShapeTreeNode, + isSplitChart: boolean, + overwriteColors: { [key: string]: string } = {}, + visParams: PartitionVisParams, + palettes: PaletteRegistry | null, + syncColors: boolean, + { parentSeries, allSeries }: DistinctSeries, + name: string +) => { + let overwriteColor; + // this is for supporting old visualizations (created by vislib plugin) + // it seems that there for some aggs, the uiState saved from vislib is + // different than the es-charts handle it + if (overwriteColors.hasOwnProperty(name)) { + overwriteColor = overwriteColors[name]; + } + + if (Object.keys(overwriteColors).includes(d.dataName.toString())) { + overwriteColor = overwriteColors[d.dataName]; + } + + if (overwriteColor) { + return overwriteColor; + } + + const index = allSeries.findIndex((dataName) => isEqual(dataName, d.dataName)); + const isSplitParentLayer = isSplitChart && parentSeries.includes(d.dataName); + return palettes?.get(visParams.palette.name).getCategoricalColor( + [ + { + name: d.dataName, + rankAtDepth: isSplitParentLayer + ? parentSeries.findIndex((dataName) => dataName === d.dataName) + : index > -1 + ? index + : 0, + totalSeriesAtDepth: isSplitParentLayer ? parentSeries.length : allSeries.length || 1, + }, + ], + { + maxDepth: 1, + totalSeries: allSeries.length || 1, + behindText: visParams.labels.show, + syncColors, + }, + visParams.palette?.params ?? { colors: [] } + ); +}; + +const createSeriesLayers = ( + d: ShapeTreeNode, + parentSeries: DistinctSeries['parentSeries'], + isSplitChart: boolean +) => { + const seriesLayers: SeriesLayer[] = []; + let tempParent: typeof d | typeof d['parent'] = d; + while (tempParent.parent && tempParent.depth > 0) { + const seriesName = String(tempParent.parent.children[tempParent.sortIndex][0]); + const isSplitParentLayer = isSplitChart && parentSeries.includes(seriesName); + seriesLayers.unshift({ + name: seriesName, + rankAtDepth: isSplitParentLayer + ? parentSeries.findIndex((name) => name === seriesName) + : tempParent.sortIndex, + totalSeriesAtDepth: isSplitParentLayer + ? parentSeries.length + : tempParent.parent.children.length, + }); + tempParent = tempParent.parent; + } + return seriesLayers; +}; + +const overrideColorForOldVisualization = ( + seriesLayers: SeriesLayer[], + overwriteColors: { [key: string]: string }, + name: string +) => { + let overwriteColor; + // this is for supporting old visualizations (created by vislib plugin) + // it seems that there for some aggs, the uiState saved from vislib is + // different than the es-charts handle it + if (overwriteColors.hasOwnProperty(name)) { + overwriteColor = overwriteColors[name]; + } + + seriesLayers.forEach((layer) => { + if (Object.keys(overwriteColors).includes(layer.name)) { + overwriteColor = overwriteColors[layer.name]; + } + }); + + return overwriteColor; +}; + +export const getColor = ( + chartType: ChartTypes, + d: ShapeTreeNode, + layerIndex: number, + isSplitChart: boolean, + overwriteColors: { [key: string]: string } = {}, + columns: Array>, + rows: DatatableRow[], + visParams: PartitionVisParams, + palettes: PaletteRegistry | null, + byDataPalette: ReturnType, + syncColors: boolean, + isDarkMode: boolean, + formatter: FieldFormatsStart, + format?: BucketColumns['format'] +) => { + const distinctSeries = getDistinctSeries(rows, columns); + const { parentSeries } = distinctSeries; + const dataName = d.dataName; + + // Mind the difference here: the contrast computation for the text ignores the alpha/opacity + // therefore change it for dask mode + const defaultColor = isDarkMode ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)'; + + let name = ''; + if (format) { + name = formatter.deserialize(format).convert(dataName) ?? ''; + } + + if (visParams.distinctColors) { + return ( + getDistinctColor( + d, + isSplitChart, + overwriteColors, + visParams, + palettes, + syncColors, + distinctSeries, + name + ) || defaultColor + ); + } + + const seriesLayers = createSeriesLayers(d, parentSeries, isSplitChart); + + const overwriteColor = overrideColorForOldVisualization(seriesLayers, overwriteColors, name); + if (overwriteColor) { + return lightenColor(overwriteColor, seriesLayers.length, columns.length); + } + + if (chartType === ChartTypes.MOSAIC && byDataPalette && seriesLayers[1]) { + return byDataPalette.getColor(seriesLayers[1].name) || defaultColor; + } + + if (isTreemapOrMosaicChart(chartType)) { + if (layerIndex < columns.length - 1) { + return defaultColor; + } + // only use the top level series layer for coloring + if (seriesLayers.length > 1) { + seriesLayers.pop(); + } + } + + const outputColor = palettes?.get(visParams.palette.name).getCategoricalColor( + seriesLayers, + { + behindText: visParams.labels.show || isTreemapOrMosaicChart(chartType), + maxDepth: columns.length, + totalSeries: rows.length, + syncColors, + }, + visParams.palette?.params ?? { colors: [] } + ); + + return outputColor || defaultColor; +}; diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_layers.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.test.ts similarity index 84% rename from src/plugins/chart_expressions/expression_pie/public/utils/get_layers.test.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.test.ts index 39c3ccfc45a93..34daed61f67cf 100644 --- a/src/plugins/chart_expressions/expression_pie/public/utils/get_layers.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.test.ts @@ -6,11 +6,12 @@ * Side Public License, v 1. */ import { ShapeTreeNode } from '@elastic/charts'; -import { PaletteDefinition, SeriesLayer } from '../../../../charts/public'; -import { dataPluginMock } from '../../../../data/public/mocks'; -import type { DataPublicPluginStart } from '../../../../data/public'; -import { computeColor } from './get_layers'; -import { createMockVisData, createMockBucketColumns, createMockPieParams } from '../mocks'; +import { PaletteDefinition, SeriesLayer } from '../../../../../charts/public'; +import { dataPluginMock } from '../../../../../data/public/mocks'; +import type { DataPublicPluginStart } from '../../../../../data/public'; +import { getColor } from './get_color'; +import { createMockVisData, createMockBucketColumns, createMockPieParams } from '../../mocks'; +import { ChartTypes } from '../../../common/types'; const visData = createMockVisData(); const buckets = createMockBucketColumns(); @@ -68,14 +69,18 @@ describe('computeColor', () => { sortIndex: 0, }, } as unknown as ShapeTreeNode; - const color = computeColor( + const color = getColor( + ChartTypes.PIE, d, + 0, false, {}, buckets, visData.rows, visParams, getPaletteRegistry(), + { getColor: () => undefined }, + false, false, dataMock.fieldFormats ); @@ -93,14 +98,18 @@ describe('computeColor', () => { sortIndex: 0, }, } as unknown as ShapeTreeNode; - const color = computeColor( + const color = getColor( + ChartTypes.PIE, d, + 0, true, {}, buckets, visData.rows, visParams, getPaletteRegistry(), + { getColor: () => undefined }, + false, false, dataMock.fieldFormats ); @@ -117,14 +126,18 @@ describe('computeColor', () => { sortIndex: 0, }, } as unknown as ShapeTreeNode; - const color = computeColor( + const color = getColor( + ChartTypes.PIE, d, + 0, true, { 'ES-Air': '#000028' }, buckets, visData.rows, visParams, getPaletteRegistry(), + { getColor: () => undefined }, + false, false, dataMock.fieldFormats ); @@ -162,14 +175,18 @@ describe('computeColor', () => { ...visParams, distinctColors: true, }; - const color = computeColor( + const color = getColor( + ChartTypes.PIE, d, + 0, true, { '≥ 1000 and < 2000': '#3F6833' }, buckets, visData.rows, visParamsNew, getPaletteRegistry(), + { getColor: () => undefined }, + false, false, dataMock.fieldFormats, { diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts new file mode 100644 index 0000000000000..9f27ff628cf97 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datum, PartitionLayer } from '@elastic/charts'; +import { FieldFormat } from '../../../../../field_formats/common'; +import type { FieldFormatsStart } from '../../../../../field_formats/public'; +import { PaletteRegistry } from '../../../../../charts/public'; +import type { Datatable, DatatableRow } from '../../../../../expressions/public'; +import { BucketColumns, ChartTypes, PartitionVisParams } from '../../../common/types'; +import { sortPredicateByType } from './sort_predicate'; +import { byDataColorPaletteMap, getColor } from './get_color'; +import { getNodeLabel } from './get_node_labels'; + +const EMPTY_SLICE = Symbol('empty_slice'); + +export const getLayers = ( + chartType: ChartTypes, + columns: Array>, + visParams: PartitionVisParams, + visData: Datatable, + overwriteColors: { [key: string]: string } = {}, + rows: DatatableRow[], + palettes: PaletteRegistry | null, + formatters: Record, + formatter: FieldFormatsStart, + syncColors: boolean, + isDarkMode: boolean +): PartitionLayer[] => { + const fillLabel: PartitionLayer['fillLabel'] = { + valueFont: { + fontWeight: 700, + }, + }; + + if (!visParams.labels.values) { + fillLabel.valueFormatter = () => ''; + } + + const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); + let byDataPalette: ReturnType; + if (!syncColors && columns[1]?.id && palettes && visParams.palette) { + byDataPalette = byDataColorPaletteMap( + rows, + columns[1].id, + palettes?.get(visParams.palette.name), + visParams.palette + ); + } + + const sortPredicate = sortPredicateByType(chartType, visParams, visData, columns); + return columns.map((col, layerIndex) => { + return { + groupByRollup: (d: Datum) => (col.id ? d[col.id] ?? EMPTY_SLICE : col.name), + showAccessor: (d: Datum) => d !== EMPTY_SLICE, + nodeLabel: (d: unknown) => getNodeLabel(d, col, formatters, formatter.deserialize), + fillLabel, + sortPredicate, + shape: { + fillColor: (d) => + getColor( + chartType, + d, + layerIndex, + isSplitChart, + overwriteColors, + columns, + rows, + visParams, + palettes, + byDataPalette, + syncColors, + isDarkMode, + formatter, + col.format + ), + }, + }; + }); +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_node_labels.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_node_labels.ts new file mode 100644 index 0000000000000..90c271daef6a4 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_node_labels.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FieldFormat, FormatFactory } from '../../../../../field_formats/common'; +import { BucketColumns } from '../../../common/types'; +import { getAvailableFormatter } from '../formatters'; + +export const getNodeLabel = ( + nodeName: unknown, + column: Partial, + formatters: Record, + defaultFormatFactory: FormatFactory +) => { + const formatter = getAvailableFormatter(column, formatters, defaultFormatFactory); + if (formatter) { + return formatter.convert(nodeName) ?? ''; + } + + return String(nodeName); +}; diff --git a/src/plugins/chart_expressions/expression_pie/public/components/index.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/index.ts similarity index 89% rename from src/plugins/chart_expressions/expression_pie/public/components/index.ts rename to src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/index.ts index ef4589dac271e..84dad45cb3999 100644 --- a/src/plugins/chart_expressions/expression_pie/public/components/index.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export * from './pie_vis_component'; +export { getLayers } from './get_layers'; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/sort_predicate.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/sort_predicate.ts new file mode 100644 index 0000000000000..c7eaa2c494492 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/sort_predicate.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ArrayEntry } from '@elastic/charts'; +import { Datatable } from '../../../../../../../src/plugins/expressions'; +import { BucketColumns, ChartTypes, PartitionVisParams } from '../../../common/types'; + +type SortFn = (([name1, node1]: ArrayEntry, [name2, node2]: ArrayEntry) => number) | undefined; + +type SortPredicateDefaultFn = ( + visData: Datatable, + columns: Array> +) => SortFn; + +type SortPredicatePieDonutFn = (visParams: PartitionVisParams) => SortFn; + +type SortPredicatePureFn = () => SortFn; + +export const extractUniqTermsMap = (dataTable: Datatable, columnId: string) => + [...new Set(dataTable.rows.map((item) => item[columnId]))].reduce( + (acc, item, index) => ({ + ...acc, + [item]: index, + }), + {} + ); + +const sortPredicateSaveSourceOrder: SortPredicatePureFn = + () => + ([, node1], [, node2]) => { + const [index1] = node1.inputIndex ?? []; + if (index1 !== undefined) { + return index1; + } + return node2.value - node1.value; + }; + +const sortPredicatePieDonut: SortPredicatePieDonutFn = (visParams) => + visParams.respectSourceOrder ? sortPredicateSaveSourceOrder() : undefined; + +const sortPredicateMosaic: SortPredicateDefaultFn = (visData, columns) => { + const sortingMap = columns[0]?.id ? extractUniqTermsMap(visData, columns[0].id) : {}; + + return ([name1, node1], [, node2]) => { + // Sorting for first group + if (columns.length === 1 || (node1.children.length && name1 in sortingMap)) { + return sortingMap[name1]; + } + + // Sorting for second group + return node2.value - node1.value; + }; +}; + +const sortPredicateWaffle: SortPredicatePureFn = + () => + ([, node1], [, node2]) => + node2.value - node1.value; + +export const sortPredicateByType = ( + chartType: ChartTypes, + visParams: PartitionVisParams, + visData: Datatable, + columns: Array> +) => + ({ + [ChartTypes.PIE]: () => sortPredicatePieDonut(visParams), + [ChartTypes.DONUT]: () => sortPredicatePieDonut(visParams), + [ChartTypes.WAFFLE]: () => sortPredicateWaffle(), + [ChartTypes.TREEMAP]: () => undefined, + [ChartTypes.MOSAIC]: () => sortPredicateMosaic(visData, columns), + }[chartType]()); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/legend.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/legend.test.ts new file mode 100644 index 0000000000000..9d5e512de9956 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/legend.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ChartTypes, LegendDisplay } from '../../common/types'; +import { createMockVisData } from '../mocks'; +import { isLegendFlat, shouldShowLegend } from './legend'; + +describe('isLegendFlat', () => { + const visData = createMockVisData(); + const splitChartDimension = visData.columns[0]; + + const runIsFlatCommonScenario = (chartType: ChartTypes) => { + it(`legend should be flat for ${chartType} if split dimension is specified`, () => { + const flat = isLegendFlat(chartType, splitChartDimension); + expect(flat).toBeTruthy(); + }); + + it(`legend should be not flat for ${chartType} if split dimension is not specified`, () => { + const flat = isLegendFlat(chartType, undefined); + expect(flat).toBeFalsy(); + }); + }; + + runIsFlatCommonScenario(ChartTypes.PIE); + runIsFlatCommonScenario(ChartTypes.DONUT); + runIsFlatCommonScenario(ChartTypes.TREEMAP); + runIsFlatCommonScenario(ChartTypes.MOSAIC); + + it('legend should be flat for Waffle if split dimension is specified', () => { + const flat = isLegendFlat(ChartTypes.WAFFLE, splitChartDimension); + expect(flat).toBeTruthy(); + }); + + it('legend should be flat for Waffle if split dimension is not specified', () => { + const flat = isLegendFlat(ChartTypes.WAFFLE, undefined); + expect(flat).toBeTruthy(); + }); +}); + +describe('shouldShowLegend', () => { + const visData = createMockVisData(); + + const runCommonShouldShowLegendScenario = (chartType: ChartTypes) => { + it(`should hide legend if legendDisplay = hide for ${chartType}`, () => { + const show = shouldShowLegend(chartType, LegendDisplay.HIDE); + expect(show).toBeFalsy(); + }); + + it(`should show legend if legendDisplay = show for ${chartType}`, () => { + const show = shouldShowLegend(chartType, LegendDisplay.SHOW); + expect(show).toBeTruthy(); + }); + }; + + const runShouldShowLegendDefaultBucketsScenario = (chartType: ChartTypes) => { + it(`should show legend if legendDisplay = default and multiple buckets for ${chartType}`, () => { + const show = shouldShowLegend(chartType, LegendDisplay.DEFAULT, [ + visData.columns[0], + visData.columns[1], + ]); + + expect(show).toBeTruthy(); + }); + + it(`should hide legend if legendDisplay = default and one bucket or less for ${chartType}`, () => { + const show1 = shouldShowLegend(chartType, LegendDisplay.DEFAULT, [visData.columns[0]]); + expect(show1).toBeFalsy(); + + const show2 = shouldShowLegend(chartType, LegendDisplay.DEFAULT, []); + expect(show2).toBeFalsy(); + + const show3 = shouldShowLegend(chartType, LegendDisplay.DEFAULT); + expect(show3).toBeFalsy(); + }); + }; + + const runShouldShowLegendDefaultAlwaysFalsyScenario = (chartType: ChartTypes) => { + it(`should hide legend if legendDisplay = default and multiple buckets for ${chartType}`, () => { + const show = shouldShowLegend(chartType, LegendDisplay.DEFAULT, [ + visData.columns[0], + visData.columns[1], + ]); + + expect(show).toBeFalsy(); + }); + + it(`should hide legend if legendDisplay = default and one bucket or less for ${chartType}`, () => { + const show1 = shouldShowLegend(chartType, LegendDisplay.DEFAULT, [visData.columns[0]]); + expect(show1).toBeFalsy(); + + const show2 = shouldShowLegend(chartType, LegendDisplay.DEFAULT, []); + expect(show2).toBeFalsy(); + + const show3 = shouldShowLegend(chartType, LegendDisplay.DEFAULT); + expect(show3).toBeFalsy(); + }); + }; + + const runShouldShowLegendDefaultAlwaysTruthyScenario = (chartType: ChartTypes) => { + it(`should show legend if legendDisplay = default and multiple buckets for ${chartType}`, () => { + const show = shouldShowLegend(chartType, LegendDisplay.DEFAULT, [ + visData.columns[0], + visData.columns[1], + ]); + + expect(show).toBeTruthy(); + }); + + it(`should show legend if legendDisplay = default and one bucket or less for ${chartType}`, () => { + const show1 = shouldShowLegend(chartType, LegendDisplay.DEFAULT, [visData.columns[0]]); + expect(show1).toBeTruthy(); + + const show2 = shouldShowLegend(chartType, LegendDisplay.DEFAULT, []); + expect(show2).toBeTruthy(); + + const show3 = shouldShowLegend(chartType, LegendDisplay.DEFAULT); + expect(show3).toBeTruthy(); + }); + }; + + runCommonShouldShowLegendScenario(ChartTypes.PIE); + runShouldShowLegendDefaultBucketsScenario(ChartTypes.PIE); + + runCommonShouldShowLegendScenario(ChartTypes.DONUT); + runShouldShowLegendDefaultBucketsScenario(ChartTypes.DONUT); + + runCommonShouldShowLegendScenario(ChartTypes.TREEMAP); + runShouldShowLegendDefaultAlwaysFalsyScenario(ChartTypes.TREEMAP); + + runCommonShouldShowLegendScenario(ChartTypes.MOSAIC); + runShouldShowLegendDefaultAlwaysFalsyScenario(ChartTypes.MOSAIC); + + runCommonShouldShowLegendScenario(ChartTypes.WAFFLE); + runShouldShowLegendDefaultAlwaysTruthyScenario(ChartTypes.WAFFLE); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/legend.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/legend.ts new file mode 100644 index 0000000000000..9990c1a8e65ea --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/legend.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DatatableColumn } from '../../../../expressions'; +import { BucketColumns, ChartTypes, LegendDisplay } from '../../common/types'; + +type GetLegendIsFlatFn = (splitChartDimension: DatatableColumn | undefined) => boolean; + +const isLegendFlatCommon: GetLegendIsFlatFn = (splitChartDimension) => Boolean(splitChartDimension); + +export const isLegendFlat = ( + visType: ChartTypes, + splitChartDimension: DatatableColumn | undefined +) => + ({ + [ChartTypes.PIE]: () => isLegendFlatCommon(splitChartDimension), + [ChartTypes.DONUT]: () => isLegendFlatCommon(splitChartDimension), + [ChartTypes.TREEMAP]: () => isLegendFlatCommon(splitChartDimension), + [ChartTypes.MOSAIC]: () => isLegendFlatCommon(splitChartDimension), + [ChartTypes.WAFFLE]: () => true, + }[visType]()); + +const showIfBuckets = (bucketColumns: Array>) => bucketColumns.length > 1; + +const showLegendDefault = (visType: ChartTypes, bucketColumns: Array>) => + ({ + [ChartTypes.PIE]: () => showIfBuckets(bucketColumns), + [ChartTypes.DONUT]: () => showIfBuckets(bucketColumns), + [ChartTypes.TREEMAP]: () => false, + [ChartTypes.MOSAIC]: () => false, + [ChartTypes.WAFFLE]: () => true, + }[visType]()); + +export const shouldShowLegend = ( + visType: ChartTypes, + legendDisplay: LegendDisplay, + bucketColumns: Array> = [] +) => + legendDisplay === LegendDisplay.SHOW || + (legendDisplay === LegendDisplay.DEFAULT && showLegendDefault(visType, bucketColumns)); diff --git a/src/plugins/chart_expressions/expression_pie/public/index.ts b/src/plugins/chart_expressions/expression_partition_vis/server/index.ts similarity index 65% rename from src/plugins/chart_expressions/expression_pie/public/index.ts rename to src/plugins/chart_expressions/expression_partition_vis/server/index.ts index 8123bc03c8e1b..98395d521e238 100755 --- a/src/plugins/chart_expressions/expression_pie/public/index.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/server/index.ts @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import { ExpressionPiePlugin } from './plugin'; +import { ExpressionPartitionVisPlugin } from './plugin'; export function plugin() { - return new ExpressionPiePlugin(); + return new ExpressionPartitionVisPlugin(); } -export type { ExpressionPiePluginSetup, ExpressionPiePluginStart } from './types'; +export type { ExpressionPartitionVisPluginSetup, ExpressionPartitionVisPluginStart } from './types'; diff --git a/src/plugins/chart_expressions/expression_partition_vis/server/plugin.ts b/src/plugins/chart_expressions/expression_partition_vis/server/plugin.ts new file mode 100755 index 0000000000000..190e43e4c7dfa --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/server/plugin.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { CoreSetup, CoreStart, Plugin } from '../../../../core/server'; +import { + partitionLabelsFunction, + pieVisFunction, + treemapVisFunction, + mosaicVisFunction, + waffleVisFunction, +} from '../common'; +import { + ExpressionPartitionVisPluginSetup, + ExpressionPartitionVisPluginStart, + SetupDeps, + StartDeps, +} from './types'; + +export class ExpressionPartitionVisPlugin + implements + Plugin< + ExpressionPartitionVisPluginSetup, + ExpressionPartitionVisPluginStart, + SetupDeps, + StartDeps + > +{ + public setup(core: CoreSetup, { expressions }: SetupDeps) { + expressions.registerFunction(partitionLabelsFunction); + expressions.registerFunction(pieVisFunction); + expressions.registerFunction(treemapVisFunction); + expressions.registerFunction(mosaicVisFunction); + expressions.registerFunction(waffleVisFunction); + } + + public start(core: CoreStart, deps: StartDeps) {} + + public stop() {} +} diff --git a/src/plugins/chart_expressions/expression_pie/server/types.ts b/src/plugins/chart_expressions/expression_partition_vis/server/types.ts similarity index 84% rename from src/plugins/chart_expressions/expression_pie/server/types.ts rename to src/plugins/chart_expressions/expression_partition_vis/server/types.ts index ff3e00bdf6dab..0fdca6e6b319e 100755 --- a/src/plugins/chart_expressions/expression_pie/server/types.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/server/types.ts @@ -7,8 +7,8 @@ */ import { ExpressionsServerStart, ExpressionsServerSetup } from '../../../expressions/server'; -export type ExpressionPiePluginSetup = void; -export type ExpressionPiePluginStart = void; +export type ExpressionPartitionVisPluginSetup = void; +export type ExpressionPartitionVisPluginStart = void; export interface SetupDeps { expressions: ExpressionsServerSetup; diff --git a/src/plugins/chart_expressions/expression_pie/tsconfig.json b/src/plugins/chart_expressions/expression_partition_vis/tsconfig.json similarity index 100% rename from src/plugins/chart_expressions/expression_pie/tsconfig.json rename to src/plugins/chart_expressions/expression_partition_vis/tsconfig.json diff --git a/src/plugins/chart_expressions/expression_pie/README.md b/src/plugins/chart_expressions/expression_pie/README.md deleted file mode 100755 index 95f4298aa293d..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# expressionPie - -Expression Pie plugin adds a `pie` renderer and function to the expression plugin. The renderer will display the `Pie` chart. - ---- - -## Development - -See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/src/plugins/chart_expressions/expression_pie/common/constants.ts b/src/plugins/chart_expressions/expression_pie/common/constants.ts deleted file mode 100644 index c666692c3ea7f..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/common/constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export const PLUGIN_ID = 'expressionPie'; -export const PLUGIN_NAME = 'expressionPie'; - -export const PIE_VIS_EXPRESSION_NAME = 'pie_vis'; -export const PIE_LABELS_VALUE = 'pie_labels_value'; -export const PIE_LABELS_FUNCTION = 'pie_labels'; - -export const DEFAULT_PERCENT_DECIMALS = 2; diff --git a/src/plugins/chart_expressions/expression_pie/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_pie/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap deleted file mode 100644 index e4d994c058f0e..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap +++ /dev/null @@ -1,98 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`interpreter/functions#pie logs correct datatable to inspector 1`] = ` -Object { - "columns": Array [ - Object { - "id": "col-0-1", - "meta": Object { - "dimensionName": "Slice size", - }, - "name": "Count", - }, - ], - "rows": Array [ - Object { - "col-0-1": 0, - }, - ], - "type": "datatable", -} -`; - -exports[`interpreter/functions#pie returns an object with the correct structure 1`] = ` -Object { - "as": "pie_vis", - "type": "render", - "value": Object { - "params": Object { - "listenOnChange": true, - }, - "syncColors": false, - "visConfig": Object { - "addLegend": true, - "addTooltip": true, - "buckets": undefined, - "dimensions": Object { - "buckets": undefined, - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, - }, - "type": "vis_dimension", - }, - "splitColumn": undefined, - "splitRow": undefined, - }, - "distinctColors": false, - "emptySizeRatio": 0.3, - "isDonut": true, - "labels": Object { - "last_level": false, - "percentDecimals": 2, - "position": "default", - "show": false, - "truncate": 100, - "type": "pie_labels_value", - "values": true, - "valuesFormat": "percent", - }, - "legendPosition": "right", - "maxLegendLines": 2, - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, - }, - "type": "vis_dimension", - }, - "nestedLegend": true, - "palette": Object { - "name": "kibana_palette", - "type": "system_palette", - }, - "splitColumn": undefined, - "splitRow": undefined, - "truncateLegend": true, - }, - "visData": Object { - "columns": Array [ - Object { - "id": "col-0-1", - "name": "Count", - }, - ], - "rows": Array [ - Object { - "col-0-1": 0, - }, - ], - "type": "datatable", - }, - "visType": "pie", - }, -} -`; diff --git a/src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_labels_function.ts b/src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_labels_function.ts deleted file mode 100644 index 6bdb4f6b0408d..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_labels_function.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { ExpressionFunctionDefinition, Datatable } from '../../../../expressions/common'; -import { PIE_LABELS_FUNCTION, PIE_LABELS_VALUE } from '../constants'; -import { ExpressionValuePieLabels, PieLabelsArguments } from '../types/expression_functions'; - -export const pieLabelsFunction = (): ExpressionFunctionDefinition< - typeof PIE_LABELS_FUNCTION, - Datatable | null, - PieLabelsArguments, - ExpressionValuePieLabels -> => ({ - name: PIE_LABELS_FUNCTION, - help: i18n.translate('expressionPie.pieLabels.function.help', { - defaultMessage: 'Generates the pie labels object', - }), - type: PIE_LABELS_VALUE, - args: { - show: { - types: ['boolean'], - help: i18n.translate('expressionPie.pieLabels.function.args.show.help', { - defaultMessage: 'Displays the pie labels', - }), - default: true, - }, - position: { - types: ['string'], - default: 'default', - help: i18n.translate('expressionPie.pieLabels.function.args.position.help', { - defaultMessage: 'Defines the label position', - }), - }, - values: { - types: ['boolean'], - help: i18n.translate('expressionPie.pieLabels.function.args.values.help', { - defaultMessage: 'Displays the values inside the slices', - }), - default: true, - }, - percentDecimals: { - types: ['number'], - help: i18n.translate('expressionPie.pieLabels.function.args.percentDecimals.help', { - defaultMessage: 'Defines the number of decimals that will appear on the values as percent', - }), - default: 2, - }, - lastLevel: { - types: ['boolean'], - help: i18n.translate('expressionPie.pieLabels.function.args.lastLevel.help', { - defaultMessage: 'Show top level labels only', - }), - default: true, - }, - truncate: { - types: ['number'], - help: i18n.translate('expressionPie.pieLabels.function.args.truncate.help', { - defaultMessage: 'Defines the number of characters that the slice value will display', - }), - default: null, - }, - valuesFormat: { - types: ['string'], - default: 'percent', - help: i18n.translate('expressionPie.pieLabels.function.args.valuesFormat.help', { - defaultMessage: 'Defines the format of the values', - }), - }, - }, - fn: (context, args) => { - return { - type: PIE_LABELS_VALUE, - show: args.show, - position: args.position, - percentDecimals: args.percentDecimals, - values: args.values, - truncate: args.truncate, - valuesFormat: args.valuesFormat, - last_level: args.lastLevel, - }; - }, -}); diff --git a/src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_vis_function.ts b/src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_vis_function.ts deleted file mode 100644 index 1e5507c818449..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/common/expression_functions/pie_vis_function.ts +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { EmptySizeRatios, PieVisParams } from '../types/expression_renderers'; -import { prepareLogTable } from '../../../../visualizations/common/prepare_log_table'; -import { PieVisExpressionFunctionDefinition } from '../types/expression_functions'; -import { PIE_LABELS_FUNCTION, PIE_LABELS_VALUE, PIE_VIS_EXPRESSION_NAME } from '../constants'; - -export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ - name: PIE_VIS_EXPRESSION_NAME, - type: 'render', - inputTypes: ['datatable'], - help: i18n.translate('expressionPie.pieVis.function.help', { - defaultMessage: 'Pie visualization', - }), - args: { - metric: { - types: ['vis_dimension'], - help: i18n.translate('expressionPie.pieVis.function.args.metricHelpText', { - defaultMessage: 'Metric dimensions config', - }), - required: true, - }, - buckets: { - types: ['vis_dimension'], - help: i18n.translate('expressionPie.pieVis.function.args.bucketsHelpText', { - defaultMessage: 'Buckets dimensions config', - }), - multi: true, - }, - splitColumn: { - types: ['vis_dimension'], - help: i18n.translate('expressionPie.pieVis.function.args.splitColumnHelpText', { - defaultMessage: 'Split by column dimension config', - }), - multi: true, - }, - splitRow: { - types: ['vis_dimension'], - help: i18n.translate('expressionPie.pieVis.function.args.splitRowHelpText', { - defaultMessage: 'Split by row dimension config', - }), - multi: true, - }, - addTooltip: { - types: ['boolean'], - help: i18n.translate('expressionPie.pieVis.function.args.addTooltipHelpText', { - defaultMessage: 'Show tooltip on slice hover', - }), - default: true, - }, - addLegend: { - types: ['boolean'], - help: i18n.translate('expressionPie.pieVis.function.args.addLegendHelpText', { - defaultMessage: 'Show legend chart legend', - }), - }, - legendPosition: { - types: ['string'], - help: i18n.translate('expressionPie.pieVis.function.args.legendPositionHelpText', { - defaultMessage: 'Position the legend on top, bottom, left, right of the chart', - }), - }, - nestedLegend: { - types: ['boolean'], - help: i18n.translate('expressionPie.pieVis.function.args.nestedLegendHelpText', { - defaultMessage: 'Show a more detailed legend', - }), - default: false, - }, - truncateLegend: { - types: ['boolean'], - help: i18n.translate('expressionPie.pieVis.function.args.truncateLegendHelpText', { - defaultMessage: 'Defines if the legend items will be truncated or not', - }), - default: true, - }, - maxLegendLines: { - types: ['number'], - help: i18n.translate('expressionPie.pieVis.function.args.maxLegendLinesHelpText', { - defaultMessage: 'Defines the number of lines per legend item', - }), - }, - distinctColors: { - types: ['boolean'], - help: i18n.translate('expressionPie.pieVis.function.args.distinctColorsHelpText', { - defaultMessage: - 'Maps different color per slice. Slices with the same value have the same color', - }), - default: false, - }, - isDonut: { - types: ['boolean'], - help: i18n.translate('expressionPie.pieVis.function.args.isDonutHelpText', { - defaultMessage: 'Displays the pie chart as donut', - }), - default: false, - }, - emptySizeRatio: { - types: ['number'], - help: i18n.translate('expressionPie.pieVis.function.args.emptySizeRatioHelpText', { - defaultMessage: 'Defines donut inner empty area size', - }), - default: EmptySizeRatios.SMALL, - }, - palette: { - types: ['palette', 'system_palette'], - help: i18n.translate('expressionPie.pieVis.function.args.paletteHelpText', { - defaultMessage: 'Defines the chart palette name', - }), - default: '{palette}', - }, - labels: { - types: [PIE_LABELS_VALUE], - help: i18n.translate('expressionPie.pieVis.function.args.labelsHelpText', { - defaultMessage: 'Pie labels config', - }), - default: `{${PIE_LABELS_FUNCTION}}`, - }, - }, - fn(context, args, handlers) { - const visConfig: PieVisParams = { - ...args, - palette: args.palette, - dimensions: { - metric: args.metric, - buckets: args.buckets, - splitColumn: args.splitColumn, - splitRow: args.splitRow, - }, - }; - - if (handlers?.inspectorAdapters?.tables) { - const logTable = prepareLogTable(context, [ - [ - [args.metric], - i18n.translate('expressionPie.pieVis.function.dimension.metric', { - defaultMessage: 'Slice size', - }), - ], - [ - args.buckets, - i18n.translate('expressionPie.pieVis.function.dimension.buckets', { - defaultMessage: 'Slice', - }), - ], - [ - args.splitColumn, - i18n.translate('expressionPie.pieVis.function.dimension.splitcolumn', { - defaultMessage: 'Column split', - }), - ], - [ - args.splitRow, - i18n.translate('expressionPie.pieVis.function.dimension.splitrow', { - defaultMessage: 'Row split', - }), - ], - ]); - handlers.inspectorAdapters.tables.logDatatable('default', logTable); - } - - return { - type: 'render', - as: PIE_VIS_EXPRESSION_NAME, - value: { - visData: context, - visConfig, - syncColors: handlers?.isSyncColorsEnabled?.() ?? false, - visType: 'pie', - params: { - listenOnChange: true, - }, - }, - }; - }, -}); diff --git a/src/plugins/chart_expressions/expression_pie/common/index.ts b/src/plugins/chart_expressions/expression_pie/common/index.ts deleted file mode 100755 index c5943c54c0c65..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/common/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { - PLUGIN_ID, - PLUGIN_NAME, - PIE_VIS_EXPRESSION_NAME, - PIE_LABELS_VALUE, - PIE_LABELS_FUNCTION, -} from './constants'; - -export { pieVisFunction, pieLabelsFunction } from './expression_functions'; - -export type { - ExpressionValuePieLabels, - PieVisExpressionFunctionDefinition, -} from './types/expression_functions'; - -export type { - PieVisParams, - PieVisConfig, - LabelsParams, - Dimension, - Dimensions, -} from './types/expression_renderers'; - -export { ValueFormats, LabelPositions, EmptySizeRatios } from './types/expression_renderers'; diff --git a/src/plugins/chart_expressions/expression_pie/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_pie/common/types/expression_functions.ts deleted file mode 100644 index 39d5392c65ed5..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/common/types/expression_functions.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { PIE_LABELS_VALUE, PIE_VIS_EXPRESSION_NAME } from '../constants'; -import { - ExpressionFunctionDefinition, - Datatable, - ExpressionValueRender, - ExpressionValueBoxed, -} from '../../../../expressions/common'; -import { RenderValue, PieVisConfig, LabelPositions, ValueFormats } from './expression_renderers'; - -export interface PieLabelsArguments { - show: boolean; - position: LabelPositions; - values: boolean; - truncate: number | null; - valuesFormat: ValueFormats; - lastLevel: boolean; - percentDecimals: number; -} - -export type ExpressionValuePieLabels = ExpressionValueBoxed< - typeof PIE_LABELS_VALUE, - { - show: boolean; - position: LabelPositions; - values: boolean; - truncate: number | null; - valuesFormat: ValueFormats; - last_level: boolean; - percentDecimals: number; - } ->; - -export type PieVisExpressionFunctionDefinition = ExpressionFunctionDefinition< - typeof PIE_VIS_EXPRESSION_NAME, - Datatable, - PieVisConfig, - ExpressionValueRender ->; diff --git a/src/plugins/chart_expressions/expression_pie/public/__stories__/pie_renderer.stories.tsx b/src/plugins/chart_expressions/expression_pie/public/__stories__/pie_renderer.stories.tsx deleted file mode 100644 index 8afca1f9912f7..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/public/__stories__/pie_renderer.stories.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { storiesOf } from '@storybook/react'; -import { Datatable } from '../../../../expressions'; -import { Render } from '../../../../presentation_util/public/__stories__'; -import { getPieVisRenderer } from '../expression_renderers'; -import { LabelPositions, RenderValue, ValueFormats } from '../../common/types'; -import { palettes, theme, getStartDeps } from '../__mocks__'; - -const visData: Datatable = { - type: 'datatable', - columns: [ - { id: 'cost', name: 'cost', meta: { type: 'number' } }, - { id: 'age', name: 'age', meta: { type: 'number' } }, - { id: 'price', name: 'price', meta: { type: 'number' } }, - { id: 'project', name: 'project', meta: { type: 'string' } }, - { id: '@timestamp', name: '@timestamp', meta: { type: 'date' } }, - ], - rows: [ - { - cost: 32.15, - age: 63, - price: 53, - project: 'elasticsearch', - '@timestamp': 1546334211208, - }, - { - cost: 20.52, - age: 68, - price: 33, - project: 'beats', - '@timestamp': 1546351551031, - }, - { - cost: 21.15, - age: 57, - price: 59, - project: 'apm', - '@timestamp': 1546352631083, - }, - { - cost: 35.64, - age: 73, - price: 71, - project: 'machine-learning', - '@timestamp': 1546402490956, - }, - { - cost: 27.19, - age: 38, - price: 36, - project: 'kibana', - '@timestamp': 1546467111351, - }, - ], -}; - -const config: RenderValue = { - visType: 'pie_vis', - visData, - visConfig: { - dimensions: { - metric: { - type: 'vis_dimension', - accessor: { id: 'cost', name: 'cost', meta: { type: 'number' } }, - format: { id: 'number', params: {} }, - }, - buckets: [ - { - type: 'vis_dimension', - accessor: { id: 'age', name: 'age', meta: { type: 'number' } }, - format: { id: 'number', params: {} }, - }, - ], - }, - palette: { type: 'system_palette', name: 'default' }, - addTooltip: false, - addLegend: false, - legendPosition: 'right', - nestedLegend: false, - truncateLegend: false, - distinctColors: false, - isDonut: false, - emptySizeRatio: 0.37, - maxLegendLines: 1, - labels: { - show: false, - last_level: false, - position: LabelPositions.DEFAULT, - values: false, - truncate: null, - valuesFormat: ValueFormats.VALUE, - percentDecimals: 1, - }, - }, - syncColors: false, -}; - -const containerSize = { - width: '700px', - height: '700px', -}; - -const pieRenderer = getPieVisRenderer({ palettes, theme, getStartDeps }); - -storiesOf('renderers/pieVis', module).add('Default', () => { - return pieRenderer} config={config} {...containerSize} />; -}); diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_layers.ts b/src/plugins/chart_expressions/expression_pie/public/utils/get_layers.ts deleted file mode 100644 index 9268f5631e735..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/public/utils/get_layers.ts +++ /dev/null @@ -1,201 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { Datum, PartitionLayer, ShapeTreeNode, ArrayEntry } from '@elastic/charts'; -import { isEqual } from 'lodash'; -import type { FieldFormatsStart } from 'src/plugins/field_formats/public'; -import { SeriesLayer, PaletteRegistry, lightenColor } from '../../../../charts/public'; -import type { DatatableRow } from '../../../../expressions/public'; -import type { BucketColumns, PieVisParams, SplitDimensionParams } from '../../common/types'; -import { getDistinctSeries } from './get_distinct_series'; - -const EMPTY_SLICE = Symbol('empty_slice'); - -export const computeColor = ( - d: ShapeTreeNode, - isSplitChart: boolean, - overwriteColors: { [key: string]: string } = {}, - columns: Array>, - rows: DatatableRow[], - visParams: PieVisParams, - palettes: PaletteRegistry | null, - syncColors: boolean, - formatter: FieldFormatsStart, - format?: BucketColumns['format'] -) => { - const { parentSeries, allSeries } = getDistinctSeries(rows, columns); - const dataName = d.dataName; - - let formattedLabel = ''; - if (format) { - formattedLabel = formatter.deserialize(format).convert(dataName) ?? ''; - } - - if (visParams.distinctColors) { - let overwriteColor; - // this is for supporting old visualizations (created by vislib plugin) - // it seems that there for some aggs, the uiState saved from vislib is - // different than the es-charts handle it - if (overwriteColors.hasOwnProperty(formattedLabel)) { - overwriteColor = overwriteColors[formattedLabel]; - } - - if (Object.keys(overwriteColors).includes(dataName.toString())) { - overwriteColor = overwriteColors[dataName]; - } - - if (overwriteColor) { - return overwriteColor; - } - - const index = allSeries.findIndex((name) => isEqual(name, dataName)); - const isSplitParentLayer = isSplitChart && parentSeries.includes(dataName); - return palettes?.get(visParams.palette.name).getCategoricalColor( - [ - { - name: dataName, - rankAtDepth: isSplitParentLayer - ? parentSeries.findIndex((name) => name === dataName) - : index > -1 - ? index - : 0, - totalSeriesAtDepth: isSplitParentLayer ? parentSeries.length : allSeries.length || 1, - }, - ], - { - maxDepth: 1, - totalSeries: allSeries.length || 1, - behindText: visParams.labels.show, - syncColors, - }, - visParams.palette?.params ?? { colors: [] } - ); - } - const seriesLayers: SeriesLayer[] = []; - let tempParent: typeof d | typeof d['parent'] = d; - while (tempParent.parent && tempParent.depth > 0) { - const seriesName = String(tempParent.parent.children[tempParent.sortIndex][0]); - const isSplitParentLayer = isSplitChart && parentSeries.includes(seriesName); - seriesLayers.unshift({ - name: seriesName, - rankAtDepth: isSplitParentLayer - ? parentSeries.findIndex((name) => name === seriesName) - : tempParent.sortIndex, - totalSeriesAtDepth: isSplitParentLayer - ? parentSeries.length - : tempParent.parent.children.length, - }); - tempParent = tempParent.parent; - } - - let overwriteColor; - // this is for supporting old visualizations (created by vislib plugin) - // it seems that there for some aggs, the uiState saved from vislib is - // different than the es-charts handle it - if (overwriteColors.hasOwnProperty(formattedLabel)) { - overwriteColor = overwriteColors[formattedLabel]; - } - - seriesLayers.forEach((layer) => { - if (Object.keys(overwriteColors).includes(layer.name)) { - overwriteColor = overwriteColors[layer.name]; - } - }); - - if (overwriteColor) { - return lightenColor(overwriteColor, seriesLayers.length, columns.length); - } - return palettes?.get(visParams.palette.name).getCategoricalColor( - seriesLayers, - { - behindText: visParams.labels.show, - maxDepth: columns.length, - totalSeries: rows.length, - syncColors, - }, - visParams.palette?.params ?? { colors: [] } - ); -}; - -export const getLayers = ( - columns: Array>, - visParams: PieVisParams, - overwriteColors: { [key: string]: string } = {}, - rows: DatatableRow[], - palettes: PaletteRegistry | null, - formatter: FieldFormatsStart, - syncColors: boolean -): PartitionLayer[] => { - const fillLabel: PartitionLayer['fillLabel'] = { - valueFont: { - fontWeight: 700, - }, - }; - - if (!visParams.labels.values) { - fillLabel.valueFormatter = () => ''; - } - const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); - return columns.map((col) => { - return { - groupByRollup: (d: Datum) => { - return col.id ? d[col.id] : col.name; - }, - showAccessor: (d: Datum) => d !== EMPTY_SLICE, - nodeLabel: (d: unknown) => { - if (col.format) { - return formatter.deserialize(col.format).convert(d) ?? ''; - } - return String(d); - }, - sortPredicate: ([name1, node1]: ArrayEntry, [name2, node2]: ArrayEntry) => { - const params = col.meta?.sourceParams?.params as SplitDimensionParams | undefined; - const sort: string | undefined = params?.orderBy; - // unconditionally put "Other" to the end (as the "Other" slice may be larger than a regular slice, yet should be at the end) - if (name1 === '__other__' && name2 !== '__other__') return 1; - if (name2 === '__other__' && name1 !== '__other__') return -1; - // metric sorting - if (sort && sort !== '_key') { - if (params?.order === 'desc') { - return node2.value - node1.value; - } else { - return node1.value - node2.value; - } - // alphabetical sorting - } else { - if (name1 > name2) { - return params?.order === 'desc' ? -1 : 1; - } - if (name2 > name1) { - return params?.order === 'desc' ? 1 : -1; - } - } - return 0; - }, - fillLabel, - shape: { - fillColor: (d) => { - const outputColor = computeColor( - d, - isSplitChart, - overwriteColors, - columns, - rows, - visParams, - palettes, - syncColors, - formatter, - col.format - ); - - return outputColor || 'rgba(0,0,0,0)'; - }, - }, - }; - }); -}; diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_partition_theme.test.ts b/src/plugins/chart_expressions/expression_pie/public/utils/get_partition_theme.test.ts deleted file mode 100644 index 1cccdf8a5e47b..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/public/utils/get_partition_theme.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getPartitionTheme } from './get_partition_theme'; -import { createMockPieParams } from '../mocks'; - -const visParams = createMockPieParams(); - -describe('getConfig', () => { - it('should cap the outerSizeRatio to 1', () => { - expect( - getPartitionTheme(visParams, {}, { width: 400, height: 400 }).partition?.outerSizeRatio - ).toBe(1); - }); - - it('should not have outerSizeRatio for split chart', () => { - expect( - getPartitionTheme( - { - ...visParams, - dimensions: { - ...visParams.dimensions, - splitColumn: [ - { - type: 'vis_dimension', - accessor: 1, - format: { - id: 'number', - params: {}, - }, - }, - ], - }, - }, - {}, - { width: 400, height: 400 } - ).partition?.outerSizeRatio - ).toBeUndefined(); - - expect( - getPartitionTheme( - { - ...visParams, - dimensions: { - ...visParams.dimensions, - splitRow: [ - { - type: 'vis_dimension', - accessor: 1, - format: { - id: 'number', - params: {}, - }, - }, - ], - }, - }, - {}, - { width: 400, height: 400 } - ).partition?.outerSizeRatio - ).toBeUndefined(); - }); - - it('should not set outerSizeRatio if dimensions are not defined', () => { - expect(getPartitionTheme(visParams, {}).partition?.outerSizeRatio).toBeUndefined(); - }); -}); diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_partition_theme.ts b/src/plugins/chart_expressions/expression_pie/public/utils/get_partition_theme.ts deleted file mode 100644 index 4daaf835fa782..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/public/utils/get_partition_theme.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { PartialTheme } from '@elastic/charts'; -import { Required } from '@kbn/utility-types'; -import { LabelPositions, PieVisParams, PieContainerDimensions } from '../../common/types'; - -const MAX_SIZE = 1000; - -export const getPartitionTheme = ( - visParams: PieVisParams, - chartTheme: PartialTheme, - dimensions?: PieContainerDimensions, - rescaleFactor: number = 1 -): PartialTheme => { - // On small multiples we want the labels to only appear inside - const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); - const paddingProps: PartialTheme | null = - dimensions && !isSplitChart - ? { - chartPaddings: { - // TODO: simplify ratio logic to be static px units - top: ((1 - Math.min(1, MAX_SIZE / dimensions?.height)) / 2) * dimensions?.height, - bottom: ((1 - Math.min(1, MAX_SIZE / dimensions?.height)) / 2) * dimensions?.height, - left: ((1 - Math.min(1, MAX_SIZE / dimensions?.width)) / 2) * dimensions?.height, - right: ((1 - Math.min(1, MAX_SIZE / dimensions?.width)) / 2) * dimensions?.height, - }, - } - : null; - - const outerSizeRatio: PartialTheme['partition'] | null = - dimensions && !isSplitChart - ? { - outerSizeRatio: - // Cap the ratio to 1 and then rescale - rescaleFactor * Math.min(MAX_SIZE / Math.min(dimensions?.width, dimensions?.height), 1), - } - : null; - const theme: Required = { - chartMargins: { top: 0, bottom: 0, left: 0, right: 0 }, - ...paddingProps, - partition: { - fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily, - ...outerSizeRatio, - minFontSize: 10, - maxFontSize: 16, - linkLabel: { - maxCount: 5, - fontSize: 11, - textColor: chartTheme.axes?.axisTitle?.fill, - maxTextLength: visParams.labels.truncate ?? undefined, - }, - sectorLineStroke: chartTheme.lineSeriesStyle?.point?.fill, - sectorLineWidth: 1.5, - circlePadding: 4, - emptySizeRatio: visParams.isDonut ? visParams.emptySizeRatio : 0, - }, - }; - if (!visParams.labels.show) { - // Force all labels to be linked, then prevent links from showing - theme.partition.linkLabel = { maxCount: 0, maximumSection: Number.POSITIVE_INFINITY }; - } - - if (visParams.labels.last_level && visParams.labels.show) { - theme.partition.linkLabel = { - maxCount: Number.POSITIVE_INFINITY, - maximumSection: Number.POSITIVE_INFINITY, - maxTextLength: visParams.labels.truncate ?? undefined, - }; - } - - if ( - (visParams.labels.position === LabelPositions.INSIDE || isSplitChart) && - visParams.labels.show - ) { - theme.partition.linkLabel = { maxCount: 0 }; - } - - return theme; -}; diff --git a/src/plugins/chart_expressions/expression_pie/public/utils/get_split_dimension_accessor.ts b/src/plugins/chart_expressions/expression_pie/public/utils/get_split_dimension_accessor.ts deleted file mode 100644 index ebc1979908459..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/public/utils/get_split_dimension_accessor.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ -import { AccessorFn } from '@elastic/charts'; -import { getColumnByAccessor } from './accessor'; -import { DatatableColumn } from '../../../../expressions/public'; -import type { FieldFormatsStart } from '../../../../field_formats/public'; -import { ExpressionValueVisDimension } from '../../../../visualizations/common'; - -export const getSplitDimensionAccessor = - (fieldFormats: FieldFormatsStart, columns: DatatableColumn[]) => - (splitDimension: ExpressionValueVisDimension): AccessorFn => { - const formatter = fieldFormats.deserialize(splitDimension.format); - const splitChartColumn = getColumnByAccessor(splitDimension.accessor, columns); - const accessor = splitChartColumn.id; - - const fn: AccessorFn = (d) => { - const v = d[accessor]; - if (v === undefined) { - return; - } - const f = formatter.convert(v); - return f; - }; - - return fn; - }; diff --git a/src/plugins/chart_expressions/expression_pie/server/plugin.ts b/src/plugins/chart_expressions/expression_pie/server/plugin.ts deleted file mode 100755 index f46983f0f8825..0000000000000 --- a/src/plugins/chart_expressions/expression_pie/server/plugin.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ -import { CoreSetup, CoreStart, Plugin } from '../../../../core/server'; -import { pieLabelsFunction, pieVisFunction } from '../common'; -import { ExpressionPiePluginSetup, ExpressionPiePluginStart, SetupDeps, StartDeps } from './types'; - -export class ExpressionPiePlugin - implements Plugin -{ - public setup(core: CoreSetup, { expressions }: SetupDeps) { - expressions.registerFunction(pieLabelsFunction); - expressions.registerFunction(pieVisFunction); - } - - public start(core: CoreStart, deps: StartDeps) {} - - public stop() {} -} diff --git a/src/plugins/vis_types/pie/kibana.json b/src/plugins/vis_types/pie/kibana.json index fb310d8afd82d..abed576cc6732 100644 --- a/src/plugins/vis_types/pie/kibana.json +++ b/src/plugins/vis_types/pie/kibana.json @@ -3,7 +3,7 @@ "version": "kibana", "ui": true, "server": true, - "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection", "expressionPie"], + "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection", "expressionPartitionVis"], "requiredBundles": ["visDefaultEditor"], "extraPublicDirs": ["common/index"], "owner": { diff --git a/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap index 2edf2fff72a38..904dff6ee1192 100644 --- a/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap @@ -34,9 +34,6 @@ Object { }, Object { "arguments": Object { - "addLegend": Array [ - true, - ], "addTooltip": Array [ true, ], @@ -70,7 +67,7 @@ Object { "chain": Array [ Object { "arguments": Object { - "lastLevel": Array [ + "last_level": Array [ true, ], "show": Array [ @@ -83,13 +80,16 @@ Object { true, ], }, - "function": "pie_labels", + "function": "partitionLabels", "type": "function", }, ], "type": "expression", }, ], + "legendDisplay": Array [ + "show", + ], "legendPosition": Array [ "right", ], @@ -112,6 +112,9 @@ Object { "type": "expression", }, ], + "nestedLegend": Array [ + false, + ], "palette": Array [ Object { "chain": Array [ @@ -124,8 +127,11 @@ Object { "type": "expression", }, ], + "startFromSecondLargestSlice": Array [ + false, + ], }, - "function": "pie_vis", + "function": "pieVis", "type": "function", }, ], diff --git a/src/plugins/vis_types/pie/public/editor/collections.ts b/src/plugins/vis_types/pie/public/editor/collections.ts index 16e6bd9372897..dd9d3fb3737b0 100644 --- a/src/plugins/vis_types/pie/public/editor/collections.ts +++ b/src/plugins/vis_types/pie/public/editor/collections.ts @@ -11,7 +11,7 @@ import { LabelPositions, ValueFormats, EmptySizeRatios, -} from '../../../../chart_expressions/expression_pie/common'; +} from '../../../../chart_expressions/expression_partition_vis/common'; export const getLabelPositions = [ { diff --git a/src/plugins/vis_types/pie/public/editor/components/index.tsx b/src/plugins/vis_types/pie/public/editor/components/index.tsx index c61e2724a466c..591eaba64ecc5 100644 --- a/src/plugins/vis_types/pie/public/editor/components/index.tsx +++ b/src/plugins/vis_types/pie/public/editor/components/index.tsx @@ -9,13 +9,13 @@ import React, { lazy } from 'react'; import { VisEditorOptionsProps } from '../../../../../visualizations/public'; import { PieTypeProps } from '../../types'; -import { PieVisParams } from '../../../../../chart_expressions/expression_pie/common'; +import { PartitionVisParams } from '../../../../../chart_expressions/expression_partition_vis/common'; const PieOptionsLazy = lazy(() => import('./pie')); export const getPieOptions = ({ showElasticChartsOptions, palettes, trackUiMetric }: PieTypeProps) => - (props: VisEditorOptionsProps) => + (props: VisEditorOptionsProps) => ( , PieTypeProps {} +export interface PieOptionsProps extends VisEditorOptionsProps, PieTypeProps {} const emptySizeRatioLabel = i18n.translate('visTypePie.editors.pie.emptySizeRatioLabel', { defaultMessage: 'Inner area size', @@ -82,19 +83,24 @@ function DecimalSlider({ const PieOptions = (props: PieOptionsProps) => { const { stateParams, setValue, aggs } = props; - const setLabels = ( + const setLabels = ( paramName: T, - value: PieVisParams['labels'][T] + value: PartitionVisParams['labels'][T] ) => setValue('labels', { ...stateParams.labels, [paramName]: value }); const legendUiStateValue = props.uiState?.get('vis.legendOpen'); const [palettesRegistry, setPalettesRegistry] = useState(undefined); const [legendVisibility, setLegendVisibility] = useState(() => { - const bwcLegendStateDefault = stateParams.addLegend == null ? false : stateParams.addLegend; - return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) as boolean; + const bwcLegendStateDefault = stateParams.legendDisplay === LegendDisplay.SHOW; + return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault); }); const hasSplitChart = Boolean(aggs?.aggs?.find((agg) => agg.schema === 'split' && agg.enabled)); const segments = aggs?.aggs?.filter((agg) => agg.schema === 'segment' && agg.enabled) ?? []; + const getLegendDisplay = useCallback( + (isVisible: boolean) => (isVisible ? LegendDisplay.SHOW : LegendDisplay.HIDE), + [] + ); + useEffect(() => { setLegendVisibility(legendUiStateValue); }, [legendUiStateValue]); @@ -115,6 +121,21 @@ const PieOptions = (props: PieOptionsProps) => { [setValue] ); + const handleLegendDisplayChange = useCallback( + (name: keyof PartitionVisParams, show: boolean) => { + setLegendVisibility(show); + + const legendDisplay = getLegendDisplay(show); + if (legendDisplay === stateParams[name]) { + setValue(name, getLegendDisplay(!show)); + } + setValue(name, legendDisplay); + + props.uiState?.set('vis.legendOpen', show); + }, + [getLegendDisplay, props.uiState, setValue, stateParams] + ); + return ( <> @@ -180,15 +201,12 @@ const PieOptions = (props: PieOptionsProps) => { { - setLegendVisibility(value); - setValue(paramName, value); - }} + setValue={handleLegendDisplayChange} data-test-subj="visTypePieAddLegendSwitch" /> { })} paramName="nestedLegend" value={stateParams.nestedLegend} - disabled={!stateParams.addLegend} + disabled={stateParams.legendDisplay === LegendDisplay.HIDE} setValue={(paramName, value) => { if (props.trackUiMetric) { props.trackUiMetric(METRIC_TYPE.CLICK, 'nested_legend_switched'); diff --git a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts index 56f0620787886..53140c822bd9b 100644 --- a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { LegendDisplay } from '../../../chart_expressions/expression_partition_vis/common'; + export const samplePieVis = { type: { name: 'pie', @@ -24,7 +26,7 @@ export const samplePieVis = { defaults: { type: 'pie', addTooltip: true, - addLegend: true, + legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', isDonut: true, nestedLegend: true, @@ -138,7 +140,7 @@ export const samplePieVis = { params: { type: 'pie', addTooltip: true, - addLegend: true, + legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', isDonut: true, labels: { diff --git a/src/plugins/vis_types/pie/public/to_ast.test.ts b/src/plugins/vis_types/pie/public/to_ast.test.ts index 87e279e787b32..62421d30c2ac0 100644 --- a/src/plugins/vis_types/pie/public/to_ast.test.ts +++ b/src/plugins/vis_types/pie/public/to_ast.test.ts @@ -8,12 +8,12 @@ import { Vis } from '../../../visualizations/public'; -import { PieVisParams } from '../../../chart_expressions/expression_pie/common'; +import { PartitionVisParams } from '../../../chart_expressions/expression_partition_vis/common'; import { samplePieVis } from './sample_vis.test.mocks'; import { toExpressionAst } from './to_ast'; describe('vis type pie vis toExpressionAst function', () => { - let vis: Vis; + let vis: Vis; const params = { timefilter: {}, timeRange: {}, diff --git a/src/plugins/vis_types/pie/public/to_ast.ts b/src/plugins/vis_types/pie/public/to_ast.ts index 09e00918d47d5..3879980bbf85c 100644 --- a/src/plugins/vis_types/pie/public/to_ast.ts +++ b/src/plugins/vis_types/pie/public/to_ast.ts @@ -11,11 +11,11 @@ import { buildExpression, buildExpressionFunction } from '../../../expressions/p import { PaletteOutput } from '../../../charts/common'; import { PIE_VIS_EXPRESSION_NAME, - PIE_LABELS_FUNCTION, + PARTITION_LABELS_FUNCTION, PieVisExpressionFunctionDefinition, - PieVisParams, + PartitionVisParams, LabelsParams, -} from '../../../chart_expressions/expression_pie/common'; +} from '../../../chart_expressions/expression_partition_vis/common'; import { getEsaggsFn } from './to_ast_esaggs'; const prepareDimension = (params: SchemaConfig) => { @@ -37,9 +37,9 @@ const preparePalette = (palette?: PaletteOutput) => { }; const prepareLabels = (params: LabelsParams) => { - const pieLabels = buildExpressionFunction(PIE_LABELS_FUNCTION, { + const pieLabels = buildExpressionFunction(PARTITION_LABELS_FUNCTION, { show: params.show, - lastLevel: params.last_level, + last_level: params.last_level, values: params.values, truncate: params.truncate, }); @@ -55,18 +55,18 @@ const prepareLabels = (params: LabelsParams) => { return buildExpression([pieLabels]); }; -export const toExpressionAst: VisToExpressionAst = async (vis, params) => { +export const toExpressionAst: VisToExpressionAst = async (vis, params) => { const schemas = getVisSchemas(vis, params); const args = { // explicitly pass each param to prevent extra values trapping addTooltip: vis.params.addTooltip, - addLegend: vis.params.addLegend, + legendDisplay: vis.params.legendDisplay, legendPosition: vis.params.legendPosition, - nestedLegend: vis.params?.nestedLegend, + nestedLegend: vis.params?.nestedLegend ?? false, truncateLegend: vis.params.truncateLegend, maxLegendLines: vis.params.maxLegendLines, distinctColors: vis.params?.distinctColors, - isDonut: vis.params.isDonut, + isDonut: vis.params.isDonut ?? false, emptySizeRatio: vis.params.emptySizeRatio, palette: preparePalette(vis.params?.palette), labels: prepareLabels(vis.params.labels), @@ -74,6 +74,7 @@ export const toExpressionAst: VisToExpressionAst = async (vis, par buckets: schemas.segment?.map(prepareDimension), splitColumn: schemas.split_column?.map(prepareDimension), splitRow: schemas.split_row?.map(prepareDimension), + startFromSecondLargestSlice: false, }; const visTypePie = buildExpressionFunction( diff --git a/src/plugins/vis_types/pie/public/to_ast_esaggs.ts b/src/plugins/vis_types/pie/public/to_ast_esaggs.ts index 41eddedd6fa2c..3f41a59e3aa8e 100644 --- a/src/plugins/vis_types/pie/public/to_ast_esaggs.ts +++ b/src/plugins/vis_types/pie/public/to_ast_esaggs.ts @@ -12,13 +12,13 @@ import { EsaggsExpressionFunctionDefinition, IndexPatternLoadExpressionFunctionDefinition, } from '../../../data/public'; -import { PieVisParams } from '../../../chart_expressions/expression_pie/common'; +import { PartitionVisParams } from '../../../chart_expressions/expression_partition_vis/common'; /** * Get esaggs expressions function * @param vis */ -export function getEsaggsFn(vis: Vis) { +export function getEsaggsFn(vis: Vis) { return buildExpressionFunction('esaggs', { index: buildExpression([ buildExpressionFunction('indexPatternLoad', { diff --git a/src/plugins/vis_types/pie/public/vis_type/pie.ts b/src/plugins/vis_types/pie/public/vis_type/pie.ts index 827b25d541c9e..c9cc573e27781 100644 --- a/src/plugins/vis_types/pie/public/vis_type/pie.ts +++ b/src/plugins/vis_types/pie/public/vis_type/pie.ts @@ -13,11 +13,12 @@ import { VIS_EVENT_TO_TRIGGER, VisTypeDefinition } from '../../../../visualizati import { DEFAULT_PERCENT_DECIMALS } from '../../common'; import { PieTypeProps } from '../types'; import { - PieVisParams, + PartitionVisParams, LabelPositions, ValueFormats, EmptySizeRatios, -} from '../../../../chart_expressions/expression_pie/common'; + LegendDisplay, +} from '../../../../chart_expressions/expression_partition_vis/common'; import { toExpressionAst } from '../to_ast'; import { getPieOptions } from '../editor/components'; @@ -25,7 +26,7 @@ export const getPieVisTypeDefinition = ({ showElasticChartsOptions = false, palettes, trackUiMetric, -}: PieTypeProps): VisTypeDefinition => ({ +}: PieTypeProps): VisTypeDefinition => ({ name: 'pie', title: i18n.translate('visTypePie.pie.pieTitle', { defaultMessage: 'Pie' }), icon: 'visPie', @@ -38,7 +39,7 @@ export const getPieVisTypeDefinition = ({ defaults: { type: 'pie', addTooltip: true, - addLegend: !showElasticChartsOptions, + legendDisplay: !showElasticChartsOptions ? LegendDisplay.SHOW : LegendDisplay.HIDE, legendPosition: Position.Right, nestedLegend: false, truncateLegend: true, diff --git a/src/plugins/vis_types/pie/tsconfig.json b/src/plugins/vis_types/pie/tsconfig.json index 9ad4e8efe907b..ed052af072f2a 100644 --- a/src/plugins/vis_types/pie/tsconfig.json +++ b/src/plugins/vis_types/pie/tsconfig.json @@ -21,6 +21,6 @@ { "path": "../../usage_collection/tsconfig.json" }, { "path": "../../vis_default_editor/tsconfig.json" }, { "path": "../../field_formats/tsconfig.json" }, - { "path": "../../chart_expressions/expression_pie/tsconfig.json" } + { "path": "../../chart_expressions/expression_partition_vis/tsconfig.json" } ] } \ No newline at end of file diff --git a/src/plugins/vis_types/vislib/public/__snapshots__/pie_fn.test.ts.snap b/src/plugins/vis_types/vislib/public/__snapshots__/pie_fn.test.ts.snap index b64366c1ce0f3..5c6bc7e8dc5d5 100644 --- a/src/plugins/vis_types/vislib/public/__snapshots__/pie_fn.test.ts.snap +++ b/src/plugins/vis_types/vislib/public/__snapshots__/pie_fn.test.ts.snap @@ -6,7 +6,6 @@ Object { "type": "render", "value": Object { "visConfig": Object { - "addLegend": true, "addTooltip": true, "dimensions": Object { "metric": Object { @@ -25,6 +24,7 @@ Object { "truncate": 100, "values": true, }, + "legendDisplay": "show", "legendPosition": "right", "type": "pie", }, diff --git a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap index b8dc4b31747c4..1eedae99ffedb 100644 --- a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap +++ b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap @@ -5,7 +5,7 @@ Object { "addArgument": [Function], "arguments": Object { "visConfig": Array [ - "{\\"type\\":\\"pie\\",\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"right\\",\\"isDonut\\":true,\\"labels\\":{\\"show\\":true,\\"values\\":true,\\"last_level\\":true,\\"truncate\\":100},\\"dimensions\\":{\\"metric\\":{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{}},\\"buckets\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", + "{\\"type\\":\\"pie\\",\\"addTooltip\\":true,\\"legendDisplay\\":\\"show\\",\\"legendPosition\\":\\"right\\",\\"isDonut\\":true,\\"labels\\":{\\"show\\":true,\\"values\\":true,\\"last_level\\":true,\\"truncate\\":100},\\"dimensions\\":{\\"metric\\":{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{}},\\"buckets\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", ], }, "getArgument": [Function], diff --git a/src/plugins/vis_types/vislib/public/pie.ts b/src/plugins/vis_types/vislib/public/pie.ts index 45794776bc998..66d4c326fa47b 100644 --- a/src/plugins/vis_types/vislib/public/pie.ts +++ b/src/plugins/vis_types/vislib/public/pie.ts @@ -11,7 +11,13 @@ import { VisTypeDefinition } from '../../../visualizations/public'; import { CommonVislibParams } from './types'; import { toExpressionAst } from './to_ast_pie'; -export interface PieVisParams extends CommonVislibParams { +export enum LegendDisplay { + SHOW = 'show', + HIDE = 'hide', + DEFAULT = 'default', +} + +export type PieVisParams = Omit & { type: 'pie'; isDonut: boolean; labels: { @@ -20,7 +26,8 @@ export interface PieVisParams extends CommonVislibParams { last_level: boolean; truncate: number | null; }; -} + legendDisplay: LegendDisplay; +}; export const pieVisTypeDefinition = { ...pieVisType({}), diff --git a/src/plugins/vis_types/vislib/public/pie_fn.test.ts b/src/plugins/vis_types/vislib/public/pie_fn.test.ts index 9c317f9e72dc1..42061397d0ac7 100644 --- a/src/plugins/vis_types/vislib/public/pie_fn.test.ts +++ b/src/plugins/vis_types/vislib/public/pie_fn.test.ts @@ -39,7 +39,7 @@ describe('interpreter/functions#pie', () => { const visConfig = { type: 'pie', addTooltip: true, - addLegend: true, + legendDisplay: 'show', legendPosition: 'right', isDonut: true, labels: { diff --git a/src/plugins/vis_types/vislib/public/vis_controller.tsx b/src/plugins/vis_types/vislib/public/vis_controller.tsx index 1e940d23e83da..94a7e819f16f0 100644 --- a/src/plugins/vis_types/vislib/public/vis_controller.tsx +++ b/src/plugins/vis_types/vislib/public/vis_controller.tsx @@ -17,7 +17,7 @@ import { IInterpreterRenderHandlers } from '../../../expressions/public'; import { VisTypeVislibCoreSetup } from './plugin'; import { VisLegend, CUSTOM_LEGEND_VIS_TYPES } from './vislib/components/legend'; import { BasicVislibParams } from './types'; -import { PieVisParams } from './pie'; +import { LegendDisplay, PieVisParams } from './pie'; const legendClassName = { top: 'vislib--legend-top', @@ -94,7 +94,7 @@ export const createVislibVisController = ( this.vislibVis.initVisConfig(esResponse, uiState); - if (visParams.addLegend) { + if (this.showLegend(visParams)) { $(this.container) .attr('class', (i, cls) => { return cls.replace(/vislib--legend-\S+/g, ''); @@ -110,7 +110,7 @@ export const createVislibVisController = ( // this is necessary because some visualizations // provide data necessary for the legend only after a render cycle. if ( - visParams.addLegend && + this.showLegend(visParams) && CUSTOM_LEGEND_VIS_TYPES.includes(this.vislibVis.visConfigArgs.type) ) { this.unmountLegend?.(); @@ -121,10 +121,11 @@ export const createVislibVisController = ( mountLegend( visData: unknown, - { legendPosition, addLegend }: BasicVislibParams | PieVisParams, + visParams: BasicVislibParams | PieVisParams, fireEvent: IInterpreterRenderHandlers['event'], uiState?: PersistedState ) { + const { legendPosition } = visParams; this.unmountLegend = mountReactNode( )(this.legendEl); @@ -151,5 +152,16 @@ export const createVislibVisController = ( delete this.vislibVis; } } + + showLegend(visParams: BasicVislibParams | PieVisParams) { + if (this.arePieVisParams(visParams)) { + return visParams.legendDisplay === LegendDisplay.SHOW; + } + return visParams.addLegend ?? false; + } + + arePieVisParams(visParams: BasicVislibParams | PieVisParams): visParams is PieVisParams { + return Object.values(LegendDisplay).includes((visParams as PieVisParams).legendDisplay); + } }; }; diff --git a/src/plugins/vis_types/vislib/public/vislib/visualizations/pie_chart.test.js b/src/plugins/vis_types/vislib/public/vislib/visualizations/pie_chart.test.js index ba11df923b8a6..6705875ce1405 100644 --- a/src/plugins/vis_types/vislib/public/vislib/visualizations/pie_chart.test.js +++ b/src/plugins/vis_types/vislib/public/vislib/visualizations/pie_chart.test.js @@ -32,7 +32,7 @@ describe('No global chart settings', function () { const vislibParams1 = { el: '
', type: 'pie', - addLegend: true, + legendDisplay: 'show', addTooltip: true, }; let chart1; @@ -144,7 +144,7 @@ describe('Vislib PieChart Class Test Suite', function () { const vislibParams = { type: 'pie', - addLegend: true, + legendDisplay: 'show', addTooltip: true, }; let vis; diff --git a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts index fa41cf6354d65..4b72cc0320c14 100644 --- a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts +++ b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts @@ -25,6 +25,7 @@ import { commonAddDropLastBucketIntoTSVBModel, commonAddDropLastBucketIntoTSVBModel714Above, commonRemoveMarkdownLessFromTSVB, + commonUpdatePieVisApi, } from '../migrations/visualization_common_migrations'; import { SerializedVis } from '../../common'; @@ -91,6 +92,11 @@ const byValueRemoveMarkdownLessFromTSVB = (state: SerializableRecord) => { }; }; +const byValueUpdatePieVisApi = (state: SerializableRecord) => ({ + ...state, + savedVis: commonUpdatePieVisApi(state.savedVis), +}); + const getEmbeddedVisualizationSearchSourceMigrations = ( searchSourceMigrations: MigrateFunctionsObject ) => @@ -137,6 +143,7 @@ export const makeVisualizeEmbeddableFactory = )(state), '7.17.0': (state) => flow(byValueAddDropLastBucketIntoTSVBModel714Above)(state), '8.0.0': (state) => flow(byValueRemoveMarkdownLessFromTSVB)(state), + '8.1.0': (state) => flow(byValueUpdatePieVisApi)(state), } ), }; diff --git a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts index 65e61c4cd81aa..aec452e356abe 100644 --- a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts @@ -199,3 +199,19 @@ export const commonRemoveMarkdownLessFromTSVB = (visState: any) => { return visState; }; + +export const commonUpdatePieVisApi = (visState: any) => { + if (visState && visState.type === 'pie') { + const { addLegend, ...restParams } = visState.params; + + return { + ...visState, + params: { + ...restParams, + legendDisplay: addLegend ? 'show' : 'hide', + }, + }; + } + + return visState; +}; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index 093990eab8584..083e1ae51575b 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -2469,4 +2469,74 @@ describe('migration visualization', () => { }, }); }); + + describe('8.1.0 pie - labels and addLegend migration', () => { + const getDoc = (addLegend: boolean, lastLevel: boolean = false) => ({ + attributes: { + title: 'Pie Vis', + description: 'Pie vis', + visState: JSON.stringify({ + type: 'pie', + title: 'Pie vis', + params: { + addLegend, + addTooltip: true, + isDonut: true, + labels: { + position: 'default', + show: true, + truncate: 100, + values: true, + valuesFormat: 'percent', + percentDecimals: 2, + last_level: lastLevel, + }, + legendPosition: 'right', + nestedLegend: false, + maxLegendLines: 1, + truncateLegend: true, + distinctColors: false, + palette: { + name: 'default', + type: 'palette', + }, + dimensions: { + metric: { + type: 'vis_dimension', + accessor: 1, + format: { + id: 'number', + params: { + id: 'number', + }, + }, + }, + buckets: [], + }, + }, + }), + }, + }); + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['8.1.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + + it('should migrate addLegend to legendDisplay', () => { + const pie = getDoc(true); + const migrated = migrate(pie); + const params = JSON.parse(migrated.attributes.visState).params; + + expect(params.legendDisplay).toBe('show'); + expect(params.addLegend).toBeUndefined(); + + const otherPie = getDoc(false); + const otherMigrated = migrate(otherPie); + const otherParams = JSON.parse(otherMigrated.attributes.visState).params; + + expect(otherParams.legendDisplay).toBe('hide'); + expect(otherParams.addLegend).toBeUndefined(); + }); + }); }); diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index d168e82f69739..4855b2589bed3 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -26,6 +26,7 @@ import { commonAddDropLastBucketIntoTSVBModel, commonAddDropLastBucketIntoTSVBModel714Above, commonRemoveMarkdownLessFromTSVB, + commonUpdatePieVisApi, } from './visualization_common_migrations'; import { VisualizationSavedObjectAttributes } from '../../common'; @@ -1132,6 +1133,30 @@ export const removeMarkdownLessFromTSVB: SavedObjectMigrationFn = (doc return doc; }; +export const updatePieVisApi: SavedObjectMigrationFn = (doc) => { + const visStateJSON = get(doc, 'attributes.visState'); + let visState; + + if (visStateJSON) { + try { + visState = JSON.parse(visStateJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + + const newVisState = commonUpdatePieVisApi(visState); + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; + } + + return doc; +}; + const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -1187,6 +1212,7 @@ const visualizationSavedObjectTypeMigrations = { ), '7.17.0': flow(addDropLastBucketIntoTSVBModel714Above), '8.0.0': flow(removeMarkdownLessFromTSVB), + '8.1.0': flow(updatePieVisApi), }; /** diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2dd515f42ff33..43db19704dcf5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2956,6 +2956,35 @@ "expressionTagcloud.functions.tagcloudHelpText": "Tagcloudのビジュアライゼーションです。", "expressionTagcloud.renderer.tagcloud.displayName": "Tag Cloudのビジュアライゼーションです", "expressionTagcloud.renderer.tagcloud.helpDescription": "Tag Cloudを表示", + "expressionPartitionVis.reusable.function.dimension.buckets": "スライス", + "expressionPartitionVis.reusable.function.args.legendDisplayHelpText": "グラフ凡例を表示", + "expressionPartitionVis.reusable.function.args.addTooltipHelpText": "スライスにカーソルを置いたときにツールチップを表示", + "expressionPartitionVis.reusable.function.args.bucketsHelpText": "バケットディメンション構成", + "expressionPartitionVis.pieVis.function.args.distinctColorsHelpText": "スライスごとに異なる色をマッピングします。同じ値のスライスは同じ色になります", + "expressionPartitionVis.reusable.function.args.isDonutHelpText": "円グラフをドーナツグラフとして表示します", + "expressionPartitionVis.reusable.function.args.labelsHelpText": "円グラフラベル構成", + "expressionPartitionVis.reusable.function.args.legendPositionHelpText": "グラフの上、下、左、右に凡例を配置", + "expressionPartitionVis.reusable.function.args.maxLegendLinesHelpText": "凡例項目ごとの行数を定義します", + "expressionPartitionVis.reusable.function.args.metricHelpText": "メトリックディメンション構成", + "expressionPartitionVis.reusable.function.args.nestedLegendHelpText": "詳細凡例を表示", + "expressionPartitionVis.reusable.function.args.paletteHelpText": "グラフパレット名を定義します", + "expressionPartitionVis.reusable.function.args.splitColumnHelpText": "列ディメンション構成で分割", + "expressionPartitionVis.reusable.function.args.splitRowHelpText": "行ディメンション構成で分割", + "expressionPartitionVis.reusable.function.args.truncateLegendHelpText": "凡例項目が切り捨てられるかどうかを定義します", + "expressionPartitionVis.reusable.function.dimension.metric": "スライスサイズ", + "expressionPartitionVis.reusable.function.dimension.splitcolumn": "列分割", + "expressionPartitionVis.reusable.function.dimension.splitrow": "行分割", + "expressionPartitionVis.partitionLabels.function.help": "円グラフラベルオブジェクトを生成します", + "expressionPartitionVis.partitionLabels.function.args.percentDecimals.help": "割合として値に表示される10進数を定義します", + "expressionPartitionVis.partitionLabels.function.args.position.help": "ラベル位置を定義します", + "expressionPartitionVis.partitionLabels.function.args.values.help": "スライス内の値を定義します", + "expressionPartitionVis.partitionLabels.function.args.valuesFormat.help": "値の形式を定義します", + "expressionPartitionVis.pieVis.function.help": "パイビジュアライゼーション", + "expressionPartitionVis.legend.filterForValueButtonAriaLabel": "値でフィルター", + "expressionPartitionVis.legend.filterOptionsLegend": "{legendDataLabel}、フィルターオプション", + "expressionPartitionVis.legend.filterOutValueButtonAriaLabel": "値を除外", + "expressionPartitionVis.negativeValuesFound": "円/ドーナツグラフは負の値では表示できません。", + "expressionPartitionVis.noResultsFoundTitle": "結果が見つかりませんでした", "fieldFormats.advancedSettings.format.bytesFormat.numeralFormatLinkText": "数字フォーマット", "fieldFormats.advancedSettings.format.bytesFormatText": "「バイト」フォーマットのデフォルト{numeralFormatLink}です", "fieldFormats.advancedSettings.format.bytesFormatTitle": "バイトフォーマット", @@ -5175,7 +5204,6 @@ "visTypePie.advancedSettings.visualization.legacyPieChartsLibrary.name": "円グラフのレガシーグラフライブラリ", "visTypePie.controls.truncateLabel": "切り捨て", "visTypePie.controls.truncateTooltip": "グラフ外に配置されたラベルの文字数。", - "visTypePie.editors.pie.addLegendLabel": "凡例を表示", "visTypePie.editors.pie.decimalSliderLabel": "割合の最大小数点桁数", "visTypePie.editors.pie.distinctColorsLabel": "スライスごとに異なる色を使用", "visTypePie.editors.pie.donutLabel": "ドーナッツ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7fa49ec247162..d299a58c3bdbe 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -923,255 +923,8 @@ "xpack.lens.xyVisualization.stackedPercentageBarLabel": "垂直百分比条形图", "xpack.lens.xyVisualization.xyLabel": "XY", "advancedSettings.advancedSettingsLabel": "高级设置", - "advancedSettings.badge.readOnly.text": "只读", - "advancedSettings.badge.readOnly.tooltip": "无法保存高级设置", - "advancedSettings.callOutCautionDescription": "此处请谨慎操作,这些设置仅供高级用户使用。您在这里所做的更改可能使 Kibana 的大部分功能出现问题。这些设置有一部分可能未在文档中说明、不受支持或是实验性设置。如果字段有默认值,将字段留空会将其设置为默认值,其他配置指令可能不接受其默认值。删除定制设置会将其从 Kibana 的配置中永久删除。", - "advancedSettings.callOutCautionTitle": "注意:在这里您可能会使问题出现", - "advancedSettings.categoryNames.dashboardLabel": "仪表板", - "advancedSettings.categoryNames.discoverLabel": "Discover", - "advancedSettings.categoryNames.generalLabel": "常规", - "advancedSettings.categoryNames.machineLearningLabel": "Machine Learning", - "advancedSettings.categoryNames.notificationsLabel": "通知", - "advancedSettings.categoryNames.observabilityLabel": "Observability", - "advancedSettings.categoryNames.reportingLabel": "报告", - "advancedSettings.categoryNames.searchLabel": "搜索", - "advancedSettings.categoryNames.securitySolutionLabel": "安全解决方案", - "advancedSettings.categoryNames.timelionLabel": "Timelion", - "advancedSettings.categoryNames.visualizationsLabel": "可视化", - "advancedSettings.categorySearchLabel": "类别", - "advancedSettings.featureCatalogueTitle": "定制您的 Kibana 体验 — 更改日期格式、打开深色模式,等等。", - "advancedSettings.field.changeImageLinkAriaLabel": "更改 {ariaName}", - "advancedSettings.field.changeImageLinkText": "更改图片", - "advancedSettings.field.codeEditorSyntaxErrorMessage": "JSON 语法无效", - "advancedSettings.field.customSettingAriaLabel": "定制设置", - "advancedSettings.field.customSettingTooltip": "定制设置", - "advancedSettings.field.defaultValueText": "默认值:{value}", - "advancedSettings.field.defaultValueTypeJsonText": "默认值:{value}", - "advancedSettings.field.deprecationClickAreaLabel": "单击以查看 {settingName} 的过时文档。", - "advancedSettings.field.helpText": "此设置已由 Kibana 服务器覆盖,无法更改。", - "advancedSettings.field.imageChangeErrorMessage": "图片无法保存", - "advancedSettings.field.invalidIconLabel": "无效", - "advancedSettings.field.offLabel": "关闭", - "advancedSettings.field.onLabel": "开启", - "advancedSettings.field.resetToDefaultLinkAriaLabel": "将 {ariaName} 重置为默认值", - "advancedSettings.field.resetToDefaultLinkText": "重置为默认值", - "advancedSettings.field.settingIsUnsaved": "设备当前未保存。", - "advancedSettings.field.unsavedIconLabel": "未保存", - "advancedSettings.form.cancelButtonLabel": "取消更改", - "advancedSettings.form.clearNoSearchResultText": "(清除搜索)", - "advancedSettings.form.clearSearchResultText": "(清除搜索)", - "advancedSettings.form.countOfSettingsChanged": "{unsavedCount} 个未保存{unsavedCount, plural, other {设置} }{hiddenCount, plural, =0 {} other {,# 个已隐藏} }", - "advancedSettings.form.noSearchResultText": "找不到 {queryText} {clearSearch} 的设置", - "advancedSettings.form.requiresPageReloadToastButtonLabel": "重新加载页面", - "advancedSettings.form.requiresPageReloadToastDescription": "一个或多个设置需要您重新加载页面才能生效。", - "advancedSettings.form.saveButtonLabel": "保存更改", - "advancedSettings.form.saveButtonTooltipWithInvalidChanges": "保存前请修复无效的设置。", - "advancedSettings.form.saveErrorMessage": "无法保存", - "advancedSettings.form.searchResultText": "搜索词隐藏了 {settingsCount} 个设置{clearSearch}", - "advancedSettings.pageTitle": "设置", - "advancedSettings.searchBar.unableToParseQueryErrorMessage": "无法解析查询", - "advancedSettings.searchBarAriaLabel": "搜索高级设置", - "advancedSettings.voiceAnnouncement.ariaLabel": "“高级设置”的结果信息", - "advancedSettings.voiceAnnouncement.noSearchResultScreenReaderMessage": "{sectionLenght, plural, other {# 个部分}}中有 {optionLenght, plural, other {# 个选项}}", - "advancedSettings.voiceAnnouncement.searchResultScreenReaderMessage": "您搜索了“{query}”。{sectionLenght, plural, other {# 个部分}}中有 {optionLenght, plural, other {# 个选项}}", - "alerts.documentationTitle": "查看文档", - "alerts.noPermissionsMessage": "要查看告警,必须对 Kibana 工作区中的告警功能有权限。有关详细信息,请联系您的 Kibana 管理员。", - "alerts.noPermissionsTitle": "需要 Kibana 功能权限", - "autocomplete.fieldRequiredError": "值不能为空", - "autocomplete.invalidDateError": "不是有效日期", - "autocomplete.invalidNumberError": "不是有效数字", - "autocomplete.loadingDescription": "正在加载……", - "autocomplete.selectField": "请首先选择字段......", - "bfetch.disableBfetchCompression": "禁用批量压缩", - "bfetch.disableBfetchCompressionDesc": "禁用批量压缩。这允许您对单个请求进行故障排查,但会增加响应大小。", - "charts.advancedSettings.visualization.colorMappingText": "使用兼容性调色板将值映射到图表中的特定颜色。", - "charts.advancedSettings.visualization.colorMappingTextDeprecation": "此设置已过时,在未来版本中将不受支持。", - "charts.advancedSettings.visualization.colorMappingTitle": "颜色映射", "charts.advancedSettings.visualization.useLegacyTimeAxis.description": "在 Lens、Discover、Visualize 和 TSVB 中为图表启用旧版时间轴", "charts.advancedSettings.visualization.useLegacyTimeAxis.name": "旧版图表时间轴", - "charts.colormaps.bluesText": "蓝色", - "charts.colormaps.greensText": "绿色", - "charts.colormaps.greenToRedText": "绿到红", - "charts.colormaps.greysText": "灰色", - "charts.colormaps.redsText": "红色", - "charts.colormaps.yellowToRedText": "黄到红", - "charts.colorPicker.clearColor": "重置颜色", - "charts.colorPicker.setColor.screenReaderDescription": "为值 {legendDataLabel} 设置颜色", - "charts.countText": "计数", - "charts.functions.palette.args.colorHelpText": "调色板颜色。接受 {html} 颜色名称 {hex}、{hsl}、{hsla}、{rgb} 或 {rgba}。", - "charts.functions.palette.args.gradientHelpText": "受支持时提供渐变的调色板?", - "charts.functions.palette.args.reverseHelpText": "反转调色板?", - "charts.functions.palette.args.stopHelpText": "调色板颜色停止。使用时,必须与每个颜色关联。", - "charts.functions.paletteHelpText": "创建颜色调色板。", - "charts.functions.systemPalette.args.nameHelpText": "调色板列表中的调色板名称", - "charts.functions.systemPaletteHelpText": "创建动态颜色调色板。", - "charts.legend.toggleLegendButtonAriaLabel": "切换图例", - "charts.legend.toggleLegendButtonTitle": "切换图例", - "charts.palettes.complimentaryLabel": "免费", - "charts.palettes.coolLabel": "冷", - "charts.palettes.customLabel": "定制", - "charts.palettes.defaultPaletteLabel": "默认", - "charts.palettes.grayLabel": "灰", - "charts.palettes.kibanaPaletteLabel": "兼容性", - "charts.palettes.negativeLabel": "负", - "charts.palettes.positiveLabel": "正", - "charts.palettes.statusLabel": "状态", - "charts.palettes.temperatureLabel": "温度", - "charts.palettes.warmLabel": "暖", - "charts.partialData.bucketTooltipText": "选定的时间范围不包括此整个存储桶。其可能包含部分数据。", - "console.autocomplete.addMethodMetaText": "方法", - "console.consoleDisplayName": "控制台", - "console.consoleMenu.copyAsCurlFailedMessage": "无法将请求复制为 cURL", - "console.consoleMenu.copyAsCurlMessage": "请求已复制为 cURL", - "console.deprecations.enabled.manualStepOneMessage": "打开 kibana.yml 配置文件。", - "console.deprecations.enabled.manualStepTwoMessage": "将“console.enabled”设置更改为“console.ui.enabled”。", - "console.deprecations.enabledMessage": "要禁止用户访问 Console UI,请使用“console.ui.enabled”设置,而不是“console.enabled”。", - "console.deprecations.enabledTitle": "“console.enabled”设置已过时", - "console.deprecations.proxyConfig.manualStepOneMessage": "打开 kibana.yml 配置文件。", - "console.deprecations.proxyConfig.manualStepThreeMessage": "使用“server.ssl.*”设置配置 Kibana 与 Elasticsearch 之间的安全连接。", - "console.deprecations.proxyConfig.manualStepTwoMessage": "移除“console.proxyConfig”设置。", - "console.deprecations.proxyConfigMessage": "配置“console.proxyConfig”已过时,将在 8.0.0 中移除。为保护 Kibana 与 Elasticsearch 之间的连接,请改为使用标准“server.ssl.*”设置。", - "console.deprecations.proxyConfigTitle": "“console.proxyConfig”设置已过时", - "console.deprecations.proxyFilter.manualStepOneMessage": "打开 kibana.yml 配置文件。", - "console.deprecations.proxyFilter.manualStepThreeMessage": "使用“server.ssl.*”设置配置 Kibana 与 Elasticsearch 之间的安全连接。", - "console.deprecations.proxyFilter.manualStepTwoMessage": "移除“console.proxyFilter”设置。", - "console.deprecations.proxyFilterMessage": "配置“console.proxyFilter”已过时,将在 8.0.0 中移除。为保护 Kibana 与 Elasticsearch 之间的连接,请改为使用标准“server.ssl.*”设置。", - "console.deprecations.proxyFilterTitle": "“console.proxyFilter”设置已过时", - "console.devToolsDescription": "跳过 cURL 并使用 JSON 接口在控制台中处理您的数据。", - "console.devToolsTitle": "与 Elasticsearch API 进行交互", - "console.exampleOutputTextarea": "开发工具控制台编辑器示例", - "console.helpPage.keyboardCommands.autoIndentDescription": "自动缩进当前请求", - "console.helpPage.keyboardCommands.closeAutoCompleteMenuDescription": "关闭自动完成菜单", - "console.helpPage.keyboardCommands.collapseAllScopesDescription": "折叠当前范围除外的所有范围。通过加按 Shift 键来展开。", - "console.helpPage.keyboardCommands.collapseExpandCurrentScopeDescription": "折叠/展开当前范围。", - "console.helpPage.keyboardCommands.jumpToPreviousNextRequestDescription": "跳转至前一/后一请求开头或结尾。", - "console.helpPage.keyboardCommands.openAutoCompleteDescription": "打开自动完成(即使未键入)", - "console.helpPage.keyboardCommands.openDocumentationDescription": "打开当前请求的文档", - "console.helpPage.keyboardCommands.selectCurrentlySelectedInAutoCompleteMenuDescription": "选择自动完成菜单中当前选定的词或最顶部的词", - "console.helpPage.keyboardCommands.submitRequestDescription": "提交请求", - "console.helpPage.keyboardCommands.switchFocusToAutoCompleteMenuDescription": "将焦点切换到自动完成菜单。使用箭头进一步选择词", - "console.helpPage.keyboardCommandsTitle": "键盘命令", - "console.helpPage.pageTitle": "帮助", - "console.helpPage.requestFormatDescription": "您可以在空白编辑器中键入一个或多个请求。Console 理解紧凑格式的请求:", - "console.helpPage.requestFormatTitle": "请求格式", - "console.historyPage.applyHistoryButtonLabel": "应用", - "console.historyPage.clearHistoryButtonLabel": "清除", - "console.historyPage.closehistoryButtonLabel": "关闭", - "console.historyPage.itemOfRequestListAriaLabel": "请求:{historyItem}", - "console.historyPage.noHistoryTextMessage": "没有可用的历史记录", - "console.historyPage.pageTitle": "历史记录", - "console.historyPage.requestListAriaLabel": "已发送请求的历史记录", - "console.inputTextarea": "开发工具控制台", - "console.loadFromDataUriErrorMessage": "无法从 URL 中的 load_from 查询参数加载数据", - "console.loadingError.buttonLabel": "重新加载控制台", - "console.loadingError.message": "尝试重新加载以获取最新的数据。", - "console.loadingError.title": "无法加载控制台", - "console.notification.error.couldNotSaveRequestTitle": "无法将请求保存到控制台历史记录。", - "console.notification.error.historyQuotaReachedMessage": "请求历史记录已满。请清除控制台历史记录以保存新的请求。", - "console.notification.error.noRequestSelectedTitle": "未选择任何请求。将鼠标置于请求内即可选择。", - "console.notification.error.unknownErrorTitle": "未知请求错误", - "console.outputTextarea": "开发工具控制台输出", - "console.pageHeading": "控制台", - "console.requestInProgressBadgeText": "进行中的请求", - "console.requestOptions.autoIndentButtonLabel": "自动缩进", - "console.requestOptions.copyAsUrlButtonLabel": "复制为 cURL", - "console.requestOptions.openDocumentationButtonLabel": "打开文档", - "console.requestOptionsButtonAriaLabel": "请求选项", - "console.requestTimeElapasedBadgeTooltipContent": "已用时间", - "console.sendRequestButtonTooltip": "单击以发送请求", - "console.settingsPage.autocompleteLabel": "自动完成", - "console.settingsPage.cancelButtonLabel": "取消", - "console.settingsPage.fieldsLabelText": "字段", - "console.settingsPage.fontSizeLabel": "字体大小", - "console.settingsPage.indicesAndAliasesLabelText": "索引和别名", - "console.settingsPage.jsonSyntaxLabel": "JSON 语法", - "console.settingsPage.pageTitle": "控制台设置", - "console.settingsPage.refreshButtonLabel": "刷新自动完成建议", - "console.settingsPage.refreshingDataDescription": "控制台通过查询 Elasticsearch 来刷新自动完成建议。如果您的集群较大或您的网络有限制,则自动刷新可能会造成问题。", - "console.settingsPage.refreshingDataLabel": "正在刷新自动完成建议", - "console.settingsPage.saveButtonLabel": "保存", - "console.settingsPage.templatesLabelText": "模板", - "console.settingsPage.tripleQuotesMessage": "在输出窗格中使用三重引号", - "console.settingsPage.wrapLongLinesLabelText": "长行换行", - "console.topNav.helpTabDescription": "帮助", - "console.topNav.helpTabLabel": "帮助", - "console.topNav.historyTabDescription": "历史记录", - "console.topNav.historyTabLabel": "历史记录", - "console.topNav.settingsTabDescription": "设置", - "console.topNav.settingsTabLabel": "设置", - "console.welcomePage.closeButtonLabel": "关闭", - "console.welcomePage.pageTitle": "欢迎使用 Console", - "console.welcomePage.quickIntroDescription": "Console UI 分为两个窗格:编辑器窗格(左)和响应窗格(右)。使用编辑器键入请求并将它们提交到 Elasticsearch。结果将显示在右侧的响应窗格中。", - "console.welcomePage.quickIntroTitle": "UI 简介", - "console.welcomePage.quickTips.cUrlFormatForRequestsDescription": "您可以粘贴 cURL 格式的请求,这些请求将转换成 Console 语法格式。", - "console.welcomePage.quickTips.keyboardShortcutsDescription": "学习“帮助”按钮下的键盘快捷方式。那里有非常实用的信息!", - "console.welcomePage.quickTips.resizeEditorDescription": "您可以通过拖动编辑器和输出窗格之间的分隔条来调整它们的大小。", - "console.welcomePage.quickTips.submitRequestDescription": "使用绿色三角按钮将请求提交到 ES。", - "console.welcomePage.quickTips.useWrenchMenuDescription": "使用扳手菜单执行其他有用的操作。", - "console.welcomePage.quickTipsTitle": "有几个需要您注意的有用提示", - "console.welcomePage.supportedRequestFormatDescription": "键入请求时,控制台将提供建议,您可以通过按 Enter/Tab 键来接受建议。这些建议基于请求结构以及索引和类型进行提供。", - "console.welcomePage.supportedRequestFormatTitle": "Console 理解紧凑格式的请求,类似于 cURL:", - "core.application.appContainer.loadingAriaLabel": "正在加载应用程序", - "core.application.appNotFound.pageDescription": "在此 URL 未找到任何应用程序。尝试返回或从菜单中选择应用。", - "core.application.appNotFound.title": "未找到应用程序", - "core.application.appRenderError.defaultTitle": "应用程序错误", - "core.chrome.browserDeprecationLink": "我们网站上的支持矩阵", - "core.chrome.browserDeprecationWarning": "本软件的未来版本将放弃对 Internet Explorer 的支持,请查看{link}。", - "core.chrome.legacyBrowserWarning": "您的浏览器不满足 Kibana 的安全要求。", - "core.deprecations.deprecations.fetchFailed.manualStepOneMessage": "请在 Kibana 服务器日志中查看错误消息。", - "core.deprecations.deprecations.fetchFailedMessage": "无法提取插件 {domainId} 的弃用信息。", - "core.deprecations.deprecations.fetchFailedTitle": "无法提取 {domainId} 的弃用信息", - "core.deprecations.elasticsearchSSL.manualSteps1": "将“{missingSetting}”设置添加到 kibana.yml。", - "core.deprecations.elasticsearchSSL.manualSteps2": "或者,如果不想使用相互 TLS 身份验证,请从 kibana.yml 中移除“{existingSetting}”。", - "core.deprecations.elasticsearchSSL.message": "同时使用“{existingSetting}”和“{missingSetting}”,以便 Kibana 将相互 TLS 身份验证用于 Elasticsearch。", - "core.deprecations.elasticsearchSSL.title": "使用不含“{missingSetting}”的“{existingSetting}”无效", - "core.deprecations.elasticsearchUsername.manualSteps1": "使用 elasticsearch-service-tokens CLI 工具为“elastic/kibana”服务帐户创建新的服务帐户令牌。", - "core.deprecations.elasticsearchUsername.manualSteps2": "将“elasticsearch.serviceAccountToken”设置添加到 kibana.yml。", - "core.deprecations.elasticsearchUsername.manualSteps3": "从 kibana.yml 中移除“elasticsearch.username”和“elasticsearch.password”。", - "core.deprecations.elasticsearchUsername.message": "Kibana 已配置为通过“{username}”用户验证到 Elasticsearch。改为使用服务帐户令牌。", - "core.deprecations.elasticsearchUsername.title": "使用“elasticsearch.username: {username}”已过时", - "core.deprecations.noCorrectiveAction": "无法自动解决此弃用。", - "core.euiAccordion.isLoading": "正在加载", - "core.euiBasicTable.noItemsMessage": "找不到项目", - "core.euiBasicTable.selectAllRows": "选择所有行", - "core.euiBasicTable.selectThisRow": "选择此行", - "core.euiBasicTable.tableAutoCaptionWithoutPagination": "此表包含 {itemCount} 行。", - "core.euiBasicTable.tableAutoCaptionWithPagination": "此表包含 {itemCount} 行,共有 {totalItemCount} 行;第 {page} 页,共 {pageCount} 页。", - "core.euiBasicTable.tableCaptionWithPagination": "{tableCaption};第 {page} 页,共 {pageCount} 页。", - "core.euiBasicTable.tablePagination": "表分页:{tableCaption}", - "core.euiBasicTable.tableSimpleAutoCaptionWithPagination": "此表包含 {itemCount} 行;第 {page} 页,共 {pageCount} 页。", - "core.euiBottomBar.customScreenReaderAnnouncement": "有称作 {landmarkHeading} 且页面级别控件位于文档结尾的新地区地标。", - "core.euiBottomBar.screenReaderAnnouncement": "有页面级别控件位于文档结尾的新地区地标。", - "core.euiBottomBar.screenReaderHeading": "页面级别控件", - "core.euiBreadcrumbs.collapsedBadge.ariaLabel": "查看折叠的痕迹导航", - "core.euiBreadcrumbs.nav.ariaLabel": "痕迹导航", - "core.euiCardSelect.select": "选择", - "core.euiCardSelect.selected": "已选定", - "core.euiCardSelect.unavailable": "不可用", - "core.euiCodeBlock.copyButton": "复制", - "core.euiCodeBlock.fullscreenCollapse": "折叠", - "core.euiCodeBlock.fullscreenExpand": "展开", - "core.euiCollapsedItemActions.allActions": "所有操作", - "core.euiColorPicker.alphaLabel": "Alpha 通道(不透明度)值", - "core.euiColorPicker.closeLabel": "按向下箭头键可打开包含颜色选项的弹出框", - "core.euiColorPicker.colorErrorMessage": "颜色值无效", - "core.euiColorPicker.colorLabel": "颜色值", - "core.euiColorPicker.openLabel": "按 Esc 键关闭弹出框", - "core.euiColorPicker.popoverLabel": "颜色选择对话框", - "core.euiColorPicker.transparent": "透明", - "core.euiColorPickerSwatch.ariaLabel": "选择 {color} 作为颜色", - "core.euiColorStops.screenReaderAnnouncement": "{label}:{readOnly} {disabled} 颜色停止点选取器。每个停止点由数字和相应颜色值构成。使用向下和向上箭头键选择单个停止点。按 Enter 键创建新的停止点。", - "core.euiColorStopThumb.buttonAriaLabel": "按 Enter 键修改此停止点。按 Esc 键聚焦该组", - "core.euiColorStopThumb.buttonTitle": "单击编辑,拖动重新定位", - "core.euiColorStopThumb.removeLabel": "删除此停止点", - "core.euiColorStopThumb.screenReaderAnnouncement": "打开颜色停止点编辑表单的弹出式窗口。按 Tab 键正向依次选择表单控件或按 Esc 键关闭此弹出式窗口。", - "core.euiColorStopThumb.stopErrorMessage": "值超出范围", - "core.euiColorStopThumb.stopLabel": "停止点值", - "core.euiColumnActions.hideColumn": "隐藏列", - "core.euiColumnActions.moveLeft": "左移", - "core.euiColumnActions.moveRight": "右移", - "core.euiColumnActions.sort": "排序 {schemaLabel}", - "core.euiColumnSelector.button": "列", "core.euiColumnSelector.buttonActivePlural": "{numberOfHiddenFields} 列已隐藏", "core.euiColumnSelector.buttonActiveSingular": "{numberOfHiddenFields} 列已隐藏", "core.euiColumnSelector.hideAll": "全部隐藏", @@ -2987,6 +2740,35 @@ "expressionTagcloud.functions.tagcloudHelpText": "标签云图可视化。", "expressionTagcloud.renderer.tagcloud.displayName": "标签云图可视化", "expressionTagcloud.renderer.tagcloud.helpDescription": "呈现标签云图", + "expressionPartitionVis.reusable.function.dimension.buckets": "切片", + "expressionPartitionVis.reusable.function.args.legendDisplayHelpText": "显示图表图例", + "expressionPartitionVis.reusable.function.args.addTooltipHelpText": "在切片上悬浮时显示工具提示", + "expressionPartitionVis.reusable.function.args.bucketsHelpText": "存储桶维度配置", + "expressionPartitionVis.pieVis.function.args.distinctColorsHelpText": "每个切片映射不同颜色。具有相同值的切片具有相同的颜色", + "expressionPartitionVis.reusable.function.args.isDonutHelpText": "将饼图显示为圆环图", + "expressionPartitionVis.reusable.function.args.labelsHelpText": "饼图标签配置", + "expressionPartitionVis.reusable.function.args.legendPositionHelpText": "将图例定位于图表的顶部、底部、左侧、右侧", + "expressionPartitionVis.reusable.function.args.maxLegendLinesHelpText": "定义每个图例项的行数", + "expressionPartitionVis.reusable.function.args.metricHelpText": "指标维度配置", + "expressionPartitionVis.reusable.function.args.nestedLegendHelpText": "显示更详细的图例", + "expressionPartitionVis.reusable.function.args.paletteHelpText": "定义图表调色板名称", + "expressionPartitionVis.reusable.function.args.splitColumnHelpText": "按列维度配置拆分", + "expressionPartitionVis.reusable.function.args.splitRowHelpText": "按行维度配置拆分", + "expressionPartitionVis.reusable.function.args.truncateLegendHelpText": "定义是否将截断图例项", + "expressionPartitionVis.reusable.function.dimension.metric": "切片大小", + "expressionPartitionVis.reusable.function.dimension.splitcolumn": "列拆分", + "expressionPartitionVis.reusable.function.dimension.splitrow": "行拆分", + "expressionPartitionVis.partitionLabels.function.help": "生成饼图标签对象", + "expressionPartitionVis.partitionLabels.function.args.percentDecimals.help": "定义在值中将显示为百分比的小数位数", + "expressionPartitionVis.partitionLabels.function.args.position.help": "定义标签位置", + "expressionPartitionVis.partitionLabels.function.args.values.help": "定义切片内的值", + "expressionPartitionVis.partitionLabels.function.args.valuesFormat.help": "定义值的格式", + "expressionPartitionVis.pieVis.function.help": "饼图可视化", + "expressionPartitionVis.legend.filterForValueButtonAriaLabel": "筛留值", + "expressionPartitionVis.legend.filterOptionsLegend": "{legendDataLabel}, 筛选选项", + "expressionPartitionVis.legend.filterOutValueButtonAriaLabel": "筛除值", + "expressionPartitionVis.negativeValuesFound": "饼图/圆环图无法使用负值进行呈现。", + "expressionPartitionVis.noResultsFoundTitle": "找不到结果", "fieldFormats.advancedSettings.format.bytesFormat.numeralFormatLinkText": "数值格式", "fieldFormats.advancedSettings.format.bytesFormatText": "“字节”格式的默认{numeralFormatLink}", "fieldFormats.advancedSettings.format.bytesFormatTitle": "字节格式", @@ -5222,7 +5004,6 @@ "visTypePie.advancedSettings.visualization.legacyPieChartsLibrary.name": "饼图旧版图表库", "visTypePie.controls.truncateLabel": "截断", "visTypePie.controls.truncateTooltip": "标签位于图表之外的字符数。", - "visTypePie.editors.pie.addLegendLabel": "显示图例", "visTypePie.editors.pie.decimalSliderLabel": "百分比的最大小数位数", "visTypePie.editors.pie.distinctColorsLabel": "每个切片使用不同的颜色", "visTypePie.editors.pie.donutLabel": "圆环图", From d9d3230b94326a0874f170542087be3d8207aaa2 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Fri, 4 Feb 2022 13:49:41 -0500 Subject: [PATCH 10/12] [Response Ops][Cases] Refactoring tech debt after sub cases removal (#124181) * Starting aggs refactor * Removing so client from params adding aggs * Refactoring some comments * Addressing feedback * asArray returns empty array * Fixing type error --- .../aggregations/aggs_types/bucket_aggs.ts | 26 +- .../cases/server/client/attachments/add.ts | 1 - .../cases/server/client/attachments/delete.ts | 1 - .../cases/server/client/attachments/get.ts | 3 +- .../cases/server/client/attachments/update.ts | 1 - .../cases/server/client/cases/create.ts | 1 - .../cases/server/client/cases/delete.ts | 4 +- .../plugins/cases/server/client/cases/find.ts | 54 ++-- .../plugins/cases/server/client/cases/get.ts | 12 +- .../plugins/cases/server/client/cases/push.ts | 3 - .../cases/server/client/cases/update.ts | 6 - x-pack/plugins/cases/server/client/client.ts | 10 - x-pack/plugins/cases/server/client/factory.ts | 22 +- .../cases/server/client/metrics/client.ts | 9 +- .../server/client/metrics/get_case_metrics.ts | 3 +- .../client/metrics/get_cases_metrics.ts | 59 ++++ x-pack/plugins/cases/server/client/mocks.ts | 11 +- .../cases/server/client/stats/client.ts | 88 ------ .../server/common/models/commentable_case.ts | 2 - .../plugins/cases/server/common/utils.test.ts | 23 ++ x-pack/plugins/cases/server/common/utils.ts | 8 + .../server/routes/api/stats/get_status.ts | 2 +- .../server/services/attachments/index.ts | 106 ++++++++ .../cases/server/services/cases/index.test.ts | 53 ++-- .../cases/server/services/cases/index.ts | 251 +++++++----------- x-pack/plugins/cases/server/services/mocks.ts | 4 +- 26 files changed, 390 insertions(+), 373 deletions(-) create mode 100644 x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts delete mode 100644 x-pack/plugins/cases/server/client/stats/client.ts diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts index f85576aa64451..acbbd0fe0d778 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts @@ -19,6 +19,10 @@ import { sortOrderSchema } from './common_schemas'; * - reverse_nested * - terms * + * Not fully supported: + * - filter + * - filters + * * Not implemented: * - adjacency_matrix * - auto_date_histogram @@ -27,7 +31,6 @@ import { sortOrderSchema } from './common_schemas'; * - date_histogram * - date_range * - diversified_sampler - * - filters * - geo_distance * - geohash_grid * - geotile_grid @@ -44,9 +47,26 @@ import { sortOrderSchema } from './common_schemas'; * - variable_width_histogram */ +// TODO: it would be great if we could recursively build the schema since the aggregation have be nested +// For more details see how the types are defined in the elasticsearch javascript client: +// https://github.com/elastic/elasticsearch-js/blob/4ad5daeaf401ce8ebb28b940075e0a67e56ff9ce/src/api/typesWithBodyKey.ts#L5295 +const termSchema = s.object({ + term: s.recordOf(s.string(), s.oneOf([s.string(), s.boolean(), s.number()])), +}); + +// TODO: it would be great if we could recursively build the schema since the aggregation have be nested +// For more details see how the types are defined in the elasticsearch javascript client: +// https://github.com/elastic/elasticsearch-js/blob/4ad5daeaf401ce8ebb28b940075e0a67e56ff9ce/src/api/typesWithBodyKey.ts#L5295 +const boolSchema = s.object({ + bool: s.object({ + must_not: s.oneOf([termSchema]), + }), +}); + export const bucketAggsSchemas: Record = { - filter: s.object({ - term: s.recordOf(s.string(), s.oneOf([s.string(), s.boolean(), s.number()])), + filter: termSchema, + filters: s.object({ + filters: s.recordOf(s.string(), s.oneOf([termSchema, boolSchema])), }), histogram: s.object({ field: s.maybe(s.string()), diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts index ca03381681796..c0999b25ebf7d 100644 --- a/x-pack/plugins/cases/server/client/attachments/add.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.ts @@ -53,7 +53,6 @@ async function createCommentableCase({ lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; }): Promise { const caseInfo = await caseService.getCase({ - unsecuredSavedObjectsClient, id, }); diff --git a/x-pack/plugins/cases/server/client/attachments/delete.ts b/x-pack/plugins/cases/server/client/attachments/delete.ts index 9d048162da7eb..6071570029d63 100644 --- a/x-pack/plugins/cases/server/client/attachments/delete.ts +++ b/x-pack/plugins/cases/server/client/attachments/delete.ts @@ -60,7 +60,6 @@ export async function deleteAll( try { const comments = await caseService.getAllCaseComments({ - unsecuredSavedObjectsClient, id: caseID, }); diff --git a/x-pack/plugins/cases/server/client/attachments/get.ts b/x-pack/plugins/cases/server/client/attachments/get.ts index b9ac0f2eea000..4cd620ac5a772 100644 --- a/x-pack/plugins/cases/server/client/attachments/get.ts +++ b/x-pack/plugins/cases/server/client/attachments/get.ts @@ -250,7 +250,7 @@ export async function getAll( { caseID }: GetAllArgs, clientArgs: CasesClientArgs ): Promise { - const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + const { caseService, logger, authorization } = clientArgs; try { const { filter, ensureSavedObjectsAreAuthorized } = await authorization.getAuthorizationFilter( @@ -258,7 +258,6 @@ export async function getAll( ); const comments = await caseService.getAllCaseComments({ - unsecuredSavedObjectsClient, id: caseID, options: { filter, diff --git a/x-pack/plugins/cases/server/client/attachments/update.ts b/x-pack/plugins/cases/server/client/attachments/update.ts index 1928057f17edf..bd91827a9c852 100644 --- a/x-pack/plugins/cases/server/client/attachments/update.ts +++ b/x-pack/plugins/cases/server/client/attachments/update.ts @@ -50,7 +50,6 @@ async function createCommentableCase({ lensEmbeddableFactory, }: CombinedCaseParams) { const caseInfo = await caseService.getCase({ - unsecuredSavedObjectsClient, id: caseID, }); diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 30580ed493b63..23b295602c213 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -73,7 +73,6 @@ export const create = async ( }); const newCase = await caseService.postNewCase({ - unsecuredSavedObjectsClient, attributes: transformNewCase({ user, newCase: query, diff --git a/x-pack/plugins/cases/server/client/cases/delete.ts b/x-pack/plugins/cases/server/client/cases/delete.ts index d1c12219f2ef2..8d0a97079f1cc 100644 --- a/x-pack/plugins/cases/server/client/cases/delete.ts +++ b/x-pack/plugins/cases/server/client/cases/delete.ts @@ -30,7 +30,7 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P authorization, } = clientArgs; try { - const cases = await caseService.getCases({ unsecuredSavedObjectsClient, caseIds: ids }); + const cases = await caseService.getCases({ caseIds: ids }); const entities = new Map(); for (const theCase of cases.saved_objects) { @@ -52,7 +52,6 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P const deleteCasesMapper = async (id: string) => caseService.deleteCase({ - unsecuredSavedObjectsClient, id, }); @@ -63,7 +62,6 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P const getCommentsMapper = async (id: string) => caseService.getAllCaseComments({ - unsecuredSavedObjectsClient, id, }); diff --git a/x-pack/plugins/cases/server/client/cases/find.ts b/x-pack/plugins/cases/server/client/cases/find.ts index 3e4bc47231d12..c8bdb40b41310 100644 --- a/x-pack/plugins/cases/server/client/cases/find.ts +++ b/x-pack/plugins/cases/server/client/cases/find.ts @@ -15,13 +15,12 @@ import { CasesFindRequest, CasesFindRequestRt, throwErrors, - caseStatuses, CasesFindResponseRt, excess, } from '../../../common/api'; import { createCaseError } from '../../common/error'; -import { transformCases } from '../../common/utils'; +import { asArray, transformCases } from '../../common/utils'; import { constructQueryOptions } from '../utils'; import { includeFieldsRequiredForAuthentication } from '../../authorization/utils'; import { Operations } from '../../authorization'; @@ -36,7 +35,7 @@ export const find = async ( params: CasesFindRequest, clientArgs: CasesClientArgs ): Promise => { - const { unsecuredSavedObjectsClient, caseService, authorization, logger } = clientArgs; + const { caseService, authorization, logger } = clientArgs; try { const queryParams = pipe( @@ -55,45 +54,38 @@ export const find = async ( owner: queryParams.owner, }; - const caseQueries = constructQueryOptions({ ...queryArgs, authorizationFilter }); - const cases = await caseService.findCasesGroupedByID({ - unsecuredSavedObjectsClient, - caseOptions: { - ...queryParams, - ...caseQueries, - searchFields: - queryParams.searchFields != null - ? Array.isArray(queryParams.searchFields) - ? queryParams.searchFields - : [queryParams.searchFields] - : queryParams.searchFields, - fields: includeFieldsRequiredForAuthentication(queryParams.fields), - }, + const statusStatsOptions = constructQueryOptions({ + ...queryArgs, + status: undefined, + authorizationFilter, }); + const caseQueryOptions = constructQueryOptions({ ...queryArgs, authorizationFilter }); - ensureSavedObjectsAreAuthorized([...cases.casesMap.values()]); - - // casesStatuses are bounded by us. No need to limit concurrent calls. - const [openCases, inProgressCases, closedCases] = await Promise.all([ - ...caseStatuses.map((status) => { - const statusQuery = constructQueryOptions({ ...queryArgs, status, authorizationFilter }); - return caseService.findCaseStatusStats({ - unsecuredSavedObjectsClient, - caseOptions: statusQuery, - ensureSavedObjectsAreAuthorized, - }); + const [cases, statusStats] = await Promise.all([ + caseService.findCasesGroupedByID({ + caseOptions: { + ...queryParams, + ...caseQueryOptions, + searchFields: asArray(queryParams.searchFields), + fields: includeFieldsRequiredForAuthentication(queryParams.fields), + }, + }), + caseService.getCaseStatusStats({ + searchOptions: statusStatsOptions, }), ]); + ensureSavedObjectsAreAuthorized([...cases.casesMap.values()]); + return CasesFindResponseRt.encode( transformCases({ casesMap: cases.casesMap, page: cases.page, perPage: cases.perPage, total: cases.total, - countOpenCases: openCases, - countInProgressCases: inProgressCases, - countClosedCases: closedCases, + countOpenCases: statusStats.open, + countInProgressCases: statusStats['in-progress'], + countClosedCases: statusStats.closed, }) ); } catch (error) { diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index 72d5ca2708d28..76f0667023e54 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -59,7 +59,7 @@ export const getCasesByAlertID = async ( { alertID, options }: CasesByAlertIDParams, clientArgs: CasesClientArgs ): Promise => { - const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + const { caseService, logger, authorization } = clientArgs; try { const queryParams = pipe( @@ -79,7 +79,6 @@ export const getCasesByAlertID = async ( // This will likely only return one comment saved object, the response aggregation will contain // the keys we need to retrieve the cases const commentsWithAlert = await caseService.getCaseIdsByAlertId({ - unsecuredSavedObjectsClient, alertId: alertID, filter, }); @@ -100,7 +99,6 @@ export const getCasesByAlertID = async ( } const casesInfo = await caseService.getCases({ - unsecuredSavedObjectsClient, caseIds, }); @@ -157,11 +155,10 @@ export const get = async ( { id, includeComments }: GetParams, clientArgs: CasesClientArgs ): Promise => { - const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + const { caseService, logger, authorization } = clientArgs; try { const theCase: SavedObject = await caseService.getCase({ - unsecuredSavedObjectsClient, id, }); @@ -179,7 +176,6 @@ export const get = async ( } const theComments = await caseService.getAllCaseComments({ - unsecuredSavedObjectsClient, id, options: { sortField: 'created_at', @@ -209,14 +205,13 @@ export const resolve = async ( { id, includeComments }: GetParams, clientArgs: CasesClientArgs ): Promise => { - const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + const { caseService, logger, authorization } = clientArgs; try { const { saved_object: resolvedSavedObject, ...resolveData }: SavedObjectsResolveResponse = await caseService.getResolveCase({ - unsecuredSavedObjectsClient, id, }); @@ -240,7 +235,6 @@ export const resolve = async ( } const theComments = await caseService.getAllCaseComments({ - unsecuredSavedObjectsClient, id: resolvedSavedObject.id, options: { sortField: 'created_at', diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index e68c67951d571..112fd6ef2c04c 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -142,12 +142,10 @@ export const push = async ( /* Start of update case with push information */ const [myCase, myCaseConfigure, comments] = await Promise.all([ caseService.getCase({ - unsecuredSavedObjectsClient, id: caseId, }), caseConfigureService.find({ unsecuredSavedObjectsClient }), caseService.getAllCaseComments({ - unsecuredSavedObjectsClient, id: caseId, options: { fields: [], @@ -177,7 +175,6 @@ export const push = async ( const [updatedCase, updatedComments] = await Promise.all([ caseService.patchCase({ originalCase: myCase, - unsecuredSavedObjectsClient, caseId, updatedAttributes: { ...(shouldMarkAsClosed diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index fa8319d37efd8..184810098bde4 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -97,17 +97,14 @@ function getID( async function getAlertComments({ casesToSync, caseService, - unsecuredSavedObjectsClient, }: { casesToSync: UpdateRequestWithOriginalCase[]; caseService: CasesService; - unsecuredSavedObjectsClient: SavedObjectsClientContract; }): Promise> { const idsOfCasesToSync = casesToSync.map(({ updateReq }) => updateReq.id); // getAllCaseComments will by default get all the comments, unless page or perPage fields are set return caseService.getAllCaseComments({ - unsecuredSavedObjectsClient, id: idsOfCasesToSync, options: { filter: nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.alert), @@ -166,7 +163,6 @@ async function updateAlerts({ const totalAlerts = await getAlertComments({ casesToSync, caseService, - unsecuredSavedObjectsClient, }); // create an array of requests that indicate the id, index, and status to update an alert @@ -253,7 +249,6 @@ export const update = async ( try { const myCases = await caseService.getCases({ - unsecuredSavedObjectsClient, caseIds: query.cases.map((q) => q.id), }); @@ -320,7 +315,6 @@ export const update = async ( const { username, full_name, email } = user; const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ - unsecuredSavedObjectsClient, cases: updateCases.map(({ updateReq, originalCase }) => { // intentionally removing owner from the case so that we don't accidentally allow it to be updated const { id: caseId, version, owner, ...updateCaseAttributes } = updateReq; diff --git a/x-pack/plugins/cases/server/client/client.ts b/x-pack/plugins/cases/server/client/client.ts index fb111f267ddac..266c988212cdf 100644 --- a/x-pack/plugins/cases/server/client/client.ts +++ b/x-pack/plugins/cases/server/client/client.ts @@ -11,7 +11,6 @@ import { AttachmentsSubClient, createAttachmentsSubClient } from './attachments/ import { UserActionsSubClient, createUserActionsSubClient } from './user_actions/client'; import { CasesClientInternal, createCasesClientInternal } from './client_internal'; import { ConfigureSubClient, createConfigurationSubClient } from './configure/client'; -import { createStatsSubClient, StatsSubClient } from './stats/client'; import { createMetricsSubClient, MetricsSubClient } from './metrics/client'; /** @@ -23,7 +22,6 @@ export class CasesClient { private readonly _attachments: AttachmentsSubClient; private readonly _userActions: UserActionsSubClient; private readonly _configure: ConfigureSubClient; - private readonly _stats: StatsSubClient; private readonly _metrics: MetricsSubClient; constructor(args: CasesClientArgs) { @@ -32,7 +30,6 @@ export class CasesClient { this._attachments = createAttachmentsSubClient(args, this, this._casesClientInternal); this._userActions = createUserActionsSubClient(args); this._configure = createConfigurationSubClient(args, this._casesClientInternal); - this._stats = createStatsSubClient(args); this._metrics = createMetricsSubClient(args, this); } @@ -64,13 +61,6 @@ export class CasesClient { return this._configure; } - /** - * Retrieves an interface for retrieving statistics related to the cases entities. - */ - public get stats() { - return this._stats; - } - /** * Retrieves an interface for retrieving metrics related to the cases entities. */ diff --git a/x-pack/plugins/cases/server/client/factory.ts b/x-pack/plugins/cases/server/client/factory.ts index d657f1a3f4f48..3cbcee62d8c09 100644 --- a/x-pack/plugins/cases/server/client/factory.ts +++ b/x-pack/plugins/cases/server/client/factory.ts @@ -91,17 +91,25 @@ export class CasesClientFactory { logger: this.logger, }); - const caseService = new CasesService(this.logger, this.options?.securityPluginStart?.authc); + const unsecuredSavedObjectsClient = savedObjectsService.getScopedClient(request, { + includedHiddenTypes: SAVED_OBJECT_TYPES, + // this tells the security plugin to not perform SO authorization and audit logging since we are handling + // that manually using our Authorization class and audit logger. + excludedWrappers: ['security'], + }); + + const attachmentService = new AttachmentService(this.logger); + const caseService = new CasesService({ + log: this.logger, + authentication: this.options?.securityPluginStart?.authc, + unsecuredSavedObjectsClient, + attachmentService, + }); const userInfo = caseService.getUser({ request }); return createCasesClient({ alertsService: new AlertService(scopedClusterClient, this.logger), - unsecuredSavedObjectsClient: savedObjectsService.getScopedClient(request, { - includedHiddenTypes: SAVED_OBJECT_TYPES, - // this tells the security plugin to not perform SO authorization and audit logging since we are handling - // that manually using our Authorization class and audit logger. - excludedWrappers: ['security'], - }), + unsecuredSavedObjectsClient, // We only want these fields from the userInfo object user: { username: userInfo.username, email: userInfo.email, full_name: userInfo.full_name }, caseService, diff --git a/x-pack/plugins/cases/server/client/metrics/client.ts b/x-pack/plugins/cases/server/client/metrics/client.ts index c5420213f3f97..8fbb30486bc41 100644 --- a/x-pack/plugins/cases/server/client/metrics/client.ts +++ b/x-pack/plugins/cases/server/client/metrics/client.ts @@ -5,10 +5,11 @@ * 2.0. */ -import { CaseMetricsResponse } from '../../../common/api'; +import { CaseMetricsResponse, CasesStatusRequest, CasesStatusResponse } from '../../../common/api'; import { CasesClient } from '../client'; import { CasesClientArgs } from '../types'; +import { getStatusTotalsByType } from './get_cases_metrics'; import { getCaseMetrics, CaseMetricsParams } from './get_case_metrics'; @@ -17,6 +18,10 @@ import { getCaseMetrics, CaseMetricsParams } from './get_case_metrics'; */ export interface MetricsSubClient { getCaseMetrics(params: CaseMetricsParams): Promise; + /** + * Retrieves the total number of open, closed, and in-progress cases. + */ + getStatusTotalsByType(params: CasesStatusRequest): Promise; } /** @@ -30,6 +35,8 @@ export const createMetricsSubClient = ( ): MetricsSubClient => { const casesSubClient: MetricsSubClient = { getCaseMetrics: (params: CaseMetricsParams) => getCaseMetrics(params, casesClient, clientArgs), + getStatusTotalsByType: (params: CasesStatusRequest) => + getStatusTotalsByType(params, clientArgs), }; return Object.freeze(casesSubClient); diff --git a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts index 57755c17e65eb..d2ce8c03edeb7 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts @@ -105,10 +105,9 @@ const checkAndThrowIfInvalidFeatures = ( }; const checkAuthorization = async (params: CaseMetricsParams, clientArgs: CasesClientArgs) => { - const { caseService, unsecuredSavedObjectsClient, authorization } = clientArgs; + const { caseService, authorization } = clientArgs; const caseInfo = await caseService.getCase({ - unsecuredSavedObjectsClient, id: params.caseId, }); diff --git a/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts b/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts new file mode 100644 index 0000000000000..82c3a52a10d63 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + CasesStatusRequest, + CasesStatusResponse, + excess, + CasesStatusRequestRt, + throwErrors, + CasesStatusResponseRt, +} from '../../../common/api'; +import { CasesClientArgs } from '../types'; +import { Operations } from '../../authorization'; +import { constructQueryOptions } from '../utils'; +import { createCaseError } from '../../common/error'; + +export async function getStatusTotalsByType( + params: CasesStatusRequest, + clientArgs: CasesClientArgs +): Promise { + const { caseService, logger, authorization } = clientArgs; + + try { + const queryParams = pipe( + excess(CasesStatusRequestRt).decode(params), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( + Operations.getCaseStatuses + ); + + const options = constructQueryOptions({ + owner: queryParams.owner, + authorizationFilter, + }); + + const statusStats = await caseService.getCaseStatusStats({ + searchOptions: options, + }); + + return CasesStatusResponseRt.encode({ + count_open_cases: statusStats.open, + count_in_progress_cases: statusStats['in-progress'], + count_closed_cases: statusStats.closed, + }); + } catch (error) { + throw createCaseError({ message: `Failed to get status stats: ${error}`, error, logger }); + } +} diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index 1d0cf000018cb..ecedc7cb05071 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -13,7 +13,6 @@ import { CasesSubClient } from './cases/client'; import { ConfigureSubClient } from './configure/client'; import { CasesClientFactory } from './factory'; import { MetricsSubClient } from './metrics/client'; -import { StatsSubClient } from './stats/client'; import { UserActionsSubClient } from './user_actions/client'; type CasesSubClientMock = jest.Mocked; @@ -38,6 +37,7 @@ type MetricsSubClientMock = jest.Mocked; const createMetricsSubClientMock = (): MetricsSubClientMock => { return { getCaseMetrics: jest.fn(), + getStatusTotalsByType: jest.fn(), }; }; @@ -75,14 +75,6 @@ const createConfigureSubClientMock = (): ConfigureSubClientMock => { }; }; -type StatsSubClientMock = jest.Mocked; - -const createStatsSubClientMock = (): StatsSubClientMock => { - return { - getStatusTotalsByType: jest.fn(), - }; -}; - export interface CasesClientMock extends CasesClient { cases: CasesSubClientMock; attachments: AttachmentsSubClientMock; @@ -95,7 +87,6 @@ export const createCasesClientMock = (): CasesClientMock => { attachments: createAttachmentsSubClientMock(), userActions: createUserActionsSubClientMock(), configure: createConfigureSubClientMock(), - stats: createStatsSubClientMock(), metrics: createMetricsSubClientMock(), }; return client as unknown as CasesClientMock; diff --git a/x-pack/plugins/cases/server/client/stats/client.ts b/x-pack/plugins/cases/server/client/stats/client.ts deleted file mode 100644 index 6cb945e0fead1..0000000000000 --- a/x-pack/plugins/cases/server/client/stats/client.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { CasesClientArgs } from '..'; -import { - CasesStatusRequest, - CasesStatusResponse, - CasesStatusResponseRt, - caseStatuses, - throwErrors, - excess, - CasesStatusRequestRt, -} from '../../../common/api'; -import { Operations } from '../../authorization'; -import { createCaseError } from '../../common/error'; -import { constructQueryOptions } from '../utils'; - -/** - * Statistics API contract. - */ -export interface StatsSubClient { - /** - * Retrieves the total number of open, closed, and in-progress cases. - */ - getStatusTotalsByType(params: CasesStatusRequest): Promise; -} - -/** - * Creates the interface for retrieving the number of open, closed, and in progress cases. - * - * @ignore - */ -export function createStatsSubClient(clientArgs: CasesClientArgs): StatsSubClient { - return Object.freeze({ - getStatusTotalsByType: (params: CasesStatusRequest) => - getStatusTotalsByType(params, clientArgs), - }); -} - -async function getStatusTotalsByType( - params: CasesStatusRequest, - clientArgs: CasesClientArgs -): Promise { - const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; - - try { - const queryParams = pipe( - excess(CasesStatusRequestRt).decode(params), - fold(throwErrors(Boom.badRequest), identity) - ); - - const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = - await authorization.getAuthorizationFilter(Operations.getCaseStatuses); - - // casesStatuses are bounded by us. No need to limit concurrent calls. - const [openCases, inProgressCases, closedCases] = await Promise.all([ - ...caseStatuses.map((status) => { - const statusQuery = constructQueryOptions({ - owner: queryParams.owner, - status, - authorizationFilter, - }); - return caseService.findCaseStatusStats({ - unsecuredSavedObjectsClient, - caseOptions: statusQuery, - ensureSavedObjectsAreAuthorized, - }); - }), - ]); - - return CasesStatusResponseRt.encode({ - count_open_cases: openCases, - count_in_progress_cases: inProgressCases, - count_closed_cases: closedCases, - }); - } catch (error) { - throw createCaseError({ message: `Failed to get status stats: ${error}`, error, logger }); - } -} diff --git a/x-pack/plugins/cases/server/common/models/commentable_case.ts b/x-pack/plugins/cases/server/common/models/commentable_case.ts index 4afb427dea1f8..a56e55670ec83 100644 --- a/x-pack/plugins/cases/server/common/models/commentable_case.ts +++ b/x-pack/plugins/cases/server/common/models/commentable_case.ts @@ -118,7 +118,6 @@ export class CommentableCase { try { const updatedCase = await this.caseService.patchCase({ originalCase: this.caseInfo, - unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, caseId: this.caseInfo.id, updatedAttributes: { updated_at: date, @@ -282,7 +281,6 @@ export class CommentableCase { public async encode(): Promise { try { const comments = await this.caseService.getAllCaseComments({ - unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, id: this.caseInfo.id, options: { fields: [], diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 314f986f831ae..78ffd6c22a9af 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -28,6 +28,7 @@ import { flattenCommentSavedObject, extractLensReferencesFromCommentString, getOrUpdateLensReferences, + asArray, } from './utils'; interface CommentReference { @@ -940,4 +941,26 @@ describe('common utils', () => { expect(expectedReferences).toEqual(expect.arrayContaining(updatedReferences)); }); }); + + describe('asArray', () => { + it('returns an empty array when the field is undefined', () => { + expect(asArray(undefined)).toEqual([]); + }); + + it('returns an empty array when the field is null', () => { + expect(asArray(null)).toEqual([]); + }); + + it('leaves the string array as is when it is already an array', () => { + expect(asArray(['value'])).toEqual(['value']); + }); + + it('returns an array of one item when passed a string', () => { + expect(asArray('value')).toEqual(['value']); + }); + + it('returns an array of one item when passed a number', () => { + expect(asArray(100)).toEqual([100]); + }); + }); }); diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index ef04136b0b4a1..8bbf247b24773 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -360,3 +360,11 @@ export const getOrUpdateLensReferences = ( return currentNonLensReferences.concat(newCommentLensReferences); }; + +export const asArray = (field?: T | T[] | null): T[] => { + if (field === undefined || field === null) { + return []; + } + + return Array.isArray(field) ? field : [field]; +}; diff --git a/x-pack/plugins/cases/server/routes/api/stats/get_status.ts b/x-pack/plugins/cases/server/routes/api/stats/get_status.ts index 4f666c399d8fd..ffbc101a75dc4 100644 --- a/x-pack/plugins/cases/server/routes/api/stats/get_status.ts +++ b/x-pack/plugins/cases/server/routes/api/stats/get_status.ts @@ -21,7 +21,7 @@ export function initGetCasesStatusApi({ router, logger }: RouteDeps) { try { const client = await context.cases.getCasesClient(); return response.ok({ - body: await client.stats.getStatusTotalsByType(request.query as CasesStatusRequest), + body: await client.metrics.getStatusTotalsByType(request.query as CasesStatusRequest), }); } catch (error) { logger.error(`Failed to get status stats in route: ${error}`); diff --git a/x-pack/plugins/cases/server/services/attachments/index.ts b/x-pack/plugins/cases/server/services/attachments/index.ts index eb09ddac95dd8..5b3ee796faf96 100644 --- a/x-pack/plugins/cases/server/services/attachments/index.ts +++ b/x-pack/plugins/cases/server/services/attachments/index.ts @@ -9,6 +9,7 @@ import { Logger, SavedObject, SavedObjectReference, + SavedObjectsClientContract, SavedObjectsUpdateOptions, } from 'kibana/server'; @@ -62,6 +63,11 @@ interface BulkUpdateAttachmentArgs extends ClientArgs { comments: UpdateArgs[]; } +interface CommentStats { + nonAlerts: number; + alerts: number; +} + export class AttachmentService { constructor(private readonly log: Logger) {} @@ -279,4 +285,104 @@ export class AttachmentService { throw error; } } + + public async getCaseCommentStats({ + unsecuredSavedObjectsClient, + caseIds, + }: { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + caseIds: string[]; + }): Promise> { + if (caseIds.length <= 0) { + return new Map(); + } + + interface AggsResult { + references: { + caseIds: { + buckets: Array<{ + key: string; + doc_count: number; + reverse: { + comments: { + buckets: { + alerts: { + doc_count: number; + }; + nonAlerts: { + doc_count: number; + }; + }; + }; + }; + }>; + }; + }; + } + + const res = await unsecuredSavedObjectsClient.find({ + hasReference: caseIds.map((id) => ({ type: CASE_SAVED_OBJECT, id })), + hasReferenceOperator: 'OR', + type: CASE_COMMENT_SAVED_OBJECT, + perPage: 0, + aggs: AttachmentService.buildCommentStatsAggs(caseIds), + }); + + return ( + res.aggregations?.references.caseIds.buckets.reduce((acc, idBucket) => { + acc.set(idBucket.key, { + nonAlerts: idBucket.reverse.comments.buckets.nonAlerts.doc_count, + alerts: idBucket.reverse.comments.buckets.alerts.doc_count, + }); + return acc; + }, new Map()) ?? new Map() + ); + } + + private static buildCommentStatsAggs( + ids: string[] + ): Record { + return { + references: { + nested: { + path: `${CASE_COMMENT_SAVED_OBJECT}.references`, + }, + aggregations: { + caseIds: { + terms: { + field: `${CASE_COMMENT_SAVED_OBJECT}.references.id`, + size: ids.length, + }, + aggregations: { + reverse: { + reverse_nested: {}, + aggregations: { + comments: { + filters: { + filters: { + alerts: { + term: { + [`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`]: CommentType.alert, + }, + }, + nonAlerts: { + bool: { + must_not: { + term: { + [`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`]: CommentType.alert, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + } } diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts index 10026682e68c8..5ed2d2978f154 100644 --- a/x-pack/plugins/cases/server/services/cases/index.test.ts +++ b/x-pack/plugins/cases/server/services/cases/index.test.ts @@ -40,6 +40,7 @@ import { createSOFindResponse, } from '../test_utils'; import { ESCaseAttributes } from './types'; +import { AttachmentService } from '../attachments'; const createUpdateSOResponse = ({ connector, @@ -117,12 +118,17 @@ const createCasePatchParams = ({ describe('CasesService', () => { const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const mockLogger = loggerMock.create(); + const attachmentService = new AttachmentService(mockLogger); let service: CasesService; beforeEach(() => { jest.resetAllMocks(); - service = new CasesService(mockLogger); + service = new CasesService({ + log: mockLogger, + unsecuredSavedObjectsClient, + attachmentService, + }); }); describe('transforms the external model to the Elasticsearch model', () => { @@ -134,7 +140,6 @@ describe('CasesService', () => { await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), originalCase: {} as SavedObject, }); @@ -181,7 +186,6 @@ describe('CasesService', () => { await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), originalCase: {} as SavedObject, }); @@ -213,7 +217,6 @@ describe('CasesService', () => { await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), originalCase: {} as SavedObject, }); @@ -249,7 +252,6 @@ describe('CasesService', () => { await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(createJiraConnector()), originalCase: {} as SavedObject, }); @@ -278,7 +280,6 @@ describe('CasesService', () => { await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCasePostParams(getNoneCaseConnector(), createExternalService()), originalCase: {} as SavedObject, }); @@ -307,7 +308,6 @@ describe('CasesService', () => { await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), originalCase: { references: [{ id: 'a', name: 'awesome', type: 'hello' }], @@ -344,7 +344,6 @@ describe('CasesService', () => { await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCasePatchParams({ externalService: createExternalService() }), originalCase: { references: [ @@ -378,7 +377,6 @@ describe('CasesService', () => { await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCasePostParams(getNoneCaseConnector(), createExternalService()), originalCase: {} as SavedObject, }); @@ -408,7 +406,6 @@ describe('CasesService', () => { await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(), originalCase: {} as SavedObject, }); @@ -428,7 +425,6 @@ describe('CasesService', () => { await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(getNoneCaseConnector()), originalCase: {} as SavedObject, }); @@ -452,7 +448,6 @@ describe('CasesService', () => { ); await service.postNewCase({ - unsecuredSavedObjectsClient, attributes: createCasePostParams(createJiraConnector()), id: '1', }); @@ -468,7 +463,6 @@ describe('CasesService', () => { ); await service.postNewCase({ - unsecuredSavedObjectsClient, attributes: createCasePostParams(createJiraConnector(), createExternalService()), id: '1', }); @@ -560,7 +554,6 @@ describe('CasesService', () => { ); await service.postNewCase({ - unsecuredSavedObjectsClient, attributes: createCasePostParams(createJiraConnector(), createExternalService()), id: '1', }); @@ -589,7 +582,6 @@ describe('CasesService', () => { ); await service.postNewCase({ - unsecuredSavedObjectsClient, attributes: createCasePostParams( createJiraConnector({ setFieldsToNull: true }), createExternalService() @@ -608,7 +600,6 @@ describe('CasesService', () => { ); await service.postNewCase({ - unsecuredSavedObjectsClient, attributes: createCasePostParams(getNoneCaseConnector()), id: '1', }); @@ -624,7 +615,6 @@ describe('CasesService', () => { ); await service.postNewCase({ - unsecuredSavedObjectsClient, attributes: createCasePostParams(getNoneCaseConnector()), id: '1', }); @@ -655,7 +645,6 @@ describe('CasesService', () => { ); const res = await service.patchCases({ - unsecuredSavedObjectsClient, cases: [ { caseId: '1', @@ -710,7 +699,6 @@ describe('CasesService', () => { const res = await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(), originalCase: {} as SavedObject, }); @@ -735,7 +723,6 @@ describe('CasesService', () => { const res = await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(), originalCase: {} as SavedObject, }); @@ -755,7 +742,6 @@ describe('CasesService', () => { const res = await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(), originalCase: {} as SavedObject, }); @@ -771,7 +757,6 @@ describe('CasesService', () => { const res = await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(), originalCase: {} as SavedObject, }); @@ -803,7 +788,6 @@ describe('CasesService', () => { const res = await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(), originalCase: {} as SavedObject, }); @@ -834,7 +818,6 @@ describe('CasesService', () => { const res = await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(), originalCase: {} as SavedObject, }); @@ -858,7 +841,6 @@ describe('CasesService', () => { const res = await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(), originalCase: {} as SavedObject, }); @@ -895,7 +877,6 @@ describe('CasesService', () => { const res = await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(), originalCase: {} as SavedObject, }); @@ -922,7 +903,6 @@ describe('CasesService', () => { const res = await service.patchCase({ caseId: '1', - unsecuredSavedObjectsClient, updatedAttributes: createCaseUpdateParams(), originalCase: {} as SavedObject, }); @@ -958,7 +938,6 @@ describe('CasesService', () => { ); const res = await service.postNewCase({ - unsecuredSavedObjectsClient, attributes: createCasePostParams(getNoneCaseConnector()), id: '1', }); @@ -979,7 +958,7 @@ describe('CasesService', () => { ]); unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); - const res = await service.findCases({ unsecuredSavedObjectsClient }); + const res = await service.findCases(); expect(res.saved_objects[0].attributes.connector.id).toMatchInlineSnapshot(`"1"`); expect( res.saved_objects[0].attributes.external_service?.connector_id @@ -996,7 +975,7 @@ describe('CasesService', () => { ]); unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); - const res = await service.findCases({ unsecuredSavedObjectsClient }); + const res = await service.findCases(); const { saved_objects: ignored, ...findResponseFields } = res; expect(findResponseFields).toMatchInlineSnapshot(` Object { @@ -1025,7 +1004,7 @@ describe('CasesService', () => { }) ); - const res = await service.getCases({ unsecuredSavedObjectsClient, caseIds: ['a'] }); + const res = await service.getCases({ caseIds: ['a'] }); expect(res.saved_objects[0].attributes.connector.id).toMatchInlineSnapshot(`"1"`); expect( @@ -1050,7 +1029,7 @@ describe('CasesService', () => { ) ); - const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + const res = await service.getCase({ id: 'a' }); expect(res.attributes.connector.id).toMatchInlineSnapshot(`"1"`); expect(res.attributes.external_service?.connector_id).toMatchInlineSnapshot(`"100"`); @@ -1062,7 +1041,7 @@ describe('CasesService', () => { createCaseSavedObjectResponse({ externalService: createExternalService() }) ) ); - const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + const res = await service.getCase({ id: 'a' }); expect(res.attributes.connector).toMatchInlineSnapshot(` Object { @@ -1078,7 +1057,7 @@ describe('CasesService', () => { unsecuredSavedObjectsClient.get.mockReturnValue( Promise.resolve(createCaseSavedObjectResponse()) ); - const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + const res = await service.getCase({ id: 'a' }); expect(res.attributes.external_service?.connector_id).toMatchInlineSnapshot(`"none"`); }); @@ -1087,7 +1066,7 @@ describe('CasesService', () => { unsecuredSavedObjectsClient.get.mockReturnValue( Promise.resolve(createCaseSavedObjectResponse()) ); - const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + const res = await service.getCase({ id: 'a' }); expect(res.attributes.external_service).toMatchInlineSnapshot(` Object { @@ -1118,7 +1097,7 @@ describe('CasesService', () => { ], } as unknown as SavedObject) ); - const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + const res = await service.getCase({ id: 'a' }); expect(res.attributes.connector).toMatchInlineSnapshot(` Object { @@ -1138,7 +1117,7 @@ describe('CasesService', () => { attributes: { external_service: null }, } as SavedObject) ); - const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + const res = await service.getCase({ id: 'a' }); expect(res.attributes.connector).toMatchInlineSnapshot(` Object { diff --git a/x-pack/plugins/cases/server/services/cases/index.ts b/x-pack/plugins/cases/server/services/cases/index.ts index 3e23a3989eed3..832d12071b466 100644 --- a/x-pack/plugins/cases/server/services/cases/index.ts +++ b/x-pack/plugins/cases/server/services/cases/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import pMap from 'p-map'; import { KibanaRequest, Logger, @@ -26,7 +25,6 @@ import { SecurityPluginSetup } from '../../../../security/server'; import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, - MAX_CONCURRENT_SEARCHES, MAX_DOCS_PER_PAGE, } from '../../../common/constants'; import { @@ -34,17 +32,16 @@ import { CaseResponse, CasesFindRequest, CommentAttributes, - CommentType, User, CaseAttributes, + CaseStatuses, + caseStatuses, } from '../../../common/api'; import { SavedObjectFindOptionsKueryNode } from '../../common/types'; -import { defaultSortField, flattenCaseSavedObject, groupTotalAlertsByID } from '../../common/utils'; +import { defaultSortField, flattenCaseSavedObject } from '../../common/utils'; import { defaultPage, defaultPerPage } from '../../routes/api'; -import { ClientArgs } from '..'; import { combineFilters } from '../../client/utils'; import { includeFieldsRequiredForAuthentication } from '../../authorization/utils'; -import { EnsureSOAuthCallback } from '../../authorization'; import { transformSavedObjectToExternalModel, transformAttributesToESModel, @@ -54,8 +51,9 @@ import { transformFindResponseToExternalModel, } from './transform'; import { ESCaseAttributes } from './types'; +import { AttachmentService } from '../attachments'; -interface GetCaseIdsByAlertIdArgs extends ClientArgs { +interface GetCaseIdsByAlertIdArgs { alertId: string; filter?: KueryNode; } @@ -65,31 +63,25 @@ interface PushedArgs { pushed_by: User; } -interface GetCaseArgs extends ClientArgs { +interface GetCaseArgs { id: string; } -interface GetCasesArgs extends ClientArgs { +interface GetCasesArgs { caseIds: string[]; } interface FindCommentsArgs { - unsecuredSavedObjectsClient: SavedObjectsClientContract; id: string | string[]; options?: SavedObjectFindOptionsKueryNode; } interface FindCaseCommentsArgs { - unsecuredSavedObjectsClient: SavedObjectsClientContract; id: string | string[]; options?: SavedObjectFindOptionsKueryNode; } -interface FindCasesArgs extends ClientArgs { - options?: SavedObjectFindOptionsKueryNode; -} - -interface PostCaseArgs extends ClientArgs { +interface PostCaseArgs { attributes: CaseAttributes; id: string; } @@ -100,9 +92,9 @@ interface PatchCase { originalCase: SavedObject; version?: string; } -type PatchCaseArgs = PatchCase & ClientArgs; +type PatchCaseArgs = PatchCase; -interface PatchCasesArgs extends ClientArgs { +interface PatchCasesArgs { cases: PatchCase[]; } @@ -110,11 +102,6 @@ interface GetUserArgs { request: KibanaRequest; } -interface CaseCommentStats { - commentTotals: Map; - alertTotals: Map; -} - interface CasesMapWithPageInfo { casesMap: Map; page: number; @@ -135,10 +122,27 @@ interface GetReportersArgs { } export class CasesService { - constructor( - private readonly log: Logger, - private readonly authentication?: SecurityPluginSetup['authc'] - ) {} + private readonly log: Logger; + private readonly authentication?: SecurityPluginSetup['authc']; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + private readonly attachmentService: AttachmentService; + + constructor({ + log, + authentication, + unsecuredSavedObjectsClient, + attachmentService, + }: { + log: Logger; + authentication?: SecurityPluginSetup['authc']; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + attachmentService: AttachmentService; + }) { + this.log = log; + this.authentication = authentication; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + this.attachmentService = attachmentService; + } private buildCaseIdsAggs = ( size: number = 100 @@ -159,7 +163,6 @@ export class CasesService { }); public async getCaseIdsByAlertId({ - unsecuredSavedObjectsClient, alertId, filter, }: GetCaseIdsByAlertIdArgs): Promise< @@ -172,7 +175,7 @@ export class CasesService { filter, ]); - const response = await unsecuredSavedObjectsClient.find< + const response = await this.unsecuredSavedObjectsClient.find< CommentAttributes, GetCaseIdsByAlertIdAggs >({ @@ -204,35 +207,32 @@ export class CasesService { * Returns a map of all cases. */ public async findCasesGroupedByID({ - unsecuredSavedObjectsClient, caseOptions, }: { - unsecuredSavedObjectsClient: SavedObjectsClientContract; caseOptions: FindCaseOptions; }): Promise { - const cases = await this.findCases({ - unsecuredSavedObjectsClient, - options: caseOptions, - }); + const cases = await this.findCases(caseOptions); const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { accMap.set(caseInfo.id, caseInfo); return accMap; }, new Map>()); - const totalCommentsForCases = await this.getCaseCommentStats({ - unsecuredSavedObjectsClient, - ids: Array.from(casesMap.keys()), + const commentTotals = await this.attachmentService.getCaseCommentStats({ + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + caseIds: Array.from(casesMap.keys()), }); const casesWithComments = new Map(); for (const [id, caseInfo] of casesMap.entries()) { + const { alerts, nonAlerts } = commentTotals.get(id) ?? { alerts: 0, nonAlerts: 0 }; + casesWithComments.set( id, flattenCaseSavedObject({ savedObject: caseInfo, - totalComment: totalCommentsForCases.commentTotals.get(id) ?? 0, - totalAlerts: totalCommentsForCases.alertTotals.get(id) ?? 0, + totalComment: nonAlerts, + totalAlerts: alerts, }) ); } @@ -245,107 +245,69 @@ export class CasesService { }; } - /** - * Retrieves the number of cases that exist with a given status (open, closed, etc). - */ - public async findCaseStatusStats({ - unsecuredSavedObjectsClient, - caseOptions, - ensureSavedObjectsAreAuthorized, - }: { - unsecuredSavedObjectsClient: SavedObjectsClientContract; - caseOptions: SavedObjectFindOptionsKueryNode; - ensureSavedObjectsAreAuthorized: EnsureSOAuthCallback; - }): Promise { - const cases = await this.findCases({ - unsecuredSavedObjectsClient, - options: { - ...caseOptions, - page: 1, - perPage: MAX_DOCS_PER_PAGE, - }, - }); - - // make sure that the retrieved cases were correctly filtered by owner - ensureSavedObjectsAreAuthorized( - cases.saved_objects.map((caseInfo) => ({ id: caseInfo.id, owner: caseInfo.attributes.owner })) - ); - - return cases.saved_objects.length; - } - - /** - * Returns the number of total comments and alerts for a case - */ - public async getCaseCommentStats({ - unsecuredSavedObjectsClient, - ids, + public async getCaseStatusStats({ + searchOptions, }: { - unsecuredSavedObjectsClient: SavedObjectsClientContract; - ids: string[]; - }): Promise { - if (ids.length <= 0) { - return { - commentTotals: new Map(), - alertTotals: new Map(), - }; - } - - const getCommentsMapper = async (id: string) => - this.getAllCaseComments({ - unsecuredSavedObjectsClient, - id, - options: { page: 1, perPage: 1 }, - }); - - // Ensuring we don't do too many concurrent get running. - const allComments = await pMap(ids, getCommentsMapper, { - concurrency: MAX_CONCURRENT_SEARCHES, - }); - - const alerts = await this.getAllCaseComments({ - unsecuredSavedObjectsClient, - id: ids, - options: { - filter: nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.alert), + searchOptions: SavedObjectFindOptionsKueryNode; + }): Promise<{ + [status in CaseStatuses]: number; + }> { + const cases = await this.unsecuredSavedObjectsClient.find< + ESCaseAttributes, + { + statuses: { + buckets: Array<{ + key: string; + doc_count: number; + }>; + }; + } + >({ + ...searchOptions, + type: CASE_SAVED_OBJECT, + perPage: 0, + aggs: { + statuses: { + terms: { + field: `${CASE_SAVED_OBJECT}.attributes.status`, + size: caseStatuses.length, + order: { _key: 'asc' }, + }, + }, }, }); - const getID = (comments: SavedObjectsFindResponse) => { - return comments.saved_objects.length > 0 - ? comments.saved_objects[0].references.find((ref) => ref.type === CASE_SAVED_OBJECT)?.id - : undefined; + const statusBuckets = CasesService.getStatusBuckets(cases.aggregations?.statuses.buckets); + return { + open: statusBuckets?.get('open') ?? 0, + 'in-progress': statusBuckets?.get('in-progress') ?? 0, + closed: statusBuckets?.get('closed') ?? 0, }; + } - const groupedComments = allComments.reduce((acc, comments) => { - const id = getID(comments); - if (id) { - acc.set(id, comments.total); - } + private static getStatusBuckets( + buckets: Array<{ key: string; doc_count: number }> | undefined + ): Map | undefined { + return buckets?.reduce((acc, bucket) => { + acc.set(bucket.key, bucket.doc_count); return acc; }, new Map()); - - const groupedAlerts = groupTotalAlertsByID({ comments: alerts }); - return { commentTotals: groupedComments, alertTotals: groupedAlerts }; } - public async deleteCase({ unsecuredSavedObjectsClient, id: caseId }: GetCaseArgs) { + public async deleteCase({ id: caseId }: GetCaseArgs) { try { this.log.debug(`Attempting to DELETE case ${caseId}`); - return await unsecuredSavedObjectsClient.delete(CASE_SAVED_OBJECT, caseId); + return await this.unsecuredSavedObjectsClient.delete(CASE_SAVED_OBJECT, caseId); } catch (error) { this.log.error(`Error on DELETE case ${caseId}: ${error}`); throw error; } } - public async getCase({ - unsecuredSavedObjectsClient, - id: caseId, - }: GetCaseArgs): Promise> { + public async getCase({ id: caseId }: GetCaseArgs): Promise> { try { this.log.debug(`Attempting to GET case ${caseId}`); - const caseSavedObject = await unsecuredSavedObjectsClient.get( + const caseSavedObject = await this.unsecuredSavedObjectsClient.get( CASE_SAVED_OBJECT, caseId ); @@ -357,12 +319,11 @@ export class CasesService { } public async getResolveCase({ - unsecuredSavedObjectsClient, id: caseId, }: GetCaseArgs): Promise> { try { this.log.debug(`Attempting to resolve case ${caseId}`); - const resolveCaseResult = await unsecuredSavedObjectsClient.resolve( + const resolveCaseResult = await this.unsecuredSavedObjectsClient.resolve( CASE_SAVED_OBJECT, caseId ); @@ -377,12 +338,11 @@ export class CasesService { } public async getCases({ - unsecuredSavedObjectsClient, caseIds, }: GetCasesArgs): Promise> { try { this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); - const cases = await unsecuredSavedObjectsClient.bulkGet( + const cases = await this.unsecuredSavedObjectsClient.bulkGet( caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) ); return transformBulkResponseToExternalModel(cases); @@ -392,13 +352,12 @@ export class CasesService { } } - public async findCases({ - unsecuredSavedObjectsClient, - options, - }: FindCasesArgs): Promise> { + public async findCases( + options?: SavedObjectFindOptionsKueryNode + ): Promise> { try { this.log.debug(`Attempting to find cases`); - const cases = await unsecuredSavedObjectsClient.find({ + const cases = await this.unsecuredSavedObjectsClient.find({ sortField: defaultSortField, ...options, type: CASE_SAVED_OBJECT, @@ -421,21 +380,20 @@ export class CasesService { } private async getAllComments({ - unsecuredSavedObjectsClient, id, options, }: FindCommentsArgs): Promise> { try { this.log.debug(`Attempting to GET all comments internal for id ${JSON.stringify(id)}`); if (options?.page !== undefined || options?.perPage !== undefined) { - return unsecuredSavedObjectsClient.find({ + return this.unsecuredSavedObjectsClient.find({ type: CASE_COMMENT_SAVED_OBJECT, sortField: defaultSortField, ...options, }); } - return unsecuredSavedObjectsClient.find({ + return this.unsecuredSavedObjectsClient.find({ type: CASE_COMMENT_SAVED_OBJECT, page: 1, perPage: MAX_DOCS_PER_PAGE, @@ -453,7 +411,6 @@ export class CasesService { * to override this pass in the either the page or perPage options. */ public async getAllCaseComments({ - unsecuredSavedObjectsClient, id, options, }: FindCaseCommentsArgs): Promise> { @@ -470,7 +427,6 @@ export class CasesService { this.log.debug(`Attempting to GET all comments for case caseID ${JSON.stringify(id)}`); return await this.getAllComments({ - unsecuredSavedObjectsClient, id, options: { hasReferenceOperator: 'OR', @@ -485,14 +441,11 @@ export class CasesService { } } - public async getReporters({ - unsecuredSavedObjectsClient, - filter, - }: GetReportersArgs): Promise { + public async getReporters({ filter }: GetReportersArgs): Promise { try { this.log.debug(`Attempting to GET all reporters`); - const results = await unsecuredSavedObjectsClient.find< + const results = await this.unsecuredSavedObjectsClient.find< ESCaseAttributes, { reporters: { @@ -549,11 +502,11 @@ export class CasesService { } } - public async getTags({ unsecuredSavedObjectsClient, filter }: GetTagsArgs): Promise { + public async getTags({ filter }: GetTagsArgs): Promise { try { this.log.debug(`Attempting to GET all cases`); - const results = await unsecuredSavedObjectsClient.find< + const results = await this.unsecuredSavedObjectsClient.find< ESCaseAttributes, { tags: { buckets: Array<{ key: string }> } } >({ @@ -604,15 +557,11 @@ export class CasesService { } } - public async postNewCase({ - unsecuredSavedObjectsClient, - attributes, - id, - }: PostCaseArgs): Promise> { + public async postNewCase({ attributes, id }: PostCaseArgs): Promise> { try { this.log.debug(`Attempting to POST a new case`); const transformedAttributes = transformAttributesToESModel(attributes); - const createdCase = await unsecuredSavedObjectsClient.create( + const createdCase = await this.unsecuredSavedObjectsClient.create( CASE_SAVED_OBJECT, transformedAttributes.attributes, { id, references: transformedAttributes.referenceHandler.build() } @@ -625,7 +574,6 @@ export class CasesService { } public async patchCase({ - unsecuredSavedObjectsClient, caseId, updatedAttributes, originalCase, @@ -635,7 +583,7 @@ export class CasesService { this.log.debug(`Attempting to UPDATE case ${caseId}`); const transformedAttributes = transformAttributesToESModel(updatedAttributes); - const updatedCase = await unsecuredSavedObjectsClient.update( + const updatedCase = await this.unsecuredSavedObjectsClient.update( CASE_SAVED_OBJECT, caseId, transformedAttributes.attributes, @@ -653,7 +601,6 @@ export class CasesService { } public async patchCases({ - unsecuredSavedObjectsClient, cases, }: PatchCasesArgs): Promise> { try { @@ -670,7 +617,7 @@ export class CasesService { }; }); - const updatedCases = await unsecuredSavedObjectsClient.bulkUpdate( + const updatedCases = await this.unsecuredSavedObjectsClient.bulkUpdate( bulkUpdate ); return transformUpdateResponsesToExternalModels(updatedCases); diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index 4c7210224c929..8e21db9ccb4e0 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -37,9 +37,8 @@ export const createCaseServiceMock = (): CaseServiceMock => { postNewCase: jest.fn(), patchCase: jest.fn(), patchCases: jest.fn(), - getCaseCommentStats: jest.fn(), - findCaseStatusStats: jest.fn(), findCasesGroupedByID: jest.fn(), + getCaseStatusStats: jest.fn(), }; // the cast here is required because jest.Mocked tries to include private members and would throw an error @@ -108,6 +107,7 @@ export const createAttachmentServiceMock = (): AttachmentServiceMock => { getAllAlertsAttachToCase: jest.fn(), countAlertsAttachedToCase: jest.fn(), executeCaseActionsAggregations: jest.fn(), + getCaseCommentStats: jest.fn(), }; // the cast here is required because jest.Mocked tries to include private members and would throw an error From 2f869baf1836e3004d5c1e1f6c3f30a6e758ad68 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 4 Feb 2022 21:18:13 +0100 Subject: [PATCH 11/12] [Lens] Expose vis registration (#122348) * expose vis registration * add example app * remove file * fix and stabilize * tsconfig fix * fix type problems * handle migrations * fix problems * fix tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../.eslintrc.json | 5 + .../third_party_vis_lens_example/README.md | 23 +++ .../common/constants.ts | 8 + .../common/types.ts | 12 ++ .../third_party_vis_lens_example/kibana.json | 24 +++ .../third_party_vis_lens_example/package.json | 14 ++ .../public/expression.tsx | 133 +++++++++++++ .../public/index.ts | 10 + .../public/plugin.ts | 138 +++++++++++++ .../public/visualization.tsx | 183 ++++++++++++++++++ .../server/index.ts | 11 ++ .../server/plugin.ts | 42 ++++ .../tsconfig.json | 24 +++ .../plugins/cases/server/common/utils.test.ts | 4 +- .../migrations/comments.test.ts | 2 +- .../datatable_visualization/visualization.tsx | 3 + .../editor_frame/editor_frame.tsx | 4 + .../editor_frame/state_helpers.ts | 7 + .../workspace_panel/chart_switch.tsx | 11 +- .../workspace_panel/workspace_panel.tsx | 18 +- .../editor_frame_service/error_helper.ts | 14 ++ .../lens/public/embeddable/embeddable.tsx | 16 +- .../embeddable/embeddable_component.tsx | 3 +- x-pack/plugins/lens/public/index.ts | 5 +- .../pie_visualization/visualization.tsx | 3 + x-pack/plugins/lens/public/plugin.ts | 37 +++- x-pack/plugins/lens/public/types.ts | 4 + .../public/xy_visualization/visualization.tsx | 3 + .../make_lens_embeddable_factory.test.ts | 76 +++++++- .../make_lens_embeddable_factory.ts | 103 +++++----- .../server/migrations/common_migrations.ts | 53 ++++- .../saved_object_migrations.test.ts | 78 +++++++- .../migrations/saved_object_migrations.ts | 12 +- .../plugins/lens/server/migrations/types.ts | 3 + x-pack/plugins/lens/server/plugin.tsx | 30 ++- x-pack/plugins/lens/server/saved_objects.ts | 6 +- 36 files changed, 1023 insertions(+), 99 deletions(-) create mode 100644 x-pack/examples/third_party_vis_lens_example/.eslintrc.json create mode 100644 x-pack/examples/third_party_vis_lens_example/README.md create mode 100644 x-pack/examples/third_party_vis_lens_example/common/constants.ts create mode 100644 x-pack/examples/third_party_vis_lens_example/common/types.ts create mode 100644 x-pack/examples/third_party_vis_lens_example/kibana.json create mode 100644 x-pack/examples/third_party_vis_lens_example/package.json create mode 100644 x-pack/examples/third_party_vis_lens_example/public/expression.tsx create mode 100644 x-pack/examples/third_party_vis_lens_example/public/index.ts create mode 100644 x-pack/examples/third_party_vis_lens_example/public/plugin.ts create mode 100644 x-pack/examples/third_party_vis_lens_example/public/visualization.tsx create mode 100644 x-pack/examples/third_party_vis_lens_example/server/index.ts create mode 100644 x-pack/examples/third_party_vis_lens_example/server/plugin.ts create mode 100644 x-pack/examples/third_party_vis_lens_example/tsconfig.json diff --git a/x-pack/examples/third_party_vis_lens_example/.eslintrc.json b/x-pack/examples/third_party_vis_lens_example/.eslintrc.json new file mode 100644 index 0000000000000..2aab6c2d9093b --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "@typescript-eslint/consistent-type-definitions": 0 + } +} diff --git a/x-pack/examples/third_party_vis_lens_example/README.md b/x-pack/examples/third_party_vis_lens_example/README.md new file mode 100644 index 0000000000000..f2fa563f33393 --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/README.md @@ -0,0 +1,23 @@ +# Third party Lens visualization + +To run this example plugin, use the command `yarn start --run-examples`. + +This example shows how to register a visualization to Lens which lives along the regular visualizations (xy, table and so on). + +The following parts can be seen in this example: +* Registering the visualization type so it shows up in the Lens editor along with custom edit UI and hooks to update state on user interactions (add dimension, delete dimension). +* Registering the used expression functions and expression renderers to actually render the expression into a DOM element. +* Providing a sample migration on the Kibana server which allows to update existing stored visualizations and change their state on Kibana upgrade / import of old saved objects. + + +To test the migration, you can import the following ndjson file via saved object import (requires installed logs sample data): +
+ Click to expand + +``` +{"attributes":{"fieldFormatMap":"{\"hour_of_day\":{}}","runtimeFieldMap":"{\"hour_of_day\":{\"type\":\"long\",\"script\":{\"source\":\"emit(doc['timestamp'].value.getHour());\"}}}","timeFieldName":"timestamp","title":"kibana_sample_data_logs"},"coreMigrationVersion":"8.0.0","id":"90943e30-9a47-11e8-b64d-95841ca0b247","migrationVersion":{"index-pattern":"8.0.0"},"references":[],"type":"index-pattern","updated_at":"2022-01-24T10:54:24.209Z","version":"WzQzMTQ3LDFd"} +{"attributes":{"description":"","state":{"datasourceStates":{"indexpattern":{"layers":{"f2700077-50bf-48e4-829c-f695f87e226d":{"columnOrder":["5e704cac-8490-457a-b635-01f3a5a132b7"],"columns":{"5e704cac-8490-457a-b635-01f3a5a132b7":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"}},"incompleteColumns":{}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"column":"5e704cac-8490-457a-b635-01f3a5a132b7","layerId":"f2700077-50bf-48e4-829c-f695f87e226d"}},"title":"Rotating number test","visualizationType":"rotatingNumber"},"coreMigrationVersion":"8.0.0","id":"468f0be0-7e86-11ec-9739-d570ffd3fbe4","migrationVersion":{"lens":"8.0.0"},"references":[{"id":"90943e30-9a47-11e8-b64d-95841ca0b247","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"90943e30-9a47-11e8-b64d-95841ca0b247","name":"indexpattern-datasource-layer-f2700077-50bf-48e4-829c-f695f87e226d","type":"index-pattern"}],"type":"lens","updated_at":"2022-01-26T08:59:31.618Z","version":"WzQzNjUzLDFd"} +{"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":2,"missingRefCount":0,"missingReferences":[]} +``` + +
\ No newline at end of file diff --git a/x-pack/examples/third_party_vis_lens_example/common/constants.ts b/x-pack/examples/third_party_vis_lens_example/common/constants.ts new file mode 100644 index 0000000000000..216ad8b1d0e34 --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/common/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const DEFAULT_COLOR = '#000000'; diff --git a/x-pack/examples/third_party_vis_lens_example/common/types.ts b/x-pack/examples/third_party_vis_lens_example/common/types.ts new file mode 100644 index 0000000000000..b4afef06fb482 --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/common/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface RotatingNumberState { + accessor?: string; + color: string; + layerId: string; +} diff --git a/x-pack/examples/third_party_vis_lens_example/kibana.json b/x-pack/examples/third_party_vis_lens_example/kibana.json new file mode 100644 index 0000000000000..858466f7f7640 --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/kibana.json @@ -0,0 +1,24 @@ +{ + "id": "thirdPartyVisLensExample", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["third_part_vis_lens_example"], + "server": true, + "ui": true, + "requiredPlugins": [ + "lens", + "dataViews", + "embeddable", + "developerExamples", + "expressions", + "fieldFormats" + ], + "optionalPlugins": [], + "requiredBundles": [ + "kibanaReact" + ], + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + } +} diff --git a/x-pack/examples/third_party_vis_lens_example/package.json b/x-pack/examples/third_party_vis_lens_example/package.json new file mode 100644 index 0000000000000..5892f4e508f2d --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/package.json @@ -0,0 +1,14 @@ +{ + "name": "third_party_vis_lens_example", + "version": "1.0.0", + "main": "target/examples/third_party_vis_lens_example", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Elastic License 2.0", + "scripts": { + "kbn": "node ../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/x-pack/examples/third_party_vis_lens_example/public/expression.tsx b/x-pack/examples/third_party_vis_lens_example/public/expression.tsx new file mode 100644 index 0000000000000..6e38f2e582209 --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/public/expression.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { css, keyframes } from '@emotion/css'; +import type { + Datatable, + ExpressionFunctionDefinition, + ExpressionRenderDefinition, + IInterpreterRenderHandlers, +} from '../../../../src/plugins/expressions/public'; +import { RotatingNumberState } from '../common/types'; +import { FormatFactory } from '../../../../src/plugins/field_formats/common'; + +export const getRotatingNumberRenderer = ( + formatFactory: Promise +): ExpressionRenderDefinition => ({ + name: 'rotating_number', + displayName: 'Rotating number', + help: 'Rotating number renderer', + validate: () => undefined, + reuseDomNode: true, + render: async ( + domNode: Element, + config: RotatingNumberChartProps, + handlers: IInterpreterRenderHandlers + ) => { + ReactDOM.render( + , + domNode, + () => { + handlers.done(); + } + ); + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); + +const rotating = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +`; + +function RotatingNumberChart({ + data, + args, + formatFactory, +}: RotatingNumberChartProps & { formatFactory: FormatFactory }) { + const { accessor, color } = args; + const column = data.columns.find((col) => col.id === accessor); + const rawValue = accessor && data.rows[0]?.[accessor]; + + const value = + column && column.meta?.params + ? formatFactory(column.meta?.params).convert(rawValue) + : Number(Number(rawValue).toFixed(3)).toString(); + + return ( +
+
+ {value} +
+
+ ); +} +export interface RotatingNumberChartProps { + data: Datatable; + args: RotatingNumberState; +} + +interface RotatingNumberRender { + type: 'render'; + as: 'rotating_number'; + value: RotatingNumberChartProps; +} + +export const rotatingNumberFunction: ExpressionFunctionDefinition< + 'rotating_number', + Datatable, + Omit, + RotatingNumberRender +> = { + name: 'rotating_number', + type: 'render', + help: 'A rotating number', + args: { + accessor: { + types: ['string'], + help: 'The column whose value is being displayed', + }, + color: { + types: ['string'], + help: 'Color of the number', + }, + }, + inputTypes: ['datatable'], + fn(data, args) { + return { + type: 'render', + as: 'rotating_number', + value: { + data, + args, + }, + } as RotatingNumberRender; + }, +}; diff --git a/x-pack/examples/third_party_vis_lens_example/public/index.ts b/x-pack/examples/third_party_vis_lens_example/public/index.ts new file mode 100644 index 0000000000000..afe76682f6123 --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/public/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EmbeddedLensExamplePlugin } from './plugin'; + +export const plugin = () => new EmbeddedLensExamplePlugin(); diff --git a/x-pack/examples/third_party_vis_lens_example/public/plugin.ts b/x-pack/examples/third_party_vis_lens_example/public/plugin.ts new file mode 100644 index 0000000000000..f185f0ee28efb --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/public/plugin.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExpressionsSetup } from 'src/plugins/expressions/public'; +import { FieldFormatsStart } from 'src/plugins/field_formats/public'; +import { Plugin, CoreSetup, AppNavLinkStatus } from '../../../../src/core/public'; +import { DataViewsPublicPluginStart, DataView } from '../../../../src/plugins/data_views/public'; +import { LensPublicSetup, LensPublicStart } from '../../../plugins/lens/public'; +import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public'; +import { TypedLensByValueInput, PersistedIndexPatternLayer } from '../../../plugins/lens/public'; +import { getRotatingNumberRenderer, rotatingNumberFunction } from './expression'; +import { getRotatingNumberVisualization } from './visualization'; +import { RotatingNumberState } from '../common/types'; + +export interface SetupDependencies { + developerExamples: DeveloperExamplesSetup; + lens: LensPublicSetup; + expressions: ExpressionsSetup; +} + +export interface StartDependencies { + dataViews: DataViewsPublicPluginStart; + lens: LensPublicStart; + fieldFormats: FieldFormatsStart; +} + +function getLensAttributes(defaultDataView: DataView): TypedLensByValueInput['attributes'] { + const dataLayer: PersistedIndexPatternLayer = { + columnOrder: ['col1'], + columns: { + col1: { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + operationType: 'count', + scale: 'ratio', + sourceField: 'Records', + }, + }, + }; + + const rotatingNumberConfig: RotatingNumberState = { + accessor: 'col1', + color: '#ff0000', + layerId: 'layer1', + }; + + return { + visualizationType: 'rotatingNumber', + title: 'Prefilled from example app', + references: [ + { + id: defaultDataView.id!, + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: defaultDataView.id!, + name: 'indexpattern-datasource-layer-layer1', + type: 'index-pattern', + }, + ], + state: { + datasourceStates: { + indexpattern: { + layers: { + layer1: dataLayer, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: rotatingNumberConfig, + }, + }; +} + +export class EmbeddedLensExamplePlugin + implements Plugin +{ + public setup( + core: CoreSetup, + { developerExamples, lens, expressions }: SetupDependencies + ) { + core.application.register({ + id: 'third_party_lens_vis_example', + title: 'Third party Lens vis example', + navLinkStatus: AppNavLinkStatus.hidden, + mount: (params) => { + (async () => { + const [, { lens: lensStart, dataViews }] = await core.getStartServices(); + const defaultDataView = await dataViews.getDefault(); + lensStart.navigateToPrefilledEditor({ + id: '', + timeRange: { + from: 'now-5d', + to: 'now', + }, + attributes: getLensAttributes(defaultDataView!), + }); + })(); + return () => {}; + }, + }); + + developerExamples.register({ + appId: 'third_party_lens_vis_example', + title: 'Third party Lens visualization', + description: 'Add custom visualization types to the Lens editor', + links: [ + { + label: 'README', + href: 'https://github.com/elastic/kibana/tree/main/x-pack/examples/third_party_lens_vis_example', + iconType: 'logoGithub', + size: 's', + target: '_blank', + }, + ], + }); + + expressions.registerRenderer(() => + getRotatingNumberRenderer( + core.getStartServices().then(([, { fieldFormats }]) => fieldFormats.deserialize) + ) + ); + expressions.registerFunction(() => rotatingNumberFunction); + + lens.registerVisualization(async () => getRotatingNumberVisualization({ theme: core.theme })); + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/examples/third_party_vis_lens_example/public/visualization.tsx b/x-pack/examples/third_party_vis_lens_example/public/visualization.tsx new file mode 100644 index 0000000000000..e7a6c60ad5b2e --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/public/visualization.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFormRow, EuiColorPicker } from '@elastic/eui'; +import { render } from 'react-dom'; +import { Ast } from '@kbn/interpreter'; +import { ThemeServiceStart } from '../../../../src/core/public'; +import { KibanaThemeProvider } from '../../../../src/plugins/kibana_react/public'; +import { Visualization, OperationMetadata } from '../../../plugins/lens/public'; +import type { RotatingNumberState } from '../common/types'; +import { DEFAULT_COLOR } from '../common/constants'; +import { layerTypes } from '../../../plugins/lens/public'; + +const toExpression = (state: RotatingNumberState): Ast | null => { + if (!state.accessor) { + return null; + } + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'rotating_number', + arguments: { + accessor: [state.accessor], + color: [state?.color || 'black'], + }, + }, + ], + }; +}; +export const getRotatingNumberVisualization = ({ + theme, +}: { + theme: ThemeServiceStart; +}): Visualization => ({ + id: 'rotatingNumber', + + visualizationTypes: [ + { + id: 'rotatingNumber', + icon: 'refresh', + label: 'Rotating number', + groupLabel: 'Goal and single value', + sortPriority: 3, + }, + ], + + getVisualizationTypeId() { + return 'rotatingNumber'; + }, + + clearLayer(state) { + return { + ...state, + accessor: undefined, + }; + }, + + getLayerIds(state) { + return [state.layerId]; + }, + + getDescription() { + return { + icon: 'refresh', + label: 'A number that rotates', + }; + }, + + getSuggestions: ({ state, table }) => { + if (table.columns.length > 1) { + return []; + } + if (state && table.changeType === 'unchanged') { + return []; + } + const column = table.columns[0]; + if (column.operation.isBucketed || column.operation.dataType !== 'number') { + return []; + } + return [ + { + previewIcon: 'refresh', + score: 0.5, + title: `Rotating ${table.label}` || 'Rotating number', + state: { + layerId: table.layerId, + color: state?.color || DEFAULT_COLOR, + accessor: column.columnId, + }, + }, + ]; + }, + + initialize(addNewLayer, state) { + return ( + state || { + layerId: addNewLayer(), + accessor: undefined, + color: DEFAULT_COLOR, + } + ); + }, + + getConfiguration(props) { + return { + groups: [ + { + groupId: 'metric', + groupLabel: 'Rotating number', + layerId: props.state.layerId, + accessors: props.state.accessor + ? [ + { + columnId: props.state.accessor, + triggerIcon: 'color', + color: props.state.color, + }, + ] + : [], + supportsMoreColumns: !props.state.accessor, + filterOperations: (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number', + enableDimensionEditor: true, + required: true, + }, + ], + }; + }, + + getSupportedLayers() { + return [ + { + type: layerTypes.DATA, + label: 'Add visualization layer', + }, + ]; + }, + + getLayerType(layerId, state) { + if (state?.layerId === layerId) { + return layerTypes.DATA; + } + }, + + toExpression: (state) => toExpression(state), + toPreviewExpression: (state) => toExpression(state), + + setDimension({ prevState, columnId }) { + return { ...prevState, accessor: columnId }; + }, + + removeDimension({ prevState }) { + return { ...prevState, accessor: undefined }; + }, + + renderDimensionEditor(domElement, props) { + render( + + + { + props.setState({ ...props.state, color: newColor }); + }} + color={props.state.color} + /> + + , + domElement + ); + }, + + getErrorMessages(state) { + // Is it possible to break it? + return undefined; + }, +}); diff --git a/x-pack/examples/third_party_vis_lens_example/server/index.ts b/x-pack/examples/third_party_vis_lens_example/server/index.ts new file mode 100644 index 0000000000000..cab22f4ad29c9 --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializer } from 'kibana/server'; +import { ThirdPartyVisLensExamplePlugin } from './plugin'; + +export const plugin: PluginInitializer = () => new ThirdPartyVisLensExamplePlugin(); diff --git a/x-pack/examples/third_party_vis_lens_example/server/plugin.ts b/x-pack/examples/third_party_vis_lens_example/server/plugin.ts new file mode 100644 index 0000000000000..b3e99b665c494 --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/server/plugin.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Plugin, CoreSetup } from 'kibana/server'; +import { LensServerPluginSetup } from '../../../plugins/lens/server'; +import { DEFAULT_COLOR } from '../common/constants'; +import { RotatingNumberState as Post81RotatingNumberState } from '../common/types'; + +// Old versions of this visualization had a slightly different shape of state +interface Pre81RotatingNumberState { + column?: string; + layerId: string; +} + +// this plugin's dependencies +export interface Dependencies { + lens: LensServerPluginSetup; +} + +export class ThirdPartyVisLensExamplePlugin implements Plugin { + public setup(core: CoreSetup, { lens }: Dependencies) { + lens.registerVisualizationMigration('rotatingNumber', () => ({ + // Example state migration which will be picked by all the places Lens visualizations are stored + '8.1.0': (oldState: Pre81RotatingNumberState): Post81RotatingNumberState => { + return { + // column gets renamed to accessor + accessor: oldState.column, + // layer id just gets copied over + layerId: oldState.layerId, + // color gets pre-set with default color + color: DEFAULT_COLOR, + }; + }, + })); + } + + public start() {} + public stop() {} +} diff --git a/x-pack/examples/third_party_vis_lens_example/tsconfig.json b/x-pack/examples/third_party_vis_lens_example/tsconfig.json new file mode 100644 index 0000000000000..d9d1af39a2b98 --- /dev/null +++ b/x-pack/examples/third_party_vis_lens_example/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types" + }, + "include": [ + "index.ts", + "public/**/*", + "server/**/*", + "common/**/*", + "../../../typings/**/*" + ], + "exclude": [], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/expressions/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/data_views/tsconfig.json" }, + { "path": "../../../src/plugins/field_formats/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../plugins/lens/tsconfig.json" }, + { "path": "../../../examples/developer_examples/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 78ffd6c22a9af..0479dc73ff6b6 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -823,7 +823,7 @@ describe('common utils', () => { ].join('\n\n'); const extractedReferences = extractLensReferencesFromCommentString( - makeLensEmbeddableFactory(() => ({})), + makeLensEmbeddableFactory(() => ({}), {}), commentString ); @@ -922,7 +922,7 @@ describe('common utils', () => { ].join('\n\n'); const updatedReferences = getOrUpdateLensReferences( - makeLensEmbeddableFactory(() => ({})), + makeLensEmbeddableFactory(() => ({}), {}), newCommentString, { references: currentCommentReferences, diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts index bd48641975247..837a1e4f10d70 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts @@ -36,7 +36,7 @@ import { GENERATED_ALERT, SUB_CASE_SAVED_OBJECT } from './constants'; describe('comments migrations', () => { const migrations = createCommentsMigrations({ - lensEmbeddableFactory: makeLensEmbeddableFactory(() => ({})), + lensEmbeddableFactory: makeLensEmbeddableFactory(() => ({}), {}), }); const contextMock = savedObjectsServiceMock.createMigrationContext(); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 0115a8c5b39c7..ccfd6a49a2d8b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -24,6 +24,7 @@ import { TableDimensionEditor } from './components/dimension_editor'; import { CUSTOM_PALETTE } from '../shared_components/coloring/constants'; import { LayerType, layerTypes } from '../../common'; import { getDefaultSummaryLabel, PagingState } from '../../common/expressions'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; import type { ColumnState, SortingState } from '../../common/expressions'; import { DataTableToolbar } from './components/toolbar'; export interface DatatableVisualizationState { @@ -84,6 +85,8 @@ export const getDatatableVisualization = ({ switchVisualizationType: (_, state) => state, + triggers: [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.tableRowContextMenuClick], + initialize(addNewLayer, state) { return ( state || { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index c68c04b4b3e21..6879c35f30fe1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -47,6 +47,9 @@ export function EditorFrame(props: EditorFrameProps) { const visualization = useLensSelector(selectVisualization); const areDatasourcesLoaded = useLensSelector(selectAreDatasourcesLoaded); const isVisualizationLoaded = !!visualization.state; + const visualizationTypeIsKnown = Boolean( + visualization.activeId && props.visualizationMap[visualization.activeId] + ); const framePublicAPI: FramePublicAPI = useLensSelector((state) => selectFramePublicAPI(state, datasourceMap) ); @@ -121,6 +124,7 @@ export function EditorFrame(props: EditorFrameProps) { ) } suggestionsPanel={ + visualizationTypeIsKnown && areDatasourcesLoaded && ( { - if (!configurationValidationError?.length && !missingRefsErrors.length) { + if (!configurationValidationError?.length && !missingRefsErrors.length && !unknownVisError) { try { const ast = buildExpression({ visualization: activeVisualization, @@ -213,6 +218,12 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ })); } } + if (unknownVisError) { + setLocalState((s) => ({ + ...s, + expressionBuildError: [getUnknownVisualizationTypeError(visualization.activeId!)], + })); + } }, [ activeVisualization, visualization.state, @@ -221,6 +232,8 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ datasourceLayers, configurationValidationError?.length, missingRefsErrors.length, + unknownVisError, + visualization.activeId, ]); const expressionExists = Boolean(expression); @@ -415,6 +428,7 @@ export const VisualizationWrapper = ({ fixAction?: DatasourceFixAction; }>; missingRefsErrors?: Array<{ shortMessage: string; longMessage: React.ReactNode }>; + unknownVisError?: Array<{ shortMessage: string; longMessage: React.ReactNode }>; }; ExpressionRendererComponent: ReactExpressionRendererType; application: ApplicationStart; diff --git a/x-pack/plugins/lens/public/editor_frame_service/error_helper.ts b/x-pack/plugins/lens/public/editor_frame_service/error_helper.ts index 9df48d99ce762..7d237cb783c5e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/error_helper.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/error_helper.ts @@ -165,3 +165,17 @@ export function getMissingIndexPatterns(indexPatternIds: string[]) { values: { count: indexPatternIds.length, ids: indexPatternIds.join(', ') }, }); } + +export function getUnknownVisualizationTypeError(visType: string) { + return { + shortMessage: i18n.translate('xpack.lens.unknownVisType.shortMessage', { + defaultMessage: `Unknown visualization type`, + }), + longMessage: i18n.translate('xpack.lens.unknownVisType.longMessage', { + defaultMessage: `The visualization type {visType} could not be resolved.`, + values: { + visType, + }, + }), + }; +} diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 420416dff111c..a430a72276ca3 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -265,22 +265,10 @@ export class Embeddable } public supportedTriggers() { - if (!this.savedVis) { + if (!this.savedVis || !this.savedVis.visualizationType) { return []; } - switch (this.savedVis.visualizationType) { - case 'lnsXY': - case 'lnsHeatmap': - return [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush]; - case 'lnsDatatable': - return [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.tableRowContextMenuClick]; - case 'lnsPie': - return [VIS_EVENT_TO_TRIGGER.filter]; - case 'lnsGauge': - case 'lnsMetric': - default: - return []; - } + return this.deps.visualizationMap[this.savedVis.visualizationType]?.triggers || []; } public getInspectorAdapters() { diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx index c4bbe3800ab71..b8fd06a09ebcd 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx @@ -52,7 +52,8 @@ export type TypedLensByValueInput = Omit & { | LensAttributes<'lnsDatatable', DatatableVisualizationState> | LensAttributes<'lnsMetric', MetricState> | LensAttributes<'lnsHeatmap', HeatmapVisualizationState> - | LensAttributes<'lnsGauge', GaugeVisualizationState>; + | LensAttributes<'lnsGauge', GaugeVisualizationState> + | LensAttributes; }; export type EmbeddableComponentProps = (TypedLensByValueInput | LensByReferenceInput) & { diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index 463687edd3c6c..1c045e63e9e26 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -12,7 +12,7 @@ export type { TypedLensByValueInput, } from './embeddable/embeddable_component'; export type { XYState } from './xy_visualization/types'; -export type { DataType, OperationMetadata } from './types'; +export type { DataType, OperationMetadata, Visualization } from './types'; export type { PieVisualizationState, PieLayerState, @@ -61,7 +61,8 @@ export type { StaticValueIndexPatternColumn, } from './indexpattern_datasource/types'; export type { LensEmbeddableInput } from './embeddable'; +export { layerTypes } from '../common'; -export type { LensPublicStart } from './plugin'; +export type { LensPublicStart, LensPublicSetup } from './plugin'; export const plugin = () => new LensPlugin(); diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index e7c5e2f78920b..eb154abe6d14d 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -12,6 +12,7 @@ import { FormattedMessage, I18nProvider } from '@kbn/i18n-react'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import { ThemeServiceStart } from 'kibana/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; import type { Visualization, OperationMetadata, @@ -102,6 +103,8 @@ export const getPieVisualization = ({ shape: visualizationTypeId as PieVisualizationState['shape'], }), + triggers: [VIS_EVENT_TO_TRIGGER.filter], + initialize(addNewLayer, state, mainPalette) { return ( state || { diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index decd9d8c69510..b3b78ffc4c2e8 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -70,7 +70,7 @@ import { } from '../../../../src/plugins/ui_actions/public'; import { APP_ID, getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common/constants'; import type { FormatFactory } from '../common/types'; -import type { VisualizationType } from './types'; +import type { Visualization, VisualizationType, EditorFrameSetup } from './types'; import { getLensAliasConfig } from './vis_type_alias'; import { visualizeFieldAction } from './trigger_actions/visualize_field_actions'; @@ -117,6 +117,23 @@ export interface LensPluginStartDependencies { usageCollection?: UsageCollectionStart; } +export interface LensPublicSetup { + /** + * Register 3rd party visualization type + * See `x-pack/examples/3rd_party_lens_vis` for exemplary usage. + * + * In case the visualization is a function returning a promise, it will only be called once Lens is actually requiring it. + * This can be used to lazy-load parts of the code to keep the initial bundle as small as possible. + * + * This API might undergo breaking changes even in minor versions. + * + * @experimental + */ + registerVisualization: ( + visualization: Visualization | (() => Promise>) + ) => void; +} + export interface LensPublicStart { /** * React component which can be used to embed a Lens visualization into another application. @@ -173,6 +190,8 @@ export interface LensPublicStart { export class LensPlugin { private datatableVisualization: DatatableVisualizationType | undefined; private editorFrameService: EditorFrameServiceType | undefined; + private editorFrameSetup: EditorFrameSetup | undefined; + private queuedVisualizations: Array Promise)> = []; private indexpatternDatasource: IndexPatternDatasourceType | undefined; private xyVisualization: XyVisualizationType | undefined; private metricVisualization: MetricVisualizationType | undefined; @@ -301,6 +320,17 @@ export class LensPlugin { } urlForwarding.forwardApp('lens', 'lens'); + + return { + registerVisualization: (vis: Visualization | (() => Promise)) => { + if (this.editorFrameSetup) { + this.editorFrameSetup.registerVisualization(vis); + } else { + // queue visualizations if editor frame is not yet ready as it's loaded async + this.queuedVisualizations.push(vis); + } + }, + }; } private async initParts( @@ -351,6 +381,11 @@ export class LensPlugin { this.pieVisualization.setup(core, dependencies); this.heatmapVisualization.setup(core, dependencies); this.gaugeVisualization.setup(core, dependencies); + + this.queuedVisualizations.forEach((queuedVis) => { + editorFrameSetupInterface.registerVisualization(queuedVis); + }); + this.editorFrameSetup = editorFrameSetupInterface; } start(core: CoreStart, startDependencies: LensPluginStartDependencies): LensPublicStart { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index aa41025b818eb..91009dffe2081 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -654,6 +654,10 @@ export interface Visualization { getMainPalette?: (state: T) => undefined | PaletteOutput; + /** + * Supported triggers of this visualization type when embedded somewhere + */ + triggers?: string[]; /** * Visualizations must provide at least one type for the chart switcher, * but can register multiple subtypes diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 8957a522303e0..49c8705c5cb5e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -15,6 +15,7 @@ import { PaletteRegistry } from 'src/plugins/charts/public'; import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { ThemeServiceStart } from 'kibana/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; import { getSuggestions } from './xy_suggestions'; import { XyToolbar, DimensionEditor } from './xy_config_panel'; import { LayerHeader } from './xy_config_panel/layer_header'; @@ -177,6 +178,8 @@ export const getXyVisualization = ({ getSuggestions, + triggers: [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush], + initialize(addNewLayer, state) { return ( state || { diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts index 397853ee2183c..655291ec357f4 100644 --- a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts @@ -13,11 +13,11 @@ import { GetMigrationFunctionObjectFn } from 'src/plugins/kibana_utils/common'; describe('embeddable migrations', () => { test('should have all saved object migrations versions (>7.13.0)', () => { - const savedObjectMigrationVersions = Object.keys(getAllMigrations({})).filter((version) => { + const savedObjectMigrationVersions = Object.keys(getAllMigrations({}, {})).filter((version) => { return semverGte(version, '7.13.1'); }); const embeddableMigrationVersions = ( - makeLensEmbeddableFactory(() => ({}))()?.migrations as GetMigrationFunctionObjectFn + makeLensEmbeddableFactory(() => ({}), {})()?.migrations as GetMigrationFunctionObjectFn )(); if (embeddableMigrationVersions) { expect(savedObjectMigrationVersions.sort()).toEqual( @@ -47,14 +47,17 @@ describe('embeddable migrations', () => { }; const migrations = ( - makeLensEmbeddableFactory(() => ({ - [migrationVersion]: (filters: Filter[]) => { - return filters.map((filterState) => ({ - ...filterState, - migrated: true, - })); - }, - }))()?.migrations as GetMigrationFunctionObjectFn + makeLensEmbeddableFactory( + () => ({ + [migrationVersion]: (filters: Filter[]) => { + return filters.map((filterState) => ({ + ...filterState, + migrated: true, + })); + }, + }), + {} + )()?.migrations as GetMigrationFunctionObjectFn )(); const migratedLensDoc = migrations[migrationVersion](lensVisualizationDoc); @@ -76,4 +79,57 @@ describe('embeddable migrations', () => { }, }); }); + + test('should properly apply a custom visualization migration', () => { + const migrationVersion = 'some-version'; + + const lensVisualizationDoc = { + attributes: { + visualizationType: 'abc', + state: { + visualization: { oldState: true }, + }, + }, + }; + + const migrationFn = jest.fn((oldState: { oldState: boolean }) => ({ + newState: oldState.oldState, + })); + + const embeddableMigrationVersions = ( + makeLensEmbeddableFactory(() => ({}), { + abc: () => ({ + [migrationVersion]: migrationFn, + }), + })()?.migrations as GetMigrationFunctionObjectFn + )(); + + const migratedLensDoc = embeddableMigrationVersions?.[migrationVersion](lensVisualizationDoc); + const otherLensDoc = embeddableMigrationVersions?.[migrationVersion]({ + ...lensVisualizationDoc, + attributes: { + ...lensVisualizationDoc.attributes, + visualizationType: 'def', + }, + }); + + expect(migrationFn).toHaveBeenCalledTimes(1); + + expect(migratedLensDoc).toEqual({ + attributes: { + visualizationType: 'abc', + state: { + visualization: { newState: true }, + }, + }, + }); + expect(otherLensDoc).toEqual({ + attributes: { + visualizationType: 'def', + state: { + visualization: { oldState: true }, + }, + }, + }); + }); }); diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts index db912584fdcdb..39d36a9f306a2 100644 --- a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts @@ -19,9 +19,11 @@ import { commonRenameOperationsForFormula, commonRenameRecordsField, commonUpdateVisLayerType, + getLensCustomVisualizationMigrations, getLensFilterMigrations, } from '../migrations/common_migrations'; import { + CustomVisualizationMigrations, LensDocShape713, LensDocShape715, LensDocShapePre712, @@ -31,55 +33,64 @@ import { import { extract, inject } from '../../common/embeddable_factory'; export const makeLensEmbeddableFactory = - (getFilterMigrations: () => MigrateFunctionsObject) => (): EmbeddableRegistryDefinition => { + ( + getFilterMigrations: () => MigrateFunctionsObject, + customVisualizationMigrations: CustomVisualizationMigrations + ) => + (): EmbeddableRegistryDefinition => { return { id: DOC_TYPE, migrations: () => - mergeMigrationFunctionMaps(getLensFilterMigrations(getFilterMigrations()), { - // This migration is run in 7.13.1 for `by value` panels because the 7.13 release window was missed. - '7.13.1': (state) => { - const lensState = state as unknown as { attributes: LensDocShapePre712 }; - const migratedLensState = commonRenameOperationsForFormula(lensState.attributes); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - '7.14.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape713 }; - const migratedLensState = commonRemoveTimezoneDateHistogramParam(lensState.attributes); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - '7.15.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape715 }; - const migratedLensState = commonUpdateVisLayerType(lensState.attributes); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - '7.16.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape715 }; - const migratedLensState = commonMakeReversePaletteAsCustom(lensState.attributes); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - '8.1.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape715 }; - const migratedLensState = commonRenameRecordsField( - commonRenameFilterReferences(lensState.attributes) - ); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - }), + mergeMigrationFunctionMaps( + mergeMigrationFunctionMaps(getLensFilterMigrations(getFilterMigrations()), { + // This migration is run in 7.13.1 for `by value` panels because the 7.13 release window was missed. + '7.13.1': (state) => { + const lensState = state as unknown as { attributes: LensDocShapePre712 }; + const migratedLensState = commonRenameOperationsForFormula(lensState.attributes); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '7.14.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape713 }; + const migratedLensState = commonRemoveTimezoneDateHistogramParam( + lensState.attributes + ); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '7.15.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape715 }; + const migratedLensState = commonUpdateVisLayerType(lensState.attributes); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '7.16.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape715 }; + const migratedLensState = commonMakeReversePaletteAsCustom(lensState.attributes); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '8.1.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape715 }; + const migratedLensState = commonRenameRecordsField( + commonRenameFilterReferences(lensState.attributes) + ); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + }), + getLensCustomVisualizationMigrations(customVisualizationMigrations) + ), extract, inject, }; diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index ad4f3406c5b3b..87edc94fd1ae6 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -7,7 +7,12 @@ import { cloneDeep, mapValues } from 'lodash'; import { PaletteOutput } from 'src/plugins/charts/common'; -import { MigrateFunctionsObject } from '../../../../../src/plugins/kibana_utils/common'; +import { SerializableRecord } from '@kbn/utility-types'; +import { + mergeMigrationFunctionMaps, + MigrateFunction, + MigrateFunctionsObject, +} from '../../../../../src/plugins/kibana_utils/common'; import { LensDocShapePre712, OperationTypePre712, @@ -18,6 +23,7 @@ import { VisStatePost715, VisStatePre715, VisState716, + CustomVisualizationMigrations, LensDocShape810, } from './types'; import { CustomPaletteParams, DOCUMENT_FIELD_NAME, layerTypes } from '../../common'; @@ -186,6 +192,51 @@ export const commonRenameFilterReferences = (attributes: LensDocShape715): LensD return newAttributes as LensDocShape810; }; +const getApplyCustomVisualizationMigrationToLens = (id: string, migration: MigrateFunction) => { + return (savedObject: { attributes: LensDocShape }) => { + if (savedObject.attributes.visualizationType !== id) return savedObject; + return { + ...savedObject, + attributes: { + ...savedObject.attributes, + state: { + ...savedObject.attributes.state, + visualization: migration( + savedObject.attributes.state.visualization as SerializableRecord + ), + }, + }, + }; + }; +}; + +/** + * This creates a migration map that applies custom visualization migrations + */ +export const getLensCustomVisualizationMigrations = ( + customVisualizationMigrations: CustomVisualizationMigrations +) => { + return Object.entries(customVisualizationMigrations) + .map(([id, migrationGetter]) => { + const migrationMap: MigrateFunctionsObject = {}; + const currentMigrations = migrationGetter(); + for (const version in currentMigrations) { + if (currentMigrations.hasOwnProperty(version)) { + migrationMap[version] = getApplyCustomVisualizationMigrationToLens( + id, + currentMigrations[version] + ); + } + } + return migrationMap; + }) + .reduce( + (fullMigrationMap, currentVisualizationTypeMigrationMap) => + mergeMigrationFunctionMaps(fullMigrationMap, currentVisualizationTypeMigrationMap), + {} + ); +}; + /** * This creates a migration map that applies filter migrations to Lens visualizations */ diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index a78cfa5e72237..11572b7a1f7d9 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -24,7 +24,7 @@ import { PaletteOutput } from 'src/plugins/charts/common'; import { Filter } from '@kbn/es-query'; describe('Lens migrations', () => { - const migrations = getAllMigrations({}); + const migrations = getAllMigrations({}, {}); describe('7.7.0 missing dimensions in XY', () => { const context = {} as SavedObjectMigrationContext; @@ -1611,14 +1611,17 @@ describe('Lens migrations', () => { }, }; - const migrationFunctionsObject = getAllMigrations({ - [migrationVersion]: (filters: Filter[]) => { - return filters.map((filterState) => ({ - ...filterState, - migrated: true, - })); + const migrationFunctionsObject = getAllMigrations( + { + [migrationVersion]: (filters: Filter[]) => { + return filters.map((filterState) => ({ + ...filterState, + migrated: true, + })); + }, }, - }); + {} + ); const migratedLensDoc = migrationFunctionsObject[migrationVersion]( lensVisualizationDoc as SavedObjectUnsanitizedDoc, @@ -1642,4 +1645,63 @@ describe('Lens migrations', () => { }, }); }); + + test('should properly apply a custom visualization migration', () => { + const migrationVersion = 'some-version'; + + const lensVisualizationDoc = { + attributes: { + visualizationType: 'abc', + state: { + visualization: { oldState: true }, + }, + }, + }; + + const migrationFn = jest.fn((oldState: { oldState: boolean }) => ({ + newState: oldState.oldState, + })); + + const migrationFunctionsObject = getAllMigrations( + {}, + { + abc: () => ({ + [migrationVersion]: migrationFn, + }), + } + ); + const migratedLensDoc = migrationFunctionsObject[migrationVersion]( + lensVisualizationDoc as SavedObjectUnsanitizedDoc, + {} as SavedObjectMigrationContext + ); + const otherLensDoc = migrationFunctionsObject[migrationVersion]( + { + ...lensVisualizationDoc, + attributes: { + ...lensVisualizationDoc.attributes, + visualizationType: 'def', + }, + } as SavedObjectUnsanitizedDoc, + {} as SavedObjectMigrationContext + ); + + expect(migrationFn).toHaveBeenCalledTimes(1); + + expect(migratedLensDoc).toEqual({ + attributes: { + visualizationType: 'abc', + state: { + visualization: { newState: true }, + }, + }, + }); + expect(otherLensDoc).toEqual({ + attributes: { + visualizationType: 'def', + state: { + visualization: { oldState: true }, + }, + }, + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index d0abe3877c1df..8e7d555b33694 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -27,6 +27,7 @@ import { VisStatePost715, VisStatePre715, VisState716, + CustomVisualizationMigrations, LensDocShape810, } from './types'; import { @@ -36,6 +37,7 @@ import { commonMakeReversePaletteAsCustom, commonRenameFilterReferences, getLensFilterMigrations, + getLensCustomVisualizationMigrations, commonRenameRecordsField, } from './common_migrations'; @@ -473,9 +475,13 @@ const lensMigrations: SavedObjectMigrationMap = { }; export const getAllMigrations = ( - filterMigrations: MigrateFunctionsObject + filterMigrations: MigrateFunctionsObject, + customVisualizationMigrations: CustomVisualizationMigrations ): SavedObjectMigrationMap => mergeSavedObjectMigrationMaps( - lensMigrations, - getLensFilterMigrations(filterMigrations) as unknown as SavedObjectMigrationMap + mergeSavedObjectMigrationMaps( + lensMigrations, + getLensFilterMigrations(filterMigrations) as unknown as SavedObjectMigrationMap + ), + getLensCustomVisualizationMigrations(customVisualizationMigrations) ); diff --git a/x-pack/plugins/lens/server/migrations/types.ts b/x-pack/plugins/lens/server/migrations/types.ts index de643f9234156..11c23d98dab37 100644 --- a/x-pack/plugins/lens/server/migrations/types.ts +++ b/x-pack/plugins/lens/server/migrations/types.ts @@ -8,8 +8,11 @@ import type { PaletteOutput } from 'src/plugins/charts/common'; import { Filter } from '@kbn/es-query'; import { Query } from 'src/plugins/data/public'; +import type { MigrateFunctionsObject } from 'src/plugins/kibana_utils/common'; import type { CustomPaletteParams, LayerType, PersistableFilter } from '../../common'; +export type CustomVisualizationMigrations = Record MigrateFunctionsObject>; + export type OperationTypePre712 = | 'avg' | 'cardinality' diff --git a/x-pack/plugins/lens/server/plugin.tsx b/x-pack/plugins/lens/server/plugin.tsx index 13def5c0557c0..b63ea52f115f8 100644 --- a/x-pack/plugins/lens/server/plugin.tsx +++ b/x-pack/plugins/lens/server/plugin.tsx @@ -14,6 +14,8 @@ import { } from 'src/plugins/data/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { FieldFormatsStart } from 'src/plugins/field_formats/server'; +import type { MigrateFunctionsObject } from 'src/plugins/kibana_utils/common'; + import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import { setupRoutes } from './routes'; import { getUiSettings } from './ui_settings'; @@ -26,6 +28,7 @@ import { setupSavedObjects } from './saved_objects'; import { EmbeddableSetup } from '../../../../src/plugins/embeddable/server'; import { setupExpressions } from './expressions'; import { makeLensEmbeddableFactory } from './embeddable/make_lens_embeddable_factory'; +import type { CustomVisualizationMigrations } from './migrations/types'; export interface PluginSetupContract { usageCollection?: UsageCollectionSetup; @@ -43,11 +46,22 @@ export interface PluginStartContract { } export interface LensServerPluginSetup { + /** + * Server side embeddable definition which provides migrations to run if Lens state is embedded into another saved object somewhere + */ lensEmbeddableFactory: ReturnType; + /** + * Register custom migration functions for custom third party Lens visualizations + */ + registerVisualizationMigration: ( + id: string, + migrationsGetter: () => MigrateFunctionsObject + ) => void; } export class LensServerPlugin implements Plugin { private readonly telemetryLogger: Logger; + private customVisualizationMigrations: CustomVisualizationMigrations = {}; constructor(private initializerContext: PluginInitializerContext) { this.telemetryLogger = initializerContext.logger.get('usage'); @@ -57,7 +71,7 @@ export class LensServerPlugin implements Plugin MigrateFunctionsObject + ) => { + if (this.customVisualizationMigrations[id]) { + throw new Error(`Migrations object for visualization ${id} registered already`); + } + this.customVisualizationMigrations[id] = migrationsGetter; + }, }; } diff --git a/x-pack/plugins/lens/server/saved_objects.ts b/x-pack/plugins/lens/server/saved_objects.ts index d9a851947c4d1..e36421d26e3c2 100644 --- a/x-pack/plugins/lens/server/saved_objects.ts +++ b/x-pack/plugins/lens/server/saved_objects.ts @@ -9,10 +9,12 @@ import { CoreSetup } from 'kibana/server'; import { MigrateFunctionsObject } from '../../../../src/plugins/kibana_utils/common'; import { getEditPath } from '../common'; import { getAllMigrations } from './migrations/saved_object_migrations'; +import { CustomVisualizationMigrations } from './migrations/types'; export function setupSavedObjects( core: CoreSetup, - getFilterMigrations: () => MigrateFunctionsObject + getFilterMigrations: () => MigrateFunctionsObject, + customVisualizationMigrations: CustomVisualizationMigrations ) { core.savedObjects.registerType({ name: 'lens', @@ -29,7 +31,7 @@ export function setupSavedObjects( uiCapabilitiesPath: 'visualize.show', }), }, - migrations: () => getAllMigrations(getFilterMigrations()), + migrations: () => getAllMigrations(getFilterMigrations(), customVisualizationMigrations), mappings: { properties: { title: { From b92e180e852bd4416512d55747a1c064641be67c Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Fri, 4 Feb 2022 15:07:07 -0600 Subject: [PATCH 12/12] Remove beta badge (#124574) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../failed_transactions_correlations.tsx | 26 ------------------- .../translations/translations/ja-JP.json | 3 --- .../translations/translations/zh-CN.json | 3 --- 3 files changed, 32 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx index f00487cdc4082..6d20faae89a10 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx @@ -16,7 +16,6 @@ import { EuiSpacer, EuiIcon, EuiTitle, - EuiBetaBadge, EuiBadge, EuiToolTip, EuiSwitch, @@ -451,31 +450,6 @@ export function FailedTransactionsCorrelations({ - - - - - diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 43db19704dcf5..8c9548e9b1f8e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7129,9 +7129,6 @@ "xpack.apm.transactionDetails.statusCode": "ステータスコード", "xpack.apm.transactionDetails.syncBadgeAsync": "非同期", "xpack.apm.transactionDetails.syncBadgeBlocking": "ブロック", - "xpack.apm.transactionDetails.tabs.failedTransactionsCorrelationsBetaDescription": "失敗したトランザクションの相関関係はGAではありません。不具合が発生したら報告してください。", - "xpack.apm.transactionDetails.tabs.failedTransactionsCorrelationsBetaLabel": "ベータ", - "xpack.apm.transactionDetails.tabs.failedTransactionsCorrelationsBetaTitle": "失敗したトランザクションの相関関係", "xpack.apm.transactionDetails.tabs.failedTransactionsCorrelationsLabel": "失敗したトランザクションの相関関係", "xpack.apm.transactionDetails.tabs.latencyLabel": "遅延の相関関係", "xpack.apm.transactionDetails.tabs.traceSamplesLabel": "トレースのサンプル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d299a58c3bdbe..20f0748703770 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6941,9 +6941,6 @@ "xpack.apm.transactionDetails.statusCode": "状态代码", "xpack.apm.transactionDetails.syncBadgeAsync": "异步", "xpack.apm.transactionDetails.syncBadgeBlocking": "正在阻止", - "xpack.apm.transactionDetails.tabs.failedTransactionsCorrelationsBetaDescription": "失败事务相关性不是 GA 版。请通过报告错误来帮助我们。", - "xpack.apm.transactionDetails.tabs.failedTransactionsCorrelationsBetaLabel": "公测版", - "xpack.apm.transactionDetails.tabs.failedTransactionsCorrelationsBetaTitle": "失败事务相关性", "xpack.apm.transactionDetails.tabs.failedTransactionsCorrelationsLabel": "失败事务相关性", "xpack.apm.transactionDetails.tabs.latencyLabel": "延迟相关性", "xpack.apm.transactionDetails.tabs.traceSamplesLabel": "跟踪样例",