Commit 47dd2063 authored by GitLab Bot's avatar GitLab Bot

Merge remote-tracking branch 'upstream/master' into ce-to-ee-2018-08-07

# Conflicts:
#	GITALY_SERVER_VERSION
#	app/helpers/namespaces_helper.rb
#	app/views/projects/_new_project_fields.html.haml
#	db/schema.rb
#	doc/user/project/labels.md
#	locale/gitlab.pot

[ci skip]
parents 1797f347 e22effd4
...@@ -377,13 +377,14 @@ on those issues. Please select someone with relevant experience from the ...@@ -377,13 +377,14 @@ on those issues. Please select someone with relevant experience from the
the commit history for the affected files to find someone. the commit history for the affected files to find someone.
We also use [GitLab Triage] to automate some triaging policies. This is We also use [GitLab Triage] to automate some triaging policies. This is
currently setup as a [scheduled pipeline] running on the [`gl-triage`] branch. currently setup as a [scheduled pipeline] running on [quality/triage-ops]
project.
[described in our handbook]: https://about.gitlab.com/handbook/engineering/issue-triage/ [described in our handbook]: https://about.gitlab.com/handbook/engineering/issue-triage/
[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815 [issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815
[GitLab Triage]: https://gitlab.com/gitlab-org/gitlab-triage [GitLab Triage]: https://gitlab.com/gitlab-org/gitlab-triage
[scheduled pipeline]: https://gitlab.com/gitlab-org/gitlab-ce/pipeline_schedules/3732/edit [scheduled pipeline]: https://gitlab.com/gitlab-org/quality/triage-ops/pipeline_schedules/10512/edit
[`gl-triage`]: https://gitlab.com/gitlab-org/gitlab-ce/tree/gl-triage [quality/triage-ops]: https://gitlab.com/gitlab-org/quality/triage-ops
### Feature proposals ### Feature proposals
......
<<<<<<< HEAD
0.116.0 0.116.0
=======
0.117.0
>>>>>>> upstream/master
...@@ -246,6 +246,18 @@ const Api = { ...@@ -246,6 +246,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 }) { createBranch(id, { ref, branch }) {
const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
......
...@@ -13,11 +13,8 @@ export default { ...@@ -13,11 +13,8 @@ export default {
tooltip, tooltip,
}, },
computed: { computed: {
...mapGetters(['currentProject', 'hasChanges']), ...mapGetters(['hasChanges']),
...mapState(['currentActivityView']), ...mapState(['currentActivityView']),
goBackUrl() {
return document.referrer || this.currentProject.web_url;
},
}, },
methods: { methods: {
...mapActions(['updateActivityBarView']), ...mapActions(['updateActivityBarView']),
...@@ -36,22 +33,6 @@ export default { ...@@ -36,22 +33,6 @@ export default {
<template> <template>
<nav class="ide-activity-bar"> <nav class="ide-activity-bar">
<ul class="list-unstyled"> <ul class="list-unstyled">
<li v-once>
<a
v-tooltip
:href="goBackUrl"
:title="s__('IDE|Go back')"
:aria-label="s__('IDE|Go back')"
data-container="body"
data-placement="right"
class="ide-sidebar-link"
>
<icon
:size="16"
name="go-back"
/>
</a>
</li>
<li> <li>
<button <button
v-tooltip v-tooltip
......
<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>
<script>
import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue';
export default {
components: {
ProjectAvatarDefault,
},
props: {
project: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div class="context-header ide-context-header">
<a
:href="project.web_url"
:title="s__('IDE|Go to project')"
>
<project-avatar-default
:project="project"
:size="48"
/>
<span class="ide-sidebar-project-title">
<span class="sidebar-context-title">
{{ project.name }}
</span>
<span class="sidebar-context-title text-secondary">
{{ project.path_with_namespace }}
</span>
</span>
</a>
</div>
</template>
<script> <script>
import $ from 'jquery';
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters } from 'vuex';
import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import Identicon from '../../vue_shared/components/identicon.vue';
import IdeTree from './ide_tree.vue'; import IdeTree from './ide_tree.vue';
import ResizablePanel from './resizable_panel.vue'; import ResizablePanel from './resizable_panel.vue';
import ActivityBar from './activity_bar.vue'; import ActivityBar from './activity_bar.vue';
...@@ -14,43 +8,28 @@ import CommitSection from './repo_commit_section.vue'; ...@@ -14,43 +8,28 @@ import CommitSection from './repo_commit_section.vue';
import CommitForm from './commit_sidebar/form.vue'; import CommitForm from './commit_sidebar/form.vue';
import IdeReview from './ide_review.vue'; import IdeReview from './ide_review.vue';
import SuccessMessage from './commit_sidebar/success_message.vue'; import SuccessMessage from './commit_sidebar/success_message.vue';
import MergeRequestDropdown from './merge_requests/dropdown.vue'; import IdeProjectHeader from './ide_project_header.vue';
import { activityBarViews } from '../constants'; import { activityBarViews } from '../constants';
export default { export default {
directives: {
tooltip,
},
components: { components: {
Icon,
PanelResizer,
SkeletonLoadingContainer, SkeletonLoadingContainer,
ResizablePanel, ResizablePanel,
ActivityBar, ActivityBar,
ProjectAvatarImage,
Identicon,
CommitSection, CommitSection,
IdeTree, IdeTree,
CommitForm, CommitForm,
IdeReview, IdeReview,
SuccessMessage, SuccessMessage,
MergeRequestDropdown, IdeProjectHeader,
},
data() {
return {
showTooltip: false,
showMergeRequestsDropdown: false,
};
}, },
computed: { computed: {
...mapState([ ...mapState([
'loading', 'loading',
'currentBranchId',
'currentActivityView', 'currentActivityView',
'changedFiles', 'changedFiles',
'stagedFiles', 'stagedFiles',
'lastCommitMsg', 'lastCommitMsg',
'currentMergeRequestId',
]), ]),
...mapGetters(['currentProject', 'someUncommitedChanges']), ...mapGetters(['currentProject', 'someUncommitedChanges']),
showSuccessMessage() { showSuccessMessage() {
...@@ -59,46 +38,6 @@ export default { ...@@ -59,46 +38,6 @@ export default {
(this.lastCommitMsg && !this.someUncommitedChanges) (this.lastCommitMsg && !this.someUncommitedChanges)
); );
}, },
branchTooltipTitle() {
return this.showTooltip ? this.currentBranchId : undefined;
},
},
watch: {
currentBranchId() {
this.$nextTick(() => {
if (!this.$refs.branchId) return;
this.showTooltip = this.$refs.branchId.scrollWidth > this.$refs.branchId.offsetWidth;
});
},
loading() {
this.$nextTick(() => {
this.addDropdownListeners();
});
},
},
mounted() {
this.addDropdownListeners();
},
beforeDestroy() {
$(this.$refs.mergeRequestDropdown)
.off('show.bs.dropdown')
.off('hide.bs.dropdown');
},
methods: {
addDropdownListeners() {
if (!this.$refs.mergeRequestDropdown) return;
$(this.$refs.mergeRequestDropdown)
.on('show.bs.dropdown', () => {
this.toggleMergeRequestDropdown();
}).on('hide.bs.dropdown', () => {
this.toggleMergeRequestDropdown();
});
},
toggleMergeRequestDropdown() {
this.showMergeRequestsDropdown = !this.showMergeRequestsDropdown;
},
}, },
}; };
</script> </script>
...@@ -108,12 +47,10 @@ export default { ...@@ -108,12 +47,10 @@ export default {
:collapsible="false" :collapsible="false"
:initial-width="340" :initial-width="340"
side="left" side="left"
class="flex-column"
> >
<activity-bar <template v-if="loading">
v-if="!loading" <div class="multi-file-commit-panel-inner">
/>
<div class="multi-file-commit-panel-inner">
<template v-if="loading">
<div <div
v-for="n in 3" v-for="n in 3"
:key="n" :key="n"
...@@ -121,81 +58,23 @@ export default { ...@@ -121,81 +58,23 @@ export default {
> >
<skeleton-loading-container /> <skeleton-loading-container />
</div> </div>
</template> </div>
<template v-else> </template>
<div <template v-else>
ref="mergeRequestDropdown" <ide-project-header
class="context-header ide-context-header dropdown" :project="currentProject"
> />
<button <div class="ide-context-body d-flex flex-fill">
type="button" <activity-bar />
data-toggle="dropdown" <div class="multi-file-commit-panel-inner">
> <div class="multi-file-commit-panel-inner-content">
<div <component
v-if="currentProject.avatar_url" :is="currentActivityView"
class="avatar-container s40 project-avatar"
>
<project-avatar-image
:link-href="currentProject.path"
:img-src="currentProject.avatar_url"
:img-alt="currentProject.name"
:img-size="40"
class="avatar-container project-avatar"
/>
</div>
<identicon
v-else
:entity-id="currentProject.id"
:entity-name="currentProject.name"
size-class="s40"
/> />
<div class="ide-sidebar-project-title"> </div>
<div class="sidebar-context-title"> <commit-form />
{{ currentProject.name }}
</div>
<div class="d-flex">
<div
v-tooltip
v-if="currentBranchId"
ref="branchId"
:title="branchTooltipTitle"
class="sidebar-context-title ide-sidebar-branch-title"
>
<icon
name="branch"
css-classes="append-right-5"
/>{{ currentBranchId }}
</div>
<div
v-if="currentMergeRequestId"
:class="{
'prepend-left-8': currentBranchId
}"
class="sidebar-context-title ide-sidebar-branch-title"
>
<icon
name="git-merge"
css-classes="append-right-5"
/>!{{ currentMergeRequestId }}
</div>
</div>
</div>
<icon
class="ml-auto"
name="chevron-down"
/>
</button>
<merge-request-dropdown
:show="showMergeRequestsDropdown"
/>
</div>
<div class="multi-file-commit-panel-inner-scroll">
<component
:is="currentActivityView"
/>
</div> </div>
<commit-form /> </div>
</template> </template>
</div>
</resizable-panel> </resizable-panel>
</template> </template>
...@@ -35,14 +35,13 @@ export default { ...@@ -35,14 +35,13 @@ export default {
<template> <template>
<ide-tree-list <ide-tree-list
header-class="d-flex w-100"
viewer-type="editor" viewer-type="editor"
> >
<template <template
slot="header" slot="header"
> >
{{ __('Edit') }} {{ __('Edit') }}
<div class="ml-auto d-flex"> <div class="ide-tree-actions ml-auto d-flex">
<new-entry-button <new-entry-button
:label="__('New file')" :label="__('New file')"
:show-label="false" :show-label="false"
......
...@@ -3,14 +3,14 @@ import { mapActions, mapGetters, mapState } from 'vuex'; ...@@ -3,14 +3,14 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import RepoFile from './repo_file.vue'; import RepoFile from './repo_file.vue';
import NewDropdown from './new_dropdown/index.vue'; import NavDropdown from './nav_dropdown.vue';
export default { export default {
components: { components: {
Icon, Icon,
RepoFile, RepoFile,
SkeletonLoadingContainer, SkeletonLoadingContainer,
NewDropdown, NavDropdown,
}, },
props: { props: {
viewerType: { viewerType: {
...@@ -57,14 +57,19 @@ export default { ...@@ -57,14 +57,19 @@ export default {
:class="headerClass" :class="headerClass"
class="ide-tree-header" class="ide-tree-header"
> >
<nav-dropdown />
<slot name="header"></slot> <slot name="header"></slot>
</header> </header>
<repo-file <div
v-for="file in currentTree.tree" class="ide-tree-body"
:key="file.key" >
:file="file" <repo-file
:level="0" v-for="file in currentTree.tree"
/> :key="file.key"
:file="file"
:level="0"
/>
</div>
</template> </template>
</div> </div>
</template> </template>
<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> <script>
import Icon from '../../../vue_shared/components/icon.vue'; import Icon from '../../../vue_shared/components/icon.vue';
import router from '../../ide_router';
export default { export default {
components: { components: {
...@@ -29,22 +30,21 @@ export default { ...@@ -29,22 +30,21 @@ export default {
pathWithID() { pathWithID() {
return `${this.item.projectPathWithNamespace}!${this.item.iid}`; return `${this.item.projectPathWithNamespace}!${this.item.iid}`;
}, },
}, mergeRequestHref() {
methods: { const path = `/project/${this.item.projectPathWithNamespace}/merge_requests/${this.item.iid}`;
clickItem() {
this.$emit('click', this.item); return router.resolve(path).href;
}, },
}, },
}; };
</script> </script>
<template> <template>
<button <a
type="button" :href="mergeRequestHref"
class="btn-link d-flex align-items-center" 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 <icon
v-if="isActive" v-if="isActive"
:size="18" :size="18"
...@@ -59,5 +59,5 @@ export default { ...@@ -59,5 +59,5 @@ export default {
{{ pathWithID }} {{ pathWithID }}
</span> </span>
</span> </span>
</button> </a>
</template> </template>
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import _ from 'underscore'; 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 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 { export default {
components: { components: {
LoadingIcon, LoadingIcon,
TokenedInput,
Item, Item,
}, Icon,
props: {
type: {
type: String,
required: true,
},
emptyText: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
search: '', search: '',
currentSearchType: null,
hasSearchFocus: false,
}; };
}, },
computed: { computed: {
...mapGetters('mergeRequests', ['getData']), ...mapState('mergeRequests', ['mergeRequests', 'isLoading']),
...mapState(['currentMergeRequestId', 'currentProjectId']), ...mapState(['currentMergeRequestId', 'currentProjectId']),
data() {
return this.getData(this.type);
},
isLoading() {
return this.data.isLoading;
},
mergeRequests() {
return this.data.mergeRequests;
},
hasMergeRequests() { hasMergeRequests() {
return this.mergeRequests.length !== 0; return this.mergeRequests.length !== 0;
}, },
hasNoSearchResults() { hasNoSearchResults() {
return this.search !== '' && !this.hasMergeRequests; 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: { watch: {
isLoading: { search() {
handler: 'focusSearch', // When the search is updated, let's turn off this flag to hide the search types
this.hasSearchFocus = false;
}, },
}, },
mounted() { mounted() {
this.loadMergeRequests(); this.loadMergeRequests();
}, },
methods: { methods: {
...mapActions('mergeRequests', ['fetchMergeRequests', 'openMergeRequest']), ...mapActions('mergeRequests', ['fetchMergeRequests']),
loadMergeRequests() { loadMergeRequests() {
this.fetchMergeRequests({ type: this.type, search: this.search }); this.fetchMergeRequests({ type: this.type, search: this.search });
}, },
viewMergeRequest(item) {
this.openMergeRequest({
projectPath: item.projectPathWithNamespace,
id: item.iid,
});
},
searchMergeRequests: _.debounce(function debounceSearch() { searchMergeRequests: _.debounce(function debounceSearch() {
this.loadMergeRequests(); this.loadMergeRequests();
}, 250), }, 250),
focusSearch() { onSearchFocus() {
if (!this.isLoading) { this.hasSearchFocus = true;
this.$nextTick(() => { },
this.$refs.searchInput.focus(); setSearchType(searchType) {
}); this.currentSearchType = searchType;
} this.loadMergeRequests();
}, },
}, },
searchTypes: SEARCH_TYPES,
}; };
</script> </script>
<template> <template>
<div> <div>
<div class="dropdown-input mt-3 pb-3 mb-0 border-bottom"> <div class="dropdown-input mt-3 pb-3 mb-0 border-bottom">
<input <div class="position-relative">
ref="searchInput" <tokened-input
:placeholder="__('Search merge requests')" v-model="search"
v-model="search" :tokens="searchTokens"
type="search" :placeholder="__('Search merge requests')"
class="dropdown-input-field" @focus="onSearchFocus"
@input="searchMergeRequests" @input="searchMergeRequests"
/> @removeToken="setSearchType(null)"
<i />
aria-hidden="true" <icon
class="fa fa-search dropdown-input-search" :size="18"
></i> name="search"
class="input-icon"
/>
</div>
</div> </div>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> <div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<loading-icon <loading-icon
...@@ -98,35 +103,52 @@ export default { ...@@ -98,35 +103,52 @@ export default {
class="mt-3 mb-3 align-self-center ml-auto mr-auto" class="mt-3 mb-3 align-self-center ml-auto mr-auto"
size="2" size="2"
/> />
<ul <template v-else>
v-else <ul
class="mb-3 w-100" 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-if="hasNoSearchResults"> <template v-if="showSearchTypes">
{{ __('No merge requests found') }} <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>
<template v-else> <template v-else-if="hasMergeRequests">
{{ emptyText }} <li
v-for="item in mergeRequests"
:key="item.id"
>
<item
:item="item"
:current-id="currentMergeRequestId"
:current-project-id="currentProjectId"
/>
</li>
</template> </template>
</li> <li
</ul> v-else
class="ide-search-list-empty d-flex align-items-center justify-content-center"
>
{{ __('No merge requests found') }}
</li>
</ul>
</template>
</div> </div>
</div> </div>
</template> </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'; ...@@ -7,6 +7,7 @@ import mutations from './mutations';
import commitModule from './modules/commit'; import commitModule from './modules/commit';
import pipelines from './modules/pipelines'; import pipelines from './modules/pipelines';
import mergeRequests from './modules/merge_requests'; import mergeRequests from './modules/merge_requests';
import branches from './modules/branches';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -20,6 +21,7 @@ export const createStore = () => ...@@ -20,6 +21,7 @@ export const createStore = () =>
commit: commitModule, commit: commitModule,
pipelines, pipelines,
mergeRequests, 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 { __ } from '../../../../locale';
import Api from '../../../../api'; import Api from '../../../../api';
import router from '../../../ide_router';
import { scopes } from './constants'; import { scopes } from './constants';
import * as types from './mutation_types'; import * as types from './mutation_types';
import * as rootTypes from '../../mutation_types';
export const requestMergeRequests = ({ commit }, type) => export const requestMergeRequests = ({ commit }) =>
commit(types.REQUEST_MERGE_REQUESTS, type); commit(types.REQUEST_MERGE_REQUESTS);
export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }) => { export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }) => {
dispatch( dispatch(
'setErrorMessage', 'setErrorMessage',
...@@ -21,39 +19,22 @@ export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search } ...@@ -21,39 +19,22 @@ export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }
}, },
{ root: true }, { root: true },
); );
commit(types.RECEIVE_MERGE_REQUESTS_ERROR, type); commit(types.RECEIVE_MERGE_REQUESTS_ERROR);
}; };
export const receiveMergeRequestsSuccess = ({ commit }, { type, data }) => export const receiveMergeRequestsSuccess = ({ commit }, data) =>
commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, { type, data }); commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, data);
export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, search = '' }) => { export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, search = '' }) => {
const scope = scopes[type]; dispatch('requestMergeRequests');
dispatch('requestMergeRequests', type); dispatch('resetMergeRequests');
dispatch('resetMergeRequests', type);
const scope = type ? scopes[type] : 'all';
return Api.mergeRequests({ scope, state, search }) return Api.mergeRequests({ scope, state, search })
.then(({ data }) => dispatch('receiveMergeRequestsSuccess', { type, data })) .then(({ data }) => dispatch('receiveMergeRequestsSuccess', data))
.catch(() => dispatch('receiveMergeRequestsError', { type, search })); .catch(() => dispatch('receiveMergeRequestsError', { type, search }));
}; };
export const resetMergeRequests = ({ commit }, type) => commit(types.RESET_MERGE_REQUESTS, type); export const resetMergeRequests = ({ commit }) => commit(types.RESET_MERGE_REQUESTS);
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 default () => {}; 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 state from './state';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
export default { export default {
...@@ -8,5 +7,4 @@ export default { ...@@ -8,5 +7,4 @@ export default {
state: state(), state: state(),
actions, actions,
mutations, mutations,
getters,
}; };
...@@ -2,15 +2,15 @@ ...@@ -2,15 +2,15 @@
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.REQUEST_MERGE_REQUESTS](state, type) { [types.REQUEST_MERGE_REQUESTS](state) {
state[type].isLoading = true; state.isLoading = true;
}, },
[types.RECEIVE_MERGE_REQUESTS_ERROR](state, type) { [types.RECEIVE_MERGE_REQUESTS_ERROR](state) {
state[type].isLoading = false; state.isLoading = false;
}, },
[types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, { type, data }) { [types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, data) {
state[type].isLoading = false; state.isLoading = false;
state[type].mergeRequests = data.map(mergeRequest => ({ state.mergeRequests = data.map(mergeRequest => ({
id: mergeRequest.id, id: mergeRequest.id,
iid: mergeRequest.iid, iid: mergeRequest.iid,
title: mergeRequest.title, title: mergeRequest.title,
...@@ -20,7 +20,7 @@ export default { ...@@ -20,7 +20,7 @@ export default {
.replace(`/merge_requests/${mergeRequest.iid}`, ''), .replace(`/merge_requests/${mergeRequest.iid}`, ''),
})); }));
}, },
[types.RESET_MERGE_REQUESTS](state, type) { [types.RESET_MERGE_REQUESTS](state) {
state[type].mergeRequests = []; state.mergeRequests = [];
}, },
}; };
import { states } from './constants'; import { states } from './constants';
export default () => ({ export default () => ({
created: { isLoading: false,
isLoading: false, mergeRequests: [],
mergeRequests: [],
},
assigned: {
isLoading: false,
mergeRequests: [],
},
state: states.opened, state: states.opened,
}); });
...@@ -69,7 +69,7 @@ ...@@ -69,7 +69,7 @@
return ( return (
report.existing_failures.length > 0 || report.existing_failures.length > 0 ||
report.new_failures.length > 0 || report.new_failures.length > 0 ||
report.resolved_failures > 0 report.resolved_failures.length > 0
); );
}, },
}, },
......
...@@ -9,6 +9,8 @@ export default { ...@@ -9,6 +9,8 @@ export default {
state.isLoading = true; state.isLoading = true;
}, },
[types.RECEIVE_REPORTS_SUCCESS](state, response) { [types.RECEIVE_REPORTS_SUCCESS](state, response) {
// Make sure to clean previous state in case it was an error
state.hasError = false;
state.isLoading = false; state.isLoading = false;
......
...@@ -38,9 +38,17 @@ export default { ...@@ -38,9 +38,17 @@ export default {
v-show="isLoading" v-show="isLoading"
:inline="true" :inline="true"
/> />
<span class="dropdown-toggle-text"> <template>
{{ toggleText }} <slot
</span> v-if="$slots.default"
></slot>
<span
v-else
class="dropdown-toggle-text"
>
{{ toggleText }}
</span>
</template>
<span <span
v-show="!isLoading" v-show="!isLoading"
class="dropdown-toggle-icon" class="dropdown-toggle-icon"
......
<script> <script>
// only allow classes in images.scss e.g. s12 // 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; let iconValidator = () => true;
/* /*
...@@ -75,6 +75,12 @@ export default { ...@@ -75,6 +75,12 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
tabIndex: {
type: String,
required: false,
default: null,
},
}, },
computed: { computed: {
...@@ -98,6 +104,7 @@ export default { ...@@ -98,6 +104,7 @@ export default {
:height="height" :height="height"
:x="x" :x="x"
:y="y" :y="y"
:tabindex="tabIndex"
> >
<use v-bind="{ 'xlink:href':spriteHref }"/> <use v-bind="{ 'xlink:href':spriteHref }"/>
</svg> </svg>
......
<script>
import Identicon from '../identicon.vue';
import ProjectAvatarImage from './image.vue';
export default {
components: {
Identicon,
ProjectAvatarImage,
},
props: {
project: {
type: Object,
required: true,
},
size: {
type: Number,
default: 40,
},
},
computed: {
sizeClass() {
return `s${this.size}`;
},
},
};
</script>
<template>
<span
:class="sizeClass"
class="avatar-container project-avatar"
>
<project-avatar-image
v-if="project.avatar_url"
:link-href="project.path"
:img-src="project.avatar_url"
:img-alt="project.name"
:img-size="size"
/>
<identicon
v-else
:entity-id="project.id"
:entity-name="project.name"
:size-class="sizeClass"
/>
</span>
</template>
...@@ -82,6 +82,7 @@ ...@@ -82,6 +82,7 @@
&.s26 { font-size: 20px; line-height: 1.33; } &.s26 { font-size: 20px; line-height: 1.33; }
&.s32 { font-size: 20px; line-height: 30px; } &.s32 { font-size: 20px; line-height: 30px; }
&.s40 { font-size: 16px; line-height: 38px; } &.s40 { font-size: 16px; line-height: 38px; }
&.s48 { font-size: 20px; line-height: 46px; }
&.s60 { font-size: 32px; line-height: 58px; } &.s60 { font-size: 32px; line-height: 58px; }
&.s70 { font-size: 34px; line-height: 70px; } &.s70 { font-size: 34px; line-height: 70px; }
&.s90 { font-size: 36px; line-height: 88px; } &.s90 { font-size: 36px; line-height: 88px; }
......
...@@ -55,6 +55,11 @@ ...@@ -55,6 +55,11 @@
.sidebar-context-title { .sidebar-context-title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
&.text-secondary {
font-weight: normal;
font-size: 0.8em;
}
} }
} }
......
...@@ -571,7 +571,8 @@ ...@@ -571,7 +571,8 @@
margin-bottom: 10px; margin-bottom: 10px;
padding: 0 10px; padding: 0 10px;
.fa { .fa,
.input-icon {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 20px; right: 20px;
......
...@@ -39,7 +39,7 @@ ...@@ -39,7 +39,7 @@
svg { svg {
fill: currentColor; 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 { @each $svg-size in $svg-sizes {
&.s#{$svg-size} { &.s#{$svg-size} {
@include svg-size(#{$svg-size}px); @include svg-size(#{$svg-size}px);
......
@import 'framework/variables'; @import 'framework/variables';
@import 'framework/mixins'; @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;
$ide-tree-padding: $gl-padding;
$ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
.project-refs-form, .project-refs-form,
.project-refs-target-form { .project-refs-target-form {
display: inline-block; display: inline-block;
...@@ -24,7 +31,6 @@ ...@@ -24,7 +31,6 @@
display: flex; display: flex;
height: calc(100vh - #{$header-height}); height: calc(100vh - #{$header-height});
margin-top: 0; margin-top: 0;
border-top: 1px solid $white-dark;
padding-bottom: $ide-statusbar-height; padding-bottom: $ide-statusbar-height;
color: $gl-text-color; color: $gl-text-color;
...@@ -41,10 +47,10 @@ ...@@ -41,10 +47,10 @@
} }
.ide-file-list { .ide-file-list {
display: flex;
flex-direction: column;
flex: 1; flex: 1;
padding-left: $gl-padding; min-height: 0;
padding-right: $gl-padding;
padding-bottom: $grid-size;
.file { .file {
height: 32px; height: 32px;
...@@ -517,35 +523,30 @@ ...@@ -517,35 +523,30 @@
> a, > a,
> button { > button {
height: 60px; text-decoration: none;
padding-top: $gl-padding-8;
padding-bottom: $gl-padding-8;
} }
} }
.projects-sidebar {
min-height: 0;
display: flex;
flex-direction: column;
flex: 1;
}
.multi-file-commit-panel-inner { .multi-file-commit-panel-inner {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; min-height: 100%;
min-width: 0; min-width: 0;
width: 100%; width: 100%;
} }
.multi-file-commit-panel-inner-scroll { .multi-file-commit-panel-inner-content {
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
overflow: auto;
background-color: $white-light; background-color: $white-light;
border-left: 1px solid $white-dark; border-left: 1px solid $white-dark;
border-top: 1px solid $white-dark; border-top: 1px solid $white-dark;
border-top-left-radius: $border-radius-small; border-top-left-radius: $border-radius-small;
min-height: 0;
} }
} }
...@@ -803,12 +804,6 @@ ...@@ -803,12 +804,6 @@
height: calc(100vh - #{$header-height + $flash-height}); height: calc(100vh - #{$header-height + $flash-height});
} }
} }
.projects-sidebar {
.multi-file-commit-panel-inner-scroll {
flex: 1;
}
}
} }
} }
...@@ -964,7 +959,7 @@ ...@@ -964,7 +959,7 @@
.ide-activity-bar { .ide-activity-bar {
position: relative; position: relative;
flex: 0 0 60px; flex: 0 0 $ide-activity-bar-width;
z-index: 1; z-index: 1;
} }
...@@ -1060,21 +1055,56 @@ ...@@ -1060,21 +1055,56 @@
} }
.ide-tree-header { .ide-tree-header {
flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 8px; flex-wrap: wrap;
padding: 12px 0; padding: 12px 0;
margin-left: $ide-tree-padding;
margin-right: $ide-tree-padding;
border-bottom: 1px solid $white-dark; border-bottom: 1px solid $white-dark;
.ide-new-btn { .ide-new-btn {
margin-left: auto; 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 { button {
color: $gl-text-color; color: $gl-text-color;
} }
} }
.ide-tree-body {
overflow: auto;
padding-left: $ide-tree-padding;
padding-right: $ide-tree-padding;
}
.ide-sidebar-branch-title { .ide-sidebar-branch-title {
font-weight: $gl-font-weight-normal; font-weight: $gl-font-weight-normal;
...@@ -1163,14 +1193,23 @@ ...@@ -1163,14 +1193,23 @@
} }
.ide-context-header { .ide-context-header {
.avatar {
flex: 0 0 38px;
}
.ide-merge-requests-dropdown.dropdown-menu { .ide-merge-requests-dropdown.dropdown-menu {
width: 385px; width: 385px;
max-height: initial; max-height: initial;
} }
.avatar-container {
flex: initial;
margin-right: 0;
}
.ide-sidebar-project-title {
margin-left: $ide-tree-text-start - $ide-project-avatar-end;
}
}
.ide-context-body {
min-height: 0;
} }
.ide-sidebar-project-title { .ide-sidebar-project-title {
...@@ -1178,10 +1217,11 @@ ...@@ -1178,10 +1217,11 @@
.sidebar-context-title { .sidebar-context-title {
white-space: nowrap; white-space: nowrap;
} display: block;
.ide-sidebar-branch-title { &.text-secondary {
min-width: 50px; font-weight: normal;
}
} }
} }
...@@ -1319,7 +1359,7 @@ ...@@ -1319,7 +1359,7 @@
min-height: 60px; min-height: 60px;
} }
.ide-merge-requests-dropdown { .ide-nav-form {
.nav-links li { .nav-links li {
width: 50%; width: 50%;
padding-left: 0; padding-left: 0;
...@@ -1338,22 +1378,36 @@ ...@@ -1338,22 +1378,36 @@
padding-left: $gl-padding; padding-left: $gl-padding;
padding-right: $gl-padding; padding-right: $gl-padding;
.fa { .input-icon {
right: 26px; 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 { .btn-link {
padding-top: $gl-padding; padding-top: $gl-padding;
padding-bottom: $gl-padding; padding-bottom: $gl-padding;
} }
} }
.ide-merge-request-current-icon { .ide-search-list-current-icon {
min-width: 18px; min-width: $search-list-icon-width;
} }
.ide-merge-requests-empty { .ide-search-list-empty {
height: 230px; height: 230px;
} }
......
...@@ -105,10 +105,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -105,10 +105,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def test_reports def test_reports
result = @merge_request.compare_test_reports result = @merge_request.compare_test_reports
Gitlab::PollingInterval.set_header(response, interval: 10_000)
case result[:status] case result[:status]
when :parsing when :parsing
Gitlab::PollingInterval.set_header(response, interval: 3000)
render json: '', status: :no_content render json: '', status: :no_content
when :parsed when :parsed
render json: result[:data].to_json, status: :ok render json: result[:data].to_json, status: :ok
......
...@@ -7,9 +7,14 @@ module NamespacesHelper ...@@ -7,9 +7,14 @@ module NamespacesHelper
def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false) def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false)
groups ||= current_user.manageable_groups groups ||= current_user.manageable_groups
<<<<<<< HEAD
.joins(:route) .joins(:route)
.includes(:route) .includes(:route)
.order('routes.path') .order('routes.path')
=======
.eager_load(:route)
.order('routes.path')
>>>>>>> upstream/master
users = [current_user.namespace] users = [current_user.namespace]
selected_id = selected selected_id = selected
......
...@@ -623,12 +623,12 @@ module Ci ...@@ -623,12 +623,12 @@ module Ci
end end
def has_test_reports? def has_test_reports?
complete? && builds.with_test_reports.any? complete? && builds.latest.with_test_reports.any?
end end
def test_reports def test_reports
Gitlab::Ci::Reports::TestReports.new.tap do |test_reports| Gitlab::Ci::Reports::TestReports.new.tap do |test_reports|
builds.with_test_reports.each do |build| builds.latest.with_test_reports.each do |build|
build.collect_test_reports!(test_reports) build.collect_test_reports!(test_reports)
end end
end end
......
...@@ -42,6 +42,8 @@ ...@@ -42,6 +42,8 @@
module ReactiveCaching module ReactiveCaching
extend ActiveSupport::Concern extend ActiveSupport::Concern
InvalidateReactiveCache = Class.new(StandardError)
included do included do
class_attribute :reactive_cache_lease_timeout class_attribute :reactive_cache_lease_timeout
...@@ -63,15 +65,19 @@ module ReactiveCaching ...@@ -63,15 +65,19 @@ module ReactiveCaching
end end
def with_reactive_cache(*args, &blk) def with_reactive_cache(*args, &blk)
bootstrap = !within_reactive_cache_lifetime?(*args) unless within_reactive_cache_lifetime?(*args)
Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime) refresh_reactive_cache!(*args)
return nil
end
if bootstrap keep_alive_reactive_cache!(*args)
ReactiveCachingWorker.perform_async(self.class, id, *args)
nil begin
else
data = Rails.cache.read(full_reactive_cache_key(*args)) data = Rails.cache.read(full_reactive_cache_key(*args))
yield data if data.present? yield data if data.present?
rescue InvalidateReactiveCache
refresh_reactive_cache!(*args)
nil
end end
end end
...@@ -96,6 +102,16 @@ module ReactiveCaching ...@@ -96,6 +102,16 @@ module ReactiveCaching
private private
def refresh_reactive_cache!(*args)
clear_reactive_cache!(*args)
keep_alive_reactive_cache!(*args)
ReactiveCachingWorker.perform_async(self.class, id, *args)
end
def keep_alive_reactive_cache!(*args)
Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime)
end
def full_reactive_cache_key(*qualifiers) def full_reactive_cache_key(*qualifiers)
prefix = self.class.reactive_cache_key prefix = self.class.reactive_cache_key
prefix = prefix.call(self) if prefix.respond_to?(:call) prefix = prefix.call(self) if prefix.respond_to?(:call)
......
...@@ -17,8 +17,8 @@ class MergeRequest < ActiveRecord::Base ...@@ -17,8 +17,8 @@ class MergeRequest < ActiveRecord::Base
include ReactiveCaching include ReactiveCaching
self.reactive_cache_key = ->(model) { [model.project.id, model.iid] } self.reactive_cache_key = ->(model) { [model.project.id, model.iid] }
self.reactive_cache_refresh_interval = 1.hour self.reactive_cache_refresh_interval = 10.minutes
self.reactive_cache_lifetime = 1.hour self.reactive_cache_lifetime = 10.minutes
ignore_column :locked_at, ignore_column :locked_at,
:ref_fetched, :ref_fetched,
...@@ -1047,16 +1047,21 @@ class MergeRequest < ActiveRecord::Base ...@@ -1047,16 +1047,21 @@ class MergeRequest < ActiveRecord::Base
return { status: :error, status_reason: 'This merge request does not have test reports' } return { status: :error, status_reason: 'This merge request does not have test reports' }
end end
with_reactive_cache( with_reactive_cache(:compare_test_results) do |data|
:compare_test_results, unless Ci::CompareTestReportsService.new(project)
base_pipeline&.iid, .latest?(base_pipeline, actual_head_pipeline, data)
actual_head_pipeline.iid) { |data| data } || { status: :parsing } raise InvalidateReactiveCache
end
data
end || { status: :parsing }
end end
def calculate_reactive_cache(identifier, *args) def calculate_reactive_cache(identifier, *args)
case identifier.to_sym case identifier.to_sym
when :compare_test_results when :compare_test_results
Ci::CompareTestReportsService.new(project).execute(*args) Ci::CompareTestReportsService.new(project).execute(
base_pipeline, actual_head_pipeline)
else else
raise NotImplementedError, "Unknown identifier: #{identifier}" raise NotImplementedError, "Unknown identifier: #{identifier}"
end end
......
...@@ -2,23 +2,36 @@ ...@@ -2,23 +2,36 @@
module Ci module Ci
class CompareTestReportsService < ::BaseService class CompareTestReportsService < ::BaseService
def execute(base_pipeline_iid, head_pipeline_iid) def execute(base_pipeline, head_pipeline)
base_pipeline = project.pipelines.find_by_iid(base_pipeline_iid) if base_pipeline_iid comparer = Gitlab::Ci::Reports::TestReportsComparer
head_pipeline = project.pipelines.find_by_iid(head_pipeline_iid) .new(base_pipeline&.test_reports, head_pipeline.test_reports)
begin {
comparer = Gitlab::Ci::Reports::TestReportsComparer status: :parsed,
.new(base_pipeline&.test_reports, head_pipeline.test_reports) key: key(base_pipeline, head_pipeline),
data: TestReportsComparerSerializer
.new(project: project)
.represent(comparer).as_json
}
rescue => e
{
status: :error,
key: key(base_pipeline, head_pipeline),
status_reason: e.message
}
end
def latest?(base_pipeline, head_pipeline, data)
data&.fetch(:key, nil) == key(base_pipeline, head_pipeline)
end
private
{ def key(base_pipeline, head_pipeline)
status: :parsed, [
data: TestReportsComparerSerializer base_pipeline&.id, base_pipeline&.updated_at,
.new(project: project) head_pipeline&.id, head_pipeline&.updated_at
.represent(comparer).as_json ]
}
rescue => e
{ status: :error, status_reason: e.message }
end
end end
end end
end end
...@@ -14,9 +14,15 @@ ...@@ -14,9 +14,15 @@
= root_url = root_url
- namespace_id = namespace_id_from(params) - namespace_id = namespace_id_from(params)
= f.select(:namespace_id, = f.select(:namespace_id,
<<<<<<< HEAD
namespaces_options_with_developer_maintainer_access(selected: namespace_id, namespaces_options_with_developer_maintainer_access(selected: namespace_id,
display_path: true, display_path: true,
extra_group: namespace_id), extra_group: namespace_id),
=======
namespaces_options(namespace_id || :current_user,
display_path: true,
extra_group: namespace_id),
>>>>>>> upstream/master
{}, {},
{ class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1}) { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1})
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
- page_title @blob.path, @ref - page_title @blob.path, @ref
.js-signature-container{ data: { 'signatures-path': namespace_project_signatures_path } } - signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit)
.js-signature-container{ data: { 'signatures-path': signatures_path } }
%div{ class: container_class } %div{ class: container_class }
= render 'projects/last_push' = render 'projects/last_push'
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
= render partial: 'flash_messages', locals: { project: @project } = render partial: 'flash_messages', locals: { project: @project }
- if @project.repository_exists? && !@project.empty_repo? - if @project.repository_exists? && !@project.empty_repo?
- signatures_path = namespace_project_signatures_path(project_id: @project.path, id: @project.default_branch) - signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @project.default_branch)
.js-signature-container{ data: { 'signatures-path': signatures_path } } .js-signature-container{ data: { 'signatures-path': signatures_path } }
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
......
- @no_container = true - @no_container = true
- breadcrumb_title _("Repository") - breadcrumb_title _("Repository")
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.path, project_id: @project.path, id: @ref) - signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit)
- page_title @path.presence || _("Files"), @ref - page_title @path.presence || _("Files"), @ref
= content_for :meta_tags do = content_for :meta_tags do
......
...@@ -80,6 +80,7 @@ ...@@ -80,6 +80,7 @@
- todos_destroyer:todos_destroyer_project_private - todos_destroyer:todos_destroyer_project_private
- todos_destroyer:todos_destroyer_group_private - todos_destroyer:todos_destroyer_group_private
- todos_destroyer:todos_destroyer_private_features - todos_destroyer:todos_destroyer_private_features
- todos_destroyer:todos_destroyer_group_private
- default - default
- mailers # ActionMailer::DeliveryJob.queue_name - mailers # ActionMailer::DeliveryJob.queue_name
......
---
title: Create branch and MR picker for Web IDE
merge_request: 20978
author:
type: changed
---
title: Redesign Web IDE back button and context header
merge_request: 20850
author:
type: changed
---
title: Renders test reports for resolved failures and resets error state
merge_request:
author:
type: fixed
---
title: Add link to homepage on static http status pages (404, 500, etc)
merge_request: 20898
author: Jason Funk
type: added
---
title: CE port of "List groups with developer maintainer access on project creation"
merge_request: 21051
author:
type: other
---
title: Improve JUnit test reports in merge request widgets
merge_request: 49966
author:
type: fixed
---
title: Add 'tabindex' attribute support on Icon component to show BS4 popover on trigger type 'focus'
merge_request: 21066
author:
type: other
---
title: Bump Gitaly to 0.117.0
merge_request: 21055
author:
type: performance
---
title: Remove todos of users without access to targets migration
merge_request: 20927
author:
type: other
---
title: Fix GPG status badge loading regressions
merge_request: 20987
author:
type: fixed
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
# frozen_string_literal: true
class RemoveRestrictedTodos < ActiveRecord::Migration
DOWNTIME = false
disable_ddl_transaction!
MIGRATION = 'RemoveRestrictedTodos'.freeze
BATCH_SIZE = 1000
DELAY_INTERVAL = 5.minutes.to_i
class Project < ActiveRecord::Base
include EachBatch
self.table_name = 'projects'
end
def up
Project.where('EXISTS (SELECT 1 FROM todos WHERE todos.project_id = projects.id)')
.each_batch(of: BATCH_SIZE) do |batch, index|
range = batch.pluck('MIN(id)', 'MAX(id)').first
BackgroundMigrationWorker.perform_in(index * DELAY_INTERVAL, MIGRATION, range)
end
end
def down
# nothing to do
end
end
...@@ -210,6 +210,7 @@ ActiveRecord::Schema.define(version: 20180803001726) do ...@@ -210,6 +210,7 @@ ActiveRecord::Schema.define(version: 20180803001726) do
t.boolean "hide_third_party_offers", default: false, null: false t.boolean "hide_third_party_offers", default: false, null: false
t.boolean "instance_statistics_visibility_private", default: false, null: false t.boolean "instance_statistics_visibility_private", default: false, null: false
t.boolean "web_ide_clientside_preview_enabled", default: false, null: false t.boolean "web_ide_clientside_preview_enabled", default: false, null: false
<<<<<<< HEAD
t.integer "custom_project_templates_group_id" t.integer "custom_project_templates_group_id"
end end
...@@ -228,6 +229,8 @@ ActiveRecord::Schema.define(version: 20180803001726) do ...@@ -228,6 +229,8 @@ ActiveRecord::Schema.define(version: 20180803001726) do
t.integer "group_id", null: false t.integer "group_id", null: false
t.datetime "created_at" t.datetime "created_at"
t.datetime "updated_at" t.datetime "updated_at"
=======
>>>>>>> upstream/master
end end
add_index "approver_groups", ["group_id"], name: "index_approver_groups_on_group_id", using: :btree add_index "approver_groups", ["group_id"], name: "index_approver_groups_on_group_id", using: :btree
......
...@@ -101,7 +101,9 @@ documentation on configuring Gitaly ...@@ -101,7 +101,9 @@ documentation on configuring Gitaly
authentication](https://gitlab.com/gitlab-org/gitaly/blob/master/doc/configuration/README.md#authentication) authentication](https://gitlab.com/gitlab-org/gitaly/blob/master/doc/configuration/README.md#authentication)
. .
In most or all cases the storage paths below end in `/repositories`. Check the >
**NOTE:** In most or all cases the storage paths below end in `/repositories` which is
different than `path` in `git_data_dirs` of Omnibus installations. Check the
directory layout on your Gitaly server to be sure. directory layout on your Gitaly server to be sure.
Omnibus installations: Omnibus installations:
...@@ -133,8 +135,8 @@ gitaly['listen_addr'] = "0.0.0.0:8075" ...@@ -133,8 +135,8 @@ gitaly['listen_addr'] = "0.0.0.0:8075"
gitaly['auth_token'] = 'abc123secret' gitaly['auth_token'] = 'abc123secret'
gitaly['storage'] = [ gitaly['storage'] = [
{ 'name' => 'default', 'path' => '/path/to/default/repositories' }, { 'name' => 'default', 'path' => '/mnt/gitlab/default/repositories' },
{ 'name' => 'storage1', 'path' => '/path/to/storage1/repositories' }, { 'name' => 'storage1', 'path' => '/mnt/gitlab/storage1/repositories' },
] ]
``` ```
...@@ -149,11 +151,11 @@ token = 'abc123secret' ...@@ -149,11 +151,11 @@ token = 'abc123secret'
[[storage] [[storage]
name = 'default' name = 'default'
path = '/path/to/default/repositories' path = '/mnt/gitlab/default/repositories'
[[storage]] [[storage]]
name = 'storage1' name = 'storage1'
path = '/path/to/storage1/repositories' path = '/mnt/gitlab/storage1/repositories'
``` ```
Again, reconfigure (Omnibus) or restart (source). Again, reconfigure (Omnibus) or restart (source).
......
...@@ -82,7 +82,11 @@ top-right: ...@@ -82,7 +82,11 @@ top-right:
GitLab will consider the label title and description for the search. GitLab will consider the label title and description for the search.
<<<<<<< HEAD
## Filtering issues, merge requests and epics by label ## Filtering issues, merge requests and epics by label
=======
## Filtering issues and merge requests by label
>>>>>>> upstream/master
### Filtering in list pages ### Filtering in list pages
......
...@@ -59,9 +59,18 @@ left. ...@@ -59,9 +59,18 @@ left.
> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) [GitLab Core][ce] 11.0. > [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 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 leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list
merge requests. You will need to commit or discard all your changes before of merge requests. You will need to commit or discard all your changes before
switching to a different merge request. 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/ [ce]: https://about.gitlab.com/pricing/
[ee]: https://about.gitlab.com/pricing/ [ee]: https://about.gitlab.com/pricing/
...@@ -19,6 +19,7 @@ module API ...@@ -19,6 +19,7 @@ module API
params :filter_params do params :filter_params do
optional :search, type: String, desc: 'Return list of branches matching the search criteria' 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
end end
......
# frozen_string_literal: true
# rubocop:disable Style/Documentation
module Gitlab
module BackgroundMigration
class RemoveRestrictedTodos
PRIVATE_FEATURE = 10
PRIVATE_PROJECT = 0
class Project < ActiveRecord::Base
self.table_name = 'projects'
end
class ProjectAuthorization < ActiveRecord::Base
self.table_name = 'project_authorizations'
end
class ProjectFeature < ActiveRecord::Base
self.table_name = 'project_features'
end
class Todo < ActiveRecord::Base
include EachBatch
self.table_name = 'todos'
end
class Issue < ActiveRecord::Base
include EachBatch
self.table_name = 'issues'
end
def perform(start_id, stop_id)
projects = Project.where('EXISTS (SELECT 1 FROM todos WHERE todos.project_id = projects.id)')
.where(id: start_id..stop_id)
projects.each do |project|
remove_confidential_issue_todos(project.id)
if project.visibility_level == PRIVATE_PROJECT
remove_non_members_todos(project.id)
else
remove_restricted_features_todos(project.id)
end
end
end
private
def remove_non_members_todos(project_id)
Todo.where(project_id: project_id)
.where('user_id NOT IN (?)', authorized_users(project_id))
.each_batch(of: 5000) do |batch|
batch.delete_all
end
end
def remove_confidential_issue_todos(project_id)
# min access level to access a confidential issue is reporter
min_reporters = authorized_users(project_id)
.select(:user_id)
.where('access_level >= ?', 20)
confidential_issues = Issue.select(:id, :author_id).where(confidential: true, project_id: project_id)
confidential_issues.each_batch(of: 100) do |batch|
batch.each do |issue|
assigned_users = IssueAssignee.select(:user_id).where(issue_id: issue.id)
todos = Todo.where(target_type: 'Issue', target_id: issue.id)
.where('user_id NOT IN (?)', min_reporters)
.where('user_id NOT IN (?)', assigned_users)
todos = todos.where('user_id != ?', issue.author_id) if issue.author_id
todos.delete_all
end
end
end
def remove_restricted_features_todos(project_id)
ProjectFeature.where(project_id: project_id).each do |project_features|
target_types = []
target_types << 'Issue' if private?(project_features.issues_access_level)
target_types << 'MergeRequest' if private?(project_features.merge_requests_access_level)
target_types << 'Commit' if private?(project_features.repository_access_level)
next if target_types.empty?
Todo.where(project_id: project_id)
.where('user_id NOT IN (?)', authorized_users(project_id))
.where(target_type: target_types)
.delete_all
end
end
def private?(feature_level)
feature_level == PRIVATE_FEATURE
end
def authorized_users(project_id)
ProjectAuthorization.select(:user_id).where(project_id: project_id)
end
end
end
end
...@@ -2226,7 +2226,11 @@ msgstr "" ...@@ -2226,7 +2226,11 @@ msgstr ""
msgid "Cron syntax" msgid "Cron syntax"
msgstr "" msgstr ""
<<<<<<< HEAD
msgid "Current node" msgid "Current node"
=======
msgid "Current Branch"
>>>>>>> upstream/master
msgstr "" msgstr ""
msgid "CurrentUser|Profile" msgid "CurrentUser|Profile"
...@@ -2783,6 +2787,9 @@ msgstr "" ...@@ -2783,6 +2787,9 @@ msgstr ""
msgid "Error loading branch data. Please try again." msgid "Error loading branch data. Please try again."
msgstr "" msgstr ""
msgid "Error loading branches."
msgstr ""
msgid "Error loading last commit." msgid "Error loading last commit."
msgstr "" msgstr ""
...@@ -3651,7 +3658,14 @@ msgstr "" ...@@ -3651,7 +3658,14 @@ msgstr ""
msgid "IDE|Get started with Live Preview" msgid "IDE|Get started with Live Preview"
msgstr "" msgstr ""
<<<<<<< HEAD
msgid "IDE|Go back" msgid "IDE|Go back"
=======
msgid "IDE|Go to project"
msgstr ""
msgid "IDE|Live Preview"
>>>>>>> upstream/master
msgstr "" msgstr ""
msgid "IDE|Live Preview" msgid "IDE|Live Preview"
...@@ -4078,9 +4092,12 @@ msgstr "" ...@@ -4078,9 +4092,12 @@ msgstr ""
msgid "Live preview" msgid "Live preview"
msgstr "" msgstr ""
<<<<<<< HEAD
msgid "Loading contribution stats for group members" msgid "Loading contribution stats for group members"
msgstr "" msgstr ""
=======
>>>>>>> upstream/master
msgid "Loading the GitLab IDE..." msgid "Loading the GitLab IDE..."
msgstr "" msgstr ""
...@@ -4545,6 +4562,9 @@ msgstr "" ...@@ -4545,6 +4562,9 @@ msgstr ""
msgid "No assignee" msgid "No assignee"
msgstr "" msgstr ""
msgid "No branches found"
msgstr ""
msgid "No changes" msgid "No changes"
msgstr "" msgstr ""
...@@ -7423,12 +7443,15 @@ msgstr "" ...@@ -7423,12 +7443,15 @@ msgstr ""
msgid "You cannot write to this read-only GitLab instance." msgid "You cannot write to this read-only GitLab instance."
msgstr "" msgstr ""
<<<<<<< HEAD
msgid "You do not have any assigned merge requests" msgid "You do not have any assigned merge requests"
msgstr "" msgstr ""
msgid "You do not have the correct permissions to override the settings from the LDAP group sync." msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
msgstr "" msgstr ""
=======
>>>>>>> upstream/master
msgid "You don't have any applications" msgid "You don't have any applications"
msgstr "" msgstr ""
...@@ -7438,9 +7461,6 @@ msgstr "" ...@@ -7438,9 +7461,6 @@ msgstr ""
msgid "You have no permissions" msgid "You have no permissions"
msgstr "" msgstr ""
msgid "You have not created any merge requests"
msgstr ""
msgid "You have reached your project limit" msgid "You have reached your project limit"
msgstr "" msgstr ""
......
...@@ -66,8 +66,10 @@ ...@@ -66,8 +66,10 @@
</head> </head>
<body> <body>
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" <a href="/">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
alt="GitLab Logo" /> alt="GitLab Logo" />
</a>
<h1> <h1>
404 404
</h1> </h1>
......
...@@ -66,8 +66,10 @@ ...@@ -66,8 +66,10 @@
</head> </head>
<body> <body>
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" <a href="/">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
alt="GitLab Logo" /> alt="GitLab Logo" />
</a>
<h1> <h1>
422 422
</h1> </h1>
......
...@@ -66,8 +66,10 @@ ...@@ -66,8 +66,10 @@
</head> </head>
<body> <body>
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" <a href="/">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
alt="GitLab Logo" /> alt="GitLab Logo" />
</a>
<h1> <h1>
500 500
</h1> </h1>
......
...@@ -66,8 +66,10 @@ ...@@ -66,8 +66,10 @@
</head> </head>
<body> <body>
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" <a href="/">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
alt="GitLab Logo" /> alt="GitLab Logo" />
</a>
<h1> <h1>
502 502
</h1> </h1>
......
...@@ -66,8 +66,10 @@ ...@@ -66,8 +66,10 @@
</head> </head>
<body> <body>
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" <a href="/">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo="
alt="GitLab Logo" /> alt="GitLab Logo" />
</a>
<h1> <h1>
503 503
</h1> </h1>
......
...@@ -597,6 +597,12 @@ describe Projects::MergeRequestsController do ...@@ -597,6 +597,12 @@ describe Projects::MergeRequestsController do
context 'when comparison is being processed' do context 'when comparison is being processed' do
let(:comparison_status) { { status: :parsing } } let(:comparison_status) { { status: :parsing } }
it 'sends polling interval' do
expect(Gitlab::PollingInterval).to receive(:set_header)
subject
end
it 'returns 204 HTTP status' do it 'returns 204 HTTP status' do
subject subject
...@@ -607,6 +613,12 @@ describe Projects::MergeRequestsController do ...@@ -607,6 +613,12 @@ describe Projects::MergeRequestsController do
context 'when comparison is done' do context 'when comparison is done' do
let(:comparison_status) { { status: :parsed, data: { summary: 1 } } } let(:comparison_status) { { status: :parsed, data: { summary: 1 } } }
it 'does not send polling interval' do
expect(Gitlab::PollingInterval).not_to receive(:set_header)
subject
end
it 'returns 200 HTTP status' do it 'returns 200 HTTP status' do
subject subject
...@@ -618,6 +630,12 @@ describe Projects::MergeRequestsController do ...@@ -618,6 +630,12 @@ describe Projects::MergeRequestsController do
context 'when user created corrupted test reports' do context 'when user created corrupted test reports' do
let(:comparison_status) { { status: :error, status_reason: 'Failed to parse test reports' } } let(:comparison_status) { { status: :error, status_reason: 'Failed to parse test reports' } }
it 'does not send polling interval' do
expect(Gitlab::PollingInterval).not_to receive(:set_header)
subject
end
it 'returns 400 HTTP status' do it 'returns 400 HTTP status' do
subject subject
...@@ -629,6 +647,12 @@ describe Projects::MergeRequestsController do ...@@ -629,6 +647,12 @@ describe Projects::MergeRequestsController do
context 'when something went wrong on our system' do context 'when something went wrong on our system' do
let(:comparison_status) { {} } let(:comparison_status) { {} }
it 'does not send polling interval' do
expect(Gitlab::PollingInterval).not_to receive(:set_header)
subject
end
it 'returns 500 HTTP status' do it 'returns 500 HTTP status' do
subject subject
......
...@@ -2,6 +2,7 @@ require 'rails_helper' ...@@ -2,6 +2,7 @@ require 'rails_helper'
describe 'Merge request > User sees merge widget', :js do describe 'Merge request > User sees merge widget', :js do
include ProjectForksHelper include ProjectForksHelper
include TestReportsHelper
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
let(:project_only_mwps) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: true) } let(:project_only_mwps) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: true) }
...@@ -325,4 +326,229 @@ describe 'Merge request > User sees merge widget', :js do ...@@ -325,4 +326,229 @@ describe 'Merge request > User sees merge widget', :js do
expect(page).to have_content('This merge request is in the process of being merged') expect(page).to have_content('This merge request is in the process of being merged')
end end
end end
context 'when merge request has test reports' do
let!(:head_pipeline) do
create(:ci_pipeline,
:success,
project: project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha)
end
let!(:build) { create(:ci_build, :success, pipeline: head_pipeline, project: project) }
before do
merge_request.update!(head_pipeline_id: head_pipeline.id)
end
context 'when result has not been parsed yet' do
let!(:job_artifact) { create(:ci_job_artifact, :junit, job: build, project: project) }
before do
visit project_merge_request_path(project, merge_request)
end
it 'shows parsing status' do
expect(page).to have_content('Test summary results are being parsed')
end
end
context 'when result has already been parsed' do
context 'when JUnit xml is correctly formatted' do
let!(:job_artifact) { create(:ci_job_artifact, :junit, job: build, project: project) }
before do
allow_any_instance_of(MergeRequest).to receive(:compare_test_reports).and_return(compared_data)
visit project_merge_request_path(project, merge_request)
end
it 'shows parsed results' do
expect(page).to have_content('Test summary contained')
end
end
context 'when JUnit xml is corrupted' do
let!(:job_artifact) { create(:ci_job_artifact, :junit_with_corrupted_data, job: build, project: project) }
before do
allow_any_instance_of(MergeRequest).to receive(:compare_test_reports).and_return(compared_data)
visit project_merge_request_path(project, merge_request)
end
it 'shows the error state' do
expect(page).to have_content('Test summary failed loading results')
end
end
def compared_data
Ci::CompareTestReportsService.new(project).execute(nil, head_pipeline)
end
end
context 'when test reports have been parsed correctly' do
let(:serialized_data) do
{
status: :parsed,
data: TestReportsComparerSerializer
.new(project: project)
.represent(comparer)
}
end
before do
allow_any_instance_of(MergeRequest)
.to receive(:has_test_reports?).and_return(true)
allow_any_instance_of(MergeRequest)
.to receive(:compare_test_reports).and_return(serialized_data)
visit project_merge_request_path(project, merge_request)
end
context 'when a new failures exists' do
let(:base_reports) do
Gitlab::Ci::Reports::TestReports.new.tap do |reports|
reports.get_suite('rspec').add_test_case(create_test_case_rspec_success)
reports.get_suite('junit').add_test_case(create_test_case_java_success)
end
end
let(:head_reports) do
Gitlab::Ci::Reports::TestReports.new.tap do |reports|
reports.get_suite('rspec').add_test_case(create_test_case_rspec_success)
reports.get_suite('junit').add_test_case(create_test_case_java_failed)
end
end
it 'shows test reports summary which includes the new failure' do
within(".mr-section-container") do
click_button 'Expand'
expect(page).to have_content('Test summary contained 1 failed test result out of 2 total tests')
within(".js-report-section-container") do
expect(page).to have_content('rspec found no changed test results out of 1 total test')
expect(page).to have_content('junit found 1 failed test result out of 1 total test')
expect(page).to have_content('New')
expect(page).to have_content('subtractTest')
end
end
end
context 'when user clicks the new failure' do
it 'shows the test report detail' do
within(".mr-section-container") do
click_button 'Expand'
within(".js-report-section-container") do
click_button 'subtractTest'
expect(page).to have_content('6.66')
expect(page).to have_content(sample_java_failed_message)
end
end
end
end
end
context 'when an existing failure exists' do
let(:base_reports) do
Gitlab::Ci::Reports::TestReports.new.tap do |reports|
reports.get_suite('rspec').add_test_case(create_test_case_rspec_failed)
reports.get_suite('junit').add_test_case(create_test_case_java_success)
end
end
let(:head_reports) do
Gitlab::Ci::Reports::TestReports.new.tap do |reports|
reports.get_suite('rspec').add_test_case(create_test_case_rspec_failed)
reports.get_suite('junit').add_test_case(create_test_case_java_success)
end
end
it 'shows test reports summary which includes the existing failure' do
within(".mr-section-container") do
click_button 'Expand'
expect(page).to have_content('Test summary contained 1 failed test result out of 2 total tests')
within(".js-report-section-container") do
expect(page).to have_content('rspec found 1 failed test result out of 1 total test')
expect(page).to have_content('junit found no changed test results out of 1 total test')
expect(page).not_to have_content('New')
expect(page).to have_content('Test#sum when a is 2 and b is 2 returns summary')
end
end
end
context 'when user clicks the existing failure' do
it 'shows test report detail of it' do
within(".mr-section-container") do
click_button 'Expand'
within(".js-report-section-container") do
click_button 'Test#sum when a is 2 and b is 2 returns summary'
expect(page).to have_content('2.22')
expect(page).to have_content(sample_rspec_failed_message)
end
end
end
end
end
context 'when a resolved failure exists' do
let(:base_reports) do
Gitlab::Ci::Reports::TestReports.new.tap do |reports|
reports.get_suite('rspec').add_test_case(create_test_case_rspec_success)
reports.get_suite('junit').add_test_case(create_test_case_java_failed)
end
end
let(:head_reports) do
Gitlab::Ci::Reports::TestReports.new.tap do |reports|
reports.get_suite('rspec').add_test_case(create_test_case_rspec_success)
reports.get_suite('junit').add_test_case(create_test_case_java_resolved)
end
end
let(:create_test_case_java_resolved) do
create_test_case_java_failed.tap do |test_case|
test_case.instance_variable_set("@status", Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS)
end
end
it 'shows test reports summary which includes the resolved failure' do
within(".mr-section-container") do
click_button 'Expand'
expect(page).to have_content('Test summary contained 1 fixed test result out of 2 total tests')
within(".js-report-section-container") do
expect(page).to have_content('rspec found no changed test results out of 1 total test')
expect(page).to have_content('junit found 1 fixed test result out of 1 total test')
expect(page).to have_content('subtractTest')
end
end
end
context 'when user clicks the resolved failure' do
it 'shows test report detail of it' do
within(".mr-section-container") do
click_button 'Expand'
within(".js-report-section-container") do
click_button 'subtractTest'
expect(page).to have_content('6.66')
end
end
end
end
end
def comparer
Gitlab::Ci::Reports::TestReportsComparer.new(base_reports, head_reports)
end
end
end
end end
...@@ -552,4 +552,33 @@ describe 'File blob', :js do ...@@ -552,4 +552,33 @@ describe 'File blob', :js do
end end
end end
end end
context 'for subgroups' do
let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) }
let(:project) { create(:project, :public, :repository, group: subgroup) }
it 'renders tree table without errors' do
visit_blob('README.md')
expect(page).to have_selector('.file-content')
expect(page).not_to have_selector('.flash-alert')
end
it 'displays a GPG badge' do
visit_blob('CONTRIBUTING.md', ref: '33f3729a45c02fc67d00adb1b8bca394b0e761d9')
expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
expect(page).to have_selector '.gpg-status-box.invalid'
end
end
context 'on signed merge commit' do
it 'displays a GPG badge' do
visit_blob('conflicting-file.md', ref: '6101e87e575de14b38b4e1ce180519a813671e10')
expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
expect(page).to have_selector '.gpg-status-box.invalid'
end
end
end end
...@@ -22,7 +22,7 @@ describe 'Multi-file editor new directory', :js do ...@@ -22,7 +22,7 @@ describe 'Multi-file editor new directory', :js do
end end
it 'creates directory in current directory' do 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 page.within('.modal') do
find('.form-control').set('folder name') find('.form-control').set('folder name')
...@@ -30,7 +30,7 @@ describe 'Multi-file editor new directory', :js do ...@@ -30,7 +30,7 @@ describe 'Multi-file editor new directory', :js do
click_button('Create directory') click_button('Create directory')
end end
first('.ide-tree-header button').click first('.ide-tree-actions button').click
page.within('.modal-dialog') do page.within('.modal-dialog') do
find('.form-control').set('file name') find('.form-control').set('file name')
......
...@@ -22,7 +22,7 @@ describe 'Multi-file editor new file', :js do ...@@ -22,7 +22,7 @@ describe 'Multi-file editor new file', :js do
end end
it 'creates file in current directory' do it 'creates file in current directory' do
first('.ide-tree-header button').click first('.ide-tree-actions button').click
page.within('.modal') do page.within('.modal') do
find('.form-control').set('file name') find('.form-control').set('file name')
......
require 'spec_helper' require 'spec_helper'
describe 'Projects tree' do describe 'Projects tree', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
before do before do
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
end
it 'renders tree table without errors' do
visit project_tree_path(project, 'master') visit project_tree_path(project, 'master')
end wait_for_requests
it 'renders tree table' do
expect(page).to have_selector('.tree-item') expect(page).to have_selector('.tree-item')
expect(page).not_to have_selector('.label-lfs', text: 'LFS') expect(page).not_to have_selector('.label-lfs', text: 'LFS')
expect(page).not_to have_selector('.flash-alert')
end end
context 'LFS' do context 'for signed commit' do
before do it 'displays a GPG badge' do
visit project_tree_path(project, File.join('master', 'files/lfs')) visit project_tree_path(project, '33f3729a45c02fc67d00adb1b8bca394b0e761d9')
wait_for_requests
expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
expect(page).to have_selector '.gpg-status-box.invalid'
end end
context 'on a directory that has not changed recently' do
it 'displays a GPG badge' do
tree_path = File.join('eee736adc74341c5d3e26cd0438bc697f26a7575', 'subdir')
visit project_tree_path(project, tree_path)
wait_for_requests
expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
expect(page).to have_selector '.gpg-status-box.invalid'
end
end
end
context 'LFS' do
it 'renders LFS badge on blob item' do it 'renders LFS badge on blob item' do
visit project_tree_path(project, File.join('master', 'files/lfs'))
expect(page).to have_selector('.label-lfs', text: 'LFS') expect(page).to have_selector('.label-lfs', text: 'LFS')
end end
end end
context 'web IDE', :js do context 'web IDE' do
before do it 'opens folder in IDE' do
visit project_tree_path(project, File.join('master', 'bar')) visit project_tree_path(project, File.join('master', 'bar'))
click_link 'Web IDE' click_link 'Web IDE'
wait_for_requests
find('.ide-file-list') find('.ide-file-list')
wait_for_requests
expect(page).to have_selector('.is-open', text: 'bar')
end end
end
it 'opens folder in IDE' do context 'for subgroups' do
expect(page).to have_selector('.is-open', text: 'bar') let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) }
let(:project) { create(:project, :repository, group: subgroup) }
it 'renders tree table without errors' do
visit project_tree_path(project, 'master')
wait_for_requests
expect(page).to have_selector('.tree-item')
expect(page).not_to have_selector('.flash-alert')
end
context 'for signed commit' do
it 'displays a GPG badge' do
visit project_tree_path(project, '33f3729a45c02fc67d00adb1b8bca394b0e761d9')
wait_for_requests
expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
expect(page).to have_selector '.gpg-status-box.invalid'
end
end end
end end
end end
...@@ -197,6 +197,49 @@ describe 'Project' do ...@@ -197,6 +197,49 @@ describe 'Project' do
expect(page.status_code).to eq(200) expect(page.status_code).to eq(200)
end end
context 'for signed commit on default branch', :js do
before do
project.change_head('33f3729a45c02fc67d00adb1b8bca394b0e761d9')
end
it 'displays a GPG badge' do
visit project_path(project)
wait_for_requests
expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
expect(page).to have_selector '.gpg-status-box.invalid'
end
end
context 'for subgroups', :js do
let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) }
let(:project) { create(:project, :repository, group: subgroup) }
it 'renders tree table without errors' do
wait_for_requests
expect(page).to have_selector('.tree-item')
expect(page).not_to have_selector('.flash-alert')
end
context 'for signed commit' do
before do
repository = project.repository
repository.write_ref("refs/heads/#{project.default_branch}", '33f3729a45c02fc67d00adb1b8bca394b0e761d9')
repository.expire_branches_cache
end
it 'displays a GPG badge' do
visit project_path(project)
wait_for_requests
expect(page).not_to have_selector '.gpg-status-box.js-loading-gpg-badge'
expect(page).to have_selector '.gpg-status-box.invalid'
end
end
end
end end
describe 'activity view' do describe 'activity view' do
......
require 'spec_helper' require 'spec_helper'
describe 'GPG signed commits', :js do describe 'GPG signed commits', :js do
set(:ref) { :'2d1096e3a0ecf1d2baf6dee036cc80775d4940ba' }
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
it 'changes from unverified to verified when the user changes his email to match the gpg key' do it 'changes from unverified to verified when the user changes his email to match the gpg key' do
...@@ -13,7 +14,7 @@ describe 'GPG signed commits', :js do ...@@ -13,7 +14,7 @@ describe 'GPG signed commits', :js do
sign_in(user) sign_in(user)
visit project_commits_path(project, :'signed-commits') visit project_commits_path(project, ref)
within '#commits-list' do within '#commits-list' do
expect(page).to have_content 'Unverified' expect(page).to have_content 'Unverified'
...@@ -26,7 +27,7 @@ describe 'GPG signed commits', :js do ...@@ -26,7 +27,7 @@ describe 'GPG signed commits', :js do
user.update!(email: GpgHelpers::User1.emails.first) user.update!(email: GpgHelpers::User1.emails.first)
end end
visit project_commits_path(project, :'signed-commits') visit project_commits_path(project, ref)
within '#commits-list' do within '#commits-list' do
expect(page).to have_content 'Unverified' expect(page).to have_content 'Unverified'
...@@ -40,7 +41,7 @@ describe 'GPG signed commits', :js do ...@@ -40,7 +41,7 @@ describe 'GPG signed commits', :js do
sign_in(user) sign_in(user)
visit project_commits_path(project, :'signed-commits') visit project_commits_path(project, ref)
within '#commits-list' do within '#commits-list' do
expect(page).to have_content 'Unverified' expect(page).to have_content 'Unverified'
...@@ -52,7 +53,7 @@ describe 'GPG signed commits', :js do ...@@ -52,7 +53,7 @@ describe 'GPG signed commits', :js do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end end
visit project_commits_path(project, :'signed-commits') visit project_commits_path(project, ref)
within '#commits-list' do within '#commits-list' do
expect(page).to have_content 'Unverified' expect(page).to have_content 'Unverified'
...@@ -92,7 +93,7 @@ describe 'GPG signed commits', :js do ...@@ -92,7 +93,7 @@ describe 'GPG signed commits', :js do
end end
it 'unverified signature' do it 'unverified signature' do
visit project_commits_path(project, :'signed-commits') visit project_commits_path(project, ref)
within(find('.commit', text: 'signed commit by bette cartwright')) do within(find('.commit', text: 'signed commit by bette cartwright')) do
click_on 'Unverified' click_on 'Unverified'
...@@ -107,7 +108,7 @@ describe 'GPG signed commits', :js do ...@@ -107,7 +108,7 @@ describe 'GPG signed commits', :js do
it 'unverified signature: user email does not match the committer email, but is the same user' do it 'unverified signature: user email does not match the committer email, but is the same user' do
user_2_key user_2_key
visit project_commits_path(project, :'signed-commits') visit project_commits_path(project, ref)
within(find('.commit', text: 'signed and authored commit by bette cartwright, different email')) do within(find('.commit', text: 'signed and authored commit by bette cartwright, different email')) do
click_on 'Unverified' click_on 'Unverified'
...@@ -124,7 +125,7 @@ describe 'GPG signed commits', :js do ...@@ -124,7 +125,7 @@ describe 'GPG signed commits', :js do
it 'unverified signature: user email does not match the committer email' do it 'unverified signature: user email does not match the committer email' do
user_2_key user_2_key
visit project_commits_path(project, :'signed-commits') visit project_commits_path(project, ref)
within(find('.commit', text: 'signed commit by bette cartwright')) do within(find('.commit', text: 'signed commit by bette cartwright')) do
click_on 'Unverified' click_on 'Unverified'
...@@ -141,7 +142,7 @@ describe 'GPG signed commits', :js do ...@@ -141,7 +142,7 @@ describe 'GPG signed commits', :js do
it 'verified and the gpg user has a gitlab profile' do it 'verified and the gpg user has a gitlab profile' do
user_1_key user_1_key
visit project_commits_path(project, :'signed-commits') visit project_commits_path(project, ref)
within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do
click_on 'Verified' click_on 'Verified'
...@@ -158,7 +159,7 @@ describe 'GPG signed commits', :js do ...@@ -158,7 +159,7 @@ describe 'GPG signed commits', :js do
it "verified and the gpg user's profile doesn't exist anymore" do it "verified and the gpg user's profile doesn't exist anymore" do
user_1_key user_1_key
visit project_commits_path(project, :'signed-commits') visit project_commits_path(project, ref)
# wait for the signature to get generated # wait for the signature to get generated
within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do
......
...@@ -84,7 +84,7 @@ export default ( ...@@ -84,7 +84,7 @@ export default (
done(); 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 => { return new Promise(resolve => {
setImmediate(resolve); setImmediate(resolve);
......
...@@ -24,26 +24,6 @@ describe('IDE activity bar', () => { ...@@ -24,26 +24,6 @@ describe('IDE activity bar', () => {
resetStore(vm.$store); resetStore(vm.$store);
}); });
describe('goBackUrl', () => {
it('renders the Go Back link with the referrer when present', () => {
const fakeReferrer = '/example/README.md';
spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
vm.$mount();
expect(vm.goBackUrl).toEqual(fakeReferrer);
});
it('renders the Go Back link with the project url when referrer is not present', () => {
const fakeReferrer = '';
spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
vm.$mount();
expect(vm.goBackUrl).toEqual('testing');
});
});
describe('updateActivityBarView', () => { describe('updateActivityBarView', () => {
beforeEach(() => { beforeEach(() => {
spyOn(vm, 'updateActivityBarView'); spyOn(vm, 'updateActivityBarView');
......
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 Vue from 'vue';
import router from '~/ide/ide_router';
import Item from '~/ide/components/merge_requests/item.vue'; import Item from '~/ide/components/merge_requests/item.vue';
import mountCompontent from '../../../helpers/vue_mount_component_helper'; import mountCompontent from '../../../helpers/vue_mount_component_helper';
...@@ -27,6 +28,12 @@ describe('IDE merge request item', () => { ...@@ -27,6 +28,12 @@ describe('IDE merge request item', () => {
expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1'); 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', () => { it('renders icon if ID matches currentId', () => {
expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null); expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null);
}); });
...@@ -50,12 +57,4 @@ describe('IDE merge request item', () => { ...@@ -50,12 +57,4 @@ describe('IDE merge request item', () => {
done(); 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', () => { ...@@ -10,10 +10,7 @@ describe('IDE merge requests list', () => {
let vm; let vm;
beforeEach(() => { beforeEach(() => {
vm = createComponentWithStore(Component, store, { vm = createComponentWithStore(Component, store, {});
type: 'created',
emptyText: 'empty text',
});
spyOn(vm, 'fetchMergeRequests'); spyOn(vm, 'fetchMergeRequests');
...@@ -28,13 +25,13 @@ describe('IDE merge requests list', () => { ...@@ -28,13 +25,13 @@ describe('IDE merge requests list', () => {
it('calls fetch on mounted', () => { it('calls fetch on mounted', () => {
expect(vm.fetchMergeRequests).toHaveBeenCalledWith({ expect(vm.fetchMergeRequests).toHaveBeenCalledWith({
type: 'created',
search: '', search: '',
type: '',
}); });
}); });
it('renders loading icon', done => { it('renders loading icon', done => {
vm.$store.state.mergeRequests.created.isLoading = true; vm.$store.state.mergeRequests.isLoading = true;
vm.$nextTick(() => { vm.$nextTick(() => {
expect(vm.$el.querySelector('.loading-container')).not.toBe(null); expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
...@@ -43,10 +40,6 @@ describe('IDE merge requests list', () => { ...@@ -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 => { it('renders no search results text when search is not empty', done => {
vm.search = 'testing'; vm.search = 'testing';
...@@ -57,9 +50,29 @@ describe('IDE merge requests list', () => { ...@@ -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', () => { describe('with merge requests', () => {
beforeEach(done => { beforeEach(done => {
vm.$store.state.mergeRequests.created.mergeRequests.push({ vm.$store.state.mergeRequests.mergeRequests.push({
...mergeRequests[0], ...mergeRequests[0],
projectPathWithNamespace: 'gitlab-org/gitlab-ce', projectPathWithNamespace: 'gitlab-org/gitlab-ce',
}); });
...@@ -71,35 +84,6 @@ describe('IDE merge requests list', () => { ...@@ -71,35 +84,6 @@ describe('IDE merge requests list', () => {
expect(vm.$el.querySelectorAll('li').length).toBe(1); expect(vm.$el.querySelectorAll('li').length).toBe(1);
expect(vm.$el.querySelector('li').textContent).toContain(mergeRequests[0].title); 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', () => { describe('searchMergeRequests', () => {
...@@ -123,4 +107,52 @@ describe('IDE merge requests list', () => { ...@@ -123,4 +107,52 @@ describe('IDE merge requests list', () => {
expect(vm.loadMergeRequests).toHaveBeenCalled(); 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'; ...@@ -4,6 +4,7 @@ import state from '~/ide/stores/state';
import commitState from '~/ide/stores/modules/commit/state'; import commitState from '~/ide/stores/modules/commit/state';
import mergeRequestsState from '~/ide/stores/modules/merge_requests/state'; import mergeRequestsState from '~/ide/stores/modules/merge_requests/state';
import pipelinesState from '~/ide/stores/modules/pipelines/state'; import pipelinesState from '~/ide/stores/modules/pipelines/state';
import branchesState from '~/ide/stores/modules/branches/state';
export const resetStore = store => { export const resetStore = store => {
const newState = { const newState = {
...@@ -11,6 +12,7 @@ export const resetStore = store => { ...@@ -11,6 +12,7 @@ export const resetStore = store => {
commit: commitState(), commit: commitState(),
mergeRequests: mergeRequestsState(), mergeRequests: mergeRequestsState(),
pipelines: pipelinesState(), pipelines: pipelinesState(),
branches: branchesState(),
}; };
store.replaceState(newState); store.replaceState(newState);
}; };
......
...@@ -165,3 +165,33 @@ export const mergeRequests = [ ...@@ -165,3 +165,33 @@ export const mergeRequests = [
web_url: `${gl.TEST_HOST}/namespace/project-path/merge_requests/1`, 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 { ...@@ -8,9 +8,7 @@ import {
receiveMergeRequestsSuccess, receiveMergeRequestsSuccess,
fetchMergeRequests, fetchMergeRequests,
resetMergeRequests, resetMergeRequests,
openMergeRequest,
} from '~/ide/stores/modules/merge_requests/actions'; } from '~/ide/stores/modules/merge_requests/actions';
import router from '~/ide/ide_router';
import { mergeRequests } from '../../../mock_data'; import { mergeRequests } from '../../../mock_data';
import testAction from '../../../../helpers/vuex_action_helper'; import testAction from '../../../../helpers/vuex_action_helper';
...@@ -28,12 +26,12 @@ describe('IDE merge requests actions', () => { ...@@ -28,12 +26,12 @@ describe('IDE merge requests actions', () => {
}); });
describe('requestMergeRequests', () => { describe('requestMergeRequests', () => {
it('should should commit request', done => { it('should commit request', done => {
testAction( testAction(
requestMergeRequests, requestMergeRequests,
'created', null,
mockedState, mockedState,
[{ type: types.REQUEST_MERGE_REQUESTS, payload: 'created' }], [{ type: types.REQUEST_MERGE_REQUESTS }],
[], [],
done, done,
); );
...@@ -46,7 +44,7 @@ describe('IDE merge requests actions', () => { ...@@ -46,7 +44,7 @@ describe('IDE merge requests actions', () => {
receiveMergeRequestsError, receiveMergeRequestsError,
{ type: 'created', search: '' }, { type: 'created', search: '' },
mockedState, mockedState,
[{ type: types.RECEIVE_MERGE_REQUESTS_ERROR, payload: 'created' }], [{ type: types.RECEIVE_MERGE_REQUESTS_ERROR }],
[ [
{ {
type: 'setErrorMessage', type: 'setErrorMessage',
...@@ -67,12 +65,12 @@ describe('IDE merge requests actions', () => { ...@@ -67,12 +65,12 @@ describe('IDE merge requests actions', () => {
it('should commit received data', done => { it('should commit received data', done => {
testAction( testAction(
receiveMergeRequestsSuccess, receiveMergeRequestsSuccess,
{ type: 'created', data: 'data' }, mergeRequests,
mockedState, mockedState,
[ [
{ {
type: types.RECEIVE_MERGE_REQUESTS_SUCCESS, type: types.RECEIVE_MERGE_REQUESTS_SUCCESS,
payload: { type: 'created', data: 'data' }, payload: mergeRequests,
}, },
], ],
[], [],
...@@ -129,11 +127,11 @@ describe('IDE merge requests actions', () => { ...@@ -129,11 +127,11 @@ describe('IDE merge requests actions', () => {
mockedState, mockedState,
[], [],
[ [
{ type: 'requestMergeRequests', payload: 'created' }, { type: 'requestMergeRequests' },
{ type: 'resetMergeRequests', payload: 'created' }, { type: 'resetMergeRequests' },
{ {
type: 'receiveMergeRequestsSuccess', type: 'receiveMergeRequestsSuccess',
payload: { type: 'created', data: mergeRequests }, payload: mergeRequests,
}, },
], ],
done, done,
...@@ -149,12 +147,12 @@ describe('IDE merge requests actions', () => { ...@@ -149,12 +147,12 @@ describe('IDE merge requests actions', () => {
it('dispatches error', done => { it('dispatches error', done => {
testAction( testAction(
fetchMergeRequests, fetchMergeRequests,
{ type: 'created' }, { type: 'created', search: '' },
mockedState, mockedState,
[], [],
[ [
{ type: 'requestMergeRequests', payload: 'created' }, { type: 'requestMergeRequests' },
{ type: 'resetMergeRequests', payload: 'created' }, { type: 'resetMergeRequests' },
{ type: 'receiveMergeRequestsError', payload: { type: 'created', search: '' } }, { type: 'receiveMergeRequestsError', payload: { type: 'created', search: '' } },
], ],
done, done,
...@@ -167,59 +165,12 @@ describe('IDE merge requests actions', () => { ...@@ -167,59 +165,12 @@ describe('IDE merge requests actions', () => {
it('commits reset', done => { it('commits reset', done => {
testAction( testAction(
resetMergeRequests, resetMergeRequests,
'created', null,
mockedState, mockedState,
[{ type: types.RESET_MERGE_REQUESTS, payload: 'created' }], [{ type: types.RESET_MERGE_REQUESTS }],
[], [],
done, 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', () => { ...@@ -12,29 +12,26 @@ describe('IDE merge requests mutations', () => {
describe(types.REQUEST_MERGE_REQUESTS, () => { describe(types.REQUEST_MERGE_REQUESTS, () => {
it('sets loading to true', () => { 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, () => { describe(types.RECEIVE_MERGE_REQUESTS_ERROR, () => {
it('sets loading to false', () => { 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, () => { describe(types.RECEIVE_MERGE_REQUESTS_SUCCESS, () => {
it('sets merge requests', () => { it('sets merge requests', () => {
gon.gitlab_url = gl.TEST_HOST; gon.gitlab_url = gl.TEST_HOST;
mutations[types.RECEIVE_MERGE_REQUESTS_SUCCESS](mockedState, { mutations[types.RECEIVE_MERGE_REQUESTS_SUCCESS](mockedState, mergeRequests);
type: 'created',
data: mergeRequests,
});
expect(mockedState.created.mergeRequests).toEqual([ expect(mockedState.mergeRequests).toEqual([
{ {
id: 1, id: 1,
iid: 1, iid: 1,
...@@ -50,9 +47,9 @@ describe('IDE merge requests mutations', () => { ...@@ -50,9 +47,9 @@ describe('IDE merge requests mutations', () => {
it('clears merge request array', () => { it('clears merge request array', () => {
mockedState.mergeRequests = ['test']; 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([]);
}); });
}); });
}); });
...@@ -7,6 +7,7 @@ import mountComponent from '../../helpers/vue_mount_component_helper'; ...@@ -7,6 +7,7 @@ import mountComponent from '../../helpers/vue_mount_component_helper';
import newFailedTestReports from '../mock_data/new_failures_report.json'; import newFailedTestReports from '../mock_data/new_failures_report.json';
import successTestReports from '../mock_data/no_failures_report.json'; import successTestReports from '../mock_data/no_failures_report.json';
import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json'; import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json';
import resolvedFailures from '../mock_data/resolved_failures.json';
describe('Grouped Test Reports App', () => { describe('Grouped Test Reports App', () => {
let vm; let vm;
...@@ -123,6 +124,41 @@ describe('Grouped Test Reports App', () => { ...@@ -123,6 +124,41 @@ describe('Grouped Test Reports App', () => {
}); });
}); });
describe('with resolved failures', () => {
beforeEach(() => {
mock.onGet('test_results.json').reply(200, resolvedFailures, {});
vm = mountComponent(Component, {
endpoint: 'test_results.json',
});
});
it('renders summary text', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Test summary contained 2 fixed test results out of 11 total tests',
);
expect(vm.$el.textContent).toContain(
'rspec:pg found 2 fixed test results out of 8 total tests',
);
done();
}, 0);
});
it('renders resolved failures', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.js-mr-code-resolved-issues').textContent).toContain(
resolvedFailures.suites[0].resolved_failures[0].name,
);
expect(vm.$el.querySelector('.js-mr-code-resolved-issues').textContent).toContain(
resolvedFailures.suites[0].resolved_failures[1].name,
);
done();
}, 0);
});
});
describe('with error', () => { describe('with error', () => {
beforeEach(() => { beforeEach(() => {
mock.onGet('test_results.json').reply(500, {}, {}); mock.onGet('test_results.json').reply(500, {}, {});
......
{
"status": "success",
"summary": { "total": 11, "resolved": 2, "failed": 0 },
"suites": [
{
"name": "rspec:pg",
"status": "success",
"summary": { "total": 8, "resolved": 2, "failed": 0 },
"new_failures": [],
"resolved_failures": [
{
"status": "success",
"name": "Test#sum when a is 1 and b is 2 returns summary",
"execution_time": 0.000411,
"system_output": null,
"stack_trace": null
},
{
"status": "success",
"name": "Test#sum when a is 100 and b is 200 returns summary",
"execution_time": 7.6e-5,
"system_output": null,
"stack_trace": null
}
],
"existing_failures": []
},
{
"name": "java ant",
"status": "success",
"summary": { "total": 3, "resolved": 0, "failed": 0 },
"new_failures": [],
"resolved_failures": [],
"existing_failures": []
}
]
}
...@@ -72,6 +72,10 @@ describe('Reports Store Mutations', () => { ...@@ -72,6 +72,10 @@ describe('Reports Store Mutations', () => {
expect(stateCopy.isLoading).toEqual(false); expect(stateCopy.isLoading).toEqual(false);
}); });
it('should reset hasError', () => {
expect(stateCopy.hasError).toEqual(false);
});
it('should set summary counts', () => { it('should set summary counts', () => {
expect(stateCopy.summary.total).toEqual(mockedResponse.summary.total); expect(stateCopy.summary.total).toEqual(mockedResponse.summary.total);
expect(stateCopy.summary.resolved).toEqual(mockedResponse.summary.resolved); expect(stateCopy.summary.resolved).toEqual(mockedResponse.summary.resolved);
......
...@@ -2,15 +2,15 @@ import Vue from 'vue'; ...@@ -2,15 +2,15 @@ import Vue from 'vue';
import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.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 defaultLabel = 'Select';
const customLabel = 'Select project'; const customLabel = 'Select project';
const createComponent = config => { const createComponent = (props, slots = {}) => {
const Component = Vue.extend(dropdownButtonComponent); const Component = Vue.extend(dropdownButtonComponent);
return mountComponent(Component, config); return mountComponentWithSlots(Component, { props, slots });
}; };
describe('DropdownButtonComponent', () => { describe('DropdownButtonComponent', () => {
...@@ -65,5 +65,14 @@ describe('DropdownButtonComponent', () => { ...@@ -65,5 +65,14 @@ describe('DropdownButtonComponent', () => {
expect(dropdownIconEl).not.toBeNull(); expect(dropdownIconEl).not.toBeNull();
expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); 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');
});
}); });
}); });
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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