Commit 190b76ae authored by Adriel Santiago's avatar Adriel Santiago Committed by Clement Ho

Move custom metrics form to its own component

Allow for reuse of the custom metric form fields
parent ceff1529
<script>
import { GlFormInput, GlButton, GlLink, GlFormGroup, GlFormRadioGroup } from '@gitlab/ui';
import _ from 'underscore';
import { GlButton } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import csrf from '~/lib/utils/csrf';
import Icon from '~/vue_shared/components/icon.vue';
import axios from '~/lib/utils/axios_utils';
import CustomMetricsFormFields from './custom_metrics_form_fields.vue';
import DeleteCustomMetricModal from './delete_custom_metric_modal.vue';
import QueryTypes from '../constants';
import { formDataValidator } from '../constants';
export default {
components: {
CustomMetricsFormFields,
DeleteCustomMetricModal,
GlFormInput,
GlButton,
GlLink,
GlFormGroup,
GlFormRadioGroup,
Icon,
},
props: {
customMetricsPath: {
......@@ -39,220 +33,50 @@ export default {
formData: {
type: Object,
required: true,
validator: val => {
const fieldNames = Object.keys(val);
const requiredFields = ['title', 'query', 'yLabel', 'unit', 'group', 'legend'];
return requiredFields.every(name => fieldNames.includes(name));
},
validator: formDataValidator,
},
},
data() {
return {
validCustomQuery: null,
formIsValid: null,
errorMessage: '',
formGroupOptions: [
{ text: __('Business'), value: QueryTypes.business },
{ text: __('Response'), value: QueryTypes.response },
{ text: __('System'), value: QueryTypes.system },
],
};
},
computed: {
disabledForm() {
return this.validCustomQuery;
},
saveButtonText() {
return this.metricPersisted ? __('Save Changes') : s__('Metrics|Create metric');
},
titleText() {
return this.metricPersisted ? s__('Metrics|Edit metric') : s__('Metrics|New metric');
},
validQueryMsg() {
return this.validCustomQuery ? s__('Metrics|PromQL query is valid') : '';
},
invalidQueryMsg() {
return !this.validCustomQuery ? this.errorMessage : '';
},
},
created() {
this.csrf = csrf.token != null ? csrf.token : '';
this.formOperation = this.metricPersisted ? 'patch' : 'post';
this.formData.group = this.formData.group.length ? this.formData.group : QueryTypes.business;
if (this.metricPersisted) {
this.validate();
}
},
methods: {
formValidation(isValid) {
this.formIsValid = isValid;
},
submit() {
this.$refs.form.submit();
},
validate() {
this.requestValidation()
.then(res => {
const response = res.data;
const { valid, error } = response.query;
if (response.success) {
this.errorMessage = valid ? '' : error;
this.validCustomQuery = valid;
} else {
throw new Error('There was an error trying to validate your query');
}
})
.catch(() => {
this.errorMessage = s__('Metrics|There was an error trying to validate your query');
this.validCustomQuery = false;
});
},
validateQuery: _.debounce(function debounceValidateQuery() {
this.validate();
}, 500),
requestValidation() {
return axios.post(this.validateQueryPath, {
query: this.formData.query,
});
},
},
QueryTypes,
};
</script>
<template>
<div class="row my-3">
<h4 class="text-center prepend-top-0">{{ titleText }}</h4>
<form ref="form" class="col-lg-8 offset-lg-2" :action="customMetricsPath" method="post">
<input ref="method" type="hidden" name="_method" :value="formOperation" />
<input :value="csrf" type="hidden" name="authenticity_token" />
<gl-form-group
:label="__('Name')"
label-for="prometheus_metric_title"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_title"
v-model="formData.title"
:value="formData.title"
name="prometheus_metric[title]"
class="form-control"
:placeholder="s__('Metrics|e.g. Throughput')"
required
/>
<span class="form-text text-muted">{{ s__('Metrics|Used as a title for the chart') }}</span>
</gl-form-group>
<gl-form-group
:label="__('Type')"
label-for="prometheus_metric_group"
label-class="label-bold"
>
<gl-form-radio-group
id="metric-group"
v-model="formData.group"
:options="formGroupOptions"
:checked="formData.group"
name="prometheus_metric[group]"
/>
<span class="form-text text-muted">{{ s__('Metrics|For grouping similar metrics') }}</span>
</gl-form-group>
<gl-form-group
:label="__('Query')"
label-for="prometheus_metric_query"
label-class="label-bold"
:state="validCustomQuery"
>
<gl-form-input
id="prometheus_metric_query"
v-model="formData.query"
:value="formData.query"
name="prometheus_metric[query]"
class="form-control"
placeholder="e.g. rate(http_requests_total[5m])"
required
:state="validCustomQuery"
@input="validateQuery"
/>
<slot name="valid-feedback">
<span class="form-text cgreen">
{{ validQueryMsg }}
</span>
</slot>
<slot name="invalid-feedback">
<span class="form-text cred">
{{ invalidQueryMsg }}
</span>
</slot>
<span v-show="formData.query.length === 0" class="form-text text-muted">
{{ s__('Metrics|Must be a valid PromQL query.') }}
<gl-link
href="https://prometheus.io/docs/prometheus/latest/querying/basics/"
tabindex="-1"
>
{{ s__('Metrics|Prometheus Query Documentation') }}
<icon name="external-link" :size="12" />
</gl-link>
</span>
</gl-form-group>
<gl-form-group
:label="s__('Metrics|Y-axis label')"
label-for="prometheus_metric_y_label"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_y_label"
v-model="formData.yLabel"
:value="formData.yLabel"
name="prometheus_metric[y_label]"
class="form-control"
placeholder="e.g. Requests/second"
required
/>
<span class="form-text text-muted">
{{
s__(
'Metrics|Label of the y-axis (usually the unit). The x-axis always represents time.',
)
}}
</span>
</gl-form-group>
<gl-form-group
:label="s__('Metrics|Unit label')"
label-for="prometheus_metric_unit"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_unit"
v-model="formData.unit"
:value="formData.unit"
name="prometheus_metric[unit]"
class="form-control"
placeholder="e.g. req/sec"
required
/>
</gl-form-group>
<gl-form-group
:label="s__('Metrics|Legend label (optional)')"
label-for="prometheus_metric_legend"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_legend"
v-model="formData.legend"
:value="formData.legend"
name="prometheus_metric[legend]"
class="form-control"
placeholder="e.g. HTTP requests"
required
/>
<span class="form-text text-muted">
{{
s__(
'Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response.',
)
}}
</span>
</gl-form-group>
<custom-metrics-form-fields
:form-operation="formOperation"
:form-data="formData"
:metric-persisted="metricPersisted"
:validate-query-path="validateQueryPath"
@formValidation="formValidation"
/>
<div class="form-actions">
<gl-button variant="success" :disabled="!disabledForm" @click="submit">
<gl-button variant="success" :disabled="!formIsValid" @click="submit">
{{ saveButtonText }}
</gl-button>
<gl-button variant="secondary" class="float-right" :href="editProjectServicePath">{{
......
<script>
import { GlFormInput, GlButton, GlLink, GlFormGroup, GlFormRadioGroup } from '@gitlab/ui';
import { debounce } from 'underscore';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import csrf from '~/lib/utils/csrf';
import { queryTypes, formDataValidator } from '../constants';
export default {
components: {
GlFormInput,
GlButton,
GlLink,
GlFormGroup,
GlFormRadioGroup,
Icon,
},
props: {
formOperation: {
type: String,
required: true,
},
formData: {
type: Object,
required: false,
default: () => ({
title: '',
yLabel: '',
query: '',
unit: '',
group: '',
legend: '',
}),
validator: formDataValidator,
},
metricPersisted: {
type: Boolean,
required: false,
default: false,
},
validateQueryPath: {
type: String,
required: true,
},
},
data() {
const group = this.formData.group.length ? this.formData.group : queryTypes.business;
return {
queryIsValid: null,
...this.formData,
group,
};
},
computed: {
formIsValid() {
return !!(
this.queryIsValid &&
this.title.length &&
this.yLabel.length &&
this.unit.length &&
this.group.length
);
},
validQueryMsg() {
return this.queryIsValid ? s__('Metrics|PromQL query is valid') : '';
},
invalidQueryMsg() {
return !this.queryIsValid ? this.errorMessage : '';
},
},
watch: {
formIsValid(value) {
this.$emit('formValidation', value);
},
},
beforeMount() {
if (this.metricPersisted) {
this.validateQuery();
}
},
methods: {
requestValidation() {
return axios.post(this.validateQueryPath, {
query: this.query,
});
},
validateQuery() {
this.requestValidation()
.then(res => {
const response = res.data;
const { valid, error } = response.query;
if (response.success) {
this.errorMessage = valid ? '' : error;
this.queryIsValid = valid;
} else {
throw new Error('There was an error trying to validate your query');
}
})
.catch(() => {
this.errorMessage = s__('Metrics|There was an error trying to validate your query');
this.queryIsValid = false;
});
},
debouncedValidateQuery: debounce(function checkQuery() {
this.validateQuery();
}, 500),
},
csrfToken: csrf.token || '',
formGroupOptions: [
{ text: __('Business'), value: queryTypes.business },
{ text: __('Response'), value: queryTypes.response },
{ text: __('System'), value: queryTypes.system },
],
};
</script>
<template>
<div>
<input ref="method" type="hidden" name="_method" :value="formOperation" />
<input :value="$options.csrfToken" type="hidden" name="authenticity_token" />
<gl-form-group :label="__('Name')" label-for="prometheus_metric_title" label-class="label-bold">
<gl-form-input
id="prometheus_metric_title"
v-model="title"
name="prometheus_metric[title]"
class="form-control"
:placeholder="s__('Metrics|e.g. Throughput')"
required
/>
<span class="form-text text-muted">{{ s__('Metrics|Used as a title for the chart') }}</span>
</gl-form-group>
<gl-form-group :label="__('Type')" label-for="prometheus_metric_group" label-class="label-bold">
<gl-form-radio-group
id="metric-group"
v-model="group"
:options="$options.formGroupOptions"
:checked="group"
name="prometheus_metric[group]"
/>
<span class="form-text text-muted">{{ s__('Metrics|For grouping similar metrics') }}</span>
</gl-form-group>
<gl-form-group
:label="__('Query')"
label-for="prometheus_metric_query"
label-class="label-bold"
:state="queryIsValid"
>
<gl-form-input
id="prometheus_metric_query"
v-model="query"
name="prometheus_metric[query]"
class="form-control"
:placeholder="s__('Metrics|e.g. rate(http_requests_total[5m])')"
required
:state="queryIsValid"
@input="debouncedValidateQuery"
/>
<slot name="valid-feedback">
<span class="form-text cgreen">
{{ validQueryMsg }}
</span>
</slot>
<slot name="invalid-feedback">
<span class="form-text cred">
{{ invalidQueryMsg }}
</span>
</slot>
<span v-show="query.length === 0" class="form-text text-muted">
{{ s__('Metrics|Must be a valid PromQL query.') }}
<gl-link href="https://prometheus.io/docs/prometheus/latest/querying/basics/" tabindex="-1">
{{ s__('Metrics|Prometheus Query Documentation') }}
<icon name="external-link" :size="12" />
</gl-link>
</span>
</gl-form-group>
<gl-form-group
:label="s__('Metrics|Y-axis label')"
label-for="prometheus_metric_y_label"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_y_label"
v-model="yLabel"
name="prometheus_metric[y_label]"
class="form-control"
:placeholder="s__('Metrics|e.g. Requests/second')"
required
/>
<span class="form-text text-muted">
{{
s__('Metrics|Label of the y-axis (usually the unit). The x-axis always represents time.')
}}
</span>
</gl-form-group>
<gl-form-group
:label="s__('Metrics|Unit label')"
label-for="prometheus_metric_unit"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_unit"
v-model="unit"
name="prometheus_metric[unit]"
class="form-control"
:placeholder="s__('Metrics|e.g. req/sec')"
required
/>
</gl-form-group>
<gl-form-group
:label="s__('Metrics|Legend label (optional)')"
label-for="prometheus_metric_legend"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_legend"
v-model="legend"
name="prometheus_metric[legend]"
class="form-control"
:placeholder="s__('Metrics|e.g. HTTP requests')"
required
/>
<span class="form-text text-muted">
{{
s__(
'Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response.',
)
}}
</span>
</gl-form-group>
</div>
</template>
export default {
export const queryTypes = {
business: 'business',
response: 'response',
system: 'system',
};
export const formDataValidator = val => {
const fieldNames = Object.keys(val);
const requiredFields = ['title', 'query', 'yLabel', 'unit', 'group', 'legend'];
return requiredFields.every(name => fieldNames.includes(name));
};
import { mount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import { TEST_HOST } from 'helpers/test_constants';
import CustomMetricsFormFields from 'ee/custom_metrics/components/custom_metrics_form_fields.vue';
jest.mock('~/lib/utils/axios_utils');
describe('custom metrics form fields component', () => {
let component;
const getNamedInput = name => component.element.querySelector(`input[name="${name}"]`);
const validateQueryPath = `${TEST_HOST}/mock/path`;
const validQueryResponse = { data: { success: true, query: { valid: true, error: '' } } };
const csrfToken = 'mockToken';
const formOperation = 'post';
const makeFormData = (data = {}) => ({
formData: {
title: '',
yLabel: '',
query: '',
unit: '',
group: '',
legend: '',
...data,
},
});
const mountComponent = props => {
component = mount(CustomMetricsFormFields, {
propsData: {
formOperation,
validateQueryPath,
...props,
},
csrfToken,
sync: false,
});
};
beforeEach(() => {
axios.post.mockRestore();
axios.post.mockResolvedValue(validQueryResponse);
});
afterEach(() => {
component.destroy();
});
it('checks form validity', done => {
mountComponent({
metricPersisted: true,
...makeFormData({
title: 'title',
yLabel: 'yLabel',
unit: 'unit',
group: 'group',
}),
});
component.vm.$nextTick(() => {
expect(component.vm.formIsValid).toBe(true);
done();
});
});
describe('hidden inputs', () => {
beforeEach(() => {
mountComponent();
});
it('specifies form operation _method', () => {
expect(getNamedInput('_method', 'input').value).toBe('post');
});
it('specifies authenticity token', () => {
expect(getNamedInput('authenticity_token', 'input').value).toBe(csrfToken);
});
});
describe('name input', () => {
const name = 'prometheus_metric[title]';
it('is empty by default', () => {
mountComponent();
expect(getNamedInput(name).value).toBe('');
});
it('receives a persisted value', () => {
const title = 'mockTitle';
mountComponent(makeFormData({ title }));
expect(getNamedInput(name).value).toBe(title);
});
});
describe('group input', () => {
it('has a default value', () => {
mountComponent();
expect(getNamedInput('prometheus_metric[group]', 'glformradiogroup-stub').value).toBe(
'business',
);
});
});
describe('query input', () => {
const name = 'prometheus_metric[query]';
it('is empty by default', () => {
mountComponent();
expect(getNamedInput(name).value).toBe('');
});
it('receives and validates a persisted value', () => {
const query = 'persistedQuery';
mountComponent({ metricPersisted: true, ...makeFormData({ query }) });
expect(axios.post).toHaveBeenCalledWith(validateQueryPath, { query });
expect(getNamedInput(name).value).toBe(query);
jest.runAllTimers();
});
it('checks validity on user input', done => {
const query = 'changedQuery';
mountComponent();
const spy = jest.spyOn(component.vm, 'debouncedValidateQuery');
const queryInput = getNamedInput(name);
queryInput.value = query;
queryInput.dispatchEvent(new Event('input'));
component.vm.$nextTick(() => {
expect(spy).toHaveBeenCalledWith(query);
done();
});
});
describe('when query is invalid', () => {
const errorMessage = 'mockErrorMessage';
const invalidQueryResponse = {
data: { success: true, query: { valid: false, error: errorMessage } },
};
beforeEach(() => {
axios.post.mockResolvedValue(invalidQueryResponse);
mountComponent({ metricPersisted: true, ...makeFormData({ query: 'invalidQuery' }) });
});
it('sets queryIsValid to false', done => {
component.vm.$nextTick(() => {
expect(component.vm.queryIsValid).toBe(false);
done();
});
});
it('shows invalid query message', () => {
expect(component.text()).toContain(errorMessage);
});
});
describe('when query is valid', () => {
beforeEach(() => {
mountComponent({ metricPersisted: true, ...makeFormData({ query: 'validQuery' }) });
});
it('sets queryIsValid to true when query is valid', done => {
component.vm.$nextTick(() => {
expect(component.vm.queryIsValid).toBe(true);
done();
});
});
it('shows valid query message', () => {
expect(component.text()).toContain('PromQL query is valid');
});
});
});
describe('yLabel input', () => {
const name = 'prometheus_metric[y_label]';
it('is empty by default', () => {
mountComponent();
expect(getNamedInput(name).value).toBe('');
});
it('receives a persisted value', () => {
const yLabel = 'mockYLabel';
mountComponent(makeFormData({ yLabel }));
expect(getNamedInput(name).value).toBe(yLabel);
});
});
describe('unit input', () => {
const name = 'prometheus_metric[unit]';
it('is empty by default', () => {
mountComponent();
expect(getNamedInput(name).value).toBe('');
});
it('receives a persisted value', () => {
const unit = 'mockUnit';
mountComponent(makeFormData({ unit }));
expect(getNamedInput(name).value).toBe(unit);
});
});
describe('legend input', () => {
const name = 'prometheus_metric[legend]';
it('is empty by default', () => {
mountComponent();
expect(getNamedInput(name).value).toBe('');
});
it('receives a persisted value', () => {
const legend = 'mockLegend';
mountComponent(makeFormData({ legend }));
expect(getNamedInput(name).value).toBe(legend);
});
});
});
......@@ -50,20 +50,5 @@ describe('CustomMetricsForm', () => {
expect(wrapper.vm.saveButtonText).toEqual('Create metric');
expect(wrapper.vm.titleText).toEqual('New metric');
});
it('Shows a correct validation message for a valid custom query', () => {
mountComponent({ metricPersisted: false });
wrapper.vm.validCustomQuery = true;
expect(wrapper.vm.validQueryMsg).toEqual('PromQL query is valid');
});
it('Shows an incorrect validation message for an invalid custom query', () => {
mountComponent({ metricPersisted: false });
wrapper.vm.validCustomQuery = false;
wrapper.vm.errorMessage = 'parse error at char...';
expect(wrapper.vm.invalidQueryMsg).toEqual(wrapper.vm.errorMessage);
});
});
});
......@@ -7631,9 +7631,21 @@ msgstr ""
msgid "Metrics|You're about to permanently delete this metric. This cannot be undone."
msgstr ""
msgid "Metrics|e.g. HTTP requests"
msgstr ""
msgid "Metrics|e.g. Requests/second"
msgstr ""
msgid "Metrics|e.g. Throughput"
msgstr ""
msgid "Metrics|e.g. rate(http_requests_total[5m])"
msgstr ""
msgid "Metrics|e.g. req/sec"
msgstr ""
msgid "Migrated %{success_count}/%{total_count} files."
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