diff --git a/src/plugins/data/common/search/aggs/agg_configs.test.ts b/src/plugins/data/common/search/aggs/agg_configs.test.ts
index cd0495a3f78c6..c3b7ffe937b35 100644
--- a/src/plugins/data/common/search/aggs/agg_configs.test.ts
+++ b/src/plugins/data/common/search/aggs/agg_configs.test.ts
@@ -16,6 +16,15 @@ import type { DataView } from '@kbn/data-views-plugin/common';
import { stubIndexPattern } from '../../stubs';
import { IEsSearchResponse } from '..';
+// Mute moment.tz warnings about not finding a mock timezone
+jest.mock('../utils', () => {
+ const original = jest.requireActual('../utils');
+ return {
+ ...original,
+ getUserTimeZone: jest.fn(() => 'US/Pacific'),
+ };
+});
+
describe('AggConfigs', () => {
const indexPattern: DataView = stubIndexPattern;
let typesRegistry: AggTypesRegistryStart;
@@ -563,6 +572,82 @@ describe('AggConfigs', () => {
'1-bucket>_count'
);
});
+
+ it('prepends a sampling agg whenever sampling is enabled', () => {
+ const configStates = [
+ {
+ enabled: true,
+ id: '1',
+ type: 'avg_bucket',
+ schema: 'metric',
+ params: {
+ customBucket: {
+ id: '1-bucket',
+ type: 'date_histogram',
+ schema: 'bucketAgg',
+ params: {
+ field: '@timestamp',
+ interval: '10s',
+ },
+ },
+ customMetric: {
+ id: '1-metric',
+ type: 'count',
+ schema: 'metricAgg',
+ params: {},
+ },
+ },
+ },
+ {
+ enabled: true,
+ id: '2',
+ type: 'terms',
+ schema: 'bucket',
+ params: {
+ field: 'clientip',
+ },
+ },
+ {
+ enabled: true,
+ id: '3',
+ type: 'terms',
+ schema: 'bucket',
+ params: {
+ field: 'machine.os.raw',
+ },
+ },
+ ];
+
+ const ac = new AggConfigs(
+ indexPattern,
+ configStates,
+ { typesRegistry, hierarchical: true, probability: 0.5 },
+ jest.fn()
+ );
+ const topLevelDsl = ac.toDsl();
+
+ expect(Object.keys(topLevelDsl)).toContain('sampling');
+ expect(Object.keys(topLevelDsl.sampling)).toEqual(['random_sampler', 'aggs']);
+ expect(Object.keys(topLevelDsl.sampling.aggs)).toContain('2');
+ expect(Object.keys(topLevelDsl.sampling.aggs['2'].aggs)).toEqual(['1', '3', '1-bucket']);
+ });
+
+ it('should not prepend a sampling agg when no nested agg is avaialble', () => {
+ const ac = new AggConfigs(
+ indexPattern,
+ [
+ {
+ enabled: true,
+ type: 'count',
+ schema: 'metric',
+ },
+ ],
+ { typesRegistry, probability: 0.5 },
+ jest.fn()
+ );
+ const topLevelDsl = ac.toDsl();
+ expect(Object.keys(topLevelDsl)).not.toContain('sampling');
+ });
});
describe('#postFlightTransform', () => {
@@ -854,4 +939,74 @@ describe('AggConfigs', () => {
`);
});
});
+
+ describe('isSamplingEnabled', () => {
+ it('should return false if probability is 1', () => {
+ const ac = new AggConfigs(
+ indexPattern,
+ [{ enabled: true, type: 'avg', schema: 'metric', params: { field: 'bytes' } }],
+ { typesRegistry, probability: 1 },
+ jest.fn()
+ );
+
+ expect(ac.isSamplingEnabled()).toBeFalsy();
+ });
+
+ it('should return true if probability is less than 1', () => {
+ const ac = new AggConfigs(
+ indexPattern,
+ [{ enabled: true, type: 'avg', schema: 'metric', params: { field: 'bytes' } }],
+ { typesRegistry, probability: 0.1 },
+ jest.fn()
+ );
+
+ expect(ac.isSamplingEnabled()).toBeTruthy();
+ });
+
+ it('should return false when all aggs have hasNoDsl flag enabled', () => {
+ const ac = new AggConfigs(
+ indexPattern,
+ [
+ {
+ enabled: true,
+ type: 'count',
+ schema: 'metric',
+ },
+ ],
+ { typesRegistry, probability: 1 },
+ jest.fn()
+ );
+
+ expect(ac.isSamplingEnabled()).toBeFalsy();
+ });
+
+ it('should return false when no nested aggs are avaialble', () => {
+ const ac = new AggConfigs(
+ indexPattern,
+ [{ enabled: false, type: 'avg', schema: 'metric', params: { field: 'bytes' } }],
+ { typesRegistry, probability: 1 },
+ jest.fn()
+ );
+
+ expect(ac.isSamplingEnabled()).toBeFalsy();
+ });
+
+ it('should return true if at least one nested agg is available and probability < 1', () => {
+ const ac = new AggConfigs(
+ indexPattern,
+ [
+ {
+ enabled: true,
+ type: 'count',
+ schema: 'metric',
+ },
+ { enabled: true, type: 'avg', schema: 'metric', params: { field: 'bytes' } },
+ ],
+ { typesRegistry, probability: 0.1 },
+ jest.fn()
+ );
+
+ expect(ac.isSamplingEnabled()).toBeTruthy();
+ });
+ });
});
diff --git a/src/plugins/data/common/search/aggs/agg_configs.ts b/src/plugins/data/common/search/aggs/agg_configs.ts
index c61ca69e0c6df..7cab863fba11d 100644
--- a/src/plugins/data/common/search/aggs/agg_configs.ts
+++ b/src/plugins/data/common/search/aggs/agg_configs.ts
@@ -26,6 +26,7 @@ import { AggTypesDependencies, GetConfigFn, getUserTimeZone } from '../..';
import { getTime, calculateBounds } from '../..';
import type { IBucketAggConfig } from './buckets';
import { insertTimeShiftSplit, mergeTimeShifts } from './utils/time_splits';
+import { createSamplerAgg, isSamplingEnabled } from './utils/sampler';
function removeParentAggs(obj: any) {
for (const prop in obj) {
@@ -55,6 +56,8 @@ export interface AggConfigsOptions {
hierarchical?: boolean;
aggExecutionContext?: AggTypesDependencies['aggExecutionContext'];
partialRows?: boolean;
+ probability?: number;
+ samplerSeed?: number;
}
export type CreateAggConfigParams = Assign
;
@@ -107,6 +110,17 @@ export class AggConfigs {
return this.opts.partialRows ?? false;
}
+ public get samplerConfig() {
+ return { probability: this.opts.probability ?? 1, seed: this.opts.samplerSeed };
+ }
+
+ isSamplingEnabled() {
+ return (
+ isSamplingEnabled(this.opts.probability) &&
+ this.getRequestAggs().filter((agg) => !agg.type.hasNoDsl).length > 0
+ );
+ }
+
setTimeFields(timeFields: string[] | undefined) {
this.timeFields = timeFields;
}
@@ -225,7 +239,7 @@ export class AggConfigs {
}
toDsl(): Record {
- const dslTopLvl = {};
+ const dslTopLvl: Record = {};
let dslLvlCursor: Record;
let nestedMetrics: Array<{ config: AggConfig; dsl: Record }> | [];
@@ -254,10 +268,21 @@ export class AggConfigs {
(config) => 'splitForTimeShift' in config.type && config.type.splitForTimeShift(config, this)
);
+ if (this.isSamplingEnabled()) {
+ dslTopLvl.sampling = createSamplerAgg({
+ probability: this.opts.probability ?? 1,
+ seed: this.opts.samplerSeed,
+ });
+ }
+
requestAggs.forEach((config: AggConfig, i: number, list) => {
if (!dslLvlCursor) {
// start at the top level
dslLvlCursor = dslTopLvl;
+ // when sampling jump directly to the aggs
+ if (this.isSamplingEnabled()) {
+ dslLvlCursor = dslLvlCursor.sampling.aggs;
+ }
} else {
const prevConfig: AggConfig = list[i - 1];
const prevDsl = dslLvlCursor[prevConfig.id];
@@ -452,7 +477,12 @@ export class AggConfigs {
doc_count: response.rawResponse.hits?.total as estypes.AggregationsAggregate,
};
}
- const aggCursor = transformedRawResponse.aggregations!;
+ const aggCursor = this.isSamplingEnabled()
+ ? (transformedRawResponse.aggregations!.sampling! as Record<
+ string,
+ estypes.AggregationsAggregate
+ >)
+ : transformedRawResponse.aggregations!;
mergeTimeShifts(this, aggCursor);
return {
@@ -531,6 +561,8 @@ export class AggConfigs {
metricsAtAllLevels: this.hierarchical,
partialRows: this.partialRows,
aggs: this.aggs.map((agg) => buildExpression(agg.toExpressionAst())),
+ probability: this.opts.probability,
+ samplerSeed: this.opts.samplerSeed,
}),
]).toAst();
}
diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts
index 6ce588b98fa9c..be2e969f279b1 100644
--- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts
+++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts
@@ -18,6 +18,8 @@ import { AggConfigs, CreateAggConfigParams } from '../agg_configs';
import { BUCKET_TYPES } from './bucket_agg_types';
import { IBucketAggConfig } from './bucket_agg_type';
import { mockAggTypesRegistry } from '../test_helpers';
+import { estypes } from '@elastic/elasticsearch';
+import { isSamplingEnabled } from '../utils/sampler';
const indexPattern = {
id: '1234',
@@ -281,71 +283,63 @@ const nestedOtherResponse = {
describe('Terms Agg Other bucket helper', () => {
const typesRegistry = mockAggTypesRegistry();
- const getAggConfigs = (aggs: CreateAggConfigParams[] = []) => {
- return new AggConfigs(indexPattern, [...aggs], { typesRegistry }, jest.fn());
- };
-
- describe('buildOtherBucketAgg', () => {
- test('returns a function', () => {
- const aggConfigs = getAggConfigs(singleTerm.aggs);
- const agg = buildOtherBucketAgg(
- aggConfigs,
- aggConfigs.aggs[0] as IBucketAggConfig,
- singleTermResponse
- );
- expect(typeof agg).toBe('function');
- });
-
- test('correctly builds query with single terms agg', () => {
- const aggConfigs = getAggConfigs(singleTerm.aggs);
- const agg = buildOtherBucketAgg(
- aggConfigs,
- aggConfigs.aggs[0] as IBucketAggConfig,
- singleTermResponse
- );
- const expectedResponse = {
- aggs: undefined,
- filters: {
- filters: {
- '': {
- bool: {
- must: [],
- filter: [{ exists: { field: 'machine.os.raw' } }],
- should: [],
- must_not: [
- { match_phrase: { 'machine.os.raw': 'ios' } },
- { match_phrase: { 'machine.os.raw': 'win xp' } },
- ],
- },
- },
+ for (const probability of [1, 0.5, undefined]) {
+ function getTitlePostfix() {
+ if (!isSamplingEnabled(probability)) {
+ return '';
+ }
+ return ` - with sampling (probability = ${probability})`;
+ }
+ function enrichResponseWithSampling(response: any) {
+ if (!isSamplingEnabled(probability)) {
+ return response;
+ }
+ return {
+ ...response,
+ aggregations: {
+ sampling: {
+ ...response.aggregations,
},
},
};
- expect(agg).toBeDefined();
- if (agg) {
- expect(agg()['other-filter']).toEqual(expectedResponse);
- }
- });
+ }
- test('correctly builds query for nested terms agg', () => {
- const aggConfigs = getAggConfigs(nestedTerm.aggs);
- const agg = buildOtherBucketAgg(
- aggConfigs,
- aggConfigs.aggs[1] as IBucketAggConfig,
- nestedTermResponse
- );
- const expectedResponse = {
- 'other-filter': {
+ function getAggConfigs(aggs: CreateAggConfigParams[] = []) {
+ return new AggConfigs(indexPattern, [...aggs], { typesRegistry, probability }, jest.fn());
+ }
+
+ function getTopAggregations(updatedResponse: estypes.SearchResponse) {
+ return !isSamplingEnabled(probability)
+ ? updatedResponse.aggregations!
+ : (updatedResponse.aggregations!.sampling as Record);
+ }
+
+ describe(`buildOtherBucketAgg${getTitlePostfix()}`, () => {
+ test('returns a function', () => {
+ const aggConfigs = getAggConfigs(singleTerm.aggs);
+ const agg = buildOtherBucketAgg(
+ aggConfigs,
+ aggConfigs.aggs[0] as IBucketAggConfig,
+ enrichResponseWithSampling(singleTermResponse)
+ );
+ expect(typeof agg).toBe('function');
+ });
+
+ test('correctly builds query with single terms agg', () => {
+ const aggConfigs = getAggConfigs(singleTerm.aggs);
+ const agg = buildOtherBucketAgg(
+ aggConfigs,
+ aggConfigs.aggs[0] as IBucketAggConfig,
+ enrichResponseWithSampling(singleTermResponse)
+ );
+ const expectedResponse = {
aggs: undefined,
filters: {
filters: {
- [`${SEP}IN-with-dash`]: {
+ '': {
bool: {
must: [],
- filter: [
- { match_phrase: { 'geo.src': 'IN-with-dash' } },
- { exists: { field: 'machine.os.raw' } },
- ],
+ filter: [{ exists: { field: 'machine.os.raw' } }],
should: [],
must_not: [
{ match_phrase: { 'machine.os.raw': 'ios' } },
@@ -353,272 +347,322 @@ describe('Terms Agg Other bucket helper', () => {
],
},
},
- [`${SEP}US-with-dash`]: {
- bool: {
- must: [],
- filter: [
- { match_phrase: { 'geo.src': 'US-with-dash' } },
- { exists: { field: 'machine.os.raw' } },
- ],
- should: [],
- must_not: [
- { match_phrase: { 'machine.os.raw': 'ios' } },
- { match_phrase: { 'machine.os.raw': 'win xp' } },
- ],
+ },
+ },
+ };
+ expect(agg).toBeDefined();
+ if (agg) {
+ const resp = agg();
+ const topAgg = !isSamplingEnabled(probability) ? resp : resp.sampling!.aggs;
+ expect(topAgg['other-filter']).toEqual(expectedResponse);
+ }
+ });
+
+ test('correctly builds query for nested terms agg', () => {
+ const aggConfigs = getAggConfigs(nestedTerm.aggs);
+ const agg = buildOtherBucketAgg(
+ aggConfigs,
+ aggConfigs.aggs[1] as IBucketAggConfig,
+ enrichResponseWithSampling(nestedTermResponse)
+ );
+ const expectedResponse = {
+ 'other-filter': {
+ aggs: undefined,
+ filters: {
+ filters: {
+ [`${SEP}IN-with-dash`]: {
+ bool: {
+ must: [],
+ filter: [
+ { match_phrase: { 'geo.src': 'IN-with-dash' } },
+ { exists: { field: 'machine.os.raw' } },
+ ],
+ should: [],
+ must_not: [
+ { match_phrase: { 'machine.os.raw': 'ios' } },
+ { match_phrase: { 'machine.os.raw': 'win xp' } },
+ ],
+ },
+ },
+ [`${SEP}US-with-dash`]: {
+ bool: {
+ must: [],
+ filter: [
+ { match_phrase: { 'geo.src': 'US-with-dash' } },
+ { exists: { field: 'machine.os.raw' } },
+ ],
+ should: [],
+ must_not: [
+ { match_phrase: { 'machine.os.raw': 'ios' } },
+ { match_phrase: { 'machine.os.raw': 'win xp' } },
+ ],
+ },
},
},
},
},
- },
- };
- expect(agg).toBeDefined();
- if (agg) {
- expect(agg()).toEqual(expectedResponse);
- }
- });
+ };
+ expect(agg).toBeDefined();
+ if (agg) {
+ const resp = agg();
+ const topAgg = !isSamplingEnabled(probability) ? resp : resp.sampling!.aggs;
+ // console.log({ probability }, JSON.stringify(topAgg, null, 2));
+ expect(topAgg).toEqual(expectedResponse);
+ }
+ });
- test('correctly builds query for nested terms agg with one disabled', () => {
- const oneDisabledNestedTerms = {
- aggs: [
- {
- id: '2',
- type: BUCKET_TYPES.TERMS,
- enabled: false,
- params: {
- field: {
- name: 'machine.os.raw',
- indexPattern,
- filterable: true,
+ test('correctly builds query for nested terms agg with one disabled', () => {
+ const oneDisabledNestedTerms = {
+ aggs: [
+ {
+ id: '2',
+ type: BUCKET_TYPES.TERMS,
+ enabled: false,
+ params: {
+ field: {
+ name: 'machine.os.raw',
+ indexPattern,
+ filterable: true,
+ },
+ size: 2,
+ otherBucket: false,
+ missingBucket: true,
},
- size: 2,
- otherBucket: false,
- missingBucket: true,
},
- },
- {
- id: '1',
- type: BUCKET_TYPES.TERMS,
- params: {
- field: {
- name: 'geo.src',
- indexPattern,
- filterable: true,
+ {
+ id: '1',
+ type: BUCKET_TYPES.TERMS,
+ params: {
+ field: {
+ name: 'geo.src',
+ indexPattern,
+ filterable: true,
+ },
+ size: 2,
+ otherBucket: true,
+ missingBucket: false,
},
- size: 2,
- otherBucket: true,
- missingBucket: false,
},
- },
- ],
- };
- const aggConfigs = getAggConfigs(oneDisabledNestedTerms.aggs);
- const agg = buildOtherBucketAgg(
- aggConfigs,
- aggConfigs.aggs[1] as IBucketAggConfig,
- singleTermResponse
- );
- const expectedResponse = {
- 'other-filter': {
- aggs: undefined,
- filters: {
+ ],
+ };
+ const aggConfigs = getAggConfigs(oneDisabledNestedTerms.aggs);
+ const agg = buildOtherBucketAgg(
+ aggConfigs,
+ aggConfigs.aggs[1] as IBucketAggConfig,
+ enrichResponseWithSampling(singleTermResponse)
+ );
+ const expectedResponse = {
+ 'other-filter': {
+ aggs: undefined,
filters: {
- '': {
- bool: {
- filter: [
- {
- exists: {
- field: 'geo.src',
+ filters: {
+ '': {
+ bool: {
+ filter: [
+ {
+ exists: {
+ field: 'geo.src',
+ },
},
- },
- ],
- must: [],
- must_not: [
- {
- match_phrase: {
- 'geo.src': 'ios',
+ ],
+ must: [],
+ must_not: [
+ {
+ match_phrase: {
+ 'geo.src': 'ios',
+ },
},
- },
- {
- match_phrase: {
- 'geo.src': 'win xp',
+ {
+ match_phrase: {
+ 'geo.src': 'win xp',
+ },
},
- },
- ],
- should: [],
+ ],
+ should: [],
+ },
},
},
},
},
- },
- };
- expect(agg).toBeDefined();
- if (agg) {
- expect(agg()).toEqual(expectedResponse);
- }
- });
+ };
+ expect(agg).toBeDefined();
+ if (agg) {
+ const resp = agg();
+ const topAgg = !isSamplingEnabled(probability) ? resp : resp.sampling!.aggs;
+ expect(topAgg).toEqual(expectedResponse);
+ }
+ });
- test('does not build query if sum_other_doc_count is 0 (exhaustive terms)', () => {
- const aggConfigs = getAggConfigs(nestedTerm.aggs);
- expect(
- buildOtherBucketAgg(
+ test('does not build query if sum_other_doc_count is 0 (exhaustive terms)', () => {
+ const aggConfigs = getAggConfigs(nestedTerm.aggs);
+ expect(
+ buildOtherBucketAgg(
+ aggConfigs,
+ aggConfigs.aggs[1] as IBucketAggConfig,
+ enrichResponseWithSampling(exhaustiveNestedTermResponse)
+ )
+ ).toBeFalsy();
+ });
+
+ test('excludes exists filter for scripted fields', () => {
+ const aggConfigs = getAggConfigs(nestedTerm.aggs);
+ aggConfigs.aggs[1].params.field = {
+ ...aggConfigs.aggs[1].params.field,
+ scripted: true,
+ };
+ const agg = buildOtherBucketAgg(
aggConfigs,
aggConfigs.aggs[1] as IBucketAggConfig,
- exhaustiveNestedTermResponse
- )
- ).toBeFalsy();
- });
-
- test('excludes exists filter for scripted fields', () => {
- const aggConfigs = getAggConfigs(nestedTerm.aggs);
- aggConfigs.aggs[1].params.field.scripted = true;
- const agg = buildOtherBucketAgg(
- aggConfigs,
- aggConfigs.aggs[1] as IBucketAggConfig,
- nestedTermResponse
- );
- const expectedResponse = {
- 'other-filter': {
- aggs: undefined,
- filters: {
+ enrichResponseWithSampling(nestedTermResponse)
+ );
+ const expectedResponse = {
+ 'other-filter': {
+ aggs: undefined,
filters: {
- [`${SEP}IN-with-dash`]: {
- bool: {
- must: [],
- filter: [{ match_phrase: { 'geo.src': 'IN-with-dash' } }],
- should: [],
- must_not: [
- {
- script: {
+ filters: {
+ [`${SEP}IN-with-dash`]: {
+ bool: {
+ must: [],
+ filter: [{ match_phrase: { 'geo.src': 'IN-with-dash' } }],
+ should: [],
+ must_not: [
+ {
script: {
- lang: undefined,
- params: { value: 'ios' },
- source: '(undefined) == value',
+ script: {
+ lang: undefined,
+ params: { value: 'ios' },
+ source: '(undefined) == value',
+ },
},
},
- },
- {
- script: {
+ {
script: {
- lang: undefined,
- params: { value: 'win xp' },
- source: '(undefined) == value',
+ script: {
+ lang: undefined,
+ params: { value: 'win xp' },
+ source: '(undefined) == value',
+ },
},
},
- },
- ],
+ ],
+ },
},
- },
- [`${SEP}US-with-dash`]: {
- bool: {
- must: [],
- filter: [{ match_phrase: { 'geo.src': 'US-with-dash' } }],
- should: [],
- must_not: [
- {
- script: {
+ [`${SEP}US-with-dash`]: {
+ bool: {
+ must: [],
+ filter: [{ match_phrase: { 'geo.src': 'US-with-dash' } }],
+ should: [],
+ must_not: [
+ {
script: {
- lang: undefined,
- params: { value: 'ios' },
- source: '(undefined) == value',
+ script: {
+ lang: undefined,
+ params: { value: 'ios' },
+ source: '(undefined) == value',
+ },
},
},
- },
- {
- script: {
+ {
script: {
- lang: undefined,
- params: { value: 'win xp' },
- source: '(undefined) == value',
+ script: {
+ lang: undefined,
+ params: { value: 'win xp' },
+ source: '(undefined) == value',
+ },
},
},
- },
- ],
+ ],
+ },
},
},
},
},
- },
- };
- expect(agg).toBeDefined();
- if (agg) {
- expect(agg()).toEqual(expectedResponse);
- }
- });
+ };
+ expect(agg).toBeDefined();
+ if (agg) {
+ const resp = agg();
+ const topAgg = !isSamplingEnabled(probability) ? resp : resp.sampling!.aggs;
+ expect(topAgg).toEqual(expectedResponse);
+ }
+ });
- test('returns false when nested terms agg has no buckets', () => {
- const aggConfigs = getAggConfigs(nestedTerm.aggs);
- const agg = buildOtherBucketAgg(
- aggConfigs,
- aggConfigs.aggs[1] as IBucketAggConfig,
- nestedTermResponseNoResults
- );
+ test('returns false when nested terms agg has no buckets', () => {
+ const aggConfigs = getAggConfigs(nestedTerm.aggs);
+ const agg = buildOtherBucketAgg(
+ aggConfigs,
+ aggConfigs.aggs[1] as IBucketAggConfig,
+ enrichResponseWithSampling(nestedTermResponseNoResults)
+ );
- expect(agg).toEqual(false);
+ expect(agg).toEqual(false);
+ });
});
- });
- describe('mergeOtherBucketAggResponse', () => {
- test('correctly merges other bucket with single terms agg', () => {
- const aggConfigs = getAggConfigs(singleTerm.aggs);
- const otherAggConfig = buildOtherBucketAgg(
- aggConfigs,
- aggConfigs.aggs[0] as IBucketAggConfig,
- singleTermResponse
- );
-
- expect(otherAggConfig).toBeDefined();
- if (otherAggConfig) {
- const mergedResponse = mergeOtherBucketAggResponse(
+ describe(`mergeOtherBucketAggResponse${getTitlePostfix()}`, () => {
+ test('correctly merges other bucket with single terms agg', () => {
+ const aggConfigs = getAggConfigs(singleTerm.aggs);
+ const otherAggConfig = buildOtherBucketAgg(
aggConfigs,
- singleTermResponse,
- singleOtherResponse,
aggConfigs.aggs[0] as IBucketAggConfig,
- otherAggConfig(),
- constructSingleTermOtherFilter
+ enrichResponseWithSampling(singleTermResponse)
);
- expect((mergedResponse!.aggregations!['1'] as any).buckets[3].key).toEqual('__other__');
- }
- });
- test('correctly merges other bucket with nested terms agg', () => {
- const aggConfigs = getAggConfigs(nestedTerm.aggs);
- const otherAggConfig = buildOtherBucketAgg(
- aggConfigs,
- aggConfigs.aggs[1] as IBucketAggConfig,
- nestedTermResponse
- );
+ expect(otherAggConfig).toBeDefined();
+ if (otherAggConfig) {
+ const mergedResponse = mergeOtherBucketAggResponse(
+ aggConfigs,
+ enrichResponseWithSampling(singleTermResponse),
+ enrichResponseWithSampling(singleOtherResponse),
+ aggConfigs.aggs[0] as IBucketAggConfig,
+ otherAggConfig(),
+ constructSingleTermOtherFilter
+ );
- expect(otherAggConfig).toBeDefined();
- if (otherAggConfig) {
- const mergedResponse = mergeOtherBucketAggResponse(
+ const topAgg = getTopAggregations(mergedResponse);
+ expect((topAgg['1'] as any).buckets[3].key).toEqual('__other__');
+ }
+ });
+
+ test('correctly merges other bucket with nested terms agg', () => {
+ const aggConfigs = getAggConfigs(nestedTerm.aggs);
+ const otherAggConfig = buildOtherBucketAgg(
aggConfigs,
- nestedTermResponse,
- nestedOtherResponse,
aggConfigs.aggs[1] as IBucketAggConfig,
- otherAggConfig(),
- constructSingleTermOtherFilter
+ enrichResponseWithSampling(nestedTermResponse)
);
- expect((mergedResponse!.aggregations!['1'] as any).buckets[1]['2'].buckets[3].key).toEqual(
- '__other__'
- );
- }
+ expect(otherAggConfig).toBeDefined();
+ if (otherAggConfig) {
+ const mergedResponse = mergeOtherBucketAggResponse(
+ aggConfigs,
+ enrichResponseWithSampling(nestedTermResponse),
+ enrichResponseWithSampling(nestedOtherResponse),
+ aggConfigs.aggs[1] as IBucketAggConfig,
+ otherAggConfig(),
+ constructSingleTermOtherFilter
+ );
+
+ const topAgg = getTopAggregations(mergedResponse);
+ expect((topAgg['1'] as any).buckets[1]['2'].buckets[3].key).toEqual('__other__');
+ }
+ });
});
- });
- describe('updateMissingBucket', () => {
- test('correctly updates missing bucket key', () => {
- const aggConfigs = getAggConfigs(nestedTerm.aggs);
- const updatedResponse = updateMissingBucket(
- singleTermResponse,
- aggConfigs,
- aggConfigs.aggs[0] as IBucketAggConfig
- );
- expect(
- (updatedResponse!.aggregations!['1'] as any).buckets.find(
- (bucket: Record) => bucket.key === '__missing__'
- )
- ).toBeDefined();
+ describe(`updateMissingBucket${getTitlePostfix()}`, () => {
+ test('correctly updates missing bucket key', () => {
+ const aggConfigs = getAggConfigs(nestedTerm.aggs);
+ const updatedResponse = updateMissingBucket(
+ enrichResponseWithSampling(singleTermResponse),
+ aggConfigs,
+ aggConfigs.aggs[0] as IBucketAggConfig
+ );
+ const topAgg = getTopAggregations(updatedResponse);
+ expect(
+ (topAgg['1'] as any).buckets.find(
+ (bucket: Record) => bucket.key === '__missing__'
+ )
+ ).toBeDefined();
+ });
});
- });
+ }
});
diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts
index f695dc1b1d399..68c64f67ef27f 100644
--- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts
+++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts
@@ -20,6 +20,7 @@ import { AggGroupNames } from '../agg_groups';
import { IAggConfigs } from '../agg_configs';
import { IAggType } from '../agg_type';
import { IAggConfig } from '../agg_config';
+import { createSamplerAgg } from '../utils/sampler';
export const OTHER_BUCKET_SEPARATOR = '╰┄►';
@@ -128,6 +129,28 @@ const getOtherAggTerms = (requestAgg: Record, key: string, otherAgg
.map((filter: Record) => filter.match_phrase[otherAgg.params.field.name]);
};
+/**
+ * Helper function to handle sampling case and get the correct cursor agg from a request object
+ */
+const getCorrectAggCursorFromRequest = (
+ requestAgg: Record,
+ aggConfigs: IAggConfigs
+) => {
+ return aggConfigs.isSamplingEnabled() ? requestAgg.sampling.aggs : requestAgg;
+};
+
+/**
+ * Helper function to handle sampling case and get the correct cursor agg from a response object
+ */
+const getCorrectAggregationsCursorFromResponse = (
+ response: estypes.SearchResponse,
+ aggConfigs: IAggConfigs
+) => {
+ return aggConfigs.isSamplingEnabled()
+ ? (response.aggregations?.sampling as Record)
+ : response.aggregations;
+};
+
export const buildOtherBucketAgg = (
aggConfigs: IAggConfigs,
aggWithOtherBucket: IAggConfig,
@@ -234,7 +257,13 @@ export const buildOtherBucketAgg = (
bool: buildQueryFromFilters(filters, indexPattern),
};
};
- walkBucketTree(0, response.aggregations, bucketAggs[0].id, [], '');
+ walkBucketTree(
+ 0,
+ getCorrectAggregationsCursorFromResponse(response, aggConfigs),
+ bucketAggs[0].id,
+ [],
+ ''
+ );
// bail if there were no bucket results
if (noAggBucketResults || exhaustiveBuckets) {
@@ -242,6 +271,14 @@ export const buildOtherBucketAgg = (
}
return () => {
+ if (aggConfigs.isSamplingEnabled()) {
+ return {
+ sampling: {
+ ...createSamplerAgg(aggConfigs.samplerConfig),
+ aggs: { 'other-filter': resultAgg },
+ },
+ };
+ }
return {
'other-filter': resultAgg,
};
@@ -257,16 +294,27 @@ export const mergeOtherBucketAggResponse = (
otherFilterBuilder: (requestAgg: Record, key: string, otherAgg: IAggConfig) => Filter
): estypes.SearchResponse => {
const updatedResponse = cloneDeep(response);
- each(otherResponse.aggregations['other-filter'].buckets, (bucket, key) => {
+ const aggregationsRoot = getCorrectAggregationsCursorFromResponse(otherResponse, aggsConfig);
+ const updatedAggregationsRoot = getCorrectAggregationsCursorFromResponse(
+ updatedResponse,
+ aggsConfig
+ );
+ const buckets =
+ 'buckets' in aggregationsRoot!['other-filter'] ? aggregationsRoot!['other-filter'].buckets : {};
+ each(buckets, (bucket, key) => {
if (!bucket.doc_count || key === undefined) return;
const bucketKey = key.replace(new RegExp(`^${OTHER_BUCKET_SEPARATOR}`), '');
const aggResultBuckets = getAggResultBuckets(
aggsConfig,
- updatedResponse.aggregations,
+ updatedAggregationsRoot,
otherAgg,
bucketKey
);
- const otherFilter = otherFilterBuilder(requestAgg, key, otherAgg);
+ const otherFilter = otherFilterBuilder(
+ getCorrectAggCursorFromRequest(requestAgg, aggsConfig),
+ key,
+ otherAgg
+ );
bucket.filters = [otherFilter];
bucket.key = '__other__';
@@ -290,7 +338,10 @@ export const updateMissingBucket = (
agg: IAggConfig
) => {
const updatedResponse = cloneDeep(response);
- const aggResultBuckets = getAggConfigResultMissingBuckets(updatedResponse.aggregations, agg.id);
+ const aggResultBuckets = getAggConfigResultMissingBuckets(
+ getCorrectAggregationsCursorFromResponse(updatedResponse, aggConfigs),
+ agg.id
+ );
aggResultBuckets.forEach((bucket) => {
bucket.key = '__missing__';
});
diff --git a/src/plugins/data/common/search/aggs/utils/sampler.ts b/src/plugins/data/common/search/aggs/utils/sampler.ts
new file mode 100644
index 0000000000000..5a6fde63b0a29
--- /dev/null
+++ b/src/plugins/data/common/search/aggs/utils/sampler.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 function createSamplerAgg({
+ type = 'random_sampler',
+ probability,
+ seed,
+}: {
+ type?: string;
+ probability: number;
+ seed?: number;
+}) {
+ return {
+ [type]: {
+ probability,
+ seed,
+ },
+ aggs: {},
+ };
+}
+
+export function isSamplingEnabled(probability: number | undefined) {
+ return probability != null && probability !== 1;
+}
diff --git a/src/plugins/data/common/search/aggs/utils/time_splits.ts b/src/plugins/data/common/search/aggs/utils/time_splits.ts
index c2fe8aaca0fb2..b1262683446f3 100644
--- a/src/plugins/data/common/search/aggs/utils/time_splits.ts
+++ b/src/plugins/data/common/search/aggs/utils/time_splits.ts
@@ -427,11 +427,11 @@ export function insertTimeShiftSplit(
const timeRange = aggConfigs.timeRange;
const filters: Record = {};
const timeField = aggConfigs.timeFields[0];
+ const timeFilter = getTime(aggConfigs.indexPattern, timeRange, {
+ fieldName: timeField,
+ forceNow: aggConfigs.forceNow,
+ }) as RangeFilter;
Object.entries(timeShifts).forEach(([key, shift]) => {
- const timeFilter = getTime(aggConfigs.indexPattern, timeRange, {
- fieldName: timeField,
- forceNow: aggConfigs.forceNow,
- }) as RangeFilter;
if (timeFilter) {
filters[key] = {
range: {
diff --git a/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts b/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts
index c8296b3c06557..f0b55de8ffeff 100644
--- a/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts
+++ b/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts
@@ -32,6 +32,8 @@ interface Arguments {
metricsAtAllLevels?: boolean;
partialRows?: boolean;
timeFields?: string[];
+ probability?: number;
+ samplerSeed?: number;
}
export type EsaggsExpressionFunctionDefinition = ExpressionFunctionDefinition<
@@ -94,6 +96,21 @@ export const getEsaggsMeta: () => Omit
defaultMessage: 'Provide time fields to get the resolved time ranges for the query',
}),
},
+ probability: {
+ types: ['number'],
+ default: 1,
+ help: i18n.translate('data.search.functions.esaggs.probability.help', {
+ defaultMessage:
+ 'The probability that a document will be included in the aggregated data. Uses random sampler.',
+ }),
+ },
+ samplerSeed: {
+ types: ['number'],
+ help: i18n.translate('data.search.functions.esaggs.samplerSeed.help', {
+ defaultMessage:
+ 'The seed to generate the random sampling of documents. Uses random sampler.',
+ }),
+ },
},
});
diff --git a/src/plugins/data/common/search/tabify/tabify.test.ts b/src/plugins/data/common/search/tabify/tabify.test.ts
index d7d983fabfdaf..90ef53623c298 100644
--- a/src/plugins/data/common/search/tabify/tabify.test.ts
+++ b/src/plugins/data/common/search/tabify/tabify.test.ts
@@ -11,184 +11,217 @@ import type { DataView } from '@kbn/data-views-plugin/common';
import { AggConfigs, BucketAggParam, IAggConfig, IAggConfigs } from '../aggs';
import { mockAggTypesRegistry } from '../aggs/test_helpers';
import { metricOnly, threeTermBuckets } from './fixtures/fake_hierarchical_data';
+import { isSamplingEnabled } from '../aggs/utils/sampler';
describe('tabifyAggResponse Integration', () => {
const typesRegistry = mockAggTypesRegistry();
- const createAggConfigs = (aggs: IAggConfig[] = []) => {
- const field = {
- name: '@timestamp',
- };
-
- const indexPattern = {
- id: '1234',
- title: 'logstash-*',
- fields: {
- getByName: () => field,
- filter: () => [field],
- },
- getFormatterForField: () => ({
- toJSON: () => '{}',
- }),
- } as unknown as DataView;
-
- return new AggConfigs(indexPattern, aggs, { typesRegistry }, jest.fn());
- };
-
- const mockAggConfig = (agg: any): IAggConfig => agg as unknown as IAggConfig;
-
- test('transforms a simple response properly', () => {
- const aggConfigs = createAggConfigs([{ type: 'count' } as any]);
-
- const resp = tabifyAggResponse(aggConfigs, metricOnly, {
- metricsAtAllLevels: true,
- });
-
- expect(resp).toHaveProperty('rows');
- expect(resp).toHaveProperty('columns');
+ for (const probability of [1, 0.5, undefined]) {
+ function getTitlePostfix() {
+ if (!isSamplingEnabled(probability)) {
+ return '';
+ }
+ return ` - with sampling (probability = ${probability})`;
+ }
- expect(resp.rows).toHaveLength(1);
- expect(resp.columns).toHaveLength(1);
+ function enrichResponseWithSampling(response: any) {
+ if (!isSamplingEnabled(probability)) {
+ return response;
+ }
+ return {
+ ...response,
+ aggregations: {
+ sampling: {
+ ...response.aggregations,
+ },
+ },
+ };
+ }
- expect(resp.rows[0]).toEqual({ 'col-0-1': 1000 });
- expect(resp.columns[0]).toHaveProperty('name', aggConfigs.aggs[0].makeLabel());
+ const createAggConfigs = (aggs: IAggConfig[] = []) => {
+ const field = {
+ name: '@timestamp',
+ };
+
+ const indexPattern = {
+ id: '1234',
+ title: 'logstash-*',
+ fields: {
+ getByName: () => field,
+ filter: () => [field],
+ },
+ getFormatterForField: () => ({
+ toJSON: () => '{}',
+ }),
+ } as unknown as DataView;
+
+ return new AggConfigs(indexPattern, aggs, { typesRegistry, probability }, jest.fn());
+ };
- expect(resp).toHaveProperty('meta.type', 'esaggs');
- expect(resp).toHaveProperty('meta.source', '1234');
- expect(resp).toHaveProperty('meta.statistics.totalCount', 1000);
- });
+ const mockAggConfig = (agg: any): IAggConfig => agg as unknown as IAggConfig;
- describe('scaleMetricValues performance check', () => {
- beforeAll(() => {
- typesRegistry.get('count').params.push({
- name: 'scaleMetricValues',
- default: false,
- write: () => {},
- advanced: true,
- } as any as BucketAggParam);
- });
- test('does not call write if scaleMetricValues is not set', () => {
+ test(`transforms a simple response properly${getTitlePostfix()}`, () => {
const aggConfigs = createAggConfigs([{ type: 'count' } as any]);
- const writeMock = jest.fn();
- aggConfigs.getRequestAggs()[0].write = writeMock;
-
- tabifyAggResponse(aggConfigs, metricOnly, {
+ const resp = tabifyAggResponse(aggConfigs, enrichResponseWithSampling(metricOnly), {
metricsAtAllLevels: true,
});
- expect(writeMock).not.toHaveBeenCalled();
- });
- test('does call write if scaleMetricValues is set', () => {
- const aggConfigs = createAggConfigs([
- { type: 'count', params: { scaleMetricValues: true } } as any,
- ]);
+ expect(resp).toHaveProperty('rows');
+ expect(resp).toHaveProperty('columns');
- const writeMock = jest.fn(() => ({}));
- aggConfigs.getRequestAggs()[0].write = writeMock;
-
- tabifyAggResponse(aggConfigs, metricOnly, {
- metricsAtAllLevels: true,
- });
- expect(writeMock).toHaveBeenCalled();
- });
- });
-
- describe('transforms a complex response', () => {
- let esResp: typeof threeTermBuckets;
- let aggConfigs: IAggConfigs;
- let avg: IAggConfig;
- let ext: IAggConfig;
- let src: IAggConfig;
- let os: IAggConfig;
-
- beforeEach(() => {
- aggConfigs = createAggConfigs([
- mockAggConfig({ type: 'avg', schema: 'metric', params: { field: '@timestamp' } }),
- mockAggConfig({ type: 'terms', schema: 'split', params: { field: '@timestamp' } }),
- mockAggConfig({ type: 'terms', schema: 'segment', params: { field: '@timestamp' } }),
- mockAggConfig({ type: 'terms', schema: 'segment', params: { field: '@timestamp' } }),
- ]);
-
- [avg, ext, src, os] = aggConfigs.aggs;
-
- esResp = threeTermBuckets;
- esResp.aggregations.agg_2.buckets[1].agg_3.buckets[0].agg_4.buckets = [];
- });
+ expect(resp.rows).toHaveLength(1);
+ expect(resp.columns).toHaveLength(1);
- // check that the columns of a table are formed properly
- function expectColumns(table: ReturnType, aggs: IAggConfig[]) {
- expect(table.columns).toHaveLength(aggs.length);
+ expect(resp.rows[0]).toEqual({ 'col-0-1': 1000 });
+ expect(resp.columns[0]).toHaveProperty('name', aggConfigs.aggs[0].makeLabel());
- aggs.forEach((agg, i) => {
- expect(table.columns[i]).toHaveProperty('name', agg.makeLabel());
- });
- }
+ expect(resp).toHaveProperty('meta.type', 'esaggs');
+ expect(resp).toHaveProperty('meta.source', '1234');
+ expect(resp).toHaveProperty('meta.statistics.totalCount', 1000);
+ });
- // check that a row has expected values
- function expectRow(
- row: Record,
- asserts: Array<(val: string | number) => void>
- ) {
- expect(typeof row).toBe('object');
-
- asserts.forEach((assert, i: number) => {
- if (row[`col-${i}`]) {
- assert(row[`col-${i}`]);
- }
+ describe(`scaleMetricValues performance check${getTitlePostfix()}`, () => {
+ beforeAll(() => {
+ typesRegistry.get('count').params.push({
+ name: 'scaleMetricValues',
+ default: false,
+ write: () => {},
+ advanced: true,
+ } as any as BucketAggParam);
});
- }
+ test('does not call write if scaleMetricValues is not set', () => {
+ const aggConfigs = createAggConfigs([{ type: 'count' } as any]);
- // check for two character country code
- function expectCountry(val: string | number) {
- expect(typeof val).toBe('string');
- expect(val).toHaveLength(2);
- }
+ const writeMock = jest.fn();
+ aggConfigs.getRequestAggs()[0].write = writeMock;
- // check for an OS term
- function expectExtension(val: string | number) {
- expect(val).toMatch(/^(js|png|html|css|jpg)$/);
- }
+ tabifyAggResponse(aggConfigs, enrichResponseWithSampling(metricOnly), {
+ metricsAtAllLevels: true,
+ });
+ expect(writeMock).not.toHaveBeenCalled();
+ });
- // check for an OS term
- function expectOS(val: string | number) {
- expect(val).toMatch(/^(win|mac|linux)$/);
- }
+ test('does call write if scaleMetricValues is set', () => {
+ const aggConfigs = createAggConfigs([
+ { type: 'count', params: { scaleMetricValues: true } } as any,
+ ]);
- // check for something like an average bytes result
- function expectAvgBytes(val: string | number) {
- expect(typeof val).toBe('number');
- expect(val === 0 || val > 1000).toBeDefined();
- }
+ const writeMock = jest.fn(() => ({}));
+ aggConfigs.getRequestAggs()[0].write = writeMock;
- test('for non-hierarchical vis', () => {
- // the default for a non-hierarchical vis is to display
- // only complete rows, and only put the metrics at the end.
+ tabifyAggResponse(aggConfigs, enrichResponseWithSampling(metricOnly), {
+ metricsAtAllLevels: true,
+ });
+ expect(writeMock).toHaveBeenCalled();
+ });
+ });
- const tabbed = tabifyAggResponse(aggConfigs, esResp, { metricsAtAllLevels: false });
+ describe(`transforms a complex response${getTitlePostfix()}`, () => {
+ let esResp: typeof threeTermBuckets;
+ let aggConfigs: IAggConfigs;
+ let avg: IAggConfig;
+ let ext: IAggConfig;
+ let src: IAggConfig;
+ let os: IAggConfig;
+
+ function getTopAggregations(
+ rawResp: typeof threeTermBuckets
+ ): typeof threeTermBuckets['aggregations'] {
+ return !isSamplingEnabled(probability)
+ ? rawResp.aggregations!
+ : // @ts-ignore
+ rawResp.aggregations!.sampling!;
+ }
+
+ beforeEach(() => {
+ aggConfigs = createAggConfigs([
+ mockAggConfig({ type: 'avg', schema: 'metric', params: { field: '@timestamp' } }),
+ mockAggConfig({ type: 'terms', schema: 'split', params: { field: '@timestamp' } }),
+ mockAggConfig({ type: 'terms', schema: 'segment', params: { field: '@timestamp' } }),
+ mockAggConfig({ type: 'terms', schema: 'segment', params: { field: '@timestamp' } }),
+ ]);
- expectColumns(tabbed, [ext, src, os, avg]);
+ [avg, ext, src, os] = aggConfigs.aggs;
- tabbed.rows.forEach((row) => {
- expectRow(row, [expectExtension, expectCountry, expectOS, expectAvgBytes]);
+ esResp = enrichResponseWithSampling(threeTermBuckets);
+ getTopAggregations(esResp).agg_2.buckets[1].agg_3.buckets[0].agg_4.buckets = [];
});
- });
-
- test('for hierarchical vis', () => {
- const tabbed = tabifyAggResponse(aggConfigs, esResp, { metricsAtAllLevels: true });
- expectColumns(tabbed, [ext, avg, src, avg, os, avg]);
+ // check that the columns of a table are formed properly
+ function expectColumns(table: ReturnType, aggs: IAggConfig[]) {
+ expect(table.columns).toHaveLength(aggs.length);
+
+ aggs.forEach((agg, i) => {
+ expect(table.columns[i]).toHaveProperty('name', agg.makeLabel());
+ });
+ }
+
+ // check that a row has expected values
+ function expectRow(
+ row: Record,
+ asserts: Array<(val: string | number) => void>
+ ) {
+ expect(typeof row).toBe('object');
+
+ asserts.forEach((assert, i: number) => {
+ if (row[`col-${i}`]) {
+ assert(row[`col-${i}`]);
+ }
+ });
+ }
+
+ // check for two character country code
+ function expectCountry(val: string | number) {
+ expect(typeof val).toBe('string');
+ expect(val).toHaveLength(2);
+ }
+
+ // check for an OS term
+ function expectExtension(val: string | number) {
+ expect(val).toMatch(/^(js|png|html|css|jpg)$/);
+ }
+
+ // check for an OS term
+ function expectOS(val: string | number) {
+ expect(val).toMatch(/^(win|mac|linux)$/);
+ }
+
+ // check for something like an average bytes result
+ function expectAvgBytes(val: string | number) {
+ expect(typeof val).toBe('number');
+ expect(val === 0 || val > 1000).toBeDefined();
+ }
+
+ test('for non-hierarchical vis', () => {
+ // the default for a non-hierarchical vis is to display
+ // only complete rows, and only put the metrics at the end.
+
+ const tabbed = tabifyAggResponse(aggConfigs, esResp, { metricsAtAllLevels: false });
+
+ expectColumns(tabbed, [ext, src, os, avg]);
+
+ tabbed.rows.forEach((row) => {
+ expectRow(row, [expectExtension, expectCountry, expectOS, expectAvgBytes]);
+ });
+ });
- tabbed.rows.forEach((row) => {
- expectRow(row, [
- expectExtension,
- expectAvgBytes,
- expectCountry,
- expectAvgBytes,
- expectOS,
- expectAvgBytes,
- ]);
+ test('for hierarchical vis', () => {
+ const tabbed = tabifyAggResponse(aggConfigs, esResp, { metricsAtAllLevels: true });
+
+ expectColumns(tabbed, [ext, avg, src, avg, os, avg]);
+
+ tabbed.rows.forEach((row) => {
+ expectRow(row, [
+ expectExtension,
+ expectAvgBytes,
+ expectCountry,
+ expectAvgBytes,
+ expectOS,
+ expectAvgBytes,
+ ]);
+ });
});
});
- });
+ }
});
diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts
index ea26afd30129a..2c332c1ad6a75 100644
--- a/src/plugins/data/common/search/tabify/tabify.ts
+++ b/src/plugins/data/common/search/tabify/tabify.ts
@@ -147,7 +147,9 @@ export function tabifyAggResponse(
const write = new TabbedAggResponseWriter(aggConfigs, respOpts || {});
const topLevelBucket: AggResponseBucket = {
- ...esResponse.aggregations,
+ ...(aggConfigs.isSamplingEnabled()
+ ? esResponse.aggregations.sampling
+ : esResponse.aggregations),
doc_count: esResponse.aggregations?.doc_count || esResponse.hits?.total,
};
diff --git a/src/plugins/data/public/search/expressions/esaggs.test.ts b/src/plugins/data/public/search/expressions/esaggs.test.ts
index aa26ceab9ae6b..1a04e4ffeb839 100644
--- a/src/plugins/data/public/search/expressions/esaggs.test.ts
+++ b/src/plugins/data/public/search/expressions/esaggs.test.ts
@@ -50,6 +50,7 @@ describe('esaggs expression function - public', () => {
metricsAtAllLevels: true,
partialRows: false,
timeFields: ['@timestamp', 'utc_time'],
+ probability: 1,
};
beforeEach(() => {
@@ -88,7 +89,7 @@ describe('esaggs expression function - public', () => {
expect(startDependencies.aggs.createAggConfigs).toHaveBeenCalledWith(
{},
args.aggs.map((agg) => agg.value),
- { hierarchical: true, partialRows: false }
+ { hierarchical: true, partialRows: false, probability: 1, samplerSeed: undefined }
);
});
@@ -98,6 +99,8 @@ describe('esaggs expression function - public', () => {
expect(startDependencies.aggs.createAggConfigs).toHaveBeenCalledWith({}, [], {
hierarchical: true,
partialRows: false,
+ probability: 1,
+ samplerSeed: undefined,
});
});
diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts
index d944dcd5c1a5d..b82401d4d8caf 100644
--- a/src/plugins/data/public/search/expressions/esaggs.ts
+++ b/src/plugins/data/public/search/expressions/esaggs.ts
@@ -48,7 +48,12 @@ export function getFunctionDefinition({
const aggConfigs = aggs.createAggConfigs(
indexPattern,
args.aggs?.map((agg) => agg.value) ?? [],
- { hierarchical: args.metricsAtAllLevels, partialRows: args.partialRows }
+ {
+ hierarchical: args.metricsAtAllLevels,
+ partialRows: args.partialRows,
+ probability: args.probability,
+ samplerSeed: args.samplerSeed,
+ }
);
const { handleEsaggsRequest } = await import('../../../common/search/expressions');
diff --git a/src/plugins/data/server/search/expressions/esaggs.test.ts b/src/plugins/data/server/search/expressions/esaggs.test.ts
index 166f2719e356d..9954fb2457968 100644
--- a/src/plugins/data/server/search/expressions/esaggs.test.ts
+++ b/src/plugins/data/server/search/expressions/esaggs.test.ts
@@ -51,6 +51,7 @@ describe('esaggs expression function - server', () => {
metricsAtAllLevels: true,
partialRows: false,
timeFields: ['@timestamp', 'utc_time'],
+ probability: 1,
};
beforeEach(() => {
diff --git a/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx b/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx
index 9c86170795961..e9b2b8de5c5f7 100644
--- a/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx
+++ b/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx
@@ -335,7 +335,7 @@ const IndexPatternEditorFlyoutContentComponent = ({
})}
>
{i18n.translate('indexPatternEditor.goToManagementPage', {
- defaultMessage: 'View on data view management page',
+ defaultMessage: 'Manage settings and view field details',
})}
)}
diff --git a/src/plugins/embeddable/README.md b/src/plugins/embeddable/README.md
index 14fab2f8412f3..eae9ef04cfb9b 100644
--- a/src/plugins/embeddable/README.md
+++ b/src/plugins/embeddable/README.md
@@ -90,7 +90,6 @@ export class HelloWorldEmbeddableFactoryDefinition implements EmbeddableFactoryD
The embeddable should implement the `IEmbeddable` interface, and usually, that just extends the base class `Embeddable`.
```tsx
import React from 'react';
-import { render } from 'react-dom';
import { Embeddable } from '@kbn/embeddable-plugin/public';
export const HELLO_WORLD = 'HELLO_WORLD';
@@ -98,8 +97,8 @@ export const HELLO_WORLD = 'HELLO_WORLD';
export class HelloWorld extends Embeddable {
readonly type = HELLO_WORLD;
- render(node: HTMLElement) {
- render({this.getTitle()}
, node);
+ render() {
+ return {this.getTitle()}
;
}
reload() {}
@@ -126,6 +125,21 @@ export class HelloWorld extends Embeddable {
}
```
+There is also an option to return a [React node](https://reactjs.org/docs/react-component.html#render) directly.
+In that case, the returned node will be automatically mounted and unmounted.
+```tsx
+import React from 'react';
+import { Embeddable } from '@kbn/embeddable-plugin/public';
+
+export class HelloWorld extends Embeddable {
+ // ...
+
+ render() {
+ return {this.getTitle()}
;
+ }
+}
+```
+
#### `reload`
This hook is called after every input update to perform some UI changes.
```typescript
@@ -150,13 +164,13 @@ export class HelloWorld extends Embeddable {
}
```
-#### `renderError`
+#### `catchError`
This is an optional error handler to provide a custom UI for the error state.
The embeddable may change its state in the future so that the error should be able to disappear.
In that case, the method should return a callback performing cleanup actions for the error UI.
-If there is no implementation provided for the `renderError` hook, the embeddable will render a fallback error UI.
+If there is no implementation provided for the `catchError` hook, the embeddable will render a fallback error UI.
In case of an error, the embeddable UI will not be destroyed or unmounted.
The default behavior is to hide that visually and show the error message on top of that.
@@ -169,7 +183,7 @@ import { Embeddable } from '@kbn/embeddable-plugin/public';
export class HelloWorld extends Embeddable {
// ...
- renderError(node: HTMLElement, error: Error) {
+ catchError(error: Error, node: HTMLElement) {
render(Something went wrong: {error.message}
, node);
return () => unmountComponentAtNode(node);
@@ -177,6 +191,21 @@ export class HelloWorld extends Embeddable {
}
```
+There is also an option to return a [React node](https://reactjs.org/docs/react-component.html#render) directly.
+In that case, the returned node will be automatically mounted and unmounted.
+```typescript
+import React from 'react';
+import { Embeddable } from '@kbn/embeddable-plugin/public';
+
+export class HelloWorld extends Embeddable {
+ // ...
+
+ catchError(error: Error) {
+ return Something went wrong: {error.message}
;
+ }
+}
+```
+
#### `destroy`
This hook is invoked when the embeddable is destroyed and should perform cleanup actions.
```typescript
@@ -366,7 +395,6 @@ To perform state mutations, the plugin also exposes a pre-defined state of the a
Here is an example of initializing a Redux store:
```tsx
import React from 'react';
-import { render } from 'react-dom';
import { connect, Provider } from 'react-redux';
import { Embeddable, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { createStore, State } from '@kbn/embeddable-plugin/public/store';
@@ -381,16 +409,15 @@ export class HelloWorld extends Embeddable {
reload() {}
- render(node: HTMLElement) {
+ render() {
const Component = connect((state: State) => ({ title: state.input.title }))(
HelloWorldComponent
);
- render(
+ return (
- ,
- node
+
);
}
}
@@ -434,7 +461,6 @@ That means there is no need to reimplement already existing actions.
```tsx
import React from 'react';
-import { render } from 'react-dom';
import { createSlice } from '@reduxjs/toolkit';
import {
Embeddable,
@@ -523,7 +549,6 @@ This can be achieved by passing a custom reducer.
```tsx
import React from 'react';
-import { render } from 'react-dom';
import { createSlice } from '@reduxjs/toolkit';
import { Embeddable, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { createStore, State } from '@kbn/embeddable-plugin/public/store';
diff --git a/src/plugins/embeddable/public/__stories__/embeddable_panel.stories.tsx b/src/plugins/embeddable/public/__stories__/embeddable_panel.stories.tsx
index 97e8b83b9f7b1..c7fba909068da 100644
--- a/src/plugins/embeddable/public/__stories__/embeddable_panel.stories.tsx
+++ b/src/plugins/embeddable/public/__stories__/embeddable_panel.stories.tsx
@@ -15,7 +15,6 @@ import React, {
useMemo,
useRef,
} from 'react';
-import { render, unmountComponentAtNode } from 'react-dom';
import { ReplaySubject } from 'rxjs';
import { ThemeContext } from '@emotion/react';
import { DecoratorFn, Meta } from '@storybook/react';
@@ -251,15 +250,13 @@ DefaultWithError.argTypes = {
export function DefaultWithCustomError({ message, ...props }: DefaultWithErrorProps) {
const ref = useRef>(null);
- useEffect(
- () =>
- ref.current?.embeddable.setErrorRenderer((node, error) => {
- render( , node);
-
- return () => unmountComponentAtNode(node);
- }),
- []
- );
+ useEffect(() => {
+ if (ref.current) {
+ ref.current.embeddable.catchError = (error) => {
+ return ;
+ };
+ }
+ }, []);
useEffect(
() => void ref.current?.embeddable.store.dispatch(actions.output.setError(new Error(message))),
[message]
diff --git a/src/plugins/embeddable/public/__stories__/embeddable_root.stories.tsx b/src/plugins/embeddable/public/__stories__/embeddable_root.stories.tsx
index e8ccc0edd66a2..2539185022f3f 100644
--- a/src/plugins/embeddable/public/__stories__/embeddable_root.stories.tsx
+++ b/src/plugins/embeddable/public/__stories__/embeddable_root.stories.tsx
@@ -65,7 +65,7 @@ Default.args = {
loading: false,
};
-export const DefaultWithError = Default as Meta;
+export const DefaultWithError = Default.bind({}) as Meta;
DefaultWithError.args = {
...Default.args,
diff --git a/src/plugins/embeddable/public/__stories__/error_embeddable.stories.tsx b/src/plugins/embeddable/public/__stories__/error_embeddable.stories.tsx
index ad65b2412c4c4..2d83d9b8ed702 100644
--- a/src/plugins/embeddable/public/__stories__/error_embeddable.stories.tsx
+++ b/src/plugins/embeddable/public/__stories__/error_embeddable.stories.tsx
@@ -6,14 +6,10 @@
* Side Public License, v 1.
*/
-import React, { useContext, useEffect, useMemo, useRef } from 'react';
-import { filter, ReplaySubject } from 'rxjs';
-import { ThemeContext } from '@emotion/react';
+import { useEffect, useMemo } from 'react';
import { Meta } from '@storybook/react';
-import { CoreTheme } from '@kbn/core-theme-browser';
import { ErrorEmbeddable } from '..';
-import { setTheme } from '../services';
export default {
title: 'components/ErrorEmbeddable',
@@ -26,32 +22,17 @@ export default {
} as Meta;
interface ErrorEmbeddableWrapperProps {
- compact?: boolean;
message: string;
}
-function ErrorEmbeddableWrapper({ compact, message }: ErrorEmbeddableWrapperProps) {
+function ErrorEmbeddableWrapper({ message }: ErrorEmbeddableWrapperProps) {
const embeddable = useMemo(
- () => new ErrorEmbeddable(message, { id: `${Math.random()}` }, undefined, compact),
- [compact, message]
+ () => new ErrorEmbeddable(message, { id: `${Math.random()}` }, undefined),
+ [message]
);
- const root = useRef(null);
- const theme$ = useMemo(() => new ReplaySubject(1), []);
- const theme = useContext(ThemeContext) as CoreTheme;
+ useEffect(() => () => embeddable.destroy(), [embeddable]);
- useEffect(() => setTheme({ theme$: theme$.pipe(filter(Boolean)) }), [theme$]);
- useEffect(() => theme$.next(theme), [theme$, theme]);
- useEffect(() => {
- if (!root.current) {
- return;
- }
-
- embeddable.render(root.current);
-
- return () => embeddable.destroy();
- }, [embeddable]);
-
- return
;
+ return embeddable.render();
}
export const Default = ErrorEmbeddableWrapper as Meta;
@@ -59,9 +40,3 @@ export const Default = ErrorEmbeddableWrapper as Meta (
-
-)) as Meta;
-
-DefaultCompact.args = { ...Default.args };
diff --git a/src/plugins/embeddable/public/__stories__/hello_world_embeddable.tsx b/src/plugins/embeddable/public/__stories__/hello_world_embeddable.tsx
index 5cf2c5fdc46e8..d343425bced3e 100644
--- a/src/plugins/embeddable/public/__stories__/hello_world_embeddable.tsx
+++ b/src/plugins/embeddable/public/__stories__/hello_world_embeddable.tsx
@@ -7,10 +7,9 @@
*/
import React from 'react';
-import { render } from 'react-dom';
import { connect, Provider } from 'react-redux';
import { EuiEmptyPrompt } from '@elastic/eui';
-import { Embeddable, IEmbeddable } from '..';
+import { Embeddable } from '..';
import { createStore, State } from '../store';
export class HelloWorldEmbeddable extends Embeddable {
@@ -19,22 +18,15 @@ export class HelloWorldEmbeddable extends Embeddable {
readonly type = 'hello-world';
- renderError: IEmbeddable['renderError'];
-
reload() {}
- render(node: HTMLElement) {
- const App = connect((state: State) => ({ body: state.input.title }))(EuiEmptyPrompt);
+ render() {
+ const HelloWorld = connect((state: State) => ({ body: state.input.title }))(EuiEmptyPrompt);
- render(
+ return (
-
- ,
- node
+
+
);
}
-
- setErrorRenderer(renderer: IEmbeddable['renderError']) {
- this.renderError = renderer;
- }
}
diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx
index 94025320ec86d..d1871ce2ffc98 100644
--- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx
+++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx
@@ -14,7 +14,7 @@ import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators';
import { RenderCompleteDispatcher } from '@kbn/kibana-utils-plugin/public';
import { Adapters } from '../types';
import { IContainer } from '../containers';
-import { EmbeddableOutput, IEmbeddable } from './i_embeddable';
+import { EmbeddableError, EmbeddableOutput, IEmbeddable } from './i_embeddable';
import { EmbeddableInput, ViewMode } from '../../../common/types';
import { genericEmbeddableInputIsEqual, omitGenericEmbeddableInput } from './diff_embeddable_input';
@@ -23,8 +23,9 @@ function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) {
}
export abstract class Embeddable<
TEmbeddableInput extends EmbeddableInput = EmbeddableInput,
- TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput
-> implements IEmbeddable
+ TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput,
+ TNode = any
+> implements IEmbeddable
{
static runtimeId: number = 0;
@@ -33,6 +34,7 @@ export abstract class Embeddable<
public readonly parent?: IContainer;
public readonly isContainer: boolean = false;
public readonly deferEmbeddableLoad: boolean = false;
+ public catchError?(error: EmbeddableError, domNode: HTMLElement | Element): TNode | (() => void);
public abstract readonly type: string;
public readonly id: string;
@@ -209,14 +211,13 @@ export abstract class Embeddable<
}
}
- public render(el: HTMLElement): void {
+ public render(el: HTMLElement): TNode | void {
this.renderComplete.setEl(el);
this.renderComplete.setTitle(this.output.title || '');
if (this.destroyed) {
throw new Error('Embeddable has been destroyed');
}
- return;
}
/**
diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_root.test.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_root.test.tsx
index c5912427893a6..056c652e104f0 100644
--- a/src/plugins/embeddable/public/lib/embeddables/embeddable_root.test.tsx
+++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_root.test.tsx
@@ -7,7 +7,7 @@
*/
import React from 'react';
-import { HelloWorldEmbeddable } from '../../tests/fixtures';
+import { HelloWorldEmbeddable, HelloWorldEmbeddableReact } from '../../tests/fixtures';
import { EmbeddableRoot } from './embeddable_root';
import { mount } from 'enzyme';
import { findTestSubject } from '@elastic/eui/lib/test';
@@ -25,6 +25,13 @@ test('EmbeddableRoot renders an embeddable', async () => {
expect(findTestSubject(component, 'embedError').length).toBe(0);
});
+test('EmbeddableRoot renders a React-based embeddable', async () => {
+ const embeddable = new HelloWorldEmbeddableReact({ id: 'hello' });
+ const component = mount( );
+
+ expect(component.find('[data-test-subj="helloWorldEmbeddable"]')).toHaveLength(1);
+});
+
test('EmbeddableRoot updates input', async () => {
const embeddable = new HelloWorldEmbeddable({ id: 'hello' });
const component = mount( );
diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_root.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_root.tsx
index cab7fcbd54e1d..bfaefe09b5e6b 100644
--- a/src/plugins/embeddable/public/lib/embeddables/embeddable_root.tsx
+++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_root.tsx
@@ -6,19 +6,25 @@
* Side Public License, v 1.
*/
-import React from 'react';
+import React, { ReactNode } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { EuiText } from '@elastic/eui';
-import { EmbeddableInput, IEmbeddable } from './i_embeddable';
+import { isPromise } from '@kbn/std';
+import { MaybePromise } from '@kbn/utility-types';
+import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable';
interface Props {
- embeddable?: IEmbeddable;
+ embeddable?: IEmbeddable>;
loading?: boolean;
error?: string;
input?: EmbeddableInput;
}
-export class EmbeddableRoot extends React.Component {
+interface State {
+ node?: ReactNode;
+}
+
+export class EmbeddableRoot extends React.Component {
private root?: React.RefObject;
private alreadyMounted: boolean = false;
@@ -26,20 +32,33 @@ export class EmbeddableRoot extends React.Component {
super(props);
this.root = React.createRef();
+ this.state = {};
}
+ private updateNode = (node: MaybePromise) => {
+ if (isPromise(node)) {
+ node.then(this.updateNode);
+
+ return;
+ }
+
+ this.setState({ node });
+ };
+
public componentDidMount() {
- if (this.root && this.root.current && this.props.embeddable) {
- this.alreadyMounted = true;
- this.props.embeddable.render(this.root.current);
+ if (!this.root?.current || !this.props.embeddable) {
+ return;
}
+
+ this.alreadyMounted = true;
+ this.updateNode(this.props.embeddable.render(this.root.current) ?? undefined);
}
public componentDidUpdate(prevProps?: Props) {
let justRendered = false;
- if (this.root && this.root.current && this.props.embeddable && !this.alreadyMounted) {
+ if (this.root?.current && this.props.embeddable && !this.alreadyMounted) {
this.alreadyMounted = true;
- this.props.embeddable.render(this.root.current);
+ this.updateNode(this.props.embeddable.render(this.root.current) ?? undefined);
justRendered = true;
}
@@ -56,20 +75,21 @@ export class EmbeddableRoot extends React.Component {
}
}
- public shouldComponentUpdate(newProps: Props) {
+ public shouldComponentUpdate({ embeddable, error, input, loading }: Props, { node }: State) {
return Boolean(
- newProps.error !== this.props.error ||
- newProps.loading !== this.props.loading ||
- newProps.embeddable !== this.props.embeddable ||
- (this.root && this.root.current && newProps.embeddable && !this.alreadyMounted) ||
- newProps.input !== this.props.input
+ error !== this.props.error ||
+ loading !== this.props.loading ||
+ embeddable !== this.props.embeddable ||
+ (this.root && this.root.current && embeddable && !this.alreadyMounted) ||
+ input !== this.props.input ||
+ node !== this.state.node
);
}
public render() {
return (
-
+ {this.state.node}
{this.props.loading && }
{this.props.error && {this.props.error} }
diff --git a/src/plugins/embeddable/public/lib/embeddables/error_embeddable.test.tsx b/src/plugins/embeddable/public/lib/embeddables/error_embeddable.test.tsx
index 8f17a3bf84198..d932018c3f4fe 100644
--- a/src/plugins/embeddable/public/lib/embeddables/error_embeddable.test.tsx
+++ b/src/plugins/embeddable/public/lib/embeddables/error_embeddable.test.tsx
@@ -20,47 +20,6 @@ test('ErrorEmbeddable renders an embeddable', async () => {
expect(getByText(/some error occurred/i)).toBeVisible();
});
-test('ErrorEmbeddable renders in compact mode', async () => {
- const embeddable = new ErrorEmbeddable(
- 'some error occurred',
- { id: '123', title: 'Error' },
- undefined,
- true
- );
- const component = render( );
-
- expect(component.baseElement).toMatchInlineSnapshot(`
-
-
-
-
-
-
-
-
- An error has occurred. Read more
-
-
-
-
-
-
-
- `);
-});
-
test('ErrorEmbeddable renders an embeddable with markdown message', async () => {
const error = '[some link](http://localhost:5601/takeMeThere)';
const embeddable = new ErrorEmbeddable(error, { id: '123', title: 'Error' });
diff --git a/src/plugins/embeddable/public/lib/embeddables/error_embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/error_embeddable.tsx
index 55946aad5da02..8dff4ecee8976 100644
--- a/src/plugins/embeddable/public/lib/embeddables/error_embeddable.tsx
+++ b/src/plugins/embeddable/public/lib/embeddables/error_embeddable.tsx
@@ -6,16 +6,12 @@
* Side Public License, v 1.
*/
-import { EuiText, EuiIcon, EuiPopover, EuiLink, EuiEmptyPrompt } from '@elastic/eui';
-import React, { useState } from 'react';
-import ReactDOM from 'react-dom';
-import { KibanaThemeProvider, Markdown } from '@kbn/kibana-react-plugin/public';
-import { i18n } from '@kbn/i18n';
+import { EuiEmptyPrompt } from '@elastic/eui';
+import React, { ReactNode } from 'react';
+import { Markdown } from '@kbn/kibana-react-plugin/public';
import { Embeddable } from './embeddable';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable';
import { IContainer } from '../containers';
-import { getTheme } from '../../services';
-import './error_embedabble.scss';
export const ERROR_EMBEDDABLE_TYPE = 'error';
@@ -25,91 +21,32 @@ export function isErrorEmbeddable(
return Boolean(embeddable.fatalError || (embeddable as ErrorEmbeddable).error !== undefined);
}
-export class ErrorEmbeddable extends Embeddable {
+export class ErrorEmbeddable extends Embeddable {
public readonly type = ERROR_EMBEDDABLE_TYPE;
public error: Error | string;
- private dom?: HTMLElement;
- constructor(
- error: Error | string,
- input: EmbeddableInput,
- parent?: IContainer,
- private compact: boolean = false
- ) {
+ constructor(error: Error | string, input: EmbeddableInput, parent?: IContainer) {
super(input, {}, parent);
this.error = error;
}
public reload() {}
- public render(dom: HTMLElement) {
+ public render() {
const title = typeof this.error === 'string' ? this.error : this.error.message;
- this.dom = dom;
- let theme;
- try {
- theme = getTheme();
- } catch (err) {
- theme = {};
- }
- const errorMarkdown = (
+ const body = (
);
- const node = this.compact ? (
- {errorMarkdown}
- ) : (
+ return (
);
- const content =
- theme && theme.theme$ ? (
- {node}
- ) : (
- node
- );
-
- ReactDOM.render(content, dom);
- }
-
- public destroy() {
- if (this.dom) {
- ReactDOM.unmountComponentAtNode(this.dom);
- }
}
}
-
-const CompactEmbeddableError = ({ children }: { children?: React.ReactNode }) => {
- const [isPopoverOpen, setPopoverOpen] = useState(false);
-
- const popoverButton = (
-
- setPopoverOpen((open) => !open)}
- >
-
- {i18n.translate('embeddableApi.panel.errorEmbeddable.message', {
- defaultMessage: 'An error has occurred. Read more',
- })}
-
-
- );
-
- return (
- setPopoverOpen(false)}
- >
- {children}
-
- );
-};
diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts
index 1c9bdebcefc9b..1d3cc7980ad62 100644
--- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts
+++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts
@@ -33,7 +33,8 @@ export interface EmbeddableOutput {
export interface IEmbeddable<
I extends EmbeddableInput = EmbeddableInput,
- O extends EmbeddableOutput = EmbeddableOutput
+ O extends EmbeddableOutput = EmbeddableOutput,
+ N = any
> {
/**
* Is this embeddable an instance of a Container class, can it contain
@@ -172,15 +173,17 @@ export interface IEmbeddable<
/**
* Renders the embeddable at the given node.
* @param domNode
+ * @returns A React node to mount or void in the case when rendering is done without React.
*/
- render(domNode: HTMLElement | Element): void;
+ render(domNode: HTMLElement | Element): N | void;
/**
* Renders a custom embeddable error at the given node.
+ * @param error
* @param domNode
- * @returns A callback that will be called on error destroy.
+ * @returns A React node or callback that will be called on error destroy.
*/
- renderError?(domNode: HTMLElement | Element, error: ErrorLike): () => void;
+ catchError?(error: EmbeddableError, domNode: HTMLElement | Element): N | (() => void);
/**
* Reload the embeddable so output and rendering is up to date. Especially relevant
diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx
index a79f19cb4225c..8f096020ae60e 100644
--- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx
+++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx
@@ -17,17 +17,17 @@ import { Action, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { Trigger, ViewMode } from '../types';
import { isErrorEmbeddable } from '../embeddables';
import { EmbeddablePanel } from './embeddable_panel';
-import { createEditModeAction } from '../test_samples/actions';
-import {
- ContactCardEmbeddableFactory,
- CONTACT_CARD_EMBEDDABLE,
-} from '../test_samples/embeddables/contact_card/contact_card_embeddable_factory';
-import { HelloWorldContainer } from '../test_samples/embeddables/hello_world_container';
import {
+ createEditModeAction,
ContactCardEmbeddable,
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
-} from '../test_samples/embeddables/contact_card/contact_card_embeddable';
+ ContactCardEmbeddableFactory,
+ ContactCardEmbeddableReactFactory,
+ CONTACT_CARD_EMBEDDABLE,
+ CONTACT_CARD_EMBEDDABLE_REACT,
+ HelloWorldContainer,
+} from '../test_samples';
import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks';
import { EuiBadge } from '@elastic/eui';
import { embeddablePluginMock } from '../../mocks';
@@ -43,12 +43,17 @@ const trigger: Trigger = {
id: CONTEXT_MENU_TRIGGER,
};
const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
+const embeddableReactFactory = new ContactCardEmbeddableReactFactory(
+ (() => null) as any,
+ {} as any
+);
const applicationMock = applicationServiceMock.createStartContract();
const theme = themeServiceMock.createStartContract();
actionRegistry.set(editModeAction.id, editModeAction);
triggerRegistry.set(trigger.id, trigger);
setup.registerEmbeddableFactory(embeddableFactory.type, embeddableFactory);
+setup.registerEmbeddableFactory(embeddableReactFactory.type, embeddableReactFactory);
const start = doStart();
const getEmbeddableFactory = start.getEmbeddableFactory;
@@ -198,7 +203,7 @@ describe('HelloWorldContainer in error state', () => {
);
- jest.spyOn(embeddable, 'renderError');
+ jest.spyOn(embeddable, 'catchError');
});
test('renders a custom error', () => {
@@ -207,9 +212,9 @@ describe('HelloWorldContainer in error state', () => {
const embeddableError = findTestSubject(component, 'embeddableError');
- expect(embeddable.renderError).toHaveBeenCalledWith(
- expect.any(HTMLElement),
- new Error('something')
+ expect(embeddable.catchError).toHaveBeenCalledWith(
+ new Error('something'),
+ expect.any(HTMLElement)
);
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('something');
@@ -222,21 +227,21 @@ describe('HelloWorldContainer in error state', () => {
const embeddableError = findTestSubject(component, 'embeddableError');
- expect(embeddable.renderError).toHaveBeenCalledWith(
- expect.any(HTMLElement),
- new Error('something')
+ expect(embeddable.catchError).toHaveBeenCalledWith(
+ new Error('something'),
+ expect.any(HTMLElement)
);
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('something');
});
test('destroys previous error', () => {
- const { renderError } = embeddable as Required;
- let destroyError: jest.MockedFunction>;
+ const { catchError } = embeddable as Required;
+ let destroyError: jest.MockedFunction>;
- (embeddable.renderError as jest.MockedFunction).mockImplementationOnce(
+ (embeddable.catchError as jest.MockedFunction).mockImplementationOnce(
(...args) => {
- destroyError = jest.fn(renderError(...args));
+ destroyError = jest.fn(catchError(...args));
return destroyError;
}
@@ -254,7 +259,7 @@ describe('HelloWorldContainer in error state', () => {
});
test('renders a default error', async () => {
- embeddable.renderError = undefined;
+ embeddable.catchError = undefined;
embeddable.triggerError(new Error('something'));
component.update();
@@ -263,6 +268,17 @@ describe('HelloWorldContainer in error state', () => {
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.children.length).toBeGreaterThan(0);
});
+
+ test('renders a React node', () => {
+ (embeddable.catchError as jest.Mock).mockReturnValueOnce(Something
);
+ embeddable.triggerError(new Error('something'));
+ component.update();
+
+ const embeddableError = findTestSubject(component, 'embeddableError');
+
+ expect(embeddableError).toHaveProperty('length', 1);
+ expect(embeddableError.text()).toBe('Something');
+ });
});
const renderInEditModeAndOpenContextMenu = async (
@@ -735,3 +751,37 @@ test('Should work in minimal way rendering only the inspector action', async ()
const action = findTestSubject(component, `embeddablePanelAction-ACTION_CUSTOMIZE_PANEL`);
expect(action.length).toBe(0);
});
+
+test('Renders an embeddable returning a React node', async () => {
+ const container = new HelloWorldContainer(
+ { id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false },
+ { getEmbeddableFactory } as any
+ );
+
+ const embeddable = await container.addNewEmbeddable<
+ ContactCardEmbeddableInput,
+ ContactCardEmbeddableOutput,
+ ContactCardEmbeddable
+ >(CONTACT_CARD_EMBEDDABLE_REACT, {
+ firstName: 'Bran',
+ lastName: 'Stark',
+ });
+
+ const component = mount(
+
+ Promise.resolve([])}
+ getAllEmbeddableFactories={start.getEmbeddableFactories}
+ getEmbeddableFactory={start.getEmbeddableFactory}
+ notifications={{} as any}
+ overlays={{} as any}
+ application={applicationMock}
+ SavedObjectFinder={() => null}
+ theme={theme}
+ />
+
+ );
+
+ expect(component.find('.embPanel__titleText').text()).toBe('Hello Bran Stark');
+});
diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx
index 52b70f3b53406..f5b072a591225 100644
--- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx
+++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx
@@ -8,12 +8,14 @@
import { EuiContextMenuPanelDescriptor, EuiPanel, htmlIdGenerator } from '@elastic/eui';
import classNames from 'classnames';
-import React from 'react';
+import React, { ReactNode } from 'react';
import { Subscription } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import { CoreStart, OverlayStart, ThemeServiceStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
+import { isPromise } from '@kbn/std';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
+import { MaybePromise } from '@kbn/utility-types';
import { buildContextMenuForActions, UiActionsService, Action } from '../ui_actions';
import { Start as InspectorStartContract } from '../inspector';
@@ -66,7 +68,7 @@ export interface EmbeddableContainerContext {
}
interface Props {
- embeddable: IEmbeddable;
+ embeddable: IEmbeddable>;
/**
* Ordinal number of the embeddable in the container, used as a
@@ -105,6 +107,7 @@ interface State {
loading?: boolean;
error?: EmbeddableError;
destroyError?(): void;
+ node?: ReactNode;
}
interface InspectorPanelAction {
@@ -304,27 +307,37 @@ export class EmbeddablePanel extends React.Component {
error={this.state.error}
/>
)}
-
+
+ {this.state.node}
+
);
}
public componentDidMount() {
- if (this.embeddableRoot.current) {
- this.subscription.add(
- this.props.embeddable.getOutput$().subscribe(
- (output: EmbeddableOutput) => {
- this.setState({
- error: output.error,
- loading: output.loading,
- });
- },
- (error) => {
- this.setState({ error });
- }
- )
- );
- this.props.embeddable.render(this.embeddableRoot.current);
+ if (!this.embeddableRoot.current) {
+ return;
+ }
+
+ this.subscription.add(
+ this.props.embeddable.getOutput$().subscribe(
+ (output: EmbeddableOutput) => {
+ this.setState({
+ error: output.error,
+ loading: output.loading,
+ });
+ },
+ (error) => {
+ this.setState({ error });
+ }
+ )
+ );
+
+ const node = this.props.embeddable.render(this.embeddableRoot.current) ?? undefined;
+ if (isPromise(node)) {
+ node.then((resolved) => this.setState({ node: resolved }));
+ } else {
+ this.setState({ node });
}
}
diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel_error.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel_error.tsx
index 46e26fd1448bb..69af8e7220e62 100644
--- a/src/plugins/embeddable/public/lib/panel/embeddable_panel_error.tsx
+++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel_error.tsx
@@ -6,17 +6,20 @@
* Side Public License, v 1.
*/
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { isFunction } from 'lodash';
+import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { EuiPanel } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
+import { isPromise } from '@kbn/std';
+import type { MaybePromise } from '@kbn/utility-types';
import { ErrorLike } from '@kbn/expressions-plugin/common';
import { distinctUntilChanged, merge, of, switchMap } from 'rxjs';
import { EditPanelAction } from '../actions';
-import { ErrorEmbeddable, IEmbeddable } from '../embeddables';
+import { EmbeddableInput, EmbeddableOutput, ErrorEmbeddable, IEmbeddable } from '../embeddables';
interface EmbeddablePanelErrorProps {
editPanelAction?: EditPanelAction;
- embeddable: IEmbeddable;
+ embeddable: IEmbeddable>;
error: ErrorLike;
}
@@ -26,6 +29,7 @@ export function EmbeddablePanelError({
error,
}: EmbeddablePanelErrorProps) {
const [isEditable, setEditable] = useState(false);
+ const [node, setNode] = useState();
const ref = useRef(null);
const handleErrorClick = useMemo(
() => (isEditable ? () => editPanelAction?.execute({ embeddable }) : undefined),
@@ -63,14 +67,22 @@ export function EmbeddablePanelError({
return;
}
- if (embeddable.renderError) {
- return embeddable.renderError(ref.current, error);
- }
+ if (!embeddable.catchError) {
+ const errorEmbeddable = new ErrorEmbeddable(error, { id: embeddable.id });
+ setNode(errorEmbeddable.render());
- const errorEmbeddable = new ErrorEmbeddable(error, { id: embeddable.id });
- errorEmbeddable.render(ref.current);
+ return () => errorEmbeddable.destroy();
+ }
- return () => errorEmbeddable.destroy();
+ const renderedNode = embeddable.catchError(error, ref.current);
+ if (isFunction(renderedNode)) {
+ return renderedNode;
+ }
+ if (isPromise(renderedNode)) {
+ renderedNode.then(setNode);
+ } else {
+ setNode(renderedNode);
+ }
}, [embeddable, error]);
return (
@@ -84,6 +96,8 @@ export function EmbeddablePanelError({
role={isEditable ? 'button' : undefined}
aria-label={isEditable ? ariaLabel : undefined}
onClick={handleErrorClick}
- />
+ >
+ {node}
+
);
}
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable.tsx
index 7d9a929299f35..0287b9d115827 100644
--- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable.tsx
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable.tsx
@@ -47,7 +47,7 @@ export class ContactCardEmbeddable extends Embeddable<
constructor(
initialInput: ContactCardEmbeddableInput,
- private readonly options: ContactCardEmbeddableOptions,
+ protected readonly options: ContactCardEmbeddableOptions,
parent?: Container
) {
super(
@@ -77,7 +77,7 @@ export class ContactCardEmbeddable extends Embeddable<
);
}
- public renderError?(node: HTMLElement, error: ErrorLike) {
+ public catchError?(error: ErrorLike, node: HTMLElement) {
ReactDom.render({error.message}
, node);
return () => ReactDom.unmountComponentAtNode(node);
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx
index 282f6a8c627c2..317e0d5e741c8 100644
--- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx
@@ -25,7 +25,7 @@ export class ContactCardEmbeddableFactory
public readonly type = CONTACT_CARD_EMBEDDABLE;
constructor(
- private readonly execTrigger: UiActionsStart['executeTriggerActions'],
+ protected readonly execTrigger: UiActionsStart['executeTriggerActions'],
private readonly overlays: CoreStart['overlays']
) {}
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_react.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_react.tsx
new file mode 100644
index 0000000000000..d42ba42a0cfb3
--- /dev/null
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_react.tsx
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+import { ContactCardEmbeddableComponent } from './contact_card';
+import { ContactCardEmbeddable } from './contact_card_embeddable';
+
+export class ContactCardEmbeddableReact extends ContactCardEmbeddable {
+ public render() {
+ return (
+
+ );
+ }
+}
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_react_factory.ts b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_react_factory.ts
new file mode 100644
index 0000000000000..7378dc24ea5f8
--- /dev/null
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_react_factory.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 { Container } from '../../../containers';
+import { ContactCardEmbeddableInput } from './contact_card_embeddable';
+import { ContactCardEmbeddableFactory } from './contact_card_embeddable_factory';
+import { ContactCardEmbeddableReact } from './contact_card_embeddable_react';
+
+export const CONTACT_CARD_EMBEDDABLE_REACT = 'CONTACT_CARD_EMBEDDABLE_REACT';
+
+export class ContactCardEmbeddableReactFactory extends ContactCardEmbeddableFactory {
+ public readonly type = CONTACT_CARD_EMBEDDABLE_REACT as ContactCardEmbeddableFactory['type'];
+
+ public create = async (initialInput: ContactCardEmbeddableInput, parent?: Container) => {
+ return new ContactCardEmbeddableReact(
+ initialInput,
+ {
+ execAction: this.execTrigger,
+ },
+ parent
+ );
+ };
+}
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/index.ts b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/index.ts
index 526256d375963..fc63fcacbab79 100644
--- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/index.ts
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/index.ts
@@ -11,5 +11,7 @@ export * from './contact_card_embeddable';
export * from './contact_card_embeddable_factory';
export * from './contact_card_exportable_embeddable';
export * from './contact_card_exportable_embeddable_factory';
+export * from './contact_card_embeddable_react';
+export * from './contact_card_embeddable_react_factory';
export * from './contact_card_initializer';
export * from './slow_contact_card_embeddable_factory';
diff --git a/src/plugins/embeddable/public/tests/fixtures/hello_world_embeddable_react.tsx b/src/plugins/embeddable/public/tests/fixtures/hello_world_embeddable_react.tsx
new file mode 100644
index 0000000000000..aa9ac3175fd5e
--- /dev/null
+++ b/src/plugins/embeddable/public/tests/fixtures/hello_world_embeddable_react.tsx
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+import { HelloWorldEmbeddable } from './hello_world_embeddable';
+
+export class HelloWorldEmbeddableReact extends HelloWorldEmbeddable {
+ public render() {
+ return HELLO WORLD!
;
+ }
+}
diff --git a/src/plugins/embeddable/public/tests/fixtures/index.ts b/src/plugins/embeddable/public/tests/fixtures/index.ts
index a155f65d47858..ea9533a359ca7 100644
--- a/src/plugins/embeddable/public/tests/fixtures/index.ts
+++ b/src/plugins/embeddable/public/tests/fixtures/index.ts
@@ -8,3 +8,4 @@
export * from './hello_world_embeddable';
export * from './hello_world_embeddable_factory';
+export * from './hello_world_embeddable_react';
diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx
index ef9e8d53a4f11..f2a2a7f8ae000 100644
--- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx
+++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx
@@ -10,7 +10,7 @@ import _, { get } from 'lodash';
import { Subscription } from 'rxjs';
import { i18n } from '@kbn/i18n';
import React from 'react';
-import { render, unmountComponentAtNode } from 'react-dom';
+import { render } from 'react-dom';
import { EuiLoadingChart } from '@elastic/eui';
import { Filter, onlyDisabledFiltersChanged, Query, TimeRange } from '@kbn/es-query';
import type { KibanaExecutionContext, SavedObjectAttributes } from '@kbn/core/public';
@@ -503,7 +503,7 @@ export class VisualizeEmbeddable
const { error } = this.getOutput();
if (error) {
- this.renderError(this.domNode, error);
+ render(this.catchError(error), this.domNode);
}
})
);
@@ -511,9 +511,9 @@ export class VisualizeEmbeddable
await this.updateHandler();
}
- public renderError(domNode: HTMLElement, error: ErrorLike | string) {
+ public catchError(error: ErrorLike | string) {
if (isFallbackDataView(this.vis.data.indexPattern)) {
- render(
+ return (
,
- domNode
+ />
);
- } else {
- render( , domNode);
}
- return () => unmountComponentAtNode(domNode);
+ return ;
}
public destroy() {
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 3f30dff6fd1a7..251d83201f468 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
@@ -315,7 +315,9 @@ describe('IndexPattern Data Source', () => {
describe('#toExpression', () => {
it('should generate an empty expression when no columns are selected', async () => {
const state = FormBasedDatasource.initialize();
- expect(FormBasedDatasource.toExpression(state, 'first', indexPatterns)).toEqual(null);
+ expect(
+ FormBasedDatasource.toExpression(state, 'first', indexPatterns, 'testing-seed')
+ ).toEqual(null);
});
it('should create a table when there is a formula without aggs', async () => {
@@ -338,7 +340,9 @@ describe('IndexPattern Data Source', () => {
},
},
};
- expect(FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns)).toEqual({
+ expect(
+ FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns, 'testing-seed')
+ ).toEqual({
chain: [
{
function: 'createTable',
@@ -385,8 +389,9 @@ describe('IndexPattern Data Source', () => {
},
};
- expect(FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns))
- .toMatchInlineSnapshot(`
+ expect(
+ FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns, 'testing-seed')
+ ).toMatchInlineSnapshot(`
Object {
"chain": Array [
Object {
@@ -487,6 +492,12 @@ describe('IndexPattern Data Source', () => {
"partialRows": Array [
false,
],
+ "probability": Array [
+ 1,
+ ],
+ "samplerSeed": Array [
+ 1889181588,
+ ],
"timeFields": Array [
"timestamp",
],
@@ -560,7 +571,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp', 'another_datefield']);
});
@@ -595,7 +611,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
expect((ast.chain[1].arguments.aggs[1] as Ast).chain[0].arguments.timeShift).toEqual(['1d']);
});
@@ -802,7 +823,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
const count = (ast.chain[1].arguments.aggs[1] as Ast).chain[0];
const sum = (ast.chain[1].arguments.aggs[2] as Ast).chain[0];
const average = (ast.chain[1].arguments.aggs[3] as Ast).chain[0];
@@ -866,7 +892,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
expect(ast.chain[1].arguments.aggs[0]).toMatchInlineSnapshot(`
Object {
"chain": Array [
@@ -990,7 +1021,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
const timeScaleCalls = ast.chain.filter((fn) => fn.function === 'lens_time_scale');
const formatCalls = ast.chain.filter((fn) => fn.function === 'lens_format_column');
expect(timeScaleCalls).toHaveLength(1);
@@ -1055,7 +1091,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
const filteredMetricAgg = (ast.chain[1].arguments.aggs[0] as Ast).chain[0].arguments;
const metricAgg = (filteredMetricAgg.customMetric[0] as Ast).chain[0].arguments;
const bucketAgg = (filteredMetricAgg.customBucket[0] as Ast).chain[0].arguments;
@@ -1106,7 +1147,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
const formatIndex = ast.chain.findIndex((fn) => fn.function === 'lens_format_column');
const calculationIndex = ast.chain.findIndex((fn) => fn.function === 'moving_average');
expect(calculationIndex).toBeLessThan(formatIndex);
@@ -1154,7 +1200,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
expect(ast.chain[1].arguments.metricsAtAllLevels).toEqual([false]);
expect(JSON.parse(ast.chain[2].arguments.idMap[0] as string)).toEqual({
'col-0-0': [expect.objectContaining({ id: 'bucket1' })],
@@ -1193,7 +1244,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp']);
expect(ast.chain[1].arguments.timeFields).not.toContain('timefield');
});
@@ -1250,7 +1306,7 @@ describe('IndexPattern Data Source', () => {
const optimizeMock = jest.spyOn(operationDefinitionMap.percentile, 'optimizeEsAggs');
- FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns);
+ FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns, 'testing-seed');
expect(operationDefinitionMap.percentile.optimizeEsAggs).toHaveBeenCalledTimes(1);
@@ -1318,7 +1374,12 @@ describe('IndexPattern Data Source', () => {
return { aggs: aggs.reverse(), esAggsIdMap };
});
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
expect(operationDefinitionMap.percentile.optimizeEsAggs).toHaveBeenCalledTimes(1);
@@ -1382,7 +1443,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
const idMap = JSON.parse(ast.chain[2].arguments.idMap as unknown as string);
@@ -1487,7 +1553,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
// @ts-expect-error we can't isolate just the reference type
expect(operationDefinitionMap.testReference.toExpression).toHaveBeenCalled();
expect(ast.chain[3]).toEqual('mock');
@@ -1520,7 +1591,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
expect(JSON.parse(ast.chain[2].arguments.idMap[0] as string)).toEqual({
'col-0-0': [
@@ -1607,7 +1683,12 @@ describe('IndexPattern Data Source', () => {
},
};
- const ast = FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns) as Ast;
+ const ast = FormBasedDatasource.toExpression(
+ queryBaseState,
+ 'first',
+ indexPatterns,
+ 'testing-seed'
+ ) as Ast;
const chainLength = ast.chain.length;
expect(ast.chain[chainLength - 2].arguments.name).toEqual(['math']);
expect(ast.chain[chainLength - 1].arguments.id).toEqual(['formula']);
@@ -1631,6 +1712,7 @@ describe('IndexPattern Data Source', () => {
},
},
currentIndexPatternId: '1',
+ sampling: 1,
};
expect(FormBasedDatasource.insertLayer(state, 'newLayer', ['link-to-id'])).toEqual({
...state,
@@ -1640,6 +1722,7 @@ describe('IndexPattern Data Source', () => {
indexPatternId: '1',
columnOrder: [],
columns: {},
+ sampling: 1,
linkToLayers: ['link-to-id'],
},
},
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 62c77ec5225fe..f772e432268c2 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
@@ -36,6 +36,7 @@ import type {
IndexPatternField,
IndexPattern,
IndexPatternRef,
+ DatasourceLayerSettingsProps,
} from '../../types';
import {
changeIndexPattern,
@@ -96,6 +97,7 @@ import { getStateTimeShiftWarningMessages } from './time_shift_utils';
import { getPrecisionErrorWarningMessages } from './utils';
import { DOCUMENT_FIELD_NAME } from '../../../common/constants';
import { isColumnOfType } from './operations/definitions/helpers';
+import { LayerSettingsPanel } from './layer_settings';
import { FormBasedLayer } from '../..';
export type { OperationType, GenericIndexPatternColumn } from './operations';
export { deleteColumn } from './operations';
@@ -392,8 +394,34 @@ export function getFormBasedDatasource({
return fields;
},
- toExpression: (state, layerId, indexPatterns) =>
- toExpression(state, layerId, indexPatterns, uiSettings),
+ toExpression: (state, layerId, indexPatterns, searchSessionId) =>
+ toExpression(state, layerId, indexPatterns, uiSettings, searchSessionId),
+
+ renderLayerSettings(
+ domElement: Element,
+ props: DatasourceLayerSettingsProps
+ ) {
+ render(
+
+
+
+
+
+
+ ,
+ domElement
+ );
+ },
renderDataPanel(domElement: Element, props: DatasourceDataPanelProps) {
const { onChangeIndexPattern, ...otherProps } = props;
@@ -569,6 +597,22 @@ export function getFormBasedDatasource({
getDropProps,
onDrop,
+ getSupportedActionsForLayer(layerId, state, _, openLayerSettings) {
+ if (!openLayerSettings) {
+ return [];
+ }
+ return [
+ {
+ displayName: i18n.translate('xpack.lens.indexPattern.layerSettingsAction', {
+ defaultMessage: 'Layer settings',
+ }),
+ execute: openLayerSettings,
+ icon: 'gear',
+ isCompatible: Boolean(state.layers[layerId]),
+ 'data-test-subj': 'lnsLayerSettings',
+ },
+ ];
+ },
getCustomWorkspaceRenderer: (
state: FormBasedPrivateState,
@@ -931,5 +975,6 @@ function blankLayer(indexPatternId: string, linkToLayers?: string[]): FormBasedL
linkToLayers,
columns: {},
columnOrder: [],
+ sampling: 1,
};
}
diff --git a/x-pack/plugins/lens/public/datasources/form_based/form_based_suggestions.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/form_based_suggestions.test.tsx
index 2489659f0da55..eca0c032ee224 100644
--- a/x-pack/plugins/lens/public/datasources/form_based/form_based_suggestions.test.tsx
+++ b/x-pack/plugins/lens/public/datasources/form_based/form_based_suggestions.test.tsx
@@ -176,6 +176,7 @@ function testInitialState(): FormBasedPrivateState {
currentIndexPatternId: '1',
layers: {
first: {
+ sampling: 1,
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
@@ -458,7 +459,7 @@ describe('IndexPattern Data Source suggestions', () => {
});
describe('with a previous empty layer', () => {
- function stateWithEmptyLayer() {
+ function stateWithEmptyLayer(): FormBasedPrivateState {
const state = testInitialState();
return {
...state,
@@ -761,6 +762,35 @@ describe('IndexPattern Data Source suggestions', () => {
})
);
});
+
+ it('should inherit the sampling rate when generating new layer, if avaialble', () => {
+ const state = stateWithEmptyLayer();
+ state.layers.previousLayer.sampling = 0.001;
+ const suggestions = getDatasourceSuggestionsForField(
+ state,
+ '1',
+ {
+ name: 'bytes',
+ displayName: 'bytes',
+ type: 'number',
+ aggregatable: true,
+ searchable: true,
+ },
+ expectedIndexPatterns
+ );
+
+ expect(suggestions).toContainEqual(
+ expect.objectContaining({
+ state: expect.objectContaining({
+ layers: {
+ previousLayer: expect.objectContaining({
+ sampling: 0.001,
+ }),
+ },
+ }),
+ })
+ );
+ });
});
describe('suggesting extensions to non-empty tables', () => {
diff --git a/x-pack/plugins/lens/public/datasources/form_based/form_based_suggestions.ts b/x-pack/plugins/lens/public/datasources/form_based/form_based_suggestions.ts
index 52008f10bcdeb..81ce81bb49053 100644
--- a/x-pack/plugins/lens/public/datasources/form_based/form_based_suggestions.ts
+++ b/x-pack/plugins/lens/public/datasources/form_based/form_based_suggestions.ts
@@ -529,6 +529,12 @@ function getEmptyLayerSuggestionsForField(
newLayer = createNewLayerWithMetricAggregation(indexPattern, field);
}
+ // copy the sampling rate to the new layer
+ // or just default to 1
+ if (newLayer) {
+ newLayer.sampling = state.layers[layerId]?.sampling ?? 1;
+ }
+
const newLayerSuggestions = newLayer
? [
buildSuggestion({
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
new file mode 100644
index 0000000000000..7d02ac98f23a4
--- /dev/null
+++ b/x-pack/plugins/lens/public/datasources/form_based/layer_settings.tsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { EuiFormRow, EuiRange, EuiBetaBadge } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import React from 'react';
+import type { DatasourceLayerSettingsProps } from '../../types';
+import type { FormBasedPrivateState } from './types';
+
+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;
+ return (
+
+ {i18n.translate('xpack.lens.xyChart.randomSampling.label', {
+ defaultMessage: 'Sampling',
+ })}{' '}
+
+ >
+ }
+ >
+ {
+ setState({
+ ...state,
+ layers: {
+ ...state.layers,
+ [layerId]: {
+ ...state.layers[layerId],
+ sampling: samplingValue[Number(e.currentTarget.value)],
+ },
+ },
+ });
+ }}
+ showInput={false}
+ showRange={false}
+ showTicks
+ step={1}
+ min={0}
+ max={samplingValue.length - 1}
+ ticks={samplingValue.map((v, i) => ({ label: `${v}`, value: i }))}
+ />
+
+ );
+}
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 fc77aa6520bd0..365d80a3d8285 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
@@ -7,6 +7,7 @@
import type { IUiSettingsClient } from '@kbn/core/public';
import { partition, uniq } from 'lodash';
+import seedrandom from 'seedrandom';
import {
AggFunctionsMapping,
EsaggsExpressionFunctionDefinition,
@@ -52,7 +53,8 @@ const updatePositionIndex = (currentId: string, newIndex: number) => {
function getExpressionForLayer(
layer: FormBasedLayer,
indexPattern: IndexPattern,
- uiSettings: IUiSettingsClient
+ uiSettings: IUiSettingsClient,
+ searchSessionId?: string
): ExpressionAstExpression | null {
const { columnOrder } = layer;
if (columnOrder.length === 0 || !indexPattern) {
@@ -392,6 +394,8 @@ function getExpressionForLayer(
metricsAtAllLevels: false,
partialRows: false,
timeFields: allDateHistogramFields,
+ probability: layer.sampling || 1,
+ samplerSeed: seedrandom(searchSessionId).int32(),
}).toAst(),
{
type: 'function',
@@ -441,13 +445,15 @@ export function toExpression(
state: FormBasedPrivateState,
layerId: string,
indexPatterns: IndexPatternMap,
- uiSettings: IUiSettingsClient
+ uiSettings: IUiSettingsClient,
+ searchSessionId?: string
) {
if (state.layers[layerId]) {
return getExpressionForLayer(
state.layers[layerId],
indexPatterns[state.layers[layerId].indexPatternId],
- uiSettings
+ uiSettings,
+ searchSessionId
);
}
diff --git a/x-pack/plugins/lens/public/datasources/form_based/types.ts b/x-pack/plugins/lens/public/datasources/form_based/types.ts
index 3c695d5064e3f..0846c96d76dc8 100644
--- a/x-pack/plugins/lens/public/datasources/form_based/types.ts
+++ b/x-pack/plugins/lens/public/datasources/form_based/types.ts
@@ -54,6 +54,7 @@ export interface FormBasedLayer {
linkToLayers?: string[];
// Partial columns represent the temporary invalid states
incompleteColumns?: Record;
+ sampling?: number;
}
export interface FormBasedPersistedState {
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss
index b8ae7fa515190..7b74d0e966410 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss
@@ -1,52 +1,3 @@
-@import '@elastic/eui/src/components/flyout/variables';
-@import '@elastic/eui/src/components/flyout/mixins';
-
-.lnsDimensionContainer {
- // Use the EuiFlyout style
- @include euiFlyout;
- // But with custom positioning to keep it within the sidebar contents
- animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance;
- left: 0;
- max-width: none !important;
- z-index: $euiZContentMenu;
-
- @include euiBreakpoint('l', 'xl') {
- height: 100% !important;
- position: absolute;
- top: 0 !important;
- }
-
- .lnsFrameLayout__sidebar-isFullscreen & {
- border-left: $euiBorderThin; // Force border regardless of theme in fullscreen
- box-shadow: none;
- }
-}
-
-.lnsDimensionContainer__header {
- padding: $euiSize;
-
- .lnsFrameLayout__sidebar-isFullscreen & {
- display: none;
- }
-}
-
-.lnsDimensionContainer__content {
- @include euiYScroll;
- flex: 1;
-}
-
-.lnsDimensionContainer__footer {
- padding: $euiSize;
-
- .lnsFrameLayout__sidebar-isFullscreen & {
- display: none;
- }
-}
-
-.lnsBody--overflowHidden {
- overflow: hidden;
-}
-
.lnsLayerAddButton:hover {
text-decoration: none;
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx
index 9d71e74eff473..13ba032f9b902 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx
@@ -7,44 +7,12 @@
import './dimension_container.scss';
-import React, { useState, useEffect, useCallback } from 'react';
-import {
- EuiFlyoutHeader,
- EuiFlyoutFooter,
- EuiTitle,
- EuiButtonIcon,
- EuiButtonEmpty,
- EuiFlexGroup,
- EuiFlexItem,
- EuiFocusTrap,
-} from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../../../utils';
-
-function fromExcludedClickTarget(event: Event) {
- for (
- let node: HTMLElement | null = event.target as HTMLElement;
- node !== null;
- node = node!.parentElement
- ) {
- if (
- node.classList!.contains(DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS) ||
- node.classList!.contains('euiBody-hasPortalContent') ||
- node.getAttribute('data-euiportal') === 'true'
- ) {
- return true;
- }
- }
- return false;
-}
+import React from 'react';
+import { FlyoutContainer } from './flyout_container';
export function DimensionContainer({
- isOpen,
- groupLabel,
- handleClose,
panel,
- isFullscreen,
- panelRef,
+ ...props
}: {
isOpen: boolean;
handleClose: () => boolean;
@@ -53,107 +21,5 @@ export function DimensionContainer({
isFullscreen: boolean;
panelRef: (el: HTMLDivElement) => void;
}) {
- const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false);
-
- const closeFlyout = useCallback(() => {
- const canClose = handleClose();
- if (canClose) {
- setFocusTrapIsEnabled(false);
- }
- return canClose;
- }, [handleClose]);
-
- useEffect(() => {
- document.body.classList.toggle('lnsBody--overflowHidden', isOpen);
- return () => {
- if (isOpen) {
- setFocusTrapIsEnabled(false);
- }
- document.body.classList.remove('lnsBody--overflowHidden');
- };
- }, [isOpen]);
-
- if (!isOpen) {
- return null;
- }
-
- return (
-
-
{
- if (isFullscreen || fromExcludedClickTarget(event)) {
- return;
- }
- closeFlyout();
- }}
- onEscapeKey={closeFlyout}
- >
- {
- if (isOpen) {
- // EuiFocusTrap interferes with animating elements with absolute position:
- // running this onAnimationEnd, otherwise the flyout pushes content when animating
- setFocusTrapIsEnabled(true);
- }
- }}
- >
-
-
-
-
-
-
- {i18n.translate('xpack.lens.configure.configurePanelTitle', {
- defaultMessage: '{groupLabel}',
- values: {
- groupLabel,
- },
- })}
-
-
-
-
-
-
-
-
-
-
-
-
{panel}
-
-
-
- {i18n.translate('xpack.lens.dimensionContainer.close', {
- defaultMessage: 'Close',
- })}
-
-
-
-
-
- );
+ return {panel} ;
}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/flyout_container.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/flyout_container.scss
new file mode 100644
index 0000000000000..b08eb6281fa0e
--- /dev/null
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/flyout_container.scss
@@ -0,0 +1,48 @@
+@import '@elastic/eui/src/components/flyout/variables';
+@import '@elastic/eui/src/components/flyout/mixins';
+
+.lnsDimensionContainer {
+ // Use the EuiFlyout style
+ @include euiFlyout;
+ // But with custom positioning to keep it within the sidebar contents
+ animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance;
+ left: 0;
+ max-width: none !important;
+ z-index: $euiZContentMenu;
+
+ @include euiBreakpoint('l', 'xl') {
+ height: 100% !important;
+ position: absolute;
+ top: 0 !important;
+ }
+
+ .lnsFrameLayout__sidebar-isFullscreen & {
+ border-left: $euiBorderThin; // Force border regardless of theme in fullscreen
+ box-shadow: none;
+ }
+}
+
+.lnsDimensionContainer__header {
+ padding: $euiSize;
+
+ .lnsFrameLayout__sidebar-isFullscreen & {
+ display: none;
+ }
+}
+
+.lnsDimensionContainer__content {
+ @include euiYScroll;
+ flex: 1;
+}
+
+.lnsDimensionContainer__footer {
+ padding: $euiSize;
+
+ .lnsFrameLayout__sidebar-isFullscreen & {
+ display: none;
+ }
+}
+
+.lnsBody--overflowHidden {
+ overflow: hidden;
+}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/flyout_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/flyout_container.tsx
new file mode 100644
index 0000000000000..041f59df332f4
--- /dev/null
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/flyout_container.tsx
@@ -0,0 +1,157 @@
+/*
+ * 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 './flyout_container.scss';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ EuiFlyoutHeader,
+ EuiFlyoutFooter,
+ EuiTitle,
+ EuiButtonIcon,
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFocusTrap,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../../../utils';
+
+function fromExcludedClickTarget(event: Event) {
+ for (
+ let node: HTMLElement | null = event.target as HTMLElement;
+ node !== null;
+ node = node!.parentElement
+ ) {
+ if (
+ node.classList!.contains(DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS) ||
+ node.classList!.contains('euiBody-hasPortalContent') ||
+ node.getAttribute('data-euiportal') === 'true'
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+export function FlyoutContainer({
+ isOpen,
+ groupLabel,
+ handleClose,
+ isFullscreen,
+ panelRef,
+ children,
+}: {
+ isOpen: boolean;
+ handleClose: () => boolean;
+ children: React.ReactElement | null;
+ groupLabel: string;
+ isFullscreen: boolean;
+ panelRef: (el: HTMLDivElement) => void;
+}) {
+ const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false);
+
+ const closeFlyout = useCallback(() => {
+ const canClose = handleClose();
+ if (canClose) {
+ setFocusTrapIsEnabled(false);
+ }
+ return canClose;
+ }, [handleClose]);
+
+ useEffect(() => {
+ document.body.classList.toggle('lnsBody--overflowHidden', isOpen);
+ return () => {
+ if (isOpen) {
+ setFocusTrapIsEnabled(false);
+ }
+ document.body.classList.remove('lnsBody--overflowHidden');
+ };
+ }, [isOpen]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
{
+ if (isFullscreen || fromExcludedClickTarget(event)) {
+ return;
+ }
+ closeFlyout();
+ }}
+ onEscapeKey={closeFlyout}
+ >
+ {
+ if (isOpen) {
+ // EuiFocusTrap interferes with animating elements with absolute position:
+ // running this onAnimationEnd, otherwise the flyout pushes content when animating
+ setFocusTrapIsEnabled(true);
+ }
+ }}
+ >
+
+
+
+
+
+ {i18n.translate('xpack.lens.configure.configurePanelTitle', {
+ defaultMessage: '{groupLabel}',
+ values: {
+ groupLabel,
+ },
+ })}
+
+
+
+
+
+
+
+
+
+
+
{children}
+
+
+
+ {i18n.translate('xpack.lens.dimensionContainer.close', {
+ defaultMessage: 'Close',
+ })}
+
+
+
+
+
+ );
+}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx
index bc1c41caa650b..9c32a24eac4dd 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx
@@ -104,6 +104,9 @@ const InContextMenuActions = (props: LayerActionsProps) => {
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
+ panelProps={{
+ 'data-test-subj': 'lnsLayerActionsMenu',
+ }}
>
(
initialActiveDimensionState
);
+ const [isPanelSettingsOpen, setPanelSettingsOpen] = useState(false);
+
const [hideTooltip, setHideTooltip] = useState(false);
const {
@@ -120,6 +123,7 @@ export function LayerPanel(
}, [activeVisualization.id]);
const panelRef = useRef(null);
+ const settingsPanelRef = useRef(null);
const registerLayerRef = useCallback(
(el) => registerNewLayerRef(layerId, el),
[layerId, registerNewLayerRef]
@@ -316,7 +320,14 @@ export function LayerPanel(
...(activeVisualization.getSupportedActionsForLayer?.(
layerId,
visualizationState,
- updateVisualization
+ updateVisualization,
+ () => setPanelSettingsOpen(true)
+ ) || []),
+ ...(layerDatasource?.getSupportedActionsForLayer?.(
+ layerId,
+ layerDatasourceState,
+ (newState) => updateDatasource(datasourceId, newState),
+ () => setPanelSettingsOpen(true)
) || []),
...getSharedActions({
activeVisualization,
@@ -332,12 +343,16 @@ export function LayerPanel(
[
activeVisualization,
core,
+ datasourceId,
isOnlyLayer,
isTextBasedLanguage,
+ layerDatasource,
+ layerDatasourceState,
layerId,
layerIndex,
onCloneLayer,
onRemoveLayer,
+ updateDatasource,
updateVisualization,
visualizationState,
]
@@ -624,7 +639,42 @@ export function LayerPanel(
})}
-
+ {(layerDatasource?.renderLayerSettings || activeVisualization?.renderLayerSettings) && (
+ (settingsPanelRef.current = el)}
+ isOpen={isPanelSettingsOpen}
+ isFullscreen={false}
+ groupLabel={i18n.translate('xpack.lens.editorFrame.layerSettingsTitle', {
+ defaultMessage: 'Layer settings',
+ })}
+ handleClose={() => {
+ // update the current layer settings
+ setPanelSettingsOpen(false);
+ return true;
+ }}
+ >
+
+
+ {layerDatasource?.renderLayerSettings && (
+
+ )}
+ {activeVisualization?.renderLayerSettings && (
+
+ )}
+
+
+
+ )}
(panelRef.current = el)}
isOpen={isDimensionPanelOpen}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts
index c9b0358b81a0b..aee10196d5156 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts
@@ -11,7 +11,8 @@ import { Visualization, DatasourceMap, DatasourceLayers, IndexPatternMap } from
export function getDatasourceExpressionsByLayers(
datasourceMap: DatasourceMap,
datasourceStates: DatasourceStates,
- indexPatterns: IndexPatternMap
+ indexPatterns: IndexPatternMap,
+ searchSessionId?: string
): null | Record {
const datasourceExpressions: Array<[string, Ast | string]> = [];
@@ -24,7 +25,7 @@ export function getDatasourceExpressionsByLayers(
const layers = datasource.getLayers(state);
layers.forEach((layerId) => {
- const result = datasource.toExpression(state, layerId, indexPatterns);
+ const result = datasource.toExpression(state, layerId, indexPatterns, searchSessionId);
if (result) {
datasourceExpressions.push([layerId, result]);
}
@@ -53,6 +54,7 @@ export function buildExpression({
title,
description,
indexPatterns,
+ searchSessionId,
}: {
title?: string;
description?: string;
@@ -62,6 +64,7 @@ export function buildExpression({
datasourceStates: DatasourceStates;
datasourceLayers: DatasourceLayers;
indexPatterns: IndexPatternMap;
+ searchSessionId?: string;
}): Ast | null {
if (visualization === null) {
return null;
@@ -70,7 +73,8 @@ export function buildExpression({
const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers(
datasourceMap,
datasourceStates,
- indexPatterns
+ indexPatterns,
+ searchSessionId
);
const visualizationExpression = visualization.toExpression(
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx
index 38f62b6e928b6..2f2003970171a 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx
@@ -162,6 +162,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
const changesApplied = useLensSelector(selectChangesApplied);
const triggerApply = useLensSelector(selectTriggerApplyChanges);
const datasourceLayers = useLensSelector((state) => selectDatasourceLayers(state, datasourceMap));
+ const searchSessionId = useLensSelector(selectSearchSessionId);
const [localState, setLocalState] = useState({
expressionBuildError: undefined,
@@ -317,6 +318,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
datasourceStates,
datasourceLayers,
indexPatterns: dataViews.indexPatterns,
+ searchSessionId,
});
if (ast) {
@@ -349,16 +351,17 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
}));
}
}, [
+ configurationValidationError?.length,
+ missingRefsErrors.length,
+ unknownVisError,
activeVisualization,
visualization.state,
+ visualization.activeId,
datasourceMap,
datasourceStates,
datasourceLayers,
- configurationValidationError?.length,
- missingRefsErrors.length,
- unknownVisError,
- visualization.activeId,
dataViews.indexPatterns,
+ searchSessionId,
]);
useEffect(() => {
diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts
index e01c35f3457d9..dd5d12809c94a 100644
--- a/x-pack/plugins/lens/public/types.ts
+++ b/x-pack/plugins/lens/public/types.ts
@@ -301,6 +301,10 @@ export interface Datasource {
}) => T;
getSelectedFields?: (state: T) => string[];
+ renderLayerSettings?: (
+ domElement: Element,
+ props: DatasourceLayerSettingsProps
+ ) => ((cleanupElement: Element) => void) | void;
renderDataPanel: (
domElement: Element,
props: DatasourceDataPanelProps
@@ -360,7 +364,8 @@ export interface Datasource {
toExpression: (
state: T,
layerId: string,
- indexPatterns: IndexPatternMap
+ indexPatterns: IndexPatternMap,
+ searchSessionId?: string
) => ExpressionAstExpression | string | null;
getDatasourceSuggestionsForField: (
@@ -458,6 +463,13 @@ export interface Datasource {
* Get all the used DataViews from state
*/
getUsedDataViews: (state: T) => string[];
+
+ getSupportedActionsForLayer?: (
+ layerId: string,
+ state: T,
+ setState: StateSetter,
+ openLayerSettings?: () => void
+ ) => LayerAction[];
}
export interface DatasourceFixAction {
@@ -509,6 +521,12 @@ export interface DatasourcePublicAPI {
hasDefaultTimeField: () => boolean;
}
+export interface DatasourceLayerSettingsProps {
+ layerId: string;
+ state: T;
+ setState: StateSetter;
+}
+
export interface DatasourceDataPanelProps {
state: T;
dragDropContext: DragContextState;
@@ -715,6 +733,11 @@ export interface VisualizationToolbarProps {
state: T;
}
+export type VisualizationLayerSettingsProps = VisualizationConfigProps & {
+ setState(newState: T | ((currState: T) => T)): void;
+ panelRef: MutableRefObject;
+};
+
export type VisualizationDimensionEditorProps = VisualizationConfigProps & {
groupId: string;
accessor: string;
@@ -1010,7 +1033,8 @@ export interface Visualization {
getSupportedActionsForLayer?: (
layerId: string,
state: T,
- setState: StateSetter
+ setState: StateSetter,
+ openLayerSettings?: () => void
) => LayerAction[];
/** returns the type string of the given layer */
getLayerType: (layerId: string, state?: T) => LayerType | undefined;
@@ -1090,6 +1114,11 @@ export interface Visualization {
dropProps: GetDropPropsArgs
) => { dropTypes: DropType[]; nextLabel?: string } | undefined;
+ renderLayerSettings?: (
+ domElement: Element,
+ props: VisualizationLayerSettingsProps
+ ) => ((cleanupElement: Element) => void) | void;
+
/**
* Additional editor that gets rendered inside the dimension popover.
* This can be used to configure dimension-specific options
diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json
index bf55cf6174fda..0bf0d3908eeb9 100644
--- a/x-pack/plugins/translations/translations/fr-FR.json
+++ b/x-pack/plugins/translations/translations/fr-FR.json
@@ -372,6 +372,7 @@
"controls.controlGroup.management.validate.title": "Valider les sélections utilisateur",
"controls.controlGroup.title": "Groupe de contrôle",
"controls.controlGroup.toolbarButtonTitle": "Contrôles",
+ "controls.frame.error.message": "Une erreur s'est produite. En savoir plus",
"controls.optionsList.description": "Ajoutez un menu pour la sélection de valeurs de champ.",
"controls.optionsList.displayName": "Liste des options",
"controls.optionsList.editor.allowMultiselectTitle": "Permettre des sélections multiples dans une liste déroulante",
@@ -2331,7 +2332,6 @@
"embeddableApi.errors.paneldoesNotExist": "Panneau introuvable",
"embeddableApi.helloworld.displayName": "bonjour",
"embeddableApi.panel.dashboardPanelAriaLabel": "Panneau du tableau de bord",
- "embeddableApi.panel.errorEmbeddable.message": "Une erreur s'est produite. En savoir plus",
"embeddableApi.panel.inspectPanel.displayName": "Inspecter",
"embeddableApi.panel.inspectPanel.untitledEmbeddableFilename": "sans titre",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabel": "Options de panneau",
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index e792e7981b0ee..91d6ae6f2bcb5 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -372,6 +372,7 @@
"controls.controlGroup.management.validate.title": "ユーザー選択を検証",
"controls.controlGroup.title": "コントロールグループ",
"controls.controlGroup.toolbarButtonTitle": "コントロール",
+ "controls.frame.error.message": "エラーが発生しました。続きを読む",
"controls.optionsList.description": "フィールド値を選択するメニューを追加",
"controls.optionsList.displayName": "オプションリスト",
"controls.optionsList.editor.allowMultiselectTitle": "ドロップダウンでの複数選択を許可",
@@ -2327,7 +2328,6 @@
"embeddableApi.errors.paneldoesNotExist": "パネルが見つかりません",
"embeddableApi.helloworld.displayName": "こんにちは",
"embeddableApi.panel.dashboardPanelAriaLabel": "ダッシュボードパネル",
- "embeddableApi.panel.errorEmbeddable.message": "エラーが発生しました。続きを読む",
"embeddableApi.panel.inspectPanel.displayName": "検査",
"embeddableApi.panel.inspectPanel.untitledEmbeddableFilename": "無題",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabel": "パネルオプション",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 0530b872ef3b7..c929f0f0d80ba 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -372,6 +372,7 @@
"controls.controlGroup.management.validate.title": "验证用户选择",
"controls.controlGroup.title": "控件组",
"controls.controlGroup.toolbarButtonTitle": "控件",
+ "controls.frame.error.message": "发生错误。阅读更多内容",
"controls.optionsList.description": "添加用于选择字段值的菜单。",
"controls.optionsList.displayName": "选项列表",
"controls.optionsList.editor.allowMultiselectTitle": "下拉列表中允许多选",
@@ -2331,7 +2332,6 @@
"embeddableApi.errors.paneldoesNotExist": "未找到面板",
"embeddableApi.helloworld.displayName": "hello world",
"embeddableApi.panel.dashboardPanelAriaLabel": "仪表板面板",
- "embeddableApi.panel.errorEmbeddable.message": "发生错误。阅读更多内容",
"embeddableApi.panel.inspectPanel.displayName": "检查",
"embeddableApi.panel.inspectPanel.untitledEmbeddableFilename": "未命名",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabel": "面板选项",
diff --git a/x-pack/test/functional/apps/lens/group1/index.ts b/x-pack/test/functional/apps/lens/group1/index.ts
index aa2b078a50a6b..47f08a59e7341 100644
--- a/x-pack/test/functional/apps/lens/group1/index.ts
+++ b/x-pack/test/functional/apps/lens/group1/index.ts
@@ -79,6 +79,7 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext
loadTestFile(require.resolve('./table_dashboard'));
loadTestFile(require.resolve('./table'));
loadTestFile(require.resolve('./text_based_languages'));
+ loadTestFile(require.resolve('./layer_actions'));
}
});
};
diff --git a/x-pack/test/functional/apps/lens/group1/layer_actions.ts b/x-pack/test/functional/apps/lens/group1/layer_actions.ts
new file mode 100644
index 0000000000000..be22e4ad62511
--- /dev/null
+++ b/x-pack/test/functional/apps/lens/group1/layer_actions.ts
@@ -0,0 +1,91 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import expect from '@kbn/expect';
+import { FtrProviderContext } from '../../../ftr_provider_context';
+
+export default function ({ getService, getPageObjects }: FtrProviderContext) {
+ const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']);
+ const find = getService('find');
+ const testSubjects = getService('testSubjects');
+
+ 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();
+
+ await PageObjects.lens.openLayerContextMenu();
+
+ // should be 3 actions available
+ expect(
+ (await find.allByCssSelector('[data-test-subj=lnsLayerActionsMenu] button')).length
+ ).to.eql(3);
+ });
+
+ it('should open layer settings for a data layer', 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');
+
+ expect(await PageObjects.lens.getRangeInputValue('lns-indexPattern-random-sampling')).to.eql(
+ 2 // 0.01
+ );
+ await testSubjects.click('lns-indexPattern-dimensionContainerBack');
+ });
+
+ it('should add an annotation layer and settings shoud not be available', async () => {
+ // configure a date histogram
+ 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 annotation layer
+ await testSubjects.click('lnsLayerAddButton');
+ await testSubjects.click(`lnsLayerAddButton-annotations`);
+ await PageObjects.lens.openLayerContextMenu(1);
+ // layer settings not available
+ await testSubjects.missingOrFail('lnsLayerSettings');
+ });
+
+ it('should switch to pie chart and have layer settings available', async () => {
+ await PageObjects.lens.switchToVisualization('pie');
+ await PageObjects.lens.openLayerContextMenu();
+ // layer settings still available
+ // 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
+ );
+ await testSubjects.click('lns-indexPattern-dimensionContainerBack');
+ });
+
+ it('should switch to table and still have layer settings', async () => {
+ await PageObjects.lens.switchToVisualization('lnsDatatable');
+ await PageObjects.lens.openLayerContextMenu();
+ // layer settings still available
+ // 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
+ );
+ await testSubjects.click('lns-indexPattern-dimensionContainerBack');
+ });
+ });
+}
diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts
index a336a0da3e4ba..8dd95aa107929 100644
--- a/x-pack/test/functional/page_objects/lens_page.ts
+++ b/x-pack/test/functional/page_objects/lens_page.ts
@@ -600,6 +600,19 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await this.waitForVisualization();
},
+ async dragRangeInput(testId: string, steps: number = 1, direction: 'left' | 'right' = 'right') {
+ const inputEl = await testSubjects.find(testId);
+ await inputEl.focus();
+ const browserKey = direction === 'left' ? browser.keys.LEFT : browser.keys.RIGHT;
+ while (steps--) {
+ await browser.pressKeys(browserKey);
+ }
+ },
+
+ async getRangeInputValue(testId: string) {
+ return (await testSubjects.find(testId)).getAttribute('value');
+ },
+
async isTopLevelAggregation() {
return await testSubjects.isEuiSwitchChecked('indexPattern-nesting-switch');
},
@@ -1117,6 +1130,10 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
);
},
+ async openLayerContextMenu(index: number = 0) {
+ await testSubjects.click(`lnsLayerSplitButton--${index}`);
+ },
+
async toggleColumnVisibility(dimension: string, no = 1) {
await this.openDimensionEditor(dimension);
const id = 'lns-table-column-hidden';