Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Lens] Custom labels for ranges #79628

Merged
merged 13 commits into from
Oct 14, 2020
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ describe('AggConfig Filters', () => {
{
gte: 1024,
lt: 2048.0,
label: 'A custom label',
}
);

Expand All @@ -78,6 +79,7 @@ describe('AggConfig Filters', () => {
expect(filter.range).toHaveProperty('bytes');
expect(filter.range.bytes).toHaveProperty('gte', 1024.0);
expect(filter.range.bytes).toHaveProperty('lt', 2048.0);
expect(filter.range.bytes).not.toHaveProperty('label');
expect(filter.meta).toHaveProperty('formattedValue');
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { IBucketAggConfig } from '../bucket_agg_type';
export const createFilterRange = (
getFieldFormatsStart: AggTypesDependencies['getFieldFormatsStart']
) => {
return (aggConfig: IBucketAggConfig, params: any) => {
return (aggConfig: IBucketAggConfig, { label, ...params }: any) => {
const { deserialize } = getFieldFormatsStart();
return buildRangeFilter(
aggConfig.params.field,
Expand Down
11 changes: 8 additions & 3 deletions src/plugins/data/common/search/aggs/buckets/range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface AggParamsRange extends BaseAggParams {
ranges?: Array<{
from: number;
to: number;
label?: string;
}>;
}

Expand All @@ -67,11 +68,11 @@ export const getRangeBucketAgg = ({ getFieldFormatsStart }: RangeBucketAggDepend
keyCaches.set(agg, keys);
}

const id = RangeKey.idBucket(bucket);
const id = RangeKey.idBucket(bucket, agg.params.ranges);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgive me if this is a naive question -- but why is it necessary to change the format of the bucket id to include the label? I assume this is solving an edge case, but it wasn't completely clear to me from reading the discussion above.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was under the impression it's necessary to not run into cached RangeKey objects with outdated labels, but I realized the cache is only used for a single request, so it's not an issue. Removed it


key = keys.get(id);
if (!key) {
key = new RangeKey(bucket);
key = new RangeKey(bucket, agg.params.ranges);
keys.set(id, key);
}

Expand Down Expand Up @@ -102,7 +103,11 @@ export const getRangeBucketAgg = ({ getFieldFormatsStart }: RangeBucketAggDepend
{ from: 1000, to: 2000 },
],
write(aggConfig, output) {
output.params.ranges = aggConfig.params.ranges;
output.params.ranges = (aggConfig.params as AggParamsRange).ranges?.map((range) => ({
to: range.to,
from: range.from,
}));

output.params.keyed = true;
},
},
Expand Down
27 changes: 24 additions & 3 deletions src/plugins/data/common/search/aggs/buckets/range_key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,41 @@

const id = Symbol('id');

type Ranges = Array<{
from: string | number | undefined;
to: string | number | undefined;
label?: string;
}>;
flash1293 marked this conversation as resolved.
Show resolved Hide resolved

export class RangeKey {
[id]: string;
gte: string | number;
lt: string | number;
label?: string;

private static findCustomLabel(from: string | number, to: string | number, ranges: Ranges) {
flash1293 marked this conversation as resolved.
Show resolved Hide resolved
return ranges
? ranges.find(
(range) =>
((from === -Infinity && range.from === undefined) || range.from === from) &&
((to === Infinity && range.to === undefined) || range.to === to)
)?.label
: undefined;
}

constructor(bucket: any) {
constructor(bucket: any, allRanges?: Ranges) {
this.gte = bucket.from == null ? -Infinity : bucket.from;
this.lt = bucket.to == null ? +Infinity : bucket.to;
this.label = allRanges ? RangeKey.findCustomLabel(this.gte, this.lt, allRanges) : undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing bucket.from/to here can simplify the code in the findCustomLabel a bit (removing non-finite check).
They effectively equivalent, but the current version is more verbose.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also the ternary operator can be dropped as the missing ranges would be handled by findCustomLabel

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch for the ternary operator, I'm not sure whether it makes it much easier because we still have to look for null/undefined.


this[id] = RangeKey.idBucket(bucket);
}

static idBucket(bucket: any) {
return `from:${bucket.from},to:${bucket.to}`;
static idBucket(bucket: any, allRanges?: Ranges) {
const customLabel = allRanges
flash1293 marked this conversation as resolved.
Show resolved Hide resolved
? RangeKey.findCustomLabel(bucket.from, bucket.to, allRanges)
: undefined;
return `from:${bucket.from},to:${bucket.to},label:${customLabel}`;
}

toString() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ describe('getFormatWithAggs', () => {
expect(getFormat).toHaveBeenCalledTimes(1);
});

test('returns custom label for range if provided', () => {
const mapping = { id: 'range', params: {} };
const getFieldFormat = getFormatWithAggs(getFormat);
const format = getFieldFormat(mapping);

expect(format.convert({ gte: 1, lt: 20, label: 'custom' })).toBe('custom');
// underlying formatter is not called because custom label can be used directly
expect(getFormat).toHaveBeenCalledTimes(0);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test for handling multiple overlapping ranges?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What kind of test are you expecting? The formatter only ever gets a single value passed in. Do you want to cover the behavior of the built-in cache?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I left this in the wrong place- I was worried about the duplicate label case, but this is the wrong place to test it.


test('creates custom format for terms', () => {
const mapping = {
id: 'terms',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export function getFormatWithAggs(getFieldFormat: GetFieldFormat): GetFieldForma
const customFormats: Record<string, () => IFieldFormat> = {
range: () => {
const RangeFormat = FieldFormat.from((range: any) => {
if (range.label) {
return range.label;
}
const nestedFormatter = params as SerializedFieldFormat;
const format = getFieldFormat({
id: nestedFormatter.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface Range {
type: typeof name;
from: number;
to: number;
label?: string;
}

export const range: ExpressionTypeDefinition<typeof name, Range> = {
Expand All @@ -41,7 +42,7 @@ export const range: ExpressionTypeDefinition<typeof name, Range> = {
},
to: {
render: (value: Range): ExpressionValueRender<{ text: string }> => {
const text = `from ${value.from} to ${value.to}`;
const text = value?.label ? value.label : `from ${value.from} to ${value.to}`;
flash1293 marked this conversation as resolved.
Show resolved Hide resolved
return {
type: 'render',
as: 'text',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import { keys } from '@elastic/eui';
import { IFieldFormat } from '../../../../../../../../src/plugins/data/common';
import { RangeTypeLens, isValidRange, isValidNumber } from './ranges';
import { FROM_PLACEHOLDER, TO_PLACEHOLDER, TYPING_DEBOUNCE_TIME } from './constants';
import { NewBucketButton, DragDropBuckets, DraggableBucketContainer } from '../shared_components';
import {
NewBucketButton,
DragDropBuckets,
DraggableBucketContainer,
LabelInput,
} from '../shared_components';

const generateId = htmlIdGenerator();

Expand Down Expand Up @@ -63,7 +68,7 @@ export const RangePopover = ({
// send the range back to the main state
setRange(newRange);
};
const { from, to } = tempRange;
const { from, to, label } = tempRange;

const lteAppendLabel = i18n.translate('xpack.lens.indexPattern.ranges.lessThanOrEqualAppend', {
defaultMessage: '\u2264',
Expand Down Expand Up @@ -159,6 +164,25 @@ export const RangePopover = ({
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiFormRow>
<LabelInput
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @flash1293, I saw this one got merged already, but you make this input compressed since it lives in a popover and will then match the range inputs better.

value={label || ''}
onChange={(newLabel) => {
const newRange = {
...tempRange,
label: newLabel,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this resets to the empty string to indicate that it's empty, is that going to cause problems with equality? Imagine the steps 1. Create custom label 2. Delete custom label 3. Formats correctly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested, this doesn't cause any issues

};
setTempRange(newRange);
saveRangeAndReset(newRange);
}}
placeholder={i18n.translate(
'xpack.lens.indexPattern.ranges.customRangeLabelPlaceholder',
{ defaultMessage: 'Custom label' }
)}
onSubmit={onSubmit}
dataTestSubj="indexPattern-ranges-label"
/>
</EuiFormRow>
</EuiPopover>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from './constants';
import { RangePopover } from './advanced_editor';
import { DragDropBuckets } from '../shared_components';
import { EuiFieldText } from '@elastic/eui';

const dataPluginMockValue = dataPluginMock.createStartContract();
// need to overwrite the formatter field first
Expand Down Expand Up @@ -152,6 +153,25 @@ describe('ranges', () => {
})
);
});

it('should include custom labels', () => {
setToRangeMode();
(state.layers.first.columns.col1 as RangeIndexPatternColumn).params.ranges = [
{ from: 0, to: 100, label: 'customlabel' },
];

const esAggsConfig = rangeOperation.toEsAggsConfig(
state.layers.first.columns.col1 as RangeIndexPatternColumn,
'col1',
{} as IndexPattern
);

expect((esAggsConfig as { params: unknown }).params).toEqual(
expect.objectContaining({
ranges: [{ from: 0, to: 100, label: 'customlabel' }],
})
);
});
});

describe('getPossibleOperationForField', () => {
Expand Down Expand Up @@ -419,6 +439,63 @@ describe('ranges', () => {
});
});

it('should add a new range with custom label', () => {
const setStateSpy = jest.fn();

const instance = mount(
<InlineOptions
{...defaultOptions}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as RangeIndexPatternColumn}
layerId="first"
/>
);

// This series of act clojures are made to make it work properly the update flush
act(() => {
instance.find(EuiButtonEmpty).prop('onClick')!({} as ReactMouseEvent);
});

act(() => {
// need another wrapping for this in order to work
instance.update();

expect(instance.find(RangePopover)).toHaveLength(2);

// edit the label and check
instance.find(RangePopover).find(EuiFieldText).first().prop('onChange')!({
target: {
value: 'customlabel',
},
} as React.ChangeEvent<HTMLInputElement>);
jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4);

expect(setStateSpy).toHaveBeenCalledWith({
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
...state.layers.first.columns.col1,
params: {
...state.layers.first.columns.col1.params,
ranges: [
{ from: 0, to: DEFAULT_INTERVAL, label: '' },
{ from: DEFAULT_INTERVAL, to: Infinity, label: 'customlabel' },
],
},
},
},
},
},
});
});
});

it('should open a popover to edit an existing range', () => {
const setStateSpy = jest.fn();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ function getEsAggsParams({ sourceField, params }: RangeIndexPatternColumn) {
field: sourceField,
ranges: params.ranges.filter(isValidRange).map<Partial<RangeType>>((range) => {
if (isFullRange(range)) {
return { from: range.from, to: range.to };
return { from: range.from, to: range.to, label: range.label };
flash1293 marked this conversation as resolved.
Show resolved Hide resolved
}
const partialRange: Partial<RangeType> = {};
const partialRange: Partial<RangeType> = { label: range.label };
// be careful with the fields to set on partial ranges
if (isValidNumber(range.from)) {
partialRange.from = range.from;
Expand Down