diff --git a/packages/kbn-datemath/package.json b/packages/kbn-datemath/package.json
index 5338b65d83f2dc..c469661070816f 100644
--- a/packages/kbn-datemath/package.json
+++ b/packages/kbn-datemath/package.json
@@ -5,8 +5,9 @@
"license": "Apache-2.0",
"private": true,
"main": "target/index.js",
+ "typings": "target/index.d.ts",
"scripts": {
- "build": "babel src --out-dir target",
+ "build": "babel src --out-dir target --copy-files",
"kbn:bootstrap": "yarn build",
"kbn:watch": "yarn build --watch"
},
diff --git a/packages/kbn-datemath/src/index.d.ts b/packages/kbn-datemath/src/index.d.ts
new file mode 100644
index 00000000000000..e3389fb255700f
--- /dev/null
+++ b/packages/kbn-datemath/src/index.d.ts
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+
+declare module '@kbn/datemath' {
+ const dateMath: {
+ parse: any;
+ unitsMap: any;
+ units: string[];
+ unitsAsc: string[];
+ unitsDesc: string[];
+ };
+ export default dateMath;
+}
diff --git a/packages/kbn-datemath/src/index.js b/packages/kbn-datemath/src/index.js
index 17d91a530fdb38..6576a458fe77b6 100644
--- a/packages/kbn-datemath/src/index.js
+++ b/packages/kbn-datemath/src/index.js
@@ -19,9 +19,20 @@
import moment from 'moment';
-const units = ['y', 'M', 'w', 'd', 'h', 'm', 's', 'ms'];
-const unitsDesc = units;
-const unitsAsc = [...unitsDesc].reverse();
+const unitsMap = {
+ ms: { weight: 1, type: 'fixed', base: 1 },
+ s: { weight: 2, type: 'fixed', base: 1000 },
+ m: { weight: 3, type: 'mixed', base: 1000 * 60 },
+ h: { weight: 4, type: 'mixed', base: 1000 * 60 * 60 },
+ d: { weight: 5, type: 'mixed', base: 1000 * 60 * 60 * 24 },
+ w: { weight: 6, type: 'calendar' },
+ M: { weight: 7, type: 'calendar' },
+ // q: { weight: 8, type: 'calendar' }, // TODO: moment duration does not support quarter
+ y: { weight: 9, type: 'calendar' },
+};
+const units = Object.keys(unitsMap).sort((a, b) => unitsMap[b].weight - unitsMap[a].weight);
+const unitsDesc = [...units];
+const unitsAsc = [...units].reverse();
const isDate = d => Object.prototype.toString.call(d) === '[object Date]';
@@ -142,6 +153,7 @@ function parseDateMath(mathString, time, roundUp) {
export default {
parse: parse,
+ unitsMap: Object.freeze(unitsMap),
units: Object.freeze(units),
unitsAsc: Object.freeze(unitsAsc),
unitsDesc: Object.freeze(unitsDesc),
diff --git a/packages/kbn-datemath/tsconfig.json b/packages/kbn-datemath/tsconfig.json
new file mode 100644
index 00000000000000..c23b6635a5c19e
--- /dev/null
+++ b/packages/kbn-datemath/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "declaration": true,
+ "outDir": "./target"
+ },
+ "include": [
+ "./src/**/*.ts"
+ ]
+}
diff --git a/src/ui/public/agg_types/controls/number_interval.html b/src/ui/public/agg_types/controls/number_interval.html
index f0283d614cf163..a281875531d114 100644
--- a/src/ui/public/agg_types/controls/number_interval.html
+++ b/src/ui/public/agg_types/controls/number_interval.html
@@ -6,15 +6,6 @@
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'"
>
-
-
+
+ {{editorConfig.interval.help}}
+
diff --git a/src/ui/public/agg_types/controls/time_interval.html b/src/ui/public/agg_types/controls/time_interval.html
index 1da3e3ddcd39be..4a980f39c727c5 100644
--- a/src/ui/public/agg_types/controls/time_interval.html
+++ b/src/ui/public/agg_types/controls/time_interval.html
@@ -9,6 +9,7 @@
>
+
+ {{editorConfig.customInterval.help}}
+
diff --git a/src/ui/public/utils/parse_es_interval.test.ts b/src/ui/public/utils/parse_es_interval.test.ts
new file mode 100644
index 00000000000000..05b29a5fb69e71
--- /dev/null
+++ b/src/ui/public/utils/parse_es_interval.test.ts
@@ -0,0 +1,54 @@
+/*
+ * 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 { parseEsInterval } from './parse_es_interval';
+
+describe('parseEsInterval', () => {
+ it('should correctly parse an interval containing unit and single value', () => {
+ expect(parseEsInterval('1ms')).toEqual({ value: 1, unit: 'ms', type: 'fixed' });
+ expect(parseEsInterval('1s')).toEqual({ value: 1, unit: 's', type: 'fixed' });
+ expect(parseEsInterval('1m')).toEqual({ value: 1, unit: 'm', type: 'calendar' });
+ expect(parseEsInterval('1h')).toEqual({ value: 1, unit: 'h', type: 'calendar' });
+ expect(parseEsInterval('1d')).toEqual({ value: 1, unit: 'd', type: 'calendar' });
+ expect(parseEsInterval('1w')).toEqual({ value: 1, unit: 'w', type: 'calendar' });
+ expect(parseEsInterval('1M')).toEqual({ value: 1, unit: 'M', type: 'calendar' });
+ expect(parseEsInterval('1y')).toEqual({ value: 1, unit: 'y', type: 'calendar' });
+ });
+
+ it('should correctly parse an interval containing unit and multiple value', () => {
+ expect(parseEsInterval('250ms')).toEqual({ value: 250, unit: 'ms', type: 'fixed' });
+ expect(parseEsInterval('90s')).toEqual({ value: 90, unit: 's', type: 'fixed' });
+ expect(parseEsInterval('60m')).toEqual({ value: 60, unit: 'm', type: 'fixed' });
+ expect(parseEsInterval('12h')).toEqual({ value: 12, unit: 'h', type: 'fixed' });
+ expect(parseEsInterval('7d')).toEqual({ value: 7, unit: 'd', type: 'fixed' });
+ });
+
+ it('should throw an error for intervals containing calendar unit and multiple value', () => {
+ expect(() => parseEsInterval('4w')).toThrowError();
+ expect(() => parseEsInterval('12M')).toThrowError();
+ expect(() => parseEsInterval('10y')).toThrowError();
+ });
+
+ it('should throw an error for invalid interval formats', () => {
+ expect(() => parseEsInterval('1')).toThrowError();
+ expect(() => parseEsInterval('h')).toThrowError();
+ expect(() => parseEsInterval('0m')).toThrowError();
+ expect(() => parseEsInterval('0.5h')).toThrowError();
+ });
+});
diff --git a/src/ui/public/utils/parse_es_interval.ts b/src/ui/public/utils/parse_es_interval.ts
new file mode 100644
index 00000000000000..984f7e278d4fb1
--- /dev/null
+++ b/src/ui/public/utils/parse_es_interval.ts
@@ -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 dateMath from '@kbn/datemath';
+
+const ES_INTERVAL_STRING_REGEX = new RegExp(
+ '^([1-9][0-9]*)\\s*(' + dateMath.units.join('|') + ')$'
+);
+
+/**
+ * Extracts interval properties from an ES interval string. Disallows unrecognized interval formats
+ * and fractional values. Converts some intervals from "calendar" to "fixed" when the number of
+ * units is larger than 1, and throws an error for others.
+ *
+ * Conversion rules:
+ *
+ * | Interval | Single unit type | Multiple units type |
+ * | -------- | ---------------- | ------------------- |
+ * | ms | fixed | fixed |
+ * | s | fixed | fixed |
+ * | m | fixed | fixed |
+ * | h | calendar | fixed |
+ * | d | calendar | fixed |
+ * | w | calendar | N/A - disallowed |
+ * | M | calendar | N/A - disallowed |
+ * | y | calendar | N/A - disallowed |
+ *
+ */
+export function parseEsInterval(interval: string): { value: number; unit: string; type: string } {
+ const matches = String(interval)
+ .trim()
+ .match(ES_INTERVAL_STRING_REGEX);
+
+ if (!matches) {
+ throw Error(`Invalid interval format: ${interval}`);
+ }
+
+ const value = matches && parseFloat(matches[1]);
+ const unit = matches && matches[2];
+ const type = unit && dateMath.unitsMap[unit].type;
+
+ if (type === 'calendar' && value !== 1) {
+ throw Error(`Invalid calendar interval: ${interval}, value must be 1`);
+ }
+
+ return {
+ value,
+ unit,
+ type: (type === 'mixed' && value === 1) || type === 'calendar' ? 'calendar' : 'fixed',
+ };
+}
diff --git a/src/ui/public/validate_date_interval.js b/src/ui/public/validate_date_interval.js
index ab6906c678f0e5..a315d4eaaa0b70 100644
--- a/src/ui/public/validate_date_interval.js
+++ b/src/ui/public/validate_date_interval.js
@@ -19,6 +19,7 @@
import { parseInterval } from './utils/parse_interval';
import { uiModules } from './modules';
+import { leastCommonInterval } from './vis/lib/least_common_interval';
uiModules
.get('kibana')
@@ -27,14 +28,31 @@ uiModules
restrict: 'A',
require: 'ngModel',
link: function ($scope, $el, attrs, ngModelCntrl) {
+ const baseInterval = attrs.validateDateInterval || null;
ngModelCntrl.$parsers.push(check);
ngModelCntrl.$formatters.push(check);
function check(value) {
- ngModelCntrl.$setValidity('dateInterval', parseInterval(value) != null);
+ if(baseInterval) {
+ ngModelCntrl.$setValidity('dateInterval', parseWithBase(value) === true);
+ } else {
+ ngModelCntrl.$setValidity('dateInterval', parseInterval(value) != null);
+ }
return value;
}
+
+ // When base interval is set, check for least common interval and allow
+ // input the value is the same. This means that the input interval is a
+ // multiple of the base interval.
+ function parseWithBase(value) {
+ try {
+ const interval = leastCommonInterval(baseInterval, value);
+ return interval === value.replace(/\s/g, '');
+ } catch(e) {
+ return false;
+ }
+ }
}
};
});
diff --git a/src/ui/public/vis/editors/config/editor_config_providers.test.ts b/src/ui/public/vis/editors/config/editor_config_providers.test.ts
index ab0b8b10718b14..3ba4c78b0abb5c 100644
--- a/src/ui/public/vis/editors/config/editor_config_providers.test.ts
+++ b/src/ui/public/vis/editors/config/editor_config_providers.test.ts
@@ -18,7 +18,7 @@
*/
import { EditorConfigProviderRegistry } from './editor_config_providers';
-import { EditorParamConfig, FixedParam, NumericIntervalParam } from './types';
+import { EditorParamConfig, FixedParam, NumericIntervalParam, TimeIntervalParam } from './types';
describe('EditorConfigProvider', () => {
let registry: EditorConfigProviderRegistry;
@@ -111,6 +111,49 @@ describe('EditorConfigProvider', () => {
}).toThrowError();
});
+ it('should allow same timeBase values', () => {
+ registry.register(singleConfig({ timeBase: '2h', default: '2h' }));
+ registry.register(singleConfig({ timeBase: '2h', default: '2h' }));
+ const config = getOutputConfig(registry) as TimeIntervalParam;
+ expect(config).toHaveProperty('timeBase');
+ expect(config).toHaveProperty('default');
+ expect(config.timeBase).toBe('2h');
+ expect(config.default).toBe('2h');
+ });
+
+ it('should merge multiple compatible timeBase values, using least common interval', () => {
+ registry.register(singleConfig({ timeBase: '2h', default: '2h' }));
+ registry.register(singleConfig({ timeBase: '3h', default: '3h' }));
+ registry.register(singleConfig({ timeBase: '4h', default: '4h' }));
+ const config = getOutputConfig(registry) as TimeIntervalParam;
+ expect(config).toHaveProperty('timeBase');
+ expect(config).toHaveProperty('default');
+ expect(config.timeBase).toBe('12h');
+ expect(config.default).toBe('12h');
+ });
+
+ it('should throw on combining incompatible timeBase values', () => {
+ registry.register(singleConfig({ timeBase: '2h', default: '2h' }));
+ registry.register(singleConfig({ timeBase: '1d', default: '1d' }));
+ expect(() => {
+ getOutputConfig(registry);
+ }).toThrowError();
+ });
+
+ it('should throw on invalid timeBase values', () => {
+ registry.register(singleConfig({ timeBase: '2w', default: '2w' }));
+ expect(() => {
+ getOutputConfig(registry);
+ }).toThrowError();
+ });
+
+ it('should throw if timeBase and default are different', () => {
+ registry.register(singleConfig({ timeBase: '1h', default: '2h' }));
+ 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 }));
@@ -131,12 +174,24 @@ describe('EditorConfigProvider', () => {
expect(config.hidden).toBe(false);
});
- it('should merge warnings together into one string', () => {
- registry.register(singleConfig({ warning: 'Warning' }));
- registry.register(singleConfig({ warning: 'Another warning' }));
+ it('should merge hidden together with timeBase', () => {
+ registry.register(singleConfig({ timeBase: '2h', default: '2h', hidden: false }));
+ registry.register(singleConfig({ timeBase: '4h', default: '4h', hidden: false }));
+ const config = getOutputConfig(registry) as TimeIntervalParam;
+ expect(config).toHaveProperty('timeBase');
+ expect(config).toHaveProperty('default');
+ expect(config).toHaveProperty('hidden');
+ expect(config.timeBase).toBe('4h');
+ expect(config.default).toBe('4h');
+ expect(config.hidden).toBe(false);
+ });
+
+ it('should merge helps together into one string', () => {
+ registry.register(singleConfig({ help: 'Warning' }));
+ registry.register(singleConfig({ help: 'Another help' }));
const config = getOutputConfig(registry);
- expect(config).toHaveProperty('warning');
- expect(config.warning).toBe('Warning\n\nAnother warning');
+ expect(config).toHaveProperty('help');
+ expect(config.help).toBe('Warning\n\nAnother help');
});
});
});
diff --git a/src/ui/public/vis/editors/config/editor_config_providers.ts b/src/ui/public/vis/editors/config/editor_config_providers.ts
index d11144ab0ae613..6175b897f62fea 100644
--- a/src/ui/public/vis/editors/config/editor_config_providers.ts
+++ b/src/ui/public/vis/editors/config/editor_config_providers.ts
@@ -17,10 +17,13 @@
* under the License.
*/
+import { TimeIntervalParam } from 'ui/vis/editors/config/types';
import { AggConfig } from '../..';
import { AggType } from '../../../agg_types';
import { IndexPattern } from '../../../index_patterns';
import { leastCommonMultiple } from '../../../utils/math';
+import { parseEsInterval } from '../../../utils/parse_es_interval';
+import { leastCommonInterval } from '../../lib/least_common_interval';
import { EditorConfig, EditorParamConfig, FixedParam, NumericIntervalParam } from './types';
type EditorConfigProvider = (
@@ -47,6 +50,10 @@ class EditorConfigProviderRegistry {
return this.mergeConfigs(configs);
}
+ private isTimeBaseParam(config: EditorParamConfig): config is TimeIntervalParam {
+ return config.hasOwnProperty('default') && config.hasOwnProperty('timeBase');
+ }
+
private isBaseParam(config: EditorParamConfig): config is NumericIntervalParam {
return config.hasOwnProperty('base');
}
@@ -59,12 +66,12 @@ class EditorConfigProviderRegistry {
return Boolean(current.hidden || merged.hidden);
}
- private mergeWarning(current: EditorParamConfig, merged: EditorParamConfig): string | undefined {
- if (!current.warning) {
- return merged.warning;
+ private mergeHelp(current: EditorParamConfig, merged: EditorParamConfig): string | undefined {
+ if (!current.help) {
+ return merged.help;
}
- return merged.warning ? `${merged.warning}\n\n${current.warning}` : current.warning;
+ return merged.help ? `${merged.help}\n\n${current.help}` : current.help;
}
private mergeFixedAndBase(
@@ -95,7 +102,7 @@ class EditorConfigProviderRegistry {
}
if (this.isBaseParam(current) && this.isBaseParam(merged)) {
- // In case both had where interval values, just use the least common multiple between both interval
+ // In case where both had interval values, just use the least common multiple between both interval
return {
base: leastCommonMultiple(current.base, merged.base),
};
@@ -108,7 +115,6 @@ class EditorConfigProviderRegistry {
fixedValue: current.fixedValue,
};
}
-
if (this.isBaseParam(current)) {
return {
base: current.base,
@@ -118,18 +124,57 @@ class EditorConfigProviderRegistry {
return {};
}
+ private mergeTimeBase(
+ current: TimeIntervalParam,
+ merged: EditorParamConfig,
+ paramName: string
+ ): { timeBase?: string; default?: string } {
+ if (current.default !== current.timeBase) {
+ throw new Error(`Tried to provide differing default and timeBase values for ${paramName}.`);
+ }
+
+ if (this.isTimeBaseParam(current) && this.isTimeBaseParam(merged)) {
+ // In case both had where interval values, just use the least common multiple between both intervals
+ try {
+ const timeBase = leastCommonInterval(current.timeBase, merged.timeBase);
+ return {
+ default: timeBase,
+ timeBase,
+ };
+ } catch (e) {
+ throw e;
+ }
+ }
+
+ if (this.isTimeBaseParam(current)) {
+ try {
+ parseEsInterval(current.timeBase);
+ return {
+ default: current.timeBase,
+ timeBase: current.timeBase,
+ };
+ } catch (e) {
+ throw e;
+ }
+ }
+
+ 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),
- };
+ output[paramName] = {};
}
+
+ output[paramName] = {
+ hidden: this.mergeHidden(paramConfig, output[paramName]),
+ help: this.mergeHelp(paramConfig, output[paramName]),
+ ...(this.isTimeBaseParam(paramConfig)
+ ? this.mergeTimeBase(paramConfig, output[paramName], paramName)
+ : this.mergeFixedAndBase(paramConfig, output[paramName], paramName)),
+ };
});
return output;
}, {});
diff --git a/src/ui/public/vis/editors/config/types.ts b/src/ui/public/vis/editors/config/types.ts
index e8c49232c878f8..61c0ced3cd5198 100644
--- a/src/ui/public/vis/editors/config/types.ts
+++ b/src/ui/public/vis/editors/config/types.ts
@@ -22,7 +22,7 @@
*/
interface Param {
hidden?: boolean;
- warning?: string;
+ help?: string;
}
/**
@@ -41,7 +41,16 @@ export type NumericIntervalParam = Partial & {
base: number;
};
-export type EditorParamConfig = NumericIntervalParam | FixedParam | Param;
+/**
+ * Time interval parameters must always be set in the editor to a multiple of
+ * the specified base. It can optionally also be hidden.
+ */
+export type TimeIntervalParam = Partial & {
+ default: string;
+ timeBase: string;
+};
+
+export type EditorParamConfig = NumericIntervalParam | TimeIntervalParam | FixedParam | Param;
export interface EditorConfig {
[paramName: string]: EditorParamConfig;
diff --git a/src/ui/public/vis/editors/default/agg_params.js b/src/ui/public/vis/editors/default/agg_params.js
index d89e7e3944818d..da9a4b074eeeff 100644
--- a/src/ui/public/vis/editors/default/agg_params.js
+++ b/src/ui/public/vis/editors/default/agg_params.js
@@ -50,9 +50,12 @@ uiModules
// We set up this watch prior to adding the controls below, because when the controls are added,
// there is a possibility that the agg type can be automatically selected (if there is only one)
- $scope.$watch('agg.type', updateAggParamEditor);
+ $scope.$watch('agg.type', () => {
+ updateAggParamEditor();
+ updateEditorConfig('default');
+ });
- function updateEditorConfig() {
+ function updateEditorConfig(property = 'fixedValue') {
$scope.editorConfig = editorConfigProviders.getConfigForAgg(
aggTypes.byType[$scope.groupName],
$scope.indexPattern,
@@ -61,17 +64,21 @@ uiModules
Object.keys($scope.editorConfig).forEach(param => {
const config = $scope.editorConfig[param];
+ const paramOptions = $scope.agg.type.params.find((paramOption) => paramOption.name === 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;
+ if (config.hasOwnProperty(property)) {
+ if(paramOptions && paramOptions.deserialize) {
+ $scope.agg.params[param] = paramOptions.deserialize(config[property]);
+ } else {
+ $scope.agg.params[param] = config[property];
+ }
}
});
}
- $scope.$watchCollection('agg.params', updateEditorConfig);
-
updateEditorConfig();
+ $scope.$watchCollection('agg.params', 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
diff --git a/src/ui/public/vis/lib/least_common_interval.test.ts b/src/ui/public/vis/lib/least_common_interval.test.ts
new file mode 100644
index 00000000000000..f19b5c51f68b33
--- /dev/null
+++ b/src/ui/public/vis/lib/least_common_interval.test.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 { leastCommonInterval } from './least_common_interval';
+
+describe('leastCommonInterval', () => {
+ it('should correctly return lowest common interval for fixed units', () => {
+ expect(leastCommonInterval('1ms', '1s')).toBe('1s');
+ expect(leastCommonInterval('500ms', '1s')).toBe('1s');
+ expect(leastCommonInterval('1000ms', '1s')).toBe('1s');
+ expect(leastCommonInterval('1500ms', '1s')).toBe('3s');
+ expect(leastCommonInterval('1234ms', '1s')).toBe('617s');
+ expect(leastCommonInterval('1s', '2m')).toBe('2m');
+ expect(leastCommonInterval('300s', '2m')).toBe('10m');
+ expect(leastCommonInterval('1234ms', '7m')).toBe('4319m');
+ expect(leastCommonInterval('45m', '2h')).toBe('6h');
+ expect(leastCommonInterval('12h', '4d')).toBe('4d');
+ expect(leastCommonInterval(' 20 h', '7d')).toBe('35d');
+ });
+
+ it('should correctly return lowest common interval for calendar units', () => {
+ expect(leastCommonInterval('1m', '1h')).toBe('1h');
+ expect(leastCommonInterval('1h', '1d')).toBe('1d');
+ expect(leastCommonInterval('1d', '1w')).toBe('1w');
+ expect(leastCommonInterval('1w', '1M')).toBe('1M');
+ expect(leastCommonInterval('1M', '1y')).toBe('1y');
+ expect(leastCommonInterval('1M', '1m')).toBe('1M');
+ expect(leastCommonInterval('1y', '1w')).toBe('1y');
+ });
+
+ it('should throw an error for intervals of different types', () => {
+ expect(() => {
+ leastCommonInterval('60 s', '1m');
+ }).toThrowError();
+ expect(() => {
+ leastCommonInterval('1d', '7d');
+ }).toThrowError();
+ expect(() => {
+ leastCommonInterval('1h', '3d');
+ }).toThrowError();
+ expect(() => {
+ leastCommonInterval('7d', '1w');
+ }).toThrowError();
+ expect(() => {
+ leastCommonInterval('1M', '1000ms');
+ }).toThrowError();
+ });
+
+ it('should throw an error for invalid intervals', () => {
+ expect(() => {
+ leastCommonInterval('foo', 'bar');
+ }).toThrowError();
+ expect(() => {
+ leastCommonInterval('0h', '1h');
+ }).toThrowError();
+ expect(() => {
+ leastCommonInterval('0.5h', '1h');
+ }).toThrowError();
+ expect(() => {
+ leastCommonInterval('5w', '1h');
+ }).toThrowError();
+ expect(() => {
+ leastCommonInterval('2M', '4w');
+ }).toThrowError();
+ });
+});
diff --git a/src/ui/public/vis/lib/least_common_interval.ts b/src/ui/public/vis/lib/least_common_interval.ts
new file mode 100644
index 00000000000000..bbe3e6d4ee7c5a
--- /dev/null
+++ b/src/ui/public/vis/lib/least_common_interval.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 dateMath from '@kbn/datemath';
+import { leastCommonMultiple } from '../../utils/math';
+import { parseEsInterval } from '../../utils/parse_es_interval';
+
+/**
+ * Finds the lowest common interval between two given ES date histogram intervals
+ * in the format of (value)(unit)
+ *
+ * - `ms, s` units are fixed-length intervals
+ * - `m, h, d` units are fixed-length intervals when value > 1 (i.e. 2m, 24h, 7d),
+ * but calendar interval when value === 1
+ * - `w, M, q, y` units are calendar intervals and do not support multiple, aka
+ * value must === 1
+ *
+ * @returns {string}
+ */
+export function leastCommonInterval(a: string, b: string): string {
+ const { unitsMap, unitsDesc } = dateMath;
+ const aInt = parseEsInterval(a);
+ const bInt = parseEsInterval(b);
+
+ if (a === b) {
+ return a;
+ }
+
+ const aUnit = unitsMap[aInt.unit];
+ const bUnit = unitsMap[bInt.unit];
+
+ // If intervals aren't the same type, throw error
+ if (aInt.type !== bInt.type) {
+ throw Error(`Incompatible intervals: ${a} (${aInt.type}), ${b} (${bInt.type})`);
+ }
+
+ // If intervals are calendar units, pick the larger one (calendar value is always 1)
+ if (aInt.type === 'calendar') {
+ return aUnit.weight > bUnit.weight ? `${aInt.value}${aInt.unit}` : `${bInt.value}${bInt.unit}`;
+ }
+
+ // Otherwise if intervals are fixed units, find least common multiple in milliseconds
+ const aMs = aInt.value * aUnit.base;
+ const bMs = bInt.value * bUnit.base;
+ const lcmMs = leastCommonMultiple(aMs, bMs);
+
+ // Return original interval string if it matches one of the original milliseconds
+ if (lcmMs === bMs) {
+ return b.replace(/\s/g, '');
+ }
+ if (lcmMs === aMs) {
+ return a.replace(/\s/g, '');
+ }
+
+ // Otherwise find the biggest unit that divides evenly
+ const lcmUnit = unitsDesc.find(unit => unitsMap[unit].base && lcmMs % unitsMap[unit].base === 0);
+
+ // Throw error in case we couldn't divide evenly, theoretically we never get here as everything is
+ // divisible by 1 millisecond
+ if (!lcmUnit) {
+ throw Error(`Unable to find common interval for: ${a}, ${b}`);
+ }
+
+ // Return the interval string
+ return `${lcmMs / unitsMap[lcmUnit].base}${lcmUnit}`;
+}