Commit 7b85c6d0 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch...

Merge branch '29513-continue-improvements-for-time-window-filtering-on-metrics-dashboard' into 'master'

Continue improvements for time window filtering on metrics dashboard

Closes #29513

See merge request gitlab-org/gitlab!17554
parents 1433fc2e 15a6dcb4
...@@ -12,23 +12,19 @@ import { ...@@ -12,23 +12,19 @@ import {
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url'; import invalidUrl from '~/lib/utils/invalid_url';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import DateTimePicker from './date_time_picker/date_time_picker.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorSingleStatChart from './charts/single_stat.vue'; import MonitorSingleStatChart from './charts/single_stat.vue';
import GraphGroup from './graph_group.vue'; import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
import { sidebarAnimationDuration, timeWindows } from '../constants'; import { sidebarAnimationDuration } from '../constants';
import TrackEventDirective from '~/vue_shared/directives/track_event'; import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
import {
getTimeDiff,
getTimeWindow,
downloadCSVOptions,
generateLinkToChartOptions,
} from '../utils';
let sidebarMutationObserver; let sidebarMutationObserver;
...@@ -46,6 +42,7 @@ export default { ...@@ -46,6 +42,7 @@ export default {
GlDropdownItem, GlDropdownItem,
GlFormGroup, GlFormGroup,
GlModal, GlModal,
DateTimePicker,
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
...@@ -171,10 +168,8 @@ export default { ...@@ -171,10 +168,8 @@ export default {
return { return {
state: 'gettingStarted', state: 'gettingStarted',
elWidth: 0, elWidth: 0,
selectedTimeWindow: '',
selectedTimeWindowKey: '',
formIsValid: null, formIsValid: null,
timeWindows: {}, selectedTimeWindow: {},
isRearrangingPanels: false, isRearrangingPanels: false,
}; };
}, },
...@@ -237,11 +232,13 @@ export default { ...@@ -237,11 +232,13 @@ export default {
end, end,
}; };
this.timeWindows = timeWindows; this.selectedTimeWindow = range;
this.selectedTimeWindowKey = getTimeWindow(range);
this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey];
this.fetchData(range); if (!isValidDate(start) || !isValidDate(end)) {
this.showInvalidDateError();
} else {
this.fetchData(range);
}
sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
sidebarMutationObserver.observe(document.querySelector('.layout-page'), { sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
...@@ -298,6 +295,9 @@ export default { ...@@ -298,6 +295,9 @@ export default {
// See https://gitlab.com/gitlab-org/gitlab/issues/27835 // See https://gitlab.com/gitlab-org/gitlab/issues/27835
metrics.splice(graphIndex, 1); metrics.splice(graphIndex, 1);
}, },
showInvalidDateError() {
createFlash(s__('Metrics|Link contains an invalid time window.'));
},
generateLink(group, title, yLabel) { generateLink(group, title, yLabel) {
const dashboard = this.currentDashboard || this.firstDashboard.path; const dashboard = this.currentDashboard || this.firstDashboard.path;
const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null); const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null);
...@@ -320,16 +320,12 @@ export default { ...@@ -320,16 +320,12 @@ export default {
submitCustomMetricsForm() { submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit(); this.$refs.customMetricsForm.submit();
}, },
activeTimeWindow(key) {
return this.timeWindows[key] === this.selectedTimeWindow;
},
setTimeWindowParameter(key) {
const { start, end } = getTimeDiff(key);
return `?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
},
groupHasData(group) { groupHasData(group) {
return this.chartsWithData(group.metrics).length > 0; return this.chartsWithData(group.metrics).length > 0;
}, },
onDateTimePickerApply(timeWindowUrlParams) {
return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
},
downloadCSVOptions, downloadCSVOptions,
generateLinkToChartOptions, generateLinkToChartOptions,
}, },
...@@ -342,14 +338,14 @@ export default { ...@@ -342,14 +338,14 @@ export default {
<template> <template>
<div class="prometheus-graphs"> <div class="prometheus-graphs">
<div class="gl-p-3 pb-0 border-bottom bg-gray-light"> <div class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light">
<div class="row"> <div class="row">
<template v-if="environmentsEndpoint"> <template v-if="environmentsEndpoint">
<gl-form-group <gl-form-group
:label="__('Dashboard')" :label="__('Dashboard')"
label-size="sm" label-size="sm"
label-for="monitor-dashboards-dropdown" label-for="monitor-dashboards-dropdown"
class="col-sm-12 col-md-4 col-lg-2" class="col-sm-12 col-md-6 col-lg-2"
> >
<gl-dropdown <gl-dropdown
id="monitor-dashboards-dropdown" id="monitor-dashboards-dropdown"
...@@ -372,7 +368,7 @@ export default { ...@@ -372,7 +368,7 @@ export default {
:label="s__('Metrics|Environment')" :label="s__('Metrics|Environment')"
label-size="sm" label-size="sm"
label-for="monitor-environments-dropdown" label-for="monitor-environments-dropdown"
class="col-sm-6 col-md-4 col-lg-2" class="col-sm-6 col-md-6 col-lg-2"
> >
<gl-dropdown <gl-dropdown
id="monitor-environments-dropdown" id="monitor-environments-dropdown"
...@@ -397,30 +393,19 @@ export default { ...@@ -397,30 +393,19 @@ export default {
:label="s__('Metrics|Show last')" :label="s__('Metrics|Show last')"
label-size="sm" label-size="sm"
label-for="monitor-time-window-dropdown" label-for="monitor-time-window-dropdown"
class="col-sm-6 col-md-4 col-lg-2" class="col-sm-6 col-md-6 col-lg-4"
> >
<gl-dropdown <date-time-picker
id="monitor-time-window-dropdown" :selected-time-window="selectedTimeWindow"
class="mb-0 d-flex js-time-window-dropdown" @onApply="onDateTimePickerApply"
toggle-class="dropdown-menu-toggle" />
:text="selectedTimeWindow"
>
<gl-dropdown-item
v-for="(value, key) in timeWindows"
:key="key"
:active="activeTimeWindow(key)"
:href="setTimeWindowParameter(key)"
active-class="active"
>{{ value }}</gl-dropdown-item
>
</gl-dropdown>
</gl-form-group> </gl-form-group>
</template> </template>
<gl-form-group <gl-form-group
v-if="addingMetricsAvailable || showRearrangePanelsBtn || externalDashboardUrl.length" v-if="addingMetricsAvailable || showRearrangePanelsBtn || externalDashboardUrl.length"
label-for="prometheus-graphs-dropdown-buttons" label-for="prometheus-graphs-dropdown-buttons"
class="dropdown-buttons col-lg d-lg-flex align-items-end" class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end"
> >
<div id="prometheus-graphs-dropdown-buttons"> <div id="prometheus-graphs-dropdown-buttons">
<gl-button <gl-button
......
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
getTimeDiff,
getTimeWindow,
stringToISODate,
ISODateToString,
truncateZerosInDateTime,
isDateTimePickerInputValid,
} from '~/monitoring/utils';
import { timeWindows } from '~/monitoring/constants';
export default {
components: {
Icon,
DateTimePickerInput,
GlFormGroup,
GlButton,
GlDropdown,
GlDropdownItem,
},
props: {
timeWindows: {
type: Object,
required: false,
default: () => timeWindows,
},
selectedTimeWindow: {
type: Object,
required: false,
default: () => {},
},
},
data() {
return {
selectedTimeWindowText: '',
customTime: {
from: null,
to: null,
},
};
},
computed: {
applyEnabled() {
return Boolean(this.inputState.from && this.inputState.to);
},
inputState() {
const { from, to } = this.customTime;
return {
from: from && isDateTimePickerInputValid(from),
to: to && isDateTimePickerInputValid(to),
};
},
},
mounted() {
const range = getTimeWindow(this.selectedTimeWindow);
if (range) {
this.selectedTimeWindowText = this.timeWindows[range];
} else {
this.customTime = {
from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)),
to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)),
};
this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime);
}
},
methods: {
activeTimeWindow(key) {
return this.timeWindows[key] === this.selectedTimeWindowText;
},
setCustomTimeWindowParameter() {
this.$emit('onApply', {
start: stringToISODate(this.customTime.from),
end: stringToISODate(this.customTime.to),
});
},
setTimeWindowParameter(key) {
const { start, end } = getTimeDiff(key);
this.$emit('onApply', {
start,
end,
});
},
closeDropdown() {
this.$refs.dropdown.hide();
},
},
};
</script>
<template>
<gl-dropdown
ref="dropdown"
:text="selectedTimeWindowText"
menu-class="time-window-dropdown-menu"
class="js-time-window-dropdown"
>
<div class="d-flex justify-content-between time-window-dropdown-menu-container">
<gl-form-group
:label="__('Custom range')"
label-for="custom-from-time"
class="custom-time-range-form-group col-md-7 p-0 m-0"
>
<date-time-picker-input
id="custom-time-from"
v-model="customTime.from"
:label="__('From')"
:state="inputState.from"
/>
<date-time-picker-input
id="custom-time-to"
v-model="customTime.to"
:label="__('To')"
:state="inputState.to"
/>
<gl-form-group>
<gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
<gl-button
variant="success"
:disabled="!applyEnabled"
@click="setCustomTimeWindowParameter"
>{{ __('Apply') }}</gl-button
>
</gl-form-group>
</gl-form-group>
<gl-form-group
:label="__('Quick range')"
label-for="group-id-dropdown"
label-align="center"
class="col-md-4 p-0 m-0"
>
<gl-dropdown-item
v-for="(value, key) in timeWindows"
:key="key"
:active="activeTimeWindow(key)"
active-class="active"
@click="setTimeWindowParameter(key)"
>
<icon
name="mobile-issue-close"
class="align-bottom"
:class="{ invisible: !activeTimeWindow(key) }"
/>
{{ value }}
</gl-dropdown-item>
</gl-form-group>
</div>
</gl-dropdown>
</template>
<script>
import _ from 'underscore';
import { s__, sprintf } from '~/locale';
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
import { dateFormats } from '~/monitoring/constants';
const inputGroupText = {
invalidFeedback: sprintf(s__('Format: %{dateFormat}'), {
dateFormat: dateFormats.dateTimePicker.format,
}),
placeholder: dateFormats.dateTimePicker.format,
};
export default {
components: {
GlFormGroup,
GlFormInput,
},
props: {
state: {
default: null,
required: true,
validator: prop => typeof prop === 'boolean' || prop === null,
},
value: {
default: null,
required: false,
validator: prop => typeof prop === 'string' || prop === null,
},
label: {
type: String,
default: '',
required: true,
},
id: {
type: String,
required: false,
default: () => _.uniqueId('dateTimePicker_'),
},
},
data() {
return {
inputGroupText,
};
},
computed: {
invalidFeedback() {
return this.state ? '' : this.inputGroupText.invalidFeedback;
},
inputState() {
// When the state is valid we want to show no
// green outline. Hence passing null and not true.
if (this.state === true) {
return null;
}
return this.state;
},
},
methods: {
onInputBlur(e) {
this.$emit('input', e.target.value.trim() || null);
},
},
};
</script>
<template>
<gl-form-group :label="label" label-size="sm" :label-for="id" :invalid-feedback="invalidFeedback">
<gl-form-input
:id="id"
:value="value"
:state="inputState"
:placeholder="inputGroupText.placeholder"
@blur="onInputBlur"
/>
</gl-form-group>
</template>
...@@ -3,6 +3,11 @@ import { __ } from '~/locale'; ...@@ -3,6 +3,11 @@ import { __ } from '~/locale';
export const sidebarAnimationDuration = 300; // milliseconds. export const sidebarAnimationDuration = 300; // milliseconds.
export const chartHeight = 300; export const chartHeight = 300;
/**
* Valid strings for this regex are
* 2019-10-01 and 2019-10-01 01:02:03
*/
export 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]))?$/;
export const graphTypes = { export const graphTypes = {
deploymentData: 'scatter', deploymentData: 'scatter',
...@@ -28,6 +33,11 @@ export const timeWindows = { ...@@ -28,6 +33,11 @@ export const timeWindows = {
export const dateFormats = { export const dateFormats = {
timeOfDay: 'h:MM TT', timeOfDay: 'h:MM TT',
default: 'dd mmm yyyy, h:MMTT', default: 'dd mmm yyyy, h:MMTT',
dateTimePicker: {
format: 'yyyy-mm-dd hh:mm:ss',
ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
stringDate: 'yyyy-mm-dd HH:MM:ss',
},
}; };
export const secondsIn = { export const secondsIn = {
......
import { secondsIn, timeWindowsKeyNames } from './constants'; import dateformat from 'dateformat';
import { secondsIn, dateTimePickerRegex, dateFormats } from './constants';
const secondsToMilliseconds = seconds => seconds * 1000; const secondsToMilliseconds = seconds => seconds * 1000;
...@@ -19,7 +20,49 @@ export const getTimeWindow = ({ start, end }) => ...@@ -19,7 +20,49 @@ export const getTimeWindow = ({ start, end }) =>
return timeRange; return timeRange;
} }
return acc; return acc;
}, timeWindowsKeyNames.eightHours); }, null);
export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val);
export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', '');
/**
* The URL params start and end need to be validated
* before passing them down to other components.
*
* @param {string} dateString
*/
export const isValidDate = dateString => {
try {
// dateformat throws error that can be caught.
// This is better than using `new Date()`
if (dateString && dateString.trim()) {
dateformat(dateString, 'isoDateTime');
return true;
}
return false;
} catch {
return false;
}
};
/**
* Convert the input in Time picker component to ISO date.
*
* @param {string} val
* @returns {string}
*/
export const stringToISODate = val =>
dateformat(new Date(val.replace(/-/g, '/')), dateFormats.dateTimePicker.ISODate, true);
/**
* Convert the ISO date received from the URL to string
* for the Time picker component.
*
* @param {Date} date
* @returns {string}
*/
export const ISODateToString = date => dateformat(date, dateFormats.dateTimePicker.stringDate);
/** /**
* This method is used to validate if the graph data format for a chart component * This method is used to validate if the graph data format for a chart component
......
...@@ -46,6 +46,20 @@ ...@@ -46,6 +46,20 @@
} }
} }
.prometheus-graphs-header {
.time-window-dropdown-menu {
padding: $gl-padding $gl-padding 0 $gl-padding-12;
}
.time-window-dropdown-menu-container {
width: 360px;
}
.custom-time-range-form-group > label {
padding-bottom: $gl-padding;
}
}
.prometheus-panel { .prometheus-panel {
margin-top: 20px; margin-top: 20px;
} }
......
---
title: Improve time window filtering on metrics dashboard
merge_request: 17554
author:
type: added
...@@ -250,6 +250,9 @@ msgstr "" ...@@ -250,6 +250,9 @@ msgstr ""
msgid "%{firstLabel} +%{labelCount} more" msgid "%{firstLabel} +%{labelCount} more"
msgstr "" msgstr ""
msgid "%{from} to %{to}"
msgstr ""
msgid "%{gitlab_ci_yml} not found in this commit" msgid "%{gitlab_ci_yml} not found in this commit"
msgstr "" msgstr ""
...@@ -1776,6 +1779,9 @@ msgstr "" ...@@ -1776,6 +1779,9 @@ msgstr ""
msgid "Applied" msgid "Applied"
msgstr "" msgstr ""
msgid "Apply"
msgstr ""
msgid "Apply a label" msgid "Apply a label"
msgstr "" msgstr ""
...@@ -4855,6 +4861,9 @@ msgstr "" ...@@ -4855,6 +4861,9 @@ msgstr ""
msgid "Custom project templates have not been set up for groups that you are a member of. They are enabled from a group’s settings page. Contact your group’s Owner or Maintainer to setup custom project templates." msgid "Custom project templates have not been set up for groups that you are a member of. They are enabled from a group’s settings page. Contact your group’s Owner or Maintainer to setup custom project templates."
msgstr "" msgstr ""
msgid "Custom range"
msgstr ""
msgid "CustomCycleAnalytics|Add a stage" msgid "CustomCycleAnalytics|Add a stage"
msgstr "" msgstr ""
...@@ -7331,6 +7340,9 @@ msgstr "" ...@@ -7331,6 +7340,9 @@ msgstr ""
msgid "Format" msgid "Format"
msgstr "" msgstr ""
msgid "Format: %{dateFormat}"
msgstr ""
msgid "Forward external support email address to" msgid "Forward external support email address to"
msgstr "" msgstr ""
...@@ -10412,6 +10424,9 @@ msgstr "" ...@@ -10412,6 +10424,9 @@ msgstr ""
msgid "Metrics|Legend label (optional)" msgid "Metrics|Legend label (optional)"
msgstr "" msgstr ""
msgid "Metrics|Link contains an invalid time window."
msgstr ""
msgid "Metrics|Max" msgid "Metrics|Max"
msgstr "" msgstr ""
...@@ -13379,6 +13394,9 @@ msgstr "" ...@@ -13379,6 +13394,9 @@ msgstr ""
msgid "Quick actions can be used in the issues description and comment boxes." msgid "Quick actions can be used in the issues description and comment boxes."
msgstr "" msgstr ""
msgid "Quick range"
msgstr ""
msgid "README" msgid "README"
msgstr "" msgstr ""
...@@ -17155,6 +17173,9 @@ msgstr "" ...@@ -17155,6 +17173,9 @@ msgstr ""
msgid "Titles and Filenames" msgid "Titles and Filenames"
msgstr "" msgstr ""
msgid "To"
msgstr ""
msgid "To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration." msgid "To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration."
msgstr "" msgstr ""
......
import { mount } from '@vue/test-utils';
import DateTimePickerInput from '~/monitoring/components/date_time_picker/date_time_picker_input.vue';
const inputLabel = 'This is a label';
const inputValue = 'something';
describe('DateTimePickerInput', () => {
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = mount(DateTimePickerInput, {
propsData: {
state: null,
value: '',
label: '',
...propsData,
},
sync: false,
});
};
afterEach(() => {
wrapper.destroy();
});
it('renders label above the input', () => {
createComponent({
label: inputLabel,
});
expect(wrapper.find('.gl-form-group label').text()).toBe(inputLabel);
});
it('renders the same `ID` for input and `for` for label', () => {
createComponent({ label: inputLabel });
expect(wrapper.find('.gl-form-group label').attributes('for')).toBe(
wrapper.find('input').attributes('id'),
);
});
it('renders valid input in gray color instead of green', () => {
createComponent({
state: true,
});
expect(wrapper.find('input').classes('is-valid')).toBe(false);
});
it('renders invalid input in red color', () => {
createComponent({
state: false,
});
expect(wrapper.find('input').classes('is-invalid')).toBe(true);
});
it('input event is emitted when focus is lost', () => {
createComponent();
jest.spyOn(wrapper.vm, '$emit');
wrapper.find('input').setValue(inputValue);
wrapper.find('input').trigger('blur');
expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', inputValue);
});
});
import { mount } from '@vue/test-utils';
import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_picker.vue';
import { timeWindows } from '~/monitoring/constants';
const timeWindowsCount = Object.keys(timeWindows).length;
const selectedTimeWindow = {
start: '2019-10-10T07:00:00.000Z',
end: '2019-10-13T07:00:00.000Z',
};
const selectedTimeWindowText = `3 days`;
describe('DateTimePicker', () => {
let dateTimePicker;
const dropdownToggle = () => dateTimePicker.find('.dropdown-toggle');
const dropdownMenu = () => dateTimePicker.find('.dropdown-menu');
const applyButtonElement = () => dateTimePicker.find('button[variant="success"]').element;
const cancelButtonElement = () => dateTimePicker.find('button.btn-secondary').element;
const fillInputAndBlur = (input, val) => {
dateTimePicker.find(input).setValue(val);
dateTimePicker.find(input).trigger('blur');
};
const createComponent = props => {
dateTimePicker = mount(DateTimePicker, {
propsData: {
timeWindows,
selectedTimeWindow,
...props,
},
sync: false,
});
};
afterEach(() => {
dateTimePicker.destroy();
});
it('renders dropdown toggle button with selected text', done => {
createComponent();
dateTimePicker.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe(selectedTimeWindowText);
done();
});
});
it('renders dropdown with 2 custom time range inputs', () => {
createComponent();
dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.findAll('input').length).toBe(2);
});
});
it('renders inputs with h/m/s truncated if its all 0s', done => {
createComponent({
selectedTimeWindow: {
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');
expect(dateTimePicker.find('#custom-time-to').element.value).toBe('2019-10-14 00:10:00');
done();
});
});
it(`renders dropdown with ${timeWindowsCount} items in quick range`, done => {
createComponent();
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.findAll('.dropdown-item').length).toBe(timeWindowsCount);
done();
});
});
it(`renders dropdown with correct 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);
done();
});
});
it('renders a disabled apply button on load', () => {
createComponent();
expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
});
it('displays inline error message if custom time range inputs are invalid', done => {
createComponent();
fillInputAndBlur('#custom-time-from', '2019-10-01abc');
fillInputAndBlur('#custom-time-to', '2019-10-10abc');
dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.findAll('.invalid-feedback').length).toBe(2);
done();
});
});
it('keeps apply button disabled with invalid custom time range inputs', done => {
createComponent();
fillInputAndBlur('#custom-time-from', '2019-10-01abc');
fillInputAndBlur('#custom-time-to', '2019-09-19');
dateTimePicker.vm.$nextTick(() => {
expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
done();
});
});
it('enables apply button with valid custom time range inputs', done => {
createComponent();
fillInputAndBlur('#custom-time-from', '2019-10-01');
fillInputAndBlur('#custom-time-to', '2019-10-19');
dateTimePicker.vm.$nextTick(() => {
expect(applyButtonElement().getAttribute('disabled')).toBeNull();
done();
});
});
it('returns an object when apply is clicked', done => {
createComponent();
fillInputAndBlur('#custom-time-from', '2019-10-01');
fillInputAndBlur('#custom-time-to', '2019-10-19');
dateTimePicker.vm.$nextTick(() => {
jest.spyOn(dateTimePicker.vm, '$emit');
applyButtonElement().click();
expect(dateTimePicker.vm.$emit).toHaveBeenCalledWith('onApply', {
end: '2019-10-19T00:00:00Z',
start: '2019-10-01T00:00:00Z',
});
done();
});
});
it('hides the popover with cancel button', done => {
createComponent();
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
cancelButtonElement().click();
dateTimePicker.vm.$nextTick(() => {
expect(dropdownMenu().classes('show')).toBe(false);
done();
});
});
});
});
...@@ -4,7 +4,6 @@ import { GlToast } from '@gitlab/ui'; ...@@ -4,7 +4,6 @@ import { GlToast } from '@gitlab/ui';
import VueDraggable from 'vuedraggable'; import VueDraggable from 'vuedraggable';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Dashboard from '~/monitoring/components/dashboard.vue'; import Dashboard from '~/monitoring/components/dashboard.vue';
import { timeWindows, timeWindowsKeyNames } from '~/monitoring/constants';
import * as types from '~/monitoring/stores/mutation_types'; import * as types from '~/monitoring/stores/mutation_types';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
...@@ -37,6 +36,12 @@ const propsData = { ...@@ -37,6 +36,12 @@ const propsData = {
validateQueryPath: '', validateQueryPath: '',
}; };
const resetSpy = spy => {
if (spy) {
spy.calls.reset();
}
};
export default propsData; export default propsData;
describe('Dashboard', () => { describe('Dashboard', () => {
...@@ -96,10 +101,15 @@ describe('Dashboard', () => { ...@@ -96,10 +101,15 @@ describe('Dashboard', () => {
}); });
describe('requests information to the server', () => { describe('requests information to the server', () => {
let spy;
beforeEach(() => { beforeEach(() => {
mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse);
}); });
afterEach(() => {
resetSpy(spy);
});
it('shows up a loading state', done => { it('shows up a loading state', done => {
component = new DashboardComponent({ component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'), el: document.querySelector('.prometheus-graphs'),
...@@ -272,7 +282,7 @@ describe('Dashboard', () => { ...@@ -272,7 +282,7 @@ describe('Dashboard', () => {
}); });
}); });
it('renders the time window dropdown with a set of options', done => { it('renders the datetimepicker dropdown', done => {
component = new DashboardComponent({ component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'), el: document.querySelector('.prometheus-graphs'),
propsData: { propsData: {
...@@ -282,17 +292,9 @@ describe('Dashboard', () => { ...@@ -282,17 +292,9 @@ describe('Dashboard', () => {
}, },
store, store,
}); });
const numberOfTimeWindows = Object.keys(timeWindows).length;
setTimeout(() => { setTimeout(() => {
const timeWindowDropdown = component.$el.querySelector('.js-time-window-dropdown'); expect(component.$el.querySelector('.js-time-window-dropdown')).not.toBeNull();
const timeWindowDropdownEls = component.$el.querySelectorAll(
'.js-time-window-dropdown .dropdown-item',
);
expect(timeWindowDropdown).not.toBeNull();
expect(timeWindowDropdownEls.length).toEqual(numberOfTimeWindows);
done(); done();
}); });
}); });
...@@ -355,7 +357,7 @@ describe('Dashboard', () => { ...@@ -355,7 +357,7 @@ describe('Dashboard', () => {
}); });
}); });
it('defaults to the eight hours time window for non valid url parameters', done => { it('shows an error message if invalid url parameters are passed', done => {
spyOnDependency(Dashboard, 'getParameterValues').and.returnValue([ spyOnDependency(Dashboard, 'getParameterValues').and.returnValue([
'<script>alert("XSS")</script>', '<script>alert("XSS")</script>',
]); ]);
...@@ -366,9 +368,11 @@ describe('Dashboard', () => { ...@@ -366,9 +368,11 @@ describe('Dashboard', () => {
store, store,
}); });
Vue.nextTick(() => { spy = spyOn(component, 'showInvalidDateError');
expect(component.selectedTimeWindowKey).toEqual(timeWindowsKeyNames.eightHours); component.$mount();
component.$nextTick(() => {
expect(component.showInvalidDateError).toHaveBeenCalled();
done(); done();
}); });
}); });
......
import { getTimeDiff, getTimeWindow, graphDataValidatorForValues } from '~/monitoring/utils'; import {
getTimeDiff,
getTimeWindow,
graphDataValidatorForValues,
isDateTimePickerInputValid,
truncateZerosInDateTime,
stringToISODate,
ISODateToString,
isValidDate,
} from '~/monitoring/utils';
import { timeWindows, timeWindowsKeyNames } from '~/monitoring/constants'; import { timeWindows, timeWindowsKeyNames } from '~/monitoring/constants';
import { graphDataPrometheusQuery, graphDataPrometheusQueryRange } from './mock_data'; import { graphDataPrometheusQuery, graphDataPrometheusQueryRange } from './mock_data';
...@@ -57,7 +66,7 @@ describe('getTimeWindow', () => { ...@@ -57,7 +66,7 @@ describe('getTimeWindow', () => {
end: '2019-10-01T21:27:47.000Z', end: '2019-10-01T21:27:47.000Z',
}, },
], ],
expected: timeWindowsKeyNames.eightHours, expected: null,
}, },
{ {
args: [ args: [
...@@ -66,7 +75,7 @@ describe('getTimeWindow', () => { ...@@ -66,7 +75,7 @@ describe('getTimeWindow', () => {
end: '', end: '',
}, },
], ],
expected: timeWindowsKeyNames.eightHours, expected: null,
}, },
{ {
args: [ args: [
...@@ -75,11 +84,11 @@ describe('getTimeWindow', () => { ...@@ -75,11 +84,11 @@ describe('getTimeWindow', () => {
end: null, end: null,
}, },
], ],
expected: timeWindowsKeyNames.eightHours, expected: null,
}, },
{ {
args: [{}], args: [{}],
expected: timeWindowsKeyNames.eightHours, expected: null,
}, },
].forEach(({ args, expected }) => { ].forEach(({ args, expected }) => {
it(`returns "${expected}" with args=${JSON.stringify(args)}`, () => { it(`returns "${expected}" with args=${JSON.stringify(args)}`, () => {
...@@ -111,3 +120,190 @@ describe('graphDataValidatorForValues', () => { ...@@ -111,3 +120,190 @@ describe('graphDataValidatorForValues', () => {
expect(validGraphData).toBe(true); expect(validGraphData).toBe(true);
}); });
}); });
describe('stringToISODate', () => {
['', 'null', undefined, 'abc'].forEach(input => {
it(`throws error for invalid input like ${input}`, done => {
try {
stringToISODate(input);
} catch (e) {
expect(e).toBeDefined();
done();
}
});
});
[
{
input: '2019-09-09 01:01:01',
output: '2019-09-09T01:01:01Z',
},
{
input: '2019-09-09 00:00:00',
output: '2019-09-09T00:00:00Z',
},
{
input: '2019-09-09 23:59:59',
output: '2019-09-09T23:59:59Z',
},
{
input: '2019-09-09',
output: '2019-09-09T00:00:00Z',
},
].forEach(({ input, output }) => {
it(`returns ${output} from ${input}`, () => {
expect(stringToISODate(input)).toBe(output);
});
});
});
describe('ISODateToString', () => {
[
{
input: new Date('2019-09-09T00:00:00.000Z'),
output: '2019-09-09 00:00:00',
},
{
input: new Date('2019-09-09T07:00:00.000Z'),
output: '2019-09-09 07:00:00',
},
].forEach(({ input, output }) => {
it(`ISODateToString return ${output} for ${input}`, () => {
expect(ISODateToString(input)).toBe(output);
});
});
});
describe('truncateZerosInDateTime', () => {
[
{
input: '',
output: '',
},
{
input: '2019-10-10',
output: '2019-10-10',
},
{
input: '2019-10-10 00:00:01',
output: '2019-10-10 00:00:01',
},
{
input: '2019-10-10 00:00:00',
output: '2019-10-10',
},
].forEach(({ input, output }) => {
it(`truncateZerosInDateTime return ${output} for ${input}`, () => {
expect(truncateZerosInDateTime(input)).toBe(output);
});
});
});
describe('isValidDate', () => {
[
{
input: '2019-09-09T00:00:00.000Z',
output: true,
},
{
input: '2019-09-09T000:00.000Z',
output: false,
},
{
input: 'a2019-09-09T000:00.000Z',
output: false,
},
{
input: '2019-09-09T',
output: false,
},
{
input: '2019-09-09',
output: true,
},
{
input: '2019-9-9',
output: true,
},
{
input: '2019-9-',
output: true,
},
{
input: '2019--',
output: false,
},
{
input: '2019',
output: true,
},
{
input: '',
output: false,
},
{
input: null,
output: false,
},
].forEach(({ input, output }) => {
it(`isValidDate return ${output} for ${input}`, () => {
expect(isValidDate(input)).toBe(output);
});
});
});
describe('isDateTimePickerInputValid', () => {
[
{
input: null,
output: false,
},
{
input: '',
output: false,
},
{
input: 'xxxx-xx-xx',
output: false,
},
{
input: '9999-99-19',
output: false,
},
{
input: '2019-19-23',
output: false,
},
{
input: '2019-09-23',
output: true,
},
{
input: '2019-09-23 x',
output: false,
},
{
input: '2019-09-29 0:0:0',
output: false,
},
{
input: '2019-09-29 00:00:00',
output: true,
},
{
input: '2019-09-29 24:24:24',
output: false,
},
{
input: '2019-09-29 23:24:24',
output: true,
},
{
input: '2019-09-29 23:24:24 ',
output: false,
},
].forEach(({ input, output }) => {
it(`returns ${output} for ${input}`, () => {
expect(isDateTimePickerInputValid(input)).toBe(output);
});
});
});
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