[Lens] Truncate field dropdown in the middle (#106285)

* [Lens] Truncate field dropdown in the middle

* implementation

* aligning width of the elements, calculating width in canvas, serving edgecases like selected element, tests update

* revert selectedField as it doesn't solve all cases

* code review

* cr

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marta Bondyra 2021-07-26 11:32:00 +02:00 committed by GitHub
parent 7c064ec31e
commit 91aa2bb8ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 251 additions and 53 deletions

View file

@ -7,8 +7,9 @@
import './field_select.scss';
import { partition } from 'lodash';
import React, { useMemo } from 'react';
import React, { useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import {
EuiComboBox,
EuiFlexGroup,
@ -17,7 +18,6 @@ import {
EuiComboBoxProps,
} from '@elastic/eui';
import classNames from 'classnames';
import { EuiHighlight } from '@elastic/eui';
import { OperationType } from '../indexpattern';
import { LensFieldIcon } from '../lens_field_icon';
import { DataType } from '../../types';
@ -25,7 +25,7 @@ import { OperationSupportMatrix } from './operation_support';
import { IndexPattern, IndexPatternPrivateState } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { fieldExists } from '../pure_helpers';
import { TruncatedLabel } from './truncated_label';
export interface FieldChoice {
type: 'field';
field: string;
@ -45,6 +45,10 @@ export interface FieldSelectProps extends EuiComboBoxProps<EuiComboBoxOptionOpti
markAllFieldsCompatible?: boolean;
}
const DEFAULT_COMBOBOX_WIDTH = 305;
const COMBOBOX_PADDINGS = 90;
const DEFAULT_FONT = '14px Inter';
export function FieldSelect({
currentIndexPattern,
incompleteOperation,
@ -168,60 +172,90 @@ export function FieldSelect({
existingFields,
markAllFieldsCompatible,
]);
const comboBoxRef = useRef<HTMLInputElement>(null);
const [labelProps, setLabelProps] = React.useState<{
width: number;
font: string;
}>({
width: DEFAULT_COMBOBOX_WIDTH - COMBOBOX_PADDINGS,
font: DEFAULT_FONT,
});
const computeStyles = (_e: UIEvent | undefined, shouldRecomputeAll = false) => {
if (comboBoxRef.current) {
const current = {
...labelProps,
width: comboBoxRef.current?.clientWidth - COMBOBOX_PADDINGS,
};
if (shouldRecomputeAll) {
current.font = window.getComputedStyle(comboBoxRef.current).font;
}
setLabelProps(current);
}
};
useEffectOnce(() => {
if (comboBoxRef.current) {
computeStyles(undefined, true);
}
window.addEventListener('resize', computeStyles);
});
return (
<EuiComboBox
fullWidth
compressed
isClearable={false}
data-test-subj="indexPattern-dimension-field"
placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', {
defaultMessage: 'Field',
})}
options={(memoizedFieldOptions as unknown) as EuiComboBoxOptionOption[]}
isInvalid={Boolean(incompleteOperation || fieldIsInvalid)}
selectedOptions={
((selectedOperationType && selectedField
? [
{
label: fieldIsInvalid
? selectedField
: currentIndexPattern.getFieldByName(selectedField)?.displayName,
value: { type: 'field', field: selectedField },
},
]
: []) as unknown) as EuiComboBoxOptionOption[]
}
singleSelection={{ asPlainText: true }}
onChange={(choices) => {
if (choices.length === 0) {
onDeleteColumn?.();
return;
<div ref={comboBoxRef}>
<EuiComboBox
fullWidth
compressed
isClearable={false}
data-test-subj="indexPattern-dimension-field"
placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', {
defaultMessage: 'Field',
})}
options={(memoizedFieldOptions as unknown) as EuiComboBoxOptionOption[]}
isInvalid={Boolean(incompleteOperation || fieldIsInvalid)}
selectedOptions={
((selectedOperationType && selectedField
? [
{
label: fieldIsInvalid
? selectedField
: currentIndexPattern.getFieldByName(selectedField)?.displayName,
value: { type: 'field', field: selectedField },
},
]
: []) as unknown) as EuiComboBoxOptionOption[]
}
singleSelection={{ asPlainText: true }}
onChange={(choices) => {
if (choices.length === 0) {
onDeleteColumn?.();
return;
}
const choice = (choices[0].value as unknown) as FieldChoice;
const choice = (choices[0].value as unknown) as FieldChoice;
if (choice.field !== selectedField) {
trackUiEvent('indexpattern_dimension_field_changed');
onChoose(choice);
}
}}
renderOption={(option, searchValue) => {
return (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={null}>
<LensFieldIcon
type={((option.value as unknown) as { dataType: DataType }).dataType}
fill="none"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiHighlight search={searchValue}>{option.label}</EuiHighlight>
</EuiFlexItem>
</EuiFlexGroup>
);
}}
{...rest}
/>
if (choice.field !== selectedField) {
trackUiEvent('indexpattern_dimension_field_changed');
onChoose(choice);
}
}}
renderOption={(option, searchValue) => {
return (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={null}>
<LensFieldIcon
type={((option.value as unknown) as { dataType: DataType }).dataType}
fill="none"
/>
</EuiFlexItem>
<EuiFlexItem>
<TruncatedLabel {...labelProps} label={option.label} search={searchValue} />
</EuiFlexItem>
</EuiFlexGroup>
);
}}
{...rest}
/>
</div>
);
}

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import 'jest-canvas-mock';
import { TruncatedLabel } from './truncated_label';
describe('truncated_label', () => {
const defaultProps = {
font: '14px Inter',
// jest-canvas-mock mocks measureText as the number of string characters, thats why the width is so low
width: 30,
search: '',
label: 'example_field',
};
it('displays passed label if shorter than passed labelLength', () => {
const wrapper = mount(<TruncatedLabel {...defaultProps} />);
expect(wrapper.text()).toEqual('example_field');
});
it('middle truncates label', () => {
const wrapper = mount(
<TruncatedLabel {...defaultProps} label="example_space.example_field.subcategory.subfield" />
);
expect(wrapper.text()).toEqual('example_….subcategory.subfield');
});
describe('with search value passed', () => {
it('constructs truncated label when searching for the string of index = 0', () => {
const wrapper = mount(
<TruncatedLabel
{...defaultProps}
search="example_space"
label="example_space.example_field.subcategory.subfield"
/>
);
expect(wrapper.text()).toEqual('example_space.example_field.s…');
expect(wrapper.find('mark').text()).toEqual('example_space');
});
it('constructs truncated label when searching for the string in the middle', () => {
const wrapper = mount(
<TruncatedLabel
{...defaultProps}
search={'ample_field'}
label="example_space.example_field.subcategory.subfield"
/>
);
expect(wrapper.text()).toEqual('…ample_field.subcategory.subf…');
expect(wrapper.find('mark').text()).toEqual('ample_field');
});
it('constructs truncated label when searching for the string at the end of the label', () => {
const wrapper = mount(
<TruncatedLabel
{...defaultProps}
search={'subf'}
label="example_space.example_field.subcategory.subfield"
/>
);
expect(wrapper.text()).toEqual('…le_field.subcategory.subfield');
expect(wrapper.find('mark').text()).toEqual('subf');
});
it('constructs truncated label when searching for the string longer than the truncated width and highlights the whole content', () => {
const wrapper = mount(
<TruncatedLabel
{...defaultProps}
search={'ample_space.example_field.subcategory.subfie'}
label="example_space.example_field.subcategory.subfield"
/>
);
expect(wrapper.text()).toEqual('…ample_space.example_field.su…');
expect(wrapper.find('mark').text()).toEqual('…ample_space.example_field.su…');
});
});
});

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { EuiMark } from '@elastic/eui';
import { EuiHighlight } from '@elastic/eui';
const createContext = () =>
document.createElement('canvas').getContext('2d') as CanvasRenderingContext2D;
// extracted from getTextWidth for performance
const context = createContext();
const getTextWidth = (text: string, font: string) => {
const ctx = context ?? createContext();
ctx.font = font;
const metrics = ctx.measureText(text);
return metrics.width;
};
const truncateLabel = (
width: number,
font: string,
label: string,
approximateLength: number,
labelFn: (label: string, length: number) => string
) => {
let output = labelFn(label, approximateLength);
while (getTextWidth(output, font) > width) {
approximateLength = approximateLength - 1;
output = labelFn(label, approximateLength);
}
return output;
};
export const TruncatedLabel = React.memo(function TruncatedLabel({
label,
width,
search,
font,
}: {
label: string;
search: string;
width: number;
font: string;
}) {
const textWidth = useMemo(() => getTextWidth(label, font), [label, font]);
if (textWidth < width) {
return <EuiHighlight search={search}>{label}</EuiHighlight>;
}
const searchPosition = label.indexOf(search);
const approximateLen = Math.round((width * label.length) / textWidth);
const separator = ``;
let separatorsLength = separator.length;
let labelFn;
if (!search || searchPosition === -1) {
labelFn = (text: string, length: number) =>
`${text.substr(0, 8)}${separator}${text.substr(text.length - (length - 8))}`;
} else if (searchPosition === 0) {
// search phrase at the beginning
labelFn = (text: string, length: number) => `${text.substr(0, length)}${separator}`;
} else if (approximateLen > label.length - searchPosition) {
// search phrase close to the end or at the end
labelFn = (text: string, length: number) => `${separator}${text.substr(text.length - length)}`;
} else {
// search phrase is in the middle
labelFn = (text: string, length: number) =>
`${separator}${text.substr(searchPosition, length)}${separator}`;
separatorsLength = 2 * separator.length;
}
const outputLabel = truncateLabel(width, font, label, approximateLen, labelFn);
return search.length < outputLabel.length - separatorsLength ? (
<EuiHighlight search={search}>{outputLabel}</EuiHighlight>
) : (
<EuiMark>{outputLabel}</EuiMark>
);
});