From b03f6c3eae28bcaaf61aefe2083d27cda39d6ec3 Mon Sep 17 00:00:00 2001 From: Maryia Lapata Date: Thu, 28 Mar 2019 13:02:34 +0300 Subject: [PATCH] [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 --- .../ui/public/agg_types/controls/string.tsx | 2 +- .../__tests__/default_editor_utils.test.tsx | 194 ++++++++++++++++++ .../vis/editors/default/_agg_select.scss | 4 + .../public/vis/editors/default/_sidebar.scss | 1 + .../ui/public/vis/editors/default/agg.js | 2 +- .../vis/editors/default/agg_params.html | 7 + .../public/vis/editors/default/agg_params.js | 27 ++- .../vis/editors/default/agg_select.html | 50 ----- .../public/vis/editors/default/agg_select.js | 91 ++++++++ .../components/default_editor_agg_select.tsx | 124 +++++++++++ .../editors/default/default_editor_utils.tsx | 79 +++++++ .../functional/page_objects/visualize_page.js | 25 +-- test/functional/services/combo_box.js | 15 +- .../translations/translations/zh-CN.json | 4 - 14 files changed, 538 insertions(+), 87 deletions(-) create mode 100644 src/legacy/ui/public/vis/editors/default/__tests__/default_editor_utils.test.tsx delete mode 100644 src/legacy/ui/public/vis/editors/default/agg_select.html create mode 100644 src/legacy/ui/public/vis/editors/default/agg_select.js create mode 100644 src/legacy/ui/public/vis/editors/default/components/default_editor_agg_select.tsx create mode 100644 src/legacy/ui/public/vis/editors/default/default_editor_utils.tsx diff --git a/src/legacy/ui/public/agg_types/controls/string.tsx b/src/legacy/ui/public/agg_types/controls/string.tsx index c29a54cebd31..330be44b9da8 100644 --- a/src/legacy/ui/public/agg_types/controls/string.tsx +++ b/src/legacy/ui/public/agg_types/controls/string.tsx @@ -26,8 +26,8 @@ function StringParamEditor({ agg, aggParam, value, setValue }: AggParamEditorPro return ( { + 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); + }); +}); diff --git a/src/legacy/ui/public/vis/editors/default/_agg_select.scss b/src/legacy/ui/public/vis/editors/default/_agg_select.scss index 501344bc1e4c..cd3a84cb9d3a 100644 --- a/src/legacy/ui/public/vis/editors/default/_agg_select.scss +++ b/src/legacy/ui/public/vis/editors/default/_agg_select.scss @@ -20,3 +20,7 @@ .visEditorAggSelect__helpLink { @include euiFontSizeXS; } + +.visEditorAggSelect__formRow { + margin-bottom: $euiSizeS; +} diff --git a/src/legacy/ui/public/vis/editors/default/_sidebar.scss b/src/legacy/ui/public/vis/editors/default/_sidebar.scss index a13935b50d88..2fcc0e79f4a4 100644 --- a/src/legacy/ui/public/vis/editors/default/_sidebar.scss +++ b/src/legacy/ui/public/vis/editors/default/_sidebar.scss @@ -222,5 +222,6 @@ } .visEditorSidebar__aggParamFormRow { + margin-top: $euiSizeS; margin-bottom: $euiSizeS; } diff --git a/src/legacy/ui/public/vis/editors/default/agg.js b/src/legacy/ui/public/vis/editors/default/agg.js index ce3de939dca4..c80bdc45e008 100644 --- a/src/legacy/ui/public/vis/editors/default/agg.js +++ b/src/legacy/ui/public/vis/editors/default/agg.js @@ -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 : ''; }; diff --git a/src/legacy/ui/public/vis/editors/default/agg_params.html b/src/legacy/ui/public/vis/editors/default/agg_params.html index 28a2a3472fae..411357dbfa78 100644 --- a/src/legacy/ui/public/vis/editors/default/agg_params.html +++ b/src/legacy/ui/public/vis/editors/default/agg_params.html @@ -33,4 +33,11 @@

+ + diff --git a/src/legacy/ui/public/vis/editors/default/agg_params.js b/src/legacy/ui/public/vis/editors/default/agg_params.js index 2663e0949749..b3005a2b0929 100644 --- a/src/legacy/ui/public/vis/editors/default/agg_params.js +++ b/src/legacy/ui/public/vis/editors/default/agg_params.js @@ -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 = $('
').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(); diff --git a/src/legacy/ui/public/vis/editors/default/agg_select.html b/src/legacy/ui/public/vis/editors/default/agg_select.html deleted file mode 100644 index d899fb0e7202..000000000000 --- a/src/legacy/ui/public/vis/editors/default/agg_select.html +++ /dev/null @@ -1,50 +0,0 @@ -
-
- - - - -
-
- - - {{$select.selected.title}} - - -
-
-
-
diff --git a/src/legacy/ui/public/vis/editors/default/agg_select.js b/src/legacy/ui/public/vis/editors/default/agg_select.js new file mode 100644 index 000000000000..5bcf5aaeef71 --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/agg_select.js @@ -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 ``; + }, + 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); + }; + } + } + }; + }); diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_select.tsx b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_select.tsx new file mode 100644 index 000000000000..71d09288a2be --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_select.tsx @@ -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 ? ( + + ) : ( + + ); + + let aggHelpLink = null; + if (has(agg, 'type.name')) { + aggHelpLink = get(documentationLinks, ['aggs', agg.type.name]); + } + + const helpLink = isAggTypeDefined && aggHelpLink && ( + + + + ); + + useEffect( + () => { + if (isFunction(setValidity)) { + setValidity(isAggTypeDefined); + } + }, + [isAggTypeDefined] + ); + + return ( + + setValue(get(options, '0.value'))} + data-test-subj="defaultEditorAggSelect" + isClearable={false} + isInvalid={isSelectInvalid} + fullWidth={true} + onBlur={() => setTouched()} + /> + + ); +} + +export { DefaultEditorAggSelect }; diff --git a/src/legacy/ui/public/vis/editors/default/default_editor_utils.tsx b/src/legacy/ui/public/vis/editors/default/default_editor_utils.tsx new file mode 100644 index 000000000000..5d0c50012795 --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/default_editor_utils.tsx @@ -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 }; diff --git a/test/functional/page_objects/visualize_page.js b/test/functional/page_objects/visualize_page.js index 10822b92219b..94f951bd793c 100644 --- a/test/functional/page_objects/visualize_page.js +++ b/test/functional/page_objects/visualize_page.js @@ -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 diff --git a/test/functional/services/combo_box.js b/test/functional/services/combo_box.js index 33d0efc5b393..297e0ee6abaa 100644 --- a/test/functional/services/combo_box.js +++ b/test/functional/services/combo_box.js @@ -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); } diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5f43cde37025..9ba338f905dd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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": "使用您的更改更新可视化",