[Controls] Use EUI Selectable for Field search (#151231)

## Summary
Replaces Control field selection list with EUISelectable.
This commit is contained in:
Devon Thomson 2023-02-24 09:39:15 -06:00 committed by GitHub
parent 2baf551deb
commit eb9cc11a7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 227 additions and 280 deletions

View file

@ -109,7 +109,7 @@ export const ControlEditor = ({
});
const [defaultTitle, setDefaultTitle] = useState<string>();
const [currentTitle, setCurrentTitle] = useState(title);
const [currentTitle, setCurrentTitle] = useState(title ?? '');
const [currentWidth, setCurrentWidth] = useState(width);
const [currentGrow, setCurrentGrow] = useState(grow);
const [controlEditorValid, setControlEditorValid] = useState(false);
@ -198,27 +198,27 @@ export const ControlEditor = ({
/>
</EuiFormRow>
)}
<EuiFormRow label={ControlGroupStrings.manageControl.getFieldTitle()}>
<FieldPicker
filterPredicate={(field: DataViewField) => {
return Boolean(fieldRegistry?.[field.name]);
}}
selectedFieldName={selectedField}
dataView={dataView}
onSelectField={(field) => {
onTypeEditorChange({
fieldName: field.name,
});
const newDefaultTitle = field.displayName ?? field.name;
setDefaultTitle(newDefaultTitle);
setSelectedField(field.name);
if (!currentTitle || currentTitle === defaultTitle) {
setCurrentTitle(newDefaultTitle);
updateTitle(newDefaultTitle);
}
}}
/>
</EuiFormRow>
{fieldRegistry && (
<EuiFormRow label={ControlGroupStrings.manageControl.getFieldTitle()}>
<FieldPicker
filterPredicate={(field: DataViewField) => Boolean(fieldRegistry[field.name])}
selectedFieldName={selectedField}
dataView={dataView}
onSelectField={(field) => {
onTypeEditorChange({
fieldName: field.name,
});
const newDefaultTitle = field.displayName ?? field.name;
setDefaultTitle(newDefaultTitle);
setSelectedField(field.name);
if (!currentTitle || currentTitle === defaultTitle) {
setCurrentTitle(newDefaultTitle);
updateTitle(newDefaultTitle);
}
}}
/>
</EuiFormRow>
)}
<EuiFormRow label={ControlGroupStrings.manageControl.getControlTypeTitle()}>
{factory ? (
<EuiFlexGroup alignItems="center" gutterSize="xs">

View file

@ -1,17 +1,4 @@
.presFieldPicker__fieldButton {
background: $euiColorEmptyShade;
}
.presFieldPickerFieldButtonActive {
box-shadow: 0 0 0 2px $euiColorPrimary;
}
.presFieldPicker__fieldPanel {
height: 300px;
overflow-y: scroll;
}
.presFieldPicker__container--disabled {
opacity: .7;
pointer-events: none;
background-color: transparentize($euiColorPrimary, .9);
}

View file

@ -8,13 +8,14 @@
import classNames from 'classnames';
import { sortBy, uniq } from 'lodash';
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui';
import { FieldButton, FieldIcon } from '@kbn/react-field';
import React, { useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FieldIcon } from '@kbn/react-field';
import { EuiSelectable, EuiSelectableOption, EuiSpacer } from '@elastic/eui';
import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { FieldSearch } from './field_search';
import { FieldTypeFilter } from './field_type_filter';
import './field_picker.scss';
@ -31,115 +32,102 @@ export const FieldPicker = ({
filterPredicate,
selectedFieldName,
}: FieldPickerProps) => {
const [nameFilter, setNameFilter] = useState<string>('');
const [typesFilter, setTypesFilter] = useState<string[]>([]);
const [fieldSelectableOptions, setFieldSelectableOptions] = useState<EuiSelectableOption[]>([]);
// Retrieve, filter, and sort fields from data view
const fields = dataView
? sortBy(
dataView.fields
.filter(
(f) =>
f.name.toLowerCase().includes(nameFilter.toLowerCase()) &&
(typesFilter.length === 0 || typesFilter.includes(f.type as string))
)
const availableFields = useMemo(
() =>
sortBy(
(dataView?.fields ?? [])
.filter((f) => typesFilter.length === 0 || typesFilter.includes(f.type as string))
.filter((f) => (filterPredicate ? filterPredicate(f) : true)),
['name']
)
: [];
),
[dataView, filterPredicate, typesFilter]
);
const uniqueTypes = dataView
? uniq(
dataView.fields
.filter((f) => (filterPredicate ? filterPredicate(f) : true))
.map((f) => f.type as string)
)
: [];
useEffect(() => {
if (!dataView) return;
const options: EuiSelectableOption[] = (availableFields ?? []).map((field) => {
return {
key: field.name,
label: field.displayName ?? field.name,
className: classNames('presFieldPicker__fieldButton', {
presFieldPickerFieldButtonActive: field.name === selectedFieldName,
}),
'data-test-subj': `field-picker-select-${field.name}`,
prepend: (
<FieldIcon
type={field.type}
label={field.name}
scripted={field.scripted}
className="eui-alignMiddle"
/>
),
};
});
setFieldSelectableOptions(options);
}, [availableFields, dataView, filterPredicate, selectedFieldName, typesFilter]);
const uniqueTypes = useMemo(
() =>
dataView
? uniq(
dataView.fields
.filter((f) => (filterPredicate ? filterPredicate(f) : true))
.map((f) => f.type as string)
)
: [],
[dataView, filterPredicate]
);
const fieldTypeFilter = (
<FieldTypeFilter
onFieldTypesChange={(types) => setTypesFilter(types)}
fieldTypesValue={typesFilter}
availableFieldTypes={uniqueTypes}
/>
);
return (
<EuiFlexGroup
direction="column"
alignItems="stretch"
gutterSize="s"
className={`presFieldPicker__container ${
!dataView && 'presFieldPicker__container--disabled'
}`}
<EuiSelectable
emptyMessage={i18n.translate('presentationUtil.fieldPicker.noFieldsLabel', {
defaultMessage: 'No matching fields',
})}
aria-label={i18n.translate('presentationUtil.fieldPicker.selectableAriaLabel', {
defaultMessage: 'Select a field',
})}
searchable
options={fieldSelectableOptions}
onChange={(options, _, changedOption) => {
setFieldSelectableOptions(options);
if (!dataView || !changedOption.key) return;
const field = dataView.getFieldByName(changedOption.key);
if (field) onSelectField?.(field);
}}
searchProps={{
'data-test-subj': 'field-search-input',
placeholder: i18n.translate('presentationUtil.fieldSearch.searchPlaceHolder', {
defaultMessage: 'Search field names',
}),
}}
listProps={{
isVirtualized: true,
showIcons: false,
bordered: true,
}}
height={300}
>
<EuiFlexItem grow={false}>
<FieldSearch
onSearchChange={(val) => setNameFilter(val)}
searchValue={nameFilter}
onFieldTypesChange={(types) => setTypesFilter(types)}
fieldTypesValue={typesFilter}
availableFieldTypes={uniqueTypes}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel
paddingSize="s"
hasShadow={false}
hasBorder={true}
className="presFieldPicker__fieldPanel"
>
{fields.length > 0 && (
<EuiFlexGroup direction="column" gutterSize="none">
{fields.map((f, i) => {
return (
<EuiFlexItem key={f.name}>
<FieldButton
data-test-subj={`field-picker-select-${f.name}`}
className={classNames('presFieldPicker__fieldButton', {
presFieldPickerFieldButtonActive: f.name === selectedFieldName,
})}
onClick={() => {
onSelectField?.(f);
}}
isActive={f.name === selectedFieldName}
fieldName={f.name}
fieldIcon={<FieldIcon type={f.type} label={f.name} scripted={f.scripted} />}
/>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
)}
{!dataView && (
<EuiFlexGroup
direction="column"
gutterSize="none"
alignItems="center"
justifyContent="center"
>
<EuiFlexItem>
<EuiText color="subdued">
<FormattedMessage
id="presentationUtil.fieldPicker.noDataViewLabel"
defaultMessage="No data view selected"
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
)}
{dataView && fields.length === 0 && (
<EuiFlexGroup
direction="column"
gutterSize="none"
alignItems="center"
justifyContent="center"
>
<EuiFlexItem>
<EuiText color="subdued">
<FormattedMessage
id="presentationUtil.fieldPicker.noFieldsLabel"
defaultMessage="No matching fields"
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
{(list, search) => (
<>
{search}
<EuiSpacer size={'s'} />
{fieldTypeFilter}
<EuiSpacer size={'s'} />
{list}
</>
)}
</EuiSelectable>
);
};

View file

@ -1,133 +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, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFieldSearch,
EuiFilterGroup,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiContextMenuPanel,
EuiContextMenuItem,
EuiOutsideClickDetector,
EuiFilterButton,
EuiSpacer,
EuiPopoverTitle,
} from '@elastic/eui';
import { FieldIcon } from '@kbn/react-field';
import { FormattedMessage } from '@kbn/i18n-react';
import './field_search.scss';
export interface Props {
onSearchChange: (value: string) => void;
searchValue?: string;
onFieldTypesChange: (value: string[]) => void;
fieldTypesValue: string[];
availableFieldTypes: string[];
}
export function FieldSearch({
onSearchChange,
searchValue,
onFieldTypesChange,
fieldTypesValue,
availableFieldTypes,
}: Props) {
const searchPlaceholder = i18n.translate('presentationUtil.fieldSearch.searchPlaceHolder', {
defaultMessage: 'Search field names',
});
const [isPopoverOpen, setPopoverOpen] = useState(false);
const handleFilterButtonClicked = () => {
setPopoverOpen(!isPopoverOpen);
};
const buttonContent = (
<EuiFilterButton
data-test-subj="toggleFieldFilterButton"
iconType="arrowDown"
isSelected={fieldTypesValue.length > 0}
numFilters={0}
hasActiveFilters={fieldTypesValue.length > 0}
numActiveFilters={fieldTypesValue.length}
onClick={handleFilterButtonClicked}
>
<FormattedMessage
id="presentationUtil.fieldSearch.fieldFilterButtonLabel"
defaultMessage="Filter by type"
/>
</EuiFilterButton>
);
return (
<React.Fragment>
<EuiFlexGroup responsive={false} gutterSize={'s'}>
<EuiFlexItem>
<EuiFieldSearch
aria-label={searchPlaceholder}
data-test-subj="field-search-input"
fullWidth
onChange={(event) => onSearchChange(event.currentTarget.value)}
placeholder={searchPlaceholder}
value={searchValue}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
<EuiOutsideClickDetector onOutsideClick={() => {}} isDisabled={!isPopoverOpen}>
<EuiFilterGroup>
<EuiPopover
panelClassName="euiFilterGroup__popoverPanel presFilterByType__panel"
panelPaddingSize="none"
display="block"
isOpen={isPopoverOpen}
closePopover={() => {
setPopoverOpen(false);
}}
button={buttonContent}
>
<EuiPopoverTitle paddingSize="s">
{i18n.translate('presentationUtil.fieldSearch.filterByTypeLabel', {
defaultMessage: 'Filter by type',
})}
</EuiPopoverTitle>
<EuiContextMenuPanel
items={(availableFieldTypes as string[]).map((type) => (
<EuiContextMenuItem
key={type}
icon={fieldTypesValue.includes(type) ? 'check' : 'empty'}
data-test-subj={`typeFilter-${type}`}
onClick={() => {
if (fieldTypesValue.includes(type)) {
onFieldTypesChange(fieldTypesValue.filter((f) => f !== type));
} else {
onFieldTypesChange([...fieldTypesValue, type]);
}
}}
>
<EuiFlexGroup gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<FieldIcon type={type} label={type} />
</EuiFlexItem>
<EuiFlexItem>{type}</EuiFlexItem>
</EuiFlexGroup>
</EuiContextMenuItem>
))}
/>
</EuiPopover>
</EuiFilterGroup>
</EuiOutsideClickDetector>
</React.Fragment>
);
}

View file

@ -0,0 +1,107 @@
/*
* 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, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFilterGroup,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiContextMenuPanel,
EuiContextMenuItem,
EuiOutsideClickDetector,
EuiFilterButton,
EuiPopoverTitle,
} from '@elastic/eui';
import { FieldIcon } from '@kbn/react-field';
import { FormattedMessage } from '@kbn/i18n-react';
import './field_type_filter.scss';
export interface Props {
onFieldTypesChange: (value: string[]) => void;
fieldTypesValue: string[];
availableFieldTypes: string[];
}
export function FieldTypeFilter({
onFieldTypesChange,
fieldTypesValue,
availableFieldTypes,
}: Props) {
const [isPopoverOpen, setPopoverOpen] = useState(false);
const handleFilterButtonClicked = () => {
setPopoverOpen(!isPopoverOpen);
};
const buttonContent = (
<EuiFilterButton
data-test-subj="toggleFieldFilterButton"
iconType="arrowDown"
isSelected={fieldTypesValue.length > 0}
numFilters={0}
hasActiveFilters={fieldTypesValue.length > 0}
numActiveFilters={fieldTypesValue.length}
onClick={handleFilterButtonClicked}
>
<FormattedMessage
id="presentationUtil.fieldSearch.fieldFilterButtonLabel"
defaultMessage="Filter by type"
/>
</EuiFilterButton>
);
return (
<EuiOutsideClickDetector onOutsideClick={() => {}} isDisabled={!isPopoverOpen}>
<EuiFilterGroup>
<EuiPopover
panelClassName="euiFilterGroup__popoverPanel presFilterByType__panel"
panelPaddingSize="none"
display="block"
isOpen={isPopoverOpen}
closePopover={() => {
setPopoverOpen(false);
}}
button={buttonContent}
>
<EuiPopoverTitle paddingSize="s">
{i18n.translate('presentationUtil.fieldSearch.filterByTypeLabel', {
defaultMessage: 'Filter by type',
})}
</EuiPopoverTitle>
<EuiContextMenuPanel
items={(availableFieldTypes as string[]).map((type) => (
<EuiContextMenuItem
key={type}
icon={fieldTypesValue.includes(type) ? 'check' : 'empty'}
data-test-subj={`typeFilter-${type}`}
onClick={() => {
if (fieldTypesValue.includes(type)) {
onFieldTypesChange(fieldTypesValue.filter((f) => f !== type));
} else {
onFieldTypesChange([...fieldTypesValue, type]);
}
}}
>
<EuiFlexGroup gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<FieldIcon type={type} label={type} />
</EuiFlexItem>
<EuiFlexItem>{type}</EuiFlexItem>
</EuiFlexGroup>
</EuiContextMenuItem>
))}
/>
</EuiPopover>
</EuiFilterGroup>
</EuiOutsideClickDetector>
);
}

View file

@ -576,7 +576,7 @@ export class DashboardPageControls extends FtrService {
public async controlsEditorSetfield(
fieldName: string,
expectedType?: string,
shouldSearch: boolean = false
shouldSearch: boolean = true
) {
this.log.debug(`Setting control field to ${fieldName}`);
if (shouldSearch) {

View file

@ -4483,7 +4483,6 @@
"presentationUtil.solutionToolbar.quickButton.ariaButtonLabel": "Créer {createType}",
"presentationUtil.dashboardPicker.searchDashboardPlaceholder": "Recherche dans les tableaux de bord…",
"presentationUtil.dataViewPicker.changeDataViewTitle": "Vue de données",
"presentationUtil.fieldPicker.noDataViewLabel": "Aucune vue de données sélectionnée",
"presentationUtil.fieldPicker.noFieldsLabel": "Aucun champ correspondant",
"presentationUtil.fieldSearch.fieldFilterButtonLabel": "Filtrer par type",
"presentationUtil.fieldSearch.filterByTypeLabel": "Filtrer par type",

View file

@ -4481,7 +4481,6 @@
"presentationUtil.solutionToolbar.quickButton.ariaButtonLabel": "新しい{createType}を作成",
"presentationUtil.dashboardPicker.searchDashboardPlaceholder": "ダッシュボードを検索...",
"presentationUtil.dataViewPicker.changeDataViewTitle": "データビュー",
"presentationUtil.fieldPicker.noDataViewLabel": "データビューが選択されていません",
"presentationUtil.fieldPicker.noFieldsLabel": "一致するがフィールドがありません",
"presentationUtil.fieldSearch.fieldFilterButtonLabel": "タイプでフィルタリング",
"presentationUtil.fieldSearch.filterByTypeLabel": "タイプでフィルタリング",

View file

@ -4486,7 +4486,6 @@
"presentationUtil.solutionToolbar.quickButton.ariaButtonLabel": "创建新的 {createType}",
"presentationUtil.dashboardPicker.searchDashboardPlaceholder": "搜索仪表板......",
"presentationUtil.dataViewPicker.changeDataViewTitle": "数据视图",
"presentationUtil.fieldPicker.noDataViewLabel": "未选择数据视图",
"presentationUtil.fieldPicker.noFieldsLabel": "无匹配字段",
"presentationUtil.fieldSearch.fieldFilterButtonLabel": "按类型筛选",
"presentationUtil.fieldSearch.filterByTypeLabel": "按类型筛选",

View file

@ -11,6 +11,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const a11y = getService('a11y');
const testSubjects = getService('testSubjects');
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['common', 'dashboard', 'home', 'dashboardControls']);
const browser = getService('browser');
@ -56,7 +57,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('Options control panel & dashboard with options control', async () => {
await testSubjects.click('field-picker-select-OriginCityName');
await PageObjects.dashboardControls.controlsEditorSetfield('OriginCityName');
await a11y.testAppSnapshot();
await testSubjects.click('control-editor-save');
await a11y.testAppSnapshot();