Commit 17249ad9 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '118841_05-node-form-api' into 'master'

Geo Node Form in Vue - API/Validations

Closes #118841

See merge request gitlab-org/gitlab!25851
parents 3a166309 c8488d67
......@@ -263,4 +263,14 @@ export default {
return axios.post(url);
},
createGeoNode(node) {
const url = Api.buildUrl(this.geoNodesPath);
return axios.post(url, node);
},
updateGeoNode(node) {
const url = Api.buildUrl(this.geoNodesPath);
return axios.put(`${url}/${node.id}`, node);
},
};
<script>
import { __ } from '~/locale';
import GeoNodeForm from './geo_node_form.vue';
export default {
......@@ -21,12 +22,17 @@ export default {
default: null,
},
},
computed: {
pageTitle() {
return this.node ? __('Edit Geo Node') : __('New Geo Node');
},
},
};
</script>
<template>
<article class="geo-node-form-container">
<h3 class="page-title">{{ node ? __('Edit Geo Node') : __('New Geo Node') }}</h3>
<h3 class="page-title">{{ pageTitle }}</h3>
<geo-node-form v-bind="$props" />
</article>
</template>
<script>
import { mapActions } 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';
import GeoNodeFormSelectiveSync from './geo_node_form_selective_sync.vue';
......@@ -50,12 +52,18 @@ export default {
},
};
},
computed: {
saveButtonTitle() {
return this.node ? __('Update') : __('Save');
},
},
created() {
if (this.node) {
this.nodeData = { ...this.node };
}
},
methods: {
...mapActions(['saveGeoNode']),
redirect() {
visitUrl('/admin/geo/nodes');
},
......@@ -115,7 +123,9 @@ export default {
</gl-form-group>
</section>
<section class="d-flex align-items-center mt-4">
<gl-button id="node-save-button" variant="success">{{ __('Save') }}</gl-button>
<gl-button id="node-save-button" variant="success" @click="saveGeoNode(nodeData)">{{
saveButtonTitle
}}</gl-button>
<gl-button id="node-cancel-button" class="ml-auto" @click="redirect">{{
__('Cancel')
}}</gl-button>
......
<script>
import { GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import { isSafeURL } from '~/lib/utils/url_utility';
export default {
name: 'GeoNodeFormCore',
......@@ -14,12 +16,43 @@ 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));
},
},
methods: {
blur(field) {
this.fieldBlurs[field] = true;
},
},
};
</script>
<template>
<section class="form-row">
<gl-form-group class="col-sm-6" :label="__('Name')" label-for="node-name-field">
<gl-form-group
class="col-sm-6"
:label="__('Name')"
label-for="node-name-field"
:state="validName"
:invalid-feedback="errors.name"
>
<template #description>
<gl-sprintf
:message="
......@@ -36,15 +69,22 @@ export default {
</template>
</gl-sprintf>
</template>
<gl-form-input id="node-name-field" v-model="nodeData.name" type="text" />
<gl-form-input
id="node-name-field"
v-model="nodeData.name"
type="text"
@blur="blur('name')"
/>
</gl-form-group>
<gl-form-group
class="col-sm-6"
:label="__('URL')"
label-for="node-url-field"
:description="__('The user-facing URL of the Geo node')"
:state="validUrl"
:invalid-feedback="errors.url"
>
<gl-form-input id="node-url-field" v-model="nodeData.url" type="text" />
<gl-form-input id="node-url-field" v-model="nodeData.url" type="text" @blur="blur('url')" />
</gl-form-group>
</section>
</template>
import Api from '~/api';
import ApiEE from 'ee/api';
import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import * as types from './mutation_types';
......@@ -22,3 +25,25 @@ export const fetchSyncNamespaces = ({ dispatch }, search) => {
dispatch('receiveSyncNamespacesError');
});
};
export const requestSaveGeoNode = ({ commit }) => commit(types.REQUEST_SAVE_GEO_NODE);
export const receiveSaveGeoNodeSuccess = ({ commit }) => {
commit(types.RECEIVE_SAVE_GEO_NODE_COMPLETE);
visitUrl('/admin/geo/nodes');
};
export const receiveSaveGeoNodeError = ({ commit }) => {
createFlash(__(`There was an error saving this Geo Node`));
commit(types.RECEIVE_SAVE_GEO_NODE_COMPLETE);
};
export const saveGeoNode = ({ dispatch }, node) => {
dispatch('requestSaveGeoNode');
const sanitizedNode = convertObjectPropsToSnakeCase(node);
const saveFunc = node.id ? 'updateGeoNode' : 'createGeoNode';
ApiEE[saveFunc](sanitizedNode)
.then(() => dispatch('receiveSaveGeoNodeSuccess'))
.catch(() => {
dispatch('receiveSaveGeoNodeError');
});
};
export const REQUEST_SYNC_NAMESPACES = 'REQUEST_SYNC_NAMESPACES';
export const RECEIVE_SYNC_NAMESPACES_SUCCESS = 'RECEIVE_SYNC_NAMESPACES_SUCCESS';
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';
......@@ -12,4 +12,10 @@ export default {
state.isLoading = false;
state.synchronizationNamespaces = [];
},
[types.REQUEST_SAVE_GEO_NODE](state) {
state.isLoading = true;
},
[types.RECEIVE_SAVE_GEO_NODE_COMPLETE](state) {
state.isLoading = false;
},
};
......@@ -641,4 +641,52 @@ describe('Api', () => {
});
});
});
describe('GeoNode', () => {
let expectedUrl;
let mockNode;
beforeEach(() => {
expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/geo_nodes`;
});
describe('createGeoNode', () => {
it('POSTs with correct action', () => {
mockNode = {
name: 'Mock Node',
url: 'https://mock_node.gitlab.com',
primary: false,
};
jest.spyOn(Api, 'buildUrl').mockReturnValue(expectedUrl);
jest.spyOn(axios, 'post');
mock.onPost(expectedUrl).replyOnce(201, mockNode);
return Api.createGeoNode(mockNode).then(({ data }) => {
expect(data).toEqual(mockNode);
expect(axios.post).toHaveBeenCalledWith(expectedUrl, mockNode);
});
});
});
describe('updateGeoNode', () => {
it('PUTs with correct action', () => {
mockNode = {
id: 1,
name: 'Mock Node',
url: 'https://mock_node.gitlab.com',
primary: false,
};
jest.spyOn(Api, 'buildUrl').mockReturnValue(expectedUrl);
jest.spyOn(axios, 'put');
mock.onPut(`${expectedUrl}/${mockNode.id}`).replyOnce(201, mockNode);
return Api.updateGeoNode(mockNode).then(({ data }) => {
expect(data).toEqual(mockNode);
expect(axios.put).toHaveBeenCalledWith(`${expectedUrl}/${mockNode.id}`, mockNode);
});
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import GeoNodeFormCore from 'ee/geo_node_form/components/geo_node_form_core.vue';
import { MOCK_NODE } from '../mock_data';
import { MOCK_NODE, STRING_OVER_255 } from '../mock_data';
describe('GeoNodeFormCore', () => {
let wrapper;
const propsData = {
const defaultProps = {
nodeData: MOCK_NODE,
};
const createComponent = () => {
const createComponent = (props = {}) => {
wrapper = shallowMount(GeoNodeFormCore, {
propsData,
propsData: {
...defaultProps,
...props,
},
});
};
......@@ -35,4 +38,68 @@ describe('GeoNodeFormCore', () => {
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(`when data is: ${dataDesc}`, () => {
it(`returns ${value} when blur is ${blur}`, () => {
wrapper.vm.fieldBlurs.name = blur;
expect(wrapper.vm.validName).toBe(value);
});
});
});
describe.each`
data | dataDesc | blur | value
${''} | ${'empty'} | ${false} | ${true}
${''} | ${'empty'} | ${true} | ${false}
${'abcd'} | ${'invalid url'} | ${false} | ${true}
${'abcd'} | ${'invalid url'} | ${true} | ${false}
${'https://gitlab.com'} | ${'valid url'} | ${false} | ${true}
${'https://gitlab.com'} | ${'valid url'} | ${true} | ${true}
`(`validUrl`, ({ data, dataDesc, blur, value }) => {
beforeEach(() => {
createComponent({
nodeData: { ...defaultProps.nodeData, url: data },
});
});
describe(`when data is: ${dataDesc}`, () => {
it(`returns ${value} when blur is ${blur}`, () => {
wrapper.vm.fieldBlurs.url = blur;
expect(wrapper.vm.validUrl).toBe(value);
});
});
});
});
describe('methods', () => {
describe('blur', () => {
beforeEach(() => {
createComponent();
});
it('sets fieldBlur[field] to true', () => {
expect(wrapper.vm.fieldBlurs.name).toBeFalsy();
wrapper.vm.blur('name');
expect(wrapper.vm.fieldBlurs.name).toBeTruthy();
});
});
});
});
......@@ -33,6 +33,7 @@ describe('GeoNodeForm', () => {
const findGeoNodeInternalUrlField = () => wrapper.find('#node-internal-url-field');
const findGeoNodeFormCapacitiesField = () => wrapper.find(GeoNodeFormCapacities);
const findGeoNodeObjectStorageField = () => wrapper.find('#node-object-storage-field');
const findGeoNodeSaveButton = () => wrapper.find('#node-save-button');
const findGeoNodeCancelButton = () => wrapper.find('#node-cancel-button');
describe('template', () => {
......@@ -84,6 +85,18 @@ describe('GeoNodeForm', () => {
});
describe('methods', () => {
describe('saveGeoNode', () => {
beforeEach(() => {
createComponent();
wrapper.vm.saveGeoNode = jest.fn();
});
it('calls saveGeoNode when save is clicked', () => {
findGeoNodeSaveButton().vm.$emit('click');
expect(wrapper.vm.saveGeoNode).toHaveBeenCalledWith(MOCK_NODE);
});
});
describe('redirect', () => {
beforeEach(() => {
createComponent();
......@@ -91,7 +104,7 @@ describe('GeoNodeForm', () => {
it('calls visitUrl when cancel is clicked', () => {
findGeoNodeCancelButton().vm.$emit('click');
expect(visitUrl).toHaveBeenCalled();
expect(visitUrl).toHaveBeenCalledWith('/admin/geo/nodes');
});
});
......
......@@ -43,6 +43,9 @@ export const MOCK_SYNC_NAMESPACES = [
},
];
export const STRING_OVER_255 =
'ynzF7m5XjQQAlHfzPpDLhiaFZH84Zds47cHLWpRqRGTKjmXCe4frDWjIrjzfchpoOOX2jmK4wLRbyw9oTuzFmMPZhTK14mVoZTfaLXOBeH9F0S1XT3v7kszTC4cMLJvNsto7iSQ2PGxTGpZXFSQTL2UuMTTQ5GiARLVLS7CEEW75orbJh5kbKM6CRXpu4EliGRKKSwHMtXQ2ZDi01yvWOXc7ymNHeEooT4aDC7xq7g1uslbq1aVEWylVixSDARob';
export const MOCK_NODE = {
id: 1,
name: 'Mock Node',
......
......@@ -2,17 +2,31 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import * as actions from 'ee/geo_node_form/store/actions';
import * as types from 'ee/geo_node_form/store/mutation_types';
import createState from 'ee/geo_node_form/store/state';
import { MOCK_SYNC_NAMESPACES } from '../mock_data';
import { MOCK_SYNC_NAMESPACES, MOCK_NODE } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
joinPaths: jest.fn(),
}));
describe('GeoNodeForm Store Actions', () => {
let state;
let mock;
const noCallback = () => {};
const flashCallback = () => {
expect(flash).toHaveBeenCalledTimes(1);
flash.mockClear();
};
const visitUrlCallback = () => {
expect(visitUrl).toHaveBeenCalledWith('/admin/geo/nodes');
};
beforeEach(() => {
state = createState();
mock = new MockAdapter(axios);
......@@ -22,83 +36,39 @@ describe('GeoNodeForm Store Actions', () => {
mock.restore();
});
describe('requestSyncNamespaces', () => {
it('should commit mutation REQUEST_SYNC_NAMESPACES', done => {
testAction(
actions.requestSyncNamespaces,
null,
state,
[{ type: types.REQUEST_SYNC_NAMESPACES }],
[],
done,
);
});
});
describe('receiveSyncNamespacesSuccess', () => {
it('should commit mutation RECEIVE_SYNC_NAMESPACES_SUCCESS', done => {
testAction(
actions.receiveSyncNamespacesSuccess,
MOCK_SYNC_NAMESPACES,
state,
[{ type: types.RECEIVE_SYNC_NAMESPACES_SUCCESS, payload: MOCK_SYNC_NAMESPACES }],
[],
done,
);
});
});
describe('receiveSyncNamespacesError', () => {
it('should commit mutation RECEIVE_SYNC_NAMESPACES_ERROR', () => {
testAction(
actions.receiveSyncNamespacesError,
null,
state,
[{ type: types.RECEIVE_SYNC_NAMESPACES_ERROR }],
[],
() => {
expect(flash).toHaveBeenCalledTimes(1);
flash.mockClear();
},
);
});
});
describe('fetchSyncNamespaces', () => {
describe('on success', () => {
beforeEach(() => {
mock.onGet().replyOnce(200, MOCK_SYNC_NAMESPACES);
});
it('should dispatch the request and success actions', done => {
testAction(
actions.fetchSyncNamespaces,
{},
state,
[],
[
{ type: 'requestSyncNamespaces' },
{ type: 'receiveSyncNamespacesSuccess', payload: MOCK_SYNC_NAMESPACES },
],
done,
);
describe.each`
action | data | mutationName | mutationCall | callback
${actions.requestSyncNamespaces} | ${null} | ${types.REQUEST_SYNC_NAMESPACES} | ${{ type: types.REQUEST_SYNC_NAMESPACES }} | ${noCallback}
${actions.receiveSyncNamespacesSuccess} | ${MOCK_SYNC_NAMESPACES} | ${types.RECEIVE_SYNC_NAMESPACES_SUCCESS} | ${{ type: types.RECEIVE_SYNC_NAMESPACES_SUCCESS, payload: MOCK_SYNC_NAMESPACES }} | ${noCallback}
${actions.receiveSyncNamespacesError} | ${null} | ${types.RECEIVE_SYNC_NAMESPACES_ERROR} | ${{ type: types.RECEIVE_SYNC_NAMESPACES_ERROR }} | ${flashCallback}
${actions.requestSaveGeoNode} | ${null} | ${types.REQUEST_SAVE_GEO_NODE} | ${{ type: types.REQUEST_SAVE_GEO_NODE }} | ${noCallback}
${actions.receiveSaveGeoNodeSuccess} | ${null} | ${types.RECEIVE_SAVE_GEO_NODE_COMPLETE} | ${{ type: types.RECEIVE_SAVE_GEO_NODE_COMPLETE }} | ${visitUrlCallback}
${actions.receiveSaveGeoNodeError} | ${null} | ${types.RECEIVE_SAVE_GEO_NODE_COMPLETE} | ${{ type: types.RECEIVE_SAVE_GEO_NODE_COMPLETE }} | ${flashCallback}
`(`non-axios calls`, ({ action, data, mutationName, mutationCall, callback }) => {
describe(action.name, () => {
it(`should commit mutation ${mutationName}`, () => {
testAction(action, data, state, [mutationCall], [], callback);
});
});
});
describe('on error', () => {
beforeEach(() => {
mock.onGet().replyOnce(500, {});
});
it('should dispatch the request and error actions', done => {
testAction(
actions.fetchSyncNamespaces,
{},
state,
[],
[{ type: 'requestSyncNamespaces' }, { type: 'receiveSyncNamespacesError' }],
done,
);
describe.each`
action | axiosMock | data | type | actionCalls
${actions.fetchSyncNamespaces} | ${{ method: 'onGet', code: 200, res: MOCK_SYNC_NAMESPACES }} | ${null} | ${'success'} | ${[{ type: 'requestSyncNamespaces' }, { type: 'receiveSyncNamespacesSuccess', payload: MOCK_SYNC_NAMESPACES }]}
${actions.fetchSyncNamespaces} | ${{ method: 'onGet', code: 500, res: null }} | ${null} | ${'error'} | ${[{ type: 'requestSyncNamespaces' }, { type: 'receiveSyncNamespacesError' }]}
${actions.saveGeoNode} | ${{ method: 'onPost', code: 200, res: { ...MOCK_NODE, id: null } }} | ${{ ...MOCK_NODE, id: null }} | ${'success'} | ${[{ type: 'requestSaveGeoNode' }, { type: 'receiveSaveGeoNodeSuccess' }]}
${actions.saveGeoNode} | ${{ method: 'onPost', code: 500, res: null }} | ${{ ...MOCK_NODE, id: null }} | ${'error'} | ${[{ type: 'requestSaveGeoNode' }, { type: 'receiveSaveGeoNodeError' }]}
${actions.saveGeoNode} | ${{ method: 'onPut', code: 200, res: MOCK_NODE }} | ${MOCK_NODE} | ${'success'} | ${[{ type: 'requestSaveGeoNode' }, { type: 'receiveSaveGeoNodeSuccess' }]}
${actions.saveGeoNode} | ${{ method: 'onPut', code: 500, res: null }} | ${MOCK_NODE} | ${'error'} | ${[{ type: 'requestSaveGeoNode' }, { type: 'receiveSaveGeoNodeError' }]}
`(`axios calls`, ({ action, axiosMock, data, type, actionCalls }) => {
describe(action.name, () => {
describe(`on ${type}`, () => {
beforeEach(() => {
mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res);
});
it(`should dispatch the correct request and actions`, done => {
testAction(action, data, state, [], actionCalls, done);
});
});
});
});
......
......@@ -9,21 +9,26 @@ describe('GeoNodeForm Store Mutations', () => {
state = createState();
});
describe('REQUEST_SYNC_NAMESPACES', () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_SYNC_NAMESPACES](state);
expect(state.isLoading).toEqual(true);
describe.each`
mutation | loadingBefore | loadingAfter
${types.REQUEST_SYNC_NAMESPACES} | ${false} | ${true}
${types.RECEIVE_SYNC_NAMESPACES_SUCCESS} | ${true} | ${false}
${types.RECEIVE_SYNC_NAMESPACES_ERROR} | ${true} | ${false}
${types.REQUEST_SAVE_GEO_NODE} | ${false} | ${true}
${types.RECEIVE_SAVE_GEO_NODE_COMPLETE} | ${true} | ${false}
${types.RECEIVE_SAVE_GEO_NODE_COMPLETE} | ${true} | ${false}
`(`Loading Mutations: `, ({ mutation, loadingBefore, loadingAfter }) => {
describe(`${mutation}`, () => {
it(`sets isLoading to ${loadingAfter}`, () => {
state.isLoading = loadingBefore;
mutations[mutation](state);
expect(state.isLoading).toEqual(loadingAfter);
});
});
});
describe('RECEIVE_SYNC_NAMESPACES_SUCCESS', () => {
it('sets isLoading to false', () => {
state.isLoading = true;
mutations[types.RECEIVE_SYNC_NAMESPACES_SUCCESS](state, MOCK_SYNC_NAMESPACES);
expect(state.isLoading).toEqual(false);
});
it('sets synchronizationNamespaces array with namespace data', () => {
mutations[types.RECEIVE_SYNC_NAMESPACES_SUCCESS](state, MOCK_SYNC_NAMESPACES);
expect(state.synchronizationNamespaces).toBe(MOCK_SYNC_NAMESPACES);
......@@ -31,13 +36,6 @@ describe('GeoNodeForm Store Mutations', () => {
});
describe('RECEIVE_SYNC_NAMESPACES_ERROR', () => {
it('sets isLoading to false', () => {
state.isLoading = true;
mutations[types.RECEIVE_SYNC_NAMESPACES_ERROR](state);
expect(state.isLoading).toEqual(false);
});
it('resets synchronizationNamespaces array', () => {
state.synchronizationNamespaces = MOCK_SYNC_NAMESPACES;
......
......@@ -12960,6 +12960,9 @@ msgstr ""
msgid "Name has already been taken"
msgstr ""
msgid "Name must be between 1 and 255 characters"
msgstr ""
msgid "Name new label"
msgstr ""
......@@ -20099,6 +20102,9 @@ msgstr ""
msgid "There was an error resetting user pipeline minutes."
msgstr ""
msgid "There was an error saving this Geo Node"
msgstr ""
msgid "There was an error saving your changes."
msgstr ""
......@@ -21264,6 +21270,9 @@ msgstr ""
msgid "URL"
msgstr ""
msgid "URL must be a valid url (ex: https://gitlab.com)"
msgstr ""
msgid "URL of the external storage that will serve the repository static objects (e.g. archives, blobs, ...)."
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