Commit d0f03ed9 authored by Phil Hughes's avatar Phil Hughes

Merge branch '46165-web-ide-branch-picker' into 'master'

Create Web IDE MR and branch picker

Closes #46165

See merge request gitlab-org/gitlab-ce!20978
parents 0e90f27f 0d6e50d5
......@@ -244,6 +244,18 @@ const Api = {
});
},
branches(id, query = '', options = {}) {
const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
return axios.get(url, {
params: {
search: query,
per_page: 20,
...options,
},
});
},
createBranch(id, { ref, branch }) {
const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
......
<script>
import Icon from '~/vue_shared/components/icon.vue';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import router from '../../ide_router';
export default {
components: {
Icon,
Timeago,
},
props: {
item: {
type: Object,
required: true,
},
projectId: {
type: String,
required: true,
},
isActive: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
branchHref() {
return router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href;
},
},
};
</script>
<template>
<a
:href="branchHref"
class="btn-link d-flex align-items-center"
>
<span class="d-flex append-right-default ide-search-list-current-icon">
<icon
v-if="isActive"
:size="18"
name="mobile-issue-close"
/>
</span>
<span>
<strong>
{{ item.name }}
</strong>
<span
class="ide-merge-request-project-path d-block mt-1"
>
Updated
<timeago
:time="item.committedDate || ''"
/>
</span>
</span>
</a>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import _ from 'underscore';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import Icon from '~/vue_shared/components/icon.vue';
import Item from './item.vue';
export default {
components: {
LoadingIcon,
Item,
Icon,
},
data() {
return {
search: '',
};
},
computed: {
...mapState('branches', ['branches', 'isLoading']),
...mapState(['currentBranchId', 'currentProjectId']),
hasBranches() {
return this.branches.length !== 0;
},
hasNoSearchResults() {
return this.search !== '' && !this.hasBranches;
},
},
watch: {
isLoading: {
handler: 'focusSearch',
},
},
mounted() {
this.loadBranches();
},
methods: {
...mapActions('branches', ['fetchBranches']),
loadBranches() {
this.fetchBranches({ search: this.search });
},
searchBranches: _.debounce(function debounceSearch() {
this.loadBranches();
}, 250),
focusSearch() {
if (!this.isLoading) {
this.$nextTick(() => {
this.$refs.searchInput.focus();
});
}
},
isActiveBranch(item) {
return item.name === this.currentBranchId;
},
},
};
</script>
<template>
<div>
<div class="dropdown-input mt-3 pb-3 mb-0 border-bottom">
<div class="position-relative">
<input
ref="searchInput"
:placeholder="__('Search branches')"
v-model="search"
type="search"
class="form-control dropdown-input-field"
@input="searchBranches"
/>
<icon
:size="18"
name="search"
class="input-icon"
/>
</div>
</div>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<loading-icon
v-if="isLoading"
class="mt-3 mb-3 align-self-center ml-auto mr-auto"
size="2"
/>
<ul
v-else
class="mb-3 w-100"
>
<template v-if="hasBranches">
<li
v-for="item in branches"
:key="item.name"
>
<item
:item="item"
:project-id="currentProjectId"
:is-active="isActiveBranch(item)"
/>
</li>
</template>
<li
v-else
class="ide-search-list-empty d-flex align-items-center justify-content-center"
>
<template v-if="hasNoSearchResults">
{{ __('No branches found') }}
</template>
</li>
</ul>
</div>
</div>
</template>
......@@ -41,7 +41,7 @@ export default {
slot="header"
>
{{ __('Edit') }}
<div class="ml-auto d-flex">
<div class="ide-tree-actions ml-auto d-flex">
<new-entry-button
:label="__('New file')"
:show-label="false"
......
......@@ -3,14 +3,14 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import RepoFile from './repo_file.vue';
import NewDropdown from './new_dropdown/index.vue';
import NavDropdown from './nav_dropdown.vue';
export default {
components: {
Icon,
RepoFile,
SkeletonLoadingContainer,
NewDropdown,
NavDropdown,
},
props: {
viewerType: {
......@@ -57,6 +57,7 @@ export default {
:class="headerClass"
class="ide-tree-header"
>
<nav-dropdown />
<slot name="header"></slot>
</header>
<div
......
<script>
import { mapGetters } from 'vuex';
import Tabs from '../../../vue_shared/components/tabs/tabs';
import Tab from '../../../vue_shared/components/tabs/tab.vue';
import List from './list.vue';
export default {
components: {
Tabs,
Tab,
List,
},
props: {
show: {
type: Boolean,
required: true,
},
},
computed: {
...mapGetters('mergeRequests', ['assignedData', 'createdData']),
createdMergeRequestLength() {
return this.createdData.mergeRequests.length;
},
assignedMergeRequestLength() {
return this.assignedData.mergeRequests.length;
},
},
};
</script>
<template>
<div class="dropdown-menu ide-merge-requests-dropdown p-0">
<tabs
v-if="show"
stop-propagation
>
<tab active>
<template slot="title">
{{ __('Created by me') }}
<span class="badge badge-pill">
{{ createdMergeRequestLength }}
</span>
</template>
<list
:empty-text="__('You have not created any merge requests')"
type="created"
/>
</tab>
<tab>
<template slot="title">
{{ __('Assigned to me') }}
<span class="badge badge-pill">
{{ assignedMergeRequestLength }}
</span>
</template>
<list
:empty-text="__('You do not have any assigned merge requests')"
type="assigned"
/>
</tab>
</tabs>
</div>
</template>
<script>
import Icon from '../../../vue_shared/components/icon.vue';
import router from '../../ide_router';
export default {
components: {
......@@ -29,22 +30,21 @@ export default {
pathWithID() {
return `${this.item.projectPathWithNamespace}!${this.item.iid}`;
},
},
methods: {
clickItem() {
this.$emit('click', this.item);
mergeRequestHref() {
const path = `/project/${this.item.projectPathWithNamespace}/merge_requests/${this.item.iid}`;
return router.resolve(path).href;
},
},
};
</script>
<template>
<button
type="button"
<a
:href="mergeRequestHref"
class="btn-link d-flex align-items-center"
@click="clickItem"
>
<span class="d-flex append-right-default ide-merge-request-current-icon">
<span class="d-flex append-right-default ide-search-list-current-icon">
<icon
v-if="isActive"
:size="18"
......@@ -59,5 +59,5 @@ export default {
{{ pathWithID }}
</span>
</span>
</button>
</a>
</template>
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { mapActions, mapState } from 'vuex';
import _ from 'underscore';
import LoadingIcon from '../../../vue_shared/components/loading_icon.vue';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import Item from './item.vue';
import TokenedInput from '../shared/tokened_input.vue';
const SEARCH_TYPES = [
{ type: 'created', label: __('Created by me') },
{ type: 'assigned', label: __('Assigned to me') },
];
export default {
components: {
LoadingIcon,
TokenedInput,
Item,
},
props: {
type: {
type: String,
required: true,
},
emptyText: {
type: String,
required: true,
},
Icon,
},
data() {
return {
search: '',
currentSearchType: null,
hasSearchFocus: false,
};
},
computed: {
...mapGetters('mergeRequests', ['getData']),
...mapState('mergeRequests', ['mergeRequests', 'isLoading']),
...mapState(['currentMergeRequestId', 'currentProjectId']),
data() {
return this.getData(this.type);
},
isLoading() {
return this.data.isLoading;
},
mergeRequests() {
return this.data.mergeRequests;
},
hasMergeRequests() {
return this.mergeRequests.length !== 0;
},
hasNoSearchResults() {
return this.search !== '' && !this.hasMergeRequests;
},
showSearchTypes() {
return this.hasSearchFocus && !this.search && !this.currentSearchType;
},
type() {
return this.currentSearchType
? this.currentSearchType.type
: '';
},
searchTokens() {
return this.currentSearchType
? [this.currentSearchType]
: [];
},
},
watch: {
isLoading: {
handler: 'focusSearch',
search() {
// When the search is updated, let's turn off this flag to hide the search types
this.hasSearchFocus = false;
},
},
mounted() {
this.loadMergeRequests();
},
methods: {
...mapActions('mergeRequests', ['fetchMergeRequests', 'openMergeRequest']),
...mapActions('mergeRequests', ['fetchMergeRequests']),
loadMergeRequests() {
this.fetchMergeRequests({ type: this.type, search: this.search });
},
viewMergeRequest(item) {
this.openMergeRequest({
projectPath: item.projectPathWithNamespace,
id: item.iid,
});
},
searchMergeRequests: _.debounce(function debounceSearch() {
this.loadMergeRequests();
}, 250),
focusSearch() {
if (!this.isLoading) {
this.$nextTick(() => {
this.$refs.searchInput.focus();
});
}
onSearchFocus() {
this.hasSearchFocus = true;
},
setSearchType(searchType) {
this.currentSearchType = searchType;
this.loadMergeRequests();
},
},
searchTypes: SEARCH_TYPES,
};
</script>
<template>
<div>
<div class="dropdown-input mt-3 pb-3 mb-0 border-bottom">
<input
ref="searchInput"
:placeholder="__('Search merge requests')"
v-model="search"
type="search"
class="dropdown-input-field"
@input="searchMergeRequests"
/>
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search"
></i>
<div class="position-relative">
<tokened-input
v-model="search"
:tokens="searchTokens"
:placeholder="__('Search merge requests')"
@focus="onSearchFocus"
@input="searchMergeRequests"
@removeToken="setSearchType(null)"
/>
<icon
:size="18"
name="search"
class="input-icon"
/>
</div>
</div>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<loading-icon
......@@ -98,35 +103,52 @@ export default {
class="mt-3 mb-3 align-self-center ml-auto mr-auto"
size="2"
/>
<ul
v-else
class="mb-3 w-100"
>
<template v-if="hasMergeRequests">
<li
v-for="item in mergeRequests"
:key="item.id"
>
<item
:item="item"
:current-id="currentMergeRequestId"
:current-project-id="currentProjectId"
@click="viewMergeRequest"
/>
</li>
</template>
<li
v-else
class="ide-merge-requests-empty d-flex align-items-center justify-content-center"
<template v-else>
<ul
class="mb-3 w-100"
>
<template v-if="hasNoSearchResults">
{{ __('No merge requests found') }}
<template v-if="showSearchTypes">
<li
v-for="searchType in $options.searchTypes"
:key="searchType.type"
>
<button
type="button"
class="btn-link d-flex align-items-center"
@click.stop="setSearchType(searchType)"
>
<span class="d-flex append-right-default ide-search-list-current-icon">
<icon
:size="18"
name="search"
/>
</span>
<span>
{{ searchType.label }}
</span>
</button>
</li>
</template>
<template v-else>
{{ emptyText }}
<template v-else-if="hasMergeRequests">
<li
v-for="item in mergeRequests"
:key="item.id"
>
<item
:item="item"
:current-id="currentMergeRequestId"
:current-project-id="currentProjectId"
/>
</li>
</template>
</li>
</ul>
<li
v-else
class="ide-search-list-empty d-flex align-items-center justify-content-center"
>
{{ __('No merge requests found') }}
</li>
</ul>
</template>
</div>
</div>
</template>
<script>
import $ from 'jquery';
import Icon from '~/vue_shared/components/icon.vue';
import NavForm from './nav_form.vue';
import NavDropdownButton from './nav_dropdown_button.vue';
export default {
components: {
Icon,
NavDropdownButton,
NavForm,
},
data() {
return {
isVisibleDropdown: false,
};
},
mounted() {
this.addDropdownListeners();
},
beforeDestroy() {
this.removeDropdownListeners();
},
methods: {
addDropdownListeners() {
$(this.$refs.dropdown)
.on('show.bs.dropdown', () => this.showDropdown())
.on('hide.bs.dropdown', () => this.hideDropdown());
},
removeDropdownListeners() {
$(this.$refs.dropdown)
.off('show.bs.dropdown')
.off('hide.bs.dropdown');
},
showDropdown() {
this.isVisibleDropdown = true;
},
hideDropdown() {
this.isVisibleDropdown = false;
},
},
};
</script>
<template>
<div
ref="dropdown"
class="btn-group ide-nav-dropdown dropdown"
>
<nav-dropdown-button />
<div
class="dropdown-menu dropdown-menu-left p-0"
>
<nav-form
v-if="isVisibleDropdown"
/>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
const EMPTY_LABEL = '-';
export default {
components: {
Icon,
DropdownButton,
},
computed: {
...mapState(['currentBranchId', 'currentMergeRequestId']),
mergeRequestLabel() {
return this.currentMergeRequestId
? `!${this.currentMergeRequestId}`
: EMPTY_LABEL;
},
branchLabel() {
return this.currentBranchId || EMPTY_LABEL;
},
},
};
</script>
<template>
<dropdown-button>
<span
class="row"
>
<span
class="col-7 text-truncate"
>
<icon
:size="16"
:aria-label="__('Current Branch')"
name="branch"
/>
{{ branchLabel }}
</span>
<span
class="col-5 pl-0 text-truncate"
>
<icon
:size="16"
:aria-label="__('Merge Request')"
name="merge-request"
/>
{{ mergeRequestLabel }}
</span>
</span>
</dropdown-button>
</template>
<script>
import Tabs from '~/vue_shared/components/tabs/tabs';
import Tab from '~/vue_shared/components/tabs/tab.vue';
import BranchesSearchList from './branches/search_list.vue';
import MergeRequestSearchList from './merge_requests/list.vue';
export default {
components: {
Tabs,
Tab,
BranchesSearchList,
MergeRequestSearchList,
},
};
</script>
<template>
<div
class="ide-nav-form p-0"
>
<tabs
stop-propagation
>
<tab
active
>
<template slot="title">
{{ __('Merge Requests') }}
</template>
<merge-request-search-list />
</tab>
<tab>
<template slot="title">
{{ __('Branches') }}
</template>
<branches-search-list />
</tab>
</tabs>
</div>
</template>
<script>
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
props: {
placeholder: {
type: String,
required: false,
default: __('Search'),
},
tokens: {
type: Array,
required: false,
default: () => [],
},
value: {
type: String,
required: false,
default: '',
},
},
data() {
return {
backspaceCount: 0,
};
},
computed: {
placeholderText() {
return this.tokens.length
? ''
: this.placeholder;
},
},
watch: {
tokens() {
this.$refs.input.focus();
},
},
methods: {
onFocus() {
this.$emit('focus');
},
onBlur() {
this.$emit('blur');
},
onInput(evt) {
this.$emit('input', evt.target.value);
},
onBackspace() {
if (!this.value && this.tokens.length) {
this.backspaceCount += 1;
} else {
this.backspaceCount = 0;
return;
}
if (this.backspaceCount > 1) {
this.removeToken(this.tokens[this.tokens.length - 1]);
this.backspaceCount = 0;
}
},
removeToken(token) {
this.$emit('removeToken', token);
},
},
};
</script>
<template>
<div class="filtered-search-wrapper">
<div class="filtered-search-box">
<div class="tokens-container list-unstyled">
<div
v-for="token in tokens"
:key="token.label"
class="filtered-search-token"
>
<button
class="selectable btn-blank"
type="button"
@click.stop="removeToken(token)"
@keyup.delete="removeToken(token)"
>
<div
class="value-container rounded"
>
<div
class="value"
>{{ token.label }}</div>
<div
class="remove-token inverted"
>
<icon
:size="10"
name="close"
/>
</div>
</div>
</button>
</div>
<div class="input-token">
<input
ref="input"
:placeholder="placeholderText"
:value="value"
type="search"
class="form-control filtered-search"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keyup.delete="onBackspace"
/>
</div>
</div>
</div>
</div>
</template>
......@@ -7,6 +7,7 @@ import mutations from './mutations';
import commitModule from './modules/commit';
import pipelines from './modules/pipelines';
import mergeRequests from './modules/merge_requests';
import branches from './modules/branches';
Vue.use(Vuex);
......@@ -20,6 +21,7 @@ export const createStore = () =>
commit: commitModule,
pipelines,
mergeRequests,
branches,
},
});
......
import { __ } from '~/locale';
import Api from '~/api';
import * as types from './mutation_types';
export const requestBranches = ({ commit }) => commit(types.REQUEST_BRANCHES);
export const receiveBranchesError = ({ commit, dispatch }, { search }) => {
dispatch(
'setErrorMessage',
{
text: __('Error loading branches.'),
action: payload =>
dispatch('fetchBranches', payload).then(() =>
dispatch('setErrorMessage', null, { root: true }),
),
actionText: __('Please try again'),
actionPayload: { search },
},
{ root: true },
);
commit(types.RECEIVE_BRANCHES_ERROR);
};
export const receiveBranchesSuccess = ({ commit }, data) =>
commit(types.RECEIVE_BRANCHES_SUCCESS, data);
export const fetchBranches = ({ dispatch, rootGetters }, { search = '' }) => {
dispatch('requestBranches');
dispatch('resetBranches');
return Api.branches(rootGetters.currentProject.id, search, { sort: 'updated_desc' })
.then(({ data }) => dispatch('receiveBranchesSuccess', data))
.catch(() => dispatch('receiveBranchesError', { search }));
};
export const resetBranches = ({ commit }) => commit(types.RESET_BRANCHES);
export const openBranch = ({ rootState, dispatch }, id) =>
dispatch('goToRoute', `/project/${rootState.currentProjectId}/edit/${id}`, { root: true });
export default () => {};
import state from './state';
import * as actions from './actions';
import mutations from './mutations';
export default {
namespaced: true,
state: state(),
actions,
mutations,
};
export const REQUEST_BRANCHES = 'REQUEST_BRANCHES';
export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR';
export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS';
export const RESET_BRANCHES = 'RESET_BRANCHES';
/* eslint-disable no-param-reassign */
import * as types from './mutation_types';
export default {
[types.REQUEST_BRANCHES](state) {
state.isLoading = true;
},
[types.RECEIVE_BRANCHES_ERROR](state) {
state.isLoading = false;
},
[types.RECEIVE_BRANCHES_SUCCESS](state, data) {
state.isLoading = false;
state.branches = data.map(branch => ({
name: branch.name,
committedDate: branch.commit.committed_date,
}));
},
[types.RESET_BRANCHES](state) {
state.branches = [];
},
};
export default () => ({
isLoading: false,
branches: [],
});
import { __ } from '../../../../locale';
import Api from '../../../../api';
import router from '../../../ide_router';
import { scopes } from './constants';
import * as types from './mutation_types';
import * as rootTypes from '../../mutation_types';
export const requestMergeRequests = ({ commit }, type) =>
commit(types.REQUEST_MERGE_REQUESTS, type);
export const requestMergeRequests = ({ commit }) =>
commit(types.REQUEST_MERGE_REQUESTS);
export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }) => {
dispatch(
'setErrorMessage',
......@@ -21,39 +19,22 @@ export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }
},
{ root: true },
);
commit(types.RECEIVE_MERGE_REQUESTS_ERROR, type);
commit(types.RECEIVE_MERGE_REQUESTS_ERROR);
};
export const receiveMergeRequestsSuccess = ({ commit }, { type, data }) =>
commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, { type, data });
export const receiveMergeRequestsSuccess = ({ commit }, data) =>
commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, data);
export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, search = '' }) => {
const scope = scopes[type];
dispatch('requestMergeRequests', type);
dispatch('resetMergeRequests', type);
dispatch('requestMergeRequests');
dispatch('resetMergeRequests');
const scope = type ? scopes[type] : 'all';
return Api.mergeRequests({ scope, state, search })
.then(({ data }) => dispatch('receiveMergeRequestsSuccess', { type, data }))
.then(({ data }) => dispatch('receiveMergeRequestsSuccess', data))
.catch(() => dispatch('receiveMergeRequestsError', { type, search }));
};
export const resetMergeRequests = ({ commit }, type) => commit(types.RESET_MERGE_REQUESTS, type);
export const openMergeRequest = ({ commit, dispatch }, { projectPath, id }) => {
commit(rootTypes.CLEAR_PROJECTS, null, { root: true });
commit(rootTypes.SET_CURRENT_MERGE_REQUEST, `${id}`, { root: true });
commit(rootTypes.RESET_OPEN_FILES, null, { root: true });
dispatch('setCurrentBranchId', '', { root: true });
dispatch('pipelines/stopPipelinePolling', null, { root: true })
.then(() => {
dispatch('pipelines/resetLatestPipeline', null, { root: true });
dispatch('pipelines/clearEtagPoll', null, { root: true });
})
.catch(e => {
throw e;
});
dispatch('setRightPane', null, { root: true });
router.push(`/project/${projectPath}/merge_requests/${id}`);
};
export const resetMergeRequests = ({ commit }) => commit(types.RESET_MERGE_REQUESTS);
export default () => {};
export const getData = state => type => state[type];
export const assignedData = state => state.assigned;
export const createdData = state => state.created;
import state from './state';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
export default {
......@@ -8,5 +7,4 @@ export default {
state: state(),
actions,
mutations,
getters,
};
......@@ -2,15 +2,15 @@
import * as types from './mutation_types';
export default {
[types.REQUEST_MERGE_REQUESTS](state, type) {
state[type].isLoading = true;
[types.REQUEST_MERGE_REQUESTS](state) {
state.isLoading = true;
},
[types.RECEIVE_MERGE_REQUESTS_ERROR](state, type) {
state[type].isLoading = false;
[types.RECEIVE_MERGE_REQUESTS_ERROR](state) {
state.isLoading = false;
},
[types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, { type, data }) {
state[type].isLoading = false;
state[type].mergeRequests = data.map(mergeRequest => ({
[types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, data) {
state.isLoading = false;
state.mergeRequests = data.map(mergeRequest => ({
id: mergeRequest.id,
iid: mergeRequest.iid,
title: mergeRequest.title,
......@@ -20,7 +20,7 @@ export default {
.replace(`/merge_requests/${mergeRequest.iid}`, ''),
}));
},
[types.RESET_MERGE_REQUESTS](state, type) {
state[type].mergeRequests = [];
[types.RESET_MERGE_REQUESTS](state) {
state.mergeRequests = [];
},
};
import { states } from './constants';
export default () => ({
created: {
isLoading: false,
mergeRequests: [],
},
assigned: {
isLoading: false,
mergeRequests: [],
},
isLoading: false,
mergeRequests: [],
state: states.opened,
});
......@@ -38,9 +38,17 @@ export default {
v-show="isLoading"
:inline="true"
/>
<span class="dropdown-toggle-text">
{{ toggleText }}
</span>
<template>
<slot
v-if="$slots.default"
></slot>
<span
v-else
class="dropdown-toggle-text"
>
{{ toggleText }}
</span>
</template>
<span
v-show="!isLoading"
class="dropdown-toggle-icon"
......
<script>
// only allow classes in images.scss e.g. s12
const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
const validSizes = [8, 10, 12, 16, 18, 24, 32, 48, 72];
let iconValidator = () => true;
/*
......
......@@ -571,7 +571,8 @@
margin-bottom: 10px;
padding: 0 10px;
.fa {
.fa,
.input-icon {
position: absolute;
top: 10px;
right: 20px;
......
......@@ -39,7 +39,7 @@
svg {
fill: currentColor;
$svg-sizes: 8 12 16 18 24 32 48 72;
$svg-sizes: 8 10 12 16 18 24 32 48 72;
@each $svg-size in $svg-sizes {
&.s#{$svg-size} {
@include svg-size(#{$svg-size}px);
......
@import 'framework/variables';
@import 'framework/mixins';
$search-list-icon-width: 18px;
$ide-activity-bar-width: 60px;
$ide-context-header-padding: 10px;
$ide-project-avatar-end: $ide-context-header-padding + 48px;
......@@ -49,7 +50,7 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-height: 0;
.file {
height: 32px;
......@@ -541,11 +542,11 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
background-color: $white-light;
border-left: 1px solid $white-dark;
border-top: 1px solid $white-dark;
border-top-left-radius: $border-radius-small;
min-height: 0;
}
}
......@@ -1057,6 +1058,7 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
flex: 0 0 auto;
display: flex;
align-items: center;
flex-wrap: wrap;
padding: 12px 0;
margin-left: $ide-tree-padding;
margin-right: $ide-tree-padding;
......@@ -1066,6 +1068,32 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
margin-left: auto;
}
.ide-nav-dropdown {
width: 100%;
margin-bottom: 12px;
.dropdown-menu {
width: 385px;
max-height: initial;
}
.dropdown-menu-toggle {
svg {
vertical-align: middle;
}
&:hover {
background-color: $white-normal;
}
}
&.show {
.dropdown-menu-toggle {
background-color: $white-dark;
}
}
}
button {
color: $gl-text-color;
}
......@@ -1181,7 +1209,7 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
}
.ide-context-body {
overflow: hidden;
min-height: 0;
}
.ide-sidebar-project-title {
......@@ -1331,7 +1359,7 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
min-height: 60px;
}
.ide-merge-requests-dropdown {
.ide-nav-form {
.nav-links li {
width: 50%;
padding-left: 0;
......@@ -1350,22 +1378,36 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
padding-left: $gl-padding;
padding-right: $gl-padding;
.fa {
right: 26px;
.input-icon {
right: auto;
left: 10px;
top: 50%;
transform: translateY(-50%);
}
}
.dropdown-input-field {
padding-left: $search-list-icon-width + $gl-padding;
padding-top: 2px;
padding-bottom: 2px;
}
.tokens-container {
padding-left: $search-list-icon-width + $gl-padding;
overflow-x: hidden;
}
.btn-link {
padding-top: $gl-padding;
padding-bottom: $gl-padding;
}
}
.ide-merge-request-current-icon {
min-width: 18px;
.ide-search-list-current-icon {
min-width: $search-list-icon-width;
}
.ide-merge-requests-empty {
.ide-search-list-empty {
height: 230px;
}
......
---
title: Create branch and MR picker for Web IDE
merge_request: 20978
author:
type: changed
......@@ -59,9 +59,18 @@ left.
> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) [GitLab Core][ce] 11.0.
Switching between your authored and assigned merge requests can be done without
leaving the Web IDE. Click the project name in the top left to open a list of
merge requests. You will need to commit or discard all your changes before
leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list
of merge requests. You will need to commit or discard all your changes before
switching to a different merge request.
## Switching branches
> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/20850) [GitLab Core][ce] 11.2.
Switching between branches of the current project repository can be done without
leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list
of branches. You will need to commit or discard all your changes before
switching to a different branch.
[ce]: https://about.gitlab.com/pricing/
[ee]: https://about.gitlab.com/pricing/
......@@ -19,6 +19,7 @@ module API
params :filter_params do
optional :search, type: String, desc: 'Return list of branches matching the search criteria'
optional :sort, type: String, desc: 'Return list of branches sorted by the given field'
end
end
......
......@@ -1930,6 +1930,9 @@ msgstr ""
msgid "Cron syntax"
msgstr ""
msgid "Current Branch"
msgstr ""
msgid "CurrentUser|Profile"
msgstr ""
......@@ -2409,6 +2412,9 @@ msgstr ""
msgid "Error loading branch data. Please try again."
msgstr ""
msgid "Error loading branches."
msgstr ""
msgid "Error loading last commit."
msgstr ""
......@@ -3605,6 +3611,9 @@ msgstr ""
msgid "No assignee"
msgstr ""
msgid "No branches found"
msgstr ""
msgid "No changes"
msgstr ""
......@@ -6045,9 +6054,6 @@ msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
msgid "You do not have any assigned merge requests"
msgstr ""
msgid "You don't have any applications"
msgstr ""
......@@ -6057,9 +6063,6 @@ msgstr ""
msgid "You have no permissions"
msgstr ""
msgid "You have not created any merge requests"
msgstr ""
msgid "You have reached your project limit"
msgstr ""
......
......@@ -22,7 +22,7 @@ describe 'Multi-file editor new directory', :js do
end
it 'creates directory in current directory' do
all('.ide-tree-header button').last.click
all('.ide-tree-actions button').last.click
page.within('.modal') do
find('.form-control').set('folder name')
......@@ -30,7 +30,7 @@ describe 'Multi-file editor new directory', :js do
click_button('Create directory')
end
first('.ide-tree-header button').click
first('.ide-tree-actions button').click
page.within('.modal-dialog') do
find('.form-control').set('file name')
......
......@@ -22,7 +22,7 @@ describe 'Multi-file editor new file', :js do
end
it 'creates file in current directory' do
first('.ide-tree-header button').click
first('.ide-tree-actions button').click
page.within('.modal') do
find('.form-control').set('file name')
......
......@@ -84,7 +84,7 @@ export default (
done();
};
const result = action({ commit, state, dispatch, rootState: state }, payload);
const result = action({ commit, state, dispatch, rootState: state, rootGetters: state }, payload);
return new Promise(resolve => {
setImmediate(resolve);
......
import Vue from 'vue';
import mountCompontent from 'spec/helpers/vue_mount_component_helper';
import router from '~/ide/ide_router';
import Item from '~/ide/components/branches/item.vue';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { projectData } from '../../mock_data';
const TEST_BRANCH = {
name: 'master',
committedDate: '2018-01-05T05:50Z',
};
const TEST_PROJECT_ID = projectData.name_with_namespace;
describe('IDE branch item', () => {
const Component = Vue.extend(Item);
let vm;
beforeEach(() => {
vm = mountCompontent(Component, {
item: { ...TEST_BRANCH },
projectId: TEST_PROJECT_ID,
isActive: false,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders branch name and timeago', () => {
const timeText = getTimeago().format(TEST_BRANCH.committedDate);
expect(vm.$el).toContainText(TEST_BRANCH.name);
expect(vm.$el.querySelector('time')).toHaveText(timeText);
expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
});
it('renders link to branch', () => {
const expectedHref = router.resolve(`/project/${TEST_PROJECT_ID}/edit/${TEST_BRANCH.name}`).href;
expect(vm.$el).toMatch('a');
expect(vm.$el).toHaveAttr('href', expectedHref);
});
it('renders icon if isActive', done => {
vm.isActive = true;
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null);
})
.then(done)
.catch(done.fail);
});
});
import Vue from 'vue';
import store from '~/ide/stores';
import * as types from '~/ide/stores/modules/branches/mutation_types';
import List from '~/ide/components/branches/search_list.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { branches as testBranches } from '../../mock_data';
import { resetStore } from '../../helpers';
describe('IDE branches search list', () => {
const Component = Vue.extend(List);
let vm;
beforeEach(() => {
vm = createComponentWithStore(Component, store, {});
spyOn(vm, 'fetchBranches');
vm.$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(store);
});
it('calls fetch on mounted', () => {
expect(vm.fetchBranches).toHaveBeenCalledWith({
search: '',
});
});
it('renders loading icon', done => {
vm.$store.state.branches.isLoading = true;
vm.$nextTick()
.then(() => {
expect(vm.$el).toContainElement('.loading-container');
})
.then(done)
.catch(done.fail);
});
it('renders branches not found when search is not empty', done => {
vm.search = 'testing';
vm.$nextTick(() => {
expect(vm.$el).toContainText('No branches found');
done();
});
});
describe('with branches', () => {
const currentBranch = testBranches[1];
beforeEach(done => {
vm.$store.state.currentBranchId = currentBranch.name;
vm.$store.commit(`branches/${types.RECEIVE_BRANCHES_SUCCESS}`, testBranches);
vm.$nextTick(done);
});
it('renders list', () => {
const elementText = Array.from(vm.$el.querySelectorAll('li strong'))
.map(x => x.textContent.trim());
expect(elementText).toEqual(testBranches.map(x => x.name));
});
it('renders check next to active branch', () => {
const checkedText = Array.from(vm.$el.querySelectorAll('li'))
.filter(x => x.querySelector('.ide-search-list-current-icon svg'))
.map(x => x.querySelector('strong').textContent.trim());
expect(checkedText).toEqual([currentBranch.name]);
});
});
});
import Vue from 'vue';
import { createStore } from '~/ide/stores';
import Dropdown from '~/ide/components/merge_requests/dropdown.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { mergeRequests } from '../../mock_data';
describe('IDE merge requests dropdown', () => {
const Component = Vue.extend(Dropdown);
let vm;
beforeEach(() => {
const store = createStore();
vm = createComponentWithStore(Component, store, { show: false }).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('does not render tabs when show is false', () => {
expect(vm.$el.querySelector('.nav-links')).toBe(null);
});
describe('when show is true', () => {
beforeEach(done => {
vm.show = true;
vm.$store.state.mergeRequests.assigned.mergeRequests.push(mergeRequests[0]);
vm.$nextTick(done);
});
it('renders tabs', () => {
expect(vm.$el.querySelector('.nav-links')).not.toBe(null);
});
it('renders count for assigned & created data', () => {
expect(vm.$el.querySelector('.nav-links a').textContent).toContain('Created by me');
expect(vm.$el.querySelector('.nav-links a .badge').textContent).toContain('0');
expect(vm.$el.querySelectorAll('.nav-links a')[1].textContent).toContain('Assigned to me');
expect(
vm.$el.querySelectorAll('.nav-links a')[1].querySelector('.badge').textContent,
).toContain('1');
});
});
});
import Vue from 'vue';
import router from '~/ide/ide_router';
import Item from '~/ide/components/merge_requests/item.vue';
import mountCompontent from '../../../helpers/vue_mount_component_helper';
......@@ -27,6 +28,12 @@ describe('IDE merge request item', () => {
expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1');
});
it('renders link with href', () => {
const expectedHref = router.resolve(`/project/${vm.item.projectPathWithNamespace}/merge_requests/${vm.item.iid}`).href;
expect(vm.$el).toMatch('a');
expect(vm.$el).toHaveAttr('href', expectedHref);
});
it('renders icon if ID matches currentId', () => {
expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null);
});
......@@ -50,12 +57,4 @@ describe('IDE merge request item', () => {
done();
});
});
it('emits click event on click', () => {
spyOn(vm, '$emit');
vm.$el.click();
expect(vm.$emit).toHaveBeenCalledWith('click', vm.item);
});
});
......@@ -10,10 +10,7 @@ describe('IDE merge requests list', () => {
let vm;
beforeEach(() => {
vm = createComponentWithStore(Component, store, {
type: 'created',
emptyText: 'empty text',
});
vm = createComponentWithStore(Component, store, {});
spyOn(vm, 'fetchMergeRequests');
......@@ -28,13 +25,13 @@ describe('IDE merge requests list', () => {
it('calls fetch on mounted', () => {
expect(vm.fetchMergeRequests).toHaveBeenCalledWith({
type: 'created',
search: '',
type: '',
});
});
it('renders loading icon', done => {
vm.$store.state.mergeRequests.created.isLoading = true;
vm.$store.state.mergeRequests.isLoading = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
......@@ -43,10 +40,6 @@ describe('IDE merge requests list', () => {
});
});
it('renders empty text when no merge requests exist', () => {
expect(vm.$el.textContent).toContain('empty text');
});
it('renders no search results text when search is not empty', done => {
vm.search = 'testing';
......@@ -57,9 +50,29 @@ describe('IDE merge requests list', () => {
});
});
it('clicking on search type, sets currentSearchType and loads merge requests', done => {
vm.onSearchFocus();
vm.$nextTick()
.then(() => {
vm.$el.querySelector('li button').click();
return vm.$nextTick();
})
.then(() => {
expect(vm.currentSearchType).toEqual(vm.$options.searchTypes[0]);
expect(vm.fetchMergeRequests).toHaveBeenCalledWith({
type: vm.currentSearchType.type,
search: '',
});
})
.then(done)
.catch(done.fail);
});
describe('with merge requests', () => {
beforeEach(done => {
vm.$store.state.mergeRequests.created.mergeRequests.push({
vm.$store.state.mergeRequests.mergeRequests.push({
...mergeRequests[0],
projectPathWithNamespace: 'gitlab-org/gitlab-ce',
});
......@@ -71,35 +84,6 @@ describe('IDE merge requests list', () => {
expect(vm.$el.querySelectorAll('li').length).toBe(1);
expect(vm.$el.querySelector('li').textContent).toContain(mergeRequests[0].title);
});
it('calls openMergeRequest when clicking merge request', done => {
spyOn(vm, 'openMergeRequest');
vm.$el.querySelector('li button').click();
vm.$nextTick(() => {
expect(vm.openMergeRequest).toHaveBeenCalledWith({
projectPath: 'gitlab-org/gitlab-ce',
id: 1,
});
done();
});
});
});
describe('focusSearch', () => {
it('focuses search input when loading is false', done => {
spyOn(vm.$refs.searchInput, 'focus');
vm.$store.state.mergeRequests.created.isLoading = false;
vm.focusSearch();
vm.$nextTick(() => {
expect(vm.$refs.searchInput.focus).toHaveBeenCalled();
done();
});
});
});
describe('searchMergeRequests', () => {
......@@ -123,4 +107,52 @@ describe('IDE merge requests list', () => {
expect(vm.loadMergeRequests).toHaveBeenCalled();
});
});
describe('onSearchFocus', () => {
it('shows search types', done => {
vm.$el.querySelector('input').dispatchEvent(new Event('focus'));
expect(vm.hasSearchFocus).toBe(true);
expect(vm.showSearchTypes).toBe(true);
vm.$nextTick()
.then(() => {
const expectedSearchTypes = vm.$options.searchTypes.map(x => x.label);
const renderedSearchTypes = Array.from(vm.$el.querySelectorAll('li'))
.map(x => x.textContent.trim());
expect(renderedSearchTypes).toEqual(expectedSearchTypes);
})
.then(done)
.catch(done.fail);
});
it('does not show search types, if already has search value', () => {
vm.search = 'lorem ipsum';
vm.$el.querySelector('input').dispatchEvent(new Event('focus'));
expect(vm.hasSearchFocus).toBe(true);
expect(vm.showSearchTypes).toBe(false);
});
it('does not show search types, if already has a search type', () => {
vm.currentSearchType = {};
vm.$el.querySelector('input').dispatchEvent(new Event('focus'));
expect(vm.hasSearchFocus).toBe(true);
expect(vm.showSearchTypes).toBe(false);
});
it('resets hasSearchFocus when search changes', done => {
vm.hasSearchFocus = true;
vm.search = 'something else';
vm.$nextTick()
.then(() => {
expect(vm.hasSearchFocus).toBe(false);
})
.then(done)
.catch(done.fail);
});
});
});
import Vue from 'vue';
import NavDropdownButton from '~/ide/components/nav_dropdown_button.vue';
import store from '~/ide/stores';
import { trimText } from 'spec/helpers/vue_component_helper';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../helpers';
describe('NavDropdown', () => {
const TEST_BRANCH_ID = 'lorem-ipsum-dolar';
const TEST_MR_ID = '12345';
const Component = Vue.extend(NavDropdownButton);
let vm;
beforeEach(() => {
vm = mountComponentWithStore(Component, { store });
vm.$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(store);
});
it('renders empty placeholders, if state is falsey', () => {
expect(trimText(vm.$el.textContent)).toEqual('- -');
});
it('renders branch name, if state has currentBranchId', done => {
vm.$store.state.currentBranchId = TEST_BRANCH_ID;
vm.$nextTick()
.then(() => {
expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} -`);
})
.then(done)
.catch(done.fail);
});
it('renders mr id, if state has currentMergeRequestId', done => {
vm.$store.state.currentMergeRequestId = TEST_MR_ID;
vm.$nextTick()
.then(() => {
expect(trimText(vm.$el.textContent)).toEqual(`- !${TEST_MR_ID}`);
})
.then(done)
.catch(done.fail);
});
it('renders branch and mr, if state has both', done => {
vm.$store.state.currentBranchId = TEST_BRANCH_ID;
vm.$store.state.currentMergeRequestId = TEST_MR_ID;
vm.$nextTick()
.then(() => {
expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} !${TEST_MR_ID}`);
})
.then(done)
.catch(done.fail);
});
});
import $ from 'jquery';
import Vue from 'vue';
import store from '~/ide/stores';
import NavDropdown from '~/ide/components/nav_dropdown.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('IDE NavDropdown', () => {
const Component = Vue.extend(NavDropdown);
let vm;
let $dropdown;
beforeEach(() => {
vm = mountComponentWithStore(Component, { store });
$dropdown = $(vm.$el);
// block dispatch from doing anything
spyOn(vm.$store, 'dispatch');
});
afterEach(() => {
vm.$destroy();
});
it('renders nothing initially', () => {
expect(vm.$el).not.toContainElement('.ide-nav-form');
});
it('renders nav form when show.bs.dropdown', done => {
$dropdown.trigger('show.bs.dropdown');
vm.$nextTick()
.then(() => {
expect(vm.$el).toContainElement('.ide-nav-form');
})
.then(done)
.catch(done.fail);
});
it('destroys nav form when closed', done => {
$dropdown.trigger('show.bs.dropdown');
$dropdown.trigger('hide.bs.dropdown');
vm.$nextTick()
.then(() => {
expect(vm.$el).not.toContainElement('.ide-nav-form');
})
.then(done)
.catch(done.fail);
});
});
import Vue from 'vue';
import TokenedInput from '~/ide/components/shared/tokened_input.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
const TEST_PLACEHOLDER = 'Searching in test';
const TEST_TOKENS = [
{ label: 'lorem', id: 1 },
{ label: 'ipsum', id: 2 },
{ label: 'dolar', id: 3 },
];
const TEST_VALUE = 'lorem';
function getTokenElements(vm) {
return Array.from(vm.$el.querySelectorAll('.filtered-search-token button'));
}
function createBackspaceEvent() {
const e = new Event('keyup');
e.keyCode = 8;
e.which = e.keyCode;
e.altKey = false;
e.ctrlKey = true;
e.shiftKey = false;
e.metaKey = false;
return e;
}
describe('IDE shared/TokenedInput', () => {
const Component = Vue.extend(TokenedInput);
let vm;
beforeEach(() => {
vm = mountComponent(Component, {
tokens: TEST_TOKENS,
placeholder: TEST_PLACEHOLDER,
value: TEST_VALUE,
});
spyOn(vm, '$emit');
});
afterEach(() => {
vm.$destroy();
});
it('renders tokens', () => {
const renderedTokens = getTokenElements(vm)
.map(x => x.textContent.trim());
expect(renderedTokens).toEqual(TEST_TOKENS.map(x => x.label));
});
it('renders input', () => {
expect(vm.$refs.input).toBeTruthy();
expect(vm.$refs.input).toHaveValue(TEST_VALUE);
});
it('renders placeholder, when tokens are empty', done => {
vm.tokens = [];
vm.$nextTick()
.then(() => {
expect(vm.$refs.input).toHaveAttr('placeholder', TEST_PLACEHOLDER);
})
.then(done)
.catch(done.fail);
});
it('triggers "removeToken" on token click', () => {
getTokenElements(vm)[0].click();
expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[0]);
});
it('when input triggers backspace event, it calls "onBackspace"', () => {
spyOn(vm, 'onBackspace');
vm.$refs.input.dispatchEvent(createBackspaceEvent());
vm.$refs.input.dispatchEvent(createBackspaceEvent());
expect(vm.onBackspace).toHaveBeenCalledTimes(2);
});
it('triggers "removeToken" on backspaces when value is empty', () => {
vm.value = '';
vm.onBackspace();
expect(vm.$emit).not.toHaveBeenCalled();
expect(vm.backspaceCount).toEqual(1);
vm.onBackspace();
expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[TEST_TOKENS.length - 1]);
expect(vm.backspaceCount).toEqual(0);
});
it('does not trigger "removeToken" on backspaces when value is not empty', () => {
vm.onBackspace();
vm.onBackspace();
expect(vm.backspaceCount).toEqual(0);
expect(vm.$emit).not.toHaveBeenCalled();
});
it('does not trigger "removeToken" on backspaces when tokens are empty', () => {
vm.tokens = [];
vm.onBackspace();
vm.onBackspace();
expect(vm.backspaceCount).toEqual(0);
expect(vm.$emit).not.toHaveBeenCalled();
});
it('triggers "focus" on input focus', () => {
vm.$refs.input.dispatchEvent(new Event('focus'));
expect(vm.$emit).toHaveBeenCalledWith('focus');
});
it('triggers "blur" on input blur', () => {
vm.$refs.input.dispatchEvent(new Event('blur'));
expect(vm.$emit).toHaveBeenCalledWith('blur');
});
it('triggers "input" with value on input change', () => {
vm.$refs.input.value = 'something-else';
vm.$refs.input.dispatchEvent(new Event('input'));
expect(vm.$emit).toHaveBeenCalledWith('input', 'something-else');
});
});
......@@ -4,6 +4,7 @@ import state from '~/ide/stores/state';
import commitState from '~/ide/stores/modules/commit/state';
import mergeRequestsState from '~/ide/stores/modules/merge_requests/state';
import pipelinesState from '~/ide/stores/modules/pipelines/state';
import branchesState from '~/ide/stores/modules/branches/state';
export const resetStore = store => {
const newState = {
......@@ -11,6 +12,7 @@ export const resetStore = store => {
commit: commitState(),
mergeRequests: mergeRequestsState(),
pipelines: pipelinesState(),
branches: branchesState(),
};
store.replaceState(newState);
};
......
......@@ -165,3 +165,33 @@ export const mergeRequests = [
web_url: `${gl.TEST_HOST}/namespace/project-path/merge_requests/1`,
},
];
export const branches = [
{
id: 1,
name: 'master',
commit: {
message: 'Update master branch',
committed_date: '2018-08-01T00:20:05Z',
},
can_push: true,
},
{
id: 2,
name: 'feature/lorem-ipsum',
commit: {
message: 'Update some stuff',
committed_date: '2018-08-02T00:00:05Z',
},
can_push: true,
},
{
id: 3,
name: 'feature/dolar-amit',
commit: {
message: 'Update some more stuff',
committed_date: '2018-06-30T00:20:05Z',
},
can_push: true,
},
];
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import state from '~/ide/stores/modules/branches/state';
import * as types from '~/ide/stores/modules/branches/mutation_types';
import testAction from 'spec/helpers/vuex_action_helper';
import {
requestBranches,
receiveBranchesError,
receiveBranchesSuccess,
fetchBranches,
resetBranches,
openBranch,
} from '~/ide/stores/modules/branches/actions';
import { branches, projectData } from '../../../mock_data';
describe('IDE branches actions', () => {
const TEST_SEARCH = 'foosearch';
let mockedContext;
let mockedState;
let mock;
beforeEach(() => {
mockedContext = {
dispatch() {},
rootState: {
currentProjectId: projectData.name_with_namespace,
},
rootGetters: {
currentProject: projectData,
},
state: state(),
};
// testAction looks for rootGetters in state,
// so they need to be concatenated here.
mockedState = {
...mockedContext.state,
...mockedContext.rootGetters,
...mockedContext.rootState,
};
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('requestBranches', () => {
it('should commit request', done => {
testAction(
requestBranches,
null,
mockedContext.state,
[{ type: types.REQUEST_BRANCHES }],
[],
done,
);
});
});
describe('receiveBranchesError', () => {
it('should should commit error', done => {
testAction(
receiveBranchesError,
{ search: TEST_SEARCH },
mockedContext.state,
[{ type: types.RECEIVE_BRANCHES_ERROR }],
[
{
type: 'setErrorMessage',
payload: {
text: 'Error loading branches.',
action: jasmine.any(Function),
actionText: 'Please try again',
actionPayload: { search: TEST_SEARCH },
},
},
],
done,
);
});
});
describe('receiveBranchesSuccess', () => {
it('should commit received data', done => {
testAction(
receiveBranchesSuccess,
branches,
mockedContext.state,
[{ type: types.RECEIVE_BRANCHES_SUCCESS, payload: branches }],
[],
done,
);
});
});
describe('fetchBranches', () => {
beforeEach(() => {
gon.api_version = 'v4';
});
describe('success', () => {
beforeEach(() => {
mock.onGet(/\/api\/v4\/projects\/\d+\/repository\/branches(.*)$/).replyOnce(200, branches);
});
it('calls API with params', () => {
const apiSpy = spyOn(axios, 'get').and.callThrough();
fetchBranches(mockedContext, { search: TEST_SEARCH });
expect(apiSpy).toHaveBeenCalledWith(jasmine.anything(), {
params: jasmine.objectContaining({
search: TEST_SEARCH,
sort: 'updated_desc',
}),
});
});
it('dispatches success with received data', done => {
testAction(
fetchBranches,
{ search: TEST_SEARCH },
mockedState,
[],
[
{ type: 'requestBranches' },
{ type: 'resetBranches' },
{
type: 'receiveBranchesSuccess',
payload: branches,
},
],
done,
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet(/\/api\/v4\/projects\/\d+\/repository\/branches(.*)$/).replyOnce(500);
});
it('dispatches error', done => {
testAction(
fetchBranches,
{ search: TEST_SEARCH },
mockedState,
[],
[
{ type: 'requestBranches' },
{ type: 'resetBranches' },
{
type: 'receiveBranchesError',
payload: { search: TEST_SEARCH },
},
],
done,
);
});
});
describe('resetBranches', () => {
it('commits reset', done => {
testAction(
resetBranches,
null,
mockedContext.state,
[{ type: types.RESET_BRANCHES }],
[],
done,
);
});
});
describe('openBranch', () => {
it('dispatches goToRoute action with path', done => {
const branchId = branches[0].name;
const expectedPath = `/project/${projectData.name_with_namespace}/edit/${branchId}`;
testAction(
openBranch,
branchId,
mockedState,
[],
[{ type: 'goToRoute', payload: expectedPath }],
done,
);
});
});
});
});
import state from '~/ide/stores/modules/branches/state';
import mutations from '~/ide/stores/modules/branches/mutations';
import * as types from '~/ide/stores/modules/branches/mutation_types';
import { branches } from '../../../mock_data';
describe('IDE branches mutations', () => {
let mockedState;
beforeEach(() => {
mockedState = state();
});
describe(types.REQUEST_BRANCHES, () => {
it('sets loading to true', () => {
mutations[types.REQUEST_BRANCHES](mockedState);
expect(mockedState.isLoading).toBe(true);
});
});
describe(types.RECEIVE_BRANCHES_ERROR, () => {
it('sets loading to false', () => {
mutations[types.RECEIVE_BRANCHES_ERROR](mockedState);
expect(mockedState.isLoading).toBe(false);
});
});
describe(types.RECEIVE_BRANCHES_SUCCESS, () => {
it('sets branches', () => {
const expectedBranches = branches.map(branch => ({
name: branch.name,
committedDate: branch.commit.committed_date,
}));
mutations[types.RECEIVE_BRANCHES_SUCCESS](mockedState, branches);
expect(mockedState.branches).toEqual(expectedBranches);
});
});
describe(types.RESET_BRANCHES, () => {
it('clears branches array', () => {
mockedState.branches = ['test'];
mutations[types.RESET_BRANCHES](mockedState);
expect(mockedState.branches).toEqual([]);
});
});
});
......@@ -8,9 +8,7 @@ import {
receiveMergeRequestsSuccess,
fetchMergeRequests,
resetMergeRequests,
openMergeRequest,
} from '~/ide/stores/modules/merge_requests/actions';
import router from '~/ide/ide_router';
import { mergeRequests } from '../../../mock_data';
import testAction from '../../../../helpers/vuex_action_helper';
......@@ -28,12 +26,12 @@ describe('IDE merge requests actions', () => {
});
describe('requestMergeRequests', () => {
it('should should commit request', done => {
it('should commit request', done => {
testAction(
requestMergeRequests,
'created',
null,
mockedState,
[{ type: types.REQUEST_MERGE_REQUESTS, payload: 'created' }],
[{ type: types.REQUEST_MERGE_REQUESTS }],
[],
done,
);
......@@ -46,7 +44,7 @@ describe('IDE merge requests actions', () => {
receiveMergeRequestsError,
{ type: 'created', search: '' },
mockedState,
[{ type: types.RECEIVE_MERGE_REQUESTS_ERROR, payload: 'created' }],
[{ type: types.RECEIVE_MERGE_REQUESTS_ERROR }],
[
{
type: 'setErrorMessage',
......@@ -67,12 +65,12 @@ describe('IDE merge requests actions', () => {
it('should commit received data', done => {
testAction(
receiveMergeRequestsSuccess,
{ type: 'created', data: 'data' },
mergeRequests,
mockedState,
[
{
type: types.RECEIVE_MERGE_REQUESTS_SUCCESS,
payload: { type: 'created', data: 'data' },
payload: mergeRequests,
},
],
[],
......@@ -129,11 +127,11 @@ describe('IDE merge requests actions', () => {
mockedState,
[],
[
{ type: 'requestMergeRequests', payload: 'created' },
{ type: 'resetMergeRequests', payload: 'created' },
{ type: 'requestMergeRequests' },
{ type: 'resetMergeRequests' },
{
type: 'receiveMergeRequestsSuccess',
payload: { type: 'created', data: mergeRequests },
payload: mergeRequests,
},
],
done,
......@@ -149,12 +147,12 @@ describe('IDE merge requests actions', () => {
it('dispatches error', done => {
testAction(
fetchMergeRequests,
{ type: 'created' },
{ type: 'created', search: '' },
mockedState,
[],
[
{ type: 'requestMergeRequests', payload: 'created' },
{ type: 'resetMergeRequests', payload: 'created' },
{ type: 'requestMergeRequests' },
{ type: 'resetMergeRequests' },
{ type: 'receiveMergeRequestsError', payload: { type: 'created', search: '' } },
],
done,
......@@ -167,59 +165,12 @@ describe('IDE merge requests actions', () => {
it('commits reset', done => {
testAction(
resetMergeRequests,
'created',
null,
mockedState,
[{ type: types.RESET_MERGE_REQUESTS, payload: 'created' }],
[{ type: types.RESET_MERGE_REQUESTS }],
[],
done,
);
});
});
describe('openMergeRequest', () => {
beforeEach(() => {
spyOn(router, 'push');
});
it('commits reset mutations and actions', done => {
const commit = jasmine.createSpy();
const dispatch = jasmine.createSpy().and.returnValue(Promise.resolve());
openMergeRequest({ commit, dispatch }, { projectPath: 'gitlab-org/gitlab-ce', id: '1' });
setTimeout(() => {
expect(commit.calls.argsFor(0)).toEqual(['CLEAR_PROJECTS', null, { root: true }]);
expect(commit.calls.argsFor(1)).toEqual(['SET_CURRENT_MERGE_REQUEST', '1', { root: true }]);
expect(commit.calls.argsFor(2)).toEqual(['RESET_OPEN_FILES', null, { root: true }]);
expect(dispatch.calls.argsFor(0)).toEqual(['setCurrentBranchId', '', { root: true }]);
expect(dispatch.calls.argsFor(1)).toEqual([
'pipelines/stopPipelinePolling',
null,
{ root: true },
]);
expect(dispatch.calls.argsFor(2)).toEqual(['setRightPane', null, { root: true }]);
expect(dispatch.calls.argsFor(3)).toEqual([
'pipelines/resetLatestPipeline',
null,
{ root: true },
]);
expect(dispatch.calls.argsFor(4)).toEqual([
'pipelines/clearEtagPoll',
null,
{ root: true },
]);
done();
});
});
it('pushes new route', () => {
openMergeRequest(
{ commit() {}, dispatch: () => Promise.resolve() },
{ projectPath: 'gitlab-org/gitlab-ce', id: '1' },
);
expect(router.push).toHaveBeenCalledWith('/project/gitlab-org/gitlab-ce/merge_requests/1');
});
});
});
......@@ -12,29 +12,26 @@ describe('IDE merge requests mutations', () => {
describe(types.REQUEST_MERGE_REQUESTS, () => {
it('sets loading to true', () => {
mutations[types.REQUEST_MERGE_REQUESTS](mockedState, 'created');
mutations[types.REQUEST_MERGE_REQUESTS](mockedState);
expect(mockedState.created.isLoading).toBe(true);
expect(mockedState.isLoading).toBe(true);
});
});
describe(types.RECEIVE_MERGE_REQUESTS_ERROR, () => {
it('sets loading to false', () => {
mutations[types.RECEIVE_MERGE_REQUESTS_ERROR](mockedState, 'created');
mutations[types.RECEIVE_MERGE_REQUESTS_ERROR](mockedState);
expect(mockedState.created.isLoading).toBe(false);
expect(mockedState.isLoading).toBe(false);
});
});
describe(types.RECEIVE_MERGE_REQUESTS_SUCCESS, () => {
it('sets merge requests', () => {
gon.gitlab_url = gl.TEST_HOST;
mutations[types.RECEIVE_MERGE_REQUESTS_SUCCESS](mockedState, {
type: 'created',
data: mergeRequests,
});
mutations[types.RECEIVE_MERGE_REQUESTS_SUCCESS](mockedState, mergeRequests);
expect(mockedState.created.mergeRequests).toEqual([
expect(mockedState.mergeRequests).toEqual([
{
id: 1,
iid: 1,
......@@ -50,9 +47,9 @@ describe('IDE merge requests mutations', () => {
it('clears merge request array', () => {
mockedState.mergeRequests = ['test'];
mutations[types.RESET_MERGE_REQUESTS](mockedState, 'created');
mutations[types.RESET_MERGE_REQUESTS](mockedState);
expect(mockedState.created.mergeRequests).toEqual([]);
expect(mockedState.mergeRequests).toEqual([]);
});
});
});
......@@ -2,15 +2,15 @@ import Vue from 'vue';
import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
const defaultLabel = 'Select';
const customLabel = 'Select project';
const createComponent = config => {
const createComponent = (props, slots = {}) => {
const Component = Vue.extend(dropdownButtonComponent);
return mountComponent(Component, config);
return mountComponentWithSlots(Component, { props, slots });
};
describe('DropdownButtonComponent', () => {
......@@ -65,5 +65,14 @@ describe('DropdownButtonComponent', () => {
expect(dropdownIconEl).not.toBeNull();
expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true);
});
it('renders slot, if default slot exists', () => {
vm = createComponent({}, {
default: ['Lorem Ipsum Dolar'],
});
expect(vm.$el).not.toContainElement('.dropdown-toggle-text');
expect(vm.$el).toHaveText('Lorem Ipsum Dolar');
});
});
});
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