diff --git a/src/plugins/chart_expressions/common/index.ts b/src/plugins/chart_expressions/common/index.ts index 0277ad87b7a71..4373260657909 100644 --- a/src/plugins/chart_expressions/common/index.ts +++ b/src/plugins/chart_expressions/common/index.ts @@ -6,4 +6,5 @@ * Side Public License, v 1. */ -export { extractContainerType, extractVisualizationType } from './utils'; +export { extractContainerType, extractVisualizationType, getOverridesFor } from './utils'; +export type { Simplify, MakeOverridesSerializable } from './types'; diff --git a/src/plugins/chart_expressions/common/types.ts b/src/plugins/chart_expressions/common/types.ts new file mode 100644 index 0000000000000..acdd5909f1aec --- /dev/null +++ b/src/plugins/chart_expressions/common/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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'; + +export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; + +// Overrides should not expose Functions, React nodes and children props +// So filter out any type which is not serializable +export type MakeOverridesSerializable = { + [KeyType in keyof T]: NonNullable extends Function + ? // cannot use boolean here as it would be challenging to distinguish + // between a "native" boolean props and a disabled callback + // so use a specific keyword + 'ignore' + : // be careful here to not filter out string/number types + NonNullable extends React.ReactChildren | React.ReactElement + ? never + : // make it recursive + NonNullable extends object + ? MakeOverridesSerializable + : NonNullable; +}; diff --git a/src/plugins/chart_expressions/common/utils.test.ts b/src/plugins/chart_expressions/common/utils.test.ts new file mode 100644 index 0000000000000..2ed71e9a17b92 --- /dev/null +++ b/src/plugins/chart_expressions/common/utils.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { getOverridesFor } from './utils'; + +describe('Overrides utilities', () => { + describe('getOverridesFor', () => { + it('should return an empty object for undefined values', () => { + expect(getOverridesFor(undefined, 'settings')).toEqual({}); + // @ts-expect-error + expect(getOverridesFor({}, 'settings')).toEqual({}); + // @ts-expect-error + expect(getOverridesFor({ otherOverride: {} }, 'settings')).toEqual({}); + }); + + it('should return only the component specific overrides', () => { + expect( + getOverridesFor({ otherOverride: { a: 15 }, settings: { b: 10 } }, 'settings') + ).toEqual({ b: 10 }); + }); + + it('should swap any "ignore" value into undefined value', () => { + expect( + getOverridesFor({ otherOverride: { a: 15 }, settings: { b: 10, c: 'ignore' } }, 'settings') + ).toEqual({ b: 10, c: undefined }); + }); + }); +}); diff --git a/src/plugins/chart_expressions/common/utils.ts b/src/plugins/chart_expressions/common/utils.ts index d3b9e64c827d2..2966532c44117 100644 --- a/src/plugins/chart_expressions/common/utils.ts +++ b/src/plugins/chart_expressions/common/utils.ts @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import type { KibanaExecutionContext } from '@kbn/core-execution-context-common'; export const extractContainerType = (context?: KibanaExecutionContext): string | undefined => { @@ -33,3 +32,30 @@ export const extractVisualizationType = (context?: KibanaExecutionContext): stri return recursiveGet(context)?.type; } }; + +/** + * Get an override specification and returns a props object to use directly with the Component + * @param overrides Overrides object + * @param componentName name of the Component to look for (i.e. "settings", "axisX") + * @returns an props object to use directly with the component + */ +export function getOverridesFor< + // Component props + P extends Record, + // Overrides + O extends Record, + // Overrides Component names + K extends keyof O +>(overrides: O | undefined, componentName: K) { + if (!overrides || !overrides[componentName]) { + return {}; + } + return Object.fromEntries( + Object.entries(overrides[componentName]).map(([key, value]) => { + if (value === 'ignore') { + return [key, undefined]; + } + return [key, value]; + }) + ); +} diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap index 3a1f13de9c525..93339463a7715 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap @@ -78,6 +78,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -131,6 +132,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -182,6 +184,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -233,6 +236,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -284,6 +288,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -337,6 +342,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -390,6 +396,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -441,6 +448,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -492,6 +500,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -543,6 +552,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -594,6 +604,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -645,6 +656,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -696,6 +708,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; @@ -747,6 +760,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, }, } `; diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts index 078990d237dbf..b6a966a7cd858 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts @@ -11,6 +11,7 @@ import { GaugeArguments, GaugeShapes } from '..'; import { functionWrapper } from '@kbn/expressions-plugin/common/expression_functions/specs/tests/utils'; import { Datatable } from '@kbn/expressions-plugin/common/expression_types/specs'; import { + EXPRESSION_GAUGE_NAME, GaugeCentralMajorModes, GaugeColorModes, GaugeLabelMajorModes, @@ -110,4 +111,23 @@ describe('interpreter/functions#gauge', () => { expect(loggedTable!).toMatchSnapshot(); }); + + it('should pass over overrides from variables', async () => { + const overrides = { + settings: { + onBrushEnd: 'ignore', + }, + }; + const handlers = { + variables: { overrides }, + getExecutionContext: jest.fn(), + } as unknown as ExecutionContext; + const result = await fn(context, args, handlers); + + expect(result).toEqual({ + type: 'render', + as: EXPRESSION_GAUGE_NAME, + value: expect.objectContaining({ overrides }), + }); + }); }); diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts index 72919e7c9b414..0346cef6b4ef2 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; -import { GaugeExpressionFunctionDefinition } from '../types'; +import { GaugeExpressionFunctionDefinition, GaugeRenderProps } from '../types'; import { EXPRESSION_GAUGE_NAME, GaugeCentralMajorModes, @@ -232,6 +232,7 @@ export const gaugeFunction = (): GaugeExpressionFunctionDefinition => ({ handlers.getExecutionContext?.()?.description, }, canNavigateToLens: Boolean(handlers?.variables?.canNavigateToLens), + overrides: handlers.variables?.overrides as GaugeRenderProps['overrides'], }, }; }, diff --git a/src/plugins/chart_expressions/expression_gauge/common/index.ts b/src/plugins/chart_expressions/expression_gauge/common/index.ts index 24d4dc3c8d997..a773ae1d27bb4 100755 --- a/src/plugins/chart_expressions/expression_gauge/common/index.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/index.ts @@ -10,6 +10,7 @@ export const PLUGIN_ID = 'expressionGauge'; export const PLUGIN_NAME = 'expressionGauge'; export type { + AllowedGaugeOverrides, GaugeExpressionFunctionDefinition, GaugeExpressionProps, FormatFactory, diff --git a/src/plugins/chart_expressions/expression_gauge/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_gauge/common/types/expression_functions.ts index a7a89a876d699..b354c176f7e0c 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/types/expression_functions.ts @@ -15,6 +15,8 @@ import { } from '@kbn/expressions-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { CustomPaletteState } from '@kbn/charts-plugin/common'; +import type { MakeOverridesSerializable, Simplify } from '@kbn/chart-expressions-common/types'; +import type { GoalProps } from '@elastic/charts'; import { EXPRESSION_GAUGE_NAME, GAUGE_FUNCTION_RENDERER_NAME, @@ -84,3 +86,7 @@ export interface Accessors { metric?: string; goal?: string; } + +export type AllowedGaugeOverrides = Partial< + Record<'gauge', Simplify>> +>; diff --git a/src/plugins/chart_expressions/expression_gauge/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_gauge/common/types/expression_renderers.ts index 6219134892ab1..918c02948c373 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/types/expression_renderers.ts @@ -10,7 +10,8 @@ import type { PaletteRegistry } from '@kbn/coloring'; import type { PersistedState } from '@kbn/visualizations-plugin/public'; import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; import type { IFieldFormat, SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; -import type { GaugeExpressionProps } from './expression_functions'; +import type { AllowedSettingsOverrides } from '@kbn/charts-plugin/common'; +import type { AllowedGaugeOverrides, GaugeExpressionProps } from './expression_functions'; export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; @@ -20,4 +21,5 @@ export type GaugeRenderProps = GaugeExpressionProps & { paletteService: PaletteRegistry; renderComplete: () => void; uiState: PersistedState; + overrides?: AllowedGaugeOverrides & AllowedSettingsOverrides; }; diff --git a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx index 640a8e17b47c7..da8985d0427af 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx +++ b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx @@ -21,7 +21,7 @@ import { GaugeColorModes, } from '../../common'; import GaugeComponent from './gauge_component'; -import { Chart, Goal } from '@elastic/charts'; +import { Chart, Goal, Settings } from '@elastic/charts'; jest.mock('@elastic/charts', () => { const original = jest.requireActual('@elastic/charts'); @@ -405,4 +405,19 @@ describe('GaugeComponent', function () { expect(goal.prop('bands')).toEqual([0, 2, 6, 8, 10]); }); }); + + describe('overrides', () => { + it('should apply overrides to the settings component', () => { + const component = shallowWithIntl( + + ); + + const settingsComponent = component.find(Settings); + expect(settingsComponent.prop('onBrushEnd')).toBeUndefined(); + expect(settingsComponent.prop('ariaUseDefaultSummary')).toEqual(true); + }); + }); }); diff --git a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx index 489b44e44babb..c20f5d089a889 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx +++ b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx @@ -12,6 +12,7 @@ import type { PaletteOutput } from '@kbn/coloring'; import { FieldFormat } from '@kbn/field-formats-plugin/common'; import type { CustomPaletteState } from '@kbn/charts-plugin/public'; import { EmptyPlaceholder } from '@kbn/charts-plugin/public'; +import { getOverridesFor } from '@kbn/chart-expressions-common'; import { isVisDimension } from '@kbn/visualizations-plugin/common/utils'; import { GaugeRenderProps, @@ -167,7 +168,16 @@ function getTicks( } export const GaugeComponent: FC = memo( - ({ data, args, uiState, formatFactory, paletteService, chartsThemeService, renderComplete }) => { + ({ + data, + args, + uiState, + formatFactory, + paletteService, + chartsThemeService, + renderComplete, + overrides, + }) => { const { shape: gaugeType, palette, @@ -360,6 +370,7 @@ export const GaugeComponent: FC = memo( ariaLabel={args.ariaLabel} ariaUseDefaultSummary={!args.ariaLabel} onRenderChange={onRenderChange} + {...getOverridesFor(overrides, 'settings')} /> = memo( labelMinor={labelMinor ? `${labelMinor}${minorExtraSpaces}` : ''} {...extraTitles} {...goalConfig} + {...getOverridesFor(overrides, 'gauge')} /> {commonLabel &&
{commonLabel}
} diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap index 1b644ef0a4938..c661fe65a434d 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap @@ -102,6 +102,7 @@ Object { ], "type": "datatable", }, + "overrides": undefined, "syncCursor": true, "syncTooltips": false, }, diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts index b3506cfa3578f..24eabf62245be 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts @@ -10,7 +10,11 @@ import { heatmapFunction } from './heatmap_function'; import type { HeatmapArguments } from '..'; import { functionWrapper } from '@kbn/expressions-plugin/common/expression_functions/specs/tests/utils'; import { Datatable } from '@kbn/expressions-plugin/common/expression_types/specs'; -import { EXPRESSION_HEATMAP_GRID_NAME, EXPRESSION_HEATMAP_LEGEND_NAME } from '../constants'; +import { + EXPRESSION_HEATMAP_GRID_NAME, + EXPRESSION_HEATMAP_LEGEND_NAME, + EXPRESSION_HEATMAP_NAME, +} from '../constants'; import { ExecutionContext } from '@kbn/expressions-plugin/common'; describe('interpreter/functions#heatmap', () => { @@ -80,4 +84,23 @@ describe('interpreter/functions#heatmap', () => { expect(loggedTable!).toMatchSnapshot(); }); + + it('should pass over overrides from variables', async () => { + const overrides = { + settings: { + onBrushEnd: 'ignore', + }, + }; + const handlers = { + variables: { overrides }, + getExecutionContext: jest.fn(), + } as unknown as ExecutionContext; + const result = await fn(context, args, handlers); + + expect(result).toEqual({ + type: 'render', + as: EXPRESSION_HEATMAP_NAME, + value: expect.objectContaining({ overrides }), + }); + }); }); diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts index f0c309de19236..2334aa342ad4f 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts @@ -14,7 +14,7 @@ import { Dimension, validateAccessor, } from '@kbn/visualizations-plugin/common/utils'; -import { HeatmapExpressionFunctionDefinition } from '../types'; +import type { HeatmapExpressionFunctionDefinition, HeatmapExpressionProps } from '../types'; import { EXPRESSION_HEATMAP_NAME, EXPRESSION_HEATMAP_GRID_NAME, @@ -230,9 +230,10 @@ export const heatmapFunction = (): HeatmapExpressionFunctionDefinition => ({ (handlers.variables?.embeddableTitle as string) ?? handlers.getExecutionContext?.()?.description, }, - syncTooltips: handlers?.isSyncTooltipsEnabled?.() ?? false, - syncCursor: handlers?.isSyncCursorEnabled?.() ?? true, - canNavigateToLens: Boolean(handlers?.variables?.canNavigateToLens), + syncTooltips: handlers.isSyncTooltipsEnabled?.() ?? false, + syncCursor: handlers.isSyncCursorEnabled?.() ?? true, + canNavigateToLens: Boolean(handlers.variables?.canNavigateToLens), + overrides: handlers.variables?.overrides as HeatmapExpressionProps['overrides'], }, }; }, diff --git a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts index 2537d4c7f5105..80cea368cf21f 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts @@ -14,7 +14,7 @@ import { } from '@kbn/expressions-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; -import { CustomPaletteState } from '@kbn/charts-plugin/common'; +import { AllowedSettingsOverrides, CustomPaletteState } from '@kbn/charts-plugin/common'; import type { LegendSize } from '@kbn/visualizations-plugin/public'; import { EXPRESSION_HEATMAP_NAME, @@ -95,6 +95,7 @@ export interface HeatmapExpressionProps { syncTooltips: boolean; syncCursor: boolean; canNavigateToLens?: boolean; + overrides?: AllowedSettingsOverrides; } export interface HeatmapRender { diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx index 4720e9025d63d..43d8d61dea133 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx @@ -428,4 +428,19 @@ describe('HeatmapComponent', function () { expect(component.find(Settings).first().prop('onElementClick')).toBeUndefined(); expect(component.find(Settings).first().prop('onBrushEnd')).toBeUndefined(); }); + + describe('overrides', () => { + it('should apply overrides to the settings component', () => { + const component = shallowWithIntl( + + ); + + const settingsComponent = component.find(Settings); + expect(settingsComponent.prop('onBrushEnd')).toBeUndefined(); + expect(settingsComponent.prop('ariaUseDefaultSummary')).toEqual(true); + }); + }); }); 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 51ede8e7f59ba..a9b4a2d227b27 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 @@ -22,6 +22,7 @@ import { ESFixedIntervalUnit, ESCalendarIntervalUnit, PartialTheme, + SettingsProps, } from '@elastic/charts'; import type { CustomPaletteState } from '@kbn/charts-plugin/public'; import { search } from '@kbn/data-plugin/public'; @@ -36,6 +37,7 @@ import { } from '@kbn/visualizations-plugin/common/constants'; import { DatatableColumn } from '@kbn/expressions-plugin/public'; import { IconChartHeatmap } from '@kbn/chart-icons'; +import { getOverridesFor } from '@kbn/chart-expressions-common'; import type { HeatmapRenderProps, FilterEvent, BrushEvent } from '../../common'; import { applyPaletteParams, @@ -148,6 +150,7 @@ export const HeatmapComponent: FC = memo( syncTooltips, syncCursor, renderComplete, + overrides, }) => { const chartRef = useRef(null); const chartTheme = chartsThemeService.useChartsTheme(); @@ -498,6 +501,11 @@ export const HeatmapComponent: FC = memo( }; }); + const { theme: settingsThemeOverrides = {}, ...settingsOverrides } = getOverridesFor( + overrides, + 'settings' + ) as Partial; + const themeOverrides: PartialTheme = { legend: { labelOptions: { @@ -591,7 +599,13 @@ export const HeatmapComponent: FC = memo( legendColorPicker={uiState ? LegendColorPickerWrapper : undefined} debugState={window._echDebugStateFlag ?? false} tooltip={tooltip} - theme={[themeOverrides, chartTheme]} + theme={[ + themeOverrides, + chartTheme, + ...(Array.isArray(settingsThemeOverrides) + ? settingsThemeOverrides + : [settingsThemeOverrides]), + ]} baseTheme={chartBaseTheme} xDomain={{ min: @@ -606,6 +620,7 @@ export const HeatmapComponent: FC = memo( onBrushEnd={interactive ? (onBrushEnd as BrushEndListener) : undefined} ariaLabel={args.ariaLabel} ariaUseDefaultSummary={!args.ariaLabel} + {...settingsOverrides} /> { + const fn = functionWrapper(metricVisFunction()); + const context: Datatable = { + type: 'datatable', + rows: [{ 'col-0-1': 0 }], + columns: [{ id: 'col-0-1', name: 'Count', meta: { type: 'number' } }], + }; + const args: MetricArguments = { + metric: 'col-0-1', + progressDirection: 'horizontal', + maxCols: 1, + inspectorTableId: 'random-id', + }; + + it('should pass over overrides from variables', async () => { + const overrides = { + settings: { + onBrushEnd: 'ignore', + }, + }; + const handlers = { + variables: { overrides }, + getExecutionContext: jest.fn(), + } as unknown as ExecutionContext; + const result = await fn(context, args, handlers); + + expect(result).toEqual({ + type: 'render', + as: EXPRESSION_METRIC_NAME, + value: expect.objectContaining({ overrides }), + }); + }); +}); diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts index 04a1284c1cf34..c5be73ab0b73c 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts @@ -14,7 +14,7 @@ import { validateAccessor, } from '@kbn/visualizations-plugin/common/utils'; import { LayoutDirection } from '@elastic/charts'; -import { visType } from '../types'; +import { MetricVisRenderConfig, visType } from '../types'; import { MetricVisExpressionFunctionDefinition } from '../types'; import { EXPRESSION_METRIC_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from '../constants'; @@ -194,6 +194,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ breakdownBy: args.breakdownBy, }, }, + overrides: handlers.variables?.overrides as MetricVisRenderConfig['overrides'], }, }; }, diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts index 9aa67b0df2ee5..2440ef597c0bd 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts @@ -14,7 +14,7 @@ import { ExpressionValueRender, } from '@kbn/expressions-plugin/common'; import { ExpressionValueVisDimension, prepareLogTable } from '@kbn/visualizations-plugin/common'; -import { CustomPaletteState } from '@kbn/charts-plugin/common'; +import type { AllowedSettingsOverrides, CustomPaletteState } from '@kbn/charts-plugin/common'; import { VisParams, visType } from './expression_renderers'; import { EXPRESSION_METRIC_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from '../constants'; @@ -40,6 +40,7 @@ export interface MetricVisRenderConfig { visType: typeof visType; visData: Datatable; visConfig: Pick; + overrides?: AllowedSettingsOverrides; } export type MetricVisExpressionFunctionDefinition = ExpressionFunctionDefinition< diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx index 5b2d260dbfae5..d10d1e39f3544 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx @@ -1471,4 +1471,29 @@ describe('MetricVisComponent', function () { expect(secondary).toBe('1.12K%'); }); }); + + describe('overrides', () => { + it('should apply overrides to the settings component', () => { + const component = shallow( + + ); + + const settingsComponent = component.find(Settings); + expect(settingsComponent.prop('onBrushEnd')).toBeUndefined(); + expect(settingsComponent.prop('ariaUseDefaultSummary')).toEqual(true); + }); + }); }); diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx index a3ec33b0e8023..6bac88177bf50 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx @@ -20,6 +20,7 @@ import { Settings, MetricWTrend, MetricWNumber, + SettingsProps, } from '@elastic/charts'; import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; @@ -36,6 +37,8 @@ import { CUSTOM_PALETTE } from '@kbn/coloring'; import { css } from '@emotion/react'; import { euiThemeVars } from '@kbn/ui-theme'; import { useResizeObserver, useEuiScrollBar } from '@elastic/eui'; +import { AllowedSettingsOverrides } from '@kbn/charts-plugin/common'; +import { getOverridesFor } from '@kbn/chart-expressions-common'; import { DEFAULT_TRENDLINE_NAME } from '../../common/constants'; import { VisParams } from '../../common'; import { @@ -177,6 +180,7 @@ export interface MetricVisComponentProps { fireEvent: IInterpreterRenderHandlers['event']; renderMode: RenderMode; filterable: boolean; + overrides?: AllowedSettingsOverrides; } export const MetricVis = ({ @@ -186,6 +190,7 @@ export const MetricVis = ({ fireEvent, renderMode, filterable, + overrides, }: MetricVisComponentProps) => { const primaryMetricColumn = getColumnByAccessor(config.dimensions.metric, data.columns)!; const formatPrimaryMetric = getMetricFormatter(config.dimensions.metric, data.columns); @@ -331,6 +336,11 @@ export const MetricVis = ({ ); }, [grid.length, minHeight, scrollDimensions.height]); + const { theme: settingsThemeOverrides = {}, ...settingsOverrides } = getOverridesFor( + overrides, + 'settings' + ) as Partial; + return (
diff --git a/src/plugins/chart_expressions/expression_metric/public/expression_renderers/metric_vis_renderer.tsx b/src/plugins/chart_expressions/expression_metric/public/expression_renderers/metric_vis_renderer.tsx index 677fd8db23928..9841e65d5ed20 100644 --- a/src/plugins/chart_expressions/expression_metric/public/expression_renderers/metric_vis_renderer.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/expression_renderers/metric_vis_renderer.tsx @@ -55,7 +55,7 @@ export const getMetricVisRenderer = ( name: EXPRESSION_METRIC_NAME, displayName: 'metric visualization', reuseDomNode: true, - render: async (domNode, { visData, visConfig }, handlers) => { + render: async (domNode, { visData, visConfig, overrides }, handlers) => { const { core, plugins } = deps.getStartDeps(); handlers.onDestroy(() => { @@ -103,6 +103,7 @@ export const getMetricVisRenderer = ( fireEvent={handlers.event} renderMode={handlers.getRenderMode()} filterable={filterable} + overrides={overrides} />
, 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 index f8b999c2bb764..604368d7ab130 100644 --- 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 @@ -45,6 +45,7 @@ Object { "as": "partitionVis", "type": "render", "value": Object { + "overrides": undefined, "params": Object { "listenOnChange": true, }, 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 index 9e71fcec0c8fa..293f86c6bf9ec 100644 --- 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 @@ -27,6 +27,7 @@ Object { "type": "render", "value": Object { "canNavigateToLens": false, + "overrides": undefined, "params": Object { "listenOnChange": true, }, @@ -168,6 +169,7 @@ Object { "type": "render", "value": Object { "canNavigateToLens": false, + "overrides": undefined, "params": Object { "listenOnChange": true, }, 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 index 891b217df37f0..f6817eca439cf 100644 --- 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 @@ -45,6 +45,7 @@ Object { "as": "partitionVis", "type": "render", "value": Object { + "overrides": undefined, "params": Object { "listenOnChange": true, }, 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 index 50400b3839b57..7c74291190a2d 100644 --- 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 @@ -37,6 +37,7 @@ Object { "as": "partitionVis", "type": "render", "value": Object { + "overrides": undefined, "params": Object { "listenOnChange": true, }, 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 index fd2951a2f1fb6..c10912c1e56a4 100644 --- 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 @@ -16,7 +16,7 @@ import { import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { Datatable } from '@kbn/expressions-plugin/common/expression_types/specs'; import { mosaicVisFunction } from './mosaic_vis_function'; -import { PARTITION_LABELS_VALUE } from '../constants'; +import { PARTITION_LABELS_VALUE, PARTITION_VIS_RENDERER_NAME } from '../constants'; import { ExecutionContext } from '@kbn/expressions-plugin/common'; describe('interpreter/functions#mosaicVis', () => { @@ -147,4 +147,23 @@ describe('interpreter/functions#mosaicVis', () => { expect(loggedTable!).toMatchSnapshot(); }); + + it('should pass over overrides from variables', async () => { + const overrides = { + settings: { + onBrushEnd: 'ignore', + }, + }; + const handlers = { + variables: { overrides }, + getExecutionContext: jest.fn(), + } as unknown as ExecutionContext; + const result = await fn(context, visConfig, handlers); + + expect(result).toEqual({ + type: 'render', + as: PARTITION_VIS_RENDERER_NAME, + value: expect.objectContaining({ overrides }), + }); + }); }); 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 index 75f2aa3c17dc1..fc863cf73c68c 100644 --- 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 @@ -9,7 +9,11 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; -import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; +import { + LegendDisplay, + type PartitionChartProps, + type PartitionVisParams, +} from '../types/expression_renderers'; import { ChartTypes, MosaicVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -172,6 +176,7 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ params: { listenOnChange: true, }, + overrides: handlers.variables?.overrides as PartitionChartProps['overrides'], }, }; }, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts index dc975e9a92758..4575a01237096 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts @@ -17,7 +17,7 @@ import { import { ExpressionValueVisDimension, LegendSize } from '@kbn/visualizations-plugin/common'; import { Datatable } from '@kbn/expressions-plugin/common/expression_types/specs'; import { pieVisFunction } from './pie_vis_function'; -import { PARTITION_LABELS_VALUE } from '../constants'; +import { PARTITION_LABELS_VALUE, PARTITION_VIS_RENDERER_NAME } from '../constants'; import { ExecutionContext } from '@kbn/expressions-plugin/common'; describe('interpreter/functions#pieVis', () => { @@ -144,4 +144,23 @@ describe('interpreter/functions#pieVis', () => { expect(loggedTable!).toMatchSnapshot(); }); + + it('should pass over overrides from variables', async () => { + const overrides = { + settings: { + onBrushEnd: 'ignore', + }, + }; + const handlers = { + variables: { overrides }, + getExecutionContext: jest.fn(), + } as unknown as ExecutionContext; + const result = await fn(context, { ...visConfig, isDonut: false }, handlers); + + expect(result).toEqual({ + type: 'render', + as: PARTITION_VIS_RENDERER_NAME, + value: expect.objectContaining({ overrides }), + }); + }); }); 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 index 4bf2ead1b9c52..0cf6522456c62 100644 --- 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 @@ -9,7 +9,12 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; -import { EmptySizeRatios, LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; +import { + EmptySizeRatios, + LegendDisplay, + type PartitionChartProps, + type PartitionVisParams, +} from '../types/expression_renderers'; import { ChartTypes, PieVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -199,6 +204,7 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ params: { listenOnChange: true, }, + overrides: handlers.variables?.overrides as PartitionChartProps['overrides'], }, }; }, 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 index edc8ec8b99100..4c6cffd6a9fb8 100644 --- 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 @@ -16,7 +16,7 @@ import { import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { Datatable } from '@kbn/expressions-plugin/common/expression_types/specs'; import { treemapVisFunction } from './treemap_vis_function'; -import { PARTITION_LABELS_VALUE } from '../constants'; +import { PARTITION_LABELS_VALUE, PARTITION_VIS_RENDERER_NAME } from '../constants'; import { ExecutionContext } from '@kbn/expressions-plugin/common'; describe('interpreter/functions#treemapVis', () => { @@ -150,4 +150,23 @@ describe('interpreter/functions#treemapVis', () => { expect(loggedTable!).toMatchSnapshot(); }); + + it('should pass over overrides from variables', async () => { + const overrides = { + settings: { + onBrushEnd: 'ignore', + }, + }; + const handlers = { + variables: { overrides }, + getExecutionContext: jest.fn(), + } as unknown as ExecutionContext; + const result = await fn(context, visConfig, handlers); + + expect(result).toEqual({ + type: 'render', + as: PARTITION_VIS_RENDERER_NAME, + value: expect.objectContaining({ overrides }), + }); + }); }); 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 index d5f91b1f0e1d3..2a5d0a6af7a8a 100644 --- 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 @@ -9,7 +9,11 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; -import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; +import { + LegendDisplay, + type PartitionChartProps, + type PartitionVisParams, +} from '../types/expression_renderers'; import { ChartTypes, TreemapVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -178,6 +182,7 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => params: { listenOnChange: true, }, + overrides: handlers.variables?.overrides as PartitionChartProps['overrides'], }, }; }, 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 index 606ff2c9b84c2..d84df1d4d0fc3 100644 --- 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 @@ -16,7 +16,7 @@ import { import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { Datatable } from '@kbn/expressions-plugin/common/expression_types/specs'; import { waffleVisFunction } from './waffle_vis_function'; -import { PARTITION_LABELS_VALUE } from '../constants'; +import { PARTITION_LABELS_VALUE, PARTITION_VIS_RENDERER_NAME } from '../constants'; import { ExecutionContext } from '@kbn/expressions-plugin/common'; describe('interpreter/functions#waffleVis', () => { @@ -121,4 +121,23 @@ describe('interpreter/functions#waffleVis', () => { expect(loggedTable!).toMatchSnapshot(); }); + + it('should pass over overrides from variables', async () => { + const overrides = { + settings: { + onBrushEnd: 'ignore', + }, + }; + const handlers = { + variables: { overrides }, + getExecutionContext: jest.fn(), + } as unknown as ExecutionContext; + const result = await fn(context, visConfig, handlers); + + expect(result).toEqual({ + type: 'render', + as: PARTITION_VIS_RENDERER_NAME, + value: expect.objectContaining({ overrides }), + }); + }); }); 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 index 1568454b86eb2..e4176cf6015c1 100644 --- 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 @@ -9,7 +9,11 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; -import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; +import { + LegendDisplay, + type PartitionChartProps, + type PartitionVisParams, +} from '../types/expression_renderers'; import { ChartTypes, WaffleVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -173,6 +177,7 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ params: { listenOnChange: true, }, + overrides: handlers.variables?.overrides as PartitionChartProps['overrides'], }, }; }, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/index.ts b/src/plugins/chart_expressions/expression_partition_vis/common/index.ts index d51838b334a09..d46f5a9b26b9d 100755 --- a/src/plugins/chart_expressions/expression_partition_vis/common/index.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/index.ts @@ -26,6 +26,7 @@ export { } from './expression_functions'; export type { + AllowedPartitionOverrides, ExpressionValuePartitionLabels, PieVisExpressionFunctionDefinition, TreemapVisExpressionFunctionDefinition, 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 index f5f2f0ef7f3cd..41e172c11829c 100644 --- 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 @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { PartitionProps } from '@elastic/charts'; +import type { MakeOverridesSerializable, Simplify } from '@kbn/chart-expressions-common/types'; import { ExpressionFunctionDefinition, Datatable, @@ -21,13 +23,13 @@ import { PARTITION_LABELS_FUNCTION, } from '../constants'; import { - RenderValue, - PieVisConfig, + type PartitionChartProps, + type PieVisConfig, LabelPositions, ValueFormats, - TreemapVisConfig, - MosaicVisConfig, - WaffleVisConfig, + type TreemapVisConfig, + type MosaicVisConfig, + type WaffleVisConfig, } from './expression_renderers'; export interface PartitionLabelsArguments { @@ -63,28 +65,28 @@ export type PieVisExpressionFunctionDefinition = ExpressionFunctionDefinition< typeof PIE_VIS_EXPRESSION_NAME, Datatable, PieVisConfig, - ExpressionValueRender + ExpressionValueRender >; export type TreemapVisExpressionFunctionDefinition = ExpressionFunctionDefinition< typeof TREEMAP_VIS_EXPRESSION_NAME, Datatable, TreemapVisConfig, - ExpressionValueRender + ExpressionValueRender >; export type MosaicVisExpressionFunctionDefinition = ExpressionFunctionDefinition< typeof MOSAIC_VIS_EXPRESSION_NAME, Datatable, MosaicVisConfig, - ExpressionValueRender + ExpressionValueRender >; export type WaffleVisExpressionFunctionDefinition = ExpressionFunctionDefinition< typeof WAFFLE_VIS_EXPRESSION_NAME, Datatable, WaffleVisConfig, - ExpressionValueRender + ExpressionValueRender >; export enum ChartTypes { @@ -101,3 +103,15 @@ export type PartitionLabelsExpressionFunctionDefinition = ExpressionFunctionDefi PartitionLabelsArguments, ExpressionValuePartitionLabels >; + +export type AllowedPartitionOverrides = Partial< + Record< + 'partition', + Simplify< + Omit< + MakeOverridesSerializable, + 'id' | 'data' | 'valueAccessor' | 'valueFormatter' | 'layers' | 'layout' + > + > + > +>; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts index b5c9ad985dd49..c2c4ebe72fd20 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts @@ -7,12 +7,17 @@ */ import { Position } from '@elastic/charts'; +import type { AllowedSettingsOverrides } from '@kbn/charts-plugin/common'; import type { PaletteOutput } from '@kbn/coloring'; -import { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; -import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; -import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; +import type { SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; +import type { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import type { LegendSize } from '@kbn/visualizations-plugin/public'; -import { ChartTypes, ExpressionValuePartitionLabels } from './expression_functions'; +import { + type AllowedPartitionOverrides, + ChartTypes, + type ExpressionValuePartitionLabels, +} from './expression_functions'; export enum EmptySizeRatios { SMALL = 0.3, @@ -107,12 +112,13 @@ export interface WaffleVisConfig extends Omit { showValuesInLegend: boolean; } -export interface RenderValue { +export interface PartitionChartProps { visData: Datatable; visType: ChartTypes; visConfig: PartitionVisParams; syncColors: boolean; canNavigateToLens?: boolean; + overrides?: AllowedPartitionOverrides & AllowedSettingsOverrides; } export enum LabelPositions { 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 index 2ab55c1c2c9cb..f43d7d8840b87 100644 --- 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 @@ -10,7 +10,7 @@ import React, { FC } from 'react'; import { ComponentStory } from '@storybook/react'; import { Render } from '@kbn/presentation-util-plugin/public/__stories__'; import { getPartitionVisRenderer } from '../expression_renderers'; -import { ChartTypes, RenderValue } from '../../common/types'; +import { ChartTypes, PartitionChartProps } from '../../common/types'; import { getStartDeps } from '../__mocks__'; import { mosaicArgTypes, treemapMosaicConfig, data } from './shared'; @@ -22,9 +22,9 @@ const containerSize = { const PartitionVisRenderer = () => getPartitionVisRenderer({ getStartDeps }); type Props = { - visType: RenderValue['visType']; - syncColors: RenderValue['syncColors']; -} & RenderValue['visConfig']; + visType: PartitionChartProps['visType']; + syncColors: PartitionChartProps['syncColors']; +} & PartitionChartProps['visConfig']; const PartitionVis: ComponentStory> = ({ visType, 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 index 0a58ec048c179..e896c3b382928 100644 --- 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 @@ -10,7 +10,7 @@ import React, { FC } from 'react'; import { ComponentStory } from '@storybook/react'; import { Render } from '@kbn/presentation-util-plugin/public/__stories__'; import { getPartitionVisRenderer } from '../expression_renderers'; -import { ChartTypes, RenderValue } from '../../common/types'; +import { ChartTypes, PartitionChartProps } from '../../common/types'; import { getStartDeps } from '../__mocks__'; import { pieDonutArgTypes, pieConfig, data } from './shared'; @@ -22,9 +22,9 @@ const containerSize = { const PartitionVisRenderer = () => getPartitionVisRenderer({ getStartDeps }); type Props = { - visType: RenderValue['visType']; - syncColors: RenderValue['syncColors']; -} & RenderValue['visConfig']; + visType: PartitionChartProps['visType']; + syncColors: PartitionChartProps['syncColors']; +} & PartitionChartProps['visConfig']; const PartitionVis: ComponentStory> = ({ visType, 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 index 544e5ea0ce593..c1c8098838624 100644 --- 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 @@ -9,12 +9,12 @@ import { Position } from '@elastic/charts'; import { LabelPositions, LegendDisplay, - RenderValue, + PartitionChartProps, PartitionVisParams, ValueFormats, } from '../../../common/types'; -export const config: RenderValue['visConfig'] = { +export const config: PartitionChartProps['visConfig'] = { addTooltip: true, legendDisplay: LegendDisplay.HIDE, metricsToLabels: { percent_uptime: 'percent_uptime' }, 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 index e02f090b5f7fa..33b337e652f92 100644 --- 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 @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -import { RenderValue } from '../../../common/types'; +import { PartitionChartProps } from '../../../common/types'; -export const data: RenderValue['visData'] = { +export const data: PartitionChartProps['visData'] = { type: 'datatable', columns: [ { 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 index bb6ff6de87feb..6b9248292c69b 100644 --- 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 @@ -10,7 +10,7 @@ import React, { FC } from 'react'; import { ComponentStory } from '@storybook/react'; import { Render } from '@kbn/presentation-util-plugin/public/__stories__'; import { getPartitionVisRenderer } from '../expression_renderers'; -import { ChartTypes, RenderValue } from '../../common/types'; +import { ChartTypes, PartitionChartProps } from '../../common/types'; import { getStartDeps } from '../__mocks__'; import { treemapArgTypes, treemapMosaicConfig, data } from './shared'; @@ -22,9 +22,9 @@ const containerSize = { const PartitionVisRenderer = () => getPartitionVisRenderer({ getStartDeps }); type Props = { - visType: RenderValue['visType']; - syncColors: RenderValue['syncColors']; -} & RenderValue['visConfig']; + visType: PartitionChartProps['visType']; + syncColors: PartitionChartProps['syncColors']; +} & PartitionChartProps['visConfig']; const PartitionVis: ComponentStory> = ({ visType, 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 index 08db3a58d67a7..d2c472018cf78 100644 --- 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 @@ -10,7 +10,7 @@ import React, { FC } from 'react'; import { ComponentStory } from '@storybook/react'; import { Render } from '@kbn/presentation-util-plugin/public/__stories__'; import { getPartitionVisRenderer } from '../expression_renderers'; -import { ChartTypes, RenderValue } from '../../common/types'; +import { ChartTypes, PartitionChartProps } from '../../common/types'; import { getStartDeps } from '../__mocks__'; import { waffleArgTypes, waffleConfig, data } from './shared'; @@ -22,9 +22,9 @@ const containerSize = { const PartitionVisRenderer = () => getPartitionVisRenderer({ getStartDeps }); type Props = { - visType: RenderValue['visType']; - syncColors: RenderValue['syncColors']; -} & RenderValue['visConfig']; + visType: PartitionChartProps['visType']; + syncColors: PartitionChartProps['syncColors']; +} & PartitionChartProps['visConfig']; const PartitionVis: ComponentStory> = ({ visType, 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 index 1a566571b4d6c..94512bd8b43b7 100644 --- 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 @@ -280,6 +280,7 @@ exports[`PartitionVisComponent should render correct structure for donut 1`] = ` }, }, }, + Object {}, ] } tooltip={ @@ -666,6 +667,7 @@ exports[`PartitionVisComponent should render correct structure for mosaic 1`] = }, }, }, + Object {}, ] } tooltip={ @@ -1115,6 +1117,7 @@ exports[`PartitionVisComponent should render correct structure for multi-metric }, }, }, + Object {}, ] } tooltip={ @@ -1564,6 +1567,7 @@ exports[`PartitionVisComponent should render correct structure for pie 1`] = ` }, }, }, + Object {}, ] } tooltip={ @@ -1950,6 +1954,7 @@ exports[`PartitionVisComponent should render correct structure for treemap 1`] = }, }, }, + Object {}, ] } tooltip={ @@ -2295,6 +2300,7 @@ exports[`PartitionVisComponent should render correct structure for waffle 1`] = }, }, }, + Object {}, ] } tooltip={ diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx index a16f1c2ad77ab..5eb48cfab6cd5 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx @@ -329,4 +329,19 @@ describe('PartitionVisComponent', function () { "Pie chart can't render with negative values." ); }); + + describe('overrides', () => { + it('should apply overrides to the settings component', () => { + const component = shallow( + + ); + + const settingsComponent = component.find(Settings); + expect(settingsComponent.prop('onBrushEnd')).toBeUndefined(); + expect(settingsComponent.prop('ariaUseDefaultSummary')).toEqual(true); + }); + }); }); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx index 843d6075ac60d..251caa0b4a88a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx @@ -18,6 +18,7 @@ import { TooltipType, SeriesIdentifier, PartitionElementEvent, + SettingsProps, } from '@elastic/charts'; import { useEuiTheme } from '@elastic/eui'; import type { PaletteRegistry } from '@kbn/coloring'; @@ -34,13 +35,15 @@ import { IInterpreterRenderHandlers, } from '@kbn/expressions-plugin/public'; import type { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { getOverridesFor } from '@kbn/chart-expressions-common'; import { consolidateMetricColumns } from '../../common/utils'; import { DEFAULT_PERCENT_DECIMALS } from '../../common/constants'; import { - PartitionVisParams, - BucketColumns, + type BucketColumns, ValueFormats, - PieContainerDimensions, + type PieContainerDimensions, + type PartitionChartProps, + type PartitionVisParams, } from '../../common/types/expression_renderers'; import { LegendColorPickerWrapper, @@ -66,7 +69,6 @@ import { partitionVisContainerStyle, partitionVisContainerWithToggleStyleFactory, } from './partition_vis_component.styles'; -import { ChartTypes } from '../../common/types'; import { filterOutConfig } from '../utils/filter_out_config'; import { ColumnCellValueActions, FilterEvent, StartDeps } from '../types'; @@ -78,10 +80,11 @@ declare global { _echDebugStateFlag?: boolean; } } -export interface PartitionVisComponentProps { +export type PartitionVisComponentProps = Omit< + PartitionChartProps, + 'navigateToLens' | 'visConfig' +> & { visParams: PartitionVisParams; - visData: Datatable; - visType: ChartTypes; uiState: PersistedState; fireEvent: IInterpreterRenderHandlers['event']; renderComplete: IInterpreterRenderHandlers['done']; @@ -89,9 +92,8 @@ export interface PartitionVisComponentProps { chartsThemeService: ChartsPluginSetup['theme']; palettesRegistry: PaletteRegistry; services: Pick; - syncColors: boolean; columnCellValueActions: ColumnCellValueActions; -} +}; const PartitionVisComponent = (props: PartitionVisComponentProps) => { const { @@ -102,6 +104,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { services, syncColors, interactive, + overrides, } = props; const visParams = useMemo(() => filterOutConfig(visType, preVisParams), [preVisParams, visType]); const chartTheme = props.chartsThemeService.useChartsTheme(); @@ -354,6 +357,11 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { return 1; }, [visData.rows, metricColumn]); + const { theme: settingsThemeOverrides = {}, ...settingsOverrides } = getOverridesFor( + overrides, + 'settings' + ) as Partial; + const themeOverrides = useMemo( () => getPartitionTheme(visType, visParams, chartTheme, containerDimensions, rescaleFactor), [visType, visParams, chartTheme, containerDimensions, rescaleFactor] @@ -489,11 +497,16 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { }, }, }, + + ...(Array.isArray(settingsThemeOverrides) + ? settingsThemeOverrides + : [settingsThemeOverrides]), ]} baseTheme={chartBaseTheme} onRenderChange={onRenderChange} ariaLabel={props.visParams.ariaLabel} ariaUseDefaultSummary={!props.visParams.ariaLabel} + {...settingsOverrides} /> { } layers={layers} topGroove={!visParams.labels.show ? 0 : undefined} + {...getOverridesFor(overrides, 'partition')} />
diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx index 19bd89a893cec..056ba6b7136ce 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx @@ -25,7 +25,7 @@ import { extractContainerType, extractVisualizationType } from '@kbn/chart-expre import { VisTypePieDependencies } from '../plugin'; import { PARTITION_VIS_RENDERER_NAME } from '../../common/constants'; import { CellValueAction, GetCompatibleCellValueActions } from '../types'; -import { ChartTypes, PartitionVisParams, RenderValue } from '../../common/types'; +import { ChartTypes, type PartitionVisParams, type PartitionChartProps } from '../../common/types'; export const strings = { getDisplayName: () => @@ -73,14 +73,14 @@ export const getColumnCellValueActions = async ( export const getPartitionVisRenderer: ( deps: VisTypePieDependencies -) => ExpressionRenderDefinition = ({ getStartDeps }) => ({ +) => ExpressionRenderDefinition = ({ getStartDeps }) => ({ name: PARTITION_VIS_RENDERER_NAME, displayName: strings.getDisplayName(), help: strings.getHelpDescription(), reuseDomNode: true, render: async ( domNode, - { visConfig, visData, visType, syncColors, canNavigateToLens }, + { visConfig, visData, visType, syncColors, canNavigateToLens, overrides }, handlers ) => { const { core, plugins } = getStartDeps(); @@ -127,6 +127,7 @@ export const getPartitionVisRenderer: ( services={{ data: plugins.data, fieldFormats: plugins.fieldFormats }} syncColors={syncColors} columnCellValueActions={columnCellValueActions} + overrides={overrides} /> diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts index 305c6c2ee6496..cf1325f09bf22 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts @@ -7,7 +7,7 @@ */ import { XY_VIS_RENDERER } from '../constants'; -import { LayeredXyVisFn } from '../types'; +import { LayeredXyVisFn, type XYRender } from '../types'; import { logDatatables, logDatatable } from '../utils'; import { validateMarkSizeRatioLimits, @@ -65,6 +65,7 @@ export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) syncColors: handlers?.isSyncColorsEnabled?.() ?? false, syncTooltips: handlers?.isSyncTooltipsEnabled?.() ?? false, syncCursor: handlers?.isSyncCursorEnabled?.() ?? true, + overrides: handlers.variables?.overrides as XYRender['value']['overrides'], }, }; }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 90710b945c763..9a71ec92d7a51 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -355,4 +355,53 @@ describe('xyVis', () => { }, }); }); + + test('should pass over overrides from variables', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; + const overrides = { + settings: { + onBrushEnd: 'ignore', + }, + axisX: { + showOverlappingTicks: true, + }, + }; + const context = { + ...createMockExecutionContext(), + variables: { + overrides, + }, + }; + const result = await xyVisFunction.fn( + data, + { ...rest, ...restLayerArgs, referenceLines: [] }, + context + ); + + expect(result).toEqual({ + type: 'render', + as: XY_VIS, + value: { + args: { + ...rest, + layers: [ + { + layerType, + table: data, + layerId: 'dataLayers-0', + type, + ...restLayerArgs, + }, + ], + }, + canNavigateToLens: false, + syncColors: false, + syncTooltips: false, + syncCursor: true, + overrides, + }, + }); + }); }); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index ac867401dbe24..94d788106acb3 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -11,7 +11,7 @@ import type { Datatable } from '@kbn/expressions-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; import { LayerTypes, XY_VIS_RENDERER, DATA_LAYER } from '../constants'; import { appendLayerIds, getAccessors, getShowLines, normalizeTable } from '../helpers'; -import { DataLayerConfigResult, XYLayerConfig, XyVisFn, XYArgs } from '../types'; +import type { DataLayerConfigResult, XYLayerConfig, XyVisFn, XYArgs, XYRender } from '../types'; import { hasAreaLayer, hasBarLayer, @@ -137,6 +137,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { syncColors: handlers?.isSyncColorsEnabled?.() ?? false, syncTooltips: handlers?.isSyncTooltipsEnabled?.() ?? false, syncCursor: handlers?.isSyncCursorEnabled?.() ?? true, + overrides: handlers.variables?.overrides as XYRender['value']['overrides'], }, }; }; diff --git a/src/plugins/chart_expressions/expression_xy/common/index.ts b/src/plugins/chart_expressions/expression_xy/common/index.ts index 76c73d766b6cc..7e57b8b47a4aa 100755 --- a/src/plugins/chart_expressions/expression_xy/common/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/index.ts @@ -12,6 +12,7 @@ export const PLUGIN_NAME = 'expressionXy'; export { LayerTypes } from './constants'; export type { + AllowedXYOverrides, XYArgs, EndValue, XYRender, diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index 7d5df1a7394bb..0a2c00ed4f17f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -6,14 +6,15 @@ * Side Public License, v 1. */ -import { HorizontalAlignment, Position, VerticalAlignment } from '@elastic/charts'; -import { $Values } from '@kbn/utility-types'; +import { type AxisProps, HorizontalAlignment, Position, VerticalAlignment } from '@elastic/charts'; +import type { $Values } from '@kbn/utility-types'; import type { PaletteOutput } from '@kbn/coloring'; -import { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common'; +import type { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common'; import { LegendSize } from '@kbn/visualizations-plugin/common'; import { EventAnnotationOutput } from '@kbn/event-annotation-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { MakeOverridesSerializable, Simplify } from '@kbn/chart-expressions-common/types'; import { AxisExtentModes, FillStyles, @@ -497,3 +498,11 @@ export type ExtendedAnnotationLayerFn = ExpressionFunctionDefinition< ExtendedAnnotationLayerArgs, ExtendedAnnotationLayerConfigResult >; + +export type AllowedXYOverrides = Partial< + Record< + 'axisX' | 'axisLeft' | 'axisRight', + // id and groupId should not be overridden + Simplify, 'id' | 'groupId'>> + > +>; diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_renderers.ts index 0b65347192106..4da58cf64ae66 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_renderers.ts @@ -7,12 +7,13 @@ */ import { CustomAnnotationTooltip } from '@elastic/charts'; +import { AllowedSettingsOverrides } from '@kbn/charts-plugin/common'; import { AvailableAnnotationIcon, ManualPointEventAnnotationArgs, } from '@kbn/event-annotation-plugin/common'; import { XY_VIS_RENDERER } from '../constants'; -import { XYProps } from './expression_functions'; +import type { AllowedXYOverrides, XYProps } from './expression_functions'; export interface XYChartProps { args: XYProps; @@ -20,6 +21,7 @@ export interface XYChartProps { syncCursor: boolean; syncColors: boolean; canNavigateToLens?: boolean; + overrides?: AllowedXYOverrides & AllowedSettingsOverrides; } export interface XYRender { diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index 2bcd8f89b9f60..fb8faf123fa66 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -607,19 +607,22 @@ exports[`XYChart component it renders area 1`] = ` showLegend={false} showLegendExtra={false} theme={ - Object { - "background": Object { - "color": undefined, - }, - "barSeriesStyle": Object {}, - "chartMargins": Object {}, - "legend": Object { - "labelOptions": Object { - "maxLines": 0, + Array [ + Object { + "background": Object { + "color": undefined, + }, + "barSeriesStyle": Object {}, + "chartMargins": Object {}, + "legend": Object { + "labelOptions": Object { + "maxLines": 0, + }, }, + "markSizeRatio": undefined, }, - "markSizeRatio": undefined, - } + Object {}, + ] } /> { ); expect(component.find(Settings).at(0).prop('theme')).toEqual( - expect.objectContaining(markSizeRatioArg) + expect.arrayContaining([expect.objectContaining(markSizeRatioArg)]) ); }); @@ -3468,4 +3468,84 @@ describe('XYChart component', () => { expect(headerFormatter).not.toBeUndefined(); }); }); + + describe('overrides', () => { + it('should work for settings component', () => { + const { args } = sampleArgs(); + + const component = shallow( + + ); + + const settingsComponent = component.find(Settings); + expect(settingsComponent.prop('onBrushEnd')).toBeUndefined(); + expect(settingsComponent.prop('ariaUseDefaultSummary')).toEqual(true); + }); + + it('should work for all axes components', () => { + const args = createArgsWithLayers(); + const layer = args.layers[0] as DataLayerConfig; + + const component = shallow( + + ); + + const axes = component.find(Axis); + expect(axes).toHaveLength(3); + if (Array.isArray(axes)) { + for (const axis of axes) { + expect(axis.prop('showOverlappingTicks').toEqual(true)); + } + } + }); + }); }); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 2c0327d7c4491..8d903a233fee7 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -31,6 +31,7 @@ import { Tooltip, XYChartSeriesIdentifier, TooltipValue, + SettingsProps, } from '@elastic/charts'; import { partition } from 'lodash'; import { IconType } from '@elastic/eui'; @@ -51,6 +52,7 @@ import { LegendSizeToPixels, } from '@kbn/visualizations-plugin/common/constants'; import { PersistedState } from '@kbn/visualizations-plugin/public'; +import { getOverridesFor } from '@kbn/chart-expressions-common'; import type { FilterEvent, BrushEvent, @@ -226,6 +228,7 @@ export function XYChart({ renderComplete, uiState, timeFormat, + overrides, }: XYChartRenderProps) { const { legend, @@ -792,6 +795,11 @@ export function XYChart({ // enable the tooltip actions only if there is at least one splitAccessor to the dataLayer const hasTooltipActions = dataLayers.some((dataLayer) => dataLayer.splitAccessors) && interactive; + const { theme: settingsThemeOverrides = {}, ...settingsOverrides } = getOverridesFor( + overrides, + 'settings' + ) as Partial; + return (
{showLegend !== undefined && uiState && ( @@ -886,31 +894,36 @@ export function XYChart({ showLegend={showLegend} legendPosition={legend?.isInside ? legendInsideParams : legend.position} legendSize={LegendSizeToPixels[legend.legendSize ?? DEFAULT_LEGEND_SIZE]} - theme={{ - ...chartTheme, - barSeriesStyle: { - ...chartTheme.barSeriesStyle, - ...valueLabelsStyling, - }, - background: { - color: undefined, // removes background for embeddables - }, - legend: { - labelOptions: { maxLines: legend.shouldTruncate ? legend?.maxLines ?? 1 : 0 }, + theme={[ + { + ...chartTheme, + barSeriesStyle: { + ...chartTheme.barSeriesStyle, + ...valueLabelsStyling, + }, + background: { + color: undefined, // removes background for embeddables + }, + legend: { + labelOptions: { maxLines: legend.shouldTruncate ? legend?.maxLines ?? 1 : 0 }, + }, + // if not title or labels are shown for axes, add some padding if required by reference line markers + chartMargins: { + ...chartTheme.chartPaddings, + ...computeChartMargins( + linesPaddings, + { ...tickLabelsVisibilitySettings, x: xAxisConfig?.showLabels }, + { ...axisTitlesVisibilitySettings, x: xAxisConfig?.showTitle }, + yAxesMap, + shouldRotate + ), + }, + markSizeRatio: args.markSizeRatio, }, - // if not title or labels are shown for axes, add some padding if required by reference line markers - chartMargins: { - ...chartTheme.chartPaddings, - ...computeChartMargins( - linesPaddings, - { ...tickLabelsVisibilitySettings, x: xAxisConfig?.showLabels }, - { ...axisTitlesVisibilitySettings, x: xAxisConfig?.showTitle }, - yAxesMap, - shouldRotate - ), - }, - markSizeRatio: args.markSizeRatio, - }} + ...(Array.isArray(settingsThemeOverrides) + ? settingsThemeOverrides + : [settingsThemeOverrides]), + ]} baseTheme={chartBaseTheme} allowBrushingLastHistogramBin={isTimeViz} rotation={shouldRotate ? 90 : 0} @@ -940,6 +953,7 @@ export function XYChart({ } : undefined } + {...settingsOverrides} /> {isSplitChart && splitTable && ( ); })} diff --git a/src/plugins/charts/common/index.ts b/src/plugins/charts/common/index.ts index 4d7b4df6028ee..9947305c50757 100644 --- a/src/plugins/charts/common/index.ts +++ b/src/plugins/charts/common/index.ts @@ -18,7 +18,7 @@ export type { export { palette, systemPalette } from './expressions/palette'; export { paletteIds, defaultCustomColors } from './constants'; -export type { ColorSchema, RawColorSchema, ColorMap } from './static'; +export type { AllowedSettingsOverrides, ColorSchema, RawColorSchema, ColorMap } from './static'; export { ColorSchemas, vislibColorMaps, diff --git a/src/plugins/charts/common/static/index.ts b/src/plugins/charts/common/static/index.ts index 6e1b3c5e5ebea..7f281c7d2c342 100644 --- a/src/plugins/charts/common/static/index.ts +++ b/src/plugins/charts/common/static/index.ts @@ -18,3 +18,4 @@ export { export { ColorMode, LabelRotation, defaultCountLabel } from './components'; export * from './styles'; +export type { AllowedSettingsOverrides } from './overrides'; diff --git a/src/plugins/charts/common/static/overrides/index.ts b/src/plugins/charts/common/static/overrides/index.ts new file mode 100644 index 0000000000000..578af2f9edf3f --- /dev/null +++ b/src/plugins/charts/common/static/overrides/index.ts @@ -0,0 +1,9 @@ +/* + * 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 './settings'; diff --git a/src/plugins/charts/common/static/overrides/settings.ts b/src/plugins/charts/common/static/overrides/settings.ts new file mode 100644 index 0000000000000..8fa9c9a2087c3 --- /dev/null +++ b/src/plugins/charts/common/static/overrides/settings.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 type { SettingsProps } from '@elastic/charts'; + +type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; + +// Overrides should not expose Functions, React nodes and children props +// So filter out any type which is not serializable +export type MakeOverridesSerializable = { + [KeyType in keyof T]: NonNullable extends Function + ? // cannot use boolean here as it would be challenging to distinguish + // between a "native" boolean props and a disabled callback + // so use a specific keyword + 'ignore' + : // be careful here to not filter out string/number types + NonNullable extends React.ReactChildren | React.ReactElement + ? never + : // make it recursive + NonNullable extends object + ? MakeOverridesSerializable + : NonNullable; +}; + +export type AllowedSettingsOverrides = Partial< + Record< + 'settings', + Simplify< + MakeOverridesSerializable< + Omit< + SettingsProps, + | 'onRenderChange' + | 'onPointerUpdate' + | 'orderOrdinalBinsBy' + | 'baseTheme' + | 'legendColorPicker' + > + > + > + > +>; diff --git a/src/plugins/charts/common/static/styles/multilayer_timeaxis.ts b/src/plugins/charts/common/static/styles/multilayer_timeaxis.ts index 02a5533f53fca..478cc7b52a73a 100644 --- a/src/plugins/charts/common/static/styles/multilayer_timeaxis.ts +++ b/src/plugins/charts/common/static/styles/multilayer_timeaxis.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Position, RecursivePartial, AxisStyle } from '@elastic/charts'; +import { Position, type RecursivePartial, type AxisStyle } from '@elastic/charts'; export const MULTILAYER_TIME_AXIS_STYLE: RecursivePartial = { tickLabel: { diff --git a/x-pack/examples/testing_embedded_lens/public/app.tsx b/x-pack/examples/testing_embedded_lens/public/app.tsx index 0f747135d3e99..4bf702d681054 100644 --- a/x-pack/examples/testing_embedded_lens/public/app.tsx +++ b/x-pack/examples/testing_embedded_lens/public/app.tsx @@ -36,10 +36,18 @@ import type { RangeIndexPatternColumn, PieVisualizationState, MedianIndexPatternColumn, + MetricVisualizationState, } from '@kbn/lens-plugin/public'; import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; import { CodeEditor, HJsonLang, KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { StartDependencies } from './plugin'; +import { + AllOverrides, + AttributesMenu, + LensAttributesByType, + OverridesMenu, + PanelMenu, +} from './controls'; type RequiredType = 'date' | 'string' | 'number'; type FieldsMap = Record; @@ -78,6 +86,13 @@ function getColumnFor(type: RequiredType, fieldName: string, isBucketed: boolean maxBars: 'auto', format: undefined, parentFormat: undefined, + ranges: [ + { + from: 0, + to: 1000, + label: '', + }, + ], }, } as RangeIndexPatternColumn; } @@ -162,12 +177,12 @@ function getBaseAttributes( // Generate a Lens state based on some app-specific input parameters. // `TypedLensByValueInput` can be used for type-safety - it uses the same interfaces as Lens-internal code. -function getLensAttributes( +function getLensAttributesXY( defaultIndexPattern: DataView, fields: FieldsMap, - chartType: 'bar_stacked' | 'line' | 'area', + chartType: XYState['preferredSeriesType'], color: string -): TypedLensByValueInput['attributes'] { +): LensAttributesByType<'lnsXY'> { const baseAttributes = getBaseAttributes(defaultIndexPattern, fields); const xyConfig: XYState = { @@ -203,7 +218,7 @@ function getLensAttributes( function getLensAttributesHeatmap( defaultIndexPattern: DataView, fields: FieldsMap -): TypedLensByValueInput['attributes'] { +): LensAttributesByType<'lnsHeatmap'> { const initialType = getInitialType(defaultIndexPattern); const dataLayer = getDataLayer(initialType, fields[initialType]); const heatmapDataLayer = { @@ -252,7 +267,7 @@ function getLensAttributesHeatmap( function getLensAttributesDatatable( defaultIndexPattern: DataView, fields: FieldsMap -): TypedLensByValueInput['attributes'] { +): LensAttributesByType<'lnsDatatable'> { const initialType = getInitialType(defaultIndexPattern); const baseAttributes = getBaseAttributes(defaultIndexPattern, fields, initialType); @@ -274,8 +289,9 @@ function getLensAttributesDatatable( function getLensAttributesGauge( defaultIndexPattern: DataView, - fields: FieldsMap -): TypedLensByValueInput['attributes'] { + fields: FieldsMap, + shape: GaugeVisualizationState['shape'] = 'horizontalBullet' +): LensAttributesByType<'lnsGauge'> { const dataLayer = getDataLayer('number', fields.number, false); const gaugeDataLayer = { columnOrder: ['col1'], @@ -288,7 +304,7 @@ function getLensAttributesGauge( const gaugeConfig: GaugeVisualizationState = { layerId: 'layer1', layerType: 'data', - shape: 'horizontalBullet', + shape, ticksPosition: 'auto', labelMajorMode: 'auto', metricAccessor: 'col1', @@ -306,7 +322,7 @@ function getLensAttributesGauge( function getLensAttributesPartition( defaultIndexPattern: DataView, fields: FieldsMap -): TypedLensByValueInput['attributes'] { +): LensAttributesByType<'lnsPie'> { const baseAttributes = getBaseAttributes(defaultIndexPattern, fields, 'number'); const pieConfig: PieVisualizationState = { layers: [ @@ -317,7 +333,7 @@ function getLensAttributesPartition( layerType: 'data', numberDisplay: 'percent', categoryDisplay: 'default', - legendDisplay: 'default', + legendDisplay: 'show', }, ], shape: 'pie', @@ -332,6 +348,30 @@ function getLensAttributesPartition( }; } +function getLensAttributesMetric( + defaultIndexPattern: DataView, + fields: FieldsMap, + color: string +): LensAttributesByType<'lnsMetric'> { + const dataLayer = getDataLayer('string', fields.number, true); + const baseAttributes = getBaseAttributes(defaultIndexPattern, fields, 'number', dataLayer); + const metricConfig: MetricVisualizationState = { + layerId: 'layer1', + layerType: 'data', + metricAccessor: 'col2', + color, + breakdownByAccessor: 'col1', + }; + return { + ...baseAttributes, + visualizationType: 'lnsMetric', + state: { + ...baseAttributes.state, + visualization: metricConfig, + }, + }; +} + function getFieldsByType(dataView: DataView) { const aggregatableFields = dataView.fields.filter((f) => f.aggregatable); const fields: Partial = { @@ -350,10 +390,6 @@ function getFieldsByType(dataView: DataView) { return fields as FieldsMap; } -function isXYChart(attributes: TypedLensByValueInput['attributes']) { - return attributes.visualizationType === 'lnsXY'; -} - function checkAndParseSO(newSO: string) { try { return JSON.parse(newSO) as TypedLensByValueInput['attributes']; @@ -394,23 +430,29 @@ export const App = (props: { to: 'now', }); + const initialColor = '#D6BF57'; + const defaultCharts = [ { id: 'bar_stacked', - attributes: getLensAttributes(props.defaultDataView, fields, 'bar_stacked', 'green'), + attributes: getLensAttributesXY(props.defaultDataView, fields, 'bar_stacked', initialColor), }, { id: 'line', - attributes: getLensAttributes(props.defaultDataView, fields, 'line', 'green'), + attributes: getLensAttributesXY(props.defaultDataView, fields, 'line', initialColor), }, { id: 'area', - attributes: getLensAttributes(props.defaultDataView, fields, 'area', 'green'), + attributes: getLensAttributesXY(props.defaultDataView, fields, 'area', initialColor), }, { id: 'pie', attributes: getLensAttributesPartition(props.defaultDataView, fields) }, { id: 'table', attributes: getLensAttributesDatatable(props.defaultDataView, fields) }, { id: 'heatmap', attributes: getLensAttributesHeatmap(props.defaultDataView, fields) }, { id: 'gauge', attributes: getLensAttributesGauge(props.defaultDataView, fields) }, + { + id: 'metric', + attributes: getLensAttributesMetric(props.defaultDataView, fields, initialColor), + }, ]; // eslint-disable-next-line react-hooks/exhaustive-deps const charts = useMemo(() => [...defaultCharts, ...loadedCharts], [loadedCharts]); @@ -429,11 +471,13 @@ export const App = (props: { const newAttributes = JSON.stringify(newChart.attributes, null, 2); currentSO.current = newAttributes; saveValidSO(newAttributes); + // clear the overrides + setOverrides(undefined); }, [charts] ); - const currentAttributes = useMemo(() => { + const currentAttributes: TypedLensByValueInput['attributes'] = useMemo(() => { try { return JSON.parse(currentSO.current); } catch (e) { @@ -442,10 +486,11 @@ export const App = (props: { }, [currentValid, currentSO]); const isDisabled = !currentAttributes; - const isColorDisabled = isDisabled || !isXYChart(currentAttributes); useDebounce(() => setErrorDebounced(hasParsingError), 500, [hasParsingError]); + const [overrides, setOverrides] = useState(); + return ( @@ -475,29 +520,28 @@ export const App = (props: { - { - const newColor = `rgb(${[1, 2, 3].map(() => - Math.floor(Math.random() * 256) - )})`; - const newAttributes = JSON.stringify( - getLensAttributes( - props.defaultDataView, - fields, - currentAttributes.state.visualization.preferredSeriesType, - newColor - ), - null, - 2 - ); - currentSO.current = newAttributes; - saveValidSO(newAttributes); - }} - isDisabled={isColorDisabled} - > - Change color - + + + + + + + - Edit in Lens (new tab) - - - - { - toggleTriggers((prevState) => !prevState); - }} - > - {enableTriggers ? 'Disable triggers' : 'Enable triggers'} - - - - { - setEnableExtraAction((prevState) => !prevState); - }} - > - {enableExtraAction ? 'Disable extra action' : 'Enable extra action'} - - - - { - setEnableDefaultAction((prevState) => !prevState); - }} - > - {enableDefaultAction ? 'Disable default action' : 'Enable default action'} + Open in Lens (new tab) @@ -602,6 +610,7 @@ export const App = (props: { style={{ height: 500 }} timeRange={time} attributes={currentAttributes} + overrides={overrides} onLoad={(val) => { setIsLoading(val); }} diff --git a/x-pack/examples/testing_embedded_lens/public/controls.tsx b/x-pack/examples/testing_embedded_lens/public/controls.tsx new file mode 100644 index 0000000000000..19d2ab257666f --- /dev/null +++ b/x-pack/examples/testing_embedded_lens/public/controls.tsx @@ -0,0 +1,597 @@ +/* + * 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, { useState } from 'react'; +import { isEqual } from 'lodash'; +import { + EuiButton, + EuiText, + EuiSpacer, + EuiColorPicker, + EuiFormRow, + EuiPopover, + useColorPickerState, + EuiSwitch, + EuiNotificationBadge, + EuiCodeBlock, + EuiIcon, + EuiToolTip, + EuiPopoverTitle, +} from '@elastic/eui'; +import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; + +export type LensAttributesByType = Extract< + TypedLensByValueInput['attributes'], + { visualizationType: VizType } +>; + +function isXYChart( + attributes: TypedLensByValueInput['attributes'] +): attributes is LensAttributesByType<'lnsXY'> { + return attributes.visualizationType === 'lnsXY'; +} + +function isPieChart( + attributes: TypedLensByValueInput['attributes'] +): attributes is LensAttributesByType<'lnsPie'> { + return attributes.visualizationType === 'lnsPie'; +} + +function isHeatmapChart( + attributes: TypedLensByValueInput['attributes'] +): attributes is LensAttributesByType<'lnsHeatmap'> { + return attributes.visualizationType === 'lnsHeatmap'; +} + +function isDatatable( + attributes: TypedLensByValueInput['attributes'] +): attributes is LensAttributesByType<'lnsDatatable'> { + return attributes.visualizationType === 'lnsDatatable'; +} + +function isGaugeChart( + attributes: TypedLensByValueInput['attributes'] +): attributes is LensAttributesByType<'lnsGauge'> { + return attributes.visualizationType === 'lnsGauge'; +} + +function isMetricChart( + attributes: TypedLensByValueInput['attributes'] +): attributes is LensAttributesByType<'lnsMetric'> { + return attributes.visualizationType === 'lnsMetric'; +} + +function isSupportedChart(attributes: TypedLensByValueInput['attributes']) { + return ( + isXYChart(attributes) || + isPieChart(attributes) || + isHeatmapChart(attributes) || + isGaugeChart(attributes) || + isMetricChart(attributes) + ); +} + +function mergeOverrides( + currentOverrides: AllOverrides | undefined, + newOverrides: AllOverrides | undefined, + defaultOverrides: AllOverrides +): AllOverrides | undefined { + if (currentOverrides == null || isEqual(currentOverrides, defaultOverrides)) { + return newOverrides; + } + if (newOverrides == null) { + return Object.fromEntries( + Object.entries(currentOverrides) + .map(([key, value]) => { + if (!(key in defaultOverrides)) { + return [key, value]; + } + // @ts-expect-error + if (isEqual(currentOverrides[key], defaultOverrides[key])) { + return []; + } + const newObject: Partial = {}; + // @ts-expect-error + for (const [innerKey, innerValue] of Object.entries(currentOverrides[key])) { + // @ts-expect-error + if (!(innerKey in defaultOverrides[key])) { + // @ts-expect-error + newObject[innerKey] = innerValue; + } + } + return [key, newObject]; + }) + .filter((arr) => arr.length) + ); + } + return { + ...currentOverrides, + ...newOverrides, + }; +} + +export function OverrideSwitch({ + rowLabel, + controlLabel, + value, + override, + setOverrideValue, + helpText, +}: { + rowLabel: string; + controlLabel: string; + helpText?: string; + value: AllOverrides | undefined; + override: AllOverrides; + setOverrideValue: (v: AllOverrides | undefined) => void; +}) { + // check if value contains an object with the same structure as the default override + const rootKey = Object.keys(override)[0] as keyof AllOverrides; + const overridePath = [ + rootKey, + Object.keys(override[rootKey] || {})[0] as keyof AllOverrides[keyof AllOverrides], + ]; + const hasOverrideEnabled = Boolean( + value && overridePath[0] in value && overridePath[1] in value[overridePath[0]]! + ); + return ( + } + position="right" + > + + {rowLabel} + + + } + helpText={helpText} + display="columnCompressedSwitch" + hasChildLabel={false} + > + { + const finalOverrides = mergeOverrides( + value, + hasOverrideEnabled ? undefined : override, + override + ); + setOverrideValue(finalOverrides); + }} + compressed + /> + + ); +} + +function CodeExample({ propName, code }: { propName: string; code: string }) { + return ( + + {` + + `} + + ); +} + +export function AttributesMenu({ + currentAttributes, + currentSO, + saveValidSO, +}: { + currentAttributes: TypedLensByValueInput['attributes']; + currentSO: React.MutableRefObject; + saveValidSO: (attr: string) => void; +}) { + const [attributesPopoverOpen, setAttributesPopoverOpen] = useState(false); + const [color, setColor, errors] = useColorPickerState('#D6BF57'); + + return ( + setAttributesPopoverOpen(!attributesPopoverOpen)} + iconType="arrowDown" + iconSide="right" + color="primary" + isDisabled={!isSupportedChart(currentAttributes)} + > + Lens Attributes + + } + isOpen={attributesPopoverOpen} + closePopover={() => setAttributesPopoverOpen(false)} + > +
+ {isXYChart(currentAttributes) ? ( + + { + setColor(newColor, output); + // for sake of semplicity of this example change it locally and then shallow copy it + const dataLayer = currentAttributes.state.visualization.layers[0]; + if ('yConfig' in dataLayer && dataLayer.yConfig) { + dataLayer.yConfig[0].color = newColor; + // this will make a string copy of it + const newAttributes = JSON.stringify(currentAttributes, null, 2); + currentSO.current = newAttributes; + saveValidSO(newAttributes); + } + }} + color={color} + isInvalid={!!errors} + /> + + ) : null} + {isMetricChart(currentAttributes) ? ( + + { + setColor(newColor, output); + // for sake of semplicity of this example change it locally and then shallow copy it + currentAttributes.state.visualization.color = newColor; + // this will make a string copy of it + const newAttributes = JSON.stringify(currentAttributes, null, 2); + currentSO.current = newAttributes; + saveValidSO(newAttributes); + }} + color={color} + isInvalid={!!errors} + /> + + ) : null} + {isPieChart(currentAttributes) ? ( + + { + currentAttributes.state.visualization.layers[0].numberDisplay = + currentAttributes.state.visualization.layers[0].numberDisplay === 'percent' + ? 'value' + : 'percent'; + // this will make a string copy of it + const newAttributes = JSON.stringify(currentAttributes, null, 2); + currentSO.current = newAttributes; + saveValidSO(newAttributes); + }} + compressed + /> + + ) : null} + {isHeatmapChart(currentAttributes) ? ( + + { + currentAttributes.state.visualization.percentageMode = + !currentAttributes.state.visualization.percentageMode; + // this will make a string copy of it + const newAttributes = JSON.stringify(currentAttributes, null, 2); + currentSO.current = newAttributes; + saveValidSO(newAttributes); + }} + compressed + /> + + ) : null} + {isGaugeChart(currentAttributes) ? ( + + { + currentAttributes.state.visualization.ticksPosition = + currentAttributes.state.visualization.ticksPosition === 'hidden' + ? 'auto' + : 'hidden'; + // this will make a string copy of it + const newAttributes = JSON.stringify(currentAttributes, null, 2); + currentSO.current = newAttributes; + saveValidSO(newAttributes); + }} + compressed + /> + + ) : null} +
+
+ ); +} + +type XYOverride = Record<'axisX' | 'axisLeft' | 'axisRight', { hide: boolean }>; +type PieOverride = Record<'partition', { fillOutside: boolean }>; +type GaugeOverride = Record<'gauge', { subtype: 'goal'; angleStart: number; angleEnd: number }>; +type SettingsOverride = Record< + 'settings', + | { onBrushEnd: 'ignore' } + | { + theme: { + heatmap: { xAxisLabel: { visible: boolean }; yAxisLabel: { visible: boolean } }; + }; + } + | { + theme: { + metric: { border: string }; + }; + } +>; + +export type AllOverrides = Partial; + +export function OverridesMenu({ + currentAttributes, + overrides, + setOverrides, +}: { + currentAttributes: TypedLensByValueInput['attributes']; + overrides: AllOverrides | undefined; + setOverrides: (overrides: AllOverrides | undefined) => void; +}) { + const [overridesPopoverOpen, setOverridesPopoverOpen] = useState(false); + const hasOverridesEnabled = Boolean(overrides) && !isDatatable(currentAttributes); + return ( + setOverridesPopoverOpen(!overridesPopoverOpen)} + iconType="arrowDown" + iconSide="right" + isDisabled={!isSupportedChart(currentAttributes)} + > + Overrides{' '} + + {hasOverridesEnabled ? 'ON' : 'OFF'} + + + } + isOpen={overridesPopoverOpen} + closePopover={() => setOverridesPopoverOpen(false)} + > +
+ Overrides + +

+ Overrides are local to the Embeddable and forgotten when the visualization is open in + the Editor. They should be used carefully for specific tweaks within the integration. +

+

+ There are mainly 2 use cases for overrides: +

    +
  • Specific styling/tuning feature missing in Lens
  • +
  • Disable specific chart behaviour
  • +
+

+

Here's some examples:

+
+ + {isXYChart(currentAttributes) ? ( + + ) : null} + {isHeatmapChart(currentAttributes) ? ( + + ) : null} + {isPieChart(currentAttributes) ? ( + + ) : null} + {isXYChart(currentAttributes) ? ( + + ) : null} + {isGaugeChart(currentAttributes) ? ( + + ) : null} + {isMetricChart(currentAttributes) ? ( + + ) : null} +
+
+ ); +} + +export function PanelMenu({ + enableTriggers, + toggleTriggers, + enableDefaultAction, + setEnableDefaultAction, + enableExtraAction, + setEnableExtraAction, +}: { + enableTriggers: boolean; + enableDefaultAction: boolean; + enableExtraAction: boolean; + toggleTriggers: (v: boolean) => void; + setEnableDefaultAction: (v: boolean) => void; + setEnableExtraAction: (v: boolean) => void; +}) { + const [panelPopoverOpen, setPanelPopoverOpen] = useState(false); + return ( + setPanelPopoverOpen(!panelPopoverOpen)} + iconType="arrowDown" + iconSide="right" + > + Embeddable settings + + } + isOpen={panelPopoverOpen} + closePopover={() => setPanelPopoverOpen(false)} + > +
+ Embeddable settings + +

+ It is possible to control and customize how the Embeddables is shown, disabling the + interactivity of the chart or filtering out default actions. +

+
+ + + { + toggleTriggers(!enableTriggers); + }} + compressed + /> + + + { + setEnableDefaultAction(!enableDefaultAction); + }} + compressed + /> + + +

It is also possible to pass custom actions to the panel:

+ + 'save', + async isCompatible( + context: ActionExecutionContext + ): Promise { + return true; + }, + execute: async (context: ActionExecutionContext) => { + alert('I am an extra action'); + return; + }, + getDisplayName: () => + 'Extra action', + } + ]`} + /> + } + position="right" + > + + Show custom action + + + } + display="columnCompressedSwitch" + helpText="Pass a consumer defined action to show in the panel context menu." + > + { + setEnableExtraAction(!enableExtraAction); + }} + compressed + /> + + + + ); +} diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index 6dadd451f23b2..bf348124e4616 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -31,6 +31,7 @@ import { caseViewProps, defaultGetCase, defaultGetCaseMetrics, + defaultInfiniteUseFindCaseUserActions, defaultUpdateCaseState, defaultUseFindCaseUserActions, } from './mocks'; @@ -39,6 +40,8 @@ import { userProfiles } from '../../containers/user_profiles/api.mock'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { getCaseConnectorsMockResponse } from '../../common/mock/connectors'; +import { useInfiniteFindCaseUserActions } from '../../containers/use_infinite_find_case_user_actions'; +import { useGetCaseUserActionsStats } from '../../containers/use_get_case_user_actions_stats'; const mockSetTitle = jest.fn(); @@ -46,6 +49,8 @@ jest.mock('../../containers/use_get_action_license'); jest.mock('../../containers/use_update_case'); jest.mock('../../containers/use_get_case_metrics'); jest.mock('../../containers/use_find_case_user_actions'); +jest.mock('../../containers/use_infinite_find_case_user_actions'); +jest.mock('../../containers/use_get_case_user_actions_stats'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/use_get_case'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); @@ -80,6 +85,8 @@ const useUrlParamsMock = useUrlParams as jest.Mock; const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock; const useUpdateCaseMock = useUpdateCase as jest.Mock; const useFindCaseUserActionsMock = useFindCaseUserActions as jest.Mock; +const useInfiniteFindCaseUserActionsMock = useInfiniteFindCaseUserActions as jest.Mock; +const useGetCaseUserActionsStatsMock = useGetCaseUserActionsStats as jest.Mock; const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; const usePostPushToServiceMock = usePostPushToService as jest.Mock; const useGetCaseConnectorsMock = useGetCaseConnectors as jest.Mock; @@ -111,6 +118,12 @@ export const caseClosedProps: CaseViewPageProps = { caseData: basicCaseClosed, }; +const userActionsStats = { + total: 21, + totalComments: 9, + totalOtherActions: 11, +}; + describe('CaseViewPage', () => { const updateCaseProperty = defaultUpdateCaseState.updateCaseProperty; const pushCaseToExternalService = jest.fn(); @@ -118,14 +131,15 @@ describe('CaseViewPage', () => { let appMockRenderer: AppMockRenderer; const caseConnectors = getCaseConnectorsMockResponse(); const caseUsers = getCaseUsersMockResponse(); - const refetchFindCaseUserActions = jest.fn(); beforeEach(() => { - mockGetCase(); jest.clearAllMocks(); + mockGetCase(); useUpdateCaseMock.mockReturnValue(defaultUpdateCaseState); useGetCaseMetricsMock.mockReturnValue(defaultGetCaseMetrics); useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions); + useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions); + useGetCaseUserActionsStatsMock.mockReturnValue({ data: userActionsStats, isLoading: false }); usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService }); useGetCaseConnectorsMock.mockReturnValue({ isLoading: false, @@ -349,22 +363,16 @@ describe('CaseViewPage', () => { }); }); - it('should show loading content when loading user actions', async () => { + it('should show loading content when loading user actions stats', async () => { const useFetchAlertData = jest.fn().mockReturnValue([true]); - useFindCaseUserActionsMock.mockReturnValue({ - data: undefined, - isError: false, - isLoading: true, - isFetching: true, - refetch: refetchFindCaseUserActions, - }); + useGetCaseUserActionsStatsMock.mockReturnValue({ isLoading: true }); const result = appMockRenderer.render( ); await waitFor(() => { expect(result.getByTestId('case-view-loading-content')).toBeInTheDocument(); - expect(result.queryByTestId('user-actions')).not.toBeInTheDocument(); + expect(result.queryByTestId('user-actions-list')).not.toBeInTheDocument(); }); }); @@ -374,7 +382,7 @@ describe('CaseViewPage', () => { ); - userEvent.click(result.getByTestId('comment-action-show-alert-alert-action-id')); + userEvent.click(result.getAllByTestId('comment-action-show-alert-alert-action-id')[1]); await waitFor(() => { expect(showAlertDetails).toHaveBeenCalledWith('alert-id-1', 'alert-index-1'); @@ -387,7 +395,7 @@ describe('CaseViewPage', () => { await waitFor(() => { expect( result - .getByTestId('user-action-alert-comment-create-action-alert-action-id') + .getAllByTestId('user-action-alert-comment-create-action-alert-action-id')[1] .querySelector('.euiCommentEvent__headerEvent') ).toHaveTextContent('added an alert from Awesome rule'); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx index cb2b501ed4041..aa28340c55308 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx @@ -32,10 +32,12 @@ import { useGetCaseConnectors } from '../../../containers/use_get_case_connector import { useGetCaseUsers } from '../../../containers/use_get_case_users'; import { waitForComponentToUpdate } from '../../../common/test_utils'; import { getCaseConnectorsMockResponse } from '../../../common/mock/connectors'; -import { defaultUseFindCaseUserActions } from '../mocks'; +import { defaultInfiniteUseFindCaseUserActions, defaultUseFindCaseUserActions } from '../mocks'; import { ActionTypes } from '../../../../common/api'; import { useGetCaseUserActionsStats } from '../../../containers/use_get_case_user_actions_stats'; +import { useInfiniteFindCaseUserActions } from '../../../containers/use_infinite_find_case_user_actions'; +jest.mock('../../../containers/use_infinite_find_case_user_actions'); jest.mock('../../../containers/use_find_case_user_actions'); jest.mock('../../../containers/use_get_case_user_actions_stats'); jest.mock('../../../containers/configure/use_get_supported_action_connectors'); @@ -82,8 +84,13 @@ const caseViewProps: CaseViewProps = { }, ], }; -const filterActionType = 'all'; -const sortOrder = 'asc'; + +const userActivityQueryParams = { + type: 'all', + sortOrder: 'asc', + page: 1, + perPage: 10, +}; const pushCaseToExternalService = jest.fn(); @@ -105,6 +112,7 @@ export const caseProps = { const caseUsers = getCaseUsersMockResponse(); const useFindCaseUserActionsMock = useFindCaseUserActions as jest.Mock; +const useInfiniteFindCaseUserActionsMock = useInfiniteFindCaseUserActions as jest.Mock; const useGetCaseUserActionsStatsMock = useGetCaseUserActionsStats as jest.Mock; const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; const usePostPushToServiceMock = usePostPushToService as jest.Mock; @@ -119,6 +127,7 @@ describe.skip('Case View Page activity tab', () => { beforeAll(() => { useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions); + useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions); useGetCaseUserActionsStatsMock.mockReturnValue({ data: userActionsStats, isLoading: false }); useGetConnectorsMock.mockReturnValue({ data: connectorsMock, isLoading: false }); usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService }); @@ -138,24 +147,40 @@ describe.skip('Case View Page activity tab', () => { }); beforeEach(() => { + jest.clearAllMocks(); appMockRender = createAppMockRenderer(); - useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions); useGetCaseUsersMock.mockReturnValue({ isLoading: false, data: caseUsers }); }); it('should render the activity content and main components', async () => { appMockRender = createAppMockRenderer({ license: platinumLicense }); - const result = appMockRender.render(); + appMockRender.render(); - expect(result.getByTestId('case-view-activity')).toBeInTheDocument(); - expect(result.getByTestId('user-actions')).toBeInTheDocument(); - expect(result.getByTestId('case-tags')).toBeInTheDocument(); - expect(result.getByTestId('connector-edit-header')).toBeInTheDocument(); - expect(result.getByTestId('case-view-status-action-button')).toBeInTheDocument(); - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: filterActionType, - sortOrder, - }); + expect(screen.getByTestId('case-view-activity')).toBeInTheDocument(); + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2); + expect(screen.getByTestId('case-tags')).toBeInTheDocument(); + expect(screen.getByTestId('connector-edit-header')).toBeInTheDocument(); + expect(screen.getByTestId('case-view-status-action-button')).toBeInTheDocument(); + + await waitForComponentToUpdate(); + }); + + it('should call use get user actions as per top and bottom actions list', async () => { + appMockRender = createAppMockRenderer({ license: platinumLicense }); + appMockRender.render(); + + const lastPageForAll = Math.ceil(userActionsStats.total / userActivityQueryParams.perPage); + + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith( + caseData.id, + userActivityQueryParams, + true + ); + expect(useFindCaseUserActionsMock).toHaveBeenCalledWith( + caseData.id, + { ...userActivityQueryParams, page: lastPageForAll }, + true + ); await waitForComponentToUpdate(); }); @@ -168,14 +193,10 @@ describe.skip('Case View Page activity tab', () => { const result = appMockRender.render(); expect(result.getByTestId('case-view-activity')).toBeInTheDocument(); - expect(result.getByTestId('user-actions')).toBeInTheDocument(); + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2); expect(result.getByTestId('case-tags')).toBeInTheDocument(); expect(result.getByTestId('connector-edit-header')).toBeInTheDocument(); expect(result.queryByTestId('case-view-status-action-button')).not.toBeInTheDocument(); - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: filterActionType, - sortOrder, - }); await waitForComponentToUpdate(); }); @@ -188,32 +209,20 @@ describe.skip('Case View Page activity tab', () => { const result = appMockRender.render(); expect(result.getByTestId('case-view-activity')).toBeInTheDocument(); - expect(result.getByTestId('user-actions')).toBeInTheDocument(); + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2); expect(result.getByTestId('case-tags')).toBeInTheDocument(); expect(result.getByTestId('connector-edit-header')).toBeInTheDocument(); expect(result.getByTestId('case-severity-selection')).toBeDisabled(); - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: filterActionType, - sortOrder, - }); await waitForComponentToUpdate(); }); - it('should show a loading when is fetching data is true and hide the user actions activity', () => { - useFindCaseUserActionsMock.mockReturnValue({ - ...defaultUseFindCaseUserActions, - isFetching: true, - isLoading: true, - }); + it('should show a loading when loading user actions stats', () => { + useGetCaseUserActionsStatsMock.mockReturnValue({ isLoading: true }); const result = appMockRender.render(); expect(result.getByTestId('case-view-loading-content')).toBeInTheDocument(); expect(result.queryByTestId('case-view-activity')).not.toBeInTheDocument(); - expect(result.queryByTestId('user-actions')).not.toBeInTheDocument(); - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: filterActionType, - sortOrder, - }); + expect(result.queryByTestId('user-actions-list')).not.toBeInTheDocument(); }); it('should not render the assignees on basic license', () => { @@ -251,104 +260,114 @@ describe.skip('Case View Page activity tab', () => { describe('filter activity', () => { beforeEach(() => { - useFindCaseUserActionsMock.mockReturnValue({ - ...defaultUseFindCaseUserActions, - }); + jest.clearAllMocks(); + useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions); + useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions); + useGetCaseUserActionsStatsMock.mockReturnValue({ data: userActionsStats, isLoading: false }); }); it('should show all filter as active', async () => { appMockRender.render(); - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: filterActionType, - sortOrder, - }); + const lastPageForAll = Math.ceil(userActionsStats.total / userActivityQueryParams.perPage); userEvent.click(screen.getByTestId('user-actions-filter-activity-button-all')); await waitFor(() => { - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: 'all', - sortOrder, - }); + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith( + caseData.id, + userActivityQueryParams, + true + ); + expect(useFindCaseUserActionsMock).toHaveBeenCalledWith( + caseData.id, + { ...userActivityQueryParams, page: lastPageForAll }, + true + ); expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id); - expect(screen.getByLabelText(`${userActionsStats.total - 1} active filters`)); + }); + + await waitFor(() => { + expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id); + expect(screen.getByLabelText(`${userActionsStats.total} active filters`)); expect(screen.getByLabelText(`${userActionsStats.totalComments} available filters`)); - expect( - screen.getByLabelText(`${userActionsStats.totalOtherActions - 1} available filters`) - ); + expect(screen.getByLabelText(`${userActionsStats.totalOtherActions} available filters`)); }); }); it('should show comment filter as active', async () => { appMockRender.render(); - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: filterActionType, - sortOrder, - }); + const lastPageForComment = Math.ceil( + userActionsStats.totalComments / userActivityQueryParams.perPage + ); userEvent.click(screen.getByTestId('user-actions-filter-activity-button-comments')); await waitFor(() => { - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: 'user', - sortOrder, - }); expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id); - expect(screen.getByLabelText(`${userActionsStats.totalComments} active filters`)); - expect(screen.getByLabelText(`${userActionsStats.total - 1} available filters`)); - expect( - screen.getByLabelText(`${userActionsStats.totalOtherActions - 1} available filters`) + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith( + caseData.id, + { ...userActivityQueryParams, type: 'user' }, + true + ); + expect(useFindCaseUserActionsMock).toHaveBeenCalledWith( + caseData.id, + { ...userActivityQueryParams, type: 'user', page: lastPageForComment }, + false ); }); + + await waitFor(() => { + expect(screen.getByLabelText(`${userActionsStats.totalComments} active filters`)); + expect(screen.getByLabelText(`${userActionsStats.total} available filters`)); + expect(screen.getByLabelText(`${userActionsStats.totalOtherActions} available filters`)); + }); }); it('should show history filter as active', async () => { appMockRender.render(); - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: filterActionType, - sortOrder, - }); + const lastPageForHistory = Math.ceil( + userActionsStats.totalOtherActions / userActivityQueryParams.perPage + ); userEvent.click(screen.getByTestId('user-actions-filter-activity-button-history')); await waitFor(() => { - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: 'action', - sortOrder, - }); expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id); - expect(screen.getByLabelText(`${userActionsStats.totalOtherActions - 1} active filters`)); + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith( + caseData.id, + { ...userActivityQueryParams, type: 'action' }, + true + ); + expect(useFindCaseUserActionsMock).toHaveBeenCalledWith( + caseData.id, + { ...userActivityQueryParams, type: 'action', page: lastPageForHistory }, + true + ); + }); + + await waitFor(() => { + expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id); + expect(screen.getByLabelText(`${userActionsStats.totalOtherActions} active filters`)); expect(screen.getByLabelText(`${userActionsStats.totalComments} available filters`)); - expect(screen.getByLabelText(`${userActionsStats.total - 1} available filters`)); + expect(screen.getByLabelText(`${userActionsStats.total} available filters`)); }); }); it('should render by desc sort order', async () => { appMockRender.render(); - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: filterActionType, - sortOrder, - }); - const sortSelect = screen.getByTestId('user-actions-sort-select'); fireEvent.change(sortSelect, { target: { value: 'desc' } }); await waitFor(() => { - expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, { - type: 'all', - sortOrder: 'desc', - }); expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id); - expect(screen.getByLabelText(`${userActionsStats.total - 1} active filters`)); + expect(screen.getByLabelText(`${userActionsStats.total} active filters`)); expect(screen.getByLabelText(`${userActionsStats.totalComments} available filters`)); - expect( - screen.getByLabelText(`${userActionsStats.totalOtherActions - 1} available filters`) - ); + expect(screen.getByLabelText(`${userActionsStats.totalOtherActions} available filters`)); }); }); }); @@ -496,7 +515,7 @@ describe.skip('Case View Page activity tab', () => { appMockRender = createAppMockRenderer(); const result = appMockRender.render(); - const userActions = within(result.getByTestId('user-actions')); + const userActions = within(result.getAllByTestId('user-actions-list')[1]); await waitFor(() => { expect(userActions.getByText('cases_no_connectors')).toBeInTheDocument(); @@ -524,7 +543,7 @@ describe.skip('Case View Page activity tab', () => { appMockRender = createAppMockRenderer(); const result = appMockRender.render(); - const userActions = within(result.getByTestId('user-actions')); + const userActions = within(result.getAllByTestId('user-actions-list')[1]); await waitFor(() => { expect(userActions.getByText('Fuzzy Marten')).toBeInTheDocument(); @@ -580,7 +599,7 @@ describe.skip('Case View Page activity tab', () => { appMockRender = createAppMockRenderer(); const result = appMockRender.render(); - const userActions = within(result.getByTestId('user-actions')); + const userActions = within(result.getAllByTestId('user-actions-list')[1]); await waitFor(() => { expect(userActions.getByText('Participant 1')).toBeInTheDocument(); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index b06b2f9d5398e..b178fe37bb450 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -7,7 +7,7 @@ /* eslint-disable complexity */ -import { EuiFlexGroup, EuiFlexItem, EuiSkeletonText, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import { isEqual } from 'lodash'; import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; @@ -29,7 +29,6 @@ import { useOnUpdateField } from '../use_on_update_field'; import { useCasesContext } from '../../cases_context/use_cases_context'; import * as i18n from '../translations'; import { SeveritySidebarSelector } from '../../severity/sidebar_selector'; -import { useFindCaseUserActions } from '../../../containers/use_find_case_user_actions'; import { useGetCaseUserActionsStats } from '../../../containers/use_get_case_user_actions_stats'; import { AssignUsers } from './assign_users'; import { UserActionsActivityBar } from '../../user_actions_activity_bar'; @@ -90,6 +89,8 @@ export const CaseViewActivity = ({ const [userActivityQueryParams, setUserActivityQueryParams] = useState({ type: 'all', sortOrder: 'asc', + page: 1, + perPage: 10, }); const { permissions } = useCasesContext(); @@ -100,11 +101,6 @@ export const CaseViewActivity = ({ caseData.id ); - const { data: userActionsData, isLoading: isLoadingUserActions } = useFindCaseUserActions( - caseData.id, - userActivityQueryParams - ); - const { data: userActionsStats, isLoading: isLoadingUserActionsStats } = useGetCaseUserActionsStats(caseData.id); @@ -180,25 +176,11 @@ export const CaseViewActivity = ({ [onUpdateField] ); - const showUserActions = - !isLoadingUserActions && - !isLoadingCaseConnectors && - userActionsData && - caseConnectors && - caseUsers; - - const showConnectorSidebar = - pushToServiceAuthorized && userActionsData && caseConnectors && supportedActionConnectors; - - const reporterAsArray = - caseUsers?.reporter != null - ? [caseUsers.reporter] - : [convertToCaseUserWithProfileInfo(caseData.createdBy)]; - const handleUserActionsActivityChanged = useCallback( (params: UserActivityParams) => { setUserActivityQueryParams((oldParams) => ({ ...oldParams, + page: 1, type: params.type, sortOrder: params.sortOrder, })); @@ -206,6 +188,22 @@ export const CaseViewActivity = ({ [setUserActivityQueryParams] ); + const showUserActions = + !isLoadingUserActionsStats && + !isLoadingCaseConnectors && + !isLoadingCaseUsers && + caseConnectors && + caseUsers && + userActionsStats; + + const showConnectorSidebar = + pushToServiceAuthorized && caseConnectors && supportedActionConnectors; + + const reporterAsArray = + caseUsers?.reporter != null + ? [caseUsers.reporter] + : [convertToCaseUserWithProfileInfo(caseData.createdBy)]; + const isLoadingDescription = isLoading && loadingKey === 'description'; return ( @@ -228,43 +226,38 @@ export const CaseViewActivity = ({ /> - - + {(isLoadingUserActionsStats || isLoadingCaseConnectors || isLoadingCaseUsers) && ( + + )} + {showUserActions ? ( - {showUserActions && ( - - ) : null - } - filterOptions={userActivityQueryParams.type} - useFetchAlertData={useFetchAlertData} - /> - )} + + ) : null + } + useFetchAlertData={useFetchAlertData} + userActivityQueryParams={userActivityQueryParams} + userActionsStats={userActionsStats} + /> - + ) : null} @@ -297,7 +290,7 @@ export const CaseViewActivity = ({ dataTestSubj="case-view-user-list-participants" theCase={caseData} headline={i18n.PARTICIPANTS} - loading={isLoadingUserActions} + loading={isLoadingCaseUsers} users={[...caseUsers.participants, ...caseUsers.assignees]} userProfiles={userProfiles} /> diff --git a/x-pack/plugins/cases/public/components/case_view/mocks.ts b/x-pack/plugins/cases/public/components/case_view/mocks.ts index 45bc0394cf428..1f656ea42aa8c 100644 --- a/x-pack/plugins/cases/public/components/case_view/mocks.ts +++ b/x-pack/plugins/cases/public/components/case_view/mocks.ts @@ -100,9 +100,22 @@ export const defaultUpdateCaseState = { }; export const defaultUseFindCaseUserActions = { - data: { userActions: [...caseUserActions, getAlertUserAction()] }, + data: { total: 4, perPage: 10, page: 1, userActions: [...caseUserActions, getAlertUserAction()] }, refetch: jest.fn(), isLoading: false, isFetching: false, isError: false, }; + +export const defaultInfiniteUseFindCaseUserActions = { + data: { + pages: [ + { total: 4, perPage: 10, page: 1, userActions: [...caseUserActions, getAlertUserAction()] }, + ], + }, + isLoading: false, + isFetching: false, + isError: false, + hasNextPage: false, + fetchNextPage: jest.fn(), +}; diff --git a/x-pack/plugins/cases/public/components/user_actions/builder.tsx b/x-pack/plugins/cases/public/components/user_actions/builder.tsx index 96e2ba248c729..4df1299c43292 100644 --- a/x-pack/plugins/cases/public/components/user_actions/builder.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/builder.tsx @@ -15,9 +15,11 @@ import { createSeverityUserActionBuilder } from './severity'; import { createStatusUserActionBuilder } from './status'; import { createTagsUserActionBuilder } from './tags'; import { createTitleUserActionBuilder } from './title'; +import { createCaseUserActionBuilder } from './create_case'; import type { UserActionBuilderMap } from './types'; export const builderMap: UserActionBuilderMap = { + create_case: createCaseUserActionBuilder, connector: createConnectorUserActionBuilder, tags: createTagsUserActionBuilder, title: createTitleUserActionBuilder, diff --git a/x-pack/plugins/cases/public/components/user_actions/constants.ts b/x-pack/plugins/cases/public/components/user_actions/constants.ts index 034d01d9d5b4b..be139337b5a09 100644 --- a/x-pack/plugins/cases/public/components/user_actions/constants.ts +++ b/x-pack/plugins/cases/public/components/user_actions/constants.ts @@ -11,7 +11,7 @@ import type { SupportedUserActionTypes } from './types'; export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft'; -export const UNSUPPORTED_ACTION_TYPES = ['create_case', 'delete_case'] as const; +export const UNSUPPORTED_ACTION_TYPES = ['delete_case'] as const; export const SUPPORTED_ACTION_TYPES: SupportedUserActionTypes[] = Object.keys( omit(ActionTypes, UNSUPPORTED_ACTION_TYPES) ) as SupportedUserActionTypes[]; diff --git a/x-pack/plugins/cases/public/components/user_actions/create_case.test.tsx b/x-pack/plugins/cases/public/components/user_actions/create_case.test.tsx new file mode 100644 index 0000000000000..d8e4d0888ea82 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/create_case.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { Actions } from '../../../common/api'; +import { getUserAction } from '../../containers/mock'; +import { TestProviders } from '../../common/mock'; +import { createCaseUserActionBuilder } from './create_case'; +import { getMockBuilderArgs } from './mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +describe('createCaseUserActionBuilder ', () => { + const builderArgs = getMockBuilderArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const userAction = getUserAction('create_case', Actions.create); + // @ts-ignore no need to pass all the arguments + const builder = createCaseUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('created case "a title"')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/create_case.tsx b/x-pack/plugins/cases/public/components/user_actions/create_case.tsx new file mode 100644 index 0000000000000..6a859d17b4038 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/create_case.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CreateCaseUserAction } from '../../../common/api'; +import type { UserActionBuilder, UserActionResponse } from './types'; +import { createCommonUpdateUserActionBuilder } from './common'; +import * as i18n from './translations'; + +const getLabelTitle = (userAction: UserActionResponse) => + `${i18n.CREATE_CASE.toLowerCase()} "${userAction.payload.title}"`; + +export const createCaseUserActionBuilder: UserActionBuilder = ({ + userAction, + userProfiles, + handleOutlineComment, +}) => ({ + build: () => { + const createCaseUserAction = userAction as UserActionResponse; + const label = getLabelTitle(createCaseUserAction); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + userProfiles, + handleOutlineComment, + label, + icon: 'dot', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/helpers.test.ts b/x-pack/plugins/cases/public/components/user_actions/helpers.test.ts index 9b07c99f94278..bd1873a591bee 100644 --- a/x-pack/plugins/cases/public/components/user_actions/helpers.test.ts +++ b/x-pack/plugins/cases/public/components/user_actions/helpers.test.ts @@ -62,7 +62,7 @@ describe('helpers', () => { ['title', true], ['status', true], ['settings', true], - ['create_case', false], + ['create_case', true], ['delete_case', false], ]; diff --git a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx index 8613467c3b03b..5008fc99c0f01 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx @@ -6,64 +6,79 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { waitFor, screen } from '@testing-library/react'; +import { waitFor, screen, within, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; // eslint-disable-next-line @kbn/eslint/module_migration import routeData from 'react-router'; import { useUpdateComment } from '../../containers/use_update_comment'; import { basicCase, - getUserAction, + caseUserActions, getHostIsolationUserAction, + getUserAction, hostIsolationComment, } from '../../containers/mock'; import { UserActions } from '.'; import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer, TestProviders } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; import { Actions } from '../../../common/api'; import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; -import { connectorsMock, getCaseConnectorsMockResponse } from '../../common/mock/connectors'; -import type { UserActivityFilter } from '../user_actions_activity_bar/types'; +import { getCaseConnectorsMockResponse } from '../../common/mock/connectors'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; +import { useFindCaseUserActions } from '../../containers/use_find_case_user_actions'; +import { + defaultInfiniteUseFindCaseUserActions, + defaultUseFindCaseUserActions, +} from '../case_view/mocks'; +import { waitForComponentToUpdate } from '../../common/test_utils'; +import { useInfiniteFindCaseUserActions } from '../../containers/use_infinite_find_case_user_actions'; +import { getMockBuilderArgs } from './mock'; -const fetchUserActions = jest.fn(); const onUpdateField = jest.fn(); -const updateCase = jest.fn(); -const onShowAlertDetails = jest.fn(); -const filterOptions: UserActivityFilter = 'all'; +const userActionsStats = { + total: 25, + totalComments: 9, + totalOtherActions: 16, +}; + +const userActivityQueryParams: UserActivityParams = { + type: 'all', + sortOrder: 'asc', + page: 1, + perPage: 10, +}; + +const builderArgs = getMockBuilderArgs(); const defaultProps = { + caseUserActions, + ...builderArgs, caseConnectors: getCaseConnectorsMockResponse(), - caseUserActions: [], - userProfiles: new Map(), - currentUserProfile: undefined, - connectors: connectorsMock, - actionsNavigation: { href: jest.fn(), onClick: jest.fn() }, - getRuleDetailsHref: jest.fn(), - onRuleDetailsClick: jest.fn(), data: basicCase, - fetchUserActions, - isLoadingUserActions: false, + manualAlertsData: { 'some-id': { _id: 'some-id' } }, onUpdateField, - selectedAlertPatterns: ['some-test-pattern'], + userActivityQueryParams, + userActionsStats, statusActionButton: null, - updateCase, useFetchAlertData: (): [boolean, Record] => [ false, { 'some-id': { _id: 'some-id' } }, ], - alerts: {}, - onShowAlertDetails, - filterOptions, }; +jest.mock('../../containers/use_infinite_find_case_user_actions'); +jest.mock('../../containers/use_find_case_user_actions'); jest.mock('../../containers/use_update_comment'); jest.mock('./timestamp', () => ({ UserActionTimestamp: () => <>, })); jest.mock('../../common/lib/kibana'); +const useInfiniteFindCaseUserActionsMock = useInfiniteFindCaseUserActions as jest.Mock; +const useFindCaseUserActionsMock = useFindCaseUserActions as jest.Mock; const useUpdateCommentMock = useUpdateComment as jest.Mock; const patchComment = jest.fn(); @@ -79,47 +94,38 @@ describe(`UserActions`, () => { isLoadingIds: [], patchComment, }); + useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions); + useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions); jest.spyOn(routeData, 'useParams').mockReturnValue({ detailName: 'case-id' }); appMockRender = createAppMockRenderer(); }); - it('Loading spinner when user actions loading and displays fullName/username', () => { - appMockRender.render( - - ); - - expect(screen.getByTestId('user-actions-loading')).toBeInTheDocument(); - expect(screen.getByTestId('case-user-profile-avatar-damaged_raccoon')).toBeInTheDocument(); - expect(screen.getByText('DR')).toBeInTheDocument(); - }); - it('Renders service now update line with top and bottom when push is required', async () => { const caseConnectors = getCaseConnectorsMockResponse({ 'push.needsToBePushed': true }); - const ourActions = [ getUserAction('pushed', 'push_to_service', { createdAt: '2023-01-17T09:46:29.813Z', }), ]; + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: ourActions }, + }); + const props = { ...defaultProps, caseConnectors, - caseUserActions: ourActions, }; - const wrapper = mount( - - - - ); + appMockRender.render(); + + await waitForComponentToUpdate(); await waitFor(() => { - expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toEqual(true); - expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toEqual(true); + expect(screen.getByTestId('top-footer')).toBeInTheDocument(); + expect(screen.getByTestId('bottom-footer')).toBeInTheDocument(); }); }); @@ -130,300 +136,190 @@ describe(`UserActions`, () => { }), ]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - - const wrapper = mount( - - - - ); - await waitFor(() => { - expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toEqual(true); - expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toEqual(false); + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: ourActions }, }); - }); - - it('Outlines comment when update move to link is clicked', async () => { - const ourActions = [ - getUserAction('comment', Actions.create), - getUserAction('comment', Actions.update), - ]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - - const wrapper = mount( - - - - ); - expect( - wrapper - .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) - .first() - .hasClass('outlined') - ).toEqual(false); - - wrapper - .find( - `[data-test-subj="comment-update-action-${ourActions[1].id}"] [data-test-subj="move-to-link-${props.data.comments[0].id}"]` - ) - .first() - .simulate('click'); + appMockRender.render(); await waitFor(() => { - expect( - wrapper - .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) - .first() - .hasClass('outlined') - ).toEqual(true); + expect(screen.getByTestId('top-footer')).toBeInTheDocument(); + expect(screen.queryByTestId('bottom-footer')).not.toBeInTheDocument(); }); }); + it('Switches to markdown when edit is clicked and back to panel when canceled', async () => { const ourActions = [getUserAction('comment', Actions.create)]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - const wrapper = mount( - - - + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: ourActions }, + }); + + appMockRender.render(); + + userEvent.click( + within( + screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] + ).getByTestId('property-actions-user-action-ellipses') ); - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]` - ) - .first() - .simulate('click'); - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-pencil"]` - ) - .first() - .simulate('click'); - - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` - ) - .first() - .simulate('click'); + await waitForEuiPopoverOpen(); + + userEvent.click(screen.getByTestId('property-actions-user-action-pencil')); + + userEvent.click( + within( + screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] + ).getByTestId('user-action-cancel-markdown') + ); await waitFor(() => { expect( - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(false); + within( + screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] + ).queryByTestId('user-action-markdown-form') + ).not.toBeInTheDocument(); }); }); it('calls update comment when comment markdown is saved', async () => { const ourActions = [getUserAction('comment', Actions.create)]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - const wrapper = mount( - - - + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: ourActions }, + }); + + appMockRender.render(); + + userEvent.click( + within( + screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] + ).getByTestId('property-actions-user-action-ellipses') ); - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]` - ) - .first() - .simulate('click'); - - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-pencil"]` - ) - .first() - .simulate('click'); - - wrapper - .find(`.euiMarkdownEditorTextArea`) - .first() - .simulate('change', { - target: { value: sampleData.content }, - }); + await waitForEuiPopoverOpen(); + + userEvent.click(screen.getByTestId('property-actions-user-action-pencil')); - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] button[data-test-subj="user-action-save-markdown"]` - ) - .first() - .simulate('click'); + await waitForComponentToUpdate(); + + fireEvent.change(screen.getAllByTestId(`euiMarkdownEditorTextArea`)[0], { + target: { value: sampleData.content }, + }); + + userEvent.click( + within( + screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] + ).getByTestId('user-action-save-markdown') + ); await waitFor(() => { - wrapper.update(); expect( - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(false); + within( + screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] + ).queryByTestId('user-action-markdown-form') + ).not.toBeInTheDocument(); + expect(patchComment).toBeCalledWith({ commentUpdate: sampleData.content, caseId: 'case-id', - commentId: props.data.comments[0].id, - version: props.data.comments[0].version, + commentId: defaultProps.data.comments[0].id, + version: defaultProps.data.comments[0].version, }); }); }); it('shows quoted text in last MarkdownEditorTextArea', async () => { const quoteableText = `> Solve this fast! \n\n`; - const ourActions = [getUserAction('comment', Actions.create)]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - const wrapper = mount( - - - + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: ourActions }, + }); + + appMockRender.render(); + + expect((await screen.findByTestId(`euiMarkdownEditorTextArea`)).textContent).not.toContain( + quoteableText ); - expect(wrapper.find(`.euiMarkdownEditorTextArea`).text()).not.toContain(quoteableText); + userEvent.click( + within( + screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] + ).getByTestId('property-actions-user-action-ellipses') + ); - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]` - ) - .first() - .simulate('click'); + await waitForEuiPopoverOpen(); - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-quote"]` - ) - .first() - .simulate('click'); + userEvent.click(screen.getByTestId('property-actions-user-action-quote')); await waitFor(() => { - expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).first().text()).toContain( - quoteableText - ); + expect(screen.getAllByTestId('add-comment')[0].textContent).toContain(quoteableText); }); }); it('does not show add comment markdown when history filter is selected', async () => { - appMockRender.render(); + appMockRender.render( + + ); await waitFor(() => { expect(screen.queryByTestId('add-comment')).not.toBeInTheDocument(); }); }); - it('Outlines comment when url param is provided', async () => { - const commentId = 'basic-comment-id'; - jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId }); - - const ourActions = [getUserAction('comment', Actions.create)]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - - const wrapper = mount( - - - - ); - await waitFor(() => { - expect( - wrapper - .find(`[data-test-subj="comment-create-action-${commentId}"]`) - .first() - .hasClass('outlined') - ).toEqual(true); - }); - }); - it('it should persist the draft of new comment while existing old comment is updated', async () => { const editedComment = 'it is an edited comment'; const newComment = 'another cool comment'; const ourActions = [getUserAction('comment', Actions.create)]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - const wrapper = mount( - - - + + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: ourActions }, + }); + + appMockRender.render(); + + userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea')); + userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), newComment); + + userEvent.click( + within( + screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] + ).getByTestId('property-actions-user-action-ellipses') ); - // type new comment in text area - wrapper - .find(`[data-test-subj="add-comment"] textarea`) - .first() - .simulate('change', { target: { value: newComment } }); - - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]` - ) - .first() - .simulate('click'); - - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-pencil"]` - ) - .first() - .simulate('click'); - - wrapper - .find(`.euiMarkdownEditorTextArea`) - .first() - .simulate('change', { - target: { value: editedComment }, - }); + await waitForEuiPopoverOpen(); + + userEvent.click(screen.getByTestId('property-actions-user-action-pencil')); - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] button[data-test-subj="user-action-save-markdown"]` - ) - .first() - .simulate('click'); + fireEvent.change(screen.getAllByTestId('euiMarkdownEditorTextArea')[0], { + target: { value: editedComment }, + }); + + userEvent.click( + within( + screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] + ).getByTestId('user-action-save-markdown') + ); await waitFor(() => { - wrapper.update(); expect( - wrapper - .find( - `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(false); - expect(patchComment).toBeCalledWith({ - commentUpdate: editedComment, - caseId: 'case-id', - commentId: props.data.comments[0].id, - version: props.data.comments[0].version, - }); + within( + screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] + ).queryByTestId('user-action-markdown-form') + ).not.toBeInTheDocument(); }); - expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe(newComment); + await waitFor(() => { + expect(screen.getAllByTestId('add-comment')[1].textContent).toContain(newComment); + }); }); describe('Host isolation action', () => { @@ -431,17 +327,17 @@ describe(`UserActions`, () => { const isolateAction = [getHostIsolationUserAction()]; const props = { ...defaultProps, - caseUserActions: isolateAction, data: { ...defaultProps.data, comments: [...basicCase.comments, hostIsolationComment()] }, }; - const wrapper = mount( - - - - ); + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: isolateAction }, + }); + + appMockRender.render(); await waitFor(() => { - expect(wrapper.find(`[data-test-subj="endpoint-action"]`).exists()).toBe(true); + expect(screen.getByTestId('endpoint-action')).toBeInTheDocument(); }); }); @@ -452,18 +348,263 @@ describe(`UserActions`, () => { const props = { ...defaultProps, userProfiles: userProfilesMap, - caseUserActions: isolateAction, data: { ...defaultProps.data, comments: [hostIsolationComment({ createdBy: { profileUid: userProfiles[0].uid } })], }, }; + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: isolateAction }, + }); + appMockRender.render(); - expect(screen.getByTestId('case-user-profile-avatar-damaged_raccoon')).toBeInTheDocument(); - expect(screen.getByText('DR')).toBeInTheDocument(); - expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + expect( + screen.getAllByTestId('case-user-profile-avatar-damaged_raccoon')[0] + ).toBeInTheDocument(); + expect(screen.getAllByText('DR')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Damaged Raccoon')[0]).toBeInTheDocument(); + }); + }); + + describe('pagination', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Loading spinner when user actions loading', () => { + useFindCaseUserActionsMock.mockReturnValue({ isLoading: true }); + useInfiniteFindCaseUserActionsMock.mockReturnValue({ isLoading: true }); + appMockRender.render( + + ); + + expect(screen.getByTestId('user-actions-loading')).toBeInTheDocument(); + }); + + it('renders two user actions list when user actions are more than 10', () => { + appMockRender.render(); + + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2); + }); + + it('renders only one user actions list when last page is 0', async () => { + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: [] }, + }); + const props = { + ...defaultProps, + userActionsStats: { + total: 0, + totalComments: 0, + totalOtherActions: 0, + }, + }; + + appMockRender.render(); + + await waitForComponentToUpdate(); + + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(1); + }); + + it('renders only one user actions list when last page is 1', async () => { + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: [] }, + }); + const props = { + ...defaultProps, + userActionsStats: { + total: 1, + totalComments: 0, + totalOtherActions: 1, + }, + }; + + appMockRender.render(); + + await waitForComponentToUpdate(); + + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(1); + }); + + it('renders only one action list when user actions are less than or equal to 10', async () => { + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { userActions: [] }, + }); + const props = { + ...defaultProps, + userActionsStats: { + total: 10, + totalComments: 6, + totalOtherActions: 4, + }, + }; + + appMockRender.render(); + + await waitForComponentToUpdate(); + + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(1); + }); + + it('shows more button visible when hasNext page is true', async () => { + useInfiniteFindCaseUserActionsMock.mockReturnValue({ + ...defaultInfiniteUseFindCaseUserActions, + hasNextPage: true, + }); + const props = { + ...defaultProps, + userActionsStats: { + total: 25, + totalComments: 10, + totalOtherActions: 15, + }, + }; + + appMockRender.render(); + + await waitForComponentToUpdate(); + + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2); + expect(screen.getByTestId('cases-show-more-user-actions')).toBeInTheDocument(); + }); + + it('call fetchNextPage on showMore button click', async () => { + useInfiniteFindCaseUserActionsMock.mockReturnValue({ + ...defaultInfiniteUseFindCaseUserActions, + hasNextPage: true, + }); + const props = { + ...defaultProps, + userActionsStats: { + total: 25, + totalComments: 10, + totalOtherActions: 15, + }, + }; + + appMockRender.render(); + + await waitForComponentToUpdate(); + + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2); + + const showMore = screen.getByTestId('cases-show-more-user-actions'); + + expect(showMore).toBeInTheDocument(); + + userEvent.click(showMore); + + await waitFor(() => { + expect(defaultInfiniteUseFindCaseUserActions.fetchNextPage).toHaveBeenCalled(); + }); + }); + + it('shows more button visible 21st user action added', async () => { + const mockUserActions = [ + ...caseUserActions, + getUserAction('comment', Actions.create), + getUserAction('comment', Actions.update), + getUserAction('comment', Actions.create), + getUserAction('comment', Actions.update), + getUserAction('comment', Actions.create), + getUserAction('comment', Actions.update), + getUserAction('comment', Actions.create), + ]; + useInfiniteFindCaseUserActionsMock.mockReturnValue({ + ...defaultInfiniteUseFindCaseUserActions, + data: { + pages: [ + { + total: 20, + page: 1, + perPage: 10, + userActions: mockUserActions, + }, + ], + }, + }); + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { + total: 20, + page: 2, + perPage: 10, + userActions: mockUserActions, + }, + }); + const props = { + ...defaultProps, + userActionsStats: { + total: 20, + totalComments: 10, + totalOtherActions: 10, + }, + }; + + const { rerender } = appMockRender.render(); + + await waitForComponentToUpdate(); + + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2); + expect(screen.queryByTestId('cases-show-more-user-actions')).not.toBeInTheDocument(); + + useInfiniteFindCaseUserActionsMock.mockReturnValue({ + ...defaultInfiniteUseFindCaseUserActions, + data: { + pages: [ + { + total: 21, + page: 1, + perPage: 10, + userActions: mockUserActions, + }, + { + total: 21, + page: 2, + perPage: 10, + userActions: [getUserAction('comment', Actions.create)], + }, + ], + }, + hasNextPage: true, + }); + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { + total: 21, + page: 2, + perPage: 10, + userActions: mockUserActions, + }, + }); + + const newProps = { + ...props, + userActionsStats: { + total: 21, + totalComments: 11, + totalOtherActions: 10, + }, + }; + + rerender(); + + await waitForComponentToUpdate(); + + expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2); + + const firstUserActionsList = screen.getAllByTestId('user-actions-list')[0]; + + expect(firstUserActionsList.getElementsByTagName('li')).toHaveLength(11); + + expect(screen.getByTestId('cases-show-more-user-actions')).toBeInTheDocument(); }); }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/index.tsx b/x-pack/plugins/cases/public/components/user_actions/index.tsx index 2699e8ea778a2..2547e41340019 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.tsx @@ -5,16 +5,14 @@ * 2.0. */ -import type { EuiCommentProps } from '@elastic/eui'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiCommentList } from '@elastic/eui'; - -import React, { useMemo, useState, useEffect } from 'react'; -import styled from 'styled-components'; +import { EuiFlexItem, EuiSkeletonText, useEuiTheme } from '@elastic/eui'; +import type { EuiThemeComputed } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { css } from '@emotion/react'; import { AddComment } from '../add_comment'; import { useCaseViewParams } from '../../common/navigation'; -import { builderMap } from './builder'; -import { isUserActionTypeSupported, getManualAlertIdsWithNoRuleId } from './helpers'; +import { getManualAlertIdsWithNoRuleId } from './helpers'; import type { UserActionTreeProps } from './types'; import { useUserActionsHandler } from './use_user_actions_handler'; import { NEW_COMMENT_ID } from './constants'; @@ -22,234 +20,200 @@ import { useCasesContext } from '../cases_context/use_cases_context'; import { UserToolTip } from '../user_profiles/user_tooltip'; import { Username } from '../user_profiles/username'; import { HoverableAvatar } from '../user_profiles/hoverable_avatar'; - -const MyEuiFlexGroup = styled(EuiFlexGroup)` - margin-bottom: 8px; -`; - -const MyEuiCommentList = styled(EuiCommentList)` - ${({ theme }) => ` - & .userAction__comment.outlined .euiCommentEvent { - outline: solid 5px ${theme.eui.euiColorVis1_behindText}; - margin: 0.5em; - transition: 0.8s; - } - - & .draftFooter { - & .euiCommentEvent__body { - padding: 0; - } - } - - & .euiComment.isEdit { - & .euiCommentEvent { - border: none; - box-shadow: none; - } - - & .euiCommentEvent__body { - padding: 0; - } - - & .euiCommentEvent__header { - display: none; - } - } - - & .comment-alert .euiCommentEvent { - background-color: ${theme.eui.euiColorLightestShade}; - border: ${theme.eui.euiBorderThin}; - padding: ${theme.eui.euiSizeS}; - border-radius: ${theme.eui.euiSizeXS}; - } - - & .comment-alert .euiCommentEvent__headerData { - flex-grow: 1; - } - - & .comment-action.empty-comment [class*="euiCommentEvent-regular"] { - box-shadow: none; - .euiCommentEvent__header { - padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeS}; - border-bottom: 0; +import { UserActionsList } from './user_actions_list'; +import { useUserActionsPagination } from './use_user_actions_pagination'; +import { useLastPageUserActions } from './use_user_actions_last_page'; +import { ShowMoreButton } from './show_more_button'; +import { useLastPage } from './use_last_page'; + +const getIconsCss = (hasNextPage: boolean | undefined, euiTheme: EuiThemeComputed<{}>): string => { + const customSize = hasNextPage + ? { + showMoreSectionSize: euiTheme.size.xxxl, + marginTopShowMoreSectionSize: euiTheme.size.xxl, + marginBottomShowMoreSectionSize: euiTheme.size.xxl, } - } - `} -`; - -export const UserActions = React.memo( - ({ - caseConnectors, - caseUserActions, - userProfiles, + : { + showMoreSectionSize: euiTheme.size.s, + marginTopShowMoreSectionSize: euiTheme.size.m, + marginBottomShowMoreSectionSize: euiTheme.size.m, + }; + + const blockSize = `${customSize.showMoreSectionSize} + ${customSize.marginTopShowMoreSectionSize} + + ${customSize.marginBottomShowMoreSectionSize}`; + return ` + .commentList--hasShowMore + [class*='euiTimelineItem-center']:last-child:not(:only-child) + > [class*='euiTimelineItemIcon-']::before { + block-size: calc( + 100% + ${blockSize} + ); + } + .commentList--hasShowMore + [class*='euiTimelineItem-center']:first-child + > [class*='euiTimelineItemIcon-']::before { + inset-block-start: 0%; + block-size: calc( + 100% + ${blockSize} + ); + } + .commentList--hasShowMore + [class*='euiTimelineItem-'] + > [class*='euiTimelineItemIcon-']::before { + block-size: calc( + 100% + ${blockSize} + ); + } + `; +}; + +export const UserActions = React.memo((props: UserActionTreeProps) => { + const { currentUserProfile, data: caseData, - getRuleDetailsHref, - actionsNavigation, - isLoadingUserActions, - onRuleDetailsClick, - onShowAlertDetails, - onUpdateField, statusActionButton, useFetchAlertData, - filterOptions, - }: UserActionTreeProps) => { - const { detailName: caseId, commentId } = useCaseViewParams(); - const [initLoading, setInitLoading] = useState(true); - const { - externalReferenceAttachmentTypeRegistry, - persistableStateAttachmentTypeRegistry, - appId, - } = useCasesContext(); - - const alertIdsWithoutRuleInfo = useMemo( - () => getManualAlertIdsWithNoRuleId(caseData.comments), - [caseData.comments] - ); - - const [loadingAlertData, manualAlertsData] = useFetchAlertData(alertIdsWithoutRuleInfo); - - const { - loadingCommentIds, - commentRefs, - selectedOutlineCommentId, - manageMarkdownEditIds, - handleManageMarkdownEditId, - handleOutlineComment, - handleSaveComment, - handleManageQuote, - handleDeleteComment, - handleUpdate, - } = useUserActionsHandler(); - - const MarkdownNewComment = useMemo( - () => ( - (commentRefs.current[NEW_COMMENT_ID] = element)} - onCommentPosted={handleUpdate} - onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_COMMENT_ID)} - showLoading={false} - statusActionButton={statusActionButton} - /> - ), - [caseId, handleUpdate, handleManageMarkdownEditId, statusActionButton, commentRefs] - ); - - useEffect(() => { - if (initLoading && !isLoadingUserActions && loadingCommentIds.length === 0) { - setInitLoading(false); - if (commentId != null) { - handleOutlineComment(commentId); - } - } - }, [commentId, initLoading, isLoadingUserActions, loadingCommentIds, handleOutlineComment]); - - const userActions: EuiCommentProps[] = useMemo( - () => - caseUserActions.reduce((comments, userAction, index) => { - if (!isUserActionTypeSupported(userAction.type)) { - return comments; - } - - const builder = builderMap[userAction.type]; - - if (builder == null) { - return comments; - } - - const userActionBuilder = builder({ - appId, - caseData, - caseConnectors, - externalReferenceAttachmentTypeRegistry, - persistableStateAttachmentTypeRegistry, - userAction, - userProfiles, - currentUserProfile, - comments: caseData.comments, - index, - commentRefs, - manageMarkdownEditIds, - selectedOutlineCommentId, - loadingCommentIds, - loadingAlertData, - alertData: manualAlertsData, - handleOutlineComment, - handleManageMarkdownEditId, - handleDeleteComment, - handleSaveComment, - handleManageQuote, - onShowAlertDetails, - actionsNavigation, - getRuleDetailsHref, - onRuleDetailsClick, - }); - return [...comments, ...userActionBuilder.build()]; - }, []), - [ - appId, - caseConnectors, - caseUserActions, - userProfiles, - currentUserProfile, - externalReferenceAttachmentTypeRegistry, - persistableStateAttachmentTypeRegistry, - caseData, - commentRefs, - manageMarkdownEditIds, - selectedOutlineCommentId, - loadingCommentIds, - loadingAlertData, - manualAlertsData, - handleOutlineComment, - handleManageMarkdownEditId, - handleDeleteComment, - handleSaveComment, - handleManageQuote, - onShowAlertDetails, - actionsNavigation, - getRuleDetailsHref, - onRuleDetailsClick, + userActivityQueryParams, + userActionsStats, + } = props; + const { detailName: caseId } = useCaseViewParams(); + + const { lastPage } = useLastPage({ userActivityQueryParams, userActionsStats }); + + const { + infiniteCaseUserActions, + isLoadingInfiniteUserActions, + hasNextPage, + fetchNextPage, + showBottomList, + isFetchingNextPage, + } = useUserActionsPagination({ + userActivityQueryParams, + caseId: caseData.id, + lastPage, + }); + + const { euiTheme } = useEuiTheme(); + + const { isLoadingLastPageUserActions, lastPageUserActions } = useLastPageUserActions({ + userActivityQueryParams, + caseId: caseData.id, + lastPage, + }); + + const alertIdsWithoutRuleInfo = useMemo( + () => getManualAlertIdsWithNoRuleId(caseData.comments), + [caseData.comments] + ); + + const [loadingAlertData, manualAlertsData] = useFetchAlertData(alertIdsWithoutRuleInfo); + + const { permissions } = useCasesContext(); + + // add-comment markdown is not visible in History filter + const showCommentEditor = permissions.create && userActivityQueryParams.type !== 'action'; + + const { + commentRefs, + handleManageMarkdownEditId, + handleManageQuote, + handleUpdate, + loadingCommentIds, + } = useUserActionsHandler(); + + const MarkdownNewComment = useMemo( + () => ( + (commentRefs.current[NEW_COMMENT_ID] = element)} + onCommentPosted={handleUpdate} + onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_COMMENT_ID)} + showLoading={false} + statusActionButton={statusActionButton} + /> + ), + [caseId, handleUpdate, handleManageMarkdownEditId, statusActionButton, commentRefs] + ); + + const bottomActions = showCommentEditor + ? [ + { + username: ( + + + + ), + 'data-test-subj': 'add-comment', + timelineAvatar: , + className: 'isEdit', + children: MarkdownNewComment, + }, ] - ); - - const { permissions } = useCasesContext(); - - const showCommentEditor = permissions.create && filterOptions !== 'action'; // add-comment markdown is not visible in History filter - - const bottomActions = showCommentEditor - ? [ - { - username: ( - - - - ), - 'data-test-subj': 'add-comment', - timelineAvatar: , - className: 'isEdit', - children: MarkdownNewComment, - }, - ] - : []; - - const comments = [...userActions, ...bottomActions]; - - return ( - <> - - {(isLoadingUserActions || loadingCommentIds.includes(NEW_COMMENT_ID)) && ( - - - - - + : []; + + const handleShowMore = useCallback(() => { + if (fetchNextPage) { + fetchNextPage(); + } + }, [fetchNextPage]); + + return ( + + + + {hasNextPage && ( + )} - - ); - } -); + {lastPageUserActions?.length ? ( + + + + ) : null} + + + ); +}); UserActions.displayName = 'UserActions'; diff --git a/x-pack/plugins/cases/public/components/user_actions/show_more_button.test.tsx b/x-pack/plugins/cases/public/components/user_actions/show_more_button.test.tsx new file mode 100644 index 0000000000000..7224dac4f4ef1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/show_more_button.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ShowMoreButton } from './show_more_button'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +const showMoreClickMock = jest.fn(); + +describe('ShowMoreButton', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('cases-show-more-user-actions')).toBeInTheDocument(); + }); + + it('shows loading state and is disabled when isLoading is true', () => { + appMockRender.render(); + + const btn = screen.getByTestId('cases-show-more-user-actions'); + + expect(btn).toBeInTheDocument(); + expect(btn).toHaveAttribute('disabled'); + expect(screen.getByRole('progressbar')).toBeTruthy(); + }); + + it('calls onShowMoreClick on button click', () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('cases-show-more-user-actions')); + expect(showMoreClickMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/show_more_button.tsx b/x-pack/plugins/cases/public/components/user_actions/show_more_button.tsx new file mode 100644 index 0000000000000..067650b9576b2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/show_more_button.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiPanel, useEuiTheme } from '@elastic/eui'; +import React from 'react'; +import { css } from '@emotion/react'; + +import * as i18n from './translations'; + +interface ShowMoreButtonProps { + onShowMoreClick: () => void; + isLoading?: boolean; +} + +export const ShowMoreButton = React.memo( + ({ onShowMoreClick, isLoading = false }) => { + const handleShowMore = () => { + onShowMoreClick(); + }; + + const { euiTheme } = useEuiTheme(); + + return ( + + + {i18n.SHOW_MORE} + + + ); + } +); + +ShowMoreButton.displayName = 'ShowMoreButton'; diff --git a/x-pack/plugins/cases/public/components/user_actions/translations.ts b/x-pack/plugins/cases/public/components/user_actions/translations.ts index 0bcf4a755d04f..57ffe44d15db2 100644 --- a/x-pack/plugins/cases/public/components/user_actions/translations.ts +++ b/x-pack/plugins/cases/public/components/user_actions/translations.ts @@ -101,3 +101,11 @@ export const UNSAVED_DRAFT_DESCRIPTION = i18n.translate( defaultMessage: 'You have unsaved edits for the description', } ); + +export const SHOW_MORE = i18n.translate('xpack.cases.caseView.userActions.showMore', { + defaultMessage: 'Show more', +}); + +export const CREATE_CASE = i18n.translate('xpack.cases.caseView.userActions.createCase', { + defaultMessage: 'Created case', +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/types.ts b/x-pack/plugins/cases/public/components/user_actions/types.ts index a1e02fb90d9a1..5146a97aba65b 100644 --- a/x-pack/plugins/cases/public/components/user_actions/types.ts +++ b/x-pack/plugins/cases/public/components/user_actions/types.ts @@ -15,6 +15,7 @@ import type { CaseUserActions, Comment, UseFetchAlertData, + CaseUserActionsStats, } from '../../containers/types'; import type { AddCommentRefObject } from '../add_comment'; import type { UserActionMarkdownRefObject } from './markdown_form'; @@ -24,23 +25,22 @@ import type { OnUpdateFields } from '../case_view/types'; import type { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry'; import type { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry'; import type { CurrentUserProfile } from '../types'; -import type { UserActivityFilter } from '../user_actions_activity_bar/types'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; export interface UserActionTreeProps { caseConnectors: CaseConnectors; - caseUserActions: CaseUserActions[]; userProfiles: Map; currentUserProfile: CurrentUserProfile; data: Case; getRuleDetailsHref?: RuleDetailsNavigation['href']; actionsNavigation?: ActionsNavigation; - isLoadingUserActions: boolean; onRuleDetailsClick?: RuleDetailsNavigation['onClick']; onShowAlertDetails: (alertId: string, index: string) => void; onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; statusActionButton: JSX.Element | null; useFetchAlertData: UseFetchAlertData; - filterOptions: UserActivityFilter; + userActivityQueryParams: UserActivityParams; + userActionsStats: CaseUserActionsStats; } type UnsupportedUserActionTypes = typeof UNSUPPORTED_ACTION_TYPES[number]; diff --git a/x-pack/plugins/cases/public/components/user_actions/use_last_page.test.tsx b/x-pack/plugins/cases/public/components/user_actions/use_last_page.test.tsx new file mode 100644 index 0000000000000..525fe19771e41 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_last_page.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; + +import { useLastPage } from './use_last_page'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; + +const userActivityQueryParams: UserActivityParams = { + type: 'all', + sortOrder: 'asc', + page: 1, + perPage: 10, +}; + +const userActionsStats = { + total: 5, + totalComments: 2, + totalOtherActions: 3, +}; + +jest.mock('../../common/lib/kibana'); + +describe('useLastPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns correctly', async () => { + const { result } = renderHook(() => + useLastPage({ + userActionsStats, + userActivityQueryParams, + }) + ); + + expect(result.current).toEqual({ + lastPage: 1, + }); + }); + + it('returns 1 when actions stats are 0', async () => { + const { result } = renderHook(() => + useLastPage({ + userActionsStats: { total: 0, totalComments: 0, totalOtherActions: 0 }, + userActivityQueryParams, + }) + ); + + expect(result.current).toEqual({ + lastPage: 1, + }); + }); + + it('returns correct last page when filter type is all', async () => { + const { result } = renderHook(() => + useLastPage({ + userActionsStats: { total: 38, totalComments: 17, totalOtherActions: 21 }, + userActivityQueryParams, + }) + ); + + expect(result.current).toEqual({ + lastPage: 4, + }); + }); + + it('returns correct last page when filter type is user', async () => { + const { result } = renderHook(() => + useLastPage({ + userActionsStats: { total: 38, totalComments: 17, totalOtherActions: 21 }, + userActivityQueryParams: { + ...userActivityQueryParams, + type: 'user', + }, + }) + ); + + expect(result.current).toEqual({ + lastPage: 2, + }); + }); + + it('returns correct last page when filter type is action', async () => { + const { result } = renderHook(() => + useLastPage({ + userActionsStats: { total: 38, totalComments: 17, totalOtherActions: 21 }, + userActivityQueryParams: { + ...userActivityQueryParams, + type: 'action', + }, + }) + ); + + expect(result.current).toEqual({ + lastPage: 3, + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/use_last_page.tsx b/x-pack/plugins/cases/public/components/user_actions/use_last_page.tsx new file mode 100644 index 0000000000000..d0104400c6d95 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_last_page.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { assertNever } from '@elastic/eui'; +import { useMemo } from 'react'; + +import type { CaseUserActionsStats } from '../../containers/types'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; + +export const useLastPage = ({ + userActivityQueryParams, + userActionsStats, +}: { + userActivityQueryParams: UserActivityParams; + userActionsStats: CaseUserActionsStats; +}) => { + const lastPage = useMemo(() => { + if (!userActionsStats) { + return 1; + } + + const perPage = userActivityQueryParams.perPage; + let lastPageType = 1; + + switch (userActivityQueryParams.type) { + case 'action': + lastPageType = Math.ceil(userActionsStats.totalOtherActions / perPage); + break; + case 'user': + lastPageType = Math.ceil(userActionsStats.totalComments / perPage); + break; + case 'all': + lastPageType = Math.ceil(userActionsStats.total / perPage); + break; + default: + return assertNever(userActivityQueryParams.type); + } + + return Math.max(lastPageType, 1); + }, [userActionsStats, userActivityQueryParams]); + + return { lastPage }; +}; diff --git a/x-pack/plugins/cases/public/components/user_actions/use_user_actions_last_page.test.tsx b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_last_page.test.tsx new file mode 100644 index 0000000000000..3207e4ffb13fe --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_last_page.test.tsx @@ -0,0 +1,136 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; + +import { useLastPageUserActions } from './use_user_actions_last_page'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; +import { useFindCaseUserActions } from '../../containers/use_find_case_user_actions'; +import { defaultUseFindCaseUserActions } from '../case_view/mocks'; +import { basicCase } from '../../containers/mock'; + +const userActivityQueryParams: UserActivityParams = { + type: 'all', + sortOrder: 'asc', + page: 1, + perPage: 10, +}; + +jest.mock('../../containers/use_find_case_user_actions'); +jest.mock('../../common/lib/kibana'); + +const useFindCaseUserActionsMock = useFindCaseUserActions as jest.Mock; + +describe('useLastPageUserActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions); + }); + + it('renders correctly', async () => { + const { result, waitFor } = renderHook(() => + useLastPageUserActions({ + lastPage: 5, + userActivityQueryParams, + caseId: basicCase.id, + }) + ); + + expect(useFindCaseUserActionsMock).toHaveBeenCalledTimes(1); + + expect(useFindCaseUserActionsMock).toHaveBeenCalledWith( + basicCase.id, + { ...userActivityQueryParams, page: 5 }, + true + ); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + isLoadingLastPageUserActions: false, + lastPageUserActions: defaultUseFindCaseUserActions.data.userActions, + }) + ); + }); + }); + + it('calls find API hook with enabled as false when last page is 1', async () => { + renderHook(() => + useLastPageUserActions({ + lastPage: 1, + userActivityQueryParams, + caseId: basicCase.id, + }) + ); + + expect(useFindCaseUserActionsMock).toHaveBeenCalledTimes(1); + + expect(useFindCaseUserActionsMock).toHaveBeenCalledWith( + basicCase.id, + { ...userActivityQueryParams, page: 1 }, + false + ); + }); + + it('returns loading state correctly', async () => { + useFindCaseUserActionsMock.mockReturnValue({ isLoading: true }); + + const { result, waitFor } = renderHook(() => + useLastPageUserActions({ + lastPage: 2, + userActivityQueryParams, + caseId: basicCase.id, + }) + ); + + expect(useFindCaseUserActionsMock).toHaveBeenCalledWith( + basicCase.id, + { ...userActivityQueryParams, page: 2 }, + true + ); + + expect(useFindCaseUserActionsMock).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + isLoadingLastPageUserActions: true, + lastPageUserActions: [], + }) + ); + }); + }); + + it('returns empty array when data is undefined', async () => { + useFindCaseUserActionsMock.mockReturnValue({ isLoading: false, data: undefined }); + + const { result, waitFor } = renderHook(() => + useLastPageUserActions({ + lastPage: 2, + userActivityQueryParams, + caseId: basicCase.id, + }) + ); + + expect(useFindCaseUserActionsMock).toHaveBeenCalledWith( + basicCase.id, + { ...userActivityQueryParams, page: 2 }, + true + ); + + expect(useFindCaseUserActionsMock).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + isLoadingLastPageUserActions: false, + lastPageUserActions: [], + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/use_user_actions_last_page.tsx b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_last_page.tsx new file mode 100644 index 0000000000000..800f1f94c4832 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_last_page.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; + +import type { CaseUserActions } from '../../containers/types'; +import { useFindCaseUserActions } from '../../containers/use_find_case_user_actions'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; + +interface LastPageUserActions { + userActivityQueryParams: UserActivityParams; + caseId: string; + lastPage: number; +} + +export const useLastPageUserActions = ({ + userActivityQueryParams, + caseId, + lastPage, +}: LastPageUserActions) => { + const { data: lastPageUserActionsData, isLoading: isLoadingLastPageUserActions } = + useFindCaseUserActions(caseId, { ...userActivityQueryParams, page: lastPage }, lastPage > 1); + + const lastPageUserActions = useMemo(() => { + if (isLoadingLastPageUserActions || !lastPageUserActionsData) { + return []; + } + + return lastPageUserActionsData.userActions; + }, [lastPageUserActionsData, isLoadingLastPageUserActions]); + + return { + isLoadingLastPageUserActions, + lastPageUserActions, + }; +}; diff --git a/x-pack/plugins/cases/public/components/user_actions/use_user_actions_pagination.test.tsx b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_pagination.test.tsx new file mode 100644 index 0000000000000..0d005a8b404fd --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_pagination.test.tsx @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; + +import { useUserActionsPagination } from './use_user_actions_pagination'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; +import { defaultInfiniteUseFindCaseUserActions } from '../case_view/mocks'; +import { basicCase } from '../../containers/mock'; +import { useInfiniteFindCaseUserActions } from '../../containers/use_infinite_find_case_user_actions'; + +const userActivityQueryParams: UserActivityParams = { + type: 'all', + sortOrder: 'asc', + page: 1, + perPage: 10, +}; + +jest.mock('../../containers/use_infinite_find_case_user_actions'); +jest.mock('../../common/lib/kibana'); + +const useInfiniteFindCaseUserActionsMock = useInfiniteFindCaseUserActions as jest.Mock; + +describe('useUserActionsPagination', () => { + beforeEach(() => { + jest.clearAllMocks(); + useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions); + }); + + it('renders expandable option correctly when user actions are more than 10', async () => { + const { result, waitFor } = renderHook(() => + useUserActionsPagination({ + userActivityQueryParams, + caseId: basicCase.id, + lastPage: 3, + }) + ); + + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledTimes(1); + + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith( + basicCase.id, + userActivityQueryParams, + true + ); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + showBottomList: true, + isLoadingInfiniteUserActions: defaultInfiniteUseFindCaseUserActions.isLoading, + infiniteCaseUserActions: defaultInfiniteUseFindCaseUserActions.data.pages[0].userActions, + hasNextPage: defaultInfiniteUseFindCaseUserActions.hasNextPage, + fetchNextPage: defaultInfiniteUseFindCaseUserActions.fetchNextPage, + }) + ); + }); + }); + + it('renders less than 10 user actions correctly', async () => { + const { result, waitFor } = renderHook(() => + useUserActionsPagination({ + userActivityQueryParams, + caseId: basicCase.id, + lastPage: 1, + }) + ); + + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith( + basicCase.id, + userActivityQueryParams, + true + ); + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + showBottomList: false, + isLoadingInfiniteUserActions: false, + infiniteCaseUserActions: defaultInfiniteUseFindCaseUserActions.data.pages[0].userActions, + hasNextPage: false, + }) + ); + }); + }); + + it('returns loading state correctly', async () => { + useInfiniteFindCaseUserActionsMock.mockReturnValue({ isLoading: true }); + + const { result, waitFor } = renderHook(() => + useUserActionsPagination({ + userActivityQueryParams, + caseId: basicCase.id, + lastPage: 3, + }) + ); + + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith( + basicCase.id, + userActivityQueryParams, + true + ); + + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + showBottomList: true, + isLoadingInfiniteUserActions: true, + infiniteCaseUserActions: [], + hasNextPage: undefined, + fetchNextPage: undefined, + }) + ); + }); + }); + + it('returns empty array when data is undefined', async () => { + useInfiniteFindCaseUserActionsMock.mockReturnValue({ isLoading: false, data: undefined }); + + const { result, waitFor } = renderHook(() => + useUserActionsPagination({ + userActivityQueryParams, + caseId: basicCase.id, + lastPage: 3, + }) + ); + + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith( + basicCase.id, + userActivityQueryParams, + true + ); + + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + showBottomList: true, + isLoadingInfiniteUserActions: false, + infiniteCaseUserActions: [], + hasNextPage: undefined, + fetchNextPage: undefined, + }) + ); + }); + }); + + it('return hasNextPage as false when it has less than 10 user actions', async () => { + useInfiniteFindCaseUserActionsMock.mockReturnValue({ + ...defaultInfiniteUseFindCaseUserActions, + data: { + pages: { total: 25, perPage: 10, page: 1, userActions: [] }, + }, + }); + + const { result, waitFor } = renderHook(() => + useUserActionsPagination({ + userActivityQueryParams, + caseId: basicCase.id, + lastPage: 1, + }) + ); + + expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith( + basicCase.id, + userActivityQueryParams, + true + ); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + showBottomList: false, + isLoadingInfiniteUserActions: defaultInfiniteUseFindCaseUserActions.isLoading, + infiniteCaseUserActions: [], + hasNextPage: false, + fetchNextPage: defaultInfiniteUseFindCaseUserActions.fetchNextPage, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/use_user_actions_pagination.tsx b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_pagination.tsx new file mode 100644 index 0000000000000..ec9b931af0ab2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_pagination.tsx @@ -0,0 +1,56 @@ +/* + * 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 { useMemo } from 'react'; + +import { useInfiniteFindCaseUserActions } from '../../containers/use_infinite_find_case_user_actions'; +import type { CaseUserActions } from '../../containers/types'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; + +interface UserActionsPagination { + userActivityQueryParams: UserActivityParams; + caseId: string; + lastPage: number; +} + +export const useUserActionsPagination = ({ + userActivityQueryParams, + caseId, + lastPage, +}: UserActionsPagination) => { + const { + data: caseInfiniteUserActionsData, + isLoading: isLoadingInfiniteUserActions, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useInfiniteFindCaseUserActions(caseId, userActivityQueryParams, true); + + const showBottomList = lastPage > 1; + + const infiniteCaseUserActions = useMemo(() => { + if (!caseInfiniteUserActionsData?.pages?.length || isLoadingInfiniteUserActions) { + return []; + } + + const userActionsData: CaseUserActions[] = []; + + caseInfiniteUserActionsData.pages.forEach((page) => userActionsData.push(...page.userActions)); + + return userActionsData; + }, [caseInfiniteUserActionsData, isLoadingInfiniteUserActions]); + + return { + lastPage, + showBottomList, + isLoadingInfiniteUserActions, + infiniteCaseUserActions, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + }; +}; diff --git a/x-pack/plugins/cases/public/components/user_actions/user_actions_list.test.tsx b/x-pack/plugins/cases/public/components/user_actions/user_actions_list.test.tsx new file mode 100644 index 0000000000000..2120d5a51dc2b --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/user_actions_list.test.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { waitFor, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +// eslint-disable-next-line @kbn/eslint/module_migration +import routeData from 'react-router'; + +import { basicCase, caseUserActions, getUserAction } from '../../containers/mock'; +import { UserActionsList } from './user_actions_list'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { Actions } from '../../../common/api'; +import { getCaseConnectorsMockResponse } from '../../common/mock/connectors'; +import { getMockBuilderArgs } from './mock'; + +const builderArgs = getMockBuilderArgs(); + +const defaultProps = { + caseUserActions, + ...builderArgs, + caseConnectors: getCaseConnectorsMockResponse(), + data: basicCase, + manualAlertsData: { 'some-id': { _id: 'some-id' } }, +}; + +jest.mock('../../common/lib/kibana'); + +describe(`UserActionsList`, () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(routeData, 'useParams').mockReturnValue({ detailName: 'case-id' }); + appMockRender = createAppMockRenderer(); + }); + + it('renders list correctly with isExpandable option', async () => { + appMockRender.render(); + + await waitFor(() => { + expect(screen.getByTestId('user-actions-list')).toBeInTheDocument(); + }); + }); + + it('renders list correctly with isExpandable=false option', async () => { + appMockRender.render(); + + await waitFor(() => { + expect(screen.getByTestId('user-actions-list')).toBeInTheDocument(); + }); + }); + + it('renders user actions correctly', async () => { + appMockRender.render(); + + await waitFor(() => { + expect(screen.getByTestId(`description-create-action-${caseUserActions[0].id}`)); + expect(screen.getByTestId(`comment-create-action-${caseUserActions[1].commentId}`)); + expect(screen.getByTestId(`description-update-action-${caseUserActions[2].id}`)); + }); + }); + + it('renders bottom actions correctly', async () => { + const userName = 'Username'; + const sample = 'This is an add comment bottom actions'; + + const bottomActions = [ + { + username:
{userName}
, + 'data-test-subj': 'add-comment', + timelineAvatar: null, + className: 'isEdit', + children: {sample}, + }, + ]; + appMockRender.render(); + + await waitFor(() => { + expect(screen.getByTestId('user-actions-list')).toBeInTheDocument(); + expect(screen.getByTestId('add-comment')).toBeInTheDocument(); + }); + }); + + it('Outlines comment when url param is provided', async () => { + const commentId = 'basic-comment-id'; + jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId }); + + const ourActions = [getUserAction('comment', Actions.create)]; + + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + + appMockRender.render(); + + expect( + await screen.findAllByTestId(`comment-create-action-${commentId}`) + )[0]?.classList.contains('outlined'); + }); + + it('Outlines comment when update move to link is clicked', async () => { + const ourActions = [ + getUserAction('comment', Actions.create), + getUserAction('comment', Actions.update), + ]; + + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + + appMockRender.render(); + expect( + screen + .queryAllByTestId(`comment-create-action-${props.data.comments[0].id}`)[0] + ?.classList.contains('outlined') + ).toBe(false); + + expect( + screen + .queryAllByTestId(`comment-create-action-${props.data.comments[0].id}`)[0] + ?.classList.contains('outlined') + ).toBe(false); + + userEvent.click(screen.getByTestId(`comment-update-action-${ourActions[1].id}`)); + + expect( + await screen.findAllByTestId(`comment-create-action-${props.data.comments[0].id}`) + )[0]?.classList.contains('outlined'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx b/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx new file mode 100644 index 0000000000000..72d8371734548 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiCommentProps } from '@elastic/eui'; +import { EuiCommentList } from '@elastic/eui'; + +import React, { useMemo, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import type { CaseUserActions } from '../../containers/types'; +import type { UserActionBuilderArgs, UserActionTreeProps } from './types'; +import { isUserActionTypeSupported } from './helpers'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { builderMap } from './builder'; +import { useCaseViewParams } from '../../common/navigation'; +import { useUserActionsHandler } from './use_user_actions_handler'; + +const MyEuiCommentList = styled(EuiCommentList)` + ${({ theme }) => ` + & .userAction__comment.outlined .euiCommentEvent { + outline: solid 5px ${theme.eui.euiColorVis1_behindText}; + margin: 0.5em; + transition: 0.8s; + } + + & .draftFooter { + & .euiCommentEvent__body { + padding: 0; + } + } + + & .euiComment.isEdit { + & .euiCommentEvent { + border: none; + box-shadow: none; + } + + & .euiCommentEvent__body { + padding: 0; + } + + & .euiCommentEvent__header { + display: none; + } + } + + & .comment-alert .euiCommentEvent { + background-color: ${theme.eui.euiColorLightestShade}; + border: ${theme.eui.euiBorderThin}; + padding: ${theme.eui.euiSizeS}; + border-radius: ${theme.eui.euiSizeXS}; + } + + & .comment-alert .euiCommentEvent__headerData { + flex-grow: 1; + } + + & .comment-action.empty-comment [class*="euiCommentEvent-regular"] { + box-shadow: none; + .euiCommentEvent__header { + padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeS}; + border-bottom: 0; + } + } + `} +`; + +export type UserActionListProps = Omit< + UserActionTreeProps, + | 'userActivityQueryParams' + | 'userActionsStats' + | 'useFetchAlertData' + | 'onUpdateField' + | 'statusActionButton' +> & + Pick & { + caseUserActions: CaseUserActions[]; + loadingAlertData: boolean; + manualAlertsData: Record; + bottomActions?: EuiCommentProps[]; + isExpandable?: boolean; + }; + +export const UserActionsList = React.memo( + ({ + caseUserActions, + caseConnectors, + userProfiles, + currentUserProfile, + data: caseData, + getRuleDetailsHref, + actionsNavigation, + onRuleDetailsClick, + onShowAlertDetails, + loadingAlertData, + manualAlertsData, + commentRefs, + handleManageQuote, + bottomActions = [], + isExpandable = false, + }: UserActionListProps) => { + const { + externalReferenceAttachmentTypeRegistry, + persistableStateAttachmentTypeRegistry, + appId, + } = useCasesContext(); + const { commentId } = useCaseViewParams(); + const [initLoading, setInitLoading] = useState(true); + + const { + loadingCommentIds, + selectedOutlineCommentId, + manageMarkdownEditIds, + handleManageMarkdownEditId, + handleOutlineComment, + handleSaveComment, + handleDeleteComment, + } = useUserActionsHandler(); + + const builtUserActions: EuiCommentProps[] = useMemo(() => { + if (!caseUserActions) { + return []; + } + + return caseUserActions.reduce((comments, userAction, index) => { + if (!isUserActionTypeSupported(userAction.type)) { + return comments; + } + + const builder = builderMap[userAction.type]; + + if (builder == null) { + return comments; + } + + const userActionBuilder = builder({ + appId, + caseData, + caseConnectors, + externalReferenceAttachmentTypeRegistry, + persistableStateAttachmentTypeRegistry, + userAction, + userProfiles, + currentUserProfile, + comments: caseData?.comments, + index, + commentRefs, + manageMarkdownEditIds, + selectedOutlineCommentId, + loadingCommentIds, + loadingAlertData, + alertData: manualAlertsData, + handleOutlineComment, + handleManageMarkdownEditId, + handleDeleteComment, + handleSaveComment, + handleManageQuote, + onShowAlertDetails, + actionsNavigation, + getRuleDetailsHref, + onRuleDetailsClick, + }); + return [...comments, ...userActionBuilder.build()]; + }, []); + }, [ + appId, + caseConnectors, + caseUserActions, + userProfiles, + currentUserProfile, + externalReferenceAttachmentTypeRegistry, + persistableStateAttachmentTypeRegistry, + caseData, + commentRefs, + manageMarkdownEditIds, + selectedOutlineCommentId, + loadingCommentIds, + loadingAlertData, + manualAlertsData, + handleOutlineComment, + handleManageMarkdownEditId, + handleDeleteComment, + handleSaveComment, + handleManageQuote, + onShowAlertDetails, + actionsNavigation, + getRuleDetailsHref, + onRuleDetailsClick, + ]); + + const comments = bottomActions?.length + ? [...builtUserActions, ...bottomActions] + : [...builtUserActions]; + + useEffect(() => { + if (commentId != null && initLoading) { + setInitLoading(false); + handleOutlineComment(commentId); + } + }, [commentId, initLoading, handleOutlineComment]); + + return ( + + ); + } +); + +UserActionsList.displayName = 'UserActionsList'; diff --git a/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.test.tsx b/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.test.tsx index 859c8fe97865a..503934839fca8 100644 --- a/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.test.tsx @@ -76,14 +76,12 @@ describe('FilterActivity ', () => { /> ); - expect( - screen.getByLabelText(`${userActionsStats.total - 1} active filters`) - ).toBeInTheDocument(); + expect(screen.getByLabelText(`${userActionsStats.total} active filters`)).toBeInTheDocument(); expect( screen.getByLabelText(`${userActionsStats.totalComments} available filters`) ).toBeInTheDocument(); expect( - screen.getByLabelText(`${userActionsStats.totalOtherActions - 1} available filters`) + screen.getByLabelText(`${userActionsStats.totalOtherActions} available filters`) ).toBeInTheDocument(); }); diff --git a/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.tsx b/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.tsx index 7420a0db2a341..ba95479ae6107 100644 --- a/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.tsx +++ b/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.tsx @@ -50,9 +50,7 @@ export const FilterActivity = React.memo( grow={false} onClick={() => handleFilterChange('all')} hasActiveFilters={type === 'all'} - numFilters={ - userActionsStats && userActionsStats.total > 0 ? userActionsStats.total - 1 : 0 - } // subtracting user action of description from total + numFilters={userActionsStats && userActionsStats.total > 0 ? userActionsStats.total : 0} isLoading={isLoading} isDisabled={isLoading} data-test-subj="user-actions-filter-activity-button-all" @@ -77,10 +75,10 @@ export const FilterActivity = React.memo( {i18n.COMMENTS} 0 - ? userActionsStats.totalOtherActions - 1 + ? userActionsStats.totalOtherActions : 0 } onClick={() => handleFilterChange('action')} diff --git a/x-pack/plugins/cases/public/components/user_actions_activity_bar/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions_activity_bar/index.test.tsx index 6de0797691169..b494e3fa74a1c 100644 --- a/x-pack/plugins/cases/public/components/user_actions_activity_bar/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions_activity_bar/index.test.tsx @@ -22,6 +22,8 @@ describe('UserActionsActivityBar ', () => { const params: UserActivityParams = { type: 'all', sortOrder: 'asc', + page: 1, + perPage: 10, }; beforeEach(() => { @@ -120,6 +122,7 @@ describe('UserActionsActivityBar ', () => { await waitFor(() => expect(onUserActionsActivityChanged).toHaveBeenCalledWith({ + ...params, type: 'action', sortOrder: 'asc', }) diff --git a/x-pack/plugins/cases/public/components/user_actions_activity_bar/types.ts b/x-pack/plugins/cases/public/components/user_actions_activity_bar/types.ts index d442ab4822235..aa3596e737bf6 100644 --- a/x-pack/plugins/cases/public/components/user_actions_activity_bar/types.ts +++ b/x-pack/plugins/cases/public/components/user_actions_activity_bar/types.ts @@ -12,4 +12,6 @@ export type UserActivitySortOrder = 'asc' | 'desc'; export interface UserActivityParams { type: UserActivityFilter; sortOrder: UserActivitySortOrder; + page: number; + perPage: number; } diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index 333a420d519f2..e4d7626a36402 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -15,7 +15,6 @@ import { CASES_URL, INTERNAL_BULK_CREATE_ATTACHMENTS_URL, SECURITY_SOLUTION_OWNER, - MAX_DOCS_PER_PAGE, INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL, } from '../../common/constants'; @@ -469,8 +468,8 @@ describe('Cases API', () => { describe('findCaseUserActions', () => { const findCaseUserActionsSnake = { page: 1, - perPage: 1000, - total: 20, + perPage: 10, + total: 30, userActions: [...caseUserActionsWithRegisteredAttachmentsSnake], }; const filterActionType: CaseUserActionTypeWithAll = 'all'; @@ -478,6 +477,8 @@ describe('Cases API', () => { const params = { type: filterActionType, sortOrder, + page: 1, + perPage: 10, }; beforeEach(() => { @@ -493,7 +494,8 @@ describe('Cases API', () => { query: { types: [], sortOrder: 'asc', - perPage: MAX_DOCS_PER_PAGE, + page: 1, + perPage: 10, }, }); }); @@ -501,7 +503,7 @@ describe('Cases API', () => { it('should be called with action type user action and desc sort order', async () => { await findCaseUserActions( basicCase.id, - { type: 'action', sortOrder: 'desc' }, + { type: 'action', sortOrder: 'desc', page: 2, perPage: 15 }, abortCtrl.signal ); expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/user_actions/_find`, { @@ -510,7 +512,8 @@ describe('Cases API', () => { query: { types: ['action'], sortOrder: 'desc', - perPage: MAX_DOCS_PER_PAGE, + page: 2, + perPage: 15, }, }); }); @@ -523,7 +526,8 @@ describe('Cases API', () => { query: { types: ['user'], sortOrder: 'asc', - perPage: MAX_DOCS_PER_PAGE, + page: 1, + perPage: 10, }, }); }); diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index 6d477fd0895c3..05bef55a2b508 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -51,7 +51,6 @@ import { CASE_TAGS_URL, CASES_URL, INTERNAL_BULK_CREATE_ATTACHMENTS_URL, - MAX_DOCS_PER_PAGE, } from '../../common/constants'; import { getAllConnectorTypesUrl } from '../../common/utils/connectors_api'; @@ -161,13 +160,16 @@ export const findCaseUserActions = async ( params: { type: CaseUserActionTypeWithAll; sortOrder: 'asc' | 'desc'; + page: number; + perPage: number; }, signal: AbortSignal ): Promise => { const query = { types: params.type !== 'all' ? [params.type] : [], - sortOrder: params.sortOrder ?? 'asc', - perPage: MAX_DOCS_PER_PAGE, + sortOrder: params.sortOrder, + page: params.page, + perPage: params.perPage, }; const response = await KibanaServices.get().http.fetch( diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts index 904f52295c23c..ef5595ced5db8 100644 --- a/x-pack/plugins/cases/public/containers/constants.ts +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { SingleCaseMetricsFeature, CaseUserActionTypeWithAll } from './types'; +import type { SingleCaseMetricsFeature } from './types'; export const DEFAULT_TABLE_ACTIVE_PAGE = 1; export const DEFAULT_TABLE_LIMIT = 10; @@ -27,17 +27,8 @@ export const casesQueriesKeys = { [...casesQueriesKeys.case(id), 'metrics', features] as const, caseConnectors: (id: string) => [...casesQueriesKeys.case(id), 'connectors'], caseUsers: (id: string) => [...casesQueriesKeys.case(id), 'users'], - caseUserActions: ( - id: string, - filterActionType: CaseUserActionTypeWithAll, - sortOrder: 'asc' | 'desc' - ) => - [ - ...casesQueriesKeys.case(id), - ...casesQueriesKeys.userActions, - filterActionType, - sortOrder, - ] as const, + caseUserActions: (id: string, params: unknown) => + [...casesQueriesKeys.case(id), ...casesQueriesKeys.userActions, params] as const, caseUserActionsStats: (id: string) => [ ...casesQueriesKeys.case(id), ...casesQueriesKeys.userActions, diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 4c6439231245a..34552478fcd55 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -877,8 +877,8 @@ export const caseUserActionsWithRegisteredAttachments: CaseUserActions[] = [ export const findCaseUserActionsResponse: FindCaseUserActions = { page: 1, - perPage: 1000, - total: 20, + perPage: 10, + total: 30, userActions: [...caseUserActionsWithRegisteredAttachments], }; diff --git a/x-pack/plugins/cases/public/containers/use_find_case_user_actions.test.tsx b/x-pack/plugins/cases/public/containers/use_find_case_user_actions.test.tsx index d2db7f1e4ace2..d5ecdfe92b3ff 100644 --- a/x-pack/plugins/cases/public/containers/use_find_case_user_actions.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_find_case_user_actions.test.tsx @@ -29,8 +29,12 @@ describe('UseFindCaseUserActions', () => { const params = { type: filterActionType, sortOrder, + page: 1, + perPage: 10, }; + const isEnabled = true; + let appMockRender: AppMockRenderer; beforeEach(() => { @@ -41,7 +45,7 @@ describe('UseFindCaseUserActions', () => { it('returns proper state on findCaseUserActions', async () => { const { result, waitForNextUpdate } = renderHook( - () => useFindCaseUserActions(basicCase.id, params), + () => useFindCaseUserActions(basicCase.id, params, isEnabled), { wrapper: appMockRender.AppWrapper } ); @@ -52,8 +56,8 @@ describe('UseFindCaseUserActions', () => { ...initialData, data: { userActions: [...findCaseUserActionsResponse.userActions], - total: 20, - perPage: 1000, + total: 30, + perPage: 10, page: 1, }, isError: false, @@ -67,7 +71,17 @@ describe('UseFindCaseUserActions', () => { const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(initialData); const { waitForNextUpdate } = renderHook( - () => useFindCaseUserActions(basicCase.id, { type: 'user', sortOrder: 'desc' }), + () => + useFindCaseUserActions( + basicCase.id, + { + type: 'user', + sortOrder: 'desc', + page: 1, + perPage: 5, + }, + isEnabled + ), { wrapper: appMockRender.AppWrapper } ); @@ -75,26 +89,50 @@ describe('UseFindCaseUserActions', () => { expect(spy).toHaveBeenCalledWith( basicCase.id, - { type: 'user', sortOrder: 'desc' }, + { type: 'user', sortOrder: 'desc', page: 1, perPage: 5 }, expect.any(AbortSignal) ); }); + it('does not call API when not enabled', async () => { + const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(initialData); + + renderHook( + () => + useFindCaseUserActions( + basicCase.id, + { + type: 'user', + sortOrder: 'desc', + page: 1, + perPage: 5, + }, + false + ), + { wrapper: appMockRender.AppWrapper } + ); + + expect(spy).not.toHaveBeenCalled(); + }); + it('shows a toast error when the API returns an error', async () => { const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(new Error("C'est la vie")); const addError = jest.fn(); (useToasts as jest.Mock).mockReturnValue({ addError }); - const { waitForNextUpdate } = renderHook(() => useFindCaseUserActions(basicCase.id, params), { - wrapper: appMockRender.AppWrapper, - }); + const { waitForNextUpdate } = renderHook( + () => useFindCaseUserActions(basicCase.id, params, isEnabled), + { + wrapper: appMockRender.AppWrapper, + } + ); await waitForNextUpdate(); expect(spy).toHaveBeenCalledWith( basicCase.id, - { type: filterActionType, sortOrder }, + { type: filterActionType, sortOrder, page: 1, perPage: 10 }, expect.any(AbortSignal) ); expect(addError).toHaveBeenCalled(); diff --git a/x-pack/plugins/cases/public/containers/use_find_case_user_actions.tsx b/x-pack/plugins/cases/public/containers/use_find_case_user_actions.tsx index 1c29fb792c234..713ea237f3b7e 100644 --- a/x-pack/plugins/cases/public/containers/use_find_case_user_actions.tsx +++ b/x-pack/plugins/cases/public/containers/use_find_case_user_actions.tsx @@ -18,17 +18,21 @@ export const useFindCaseUserActions = ( params: { type: CaseUserActionTypeWithAll; sortOrder: 'asc' | 'desc'; - } + page: number; + perPage: number; + }, + isEnabled: boolean ) => { const { showErrorToast } = useCasesToast(); const abortCtrlRef = new AbortController(); return useQuery( - casesQueriesKeys.caseUserActions(caseId, params.type, params.sortOrder), + casesQueriesKeys.caseUserActions(caseId, params), async () => { return findCaseUserActions(caseId, params, abortCtrlRef.signal); }, { + enabled: isEnabled, onError: (error: ServerError) => { showErrorToast(error, { title: ERROR_TITLE }); }, diff --git a/x-pack/plugins/cases/public/containers/use_infinite_find_case_user_actions.test.tsx b/x-pack/plugins/cases/public/containers/use_infinite_find_case_user_actions.test.tsx new file mode 100644 index 0000000000000..b8c51e3ce86d5 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_infinite_find_case_user_actions.test.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useInfiniteFindCaseUserActions } from './use_infinite_find_case_user_actions'; +import type { CaseUserActionTypeWithAll } from '../../common/ui/types'; +import { basicCase, findCaseUserActionsResponse } from './mock'; +import * as api from './api'; +import { useToasts } from '../common/lib/kibana'; +import type { AppMockRenderer } from '../common/mock'; +import { createAppMockRenderer } from '../common/mock'; + +jest.mock('./api'); +jest.mock('../common/lib/kibana'); + +const initialData = { + data: undefined, + isError: false, + isLoading: true, +}; + +describe('UseInfiniteFindCaseUserActions', () => { + const filterActionType: CaseUserActionTypeWithAll = 'all'; + const sortOrder: 'asc' | 'desc' = 'asc'; + const params = { + type: filterActionType, + sortOrder, + perPage: 10, + }; + const isEnabled = true; + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('returns proper state on findCaseUserActions', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useInfiniteFindCaseUserActions(basicCase.id, params, isEnabled), + { wrapper: appMockRender.AppWrapper } + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual( + expect.objectContaining({ + ...initialData, + data: { + pages: [ + { + userActions: [...findCaseUserActionsResponse.userActions], + total: 30, + perPage: 10, + page: 1, + }, + ], + pageParams: [undefined], + }, + isError: false, + isLoading: false, + isFetching: false, + }) + ); + }); + + it('calls the API with correct parameters', async () => { + const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(initialData); + + const { waitForNextUpdate } = renderHook( + () => + useInfiniteFindCaseUserActions( + basicCase.id, + { + type: 'user', + sortOrder: 'desc', + perPage: 5, + }, + isEnabled + ), + { wrapper: appMockRender.AppWrapper } + ); + + await waitForNextUpdate(); + + expect(spy).toHaveBeenCalledWith( + basicCase.id, + { type: 'user', sortOrder: 'desc', page: 1, perPage: 5 }, + expect.any(AbortSignal) + ); + }); + + it('does not call API when not enabled', async () => { + const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(initialData); + + renderHook( + () => + useInfiniteFindCaseUserActions( + basicCase.id, + { + type: 'user', + sortOrder: 'desc', + perPage: 5, + }, + false + ), + { wrapper: appMockRender.AppWrapper } + ); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('shows a toast error when the API returns an error', async () => { + const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(new Error("C'est la vie")); + + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addError }); + + const { waitForNextUpdate } = renderHook( + () => useInfiniteFindCaseUserActions(basicCase.id, params, isEnabled), + { + wrapper: appMockRender.AppWrapper, + } + ); + + await waitForNextUpdate(); + + expect(spy).toHaveBeenCalledWith( + basicCase.id, + { type: filterActionType, sortOrder, page: 1, perPage: 10 }, + expect.any(AbortSignal) + ); + expect(addError).toHaveBeenCalled(); + }); + + it('fetches next page with correct params', async () => { + const spy = jest.spyOn(api, 'findCaseUserActions'); + + const { result, waitFor } = renderHook( + () => useInfiniteFindCaseUserActions(basicCase.id, params, isEnabled), + { wrapper: appMockRender.AppWrapper } + ); + + await waitFor(() => result.current.isSuccess); + + expect(result.current.data?.pages).toStrictEqual([findCaseUserActionsResponse]); + + expect(result.current.hasNextPage).toBe(true); + + act(() => { + result.current.fetchNextPage(); + }); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith( + basicCase.id, + { type: 'all', sortOrder, page: 2, perPage: 10 }, + expect.any(AbortSignal) + ); + }); + await waitFor(() => result.current.data?.pages.length === 2); + }); + + it('returns hasNextPage correctly', async () => { + jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(initialData); + + const { result } = renderHook( + () => useInfiniteFindCaseUserActions(basicCase.id, params, isEnabled), + { wrapper: appMockRender.AppWrapper } + ); + + expect(result.current.hasNextPage).toBe(undefined); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_infinite_find_case_user_actions.tsx b/x-pack/plugins/cases/public/containers/use_infinite_find_case_user_actions.tsx new file mode 100644 index 0000000000000..2d4f0056849db --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_infinite_find_case_user_actions.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useInfiniteQuery } from '@tanstack/react-query'; +import type { FindCaseUserActions, CaseUserActionTypeWithAll } from '../../common/ui/types'; +import { findCaseUserActions } from './api'; +import type { ServerError } from '../types'; +import { useCasesToast } from '../common/use_cases_toast'; +import { ERROR_TITLE } from './translations'; +import { casesQueriesKeys } from './constants'; + +export const useInfiniteFindCaseUserActions = ( + caseId: string, + params: { + type: CaseUserActionTypeWithAll; + sortOrder: 'asc' | 'desc'; + perPage: number; + }, + isEnabled: boolean +) => { + const { showErrorToast } = useCasesToast(); + const abortCtrlRef = new AbortController(); + + return useInfiniteQuery( + casesQueriesKeys.caseUserActions(caseId, params), + async ({ pageParam = 1 }) => { + return findCaseUserActions(caseId, { ...params, page: pageParam }, abortCtrlRef.signal); + }, + { + enabled: isEnabled, + onError: (error: ServerError) => { + showErrorToast(error, { title: ERROR_TITLE }); + }, + getNextPageParam: (lastPage, pages) => { + const lastPageNumber = Math.ceil(lastPage.total / lastPage.perPage); + // here last page fetching is skipped because last page is fetched separately using useQuery hook + if (lastPage.page < lastPageNumber - 1) { + return lastPage.page + 1; + } + return undefined; + }, + } + ); +}; + +export type UseInfiniteFindCaseUserActions = ReturnType; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.ts index e6c7b64904ba1..552f717ff94cc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.ts @@ -24,7 +24,7 @@ import { AddAnalyticsCollectionApiLogicArgs, AddAnalyticsCollectionApiLogicResponse, } from '../../api/add_analytics_collection/add_analytics_collection_api_logic'; -import { COLLECTION_VIEW_PATH } from '../../routes'; +import { COLLECTION_OVERVIEW_PATH } from '../../routes'; const SERVER_ERROR_CODE = 500; @@ -102,7 +102,7 @@ export const AddAnalyticsCollectionLogic = kea< }) ); KibanaLogic.values.navigateToUrl( - generateEncodedPath(COLLECTION_VIEW_PATH, { + generateEncodedPath(COLLECTION_OVERVIEW_PATH, { name, }) ); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.test.tsx deleted file mode 100644 index ebe2c67ea8f9f..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.test.tsx +++ /dev/null @@ -1,71 +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 '../../../__mocks__/shallow_useeffect.mock'; - -import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiEmptyPrompt } from '@elastic/eui'; - -import { AnalyticsCollection } from '../../../../../common/types/analytics'; -import { EntSearchLogStream } from '../../../shared/log_stream'; - -import { AnalyticsCollectionEvents } from './analytics_collection_events'; - -describe('AnalyticsCollectionEvents', () => { - const analyticsCollection: AnalyticsCollection = { - events_datastream: 'logs-elastic_analytics.events-example', - name: 'example', - }; - - const mockActions = { - analyticsEventsIndexExists: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - - setMockActions(mockActions); - }); - - it('renders', () => { - setMockValues({ - isPresent: true, - isLoading: false, - }); - const expectedQuery = '_index: logs-elastic_analytics.events-example'; - - const wrapper = shallow(); - expect(wrapper.find(EntSearchLogStream).prop('query')).toEqual(expectedQuery); - }); - - describe('empty state', () => { - it('renders when analytics events index is not present', () => { - setMockValues({ - isPresent: false, - }); - - const wrapper = shallow(); - - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); - }); - - it('renders when analytics events index check is not performed yet', () => { - setMockValues({ - isLoading: true, - }); - - const wrapper = shallow(); - - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); - }); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.tsx deleted file mode 100644 index ad220dbb3efe1..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.tsx +++ /dev/null @@ -1,148 +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 React, { useEffect } from 'react'; - -import { useValues, useActions } from 'kea'; - -import { EuiEmptyPrompt, EuiButton, EuiLink, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { ENTERPRISE_SEARCH_ANALYTICS_LOGS_SOURCE_ID } from '../../../../../common/constants'; -import { AnalyticsCollection } from '../../../../../common/types/analytics'; -import { docLinks } from '../../../shared/doc_links'; -import { generateEncodedPath } from '../../../shared/encode_path_params'; -import { KibanaLogic } from '../../../shared/kibana'; - -import { EntSearchLogStream } from '../../../shared/log_stream'; -import { COLLECTION_VIEW_PATH } from '../../routes'; - -import { AnalyticsEventsIndexExistsLogic } from './analytics_events_index_exists_logic'; - -interface AnalyticsCollectionEventsProps { - collection: AnalyticsCollection; -} - -const EVENTS_POLLING_INTERVAL = 30 * 1000; - -export const AnalyticsCollectionEvents: React.FC = ({ - collection, -}) => { - const { analyticsEventsIndexExists } = useActions(AnalyticsEventsIndexExistsLogic); - const { isLoading, isPresent } = useValues(AnalyticsEventsIndexExistsLogic); - const { navigateToUrl } = useValues(KibanaLogic); - - useEffect(() => { - analyticsEventsIndexExists(collection.events_datastream); - - const interval = setInterval(() => { - analyticsEventsIndexExists(collection.events_datastream); - }, EVENTS_POLLING_INTERVAL); - - return () => clearInterval(interval); - }, []); - - return ( - <> - {(isLoading || !isPresent) && ( - -

- - There are no analytics events for {collection.name} yet - - ), - }} - /> -

- - } - body={i18n.translate( - 'xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.body', - { - defaultMessage: - "Start tracking events by adding the behavioral analytics client to every page of your website or application that you'd like to track", - } - )} - actions={ - - navigateToUrl( - generateEncodedPath(COLLECTION_VIEW_PATH, { - id: collection.name, - section: 'integrate', - }) - ) - } - > - {i18n.translate( - 'xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.actions', - { - defaultMessage: 'View integration instructions', - } - )} - - } - footer={ - - {i18n.translate( - 'xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.footer', - { - defaultMessage: 'Visit the behavioral analytics documentation', - } - )} - - } - /> - )} - {!isLoading && isPresent && ( - - )} - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.test.tsx index 53300b2649a08..668f58534ea5e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.test.tsx @@ -9,12 +9,17 @@ import '../../../../__mocks__/shallow_useeffect.mock'; import React from 'react'; -import { EuiCodeBlock } from '@elastic/eui'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { shallow } from 'enzyme'; + +import { EuiCodeBlock, EuiSteps } from '@elastic/eui'; import { AnalyticsCollection } from '../../../../../../common/types/analytics'; -import { AnalyticsCollectionIntegrate } from './analytics_collection_integrate'; +import { AnalyticsCollectionIntegrateView } from './analytics_collection_integrate_view'; + +jest.mock('../../../../shared/enterprise_search_url', () => ({ + getEnterpriseSearchUrl: () => 'http://localhost:3002', +})); describe('AnalyticsCollectionIntegrate', () => { const analyticsCollections: AnalyticsCollection = { @@ -27,23 +32,31 @@ describe('AnalyticsCollectionIntegrate', () => { }); it('renders', () => { - const wrapper = mountWithIntl( - + const wrapper = shallow( + ); - expect(wrapper.find(EuiCodeBlock)).toHaveLength(3); + expect(wrapper.find(EuiSteps).dive().find(EuiCodeBlock)).toHaveLength(3); wrapper.find('[data-test-subj="searchuiEmbed"]').at(0).simulate('click'); - expect(wrapper.find(EuiCodeBlock)).toHaveLength(3); + expect(wrapper.find(EuiSteps).dive().find(EuiCodeBlock)).toHaveLength(3); wrapper.find('[data-test-subj="javascriptClientEmbed"]').at(0).simulate('click'); - expect(wrapper.find(EuiCodeBlock)).toHaveLength(5); + expect(wrapper.find(EuiSteps).dive().find(EuiCodeBlock)).toHaveLength(5); }); - it('check value of analyticsDNSUrl & webClientSrc', () => { - const wrapper = mountWithIntl( - + it('check value of config & webClientSrc', () => { + const wrapper = shallow( + ); - expect(wrapper.find(EuiCodeBlock).at(0).text()).toContain( - 'data-dsn="/api/analytics/collections/example"' + expect(wrapper.find(EuiSteps).dive().find(EuiCodeBlock).at(0).dive().text()).toContain( + 'https://cdn.jsdelivr.net/npm/@elastic/behavioral-analytics-browser-tracker@2/dist/umd/index.global.js' ); - expect(wrapper.find(EuiCodeBlock).at(0).text()).toContain('src="/analytics.js"'); + + expect(wrapper.find(EuiSteps).dive().find(EuiCodeBlock).at(1).dive().text()) + .toMatchInlineSnapshot(` + "" + `); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.tsx deleted file mode 100644 index dee042584ad8d..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.tsx +++ /dev/null @@ -1,107 +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 React from 'react'; - -import { EuiPanel, EuiSpacer, EuiSteps, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; - -import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; -import { i18n } from '@kbn/i18n'; - -import { AnalyticsCollection } from '../../../../../../common/types/analytics'; -import { getEnterpriseSearchUrl } from '../../../../shared/enterprise_search_url'; - -import { javascriptClientEmbedSteps } from './analytics_collection_integrate_javascript_client_embed'; -import { javascriptEmbedSteps } from './analytics_collection_integrate_javascript_embed'; -import { searchUIEmbedSteps } from './analytics_collection_integrate_searchui'; - -interface AnalyticsCollectionIntegrateProps { - collection: AnalyticsCollection; -} - -export type TabKey = 'javascriptEmbed' | 'searchuiEmbed' | 'javascriptClientEmbed'; - -export const AnalyticsCollectionIntegrate: React.FC = ({ - collection, -}) => { - const analyticsDNSUrl = getEnterpriseSearchUrl(`/api/analytics/collections/${collection.name}`); - const webClientSrc = getEnterpriseSearchUrl('/analytics.js'); - - const [selectedTab, setSelectedTab] = React.useState('javascriptEmbed'); - - const tabs: Array<{ - key: TabKey; - title: string; - }> = [ - { - key: 'javascriptEmbed', - title: i18n.translate( - 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptEmbed.title', - { - defaultMessage: 'Javascript Embed', - } - ), - }, - { - key: 'javascriptClientEmbed', - title: i18n.translate( - 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.title', - { - defaultMessage: 'Javascript Client', - } - ), - }, - { - key: 'searchuiEmbed', - title: i18n.translate( - 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.title', - { - defaultMessage: 'Search UI', - } - ), - }, - ]; - - const steps: Record = { - javascriptClientEmbed: javascriptClientEmbedSteps(analyticsDNSUrl), - javascriptEmbed: javascriptEmbedSteps(webClientSrc, analyticsDNSUrl), - searchuiEmbed: searchUIEmbedSteps(setSelectedTab), - }; - - return ( - - -

- {i18n.translate( - 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.title', - { - defaultMessage: 'Start tracking events', - } - )} -

-
- - - {tabs.map((tab) => ( - { - setSelectedTab(tab.key); - }} - isSelected={selectedTab === tab.key} - data-test-subj={tab.key} - data-telemetry-id={`entSearch-analytics-integrate-${tab.key}-tab`} - > - {tab.title} - - ))} - - - -
- ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_javascript_client_embed.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_javascript_client_embed.tsx index 136617db09112..c240d941c74b5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_javascript_client_embed.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_javascript_client_embed.tsx @@ -11,7 +11,9 @@ import { EuiCodeBlock, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -export const javascriptClientEmbedSteps = (analyticsDNSUrl: string) => [ +import { AnalyticsConfig } from './analytics_collection_integrate_view'; + +export const javascriptClientEmbedSteps = (analyticsConfig: AnalyticsConfig) => [ { title: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepOne.title', @@ -60,7 +62,8 @@ export const javascriptClientEmbedSteps = (analyticsDNSUrl: string) => [ {`import { createTracker, trackPageView, - trackEvent, + trackSearch, + trackSearchClick } from "@elastic/behavioral-analytics-javascript-tracker";`} @@ -82,7 +85,7 @@ export const javascriptClientEmbedSteps = (analyticsDNSUrl: string) => [ 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepThree.description', { defaultMessage: - ' Use createTracker method to initialize the tracker with your DSN. You will then be able to use the tracker to send events to Behavioral Analytics.', + 'Use createTracker method to initialize the tracker with your Configuration. You will then be able to use the tracker to send events to Behavioral Analytics.', } )}

@@ -97,7 +100,9 @@ export const javascriptClientEmbedSteps = (analyticsDNSUrl: string) => [

{`createTracker({ - dsn: "${analyticsDNSUrl}", + endpoint: "${analyticsConfig.endpoint}", + collectionName: "${analyticsConfig.collectionName}", + apiKey: "${analyticsConfig.apiKey}" });`} @@ -108,7 +113,7 @@ export const javascriptClientEmbedSteps = (analyticsDNSUrl: string) => [ title: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.title', { - defaultMessage: 'Dispatch Pageview and behavior events', + defaultMessage: 'Dispatch Pageview and search behavior events', } ), children: ( @@ -155,28 +160,41 @@ const SearchPage = (props) => { 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionThree', { defaultMessage: - 'You can also dispatch custom events to Behavioral Analytics by calling the trackEvent method.', + 'You can also use trackSearch and trackSearchClick to track what your customers are searching and clicking on in your application.', } )}

- {`// track a custom event in React -import { trackEvent } from '@elastic/behavioral-analytics-javascript-tracker'; + {` +import { trackSearch } from '@elastic/behavioral-analytics-javascript-tracker'; + +const SearchResult = ({ hit }) => { -const ProductDetailPage = (props) => { + const clickHandler = () => { + trackSearchClick({ + document: { id: hit.id, index: "products" }, + search: { + query: "search term", + filters: [], + page: { current: 1, size: 10 }, + results: { + items: [ + { id: "123", index: "products" } + ], + total_results: 10 + }, + sort: { + name: "relevance", + }, + search_application: "website", + } + }) + } return ( -
-

Product detail page

- { - trackEvent("click", { - category: "product", - action: "add_to_cart", - label: "product_id", - value: "123" - }) - }} value="Add to Basket"/> -
+ +

{hit.title}

+
) }`}
diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_javascript_embed.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_javascript_embed.tsx index 425912d8166e6..632a6626404cd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_javascript_embed.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_javascript_embed.tsx @@ -14,7 +14,9 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../shared/doc_links'; -export const javascriptEmbedSteps = (webClientSrc: string, analyticsDNSUrl: string) => [ +import { AnalyticsConfig } from './analytics_collection_integrate_view'; + +export const javascriptEmbedSteps = (webClientSrc: string, analyticsConfig: AnalyticsConfig) => [ { title: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptEmbed.stepOne.title', @@ -35,7 +37,7 @@ export const javascriptEmbedSteps = (webClientSrc: string, analyticsDNSUrl: stri )}

- {``} + {``} @@ -61,7 +63,11 @@ export const javascriptEmbedSteps = (webClientSrc: string, analyticsDNSUrl: stri )}

- {''} + {``} @@ -69,9 +75,9 @@ export const javascriptEmbedSteps = (webClientSrc: string, analyticsDNSUrl: stri }, { title: i18n.translate( - 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.stepThree.title', + 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptEmbed.stepThree.title', { - defaultMessage: 'Track individual events', + defaultMessage: 'Track search events', } ), children: ( @@ -80,7 +86,7 @@ export const javascriptEmbedSteps = (webClientSrc: string, analyticsDNSUrl: stri

- {`window.elasticAnalytics.trackEvent("click", { - category: "product", - action: "add_to_cart", - label: "product_id", - value: "123" + {`window.elasticAnalytics.trackSearch({ + search: { + query: "laptop", + filters: [ + { field: "brand", value: ["apple"] }, + { field: "price", value: ["1000-2000"] }, + ], + page: { + current: 1, + size: 10, + }, + results: { + items: [ + { + document: { + id: "123", + index: "products", + }, + page: { + url: "http://my-website.com/products/123", + }, + }, + ], + total_results: 100, + }, + sort: { + name: "relevance", + }, + search_application: "website", + } });`} diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_searchui.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_searchui.tsx index 6db73c11cb7ee..c52fbdbff01f5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_searchui.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_searchui.tsx @@ -12,7 +12,7 @@ import { EuiCodeBlock, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { TabKey } from './analytics_collection_integrate'; +import { TabKey } from './analytics_collection_integrate_view'; export const searchUIEmbedSteps = (setSelectedTab: (tab: TabKey) => void) => [ { diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_view.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_view.tsx new file mode 100644 index 0000000000000..497317712646c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate_view.tsx @@ -0,0 +1,134 @@ +/* + * 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 { EuiSpacer, EuiSteps, EuiTab, EuiTabs } from '@elastic/eui'; + +import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; + +import { i18n } from '@kbn/i18n'; + +import { AnalyticsCollection } from '../../../../../../common/types/analytics'; + +import { getEnterpriseSearchUrl } from '../../../../shared/enterprise_search_url'; + +import { EnterpriseSearchAnalyticsPageTemplate } from '../../layout/page_template'; + +import { javascriptClientEmbedSteps } from './analytics_collection_integrate_javascript_client_embed'; +import { javascriptEmbedSteps } from './analytics_collection_integrate_javascript_embed'; +import { searchUIEmbedSteps } from './analytics_collection_integrate_searchui'; + +interface AnalyticsCollectionIntegrateProps { + analyticsCollection: AnalyticsCollection; +} + +export type TabKey = 'javascriptEmbed' | 'searchuiEmbed' | 'javascriptClientEmbed'; + +export interface AnalyticsConfig { + apiKey: string; + collectionName: string; + endpoint: string; +} + +export const AnalyticsCollectionIntegrateView: React.FC = ({ + analyticsCollection, +}) => { + const [selectedTab, setSelectedTab] = React.useState('javascriptEmbed'); + + const analyticsConfig: AnalyticsConfig = { + apiKey: '########', + collectionName: analyticsCollection?.name, + endpoint: getEnterpriseSearchUrl(), + }; + const webClientSrc = `https://cdn.jsdelivr.net/npm/@elastic/behavioral-analytics-browser-tracker@2/dist/umd/index.global.js`; + + const tabs: Array<{ + key: TabKey; + title: string; + }> = [ + { + key: 'javascriptEmbed', + title: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptEmbed.title', + { + defaultMessage: 'Javascript Embed', + } + ), + }, + { + key: 'javascriptClientEmbed', + title: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.title', + { + defaultMessage: 'Javascript Client', + } + ), + }, + { + key: 'searchuiEmbed', + title: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.title', + { + defaultMessage: 'Search UI', + } + ), + }, + ]; + + const steps: Record = { + javascriptClientEmbed: javascriptClientEmbedSteps(analyticsConfig), + javascriptEmbed: javascriptEmbedSteps(webClientSrc, analyticsConfig), + searchuiEmbed: searchUIEmbedSteps(setSelectedTab), + }; + + return ( + + <> + + {tabs.map((tab) => ( + { + setSelectedTab(tab.key); + }} + isSelected={selectedTab === tab.key} + data-test-subj={tab.key} + data-telemetry-id={`entSearch-analytics-integrate-${tab.key}-tab`} + > + {tab.title} + + ))} + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview.test.tsx new file mode 100644 index 0000000000000..3de4065975705 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../__mocks__/react_router'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { AnalyticsCollection } from '../../../../../common/types/analytics'; +import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template'; + +import { AnalyticsCollectionChartWithLens } from './analytics_collection_chart'; + +import { AnalyticsCollectionOverview } from './analytics_collection_overview'; + +const mockValues = { + analyticsCollection: { + events_datastream: 'analytics-events-example', + name: 'Analytics-Collection-1', + } as AnalyticsCollection, + searchSessionId: 'session-id', + timeRange: { + from: 'now-90d', + to: 'now', + }, +}; + +const mockActions = { + fetchAnalyticsCollection: jest.fn(), + fetchAnalyticsCollectionDataViewId: jest.fn(), + setTimeRange: jest.fn(), +}; + +describe('AnalyticsOverView', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseParams.mockReturnValue({ name: '1', section: 'settings' }); + }); + + it('renders with Data', async () => { + const wrapper = shallow( + + ); + expect(wrapper.find(AnalyticsCollectionChartWithLens)).toHaveLength(1); + }); + + it('sends correct telemetry page name for selected tab', async () => { + setMockValues(mockValues); + setMockActions(mockActions); + + const wrapper = shallow( + + ); + + expect(wrapper.prop('pageViewTelemetry')).toBe('View Analytics Collection - Overview'); + }); + + it('render toolbar in pageHeader rightSideItems ', async () => { + setMockValues({ ...mockValues, dataViewId: null }); + setMockActions(mockActions); + + const wrapper = shallow( + + ); + + expect( + wrapper?.find(EnterpriseSearchAnalyticsPageTemplate)?.prop('pageHeader')?.rightSideItems + ).toHaveLength(1); + }); + + it('render AnalyticsCollectionChartWithLens with collection', () => { + setMockValues(mockValues); + setMockActions(mockActions); + + const wrapper = shallow( + + ); + expect(wrapper?.find(AnalyticsCollectionChartWithLens)).toHaveLength(1); + expect(wrapper?.find(AnalyticsCollectionChartWithLens).props()).toEqual({ + dataViewQuery: 'analytics-events-example', + id: 'analytics-collection-chart-Analytics-Collection-1', + searchSessionId: 'session-id', + setTimeRange: mockActions.setTimeRange, + timeRange: { + from: 'now-90d', + to: 'now', + }, + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview.tsx new file mode 100644 index 0000000000000..e0df130e9256b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { AnalyticsCollection } from '../../../../../common/types/analytics'; + +import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template'; + +import { AnalyticsCollectionChartWithLens } from './analytics_collection_chart'; +import { AnalyticsCollectionToolbar } from './analytics_collection_toolbar/analytics_collection_toolbar'; +import { AnalyticsCollectionToolbarLogic } from './analytics_collection_toolbar/analytics_collection_toolbar_logic'; + +interface AnalyticsCollectionOverviewProps { + analyticsCollection: AnalyticsCollection; +} + +export const AnalyticsCollectionOverview: React.FC = ({ + analyticsCollection, +}) => { + const { setTimeRange } = useActions(AnalyticsCollectionToolbarLogic); + const { timeRange, searchSessionId } = useValues(AnalyticsCollectionToolbarLogic); + + return ( + ], + }} + > + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.test.tsx index 60261af169928..3aea0bf7c64b6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.test.tsx @@ -14,13 +14,12 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiEmptyPrompt } from '@elastic/eui'; + import { AnalyticsCollection } from '../../../../../common/types/analytics'; import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template'; -import { AnalyticsCollectionChartWithLens } from './analytics_collection_chart'; - -import { AnalyticsCollectionIntegrate } from './analytics_collection_integrate/analytics_collection_integrate'; -import { AnalyticsCollectionSettings } from './analytics_collection_settings'; +import { AnalyticsCollectionIntegrateView } from './analytics_collection_integrate/analytics_collection_integrate_view'; import { AnalyticsCollectionView } from './analytics_collection_view'; @@ -29,11 +28,6 @@ const mockValues = { events_datastream: 'analytics-events-example', name: 'Analytics-Collection-1', } as AnalyticsCollection, - searchSessionId: 'session-id', - timeRange: { - from: 'now-90d', - to: 'now', - }, }; const mockActions = { @@ -46,7 +40,7 @@ describe('AnalyticsView', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseParams.mockReturnValue({ name: '1', section: 'settings' }); + mockUseParams.mockReturnValue({ name: '1' }); }); it('renders when analytics collection is empty on initial query', () => { @@ -59,54 +53,18 @@ describe('AnalyticsView', () => { expect(mockActions.fetchAnalyticsCollection).toHaveBeenCalled(); - expect(wrapper.find(AnalyticsCollectionSettings)).toHaveLength(0); - expect(wrapper.find(AnalyticsCollectionIntegrate)).toHaveLength(0); - }); - - it('renders with Data', async () => { - setMockValues(mockValues); - setMockActions(mockActions); - - shallow(); - - expect(mockActions.fetchAnalyticsCollection).toHaveBeenCalled(); - }); - - it('sends correct telemetry page name for selected tab', async () => { - setMockValues(mockValues); - setMockActions(mockActions); - - const wrapper = shallow(); - - expect(wrapper.prop('pageViewTelemetry')).toBe('View Analytics Collection - settings'); + expect(wrapper.find(AnalyticsCollectionIntegrateView)).toHaveLength(0); + expect(wrapper.find(EnterpriseSearchAnalyticsPageTemplate)).toHaveLength(1); }); - it('render toolbar in pageHeader rightSideItems ', async () => { - setMockValues({ ...mockValues, dataViewId: null }); + it('render deleted state for deleted analytics collection', async () => { + setMockValues({ ...mockValues, analyticsCollection: null }); setMockActions(mockActions); const wrapper = shallow(); - expect( - wrapper?.find(EnterpriseSearchAnalyticsPageTemplate)?.prop('pageHeader')?.rightSideItems - ).toHaveLength(1); - }); - - it('render AnalyticsCollectionChartWithLens with collection', () => { - setMockValues(mockValues); - setMockActions(mockActions); - - const wrapper = shallow(); - expect(wrapper?.find(AnalyticsCollectionChartWithLens)).toHaveLength(1); - expect(wrapper?.find(AnalyticsCollectionChartWithLens).props()).toEqual({ - dataViewQuery: 'analytics-events-example', - id: 'analytics-collection-chart-Analytics-Collection-1', - searchSessionId: 'session-id', - setTimeRange: mockActions.setTimeRange, - timeRange: { - from: 'now-90d', - to: 'now', - }, - }); + expect(wrapper?.find(EnterpriseSearchAnalyticsPageTemplate).find(EuiEmptyPrompt)).toHaveLength( + 1 + ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.tsx index ace77928430a1..b33f509fb5db7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.tsx @@ -7,84 +7,80 @@ import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { Switch } from 'react-router-dom'; import { useActions, useValues } from 'kea'; import { EuiEmptyPrompt } from '@elastic/eui'; - import { i18n } from '@kbn/i18n'; +import { Route } from '@kbn/shared-ux-router'; +import { + COLLECTION_EXPLORER_PATH, + COLLECTION_INTEGRATE_PATH, + COLLECTION_OVERVIEW_PATH, +} from '../../routes'; import { AddAnalyticsCollection } from '../add_analytics_collections/add_analytics_collection'; import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template'; -import { AnalyticsCollectionChartWithLens } from './analytics_collection_chart'; -import { AnalyticsCollectionToolbar } from './analytics_collection_toolbar/analytics_collection_toolbar'; -import { AnalyticsCollectionToolbarLogic } from './analytics_collection_toolbar/analytics_collection_toolbar_logic'; +import { AnalyticsCollectionIntegrateView } from './analytics_collection_integrate/analytics_collection_integrate_view'; +import { AnalyticsCollectionOverview } from './analytics_collection_overview'; import { FetchAnalyticsCollectionLogic } from './fetch_analytics_collection_logic'; export const AnalyticsCollectionView: React.FC = () => { const { fetchAnalyticsCollection } = useActions(FetchAnalyticsCollectionLogic); - const { setTimeRange } = useActions(AnalyticsCollectionToolbarLogic); const { analyticsCollection, isLoading } = useValues(FetchAnalyticsCollectionLogic); - const { timeRange, searchSessionId } = useValues(AnalyticsCollectionToolbarLogic); - const { name, section } = useParams<{ name: string; section: string }>(); + const { name } = useParams<{ name: string }>(); useEffect(() => { fetchAnalyticsCollection(name); }, []); + if (analyticsCollection) { + return ( + + + + + + + + + + + + ); + } + return ( - ], - }} - > - {analyticsCollection ? ( - - ) : ( - - {i18n.translate( - 'xpack.enterpriseSearch.analytics.collections.collectionsView.collectionNotFoundState.headingTitle', - { - defaultMessage: 'You may have deleted this analytics collection', - } - )} - - } - body={ -

- {i18n.translate( - 'xpack.enterpriseSearch.analytics.collections.collectionsView.collectionNotFoundState.subHeading', - { - defaultMessage: - 'An analytics collection provides a place to store the analytics events for any given search application you are building. Create a new collection to get started.', - } - )} -

- } - actions={[]} - /> - )} + + + {i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.collectionNotFoundState.headingTitle', + { + defaultMessage: 'You may have deleted this analytics collection', + } + )} + + } + body={ +

+ {i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.collectionNotFoundState.subHeading', + { + defaultMessage: + 'An analytics collection provides a place to store the analytics events for any given search application you are building. Create a new collection to get started.', + } + )} +

+ } + actions={[]} + />
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_card/analytics_collection_card.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_card/analytics_collection_card.tsx index b7a288f2fca33..031212489213b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_card/analytics_collection_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_card/analytics_collection_card.tsx @@ -35,7 +35,7 @@ import { generateEncodedPath } from '../../../../shared/encode_path_params'; import { KibanaLogic } from '../../../../shared/kibana'; import { withLensData } from '../../../hoc/with_lens_data'; -import { COLLECTION_VIEW_PATH } from '../../../routes'; +import { COLLECTION_OVERVIEW_PATH } from '../../../routes'; import { FilterBy, getFormulaByFilter } from '../../../utils/get_formula_by_filter'; @@ -102,7 +102,7 @@ export const AnalyticsCollectionCard: React.FC< const cardStyles = AnalyticsCollectionCardStyles(euiTheme); const status = getChartStatus(secondaryMetric); const CARD_THEME = getCardTheme(euiTheme)[status]; - const collectionViewUrl = generateEncodedPath(COLLECTION_VIEW_PATH, { + const collectionViewUrl = generateEncodedPath(COLLECTION_OVERVIEW_PATH, { name: collection.name, }); const handleCardClick = (event: MouseEvent) => { diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/layout/page_template.tsx index a6df50dd76895..ea2a8ab6088f1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/layout/page_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/layout/page_template.tsx @@ -17,7 +17,7 @@ import { SendEnterpriseSearchTelemetry } from '../../../shared/telemetry'; import { COLLECTION_EXPLORER_PATH, COLLECTION_INTEGRATE_PATH, - COLLECTION_VIEW_PATH, + COLLECTION_OVERVIEW_PATH, } from '../../routes'; interface EnterpriseSearchAnalyticsPageTemplateProps extends PageTemplateProps { @@ -41,7 +41,7 @@ export const EnterpriseSearchAnalyticsPageTemplate: React.FC< integration: generateEncodedPath(COLLECTION_INTEGRATE_PATH, { name: analyticsName, }), - overview: generateEncodedPath(COLLECTION_VIEW_PATH, { + overview: generateEncodedPath(COLLECTION_OVERVIEW_PATH, { name: analyticsName, }), } diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/index.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/index.tsx index d84e2c33dc7cc..b578ace741182 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/index.tsx @@ -17,12 +17,7 @@ import { VersionMismatchPage } from '../shared/version_mismatch'; import { AnalyticsCollectionView } from './components/analytics_collection_view/analytics_collection_view'; import { AnalyticsOverview } from './components/analytics_overview/analytics_overview'; -import { - ROOT_PATH, - COLLECTION_VIEW_PATH, - COLLECTION_INTEGRATE_PATH, - COLLECTION_EXPLORER_PATH, -} from './routes'; +import { ROOT_PATH, COLLECTION_VIEW_PATH } from './routes'; export const Analytics: React.FC = (props) => { const { enterpriseSearchVersion, kibanaVersion } = props; @@ -40,13 +35,9 @@ export const Analytics: React.FC = (props) => { )} - + - - - - ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/routes.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/routes.ts index 1ae97b9a184b0..7da6b08e13718 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/routes.ts @@ -7,6 +7,7 @@ export const ROOT_PATH = '/'; export const COLLECTIONS_PATH = '/collections'; -export const COLLECTION_VIEW_PATH = `${COLLECTIONS_PATH}/:name/overview`; -export const COLLECTION_INTEGRATE_PATH = `${COLLECTIONS_PATH}/:name/integrate`; -export const COLLECTION_EXPLORER_PATH = `${COLLECTIONS_PATH}/:name/explorer`; +export const COLLECTION_VIEW_PATH = `${COLLECTIONS_PATH}/:name`; +export const COLLECTION_OVERVIEW_PATH = `${COLLECTION_VIEW_PATH}/overview`; +export const COLLECTION_INTEGRATE_PATH = `${COLLECTION_VIEW_PATH}/integrate`; +export const COLLECTION_EXPLORER_PATH = `${COLLECTION_VIEW_PATH}/explorer`; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_api/engine_api.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_api/engine_api.tsx index a439d9aba4950..aad7e84d99084 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_api/engine_api.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_api/engine_api.tsx @@ -23,7 +23,7 @@ import { import { i18n } from '@kbn/i18n'; import { ANALYTICS_PLUGIN } from '../../../../../../common/constants'; -import { COLLECTION_VIEW_PATH } from '../../../../analytics/routes'; +import { COLLECTION_INTEGRATE_PATH } from '../../../../analytics/routes'; import { docLinks } from '../../../../shared/doc_links'; import { generateEncodedPath } from '../../../../shared/encode_path_params'; import { getEnterpriseSearchUrl } from '../../../../shared/enterprise_search_url'; @@ -165,9 +165,8 @@ export const EngineAPI: React.FC = () => { data-telemetry-id="entSearchContent-engines-api-step4-learnHowLink" onClick={() => navigateToUrl( - generateEncodedPath(`${ANALYTICS_PLUGIN.URL}${COLLECTION_VIEW_PATH}`, { + generateEncodedPath(`${ANALYTICS_PLUGIN.URL}${COLLECTION_INTEGRATE_PATH}`, { id: engineName, - section: 'integrate', }), { shouldNotCreateHref: true } ) diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 2d8212d8c7d07..3c6830ba50b0d 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -17,6 +17,10 @@ import { layerTypes } from './layer_types'; import { CollapseFunction } from './expressions'; export type { OriginalColumn } from './expressions/map_to_columns'; +export type { AllowedPartitionOverrides } from '@kbn/expression-partition-vis-plugin/common'; +export type { AllowedSettingsOverrides } from '@kbn/charts-plugin/common'; +export type { AllowedGaugeOverrides } from '@kbn/expression-gauge-plugin/common'; +export type { AllowedXYOverrides } from '@kbn/expression-xy-plugin/common'; export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; diff --git a/x-pack/plugins/lens/public/app_plugin/get_application_user_messages.tsx b/x-pack/plugins/lens/public/app_plugin/get_application_user_messages.tsx index b56736eabe7ac..4a323a4d654b8 100644 --- a/x-pack/plugins/lens/public/app_plugin/get_application_user_messages.tsx +++ b/x-pack/plugins/lens/public/app_plugin/get_application_user_messages.tsx @@ -198,11 +198,7 @@ export const filterAndSortUserMessages = ( return false; } - if (location.id === 'dimensionButton' && location.dimensionId !== dimensionId) { - return false; - } - - return true; + return !(location.id === 'dimensionButton' && location.dimensionId !== dimensionId); }); if (!hasMatch) { @@ -221,11 +217,17 @@ export const filterAndSortUserMessages = ( }; function bySeverity(a: UserMessage, b: UserMessage) { - if (a.severity === 'warning' && b.severity === 'error') { + if (a.severity === b.severity) { + return 0; + } + if (a.severity === 'error') { + return -1; + } + if (b.severity === 'error') { return 1; - } else if (a.severity === 'error' && b.severity === 'warning') { + } + if (a.severity === 'warning') { return -1; - } else { - return 0; } + return 1; } diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx index f8d58b460e4b3..4f7842ee125c4 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx @@ -44,7 +44,7 @@ import { } from '../operations'; import { mergeLayer } from '../state_helpers'; import { getReferencedField, hasField } from '../pure_utils'; -import { fieldIsInvalid } from '../utils'; +import { fieldIsInvalid, getSamplingValue, isSamplingValueEnabled } from '../utils'; import { BucketNestingEditor } from './bucket_nesting_editor'; import type { FormBasedLayer } from '../types'; import { FormatSelector } from './format_selector'; @@ -126,6 +126,10 @@ export function DimensionEditor(props: DimensionEditorProps) { const [temporaryState, setTemporaryState] = useState('none'); const [isHelpOpen, setIsHelpOpen] = useState(false); + // If a layer has sampling disabled, assume the toast has already fired in the past + const [hasRandomSamplingToastFired, setSamplingToastAsFired] = useState( + !isSamplingValueEnabled(state.layers[layerId]) + ); const onHelpClick = () => setIsHelpOpen((prevIsHelpOpen) => !prevIsHelpOpen); const closeHelp = () => setIsHelpOpen(false); @@ -139,6 +143,28 @@ export function DimensionEditor(props: DimensionEditorProps) { [layerId, setState] ); + const fireOrResetRandomSamplingToast = useCallback( + (newLayer: FormBasedLayer) => { + // if prev and current sampling state is different, show a toast to the user + if (isSamplingValueEnabled(state.layers[layerId]) && !isSamplingValueEnabled(newLayer)) { + if (newLayer.sampling != null && newLayer.sampling < 1) { + props.notifications.toasts.add({ + title: i18n.translate('xpack.lens.uiInfo.samplingDisabledTitle', { + defaultMessage: 'Layer sampling changed to 100%', + }), + text: i18n.translate('xpack.lens.uiInfo.samplingDisabledMessage', { + defaultMessage: + 'The use of a maximum or minimum function on a layer requires all documents to be sampled in order to function properly.', + }), + }); + } + } + // reset the flag if the user switches to another supported operation + setSamplingToastAsFired(!hasRandomSamplingToastFired); + }, + [hasRandomSamplingToastFired, layerId, props.notifications.toasts, state.layers] + ); + const setStateWrapper = useCallback( ( setter: @@ -177,10 +203,14 @@ export function DimensionEditor(props: DimensionEditorProps) { } else { outputLayer = typeof setter === 'function' ? setter(prevState.layers[layerId]) : setter; } + const newLayer = adjustColumnReferencesForChangedColumn(outputLayer, columnId); + // Fire an info toast (eventually) on layer update + fireOrResetRandomSamplingToast(newLayer); + return mergeLayer({ state: prevState, layerId, - newLayer: adjustColumnReferencesForChangedColumn(outputLayer, columnId), + newLayer, }); }, { @@ -189,7 +219,7 @@ export function DimensionEditor(props: DimensionEditorProps) { } ); }, - [columnId, layerId, setState, state.layers] + [columnId, fireOrResetRandomSamplingToast, layerId, setState, state.layers] ); const setIsCloseable = (isCloseable: boolean) => { @@ -337,6 +367,9 @@ export function DimensionEditor(props: DimensionEditorProps) { state.layers[layerId], layerType ), + compatibleWithSampling: + getSamplingValue(state.layers[layerId]) === 1 || + (definition.getUnsupportedSettings?.()?.sampling ?? true), }; }); @@ -350,7 +383,7 @@ export function DimensionEditor(props: DimensionEditorProps) { (selectedColumn?.operationType != null && isQuickFunction(selectedColumn?.operationType)); const sideNavItems: EuiListGroupItemProps[] = operationsWithCompatibility.map( - ({ operationType, compatibleWithCurrentField, disabledStatus }) => { + ({ operationType, compatibleWithCurrentField, disabledStatus, compatibleWithSampling }) => { const isActive = Boolean( incompleteOperation === operationType || (!incompleteOperation && selectedColumn && selectedColumn.operationType === operationType) @@ -417,6 +450,26 @@ export function DimensionEditor(props: DimensionEditorProps) { )}
); + } else if (!compatibleWithSampling) { + label = ( + + + {label} + + {shouldDisplayDots && ( + + + + )} + + ); } return { @@ -741,16 +794,16 @@ export function DimensionEditor(props: DimensionEditorProps) { ); }} onChooseFunction={(operationType: string, field?: IndexPatternField) => { - updateLayer( - insertOrReplaceColumn({ - layer, - columnId: referenceId, - op: operationType, - indexPattern: currentIndexPattern, - field, - visualizationGroups: dimensionGroups, - }) - ); + const newLayer = insertOrReplaceColumn({ + layer, + columnId: referenceId, + op: operationType, + indexPattern: currentIndexPattern, + field, + visualizationGroups: dimensionGroups, + }); + fireOrResetRandomSamplingToast(newLayer); + updateLayer(newLayer); }} onChooseField={(choice: FieldChoiceWithOperationType) => { updateLayer( @@ -784,6 +837,7 @@ export function DimensionEditor(props: DimensionEditorProps) { } else { newLayer = setter; } + fireOrResetRandomSamplingToast(newLayer); return updateLayer(adjustColumnReferencesForChangedColumn(newLayer, referenceId)); }} validation={validation} diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.test.tsx index 1646250d75bf5..a4fd8135f00be 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.test.tsx @@ -30,6 +30,7 @@ import { SavedObjectsClientContract, HttpSetup, CoreStart, + NotificationsStart, } from '@kbn/core/public'; import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import { useExistingFieldsReader } from '@kbn/unified-field-list-plugin/public/hooks/use_existing_fields'; @@ -232,6 +233,7 @@ describe('FormBasedDimensionEditor', () => { fieldFormats: fieldFormatsServiceMock.createStartContract(), unifiedSearch: unifiedSearchPluginMock.createStartContract(), dataViews: dataViewPluginMocks.createStartContract(), + notifications: {} as NotificationsStart, data: { fieldFormats: { getType: jest.fn().mockReturnValue({ diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.tsx index c023d3045c331..dd3a66e26f036 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_panel.tsx @@ -6,7 +6,12 @@ */ import React, { memo } from 'react'; -import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from '@kbn/core/public'; +import type { + IUiSettingsClient, + SavedObjectsClientContract, + HttpSetup, + NotificationsStart, +} from '@kbn/core/public'; import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { LayerTypes } from '@kbn/expression-xy-plugin/public'; @@ -38,6 +43,7 @@ export type FormBasedDimensionEditorProps = dataViews: DataViewsPublicPluginStart; uniqueLabel: string; dateRange: DateRange; + notifications: NotificationsStart; }; export const FormBasedDimensionEditorComponent = function FormBasedDimensionPanel( diff --git a/x-pack/plugins/lens/public/datasources/form_based/form_based.test.ts b/x-pack/plugins/lens/public/datasources/form_based/form_based.test.ts index a65d29104360a..52d600a82d6ae 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/form_based.test.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/form_based.test.ts @@ -48,7 +48,7 @@ import { } from './operations'; import { createMockedFullReference } from './operations/mocks'; import { cloneDeep } from 'lodash'; -import { DatatableColumn } from '@kbn/expressions-plugin/common'; +import { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; import { createMockFramePublicAPI } from '../../mocks'; import { filterAndSortUserMessages } from '../../app_plugin/get_application_user_messages'; @@ -194,11 +194,17 @@ describe('IndexPattern Data Source', () => { let FormBasedDatasource: Datasource; beforeEach(() => { + const data = dataPluginMock.createStartContract(); + data.query.timefilter.timefilter.getAbsoluteTime = jest.fn(() => ({ + from: '', + to: '', + })); + FormBasedDatasource = getFormBasedDatasource({ unifiedSearch: unifiedSearchPluginMock.createStartContract(), storage: {} as IStorageWrapper, core: coreMock.createStart(), - data: dataPluginMock.createStartContract(), + data, dataViews: dataViewPluginMocks.createStartContract(), fieldFormats: fieldFormatsServiceMock.createStartContract(), charts: chartPluginMock.createSetupContract(), @@ -3013,6 +3019,22 @@ describe('IndexPattern Data Source', () => { }); describe('#getUserMessages', () => { + function createMockFrameDatasourceAPI({ + activeData, + dataViews, + }: Partial> & { + dataViews?: Partial; + }): FrameDatasourceAPI { + return { + ...createMockFramePublicAPI({ + activeData, + dataViews, + }), + query: { query: '', language: 'kuery' }, + filters: [], + }; + } + describe('error messages', () => { it('should generate error messages for a single layer', () => { (getErrorMessages as jest.Mock).mockClear(); @@ -3029,7 +3051,7 @@ describe('IndexPattern Data Source', () => { }; expect( FormBasedDatasource.getUserMessages(state, { - frame: { dataViews: { indexPatterns } } as unknown as FrameDatasourceAPI, + frame: createMockFrameDatasourceAPI({ dataViews: { indexPatterns } }), setState: () => {}, }) ).toMatchInlineSnapshot(` @@ -3081,7 +3103,7 @@ describe('IndexPattern Data Source', () => { }; expect( FormBasedDatasource.getUserMessages(state, { - frame: { dataViews: { indexPatterns } } as unknown as FrameDatasourceAPI, + frame: createMockFrameDatasourceAPI({ dataViews: { indexPatterns } }), setState: () => {}, }) ).toMatchInlineSnapshot(` @@ -3170,7 +3192,7 @@ describe('IndexPattern Data Source', () => { (getErrorMessages as jest.Mock).mockReturnValueOnce([]); const messages = FormBasedDatasource.getUserMessages(state, { - frame: { dataViews: { indexPatterns } } as unknown as FrameDatasourceAPI, + frame: createMockFrameDatasourceAPI({ dataViews: { indexPatterns } }), setState: () => {}, }); @@ -3208,7 +3230,7 @@ describe('IndexPattern Data Source', () => { ] as ReturnType); const messages = FormBasedDatasource.getUserMessages(state, { - frame: { dataViews: { indexPatterns } } as unknown as FrameDatasourceAPI, + frame: createMockFrameDatasourceAPI({ dataViews: { indexPatterns } }), setState: () => {}, }); @@ -3238,7 +3260,7 @@ describe('IndexPattern Data Source', () => { describe('warning messages', () => { let state: FormBasedPrivateState; - let framePublicAPI: FramePublicAPI; + let framePublicAPI: FrameDatasourceAPI; beforeEach(() => { (getErrorMessages as jest.Mock).mockReturnValueOnce([]); @@ -3320,7 +3342,7 @@ describe('IndexPattern Data Source', () => { currentIndexPatternId: '1', }; - framePublicAPI = { + framePublicAPI = createMockFrameDatasourceAPI({ activeData: { first: { type: 'datatable', @@ -3355,14 +3377,9 @@ describe('IndexPattern Data Source', () => { }, }, dataViews: { - ...createMockFramePublicAPI().dataViews, indexPatterns: expectedIndexPatterns, - indexPatternRefs: Object.values(expectedIndexPatterns).map(({ id, title }) => ({ - id, - title, - })), }, - } as unknown as FramePublicAPI; + }); }); const extractTranslationIdsFromWarnings = (warnings: UserMessage[]) => { @@ -3378,7 +3395,7 @@ describe('IndexPattern Data Source', () => { it('should return mismatched time shifts', () => { const warnings = FormBasedDatasource.getUserMessages!(state, { - frame: framePublicAPI as FrameDatasourceAPI, + frame: framePublicAPI, setState: () => {}, }); @@ -3394,7 +3411,7 @@ describe('IndexPattern Data Source', () => { framePublicAPI.activeData!.first.columns[1].meta.sourceParams!.hasPrecisionError = true; const warnings = FormBasedDatasource.getUserMessages!(state, { - frame: framePublicAPI as FrameDatasourceAPI, + frame: framePublicAPI, setState: () => {}, }); @@ -3407,6 +3424,133 @@ describe('IndexPattern Data Source', () => { `); }); }); + + describe('info messages', () => { + function createLayer( + index: number = 0, + sampling?: number + ): FormBasedPrivateState['layers'][number] { + return { + sampling, + indexPatternId: '1', + columnOrder: [`col-${index}-1`, `col-${index}-2`], + columns: { + [`col-${index}-1`]: { + operationType: 'date_histogram', + params: { + interval: '12h', + }, + label: '', + dataType: 'date', + isBucketed: true, + sourceField: 'timestamp', + } as DateHistogramIndexPatternColumn, + [`col-${index}-2`]: { + operationType: 'count', + label: '', + dataType: 'number', + isBucketed: false, + sourceField: 'records', + }, + }, + }; + } + + function createDatatableForLayer(index: number): Datatable { + return { + type: 'datatable' as const, + rows: [], + columns: [ + { + id: `col-${index}-1`, + name: `col-${index}-1`, + meta: { + type: 'date', + source: 'esaggs', + sourceParams: { + type: 'date_histogram', + params: { + used_interval: '12h', + }, + }, + }, + }, + { + id: `col-${index}-2`, + name: `col-${index}-2`, + meta: { + type: 'number', + }, + }, + ], + }; + } + + beforeEach(() => { + (getErrorMessages as jest.Mock).mockReturnValueOnce([]); + }); + + it.each` + sampling | infoMessages + ${undefined} | ${0} + ${1} | ${0} + ${0.1} | ${1} + `( + 'should return $infoMessages info messages when sampling is set to $sampling', + ({ sampling, infoMessages }) => { + const messages = FormBasedDatasource.getUserMessages!( + { + layers: { + first: createLayer(0, sampling), + }, + currentIndexPatternId: '1', + }, + { + frame: createMockFrameDatasourceAPI({ + activeData: { + first: createDatatableForLayer(0), + }, + dataViews: { + indexPatterns: expectedIndexPatterns, + }, + }), + setState: () => {}, + visualizationInfo: { layers: [] }, + } + ); + expect(messages.filter(({ severity }) => severity === 'info')).toHaveLength(infoMessages); + } + ); + + it('should return a single info message for multiple layers with sampling < 100%', () => { + const state: FormBasedPrivateState = { + layers: { + first: createLayer(0, 0.1), + second: createLayer(1, 0.001), + }, + currentIndexPatternId: '1', + }; + const messages = FormBasedDatasource.getUserMessages!(state, { + frame: createMockFrameDatasourceAPI({ + activeData: { + first: createDatatableForLayer(0), + second: createDatatableForLayer(1), + }, + dataViews: { + indexPatterns: expectedIndexPatterns, + }, + }), + setState: () => {}, + visualizationInfo: { layers: [] }, + }); + const infoMessages = messages.filter(({ severity }) => severity === 'info'); + expect(infoMessages).toHaveLength(1); + const [info] = infoMessages; + if (isFragment(info.longMessage)) { + expect(info.longMessage.props.layers).toHaveLength(2); + } + }); + }); }); describe('#updateStateOnCloseDimension', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx b/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx index 103175bf329f1..4152212e51fea 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx @@ -69,6 +69,7 @@ import { getVisualDefaultsForLayer, isColumnInvalid, cloneLayer, + getNotifiableFeatures, } from './utils'; import { isDraggedDataViewField } from '../../utils'; import { hasField, normalizeOperationDataType } from './pure_utils'; @@ -585,6 +586,7 @@ export function getFormBasedDatasource({ unifiedSearch={unifiedSearch} dataViews={dataViews} uniqueLabel={columnLabelMap[props.columnId]} + notifications={core.notifications} {...props} /> @@ -818,7 +820,7 @@ export function getFormBasedDatasource({ getDatasourceSuggestionsForVisualizeField, getDatasourceSuggestionsForVisualizeCharts, - getUserMessages(state, { frame: frameDatasourceAPI, setState }) { + getUserMessages(state, { frame: frameDatasourceAPI, setState, visualizationInfo }) { if (!state) { return []; } @@ -872,7 +874,9 @@ export function getFormBasedDatasource({ ), ]; - return [...layerErrorMessages, ...dimensionErrorMessages, ...warningMessages]; + const infoMessages = getNotifiableFeatures(state, frameDatasourceAPI, visualizationInfo); + + return layerErrorMessages.concat(dimensionErrorMessages, warningMessages, infoMessages); }, getSearchWarningMessages: (state, warning, request, response) => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/info_badges.tsx b/x-pack/plugins/lens/public/datasources/form_based/info_badges.tsx new file mode 100644 index 0000000000000..c17339945161b --- /dev/null +++ b/x-pack/plugins/lens/public/datasources/form_based/info_badges.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { FormBasedLayer } from '../..'; +import { FramePublicAPI, VisualizationInfo } from '../../types'; +import { getSamplingValue } from './utils'; + +export function ReducedSamplingSectionEntries({ + layers, + visualizationInfo, + dataViews, +}: { + layers: Array<[string, FormBasedLayer]>; + visualizationInfo: VisualizationInfo; + dataViews: FramePublicAPI['dataViews']; +}) { + const { euiTheme } = useEuiTheme(); + return ( + <> + {layers.map(([id, layer], layerIndex) => { + const dataView = dataViews.indexPatterns[layer.indexPatternId]; + const layerTitle = + visualizationInfo.layers.find(({ layerId }) => layerId === id)?.label || + i18n.translate('xpack.lens.indexPattern.samplingPerLayer.fallbackLayerName', { + defaultMessage: 'Data layer', + }); + return ( +
  • + + + {layerTitle} + + + {`${Number(getSamplingValue(layer)) * 100}%`} + + +
  • + ); + })} + + ); +} diff --git a/x-pack/plugins/lens/public/datasources/form_based/layer_settings.tsx b/x-pack/plugins/lens/public/datasources/form_based/layer_settings.tsx index 566d381ba9a4c..38da5475e22e1 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/layer_settings.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/layer_settings.tsx @@ -14,6 +14,8 @@ import { EuiText, EuiLink, EuiSpacer, + useEuiTheme, + EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; @@ -21,107 +23,187 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { DatasourceLayerSettingsProps } from '../../types'; import type { FormBasedPrivateState } from './types'; +import { isSamplingValueEnabled } from './utils'; +import { TooltipWrapper } from '../../shared_components'; -const samplingValue = [0.0001, 0.001, 0.01, 0.1, 1]; - -export function LayerSettingsPanel({ - state, - setState, - layerId, -}: DatasourceLayerSettingsProps) { - const samplingIndex = samplingValue.findIndex((v) => v === state.layers[layerId].sampling); - const currentSamplingIndex = samplingIndex > -1 ? samplingIndex : samplingValue.length - 1; +const samplingValues = [0.00001, 0.0001, 0.001, 0.01, 0.1, 1]; +interface SamplingSliderProps { + values: number[]; + currentValue: number | undefined; + disabled: boolean; + disabledReason: string; + onChange: (value: number) => void; + 'data-test-subj'?: string; +} +/** + * Stub for a shared component + */ +function SamplingSlider({ + values, + currentValue, + disabled, + disabledReason, + onChange, + 'data-test-subj': dataTestSubj, +}: SamplingSliderProps) { + const { euiTheme } = useEuiTheme(); + const samplingIndex = values.findIndex((v) => v === currentValue); + const currentSamplingIndex = samplingIndex > -1 ? samplingIndex : values.length - 1; return ( - - -

    - - - - ), - }} - /> -

    - - } - label={ - <> - {i18n.translate('xpack.lens.xyChart.randomSampling.label', { - defaultMessage: 'Random sampling', - })}{' '} - - - } + - + { - setState({ - ...state, - layers: { - ...state.layers, - [layerId]: { - ...state.layers[layerId], - sampling: samplingValue[Number(e.currentTarget.value)], - }, - }, - }); + onChange(values[Number(e.currentTarget.value)]); }} showInput={false} showRange={false} showTicks step={1} min={0} - max={samplingValue.length - 1} - ticks={samplingValue.map((v, i) => ({ label: `${v * 100}%`, value: i }))} + max={values.length - 1} + ticks={values.map((v, i) => ({ + label: `${v * 100}%`.slice(Number.isInteger(v * 100) ? 0 : 1), + value: i, + }))} /> - + -
    + + ); +} + +export function LayerSettingsPanel({ + state, + setState, + layerId, +}: DatasourceLayerSettingsProps) { + const { euiTheme } = useEuiTheme(); + const isSamplingValueDisabled = !isSamplingValueEnabled(state.layers[layerId]); + const currentValue = isSamplingValueDisabled + ? samplingValues[samplingValues.length - 1] + : state.layers[layerId].sampling; + return ( +
    + +

    + {i18n.translate('xpack.lens.indexPattern.layerSettings.headingData', { + defaultMessage: 'Data', + })} +

    +
    + + +

    + + + + ), + }} + /> +

    + + } + label={ + <> + {i18n.translate('xpack.lens.indexPattern.randomSampling.label', { + defaultMessage: 'Sampling', + })}{' '} + + + + + } + > + { + setState({ + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + sampling: newSamplingValue, + }, + }, + }); + }} + /> +
    +
    ); } diff --git a/x-pack/plugins/lens/public/datasources/form_based/layerpanel.tsx b/x-pack/plugins/lens/public/datasources/form_based/layerpanel.tsx index 349139cd41b27..1de4d0844245f 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/layerpanel.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/layerpanel.tsx @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import { DatasourceLayerPanelProps } from '../../types'; import { FormBasedPrivateState } from './types'; import { ChangeIndexPattern } from '../../shared_components/dataview_picker/dataview_picker'; +import { getSamplingValue } from './utils'; export interface FormBasedLayerPanelProps extends DatasourceLayerPanelProps { state: FormBasedPrivateState; @@ -36,6 +37,7 @@ export function LayerPanel({ isAdhoc: !isPersisted, }; }); + return ( meta.dataType === 'number' && !meta.isBucketed, }, ], + // return false for quick function as the built-in reference will use max + // in formula this check won't be used and the check is performed on the formula AST tree traversal independently + getUnsupportedSettings: () => ({ + sampling: false, + }), getPossibleOperation: (indexPattern) => { if (hasDateField(indexPattern)) { return { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts index 5e73b53bcf8d9..f050604cfc0d0 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts @@ -229,6 +229,8 @@ export interface HelpProps { export type TimeScalingMode = 'disabled' | 'mandatory' | 'optional'; +export type LayerSettingsFeatures = Record<'sampling', boolean>; + export interface AdvancedOption { dataTestSubj: string; inlineElement: React.ReactElement | null; @@ -434,6 +436,10 @@ interface BaseOperationDefinitionProps< * Boolean flag whether the data section extra element passed in from the visualization is handled by the param editor of the operation or whether the datasource general logic should be used. */ handleDataSectionExtra?: boolean; + /** + * When present returns a dictionary of unsupported layer settings + */ + getUnsupportedSettings?: () => LayerSettingsFeatures; } interface BaseBuildColumnArgs { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/metrics.tsx index b672d50232172..deb0c19dc4837 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/metrics.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { EuiSwitch, EuiText } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; import { buildExpressionFunction } from '@kbn/expressions-plugin/public'; -import { OperationDefinition, ParamEditorProps } from '.'; +import { LayerSettingsFeatures, OperationDefinition, ParamEditorProps } from '.'; import { getFormatFromPreviousColumn, getInvalidFieldMessage, @@ -64,6 +64,7 @@ function buildMetricOperation>({ aggConfigParams, documentationDescription, quickFunctionDocumentation, + unsupportedSettings, }: { type: T['operationType']; displayName: string; @@ -76,6 +77,7 @@ function buildMetricOperation>({ aggConfigParams?: Record; documentationDescription?: string; quickFunctionDocumentation?: string; + unsupportedSettings?: LayerSettingsFeatures; }) { const labelLookup = (name: string, column?: BaseIndexPatternColumn) => { const label = ofName(name); @@ -98,6 +100,7 @@ function buildMetricOperation>({ description, input: 'field', timeScalingMode: optionalTimeScaling ? 'optional' : undefined, + getUnsupportedSettings: () => unsupportedSettings, getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, @@ -281,6 +284,7 @@ export const minOperation = buildMetricOperation({ } ), supportsDate: true, + unsupportedSettings: { sampling: false }, }); export const maxOperation = buildMetricOperation({ @@ -304,6 +308,7 @@ export const maxOperation = buildMetricOperation({ } ), supportsDate: true, + unsupportedSettings: { sampling: false }, }); export const averageOperation = buildMetricOperation({ diff --git a/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts b/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts index 9ec654c47868f..8781e63de1cbe 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts @@ -31,6 +31,7 @@ import { isColumnFormatted, isColumnOfType } from './operations/definitions/help import type { IndexPattern, IndexPatternMap } from '../../types'; import { dedupeAggs } from './dedupe_aggs'; import { resolveTimeShift } from './time_shift_utils'; +import { getSamplingValue } from './utils'; export type OriginalColumn = { id: string } & GenericIndexPatternColumn; @@ -415,7 +416,7 @@ function getExpressionForLayer( metricsAtAllLevels: false, partialRows: false, timeFields: allDateHistogramFields, - probability: layer.sampling || 1, + probability: getSamplingValue(layer), samplerSeed: seedrandom(searchSessionId).int32(), }).toAst(), { diff --git a/x-pack/plugins/lens/public/datasources/form_based/utils.tsx b/x-pack/plugins/lens/public/datasources/form_based/utils.tsx index 5837d77c2a92d..d14cf42e3c31b 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/utils.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/utils.tsx @@ -26,7 +26,13 @@ import { import { estypes } from '@elastic/elasticsearch'; import type { DateRange } from '../../../common/types'; -import type { FramePublicAPI, IndexPattern, StateSetter, UserMessage } from '../../types'; +import type { + FramePublicAPI, + IndexPattern, + StateSetter, + UserMessage, + VisualizationInfo, +} from '../../types'; import { renewIDs } from '../../utils'; import type { FormBasedLayer, FormBasedPersistedState, FormBasedPrivateState } from './types'; import type { ReferenceBasedIndexPatternColumn } from './operations/definitions/column_types'; @@ -41,6 +47,8 @@ import { RangeIndexPatternColumn, FormulaIndexPatternColumn, DateHistogramIndexPatternColumn, + MaxIndexPatternColumn, + MinIndexPatternColumn, } from './operations'; import { getInvalidFieldMessage, isColumnOfType } from './operations/definitions/helpers'; @@ -51,6 +59,43 @@ import { supportsRarityRanking } from './operations/definitions/terms'; import { DEFAULT_MAX_DOC_COUNT } from './operations/definitions/terms/constants'; import { getOriginalId } from '../../../common/expressions/datatable/transpose_helpers'; import { isQueryValid } from '../../shared_components'; +import { ReducedSamplingSectionEntries } from './info_badges'; + +function isMinOrMaxColumn( + column?: GenericIndexPatternColumn +): column is MaxIndexPatternColumn | MinIndexPatternColumn { + if (!column) { + return false; + } + return ( + isColumnOfType('max', column) || + isColumnOfType('min', column) + ); +} + +function isReferenceColumn( + column: GenericIndexPatternColumn +): column is ReferenceBasedIndexPatternColumn { + return 'references' in column; +} + +export function isSamplingValueEnabled(layer: FormBasedLayer) { + // Do not use columnOrder here as it needs to check also inside formulas columns + return !Object.values(layer.columns).some( + (column) => + isMinOrMaxColumn(column) || + (isReferenceColumn(column) && isMinOrMaxColumn(layer.columns[column.references[0]])) + ); +} + +/** + * Centralized logic to get the actual random sampling value for a layer + * @param layer + * @returns + */ +export function getSamplingValue(layer: FormBasedLayer) { + return isSamplingValueEnabled(layer) ? layer.sampling ?? 1 : 1; +} export function isColumnInvalid( layer: FormBasedLayer, @@ -449,6 +494,40 @@ export function getVisualDefaultsForLayer(layer: FormBasedLayer) { ); } +export function getNotifiableFeatures( + state: FormBasedPrivateState, + frame: FramePublicAPI, + visualizationInfo?: VisualizationInfo +): UserMessage[] { + if (!visualizationInfo) { + return []; + } + const layersWithCustomSamplingValues = Object.entries(state.layers).filter( + ([, layer]) => getSamplingValue(layer) !== 1 + ); + if (!layersWithCustomSamplingValues.length) { + return []; + } + return [ + { + uniqueId: 'random_sampling_info', + severity: 'info', + fixableInEditor: false, + shortMessage: i18n.translate('xpack.lens.indexPattern.samplingPerLayer', { + defaultMessage: 'Layers with reduced sampling', + }), + longMessage: ( + + ), + displayLocations: [{ id: 'embeddableBadge' }], + }, + ]; +} + /** * Some utilities to extract queries/filters from specific column types */ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 3046df112dc4f..17df0a527c145 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -51,9 +51,6 @@ import { onDropForVisualization, shouldRemoveSource } from './buttons/drop_targe import { getSharedActions } from './layer_actions/layer_actions'; import { FlyoutContainer } from './flyout_container'; -// hide the random sampling settings from the UI -const DISPLAY_RANDOM_SAMPLING_SETTINGS = false; - const initialActiveDimensionState = { isNew: false, }; @@ -350,7 +347,7 @@ export function LayerPanel( frame: props.framePublicAPI, }) && activeVisualization.renderLayerSettings) || - (layerDatasource?.renderLayerSettings && DISPLAY_RANDOM_SAMPLING_SETTINGS) + layerDatasource?.renderLayerSettings ), openLayerSettings: () => setPanelSettingsOpen(true), onCloneLayer, @@ -684,8 +681,8 @@ export function LayerPanel( }} >
    -
    - {layerDatasource?.renderLayerSettings && DISPLAY_RANDOM_SAMPLING_SETTINGS && ( +
    + {layerDatasource?.renderLayerSettings && ( <> { const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -87,7 +85,7 @@ export const MessageList = ({ > {errorCount > 0 && ( <> - + {errorCount} )} @@ -95,7 +93,6 @@ export const MessageList = ({ <> { expect(test.initializeSavedVis).toHaveBeenCalledTimes(2); expect(test.expressionRenderer).toHaveBeenCalledTimes(2); }); + + it('should pass over the overrides as variables', async () => { + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + data: dataMock, + expressionRenderer, + coreStart: {} as CoreStart, + basePath, + dataViews: {} as DataViewsContract, + capabilities: { + canSaveDashboards: true, + canSaveVisualizations: true, + discover: {}, + navLinks: {}, + }, + inspector: inspectorPluginMock.createStartContract(), + getTrigger, + theme: themeServiceMock.createStartContract(), + visualizationMap: defaultVisualizationMap, + datasourceMap: defaultDatasourceMap, + injectFilterReferences: jest.fn(mockInjectFilterReferences), + documentToExpression: () => + Promise.resolve({ + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + indexPatterns: {}, + indexPatternRefs: [], + }), + uiSettings: { get: () => undefined } as unknown as IUiSettingsClient, + }, + { + timeRange: { + from: 'now-15m', + to: 'now', + }, + overrides: { + settings: { + onBrushEnd: 'ignore', + }, + }, + } as LensEmbeddableInput + ); + embeddable.render(mountpoint); + + // wait one tick to give embeddable time to initialize + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(expressionRenderer).toHaveBeenCalledTimes(1); + expect(expressionRenderer.mock.calls[0][0]!.variables).toEqual( + expect.objectContaining({ + overrides: { + settings: { + onBrushEnd: 'ignore', + }, + }, + }) + ); + }); }); diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 21aae0b8a4d44..519db972ca855 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { uniqBy } from 'lodash'; +import { partition, uniqBy } from 'lodash'; import React from 'react'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; @@ -75,8 +75,7 @@ import { } from '@kbn/charts-plugin/public'; import { DataViewSpec } from '@kbn/data-views-plugin/common'; import { FormattedMessage, I18nProvider } from '@kbn/i18n-react'; -import { EuiEmptyPrompt } from '@elastic/eui'; -import { useEuiFontSize, useEuiTheme } from '@elastic/eui'; +import { useEuiFontSize, useEuiTheme, EuiEmptyPrompt } from '@elastic/eui'; import { getExecutionContextEvents, trackUiCounterEvents } from '../lens_ui_telemetry'; import { Document } from '../persistence'; import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper'; @@ -102,6 +101,12 @@ import { UserMessagesDisplayLocationId, } from '../types'; +import type { + AllowedPartitionOverrides, + AllowedSettingsOverrides, + AllowedGaugeOverrides, + AllowedXYOverrides, +} from '../../common/types'; import { getEditPath, DOC_TYPE } from '../../common/constants'; import { LensAttributeService } from '../lens_attribute_service'; import type { TableInspectorAdapter } from '../editor_frame_service/types'; @@ -120,6 +125,7 @@ import { getApplicationUserMessages, } from '../app_plugin/get_application_user_messages'; import { MessageList } from '../editor_frame_service/editor_frame/workspace_panel/message_list'; +import { EmbeddableFeatureBadge } from './embeddable_info_badges'; export type LensSavedObjectAttributes = Omit; @@ -150,6 +156,18 @@ interface LensBaseEmbeddableInput extends EmbeddableInput { export type LensByValueInput = { attributes: LensSavedObjectAttributes; + /** + * Overrides can tweak the style of the final embeddable and are executed at the end of the Lens rendering pipeline. + * Each visualization type offers various type of overrides, per component (i.e. 'setting', 'axisX', 'partition', etc...) + * + * While it is not possible to pass function/callback/handlers to the renderer, it is possible to overwrite + * the current behaviour by passing the "ignore" string to the override prop (i.e. onBrushEnd: "ignore" to stop brushing) + */ + overrides?: + | AllowedSettingsOverrides + | AllowedXYOverrides + | AllowedPartitionOverrides + | AllowedGaugeOverrides; } & LensBaseEmbeddableInput; export type LensByReferenceInput = SavedObjectEmbeddableInput & LensBaseEmbeddableInput; @@ -329,13 +347,15 @@ const EmbeddableMessagesPopover = ({ messages }: { messages: UserMessage[] }) => const { euiTheme } = useEuiTheme(); const xsFontSize = useEuiFontSize('xs').fontSize; + if (!messages.length) { + return null; + } + return ( * { @@ -469,8 +489,18 @@ export class Embeddable const attributesOrSavedObjectId$ = input$.pipe( distinctUntilChanged((a, b) => fastIsEqual( - ['attributes' in a && a.attributes, 'savedObjectId' in a && a.savedObjectId], - ['attributes' in b && b.attributes, 'savedObjectId' in b && b.savedObjectId] + [ + 'attributes' in a && a.attributes, + 'savedObjectId' in a && a.savedObjectId, + 'overrides' in a && a.overrides, + 'disableTriggers' in a && a.disableTriggers, + ], + [ + 'attributes' in b && b.attributes, + 'savedObjectId' in b && b.savedObjectId, + 'overrides' in b && b.overrides, + 'disableTriggers' in b && b.disableTriggers, + ] ) ), skip(1), @@ -602,6 +632,9 @@ export class Embeddable ...(this.activeDatasource?.getUserMessages(this.activeDatasourceState, { setState: () => {}, frame: frameDatasourceAPI, + visualizationInfo: this.activeVisualization?.getVisualizationInfo?.( + this.activeVisualizationState + ), }) ?? []), ...(this.activeVisualization?.getUserMessages?.(this.activeVisualizationState, { frame: frameDatasourceAPI, @@ -875,6 +908,7 @@ export class Embeddable variables={{ embeddableTitle: this.getTitle(), ...(input.palette ? { theme: { palette: input.palette } } : {}), + ...('overrides' in input ? { overrides: input.overrides } : {}), }} searchSessionId={this.getInput().searchSessionId} handleEvent={this.handleEvent} @@ -952,11 +986,16 @@ export class Embeddable */ private renderBadgeMessages = () => { const messages = this.getUserMessages('embeddableBadge'); + const [warningOrErrorMessages, infoMessages] = partition( + messages, + ({ severity }) => severity !== 'info' + ); - if (messages.length && this.badgeDomNode) { + if (this.badgeDomNode) { render( - + + , this.badgeDomNode ); diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx index c53f9f5543ff7..943e87c9c00c2 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx @@ -24,9 +24,16 @@ import type { LensByReferenceInput, LensByValueInput } from './embeddable'; import type { Document } from '../persistence'; import type { FormBasedPersistedState } from '../datasources/form_based/types'; import type { XYState } from '../visualizations/xy/types'; -import type { PieVisualizationState, LegacyMetricState } from '../../common/types'; +import type { + PieVisualizationState, + LegacyMetricState, + AllowedGaugeOverrides, + AllowedPartitionOverrides, + AllowedSettingsOverrides, + AllowedXYOverrides, +} from '../../common/types'; import type { DatatableVisualizationState } from '../visualizations/datatable/visualization'; -import type { MetricVisualizationState } from '../visualizations/metric/visualization'; +import type { MetricVisualizationState } from '../visualizations/metric/types'; import type { HeatmapVisualizationState } from '../visualizations/heatmap/types'; import type { GaugeVisualizationState } from '../visualizations/gauge/constants'; @@ -47,16 +54,28 @@ type LensAttributes = Omit< * Type-safe variant of by value embeddable input for Lens. * This can be used to hardcode certain Lens chart configurations within another app. */ -export type TypedLensByValueInput = Omit & { +export type TypedLensByValueInput = Omit & { attributes: | LensAttributes<'lnsXY', XYState> | LensAttributes<'lnsPie', PieVisualizationState> + | LensAttributes<'lnsHeatmap', HeatmapVisualizationState> + | LensAttributes<'lnsGauge', GaugeVisualizationState> | LensAttributes<'lnsDatatable', DatatableVisualizationState> | LensAttributes<'lnsLegacyMetric', LegacyMetricState> | LensAttributes<'lnsMetric', MetricVisualizationState> - | LensAttributes<'lnsHeatmap', HeatmapVisualizationState> - | LensAttributes<'lnsGauge', GaugeVisualizationState> | LensAttributes; + + /** + * Overrides can tweak the style of the final embeddable and are executed at the end of the Lens rendering pipeline. + * XY charts offer an override of the Settings ('settings') and Axis ('axisX', 'axisLeft', 'axisRight') components. + * While it is not possible to pass function/callback/handlers to the renderer, it is possible to stop them by passing the + * "ignore" string as override value (i.e. onBrushEnd: "ignore") + */ + overrides?: + | AllowedSettingsOverrides + | AllowedXYOverrides + | AllowedPartitionOverrides + | AllowedGaugeOverrides; }; export type EmbeddableComponentProps = (TypedLensByValueInput | LensByReferenceInput) & { diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_info_badges.scss b/x-pack/plugins/lens/public/embeddable/embeddable_info_badges.scss new file mode 100644 index 0000000000000..d7f5840e4ff17 --- /dev/null +++ b/x-pack/plugins/lens/public/embeddable/embeddable_info_badges.scss @@ -0,0 +1,5 @@ + +.lnsEmbeddablePanelFeatureList { + @include euiYScroll; + max-height: $euiSize * 20; +} diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_info_badges.tsx b/x-pack/plugins/lens/public/embeddable/embeddable_info_badges.tsx new file mode 100644 index 0000000000000..d47892b0b3aa0 --- /dev/null +++ b/x-pack/plugins/lens/public/embeddable/embeddable_info_badges.tsx @@ -0,0 +1,90 @@ +/* + * 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 { + EuiPopover, + EuiToolTip, + EuiHorizontalRule, + EuiTitle, + useEuiTheme, + EuiButtonEmpty, + useEuiFontSize, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useState } from 'react'; +import type { UserMessage } from '../types'; +import './embeddable_info_badges.scss'; + +export const EmbeddableFeatureBadge = ({ messages }: { messages: UserMessage[] }) => { + const { euiTheme } = useEuiTheme(); + const xsFontSize = useEuiFontSize('xs').fontSize; + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen); + const closePopover = () => setIsPopoverOpen(false); + if (!messages.length) { + return null; + } + const iconTitle = i18n.translate('xpack.lens.embeddable.featureBadge.iconDescription', { + defaultMessage: `{count} visualization {count, plural, one {modifier} other {modifiers}}`, + values: { + count: messages.length, + }, + }); + return ( + + + {messages.length} + + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + > +
    + {messages.map(({ shortMessage, longMessage }, index) => ( + + ))} +
    +
    + ); +}; diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index c240623e706c5..65d5ca12df094 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -44,6 +44,7 @@ export type { export type { DatatableVisualizationState } from './visualizations/datatable/visualization'; export type { HeatmapVisualizationState } from './visualizations/heatmap/types'; export type { GaugeVisualizationState } from './visualizations/gauge/constants'; +export type { MetricVisualizationState } from './visualizations/metric/types'; export type { FormBasedPersistedState, PersistedIndexPatternLayer, diff --git a/x-pack/plugins/lens/public/mocks/index.ts b/x-pack/plugins/lens/public/mocks/index.ts index 4cfdfbad661af..5cd62b5427cb4 100644 --- a/x-pack/plugins/lens/public/mocks/index.ts +++ b/x-pack/plugins/lens/public/mocks/index.ts @@ -35,7 +35,9 @@ export const createMockFramePublicAPI = ({ dateRange, dataViews, activeData, -}: Partial = {}): FrameMock => ({ +}: Partial> & { + dataViews?: Partial; +} = {}): FrameMock => ({ datasourceLayers: datasourceLayers ?? {}, dateRange: dateRange ?? { fromDate: '2022-03-17T08:25:00.000Z', diff --git a/x-pack/plugins/lens/public/shared_components/dataview_picker/dataview_picker.tsx b/x-pack/plugins/lens/public/shared_components/dataview_picker/dataview_picker.tsx index 35c0215a35c53..6467cbcb58494 100644 --- a/x-pack/plugins/lens/public/shared_components/dataview_picker/dataview_picker.tsx +++ b/x-pack/plugins/lens/public/shared_components/dataview_picker/dataview_picker.tsx @@ -7,17 +7,110 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { EuiPopover, EuiPopoverTitle, EuiSelectableProps } from '@elastic/eui'; -import { ToolbarButton, ToolbarButtonProps } from '@kbn/kibana-react-plugin/public'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, + EuiSelectableProps, + EuiTextColor, + EuiToolTip, + useEuiTheme, +} from '@elastic/eui'; import { DataViewsList } from '@kbn/unified-search-plugin/public'; -import { IndexPatternRef } from '../../types'; +import { css } from '@emotion/react'; +import { type IndexPatternRef } from '../../types'; +import { type ToolbarButtonProps, ToolbarButton } from './toolbar_button'; +import { RandomSamplingIcon } from './sampling_icon'; export type ChangeIndexPatternTriggerProps = ToolbarButtonProps & { label: string; title?: string; isDisabled?: boolean; + samplingValue?: number; }; +function TriggerButton({ + label, + title, + togglePopover, + isMissingCurrent, + samplingValue, + ...rest +}: ChangeIndexPatternTriggerProps & + ToolbarButtonProps & { + togglePopover: () => void; + isMissingCurrent?: boolean; + }) { + const { euiTheme } = useEuiTheme(); + // be careful to only add color with a value, otherwise it will fallbacks to "primary" + const colorProp = isMissingCurrent + ? { + color: 'danger' as const, + } + : {}; + const content = + samplingValue != null && samplingValue !== 1 ? ( + + + {label} + + + + + + + + + + {samplingValue * 100}% + + + + + + + ) : ( + label + ); + return ( + togglePopover()} + fullWidth + {...colorProp} + {...rest} + textProps={{ style: { width: '100%' } }} + > + {content} + + ); +} + export function ChangeIndexPattern({ indexPatternRefs, isMissingCurrent, @@ -35,33 +128,17 @@ export function ChangeIndexPattern({ }) { const [isPopoverOpen, setPopoverIsOpen] = useState(false); - // be careful to only add color with a value, otherwise it will fallbacks to "primary" - const colorProp = isMissingCurrent - ? { - color: 'danger' as const, - } - : {}; - - const createTrigger = function () { - const { label, title, ...rest } = trigger; - return ( - setPopoverIsOpen(!isPopoverOpen)} - fullWidth - {...colorProp} - {...rest} - > - {label} - - ); - }; - return ( <> setPopoverIsOpen(!isPopoverOpen)} + /> + } panelProps={{ ['data-test-subj']: 'lnsChangeIndexPatternPopover', }} diff --git a/x-pack/plugins/lens/public/shared_components/dataview_picker/sampling_icon.tsx b/x-pack/plugins/lens/public/shared_components/dataview_picker/sampling_icon.tsx new file mode 100644 index 0000000000000..8241c1d86a5fc --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dataview_picker/sampling_icon.tsx @@ -0,0 +1,56 @@ +/* + * 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'; + +interface CustomProps { + title?: string; + titleId?: string; +} + +export function RandomSamplingIcon({ + title, + titleId, + ...props +}: React.SVGProps & CustomProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/dataview_picker/toolbar_button.scss b/x-pack/plugins/lens/public/shared_components/dataview_picker/toolbar_button.scss new file mode 100644 index 0000000000000..cbf6d85349446 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dataview_picker/toolbar_button.scss @@ -0,0 +1,61 @@ +.kbnToolbarButton { + line-height: $euiButtonHeight; // Keeps alignment of text and chart icon + + // Override background color for non-disabled buttons + &:not(:disabled) { + background-color: $euiColorEmptyShade; + } + + // todo: once issue https://github.com/elastic/eui/issues/4730 is merged, this code might be safe to remove + // Some toolbar buttons are just icons, but EuiButton comes with margin and min-width that need to be removed + min-width: 0; + border-width: $euiBorderWidthThin; + border-style: solid; + border-color: $euiBorderColor; // Lighten the border color for all states + + .kbnToolbarButton__text > svg { + margin-top: -1px; // Just some weird alignment issue when icon is the child not the `iconType` + } + + .kbnToolbarButton__text:empty { + margin: 0; + } + + // Toolbar buttons don't look good with centered text when fullWidth + &[class*='fullWidth'] { + text-align: left; + + .kbnToolbarButton__content { + justify-content: space-between; + } + } +} + +.kbnToolbarButton--groupLeft { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.kbnToolbarButton--groupCenter { + border-radius: 0; + border-left: none; +} + +.kbnToolbarButton--groupRight { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: none; +} + +.kbnToolbarButton--bold { + font-weight: $euiFontWeightBold; +} + +.kbnToolbarButton--normal { + font-weight: $euiFontWeightRegular; +} + +.kbnToolbarButton--s { + box-shadow: none !important; // sass-lint:disable-line no-important + font-size: $euiFontSizeS; +} diff --git a/x-pack/plugins/lens/public/shared_components/dataview_picker/toolbar_button.tsx b/x-pack/plugins/lens/public/shared_components/dataview_picker/toolbar_button.tsx new file mode 100644 index 0000000000000..55b5d1ce060fe --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dataview_picker/toolbar_button.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import './toolbar_button.scss'; +import React from 'react'; +import classNames from 'classnames'; +import { EuiButton, PropsOf, EuiButtonProps } from '@elastic/eui'; + +const groupPositionToClassMap = { + none: null, + left: 'kbnToolbarButton--groupLeft', + center: 'kbnToolbarButton--groupCenter', + right: 'kbnToolbarButton--groupRight', +}; + +type ButtonPositions = keyof typeof groupPositionToClassMap; +export const POSITIONS = Object.keys(groupPositionToClassMap) as ButtonPositions[]; + +type Weights = 'normal' | 'bold'; +export const WEIGHTS = ['normal', 'bold'] as Weights[]; + +export const TOOLBAR_BUTTON_SIZES: Array = ['s', 'm']; + +export type ToolbarButtonProps = PropsOf & { + /** + * Determines prominence + */ + fontWeight?: Weights; + /** + * Smaller buttons also remove extra shadow for less prominence + */ + size?: EuiButtonProps['size']; + /** + * Determines if the button will have a down arrow or not + */ + hasArrow?: boolean; + /** + * Adjusts the borders for groupings + */ + groupPosition?: ButtonPositions; + dataTestSubj?: string; + textProps?: EuiButtonProps['textProps']; +}; + +export const ToolbarButton: React.FunctionComponent = ({ + children, + className, + fontWeight = 'normal', + size = 'm', + hasArrow = true, + groupPosition = 'none', + dataTestSubj = '', + textProps, + ...rest +}) => { + const classes = classNames( + 'kbnToolbarButton', + groupPositionToClassMap[groupPosition], + [`kbnToolbarButton--${fontWeight}`, `kbnToolbarButton--${size}`], + className + ); + + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index c7deaeac283e9..4b3c69e8ec825 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -282,7 +282,7 @@ export type UserMessagesDisplayLocationId = UserMessageDisplayLocation['id']; export interface UserMessage { uniqueId?: string; - severity: 'error' | 'warning'; + severity: 'error' | 'warning' | 'info'; shortMessage: string; longMessage: React.ReactNode | string; fixableInEditor: boolean; @@ -475,6 +475,7 @@ export interface Datasource { deps: { frame: FrameDatasourceAPI; setState: StateSetter; + visualizationInfo?: VisualizationInfo; } ) => UserMessage[]; diff --git a/x-pack/plugins/lens/public/visualization_container.scss b/x-pack/plugins/lens/public/visualization_container.scss index ea4a8bdbce994..cdadb22feb634 100644 --- a/x-pack/plugins/lens/public/visualization_container.scss +++ b/x-pack/plugins/lens/public/visualization_container.scss @@ -25,4 +25,10 @@ align-items: center; justify-content: center; overflow: auto; +} + +// Make the visualization modifiers icon appear only on panel hover +.embPanel__content:hover .lnsEmbeddablePanelFeatureList_button { + color: $euiTextColor; + transition: color $euiAnimSpeedSlow; } \ No newline at end of file diff --git a/x-pack/plugins/lens/public/visualizations/gauge/constants.ts b/x-pack/plugins/lens/public/visualizations/gauge/constants.ts index 0a8e11f82ba08..bd7dc76d05c5a 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/constants.ts +++ b/x-pack/plugins/lens/public/visualizations/gauge/constants.ts @@ -6,7 +6,7 @@ */ import type { GaugeState as GaugeStateOriginal } from '@kbn/expression-gauge-plugin/common'; -import { LayerType } from '../../../common/types'; +import type { LayerType } from '../../../common/types'; export const LENS_GAUGE_ID = 'lnsGauge'; diff --git a/x-pack/plugins/lens/public/visualizations/metric/types.ts b/x-pack/plugins/lens/public/visualizations/metric/types.ts new file mode 100644 index 0000000000000..d25a4b1b33396 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { LayoutDirection } from '@elastic/charts'; +import type { PaletteOutput, CustomPaletteParams } from '@kbn/coloring'; +import type { CollapseFunction } from '@kbn/visualizations-plugin/common'; +import type { LayerType } from '../../../common/types'; + +export interface MetricVisualizationState { + layerId: string; + layerType: LayerType; + metricAccessor?: string; + secondaryMetricAccessor?: string; + maxAccessor?: string; + breakdownByAccessor?: string; + // the dimensions can optionally be single numbers + // computed by collapsing all rows + collapseFn?: CollapseFunction; + subtitle?: string; + secondaryPrefix?: string; + progressDirection?: LayoutDirection; + showBar?: boolean; + color?: string; + palette?: PaletteOutput; + maxCols?: number; + + trendlineLayerId?: string; + trendlineLayerType?: LayerType; + trendlineTimeAccessor?: string; + trendlineMetricAccessor?: string; + trendlineSecondaryMetricAccessor?: string; + trendlineBreakdownByAccessor?: string; +} diff --git a/x-pack/plugins/lens/public/visualizations/xy/info_badges.tsx b/x-pack/plugins/lens/public/visualizations/xy/info_badges.tsx new file mode 100644 index 0000000000000..d3c0ac1653ad5 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/xy/info_badges.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { FramePublicAPI, VisualizationInfo } from '../../types'; +import { XYAnnotationLayerConfig } from './types'; + +export function IgnoredGlobalFiltersEntries({ + layers, + visualizationInfo, + dataViews, +}: { + layers: XYAnnotationLayerConfig[]; + visualizationInfo: VisualizationInfo; + dataViews: FramePublicAPI['dataViews']; +}) { + const { euiTheme } = useEuiTheme(); + return ( + <> + {layers.map((layer, layerIndex) => { + const dataView = dataViews.indexPatterns[layer.indexPatternId]; + const layerTitle = + visualizationInfo.layers.find(({ layerId, label }) => layerId === layer.layerId)?.label || + i18n.translate('xpack.lens.xyChart.layerAnnotationsLabel', { + defaultMessage: 'Annotations', + }); + return ( +
  • + + + {layerTitle} + + +
  • + ); + })} + + ); +} diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts index 9e7f5fcdb2bc2..159014b043aec 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts @@ -40,6 +40,7 @@ import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks import { KEEP_GLOBAL_FILTERS_ACTION_ID } from './annotations/actions'; import { layerTypes, Visualization } from '../..'; +const DATE_HISTORGRAM_COLUMN_ID = 'date_histogram_column'; const exampleAnnotation: EventAnnotationConfig = { id: 'an1', type: 'manual', @@ -2623,8 +2624,6 @@ describe('xy_visualization', () => { }); describe('Annotation layers', () => { - const DATE_HISTORGRAM_COLUMN_ID = 'date_histogram_column'; - function createStateWithAnnotationProps(annotation: Partial) { return { layers: [ @@ -2693,7 +2692,7 @@ describe('xy_visualization', () => { layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], - ignoreGlobalFilters: true, + ignoreGlobalFilters: false, }, ], }; @@ -2879,6 +2878,57 @@ describe('xy_visualization', () => { `); }); }); + + describe('info', () => { + function getFrameMock() { + const datasourceMock = createMockDatasource('testDatasource'); + datasourceMock.publicAPIMock.getOperationForColumnId.mockImplementation((id) => + id === DATE_HISTORGRAM_COLUMN_ID + ? ({ + label: DATE_HISTORGRAM_COLUMN_ID, + dataType: 'date', + scale: 'interval', + } as OperationDescriptor) + : ({ + dataType: 'number', + label: 'MyOperation', + } as OperationDescriptor) + ); + + return createMockFramePublicAPI({ + datasourceLayers: { first: datasourceMock.publicAPIMock }, + dataViews: createMockDataViewsState({ + indexPatterns: { first: createMockedIndexPattern() }, + }), + }); + } + + it('should return an info message if annotation layer is ignoring the global filters', () => { + const initialState = exampleState(); + const state: State = { + ...initialState, + layers: [ + ...initialState.layers, + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, + indexPatternId: 'myIndexPattern', + }, + ], + }; + expect(xyVisualization.getUserMessages!(state, { frame: getFrameMock() })).toContainEqual( + expect.objectContaining({ + displayLocations: [{ id: 'embeddableBadge' }], + fixableInEditor: false, + severity: 'info', + shortMessage: 'Layers ignoring global filters', + uniqueId: 'ignoring-global-filters-layers', + }) + ); + }); + }); }); describe('#getUniqueLabels', () => { diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx index 44c19e5cd4467..274b82e5a1cf4 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx @@ -108,6 +108,7 @@ import { IGNORE_GLOBAL_FILTERS_ACTION_ID, KEEP_GLOBAL_FILTERS_ACTION_ID, } from './annotations/actions'; +import { IgnoredGlobalFiltersEntries } from './info_badges'; const XY_ID = 'lnsXY'; export const getXyVisualization = ({ @@ -876,7 +877,9 @@ export const getXyVisualization = ({ ); } - return [...errors, ...warnings]; + const info = getNotifiableFeatures(state, frame.dataViews); + + return errors.concat(warnings, info); }, getUniqueLabels(state) { @@ -919,88 +922,7 @@ export const getXyVisualization = ({ return suggestion; }, - getVisualizationInfo(state: XYState) { - const isHorizontal = isHorizontalChart(state.layers); - const visualizationLayersInfo = state.layers.map((layer) => { - const dimensions = []; - let chartType: SeriesType | undefined; - let icon; - let label; - if (isDataLayer(layer)) { - chartType = layer.seriesType; - const layerVisType = visualizationTypes.find((visType) => visType.id === chartType); - icon = layerVisType?.icon; - label = layerVisType?.fullLabel || layerVisType?.label; - if (layer.xAccessor) { - dimensions.push({ - name: getAxisName('x', { isHorizontal }), - id: layer.xAccessor, - dimensionType: 'x', - }); - } - if (layer.accessors && layer.accessors.length) { - layer.accessors.forEach((accessor) => { - dimensions.push({ - name: getAxisName('y', { isHorizontal }), - id: accessor, - dimensionType: 'y', - }); - }); - } - if (layer.splitAccessor) { - dimensions.push({ - name: i18n.translate('xpack.lens.xyChart.splitSeries', { - defaultMessage: 'Breakdown', - }), - dimensionType: 'breakdown', - id: layer.splitAccessor, - }); - } - } - if (isReferenceLayer(layer) && layer.accessors && layer.accessors.length) { - layer.accessors.forEach((accessor) => { - dimensions.push({ - name: i18n.translate('xpack.lens.xyChart.layerReferenceLine', { - defaultMessage: 'Reference line', - }), - dimensionType: 'reference_line', - id: accessor, - }); - }); - label = i18n.translate('xpack.lens.xyChart.layerReferenceLineLabel', { - defaultMessage: 'Reference lines', - }); - icon = IconChartBarReferenceLine; - } - if (isAnnotationsLayer(layer) && layer.annotations && layer.annotations.length) { - layer.annotations.forEach((annotation) => { - dimensions.push({ - name: i18n.translate('xpack.lens.xyChart.layerAnnotation', { - defaultMessage: 'Annotation', - }), - dimensionType: 'annotation', - id: annotation.id, - }); - }); - label = i18n.translate('xpack.lens.xyChart.layerAnnotationsLabel', { - defaultMessage: 'Annotations', - }); - icon = IconChartBarAnnotations; - } - - return { - layerId: layer.layerId, - layerType: layer.layerType, - chartType, - icon, - label, - dimensions, - }; - }); - return { - layers: visualizationLayersInfo, - }; - }, + getVisualizationInfo, }); const getMappedAccessors = ({ @@ -1040,3 +962,118 @@ const getMappedAccessors = ({ } return mappedAccessors; }; + +function getVisualizationInfo(state: XYState) { + const isHorizontal = isHorizontalChart(state.layers); + const visualizationLayersInfo = state.layers.map((layer) => { + const dimensions = []; + let chartType: SeriesType | undefined; + let icon; + let label; + if (isDataLayer(layer)) { + chartType = layer.seriesType; + const layerVisType = visualizationTypes.find((visType) => visType.id === chartType); + icon = layerVisType?.icon; + label = layerVisType?.fullLabel || layerVisType?.label; + if (layer.xAccessor) { + dimensions.push({ + name: getAxisName('x', { isHorizontal }), + id: layer.xAccessor, + dimensionType: 'x', + }); + } + if (layer.accessors && layer.accessors.length) { + layer.accessors.forEach((accessor) => { + dimensions.push({ + name: getAxisName('y', { isHorizontal }), + id: accessor, + dimensionType: 'y', + }); + }); + } + if (layer.splitAccessor) { + dimensions.push({ + name: i18n.translate('xpack.lens.xyChart.splitSeries', { + defaultMessage: 'Breakdown', + }), + dimensionType: 'breakdown', + id: layer.splitAccessor, + }); + } + } + if (isReferenceLayer(layer) && layer.accessors && layer.accessors.length) { + layer.accessors.forEach((accessor) => { + dimensions.push({ + name: i18n.translate('xpack.lens.xyChart.layerReferenceLine', { + defaultMessage: 'Reference line', + }), + dimensionType: 'reference_line', + id: accessor, + }); + }); + label = i18n.translate('xpack.lens.xyChart.layerReferenceLineLabel', { + defaultMessage: 'Reference lines', + }); + icon = IconChartBarReferenceLine; + } + if (isAnnotationsLayer(layer) && layer.annotations && layer.annotations.length) { + layer.annotations.forEach((annotation) => { + dimensions.push({ + name: i18n.translate('xpack.lens.xyChart.layerAnnotation', { + defaultMessage: 'Annotation', + }), + dimensionType: 'annotation', + id: annotation.id, + }); + }); + label = i18n.translate('xpack.lens.xyChart.layerAnnotationsLabel', { + defaultMessage: 'Annotations', + }); + icon = IconChartBarAnnotations; + } + + return { + layerId: layer.layerId, + layerType: layer.layerType, + chartType, + icon, + label, + dimensions, + }; + }); + return { + layers: visualizationLayersInfo, + }; +} + +function getNotifiableFeatures( + state: XYState, + dataViews: FramePublicAPI['dataViews'] +): UserMessage[] { + const annotationsWithIgnoreFlag = getAnnotationsLayers(state.layers).filter( + (layer) => layer.ignoreGlobalFilters + ); + if (!annotationsWithIgnoreFlag.length) { + return []; + } + const visualizationInfo = getVisualizationInfo(state); + + return [ + { + uniqueId: 'ignoring-global-filters-layers', + severity: 'info', + fixableInEditor: false, + shortMessage: i18n.translate('xpack.lens.xyChart.layerAnnotationsIgnoreTitle', { + defaultMessage: 'Layers ignoring global filters', + }), + longMessage: ( + + ), + displayLocations: [{ id: 'embeddableBadge' }], + }, + ]; +} diff --git a/x-pack/plugins/lens/readme.md b/x-pack/plugins/lens/readme.md index b01f19e4ee8ce..9fec3f154fbf3 100644 --- a/x-pack/plugins/lens/readme.md +++ b/x-pack/plugins/lens/readme.md @@ -25,11 +25,13 @@ When adding visualizations to a solution page, there are multiple ways to approa Pros: * No need to manage searches and rendering logic on your own * "Open in Lens" comes for free + * Simple extended visualization options - if Lens can't do it, there's also a limited set of overrides to customize the final result Cons: * Each panel does its own data fetching and rendering (can lead to performance problems for high number of embeddables on a single page, e.g. more than 20) * Limited data processing options - if the Lens UI doesn't support it, it can't be used - * Limited visualization options - if Lens can't do it, it's not possible + + * #### **Using custom data fetching and rendering** In case the disadvantages of using the Lens embeddable heavily affect your use case, it sometimes makes sense to roll your own data fetching and rendering by using the underlying APIs of search service and `elastic-charts` directly. This allows a high degree of flexibility when it comes to data processing, efficiently querying data for multiple charts in a single query and adjusting small details in how charts are rendered. However, do not choose these option lightly as maintenance as well as initial development effort will most likely be much higher than by using the Lens embeddable directly. In this case, almost always an "Open in Lens" button can still be offered to the user to drill down and further explore the data by generating a Lens configuration which is similar to the displayed visualization given the possibilities of Lens. Keep in mind that for the "Open in Lens" flow, the most important property isn't perfect fidelity of the chart but retaining the mental context of the user when switching so they don't have to start over. It's also possible to mix this approach with Lens embeddables on a single page. **Note**: In this situation, please let the Visualizations team know what features you are missing / why you chose not to use Lens. @@ -182,6 +184,23 @@ The Lens embeddable is handling both data fetching and rendering - all the user /> ``` +## Overrides + +The Lens embeddable offers a way to extends the current set of visualization feature provided within the Lens editor, via the `overrides` property, which enables the consumer to override some visualization configurations in the embeddable instance. + +```tsx + +``` + +The each override is component-specific and it inherits the prop from its `elastic-charts` definition directly. Callback/handlers are not supported as functions, but the special value `"ignore"` can be provided in order to disable them in the embeddable rendering. +**Note**: overrides are only applied to the local embeddable instance and will disappear when the visualization is open in the Lens editor. + # Lens Development The following sections are concerned with developing the Lens plugin itself. diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts index dce5d079d4064..9d91d2bc5e883 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts @@ -232,6 +232,7 @@ export class EndpointActionGenerator extends BaseDataGenerator { [details.agents[0]]: this.generateExecuteActionResponseOutput({ content: { output_file_id: getFileDownloadId(details, details.agents[0]), + ...overrides.outputs?.[details.agents[0]].content, }, }), }; @@ -310,11 +311,13 @@ export class EndpointActionGenerator extends BaseDataGenerator { content: { stdout: this.randomChoice([ this.randomString(1280), + this.randomString(3580), `-rw-r--r-- 1 elastic staff 458 Jan 26 09:10 doc.txt\ -rw-r--r-- 1 elastic staff 298 Feb 2 09:10 readme.md`, ]), stderr: this.randomChoice([ this.randomString(1280), + this.randomString(3580), `error line 1\ error line 2\ error line 3 that is quite very long and will be truncated, and should not be visible in the UI\ @@ -326,6 +329,8 @@ export class EndpointActionGenerator extends BaseDataGenerator { shell: 'bash', cwd: '/some/path', output_file_id: 'some-output-file-id', + output_file_stdout_truncated: this.randomChoice([true, false]), + output_file_stderr_truncated: this.randomChoice([true, false]), }, }, overrides diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index eb770549775e3..8b8ef1ed0dda6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -77,6 +77,11 @@ export interface ResponseActionExecuteOutputContent { /* The current working directory used when the command was executed */ cwd: string; output_file_id: string; + /** Informs whether the stdout/stderr files are + * truncated due to size limitations (50 Mb each) + * */ + output_file_stdout_truncated: boolean; + output_file_stderr_truncated: boolean; } export const ActivityLogItemTypes = { diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response_output.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response.test.tsx similarity index 57% rename from x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response_output.test.tsx rename to x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response.test.tsx index a0379844c2d2f..b0c33c5d3ba39 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response_output.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response.test.tsx @@ -7,94 +7,96 @@ import type { AppContextTestRender } from '../../../common/mock/endpoint'; import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; import type { - ActionDetails, ResponseActionExecuteOutputContent, ResponseActionsExecuteParameters, + ActionDetails, } from '../../../../common/endpoint/types'; import React from 'react'; import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; import { - ExecuteActionHostResponseOutput, - type ExecuteActionHostResponseOutputProps, -} from './execute_action_host_response_output'; + ExecuteActionHostResponse, + type ExecuteActionHostResponseProps, +} from './execute_action_host_response'; import { getEmptyValue } from '@kbn/cases-plugin/public/components/empty_value'; -describe('When using the `ExecuteActionHostResponseOutput` component', () => { +describe('When using the `ExecuteActionHostResponse` component', () => { let render: () => ReturnType; let renderResult: ReturnType; - let renderProps: ExecuteActionHostResponseOutputProps; + let renderProps: ExecuteActionHostResponseProps; + const action = new EndpointActionGenerator('seed').generateActionDetails< + ResponseActionExecuteOutputContent, + ResponseActionsExecuteParameters + >({ command: 'execute', agents: ['agent-a'] }); beforeEach(() => { const appTestContext = createAppRootMockRenderer(); renderProps = { - action: new EndpointActionGenerator('seed').generateActionDetails< - ResponseActionExecuteOutputContent, - ResponseActionsExecuteParameters - >({ command: 'execute' }), + action, + canAccessFileDownloadLink: true, 'data-test-subj': 'test', }; render = () => { - renderResult = appTestContext.render(); + renderResult = appTestContext.render(); return renderResult; }; }); it('should show execute output and execute errors', async () => { render(); - expect(renderResult.getByTestId('test')).toBeTruthy(); + expect(renderResult.getByTestId('test-executeResponseOutput')).toBeTruthy(); }); - it('should show execute output as `open`', async () => { + it('should show execute output accordion as `open`', async () => { render(); const accordionOutputButton = Array.from( - renderResult.getByTestId('test').querySelectorAll('.euiAccordion') + renderResult.getByTestId('test-executeResponseOutput').querySelectorAll('.euiAccordion') )[0]; expect(accordionOutputButton.className).toContain('isOpen'); }); - it('should show `-` when no output content', async () => { + it('should show `-` in output accordion when no output content', async () => { (renderProps.action as ActionDetails).outputs = { 'agent-a': { type: 'json', content: { - ...(renderProps.action as ActionDetails)?.outputs?.['agent-a'].content, - stdout: undefined, + ...(renderProps.action as ActionDetails).outputs?.[action.agents[0]].content, + stdout: '', }, }, }; render(); const accordionOutputButton = Array.from( - renderResult.getByTestId('test').querySelectorAll('.euiAccordion') + renderResult.getByTestId('test-executeResponseOutput').querySelectorAll('.euiAccordion') )[0]; expect(accordionOutputButton.textContent).toContain( `Execution output (truncated)${getEmptyValue()}` ); }); - it('should show `-` when no error content', async () => { + it('should show `-` in error accordion when no error content', async () => { (renderProps.action as ActionDetails).outputs = { 'agent-a': { type: 'json', content: { - ...(renderProps.action as ActionDetails)?.outputs?.['agent-a'].content, - stderr: undefined, + ...(renderProps.action as ActionDetails).outputs?.[action.agents[0]].content, + stderr: '', }, }, }; render(); const accordionErrorButton = Array.from( - renderResult.getByTestId('test').querySelectorAll('.euiAccordion') + renderResult.getByTestId('test-executeResponseOutput').querySelectorAll('.euiAccordion') )[1]; expect(accordionErrorButton.textContent).toContain( `Execution error (truncated)${getEmptyValue()}` ); }); - it('should show nothing when no output in action details', () => { - (renderProps.action as ActionDetails).outputs = {}; + it('should not show execute output accordions when no output in action details', () => { + (renderProps.action as ActionDetails).outputs = undefined; render(); - expect(renderResult.queryByTestId('test')).toBeNull(); + expect(renderResult.queryByTestId('test-executeResponseOutput')).toBeNull(); }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response.tsx new file mode 100644 index 0000000000000..c46a2550a32f0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiFlexItem } from '@elastic/eui'; +import React, { memo, useMemo } from 'react'; +import type { + ActionDetails, + MaybeImmutable, + ResponseActionExecuteOutputContent, +} from '../../../../common/endpoint/types'; +import { EXECUTE_FILE_LINK_TITLE } from '../endpoint_response_actions_list/translations'; +import { ResponseActionFileDownloadLink } from '../response_action_file_download_link'; +import { ExecuteActionHostResponseOutput } from './execute_action_host_response_output'; + +export interface ExecuteActionHostResponseProps { + action: MaybeImmutable; + agentId?: string; + canAccessFileDownloadLink: boolean; + 'data-test-subj'?: string; + textSize?: 'xs' | 's'; +} + +export const ExecuteActionHostResponse = memo( + ({ + action, + agentId = action.agents[0], + canAccessFileDownloadLink, + textSize = 's', + 'data-test-subj': dataTestSubj, + }) => { + const outputContent = useMemo( + () => + action.outputs && + action.outputs[agentId] && + (action.outputs[agentId].content as ResponseActionExecuteOutputContent), + [action.outputs, agentId] + ); + + const isTruncatedFile = useMemo( + () => + (outputContent?.output_file_stderr_truncated || + outputContent?.output_file_stdout_truncated) ?? + false, + [outputContent] + ); + + return ( + <> + + + + {outputContent && ( + + )} + + ); + } +); + +ExecuteActionHostResponse.displayName = 'ExecuteActionHostResponse'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response_output.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response_output.tsx index f7e011f6ed120..1db3c4cd04983 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response_output.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/execute_action_host_response_output.tsx @@ -4,15 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { memo, useMemo } from 'react'; + +import React, { memo } from 'react'; import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiText, useGeneratedHtmlId } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import type { - ActionDetails, - MaybeImmutable, - ResponseActionExecuteOutputContent, -} from '../../../../common/endpoint/types'; +import type { ResponseActionExecuteOutputContent } from '../../../../common/endpoint/types'; import { getEmptyValue } from '../../../common/components/empty_value'; const emptyValue = getEmptyValue(); @@ -84,45 +81,30 @@ const ExecutionActionOutputAccordion = memo( ExecutionActionOutputAccordion.displayName = 'ExecutionActionOutputAccordion'; export interface ExecuteActionHostResponseOutputProps { - action: MaybeImmutable; - agentId?: string; + outputContent: ResponseActionExecuteOutputContent; 'data-test-subj'?: string; textSize?: 's' | 'xs'; } export const ExecuteActionHostResponseOutput = memo( - ({ action, agentId = action.agents[0], 'data-test-subj': dataTestSubj, textSize = 'xs' }) => { - const outputContent = useMemo( - () => - action.outputs && - action.outputs[agentId] && - (action.outputs[agentId].content as ResponseActionExecuteOutputContent), - [action.outputs, agentId] - ); - - if (!outputContent) { - return <>; - } - - return ( - - - - - - - ); - } + ({ outputContent, 'data-test-subj': dataTestSubj, textSize = 'xs' }) => ( + + + + + + + ) ); ExecuteActionHostResponseOutput.displayName = 'ExecuteActionHostResponseOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/index.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/index.tsx index 7ca102ea1f75f..2189ec905364a 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/index.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_execute_action/index.tsx @@ -5,4 +5,4 @@ * 2.0. */ -export { ExecuteActionHostResponseOutput } from './execute_action_host_response_output'; +export { ExecuteActionHostResponse } from './execute_action_host_response'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/execute_action.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/execute_action.tsx index 63d9b8e311513..181ec76e3574a 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/execute_action.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/execute_action.tsx @@ -8,16 +8,13 @@ import React, { memo, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFlexItem } from '@elastic/eui'; import type { ExecuteActionRequestBody } from '../../../../../common/endpoint/schema/actions'; import { useConsoleActionSubmitter } from '../hooks/use_console_action_submitter'; import type { ResponseActionExecuteOutputContent } from '../../../../../common/endpoint/types'; import { useSendExecuteEndpoint } from '../../../hooks/response_actions/use_send_execute_endpoint_request'; import type { ActionRequestComponentProps } from '../types'; import { parsedExecuteTimeout } from '../lib/utils'; -import { ExecuteActionHostResponseOutput } from '../../endpoint_execute_action'; -import { ResponseActionFileDownloadLink } from '../../response_action_file_download_link'; -import { EXECUTE_FILE_LINK_TITLE } from '../../endpoint_response_actions_list/translations'; +import { ExecuteActionHostResponse } from '../../endpoint_execute_action'; export const ExecuteActionResult = memo< ActionRequestComponentProps<{ @@ -75,20 +72,12 @@ export const ExecuteActionResult = memo< { defaultMessage: 'Command execution was successful.' } )} > - - - - ); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/action_log_expanded_tray.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/action_log_expanded_tray.tsx index c0157c7dbda17..eb410a96ad69b 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/action_log_expanded_tray.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/action_log_expanded_tray.tsx @@ -9,11 +9,11 @@ import React, { memo, useMemo } from 'react'; import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiDescriptionList } from '@elastic/eui'; import { css, euiStyled } from '@kbn/kibana-react-plugin/common'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; -import { OUTPUT_MESSAGES, EXECUTE_FILE_LINK_TITLE } from '../translations'; +import { OUTPUT_MESSAGES } from '../translations'; import { getUiCommand } from './hooks'; import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; import { ResponseActionFileDownloadLink } from '../../response_action_file_download_link'; -import { ExecuteActionHostResponseOutput } from '../../endpoint_execute_action'; +import { ExecuteActionHostResponse } from '../../endpoint_execute_action'; import { getEmptyValue } from '../../../../common/components/empty_value'; import { type ActionDetails, type MaybeImmutable } from '../../../../../common/endpoint/types'; @@ -119,22 +119,14 @@ const OutputContent = memo<{ action: MaybeImmutable; 'data-test-s {action.agents.map((agentId) => (
    {OUTPUT_MESSAGES.wasSuccessful(command)} - - - -
    ))} @@ -160,7 +152,7 @@ export const ActionsLogExpandedTray = memo<{ () => parameters ? Object.entries(parameters).map(([key, value]) => { - return `${key}:${value}`; + return `${key}: ${value}`; }) : undefined, [parameters] diff --git a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx index e8a7d387ca0ba..aef6bf1f16fc5 100644 --- a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx @@ -16,6 +16,9 @@ import React from 'react'; import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; import { FILE_NO_LONGER_AVAILABLE_MESSAGE, + FILE_TRUNCATED_MESSAGE, + FILE_DELETED_MESSAGE, + FILE_PASSCODE_INFO_MESSAGE, ResponseActionFileDownloadLink, type ResponseActionFileDownloadLinkProps, } from './response_action_file_download_link'; @@ -42,6 +45,7 @@ describe('When using the `ResponseActionFileDownloadLink` component', () => { >({ command: 'get-file' }), 'data-test-subj': 'test', canAccessFileDownloadLink: true, + isTruncatedFile: false, }; render = () => { @@ -58,10 +62,10 @@ describe('When using the `ResponseActionFileDownloadLink` component', () => { expect(renderResult.getByTestId('test-downloadButton')).not.toBeNull(); expect(renderResult.getByTestId('test-passcodeMessage')).toHaveTextContent( - '(ZIP file passcode: elastic)' + FILE_PASSCODE_INFO_MESSAGE ); expect(renderResult.getByTestId('test-fileDeleteMessage')).toHaveTextContent( - 'Files are periodically deleted to clear storage space. Download and save file locally if needed.' + FILE_DELETED_MESSAGE ); }); @@ -114,6 +118,20 @@ describe('When using the `ResponseActionFileDownloadLink` component', () => { }); }); + it('should show file is truncated if execute file output is truncated', async () => { + renderProps.isTruncatedFile = true; + render(); + await waitFor(() => { + expect(apiMocks.responseProvider.fileInfo).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(renderResult.getByTestId('test-fileTruncatedMessage')).toHaveTextContent( + FILE_TRUNCATED_MESSAGE + ); + }); + }); + it('should show file no longer available message if file info api returns 404', async () => { const error = { message: 'not found', response: { status: 404 } } as IHttpFetchError; diff --git a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx index c795bfc9bc191..a3e99810704d9 100644 --- a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx +++ b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx @@ -6,8 +6,15 @@ */ import React, { memo, useMemo, type CSSProperties } from 'react'; -import { EuiButtonEmpty, EuiSkeletonText, EuiText } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiButtonEmpty, + EuiSkeletonText, + EuiText, + EuiSpacer, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import { getFileDownloadId } from '../../../../common/endpoint/service/response_actions/get_file_download_id'; @@ -33,18 +40,72 @@ export const FILE_NO_LONGER_AVAILABLE_MESSAGE = i18n.translate( { defaultMessage: 'File has expired and is no longer available for download.' } ); +export const FILE_DELETED_MESSAGE = i18n.translate( + 'xpack.securitySolution.responseActionFileDownloadLink.deleteNotice', + { + defaultMessage: + 'Files are periodically deleted to clear storage space. Download and save file locally if needed.', + } +); + +export const FILE_PASSCODE_INFO_MESSAGE = i18n.translate( + 'xpack.securitySolution.responseActionFileDownloadLink.passcodeInfo', + { + defaultMessage: '(ZIP file passcode: {passcode}).', + values: { + passcode: 'elastic', + }, + } +); + +export const FILE_TRUNCATED_MESSAGE = i18n.translate( + 'xpack.securitySolution.responseActionFileDownloadLink.fileTruncated', + { + defaultMessage: + 'Output data in the provided zip file is truncated due to file size limitations.', + } +); + const FileDownloadLinkContainer = styled.div` & > * { vertical-align: middle; } `; +interface TruncatedTextInfoProps { + textSize: Required; + 'data-test-subj'?: string; +} + +const TruncatedTextInfo = memo( + ({ textSize, 'data-test-subj': dataTestSubj }) => { + const alertIconSize = useMemo(() => (textSize === 'xs' ? 's' : 'm'), [textSize]); + return ( + <> + + + + + + + + {FILE_TRUNCATED_MESSAGE} + + + + + ); + } +); + +TruncatedTextInfo.displayName = 'TruncatedTextInfo'; export interface ResponseActionFileDownloadLinkProps { action: MaybeImmutable; /** If left undefined, the first agent that the action was sent to will be used */ agentId?: string; buttonTitle?: string; canAccessFileDownloadLink: boolean; + isTruncatedFile?: boolean; 'data-test-subj'?: string; textSize?: 's' | 'xs'; } @@ -61,8 +122,9 @@ export const ResponseActionFileDownloadLink = memo { const action = _action as ActionDetails; // cast to remove `Immutable` const getTestId = useTestIdGenerator(dataTestSubj); @@ -123,20 +185,17 @@ export const ResponseActionFileDownloadLink = memo - + {FILE_PASSCODE_INFO_MESSAGE} - + {FILE_DELETED_MESSAGE} + {isTruncatedFile && ( + + )} ); } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 97dba3a7a5ccf..a65243339088c 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -11104,7 +11104,6 @@ "xpack.embeddableEnhanced.actions.panelNotifications.manyDrilldowns": "Le panneau comporte {count} explorations", "xpack.embeddableEnhanced.actions.panelNotifications.oneDrilldown": "Le panneau comporte 1 recherche", "xpack.embeddableEnhanced.Drilldowns": "Explorations", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.title": "{title}", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptEmbed.stepThree.description": "Suivez des événements individuels, tels que les clics, en appelant la méthode trackEvent. {link}", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepOne.description": "Suivez les instructions pour incorporer Behavioral Analytics dans votre site via {embedLink} ou {clientLink}.", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepTwo.moreInfoDescription": "Pour en savoir plus sur l'initialisation du suivi et le déclenchement d'événements, consultez {link}.", @@ -11379,11 +11378,6 @@ "xpack.enterpriseSearch.actionsHeader": "Actions", "xpack.enterpriseSearch.analytics.collections.collectionsView.collectionNotFoundState.headingTitle": "Vous avez peut-être supprimé cette collection d'analyses", "xpack.enterpriseSearch.analytics.collections.collectionsView.collectionNotFoundState.subHeading": "Une collection d'analyse permet de stocker les événements d'analyse pour toute application de recherche que vous créez. Créez une nouvelle collection pour commencer.", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.columns.eventName": "Nom de l'événement", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.columns.userUuid": "UUID d'utilisateur", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.actions": "Afficher les instructions de l'intégration", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.body": "Commencer à suivre les événements en ajoutant le client d'analyse comportementale à chaque page de votre site web ou de l'application que vous souhaitez suivre", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.footer": "Visiter la documentation relative à l'analyse comportementale", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.description": "Une fois que vous avez appelé createTracker, vous pouvez utiliser les méthodes de suivi telles que trackPageView pour envoyer les événements vers Behavioral Analytics.", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionThree": "Vous pouvez également déployer des événements personnalisés dans Behavioral Analytics en appelant la méthode trackEvent.", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionTwo": "Une fois initialisé, vous aurez la possibilité de suivre les vues de page dans votre application.", @@ -11409,10 +11403,8 @@ "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepTwo.importDescription": "Puis importez le plug-in Behavioral Analytics dans votre application.", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepTwo.setupDescription": "Enfin, ajoutez le plug-in dans la configuration Search UI. Selon la façon dont vous avez incorporé Behavioral Analytics, vous devrez peut-être transmettre le client. L'exemple ci-dessous montre comment transmettre le client lorsque le client Javascript est utilisé.", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.stepOne.title": "Incorporer Behavioral Analytics", - "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.stepThree.title": "Suivre les événements individuels", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.stepTwo.title": "Installer le plug-in Behavioral Analytics Search UI", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.title": "Search UI", - "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.title": "Commencer à suivre les événements", "xpack.enterpriseSearch.analytics.collections.collectionsView.settingsTab.delete.buttonTitle": "Supprimer cette collection", "xpack.enterpriseSearch.analytics.collections.collectionsView.settingsTab.delete.headingTitle": "Supprimer cette collection d'analyses", "xpack.enterpriseSearch.analytics.collections.collectionsView.settingsTab.delete.warning": "Cette action est irréversible", @@ -19207,7 +19199,6 @@ "xpack.lens.xyChart.annotationError.textFieldNotFound": "Champ de texte {textField} introuvable dans la vue de données {dataView}", "xpack.lens.xyChart.annotationError.timeFieldNotFound": "Champ temporel {timeField} introuvable dans la vue de données {dataView}", "xpack.lens.xyChart.annotationError.tooltipFieldNotFound": "{missingFields, plural, one {Champ d'infobulle introuvable} other {Champs d'infobulle introuvables}} {missingTooltipFields} dans la vue de données {dataView}", - "xpack.lens.xyChart.randomSampling.help": "Des pourcentages d'échantillonnage plus faibles augmentent la vitesse, mais diminuent la précision. Une bonne pratique consiste à utiliser un échantillonnage plus faible uniquement pour les ensembles de données volumineux. {link}", "xpack.lens.xySuggestions.dateSuggestion": "{yTitle} sur {xTitle}", "xpack.lens.xySuggestions.nonDateSuggestion": "{yTitle} de {xTitle}", "xpack.lens.xyVisualization.arrayValues": "{label} contient des valeurs de tableau. Le rendu de votre visualisation peut ne pas se présenter comme attendu.", @@ -19947,7 +19938,6 @@ "xpack.lens.primaryMetric.headingLabel": "Valeur", "xpack.lens.primaryMetric.label": "Indicateur principal", "xpack.lens.queryInput.appName": "Lens", - "xpack.lens.randomSampling.experimentalLabel": "Version d'évaluation technique", "xpack.lens.reporting.shareContextMenu.csvReportsButtonLabel": "Téléchargement CSV", "xpack.lens.resetLayerAriaLabel": "Effacer le calque", "xpack.lens.saveDuplicateRejectedDescription": "La confirmation d'enregistrement avec un doublon de titre a été rejetée.", @@ -20159,10 +20149,6 @@ "xpack.lens.xyChart.missingValuesStyle": "Afficher sous la forme d’une ligne pointillée", "xpack.lens.xyChart.nestUnderRoot": "Ensemble de données entier", "xpack.lens.xyChart.placement": "Placement", - "xpack.lens.xyChart.randomSampling.accuracyLabel": "Précision", - "xpack.lens.xyChart.randomSampling.label": "Échantillonnage aléatoire", - "xpack.lens.xyChart.randomSampling.learnMore": "Afficher la documentation", - "xpack.lens.xyChart.randomSampling.speedLabel": "Rapidité", "xpack.lens.xyChart.rightAxisDisabledHelpText": "Ce paramètre s'applique uniquement lorsque l'axe de droite est activé.", "xpack.lens.xyChart.rightAxisLabel": "Axe de droite", "xpack.lens.xyChart.scaleLinear": "Linéaire", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 041bb04c8111f..53fb91ae6d566 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11103,7 +11103,6 @@ "xpack.embeddableEnhanced.actions.panelNotifications.manyDrilldowns": "パネルには{count}個のドリルダウンがあります", "xpack.embeddableEnhanced.actions.panelNotifications.oneDrilldown": "パネルには 1 個のドリルダウンがあります", "xpack.embeddableEnhanced.Drilldowns": "ドリルダウン", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.title": "{title}", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptEmbed.stepThree.description": "trackEventメソッドを呼び出し、クリックなどの個別のイベントを追跡します。{link}", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepOne.description": "指示に従って、{embedLink}または{clientLink}からサイトに行動分析を組み込んでください。", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepTwo.moreInfoDescription": "トラッカーの初期化およびイベントの発生については、{link}を参照してください。", @@ -11378,11 +11377,6 @@ "xpack.enterpriseSearch.actionsHeader": "アクション", "xpack.enterpriseSearch.analytics.collections.collectionsView.collectionNotFoundState.headingTitle": "この分析コレクションを削除した可能性があります", "xpack.enterpriseSearch.analytics.collections.collectionsView.collectionNotFoundState.subHeading": "分析コレクションには、構築している特定の検索アプリケーションの分析イベントを格納できます。開始するには、新しいコレクションを作成してください。", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.columns.eventName": "イベント名", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.columns.userUuid": "ユーザーUUID", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.actions": "統合手順を表示", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.body": "追跡したいWebサイトやアプリケーションの各ページに行動分析クライアントを追加して、イベントの追跡を開始します。", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.footer": "行動分析ドキュメントを表示", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.description": "createTrackerを呼び出したら、trackPageViewなどのtrackerメソッドを使って、Behavioral Analyticsにイベントを送ることができます。", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionThree": "また、trackEventメソッドを呼び出すことで、Behavioral Analyticsにカスタムイベントをディスパッチすることもできます。", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionTwo": "初期化すると、アプリケーションのページビューを追跡することができるようになります。", @@ -11408,10 +11402,8 @@ "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepTwo.importDescription": "Behavioral Analyticsプラグインをアプリにインポートします。", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepTwo.setupDescription": "最後に、プラグインをSearch UI構成に追加します。Behavioral Analyticsをどのように組み込んだかによって、クライアントを渡す必要がある場合があります。以下の例では、Javascriptクライアントを使用する場合の受け渡し方法を示しています。", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.stepOne.title": "Behavioral Analyticsを組み込み", - "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.stepThree.title": "個別のイベントを追跡", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.stepTwo.title": "Search UI行動分析プラグインをインストール", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.title": "Search UI", - "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.title": "追跡イベントの開始", "xpack.enterpriseSearch.analytics.collections.collectionsView.settingsTab.delete.buttonTitle": "このコレクションを削除", "xpack.enterpriseSearch.analytics.collections.collectionsView.settingsTab.delete.headingTitle": "この分析コレクションを削除", "xpack.enterpriseSearch.analytics.collections.collectionsView.settingsTab.delete.warning": "この操作は元に戻すことができません", @@ -19206,7 +19198,6 @@ "xpack.lens.xyChart.annotationError.textFieldNotFound": "テキストフィールド{textField}がデータビュー{dataView}で見つかりません", "xpack.lens.xyChart.annotationError.timeFieldNotFound": "時刻フィールド{timeField}がデータビュー{dataView}で見つかりません", "xpack.lens.xyChart.annotationError.tooltipFieldNotFound": "フィールド{missingFields, plural, other {フィールド}}{missingTooltipFields}がデータビュー{dataView}で見つかりません", - "xpack.lens.xyChart.randomSampling.help": "サンプリング割合が低いと、速度が上がりますが、精度が低下します。ベストプラクティスとして、大きいデータセットの場合にのみ低サンプリングを使用してください。{link}", "xpack.lens.xySuggestions.dateSuggestion": "{xTitle} の {yTitle}", "xpack.lens.xySuggestions.nonDateSuggestion": "{yTitle} / {xTitle}", "xpack.lens.xyVisualization.arrayValues": "{label}には配列値が含まれます。可視化が想定通りに表示されない場合があります。", @@ -19947,7 +19938,6 @@ "xpack.lens.primaryMetric.headingLabel": "値", "xpack.lens.primaryMetric.label": "主メトリック", "xpack.lens.queryInput.appName": "レンズ", - "xpack.lens.randomSampling.experimentalLabel": "テクニカルプレビュー", "xpack.lens.reporting.shareContextMenu.csvReportsButtonLabel": "CSVダウンロード", "xpack.lens.resetLayerAriaLabel": "レイヤーをクリア", "xpack.lens.saveDuplicateRejectedDescription": "重複ファイルの保存確認が拒否されました", @@ -20159,10 +20149,6 @@ "xpack.lens.xyChart.missingValuesStyle": "点線として表示", "xpack.lens.xyChart.nestUnderRoot": "データセット全体", "xpack.lens.xyChart.placement": "配置", - "xpack.lens.xyChart.randomSampling.accuracyLabel": "精度", - "xpack.lens.xyChart.randomSampling.label": "無作為抽出", - "xpack.lens.xyChart.randomSampling.learnMore": "ドキュメンテーションを表示", - "xpack.lens.xyChart.randomSampling.speedLabel": "スピード", "xpack.lens.xyChart.rightAxisDisabledHelpText": "この設定は、右の軸が有効であるときにのみ適用されます。", "xpack.lens.xyChart.rightAxisLabel": "右の軸", "xpack.lens.xyChart.scaleLinear": "線形", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1b9b344ab9986..b7b7503d9a62a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11104,7 +11104,6 @@ "xpack.embeddableEnhanced.actions.panelNotifications.manyDrilldowns": "面板有 {count} 个向下钻取", "xpack.embeddableEnhanced.actions.panelNotifications.oneDrilldown": "面板有 1 个向下钻取", "xpack.embeddableEnhanced.Drilldowns": "向下钻取", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.title": "{title}", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptEmbed.stepThree.description": "通过调用 trackEvent 方法跟踪单个事件,如点击。{link}", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepOne.description": "按照说明通过 {embedLink} 或 {clientLink} 将行为分析嵌入到您的站点中。", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepTwo.moreInfoDescription": "请参阅 {link} 了解有关初始化跟踪器和触发事件的更多信息。", @@ -11379,11 +11378,6 @@ "xpack.enterpriseSearch.actionsHeader": "操作", "xpack.enterpriseSearch.analytics.collections.collectionsView.collectionNotFoundState.headingTitle": "您可能已删除此分析集合", "xpack.enterpriseSearch.analytics.collections.collectionsView.collectionNotFoundState.subHeading": "分析集合为您正在构建的任何给定搜索应用程序提供了一个用于存储分析事件的位置。创建新集合以开始。", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.columns.eventName": "事件名称", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.columns.userUuid": "用户 UUID", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.actions": "查看集成说明", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.body": "通过将行为分析客户端添加到您要跟踪的每个网站页面或应用程序来启动事件跟踪", - "xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.footer": "访问行为分析文档", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.description": "调用 createTracker 后,可以使用跟踪器方法(如 trackPageView)将事件发送到行为分析。", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionThree": "还可以通过调用 trackEvent 方法来向行为分析分派定制事件。", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionTwo": "完成初始化后,您将能够跟踪您应用程序中的页面视图。", @@ -11409,10 +11403,8 @@ "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepTwo.importDescription": "然后将行为分析插件导入到您的应用。", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchui.stepTwo.setupDescription": "最后,将插件添加到搜索 UI 配置。根据您嵌入行为分析的方式,您可能需要传入客户端。以下示例说明如何在使用 Javascript 客户端时传入客户端。", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.stepOne.title": "嵌入行为分析", - "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.stepThree.title": "跟踪单个事件", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.stepTwo.title": "安装搜索 UI 行为分析插件", "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.searchuiEmbed.title": "搜索 UI", - "xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.title": "开始跟踪事件", "xpack.enterpriseSearch.analytics.collections.collectionsView.settingsTab.delete.buttonTitle": "删除此集合", "xpack.enterpriseSearch.analytics.collections.collectionsView.settingsTab.delete.headingTitle": "删除此分析集合", "xpack.enterpriseSearch.analytics.collections.collectionsView.settingsTab.delete.warning": "此操作不可逆", @@ -19207,7 +19199,6 @@ "xpack.lens.xyChart.annotationError.textFieldNotFound": "在数据视图 {dataView} 中未找到文本字段 {textField}", "xpack.lens.xyChart.annotationError.timeFieldNotFound": "在数据视图 {dataView} 中未找到时间字段 {timeField}", "xpack.lens.xyChart.annotationError.tooltipFieldNotFound": "在数据视图 {dataView} 中未找到工具提示{missingFields, plural, other {字段}} {missingTooltipFields}", - "xpack.lens.xyChart.randomSampling.help": "较低采样百分比会提高速度,但会降低准确性。作为最佳做法,请仅将较低采样用于大型数据库。{link}", "xpack.lens.xySuggestions.dateSuggestion": "{yTitle} / {xTitle}", "xpack.lens.xySuggestions.nonDateSuggestion": "{xTitle} 的 {yTitle}", "xpack.lens.xyVisualization.arrayValues": "{label} 包含数组值。您的可视化可能无法正常渲染。", @@ -19948,7 +19939,6 @@ "xpack.lens.primaryMetric.headingLabel": "值", "xpack.lens.primaryMetric.label": "主要指标", "xpack.lens.queryInput.appName": "Lens", - "xpack.lens.randomSampling.experimentalLabel": "技术预览", "xpack.lens.reporting.shareContextMenu.csvReportsButtonLabel": "CSV 下载", "xpack.lens.resetLayerAriaLabel": "清除图层", "xpack.lens.saveDuplicateRejectedDescription": "已拒绝使用重复标题保存确认", @@ -20160,10 +20150,6 @@ "xpack.lens.xyChart.missingValuesStyle": "显示为虚线", "xpack.lens.xyChart.nestUnderRoot": "整个数据集", "xpack.lens.xyChart.placement": "位置", - "xpack.lens.xyChart.randomSampling.accuracyLabel": "准确性", - "xpack.lens.xyChart.randomSampling.label": "随机采样", - "xpack.lens.xyChart.randomSampling.learnMore": "查看文档", - "xpack.lens.xyChart.randomSampling.speedLabel": "速度", "xpack.lens.xyChart.rightAxisDisabledHelpText": "此设置仅在启用右轴时应用。", "xpack.lens.xyChart.rightAxisLabel": "右轴", "xpack.lens.xyChart.scaleLinear": "线性", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/panel/base_snooze_panel.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/panel/base_snooze_panel.tsx index 437c75aa3ab2a..a6761ec4de882 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/panel/base_snooze_panel.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/panel/base_snooze_panel.tsx @@ -213,6 +213,7 @@ export const BaseSnoozePanel: React.FunctionComponent = ({ style={{ paddingLeft: '9px', paddingRight: '9px', + height: '36px', // Replicate euiPanel--accent vs euiPanel--subdued // Applying these classNames by themselves doesn't work due to a CSS-in-JS issue with EuiPanel color: isActive ? '#a8376a' : euiTheme.colors.subduedText, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/recurrence_scheduler/custom_recurrence_scheduler.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/recurrence_scheduler/custom_recurrence_scheduler.tsx index b7568cfdc99db..2136525c028b6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/recurrence_scheduler/custom_recurrence_scheduler.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/recurrence_scheduler/custom_recurrence_scheduler.tsx @@ -53,7 +53,7 @@ export const CustomRecurrenceScheduler: React.FC getInitialByweekday(initialState.byweekday, startDate) ); const [monthlyRecurDay, setMonthlyRecurDay] = useState( - initialState.freq === RRuleFrequency.MONTHLY && initialState.byweekday.length > 0 + initialState.freq === RRuleFrequency.MONTHLY && (initialState.byweekday?.length ?? 0) > 0 ? 'weekday' : 'day' ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/recurrence_scheduler/helpers.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/recurrence_scheduler/helpers.tsx index 6e549fdc5a032..ca72691a9839f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/recurrence_scheduler/helpers.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_snooze/recurrence_scheduler/helpers.tsx @@ -16,7 +16,7 @@ import { i18nFreqSummary, i18nNthWeekdayShort } from './translations'; export interface CustomFrequencyState { freq: RRuleFrequency; interval: number; - byweekday: string[]; + byweekday?: string[]; bymonthday: number[]; bymonth: number[]; } @@ -37,7 +37,7 @@ export const getInitialByweekday = ( (result, n) => ({ ...result, [n]: - initialStateByweekday?.length > 0 + initialStateByweekday && initialStateByweekday.length > 0 ? initialStateByweekday // Sanitize nth day strings, e.g. +2MO, -1FR, into just days of the week .map((w) => w.replace(/[0-9+\-]/g, '')) diff --git a/x-pack/test/functional/apps/lens/group1/layer_actions.ts b/x-pack/test/functional/apps/lens/group1/layer_actions.ts index 6143129aaa841..f3104a7a81c7a 100644 --- a/x-pack/test/functional/apps/lens/group1/layer_actions.ts +++ b/x-pack/test/functional/apps/lens/group1/layer_actions.ts @@ -13,13 +13,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); const testSubjects = getService('testSubjects'); - // skip random sampling FTs until we figure out next steps - describe.skip('lens layer actions tests', () => { + describe('lens layer actions tests', () => { it('should allow creation of lens xy chart', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); await PageObjects.lens.goToTimeRange(); + // check that no sampling info is shown in the dataView picker + expect(await testSubjects.exists('lnsChangeIndexPatternSamplingInfo')).to.be(false); + await PageObjects.lens.openLayerContextMenu(); // should be 3 actions available @@ -28,18 +30,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ).to.eql(3); }); - it('should open layer settings for a data layer', async () => { + it('should open layer settings for a data layer and set a sampling rate', async () => { // click on open layer settings await testSubjects.click('lnsLayerSettings'); // random sampling available await testSubjects.existOrFail('lns-indexPattern-random-sampling-row'); // tweak the value - await PageObjects.lens.dragRangeInput('lns-indexPattern-random-sampling', 2, 'left'); + await PageObjects.lens.dragRangeInput('lns-indexPattern-random-sampling-slider', 2, 'left'); - expect(await PageObjects.lens.getRangeInputValue('lns-indexPattern-random-sampling')).to.eql( - 2 // 0.01 + expect( + await PageObjects.lens.getRangeInputValue('lns-indexPattern-random-sampling-slider') + ).to.eql( + 3 // 1% ); await testSubjects.click('lns-indexPattern-dimensionContainerBack'); + + // now check that the dataView picker has the sampling info + await testSubjects.existOrFail('lnsChangeIndexPatternSamplingInfo'); + expect(await testSubjects.getVisibleText('lnsChangeIndexPatternSamplingInfo')).to.be('1%'); }); it('should add an annotation layer and settings shoud not be available', async () => { @@ -56,13 +64,54 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { field: 'bytes', }); // add annotation layer - await testSubjects.click('lnsLayerAddButton'); - await testSubjects.click(`lnsLayerAddButton-annotations`); + await PageObjects.lens.createLayer('annotations'); await PageObjects.lens.openLayerContextMenu(1); + await testSubjects.existOrFail('lnsXY_annotationLayer_keepFilters'); // layer settings not available await testSubjects.missingOrFail('lnsLayerSettings'); }); + it('should add a new visualization layer and disable the sampling if max operation is chosen', async () => { + await PageObjects.lens.createLayer('data'); + + await PageObjects.lens.openLayerContextMenu(2); + // click on open layer settings + await testSubjects.click('lnsLayerSettings'); + // tweak the value + await PageObjects.lens.dragRangeInput('lns-indexPattern-random-sampling-slider', 2, 'left'); + await testSubjects.click('lns-indexPattern-dimensionContainerBack'); + // check the sampling is shown + await testSubjects.existOrFail('lns-layerPanel-2 > lnsChangeIndexPatternSamplingInfo'); + await PageObjects.lens.configureDimension({ + dimension: 'lns-layerPanel-2 > lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + // now configure a max operation + await PageObjects.lens.configureDimension({ + dimension: 'lns-layerPanel-2 > lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'max', + field: 'bytes', + keepOpen: true, // keep it open as the toast will cover the close button anyway + }); + + // close the toast about disabling sampling + // note: this has also the side effect to close the dimension editor + await testSubjects.click('toastCloseButton'); + + // check that sampling info is hidden as disabled now the dataView picker + await testSubjects.missingOrFail('lns-layerPanel-2 > lnsChangeIndexPatternSamplingInfo'); + // open the layer settings and check that the slider is disabled + await PageObjects.lens.openLayerContextMenu(2); + // click on open layer settings + await testSubjects.click('lnsLayerSettings'); + expect( + await testSubjects.getAttribute('lns-indexPattern-random-sampling-slider', 'disabled') + ).to.be('true'); + await testSubjects.click('lns-indexPattern-dimensionContainerBack'); + }); + it('should switch to pie chart and have layer settings available', async () => { await PageObjects.lens.switchToVisualization('pie'); await PageObjects.lens.openLayerContextMenu(); @@ -70,8 +119,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // open the panel await testSubjects.click('lnsLayerSettings'); // check the sampling value - expect(await PageObjects.lens.getRangeInputValue('lns-indexPattern-random-sampling')).to.eql( - 2 // 0.01 + expect( + await PageObjects.lens.getRangeInputValue('lns-indexPattern-random-sampling-slider') + ).to.eql( + 3 // 1% ); await testSubjects.click('lns-indexPattern-dimensionContainerBack'); }); @@ -83,10 +134,79 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // open the panel await testSubjects.click('lnsLayerSettings'); // check the sampling value - expect(await PageObjects.lens.getRangeInputValue('lns-indexPattern-random-sampling')).to.eql( - 2 // 0.01 + expect( + await PageObjects.lens.getRangeInputValue('lns-indexPattern-random-sampling-slider') + ).to.eql( + 3 // 1% ); await testSubjects.click('lns-indexPattern-dimensionContainerBack'); }); + + it('should show visualization modifiers for layer settings when embedded in a dashboard', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + // click on open layer settings + await PageObjects.lens.openLayerContextMenu(); + await testSubjects.click('lnsLayerSettings'); + // tweak the value + await PageObjects.lens.dragRangeInput('lns-indexPattern-random-sampling-slider', 2, 'left'); + await testSubjects.click('lns-indexPattern-dimensionContainerBack'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + // add another layer with a different sampling rate + await PageObjects.lens.createLayer('data'); + + await PageObjects.lens.openLayerContextMenu(1); + // click on open layer settings + await testSubjects.click('lnsLayerSettings'); + // tweak the value + await PageObjects.lens.dragRangeInput('lns-indexPattern-random-sampling-slider', 3, 'left'); + await testSubjects.click('lns-indexPattern-dimensionContainerBack'); + + await PageObjects.lens.configureDimension({ + dimension: 'lns-layerPanel-1 > lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lns-layerPanel-1 > lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + // add annotation layer + // by default annotations ignore global filters + await PageObjects.lens.createLayer('annotations'); + + await PageObjects.lens.save('sampledVisualization', false, true, false, 'new'); + + // now check for the bottom-left badge + await testSubjects.existOrFail('lns-feature-badges-trigger'); + + // click on the badge and check the popover + await testSubjects.click('lns-feature-badges-trigger'); + expect( + (await testSubjects.getVisibleText('lns-feature-badges-reducedSampling-0')).split('\n') + ).to.contain('1%'); + expect( + (await testSubjects.getVisibleText('lns-feature-badges-reducedSampling-1')).split('\n') + ).to.contain('0.1%'); + expect( + (await testSubjects.getVisibleText('lns-feature-badges-ignoreGlobalFilters-0')).split('\n') + ).to.contain('Annotations'); + }); }); } diff --git a/x-pack/test/functional/page_objects/observability_page.ts b/x-pack/test/functional/page_objects/observability_page.ts index bfbd1741d9f11..38f8fd1bfc309 100644 --- a/x-pack/test/functional/page_objects/observability_page.ts +++ b/x-pack/test/functional/page_objects/observability_page.ts @@ -12,6 +12,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function ObservabilityPageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const textValue = 'Foobar'; + const PageObjects = getPageObjects(['common', 'header']); return { async clickSolutionNavigationEntry(appId: string, navId: string) { @@ -45,6 +46,7 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro }, async expectAddCommentButton() { + await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.setValue('add-comment', textValue); const button = await testSubjects.find('submit-comment', 20000); const disabledAttr = await button.getAttribute('disabled'); diff --git a/x-pack/test/functional/services/cases/api.ts b/x-pack/test/functional/services/cases/api.ts index bd2c39139dfb2..743037fed22c5 100644 --- a/x-pack/test/functional/services/cases/api.ts +++ b/x-pack/test/functional/services/cases/api.ts @@ -6,7 +6,12 @@ */ import pMap from 'p-map'; -import { CasePostRequest, CaseResponse, CaseStatuses } from '@kbn/cases-plugin/common/api'; +import { + CasePostRequest, + CaseResponse, + CaseSeverity, + CaseStatuses, +} from '@kbn/cases-plugin/common/api'; import { createCase as createCaseAPI, deleteAllCaseItems, @@ -98,5 +103,43 @@ export function CasesAPIServiceProvider({ getService }: FtrProviderContext) { async getCase({ caseId }: OmitSupertest[0]>): Promise { return getCase({ supertest: kbnSupertest, caseId }); }, + + async generateUserActions({ + caseId, + caseVersion, + totalUpdates = 1, + }: { + caseId: string; + caseVersion: string; + totalUpdates: number; + }) { + let latestVersion = caseVersion; + const severities = Object.values(CaseSeverity); + const statuses = Object.values(CaseStatuses); + + for (let index = 0; index < totalUpdates; index++) { + const severity = severities[index % severities.length]; + const status = statuses[index % statuses.length]; + + const theCase = await updateCase({ + supertest: kbnSupertest, + params: { + cases: [ + { + id: caseId, + version: latestVersion, + title: `Title update ${index}`, + description: `Desc update ${index}`, + severity, + status, + tags: [`tag-${index}`], + }, + ], + }, + }); + + latestVersion = theCase[0].version; + } + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index 01b616110153b..d0df92ac4f7e7 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -359,41 +359,41 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('filter activity', () => { createOneCaseBeforeDeleteAllAfter(getPageObject, getService); - it('filters by history successfully', async () => { - await cases.common.selectSeverity(CaseSeverity.MEDIUM); + it('filters by comment successfully', async () => { + const commentBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-comments"] span.euiNotificationBadge' + ); - await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); + expect(await commentBadge.getVisibleText()).equal('0'); + + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + await testSubjects.click('submit-comment'); await header.waitUntilLoadingHasFinished(); - await testSubjects.click('user-actions-filter-activity-button-history'); + expect(await commentBadge.getVisibleText()).equal('1'); + }); + it('filters by history successfully', async () => { const historyBadge = await find.byCssSelector( '[data-test-subj="user-actions-filter-activity-button-history"] span.euiNotificationBadge' ); - expect(await historyBadge.getVisibleText()).equal('2'); - }); - - it('filters by comment successfully', async () => { - await testSubjects.click('user-actions-filter-activity-button-comments'); + expect(await historyBadge.getVisibleText()).equal('1'); - await header.waitUntilLoadingHasFinished(); + await cases.common.selectSeverity(CaseSeverity.MEDIUM); - const commentArea = await find.byCssSelector( - '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' - ); - await commentArea.focus(); - await commentArea.type('Test comment from automation'); - await testSubjects.click('submit-comment'); + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); await header.waitUntilLoadingHasFinished(); - const commentBadge = await find.byCssSelector( - '[data-test-subj="user-actions-filter-activity-button-comments"] span.euiNotificationBadge' - ); + await testSubjects.click('user-actions-filter-activity-button-history'); - expect(await commentBadge.getVisibleText()).equal('1'); + expect(await historyBadge.getVisibleText()).equal('3'); }); it('sorts by newest first successfully', async () => { @@ -403,7 +403,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { '[data-test-subj="user-actions-filter-activity-button-all"] span.euiNotificationBadge' ); - expect(await AllBadge.getVisibleText()).equal('3'); + expect(await AllBadge.getVisibleText()).equal('4'); const sortDesc = await find.byCssSelector( '[data-test-subj="user-actions-sort-select"] [value="desc"]' @@ -413,13 +413,63 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await header.waitUntilLoadingHasFinished(); - const userActions = await find.byCssSelector('[data-test-subj="user-actions"]'); + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + const actionList = await userActionsLists[0].findAllByClassName('euiComment'); + + expect(await actionList[0].getAttribute('data-test-subj')).contain('status-update-action'); + }); + }); + + describe('pagination', async () => { + let createdCase: any; + + before(async () => { + await cases.navigation.navigateToApp(); + createdCase = await cases.api.createCase({ title: 'Pagination feature' }); + await cases.casesTable.waitForCasesToBeListed(); + await cases.casesTable.goToFirstListedCase(); + await header.waitUntilLoadingHasFinished(); + }); - const actionsList = await userActions.findAllByClassName('euiComment'); + after(async () => { + await cases.api.deleteAllCases(); + }); - expect(await actionsList[0].getAttribute('data-test-subj')).contain( - 'comment-create-action' + it('shows more actions on button click', async () => { + await cases.api.generateUserActions({ + caseId: createdCase.id, + caseVersion: createdCase.version, + totalUpdates: 4, + }); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('case-refresh'); + + await header.waitUntilLoadingHasFinished(); + + expect(testSubjects.existOrFail('cases-show-more-user-actions')); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' ); + + expect(userActionsLists).length(2); + + expect(await userActionsLists[0].findAllByClassName('euiComment')).length(10); + + expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4); + + testSubjects.click('cases-show-more-user-actions'); + + await header.waitUntilLoadingHasFinished(); + + expect(await userActionsLists[0].findAllByClassName('euiComment')).length(20); + + expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4); }); });