Commit 392b9abc authored by Illya Klymov's avatar Illya Klymov Committed by Mark Florian

Update ImportProjectsTable component

- introduce support for incompatible repos rows
- refactor storage according to latest Vuex guidelines
- added slots for warning messages
parent 631b3f2a
<script>
import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import ImportedProjectTableRow from './imported_project_table_row.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue';
import IncompatibleRepoTableRow from './incompatible_repo_table_row.vue';
import eventHub from '../event_hub';
const reposFetchThrottleDelay = 1000;
......@@ -15,8 +15,9 @@ export default {
components: {
ImportedProjectTableRow,
ProviderRepoTableRow,
LoadingButton,
IncompatibleRepoTableRow,
GlLoadingIcon,
GlButton,
},
props: {
providerTitle: {
......@@ -26,8 +27,25 @@ export default {
},
computed: {
...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos', 'filter']),
...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']),
...mapState([
'importedProjects',
'providerRepos',
'incompatibleRepos',
'isLoadingRepos',
'filter',
]),
...mapGetters([
'isImportingAnyRepo',
'hasProviderRepos',
'hasImportedProjects',
'hasIncompatibleRepos',
]),
importAllButtonText() {
return this.hasIncompatibleRepos
? __('Import all compatible repositories')
: __('Import all repositories');
},
emptyStateText() {
return sprintf(__('No %{providerTitle} repositories found'), {
......@@ -68,7 +86,6 @@ export default {
},
throttledFetchRepos: throttle(function fetch() {
eventHub.$off('importAll');
this.fetchRepos();
}, reposFetchThrottleDelay),
},
......@@ -80,17 +97,24 @@ export default {
<p class="light text-nowrap mt-2">
{{ s__('ImportProjects|Select the projects you want to import') }}
</p>
<div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
<loading-button
container-class="btn btn-success js-import-all"
<template v-if="hasIncompatibleRepos">
<slot name="incompatible-repos-warning"> </slot>
</template>
<div
v-if="!isLoadingRepos"
class="d-flex justify-content-between align-items-end flex-wrap mb-3"
>
<gl-button
variant="success"
:loading="isImportingAnyRepo"
:label="__('Import all repositories')"
:disabled="!hasProviderRepos"
type="button"
@click="importAll"
/>
<form novalidate @submit.prevent>
>
{{ importAllButtonText }}
</gl-button>
<slot name="actions"></slot>
<form class="gl-ml-auto" novalidate @submit.prevent>
<input
:value="filter"
data-qa-selector="githubish_import_filter_field"
......@@ -109,7 +133,10 @@ export default {
class="js-loading-button-icon import-projects-loading-icon"
size="md"
/>
<div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive">
<div
v-else-if="hasProviderRepos || hasImportedProjects || hasIncompatibleRepos"
class="table-responsive"
>
<table class="table import-table">
<thead>
<th class="import-jobs-from-col">{{ fromHeaderText }}</th>
......@@ -124,6 +151,11 @@ export default {
:project="project"
/>
<provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" />
<incompatible-repo-table-row
v-for="repo in incompatibleRepos"
:key="repo.id"
:repo="repo"
/>
</tbody>
</table>
</div>
......
<script>
import { GlBadge } from '@gitlab/ui';
export default {
components: {
GlBadge,
},
props: {
repo: {
type: Object,
required: true,
},
},
};
</script>
<template>
<tr class="import-row">
<td>
<a :href="repo.providerLink" rel="noreferrer noopener" target="_blank">
{{ repo.fullName }}
</a>
</td>
<td></td>
<td></td>
<td>
<gl-badge variant="danger">{{ __('Incompatible project') }}</gl-badge>
</td>
</tr>
</template>
......@@ -53,7 +53,11 @@ export default {
},
created() {
eventHub.$on('importAll', () => this.importRepo());
eventHub.$on('importAll', this.importRepo);
},
beforeDestroy() {
eventHub.$off('importAll', this.importRepo);
},
methods: {
......
import Vue from 'vue';
import { mapActions } from 'vuex';
import Translate from '../vue_shared/translate';
import ImportProjectsTable from './components/import_projects_table.vue';
import { parseBoolean } from '../lib/utils/common_utils';
......@@ -7,42 +6,44 @@ import createStore from './store';
Vue.use(Translate);
export default function mountImportProjectsTable(mountElement) {
if (!mountElement) return undefined;
export function initStoreFromElement(element) {
const {
reposPath,
provider,
providerTitle,
canSelectNamespace,
jobsPath,
importPath,
ciCdOnly,
} = mountElement.dataset;
} = element.dataset;
const store = createStore();
return new Vue({
el: mountElement,
store,
return createStore({
reposPath,
provider,
jobsPath,
importPath,
defaultTargetNamespace: gon.current_username,
ciCdOnly: parseBoolean(ciCdOnly),
canSelectNamespace: parseBoolean(canSelectNamespace),
});
}
created() {
this.setInitialData({
reposPath,
provider,
jobsPath,
importPath,
defaultTargetNamespace: gon.current_username,
ciCdOnly: parseBoolean(ciCdOnly),
canSelectNamespace: parseBoolean(canSelectNamespace),
});
},
export function initPropsFromElement(element) {
return {
providerTitle: element.dataset.providerTitle,
};
}
methods: {
...mapActions(['setInitialData', 'setFilter']),
},
export default function mountImportProjectsTable(mountElement) {
if (!mountElement) return undefined;
const store = initStoreFromElement(mountElement);
const props = initPropsFromElement(mountElement);
return new Vue({
el: mountElement,
store,
render(createElement) {
return createElement(ImportProjectsTable, { props: { providerTitle } });
return createElement(ImportProjectsTable, { props });
},
});
}
......@@ -19,23 +19,18 @@ export const restartJobsPolling = () => {
if (eTagPoll) eTagPoll.restart();
};
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter);
export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos);
export const receiveReposSuccess = ({ commit }, repos) =>
commit(types.RECEIVE_REPOS_SUCCESS, repos);
export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR);
export const fetchRepos = ({ state, dispatch }) => {
export const fetchRepos = ({ state, dispatch, commit }) => {
dispatch('stopJobsPolling');
dispatch('requestRepos');
commit(types.REQUEST_REPOS);
const { provider } = state;
return axios
.get(reposPathWithFilter(state))
.then(({ data }) =>
dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })),
)
.then(() => dispatch('fetchJobs'))
.catch(() => {
......@@ -45,19 +40,14 @@ export const fetchRepos = ({ state, dispatch }) => {
}),
);
dispatch('receiveReposError');
commit(types.RECEIVE_REPOS_ERROR);
});
};
export const requestImport = ({ commit, state }, repoId) => {
if (!state.reposBeingImported.includes(repoId)) commit(types.REQUEST_IMPORT, repoId);
};
export const receiveImportSuccess = ({ commit }, { importedProject, repoId }) =>
commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject, repoId });
export const receiveImportError = ({ commit }, repoId) =>
commit(types.RECEIVE_IMPORT_ERROR, repoId);
export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, repo }) => {
dispatch('requestImport', repo.id);
export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo }) => {
if (!state.reposBeingImported.includes(repo.id)) {
commit(types.REQUEST_IMPORT, repo.id);
}
return axios
.post(state.importPath, {
......@@ -67,7 +57,7 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep
target_namespace: targetNamespace,
})
.then(({ data }) =>
dispatch('receiveImportSuccess', {
commit(types.RECEIVE_IMPORT_SUCCESS, {
importedProject: convertObjectPropsToCamelCase(data, { deep: true }),
repoId: repo.id,
}),
......@@ -75,13 +65,14 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep
.catch(() => {
createFlash(s__('ImportProjects|Importing the project failed'));
dispatch('receiveImportError', { repoId: repo.id });
commit(types.RECEIVE_IMPORT_ERROR, repo.id);
});
};
export const receiveJobsSuccess = ({ commit }, updatedProjects) =>
commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects);
export const fetchJobs = ({ state, dispatch }) => {
export const fetchJobs = ({ state, commit, dispatch }) => {
const { filter } = state;
if (eTagPoll) {
......@@ -95,7 +86,7 @@ export const fetchJobs = ({ state, dispatch }) => {
},
method: 'fetchJobs',
successCallback: ({ data }) =>
dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
commit(types.RECEIVE_JOBS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })),
errorCallback: () =>
createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')),
data: { filter },
......
......@@ -21,6 +21,8 @@ export const hasProviderRepos = state => state.providerRepos.length > 0;
export const hasImportedProjects = state => state.importedProjects.length > 0;
export const hasIncompatibleRepos = state => state.incompatibleRepos.length > 0;
export const reposPathWithFilter = ({ reposPath, filter = '' }) =>
filter ? `${reposPath}?filter=${filter}` : reposPath;
export const jobsPathWithFilter = ({ jobsPath, filter = '' }) =>
......
......@@ -9,9 +9,9 @@ Vue.use(Vuex);
export { state, actions, getters, mutations };
export default () =>
export default initialState =>
new Vuex.Store({
state: state(),
state: { ...state(), ...initialState },
actions,
mutations,
getters,
......
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const REQUEST_REPOS = 'REQUEST_REPOS';
export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS';
export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR';
......
......@@ -2,10 +2,6 @@ import Vue from 'vue';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_DATA](state, data) {
Object.assign(state, data);
},
[types.SET_FILTER](state, filter) {
state.filter = filter;
},
......@@ -14,11 +10,15 @@ export default {
state.isLoadingRepos = true;
},
[types.RECEIVE_REPOS_SUCCESS](state, { importedProjects, providerRepos, namespaces }) {
[types.RECEIVE_REPOS_SUCCESS](
state,
{ importedProjects, providerRepos, incompatibleRepos, namespaces },
) {
state.isLoadingRepos = false;
state.importedProjects = importedProjects;
state.providerRepos = providerRepos;
state.incompatibleRepos = incompatibleRepos ?? [];
state.namespaces = namespaces;
},
......
......@@ -7,6 +7,7 @@ export default () => ({
currentUsername: '',
importedProjects: [],
providerRepos: [],
incompatibleRepos: [],
namespaces: [],
reposBeingImported: [],
isLoadingRepos: false,
......
......@@ -11690,6 +11690,9 @@ msgstr ""
msgid "Import all compatible projects"
msgstr ""
msgid "Import all compatible repositories"
msgstr ""
msgid "Import all projects"
msgstr ""
......@@ -11879,6 +11882,9 @@ msgstr ""
msgid "Incompatible options set!"
msgstr ""
msgid "Incompatible project"
msgstr ""
msgid "Indent"
msgstr ""
......
......@@ -6,7 +6,7 @@ import STATUS_MAP, { STATUSES } from '~/import_projects/constants';
describe('ProviderRepoTableRow', () => {
let vm;
const fetchImport = jest.fn((context, data) => actions.requestImport(context, data));
const fetchImport = jest.fn();
const importPath = '/import-path';
const defaultTargetNamespace = 'user';
const ciCdOnly = true;
......@@ -17,11 +17,11 @@ describe('ProviderRepoTableRow', () => {
providerLink: 'providerLink',
};
function initStore() {
function initStore(initialState) {
const stubbedActions = { ...actions, fetchImport };
const store = new Vuex.Store({
state: state(),
state: { ...state(), ...initialState },
actions: stubbedActions,
mutations,
getters,
......@@ -30,12 +30,11 @@ describe('ProviderRepoTableRow', () => {
return store;
}
function mountComponent() {
function mountComponent(initialState) {
const localVue = createLocalVue();
localVue.use(Vuex);
const store = initStore();
store.dispatch('setInitialData', { importPath, defaultTargetNamespace, ciCdOnly });
const store = initStore({ importPath, defaultTargetNamespace, ciCdOnly, ...initialState });
const component = mount(providerRepoTableRow, {
localVue,
......
......@@ -4,7 +4,6 @@ import { TEST_HOST } from 'helpers/test_constants';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
SET_INITIAL_DATA,
REQUEST_REPOS,
RECEIVE_REPOS_SUCCESS,
RECEIVE_REPOS_ERROR,
......@@ -14,14 +13,7 @@ import {
RECEIVE_JOBS_SUCCESS,
} from '~/import_projects/store/mutation_types';
import {
setInitialData,
requestRepos,
receiveReposSuccess,
receiveReposError,
fetchRepos,
requestImport,
receiveImportSuccess,
receiveImportError,
fetchImport,
receiveJobsSuccess,
fetchJobs,
......@@ -32,7 +24,6 @@ import state from '~/import_projects/store/state';
describe('import_projects store actions', () => {
let localState;
const repoId = 1;
const repos = [{ id: 1 }, { id: 2 }];
const importPayload = { newName: 'newName', targetNamespace: 'targetNamespace', repo: { id: 1 } };
......@@ -40,61 +31,6 @@ describe('import_projects store actions', () => {
localState = state();
});
describe('setInitialData', () => {
it(`commits ${SET_INITIAL_DATA} mutation`, done => {
const initialData = {
reposPath: 'reposPath',
provider: 'provider',
jobsPath: 'jobsPath',
importPath: 'impapp/assets/javascripts/vue_shared/components/select2_select.vueortPath',
defaultTargetNamespace: 'defaultTargetNamespace',
ciCdOnly: 'ciCdOnly',
canSelectNamespace: 'canSelectNamespace',
};
testAction(
setInitialData,
initialData,
localState,
[{ type: SET_INITIAL_DATA, payload: initialData }],
[],
done,
);
});
});
describe('requestRepos', () => {
it(`requestRepos commits ${REQUEST_REPOS} mutation`, done => {
testAction(
requestRepos,
null,
localState,
[{ type: REQUEST_REPOS, payload: null }],
[],
done,
);
});
});
describe('receiveReposSuccess', () => {
it(`commits ${RECEIVE_REPOS_SUCCESS} mutation`, done => {
testAction(
receiveReposSuccess,
repos,
localState,
[{ type: RECEIVE_REPOS_SUCCESS, payload: repos }],
[],
done,
);
});
});
describe('receiveReposError', () => {
it(`commits ${RECEIVE_REPOS_ERROR} mutation`, done => {
testAction(receiveReposError, repos, localState, [{ type: RECEIVE_REPOS_ERROR }], [], done);
});
});
describe('fetchRepos', () => {
let mock;
const payload = { imported_projects: [{}], provider_repos: [{}], namespaces: [{}] };
......@@ -106,39 +42,33 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
it('dispatches stopJobsPolling, requestRepos and receiveReposSuccess actions on a successful request', done => {
it('dispatches stopJobsPolling actions and commits REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, payload);
testAction(
return testAction(
fetchRepos,
null,
localState,
[],
[
{ type: 'stopJobsPolling' },
{ type: 'requestRepos' },
{ type: REQUEST_REPOS },
{
type: 'receiveReposSuccess',
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
{
type: 'fetchJobs',
},
],
done,
[{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }],
);
});
it('dispatches stopJobsPolling, requestRepos and receiveReposError actions on an unsuccessful request', done => {
it('dispatches stopJobsPolling action and commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
testAction(
return testAction(
fetchRepos,
null,
localState,
[],
[{ type: 'stopJobsPolling' }, { type: 'requestRepos' }, { type: 'receiveReposError' }],
done,
[{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }],
[{ type: 'stopJobsPolling' }],
);
});
......@@ -147,72 +77,26 @@ describe('import_projects store actions', () => {
localState.filter = 'filter';
});
it('fetches repos with filter applied', done => {
it('fetches repos with filter applied', () => {
mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload);
testAction(
return testAction(
fetchRepos,
null,
localState,
[],
[
{ type: 'stopJobsPolling' },
{ type: 'requestRepos' },
{ type: REQUEST_REPOS },
{
type: 'receiveReposSuccess',
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
{
type: 'fetchJobs',
},
],
done,
[{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }],
);
});
});
});
describe('requestImport', () => {
it(`commits ${REQUEST_IMPORT} mutation`, done => {
testAction(
requestImport,
repoId,
localState,
[{ type: REQUEST_IMPORT, payload: repoId }],
[],
done,
);
});
});
describe('receiveImportSuccess', () => {
it(`commits ${RECEIVE_IMPORT_SUCCESS} mutation`, done => {
const payload = { importedProject: { name: 'imported/project' }, repoId: 2 };
testAction(
receiveImportSuccess,
payload,
localState,
[{ type: RECEIVE_IMPORT_SUCCESS, payload }],
[],
done,
);
});
});
describe('receiveImportError', () => {
it(`commits ${RECEIVE_IMPORT_ERROR} mutation`, done => {
testAction(
receiveImportError,
repoId,
localState,
[{ type: RECEIVE_IMPORT_ERROR, payload: repoId }],
[],
done,
);
});
});
describe('fetchImport', () => {
let mock;
......@@ -223,56 +107,53 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
it('dispatches requestImport and receiveImportSuccess actions on a successful request', done => {
it('commits REQUEST_IMPORT and REQUEST_IMPORT_SUCCESS mutations on a successful request', () => {
const importedProject = { name: 'imported/project' };
const importRepoId = importPayload.repo.id;
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(200, importedProject);
testAction(
return testAction(
fetchImport,
importPayload,
localState,
[],
[
{ type: 'requestImport', payload: importRepoId },
{ type: REQUEST_IMPORT, payload: importRepoId },
{
type: 'receiveImportSuccess',
type: RECEIVE_IMPORT_SUCCESS,
payload: {
importedProject: convertObjectPropsToCamelCase(importedProject, { deep: true }),
repoId: importRepoId,
},
},
],
done,
[],
);
});
it('dispatches requestImport and receiveImportSuccess actions on an unsuccessful request', done => {
it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR on an unsuccessful request', () => {
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500);
testAction(
return testAction(
fetchImport,
importPayload,
localState,
[],
[
{ type: 'requestImport', payload: importPayload.repo.id },
{ type: 'receiveImportError', payload: { repoId: importPayload.repo.id } },
{ type: REQUEST_IMPORT, payload: importPayload.repo.id },
{ type: RECEIVE_IMPORT_ERROR, payload: importPayload.repo.id },
],
done,
[],
);
});
});
describe('receiveJobsSuccess', () => {
it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, done => {
testAction(
it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, () => {
return testAction(
receiveJobsSuccess,
repos,
localState,
[{ type: RECEIVE_JOBS_SUCCESS, payload: repos }],
[],
done,
);
});
});
......@@ -293,21 +174,20 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
it('dispatches requestJobs and receiveJobsSuccess actions on a successful request', done => {
it('commits RECEIVE_JOBS_SUCCESS mutation on a successful request', async () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, updatedProjects);
testAction(
await testAction(
fetchJobs,
null,
localState,
[],
[
{
type: 'receiveJobsSuccess',
type: RECEIVE_JOBS_SUCCESS,
payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }),
},
],
done,
[],
);
});
......@@ -316,21 +196,20 @@ describe('import_projects store actions', () => {
localState.filter = 'filter';
});
it('fetches realtime changes with filter applied', done => {
it('fetches realtime changes with filter applied', () => {
mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, updatedProjects);
testAction(
return testAction(
fetchJobs,
null,
localState,
[],
[
{
type: 'receiveJobsSuccess',
type: RECEIVE_JOBS_SUCCESS,
payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }),
},
],
done,
[],
);
});
});
......
......@@ -2,6 +2,7 @@ import {
namespaceSelectOptions,
isImportingAnyRepo,
hasProviderRepos,
hasIncompatibleRepos,
hasImportedProjects,
} from '~/import_projects/store/getters';
import state from '~/import_projects/store/state';
......@@ -80,4 +81,18 @@ describe('import_projects store getters', () => {
expect(hasImportedProjects(localState)).toBe(false);
});
});
describe('hasIncompatibleRepos', () => {
it('returns true if there are any incompatibleProjects', () => {
localState.incompatibleRepos = new Array(1);
expect(hasIncompatibleRepos(localState)).toBe(true);
});
it('returns false if there are no incompatibleProjects', () => {
localState.incompatibleRepos = [];
expect(hasIncompatibleRepos(localState)).toBe(false);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
describe ProviderRepoSerializer do
it 'represents ProviderRepoEntity entities' do
expect(described_class.entity_class).to eq(ProviderRepoEntity)
end
end
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