Skip to content
Snippets Groups Projects
Commit 0d6e50d5 authored by Paul Slaughter's avatar Paul Slaughter Committed by Phil Hughes
Browse files

Create Web IDE MR and branch picker

parent 0e90f27f
No related branches found
No related tags found
1 merge request!10495Merge Requests - Assignee
Showing
with 659 additions and 184 deletions
Loading
Loading
@@ -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));
 
Loading
Loading
<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>
Loading
Loading
@@ -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"
Loading
Loading
Loading
Loading
@@ -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: {
Loading
Loading
@@ -57,6 +57,7 @@ export default {
:class="headerClass"
class="ide-tree-header"
>
<nav-dropdown />
<slot name="header"></slot>
</header>
<div
Loading
Loading
<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: {
Loading
Loading
@@ -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"
Loading
Loading
@@ -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
Loading
Loading
@@ -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>
Loading
Loading
@@ -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);
 
Loading
Loading
@@ -20,6 +21,7 @@ export const createStore = () =>
commit: commitModule,
pipelines,
mergeRequests,
branches,
},
});
 
Loading
Loading
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',
Loading
Loading
@@ -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;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment