Commit 9b0ad368 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'design-repo-actions' into 'master'

Design Repo Sync Status - Actions

Closes #34336

See merge request gitlab-org/gitlab!19590
parents 52e736a6 a6c0096c
......@@ -208,4 +208,14 @@ export default {
const url = Api.buildUrl(this.geoDesignsPath);
return axios.get(url, { params });
},
initiateAllGeoDesignSyncs(action) {
const url = Api.buildUrl(this.geoDesignsPath);
return axios.post(`${url}/${action}`, {});
},
initiateGeoDesignSync({ projectId, action }) {
const url = Api.buildUrl(this.geoDesignsPath);
return axios.put(`${url}/${projectId}/${action}`, {});
},
};
<script>
import { GlLink } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { GlLink, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import { ACTION_TYPES } from '../store/constants';
import GeoDesignStatus from './geo_design_status.vue';
import GeoDesignTimeAgo from './geo_design_time_ago.vue';
......@@ -8,6 +10,7 @@ export default {
name: 'GeoDesign',
components: {
GlLink,
GlButton,
GeoDesignTimeAgo,
GeoDesignStatus,
},
......@@ -62,6 +65,10 @@ export default {
],
};
},
methods: {
...mapActions(['initiateDesignSync']),
},
actionTypes: ACTION_TYPES,
};
</script>
......@@ -69,6 +76,12 @@ export default {
<div class="card">
<div class="card-header d-flex align-center">
<gl-link class="font-weight-bold" :href="`/${name}`" target="_blank">{{ name }}</gl-link>
<div class="ml-auto">
<gl-button
@click="initiateDesignSync({ projectId, name, action: $options.actionTypes.RESYNC })"
>{{ __('Resync') }}</gl-button
>
</div>
</div>
<div class="card-body">
<div class="d-flex flex-column flex-md-row">
......
......@@ -26,7 +26,7 @@ export default {
<template>
<gl-empty-state :title="__('Design Sync Not Enabled')" :svg-path="geoSvgPath">
<template v-slot:description>
<template #description>
<div class="text-center">
<p>
{{
......
......@@ -36,7 +36,7 @@ export default {
<template>
<gl-empty-state :title="__('No Design Repositories match this filter')" :svg-path="issuesSvgPath">
<template v-slot:description>
<template #description>
<div class="text-center">
<p>{{ __('Adjust your filters/search criteria above.') }}</p>
<p v-html="linkText"></p>
......
<script>
import { mapActions, mapState } from 'vuex';
import { debounce } from 'underscore';
import { GlTabs, GlTab, GlFormInput } from '@gitlab/ui';
import { DEFAULT_SEARCH_DELAY } from '../store/constants';
import { GlTabs, GlTab, GlFormInput, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { DEFAULT_SEARCH_DELAY, ACTION_TYPES } from '../store/constants';
export default {
name: 'GeoDesignsFilterBar',
......@@ -10,6 +11,9 @@ export default {
GlTabs,
GlTab,
GlFormInput,
GlDropdown,
GlDropdownItem,
Icon,
},
computed: {
...mapState(['currentFilterIndex', 'filterOptions', 'searchFilter']),
......@@ -24,12 +28,13 @@ export default {
},
},
methods: {
...mapActions(['setFilter', 'setSearch', 'fetchDesigns']),
...mapActions(['setFilter', 'setSearch', 'fetchDesigns', 'initiateAllDesignSyncs']),
filterChange(filterIndex) {
this.setFilter(filterIndex);
this.fetchDesigns();
},
},
actionTypes: ACTION_TYPES,
};
</script>
......@@ -41,9 +46,21 @@ export default {
:title="filter"
title-item-class="text-capitalize"
/>
<template v-slot:tabs-end>
<template #tabs-end>
<div class="d-flex align-items-center ml-auto">
<gl-form-input v-model="search" type="text" :placeholder="__(`Filter by name...`)" />
<gl-dropdown class="ml-2">
<template #button-content>
<span>
<icon name="cloud-gear" />
{{ __('Batch operations') }}
<icon name="chevron-down" />
</span>
</template>
<gl-dropdown-item @click="initiateAllDesignSyncs($options.actionTypes.RESYNC)">{{
__('Resync all designs')
}}</gl-dropdown-item>
</gl-dropdown>
</div>
</template>
</gl-tabs>
......
......@@ -47,6 +47,49 @@ export const fetchDesigns = ({ state, dispatch }) => {
});
};
// Initiate All Design Syncs
export const requestInitiateAllDesignSyncs = ({ commit }) =>
commit(types.REQUEST_INITIATE_ALL_DESIGN_SYNCS);
export const receiveInitiateAllDesignSyncsSuccess = ({ commit, dispatch }) => {
commit(types.RECEIVE_INITIATE_ALL_DESIGN_SYNCS_SUCCESS);
dispatch('fetchDesigns');
};
export const receiveInitiateAllDesignSyncsError = ({ commit }) => {
createFlash(__(`There was an error syncing the Design Repositories.`));
commit(types.RECEIVE_INITIATE_ALL_DESIGN_SYNCS_ERROR);
};
export const initiateAllDesignSyncs = ({ dispatch }, action) => {
dispatch('requestInitiateAllDesignSyncs');
Api.initiateAllGeoDesignSyncs(action)
.then(() => dispatch('receiveInitiateAllDesignSyncsSuccess'))
.catch(() => {
dispatch('receiveInitiateAllDesignSyncsError');
});
};
// Initiate Design Sync
export const requestInitiateDesignSync = ({ commit }) => commit(types.REQUEST_INITIATE_DESIGN_SYNC);
export const receiveInitiateDesignSyncSuccess = ({ commit, dispatch }) => {
commit(types.RECEIVE_INITIATE_DESIGN_SYNC_SUCCESS);
dispatch('fetchDesigns');
};
export const receiveInitiateDesignSyncError = ({ commit }, { name }) => {
createFlash(__(`There was an error syncing project '${name}'`));
commit(types.RECEIVE_INITIATE_DESIGN_SYNC_ERROR);
};
export const initiateDesignSync = ({ dispatch }, { projectId, name, action }) => {
dispatch('requestInitiateDesignSync');
Api.initiateGeoDesignSync({ projectId, action })
.then(() => dispatch('receiveInitiateDesignSyncSuccess'))
.catch(() => {
dispatch('receiveInitiateDesignSyncError', { name });
});
};
// Filtering/Pagination
export const setFilter = ({ commit }, filterIndex) => {
commit(types.SET_FILTER, filterIndex);
......
......@@ -22,3 +22,10 @@ export const STATUS_ICON_CLASS = {
};
export const DEFAULT_SEARCH_DELAY = 500;
export const ACTION_TYPES = {
RESYNC: 'resync',
// Below not implemented yet
REVERIFY: 'reverify',
FORCE_REDOWNLOAD: 'force_redownload',
};
......@@ -5,3 +5,12 @@ export const SET_PAGE = 'SET_PAGE';
export const REQUEST_DESIGNS = 'REQUEST_DESIGNS';
export const RECEIVE_DESIGNS_SUCCESS = 'RECEIVE_DESIGNS_SUCCESS';
export const RECEIVE_DESIGNS_ERROR = 'RECEIVE_DESIGNS_ERROR';
export const REQUEST_INITIATE_ALL_DESIGN_SYNCS = 'REQUEST_INITIATE_ALL_DESIGN_SYNCS';
export const RECEIVE_INITIATE_ALL_DESIGN_SYNCS_SUCCESS =
'RECEIVE_INITIATE_ALL_DESIGN_SYNCS_SUCCESS';
export const RECEIVE_INITIATE_ALL_DESIGN_SYNCS_ERROR = 'RECEIVE_INITIATE_ALL_DESIGN_SYNCS_ERROR';
export const REQUEST_INITIATE_DESIGN_SYNC = 'REQUEST_INITIATE_DESIGN_SYNC';
export const RECEIVE_INITIATE_DESIGN_SYNC_SUCCESS = 'RECEIVE_INITIATE_DESIGN_SYNC_SUCCESS';
export const RECEIVE_INITIATE_DESIGN_SYNC_ERROR = 'RECEIVE_INITIATE_DESIGN_SYNC_ERROR';
......@@ -27,4 +27,22 @@ export default {
state.pageSize = 0;
state.totalDesigns = 0;
},
[types.REQUEST_INITIATE_ALL_DESIGN_SYNCS](state) {
state.isLoading = true;
},
[types.RECEIVE_INITIATE_ALL_DESIGN_SYNCS_SUCCESS](state) {
state.isLoading = false;
},
[types.RECEIVE_INITIATE_ALL_DESIGN_SYNCS_ERROR](state) {
state.isLoading = false;
},
[types.REQUEST_INITIATE_DESIGN_SYNC](state) {
state.isLoading = true;
},
[types.RECEIVE_INITIATE_DESIGN_SYNC_SUCCESS](state) {
state.isLoading = false;
},
[types.RECEIVE_INITIATE_DESIGN_SYNC_ERROR](state) {
state.isLoading = false;
},
};
......@@ -556,11 +556,19 @@ describe('Api', () => {
});
});
describe('GeoDesigns', () => {
let expectedUrl;
let apiResponse;
let mockParams;
beforeEach(() => {
expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/geo_replication/designs`;
});
describe('getGeoDesigns', () => {
it('fetches designs', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/geo_replication/designs`;
const apiResponse = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }];
const mockParams = { page: 1 };
apiResponse = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }];
mockParams = { page: 1 };
jest.spyOn(Api, 'buildUrl').mockReturnValue(expectedUrl);
jest.spyOn(axios, 'get');
......@@ -572,4 +580,47 @@ describe('Api', () => {
});
});
});
describe('initiateAllGeoDesignSyncs', () => {
it('POSTs with correct action', () => {
apiResponse = [{ status: 'ok' }];
mockParams = {};
const mockAction = 'reverify';
jest.spyOn(Api, 'buildUrl').mockReturnValue(expectedUrl);
jest.spyOn(axios, 'post');
mock.onPost(`${expectedUrl}/${mockAction}`).replyOnce(201, apiResponse);
return Api.initiateAllGeoDesignSyncs(mockAction).then(({ data }) => {
expect(data).toEqual(apiResponse);
expect(axios.post).toHaveBeenCalledWith(`${expectedUrl}/${mockAction}`, mockParams);
});
});
});
describe('initiateGeoDesignSync', () => {
it('PUTs with correct action and projectId', () => {
apiResponse = [{ status: 'ok' }];
mockParams = {};
const mockAction = 'reverify';
const mockProjectId = 1;
jest.spyOn(Api, 'buildUrl').mockReturnValue(expectedUrl);
jest.spyOn(axios, 'put');
mock.onPut(`${expectedUrl}/${mockProjectId}/${mockAction}`).replyOnce(201, apiResponse);
return Api.initiateGeoDesignSync({ projectId: mockProjectId, action: mockAction }).then(
({ data }) => {
expect(data).toEqual(apiResponse);
expect(axios.put).toHaveBeenCalledWith(
`${expectedUrl}/${mockProjectId}/${mockAction}`,
mockParams,
);
},
);
});
});
});
});
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
import { GlLink, GlButton } from '@gitlab/ui';
import GeoDesign from 'ee/geo_designs/components/geo_design.vue';
import store from 'ee/geo_designs/store';
import { ACTION_TYPES } from 'ee/geo_designs/store/constants';
import { MOCK_BASIC_FETCH_DATA_MAP } from '../mock_data';
const localVue = createLocalVue();
......@@ -12,6 +13,10 @@ describe('GeoDesignsApp', () => {
let wrapper;
const mockDesign = MOCK_BASIC_FETCH_DATA_MAP.data[0];
const actionSpies = {
initiateDesignSync: jest.fn(),
};
const propsData = {
name: mockDesign.name,
projectId: mockDesign.projectId,
......@@ -22,10 +27,13 @@ describe('GeoDesignsApp', () => {
};
const createComponent = () => {
wrapper = shallowMount(localVue.extend(GeoDesign), {
wrapper = mount(localVue.extend(GeoDesign), {
localVue,
store,
propsData,
methods: {
...actionSpies,
},
});
};
......@@ -35,6 +43,7 @@ describe('GeoDesignsApp', () => {
const findCard = () => wrapper.find('.card');
const findGlLink = () => findCard().find(GlLink);
const findGlButton = () => findCard().find(GlButton);
const findCardHeader = () => findCard().find('.card-header');
const findCardBody = () => findCard().find('.card-body');
......@@ -58,5 +67,20 @@ describe('GeoDesignsApp', () => {
it('GlLink renders', () => {
expect(findGlLink().exists()).toBe(true);
});
describe('ReSync Button', () => {
it('renders', () => {
expect(findGlButton().exists()).toBe(true);
});
it('calls initiateDesignSyncs when clicked', () => {
findGlButton().trigger('click');
expect(actionSpies.initiateDesignSync).toHaveBeenCalledWith({
projectId: propsData.projectId,
name: propsData.name,
action: ACTION_TYPES.RESYNC,
});
});
});
});
});
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
import { GlTabs, GlTab, GlFormInput } from '@gitlab/ui';
import { GlTabs, GlTab, GlFormInput, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import GeoDesignsFilterBar from 'ee/geo_designs/components/geo_designs_filter_bar.vue';
import store from 'ee/geo_designs/store';
import { DEFAULT_SEARCH_DELAY } from 'ee/geo_designs/store/constants';
......@@ -15,6 +15,7 @@ describe('GeoDesignsFilterBar', () => {
setSearch: jest.fn(),
setFilter: jest.fn(),
fetchDesigns: jest.fn(),
initiateAllDesignSyncs: jest.fn(),
};
const createComponent = () => {
......@@ -34,6 +35,8 @@ describe('GeoDesignsFilterBar', () => {
const findGlTabsContainer = () => wrapper.find(GlTabs);
const findGlTab = () => findGlTabsContainer().findAll(GlTab);
const findGlFormInput = () => findGlTabsContainer().find(GlFormInput);
const findGlDropdown = () => findGlTabsContainer().find(GlDropdown);
const findGlDropdownItem = () => findGlTabsContainer().find(GlDropdownItem);
describe('template', () => {
beforeEach(() => {
......@@ -58,6 +61,22 @@ describe('GeoDesignsFilterBar', () => {
it('renders GlFormInput', () => {
expect(findGlFormInput().exists()).toBe(true);
});
it('renders GlDropdown', () => {
expect(findGlDropdown().exists()).toBe(true);
});
describe('GlDropDownItem', () => {
it('renders', () => {
expect(findGlDropdownItem().exists()).toBe(true);
});
it('calls initiateAllDesignSyncs when clicked', () => {
const innerButton = findGlDropdownItem().find('button');
innerButton.trigger('click');
expect(actionSpies.initiateAllDesignSyncs).toHaveBeenCalled();
});
});
});
describe('when search changes', () => {
......
......@@ -38,3 +38,7 @@ export const MOCK_BASIC_FETCH_DATA_MAP = {
perPage: MOCK_BASIC_FETCH_RESPONSE.headers['x-per-page'],
total: MOCK_BASIC_FETCH_RESPONSE.headers['x-total'],
};
export const MOCK_BASIC_POST_RESPONSE = {
status: 'ok',
};
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 * as actions from 'ee/geo_designs/store/actions';
import * as types from 'ee/geo_designs/store/mutation_types';
import createState from 'ee/geo_designs/store/state';
import flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { MOCK_BASIC_FETCH_DATA_MAP, MOCK_BASIC_FETCH_RESPONSE } from '../mock_data';
import { ACTION_TYPES } from 'ee/geo_designs/store/constants';
import {
MOCK_BASIC_FETCH_DATA_MAP,
MOCK_BASIC_FETCH_RESPONSE,
MOCK_BASIC_POST_RESPONSE,
} from '../mock_data';
jest.mock('~/flash');
......@@ -42,16 +47,18 @@ describe('GeoDesigns Store Actions', () => {
});
describe('receiveDesignsError', () => {
it('should commit mutation RECEIVE_DESIGNS_ERROR and call flash', done => {
it('should commit mutation RECEIVE_DESIGNS_ERROR', () => {
testAction(
actions.receiveDesignsError,
null,
state,
[{ type: types.RECEIVE_DESIGNS_ERROR }],
[],
done,
);
() => {
expect(flash).toHaveBeenCalledTimes(1);
flash.mockClear();
},
);
});
});
......@@ -98,7 +105,6 @@ describe('GeoDesigns Store Actions', () => {
describe('queryParams', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock
.onGet()
.replyOnce(200, MOCK_BASIC_FETCH_RESPONSE.data, MOCK_BASIC_FETCH_RESPONSE.headers);
......@@ -162,6 +168,192 @@ describe('GeoDesigns Store Actions', () => {
});
});
describe('requestInitiateAllDesignSyncs', () => {
it('should commit mutation REQUEST_INITIATE_ALL_DESIGN_SYNCS', done => {
testAction(
actions.requestInitiateAllDesignSyncs,
null,
state,
[{ type: types.REQUEST_INITIATE_ALL_DESIGN_SYNCS }],
[],
done,
);
});
});
describe('receiveInitiateAllDesignSyncsSuccess', () => {
it('should commit mutation RECEIVE_INITIATE_ALL_DESIGN_SYNCS_SUCCESS and fetchDesigns', done => {
testAction(
actions.receiveInitiateAllDesignSyncsSuccess,
null,
state,
[{ type: types.RECEIVE_INITIATE_ALL_DESIGN_SYNCS_SUCCESS }],
[{ type: 'fetchDesigns' }],
done,
);
});
});
describe('receiveInitiateAllDesignSyncsError', () => {
it('should commit mutation RECEIVE_INITIATE_ALL_DESIGN_SYNCS_ERROR', () => {
testAction(
actions.receiveInitiateAllDesignSyncsError,
ACTION_TYPES.RESYNC,
state,
[{ type: types.RECEIVE_INITIATE_ALL_DESIGN_SYNCS_ERROR }],
[],
() => {
expect(flash).toHaveBeenCalledTimes(1);
flash.mockClear();
},
);
});
});
describe('initiateAllDesignSyncs', () => {
let action;
describe('on success', () => {
beforeEach(() => {
action = ACTION_TYPES.RESYNC;
mock.onPost().replyOnce(201, MOCK_BASIC_POST_RESPONSE);
});
it('should dispatch the request and success actions', done => {
testAction(
actions.initiateAllDesignSyncs,
action,
state,
[],
[
{ type: 'requestInitiateAllDesignSyncs' },
{ type: 'receiveInitiateAllDesignSyncsSuccess' },
],
done,
);
});
});
describe('on error', () => {
beforeEach(() => {
action = ACTION_TYPES.RESYNC;
mock.onPost().replyOnce(500);
});
it('should dispatch the request and error actions', done => {
testAction(
actions.initiateAllDesignSyncs,
action,
state,
[],
[
{ type: 'requestInitiateAllDesignSyncs' },
{ type: 'receiveInitiateAllDesignSyncsError' },
],
done,
);
});
});
});
describe('requestInitiateDesignSync', () => {
it('should commit mutation REQUEST_INITIATE_DESIGN_SYNC', done => {
testAction(
actions.requestInitiateDesignSync,
null,
state,
[{ type: types.REQUEST_INITIATE_DESIGN_SYNC }],
[],
done,
);
});
});
describe('receiveInitiateDesignSyncSuccess', () => {
it('should commit mutation RECEIVE_INITIATE_DESIGN_SYNC_SUCCESS and fetchDesigns', done => {
testAction(
actions.receiveInitiateDesignSyncSuccess,
null,
state,
[{ type: types.RECEIVE_INITIATE_DESIGN_SYNC_SUCCESS }],
[{ type: 'fetchDesigns' }],
done,
);
});
});
describe('receiveInitiateDesignSyncError', () => {
it('should commit mutation RECEIVE_INITIATE_DESIGN_SYNC_ERROR', () => {
testAction(
actions.receiveInitiateDesignSyncError,
{ action: ACTION_TYPES.RESYNC, projectId: 1, projectName: 'test' },
state,
[{ type: types.RECEIVE_INITIATE_DESIGN_SYNC_ERROR }],
[],
() => {
expect(flash).toHaveBeenCalledTimes(1);
flash.mockClear();
},
);
});
});
describe('initiateDesignSync', () => {
let action;
let projectId;
let name;
describe('on success', () => {
beforeEach(() => {
action = ACTION_TYPES.RESYNC;
projectId = 1;
name = 'test';
mock.onPut().replyOnce(201, MOCK_BASIC_POST_RESPONSE);
});
it('should dispatch the request and success actions', done => {
testAction(
actions.initiateDesignSync,
{ projectId, name, action },
state,
[],
[{ type: 'requestInitiateDesignSync' }, { type: 'receiveInitiateDesignSyncSuccess' }],
done,
);
});
});
describe('on error', () => {
beforeEach(() => {
action = ACTION_TYPES.RESYNC;
projectId = 1;
name = 'test';
mock.onPut().replyOnce(500);
});
it('should dispatch the request and error actions', done => {
testAction(
actions.initiateDesignSync,
{ projectId, name, action },
state,
[],
[
{ type: 'requestInitiateDesignSync' },
{
type: 'receiveInitiateDesignSyncError',
payload: { name: 'test' },
},
],
done,
);
});
});
});
describe('setFilter', () => {
it('should commit mutation SET_FILTER', done => {
const testValue = 1;
......
......@@ -118,4 +118,23 @@ describe('GeoDesigns Store Mutations', () => {
expect(state.totalDesigns).toEqual(0);
});
});
describe.each`
mutation | loadingBefore | loadingAfter
${types.REQUEST_INITIATE_ALL_DESIGN_SYNCS} | ${false} | ${true}
${types.RECEIVE_INITIATE_ALL_DESIGN_SYNCS_SUCCESS} | ${true} | ${false}
${types.RECEIVE_INITIATE_ALL_DESIGN_SYNCS_ERROR} | ${true} | ${false}
${types.REQUEST_INITIATE_DESIGN_SYNC} | ${false} | ${true}
${types.RECEIVE_INITIATE_DESIGN_SYNC_SUCCESS} | ${true} | ${false}
${types.RECEIVE_INITIATE_DESIGN_SYNC_ERROR} | ${true} | ${false}
`(`Sync Mutations: `, ({ mutation, loadingBefore, loadingAfter }) => {
describe(`${mutation}`, () => {
it(`sets isLoading to ${loadingAfter}`, () => {
state.isLoading = loadingBefore;
mutations[mutation](state);
expect(state.isLoading).toEqual(loadingAfter);
});
});
});
});
......@@ -2567,6 +2567,9 @@ msgstr ""
msgid "BambooService|You must set up automatic revision labeling and a repository trigger in Bamboo."
msgstr ""
msgid "Batch operations"
msgstr ""
msgid "BatchComments|Delete all pending comments"
msgstr ""
......@@ -15434,6 +15437,12 @@ msgstr ""
msgid "Resume replication"
msgstr ""
msgid "Resync"
msgstr ""
msgid "Resync all designs"
msgstr ""
msgid "Retry"
msgstr ""
......@@ -18332,6 +18341,9 @@ msgstr ""
msgid "There was an error subscribing to this label."
msgstr ""
msgid "There was an error syncing the Design Repositories."
msgstr ""
msgid "There was an error trying to validate your query"
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