[Vis: Default editor] EUIficate field selection control and apply form validation (#34548)
* EUIficate field selection control * Refactoring * Remove unused commaList filter * Remove field.html, update functional tests * Update functional tests * Remove unused translation * Update unit test since wrapped React component haven't compiled yet * Move setValidity invocation to the component * Update functional test * Fix type export * Wrap setValidity into useEffect due to react warning on init load with empty value * Update types * Remove extra tag * Removed changed translations * Update functional test * Add help and error message * Update error message * Remove unused dependency * Remove helpText * Remove unused dependencies * Remove unused translation * Refactoring * Refactoring * Update form validation; remove setTouched * Update from validation * Update form validation * Update agg select validation * Refactoring * Add ariaLabel * Revert changes * Update comments * Remove unnecessary aria-label * Disable selector with no options * Add 'required' support for string control * Update messages * Fix merge conflict * Update message * Fix eslint
This commit is contained in:
parent
c1ce349275
commit
870552dd78
|
@ -108,7 +108,7 @@ describe('editor', function () {
|
|||
|
||||
expect(params).to.have.property('field');
|
||||
expect(params.field).to.have.property('$el');
|
||||
expect(params.field.modelValue()).to.be(field);
|
||||
expect($scope.agg.params.field).to.be(field);
|
||||
});
|
||||
|
||||
it('renders the interval editor', function () {
|
||||
|
|
|
@ -17,12 +17,14 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { AggConfig } from '../vis/agg_config';
|
||||
import { AggConfig } from '../vis';
|
||||
|
||||
interface AggParam {
|
||||
type: string;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
displayName?: string;
|
||||
onChange?(agg: AggConfig): void;
|
||||
disabled?(agg: AggConfig): boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
<div class="form-group">
|
||||
<label
|
||||
i18n-id="common.ui.aggTypes.field.fieldLabel"
|
||||
i18n-default-message="Field"
|
||||
></label>
|
||||
|
||||
<ui-select
|
||||
title="{{ ::'common.ui.aggTypes.field.aggregationFieldTitle' | i18n: { defaultMessage: 'Aggregation Field' } }}"
|
||||
name="field"
|
||||
required
|
||||
class="visEditorAggSelect__select field-select"
|
||||
ng-show="indexedFields.length"
|
||||
ng-model="agg.params.field"
|
||||
on-select="aggParam.onChange(agg)"
|
||||
uis-open-close="limit = 100"
|
||||
>
|
||||
<ui-select-match
|
||||
placeholder="{{ ::'common.ui.aggTypes.field.selectFieldPlaceholder' | i18n: { defaultMessage: 'Select a field' } }}"
|
||||
>
|
||||
{{$select.selected.displayName}}
|
||||
</ui-select-match>
|
||||
<ui-select-choices
|
||||
group-by="'type'"
|
||||
kbn-scroll-bottom="limit = limit + 100"
|
||||
repeat="field in indexedFields | filter: { displayName: $select.search } | sortPrefixFirst:$select.search:'name' | limitTo: limit"
|
||||
>
|
||||
<div
|
||||
data-test-subj="{{field.displayName}}"
|
||||
class="eui-textTruncate"
|
||||
ng-bind-html="field.displayName | highlight: $select.search"
|
||||
title="{{field.displayName}}"
|
||||
></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
|
||||
<div class="hintbox" ng-if="!indexedFields.length">
|
||||
<p>
|
||||
<i class="fa fa-danger text-danger"></i>
|
||||
<strong
|
||||
i18n-id="common.ui.aggTypes.dateRanges.noCompatibleFieldsLabel"
|
||||
i18n-default-message="No Compatible Fields:"
|
||||
></strong>
|
||||
<span
|
||||
i18n-id="common.ui.aggTypes.dateRanges.noCompatibleFieldsDescription"
|
||||
i18n-default-message="The {indexPatternTitle} index pattern does not contain any of the following field types:"
|
||||
i18n-values="{ indexPatternTitle: agg.getIndexPattern().title }"
|
||||
></span>
|
||||
{{ agg.type.params.byName.field.filterFieldTypes | commaList:false }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
114
src/legacy/ui/public/agg_types/controls/field.tsx
Normal file
114
src/legacy/ui/public/agg_types/controls/field.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* 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 } from 'lodash';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { AggConfig } from 'ui/vis';
|
||||
import { formatListAsProse, parseCommaSeparatedList } from '../../../../utils';
|
||||
import { AggParamEditorProps } from '../../vis/editors/default';
|
||||
import { ComboBoxGroupedOption } from '../../vis/editors/default/default_editor_utils';
|
||||
import { FieldParamType } from '../param_types';
|
||||
|
||||
const label = i18n.translate('common.ui.aggTypes.field.fieldLabel', { defaultMessage: 'Field' });
|
||||
|
||||
function FieldParamEditor({
|
||||
agg,
|
||||
aggParam,
|
||||
indexedFields = [],
|
||||
isInvalid,
|
||||
value,
|
||||
setTouched,
|
||||
setValidity,
|
||||
setValue,
|
||||
}: AggParamEditorProps<FieldParamType>) {
|
||||
const selectedOptions: ComboBoxGroupedOption[] = value
|
||||
? [{ label: value.displayName, value }]
|
||||
: [];
|
||||
|
||||
const onChange = (options: EuiComboBoxOptionProps[]) => {
|
||||
const selectedOption = get(options, '0.value');
|
||||
if (!(aggParam.required && !selectedOption)) {
|
||||
setValue(selectedOption);
|
||||
}
|
||||
|
||||
if (aggParam.onChange) {
|
||||
aggParam.onChange(agg);
|
||||
}
|
||||
};
|
||||
const errors = [];
|
||||
|
||||
if (!indexedFields.length) {
|
||||
errors.push(
|
||||
i18n.translate('common.ui.aggTypes.field.noCompatibleFieldsDescription', {
|
||||
defaultMessage:
|
||||
'The index pattern {indexPatternTitle} does not contain any of the following compatible field types: {fieldTypes}',
|
||||
values: {
|
||||
indexPatternTitle: agg.getIndexPattern && agg.getIndexPattern().title,
|
||||
fieldTypes: getFieldTypesString(agg),
|
||||
},
|
||||
})
|
||||
);
|
||||
setTouched();
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
setValidity(!!value);
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={label}
|
||||
isInvalid={isInvalid}
|
||||
fullWidth={true}
|
||||
error={errors}
|
||||
className="visEditorSidebar__aggParamFormRow"
|
||||
>
|
||||
<EuiComboBox
|
||||
placeholder={i18n.translate('common.ui.aggTypes.field.selectFieldPlaceholder', {
|
||||
defaultMessage: 'Select a field',
|
||||
})}
|
||||
options={indexedFields}
|
||||
isDisabled={!indexedFields.length}
|
||||
selectedOptions={selectedOptions}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
isClearable={false}
|
||||
isInvalid={isInvalid}
|
||||
onChange={onChange}
|
||||
onBlur={setTouched}
|
||||
data-test-subj="visDefaultEditorField"
|
||||
fullWidth={true}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
|
||||
function getFieldTypesString(agg: AggConfig) {
|
||||
return formatListAsProse(
|
||||
parseCommaSeparatedList(get(agg, 'type.params.byName.field.filterFieldTypes')),
|
||||
{ inclusive: false }
|
||||
);
|
||||
}
|
||||
|
||||
export { FieldParamEditor };
|
|
@ -27,10 +27,11 @@ import { isValidJson } from '../utils';
|
|||
|
||||
function RawJsonParamEditor({
|
||||
agg,
|
||||
value,
|
||||
setValue,
|
||||
isInvalid,
|
||||
value,
|
||||
setValidity,
|
||||
setValue,
|
||||
setTouched,
|
||||
}: AggParamEditorProps<string>) {
|
||||
const label = (
|
||||
<>
|
||||
|
@ -68,6 +69,7 @@ function RawJsonParamEditor({
|
|||
onChange={onChange}
|
||||
rows={2}
|
||||
fullWidth={true}
|
||||
onBlur={setTouched}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
|
|
|
@ -17,29 +17,49 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { EuiFieldText, EuiFormRow } from '@elastic/eui';
|
||||
import { AggParamEditorProps } from '../../vis/editors/default';
|
||||
|
||||
function StringParamEditor({ agg, aggParam, value, setValue }: AggParamEditorProps<string>) {
|
||||
function StringParamEditor({
|
||||
agg,
|
||||
aggParam,
|
||||
isInvalid,
|
||||
value,
|
||||
setValidity,
|
||||
setValue,
|
||||
setTouched,
|
||||
}: AggParamEditorProps<string>) {
|
||||
if (aggParam.disabled && aggParam.disabled(agg)) {
|
||||
// reset model value
|
||||
setValue();
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (aggParam.required) {
|
||||
setValidity(!!value);
|
||||
}
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={aggParam.displayName || aggParam.name}
|
||||
fullWidth={true}
|
||||
className="visEditorSidebar__aggParamFormRow"
|
||||
isInvalid={isInvalid}
|
||||
>
|
||||
<EuiFieldText
|
||||
value={value || ''}
|
||||
data-test-subj={`visEditorStringInput${agg.id}${aggParam.name}`}
|
||||
onChange={ev => setValue(ev.target.value)}
|
||||
fullWidth={true}
|
||||
onBlur={setTouched}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import ngMock from 'ng_mock';
|
||||
import '../comma_list';
|
||||
|
||||
describe('Comma-List filter', function () {
|
||||
|
||||
let commaList;
|
||||
|
||||
beforeEach(ngMock.module('kibana'));
|
||||
beforeEach(ngMock.inject(function ($injector) {
|
||||
commaList = $injector.get('commaListFilter');
|
||||
}));
|
||||
|
||||
it('converts a string to a pretty list', function () {
|
||||
expect(commaList('john,jaine,jim', true)).to.be('john, jaine, and jim');
|
||||
expect(commaList('john,jaine,jim', false)).to.be('john, jaine, or jim');
|
||||
});
|
||||
|
||||
it('can accept an array too', function () {
|
||||
expect(commaList(['john', 'jaine', 'jim'])).to.be('john, jaine, or jim');
|
||||
});
|
||||
|
||||
it('handles undefined ok', function () {
|
||||
expect(commaList()).to.be('');
|
||||
});
|
||||
|
||||
it('handles single values ok', function () {
|
||||
expect(commaList(['john'])).to.be('john');
|
||||
});
|
||||
|
||||
});
|
|
@ -19,9 +19,8 @@
|
|||
|
||||
import { sortBy } from 'lodash';
|
||||
import { SavedObjectNotFound } from '../../errors';
|
||||
import { FieldParamEditor } from '../controls/field';
|
||||
import '../directives/scroll_bottom';
|
||||
import '../filter/comma_list';
|
||||
import editorHtml from '../controls/field.html';
|
||||
import { BaseParamType } from './base';
|
||||
import '../filters/sort_prefix_first';
|
||||
import '../../filters/field_type';
|
||||
|
@ -39,7 +38,8 @@ export function FieldParamType(config) {
|
|||
|
||||
createLegacyClass(FieldParamType).inherits(BaseParamType);
|
||||
|
||||
FieldParamType.prototype.editor = editorHtml;
|
||||
FieldParamType.prototype.editorComponent = FieldParamEditor;
|
||||
FieldParamType.prototype.required = true;
|
||||
FieldParamType.prototype.scriptable = true;
|
||||
FieldParamType.prototype.filterFieldTypes = '*';
|
||||
// retain only the fields with the aggregatable property if the onlyAggregatable option is true
|
||||
|
|
|
@ -24,6 +24,10 @@ function isValidJson(value: string): boolean {
|
|||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trimmedValue[0] === '{' || trimmedValue[0] === '[') {
|
||||
try {
|
||||
JSON.parse(trimmedValue);
|
||||
|
|
|
@ -23,12 +23,12 @@
|
|||
</button>
|
||||
|
||||
<!-- description -->
|
||||
<span ng-if="!editorOpen && aggForm.$valid" class="visEditorSidebar__collapsibleTitleDescription" title="{{describe()}}">
|
||||
<span ng-if="!editorOpen && aggForm.softErrorCount() < 1" class="visEditorSidebar__collapsibleTitleDescription" title="{{describe()}}">
|
||||
{{ describe() }}
|
||||
</span>
|
||||
|
||||
<!-- error -->
|
||||
<span ng-if="!editorOpen && aggForm.$invalid" class="visEditorSidebar__collapsibleTitleDescription visEditorSidebar__collapsibleTitleDescription--danger" title="{{aggForm.describeErrors()}}">
|
||||
<span ng-if="!editorOpen && aggForm.softErrorCount() > 0" class="visEditorSidebar__collapsibleTitleDescription visEditorSidebar__collapsibleTitleDescription--danger" title="{{aggForm.describeErrors()}}">
|
||||
{{ aggForm.describeErrors() }}
|
||||
</span>
|
||||
|
||||
|
|
|
@ -27,12 +27,14 @@ uiModules
|
|||
.directive('visAggParamReactWrapper', reactDirective => reactDirective(wrapInI18nContext(AggParamReactWrapper), [
|
||||
['agg', { watchDepth: 'collection' }],
|
||||
['aggParam', { watchDepth: 'reference' }],
|
||||
['indexedFields', { watchDepth: 'collection' }],
|
||||
['paramEditor', { wrapApply: false }],
|
||||
['onChange', { watchDepth: 'reference' }],
|
||||
['setTouched', { watchDepth: 'reference' }],
|
||||
['setValidity', { watchDepth: 'reference' }],
|
||||
'value',
|
||||
'field',
|
||||
'isInvalid',
|
||||
'field'
|
||||
'value',
|
||||
]))
|
||||
.directive('visAggParamEditor', function (config) {
|
||||
return {
|
||||
|
@ -55,11 +57,13 @@ uiModules
|
|||
param-editor="editorComponent"
|
||||
agg="agg"
|
||||
agg-param="aggParam"
|
||||
on-change="onChange"
|
||||
value="paramValue"
|
||||
is-invalid="isInvalid"
|
||||
set-validity="setValidity"
|
||||
field="agg.params.field"
|
||||
indexed-fields="indexedFields"
|
||||
is-invalid="isInvalid"
|
||||
value="paramValue"
|
||||
on-change="onChange"
|
||||
set-touched="setTouched"
|
||||
set-validity="setValidity"
|
||||
></vis-agg-param-react-wrapper>`;
|
||||
}
|
||||
|
||||
|
@ -70,8 +74,10 @@ uiModules
|
|||
$scope.$bind('aggParam', attr.aggParam);
|
||||
$scope.$bind('agg', attr.agg);
|
||||
$scope.$bind('editorComponent', attr.editorComponent);
|
||||
$scope.$bind('indexedFields', attr.indexedFields);
|
||||
},
|
||||
post: function ($scope, $el, attr, ngModelCtrl) {
|
||||
let _isInvalid = false;
|
||||
$scope.config = config;
|
||||
|
||||
$scope.optionEnabled = function (option) {
|
||||
|
@ -87,6 +93,18 @@ uiModules
|
|||
// 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.setValidity(true);
|
||||
showValidation();
|
||||
}, true);
|
||||
|
||||
$scope.$watch(() => {
|
||||
// The model can become touched either onBlur event or when the form is submitted.
|
||||
return ngModelCtrl.$touched;
|
||||
}, (value) => {
|
||||
if (value) {
|
||||
showValidation();
|
||||
}
|
||||
}, true);
|
||||
$scope.paramValue = $scope.agg.params[$scope.aggParam.name];
|
||||
}
|
||||
|
@ -96,17 +114,22 @@ uiModules
|
|||
// to bind function values, this is right now the best temporary fix, until all of this will be gone.
|
||||
$scope.$parent.onParamChange($scope.agg, $scope.aggParam.name, value);
|
||||
|
||||
if(ngModelCtrl) {
|
||||
ngModelCtrl.$setDirty();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.setTouched = () => {
|
||||
ngModelCtrl.$setTouched();
|
||||
showValidation();
|
||||
};
|
||||
|
||||
$scope.setValidity = (isValid) => {
|
||||
if(ngModelCtrl) {
|
||||
$scope.isInvalid = !isValid;
|
||||
_isInvalid = !isValid;
|
||||
ngModelCtrl.$setValidity(`agg${$scope.agg.id}${$scope.aggParam.name}`, isValid);
|
||||
}
|
||||
};
|
||||
|
||||
function showValidation() {
|
||||
$scope.isInvalid = _isInvalid;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -27,8 +27,10 @@ import { AggConfig } from '../../agg_config';
|
|||
export interface AggParamEditorProps<T> {
|
||||
agg: AggConfig;
|
||||
aggParam: AggParam;
|
||||
value: T;
|
||||
indexedFields?: any[];
|
||||
isInvalid: boolean;
|
||||
setValue(value?: T): void;
|
||||
value: T;
|
||||
setValidity(isValid: boolean): void;
|
||||
setValue(value?: T): void;
|
||||
setTouched(): void;
|
||||
}
|
||||
|
|
|
@ -20,16 +20,19 @@
|
|||
import React from 'react';
|
||||
|
||||
import { AggParam } from '../../../agg_types';
|
||||
import { FieldParamType } from '../../../agg_types/param_types';
|
||||
import { AggConfig } from '../../agg_config';
|
||||
import { AggParamEditorProps } from './agg_param_editor_props';
|
||||
|
||||
interface AggParamReactWrapperProps<T> {
|
||||
agg: AggConfig;
|
||||
aggParam: AggParam;
|
||||
indexedFields: FieldParamType[];
|
||||
isInvalid: boolean;
|
||||
paramEditor: React.FunctionComponent<AggParamEditorProps<T>>;
|
||||
value: T;
|
||||
isInvalid: boolean;
|
||||
onChange(value: T): void;
|
||||
setTouched(): void;
|
||||
setValidity(isValid: boolean): void;
|
||||
}
|
||||
|
||||
|
@ -37,20 +40,24 @@ function AggParamReactWrapper<T>(props: AggParamReactWrapperProps<T>) {
|
|||
const {
|
||||
agg,
|
||||
aggParam,
|
||||
paramEditor: ParamEditor,
|
||||
onChange,
|
||||
value,
|
||||
indexedFields,
|
||||
isInvalid,
|
||||
paramEditor: ParamEditor,
|
||||
value,
|
||||
onChange,
|
||||
setValidity,
|
||||
setTouched,
|
||||
} = props;
|
||||
return (
|
||||
<ParamEditor
|
||||
value={value}
|
||||
setValue={onChange}
|
||||
aggParam={aggParam}
|
||||
agg={agg}
|
||||
aggParam={aggParam}
|
||||
indexedFields={indexedFields}
|
||||
isInvalid={isInvalid}
|
||||
value={value}
|
||||
setTouched={setTouched}
|
||||
setValidity={setValidity}
|
||||
setValue={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -150,8 +150,9 @@ uiModules
|
|||
// if field param exists, compute allowed fields
|
||||
if (param.type === 'field') {
|
||||
const availableFields = param.getAvailableFields($scope.agg.getIndexPattern().fields);
|
||||
fields = $scope.indexedFields = $aggParamEditorsScope[`${param.name}Options`] =
|
||||
fields = $aggParamEditorsScope[`${param.name}Options`] =
|
||||
aggTypeFieldFilters.filter(availableFields, param.type, $scope.agg, $scope.vis);
|
||||
$scope.indexedFields = groupAggregationsBy(fields, 'type', 'displayName');
|
||||
}
|
||||
|
||||
if (fields) {
|
||||
|
@ -202,6 +203,7 @@ uiModules
|
|||
|
||||
if (param.editorComponent) {
|
||||
attrs['editor-component'] = `agg.type.params[${idx}].editorComponent`;
|
||||
attrs['indexed-fields'] = 'indexedFields';
|
||||
// The form should interact with reactified components as well.
|
||||
// So we set the ng-model (using a random ng-model variable) to have the method to set dirty
|
||||
// inside the agg_param.js directive, which can get access to the ngModelController to manipulate it.
|
||||
|
|
|
@ -27,13 +27,13 @@ uiModules
|
|||
.directive('visAggSelectReactWrapper', reactDirective => reactDirective(wrapInI18nContext(DefaultEditorAggSelect), [
|
||||
['agg', { watchDepth: 'collection' }],
|
||||
['aggTypeOptions', { watchDepth: 'collection' }],
|
||||
['setValue', { watchDepth: 'reference' }],
|
||||
['setTouched', { watchDepth: 'reference' }],
|
||||
['setValidity', { watchDepth: 'reference' }],
|
||||
'value',
|
||||
'isSubAggregation',
|
||||
['setValue', { watchDepth: 'reference' }],
|
||||
'aggHelpLink',
|
||||
'isSelectInvalid'
|
||||
'isSelectInvalid',
|
||||
'isSubAggregation',
|
||||
'value',
|
||||
]))
|
||||
.directive('visAggSelect', function () {
|
||||
return {
|
||||
|
@ -42,31 +42,50 @@ uiModules
|
|||
require: '^ngModel',
|
||||
template: function () {
|
||||
return `<vis-agg-select-react-wrapper
|
||||
ng-if="setValidity"
|
||||
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"
|
||||
is-sub-aggregation="isSubAggregation"
|
||||
value="paramValue"
|
||||
set-validity="setValidity"
|
||||
set-value="onChange"
|
||||
set-touched="setTouched"
|
||||
></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);
|
||||
$scope.$bind('isSubAggregation', attr.isSubAggregation);
|
||||
},
|
||||
post: function ($scope, $el, attr, ngModelCtrl) {
|
||||
let _isSelectInvalid = false;
|
||||
|
||||
$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.setValidity(true);
|
||||
$scope.isSelectInvalid = false;
|
||||
});
|
||||
|
||||
$scope.$watch(() => {
|
||||
// The model can become touched either onBlur event or when the form is submitted.
|
||||
return ngModelCtrl.$touched;
|
||||
}, (value) => {
|
||||
if (value === true) {
|
||||
showValidation();
|
||||
}
|
||||
}, true);
|
||||
|
||||
$scope.onChange = (value) => {
|
||||
if (!value) {
|
||||
// We prevent to make the field empty.
|
||||
return;
|
||||
}
|
||||
// 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);
|
||||
|
@ -76,15 +95,17 @@ uiModules
|
|||
|
||||
$scope.setTouched = () => {
|
||||
ngModelCtrl.$setTouched();
|
||||
$scope.isSelectInvalid = !$scope.paramValue;
|
||||
showValidation();
|
||||
};
|
||||
|
||||
$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.
|
||||
_isSelectInvalid = !isValid;
|
||||
ngModelCtrl.$setValidity(`agg${$scope.agg.id}`, isValid);
|
||||
};
|
||||
|
||||
function showValidation() {
|
||||
$scope.isSelectInvalid = _isSelectInvalid;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { get, has, isFunction } from 'lodash';
|
||||
import { get, has } from 'lodash';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { EuiComboBox, EuiFormRow, EuiLink } from '@elastic/eui';
|
||||
|
@ -29,29 +29,26 @@ import { ComboBoxGroupedOption } from '../default_editor_utils';
|
|||
|
||||
interface DefaultEditorAggSelectProps {
|
||||
agg: AggConfig;
|
||||
value: AggType;
|
||||
setValue: (aggType: AggType) => void;
|
||||
aggTypeOptions: AggType[];
|
||||
isSubAggregation: boolean;
|
||||
isSelectInvalid: boolean;
|
||||
setTouched: () => void;
|
||||
isSubAggregation: boolean;
|
||||
value: AggType;
|
||||
setValidity: (isValid: boolean) => void;
|
||||
setValue: (aggType: AggType) => void;
|
||||
setTouched: () => void;
|
||||
}
|
||||
|
||||
function DefaultEditorAggSelect({
|
||||
agg = {},
|
||||
value = { title: '' },
|
||||
agg,
|
||||
value,
|
||||
setValue,
|
||||
aggTypeOptions = [],
|
||||
aggTypeOptions,
|
||||
isSelectInvalid,
|
||||
isSubAggregation,
|
||||
setTouched,
|
||||
setValidity,
|
||||
}: DefaultEditorAggSelectProps) {
|
||||
const isAggTypeDefined = value && Boolean(value.title);
|
||||
const selectedOptions: ComboBoxGroupedOption[] = isAggTypeDefined
|
||||
? [{ label: value.title, value }]
|
||||
: [];
|
||||
const selectedOptions: ComboBoxGroupedOption[] = value ? [{ label: value.title, value }] : [];
|
||||
|
||||
const label = isSubAggregation ? (
|
||||
<FormattedMessage
|
||||
|
@ -70,7 +67,7 @@ function DefaultEditorAggSelect({
|
|||
aggHelpLink = get(documentationLinks, ['aggs', agg.type.name]);
|
||||
}
|
||||
|
||||
const helpLink = isAggTypeDefined && aggHelpLink && (
|
||||
const helpLink = value && aggHelpLink && (
|
||||
<EuiLink
|
||||
href={aggHelpLink}
|
||||
target="_blank"
|
||||
|
@ -80,42 +77,57 @@ function DefaultEditorAggSelect({
|
|||
<FormattedMessage
|
||||
id="common.ui.vis.defaultEditor.aggSelect.helpLinkLabel"
|
||||
defaultMessage="{aggTitle} help"
|
||||
values={{ aggTitle: isAggTypeDefined ? value.title : '' }}
|
||||
values={{ aggTitle: value ? value.title : '' }}
|
||||
/>
|
||||
</EuiLink>
|
||||
);
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (!aggTypeOptions.length) {
|
||||
errors.push(
|
||||
i18n.translate('common.ui.vis.defaultEditor.aggSelect.noCompatibleAggsDescription', {
|
||||
defaultMessage: 'The index pattern {indexPatternTitle} does not contain any aggregations.',
|
||||
values: {
|
||||
indexPatternTitle: agg.getIndexPattern && agg.getIndexPattern().title,
|
||||
},
|
||||
})
|
||||
);
|
||||
setTouched();
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (isFunction(setValidity)) {
|
||||
setValidity(isAggTypeDefined);
|
||||
}
|
||||
// The selector will be invalid when the value is empty.
|
||||
setValidity(!!value);
|
||||
},
|
||||
[isAggTypeDefined]
|
||||
[value]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={label}
|
||||
labelAppend={helpLink}
|
||||
error={errors}
|
||||
isInvalid={isSelectInvalid}
|
||||
fullWidth={true}
|
||||
className="visEditorAggSelect__formRow"
|
||||
>
|
||||
<EuiComboBox
|
||||
placeholder={i18n.translate('common.ui.vis.defaultEditor.aggSelect.selectAggPlaceholder', {
|
||||
defaultMessage: 'Select an aggregation…',
|
||||
defaultMessage: 'Select an aggregation',
|
||||
})}
|
||||
id={`visDefaultEditorAggSelect${agg.id}`}
|
||||
isDisabled={!aggTypeOptions.length}
|
||||
options={aggTypeOptions}
|
||||
selectedOptions={selectedOptions}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
onBlur={setTouched}
|
||||
onChange={options => setValue(get(options, '0.value'))}
|
||||
data-test-subj="defaultEditorAggSelect"
|
||||
isClearable={false}
|
||||
isInvalid={isSelectInvalid}
|
||||
fullWidth={true}
|
||||
onBlur={() => setTouched()}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
|
|
|
@ -20,7 +20,12 @@
|
|||
import { EuiComboBoxOptionProps } from '@elastic/eui';
|
||||
import { AggType } from 'ui/agg_types';
|
||||
|
||||
// NOTE: we cannot export the interface with export { InterfaceName }
|
||||
// as there is currently a bug on babel typescript transform plugin for it
|
||||
// https://github.com/babel/babel/issues/7641
|
||||
//
|
||||
export type ComboBoxGroupedOption = EuiComboBoxOptionProps & {
|
||||
label: string;
|
||||
value?: AggType;
|
||||
options?: ComboBoxGroupedOption[];
|
||||
};
|
||||
|
@ -30,12 +35,14 @@ export type ComboBoxGroupedOption = EuiComboBoxOptionProps & {
|
|||
*
|
||||
* @param aggs An array of aggregations that will be grouped.
|
||||
* @param groupBy A field name which aggregations is grouped by.
|
||||
* @param labelName A name of a property which value will be displayed.
|
||||
*
|
||||
* @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'
|
||||
groupBy: string = 'type',
|
||||
labelName = 'title'
|
||||
): ComboBoxGroupedOption[] | [] {
|
||||
if (!Array.isArray(aggs)) {
|
||||
return [];
|
||||
|
@ -44,7 +51,7 @@ function groupAggregationsBy(
|
|||
const groupedOptions: ComboBoxGroupedOption[] = aggs.reduce((array: AggType[], type: AggType) => {
|
||||
const group = array.find(element => element.label === type[groupBy]);
|
||||
const option = {
|
||||
label: type.title,
|
||||
label: type[labelName],
|
||||
value: type,
|
||||
};
|
||||
|
||||
|
@ -72,7 +79,7 @@ function groupAggregationsBy(
|
|||
return groupedOptions;
|
||||
}
|
||||
|
||||
function sortByLabel(a: { label: string }, b: { label: string }) {
|
||||
function sortByLabel(a: ComboBoxGroupedOption, b: ComboBoxGroupedOption) {
|
||||
return a.label.toLowerCase().localeCompare(b.label.toLowerCase());
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<div class="visEditorSidebar__container">
|
||||
<form
|
||||
class="visEditorSidebar__form"
|
||||
ng-submit="visualizeEditor.$invalid ? stageEditableVis(false) : stageEditableVis()"
|
||||
ng-submit="visualizeEditor.$valid && stageEditableVis()"
|
||||
name="visualizeEditor"
|
||||
novalidate
|
||||
ng-keydown="submitEditorWithKeyboard($event)"
|
||||
|
@ -69,7 +69,7 @@
|
|||
<!-- controls -->
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li
|
||||
ng-if="visualizeEditor.softErrorCount() > 0"
|
||||
ng-if="visualizeEditor.errorCount() > 0 && visualizeEditor.errorCount() === visualizeEditor.softErrorCount()"
|
||||
disabled
|
||||
tooltip="{{ visualizeEditor.describeErrors() }}"
|
||||
tooltip-placement="bottom"
|
||||
|
@ -115,13 +115,13 @@
|
|||
tooltip="{{::'common.ui.vis.editors.sidebar.applyChangesTooltip' | i18n: { defaultMessage: 'Apply changes' } }}"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-popup-delay="400" tooltip-append-to-body="1"
|
||||
ng-hide="autoApplyEnabled || visualizeEditor.softErrorCount() > 0"
|
||||
ng-hide="autoApplyEnabled || (visualizeEditor.errorCount() > 0 && visualizeEditor.errorCount() === visualizeEditor.softErrorCount())"
|
||||
>
|
||||
<button
|
||||
data-test-subj="visualizeEditorRenderButton"
|
||||
class="kuiButton kuiButton--primary navbar-btn-link visEditorSidebar__navButtonLink"
|
||||
type="submit"
|
||||
ng-disabled="!vis.dirty || visualizeEditor.errorCount() > 0 || autoApplyEnabled"
|
||||
ng-disabled="!vis.dirty || autoApplyEnabled"
|
||||
aria-label="{{::'common.ui.vis.editors.sidebar.applyChangesAriaLabel' | i18n: { defaultMessage: 'Update the visualization with your changes' } }}"
|
||||
>
|
||||
<icon aria-hidden="true" type="'play'"></icon>
|
||||
|
|
|
@ -17,25 +17,6 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { uiModules } from 'ui/modules';
|
||||
export function parseCommaSeparatedList(input: string | string[]): string[];
|
||||
|
||||
import {
|
||||
parseCommaSeparatedList,
|
||||
formatListAsProse,
|
||||
} from '../../../../utils';
|
||||
|
||||
uiModules
|
||||
.get('kibana')
|
||||
.filter('commaList', function () {
|
||||
/**
|
||||
* Angular filter that accepts either an array or a comma-separated string
|
||||
* and outputs a comma-separated string for presentation.
|
||||
*
|
||||
* @param {String|Array} input - The comma-separated list or array
|
||||
* @param {Boolean} inclusive - Should the list be joined with an "and"?
|
||||
* @return {String}
|
||||
*/
|
||||
return function (input, inclusive = false) {
|
||||
return formatListAsProse(parseCommaSeparatedList(input), { inclusive });
|
||||
};
|
||||
});
|
||||
export function formatListAsProse(list: string[], options?: { inclusive?: boolean }): string;
|
|
@ -45,9 +45,9 @@ export default function ({ getService, getPageObjects }) {
|
|||
log.debug('Click Date Histogram');
|
||||
await PageObjects.visualize.selectAggregation('Date Histogram');
|
||||
log.debug('Check field value');
|
||||
const fieldValue = await PageObjects.visualize.getField();
|
||||
log.debug('fieldValue = ' + fieldValue);
|
||||
expect(fieldValue).to.be('@timestamp');
|
||||
const fieldValues = await PageObjects.visualize.getField();
|
||||
log.debug('fieldValue = ' + fieldValues);
|
||||
expect(fieldValues[0]).to.be('@timestamp');
|
||||
const intervalValue = await PageObjects.visualize.getInterval();
|
||||
log.debug('intervalValue = ' + intervalValue);
|
||||
expect(intervalValue).to.be('Auto');
|
||||
|
|
|
@ -252,7 +252,7 @@ export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrPro
|
|||
return await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
public async fillInVariable(name = 'test', metric = 'count', nth = 0) {
|
||||
public async fillInVariable(name = 'test', metric = 'Count', nth = 0) {
|
||||
const elements = await testSubjects.findAll('varRow');
|
||||
const varNameInput = await elements[nth].findByCssSelector('.tvbAggs__varName');
|
||||
await varNameInput.type(name);
|
||||
|
|
|
@ -488,12 +488,9 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli
|
|||
.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
|
||||
await fieldSelect.click();
|
||||
const fieldSelect = await find.byCssSelector(`#visAggEditorParams${index} [data-test-subj="visDefaultEditorField"]`);
|
||||
// select our field
|
||||
await testSubjects.click(field);
|
||||
await comboBox.setElement(fieldSelect, field);
|
||||
// enter custom label
|
||||
await this.setCustomLabel(label, index);
|
||||
}
|
||||
|
@ -535,9 +532,7 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli
|
|||
}
|
||||
|
||||
async getField() {
|
||||
const field = await retry.try(
|
||||
async () => await find.byCssSelector('.ng-valid-required[name="field"] .ui-select-match-text'));
|
||||
return await field.getVisibleText();
|
||||
return await comboBox.getComboBoxSelectedOptions('visDefaultEditorField');
|
||||
}
|
||||
|
||||
async selectField(fieldValue, groupName = 'buckets', childAggregationType = null) {
|
||||
|
@ -546,12 +541,10 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli
|
|||
[group-name="${groupName}"]
|
||||
vis-editor-agg-params:not(.ng-hide)
|
||||
${childAggregationType ? `vis-editor-agg-params[group-name="'${childAggregationType}'"]:not(.ng-hide)` : ''}
|
||||
.field-select
|
||||
[data-test-subj="visDefaultEditorField"]
|
||||
`;
|
||||
await find.clickByCssSelector(selector);
|
||||
await find.setValue(`${selector} input.ui-select-search`, fieldValue);
|
||||
const input = await find.byCssSelector(`${selector} input.ui-select-search`);
|
||||
await input.pressKeys(browser.keys.RETURN);
|
||||
const fieldEl = await find.byCssSelector(selector);
|
||||
await comboBox.setElement(fieldEl, fieldValue);
|
||||
}
|
||||
|
||||
async selectFieldById(fieldValue, id) {
|
||||
|
|
|
@ -116,8 +116,6 @@
|
|||
"common.ui.aggTypes.dateRanges.acceptedDateFormatsLinkText": "已接受日期格式",
|
||||
"common.ui.aggTypes.dateRanges.addRangeButtonLabel": "添加范围",
|
||||
"common.ui.aggTypes.dateRanges.fromColumnLabel": "从",
|
||||
"common.ui.aggTypes.dateRanges.noCompatibleFieldsDescription": "“{indexPatternTitle}” 索引模式不包含任何以下字段类型:",
|
||||
"common.ui.aggTypes.dateRanges.noCompatibleFieldsLabel": "无兼容字段:",
|
||||
"common.ui.aggTypes.dateRanges.removeRangeButtonAriaLabel": "移除此范围",
|
||||
"common.ui.aggTypes.dateRanges.requiredDateRangeDescription": "必须至少指定一个日期范围。",
|
||||
"common.ui.aggTypes.dateRanges.requiredDateRangeLabel": "必需:",
|
||||
|
@ -131,9 +129,7 @@
|
|||
"common.ui.aggTypes.extendedBounds.minLabel.optionalText": "(可选)",
|
||||
"common.ui.aggTypes.extendedBoundsLabel": "扩展的边界",
|
||||
"common.ui.aggTypes.extendedBoundsTooltip": "“最小值”和“最大值”不会筛选结果,而会扩展结果集的边界",
|
||||
"common.ui.aggTypes.field.aggregationFieldTitle": "聚合字段",
|
||||
"common.ui.aggTypes.field.fieldLabel": "字段",
|
||||
"common.ui.aggTypes.field.selectFieldPlaceholder": "选择字段",
|
||||
"common.ui.aggTypes.filters.addFilterButtonLabel": "添加筛选",
|
||||
"common.ui.aggTypes.filters.definiteFilterLabel": "筛选 {index} 标签",
|
||||
"common.ui.aggTypes.filters.filterLabel": "筛选 {index}",
|
||||
|
|
Loading…
Reference in a new issue