Commit 488ba880 authored by Miguel Rincon's avatar Miguel Rincon

Use time ranges in date picker

- URL parameters in the dashboard and embed to accept time ranges
- Updates the date picker so `v-model` is available
- Add fix to embed time ranges
- Return promise instead of done() in new specs
parent 4816319c
......@@ -19,10 +19,10 @@ import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import Icon from '~/vue_shared/components/icon.vue';
import { getTimeRange } from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import GraphGroup from './graph_group.vue';
......@@ -31,11 +31,8 @@ import GroupEmptyState from './group_empty_state.vue';
import DashboardsDropdown from './dashboards_dropdown.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getAddMetricTrackingOptions } from '../utils';
import { datePickerTimeWindows, metricStates } from '../constants';
const defaultTimeRange = getTimeRange();
import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils';
import { defaultTimeRange, timeRanges, metricStates } from '../constants';
export default {
components: {
......@@ -197,10 +194,9 @@ export default {
return {
state: 'gettingStarted',
formIsValid: null,
startDate: getParameterValues('start')[0] || defaultTimeRange.start,
endDate: getParameterValues('end')[0] || defaultTimeRange.end,
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
hasValidDates: true,
datePickerTimeWindows,
timeRanges,
isRearrangingPanels: false,
};
},
......@@ -260,9 +256,11 @@ export default {
if (!this.hasMetrics) {
this.setGettingStartedEmptyState();
} else {
const { start, end } = convertToFixedRange(this.selectedTimeRange);
this.fetchData({
start: this.startDate,
end: this.endDate,
start,
end,
});
}
},
......@@ -287,8 +285,8 @@ export default {
});
},
onDateTimePickerApply(params) {
redirectTo(mergeUrlParams(params, window.location.href));
onDateTimePickerInput(timeRange) {
redirectTo(timeRangeToUrl(timeRange));
},
onDateTimePickerInvalid() {
createFlash(
......@@ -296,8 +294,8 @@ export default {
'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
),
);
this.startDate = defaultTimeRange.start;
this.endDate = defaultTimeRange.end;
// As a fallback, switch to default time range instead
this.selectedTimeRange = defaultTimeRange;
},
generateLink(group, title, yLabel) {
......@@ -447,10 +445,9 @@ export default {
>
<date-time-picker
ref="dateTimePicker"
:start="startDate"
:end="endDate"
:time-windows="datePickerTimeWindows"
@apply="onDateTimePickerApply"
:value="selectedTimeRange"
:options="timeRanges"
@input="onDateTimePickerInput"
@invalid="onDateTimePickerInvalid"
/>
</gl-form-group>
......
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
import { sidebarAnimationDuration } from '../constants';
import { getTimeRange } from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { timeRangeFromUrl, removeTimeRangeParams } from '../utils';
import { sidebarAnimationDuration, defaultTimeRange } from '../constants';
let sidebarMutationObserver;
......@@ -18,10 +18,8 @@ export default {
},
},
data() {
const defaultRange = getTimeRange();
const start = getParameterValues('start', this.dashboardUrl)[0] || defaultRange.start;
const end = getParameterValues('end', this.dashboardUrl)[0] || defaultRange.end;
const timeRange = timeRangeFromUrl(this.dashboardUrl) || defaultTimeRange;
const { start, end } = convertToFixedRange(timeRange);
const params = {
start,
end,
......@@ -81,7 +79,7 @@ export default {
},
setInitialState() {
this.setEndpoints({
dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl),
dashboardEndpoint: removeTimeRangeParams(this.dashboardUrl),
});
this.setShowErrorBanner(false);
},
......
......@@ -83,34 +83,36 @@ export const dateFormats = {
default: 'dd mmm yyyy, h:MMTT',
};
export const datePickerTimeWindows = {
thirtyMinutes: {
export const timeRanges = [
{
label: __('30 minutes'),
seconds: 60 * 30,
duration: { seconds: 60 * 30 },
},
threeHours: {
{
label: __('3 hours'),
seconds: 60 * 60 * 3,
duration: { seconds: 60 * 60 * 3 },
},
eightHours: {
{
label: __('8 hours'),
seconds: 60 * 60 * 8,
duration: { seconds: 60 * 60 * 8 },
default: true,
},
oneDay: {
{
label: __('1 day'),
seconds: 60 * 60 * 24 * 1,
duration: { seconds: 60 * 60 * 24 * 1 },
},
threeDays: {
{
label: __('3 days'),
seconds: 60 * 60 * 24 * 3,
duration: { seconds: 60 * 60 * 24 * 3 },
},
oneWeek: {
{
label: __('1 week'),
seconds: 60 * 60 * 24 * 7 * 1,
duration: { seconds: 60 * 60 * 24 * 7 * 1 },
},
twoWeeks: {
{
label: __('2 weeks'),
seconds: 60 * 60 * 24 * 7 * 2,
duration: { seconds: 60 * 60 * 24 * 7 * 2 },
},
};
];
export const defaultTimeRange = timeRanges.find(tr => tr.default);
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
import Icon from '~/vue_shared/components/icon.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
defaultTimeWindows,
defaultTimeRanges,
defaultTimeRange,
isValidDate,
getTimeRange,
getTimeWindowKey,
stringToISODate,
ISODateToString,
truncateZerosInDateTime,
......@@ -15,7 +17,7 @@ import {
} from './date_time_picker_lib';
const events = {
apply: 'apply',
input: 'input',
invalid: 'invalid',
};
......@@ -29,24 +31,22 @@ export default {
GlDropdownItem,
},
props: {
start: {
type: String,
required: true,
},
end: {
type: String,
required: true,
},
timeWindows: {
value: {
type: Object,
required: false,
default: () => defaultTimeWindows,
default: () => defaultTimeRange,
},
options: {
type: Array,
required: false,
default: () => defaultTimeRanges,
},
},
data() {
return {
startDate: this.start,
endDate: this.end,
timeRange: this.value,
startDate: '',
endDate: '',
};
},
computed: {
......@@ -67,6 +67,7 @@ export default {
set(val) {
// Attempt to set a formatted date if possible
this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
this.timeRange = null;
},
},
endInput: {
......@@ -76,23 +77,48 @@ export default {
set(val) {
// Attempt to set a formatted date if possible
this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
this.timeRange = null;
},
},
timeWindowText() {
const timeWindow = getTimeWindowKey({ start: this.start, end: this.end }, this.timeWindows);
if (timeWindow) {
return this.timeWindows[timeWindow].label;
} else if (isValidDate(this.start) && isValidDate(this.end)) {
try {
const timeRange = findTimeRange(this.value, this.options);
if (timeRange) {
return timeRange.label;
}
const { start, end } = convertToFixedRange(this.value);
if (isValidDate(start) && isValidDate(end)) {
return sprintf(__('%{start} to %{end}'), {
start: this.formatDate(this.start),
end: this.formatDate(this.end),
start: this.formatDate(start),
end: this.formatDate(end),
});
}
} catch {
return __('Invalid date range');
}
return '';
},
},
watch: {
value(newValue) {
const { start, end } = convertToFixedRange(newValue);
this.timeRange = this.value;
this.startDate = start;
this.endDate = end;
},
},
mounted() {
try {
const { start, end } = convertToFixedRange(this.timeRange);
this.startDate = start;
this.endDate = end;
} catch {
// when dates cannot be parsed, emit error.
this.$emit(events.invalid);
}
// Validate on mounted, and trigger an update if needed
if (!this.isValid) {
this.$emit(events.invalid);
......@@ -102,21 +128,22 @@ export default {
formatDate(date) {
return truncateZerosInDateTime(ISODateToString(date));
},
setTimeWindow(key) {
const { start, end } = getTimeRange(key, this.timeWindows);
this.startDate = start;
this.endDate = end;
this.apply();
},
closeDropdown() {
this.$refs.dropdown.hide();
},
apply() {
this.$emit(events.apply, {
isOptionActive(option) {
return isEqualTimeRanges(option, this.timeRange);
},
setQuickRange(option) {
this.timeRange = option;
this.$emit(events.input, this.timeRange);
},
setFixedRange() {
this.timeRange = convertToFixedRange({
start: this.startDate,
end: this.endDate,
});
this.$emit(events.input, this.timeRange);
},
},
};
......@@ -146,7 +173,7 @@ export default {
</div>
<gl-form-group>
<gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
<gl-button variant="success" :disabled="!isValid" @click="apply()">
<gl-button variant="success" :disabled="!isValid" @click="setFixedRange()">
{{ __('Apply') }}
</gl-button>
</gl-form-group>
......@@ -155,19 +182,20 @@ export default {
<template #label>
<span class="gl-pl-5">{{ __('Quick range') }}</span>
</template>
<gl-dropdown-item
v-for="(timeWindow, key) in timeWindows"
:key="key"
:active="timeWindow.label === timeWindowText"
v-for="(option, index) in options"
:key="index"
:active="isOptionActive(option)"
active-class="active"
@click="setTimeWindow(key)"
@click="setQuickRange(option)"
>
<icon
name="mobile-issue-close"
class="align-bottom"
:class="{ invisible: timeWindow.label !== timeWindowText }"
:class="{ invisible: !isOptionActive(option) }"
/>
{{ timeWindow.label }}
{{ option.label }}
</gl-dropdown-item>
</gl-form-group>
</div>
......
import dateformat from 'dateformat';
import { __ } from '~/locale';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
/**
* Valid strings for this regex are
......@@ -9,37 +8,30 @@ import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/;
/**
* A key-value pair of "time windows".
*
* A time window is a representation of period of time that starts
* some time in past until now. Keys are only used for easy reference.
*
* It is represented as user friendly `label` and number of `seconds`
* to be substracted from now.
* Default time ranges for the date picker.
* @see app/assets/javascripts/lib/utils/datetime_range.js
*/
export const defaultTimeWindows = {
thirtyMinutes: {
export const defaultTimeRanges = [
{
duration: { seconds: 60 * 30 },
label: __('30 minutes'),
seconds: 60 * 30,
},
threeHours: {
{
duration: { seconds: 60 * 60 * 3 },
label: __('3 hours'),
seconds: 60 * 60 * 3,
},
eightHours: {
{
duration: { seconds: 60 * 60 * 8 },
label: __('8 hours'),
seconds: 60 * 60 * 8,
default: true,
},
oneDay: {
{
duration: { seconds: 60 * 60 * 24 * 1 },
label: __('1 day'),
seconds: 60 * 60 * 24 * 1,
},
threeDays: {
label: __('3 days'),
seconds: 60 * 60 * 24 * 3,
},
};
];
export const defaultTimeRange = defaultTimeRanges.find(tr => tr.default);
export const dateFormats = {
ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
......@@ -67,46 +59,6 @@ export const isValidDate = dateString => {
}
};
/**
* For a given time window key (e.g. `threeHours`) and key-value pair
* object of time windows.
*
* Returns a date time range with start and end.
*
* @param {String} timeWindowKey - A key in the object of time windows.
* @param {Object} timeWindows - A key-value pair of time windows,
* with a second duration and a label.
* @returns An object with time range, start and end dates, in ISO format.
*/
export const getTimeRange = (timeWindowKey, timeWindows = defaultTimeWindows) => {
let difference;
if (timeWindows[timeWindowKey]) {
difference = timeWindows[timeWindowKey].seconds;
} else {
const [defaultEntry] = Object.entries(timeWindows).filter(
([, timeWindow]) => timeWindow.default,
);
// find default time window
difference = defaultEntry[1].seconds;
}
const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
const start = end - difference;
return {
start: new Date(secondsToMilliseconds(start)).toISOString(),
end: new Date(secondsToMilliseconds(end)).toISOString(),
};
};
export const getTimeWindowKey = ({ start, end }, timeWindows = defaultTimeWindows) =>
Object.entries(timeWindows).reduce((acc, [timeWindowKey, timeWindow]) => {
if (new Date(end) - new Date(start) === secondsToMilliseconds(timeWindow.seconds)) {
return timeWindowKey;
}
return acc;
}, null);
/**
* Convert the input in Time picker component to ISO date.
*
......
---
title: Allow for relative time ranges in metrics dashboard URLs
merge_request: 23765
author:
type: added
......@@ -10428,6 +10428,9 @@ msgstr ""
msgid "Invalid date format. Please use UTC format as YYYY-MM-DD"
msgstr ""
msgid "Invalid date range"
msgstr ""
msgid "Invalid feature"
msgstr ""
......
......@@ -69,9 +69,8 @@ exports[`Dashboard template matches the default snapshot 1`] = `
label-size="sm"
>
<date-time-picker-stub
end="2020-01-01T18:57:47.000Z"
start="2020-01-01T18:27:47.000Z"
timewindows="[object Object]"
options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
value="[object Object]"
/>
</gl-form-group-stub>
......
......@@ -5,13 +5,7 @@ import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
import { propsData } from '../init_utils';
jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockImplementation(param => {
if (param === 'start') return ['2020-01-01T18:27:47.000Z'];
if (param === 'end') return ['2020-01-01T18:57:47.000Z'];
return [];
}),
}));
jest.mock('~/lib/utils/url_utility');
describe('Dashboard template', () => {
let wrapper;
......
import { mount } from '@vue/test-utils';
import createFlash from '~/flash';
import MockAdapter from 'axios-mock-adapter';
import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
import { propsData } from '../init_utils';
import axios from '~/lib/utils/axios_utils';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockReturnValue('<script>alert("XSS")</script>'),
}));
describe('dashboard invalid url parameters', () => {
let store;
let wrapper;
let mock;
const createMountedWrapper = (props = {}, options = {}) => {
wrapper = mount(Dashboard, {
propsData: { ...propsData, ...props },
store,
...options,
});
};
beforeEach(() => {
store = createStore();
mock = new MockAdapter(axios);
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
mock.restore();
});
it('shows an error message if invalid url parameters are passed', done => {
createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
wrapper.vm
.$nextTick()
.then(() => {
expect(createFlash).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
});
import { mount } from '@vue/test-utils';
import { GlDropdownItem } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import statusCodes from '~/lib/utils/http_status';
import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
import { propsData, setupComponentStore } from '../init_utils';
import { metricsDashboardPayload, mockApiEndpoint } from '../mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockImplementation(param => {
if (param === 'start') return ['2019-10-01T18:27:47.000Z'];
if (param === 'end') return ['2019-10-01T18:57:47.000Z'];
return [];
}),
mergeUrlParams: jest.fn().mockReturnValue('#'),
}));
describe('dashboard time window', () => {
let store;
let wrapper;
let mock;
const createComponentWrapperMounted = (props = {}, options = {}) => {
wrapper = mount(Dashboard, {
propsData: { ...propsData, ...props },
store,
...options,
});
};
beforeEach(() => {
store = createStore();
mock = new MockAdapter(axios);
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
mock.restore();
});
it('shows an active quick range option', done => {
mock.onGet(mockApiEndpoint).reply(statusCodes.OK, metricsDashboardPayload);
createComponentWrapperMounted({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
setupComponentStore(wrapper);
wrapper.vm
.$nextTick()
.then(() => {
const timeWindowDropdownItems = wrapper
.find({ ref: 'dateTimePicker' })
.findAll(GlDropdownItem);
const activeItem = timeWindowDropdownItems.wrappers.filter(itemWrapper =>
itemWrapper.find('.active').exists(),
);
expect(activeItem.length).toBe(1);
done();
})
.catch(done.fail);
});
});
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import createFlash from '~/flash';
import { queryToObject, redirectTo, removeParams, mergeUrlParams } from '~/lib/utils/url_utility';
import axios from '~/lib/utils/axios_utils';
import { mockProjectDir } from '../mock_data';
import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
import { propsData } from '../init_utils';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility');
describe('dashboard invalid url parameters', () => {
let store;
let wrapper;
let mock;
const fetchDataMock = jest.fn();
const createMountedWrapper = (props = { hasMetrics: true }, options = {}) => {
wrapper = mount(Dashboard, {
propsData: { ...propsData, ...props },
store,
stubs: ['graph-group', 'panel-type'],
methods: {
fetchData: fetchDataMock,
},
...options,
});
};
const findDateTimePicker = () => wrapper.find({ ref: 'dateTimePicker' });
beforeEach(() => {
store = createStore();
mock = new MockAdapter(axios);
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
mock.restore();
fetchDataMock.mockReset();
queryToObject.mockReset();
});
it('passes default url parameters to the time range picker', () => {
queryToObject.mockReturnValue({});
createMountedWrapper();
return wrapper.vm.$nextTick().then(() => {
expect(findDateTimePicker().props('value')).toMatchObject({
duration: { seconds: 28800 },
});
expect(fetchDataMock).toHaveBeenCalledTimes(1);
expect(fetchDataMock).toHaveBeenCalledWith({
start: expect.any(String),
end: expect.any(String),
});
});
});
it('passes a fixed time range in the URL to the time range picker', () => {
const params = {
start: '2019-01-01T00:00:00.000Z',
end: '2019-01-10T00:00:00.000Z',
};
queryToObject.mockReturnValue(params);
createMountedWrapper();
return wrapper.vm.$nextTick().then(() => {
expect(findDateTimePicker().props('value')).toEqual(params);
expect(fetchDataMock).toHaveBeenCalledTimes(1);
expect(fetchDataMock).toHaveBeenCalledWith(params);
});
});
it('passes a rolling time range in the URL to the time range picker', () => {
queryToObject.mockReturnValue({
duration_seconds: '120',
});
createMountedWrapper();
return wrapper.vm.$nextTick().then(() => {
expect(findDateTimePicker().props('value')).toMatchObject({
duration: { seconds: 60 * 2 },
});
expect(fetchDataMock).toHaveBeenCalledTimes(1);
expect(fetchDataMock).toHaveBeenCalledWith({
start: expect.any(String),
end: expect.any(String),
});
});
});
it('shows an error message and loads a default time range if invalid url parameters are passed', () => {
queryToObject.mockReturnValue({
start: '<script>alert("XSS")</script>',
end: '<script>alert("XSS")</script>',
});
createMountedWrapper();
return wrapper.vm.$nextTick().then(() => {
expect(createFlash).toHaveBeenCalled();
expect(findDateTimePicker().props('value')).toMatchObject({
duration: { seconds: 28800 },
});
expect(fetchDataMock).toHaveBeenCalledTimes(1);
expect(fetchDataMock).toHaveBeenCalledWith({
start: expect.any(String),
end: expect.any(String),
});
});
});
it('redirects to different time range', () => {
const toUrl = `${mockProjectDir}/-/environments/1/metrics`;
removeParams.mockReturnValueOnce(toUrl);
createMountedWrapper();
return wrapper.vm.$nextTick().then(() => {
findDateTimePicker().vm.$emit('input', {
duration: { seconds: 120 },
});
// redirect to plus + new parameters
expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: '120' }, toUrl);
expect(redirectTo).toHaveBeenCalledTimes(1);
});
});
});
......@@ -54,97 +54,6 @@ describe('date time picker lib', () => {
});
});
describe('getTimeWindow', () => {
[
{
args: [
{
start: '2019-10-01T18:27:47.000Z',
end: '2019-10-01T21:27:47.000Z',
},
dateTimePickerLib.defaultTimeWindows,
],
expected: 'threeHours',
},
{
args: [
{
start: '2019-10-01T28:27:47.000Z',
end: '2019-10-01T21:27:47.000Z',
},
dateTimePickerLib.defaultTimeWindows,
],
expected: null,
},
{
args: [
{
start: '',
end: '',
},
dateTimePickerLib.defaultTimeWindows,
],
expected: null,
},
{
args: [
{
start: null,
end: null,
},
dateTimePickerLib.defaultTimeWindows,
],
expected: null,
},
{
args: [{}, dateTimePickerLib.defaultTimeWindows],
expected: null,
},
].forEach(({ args, expected }) => {
it(`returns "${expected}" with args=${JSON.stringify(args)}`, () => {
expect(dateTimePickerLib.getTimeWindowKey(...args)).toEqual(expected);
});
});
});
describe('getTimeRange', () => {
function secondsBetween({ start, end }) {
return (new Date(end) - new Date(start)) / 1000;
}
function minutesBetween(timeRange) {
return secondsBetween(timeRange) / 60;
}
function hoursBetween(timeRange) {
return minutesBetween(timeRange) / 60;
}
it('defaults to an 8 hour (28800s) difference', () => {
const params = dateTimePickerLib.getTimeRange();
expect(hoursBetween(params)).toEqual(8);
});
it('accepts time window as an argument', () => {
const params = dateTimePickerLib.getTimeRange('thirtyMinutes');
expect(minutesBetween(params)).toEqual(30);
});
it('returns a value for every defined time window', () => {
const nonDefaultWindows = Object.entries(dateTimePickerLib.defaultTimeWindows).filter(
([, timeWindow]) => !timeWindow.default,
);
nonDefaultWindows.forEach(timeWindow => {
const params = dateTimePickerLib.getTimeRange(timeWindow[0]);
// Ensure we're not returning the default
expect(hoursBetween(params)).not.toEqual(8);
});
});
});
describe('stringToISODate', () => {
['', 'null', undefined, 'abc'].forEach(input => {
it(`throws error for invalid input like ${input}`, done => {
......
import { mount } from '@vue/test-utils';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { defaultTimeWindows } from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
import {
defaultTimeRanges,
defaultTimeRange,
} from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
const timeWindowsCount = Object.entries(defaultTimeWindows).length;
const start = '2019-10-10T07:00:00.000Z';
const end = '2019-10-13T07:00:00.000Z';
const selectedTimeWindowText = `3 days`;
const optionsCount = defaultTimeRanges.length;
describe('DateTimePicker', () => {
let dateTimePicker;
......@@ -15,19 +15,10 @@ describe('DateTimePicker', () => {
const applyButtonElement = () => dateTimePicker.find('button.btn-success').element;
const findQuickRangeItems = () => dateTimePicker.findAll('.dropdown-item');
const cancelButtonElement = () => dateTimePicker.find('button.btn-secondary').element;
const fillInputAndBlur = (input, val) => {
dateTimePicker.find(input).setValue(val);
return dateTimePicker.vm.$nextTick().then(() => {
dateTimePicker.find(input).trigger('blur');
return dateTimePicker.vm.$nextTick();
});
};
const createComponent = props => {
dateTimePicker = mount(DateTimePicker, {
propsData: {
start,
end,
...props,
},
});
......@@ -40,7 +31,7 @@ describe('DateTimePicker', () => {
it('renders dropdown toggle button with selected text', done => {
createComponent();
dateTimePicker.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe(selectedTimeWindowText);
expect(dropdownToggle().text()).toBe(defaultTimeRange.label);
done();
});
});
......@@ -54,8 +45,10 @@ describe('DateTimePicker', () => {
it('renders inputs with h/m/s truncated if its all 0s', done => {
createComponent({
value: {
start: '2019-10-10T00:00:00.000Z',
end: '2019-10-14T00:10:00.000Z',
},
});
dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10');
......@@ -64,22 +57,21 @@ describe('DateTimePicker', () => {
});
});
it(`renders dropdown with ${timeWindowsCount} (default) items in quick range`, done => {
it(`renders dropdown with ${optionsCount} (default) items in quick range`, done => {
createComponent();
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
expect(findQuickRangeItems().length).toBe(timeWindowsCount);
expect(findQuickRangeItems().length).toBe(optionsCount);
done();
});
});
it(`renders dropdown with correct quick range item selected`, done => {
it('renders dropdown with a default quick range item selected', done => {
createComponent();
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(selectedTimeWindowText);
expect(dateTimePicker.find('.dropdown-item.active svg').isVisible()).toBe(true);
expect(dateTimePicker.find('.dropdown-item.active').exists()).toBe(true);
expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(defaultTimeRange.label);
done();
});
});
......@@ -92,8 +84,21 @@ describe('DateTimePicker', () => {
expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
});
it('displays inline error message if custom time range inputs are invalid', done => {
describe('user input', () => {
const fillInputAndBlur = (input, val) => {
dateTimePicker.find(input).setValue(val);
return dateTimePicker.vm.$nextTick().then(() => {
dateTimePicker.find(input).trigger('blur');
return dateTimePicker.vm.$nextTick();
});
};
beforeEach(done => {
createComponent();
dateTimePicker.vm.$nextTick(done);
});
it('displays inline error message if custom time range inputs are invalid', done => {
fillInputAndBlur('#custom-time-from', '2019-10-01abc')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-10abc'))
.then(() => {
......@@ -104,7 +109,6 @@ describe('DateTimePicker', () => {
});
it('keeps apply button disabled with invalid custom time range inputs', done => {
createComponent();
fillInputAndBlur('#custom-time-from', '2019-10-01abc')
.then(() => fillInputAndBlur('#custom-time-to', '2019-09-19'))
.then(() => {
......@@ -115,7 +119,6 @@ describe('DateTimePicker', () => {
});
it('enables apply button with valid custom time range inputs', done => {
createComponent();
fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.then(() => {
......@@ -126,14 +129,13 @@ describe('DateTimePicker', () => {
});
it('emits dates in an object when apply is clicked', done => {
createComponent();
fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.then(() => {
applyButtonElement().click();
expect(dateTimePicker.emitted().apply).toHaveLength(1);
expect(dateTimePicker.emitted().apply[0]).toEqual([
expect(dateTimePicker.emitted().input).toHaveLength(1);
expect(dateTimePicker.emitted().input[0]).toEqual([
{
end: '2019-10-19T00:00:00Z',
start: '2019-10-01T00:00:00Z',
......@@ -144,8 +146,34 @@ describe('DateTimePicker', () => {
.catch(done.fail);
});
it('unchecks quick range when text is input is clicked', done => {
const findActiveItems = () => findQuickRangeItems().filter(w => w.is('.active'));
expect(findActiveItems().length).toBe(1);
fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => {
expect(findActiveItems().length).toBe(0);
done();
})
.catch(done.fail);
});
it('emits dates in an object when a is clicked', () => {
findQuickRangeItems()
.at(3) // any item
.trigger('click');
expect(dateTimePicker.emitted().input).toHaveLength(1);
expect(dateTimePicker.emitted().input[0][0]).toMatchObject({
duration: {
seconds: expect.any(Number),
},
});
});
it('hides the popover with cancel button', done => {
createComponent();
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
......@@ -157,34 +185,41 @@ describe('DateTimePicker', () => {
});
});
});
});
describe('when using non-default time windows', () => {
const otherTimeWindows = {
oneMinute: {
const MOCK_NOW = Date.UTC(2020, 0, 23, 20);
const otherTimeRanges = [
{
label: '1 minute',
seconds: 60,
duration: { seconds: 60 },
},
twoMinutes: {
{
label: '2 minutes',
seconds: 60 * 2,
duration: { seconds: 60 * 2 },
default: true,
},
fiveMinutes: {
{
label: '5 minutes',
seconds: 60 * 5,
duration: { seconds: 60 * 5 },
},
};
];
beforeEach(() => {
jest.spyOn(Date, 'now').mockImplementation(() => MOCK_NOW);
});
it('renders dropdown with a label in the quick range', done => {
createComponent({
// 2 minutes range
start: '2020-01-21T15:00:00.000Z',
end: '2020-01-21T15:02:00.000Z',
timeWindows: otherTimeWindows,
value: {
duration: { seconds: 60 * 5 },
},
options: otherTimeRanges,
});
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe('2 minutes');
expect(dropdownToggle().text()).toBe('5 minutes');
done();
});
......@@ -192,16 +227,16 @@ describe('DateTimePicker', () => {
it('renders dropdown with quick range items', done => {
createComponent({
// 2 minutes range
start: '2020-01-21T15:00:00.000Z',
end: '2020-01-21T15:02:00.000Z',
timeWindows: otherTimeWindows,
value: {
duration: { seconds: 60 * 2 },
},
options: otherTimeRanges,
});
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
const items = findQuickRangeItems();
expect(items.length).toBe(Object.keys(otherTimeWindows).length);
expect(items.length).toBe(Object.keys(otherTimeRanges).length);
expect(items.at(0).text()).toBe('1 minute');
expect(items.at(0).is('.active')).toBe(false);
......@@ -217,14 +252,13 @@ describe('DateTimePicker', () => {
it('renders dropdown with a label not in the quick range', done => {
createComponent({
// 10 minutes range
start: '2020-01-21T15:00:00.000Z',
end: '2020-01-21T15:10:00.000Z',
timeWindows: otherTimeWindows,
value: {
duration: { seconds: 60 * 4 },
},
});
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe('2020-01-21 15:00:00 to 2020-01-21 15:10:00');
expect(dropdownToggle().text()).toBe('2020-01-23 19:56:00 to 2020-01-23 20:00:00');
done();
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment