Introduce EditorConfigProvider to customize vis editor (#20519)
* Add possibility to specify base for histogram interval * Move AggParamsWriter to ES6 * Add param for time_zone in date_histogram * Prevent writing intervalBase to DSL * Add basic EditorConfig providers * Merge multiple configs together * Allow hiding parameters * Improve config merging and add tests * Remove TODO * Implement review feedback * Add warning to parameter * Remove unneeded self
This commit is contained in:
parent
9646850306
commit
133d25aef1
|
@ -46,55 +46,57 @@ export default function AggParamWriterHelper(Private) {
|
|||
* @param {object} opts - describe the properties of this paramWriter
|
||||
* @param {string} opts.aggType - the name of the aggType we want to test. ('histogram', 'filter', etc.)
|
||||
*/
|
||||
function AggParamWriter(opts) {
|
||||
const self = this;
|
||||
class AggParamWriter {
|
||||
|
||||
self.aggType = opts.aggType;
|
||||
if (_.isString(self.aggType)) {
|
||||
self.aggType = aggTypes.byName[self.aggType];
|
||||
constructor(opts) {
|
||||
this.aggType = opts.aggType;
|
||||
if (_.isString(this.aggType)) {
|
||||
this.aggType = aggTypes.byName[this.aggType];
|
||||
}
|
||||
|
||||
// not configurable right now, but totally required
|
||||
this.indexPattern = stubbedLogstashIndexPattern;
|
||||
|
||||
// the schema that the aggType satisfies
|
||||
this.visAggSchema = null;
|
||||
|
||||
this.vis = new Vis(this.indexPattern, {
|
||||
type: 'histogram',
|
||||
aggs: [{
|
||||
id: 1,
|
||||
type: this.aggType.name,
|
||||
params: {}
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
// not configurable right now, but totally required
|
||||
self.indexPattern = stubbedLogstashIndexPattern;
|
||||
write(paramValues, modifyAggConfig = null) {
|
||||
paramValues = _.clone(paramValues);
|
||||
|
||||
// the schema that the aggType satisfies
|
||||
self.visAggSchema = null;
|
||||
if (this.aggType.params.byName.field && !paramValues.field) {
|
||||
// pick a field rather than force a field to be specified everywhere
|
||||
if (this.aggType.type === 'metrics') {
|
||||
paramValues.field = _.sample(this.indexPattern.fields.byType.number);
|
||||
} else {
|
||||
const type = this.aggType.params.byName.field.filterFieldTypes || 'string';
|
||||
let field;
|
||||
do {
|
||||
field = _.sample(this.indexPattern.fields.byType[type]);
|
||||
} while (!field.aggregatable);
|
||||
paramValues.field = field.name;
|
||||
}
|
||||
}
|
||||
|
||||
self.vis = new Vis(self.indexPattern, {
|
||||
type: 'histogram',
|
||||
aggs: [{
|
||||
id: 1,
|
||||
type: self.aggType.name,
|
||||
params: {}
|
||||
}]
|
||||
});
|
||||
const aggConfig = this.vis.aggs[0];
|
||||
aggConfig.setParams(paramValues);
|
||||
|
||||
if (modifyAggConfig) {
|
||||
modifyAggConfig(aggConfig);
|
||||
}
|
||||
|
||||
return aggConfig.write(this.vis.aggs);
|
||||
}
|
||||
}
|
||||
|
||||
AggParamWriter.prototype.write = function (paramValues) {
|
||||
const self = this;
|
||||
paramValues = _.clone(paramValues);
|
||||
|
||||
if (self.aggType.params.byName.field && !paramValues.field) {
|
||||
// pick a field rather than force a field to be specified everywhere
|
||||
if (self.aggType.type === 'metrics') {
|
||||
paramValues.field = _.sample(self.indexPattern.fields.byType.number);
|
||||
} else {
|
||||
const type = self.aggType.params.byName.field.filterFieldTypes || 'string';
|
||||
let field;
|
||||
do {
|
||||
field = _.sample(self.indexPattern.fields.byType[type]);
|
||||
} while (!field.aggregatable);
|
||||
paramValues.field = field.name;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const aggConfig = self.vis.aggs[0];
|
||||
aggConfig.setParams(paramValues);
|
||||
|
||||
return aggConfig.write(self.vis.aggs);
|
||||
};
|
||||
|
||||
return AggParamWriter;
|
||||
|
||||
}
|
||||
|
|
|
@ -18,10 +18,13 @@
|
|||
*/
|
||||
|
||||
import expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
import ngMock from 'ng_mock';
|
||||
import { aggTypes } from '../..';
|
||||
import chrome from '../../../chrome';
|
||||
import AggParamWriterProvider from '../agg_param_writer';
|
||||
|
||||
const config = chrome.getUiSettingsClient();
|
||||
const histogram = aggTypes.byName.histogram;
|
||||
describe('Histogram Agg', function () {
|
||||
|
||||
|
@ -46,6 +49,13 @@ describe('Histogram Agg', function () {
|
|||
paramWriter = new AggParamWriter({ aggType: 'histogram' });
|
||||
}));
|
||||
|
||||
describe('intervalBase', () => {
|
||||
it('should not be written to the DSL', () => {
|
||||
const output = paramWriter.write({ intervalBase: 100 });
|
||||
expect(output.params).not.to.have.property('intervalBase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interval', function () {
|
||||
// reads aggConfig.params.interval, writes to dsl.interval
|
||||
|
||||
|
@ -74,6 +84,57 @@ describe('Histogram Agg', function () {
|
|||
const output = paramWriter.write({ interval: [] });
|
||||
expect(isNaN(output.params.interval)).to.be.ok();
|
||||
});
|
||||
|
||||
describe('interval scaling', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(config, 'get');
|
||||
});
|
||||
|
||||
it('will respect the histogram:maxBars setting', () => {
|
||||
config.get.withArgs('histogram:maxBars').returns(5);
|
||||
const output = paramWriter.write({ interval: 5 },
|
||||
aggConfig => aggConfig.setAutoBounds({ min: 0, max: 10000 }));
|
||||
expect(output.params).to.have.property('interval', 2000);
|
||||
});
|
||||
|
||||
it('will return specified interval, if bars are below histogram:maxBars config', () => {
|
||||
config.get.withArgs('histogram:maxBars').returns(10000);
|
||||
const output = paramWriter.write({ interval: 5 },
|
||||
aggConfig => aggConfig.setAutoBounds({ min: 0, max: 10000 }));
|
||||
expect(output.params).to.have.property('interval', 5);
|
||||
});
|
||||
|
||||
it('will set to intervalBase if interval is below base', () => {
|
||||
const output = paramWriter.write({ interval: 3, intervalBase: 8 });
|
||||
expect(output.params).to.have.property('interval', 8);
|
||||
});
|
||||
|
||||
it('will round to nearest intervalBase multiple if interval is above base', () => {
|
||||
const roundUp = paramWriter.write({ interval: 46, intervalBase: 10 });
|
||||
expect(roundUp.params).to.have.property('interval', 50);
|
||||
const roundDown = paramWriter.write({ interval: 43, intervalBase: 10 });
|
||||
expect(roundDown.params).to.have.property('interval', 40);
|
||||
});
|
||||
|
||||
it('will not change interval if it is a multiple of base', () => {
|
||||
const output = paramWriter.write({ interval: 35, intervalBase: 5 });
|
||||
expect(output.params).to.have.property('interval', 35);
|
||||
});
|
||||
|
||||
it('will round to intervalBase after scaling histogram:maxBars', () => {
|
||||
config.get.withArgs('histogram:maxBars').returns(100);
|
||||
const output = paramWriter.write({ interval: 5, intervalBase: 6 },
|
||||
aggConfig => aggConfig.setAutoBounds({ min: 0, max: 1000 }));
|
||||
// 100 buckets in 0 to 1000 would result in an interval of 10, so we should
|
||||
// round to the next multiple of 6 -> 12
|
||||
expect(output.params).to.have.property('interval', 12);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.get.restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('min_doc_count', function () {
|
||||
|
|
|
@ -20,13 +20,17 @@
|
|||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
import ngMock from 'ng_mock';
|
||||
import AggParamWriterProvider from '../../agg_param_writer';
|
||||
import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern';
|
||||
import chrome from '../../../../chrome';
|
||||
import { aggTypes } from '../../..';
|
||||
import { AggConfig } from '../../../../vis/agg_config';
|
||||
import { timefilter } from 'ui/timefilter';
|
||||
|
||||
const config = chrome.getUiSettingsClient();
|
||||
|
||||
describe('params', function () {
|
||||
|
||||
let paramWriter;
|
||||
|
@ -132,6 +136,30 @@ describe('params', function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe('time_zone', () => {
|
||||
beforeEach(() => {
|
||||
sinon.stub(config, 'get');
|
||||
sinon.stub(config, 'isDefault');
|
||||
});
|
||||
|
||||
it('should use the specified time_zone', () => {
|
||||
const output = paramWriter.write({ time_zone: 'Europe/Kiev' });
|
||||
expect(output.params).to.have.property('time_zone', 'Europe/Kiev');
|
||||
});
|
||||
|
||||
it('should use the Kibana time_zone if no parameter specified', () => {
|
||||
config.isDefault.withArgs('dateFormat:tz').returns(false);
|
||||
config.get.withArgs('dateFormat:tz').returns('Europe/Riga');
|
||||
const output = paramWriter.write({});
|
||||
expect(output.params).to.have.property('time_zone', 'Europe/Riga');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.get.restore();
|
||||
config.isDefault.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extended_bounds', function () {
|
||||
it('should write a long value if a moment passed in', function () {
|
||||
const then = moment(0);
|
||||
|
|
|
@ -128,13 +128,6 @@ export const dateHistogramBucketAgg = new BucketAggType({
|
|||
output.bucketInterval = interval;
|
||||
output.params.interval = interval.expression;
|
||||
|
||||
const isDefaultTimezone = config.isDefault('dateFormat:tz');
|
||||
if (isDefaultTimezone) {
|
||||
output.params.time_zone = detectedTimezone || tzOffset;
|
||||
} else {
|
||||
output.params.time_zone = config.get('dateFormat:tz');
|
||||
}
|
||||
|
||||
const scaleMetrics = interval.scaled && interval.scale < 1;
|
||||
if (scaleMetrics && aggs) {
|
||||
const all = _.every(aggs.bySchemaGroup.metrics, function (agg) {
|
||||
|
@ -147,13 +140,18 @@ export const dateHistogramBucketAgg = new BucketAggType({
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'time_zone',
|
||||
default: () => {
|
||||
const isDefaultTimezone = config.isDefault('dateFormat:tz');
|
||||
return isDefaultTimezone ? detectedTimezone || tzOffset : config.get('dateFormat:tz');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'customInterval',
|
||||
default: '2h',
|
||||
write: _.noop
|
||||
},
|
||||
|
||||
{
|
||||
name: 'format'
|
||||
},
|
||||
|
|
|
@ -59,7 +59,15 @@ export const histogramBucketAgg = new BucketAggType({
|
|||
name: 'field',
|
||||
filterFieldTypes: 'number'
|
||||
},
|
||||
|
||||
{
|
||||
/*
|
||||
* This parameter can be set if you want the auto scaled interval to always
|
||||
* be a multiple of a specific base.
|
||||
*/
|
||||
name: 'intervalBase',
|
||||
default: null,
|
||||
write: () => {},
|
||||
},
|
||||
{
|
||||
name: 'interval',
|
||||
editor: intervalTemplate,
|
||||
|
@ -111,6 +119,17 @@ export const histogramBucketAgg = new BucketAggType({
|
|||
}
|
||||
}
|
||||
|
||||
const base = aggConfig.params.intervalBase;
|
||||
if (base) {
|
||||
if (interval < base) {
|
||||
// In case the specified interval is below the base, just increase it to it's base
|
||||
interval = base;
|
||||
} else if (interval % base !== 0) {
|
||||
// In case the interval is not a multiple of the base round it to the next base
|
||||
interval = Math.round(interval / base) * base;
|
||||
}
|
||||
}
|
||||
|
||||
output.params.interval = interval;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -6,6 +6,15 @@
|
|||
position="'right'"
|
||||
content="'Interval will be automatically scaled in the event that the provided value creates more buckets than specified by Advanced Setting\'s histogram:maxBars'"
|
||||
></icon-tip>
|
||||
|
||||
<icon-tip
|
||||
ng-if="editorConfig.interval.warning"
|
||||
position="'right'"
|
||||
content="editorConfig.interval.warning"
|
||||
type="'alert'"
|
||||
color="'warning'"
|
||||
style="float: right"
|
||||
></icon-tip>
|
||||
</label>
|
||||
<input
|
||||
id="visEditorInterval{{agg.id}}"
|
||||
|
@ -14,7 +23,8 @@
|
|||
type="number"
|
||||
class="form-control"
|
||||
name="interval"
|
||||
min="0"
|
||||
min="{{editorConfig.interval.base || 0}}"
|
||||
step="{{editorConfig.interval.base}}"
|
||||
input-number
|
||||
>
|
||||
</div>
|
||||
|
|
|
@ -43,6 +43,6 @@ app.directive('icon', reactDirective => reactDirective(EuiIcon));
|
|||
|
||||
app.directive('colorPicker', reactDirective => reactDirective(EuiColorPicker));
|
||||
|
||||
app.directive('iconTip', reactDirective => reactDirective(EuiIconTip, ['content', 'type', 'position', 'title']));
|
||||
app.directive('iconTip', reactDirective => reactDirective(EuiIconTip, ['content', 'type', 'position', 'title', 'color']));
|
||||
|
||||
app.directive('callOut', reactDirective => reactDirective(EuiCallOut, ['title', 'color', 'size', 'iconType', 'children']));
|
||||
|
|
66
src/ui/public/utils/math.test.ts
Normal file
66
src/ui/public/utils/math.test.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { greatestCommonDivisor, leastCommonMultiple } from './math';
|
||||
|
||||
describe('math utils', () => {
|
||||
describe('greatestCommonDivisor', () => {
|
||||
const tests: Array<[number, number, number]> = [
|
||||
[3, 5, 1],
|
||||
[30, 36, 6],
|
||||
[5, 1, 1],
|
||||
[9, 9, 9],
|
||||
[40, 20, 20],
|
||||
[3, 0, 3],
|
||||
[0, 5, 5],
|
||||
[0, 0, 0],
|
||||
[-9, -3, 3],
|
||||
[-24, 8, 8],
|
||||
[22, -7, 1],
|
||||
];
|
||||
|
||||
tests.map(([a, b, expected]) => {
|
||||
it(`should return ${expected} for greatestCommonDivisor(${a}, ${b})`, () => {
|
||||
expect(greatestCommonDivisor(a, b)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('leastCommonMultiple', () => {
|
||||
const tests: Array<[number, number, number]> = [
|
||||
[3, 5, 15],
|
||||
[1, 1, 1],
|
||||
[5, 6, 30],
|
||||
[3, 9, 9],
|
||||
[8, 20, 40],
|
||||
[5, 5, 5],
|
||||
[0, 5, 0],
|
||||
[-4, -5, 20],
|
||||
[-2, -3, 6],
|
||||
[-8, 2, 8],
|
||||
[-8, 5, 40],
|
||||
];
|
||||
|
||||
tests.map(([a, b, expected]) => {
|
||||
it(`should return ${expected} for leastCommonMultiple(${a}, ${b})`, () => {
|
||||
expect(leastCommonMultiple(a, b)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
42
src/ui/public/utils/math.ts
Normal file
42
src/ui/public/utils/math.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculates the greates common divisor of two numbers. This will be the
|
||||
* greatest positive integer number, that both input values share as a divisor.
|
||||
*
|
||||
* This method does not properly work for fractional (non integer) numbers. If you
|
||||
* pass in fractional numbers there usually will be an output, but that's not necessarily
|
||||
* the greatest common divisor of those two numbers.
|
||||
*/
|
||||
export function greatestCommonDivisor(a: number, b: number): number {
|
||||
return a === 0 ? Math.abs(b) : greatestCommonDivisor(b % a, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the least common multiple of two numbers. The least common multiple
|
||||
* is the smallest positive integer number, that is divisible by both input parameters.
|
||||
*
|
||||
* Since this calculation suffers from rounding issues in decimal values, this method
|
||||
* won't work for passing in fractional (non integer) numbers. It will return a value,
|
||||
* but that value won't necessarily be the mathematical correct least common multiple.
|
||||
*/
|
||||
export function leastCommonMultiple(a: number, b: number): number {
|
||||
return Math.abs((a * b) / greatestCommonDivisor(a, b));
|
||||
}
|
142
src/ui/public/vis/editors/config/editor_config_providers.test.ts
Normal file
142
src/ui/public/vis/editors/config/editor_config_providers.test.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* 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 { EditorConfigProviderRegistry } from './editor_config_providers';
|
||||
import { EditorParamConfig, FixedParam, NumericIntervalParam } from './types';
|
||||
|
||||
describe('EditorConfigProvider', () => {
|
||||
let registry: EditorConfigProviderRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new EditorConfigProviderRegistry();
|
||||
});
|
||||
|
||||
it('should call registered providers with given parameters', () => {
|
||||
const provider = jest.fn(() => ({}));
|
||||
registry.register(provider);
|
||||
expect(provider).not.toHaveBeenCalled();
|
||||
const aggType = {};
|
||||
const indexPattern = {};
|
||||
const aggConfig = {};
|
||||
registry.getConfigForAgg(aggType, indexPattern, aggConfig);
|
||||
expect(provider).toHaveBeenCalledWith(aggType, indexPattern, aggConfig);
|
||||
});
|
||||
|
||||
it('should call all registered providers with given parameters', () => {
|
||||
const provider = jest.fn(() => ({}));
|
||||
const provider2 = jest.fn(() => ({}));
|
||||
registry.register(provider);
|
||||
registry.register(provider2);
|
||||
expect(provider).not.toHaveBeenCalled();
|
||||
expect(provider2).not.toHaveBeenCalled();
|
||||
const aggType = {};
|
||||
const indexPattern = {};
|
||||
const aggConfig = {};
|
||||
registry.getConfigForAgg(aggType, indexPattern, aggConfig);
|
||||
expect(provider).toHaveBeenCalledWith(aggType, indexPattern, aggConfig);
|
||||
expect(provider2).toHaveBeenCalledWith(aggType, indexPattern, aggConfig);
|
||||
});
|
||||
|
||||
describe('merging configs', () => {
|
||||
function singleConfig(paramConfig: EditorParamConfig) {
|
||||
return () => ({ singleParam: paramConfig });
|
||||
}
|
||||
|
||||
function getOutputConfig(reg: EditorConfigProviderRegistry) {
|
||||
return reg.getConfigForAgg({}, {}, {}).singleParam;
|
||||
}
|
||||
|
||||
it('should have hidden true if at least one config was hidden true', () => {
|
||||
registry.register(singleConfig({ hidden: false }));
|
||||
registry.register(singleConfig({ hidden: true }));
|
||||
registry.register(singleConfig({ hidden: false }));
|
||||
const config = getOutputConfig(registry);
|
||||
expect(config.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('should merge the same fixed values', () => {
|
||||
registry.register(singleConfig({ fixedValue: 'foo' }));
|
||||
registry.register(singleConfig({ fixedValue: 'foo' }));
|
||||
const config = getOutputConfig(registry) as FixedParam;
|
||||
expect(config).toHaveProperty('fixedValue');
|
||||
expect(config.fixedValue).toBe('foo');
|
||||
});
|
||||
|
||||
it('should throw having different fixed values', () => {
|
||||
registry.register(singleConfig({ fixedValue: 'foo' }));
|
||||
registry.register(singleConfig({ fixedValue: 'bar' }));
|
||||
expect(() => {
|
||||
getOutputConfig(registry);
|
||||
}).toThrowError();
|
||||
});
|
||||
|
||||
it('should allow same base values', () => {
|
||||
registry.register(singleConfig({ base: 5 }));
|
||||
registry.register(singleConfig({ base: 5 }));
|
||||
const config = getOutputConfig(registry) as NumericIntervalParam;
|
||||
expect(config).toHaveProperty('base');
|
||||
expect(config.base).toBe(5);
|
||||
});
|
||||
|
||||
it('should merge multiple base values, using least common multiple', () => {
|
||||
registry.register(singleConfig({ base: 2 }));
|
||||
registry.register(singleConfig({ base: 5 }));
|
||||
registry.register(singleConfig({ base: 8 }));
|
||||
const config = getOutputConfig(registry) as NumericIntervalParam;
|
||||
expect(config).toHaveProperty('base');
|
||||
expect(config.base).toBe(40);
|
||||
});
|
||||
|
||||
it('should throw on combining fixedValue with base', () => {
|
||||
registry.register(singleConfig({ fixedValue: 'foo' }));
|
||||
registry.register(singleConfig({ base: 5 }));
|
||||
expect(() => {
|
||||
getOutputConfig(registry);
|
||||
}).toThrowError();
|
||||
});
|
||||
|
||||
it('should merge hidden together with fixedValue', () => {
|
||||
registry.register(singleConfig({ fixedValue: 'foo', hidden: true }));
|
||||
registry.register(singleConfig({ fixedValue: 'foo', hidden: false }));
|
||||
const config = getOutputConfig(registry) as FixedParam;
|
||||
expect(config).toHaveProperty('fixedValue');
|
||||
expect(config).toHaveProperty('hidden');
|
||||
expect(config.fixedValue).toBe('foo');
|
||||
expect(config.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('should merge hidden together with base', () => {
|
||||
registry.register(singleConfig({ base: 2, hidden: false }));
|
||||
registry.register(singleConfig({ base: 13, hidden: false }));
|
||||
const config = getOutputConfig(registry) as NumericIntervalParam;
|
||||
expect(config).toHaveProperty('base');
|
||||
expect(config).toHaveProperty('hidden');
|
||||
expect(config.base).toBe(26);
|
||||
expect(config.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('should merge warnings together into one string', () => {
|
||||
registry.register(singleConfig({ warning: 'Warning' }));
|
||||
registry.register(singleConfig({ warning: 'Another warning' }));
|
||||
const config = getOutputConfig(registry);
|
||||
expect(config).toHaveProperty('warning');
|
||||
expect(config.warning).toBe('Warning\n\nAnother warning');
|
||||
});
|
||||
});
|
||||
});
|
141
src/ui/public/vis/editors/config/editor_config_providers.ts
Normal file
141
src/ui/public/vis/editors/config/editor_config_providers.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* 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 { AggConfig } from '../..';
|
||||
import { AggType } from '../../../agg_types';
|
||||
import { IndexPattern } from '../../../index_patterns';
|
||||
import { leastCommonMultiple } from '../../../utils/math';
|
||||
import { EditorConfig, EditorParamConfig, FixedParam, NumericIntervalParam } from './types';
|
||||
|
||||
type EditorConfigProvider = (
|
||||
aggType: AggType,
|
||||
indexPattern: IndexPattern,
|
||||
aggConfig: AggConfig
|
||||
) => EditorConfig;
|
||||
|
||||
class EditorConfigProviderRegistry {
|
||||
private providers: Set<EditorConfigProvider> = new Set();
|
||||
|
||||
public register(configProvider: EditorConfigProvider): void {
|
||||
this.providers.add(configProvider);
|
||||
}
|
||||
|
||||
public getConfigForAgg(
|
||||
aggType: AggType,
|
||||
indexPattern: IndexPattern,
|
||||
aggConfig: AggConfig
|
||||
): EditorConfig {
|
||||
const configs = Array.from(this.providers).map(provider =>
|
||||
provider(aggType, indexPattern, aggConfig)
|
||||
);
|
||||
return this.mergeConfigs(configs);
|
||||
}
|
||||
|
||||
private isBaseParam(config: EditorParamConfig): config is NumericIntervalParam {
|
||||
return config.hasOwnProperty('base');
|
||||
}
|
||||
|
||||
private isFixedParam(config: EditorParamConfig): config is FixedParam {
|
||||
return config.hasOwnProperty('fixedValue');
|
||||
}
|
||||
|
||||
private mergeHidden(current: EditorParamConfig, merged: EditorParamConfig): boolean {
|
||||
return Boolean(current.hidden || merged.hidden);
|
||||
}
|
||||
|
||||
private mergeWarning(current: EditorParamConfig, merged: EditorParamConfig): string | undefined {
|
||||
if (!current.warning) {
|
||||
return merged.warning;
|
||||
}
|
||||
|
||||
return merged.warning ? `${merged.warning}\n\n${current.warning}` : current.warning;
|
||||
}
|
||||
|
||||
private mergeFixedAndBase(
|
||||
current: EditorParamConfig,
|
||||
merged: EditorParamConfig,
|
||||
paramName: string
|
||||
): { fixedValue?: any; base?: number } {
|
||||
if (
|
||||
this.isFixedParam(current) &&
|
||||
this.isFixedParam(merged) &&
|
||||
current.fixedValue !== merged.fixedValue
|
||||
) {
|
||||
// In case multiple configurations provided a fixedValue, these must all be the same.
|
||||
// If not we'll throw an error.
|
||||
throw new Error(`Two EditorConfigProviders provided different fixed values for field ${paramName}:
|
||||
${merged.fixedValue} !== ${current.fixedValue}`);
|
||||
}
|
||||
|
||||
if (
|
||||
(this.isFixedParam(current) && this.isBaseParam(merged)) ||
|
||||
(this.isBaseParam(current) && this.isFixedParam(merged))
|
||||
) {
|
||||
// In case one config tries to set a fixed value and another setting a base value,
|
||||
// we'll throw an error. This could be solved more elegantly, by allowing fixedValues
|
||||
// that are the multiple of the specific base value, but since there is no use-case for that
|
||||
// right now, this isn't implemented.
|
||||
throw new Error(`Tried to provide a fixedValue and a base for param ${paramName}.`);
|
||||
}
|
||||
|
||||
if (this.isBaseParam(current) && this.isBaseParam(merged)) {
|
||||
// In case both had where interval values, just use the least common multiple between both interval
|
||||
return {
|
||||
base: leastCommonMultiple(current.base, merged.base),
|
||||
};
|
||||
}
|
||||
|
||||
// In this case we haven't had a fixed value of base for that param yet, we use the one specified
|
||||
// in the current config
|
||||
if (this.isFixedParam(current)) {
|
||||
return {
|
||||
fixedValue: current.fixedValue,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isBaseParam(current)) {
|
||||
return {
|
||||
base: current.base,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private mergeConfigs(configs: EditorConfig[]): EditorConfig {
|
||||
return configs.reduce((output, conf) => {
|
||||
Object.entries(conf).forEach(([paramName, paramConfig]) => {
|
||||
if (!output[paramName]) {
|
||||
output[paramName] = { ...paramConfig };
|
||||
} else {
|
||||
output[paramName] = {
|
||||
hidden: this.mergeHidden(paramConfig, output[paramName]),
|
||||
warning: this.mergeWarning(paramConfig, output[paramName]),
|
||||
...this.mergeFixedAndBase(paramConfig, output[paramName], paramName),
|
||||
};
|
||||
}
|
||||
});
|
||||
return output;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
|
||||
const editorConfigProviders = new EditorConfigProviderRegistry();
|
||||
|
||||
export { editorConfigProviders, EditorConfigProviderRegistry };
|
48
src/ui/public/vis/editors/config/types.ts
Normal file
48
src/ui/public/vis/editors/config/types.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A hidden parameter can be hidden from the UI completely.
|
||||
*/
|
||||
interface Param {
|
||||
hidden?: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed parameter has a fixed value for a specific field.
|
||||
* It can optionally also be hidden.
|
||||
*/
|
||||
export type FixedParam = Partial<Param> & {
|
||||
fixedValue: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* Numeric interval parameters must always be set in the editor to a multiple of
|
||||
* the specified base. It can optionally also be hidden.
|
||||
*/
|
||||
export type NumericIntervalParam = Partial<Param> & {
|
||||
base: number;
|
||||
};
|
||||
|
||||
export type EditorParamConfig = NumericIntervalParam | FixedParam | Param;
|
||||
|
||||
export interface EditorConfig {
|
||||
[paramName: string]: EditorParamConfig;
|
||||
}
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import { has, get } from 'lodash';
|
||||
import aggSelectHtml from './agg_select.html';
|
||||
import advancedToggleHtml from './advanced_toggle.html';
|
||||
import '../../../filters/match_any';
|
||||
|
@ -28,6 +28,7 @@ import { uiModules } from '../../../modules';
|
|||
import { documentationLinks } from '../../../documentation_links/documentation_links';
|
||||
import aggParamsTemplate from './agg_params.html';
|
||||
import { aggTypeFilters } from '../../../agg_types/filter';
|
||||
import { editorConfigProviders } from '../config/editor_config_providers';
|
||||
|
||||
uiModules
|
||||
.get('app/visualize')
|
||||
|
@ -51,6 +52,27 @@ uiModules
|
|||
// there is a possibility that the agg type can be automatically selected (if there is only one)
|
||||
$scope.$watch('agg.type', updateAggParamEditor);
|
||||
|
||||
function updateEditorConfig() {
|
||||
$scope.editorConfig = editorConfigProviders.getConfigForAgg(
|
||||
aggTypes.byType[$scope.groupName],
|
||||
$scope.indexPattern,
|
||||
$scope.agg
|
||||
);
|
||||
|
||||
Object.keys($scope.editorConfig).forEach(param => {
|
||||
const config = $scope.editorConfig[param];
|
||||
// If the parameter has a fixed value in the config, set this value.
|
||||
// Also for all supported configs we should freeze the editor for this param.
|
||||
if (config.hasOwnProperty('fixedValue')) {
|
||||
$scope.agg.params[param] = config.fixedValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$scope.$watchCollection('agg.params', updateEditorConfig);
|
||||
|
||||
updateEditorConfig();
|
||||
|
||||
// this will contain the controls for the schema (rows or columns?), which are unrelated to
|
||||
// controls for the agg, which is why they are first
|
||||
addSchemaEditor();
|
||||
|
@ -77,9 +99,10 @@ uiModules
|
|||
let $aggParamEditorsScope;
|
||||
|
||||
function updateAggParamEditor() {
|
||||
updateEditorConfig();
|
||||
$scope.aggHelpLink = null;
|
||||
if (_.has($scope, 'agg.type.name')) {
|
||||
$scope.aggHelpLink = _.get(documentationLinks, ['aggs', $scope.agg.type.name]);
|
||||
if (has($scope, 'agg.type.name')) {
|
||||
$scope.aggHelpLink = get(documentationLinks, ['aggs', $scope.agg.type.name]);
|
||||
}
|
||||
|
||||
if ($aggParamEditors) {
|
||||
|
@ -106,36 +129,39 @@ uiModules
|
|||
};
|
||||
|
||||
// build collection of agg params html
|
||||
$scope.agg.type.params.forEach(function (param, i) {
|
||||
let aggParam;
|
||||
let fields;
|
||||
if ($scope.agg.schema.hideCustomLabel && param.name === 'customLabel') {
|
||||
return;
|
||||
}
|
||||
// if field param exists, compute allowed fields
|
||||
if (param.name === 'field') {
|
||||
fields = $aggParamEditorsScope.indexedFields;
|
||||
} else if (param.type === 'field') {
|
||||
fields = $aggParamEditorsScope[`${param.name}Options`] = param.getFieldOptions($scope.agg);
|
||||
}
|
||||
|
||||
if (fields) {
|
||||
const hasIndexedFields = fields.length > 0;
|
||||
const isExtraParam = i > 0;
|
||||
if (!hasIndexedFields && isExtraParam) { // don't draw the rest of the options if there are no indexed fields.
|
||||
$scope.agg.type.params
|
||||
// Filter out, i.e. don't render, any parameter that is hidden via the editor config.
|
||||
.filter(param => !get($scope, ['editorConfig', param.name, 'hidden'], false))
|
||||
.forEach(function (param, i) {
|
||||
let aggParam;
|
||||
let fields;
|
||||
if ($scope.agg.schema.hideCustomLabel && param.name === 'customLabel') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// if field param exists, compute allowed fields
|
||||
if (param.name === 'field') {
|
||||
fields = $aggParamEditorsScope.indexedFields;
|
||||
} else if (param.type === 'field') {
|
||||
fields = $aggParamEditorsScope[`${param.name}Options`] = param.getFieldOptions($scope.agg);
|
||||
}
|
||||
|
||||
if (fields) {
|
||||
const hasIndexedFields = fields.length > 0;
|
||||
const isExtraParam = i > 0;
|
||||
if (!hasIndexedFields && isExtraParam) { // don't draw the rest of the options if there are no indexed fields.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let type = 'basic';
|
||||
if (param.advanced) type = 'advanced';
|
||||
let type = 'basic';
|
||||
if (param.advanced) type = 'advanced';
|
||||
|
||||
if (aggParam = getAggParamHTML(param, i)) {
|
||||
aggParamHTML[type].push(aggParam);
|
||||
}
|
||||
if (aggParam = getAggParamHTML(param, i)) {
|
||||
aggParamHTML[type].push(aggParam);
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
// compile the paramEditors html elements
|
||||
let paramEditors = aggParamHTML.basic;
|
||||
|
|
Loading…
Reference in a new issue