Commit 881347ba authored by Clement Ho's avatar Clement Ho

Merge branch 'adriel-move-custom-metrics-form-fields' into 'master'

Move custom metrics form to its own component

See merge request gitlab-org/gitlab-ee!11393
parents ceff1529 190b76ae
<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