Commit 2417c86b authored by Illya Klymov's avatar Illya Klymov 💛

Merge branch '213732-form-errors-ux' into 'master'

Geo Form Validations

Closes #213732

See merge request !32263
parents 64705d75 5ee882b4
Pipeline #148776432 passed with stages
in 54 minutes and 45 seconds
<script>
import { mapActions } from 'vuex';
import { GlFormGroup, GlFormInput, GlFormCheckbox, GlDeprecatedButton } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import { GlFormGroup, GlFormInput, GlFormCheckbox, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import GeoNodeFormCore from './geo_node_form_core.vue';
......@@ -13,7 +13,7 @@ export default {
GlFormGroup,
GlFormInput,
GlFormCheckbox,
GlDeprecatedButton,
GlButton,
GeoNodeFormCore,
GeoNodeFormSelectiveSync,
GeoNodeFormCapacities,
......@@ -53,6 +53,7 @@ export default {
};
},
computed: {
...mapGetters(['formHasError']),
saveButtonTitle() {
return this.node ? __('Update') : __('Save');
},
......@@ -118,16 +119,17 @@ export default {
</gl-form-group>
</section>
<section class="d-flex align-items-center mt-4">
<gl-deprecated-button
<gl-button
id="node-save-button"
data-qa-selector="add_node_button"
variant="success"
:disabled="formHasError"
@click="saveGeoNode(nodeData)"
>{{ saveButtonTitle }}</gl-deprecated-button
>{{ saveButtonTitle }}</gl-button
>
<gl-deprecated-button id="node-cancel-button" class="ml-auto" @click="redirect">{{
<gl-button id="node-cancel-button" class="gl-ml-auto" @click="redirect">{{
__('Cancel')
}}</gl-deprecated-button>
}}</gl-button>
</section>
</form>
</template>
<script>
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import { validateCapacity } from '../validations';
import { VALIDATION_FIELD_KEYS } from '../constants';
export default {
name: 'GeoNodeFormCapacities',
......@@ -23,7 +26,7 @@ export default {
description: __(
'Control the maximum concurrency of repository backfill for this secondary node',
),
key: 'reposMaxCapacity',
key: VALIDATION_FIELD_KEYS.REPOS_MAX_CAPACITY,
conditional: 'secondary',
},
{
......@@ -32,7 +35,7 @@ export default {
description: __(
'Control the maximum concurrency of LFS/attachment backfill for this secondary node',
),
key: 'filesMaxCapacity',
key: VALIDATION_FIELD_KEYS.FILES_MAX_CAPACITY,
conditional: 'secondary',
},
{
......@@ -41,7 +44,7 @@ export default {
description: __(
'Control the maximum concurrency of container repository operations for this Geo node',
),
key: 'containerRepositoriesMaxCapacity',
key: VALIDATION_FIELD_KEYS.CONTAINER_REPOSITORIES_MAX_CAPACITY,
conditional: 'secondary',
},
{
......@@ -50,7 +53,7 @@ export default {
description: __(
'Control the maximum concurrency of verification operations for this Geo node',
),
key: 'verificationMaxCapacity',
key: VALIDATION_FIELD_KEYS.VERIFICATION_MAX_CAPACITY,
},
{
id: 'node-reverification-interval-field',
......@@ -58,13 +61,14 @@ export default {
description: __(
'Control the minimum interval in days that a repository should be reverified for this primary node',
),
key: 'minimumReverificationInterval',
key: VALIDATION_FIELD_KEYS.MINIMUM_REVERIFICATION_INTERVAL,
conditional: 'primary',
},
],
};
},
computed: {
...mapState(['formErrors']),
visibleFormGroups() {
return this.formGroups.filter(group => {
if (group.conditional) {
......@@ -76,6 +80,15 @@ export default {
});
},
},
methods: {
...mapActions(['setError']),
checkCapacity(formGroup) {
this.setError({
key: formGroup.key,
error: validateCapacity({ data: this.nodeData[formGroup.key], label: formGroup.label }),
});
},
},
};
</script>
......@@ -87,12 +100,16 @@ export default {
:label="formGroup.label"
:label-for="formGroup.id"
:description="formGroup.description"
:state="Boolean(formErrors[formGroup.key])"
:invalid-feedback="formErrors[formGroup.key]"
>
<gl-form-input
:id="formGroup.id"
v-model="nodeData[formGroup.key]"
:class="{ 'is-invalid': Boolean(formErrors[formGroup.key]) }"
class="col-sm-3"
type="number"
@input="checkCapacity(formGroup)"
/>
</gl-form-group>
</div>
......
<script>
import { GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import { isSafeURL } from '~/lib/utils/url_utility';
import { mapActions, mapState } from 'vuex';
import { validateName, validateUrl } from '../validations';
import { VALIDATION_FIELD_KEYS } from '../constants';
export default {
name: 'GeoNodeFormCore',
......@@ -16,29 +17,16 @@ export default {
required: true,
},
},
data() {
return {
fieldBlurs: {
name: false,
url: false,
},
errors: {
name: __('Name must be between 1 and 255 characters'),
url: __('URL must be a valid url (ex: https://gitlab.com)'),
},
};
},
computed: {
validName() {
return !(this.fieldBlurs.name && (!this.nodeData.name || this.nodeData.name.length > 255));
},
validUrl() {
return !(this.fieldBlurs.url && !isSafeURL(this.nodeData.url));
},
...mapState(['formErrors']),
},
methods: {
blur(field) {
this.fieldBlurs[field] = true;
...mapActions(['setError']),
checkName() {
this.setError({ key: VALIDATION_FIELD_KEYS.NAME, error: validateName(this.nodeData.name) });
},
checkUrl() {
this.setError({ key: VALIDATION_FIELD_KEYS.URL, error: validateUrl(this.nodeData.url) });
},
},
};
......@@ -50,8 +38,8 @@ export default {
class="col-sm-6"
:label="__('Name')"
label-for="node-name-field"
:state="validName"
:invalid-feedback="errors.name"
:state="Boolean(formErrors.name)"
:invalid-feedback="formErrors.name"
>
<template #description>
<gl-sprintf
......@@ -72,9 +60,10 @@ export default {
<gl-form-input
id="node-name-field"
v-model="nodeData.name"
:class="{ 'is-invalid': Boolean(formErrors.name) }"
data-qa-selector="node_name_field"
type="text"
@blur="blur('name')"
@input="checkName"
/>
</gl-form-group>
<gl-form-group
......@@ -82,15 +71,16 @@ export default {
:label="__('URL')"
label-for="node-url-field"
:description="__('The user-facing URL of the Geo node')"
:state="validUrl"
:invalid-feedback="errors.url"
:state="Boolean(formErrors.url)"
:invalid-feedback="formErrors.url"
>
<gl-form-input
id="node-url-field"
v-model="nodeData.url"
:class="{ 'is-invalid': Boolean(formErrors.url) }"
data-qa-selector="node_url_field"
type="text"
@blur="blur('url')"
@input="checkUrl"
/>
</gl-form-group>
</section>
......
export const SELECTIVE_SYNC_SHARDS = 'selectiveSyncShards';
export const SELECTIVE_SYNC_NAMESPACES = 'selectiveSyncNamespaceIds';
export const VALIDATION_FIELD_KEYS = {
NAME: 'name',
URL: 'url',
REPOS_MAX_CAPACITY: 'reposMaxCapacity',
FILES_MAX_CAPACITY: 'filesMaxCapacity',
CONTAINER_REPOSITORIES_MAX_CAPACITY: 'containerRepositoriesMaxCapacity',
VERIFICATION_MAX_CAPACITY: 'verificationMaxCapacity',
MINIMUM_REVERIFICATION_INTERVAL: 'minimumReverificationInterval',
};
......@@ -64,3 +64,5 @@ export const saveGeoNode = ({ dispatch }, node) => {
dispatch('receiveSaveGeoNodeError', response.data);
});
};
export const setError = ({ commit }, { key, error }) => commit(types.SET_ERROR, { key, error });
// eslint-disable-next-line import/prefer-default-export
export const formHasError = state => Object.values(state.formErrors).some(val => Boolean(val));
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import createState from './state';
......@@ -10,6 +11,7 @@ const createStore = () =>
new Vuex.Store({
actions,
mutations,
getters,
state: createState(),
});
export default createStore;
......@@ -4,3 +4,5 @@ export const RECEIVE_SYNC_NAMESPACES_ERROR = 'RECEIVE_SYNC_NAMESPACES_ERROR';
export const REQUEST_SAVE_GEO_NODE = 'REQUEST_SAVE_GEO_NODE';
export const RECEIVE_SAVE_GEO_NODE_COMPLETE = 'RECEIVE_SAVE_GEO_NODE_COMPLETE';
export const SET_ERROR = 'SET_ERROR';
......@@ -18,4 +18,7 @@ export default {
[types.RECEIVE_SAVE_GEO_NODE_COMPLETE](state) {
state.isLoading = false;
},
[types.SET_ERROR](state, { key, error }) {
state.formErrors[key] = error;
},
};
import { VALIDATION_FIELD_KEYS } from '../constants';
const createState = () => ({
isLoading: false,
synchronizationNamespaces: [],
formErrors: Object.values(VALIDATION_FIELD_KEYS).reduce(
(acc, cur) => ({ ...acc, [cur]: '' }),
{},
),
});
export default createState;
import { sprintf, s__ } from '~/locale';
import { isSafeURL } from '~/lib/utils/url_utility';
export const validateName = data => {
if (!data) {
return s__("Geo|Node name can't be blank");
} else if (data.length > 255) {
return s__('Geo|Node name should be between 1 and 255 characters');
}
return '';
};
export const validateUrl = data => {
if (!data) {
return s__("Geo|URL can't be blank");
} else if (!isSafeURL(data)) {
return s__('Geo|URL must be a valid url (ex: https://gitlab.com)');
}
return '';
};
export const validateCapacity = ({ data, label }) => {
if (!data && data !== 0) {
return sprintf(s__("Geo|%{label} can't be blank"), { label });
} else if (data < 1 || data > 999) {
return sprintf(s__('Geo|%{label} should be between 1-999'), { label });
}
return '';
};
---
title: Geo Form Validations
merge_request: 32263
author:
type: changed
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
import GeoNodeFormCapacities from 'ee/geo_node_form/components/geo_node_form_capacities.vue';
import { VALIDATION_FIELD_KEYS } from 'ee/geo_node_form/constants';
import { MOCK_NODE } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GeoNodeFormCapacities', () => {
let wrapper;
let store;
const propsData = {
const defaultProps = {
nodeData: MOCK_NODE,
};
const createComponent = () => {
wrapper = shallowMount(GeoNodeFormCapacities, {
propsData,
const createComponent = (props = {}) => {
store = new Vuex.Store({
state: {
formErrors: Object.values(VALIDATION_FIELD_KEYS).reduce(
(acc, cur) => ({ ...acc, [cur]: '' }),
{},
),
},
actions: {
setError({ state }, { key, error }) {
state.formErrors[key] = error;
},
},
});
wrapper = mount(GeoNodeFormCapacities, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
......@@ -28,6 +53,7 @@ describe('GeoNodeFormCapacities', () => {
wrapper.find('#node-verification-capacity-field');
const findGeoNodeFormReverificationIntervalField = () =>
wrapper.find('#node-reverification-interval-field');
const findErrorMessage = () => wrapper.find('.invalid-feedback');
describe('template', () => {
describe.each`
......@@ -45,8 +71,9 @@ describe('GeoNodeFormCapacities', () => {
showReverificationInterval,
}) => {
beforeEach(() => {
propsData.nodeData.primary = primaryNode;
createComponent();
createComponent({
nodeData: { ...defaultProps.nodeData, primary: primaryNode },
});
});
it(`it ${showRepoCapacity ? 'shows' : 'hides'} the Repository Capacity Field`, () => {
......@@ -82,14 +109,128 @@ describe('GeoNodeFormCapacities', () => {
});
},
);
describe.each`
data | showError | errorMessage
${null} | ${true} | ${"can't be blank"}
${''} | ${true} | ${"can't be blank"}
${-1} | ${true} | ${'should be between 1-999'}
${0} | ${true} | ${'should be between 1-999'}
${1} | ${false} | ${null}
${999} | ${false} | ${null}
${1000} | ${true} | ${'should be between 1-999'}
`(`errors`, ({ data, showError, errorMessage }) => {
describe('on primary node', () => {
beforeEach(() => {
createComponent({
nodeData: { ...defaultProps.nodeData, primary: true },
});
});
describe('Verification Capacity Field', () => {
beforeEach(() => {
findGeoNodeFormVerificationCapacityField().setValue(data);
});
it(`${showError ? 'shows' : 'hides'} error when data is ${data}`, () => {
expect(findGeoNodeFormVerificationCapacityField().classes('is-invalid')).toBe(
showError,
);
if (showError) {
expect(findErrorMessage().text()).toBe(`Verification capacity ${errorMessage}`);
}
});
});
describe('Reverification Interval Field', () => {
beforeEach(() => {
findGeoNodeFormReverificationIntervalField().setValue(data);
});
it(`${showError ? 'shows' : 'hides'} error when data is ${data}`, () => {
expect(findGeoNodeFormReverificationIntervalField().classes('is-invalid')).toBe(
showError,
);
if (showError) {
expect(findErrorMessage().text()).toBe(`Re-verification interval ${errorMessage}`);
}
});
});
});
describe('on secondary node', () => {
beforeEach(() => {
createComponent();
});
describe('Repository Capacity Field', () => {
beforeEach(() => {
findGeoNodeFormRepositoryCapacityField().setValue(data);
});
it(`${showError ? 'shows' : 'hides'} error when data is ${data}`, () => {
expect(findGeoNodeFormRepositoryCapacityField().classes('is-invalid')).toBe(showError);
if (showError) {
expect(findErrorMessage().text()).toBe(`Repository sync capacity ${errorMessage}`);
}
});
});
describe('File Capacity Field', () => {
beforeEach(() => {
findGeoNodeFormFileCapacityField().setValue(data);
});
it(`${showError ? 'shows' : 'hides'} error when data is ${data}`, () => {
expect(findGeoNodeFormFileCapacityField().classes('is-invalid')).toBe(showError);
if (showError) {
expect(findErrorMessage().text()).toBe(`File sync capacity ${errorMessage}`);
}
});
});
describe('Container Repository Capacity Field', () => {
beforeEach(() => {
findGeoNodeFormContainerRepositoryCapacityField().setValue(data);
});
it(`${showError ? 'shows' : 'hides'} error when data is ${data}`, () => {
expect(findGeoNodeFormContainerRepositoryCapacityField().classes('is-invalid')).toBe(
showError,
);
if (showError) {
expect(findErrorMessage().text()).toBe(
`Container repositories sync capacity ${errorMessage}`,
);
}
});
});
describe('Verification Capacity Field', () => {
beforeEach(() => {
findGeoNodeFormVerificationCapacityField().setValue(data);
});
it(`${showError ? 'shows' : 'hides'} error when data is ${data}`, () => {
expect(findGeoNodeFormVerificationCapacityField().classes('is-invalid')).toBe(
showError,
);
if (showError) {
expect(findErrorMessage().text()).toBe(`Verification capacity ${errorMessage}`);
}
});
});
});
});
});
describe('computed', () => {
describe('visibleFormGroups', () => {
describe('when nodeData.primary is true', () => {
beforeEach(() => {
propsData.nodeData.primary = true;
createComponent();
createComponent({
nodeData: { ...defaultProps.nodeData, primary: true },
});
});
it('contains conditional form groups for primary', () => {
......@@ -103,7 +244,6 @@ describe('GeoNodeFormCapacities', () => {
describe('when nodeData.primary is false', () => {
beforeEach(() => {
propsData.nodeData.primary = false;
createComponent();
});
......
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
import GeoNodeFormCore from 'ee/geo_node_form/components/geo_node_form_core.vue';
import { VALIDATION_FIELD_KEYS } from 'ee/geo_node_form/constants';
import { MOCK_NODE, STRING_OVER_255 } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GeoNodeFormCore', () => {
let wrapper;
let store;
const defaultProps = {
nodeData: MOCK_NODE,
};
const createComponent = (props = {}) => {
wrapper = shallowMount(GeoNodeFormCore, {
store = new Vuex.Store({
state: {
formErrors: Object.values(VALIDATION_FIELD_KEYS).reduce(
(acc, cur) => ({ ...acc, [cur]: '' }),
{},
),
},
actions: {
setError({ state }, { key, error }) {
state.formErrors[key] = error;
},
},
});
wrapper = mount(GeoNodeFormCore, {
localVue,
store,
propsData: {
...defaultProps,
...props,
......@@ -24,6 +46,7 @@ describe('GeoNodeFormCore', () => {
const findGeoNodeFormNameField = () => wrapper.find('#node-name-field');
const findGeoNodeFormUrlField = () => wrapper.find('#node-url-field');
const findErrorMessage = () => wrapper.find('.invalid-feedback');
describe('template', () => {
beforeEach(() => {
......@@ -37,68 +60,46 @@ describe('GeoNodeFormCore', () => {
it('renders Geo Node Form Url Field', () => {
expect(findGeoNodeFormUrlField().exists()).toBe(true);
});
});
describe('computed', () => {
describe.each`
data | dataDesc | blur | value
${''} | ${'empty'} | ${false} | ${true}
${''} | ${'empty'} | ${true} | ${false}
${STRING_OVER_255} | ${'over 255 chars'} | ${false} | ${true}
${STRING_OVER_255} | ${'over 255 chars'} | ${true} | ${false}
${'Test'} | ${'valid'} | ${false} | ${true}
${'Test'} | ${'valid'} | ${true} | ${true}
`(`validName`, ({ data, dataDesc, blur, value }) => {
beforeEach(() => {
createComponent({
nodeData: { ...defaultProps.nodeData, name: data },
describe('errors', () => {
describe.each`
data | showError | errorMessage
${null} | ${true} | ${"Node name can't be blank"}
${''} | ${true} | ${"Node name can't be blank"}
${STRING_OVER_255} | ${true} | ${'Node name should be between 1 and 255 characters'}
${'Test'} | ${false} | ${null}
`(`Name Field`, ({ data, showError, errorMessage }) => {
beforeEach(() => {
createComponent();
findGeoNodeFormNameField().setValue(data);
});
});
describe(`when data is: ${dataDesc}`, () => {
it(`returns ${value} when blur is ${blur}`, () => {
wrapper.vm.fieldBlurs.name = blur;