[Vis: Default editor] EUIficate agg-select (#31892)

* EUIficate agg-select

* Improve validation; update TS

* Apply styles for helpLink

* Fix unit test

* Update functional tests

* Adjust comboBox service to chose the item where the text mates exactly

* Update vis page object

* Add default value for agg

* Move aggs grouping function to a separate file

* Use labelAppend prop for help link node

* Add watcher for aggType to manage to discard changes

* Add default value for agg type title

* Fix defining selected option when aggType is defined

* Fix validation issues

* Remove a bootstrap specific class

* Change css selector in test

* Update according to SASS guidelines

* Update functinal comboBox service

* Added check for undefined

* Add jsdoc for groupAggregationsBy function

* Add unit tests for groupAggregationsBy

* Move setValidity invocation to DefaultEditorAggSelect component

* Wrap setValidity into useEffect due to react warning when select is cleaned at the first time

* Move help link definition to select component
This commit is contained in:
Maryia Lapata 2019-03-28 13:02:34 +03:00 committed by GitHub
parent 06a71e8676
commit b03f6c3eae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 538 additions and 87 deletions

View file

@ -26,8 +26,8 @@ function StringParamEditor({ agg, aggParam, value, setValue }: AggParamEditorPro
return (
<EuiFormRow
label={aggParam.displayName || aggParam.name}
className="form-group"
fullWidth={true}
className="visEditorSidebar__aggParamFormRow"
>
<EuiFieldText
value={value || ''}

View file

@ -0,0 +1,194 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { groupAggregationsBy } from '../default_editor_utils';
const aggs = [
{
title: 'Count',
type: 'metrics',
subtype: 'Metric Aggregations',
},
{
title: 'Average',
type: 'metrics',
subtype: 'Metric Aggregations',
},
{
title: 'Cumulative Sum',
type: 'metrics',
subtype: 'Parent Pipeline Aggregations',
},
{
title: 'Min Bucket',
type: 'metrics',
subtype: 'Parent Pipeline Aggregations',
},
{
title: 'Sub string agg',
type: 'string',
subtype: 'Sub-String aggregations',
},
{
title: 'String agg',
type: 'string',
subtype: 'String aggregations',
},
];
describe('Default Editor groupAggregationsBy', () => {
it('should return aggs grouped by default type field', () => {
const groupedAggs = [
{
label: 'metrics',
options: [
{
label: 'Average',
value: {
title: 'Average',
type: 'metrics',
subtype: 'Metric Aggregations',
},
},
{
label: 'Count',
value: {
title: 'Count',
type: 'metrics',
subtype: 'Metric Aggregations',
},
},
{
label: 'Cumulative Sum',
value: {
title: 'Cumulative Sum',
type: 'metrics',
subtype: 'Parent Pipeline Aggregations',
},
},
{
label: 'Min Bucket',
value: {
title: 'Min Bucket',
type: 'metrics',
subtype: 'Parent Pipeline Aggregations',
},
},
],
},
{
label: 'string',
options: [
{
label: 'String agg',
value: {
title: 'String agg',
type: 'string',
subtype: 'String aggregations',
},
},
{
label: 'Sub string agg',
value: {
title: 'Sub string agg',
type: 'string',
subtype: 'Sub-String aggregations',
},
},
],
},
];
expect(groupAggregationsBy(aggs)).toEqual(groupedAggs);
});
it('should return aggs grouped by subtype field', () => {
const groupedAggs = [
{
label: 'Metric Aggregations',
options: [
{
label: 'Average',
value: {
title: 'Average',
type: 'metrics',
subtype: 'Metric Aggregations',
},
},
{
label: 'Count',
value: {
title: 'Count',
type: 'metrics',
subtype: 'Metric Aggregations',
},
},
],
},
{
label: 'Parent Pipeline Aggregations',
options: [
{
label: 'Cumulative Sum',
value: {
title: 'Cumulative Sum',
type: 'metrics',
subtype: 'Parent Pipeline Aggregations',
},
},
{
label: 'Min Bucket',
value: {
title: 'Min Bucket',
type: 'metrics',
subtype: 'Parent Pipeline Aggregations',
},
},
],
},
{
label: 'String aggregations',
options: [
{
label: 'String agg',
value: {
title: 'String agg',
type: 'string',
subtype: 'String aggregations',
},
},
],
},
{
label: 'Sub-String aggregations',
options: [
{
label: 'Sub string agg',
value: {
title: 'Sub string agg',
type: 'string',
subtype: 'Sub-String aggregations',
},
},
],
},
];
expect(groupAggregationsBy(aggs, 'subtype')).toEqual(groupedAggs);
});
});

View file

@ -20,3 +20,7 @@
.visEditorAggSelect__helpLink {
@include euiFontSizeXS;
}
.visEditorAggSelect__formRow {
margin-bottom: $euiSizeS;
}

View file

@ -222,5 +222,6 @@
}
.visEditorSidebar__aggParamFormRow {
margin-top: $euiSizeS;
margin-bottom: $euiSizeS;
}

View file

@ -53,7 +53,7 @@ uiModules
* @return {[type]} [description]
*/
$scope.describe = function () {
if (!$scope.agg.type.makeLabel) return '';
if (!$scope.agg.type || !$scope.agg.type.makeLabel) return '';
const label = $scope.agg.type.makeLabel($scope.agg);
return label ? label : '';
};

View file

@ -33,4 +33,11 @@
</p>
</div>
<vis-agg-select
agg="agg"
is-sub-aggregation="isSubAggregation"
agg-type-options="groupedAggTypeOptions"
ng-model="_internalNgModelStateAggType"
/>
<!-- schema editors get added down here: aggSelect.html, agg_types/controls/*.html -->

View file

@ -18,18 +18,18 @@
*/
import $ from 'jquery';
import { get, has } from 'lodash';
import { get } from 'lodash';
import { aggTypes } from '../../../agg_types';
import { aggTypeFilters } from '../../../agg_types/filter';
import { aggTypeFieldFilters } from '../../../agg_types/param_types/filter';
import { documentationLinks } from '../../../documentation_links/documentation_links';
import '../../../filters/match_any';
import { uiModules } from '../../../modules';
import { editorConfigProviders } from '../config/editor_config_providers';
import advancedToggleHtml from './advanced_toggle.html';
import './agg_param';
import './agg_select';
import aggParamsTemplate from './agg_params.html';
import aggSelectHtml from './agg_select.html';
import { groupAggregationsBy } from './default_editor_utils';
uiModules
.get('app/visualize')
@ -56,6 +56,15 @@ uiModules
updateEditorConfig('default');
});
$scope.groupedAggTypeOptions = groupAggregationsBy($scope.aggTypeOptions, 'subtype');
$scope.isSubAggregation = $scope.$index >= 1 && $scope.groupName === 'buckets';
$scope.onAggTypeChange = (agg, value) => {
if (agg.type !== value) {
agg.type = value;
}
};
$scope.onParamChange = (agg, paramName, value) => {
if(agg.params[paramName] !== value) {
agg.params[paramName] = value;
@ -91,9 +100,6 @@ uiModules
// controls for the agg, which is why they are first
addSchemaEditor();
// allow selection of an aggregation
addAggSelector();
function addSchemaEditor() {
const $schemaEditor = $('<div>').addClass('schemaEditors form-group').appendTo($el);
@ -103,21 +109,12 @@ uiModules
}
}
function addAggSelector() {
const $aggSelect = $(aggSelectHtml).appendTo($el);
$compile($aggSelect)($scope);
}
// params for the selected agg, these are rebuilt every time the agg in $aggSelect changes
let $aggParamEditors; // container for agg type param editors
let $aggParamEditorsScope;
function updateAggParamEditor() {
updateEditorConfig();
$scope.aggHelpLink = null;
if (has($scope, 'agg.type.name')) {
$scope.aggHelpLink = get(documentationLinks, ['aggs', $scope.agg.type.name]);
}
if ($aggParamEditors) {
$aggParamEditors.remove();

View file

@ -1,50 +0,0 @@
<div class="form-group">
<div>
<label
ng-if="$index < 1 || groupName !== 'buckets'"
class="eui-displayInline"
i18n-id="common.ui.vis.editors.aggSelect.aggregationLabel"
i18n-default-message="Aggregation"
>
</label>
<label
ng-if="$index >= 1 && groupName === 'buckets'"
class="eui-displayInline"
i18n-id="common.ui.vis.editors.aggSelect.subAggregationLabel"
i18n-default-message="Sub Aggregation"
>
</label>
<a
ng-if="aggHelpLink"
href="{{aggHelpLink}}"
class="pull-right visEditorAggSelect__helpLink"
target="_blank"
rel="noopener"
i18n-id="common.ui.vis.editors.aggSelect.helpLinkLabel"
i18n-default-message="{aggTitle} help"
i18n-values="{ aggTitle: agg.type.title }"
>
</a>
</div>
<div class="euiSpacer euiSpacer--s"></div>
<ui-select
required
name="agg"
class="visEditorAggSelect__select agg-select"
data-test-subj="visEditorAggSelect"
ng-model="agg.type"
>
<ui-select-match placeholder="{{ ::'common.ui.vis.editors.aggSelect.selectAggPlaceholder' | i18n: { defaultMessage: 'Select an aggregation' } }}">
{{$select.selected.title}}
</ui-select-match>
<ui-select-choices
repeat="agg in aggTypeOptions
| filter: { title: $select.search }
| orderBy:'title'
| sortPrefixFirst:$select.search:'title'"
group-by="'subtype'"
>
<div data-test-subj="{{agg.title}}" ng-bind-html="agg.title | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>
</div>

View file

@ -0,0 +1,91 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 'ngreact';
import { uiModules } from '../../../modules';
import { DefaultEditorAggSelect } from './components/default_editor_agg_select';
import { wrapInI18nContext } from 'ui/i18n';
uiModules
.get('app/visualize', ['react'])
.directive('visAggSelectReactWrapper', reactDirective => reactDirective(wrapInI18nContext(DefaultEditorAggSelect), [
['agg', { watchDepth: 'collection' }],
['aggTypeOptions', { watchDepth: 'collection' }],
['setValue', { watchDepth: 'reference' }],
['setTouched', { watchDepth: 'reference' }],
['setValidity', { watchDepth: 'reference' }],
'value',
'isSubAggregation',
'aggHelpLink',
'isSelectInvalid'
]))
.directive('visAggSelect', function () {
return {
restrict: 'E',
scope: true,
require: '^ngModel',
template: function () {
return `<vis-agg-select-react-wrapper
agg="agg"
value="paramValue"
set-value="onChange"
is-sub-aggregation="isSubAggregation"
agg-help-link="aggHelpLink"
agg-type-options="aggTypeOptions"
is-select-invalid="isSelectInvalid"
set-touched="setTouched"
set-validity="setValidity"
></vis-agg-select-react-wrapper>`;
},
link: {
pre: function ($scope, $el, attr) {
$scope.$bind('agg', attr.agg);
$scope.$bind('isSubAggregation', attr.isSubAggregation);
$scope.$bind('aggTypeOptions', attr.aggTypeOptions);
},
post: function ($scope, $el, attr, ngModelCtrl) {
$scope.$watch('agg.type', (value) => {
// Whenever the value of the parameter changed (e.g. by a reset or actually by calling)
// we store the new value in $scope.paramValue, which will be passed as a new value to the react component.
$scope.paramValue = value;
});
$scope.onChange = (value) => {
// This is obviously not a good code quality, but without using scope binding (which we can't see above)
// to bind function values, this is right now the best temporary fix, until all of this will be gone.
$scope.$parent.onAggTypeChange($scope.agg, value);
ngModelCtrl.$setDirty();
};
$scope.setTouched = () => {
ngModelCtrl.$setTouched();
$scope.isSelectInvalid = !$scope.paramValue;
};
$scope.setValidity = (isValid) => {
// The field will be marked as invalid when the value is empty and the field is touched.
$scope.isSelectInvalid = ngModelCtrl.$touched ? !isValid : false;
// Since aggType is required field, the form should become invalid when the aggregation field is set to empty.
ngModelCtrl.$setValidity(`agg${$scope.agg.id}`, isValid);
};
}
}
};
});

View file

@ -0,0 +1,124 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { get, has, isFunction } from 'lodash';
import React, { useEffect } from 'react';
import { EuiComboBox, EuiFormRow, EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { AggType } from 'ui/agg_types';
import { AggConfig } from 'ui/vis/agg_config';
import { documentationLinks } from '../../../../documentation_links/documentation_links';
import { ComboBoxGroupedOption } from '../default_editor_utils';
interface DefaultEditorAggSelectProps {
agg: AggConfig;
value: AggType;
setValue: (aggType: AggType) => void;
aggTypeOptions: AggType[];
isSubAggregation: boolean;
isSelectInvalid: boolean;
setTouched: () => void;
setValidity: (isValid: boolean) => void;
}
function DefaultEditorAggSelect({
agg = {},
value = { title: '' },
setValue,
aggTypeOptions = [],
isSelectInvalid,
isSubAggregation,
setTouched,
setValidity,
}: DefaultEditorAggSelectProps) {
const isAggTypeDefined = value && Boolean(value.title);
const selectedOptions: ComboBoxGroupedOption[] = isAggTypeDefined
? [{ label: value.title, value }]
: [];
const label = isSubAggregation ? (
<FormattedMessage
id="common.ui.vis.defaultEditor.aggSelect.subAggregationLabel"
defaultMessage="Sub aggregation"
/>
) : (
<FormattedMessage
id="common.ui.vis.defaultEditor.aggSelect.aggregationLabel"
defaultMessage="Aggregation"
/>
);
let aggHelpLink = null;
if (has(agg, 'type.name')) {
aggHelpLink = get(documentationLinks, ['aggs', agg.type.name]);
}
const helpLink = isAggTypeDefined && aggHelpLink && (
<EuiLink
href={aggHelpLink}
target="_blank"
rel="noopener"
className="visEditorAggSelect__helpLink"
>
<FormattedMessage
id="common.ui.vis.defaultEditor.aggSelect.helpLinkLabel"
defaultMessage="{aggTitle} help"
values={{ aggTitle: isAggTypeDefined ? value.title : '' }}
/>
</EuiLink>
);
useEffect(
() => {
if (isFunction(setValidity)) {
setValidity(isAggTypeDefined);
}
},
[isAggTypeDefined]
);
return (
<EuiFormRow
label={label}
labelAppend={helpLink}
isInvalid={isSelectInvalid}
fullWidth={true}
className="visEditorAggSelect__formRow"
>
<EuiComboBox
placeholder={i18n.translate('common.ui.vis.defaultEditor.aggSelect.selectAggPlaceholder', {
defaultMessage: 'Select an aggregation…',
})}
id={`visDefaultEditorAggSelect${agg.id}`}
options={aggTypeOptions}
selectedOptions={selectedOptions}
singleSelection={{ asPlainText: true }}
onChange={options => setValue(get(options, '0.value'))}
data-test-subj="defaultEditorAggSelect"
isClearable={false}
isInvalid={isSelectInvalid}
fullWidth={true}
onBlur={() => setTouched()}
/>
</EuiFormRow>
);
}
export { DefaultEditorAggSelect };

View file

@ -0,0 +1,79 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EuiComboBoxOptionProps } from '@elastic/eui';
import { AggType } from 'ui/agg_types';
export type ComboBoxGroupedOption = EuiComboBoxOptionProps & {
value?: AggType;
options?: ComboBoxGroupedOption[];
};
/**
* Groups and sorts alphabetically aggregation objects and returns an array of options that are compatible with EuiComboBox options.
*
* @param aggs An array of aggregations that will be grouped.
* @param groupBy A field name which aggregations is grouped by.
*
* @returns An array of grouped and sorted alphabetically `aggs` that are compatible with EuiComboBox options. If `aggs` is not an array, the function returns an ampry array.
*/
function groupAggregationsBy(
aggs: AggType[],
groupBy: string = 'type'
): ComboBoxGroupedOption[] | [] {
if (!Array.isArray(aggs)) {
return [];
}
const groupedOptions: ComboBoxGroupedOption[] = aggs.reduce((array: AggType[], type: AggType) => {
const group = array.find(element => element.label === type[groupBy]);
const option = {
label: type.title,
value: type,
};
if (group) {
group.options.push(option);
} else {
array.push({ label: type[groupBy], options: [option] });
}
return array;
}, []);
groupedOptions.sort(sortByLabel);
groupedOptions.forEach((group: ComboBoxGroupedOption) => {
if (Array.isArray(group.options)) {
group.options.sort(sortByLabel);
}
});
if (groupedOptions.length === 1 && !groupedOptions[0].label) {
return groupedOptions[0].options || [];
}
return groupedOptions;
}
function sortByLabel(a: { label: string }, b: { label: string }) {
return a.label.toLowerCase().localeCompare(b.label.toLowerCase());
}
export { groupAggregationsBy };

View file

@ -34,6 +34,7 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli
const globalNav = getService('globalNav');
const PageObjects = getPageObjects(['common', 'header']);
const defaultFindTimeout = config.get('timeouts.find');
const comboBox = getService('comboBox');
class VisualizePage {
@ -431,19 +432,14 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli
}
async selectAggregation(myString, groupName = 'buckets', childAggregationType = null) {
const selector = `
const comboBoxElement = await find.byCssSelector(`
[group-name="${groupName}"]
vis-editor-agg-params:not(.ng-hide)
${childAggregationType ? `vis-editor-agg-params[group-name="'${childAggregationType}'"]:not(.ng-hide)` : ''}
.agg-select
`;
[data-test-subj="defaultEditorAggSelect"]
`);
await retry.try(async () => {
await find.clickByCssSelector(selector);
const input = await find.byCssSelector(`${selector} input.ui-select-search`);
await input.type(myString);
await input.pressKeys(browser.keys.RETURN);
});
await comboBox.setElement(comboBoxElement, myString);
await PageObjects.common.sleep(500);
}
@ -481,13 +477,12 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli
// So to modify a metric or aggregation tests need to keep track of the
// order they are added.
await this.toggleOpenEditor(index);
const aggSelect = await find
.byCssSelector(`#visAggEditorParams${index} div [data-test-subj="visEditorAggSelect"] div span[aria-label="Select box activate"]`);
// open agg selection list
await aggSelect.click();
// select our agg
const aggItem = await find.byCssSelector(`[data-test-subj="${agg}"]`);
await aggItem.click();
const aggSelect = await find
.byCssSelector(`#visAggEditorParams${index} [data-test-subj="defaultEditorAggSelect"]`);
await comboBox.setElement(aggSelect, agg);
const fieldSelect = await find
.byCssSelector(`#visAggEditorParams${index} > [agg-param="agg.type.params[0]"] > div > div > div.ui-select-match > span`);
// open field selection list

View file

@ -36,7 +36,20 @@ export function ComboBoxProvider({ getService }) {
log.debug(`comboBox.setElement, value: ${value}`);
await this._filterOptionsList(comboBoxElement, value);
await this.openOptionsList(comboBoxElement);
await find.clickByCssSelector('.euiComboBoxOption');
if (value !== undefined) {
const options = await find.allByCssSelector(`.euiComboBoxOption[title^="${value.toString().trim()}"]`);
if (options.length > 0) {
await options[0].click();
} else {
// if it doesn't find the item which text starts with value, it will choose the first option
await find.clickByCssSelector('.euiComboBoxOption');
}
} else {
await find.clickByCssSelector('.euiComboBoxOption');
}
await this.closeOptionsList(comboBoxElement);
}

View file

@ -641,10 +641,6 @@
"common.ui.vis.editors.aggGroups.metricsText": "指标",
"common.ui.vis.editors.aggParams.errors.aggWrongRunOrderErrorMessage": "“{schema}” 聚合必须在所有其他存储桶之前运行!",
"common.ui.vis.editors.aggParams.errors.schemaIsDeprecatedErrorMessage": "{schema}”已弃用。",
"common.ui.vis.editors.aggSelect.aggregationLabel": "聚合",
"common.ui.vis.editors.aggSelect.helpLinkLabel": "{aggTitle} 帮助",
"common.ui.vis.editors.aggSelect.selectAggPlaceholder": "选择聚合",
"common.ui.vis.editors.aggSelect.subAggregationLabel": "子聚合",
"common.ui.vis.editors.howToModifyScreenReaderPriorityDescription": "使用此按钮上的向上和向下键上移和下移此聚合的优先级顺序。",
"common.ui.vis.editors.resizeAriaLabels": "按向左/向右键以调整编辑器的大小",
"common.ui.vis.editors.sidebar.applyChangesAriaLabel": "使用您的更改更新可视化",