Commit 34d5501a authored by Miguel Rincon's avatar Miguel Rincon Committed by Kushal Pandya

Remove the checks for valid dates in dashboard

- Move the checks to internal date picker component
- Add events to capture when dates are not valid
- Communicate error via events
- Ensure the dashboard can load, even with invalid dates
parent 47bc5581
...@@ -22,9 +22,11 @@ import GraphGroup from './graph_group.vue'; ...@@ -22,9 +22,11 @@ import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
import GroupEmptyState from './group_empty_state.vue'; import GroupEmptyState from './group_empty_state.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event'; import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils'; import { getTimeDiff, getAddMetricTrackingOptions } from '../utils';
import { metricStates } from '../constants'; import { metricStates } from '../constants';
const defaultTimeDiff = getTimeDiff();
export default { export default {
components: { components: {
VueDraggable, VueDraggable,
...@@ -168,9 +170,10 @@ export default { ...@@ -168,9 +170,10 @@ export default {
return { return {
state: 'gettingStarted', state: 'gettingStarted',
formIsValid: null, formIsValid: null,
selectedTimeWindow: {}, startDate: getParameterValues('start')[0] || defaultTimeDiff.start,
isRearrangingPanels: false, endDate: getParameterValues('end')[0] || defaultTimeDiff.end,
hasValidDates: true, hasValidDates: true,
isRearrangingPanels: false,
}; };
}, },
computed: { computed: {
...@@ -228,24 +231,10 @@ export default { ...@@ -228,24 +231,10 @@ export default {
if (!this.hasMetrics) { if (!this.hasMetrics) {
this.setGettingStartedEmptyState(); this.setGettingStartedEmptyState();
} else { } else {
const defaultRange = getTimeDiff(); this.fetchData({
const start = getParameterValues('start')[0] || defaultRange.start; start: this.startDate,
const end = getParameterValues('end')[0] || defaultRange.end; end: this.endDate,
});
const range = {
start,
end,
};
this.selectedTimeWindow = range;
if (!isValidDate(start) || !isValidDate(end)) {
this.hasValidDates = false;
this.showInvalidDateError();
} else {
this.hasValidDates = true;
this.fetchData(range);
}
} }
}, },
methods: { methods: {
...@@ -267,9 +256,20 @@ export default { ...@@ -267,9 +256,20 @@ export default {
key, key,
}); });
}, },
showInvalidDateError() {
createFlash(s__('Metrics|Link contains an invalid time window.')); onDateTimePickerApply(params) {
redirectTo(mergeUrlParams(params, window.location.href));
},
onDateTimePickerInvalid() {
createFlash(
s__(
'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
),
);
this.startDate = defaultTimeDiff.start;
this.endDate = defaultTimeDiff.end;
}, },
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);
...@@ -287,9 +287,6 @@ export default { ...@@ -287,9 +287,6 @@ export default {
submitCustomMetricsForm() { submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit(); this.$refs.customMetricsForm.submit();
}, },
onDateTimePickerApply(timeWindowUrlParams) {
return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
},
/** /**
* Return a single empty state for a group. * Return a single empty state for a group.
* *
...@@ -378,15 +375,16 @@ export default { ...@@ -378,15 +375,16 @@ export default {
</gl-form-group> </gl-form-group>
<gl-form-group <gl-form-group
v-if="hasValidDates"
: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-6 col-lg-4" class="col-sm-6 col-md-6 col-lg-4"
> >
<date-time-picker <date-time-picker
:selected-time-window="selectedTimeWindow" :start="startDate"
@onApply="onDateTimePickerApply" :end="endDate"
@apply="onDateTimePickerApply"
@invalid="onDateTimePickerInvalid"
/> />
</gl-form-group> </gl-form-group>
</template> </template>
......
...@@ -5,14 +5,21 @@ import Icon from '~/vue_shared/components/icon.vue'; ...@@ -5,14 +5,21 @@ import Icon from '~/vue_shared/components/icon.vue';
import DateTimePickerInput from './date_time_picker_input.vue'; import DateTimePickerInput from './date_time_picker_input.vue';
import { import {
getTimeDiff, getTimeDiff,
isValidDate,
getTimeWindow, getTimeWindow,
stringToISODate, stringToISODate,
ISODateToString, ISODateToString,
truncateZerosInDateTime, truncateZerosInDateTime,
isDateTimePickerInputValid, isDateTimePickerInputValid,
} from '~/monitoring/utils'; } from '~/monitoring/utils';
import { timeWindows } from '~/monitoring/constants'; import { timeWindows } from '~/monitoring/constants';
const events = {
apply: 'apply',
invalid: 'invalid',
};
export default { export default {
components: { components: {
Icon, Icon,
...@@ -23,77 +30,94 @@ export default { ...@@ -23,77 +30,94 @@ export default {
GlDropdownItem, GlDropdownItem,
}, },
props: { props: {
start: {
type: String,
required: true,
},
end: {
type: String,
required: true,
},
timeWindows: { timeWindows: {
type: Object, type: Object,
required: false, required: false,
default: () => timeWindows, default: () => timeWindows,
}, },
selectedTimeWindow: {
type: Object,
required: false,
default: () => {},
},
}, },
data() { data() {
return { return {
selectedTimeWindowText: '', startDate: this.start,
customTime: { endDate: this.end,
from: null,
to: null,
},
}; };
}, },
computed: { computed: {
applyEnabled() { startInputValid() {
return Boolean(this.inputState.from && this.inputState.to); return isValidDate(this.startDate);
}, },
inputState() { endInputValid() {
const { from, to } = this.customTime; return isValidDate(this.endDate);
return {
from: from && isDateTimePickerInputValid(from),
to: to && isDateTimePickerInputValid(to),
};
}, },
}, isValid() {
watch: { return this.startInputValid && this.endInputValid;
selectedTimeWindow() { },
this.verifyTimeRange();
startInput: {
get() {
return this.startInputValid ? this.formatDate(this.startDate) : this.startDate;
},
set(val) {
// Attempt to set a formatted date if possible
this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
},
},
endInput: {
get() {
return this.endInputValid ? this.formatDate(this.endDate) : this.endDate;
},
set(val) {
// Attempt to set a formatted date if possible
this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
},
},
timeWindowText() {
const timeWindow = getTimeWindow({ start: this.start, end: this.end });
if (timeWindow) {
return this.timeWindows[timeWindow];
} else if (isValidDate(this.start) && isValidDate(this.end)) {
return sprintf(s__('%{start} to %{end}'), {
start: this.formatDate(this.start),
end: this.formatDate(this.end),
});
}
return '';
}, },
}, },
mounted() { mounted() {
this.verifyTimeRange(); // Validate on mounted, and trigger an update if needed
if (!this.isValid) {
this.$emit(events.invalid);
}
}, },
methods: { methods: {
activeTimeWindow(key) { formatDate(date) {
return this.timeWindows[key] === this.selectedTimeWindowText; return truncateZerosInDateTime(ISODateToString(date));
}, },
setCustomTimeWindowParameter() { setTimeWindow(key) {
this.$emit('onApply', {
start: stringToISODate(this.customTime.from),
end: stringToISODate(this.customTime.to),
});
},
setTimeWindowParameter(key) {
const { start, end } = getTimeDiff(key); const { start, end } = getTimeDiff(key);
this.$emit('onApply', { this.startDate = start;
start, this.endDate = end;
end,
}); this.apply();
}, },
closeDropdown() { closeDropdown() {
this.$refs.dropdown.hide(); this.$refs.dropdown.hide();
}, },
verifyTimeRange() { apply() {
const range = getTimeWindow(this.selectedTimeWindow); this.$emit(events.apply, {
if (range) { start: this.startDate,
this.selectedTimeWindowText = this.timeWindows[range]; end: this.endDate,
} else { });
this.customTime = {
from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)),
to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)),
};
this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime);
}
}, },
}, },
}; };
...@@ -101,7 +125,7 @@ export default { ...@@ -101,7 +125,7 @@ export default {
<template> <template>
<gl-dropdown <gl-dropdown
ref="dropdown" ref="dropdown"
:text="selectedTimeWindowText" :text="timeWindowText"
menu-class="time-window-dropdown-menu" menu-class="time-window-dropdown-menu"
class="js-time-window-dropdown" class="js-time-window-dropdown"
> >
...@@ -113,24 +137,21 @@ export default { ...@@ -113,24 +137,21 @@ export default {
> >
<date-time-picker-input <date-time-picker-input
id="custom-time-from" id="custom-time-from"
v-model="customTime.from" v-model="startInput"
:label="__('From')" :label="__('From')"
:state="inputState.from" :state="startInputValid"
/> />
<date-time-picker-input <date-time-picker-input
id="custom-time-to" id="custom-time-to"
v-model="customTime.to" v-model="endInput"
:label="__('To')" :label="__('To')"
:state="inputState.to" :state="endInputValid"
/> />
<gl-form-group> <gl-form-group>
<gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button> <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
<gl-button <gl-button variant="success" :disabled="!isValid" @click="apply()">
variant="success" {{ __('Apply') }}
:disabled="!applyEnabled" </gl-button>
@click="setCustomTimeWindowParameter"
>{{ __('Apply') }}</gl-button
>
</gl-form-group> </gl-form-group>
</gl-form-group> </gl-form-group>
<gl-form-group <gl-form-group
...@@ -142,14 +163,14 @@ export default { ...@@ -142,14 +163,14 @@ export default {
<gl-dropdown-item <gl-dropdown-item
v-for="(value, key) in timeWindows" v-for="(value, key) in timeWindows"
:key="key" :key="key"
:active="activeTimeWindow(key)" :active="value === timeWindowText"
active-class="active" active-class="active"
@click="setTimeWindowParameter(key)" @click="setTimeWindow(key)"
> >
<icon <icon
name="mobile-issue-close" name="mobile-issue-close"
class="align-bottom" class="align-bottom"
:class="{ invisible: !activeTimeWindow(key) }" :class="{ invisible: value !== timeWindowText }"
/> />
{{ value }} {{ value }}
</gl-dropdown-item> </gl-dropdown-item>
......
...@@ -269,9 +269,6 @@ msgstr "" ...@@ -269,9 +269,6 @@ msgstr ""
msgid "%{firstLabel} +%{labelCount} more" msgid "%{firstLabel} +%{labelCount} more"
msgstr "" msgstr ""
msgid "%{from} to %{to}"
msgstr ""
msgid "%{global_id} is not a valid id for %{expected_type}." msgid "%{global_id} is not a valid id for %{expected_type}."
msgstr "" msgstr ""
...@@ -370,6 +367,9 @@ msgstr "" ...@@ -370,6 +367,9 @@ msgstr ""
msgid "%{spammable_titlecase} was submitted to Akismet successfully." msgid "%{spammable_titlecase} was submitted to Akismet successfully."
msgstr "" msgstr ""
msgid "%{start} to %{end}"
msgstr ""
msgid "%{state} epics" msgid "%{state} epics"
msgstr "" msgstr ""
...@@ -11478,7 +11478,7 @@ msgstr "" ...@@ -11478,7 +11478,7 @@ msgstr ""
msgid "Metrics|Legend label (optional)" msgid "Metrics|Legend label (optional)"
msgstr "" msgstr ""
msgid "Metrics|Link contains an invalid time window." msgid "Metrics|Link contains an invalid time window, please verify the link to see the requested time range."
msgstr "" msgstr ""
msgid "Metrics|Max" msgid "Metrics|Max"
......
...@@ -10,7 +10,6 @@ import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_p ...@@ -10,7 +10,6 @@ import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_p
import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; import GroupEmptyState from '~/monitoring/components/group_empty_state.vue';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types'; import * as types from '~/monitoring/stores/mutation_types';
import * as monitoringUtils from '~/monitoring/utils';
import { setupComponentStore, propsData } from '../init_utils'; import { setupComponentStore, propsData } from '../init_utils';
import { import {
metricsGroupsAPIResponse, metricsGroupsAPIResponse,
...@@ -137,7 +136,6 @@ describe('Dashboard', () => { ...@@ -137,7 +136,6 @@ describe('Dashboard', () => {
}); });
it('fetches the metrics data with proper time window', done => { it('fetches the metrics data with proper time window', done => {
const getTimeDiffSpy = jest.spyOn(monitoringUtils, 'getTimeDiff');
jest.spyOn(store, 'dispatch'); jest.spyOn(store, 'dispatch');
createMountedWrapper( createMountedWrapper(
...@@ -154,7 +152,6 @@ describe('Dashboard', () => { ...@@ -154,7 +152,6 @@ describe('Dashboard', () => {
.$nextTick() .$nextTick()
.then(() => { .then(() => {
expect(store.dispatch).toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalled();
expect(getTimeDiffSpy).toHaveBeenCalled();
done(); done();
}) })
......
import { mount, createLocalVue } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import MockAdapter from 'axios-mock-adapter';
import Dashboard from '~/monitoring/components/dashboard.vue'; import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
import { propsData } from '../init_utils'; import { propsData } from '../init_utils';
import axios from '~/lib/utils/axios_utils';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -15,6 +17,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ ...@@ -15,6 +17,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('dashboard invalid url parameters', () => { describe('dashboard invalid url parameters', () => {
let store; let store;
let wrapper; let wrapper;
let mock;
const createMountedWrapper = (props = {}, options = {}) => { const createMountedWrapper = (props = {}, options = {}) => {
wrapper = mount(localVue.extend(Dashboard), { wrapper = mount(localVue.extend(Dashboard), {
...@@ -28,12 +31,14 @@ describe('dashboard invalid url parameters', () => { ...@@ -28,12 +31,14 @@ describe('dashboard invalid url parameters', () => {
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
mock = new MockAdapter(axios);
}); });
afterEach(() => { afterEach(() => {
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
} }
mock.restore();
}); });
it('shows an error message if invalid url parameters are passed', done => { it('shows an error message if invalid url parameters are passed', done => {
...@@ -46,7 +51,6 @@ describe('dashboard invalid url parameters', () => { ...@@ -46,7 +51,6 @@ describe('dashboard invalid url parameters', () => {
.$nextTick() .$nextTick()
.then(() => { .then(() => {
expect(createFlash).toHaveBeenCalled(); expect(createFlash).toHaveBeenCalled();
done(); done();
}) })
.catch(done.fail); .catch(done.fail);
......
...@@ -3,10 +3,8 @@ import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_p ...@@ -3,10 +3,8 @@ import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_p
import { timeWindows } from '~/monitoring/constants'; import { timeWindows } from '~/monitoring/constants';
const timeWindowsCount = Object.keys(timeWindows).length; const timeWindowsCount = Object.keys(timeWindows).length;
const selectedTimeWindow = { const start = '2019-10-10T07:00:00.000Z';
start: '2019-10-10T07:00:00.000Z', const end = '2019-10-13T07:00:00.000Z';
end: '2019-10-13T07:00:00.000Z',
};
const selectedTimeWindowText = `3 days`; const selectedTimeWindowText = `3 days`;
describe('DateTimePicker', () => { describe('DateTimePicker', () => {
...@@ -28,7 +26,8 @@ describe('DateTimePicker', () => { ...@@ -28,7 +26,8 @@ describe('DateTimePicker', () => {
dateTimePicker = mount(DateTimePicker, { dateTimePicker = mount(DateTimePicker, {
propsData: { propsData: {
timeWindows, timeWindows,
selectedTimeWindow, start,
end,
...props, ...props,
}, },
sync: false, sync: false,
...@@ -66,10 +65,8 @@ describe('DateTimePicker', () => { ...@@ -66,10 +65,8 @@ describe('DateTimePicker', () => {
it('renders inputs with h/m/s truncated if its all 0s', done => { it('renders inputs with h/m/s truncated if its all 0s', done => {
createComponent({ createComponent({
selectedTimeWindow: { start: '2019-10-10T00:00:00.000Z',
start: '2019-10-10T00:00:00.000Z', end: '2019-10-14T00:10:00.000Z',
end: '2019-10-14T00:10:00.000Z',
},
}); });
dateTimePicker.vm.$nextTick(() => { dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10'); expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10');
...@@ -98,8 +95,10 @@ describe('DateTimePicker', () => { ...@@ -98,8 +95,10 @@ describe('DateTimePicker', () => {
}); });
}); });
it('renders a disabled apply button on load', () => { it('renders a disabled apply button on wrong input', () => {
createComponent(); createComponent({
start: 'invalid-input-date',
});
expect(applyButtonElement().getAttribute('disabled')).toBe('disabled'); expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
}); });
...@@ -131,29 +130,29 @@ describe('DateTimePicker', () => { ...@@ -131,29 +130,29 @@ describe('DateTimePicker', () => {
fillInputAndBlur('#custom-time-from', '2019-10-01') fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.then(() => { .then(() => {
dateTimePicker.vm.$nextTick(() => { expect(applyButtonElement().getAttribute('disabled')).toBeNull();
expect(applyButtonElement().getAttribute('disabled')).toBeNull(); done();
done();
});
}) })
.catch(done); .catch(done.fail);
}); });
it('returns an object when apply is clicked', done => { it('emits dates in an object when apply is clicked', done => {
createComponent(); createComponent();
fillInputAndBlur('#custom-time-from', '2019-10-01') fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.then(() => { .then(() => {
jest.spyOn(dateTimePicker.vm, '$emit');
applyButtonElement().click(); applyButtonElement().click();
expect(dateTimePicker.vm.$emit).toHaveBeenCalledWith('onApply', { expect(dateTimePicker.emitted().apply).toHaveLength(1);
end: '2019-10-19T00:00:00Z', expect(dateTimePicker.emitted().apply[0]).toEqual([
start: '2019-10-01T00:00:00Z', {
}); end: '2019-10-19T00:00:00Z',
start: '2019-10-01T00:00:00Z',
},
]);
done(); done();
}) })
.catch(done); .catch(done.fail);
}); });
it('hides the popover with cancel button', done => { it('hides the popover with cancel button', 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