[Lens] [Unified Search] Fix field truncation on Combo boxes (#170889)

## Summary

Fixes 3/5 of https://github.com/elastic/kibana/issues/168753
Doesn't set up multilines. Doesn't remove auto-expanding logic. Middle
truncates.

(Unified Search) Field selector
<img width="984" alt="Screenshot 2023-11-13 at 11 30 20"
src="9acb6462-3205-4e5c-81bd-c3ae10c8323b">

(Unified Search) Value selector: 
<img width="972" alt="Screenshot 2023-11-13 at 11 30 30"
src="e58b09de-d582-431f-bbd6-97b7c5bd38de">


(Lens) Field picker within layer config: 
<img width="346" alt="Screenshot 2023-11-09 at 14 44 58"
src="4ecb0ea5-bb01-49e3-a54f-4c8c5884c418">

Also fixes tiny stylistic issue for dataview picker label cut on the
bottom:
<img width="368" alt="Screenshot 2023-11-09 at 15 06 38"
src="b9ae6956-c1ef-481e-905d-71ffe5e5545a">
<img width="386" alt="Screenshot 2023-11-09 at 15 07 08"
src="5d49ed7a-e8f2-40c1-ac53-a3580b82740e">
This commit is contained in:
Marta Bondyra 2023-11-15 15:36:08 +01:00 committed by GitHub
parent 29853c79f3
commit 362ef64751
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 98 additions and 546 deletions

View file

@ -7,14 +7,11 @@
*/
import './field_picker.scss';
import React, { useRef } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { EuiComboBox, EuiComboBoxProps, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiComboBox, EuiComboBoxProps } from '@elastic/eui';
import { FieldIcon } from '@kbn/field-utils/src/components/field_icon';
import classNames from 'classnames';
import type { DataType } from './types';
import { TruncatedLabel } from './truncated_label';
import type { FieldOptionValue, FieldOption } from './types';
export interface FieldPickerProps<T extends FieldOptionValue>
@ -27,9 +24,8 @@ export interface FieldPickerProps<T extends FieldOptionValue>
'data-test-subj'?: string;
}
const DEFAULT_COMBOBOX_WIDTH = 305;
const COMBOBOX_PADDINGS = 90;
const DEFAULT_FONT = '14px Inter';
const MIDDLE_TRUNCATION_PROPS = { truncation: 'middle' as const };
const SINGLE_SELECTION_AS_TEXT_PROPS = { asPlainText: true };
export function FieldPicker<T extends FieldOptionValue = FieldOptionValue>({
selectedOptions,
@ -40,95 +36,86 @@ export function FieldPicker<T extends FieldOptionValue = FieldOptionValue>({
['data-test-subj']: dataTestSub,
...rest
}: FieldPickerProps<T>) {
let theLongestLabel = '';
const styledOptions = options?.map(({ compatible, exists, ...otherAttr }) => {
if (otherAttr.options) {
return {
...otherAttr,
options: otherAttr.options.map(({ exists: fieldOptionExists, ...fieldOption }) => ({
...fieldOption,
className: classNames({
'lnFieldPicker__option--incompatible': !fieldOption.compatible,
'lnFieldPicker__option--nonExistant': !fieldOptionExists,
}),
})),
options: otherAttr.options.map(({ exists: fieldOptionExists, ...fieldOption }) => {
if (fieldOption.label.length > theLongestLabel.length) {
theLongestLabel = fieldOption.label;
}
return {
...fieldOption,
prepend: fieldOption.value.dataType ? (
<FieldIcon
type={fieldOption.value.dataType}
fill="none"
className="eui-alignMiddle"
/>
) : null,
className: classNames({
'lnFieldPicker__option--incompatible': !fieldOption.compatible,
'lnFieldPicker__option--nonExistant': !fieldOptionExists,
}),
};
}),
};
}
return {
...otherAttr,
compatible,
prepend: otherAttr.value.dataType ? (
<FieldIcon type={otherAttr.value.dataType} fill="none" className="eui-alignMiddle" />
) : null,
className: classNames({
'lnFieldPicker__option--incompatible': !compatible,
'lnFieldPicker__option--nonExistant': !exists,
}),
};
});
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);
});
const panelMinWidth = getPanelMinWidth(theLongestLabel.length);
return (
<div ref={comboBoxRef}>
<EuiComboBox
fullWidth
compressed
isClearable={false}
data-test-subj={dataTestSub ?? 'indexPattern-dimension-field'}
placeholder={i18n.translate('visualizationUiComponents.fieldPicker.fieldPlaceholder', {
defaultMessage: 'Select a field',
})}
options={styledOptions}
isInvalid={fieldIsInvalid}
selectedOptions={selectedOptions}
singleSelection={{ asPlainText: true }}
onChange={(choices) => {
if (choices.length === 0) {
onDelete?.();
return;
}
onChoose(choices[0].value);
}}
renderOption={(option, searchValue) => {
return (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={null}>
<FieldIcon
type={(option.value as unknown as { dataType: DataType }).dataType}
fill="none"
/>
</EuiFlexItem>
<EuiFlexItem>
<TruncatedLabel {...labelProps} label={option.label} search={searchValue} />
</EuiFlexItem>
</EuiFlexGroup>
);
}}
{...rest}
/>
</div>
<EuiComboBox
fullWidth
compressed
isClearable={false}
data-test-subj={dataTestSub ?? 'indexPattern-dimension-field'}
placeholder={i18n.translate('visualizationUiComponents.fieldPicker.fieldPlaceholder', {
defaultMessage: 'Select a field',
})}
options={styledOptions}
isInvalid={fieldIsInvalid}
selectedOptions={selectedOptions}
singleSelection={SINGLE_SELECTION_AS_TEXT_PROPS}
truncationProps={MIDDLE_TRUNCATION_PROPS}
inputPopoverProps={{ panelMinWidth }}
onChange={(choices) => {
if (choices.length === 0) {
onDelete?.();
return;
}
onChoose(choices[0].value);
}}
{...rest}
/>
);
}
const MINIMUM_POPOVER_WIDTH = 300;
const MINIMUM_POPOVER_WIDTH_CHAR_COUNT = 28;
const AVERAGE_CHAR_WIDTH = 7;
const MAXIMUM_POPOVER_WIDTH_CHAR_COUNT = 60;
const MAXIMUM_POPOVER_WIDTH = 550; // fitting 60 characters
function getPanelMinWidth(labelLength: number) {
if (labelLength > MAXIMUM_POPOVER_WIDTH_CHAR_COUNT) {
return MAXIMUM_POPOVER_WIDTH;
}
if (labelLength > MINIMUM_POPOVER_WIDTH_CHAR_COUNT) {
const overflownChars = labelLength - MINIMUM_POPOVER_WIDTH_CHAR_COUNT;
return MINIMUM_POPOVER_WIDTH + overflownChars * AVERAGE_CHAR_WIDTH;
}
return MINIMUM_POPOVER_WIDTH;
}

View file

@ -7,5 +7,4 @@
*/
export { FieldPicker } from './field_picker';
export { TruncatedLabel } from './truncated_label';
export type { FieldOptionValue, FieldOption, DataType } from './types';

View file

@ -1,78 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 { mount } from 'enzyme';
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

@ -1,86 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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, { useMemo } from 'react';
import { EuiMark, 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>
);
});

View file

@ -8,7 +8,6 @@
export {
FieldPicker,
TruncatedLabel,
NameInput,
DebouncedInput,
useDebouncedValue,

View file

@ -63,6 +63,7 @@ function FilterBadge({
<span
css={css`
white-space: normal;
overflow-wrap: break-word;
`}
>
<EuiTextBlockTruncate lines={10}>

View file

@ -56,7 +56,11 @@ import {
} from './lib/filter_editor_utils';
import { FiltersBuilder } from '../../filters_builder';
import { FilterBadgeGroup } from '../../filter_badge/filter_badge_group';
import { flattenFilters } from './lib/helpers';
import {
MIDDLE_TRUNCATION_PROPS,
SINGLE_SELECTION_AS_TEXT_PROPS,
flattenFilters,
} from './lib/helpers';
import {
filterBadgeStyle,
filterPreviewLabelStyle,
@ -301,9 +305,10 @@ class FilterEditorComponent extends Component<FilterEditorProps, State> {
selectedOptions={selectedDataView ? [selectedDataView] : []}
getLabel={(indexPattern) => indexPattern.getName()}
onChange={this.onIndexPatternChange}
singleSelection={{ asPlainText: true }}
isClearable={false}
data-test-subj="filterIndexPatternsSelect"
singleSelection={SINGLE_SELECTION_AS_TEXT_PROPS}
truncationProps={MIDDLE_TRUNCATION_PROPS}
/>
</EuiFormRow>
<EuiSpacer size="s" />
@ -371,7 +376,7 @@ class FilterEditorComponent extends Component<FilterEditorProps, State> {
</strong>
}
>
<EuiText size="xs" data-test-subj="filter-preview">
<EuiText size="xs" data-test-subj="filter-preview" css={{ overflowWrap: 'break-word' }}>
<FilterBadgeGroup
filters={[localFilter]}
dataViews={this.props.indexPatterns}

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { EuiComboBox, EuiComboBoxOptionOption, useEuiTheme } from '@elastic/eui';
import { EuiComboBox, EuiComboBoxOptionOption, useEuiTheme, EuiComboBoxProps } from '@elastic/eui';
import React from 'react';
import { genericComboBoxStyle } from './generic_combo_box.styles';
@ -21,6 +21,7 @@ export interface GenericComboBoxProps<T> {
OPTION_CONTENT_CLASSNAME: string
) => React.ReactNode;
inputRef?: ((instance: HTMLInputElement | null) => void) | undefined;
truncationProps?: EuiComboBoxProps<T>['truncationProps'];
[propName: string]: any;
}

View file

@ -36,7 +36,6 @@ export { PhrasesValuesInput } from './phrases_values_input';
export { PhraseValueInput } from './phrase_value_input';
export { RangeValueInput, isRangeParams } from './range_value_input';
export { ValueInputType } from './value_input_type';
export { TruncatedLabel } from './truncated_label';
export { FilterEditor } from './filter_editor';
export type { FilterEditorProps } from './filter_editor';

View file

@ -63,3 +63,6 @@ export const flattenFilters = (filter: Filter[]) => {
return returnArray;
};
export const MIDDLE_TRUNCATION_PROPS = { truncation: 'middle' as const };
export const SINGLE_SELECTION_AS_TEXT_PROPS = { asPlainText: true };

View file

@ -10,11 +10,10 @@ import { InjectedIntl, injectI18n } from '@kbn/i18n-react';
import { uniq } from 'lodash';
import React from 'react';
import { withKibana } from '@kbn/kibana-react-plugin/public';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { GenericComboBox, GenericComboBoxProps } from './generic_combo_box';
import { PhraseSuggestorUI, PhraseSuggestorProps } from './phrase_suggestor';
import { ValueInputType } from './value_input_type';
import { TruncatedLabel } from './truncated_label';
import { MIDDLE_TRUNCATION_PROPS, SINGLE_SELECTION_AS_TEXT_PROPS } from './lib/helpers';
interface PhraseValueInputProps extends PhraseSuggestorProps {
value?: string;
@ -26,10 +25,6 @@ interface PhraseValueInputProps extends PhraseSuggestorProps {
invalid?: boolean;
}
const DEFAULT_COMBOBOX_WIDTH = 250;
const COMBOBOX_PADDINGS = 10;
const DEFAULT_FONT = '14px Inter';
class PhraseValueInputUI extends PhraseSuggestorUI<PhraseValueInputProps> {
comboBoxWrapperRef = React.createRef<HTMLDivElement>();
inputRef: HTMLInputElement | null = null;
@ -92,24 +87,11 @@ class PhraseValueInputUI extends PhraseSuggestorUI<PhraseValueInputProps> {
});
}}
onSearchChange={this.onSearchChange}
singleSelection={{ asPlainText: true }}
onCreateOption={onChange}
isClearable={false}
data-test-subj="filterParamsComboBox phraseParamsComboxBox"
renderOption={(option, searchValue) => (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem>
<TruncatedLabel
defaultComboboxWidth={DEFAULT_COMBOBOX_WIDTH}
defaultFont={DEFAULT_FONT}
comboboxPaddings={COMBOBOX_PADDINGS}
comboBoxWrapperRef={this.comboBoxWrapperRef}
label={option.label}
search={searchValue}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
singleSelection={SINGLE_SELECTION_AS_TEXT_PROPS}
truncationProps={MIDDLE_TRUNCATION_PROPS}
/>
</div>
);

View file

@ -10,11 +10,11 @@ import { InjectedIntl, injectI18n } from '@kbn/i18n-react';
import { uniq } from 'lodash';
import React from 'react';
import { withKibana } from '@kbn/kibana-react-plugin/public';
import { EuiFlexGroup, EuiFlexItem, withEuiTheme, WithEuiThemeProps } from '@elastic/eui';
import { withEuiTheme, WithEuiThemeProps } from '@elastic/eui';
import { GenericComboBox, GenericComboBoxProps } from './generic_combo_box';
import { PhraseSuggestorUI, PhraseSuggestorProps } from './phrase_suggestor';
import { TruncatedLabel } from './truncated_label';
import { phrasesValuesComboboxCss } from './phrases_values_input.styles';
import { MIDDLE_TRUNCATION_PROPS } from './lib/helpers';
interface Props {
values?: string[];
@ -27,11 +27,6 @@ interface Props {
}
export type PhrasesValuesInputProps = Props & PhraseSuggestorProps & WithEuiThemeProps;
const DEFAULT_COMBOBOX_WIDTH = 250;
const COMBOBOX_PADDINGS = 20;
const DEFAULT_FONT = '14px Inter';
class PhrasesValuesInputUI extends PhraseSuggestorUI<PhrasesValuesInputProps> {
comboBoxWrapperRef = React.createRef<HTMLDivElement>();
@ -67,20 +62,7 @@ class PhrasesValuesInputUI extends PhraseSuggestorUI<PhrasesValuesInputProps> {
isClearable={false}
data-test-subj="filterParamsComboBox phrasesParamsComboxBox"
isDisabled={disabled}
renderOption={(option, searchValue) => (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem>
<TruncatedLabel
defaultComboboxWidth={DEFAULT_COMBOBOX_WIDTH}
defaultFont={DEFAULT_FONT}
comboboxPaddings={COMBOBOX_PADDINGS}
comboBoxWrapperRef={this.comboBoxWrapperRef}
label={option.label}
search={searchValue}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
truncationProps={MIDDLE_TRUNCATION_PROPS}
/>
</div>
);

View file

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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, { ComponentProps } from 'react';
import { mount } from 'enzyme';
import { TruncatedLabel } from './truncated_label';
describe('truncated_label', () => {
const defaultProps: ComponentProps<typeof TruncatedLabel> = {
defaultFont: '14px Inter',
defaultComboboxWidth: 130,
comboboxPaddings: 100,
comboBoxWrapperRef: React.createRef<HTMLDivElement>(),
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

@ -1,141 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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, { RefObject, useMemo } from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { EuiMark } from '@elastic/eui';
import { EuiHighlight } from '@elastic/eui';
import { throttle } from 'lodash';
interface TruncatedLabelProps {
label: string;
search: string;
comboBoxWrapperRef: RefObject<HTMLDivElement | null>;
defaultFont: string;
defaultComboboxWidth: number;
comboboxPaddings: number;
}
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;
const newOutput = labelFn(label, approximateLength);
if (newOutput === output) {
break;
}
output = newOutput;
}
return output;
};
export const TruncatedLabel = React.memo(function TruncatedLabel({
label,
comboBoxWrapperRef,
search,
defaultFont,
defaultComboboxWidth,
comboboxPaddings,
}: TruncatedLabelProps) {
const [labelProps, setLabelProps] = React.useState<{
width: number;
font: string;
}>({
width: defaultComboboxWidth - comboboxPaddings,
font: defaultFont,
});
const computeStyles = (_e: UIEvent | undefined, shouldRecomputeAll = false) => {
if (comboBoxWrapperRef.current) {
const current = {
...labelProps,
width: comboBoxWrapperRef.current.clientWidth - comboboxPaddings,
};
if (shouldRecomputeAll) {
current.font = window.getComputedStyle(comboBoxWrapperRef.current).font;
}
setLabelProps(current);
}
};
const handleResize = throttle((_e: UIEvent | undefined, shouldRecomputeAll = false) => {
computeStyles(_e, shouldRecomputeAll);
}, 50);
useEffectOnce(() => {
if (comboBoxWrapperRef.current) {
handleResize(undefined, true);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
});
const textWidth = useMemo(() => getTextWidth(label, labelProps.font), [label, labelProps.font]);
if (textWidth < labelProps.width) {
return <EuiHighlight search={search}>{label}</EuiHighlight>;
}
const searchPosition = label.indexOf(search);
const approximateLen = Math.round((labelProps.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(
labelProps.width,
labelProps.font,
label,
approximateLen,
labelFn
);
return search.length < outputLabel.length - separatorsLength ? (
<EuiHighlight search={search}>{outputLabel}</EuiHighlight>
) : (
<EuiMark>{outputLabel}</EuiMark>
);
});

View file

@ -80,7 +80,7 @@ export type FilterLabelStatus =
| typeof FILTER_ITEM_WARNING
| typeof FILTER_ITEM_ERROR;
export const FILTER_EDITOR_WIDTH = 960;
export const FILTER_EDITOR_WIDTH = 1200;
function FilterItemComponent(props: FilterItemProps) {
const { onCloseFilterPopover, onLocalFilterCreate, onLocalFilterUpdate } = props;

View file

@ -11,20 +11,13 @@ import { i18n } from '@kbn/i18n';
import { FieldIcon } from '@kbn/react-field';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import {
EuiFlexGroup,
EuiFlexItem,
useGeneratedHtmlId,
EuiComboBox,
EuiComboBoxOptionOption,
} from '@elastic/eui';
import { useGeneratedHtmlId, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { getFilterableFields } from '../../filter_bar/filter_editor';
import { FiltersBuilderContextType } from '../context';
import { TruncatedLabel } from '../../filter_bar/filter_editor';
const DEFAULT_COMBOBOX_WIDTH = 205;
const COMBOBOX_PADDINGS = 100;
const DEFAULT_FONT = '14px Inter';
import {
MIDDLE_TRUNCATION_PROPS,
SINGLE_SELECTION_AS_TEXT_PROPS,
} from '../../filter_bar/filter_editor/lib/helpers';
export const strings = {
getFieldSelectPlaceholderLabel: () =>
@ -62,6 +55,7 @@ export function FieldInput({ field, dataView, onHandleField }: FieldInputProps)
return {
label,
value: dataViewField.type as KBN_FIELD_TYPES,
prepend: <FieldIcon type={dataViewField.type} fill="none" className="eui-alignMiddle" />,
};
},
[suggestionsAbstraction]
@ -103,30 +97,14 @@ export function FieldInput({ field, dataView, onHandleField }: FieldInputProps)
isDisabled={disabled}
placeholder={strings.getFieldSelectPlaceholderLabel()}
sortMatchesBy="startsWith"
singleSelection={{ asPlainText: true }}
aria-label={strings.getFieldSelectPlaceholderLabel()}
isClearable={false}
compressed
fullWidth
onFocus={handleFocus}
data-test-subj="filterFieldSuggestionList"
renderOption={(option, searchValue) => (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={null}>
<FieldIcon type={option.value!} fill="none" />
</EuiFlexItem>
<EuiFlexItem>
<TruncatedLabel
defaultComboboxWidth={DEFAULT_COMBOBOX_WIDTH}
defaultFont={DEFAULT_FONT}
comboboxPaddings={COMBOBOX_PADDINGS}
comboBoxWrapperRef={comboBoxWrapperRef}
label={option.label}
search={searchValue}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
singleSelection={SINGLE_SELECTION_AS_TEXT_PROPS}
truncationProps={MIDDLE_TRUNCATION_PROPS}
/>
</div>
);

View file

@ -101,7 +101,7 @@ export function TriggerButton({
fullWidth
{...colorProp}
{...rest}
textProps={{ style: { width: '100%', lineHeight: '100%' } }}
textProps={{ style: { width: '100%', lineHeight: '1.2em' } }}
>
<TriggerLabel label={label} extraIcons={extraIcons} />
</ToolbarButton>