Commit e61ce041 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '13135-call-to-validate_query-in-custom-metrics-form-is-not-retried' into 'master'

Call to validate_query in custom metrics form is not retried

Closes #13135

See merge request gitlab-org/gitlab!18769
parents b3e6d37d 1653d570
---
title: Fix query validation in custom metrics form
merge_request: 18769
author:
type: fixed
<script> <script>
import { GlFormInput, GlButton, GlLink, GlFormGroup, GlFormRadioGroup } from '@gitlab/ui'; import {
GlFormInput,
GlButton,
GlLink,
GlFormGroup,
GlFormRadioGroup,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'underscore'; import { debounce } from 'underscore';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
import axios from '~/lib/utils/axios_utils';
import statusCodes from '~/lib/utils/http_status';
import { backOff } from '~/lib/utils/common_utils';
import { queryTypes, formDataValidator } from '../constants'; import { queryTypes, formDataValidator } from '../constants';
const MAX_REQUESTS = 4;
const axiosCancelToken = axios.CancelToken;
let cancelTokenSource;
function backOffRequest(makeRequestCallback) {
let requestsCount = 0;
return backOff((next, stop) => {
makeRequestCallback()
.then(resp => {
if (resp.status === statusCodes.OK) {
stop(resp);
} else {
requestsCount += 1;
if (requestsCount < MAX_REQUESTS) {
next();
} else {
stop(resp);
}
}
})
// If the request is cancelled by axios
// then consider it as noop so that its not
// caught by subsequent catches
.catch(thrown => (axios.isCancel(thrown) ? undefined : stop(thrown)));
});
}
export default { export default {
components: { components: {
GlFormInput, GlFormInput,
...@@ -14,6 +50,7 @@ export default { ...@@ -14,6 +50,7 @@ export default {
GlLink, GlLink,
GlFormGroup, GlFormGroup,
GlFormRadioGroup, GlFormRadioGroup,
GlLoadingIcon,
Icon, Icon,
}, },
props: { props: {
...@@ -49,6 +86,7 @@ export default { ...@@ -49,6 +86,7 @@ export default {
return { return {
queryIsValid: null, queryIsValid: null,
queryValidateInFlight: false,
...this.formData, ...this.formData,
group, group,
}; };
...@@ -81,27 +119,54 @@ export default { ...@@ -81,27 +119,54 @@ export default {
} }
}, },
methods: { methods: {
requestValidation() { requestValidation(query, cancelToken) {
return axios.post(this.validateQueryPath, { return backOffRequest(() =>
query: this.query, axios.post(
}); this.validateQueryPath,
{
query,
},
{
cancelToken,
},
),
);
},
setFormState(isValid, inFlight, message) {
this.queryIsValid = isValid;
this.queryValidateInFlight = inFlight;
this.errorMessage = message;
}, },
validateQuery() { validateQuery() {
this.requestValidation() if (!this.query) {
this.setFormState(null, false, '');
return;
}
this.setFormState(null, true, '');
// cancel previously dispatched backoff request
if (cancelTokenSource) {
cancelTokenSource.cancel();
}
// Creating a new token for each request because
// if a single token is used it can cancel existing requests
// as well.
cancelTokenSource = axiosCancelToken.source();
this.requestValidation(this.query, cancelTokenSource.token)
.then(res => { .then(res => {
const response = res.data; const response = res.data;
const { valid, error } = response.query; const { valid, error } = response.query;
if (response.success) { if (response.success) {
this.errorMessage = valid ? '' : error; this.setFormState(valid, false, valid ? '' : error);
this.queryIsValid = valid;
} else { } else {
throw new Error(__('There was an error trying to validate your query')); throw new Error(__('There was an error trying to validate your query'));
} }
}) })
.catch(() => { .catch(() => {
this.errorMessage = s__('Metrics|There was an error trying to validate your query'); this.setFormState(
this.queryIsValid = false; false,
false,
s__('Metrics|There was an error trying to validate your query'),
);
}); });
}, },
debouncedValidateQuery: debounce(function checkQuery() { debouncedValidateQuery: debounce(function checkQuery() {
...@@ -150,7 +215,7 @@ export default { ...@@ -150,7 +215,7 @@ export default {
> >
<gl-form-input <gl-form-input
id="prometheus_metric_query" id="prometheus_metric_query"
v-model="query" v-model.trim="query"
name="prometheus_metric[query]" name="prometheus_metric[query]"
class="form-control" class="form-control"
:placeholder="s__('Metrics|e.g. rate(http_requests_total[5m])')" :placeholder="s__('Metrics|e.g. rate(http_requests_total[5m])')"
...@@ -158,12 +223,16 @@ export default { ...@@ -158,12 +223,16 @@ export default {
:state="queryIsValid" :state="queryIsValid"
@input="debouncedValidateQuery" @input="debouncedValidateQuery"
/> />
<slot name="valid-feedback"> <span v-if="queryValidateInFlight" class="form-text text-muted">
<gl-loading-icon :inline="true" class="mr-1 align-middle" />
{{ s__('Metrics|Validating query') }}
</span>
<slot v-if="!queryValidateInFlight" name="valid-feedback">
<span class="form-text cgreen"> <span class="form-text cgreen">
{{ validQueryMsg }} {{ validQueryMsg }}
</span> </span>
</slot> </slot>
<slot name="invalid-feedback"> <slot v-if="!queryValidateInFlight" name="invalid-feedback">
<span class="form-text cred"> <span class="form-text cred">
{{ invalidQueryMsg }} {{ invalidQueryMsg }}
</span> </span>
......
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import CustomMetricsFormFields from 'ee/custom_metrics/components/custom_metrics_form_fields.vue'; import CustomMetricsFormFields from 'ee/custom_metrics/components/custom_metrics_form_fields.vue';
const { CancelToken } = axios;
describe('custom metrics form fields component', () => { describe('custom metrics form fields component', () => {
let component; let component;
let mockAxios;
const getNamedInput = name => component.element.querySelector(`input[name="${name}"]`); const getNamedInput = name => component.element.querySelector(`input[name="${name}"]`);
const validateQueryPath = `${TEST_HOST}/mock/path`; const validateQueryPath = `${TEST_HOST}/mock/path`;
const validQueryResponse = { data: { success: true, query: { valid: true, error: '' } } }; const validQueryResponse = { data: { success: true, query: { valid: true, error: '' } } };
...@@ -22,7 +27,7 @@ describe('custom metrics form fields component', () => { ...@@ -22,7 +27,7 @@ describe('custom metrics form fields component', () => {
...data, ...data,
}, },
}); });
const mountComponent = props => { const mountComponent = (props, methods = {}) => {
component = mount(CustomMetricsFormFields, { component = mount(CustomMetricsFormFields, {
propsData: { propsData: {
formOperation, formOperation,
...@@ -31,19 +36,18 @@ describe('custom metrics form fields component', () => { ...@@ -31,19 +36,18 @@ describe('custom metrics form fields component', () => {
}, },
csrfToken, csrfToken,
sync: false, sync: false,
methods: { methods,
debouncedValidateQuery: debouncedValidateQueryMock,
},
}); });
}; };
beforeEach(() => { beforeEach(() => {
jest.spyOn(axios, 'post').mockResolvedValue(validQueryResponse); mockAxios = new MockAdapter(axios);
mockAxios.onPost(validateQueryPath).reply(validQueryResponse);
}); });
afterEach(() => { afterEach(() => {
axios.post.mockRestore();
component.destroy(); component.destroy();
mockAxios.restore();
}); });
it('checks form validity', done => { it('checks form validity', done => {
...@@ -58,7 +62,7 @@ describe('custom metrics form fields component', () => { ...@@ -58,7 +62,7 @@ describe('custom metrics form fields component', () => {
}); });
component.vm.$nextTick(() => { component.vm.$nextTick(() => {
expect(component.vm.formIsValid).toBe(true); expect(component.vm.formIsValid).toBe(false);
done(); done();
}); });
}); });
...@@ -105,33 +109,112 @@ describe('custom metrics form fields component', () => { ...@@ -105,33 +109,112 @@ describe('custom metrics form fields component', () => {
}); });
describe('query input', () => { describe('query input', () => {
const name = 'prometheus_metric[query]'; const queryInputName = 'prometheus_metric[query]';
beforeEach(() => {
mockAxios.onPost(validateQueryPath).reply(validQueryResponse);
});
it('is empty by default', () => { it('is empty by default', () => {
mountComponent(); mountComponent();
expect(getNamedInput(name).value).toBe(''); expect(getNamedInput(queryInputName).value).toBe('');
}); });
it('receives and validates a persisted value', () => { it('receives and validates a persisted value', () => {
const query = 'persistedQuery'; const query = 'persistedQuery';
const axiosPost = jest.spyOn(axios, 'post');
const source = CancelToken.source();
mountComponent({ metricPersisted: true, ...makeFormData({ query }) }); mountComponent({ metricPersisted: true, ...makeFormData({ query }) });
expect(axios.post).toHaveBeenCalledWith(validateQueryPath, { query }); expect(axiosPost).toHaveBeenCalledWith(
expect(getNamedInput(name).value).toBe(query); validateQueryPath,
{ query },
{ cancelToken: source.token },
);
expect(getNamedInput(queryInputName).value).toBe(query);
jest.runAllTimers(); jest.runAllTimers();
}); });
it('checks validity on user input', () => { it('checks validity on user input', () => {
const query = 'changedQuery'; const query = 'changedQuery';
mountComponent(); mountComponent(
const queryInput = component.find(`input[name="${name}"]`); {},
{
debouncedValidateQuery: debouncedValidateQueryMock,
},
);
const queryInput = component.find(`input[name="${queryInputName}"]`);
queryInput.setValue(query); queryInput.setValue(query);
queryInput.trigger('input'); queryInput.trigger('input');
expect(debouncedValidateQueryMock).toHaveBeenCalledWith(query); expect(debouncedValidateQueryMock).toHaveBeenCalledWith(query);
}); });
describe('when query validation is in flight', () => {
beforeEach(() => {
jest.useFakeTimers();
mountComponent(
{ metricPersisted: true, ...makeFormData({ query: 'validQuery' }) },
{
requestValidation: jest.fn().mockImplementation(
() =>
new Promise(resolve =>
setTimeout(() => {
resolve(validQueryResponse);
}, 4000),
),
),
},
);
});
afterEach(() => {
jest.clearAllTimers();
});
it('expect queryValidateInFlight is in flight', done => {
const queryInput = component.find(`input[name="${queryInputName}"]`);
queryInput.setValue('query');
queryInput.trigger('input');
component.vm.$nextTick(() => {
expect(component.vm.queryValidateInFlight).toBe(true);
jest.runOnlyPendingTimers();
component.vm.$nextTick(() => {
expect(component.vm.queryValidateInFlight).toBe(false);
expect(component.vm.queryIsValid).toBe(true);
done();
});
});
});
it('expect loading message to display', done => {
const queryInput = component.find(`input[name="${queryInputName}"]`);
queryInput.setValue('query');
queryInput.trigger('input');
component.vm.$nextTick(() => {
expect(component.text()).toContain('Validating query');
jest.runOnlyPendingTimers();
done();
});
});
it('expect loading message to disappear', done => {
const queryInput = component.find(`input[name="${queryInputName}"]`);
queryInput.setValue('query');
queryInput.trigger('input');
component.vm.$nextTick(() => {
jest.runOnlyPendingTimers();
component.vm.$nextTick(() => {
expect(component.vm.queryValidateInFlight).toBe(false);
expect(component.vm.queryIsValid).toBe(true);
expect(component.vm.errorMessage).toBe('');
done();
});
});
});
});
describe('when query is invalid', () => { describe('when query is invalid', () => {
const errorMessage = 'mockErrorMessage'; const errorMessage = 'mockErrorMessage';
const invalidQueryResponse = { const invalidQueryResponse = {
...@@ -139,25 +222,42 @@ describe('custom metrics form fields component', () => { ...@@ -139,25 +222,42 @@ describe('custom metrics form fields component', () => {
}; };
beforeEach(() => { beforeEach(() => {
axios.post.mockResolvedValue(invalidQueryResponse); mountComponent(
mountComponent({ metricPersisted: true, ...makeFormData({ query: 'invalidQuery' }) }); { metricPersisted: true, ...makeFormData({ query: 'invalidQuery' }) },
{
requestValidation: jest
.fn()
.mockImplementation(() => Promise.resolve(invalidQueryResponse)),
},
);
}); });
it('sets queryIsValid to false', done => { it('sets queryIsValid to false', done => {
component.vm.$nextTick(() => { component.vm.$nextTick(() => {
expect(component.vm.queryValidateInFlight).toBe(false);
expect(component.vm.queryIsValid).toBe(false); expect(component.vm.queryIsValid).toBe(false);
done(); done();
}); });
}); });
it('shows invalid query message', () => { it('shows invalid query message', done => {
expect(component.text()).toContain(errorMessage); component.vm.$nextTick(() => {
expect(component.text()).toContain(errorMessage);
done();
});
}); });
}); });
describe('when query is valid', () => { describe('when query is valid', () => {
beforeEach(() => { beforeEach(() => {
mountComponent({ metricPersisted: true, ...makeFormData({ query: 'validQuery' }) }); mountComponent(
{ metricPersisted: true, ...makeFormData({ query: 'validQuery' }) },
{
requestValidation: jest
.fn()
.mockImplementation(() => Promise.resolve(validQueryResponse)),
},
);
}); });
it('sets queryIsValid to true when query is valid', done => { it('sets queryIsValid to true when query is valid', done => {
......
...@@ -10858,6 +10858,9 @@ msgstr "" ...@@ -10858,6 +10858,9 @@ msgstr ""
msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response." msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
msgstr "" msgstr ""
msgid "Metrics|Validating query"
msgstr ""
msgid "Metrics|Y-axis label" msgid "Metrics|Y-axis label"
msgstr "" msgstr ""
......
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