[Lens] Implement rare terms (#121500)

This commit is contained in:
Joe Reuter 2022-01-18 16:33:35 +01:00 committed by GitHub
parent e9f45f63f2
commit 38de5842a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 778 additions and 132 deletions

View file

@ -147,6 +147,7 @@ readonly links: {
readonly significant_terms: string;
readonly terms: string;
readonly terms_doc_count_error: string;
readonly rare_terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;

File diff suppressed because one or more lines are too long

View file

@ -179,6 +179,7 @@ export class DocLinksService {
significant_terms: `${ELASTICSEARCH_DOCS}search-aggregations-bucket-significantterms-aggregation.html`,
terms: `${ELASTICSEARCH_DOCS}search-aggregations-bucket-terms-aggregation.html`,
terms_doc_count_error: `${ELASTICSEARCH_DOCS}search-aggregations-bucket-terms-aggregation.html#_per_bucket_document_count_error`,
rare_terms: `${ELASTICSEARCH_DOCS}search-aggregations-bucket-rare-terms-aggregation.html`,
avg: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-avg-aggregation.html`,
avg_bucket: `${ELASTICSEARCH_DOCS}search-aggregations-pipeline-avg-bucket-aggregation.html`,
max_bucket: `${ELASTICSEARCH_DOCS}search-aggregations-pipeline-max-bucket-aggregation.html`,
@ -746,6 +747,7 @@ export interface DocLinksStart {
readonly significant_terms: string;
readonly terms: string;
readonly terms_doc_count_error: string;
readonly rare_terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;

View file

@ -630,6 +630,7 @@ export interface DocLinksStart {
readonly significant_terms: string;
readonly terms: string;
readonly terms_doc_count_error: string;
readonly rare_terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;

View file

@ -56,6 +56,7 @@ export const getAggTypes = () => ({
{ name: BUCKET_TYPES.IP_RANGE, fn: buckets.getIpRangeBucketAgg },
{ name: BUCKET_TYPES.TERMS, fn: buckets.getTermsBucketAgg },
{ name: BUCKET_TYPES.MULTI_TERMS, fn: buckets.getMultiTermsBucketAgg },
{ name: BUCKET_TYPES.RARE_TERMS, fn: buckets.getRareTermsBucketAgg },
{ name: BUCKET_TYPES.FILTER, fn: buckets.getFilterBucketAgg },
{ name: BUCKET_TYPES.FILTERS, fn: buckets.getFiltersBucketAgg },
{ name: BUCKET_TYPES.SIGNIFICANT_TERMS, fn: buckets.getSignificantTermsBucketAgg },
@ -82,6 +83,7 @@ export const getAggTypesFunctions = () => [
buckets.aggDateHistogram,
buckets.aggTerms,
buckets.aggMultiTerms,
buckets.aggRareTerms,
buckets.aggSampler,
buckets.aggDiversifiedSampler,
metrics.aggAvg,

View file

@ -68,6 +68,7 @@ describe('Aggs service', () => {
"ip_range",
"terms",
"multi_terms",
"rare_terms",
"filter",
"filters",
"significant_terms",
@ -120,6 +121,7 @@ describe('Aggs service', () => {
"ip_range",
"terms",
"multi_terms",
"rare_terms",
"filter",
"filters",
"significant_terms",

View file

@ -15,6 +15,7 @@ export enum BUCKET_TYPES {
RANGE = 'range',
TERMS = 'terms',
MULTI_TERMS = 'multi_terms',
RARE_TERMS = 'rare_terms',
SIGNIFICANT_TERMS = 'significant_terms',
SIGNIFICANT_TEXT = 'significant_text',
GEOHASH_GRID = 'geohash_grid',

View file

@ -40,6 +40,8 @@ export * from './terms_fn';
export * from './terms';
export * from './multi_terms_fn';
export * from './multi_terms';
export * from './rare_terms_fn';
export * from './rare_terms';
export * from './sampler_fn';
export * from './sampler';
export * from './diversified_sampler_fn';

View file

@ -127,7 +127,7 @@ describe('Multi Terms Agg', () => {
5,
],
},
"function": "aggTerms",
"function": "aggMultiTerms",
"type": "function",
},
],

View file

@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n';
import { BucketAggType } from './bucket_agg_type';
import { BUCKET_TYPES } from './bucket_agg_types';
import { createFilterMultiTerms } from './create_filter/multi_terms';
import { aggTermsFnName } from './terms_fn';
import { aggMultiTermsFnName } from './multi_terms_fn';
import { AggConfigSerialized, BaseAggParams } from '../types';
import { MultiFieldKey } from './multi_field_key';
@ -41,7 +41,7 @@ export const getMultiTermsBucketAgg = () => {
const keyCaches = new WeakMap();
return new BucketAggType({
name: BUCKET_TYPES.MULTI_TERMS,
expressionName: aggTermsFnName,
expressionName: aggMultiTermsFnName,
title: termsTitle,
makeLabel(agg) {
const params = agg.params;

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 { AggConfigs } from '../agg_configs';
import { mockAggTypesRegistry } from '../test_helpers';
import { BUCKET_TYPES } from './bucket_agg_types';
import type { IndexPatternField } from '../../..';
import { IndexPattern } from '../../..';
describe('rare terms Agg', () => {
const getAggConfigs = (params: Record<string, any> = {}) => {
const indexPattern = {
id: '1234',
title: 'logstash-*',
fields: [
{
name: 'field',
type: 'string',
esTypes: ['string'],
aggregatable: true,
filterable: true,
searchable: true,
},
{
name: 'string_field',
type: 'string',
esTypes: ['string'],
aggregatable: true,
filterable: true,
searchable: true,
},
{
name: 'empty_number_field',
type: 'number',
esTypes: ['number'],
aggregatable: true,
filterable: true,
searchable: true,
},
{
name: 'number_field',
type: 'number',
esTypes: ['number'],
aggregatable: true,
filterable: true,
searchable: true,
},
],
} as IndexPattern;
indexPattern.fields.getByName = (name) => ({ name } as unknown as IndexPatternField);
indexPattern.fields.filter = () => indexPattern.fields;
return new AggConfigs(
indexPattern,
[
{
id: 'test',
params,
type: BUCKET_TYPES.RARE_TERMS,
},
],
{ typesRegistry: mockAggTypesRegistry() }
);
};
test('produces the expected expression ast', () => {
const aggConfigs = getAggConfigs({
field: 'field',
max_doc_count: 5,
});
expect(aggConfigs.aggs[0].toExpressionAst()).toMatchInlineSnapshot(`
Object {
"chain": Array [
Object {
"arguments": Object {
"enabled": Array [
true,
],
"field": Array [
"field",
],
"id": Array [
"test",
],
"max_doc_count": Array [
5,
],
},
"function": "aggRareTerms",
"type": "function",
},
],
"type": "expression",
}
`);
});
});

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { BucketAggType } from './bucket_agg_type';
import { BUCKET_TYPES } from './bucket_agg_types';
import { createFilterTerms } from './create_filter/terms';
import { aggRareTermsFnName } from './rare_terms_fn';
import { BaseAggParams } from '../types';
import { KBN_FIELD_TYPES } from '../../../../common';
const termsTitle = i18n.translate('data.search.aggs.buckets.rareTermsTitle', {
defaultMessage: 'Rare terms',
});
export interface AggParamsRareTerms extends BaseAggParams {
field: string;
max_doc_count?: number;
}
export const getRareTermsBucketAgg = () => {
return new BucketAggType({
name: BUCKET_TYPES.RARE_TERMS,
expressionName: aggRareTermsFnName,
title: termsTitle,
makeLabel(agg) {
return i18n.translate('data.search.aggs.rareTerms.aggTypesLabel', {
defaultMessage: 'Rare terms of {fieldName}',
values: {
fieldName: agg.getFieldDisplayName(),
},
});
},
createFilter: createFilterTerms,
params: [
{
name: 'field',
type: 'field',
filterFieldTypes: [
KBN_FIELD_TYPES.NUMBER,
KBN_FIELD_TYPES.BOOLEAN,
KBN_FIELD_TYPES.DATE,
KBN_FIELD_TYPES.IP,
KBN_FIELD_TYPES.STRING,
],
},
{
name: 'max_doc_count',
default: 1,
},
],
});
};

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common';
import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../';
export const aggRareTermsFnName = 'aggRareTerms';
type Input = any;
type AggArgs = AggExpressionFunctionArgs<typeof BUCKET_TYPES.RARE_TERMS>;
type Output = AggExpressionType;
type FunctionDefinition = ExpressionFunctionDefinition<
typeof aggRareTermsFnName,
Input,
AggArgs,
Output
>;
export const aggRareTerms = (): FunctionDefinition => ({
name: aggRareTermsFnName,
help: i18n.translate('data.search.aggs.function.buckets.rareTerms.help', {
defaultMessage: 'Generates a serialized agg config for a Rare-Terms agg',
}),
type: 'agg_type',
args: {
id: {
types: ['string'],
help: i18n.translate('data.search.aggs.buckets.rareTerms.id.help', {
defaultMessage: 'ID for this aggregation',
}),
},
enabled: {
types: ['boolean'],
default: true,
help: i18n.translate('data.search.aggs.buckets.rareTerms.enabled.help', {
defaultMessage: 'Specifies whether this aggregation should be enabled',
}),
},
schema: {
types: ['string'],
help: i18n.translate('data.search.aggs.buckets.rareTerms.schema.help', {
defaultMessage: 'Schema to use for this aggregation',
}),
},
field: {
types: ['string'],
required: true,
help: i18n.translate('data.search.aggs.buckets.rareTerms.fields.help', {
defaultMessage: 'Field to use for this aggregation',
}),
},
max_doc_count: {
types: ['number'],
help: i18n.translate('data.search.aggs.buckets.rareTerms.maxDocCount.help', {
defaultMessage: 'Maximum number of times a term is allowed to occur to qualify as rare',
}),
},
json: {
types: ['string'],
help: i18n.translate('data.search.aggs.buckets.multiTerms.json.help', {
defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch',
}),
},
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
return {
type: 'agg_type',
value: {
id,
enabled,
schema,
type: BUCKET_TYPES.RARE_TERMS,
params: {
...rest,
},
},
};
},
});

View file

@ -68,6 +68,7 @@ import {
AggParamsSum,
AggParamsTerms,
AggParamsMultiTerms,
AggParamsRareTerms,
AggParamsTopHit,
aggPercentileRanks,
aggPercentiles,
@ -78,6 +79,7 @@ import {
aggSum,
aggTerms,
aggMultiTerms,
aggRareTerms,
aggTopHit,
AggTypesRegistry,
AggTypesRegistrySetup,
@ -166,6 +168,7 @@ export interface AggParamsMapping {
[BUCKET_TYPES.DATE_HISTOGRAM]: AggParamsDateHistogram;
[BUCKET_TYPES.TERMS]: AggParamsTerms;
[BUCKET_TYPES.MULTI_TERMS]: AggParamsMultiTerms;
[BUCKET_TYPES.RARE_TERMS]: AggParamsRareTerms;
[BUCKET_TYPES.SAMPLER]: AggParamsSampler;
[BUCKET_TYPES.DIVERSIFIED_SAMPLER]: AggParamsDiversifiedSampler;
[METRIC_TYPES.AVG]: AggParamsAvg;
@ -209,6 +212,7 @@ export interface AggFunctionsMapping {
aggDateHistogram: ReturnType<typeof aggDateHistogram>;
aggTerms: ReturnType<typeof aggTerms>;
aggMultiTerms: ReturnType<typeof aggMultiTerms>;
aggRareTerms: ReturnType<typeof aggRareTerms>;
aggAvg: ReturnType<typeof aggAvg>;
aggBucketAvg: ReturnType<typeof aggBucketAvg>;
aggBucketMax: ReturnType<typeof aggBucketMax>;

View file

@ -53,7 +53,7 @@ describe('AggsService - public', () => {
test('registers default agg types', () => {
service.setup(setupDeps);
const start = service.start(startDeps);
expect(start.types.getAll().buckets.length).toBe(15);
expect(start.types.getAll().buckets.length).toBe(16);
expect(start.types.getAll().metrics.length).toBe(23);
});
@ -69,7 +69,7 @@ describe('AggsService - public', () => {
);
const start = service.start(startDeps);
expect(start.types.getAll().buckets.length).toBe(16);
expect(start.types.getAll().buckets.length).toBe(17);
expect(start.types.getAll().buckets.some(({ name }) => name === 'foo')).toBe(true);
expect(start.types.getAll().metrics.length).toBe(24);
expect(start.types.getAll().metrics.some(({ name }) => name === 'bar')).toBe(true);

View file

@ -104,6 +104,7 @@ export const getHeatmapVisTypeDefinition = ({
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -120,6 +121,7 @@ export const getHeatmapVisTypeDefinition = ({
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -143,6 +145,7 @@ export const getHeatmapVisTypeDefinition = ({
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -92,6 +92,7 @@ export const createMetricVisTypeDefinition = (): VisTypeDefinition<VisParams> =>
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -93,6 +93,7 @@ export const samplePieVis = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -112,6 +113,7 @@ export const samplePieVis = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -93,6 +93,7 @@ export const getPieVisTypeDefinition = ({
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -112,6 +113,7 @@ export const getPieVisTypeDefinition = ({
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -68,6 +68,7 @@ export const tableVisTypeDefinition: VisTypeDefinition<TableVisParams> = {
'!diversified_sampler',
'!multi_terms',
'!significant_text',
'!rare_terms',
],
},
{
@ -84,6 +85,7 @@ export const tableVisTypeDefinition: VisTypeDefinition<TableVisParams> = {
'!diversified_sampler',
'!multi_terms',
'!significant_text',
'!rare_terms',
],
},
],

View file

@ -138,6 +138,7 @@ export const gaugeVisTypeDefinition: VisTypeDefinition<GaugeVisParams> = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -102,6 +102,7 @@ export const goalVisTypeDefinition: VisTypeDefinition<GaugeVisParams> = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -155,6 +155,7 @@ export const sampleAreaVis = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -173,6 +174,7 @@ export const sampleAreaVis = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -191,6 +193,7 @@ export const sampleAreaVis = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -163,6 +163,7 @@ export const areaVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -181,6 +182,7 @@ export const areaVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -199,6 +201,7 @@ export const areaVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -166,6 +166,7 @@ export const histogramVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -184,6 +185,7 @@ export const histogramVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -202,6 +204,7 @@ export const histogramVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -165,6 +165,7 @@ export const horizontalBarVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -183,6 +184,7 @@ export const horizontalBarVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -201,6 +203,7 @@ export const horizontalBarVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -157,6 +157,7 @@ export const lineVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -175,6 +176,7 @@ export const lineVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],
@ -193,6 +195,7 @@ export const lineVisTypeDefinition = {
'!filter',
'!sampler',
'!diversified_sampler',
'!rare_terms',
'!multi_terms',
'!significant_text',
],

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { DatatableRow } from 'src/plugins/expressions';
import { ExpectExpression, expectExpressionProvider } from './helpers';
import { FtrProviderContext } from '../../../functional/ftr_provider_context';
export default function ({
getService,
updateBaselines,
}: FtrProviderContext & { updateBaselines: boolean }) {
let expectExpression: ExpectExpression;
describe('esaggs_rareterms', () => {
before(() => {
expectExpression = expectExpressionProvider({ getService, updateBaselines });
});
const timeRange = {
from: '2015-09-21T00:00:00Z',
to: '2015-09-22T00:00:00Z',
};
describe('aggRareTerms', () => {
it('can execute aggRareTerms', async () => {
const expression = `
kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
| esaggs index={indexPatternLoad id='logstash-*'}
aggs={aggRareTerms id="1" enabled=true schema="bucket" field="geo.srcdest" max_doc_count=1}
aggs={aggCount id="2" enabled=true schema="metric"}
`;
const result = await expectExpression('rareterms', expression).getResponse();
expect(result.rows.length).to.be(1149);
result.rows.forEach((row: DatatableRow) => {
expect(row['col-1-2']).to.be(1);
});
});
});
});
}

View file

@ -46,5 +46,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid
loadTestFile(require.resolve('./esaggs_multiterms'));
loadTestFile(require.resolve('./esaggs_sampler'));
loadTestFile(require.resolve('./esaggs_significanttext'));
loadTestFile(require.resolve('./esaggs_rareterms'));
});
}

View file

@ -19,6 +19,7 @@ import {
updateVisualizationState,
DatasourceStates,
VisualizationState,
updateDatasourceState,
} from '../../../state_management';
import { WorkspaceTitle } from './title';
@ -60,6 +61,17 @@ export function WorkspacePanelWrapper({
},
[dispatchLens, activeVisualization]
);
const setDatasourceState = useCallback(
(updater: unknown, datasourceId: string) => {
dispatchLens(
updateDatasourceState({
updater,
datasourceId,
})
);
},
[dispatchLens]
);
const warningMessages: React.ReactNode[] = [];
if (activeVisualization?.getWarningMessages) {
warningMessages.push(
@ -70,7 +82,9 @@ export function WorkspacePanelWrapper({
const datasource = datasourceMap[datasourceId];
if (!datasourceState.isLoading && datasource.getWarningMessages) {
warningMessages.push(
...(datasource.getWarningMessages(datasourceState.state, framePublicAPI) || [])
...(datasource.getWarningMessages(datasourceState.state, framePublicAPI, (updater) =>
setDatasourceState(updater, datasourceId)
) || [])
);
}
});

View file

@ -634,6 +634,20 @@ export function DimensionEditor(props: DimensionEditorProps) {
},
];
const defaultLabel = useMemo(
() =>
String(
selectedColumn &&
!selectedColumn.customLabel &&
operationDefinitionMap[selectedColumn.operationType].getDefaultLabel(
selectedColumn,
state.indexPatterns[state.layers[layerId].indexPatternId],
state.layers[layerId].columns
)
),
[layerId, selectedColumn, state.indexPatterns, state.layers]
);
return (
<div id={columnId}>
{hasTabs ? <DimensionEditorTabs tabs={tabs} /> : null}
@ -750,6 +764,8 @@ export function DimensionEditor(props: DimensionEditorProps) {
<div className="lnsIndexPatternDimensionEditor__section lnsIndexPatternDimensionEditor__section--padded lnsIndexPatternDimensionEditor__section--collapseNext">
{!incompleteInfo && selectedColumn && temporaryState === 'none' && (
<LabelInput
// re-render the input from scratch to obtain new "initial value" if the underlying default label changes
key={defaultLabel}
value={selectedColumn.label}
onChange={(value) => {
updateLayer({

View file

@ -1449,7 +1449,7 @@ describe('IndexPattern Data Source', () => {
});
it('should return mismatched time shifts', () => {
const warnings = indexPatternDatasource.getWarningMessages!(state, framePublicAPI);
const warnings = indexPatternDatasource.getWarningMessages!(state, framePublicAPI, () => {});
expect(warnings!.map((item) => (item as React.ReactElement).props.id)).toMatchInlineSnapshot(`
Array [
@ -1462,7 +1462,7 @@ describe('IndexPattern Data Source', () => {
it('should show different types of warning messages', () => {
framePublicAPI.activeData!.first.columns[0].meta.sourceParams!.hasPrecisionError = true;
const warnings = indexPatternDatasource.getWarningMessages!(state, framePublicAPI);
const warnings = indexPatternDatasource.getWarningMessages!(state, framePublicAPI, () => {});
expect(warnings!.map((item) => (item as React.ReactElement).props.id)).toMatchInlineSnapshot(`
Array [

View file

@ -527,10 +527,10 @@ export function getIndexPatternDatasource({
});
return messages.length ? messages : undefined;
},
getWarningMessages: (state, frame) => {
getWarningMessages: (state, frame, setState) => {
return [
...(getStateTimeShiftWarningMessages(state, frame) || []),
...getPrecisionErrorWarningMessages(state, frame, core.docLinks),
...getPrecisionErrorWarningMessages(state, frame, core.docLinks, setState),
];
},
checkIntegrity: (state) => {

View file

@ -109,6 +109,7 @@ export function FieldInputs({
label={i18n.translate('xpack.lens.indexPattern.terms.addField', {
defaultMessage: 'Add field',
})}
isDisabled={column.params.orderBy.type === 'rare'}
/>
</>
);

View file

@ -20,7 +20,7 @@ import {
} from '@elastic/eui';
import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public';
import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public';
import { updateColumnParam } from '../../layer_helpers';
import { updateColumnParam, updateDefaultLabels } from '../../layer_helpers';
import type { DataType } from '../../../../types';
import { OperationDefinition } from '../index';
import { FieldBasedIndexPatternColumn } from '../column_types';
@ -44,7 +44,15 @@ const missingFieldLabel = i18n.translate('xpack.lens.indexPattern.missingFieldLa
defaultMessage: 'Missing field',
});
function ofName(name?: string, count: number = 0) {
function ofName(name?: string, count: number = 0, rare: boolean = false) {
if (rare) {
return i18n.translate('xpack.lens.indexPattern.rareTermsOf', {
defaultMessage: 'Rare values of {name}',
values: {
name: name ?? missingFieldLabel,
},
});
}
if (count) {
return i18n.translate('xpack.lens.indexPattern.multipleTermsOf', {
defaultMessage: 'Top values of {name} + {count} {count, plural, one {other} other {others}}',
@ -64,6 +72,9 @@ function ofName(name?: string, count: number = 0) {
const idPrefix = htmlIdGenerator()();
const DEFAULT_SIZE = 3;
// Elasticsearch limit
const MAXIMUM_MAX_DOC_COUNT = 100;
export const DEFAULT_MAX_DOC_COUNT = 1;
const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']);
export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field'> = {
@ -141,6 +152,15 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
};
},
toEsAggsFn: (column, columnId, _indexPattern, layer, uiSettings, orderedColumnIds) => {
if (column.params?.orderBy.type === 'rare') {
return buildExpressionFunction<AggFunctionsMapping['aggRareTerms']>('aggRareTerms', {
id: columnId,
enabled: true,
schema: 'segment',
field: column.sourceField,
max_doc_count: column.params.orderBy.maxDocCount,
}).toAst();
}
if (column.params?.secondaryFields?.length) {
return buildExpressionFunction<AggFunctionsMapping['aggMultiTerms']>('aggMultiTerms', {
id: columnId,
@ -183,7 +203,8 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
getDefaultLabel: (column, indexPattern) =>
ofName(
indexPattern.getFieldByName(column.sourceField)?.displayName,
column.params.secondaryFields?.length
column.params.secondaryFields?.length,
column.params.orderBy.type === 'rare'
),
onFieldChange: (oldColumn, field) => {
// reset the secondary fields
@ -194,7 +215,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
return {
...oldColumn,
dataType: field.type as DataType,
label: ofName(field.displayName),
label: ofName(field.displayName, 0, newParams.orderBy.type === 'rare'),
sourceField: field.name,
params: newParams,
};
@ -202,7 +223,11 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
onOtherColumnChanged: (layer, thisColumnId, changedColumnId) => {
const columns = layer.columns;
const currentColumn = columns[thisColumnId] as TermsIndexPatternColumn;
if (currentColumn.params.orderBy.type === 'column' || currentColumn.params.orderBy.fallback) {
if (
currentColumn.params.orderBy.type === 'column' ||
(currentColumn.params.orderBy.type === 'alphabetical' &&
currentColumn.params.orderBy.fallback)
) {
// check whether the column is still there and still a metric
const columnSortedBy =
currentColumn.params.orderBy.type === 'column'
@ -251,7 +276,11 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
[columnId]: {
...column,
sourceField: fields[0],
label: ofName(indexPattern.getFieldByName(fields[0])?.displayName, fields.length - 1),
label: ofName(
indexPattern.getFieldByName(fields[0])?.displayName,
fields.length - 1,
column.params.orderBy.type === 'rare'
),
params: {
...column.params,
secondaryFields: fields.length > 1 ? fields.slice(1) : undefined,
@ -319,7 +348,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
const SEPARATOR = '$$$';
function toValue(orderBy: TermsIndexPatternColumn['params']['orderBy']) {
if (orderBy.type === 'alphabetical') {
if (orderBy.type !== 'column') {
return orderBy.type;
}
return `${orderBy.type}${SEPARATOR}${orderBy.columnId}`;
@ -329,6 +358,9 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
if (value === 'alphabetical') {
return { type: 'alphabetical', fallback: false };
}
if (value === 'rare') {
return { type: 'rare', maxDocCount: DEFAULT_MAX_DOC_COUNT };
}
const parts = value.split(SEPARATOR);
return {
type: 'column',
@ -350,11 +382,20 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
defaultMessage: 'Alphabetical',
}),
});
if (!currentColumn.params.secondaryFields?.length) {
orderOptions.push({
value: toValue({ type: 'rare', maxDocCount: DEFAULT_MAX_DOC_COUNT }),
text: i18n.translate('xpack.lens.indexPattern.terms.orderRare', {
defaultMessage: 'Rarity',
}),
});
}
return (
<>
<ValuesInput
value={currentColumn.params.size}
disabled={currentColumn.params.orderBy.type === 'rare'}
onChange={(value) => {
updateLayer(
updateColumnParam({
@ -366,6 +407,25 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
);
}}
/>
{currentColumn.params.orderBy.type === 'rare' && (
<ValuesInput
value={currentColumn.params.orderBy.maxDocCount}
label={i18n.translate('xpack.lens.indexPattern.terms.maxDocCount', {
defaultMessage: 'Max doc count per term',
})}
maxValue={MAXIMUM_MAX_DOC_COUNT}
onChange={(value) => {
updateLayer(
updateColumnParam({
layer,
columnId,
paramName: 'orderBy',
value: { ...currentColumn.params.orderBy, maxDocCount: value },
})
);
}}
/>
)}
<EuiFormRow
label={
<>
@ -396,12 +456,15 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
value={toValue(currentColumn.params.orderBy)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const newOrderByValue = fromValue(e.target.value);
const updatedLayer = updateColumnParam({
layer,
columnId,
paramName: 'orderBy',
value: newOrderByValue,
});
const updatedLayer = updateDefaultLabels(
updateColumnParam({
layer,
columnId,
paramName: 'orderBy',
value: newOrderByValue,
}),
indexPattern
);
updateLayer(
updateColumnParam({
layer: updatedLayer,
@ -434,6 +497,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
aria-label={i18n.translate('xpack.lens.indexPattern.terms.orderDirection', {
defaultMessage: 'Rank direction',
})}
isDisabled={currentColumn.params.orderBy.type === 'rare'}
options={[
{
id: `${idPrefix}asc`,
@ -468,38 +532,6 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
);
}}
/>
{/* <EuiSelect
compressed
data-test-subj="indexPattern-terms-orderDirection"
options={[
{
value: 'asc',
text: i18n.translate('xpack.lens.indexPattern.terms.orderAscending', {
defaultMessage: 'Ascending',
}),
},
{
value: 'desc',
text: i18n.translate('xpack.lens.indexPattern.terms.orderDescending', {
defaultMessage: 'Descending',
}),
},
]}
value={currentColumn.params.orderDirection}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
updateLayer(
updateColumnParam({
layer,
columnId,
paramName: 'orderDirection',
value: e.target.value as 'asc' | 'desc',
})
)
}
aria-label={i18n.translate('xpack.lens.indexPattern.terms.orderBy', {
defaultMessage: 'Rank by',
})}
/> */}
</EuiFormRow>
{!hasRestrictions && (
<>
@ -518,6 +550,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
compressed
data-test-subj="indexPattern-terms-other-bucket"
checked={Boolean(currentColumn.params.otherBucket)}
disabled={currentColumn.params.orderBy.type === 'rare'}
onChange={(e: EuiSwitchEvent) =>
updateLayer(
updateColumnParam({
@ -537,7 +570,8 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
compressed
disabled={
!currentColumn.params.otherBucket ||
indexPattern.getFieldByName(currentColumn.sourceField)?.type !== 'string'
indexPattern.getFieldByName(currentColumn.sourceField)?.type !== 'string' ||
currentColumn.params.orderBy.type === 'rare'
}
data-test-subj="indexPattern-terms-missing-bucket"
checked={Boolean(currentColumn.params.missingBucket)}

View file

@ -67,7 +67,7 @@ describe('terms', () => {
columnOrder: ['col1', 'col2'],
columns: {
col1: {
label: 'Top value of category',
label: 'Top values of source',
dataType: 'string',
isBucketed: true,
operationType: 'terms',
@ -79,7 +79,7 @@ describe('terms', () => {
sourceField: 'source',
} as TermsIndexPatternColumn,
col2: {
label: 'Count',
label: 'Count of records',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
@ -112,6 +112,30 @@ describe('terms', () => {
);
});
it('should reflect rare terms params correctly', () => {
const termsColumn = layer.columns.col1 as TermsIndexPatternColumn;
const esAggsFn = termsOperation.toEsAggsFn(
{
...termsColumn,
params: { ...termsColumn.params, orderBy: { type: 'rare', maxDocCount: 3 } },
},
'col1',
{} as IndexPattern,
layer,
uiSettingsMock,
[]
);
expect(esAggsFn).toEqual(
expect.objectContaining({
function: 'aggRareTerms',
arguments: expect.objectContaining({
field: ['source'],
max_doc_count: [3],
}),
})
);
});
it('should not enable missing bucket if other bucket is not set', () => {
const termsColumn = layer.columns.col1 as TermsIndexPatternColumn;
const esAggsFn = termsOperation.toEsAggsFn(
@ -1379,6 +1403,64 @@ describe('terms', () => {
expect(select.prop('disabled')).toEqual(false);
});
it('should disable missing bucket and other bucket setting for rarity sorting', () => {
const updateLayerSpy = jest.fn();
const instance = shallow(
<InlineOptions
{...defaultProps}
layer={layer}
updateLayer={updateLayerSpy}
columnId="col1"
currentColumn={{
...(layer.columns.col1 as TermsIndexPatternColumn),
params: {
...(layer.columns.col1 as TermsIndexPatternColumn).params,
orderBy: { type: 'rare', maxDocCount: 3 },
},
}}
/>
);
const select1 = instance
.find('[data-test-subj="indexPattern-terms-missing-bucket"]')
.find(EuiSwitch);
expect(select1.prop('disabled')).toEqual(true);
const select2 = instance
.find('[data-test-subj="indexPattern-terms-other-bucket"]')
.find(EuiSwitch);
expect(select2.prop('disabled')).toEqual(true);
});
it('should disable size input and show max doc count input', () => {
const updateLayerSpy = jest.fn();
const instance = shallow(
<InlineOptions
{...defaultProps}
layer={layer}
updateLayer={updateLayerSpy}
columnId="col1"
currentColumn={{
...(layer.columns.col1 as TermsIndexPatternColumn),
params: {
...(layer.columns.col1 as TermsIndexPatternColumn).params,
orderBy: { type: 'rare', maxDocCount: 3 },
},
}}
/>
);
const numberInputs = instance.find(ValuesInput);
expect(numberInputs).toHaveLength(2);
expect(numberInputs.first().prop('disabled')).toBeTruthy();
expect(numberInputs.last().prop('disabled')).toBeFalsy();
expect(numberInputs.last().prop('value')).toEqual(3);
});
it('should disable missing bucket setting if field is not a string', () => {
const updateLayerSpy = jest.fn();
const instance = shallow(
@ -1462,6 +1544,7 @@ describe('terms', () => {
expect(select.prop('options')!.map(({ value }) => value)).toEqual([
'column$$$col2',
'alphabetical',
'rare',
]);
});

View file

@ -13,7 +13,10 @@ export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn {
size: number;
// if order is alphabetical, the `fallback` flag indicates whether it became alphabetical because there wasn't
// another option or whether the user explicitly chose to make it alphabetical.
orderBy: { type: 'alphabetical'; fallback?: boolean } | { type: 'column'; columnId: string };
orderBy:
| { type: 'alphabetical'; fallback?: boolean }
| { type: 'rare'; maxDocCount: number }
| { type: 'column'; columnId: string };
orderDirection: 'asc' | 'desc';
otherBucket?: boolean;
missingBucket?: boolean;

View file

@ -13,13 +13,20 @@ import { useDebounceWithOptions } from '../../../../shared_components';
export const ValuesInput = ({
value,
onChange,
minValue = 1,
maxValue = 1000,
label = i18n.translate('xpack.lens.indexPattern.terms.size', {
defaultMessage: 'Number of values',
}),
disabled,
}: {
value: number;
onChange: (value: number) => void;
minValue?: number;
maxValue?: number;
label?: string;
disabled?: boolean;
}) => {
const MIN_NUMBER_OF_VALUES = 1;
const MAX_NUMBER_OF_VALUES = 1000;
const [inputValue, setInputValue] = useState(String(value));
useDebounceWithOptions(
@ -28,7 +35,7 @@ export const ValuesInput = ({
return;
}
const inputNumber = Number(inputValue);
onChange(Math.min(MAX_NUMBER_OF_VALUES, Math.max(inputNumber, MIN_NUMBER_OF_VALUES)));
onChange(Math.min(maxValue, Math.max(inputNumber, minValue)));
},
{ skipFirstRender: true },
256,
@ -36,14 +43,12 @@ export const ValuesInput = ({
);
const isEmptyString = inputValue === '';
const isHigherThanMax = !isEmptyString && Number(inputValue) > MAX_NUMBER_OF_VALUES;
const isLowerThanMin = !isEmptyString && Number(inputValue) < MIN_NUMBER_OF_VALUES;
const isHigherThanMax = !isEmptyString && Number(inputValue) > maxValue;
const isLowerThanMin = !isEmptyString && Number(inputValue) < minValue;
return (
<EuiFormRow
label={i18n.translate('xpack.lens.indexPattern.terms.size', {
defaultMessage: 'Number of values',
})}
label={label}
display="rowCompressed"
fullWidth
isInvalid={isHigherThanMax || isLowerThanMin}
@ -54,7 +59,7 @@ export const ValuesInput = ({
defaultMessage:
'Value is higher than the maximum {max}, the maximum value is used instead.',
values: {
max: MAX_NUMBER_OF_VALUES,
max: maxValue,
},
}),
]
@ -64,7 +69,7 @@ export const ValuesInput = ({
defaultMessage:
'Value is lower than the minimum {min}, the minimum value is used instead.',
values: {
min: MIN_NUMBER_OF_VALUES,
min: minValue,
},
}),
]
@ -72,24 +77,21 @@ export const ValuesInput = ({
}
>
<EuiFieldNumber
min={MIN_NUMBER_OF_VALUES}
max={MAX_NUMBER_OF_VALUES}
min={minValue}
max={maxValue}
step={1}
value={inputValue}
compressed
isInvalid={isHigherThanMax || isLowerThanMin}
disabled={disabled}
onChange={({ currentTarget }) => setInputValue(currentTarget.value)}
aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', {
defaultMessage: 'Number of values',
})}
aria-label={label}
onBlur={() => {
if (inputValue === '') {
return setInputValue(String(value));
}
const inputNumber = Number(inputValue);
setInputValue(
String(Math.min(MAX_NUMBER_OF_VALUES, Math.max(inputNumber, MIN_NUMBER_OF_VALUES)))
);
setInputValue(String(Math.min(maxValue, Math.max(inputNumber, minValue))));
}}
/>
</EuiFormRow>

View file

@ -5,10 +5,13 @@
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { getPrecisionErrorWarningMessages } from './utils';
import type { IndexPatternPrivateState } from './types';
import type { IndexPatternPrivateState, GenericIndexPatternColumn } from './types';
import type { FramePublicAPI } from '../types';
import type { DocLinksStart } from 'kibana/public';
import { EuiButton } from '@elastic/eui';
describe('indexpattern_datasource utils', () => {
describe('getPrecisionErrorWarningMessages', () => {
@ -17,12 +20,34 @@ describe('indexpattern_datasource utils', () => {
let docLinks: DocLinksStart;
beforeEach(() => {
state = {} as IndexPatternPrivateState;
state = {
layers: {
id: {
indexPatternId: 'one',
columns: {
col1: {
operationType: 'terms',
params: {
orderBy: {
type: 'alphabetical',
},
},
},
},
},
},
indexPatterns: {
one: {
getFieldByName: (x: string) => ({ name: x, displayName: x }),
},
},
} as unknown as IndexPatternPrivateState;
framePublicAPI = {
activeData: {
id: {
columns: [
{
id: 'col1',
meta: {
sourceParams: {
hasPrecisionError: false,
@ -43,19 +68,60 @@ describe('indexpattern_datasource utils', () => {
} as DocLinksStart;
});
test('should not show precisionError if hasPrecisionError is false', () => {
expect(getPrecisionErrorWarningMessages(state, framePublicAPI, docLinks)).toHaveLength(0);
expect(
getPrecisionErrorWarningMessages(state, framePublicAPI, docLinks, () => {})
).toHaveLength(0);
});
test('should not show precisionError if hasPrecisionError is not defined', () => {
delete framePublicAPI.activeData!.id.columns[0].meta.sourceParams!.hasPrecisionError;
expect(getPrecisionErrorWarningMessages(state, framePublicAPI, docLinks)).toHaveLength(0);
expect(
getPrecisionErrorWarningMessages(state, framePublicAPI, docLinks, () => {})
).toHaveLength(0);
});
test('should show precisionError if hasPrecisionError is true', () => {
framePublicAPI.activeData!.id.columns[0].meta.sourceParams!.hasPrecisionError = true;
expect(getPrecisionErrorWarningMessages(state, framePublicAPI, docLinks)).toHaveLength(1);
expect(
getPrecisionErrorWarningMessages(state, framePublicAPI, docLinks, () => {})
).toHaveLength(1);
});
test('if has precision error and sorting is by count ascending, show fix action and switch to rare terms', () => {
framePublicAPI.activeData!.id.columns[0].meta.sourceParams!.hasPrecisionError = true;
state.layers.id.columnOrder = ['col1', 'col2'];
state.layers.id.columns = {
col1: {
operationType: 'terms',
sourceField: 'category',
params: {
orderBy: {
type: 'column',
columnId: 'col2',
},
orderDirection: 'asc',
},
} as unknown as GenericIndexPatternColumn,
col2: {
operationType: 'count',
} as unknown as GenericIndexPatternColumn,
};
const setState = jest.fn();
const warnings = getPrecisionErrorWarningMessages(state, framePublicAPI, docLinks, setState);
expect(warnings).toHaveLength(1);
const DummyComponent = () => <>{warnings[0]}</>;
const warningUi = shallow(<DummyComponent />);
warningUi.find(EuiButton).simulate('click');
const stateSetter = setState.mock.calls[0][0];
const newState = stateSetter(state);
expect(newState.layers.id.columns.col1.label).toEqual('Rare values of category');
expect(newState.layers.id.columns.col1.params.orderBy).toEqual({
type: 'rare',
maxDocCount: 1,
});
});
});
});

View file

@ -6,21 +6,31 @@
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { DocLinksStart } from 'kibana/public';
import { EuiLink, EuiTextColor } from '@elastic/eui';
import { EuiLink, EuiTextColor, EuiButton, EuiSpacer } from '@elastic/eui';
import { DatatableColumn } from 'src/plugins/expressions';
import type { FramePublicAPI } from '../types';
import type { FramePublicAPI, StateSetter } from '../types';
import type { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from './types';
import type { ReferenceBasedIndexPatternColumn } from './operations/definitions/column_types';
import { operationDefinitionMap, GenericIndexPatternColumn } from './operations';
import {
operationDefinitionMap,
GenericIndexPatternColumn,
TermsIndexPatternColumn,
CountIndexPatternColumn,
updateColumnParam,
updateDefaultLabels,
} from './operations';
import { getInvalidFieldMessage } from './operations/definitions/helpers';
import { getInvalidFieldMessage, isColumnOfType } from './operations/definitions/helpers';
import { isQueryValid } from './operations/definitions/filters';
import { checkColumnForPrecisionError } from '../../../../../src/plugins/data/common';
import { hasField } from './pure_utils';
import { mergeLayer } from './state_helpers';
import { DEFAULT_MAX_DOC_COUNT } from './operations/definitions/terms';
export function isColumnInvalid(
layer: IndexPatternLayer,
@ -80,53 +90,125 @@ export function fieldIsInvalid(
export function getPrecisionErrorWarningMessages(
state: IndexPatternPrivateState,
{ activeData }: FramePublicAPI,
docLinks: DocLinksStart
docLinks: DocLinksStart,
setState: StateSetter<IndexPatternPrivateState>
) {
const warningMessages: React.ReactNode[] = [];
if (state && activeData) {
Object.values(activeData)
.reduce((acc: DatatableColumn[], { columns }) => [...acc, ...columns], [])
.forEach((column) => {
if (checkColumnForPrecisionError(column)) {
warningMessages.push(
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning"
defaultMessage="{name} for this visualization may be approximate due to how the data is indexed. Try increasing the number of {topValues} or use {filters} instead of {topValues} for precise results. To learn more about this limit, {link}."
values={{
name: <EuiTextColor color="accent">{column.name}</EuiTextColor>,
topValues: (
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.topValues"
defaultMessage="Top values"
/>
</EuiTextColor>
),
filters: (
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.filters"
defaultMessage="Filters"
/>
</EuiTextColor>
),
link: (
<EuiLink
href={docLinks.links.aggs.terms_doc_count_error}
color="text"
target="_blank"
external={true}
>
<FormattedMessage
defaultMessage="visit the documentation"
id="xpack.lens.indexPattern.precisionErrorWarning.link"
/>
</EuiLink>
),
}}
/>
);
Object.entries(activeData)
.reduce(
(acc, [layerId, { columns }]) => [
...acc,
...columns.map((column) => ({ layerId, column })),
],
[] as Array<{ layerId: string; column: DatatableColumn }>
)
.forEach(({ layerId, column }) => {
const currentLayer = state.layers[layerId];
const currentColumn = currentLayer?.columns[column.id];
if (currentLayer && currentColumn && checkColumnForPrecisionError(column)) {
const indexPattern = state.indexPatterns[currentLayer.indexPatternId];
const isAscendingCountSorting =
isColumnOfType<TermsIndexPatternColumn>('terms', currentColumn) &&
currentColumn.params.orderBy.type === 'column' &&
currentColumn.params.orderDirection === 'asc' &&
isColumnOfType<CountIndexPatternColumn>(
'count',
currentLayer.columns[currentColumn.params.orderBy.columnId]
);
if (!isAscendingCountSorting) {
warningMessages.push(
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning"
defaultMessage="{name} for this visualization may be approximate due to how the data is indexed. Try increasing the number of {topValues} or use {filters} instead of {topValues} for precise results. To learn more about this limit, {link}."
values={{
name: <EuiTextColor color="accent">{column.name}</EuiTextColor>,
topValues: (
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.topValues"
defaultMessage="Top values"
/>
</EuiTextColor>
),
filters: (
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.filters"
defaultMessage="Filters"
/>
</EuiTextColor>
),
link: (
<EuiLink
href={docLinks.links.aggs.terms_doc_count_error}
color="text"
target="_blank"
external={true}
>
<FormattedMessage
defaultMessage="visit the documentation"
id="xpack.lens.indexPattern.precisionErrorWarning.link"
/>
</EuiLink>
),
}}
/>
);
} else {
warningMessages.push(
<>
<FormattedMessage
id="xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning"
defaultMessage="{name} for this visualization may be approximate due to how the data is indexed. Try sorting by rarity instead of ascending count of records. To learn more about this limit, {link}."
values={{
name: <EuiTextColor color="accent">{column.name}</EuiTextColor>,
link: (
<EuiLink
href={docLinks.links.aggs.rare_terms}
color="text"
target="_blank"
external={true}
>
<FormattedMessage
defaultMessage="visit the documentation"
id="xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link"
/>
</EuiLink>
),
}}
/>
<EuiSpacer size="s" />
<EuiButton
onClick={() => {
setState((prevState) =>
mergeLayer({
state: prevState,
layerId,
newLayer: updateDefaultLabels(
updateColumnParam({
layer: currentLayer,
columnId: column.id,
paramName: 'orderBy',
value: {
type: 'rare',
maxDocCount: DEFAULT_MAX_DOC_COUNT,
},
}),
indexPattern
),
})
);
}}
>
{i18n.translate('xpack.lens.indexPattern.switchToRare', {
defaultMessage: 'Rank by rarity',
})}
</EuiButton>
</>
);
}
}
});
}

View file

@ -280,7 +280,11 @@ export interface Datasource<T = unknown, P = unknown> {
/**
* The frame calls this function to display warnings about visualization
*/
getWarningMessages?: (state: T, frame: FramePublicAPI) => React.ReactNode[] | undefined;
getWarningMessages?: (
state: T,
frame: FramePublicAPI,
setState: StateSetter<T>
) => React.ReactNode[] | undefined;
/**
* Checks if the visualization created is time based, for example date histogram
*/