mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
7c064ec31e
commit
91aa2bb8ec
3 changed files with 251 additions and 53 deletions
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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…');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue