mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
29853c79f3
commit
362ef64751
17 changed files with 98 additions and 546 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -7,5 +7,4 @@
|
|||
*/
|
||||
|
||||
export { FieldPicker } from './field_picker';
|
||||
export { TruncatedLabel } from './truncated_label';
|
||||
export type { FieldOptionValue, FieldOption, DataType } from './types';
|
||||
|
|
|
@ -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…');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
export {
|
||||
FieldPicker,
|
||||
TruncatedLabel,
|
||||
NameInput,
|
||||
DebouncedInput,
|
||||
useDebouncedValue,
|
||||
|
|
|
@ -63,6 +63,7 @@ function FilterBadge({
|
|||
<span
|
||||
css={css`
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
`}
|
||||
>
|
||||
<EuiTextBlockTruncate lines={10}>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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…');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue