Commit 71c8b3e7 authored by Valery Sizov's avatar Valery Sizov

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ee into ce_upstream[ci skip]

parents 5fe95565 e24d1890
...@@ -17,6 +17,7 @@ const Api = { ...@@ -17,6 +17,7 @@ const Api = {
usersPath: '/api/:version/users.json', usersPath: '/api/:version/users.json',
commitPath: '/api/:version/projects/:id/repository/commits', commitPath: '/api/:version/projects/:id/repository/commits',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
group(groupId, callback) { group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath) const url = Api.buildUrl(Api.groupPath)
......
...@@ -2,11 +2,8 @@ import Cookies from 'js-cookie'; ...@@ -2,11 +2,8 @@ import Cookies from 'js-cookie';
import bp from './breakpoints'; import bp from './breakpoints';
import UsersSelect from './users_select'; import UsersSelect from './users_select';
const PARTICIPANTS_ROW_COUNT = 7;
export default class IssuableContext { export default class IssuableContext {
constructor(currentUser) { constructor(currentUser) {
this.initParticipants();
this.userSelect = new UsersSelect(currentUser); this.userSelect = new UsersSelect(currentUser);
$('select.select2').select2({ $('select.select2').select2({
...@@ -51,29 +48,4 @@ export default class IssuableContext { ...@@ -51,29 +48,4 @@ export default class IssuableContext {
} }
}); });
} }
initParticipants() {
$(document).on('click', '.js-participants-more', this.toggleHiddenParticipants);
return $('.js-participants-author').each(function forEachAuthor(i) {
if (i >= PARTICIPANTS_ROW_COUNT) {
$(this).addClass('js-participants-hidden').hide();
}
});
}
toggleHiddenParticipants() {
const currentText = $(this).text().trim();
const lessText = $(this).data('less-text');
const originalText = $(this).data('original-text');
if (currentText === originalText) {
$(this).text(lessText);
if (gl.lazyLoader) gl.lazyLoader.loadCheck();
} else {
$(this).text(originalText);
}
$('.js-participants-hidden').toggle();
}
} }
<script> <script>
import { mapState, mapActions } from 'vuex';
import flash, { hideFlash } from '../../flash'; import flash, { hideFlash } from '../../flash';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import eventHub from '../event_hub';
export default { export default {
components: { components: {
loadingIcon, loadingIcon,
}, },
props: {
currentBranch: {
type: String,
required: true,
},
},
data() { data() {
return { return {
branchName: '', branchName: '',
...@@ -20,11 +14,17 @@ ...@@ -20,11 +14,17 @@
}; };
}, },
computed: { computed: {
...mapState([
'currentBranch',
]),
btnDisabled() { btnDisabled() {
return this.loading || this.branchName === ''; return this.loading || this.branchName === '';
}, },
}, },
methods: { methods: {
...mapActions([
'createNewBranch',
]),
toggleDropdown() { toggleDropdown() {
this.$dropdown.dropdown('toggle'); this.$dropdown.dropdown('toggle');
}, },
...@@ -38,19 +38,21 @@ ...@@ -38,19 +38,21 @@
hideFlash(flashEl, false); hideFlash(flashEl, false);
} }
eventHub.$emit('createNewBranch', this.branchName); this.createNewBranch(this.branchName)
}, .then(() => {
showErrorMessage(message) { this.loading = false;
this.loading = false; this.branchName = '';
flash(message, 'alert', this.$el);
},
createdNewBranch(newBranchName) {
this.loading = false;
this.branchName = '';
if (this.dropdownText) { if (this.dropdownText) {
this.dropdownText.textContent = newBranchName; this.dropdownText.textContent = this.currentBranch;
} }
this.toggleDropdown();
})
.catch(res => res.json().then((data) => {
this.loading = false;
flash(data.message, 'alert', this.$el);
}));
}, },
}, },
created() { created() {
...@@ -59,15 +61,6 @@ ...@@ -59,15 +61,6 @@
// text element is outside Vue app // text element is outside Vue app
this.dropdownText = document.querySelector('.project-refs-form .dropdown-toggle-text'); this.dropdownText = document.querySelector('.project-refs-form .dropdown-toggle-text');
eventHub.$on('createNewBranchSuccess', this.createdNewBranch);
eventHub.$on('createNewBranchError', this.showErrorMessage);
eventHub.$on('toggleNewBranchDropdown', this.toggleDropdown);
},
destroyed() {
eventHub.$off('createNewBranchSuccess', this.createdNewBranch);
eventHub.$off('toggleNewBranchDropdown', this.toggleDropdown);
eventHub.$off('createNewBranchError', this.showErrorMessage);
}, },
}; };
</script> </script>
......
<script> <script>
import RepoStore from '../../stores/repo_store'; import { mapState } from 'vuex';
import RepoHelper from '../../helpers/repo_helper';
import eventHub from '../../event_hub';
import newModal from './modal.vue'; import newModal from './modal.vue';
import upload from './upload.vue'; import upload from './upload.vue';
...@@ -14,9 +12,13 @@ ...@@ -14,9 +12,13 @@
return { return {
openModal: false, openModal: false,
modalType: '', modalType: '',
currentPath: RepoStore.path,
}; };
}, },
computed: {
...mapState([
'path',
]),
},
methods: { methods: {
createNewItem(type) { createNewItem(type) {
this.modalType = type; this.modalType = type;
...@@ -25,6 +27,7 @@ ...@@ -25,6 +27,7 @@
toggleModalOpen() { toggleModalOpen() {
this.openModal = !this.openModal; this.openModal = !this.openModal;
}, },
<<<<<<< HEAD
createNewEntryInStore(options, openEditMode = true) { createNewEntryInStore(options, openEditMode = true) {
RepoHelper.createNewEntry(options, openEditMode); RepoHelper.createNewEntry(options, openEditMode);
...@@ -38,6 +41,8 @@ ...@@ -38,6 +41,8 @@
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('createNewEntry', this.createNewEntryInStore); eventHub.$off('createNewEntry', this.createNewEntryInStore);
=======
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
}, },
}; };
</script> </script>
...@@ -88,7 +93,7 @@ ...@@ -88,7 +93,7 @@
<new-modal <new-modal
v-if="openModal" v-if="openModal"
:type="modalType" :type="modalType"
:current-path="currentPath" :path="path"
@toggle="toggleModalOpen" @toggle="toggleModalOpen"
/> />
</div> </div>
......
<script> <script>
import { mapActions } from 'vuex';
import { __ } from '../../../locale'; import { __ } from '../../../locale';
import popupDialog from '../../../vue_shared/components/popup_dialog.vue'; import popupDialog from '../../../vue_shared/components/popup_dialog.vue';
import eventHub from '../../event_hub';
export default { export default {
props: { props: {
currentPath: { path: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -16,19 +16,31 @@ ...@@ -16,19 +16,31 @@
}, },
data() { data() {
return { return {
entryName: this.currentPath !== '' ? `${this.currentPath}/` : '', entryName: this.path !== '' ? `${this.path}/` : '',
}; };
}, },
components: { components: {
popupDialog, popupDialog,
}, },
methods: { methods: {
...mapActions([
'createTempEntry',
]),
createEntryInStore() { createEntryInStore() {
<<<<<<< HEAD
eventHub.$emit('createNewEntry', { eventHub.$emit('createNewEntry', {
name: this.entryName, name: this.entryName,
type: this.type, type: this.type,
toggleModal: true, toggleModal: true,
}); });
=======
this.createTempEntry({
name: this.entryName.replace(new RegExp(`^${this.path}/`), ''),
type: this.type,
});
this.toggleModalOpen();
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
}, },
toggleModalOpen() { toggleModalOpen() {
this.$emit('toggle'); this.$emit('toggle');
......
<script> <script>
import { mapState, mapGetters } from 'vuex';
import RepoSidebar from './repo_sidebar.vue'; import RepoSidebar from './repo_sidebar.vue';
import RepoCommitSection from './repo_commit_section.vue'; import RepoCommitSection from './repo_commit_section.vue';
import RepoTabs from './repo_tabs.vue'; import RepoTabs from './repo_tabs.vue';
import RepoFileButtons from './repo_file_buttons.vue'; import RepoFileButtons from './repo_file_buttons.vue';
import RepoPreview from './repo_preview.vue'; import RepoPreview from './repo_preview.vue';
import RepoMixin from '../mixins/repo_mixin'; import repoEditor from './repo_editor.vue';
import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
import Store from '../stores/repo_store';
import Helper from '../helpers/repo_helper';
import Service from '../services/repo_service';
import MonacoLoaderHelper from '../helpers/monaco_loader_helper';
import eventHub from '../event_hub';
export default { export default {
data() { computed: {
return Store; ...mapState([
'currentBlobView',
]),
...mapGetters([
'isCollapsed',
'changedFiles',
]),
}, },
mixins: [RepoMixin],
components: { components: {
RepoSidebar, RepoSidebar,
RepoTabs, RepoTabs,
RepoFileButtons, RepoFileButtons,
'repo-editor': MonacoLoaderHelper.repoEditorLoader, repoEditor,
RepoCommitSection, RepoCommitSection,
PopupDialog,
RepoPreview, RepoPreview,
}, },
created() {
eventHub.$on('createNewBranch', this.createNewBranch);
},
mounted() { mounted() {
Helper.getContent().catch(Helper.loadingError); const returnValue = 'Are you sure you want to lose unsaved changes?';
}, window.onbeforeunload = (e) => {
destroyed() { if (!this.changedFiles.length) return undefined;
eventHub.$off('createNewBranch', this.createNewBranch);
},
methods: {
getCurrentLocation() {
return location.href;
},
toggleDialogOpen(toggle) {
this.dialog.open = toggle;
},
dialogSubmitted(status) {
this.toggleDialogOpen(false);
this.dialog.status = status;
// remove tmp files
Helper.removeAllTmpFiles('openedFiles');
Helper.removeAllTmpFiles('files');
},
toggleBlobView: Store.toggleBlobView,
createNewBranch(branch) {
Service.createBranch({
branch,
ref: Store.currentBranch,
}).then((res) => {
const newBranchName = res.data.name;
const newUrl = this.getCurrentLocation().replace(Store.currentBranch, newBranchName);
Store.currentBranch = newBranchName;
history.pushState({ key: Helper.key }, '', newUrl);
eventHub.$emit('createNewBranchSuccess', newBranchName); Object.assign(e, {
eventHub.$emit('toggleNewBranchDropdown'); returnValue,
}).catch((err) => {
eventHub.$emit('createNewBranchError', err.response.data.message);
}); });
}, return returnValue;
};
}, },
}; };
</script> </script>
<template> <template>
<div class="repository-view"> <div class="repository-view">
<div class="tree-content-holder" :class="{'tree-content-holder-mini' : isMini}"> <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isCollapsed}">
<repo-sidebar/> <repo-sidebar/>
<div v-if="isMini" <div
class="panel-right" v-if="isCollapsed"
:class="{'edit-mode': editMode}"> class="panel-right"
>
<repo-tabs/> <repo-tabs/>
<component <component
:is="currentBlobView" :is="currentBlobView"
class="blob-viewer-container"/> />
<repo-file-buttons/> <repo-file-buttons/>
</div> </div>
</div> </div>
<repo-commit-section/> <repo-commit-section v-if="changedFiles.length" />
<popup-dialog
v-show="dialog.open"
:primary-button-label="__('Discard changes')"
kind="warning"
:title="__('Are you sure?')"
:text="__('Are you sure you want to discard your changes?')"
@toggle="toggleDialogOpen"
@submit="dialogSubmitted"
/>
</div> </div>
</template> </template>
<script> <script>
import Flash from '../../flash'; import { mapGetters, mapState, mapActions } from 'vuex';
import Store from '../stores/repo_store';
import RepoMixin from '../mixins/repo_mixin';
import Service from '../services/repo_service';
import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
import { visitUrl } from '../../lib/utils/url_utility'; import { n__ } from '../../locale';
export default { export default {
mixins: [RepoMixin],
data() {
return Store;
},
components: { components: {
PopupDialog, PopupDialog,
}, },
data() {
return {
showNewBranchDialog: false,
submitCommitsLoading: false,
startNewMR: false,
commitMessage: '',
};
},
computed: { computed: {
showCommitable() { ...mapState([
return this.isCommitable && this.changedFiles.length; 'currentBranch',
}, ]),
...mapGetters([
branchPaths() { 'changedFiles',
return this.changedFiles.map(f => f.path); ]),
}, commitButtonDisabled() {
cantCommitYet() {
return !this.commitMessage || this.submitCommitsLoading; return !this.commitMessage || this.submitCommitsLoading;
}, },
commitButtonText() {
filePluralize() { return n__('Commit %d file', 'Commit %d files', this.changedFiles.length);
return this.changedFiles.length > 1 ? 'files' : 'file';
}, },
}, },
methods: { methods: {
commitToNewBranch(status) { ...mapActions([
if (status) { 'checkCommitStatus',
this.showNewBranchDialog = false; 'commitChanges',
this.tryCommit(null, true, true); 'getTreeData',
} else { ]),
// reset the state makeCommit(newBranch = false) {
} const createNewBranch = newBranch || this.startNewMR;
},
<<<<<<< HEAD
makeCommit(newBranch) { makeCommit(newBranch) {
// see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
const commitMessage = this.commitMessage; const commitMessage = this.commitMessage;
...@@ -55,88 +49,65 @@ export default { ...@@ -55,88 +49,65 @@ export default {
encoding: f.base64 ? 'base64' : 'text', encoding: f.base64 ? 'base64' : 'text',
})); }));
const branch = newBranch ? `${this.currentBranch}-${this.currentShortHash}` : this.currentBranch; const branch = newBranch ? `${this.currentBranch}-${this.currentShortHash}` : this.currentBranch;
=======
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
const payload = { const payload = {
branch, branch: createNewBranch ? `${this.currentBranch}-${new Date().getTime().toString()}` : this.currentBranch,
commit_message: commitMessage, commit_message: this.commitMessage,
actions, actions: this.changedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update',
file_path: f.path,
content: f.content,
encoding: f.base64 ? 'base64' : 'text',
})),
start_branch: createNewBranch ? this.currentBranch : undefined,
}; };
if (newBranch) {
payload.start_branch = this.currentBranch; this.showNewBranchDialog = false;
} this.submitCommitsLoading = true;
Service.commitFiles(payload)
this.commitChanges({ payload, newMr: this.startNewMR })
.then(() => { .then(() => {
this.resetCommitState(); this.submitCommitsLoading = false;
if (this.startNewMR) { this.getTreeData();
this.redirectToNewMr(branch);
} else {
this.redirectToBranch(branch);
}
}) })
.catch(() => { .catch(() => {
Flash('An error occurred while committing your changes'); this.submitCommitsLoading = false;
}); });
}, },
tryCommit() {
tryCommit(e, skipBranchCheck = false, newBranch = false) {
this.submitCommitsLoading = true; this.submitCommitsLoading = true;
if (skipBranchCheck) { this.checkCommitStatus()
this.makeCommit(newBranch); .then((branchChanged) => {
} else { if (branchChanged) {
Store.setBranchHash() this.showNewBranchDialog = true;
.then(() => { } else {
if (Store.branchChanged) { this.makeCommit();
Store.showNewBranchDialog = true; }
return; })
} .catch(() => {
this.makeCommit(newBranch); this.submitCommitsLoading = false;
}) });
.catch(() => {
this.submitCommitsLoading = false;
Flash('An error occurred while committing your changes');
});
}
},
redirectToNewMr(branch) {
visitUrl(this.newMrTemplateUrl.replace('{{source_branch}}', branch));
},
redirectToBranch(branch) {
visitUrl(this.customBranchURL.replace('{{branch}}', branch));
},
resetCommitState() {
this.submitCommitsLoading = false;
this.openedFiles = this.openedFiles.map((file) => {
const f = file;
f.changed = false;
return f;
});
this.changedFiles = [];
this.commitMessage = '';
this.editMode = false;
window.scrollTo(0, 0);
}, },
}, },
}; };
</script> </script>
<template> <template>
<div <div id="commit-area">
v-if="showCommitable"
id="commit-area">
<popup-dialog <popup-dialog
v-if="showNewBranchDialog" v-if="showNewBranchDialog"
:primary-button-label="__('Create new branch')" :primary-button-label="__('Create new branch')"
kind="primary" kind="primary"
:title="__('Branch has changed')" :title="__('Branch has changed')"
:text="__('This branch has changed since you started editing. Would you like to create a new branch?')" :text="__('This branch has changed since you started editing. Would you like to create a new branch?')"
@submit="commitToNewBranch" @toggle="showNewBranchDialog = false"
@submit="makeCommit(true)"
/> />
<form <form
class="form-horizontal" class="form-horizontal"
@submit.prevent="tryCommit"> @submit.prevent="tryCommit()">
<fieldset> <fieldset>
<div class="form-group"> <div class="form-group">
<label class="col-md-4 control-label staged-files"> <label class="col-md-4 control-label staged-files">
...@@ -145,10 +116,10 @@ export default { ...@@ -145,10 +116,10 @@ export default {
<div class="col-md-6"> <div class="col-md-6">
<ul class="list-unstyled changed-files"> <ul class="list-unstyled changed-files">
<li <li
v-for="branchPath in branchPaths" v-for="(file, index) in changedFiles"
:key="branchPath"> :key="index">
<span class="help-block"> <span class="help-block">
{{branchPath}} {{ file.path }}
</span> </span>
</li> </li>
</ul> </ul>
...@@ -183,9 +154,8 @@ export default { ...@@ -183,9 +154,8 @@ export default {
</div> </div>
<div class="col-md-offset-4 col-md-6"> <div class="col-md-offset-4 col-md-6">
<button <button
ref="submitCommit"
type="submit" type="submit"
:disabled="cantCommitYet" :disabled="commitButtonDisabled"
class="btn btn-success"> class="btn btn-success">
<i <i
v-if="submitCommitsLoading" v-if="submitCommitsLoading"
...@@ -194,7 +164,7 @@ export default { ...@@ -194,7 +164,7 @@ export default {
aria-label="loading"> aria-label="loading">
</i> </i>
<span class="commit-summary"> <span class="commit-summary">
Commit {{changedFiles.length}} {{filePluralize}} {{ commitButtonText }}
</span> </span>
</button> </button>
</div> </div>
......
<script> <script>
import Store from '../stores/repo_store'; import { mapGetters, mapActions, mapState } from 'vuex';
import RepoMixin from '../mixins/repo_mixin'; import popupDialog from '../../vue_shared/components/popup_dialog.vue';
export default { export default {
data() { components: {
return Store; popupDialog,
}, },
mixins: [RepoMixin],
computed: { computed: {
...mapState([
'editMode',
'discardPopupOpen',
]),
...mapGetters([
'canEditFile',
]),
buttonLabel() { buttonLabel() {
return this.editMode ? this.__('Cancel edit') : this.__('Edit'); return this.editMode ? this.__('Cancel edit') : this.__('Edit');
}, },
showButton() {
return this.isCommitable &&
!this.activeFile.render_error &&
!this.binary &&
this.openedFiles.length;
},
}, },
methods: { methods: {
editCancelClicked() { ...mapActions([
if (this.changedFiles.length) { 'toggleEditMode',
this.dialog.open = true; 'closeDiscardPopup',
return; ]),
}
this.editMode = !this.editMode;
Store.toggleBlobView();
},
}, },
}; };
</script> </script>
<template> <template>
<button <div class="editable-mode">
v-if="showButton" <button
class="btn btn-default" v-if="canEditFile"
type="button" class="btn btn-default"
@click.prevent="editCancelClicked"> type="button"
<i @click.prevent="toggleEditMode()">
v-if="!editMode" <i
class="fa fa-pencil" v-if="!editMode"
aria-hidden="true"> class="fa fa-pencil"
</i> aria-hidden="true">
<span> </i>
{{buttonLabel}} <span>
</span> {{buttonLabel}}
</button> </span>
</button>
<popup-dialog
v-if="discardPopupOpen"
class="text-left"
:primary-button-label="__('Discard changes')"
kind="warning"
:title="__('Are you sure?')"
:text="__('Are you sure you want to discard your changes?')"
@toggle="closeDiscardPopup"
@submit="toggleEditMode(true)"
/>
</div>
</template> </template>
<script> <script>
/* global monaco */ /* global monaco */
import Store from '../stores/repo_store'; import { mapGetters, mapActions } from 'vuex';
import Service from '../services/repo_service'; import flash from '../../flash';
import Helper from '../helpers/repo_helper'; import monacoLoader from '../monaco_loader';
const RepoEditor = {
data() {
return Store;
},
export default {
destroyed() { destroyed() {
if (Helper.monacoInstance) { if (this.monacoInstance) {
Helper.monacoInstance.destroy(); this.monacoInstance.destroy();
} }
}, },
mounted() { mounted() {
Service.getRaw(this.activeFile) if (this.monaco) {
.then((rawResponse) => { this.initMonaco();
Store.blobRaw = rawResponse.data; } else {
Store.activeFile.plain = rawResponse.data; monacoLoader(['vs/editor/editor.main'], () => {
this.monaco = monaco;
const monacoInstance = Helper.monaco.editor.create(this.$el, {
model: null, this.initMonaco();
readOnly: false, });
contextmenu: true, }
scrollBeyondLastLine: false, },
}); methods: {
...mapActions([
'getRawFileData',
'changeFileContent',
]),
initMonaco() {
if (this.monacoInstance) {
this.monacoInstance.setModel(null);
}
Helper.monacoInstance = monacoInstance; this.getRawFileData(this.activeFile)
.then(() => {
if (!this.monacoInstance) {
this.monacoInstance = this.monaco.editor.create(this.$el, {
model: null,
readOnly: false,
contextmenu: true,
scrollBeyondLastLine: false,
});
this.addMonacoEvents(); this.languages = this.monaco.languages.getLanguages();
this.setupEditor(); this.addMonacoEvents();
}) }
.catch(Helper.loadingError);
},
methods: { this.setupEditor();
})
.catch(() => flash('Error setting up monaco. Please try again.'));
},
setupEditor() { setupEditor() {
this.showHide(); if (!this.activeFile) return;
const content = this.activeFile.content !== '' ? this.activeFile.content : this.activeFile.raw;
Helper.setMonacoModelFromLanguage(); const foundLang = this.languages.find(lang =>
}, lang.extensions && lang.extensions.indexOf(this.activeFileExtension) === 0,
);
const newModel = this.monaco.editor.createModel(
content, foundLang ? foundLang.id : 'plaintext',
);
showHide() { this.monacoInstance.setModel(newModel);
if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) {
this.$el.style.display = 'none';
} else {
this.$el.style.display = 'inline-block';
}
}, },
addMonacoEvents() { addMonacoEvents() {
Helper.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp); this.monacoInstance.onKeyUp(() => {
Helper.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this)); this.changeFileContent({
}, file: this.activeFile,
content: this.monacoInstance.getValue(),
onMonacoEditorKeysPressed() { });
Store.setActiveFileContents(Helper.monacoInstance.getValue()); });
},
onMonacoEditorMouseUp(e) {
if (!e.target.position) return;
const lineNumber = e.target.position.lineNumber;
if (e.target.element.classList.contains('line-numbers')) {
location.hash = `L${lineNumber}`;
Store.setActiveLine(lineNumber);
}
}, },
}, },
watch: { watch: {
dialog: { activeFile(oldVal, newVal) {
handler(obj) { if (newVal && !newVal.active) {
const newObj = obj; this.initMonaco();
if (newObj.status) {
newObj.status = false;
this.openedFiles = this.openedFiles.map((file) => {
const f = file;
if (f.active) {
this.blobRaw = f.plain;
}
f.changed = false;
delete f.newContent;
return f;
});
this.editMode = false;
Store.toggleBlobView();
}
},
deep: true,
},
blobRaw() {
if (Helper.monacoInstance) {
this.setupEditor();
}
},
activeLine() {
if (Helper.monacoInstance) {
Helper.monacoInstance.setPosition({
lineNumber: this.activeLine,
column: 1,
});
} }
}, },
}, },
computed: { computed: {
...mapGetters([
'activeFile',
'activeFileExtension',
]),
shouldHideEditor() { shouldHideEditor() {
return !this.openedFiles.length || (this.binary && !this.activeFile.raw); return this.activeFile.binary && !this.activeFile.raw;
}, },
}, },
}; };
export default RepoEditor;
</script> </script>
<template> <template>
<div id="ide" v-if='!shouldHideEditor'></div> <div
id="ide"
v-if='!shouldHideEditor'
class="blob-viewer-container blob-editor-container"
>
</div>
</template> </template>
<script> <script>
import { mapActions, mapGetters } from 'vuex';
import timeAgoMixin from '../../vue_shared/mixins/timeago'; import timeAgoMixin from '../../vue_shared/mixins/timeago';
import eventHub from '../event_hub';
import repoMixin from '../mixins/repo_mixin';
export default { export default {
mixins: [ mixins: [
repoMixin,
timeAgoMixin, timeAgoMixin,
], ],
props: { props: {
...@@ -15,13 +13,15 @@ ...@@ -15,13 +13,15 @@
}, },
}, },
computed: { computed: {
...mapGetters([
'isCollapsed',
]),
fileIcon() { fileIcon() {
const classObj = { return {
'fa-spinner fa-spin': this.file.loading, 'fa-spinner fa-spin': this.file.loading,
[this.file.icon]: !this.file.loading, [this.file.icon]: !this.file.loading,
'fa-folder-open': !this.file.loading && this.file.opened, 'fa-folder-open': !this.file.loading && this.file.opened,
}; };
return classObj;
}, },
levelIndentation() { levelIndentation() {
return { return {
...@@ -33,9 +33,9 @@ ...@@ -33,9 +33,9 @@
}, },
}, },
methods: { methods: {
linkClicked(file) { ...mapActions([
eventHub.$emit('fileNameClicked', file); 'clickedTreeRow',
}, ]),
}, },
}; };
</script> </script>
...@@ -43,7 +43,7 @@ ...@@ -43,7 +43,7 @@
<template> <template>
<tr <tr
class="file" class="file"
@click.prevent="linkClicked(file)"> @click.prevent="clickedTreeRow(file)">
<td> <td>
<i <i
class="fa fa-fw file-icon" class="fa fa-fw file-icon"
...@@ -71,7 +71,7 @@ ...@@ -71,7 +71,7 @@
</template> </template>
</td> </td>
<template v-if="!isMini"> <template v-if="!isCollapsed">
<td class="hidden-sm hidden-xs"> <td class="hidden-sm hidden-xs">
<a <a
@click.stop @click.stop
......
<script> <script>
import Store from '../stores/repo_store'; import { mapGetters } from 'vuex';
import Helper from '../helpers/repo_helper';
import RepoMixin from '../mixins/repo_mixin';
const RepoFileButtons = {
data() {
return Store;
},
mixins: [RepoMixin],
export default {
computed: { computed: {
...mapGetters([
'activeFile',
]),
showButtons() { showButtons() {
return this.activeFile.raw_path || return this.activeFile.rawPath ||
this.activeFile.blame_path || this.activeFile.blamePath ||
this.activeFile.commits_path || this.activeFile.commitsPath ||
this.activeFile.permalink; this.activeFile.permalink;
}, },
rawDownloadButtonLabel() { rawDownloadButtonLabel() {
return this.binary ? 'Download' : 'Raw'; return this.activeFile.binary ? 'Download' : 'Raw';
},
canPreview() {
return Helper.isRenderable();
}, },
}, },
methods: {
rawPreviewToggle: Store.toggleRawPreview,
},
}; };
export default RepoFileButtons;
</script> </script>
<template> <template>
...@@ -40,11 +25,11 @@ export default RepoFileButtons; ...@@ -40,11 +25,11 @@ export default RepoFileButtons;
class="repo-file-buttons" class="repo-file-buttons"
> >
<a <a
:href="activeFile.raw_path" :href="activeFile.rawPath"
target="_blank" target="_blank"
class="btn btn-default raw" class="btn btn-default raw"
rel="noopener noreferrer"> rel="noopener noreferrer">
{{rawDownloadButtonLabel}} {{ rawDownloadButtonLabel }}
</a> </a>
<div <div
...@@ -52,12 +37,12 @@ export default RepoFileButtons; ...@@ -52,12 +37,12 @@ export default RepoFileButtons;
role="group" role="group"
aria-label="File actions"> aria-label="File actions">
<a <a
:href="activeFile.blame_path" :href="activeFile.blamePath"
class="btn btn-default blame"> class="btn btn-default blame">
Blame Blame
</a> </a>
<a <a
:href="activeFile.commits_path" :href="activeFile.commitsPath"
class="btn btn-default history"> class="btn btn-default history">
History History
</a> </a>
...@@ -67,13 +52,5 @@ export default RepoFileButtons; ...@@ -67,13 +52,5 @@ export default RepoFileButtons;
Permalink Permalink
</a> </a>
</div> </div>
<a
v-if="canPreview"
href="#"
@click.prevent="rawPreviewToggle"
class="btn btn-default preview">
{{activeFileLabel}}
</a>
</div> </div>
</template> </template>
<script> <script>
import repoMixin from '../mixins/repo_mixin'; import { mapGetters } from 'vuex';
export default { export default {
mixins: [ computed: {
repoMixin, ...mapGetters([
], 'isCollapsed',
]),
},
methods: { methods: {
lineOfCode(n) { lineOfCode(n) {
return `skeleton-line-${n}`; return `skeleton-line-${n}`;
...@@ -28,7 +30,7 @@ ...@@ -28,7 +30,7 @@
</div> </div>
</div> </div>
</td> </td>
<template v-if="!isMini"> <template v-if="!isCollapsed">
<td <td
class="hidden-sm hidden-xs"> class="hidden-sm hidden-xs">
<div class="animation-container"> <div class="animation-container">
......
<script> <script>
import eventHub from '../event_hub'; import { mapGetters, mapState, mapActions } from 'vuex';
import repoMixin from '../mixins/repo_mixin';
export default { export default {
mixins: [
repoMixin,
],
props: {
prevUrl: {
type: String,
required: true,
},
},
computed: { computed: {
...mapState([
'parentTreeUrl',
]),
...mapGetters([
'isCollapsed',
]),
colSpanCondition() { colSpanCondition() {
return this.isMini ? undefined : 3; return this.isCollapsed ? undefined : 3;
}, },
}, },
methods: { methods: {
linkClicked(file) { ...mapActions([
eventHub.$emit('goToPreviousDirectoryClicked', file); 'getTreeData',
}, ]),
}, },
}; };
</script> </script>
...@@ -30,9 +26,9 @@ ...@@ -30,9 +26,9 @@
<td <td
:colspan="colSpanCondition" :colspan="colSpanCondition"
class="table-cell" class="table-cell"
@click.prevent="linkClicked(prevUrl)" @click.prevent="getTreeData({ endpoint: parentTreeUrl })"
> >
<a :href="prevUrl">...</a> <a :href="parentTreeUrl">...</a>
</td> </td>
</tr> </tr>
</template> </template>
<script> <script>
/* global LineHighlighter */ /* global LineHighlighter */
import { mapGetters } from 'vuex';
import Store from '../stores/repo_store';
export default { export default {
data() {
return Store;
},
computed: { computed: {
html() { ...mapGetters([
return this.activeFile.html; 'activeFile',
]),
renderErrorTooLarge() {
return this.activeFile.renderError === 'too_large';
}, },
}, },
methods: { methods: {
highlightFile() { highlightFile() {
$(this.$el).find('.file-content').syntaxHighlight(); $(this.$el).find('.file-content').syntaxHighlight();
}, },
highlightLine() {
if (Store.activeLine > -1) {
this.lineHighlighter.highlightHash(`#L${Store.activeLine}`);
}
},
}, },
mounted() { mounted() {
this.highlightFile(); this.highlightFile();
...@@ -29,24 +23,18 @@ export default { ...@@ -29,24 +23,18 @@ export default {
scrollFileHolder: true, scrollFileHolder: true,
}); });
}, },
watch: { updated() {
html() { this.$nextTick(() => {
this.$nextTick(() => { this.highlightFile();
this.highlightFile(); });
this.highlightLine();
});
},
activeLine() {
this.highlightLine();
},
}, },
}; };
</script> </script>
<template> <template>
<div> <div class="blob-viewer-container">
<div <div
v-if="!activeFile.render_error" v-if="!activeFile.renderError"
v-html="activeFile.html"> v-html="activeFile.html">
</div> </div>
<div <div
...@@ -57,17 +45,21 @@ export default { ...@@ -57,17 +45,21 @@ export default {
</p> </p>
</div> </div>
<div <div
<<<<<<< HEAD
v-else-if="activeFile.tooLarge" v-else-if="activeFile.tooLarge"
=======
v-else-if="renderErrorTooLarge"
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
class="vertical-center render-error"> class="vertical-center render-error">
<p class="text-center"> <p class="text-center">
The source could not be displayed because it is too large. You can <a :href="activeFile.raw_path">download</a> it instead. The source could not be displayed because it is too large. You can <a :href="activeFile.rawPath" download>download</a> it instead.
</p> </p>
</div> </div>
<div <div
v-else v-else
class="vertical-center render-error"> class="vertical-center render-error">
<p class="text-center"> <p class="text-center">
The source could not be displayed because a rendering error occurred. You can <a :href="activeFile.raw_path">download</a> it instead. The source could not be displayed because a rendering error occurred. You can <a :href="activeFile.rawPath" download>download</a> it instead.
</p> </p>
</div> </div>
</div> </div>
......
<script> <script>
import _ from 'underscore'; import { mapState, mapGetters, mapActions } from 'vuex';
import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper';
import Store from '../stores/repo_store';
import eventHub from '../event_hub';
import RepoPreviousDirectory from './repo_prev_directory.vue'; import RepoPreviousDirectory from './repo_prev_directory.vue';
import RepoFile from './repo_file.vue'; import RepoFile from './repo_file.vue';
import RepoLoadingFile from './repo_loading_file.vue'; import RepoLoadingFile from './repo_loading_file.vue';
import RepoMixin from '../mixins/repo_mixin';
export default { export default {
mixins: [RepoMixin],
components: { components: {
'repo-previous-directory': RepoPreviousDirectory, 'repo-previous-directory': RepoPreviousDirectory,
'repo-file': RepoFile, 'repo-file': RepoFile,
'repo-loading-file': RepoLoadingFile, 'repo-loading-file': RepoLoadingFile,
}, },
created() { created() {
window.addEventListener('popstate', this.checkHistory); window.addEventListener('popstate', this.popHistoryState);
}, },
destroyed() { destroyed() {
eventHub.$off('fileNameClicked', this.fileClicked); window.removeEventListener('popstate', this.popHistoryState);
eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
window.removeEventListener('popstate', this.checkHistory);
}, },
mounted() { mounted() {
eventHub.$on('fileNameClicked', this.fileClicked); this.getTreeData();
eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
},
data() {
return Store;
}, },
computed: { computed: {
flattendFiles() { ...mapState([
const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)])); 'loading',
'isRoot',
return _.chain(this.files) ]),
.map(arr => [arr, mapFiles(arr)]) ...mapState({
.flatten() projectName(state) {
.value(); return state.project.name;
}, },
}),
...mapGetters([
'treeList',
'isCollapsed',
]),
}, },
methods: { methods: {
checkHistory() { ...mapActions([
let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1); 'getTreeData',
if (!selectedFile) { 'popHistoryState',
// Maybe it is not in the current tree but in the opened tabs ]),
selectedFile = Helper.getFileFromPath(location.pathname);
}
let lineNumber = null;
if (location.hash.indexOf('#L') > -1) lineNumber = Number(location.hash.substr(2));
if (selectedFile) {
if (selectedFile.url !== this.activeFile.url) {
this.fileClicked(selectedFile, lineNumber);
} else {
Store.setActiveLine(lineNumber);
}
} else {
// Not opened at all lets open new tab
this.fileClicked({
url: location.href,
}, lineNumber);
}
},
fileClicked(clickedFile, lineNumber) {
const file = clickedFile;
if (file.loading) return;
if (file.type === 'tree' && file.opened) {
Helper.setDirectoryToClosed(file);
Store.setActiveLine(lineNumber);
} else if (file.type === 'submodule') {
file.loading = true;
gl.utils.visitUrl(file.url);
} else {
const openFile = Helper.getFileFromPath(file.url);
if (openFile) {
Store.setActiveFiles(openFile);
Store.setActiveLine(lineNumber);
} else {
file.loading = true;
Service.url = file.url;
Helper.getContent(file)
.then(() => {
file.loading = false;
Helper.scrollTabsRight();
Store.setActiveLine(lineNumber);
})
.catch(Helper.loadingError);
}
}
},
goToPreviousDirectoryClicked(prevURL) {
Service.url = prevURL;
Helper.getContent(null, true)
.then(() => Helper.scrollTabsRight())
.catch(Helper.loadingError);
},
}, },
}; };
</script> </script>
<template> <template>
<div id="sidebar" :class="{'sidebar-mini' : isMini}"> <div id="sidebar" :class="{'sidebar-mini' : isCollapsed}">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th <th
v-if="isMini" v-if="isCollapsed"
class="repo-file-options title" class="repo-file-options title"
> >
<strong class="clgray"> <strong class="clgray">
...@@ -136,17 +71,16 @@ export default { ...@@ -136,17 +71,16 @@ export default {
</thead> </thead>
<tbody> <tbody>
<repo-previous-directory <repo-previous-directory
v-if="!isRoot && !loading.tree" v-if="!isRoot && treeList.length"
:prev-url="prevURL"
/> />
<repo-loading-file <repo-loading-file
v-if="!flattendFiles.length && loading.tree" v-if="!treeList.length && loading"
v-for="n in 5" v-for="n in 5"
:key="n" :key="n"
/> />
<repo-file <repo-file
v-for="file in flattendFiles" v-for="(file, index) in treeList"
:key="file.id" :key="index"
:file="file" :file="file"
/> />
</tbody> </tbody>
......
<script> <script>
import Store from '../stores/repo_store'; import { mapActions } from 'vuex';
const RepoTab = { export default {
props: { props: {
tab: { tab: {
type: Object, type: Object,
...@@ -11,7 +11,7 @@ const RepoTab = { ...@@ -11,7 +11,7 @@ const RepoTab = {
computed: { computed: {
closeLabel() { closeLabel() {
if (this.tab.changed) { if (this.tab.changed || this.tab.tempFile) {
return `${this.tab.name} changed`; return `${this.tab.name} changed`;
} }
return `Close ${this.tab.name}`; return `Close ${this.tab.name}`;
...@@ -26,29 +26,23 @@ const RepoTab = { ...@@ -26,29 +26,23 @@ const RepoTab = {
}, },
methods: { methods: {
tabClicked(file) { ...mapActions([
Store.setActiveFiles(file); 'setFileActive',
}, 'closeFile',
closeTab(file) { ]),
if (file.changed || file.tempFile) return;
Store.removeFromOpenedFiles(file);
},
}, },
}; };
export default RepoTab;
</script> </script>
<template> <template>
<li <li
:class="{ active : tab.active }" :class="{ active : tab.active }"
@click="tabClicked(tab)" @click="setFileActive(tab)"
> >
<button <button
type="button" type="button"
class="close-btn" class="close-btn"
@click.stop.prevent="closeTab(tab)" @click.stop.prevent="closeFile({ file: tab })"
:aria-label="closeLabel"> :aria-label="closeLabel">
<i <i
class="fa" class="fa"
...@@ -61,7 +55,7 @@ export default RepoTab; ...@@ -61,7 +55,7 @@ export default RepoTab;
href="#" href="#"
class="repo-tab" class="repo-tab"
:title="tab.url" :title="tab.url"
@click.prevent="tabClicked(tab)"> @click.prevent.stop="setFileActive(tab)">
{{tab.name}} {{tab.name}}
</a> </a>
</li> </li>
......
<script> <script>
import Store from '../stores/repo_store'; import { mapState } from 'vuex';
import RepoTab from './repo_tab.vue'; import RepoTab from './repo_tab.vue';
import RepoMixin from '../mixins/repo_mixin';
export default { export default {
mixins: [RepoMixin],
components: { components: {
'repo-tab': RepoTab, 'repo-tab': RepoTab,
}, },
data() { computed: {
return Store; ...mapState([
'openFiles',
]),
}, },
}; };
</script> </script>
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
class="list-unstyled" class="list-unstyled"
> >
<repo-tab <repo-tab
v-for="tab in openedFiles" v-for="tab in openFiles"
:key="tab.id" :key="tab.id"
:tab="tab" :tab="tab"
/> />
......
import Vue from 'vue';
export default new Vue();
/* global monaco */
import RepoEditor from '../components/repo_editor.vue';
import Store from '../stores/repo_store';
import Helper from '../helpers/repo_helper';
import monacoLoader from '../monaco_loader';
function repoEditorLoader() {
Store.monacoLoading = true;
return new Promise((resolve, reject) => {
monacoLoader(['vs/editor/editor.main'], () => {
Helper.monaco = monaco;
Store.monacoLoading = false;
resolve(RepoEditor);
}, () => {
Store.monacoLoading = false;
reject();
});
});
}
const MonacoLoaderHelper = {
repoEditorLoader,
};
export default MonacoLoaderHelper;
import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import { mapActions } from 'vuex';
import { convertPermissionToBoolean } from '../lib/utils/common_utils'; import { convertPermissionToBoolean } from '../lib/utils/common_utils';
import Service from './services/repo_service';
import Store from './stores/repo_store';
import Repo from './components/repo.vue'; import Repo from './components/repo.vue';
import RepoEditButton from './components/repo_edit_button.vue'; import RepoEditButton from './components/repo_edit_button.vue';
import newBranchForm from './components/new_branch_form.vue'; import newBranchForm from './components/new_branch_form.vue';
import newDropdown from './components/new_dropdown/index.vue'; import newDropdown from './components/new_dropdown/index.vue';
import store from './stores';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
function initDropdowns() {
$('.js-tree-ref-target-holder').hide();
}
function addEventsForNonVueEls() {
window.onbeforeunload = function confirmUnload(e) {
const hasChanged = Store.openedFiles
.some(file => file.changed);
if (!hasChanged) return undefined;
const event = e || window.event;
if (event) event.returnValue = 'Are you sure you want to lose unsaved changes?';
// For Safari
return 'Are you sure you want to lose unsaved changes?';
};
}
function setInitialStore(data) {
Store.service = Service;
Store.service.url = data.url;
Store.service.refsUrl = data.refsUrl;
Store.path = data.currentPath;
Store.projectId = data.projectId;
Store.projectName = data.projectName;
Store.projectUrl = data.projectUrl;
Store.canCommit = data.canCommit;
Store.onTopOfBranch = data.onTopOfBranch;
Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl);
Store.customBranchURL = decodeURIComponent(data.blobUrl);
Store.isRoot = convertPermissionToBoolean(data.root);
Store.isInitialRoot = convertPermissionToBoolean(data.root);
Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
Store.checkIsCommitable();
Store.setBranchHash();
}
function initRepo(el) { function initRepo(el) {
if (!el) return null;
return new Vue({ return new Vue({
el, el,
store,
components: { components: {
repo: Repo, repo: Repo,
}, },
methods: {
...mapActions([
'setInitialData',
]),
},
created() {
const data = el.dataset;
this.setInitialData({
project: {
id: data.projectId,
name: data.projectName,
url: data.projectUrl,
},
endpoints: {
rootEndpoint: data.url,
newMergeRequestUrl: data.newMergeRequestUrl,
rootUrl: data.rootUrl,
},
canCommit: convertPermissionToBoolean(data.canCommit),
onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch),
currentRef: data.ref,
path: data.currentPath,
currentBranch: data.currentBranch,
isRoot: convertPermissionToBoolean(data.root),
isInitialRoot: convertPermissionToBoolean(data.root),
});
},
render(createElement) { render(createElement) {
return createElement('repo'); return createElement('repo');
}, },
...@@ -59,15 +54,20 @@ function initRepo(el) { ...@@ -59,15 +54,20 @@ function initRepo(el) {
function initRepoEditButton(el) { function initRepoEditButton(el) {
return new Vue({ return new Vue({
el, el,
store,
components: { components: {
repoEditButton: RepoEditButton, repoEditButton: RepoEditButton,
}, },
render(createElement) {
return createElement('repo-edit-button');
},
}); });
} }
function initNewDropdown(el) { function initNewDropdown(el) {
return new Vue({ return new Vue({
el, el,
store,
components: { components: {
newDropdown, newDropdown,
}, },
...@@ -87,32 +87,20 @@ function initNewBranchForm() { ...@@ -87,32 +87,20 @@ function initNewBranchForm() {
components: { components: {
newBranchForm, newBranchForm,
}, },
store,
render(createElement) { render(createElement) {
return createElement('new-branch-form', { return createElement('new-branch-form');
props: {
currentBranch: Store.currentBranch,
},
});
}, },
}); });
} }
function initRepoBundle() { const repo = document.getElementById('repo');
const repo = document.getElementById('repo'); const editButton = document.querySelector('.editable-mode');
const editButton = document.querySelector('.editable-mode'); const newDropdownHolder = document.querySelector('.js-new-dropdown');
const newDropdownHolder = document.querySelector('.js-new-dropdown');
setInitialStore(repo.dataset);
addEventsForNonVueEls();
initDropdowns();
Vue.use(Translate);
initRepo(repo);
initRepoEditButton(editButton);
initNewBranchForm();
initNewDropdown(newDropdownHolder);
}
$(initRepoBundle); Vue.use(Translate);
export default initRepoBundle; initRepo(repo);
initRepoEditButton(editButton);
initNewBranchForm();
initNewDropdown(newDropdownHolder);
import Store from '../stores/repo_store';
const RepoMixin = {
computed: {
isMini() {
return !!Store.openedFiles.length;
},
changedFiles() {
const changedFileList = this.openedFiles
.filter(file => file.changed || file.tempFile);
return changedFileList;
},
},
};
export default RepoMixin;
import Vue from 'vue';
import VueResource from 'vue-resource';
import Api from '../../api';
Vue.use(VueResource);
export default {
getTreeData(endpoint) {
return Vue.http.get(endpoint, { params: { format: 'json' } });
},
getFileData(endpoint) {
return Vue.http.get(endpoint, { params: { format: 'json' } });
},
getRawFileData(file) {
if (file.tempFile) {
return Promise.resolve(file.content);
}
return Vue.http.get(file.rawPath, { params: { format: 'json' } })
.then(res => res.text());
},
getBranchData(projectId, currentBranch) {
return Api.branchSingle(projectId, currentBranch);
},
createBranch(projectId, payload) {
const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId);
return Vue.http.post(url, payload);
},
commit(projectId, payload) {
return Api.commitMultiple(projectId, payload);
},
};
import Vue from 'vue';
import flash from '../../flash';
import service from '../services';
import * as types from './mutation_types';
export const redirectToUrl = url => gl.utils.visitUrl(url);
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const closeDiscardPopup = ({ commit }) => commit(types.TOGGLE_DISCARD_POPUP, false);
export const discardAllChanges = ({ commit, getters, dispatch }) => {
const changedFiles = getters.changedFiles;
changedFiles.forEach((file) => {
commit(types.DISCARD_FILE_CHANGES, file);
if (file.tempFile) {
dispatch('closeFile', { file, force: true });
}
});
};
export const closeAllFiles = ({ state, dispatch }) => {
state.openFiles.forEach(file => dispatch('closeFile', { file }));
};
export const toggleEditMode = ({ state, commit, getters, dispatch }, force = false) => {
const changedFiles = getters.changedFiles;
if (changedFiles.length && !force) {
commit(types.TOGGLE_DISCARD_POPUP, true);
} else {
commit(types.TOGGLE_EDIT_MODE);
commit(types.TOGGLE_DISCARD_POPUP, false);
dispatch('toggleBlobView');
if (!state.editMode) {
dispatch('discardAllChanges');
}
}
};
export const toggleBlobView = ({ commit, state }) => {
if (state.editMode) {
commit(types.SET_EDIT_MODE);
} else {
commit(types.SET_PREVIEW_MODE);
}
};
export const checkCommitStatus = ({ state }) => service.getBranchData(
state.project.id,
state.currentBranch,
)
.then((data) => {
const { id } = data.commit;
if (state.currentRef !== id) {
return true;
}
return false;
})
.catch(() => flash('Error checking branch data. Please try again.'));
export const commitChanges = ({ commit, state, dispatch }, { payload, newMr }) =>
service.commit(state.project.id, payload)
.then((data) => {
const { branch } = payload;
if (!data.short_id) {
flash(data.message);
return;
}
flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice');
if (newMr) {
redirectToUrl(`${state.endpoints.newMergeRequestUrl}${branch}`);
} else {
commit(types.SET_COMMIT_REF, data.id);
dispatch('discardAllChanges');
dispatch('closeAllFiles');
dispatch('toggleEditMode');
window.scrollTo(0, 0);
}
})
.catch(() => flash('Error committing changes. Please try again.'));
export const createTempEntry = ({ state, dispatch }, { name, type, content = '', base64 = false }) => {
if (type === 'tree') {
dispatch('createTempTree', name);
} else if (type === 'blob') {
dispatch('createTempFile', {
tree: state,
name,
base64,
content,
});
}
};
export const popHistoryState = ({ state, dispatch, getters }) => {
const treeList = getters.treeList;
const tree = treeList.find(file => file.url === state.previousUrl);
if (!tree) return;
if (tree.type === 'tree') {
dispatch('toggleTreeOpen', { endpoint: tree.url, tree });
}
};
export const scrollToTab = () => {
Vue.nextTick(() => {
const tabs = document.getElementById('tabs');
if (tabs) {
const tabEl = tabs.querySelector('.active .repo-tab');
tabEl.focus();
}
});
};
export * from './actions/tree';
export * from './actions/file';
export * from './actions/branch';
import service from '../../services';
import * as types from '../mutation_types';
import { pushState } from '../utils';
// eslint-disable-next-line import/prefer-default-export
export const createNewBranch = ({ rootState, commit }, branch) => service.createBranch(
rootState.project.id,
{
branch,
ref: rootState.currentBranch,
},
).then(res => res.json())
.then((data) => {
const branchName = data.name;
const url = location.href.replace(rootState.currentBranch, branchName);
pushState(url);
commit(types.SET_CURRENT_BRANCH, branchName);
});
import { normalizeHeaders } from '../../../lib/utils/common_utils';
import flash from '../../../flash';
import service from '../../services';
import * as types from '../mutation_types';
import {
findEntry,
pushState,
setPageTitle,
createTemp,
findIndexOfFile,
} from '../utils';
export const closeFile = ({ commit, state, dispatch }, { file, force = false }) => {
if ((file.changed || file.tempFile) && !force) return;
const indexOfClosedFile = findIndexOfFile(state.openFiles, file);
const fileWasActive = file.active;
commit(types.TOGGLE_FILE_OPEN, file);
commit(types.SET_FILE_ACTIVE, { file, active: false });
if (state.openFiles.length > 0 && fileWasActive) {
const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1;
const nextFileToOpen = state.openFiles[nextIndexToOpen];
dispatch('setFileActive', nextFileToOpen);
} else if (!state.openFiles.length) {
pushState(file.parentTreeUrl);
}
};
export const setFileActive = ({ commit, state, getters, dispatch }, file) => {
const currentActiveFile = getters.activeFile;
if (file.active) return;
if (currentActiveFile) {
commit(types.SET_FILE_ACTIVE, { file: currentActiveFile, active: false });
}
commit(types.SET_FILE_ACTIVE, { file, active: true });
dispatch('scrollToTab');
// reset hash for line highlighting
location.hash = '';
};
export const getFileData = ({ state, commit, dispatch }, file) => {
commit(types.TOGGLE_LOADING, file);
service.getFileData(file.url)
.then((res) => {
const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']);
setPageTitle(pageTitle);
return res.json();
})
.then((data) => {
commit(types.SET_FILE_DATA, { data, file });
commit(types.TOGGLE_FILE_OPEN, file);
dispatch('setFileActive', file);
commit(types.TOGGLE_LOADING, file);
pushState(file.url);
})
.catch(() => {
commit(types.TOGGLE_LOADING, file);
flash('Error loading file data. Please try again.');
});
};
export const getRawFileData = ({ commit, dispatch }, file) => service.getRawFileData(file)
.then((raw) => {
commit(types.SET_FILE_RAW_DATA, { file, raw });
})
.catch(() => flash('Error loading file content. Please try again.'));
export const changeFileContent = ({ commit }, { file, content }) => {
commit(types.UPDATE_FILE_CONTENT, { file, content });
};
export const createTempFile = ({ state, commit, dispatch }, { tree, name, content = '', base64 = '' }) => {
const file = createTemp({
name: name.replace(`${state.path}/`, ''),
path: tree.path,
type: 'blob',
level: tree.level !== undefined ? tree.level + 1 : 0,
changed: true,
content,
base64,
});
if (findEntry(tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`);
commit(types.CREATE_TMP_FILE, {
parent: tree,
file,
});
commit(types.TOGGLE_FILE_OPEN, file);
dispatch('setFileActive', file);
if (!state.editMode && !file.base64) {
dispatch('toggleEditMode', true);
}
return Promise.resolve(file);
};
import { normalizeHeaders } from '../../../lib/utils/common_utils';
import flash from '../../../flash';
import service from '../../services';
import * as types from '../mutation_types';
import {
pushState,
setPageTitle,
findEntry,
createTemp,
} from '../utils';
export const getTreeData = (
{ commit, state },
{ endpoint = state.endpoints.rootEndpoint, tree = state } = {},
) => {
commit(types.TOGGLE_LOADING, tree);
service.getTreeData(endpoint)
.then((res) => {
const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']);
setPageTitle(pageTitle);
return res.json();
})
.then((data) => {
if (!state.isInitialRoot) {
commit(types.SET_ROOT, data.path === '/');
}
commit(types.SET_DIRECTORY_DATA, { data, tree });
commit(types.SET_PARENT_TREE_URL, data.parent_tree_url);
commit(types.TOGGLE_LOADING, tree);
pushState(endpoint);
})
.catch(() => {
flash('Error loading tree data. Please try again.');
commit(types.TOGGLE_LOADING, tree);
});
};
export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => {
if (tree.opened) {
// send empty data to clear the tree
const data = { trees: [], blobs: [], submodules: [] };
pushState(tree.parentTreeUrl);
commit(types.SET_PREVIOUS_URL, tree.parentTreeUrl);
commit(types.SET_DIRECTORY_DATA, { data, tree });
} else {
commit(types.SET_PREVIOUS_URL, endpoint);
dispatch('getTreeData', { endpoint, tree });
}
commit(types.TOGGLE_TREE_OPEN, tree);
};
export const clickedTreeRow = ({ commit, dispatch }, row) => {
if (row.type === 'tree') {
dispatch('toggleTreeOpen', {
endpoint: row.url,
tree: row,
});
} else if (row.type === 'submodule') {
commit(types.TOGGLE_LOADING, row);
gl.utils.visitUrl(row.url);
} else if (row.type === 'blob' && row.opened) {
dispatch('setFileActive', row);
} else {
dispatch('getFileData', row);
}
};
export const createTempTree = ({ state, commit, dispatch }, name) => {
let tree = state;
const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/');
dirNames.forEach((dirName) => {
const foundEntry = findEntry(tree, 'tree', dirName);
if (!foundEntry) {
const tmpEntry = createTemp({
name: dirName,
path: tree.path,
type: 'tree',
level: tree.level !== undefined ? tree.level + 1 : 0,
});
commit(types.CREATE_TMP_TREE, {
parent: tree,
tmpEntry,
});
commit(types.TOGGLE_TREE_OPEN, tmpEntry);
tree = tmpEntry;
} else {
tree = foundEntry;
}
});
if (tree.tempFile) {
dispatch('createTempFile', {
tree,
name: '.gitkeep',
});
}
};
import _ from 'underscore';
/*
Takes the multi-dimensional tree and returns a flattened array.
This allows for the table to recursively render the table rows but keeps the data
structure nested to make it easier to add new files/directories.
*/
export const treeList = (state) => {
const mapTree = arr => (!arr.tree.length ? [] : _.map(arr.tree, a => [a, mapTree(a)]));
return _.chain(state.tree)
.map(arr => [arr, mapTree(arr)])
.flatten()
.value();
};
export const changedFiles = state => state.openFiles.filter(file => file.changed);
export const activeFile = state => state.openFiles.find(file => file.active);
export const activeFileExtension = (state) => {
const file = activeFile(state);
return file ? `.${file.path.split('.').pop()}` : '';
};
export const isCollapsed = state => !!state.openFiles.length;
export const canEditFile = (state) => {
const currentActiveFile = activeFile(state);
const openedFiles = state.openFiles;
return state.canCommit &&
state.onTopOfBranch &&
openedFiles.length &&
(currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary);
};
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default new Vuex.Store({
state: state(),
actions,
mutations,
getters,
});
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
export const SET_COMMIT_REF = 'SET_COMMIT_REF';
export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL';
export const SET_ROOT = 'SET_ROOT';
export const SET_PREVIOUS_URL = 'SET_PREVIOUS_URL';
// Tree mutation types
export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN';
export const CREATE_TMP_TREE = 'CREATE_TMP_TREE';
// File mutation types
export const SET_FILE_DATA = 'SET_FILE_DATA';
export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN';
export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE';
export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA';
export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
export const CREATE_TMP_FILE = 'CREATE_TMP_FILE';
// Viewer mutation types
export const SET_PREVIEW_MODE = 'SET_PREVIEW_MODE';
export const SET_EDIT_MODE = 'SET_EDIT_MODE';
export const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE';
export const TOGGLE_DISCARD_POPUP = 'TOGGLE_DISCARD_POPUP';
export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH';
import * as types from './mutation_types';
import fileMutations from './mutations/file';
import treeMutations from './mutations/tree';
import branchMutations from './mutations/branch';
export default {
[types.SET_INITIAL_DATA](state, data) {
Object.assign(state, data);
},
[types.SET_PREVIEW_MODE](state) {
Object.assign(state, {
currentBlobView: 'repo-preview',
});
},
[types.SET_EDIT_MODE](state) {
Object.assign(state, {
currentBlobView: 'repo-editor',
});
},
[types.TOGGLE_LOADING](state, entry) {
Object.assign(entry, {
loading: !entry.loading,
});
},
[types.TOGGLE_EDIT_MODE](state) {
Object.assign(state, {
editMode: !state.editMode,
});
},
[types.TOGGLE_DISCARD_POPUP](state, discardPopupOpen) {
Object.assign(state, {
discardPopupOpen,
});
},
[types.SET_COMMIT_REF](state, ref) {
Object.assign(state, {
currentRef: ref,
});
},
[types.SET_ROOT](state, isRoot) {
Object.assign(state, {
isRoot,
isInitialRoot: isRoot,
});
},
[types.SET_PREVIOUS_URL](state, previousUrl) {
Object.assign(state, {
previousUrl,
});
},
...fileMutations,
...treeMutations,
...branchMutations,
};
import * as types from '../mutation_types';
export default {
[types.SET_CURRENT_BRANCH](state, currentBranch) {
Object.assign(state, {
currentBranch,
});
},
};
import * as types from '../mutation_types';
import { findIndexOfFile } from '../utils';
export default {
[types.SET_FILE_ACTIVE](state, { file, active }) {
Object.assign(file, {
active,
});
},
[types.TOGGLE_FILE_OPEN](state, file) {
Object.assign(file, {
opened: !file.opened,
});
if (file.opened) {
state.openFiles.push(file);
} else {
state.openFiles.splice(findIndexOfFile(state.openFiles, file), 1);
}
},
[types.SET_FILE_DATA](state, { data, file }) {
Object.assign(file, {
blamePath: data.blame_path,
commitsPath: data.commits_path,
permalink: data.permalink,
rawPath: data.raw_path,
binary: data.binary,
html: data.html,
renderError: data.render_error,
});
},
[types.SET_FILE_RAW_DATA](state, { file, raw }) {
Object.assign(file, {
raw,
});
},
[types.UPDATE_FILE_CONTENT](state, { file, content }) {
const changed = content !== file.raw;
Object.assign(file, {
content,
changed,
});
},
[types.DISCARD_FILE_CHANGES](state, file) {
Object.assign(file, {
content: '',
changed: false,
});
},
[types.CREATE_TMP_FILE](state, { file, parent }) {
parent.tree.push(file);
},
};
import * as types from '../mutation_types';
import * as utils from '../utils';
export default {
[types.TOGGLE_TREE_OPEN](state, tree) {
Object.assign(tree, {
opened: !tree.opened,
});
},
[types.SET_DIRECTORY_DATA](state, { data, tree }) {
const level = tree.level !== undefined ? tree.level + 1 : 0;
const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl;
Object.assign(tree, {
tree: [
...data.trees.map(t => utils.decorateData({
...t,
type: 'tree',
parentTreeUrl,
level,
}, state.project.url)),
...data.submodules.map(m => utils.decorateData({
...m,
type: 'submodule',
parentTreeUrl,
level,
}, state.project.url)),
...data.blobs.map(b => utils.decorateData({
...b,
type: 'blob',
parentTreeUrl,
level,
}, state.project.url)),
],
});
},
[types.SET_PARENT_TREE_URL](state, url) {
Object.assign(state, {
parentTreeUrl: url,
});
},
[types.CREATE_TMP_TREE](state, { parent, tmpEntry }) {
parent.tree.push(tmpEntry);
},
};
import Helper from '../helpers/repo_helper';
import Service from '../services/repo_service';
const RepoStore = {
monacoLoading: false,
service: '',
canCommit: false,
onTopOfBranch: false,
editMode: false,
isRoot: null,
isInitialRoot: null,
prevURL: '',
projectId: '',
projectName: '',
projectUrl: '',
branchUrl: '',
blobRaw: '',
currentBlobView: 'repo-preview',
openedFiles: [],
submitCommitsLoading: false,
dialog: {
open: false,
title: '',
status: false,
},
showNewBranchDialog: false,
activeFile: Helper.getDefaultActiveFile(),
activeFileIndex: 0,
activeLine: -1,
activeFileLabel: 'Raw',
files: [],
isCommitable: false,
binary: false,
currentBranch: '',
startNewMR: false,
currentHash: '',
currentShortHash: '',
customBranchURL: '',
newMrTemplateUrl: '',
branchChanged: false,
commitMessage: '',
path: '',
loading: {
tree: false,
blob: false,
},
setBranchHash() {
return Service.getBranch()
.then((data) => {
if (RepoStore.currentHash !== '' && data.commit.id !== RepoStore.currentHash) {
RepoStore.branchChanged = true;
}
RepoStore.currentHash = data.commit.id;
RepoStore.currentShortHash = data.commit.short_id;
});
},
// mutations
checkIsCommitable() {
RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit;
},
toggleRawPreview() {
RepoStore.activeFile.raw = !RepoStore.activeFile.raw;
RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source';
},
setActiveFiles(file) {
if (RepoStore.isActiveFile(file)) return;
RepoStore.openedFiles = RepoStore.openedFiles
.map((openedFile, i) => RepoStore.setFileActivity(file, openedFile, i));
RepoStore.setActiveToRaw();
if (file.binary) {
RepoStore.blobRaw = file.base64;
} else if (file.newContent || file.plain) {
RepoStore.blobRaw = file.newContent || file.plain;
} else {
Service.getRaw(file)
.then((rawResponse) => {
RepoStore.blobRaw = rawResponse.data;
Helper.findOpenedFileFromActive().plain = rawResponse.data;
}).catch(Helper.loadingError);
}
if (!file.loading && !file.tempFile) {
Helper.updateHistoryEntry(file.url, file.pageTitle || file.name);
}
RepoStore.binary = file.binary;
RepoStore.setActiveLine(-1);
},
setFileActivity(file, openedFile, i) {
const activeFile = openedFile;
activeFile.active = file.id === activeFile.id;
if (activeFile.active) RepoStore.setActiveFile(activeFile, i);
return activeFile;
},
setActiveFile(activeFile, i) {
RepoStore.activeFile = Object.assign({}, Helper.getDefaultActiveFile(), activeFile);
RepoStore.activeFileIndex = i;
},
setActiveLine(activeLine) {
if (!isNaN(activeLine)) RepoStore.activeLine = activeLine;
},
setActiveToRaw() {
RepoStore.activeFile.raw = false;
// can't get vue to listen to raw for some reason so RepoStore for now.
RepoStore.activeFileLabel = 'Display source';
},
removeFromOpenedFiles(file) {
if (file.type === 'tree') return;
let foundIndex;
RepoStore.openedFiles = RepoStore.openedFiles.filter((openedFile, i) => {
if (openedFile.path === file.path) foundIndex = i;
return openedFile.path !== file.path;
});
// remove the file from the sidebar if it is a tempFile
if (file.tempFile) {
RepoStore.files = RepoStore.files.filter(f => !(f.tempFile && f.path === file.path));
}
// now activate the right tab based on what you closed.
if (RepoStore.openedFiles.length === 0) {
RepoStore.activeFile = {};
return;
}
if (RepoStore.openedFiles.length === 1 || foundIndex === 0) {
RepoStore.setActiveFiles(RepoStore.openedFiles[0]);
return;
}
if (foundIndex && foundIndex > 0) {
RepoStore.setActiveFiles(RepoStore.openedFiles[foundIndex - 1]);
}
},
addToOpenedFiles(file) {
const openFile = file;
const openedFilesAlreadyExists = RepoStore.openedFiles
.some(openedFile => openedFile.path === openFile.path);
if (openedFilesAlreadyExists) return;
openFile.changed = false;
openFile.active = true;
RepoStore.openedFiles.push(openFile);
},
setActiveFileContents(contents) {
if (!RepoStore.editMode) return;
const currentFile = RepoStore.openedFiles[RepoStore.activeFileIndex];
RepoStore.activeFile.newContent = contents;
RepoStore.activeFile.changed = RepoStore.activeFile.plain !== RepoStore.activeFile.newContent;
currentFile.changed = RepoStore.activeFile.changed;
currentFile.newContent = contents;
},
toggleBlobView() {
RepoStore.currentBlobView = RepoStore.isPreviewView() ? 'repo-editor' : 'repo-preview';
},
setViewToPreview() {
RepoStore.currentBlobView = 'repo-preview';
},
// getters
isActiveFile(file) {
return file && file.id === RepoStore.activeFile.id;
},
isPreviewView() {
return RepoStore.currentBlobView === 'repo-preview';
},
};
export default RepoStore;
export default () => ({
canCommit: false,
currentBranch: '',
currentBlobView: 'repo-preview',
currentRef: '',
discardPopupOpen: false,
editMode: false,
endpoints: {},
isRoot: false,
isInitialRoot: false,
loading: false,
onTopOfBranch: false,
openFiles: [],
path: '',
project: {
id: 0,
name: '',
url: '',
},
parentTreeUrl: '',
previousUrl: '',
tree: [],
});
export const dataStructure = () => ({
id: '',
type: '',
name: '',
url: '',
path: '',
level: 0,
tempFile: false,
icon: '',
tree: [],
loading: false,
opened: false,
active: false,
changed: false,
lastCommit: {},
tree_url: '',
blamePath: '',
commitsPath: '',
permalink: '',
rawPath: '',
binary: false,
html: '',
raw: '',
content: '',
parentTreeUrl: '',
renderError: false,
base64: false,
});
export const decorateData = (entity, projectUrl = '') => {
const {
id,
type,
url,
name,
icon,
last_commit,
tree_url,
path,
renderError,
content = '',
tempFile = false,
active = false,
opened = false,
changed = false,
parentTreeUrl = '',
level = 0,
base64 = false,
} = entity;
return {
...dataStructure(),
id,
type,
name,
url,
tree_url,
path,
level,
tempFile,
icon: `fa-${icon}`,
opened,
active,
parentTreeUrl,
changed,
renderError,
content,
base64,
// eslint-disable-next-line camelcase
lastCommit: last_commit ? {
url: `${projectUrl}/commit/${last_commit.id}`,
message: last_commit.message,
updatedAt: last_commit.committed_date,
} : {},
};
};
export const findEntry = (state, type, name) => state.tree.find(
f => f.type === type && f.name === name,
);
export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path);
export const setPageTitle = (title) => {
document.title = title;
};
export const pushState = (url) => {
history.pushState({ url }, '', url);
};
export const createTemp = ({ name, path, type, level, changed, content, base64 }) => {
const treePath = path ? `${path}/${name}` : name;
return decorateData({
id: new Date().getTime().toString(),
name,
type,
tempFile: true,
path: treePath,
icon: type === 'tree' ? 'folder' : 'file-text-o',
changed,
content,
parentTreeUrl: '',
level,
base64,
renderError: base64,
});
};
<script>
import { __, n__, sprintf } from '../../../locale';
import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue';
export default {
props: {
loading: {
type: Boolean,
required: false,
default: false,
},
participants: {
type: Array,
required: false,
default: () => [],
},
numberOfLessParticipants: {
type: Number,
required: false,
default: 7,
},
},
data() {
return {
isShowingMoreParticipants: false,
};
},
components: {
loadingIcon,
userAvatarImage,
},
computed: {
lessParticipants() {
return this.participants.slice(0, this.numberOfLessParticipants);
},
visibleParticipants() {
return this.isShowingMoreParticipants ? this.participants : this.lessParticipants;
},
hasMoreParticipants() {
return this.participants.length > this.numberOfLessParticipants;
},
toggleLabel() {
let label = '';
if (this.isShowingMoreParticipants) {
label = __('- show less');
} else {
label = sprintf(__('+ %{moreCount} more'), {
moreCount: this.participants.length - this.numberOfLessParticipants,
});
}
return label;
},
participantLabel() {
return sprintf(
n__('%{count} participant', '%{count} participants', this.participants.length),
{ count: this.loading ? '' : this.participantCount },
);
},
participantCount() {
return this.participants.length;
},
},
methods: {
toggleMoreParticipants() {
this.isShowingMoreParticipants = !this.isShowingMoreParticipants;
},
},
};
</script>
<template>
<div>
<div class="sidebar-collapsed-icon">
<i
class="fa fa-users"
aria-hidden="true">
</i>
<loading-icon
v-if="loading"
class="js-participants-collapsed-loading-icon" />
<span
v-else
class="js-participants-collapsed-count">
{{ participantCount }}
</span>
</div>
<div class="title hide-collapsed">
<loading-icon
v-if="loading"
:inline="true"
class="js-participants-expanded-loading-icon" />
{{ participantLabel }}
</div>
<div class="participants-list hide-collapsed">
<div
v-for="participant in visibleParticipants"
:key="participant.id"
class="participants-author js-participants-author">
<a
class="author_link"
:href="participant.web_url">
<user-avatar-image
:lazy="true"
:img-src="participant.avatar_url"
css-classes="avatar-inline"
:size="24"
:tooltip-text="participant.name"
tooltip-placement="bottom" />
</a>
</div>
</div>
<div
v-if="hasMoreParticipants"
class="participants-more hide-collapsed">
<button
type="button"
class="btn-transparent btn-blank js-toggle-participants-button"
@click="toggleMoreParticipants">
{{ toggleLabel }}
</button>
</div>
</div>
</template>
<script>
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
import participants from './participants.vue';
export default {
data() {
return {
mediator: new Mediator(),
store: new Store(),
};
},
components: {
participants,
},
};
</script>
<template>
<div class="block participants">
<participants
:loading="store.isFetching.participants"
:participants="store.participants"
:number-of-less-participants="7" />
</div>
</template>
<script>
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
import Flash from '../../../flash';
import subscriptions from './subscriptions.vue';
export default {
data() {
return {
mediator: new Mediator(),
store: new Store(),
};
},
components: {
subscriptions,
},
methods: {
onToggleSubscription() {
this.mediator.toggleSubscription()
.catch(() => {
Flash('Error occurred when toggling the notification subscription');
});
},
},
created() {
eventHub.$on('toggleSubscription', this.onToggleSubscription);
},
beforeDestroy() {
eventHub.$off('toggleSubscription', this.onToggleSubscription);
},
};
</script>
<template>
<div class="block subscriptions">
<subscriptions
:loading="store.isFetching.subscriptions"
:subscribed="store.subscribed" />
</div>
</template>
<script>
import { __ } from '../../../locale';
import eventHub from '../../event_hub';
import loadingButton from '../../../vue_shared/components/loading_button.vue';
export default {
props: {
loading: {
type: Boolean,
required: false,
default: false,
},
subscribed: {
type: Boolean,
required: false,
},
},
components: {
loadingButton,
},
computed: {
buttonLabel() {
let label;
if (this.subscribed === false) {
label = __('Subscribe');
} else if (this.subscribed === true) {
label = __('Unsubscribe');
}
return label;
},
},
methods: {
toggleSubscription() {
eventHub.$emit('toggleSubscription');
},
},
};
</script>
<template>
<div>
<div class="sidebar-collapsed-icon">
<i
class="fa fa-rss"
aria-hidden="true">
</i>
</div>
<span class="issuable-header-text hide-collapsed pull-left">
{{ __('Notifications') }}
</span>
<loading-button
ref="loadingButton"
class="btn btn-default pull-right hide-collapsed js-issuable-subscribe-button"
:loading="loading"
:label="buttonLabel"
@click="toggleSubscription"
/>
</div>
</template>
...@@ -7,6 +7,7 @@ export default class SidebarService { ...@@ -7,6 +7,7 @@ export default class SidebarService {
constructor(endpointMap) { constructor(endpointMap) {
if (!SidebarService.singleton) { if (!SidebarService.singleton) {
this.endpoint = endpointMap.endpoint; this.endpoint = endpointMap.endpoint;
this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint;
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint; this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint; this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
...@@ -36,6 +37,10 @@ export default class SidebarService { ...@@ -36,6 +37,10 @@ export default class SidebarService {
}); });
} }
toggleSubscription() {
return Vue.http.post(this.toggleSubscriptionEndpoint);
}
moveIssue(moveToProjectId) { moveIssue(moveToProjectId) {
return Vue.http.post(this.moveIssueEndpoint, { return Vue.http.post(this.moveIssueEndpoint, {
move_to_project_id: moveToProjectId, move_to_project_id: moveToProjectId,
......
...@@ -4,6 +4,8 @@ import SidebarAssignees from './components/assignees/sidebar_assignees'; ...@@ -4,6 +4,8 @@ import SidebarAssignees from './components/assignees/sidebar_assignees';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue'; import SidebarMoveIssue from './lib/sidebar_move_issue';
import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue';
import sidebarParticipants from './components/participants/sidebar_participants.vue';
import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
import Mediator from './sidebar_mediator'; import Mediator from './sidebar_mediator';
...@@ -49,6 +51,36 @@ function mountLockComponent(mediator) { ...@@ -49,6 +51,36 @@ function mountLockComponent(mediator) {
}).$mount(el); }).$mount(el);
} }
function mountParticipantsComponent() {
const el = document.querySelector('.js-sidebar-participants-entry-point');
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
sidebarParticipants,
},
render: createElement => createElement('sidebar-participants', {}),
});
}
function mountSubscriptionsComponent() {
const el = document.querySelector('.js-sidebar-subscriptions-entry-point');
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
sidebarSubscriptions,
},
render: createElement => createElement('sidebar-subscriptions', {}),
});
}
function domContentLoaded() { function domContentLoaded() {
const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
const mediator = new Mediator(sidebarOptions); const mediator = new Mediator(sidebarOptions);
...@@ -63,6 +95,8 @@ function domContentLoaded() { ...@@ -63,6 +95,8 @@ function domContentLoaded() {
mountConfidentialComponent(mediator); mountConfidentialComponent(mediator);
mountLockComponent(mediator); mountLockComponent(mediator);
mountParticipantsComponent();
mountSubscriptionsComponent();
new SidebarMoveIssue( new SidebarMoveIssue(
mediator, mediator,
......
...@@ -8,6 +8,7 @@ export default class SidebarMediator { ...@@ -8,6 +8,7 @@ export default class SidebarMediator {
this.store = new Store(options); this.store = new Store(options);
this.service = new Service({ this.service = new Service({
endpoint: options.endpoint, endpoint: options.endpoint,
toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint,
moveIssueEndpoint: options.moveIssueEndpoint, moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint, projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
}); });
...@@ -39,10 +40,25 @@ export default class SidebarMediator { ...@@ -39,10 +40,25 @@ export default class SidebarMediator {
.then((data) => { .then((data) => {
this.store.setAssigneeData(data); this.store.setAssigneeData(data);
this.store.setTimeTrackingData(data); this.store.setTimeTrackingData(data);
this.store.setParticipantsData(data);
this.store.setSubscriptionsData(data);
}) })
.catch(() => new Flash('Error occurred when fetching sidebar data')); .catch(() => new Flash('Error occurred when fetching sidebar data'));
} }
toggleSubscription() {
this.store.setFetchingState('subscriptions', true);
return this.service.toggleSubscription()
.then(() => {
this.store.setSubscribedState(!this.store.subscribed);
this.store.setFetchingState('subscriptions', false);
})
.catch((err) => {
this.store.setFetchingState('subscriptions', false);
throw err;
});
}
fetchAutocompleteProjects(searchTerm) { fetchAutocompleteProjects(searchTerm) {
return this.service.getProjectsAutocomplete(searchTerm) return this.service.getProjectsAutocomplete(searchTerm)
.then(response => response.json()) .then(response => response.json())
......
...@@ -12,10 +12,14 @@ export default class SidebarStore { ...@@ -12,10 +12,14 @@ export default class SidebarStore {
this.assignees = []; this.assignees = [];
this.isFetching = { this.isFetching = {
assignees: true, assignees: true,
participants: true,
subscriptions: true,
}; };
this.autocompleteProjects = []; this.autocompleteProjects = [];
this.moveToProjectId = 0; this.moveToProjectId = 0;
this.isLockDialogOpen = false; this.isLockDialogOpen = false;
this.participants = [];
this.subscribed = null;
SidebarStore.singleton = this; SidebarStore.singleton = this;
} }
...@@ -37,6 +41,20 @@ export default class SidebarStore { ...@@ -37,6 +41,20 @@ export default class SidebarStore {
this.humanTotalTimeSpent = data.human_total_time_spent; this.humanTotalTimeSpent = data.human_total_time_spent;
} }
setParticipantsData(data) {
this.isFetching.participants = false;
this.participants = data.participants || [];
}
setSubscriptionsData(data) {
this.isFetching.subscriptions = false;
this.subscribed = data.subscribed || false;
}
setFetchingState(key, value) {
this.isFetching[key] = value;
}
addAssignee(assignee) { addAssignee(assignee) {
if (!this.findAssignee(assignee)) { if (!this.findAssignee(assignee)) {
this.assignees.push(assignee); this.assignees.push(assignee);
...@@ -61,6 +79,10 @@ export default class SidebarStore { ...@@ -61,6 +79,10 @@ export default class SidebarStore {
this.autocompleteProjects = projects; this.autocompleteProjects = projects;
} }
setSubscribedState(subscribed) {
this.subscribed = subscribed;
}
setMoveToProjectId(moveToProjectId) { setMoveToProjectId(moveToProjectId) {
this.moveToProjectId = moveToProjectId; this.moveToProjectId = moveToProjectId;
} }
......
...@@ -11,7 +11,7 @@ export default class MRWidgetService { ...@@ -11,7 +11,7 @@ export default class MRWidgetService {
this.removeWIPResource = Vue.resource(endpoints.removeWIPPath); this.removeWIPResource = Vue.resource(endpoints.removeWIPPath);
this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath); this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath);
this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath); this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath);
this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`); this.pollResource = Vue.resource(`${endpoints.statusPath}?serializer=basic`);
this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath); this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath);
} }
......
...@@ -542,7 +542,9 @@ ...@@ -542,7 +542,9 @@
} }
.participants-list { .participants-list {
margin: -5px; display: flex;
flex-wrap: wrap;
margin: -7px;
} }
.user-list { .user-list {
...@@ -552,7 +554,7 @@ ...@@ -552,7 +554,7 @@
.participants-author { .participants-author {
display: inline-block; display: inline-block;
padding: 5px; padding: 7px;
&:nth-of-type(7n) { &:nth-of-type(7n) {
padding-right: 0; padding-right: 0;
......
.monaco-loader {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $black-transparent;
}
.modal.popup-dialog { .modal.popup-dialog {
display: block; display: block;
background-color: $black-transparent; background-color: $black-transparent;
...@@ -54,6 +45,7 @@ ...@@ -54,6 +45,7 @@
} }
.tree-content-holder { .tree-content-holder {
display: -webkit-flex;
display: flex; display: flex;
min-height: 300px; min-height: 300px;
} }
...@@ -63,7 +55,9 @@ ...@@ -63,7 +55,9 @@
} }
.panel-right { .panel-right {
display: -webkit-flex;
display: flex; display: flex;
-webkit-flex-direction: column;
flex-direction: column; flex-direction: column;
width: 80%; width: 80%;
height: 100%; height: 100%;
...@@ -81,10 +75,6 @@ ...@@ -81,10 +75,6 @@
text-decoration: underline; text-decoration: underline;
} }
} }
.cursor {
display: none !important;
}
} }
.blob-no-preview { .blob-no-preview {
...@@ -94,21 +84,12 @@ ...@@ -94,21 +84,12 @@
} }
} }
&.edit-mode { &.blob-editor-container {
.blob-viewer-container { overflow: hidden;
overflow: hidden;
}
.monaco-editor.vs {
.cursor {
background: $black;
border-color: $black;
display: block !important;
}
}
} }
.blob-viewer-container { .blob-viewer-container {
-webkit-flex: 1;
flex: 1; flex: 1;
overflow: auto; overflow: auto;
...@@ -138,6 +119,7 @@ ...@@ -138,6 +119,7 @@
} }
#tabs { #tabs {
position: relative;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
width: 100%; width: 100%;
...@@ -166,6 +148,10 @@ ...@@ -166,6 +148,10 @@
vertical-align: middle; vertical-align: middle;
text-decoration: none; text-decoration: none;
margin-right: 12px; margin-right: 12px;
&:focus {
outline: none;
}
} }
.close-btn { .close-btn {
...@@ -312,23 +298,3 @@ ...@@ -312,23 +298,3 @@
width: 100%; width: 100%;
} }
} }
@keyframes swipeRightAppear {
0% {
transform: scaleX(0.00);
}
100% {
transform: scaleX(1.00);
}
}
@keyframes swipeRightDissapear {
0% {
transform: scaleX(1.00);
}
100% {
transform: scaleX(0.00);
}
}
...@@ -76,7 +76,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -76,7 +76,7 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
render json: serializer.represent(@issue) render json: serializer.represent(@issue, serializer: params[:serializer])
end end
end end
end end
......
...@@ -85,7 +85,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -85,7 +85,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
format.json do format.json do
Gitlab::PollingInterval.set_header(response, interval: 10_000) Gitlab::PollingInterval.set_header(response, interval: 10_000)
render json: serializer.represent(@merge_request, basic: params[:basic]) render json: serializer.represent(@merge_request, serializer: params[:serializer])
end end
format.patch do format.patch do
......
...@@ -2,13 +2,13 @@ class Projects::MilestonesController < Projects::ApplicationController ...@@ -2,13 +2,13 @@ class Projects::MilestonesController < Projects::ApplicationController
include MilestoneActions include MilestoneActions
before_action :check_issuables_available! before_action :check_issuables_available!
before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels] before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote]
# Allow read any milestone # Allow read any milestone
before_action :authorize_read_milestone! before_action :authorize_read_milestone!
# Allow admin milestone # Allow admin milestone
before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels] before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels, :promote]
respond_to :html respond_to :html
...@@ -74,6 +74,14 @@ class Projects::MilestonesController < Projects::ApplicationController ...@@ -74,6 +74,14 @@ class Projects::MilestonesController < Projects::ApplicationController
end end
end end
def promote
promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone)
flash[:notice] = "Milestone has been promoted to group milestone."
redirect_to group_milestone_path(project.group, promoted_milestone.iid)
rescue Milestones::PromoteService::PromoteMilestoneError => error
redirect_to milestone, alert: error.message
end
def destroy def destroy
return access_denied! unless can?(current_user, :admin_milestone, @project) return access_denied! unless can?(current_user, :admin_milestone, @project)
......
...@@ -33,15 +33,17 @@ module IssuablesHelper ...@@ -33,15 +33,17 @@ module IssuablesHelper
end end
def serialize_issuable(issuable) def serialize_issuable(issuable)
case issuable serializer_klass = case issuable
when Issue when Issue
IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json IssueSerializer
when MergeRequest when MergeRequest
MergeRequestSerializer MergeRequestSerializer
.new(current_user: current_user, project: issuable.project) end
.represent(issuable)
.to_json serializer_klass
end .new(current_user: current_user, project: issuable.project)
.represent(issuable)
.to_json
end end
def template_dropdown_tag(issuable, &block) def template_dropdown_tag(issuable, &block)
...@@ -358,7 +360,8 @@ module IssuablesHelper ...@@ -358,7 +360,8 @@ module IssuablesHelper
def issuable_sidebar_options(issuable, can_edit_issuable) def issuable_sidebar_options(issuable, can_edit_issuable)
{ {
endpoint: "#{issuable_json_path(issuable)}?basic=true", endpoint: "#{issuable_json_path(issuable)}?serializer=sidebar",
toggleSubscriptionEndpoint: toggle_subscription_path(issuable),
moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable), moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable),
projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id), projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id),
editable: can_edit_issuable, editable: can_edit_issuable,
......
...@@ -13,6 +13,8 @@ module Subscribable ...@@ -13,6 +13,8 @@ module Subscribable
end end
def subscribed?(user, project = nil) def subscribed?(user, project = nil)
return false unless user
if subscription = subscriptions.find_by(user: user, project: project) if subscription = subscriptions.find_by(user: user, project: project)
subscription.subscribed subscription.subscribed
else else
......
...@@ -17,7 +17,9 @@ class MergeRequestDiffCommit < ActiveRecord::Base ...@@ -17,7 +17,9 @@ class MergeRequestDiffCommit < ActiveRecord::Base
commit_hash.merge( commit_hash.merge(
merge_request_diff_id: merge_request_diff_id, merge_request_diff_id: merge_request_diff_id,
relative_order: index, relative_order: index,
sha: sha_attribute.type_cast_for_database(sha) sha: sha_attribute.type_cast_for_database(sha),
authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]),
committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date])
) )
end end
......
class IssuableSidebarEntity < Grape::Entity
include RequestAwareEntity
expose :participants, using: ::API::Entities::UserBasic do |issuable|
issuable.participants(request.current_user)
end
expose :subscribed do |issuable|
issuable.subscribed?(request.current_user, issuable.project)
end
expose :time_estimate
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
end
class IssueSerializer < BaseSerializer class IssueSerializer < BaseSerializer
entity IssueEntity # This overrided method takes care of which entity should be used
# to serialize the `issue` based on `basic` key in `opts` param.
# Hence, `entity` doesn't need to be declared on the class scope.
def represent(merge_request, opts = {})
entity =
case opts[:serializer]
when 'sidebar'
IssueSidebarEntity
else
IssueEntity
end
super(merge_request, opts, entity)
end
end end
class IssueSidebarEntity < IssuableSidebarEntity
expose :assignees, using: API::Entities::UserBasic
end
class MergeRequestBasicEntity < Grape::Entity class MergeRequestBasicEntity < IssuableSidebarEntity
expose :assignee_id expose :assignee_id
expose :merge_status expose :merge_status
expose :merge_error expose :merge_error
expose :state expose :state
expose :source_branch_exists?, as: :source_branch_exists expose :source_branch_exists?, as: :source_branch_exists
expose :time_estimate
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
expose :rebase_in_progress?, as: :rebase_in_progress expose :rebase_in_progress?, as: :rebase_in_progress
end end
...@@ -3,7 +3,14 @@ class MergeRequestSerializer < BaseSerializer ...@@ -3,7 +3,14 @@ class MergeRequestSerializer < BaseSerializer
# to serialize the `merge_request` based on `basic` key in `opts` param. # to serialize the `merge_request` based on `basic` key in `opts` param.
# Hence, `entity` doesn't need to be declared on the class scope. # Hence, `entity` doesn't need to be declared on the class scope.
def represent(merge_request, opts = {}) def represent(merge_request, opts = {})
entity = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity entity =
case opts[:serializer]
when 'basic', 'sidebar'
MergeRequestBasicEntity
else
MergeRequestEntity
end
super(merge_request, opts, entity) super(merge_request, opts, entity)
end end
end end
module Milestones
class PromoteService < Milestones::BaseService
prepend EE::Milestones::PromoteService
PromoteMilestoneError = Class.new(StandardError)
def execute(milestone)
check_project_milestone!(milestone)
Milestone.transaction do
# Destroy all milestones with same title across projects
destroy_old_milestones(milestone)
group_milestone = clone_project_milestone(milestone)
move_children_to_group_milestone(group_milestone)
# Just to be safe
unless group_milestone.valid?
raise_error(group_milestone.errors.full_messages.to_sentence)
end
group_milestone
end
end
private
def milestone_ids_for_merge(group_milestone)
# Pluck need to be used here instead of select so the array of ids
# is persistent after old milestones gets deleted.
@milestone_ids_for_merge ||= begin
search_params = { title: group_milestone.title, project_ids: group_project_ids, state: 'all' }
milestones = MilestonesFinder.new(search_params).execute
milestones.pluck(:id)
end
end
def move_children_to_group_milestone(group_milestone)
milestone_ids_for_merge(group_milestone).in_groups_of(100) do |milestone_ids|
update_children(group_milestone, milestone_ids)
end
end
def check_project_milestone!(milestone)
raise_error('Only project milestones can be promoted.') unless milestone.project_milestone?
end
def clone_project_milestone(milestone)
params = milestone.slice(:title, :description, :start_date, :due_date, :state_event)
create_service = CreateService.new(group, current_user, params)
create_service.execute
end
def update_children(group_milestone, milestone_ids)
issues = Issue.where(project_id: group_project_ids, milestone_id: milestone_ids)
merge_requests = MergeRequest.where(source_project_id: group_project_ids, milestone_id: milestone_ids)
[issues, merge_requests].each do |issuable_collection|
issuable_collection.update_all(milestone_id: group_milestone.id)
end
end
def group
@group ||= parent.group || raise_error('Project does not belong to a group.')
end
def destroy_old_milestones(group_milestone)
Milestone.where(id: milestone_ids_for_merge(group_milestone)).destroy_all
end
def group_project_ids
@group_project_ids ||= group.projects.map(&:id)
end
def raise_error(message)
raise PromoteMilestoneError, "Promotion failed - #{message}"
end
end
end
...@@ -23,14 +23,18 @@ ...@@ -23,14 +23,18 @@
= milestone_date_range(@milestone) = milestone_date_range(@milestone)
.milestone-buttons .milestone-buttons
- if can?(current_user, :admin_milestone, @project) - if can?(current_user, :admin_milestone, @project)
= link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do
Edit
- if @project.group
= link_to promote_project_milestone_path(@milestone.project, @milestone), title: "Promote to Group Milestone", class: 'btn btn-grouped', data: { confirm: "Promoting this milestone will make it available for all projects inside the group. Existing project milestones with the same name will be merged. Are you sure?", toggle: "tooltip" }, method: :post do
Promote
- if @milestone.active? - if @milestone.active?
= link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped" = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
- else - else
= link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped" = link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
= link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do
Edit
= link_to project_milestone_path(@project, @milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-grouped btn-danger" do = link_to project_milestone_path(@project, @milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-grouped btn-danger" do
Delete Delete
...@@ -40,6 +44,7 @@ ...@@ -40,6 +44,7 @@
.detail-page-description.milestone-detail .detail-page-description.milestone-detail
%h2.title %h2.title
= markdown_field(@milestone, :title) = markdown_field(@milestone, :title)
%div %div
- if @milestone.description.present? - if @milestone.description.present?
.description .description
......
...@@ -2,14 +2,14 @@ ...@@ -2,14 +2,14 @@
.tree-ref-holder .tree-ref-holder
= render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true
- if show_new_repo? - if show_new_repo? && can_push_branch?(@project, @ref)
.js-new-dropdown .js-new-dropdown
- else - else
= render 'projects/tree/old_tree_header' = render 'projects/tree/old_tree_header'
.tree-controls .tree-controls
- if show_new_repo? - if show_new_repo?
= render 'shared/repo/editable_mode' .editable-mode
- else - else
= lock_file_link(html_options: { class: 'btn path-lock' }) = lock_file_link(html_options: { class: 'btn path-lock' })
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
......
- participants_row = 7
- participants_size = participants.size
- participants_extra = participants_size - participants_row
.block.participants
.sidebar-collapsed-icon
= icon('users')
%span
= participants.count
.title.hide-collapsed
= pluralize participants.count, "participant"
.hide-collapsed.participants-list
- participants.each do |participant|
.participants-author.js-participants-author
= link_to_member(@project, participant, name: false, size: 24, lazy_load: true)
- if participants_extra > 0
.hide-collapsed.participants-more
%button.btn-transparent.btn-blank.js-participants-more{ type: 'button', data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } }
+ #{participants_extra} more
...@@ -151,17 +151,10 @@ ...@@ -151,17 +151,10 @@
%script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe
#js-lock-entry-point #js-lock-entry-point
= render "shared/issuable/participants", participants: issuable.participants(current_user) .js-sidebar-participants-entry-point
- if current_user - if current_user
- subscribed = issuable.subscribed?(current_user, @project) .js-sidebar-subscriptions-entry-point
.block.light.subscription{ data: { url: toggle_subscription_path(issuable) } }
.sidebar-collapsed-icon
= icon('rss', 'aria-hidden': 'true')
%span.issuable-header-text.hide-collapsed.pull-left
Notifications
- subscribtion_status = subscribed ? 'subscribed' : 'unsubscribed'
%button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" }
%span= subscribed ? 'Unsubscribe' : 'Subscribe'
- project_ref = cross_project_reference(@project, issuable) - project_ref = cross_project_reference(@project, issuable)
.block.project-reference .block.project-reference
......
...@@ -49,6 +49,13 @@ ...@@ -49,6 +49,13 @@
= link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do = link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do
Edit Edit
\ \
- if @project.group
= link_to promote_project_milestone_path(milestone.project, milestone), title: "Promote to Group Milestone", class: 'btn btn-xs btn-grouped', data: { confirm: "Promoting this milestone will make it available for all projects inside the group. Existing project milestones with the same name will be merged. Are you sure?", toggle: "tooltip" }, method: :post do
Promote
= link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped" = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped"
= link_to project_milestone_path(milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do = link_to project_milestone_path(milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do
Delete Delete
#repo{ data: { root: @path.empty?.to_s, #repo{ data: { root: @path.empty?.to_s,
root_url: project_tree_path(project),
url: content_url, url: content_url,
current_branch: @ref,
ref: @commit.id,
project_name: project.name, project_name: project.name,
refs_url: refs_project_path(project, format: :json),
project_url: project_path(project), project_url: project_path(project),
project_id: project.id, project_id: project.id,
blob_url: namespace_project_blob_path(project.namespace, project, '{{branch}}'), new_merge_request_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '' }),
new_mr_template_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '{{source_branch}}' }),
can_commit: (!!can_push_branch?(project, @ref)).to_s, can_commit: (!!can_push_branch?(project, @ref)).to_s,
on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s, on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s,
current_path: @path } } current_path: @path } }
---
title: Update participants and subscriptions button in issuable sidebar to be async
merge_request: 14836
author:
type: changed
---
title: Allow promoting project milestones to group milestones
merge_request:
author:
type: added
...@@ -328,6 +328,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -328,6 +328,7 @@ constraints(ProjectUrlConstrainer.new) do
resources :milestones, constraints: { id: /\d+/ } do resources :milestones, constraints: { id: /\d+/ } do
member do member do
post :promote
put :sort_issues put :sort_issues
put :sort_merge_requests put :sort_merge_requests
get :merge_requests get :merge_requests
......
...@@ -59,7 +59,8 @@ In addition to that you will be able to filter issues or merge requests by group ...@@ -59,7 +59,8 @@ In addition to that you will be able to filter issues or merge requests by group
## Milestone promotion ## Milestone promotion
You will be able to promote a project milestone to a group milestone [in the future](https://gitlab.com/gitlab-org/gitlab-ce/issues/35833). Project milestones can be promoted to group milestones if its project belongs to a group. When a milestone is promoted all other milestones across the group projects with the same title will be merged into it, which means all milestone's children like issues, merge requests and boards will be moved into the new promoted milestone.
The promote button can be found in the milestone view or milestones list.
## Special milestone filters ## Special milestone filters
......
module EE
module Milestones
module PromoteService
def update_children(group_milestone, milestone_ids)
boards = ::Board.where(project_id: group_project_ids, milestone_id: milestone_ids)
boards.update_all(milestone_id: group_milestone.id)
super
end
end
end
end
...@@ -20,11 +20,13 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps ...@@ -20,11 +20,13 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end end
step 'I should see that I am subscribed' do step 'I should see that I am subscribed' do
expect(find('.issuable-subscribe-button span')).to have_content 'Unsubscribe' wait_for_requests
expect(find('.js-issuable-subscribe-button span')).to have_content 'Unsubscribe'
end end
step 'I should see that I am unsubscribed' do step 'I should see that I am unsubscribed' do
expect(find('.issuable-subscribe-button span')).to have_content 'Subscribe' wait_for_requests
expect(find('.js-issuable-subscribe-button span')).to have_content 'Subscribe'
end end
step 'I click link "Closed"' do step 'I click link "Closed"' do
......
...@@ -4,6 +4,10 @@ module Gitlab ...@@ -4,6 +4,10 @@ module Gitlab
# https://www.postgresql.org/docs/9.2/static/datatype-numeric.html # https://www.postgresql.org/docs/9.2/static/datatype-numeric.html
# http://dev.mysql.com/doc/refman/5.7/en/integer-types.html # http://dev.mysql.com/doc/refman/5.7/en/integer-types.html
MAX_INT_VALUE = 2147483647 MAX_INT_VALUE = 2147483647
# The max value between MySQL's TIMESTAMP and PostgreSQL's timestampz:
# https://www.postgresql.org/docs/9.1/static/datatype-datetime.html
# https://dev.mysql.com/doc/refman/5.7/en/datetime.html
MAX_TIMESTAMP_VALUE = Time.at((1 << 31) - 1).freeze
def self.config def self.config
ActiveRecord::Base.configurations[Rails.env] ActiveRecord::Base.configurations[Rails.env]
...@@ -124,6 +128,10 @@ module Gitlab ...@@ -124,6 +128,10 @@ module Gitlab
EOF EOF
end end
def self.sanitize_timestamp(timestamp)
MAX_TIMESTAMP_VALUE > timestamp ? timestamp : MAX_TIMESTAMP_VALUE.dup
end
# pool_size - The size of the DB pool. # pool_size - The size of the DB pool.
# host - An optional host name to use instead of the default one. # host - An optional host name to use instead of the default one.
def self.create_connection_pool(pool_size, host = nil) def self.create_connection_pool(pool_size, host = nil)
......
...@@ -83,15 +83,15 @@ describe Projects::MergeRequestsController do ...@@ -83,15 +83,15 @@ describe Projects::MergeRequestsController do
end end
describe 'as json' do describe 'as json' do
context 'with basic param' do context 'with basic serializer param' do
it 'renders basic MR entity as json' do it 'renders basic MR entity as json' do
go(basic: true, format: :json) go(serializer: 'basic', format: :json)
expect(response).to match_response_schema('entities/merge_request_basic') expect(response).to match_response_schema('entities/merge_request_basic')
end end
end end
context 'without basic param' do context 'without basic serializer param' do
it 'renders the merge request in the json format' do it 'renders the merge request in the json format' do
go(format: :json) go(format: :json)
......
...@@ -86,4 +86,32 @@ describe Projects::MilestonesController do ...@@ -86,4 +86,32 @@ describe Projects::MilestonesController do
expect(last_note).to eq('removed milestone') expect(last_note).to eq('removed milestone')
end end
end end
describe '#promote' do
context 'promotion succeeds' do
before do
group = create(:group)
group.add_developer(user)
milestone.project.update(namespace: group)
end
it 'shows group milestone' do
post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid
group_milestone = assigns(:milestone)
expect(response).to redirect_to(group_milestone_path(project.group, group_milestone.iid))
expect(flash[:notice]).to eq('Milestone has been promoted to group milestone.')
end
end
context 'promotion fails' do
it 'shows project milestone' do
post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid
expect(response).to redirect_to(project_milestone_path(project, milestone))
expect(flash[:alert]).to eq('Promotion failed - Project does not belong to a group.')
end
end
end
end end
require 'spec_helper'
describe Milestones::PromoteService do
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:user) { create(:user) }
let(:milestone_title) { 'project milestone' }
let(:milestone) { create(:milestone, project: project, title: milestone_title) }
let!(:board) { create(:board, project: project, milestone: milestone) }
let(:service) { described_class.new(project, user) }
describe '#execute' do
before do
group.add_master(user)
end
it 'updates board with new milestone' do
promoted_milestone = service.execute(milestone)
expect(board.reload.milestone).to eq(promoted_milestone)
expect(promoted_milestone.group_milestone?).to be_truthy
end
end
end
...@@ -556,7 +556,7 @@ describe 'Issue Boards', :js do ...@@ -556,7 +556,7 @@ describe 'Issue Boards', :js do
end end
it 'does not show create new list' do it 'does not show create new list' do
expect(page).not_to have_selector('.js-new-board-list') expect(page).not_to have_button('.js-new-board-list')
end end
it 'does not allow dragging' do it 'does not allow dragging' do
......
...@@ -13,7 +13,7 @@ describe 'User manages subscription', :js do ...@@ -13,7 +13,7 @@ describe 'User manages subscription', :js do
end end
it 'toggles subscription' do it 'toggles subscription' do
subscribe_button = find('.issuable-subscribe-button span') subscribe_button = find('.js-issuable-subscribe-button')
expect(subscribe_button).to have_content('Subscribe') expect(subscribe_button).to have_content('Subscribe')
......
...@@ -28,8 +28,6 @@ feature 'Multi-file editor new file', :js do ...@@ -28,8 +28,6 @@ feature 'Multi-file editor new file', :js do
click_button('Create file') click_button('Create file')
end end
find('.inputarea').send_keys('file content')
fill_in('commit-message', with: 'commit message') fill_in('commit-message', with: 'commit message')
click_button('Commit 1 file') click_button('Commit 1 file')
......
{
"type": "object",
"properties" : {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"author_id": { "type": "integer" },
"description": { "type": ["string", "null"] },
"lock_version": { "type": ["string", "null"] },
"milestone_id": { "type": ["string", "null"] },
"title": { "type": "string" },
"moved_to_id": { "type": ["integer", "null"] },
"project_id": { "type": "integer" },
"weight": { "type": ["integer", "null"] },
"web_url": { "type": "string" },
"state": { "type": "string" },
"create_note_path": { "type": "string" },
"preview_note_path": { "type": "string" },
"current_user": {
"type": "object",
"properties": {
"can_create_note": { "type": "boolean" },
"can_update": { "type": "boolean" }
}
},
"created_at": { "type": "date-time" },
"updated_at": { "type": "date-time" },
"branch_name": { "type": ["string", "null"] },
"due_date": { "type": "date" },
"confidential": { "type": "boolean" },
"discussion_locked": { "type": ["boolean", "null"] },
"updated_by_id": { "type": ["string", "null"] },
"deleted_at": { "type": ["string", "null"] },
"time_estimate": { "type": "integer" },
"total_time_spent": { "type": "integer" },
"human_time_estimate": { "type": ["integer", "null"] },
"human_total_time_spent": { "type": ["integer", "null"] },
"milestone": { "type": ["object", "null"] },
"labels": {
"type": "array",
"items": { "$ref": "label.json" }
},
"assignees": { "type": ["array", "null"] }
},
"additionalProperties": false
}
{
"type": "object",
"properties" : {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"subscribed": { "type": "boolean" },
"time_estimate": { "type": "integer" },
"total_time_spent": { "type": "integer" },
"human_time_estimate": { "type": ["integer", "null"] },
"human_total_time_spent": { "type": ["integer", "null"] },
"participants": {
"type": "array",
"items": { "$ref": "../public_api/v4/user/basic.json" }
},
"assignees": {
"type": "array",
"items": { "$ref": "../public_api/v4/user/basic.json" }
}
},
"additionalProperties": false
}
{
"type": "object",
"required": [
"id",
"color",
"description",
"title",
"priority"
],
"properties": {
"id": { "type": "integer" },
"color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{3}{1,2}$"
},
"description": { "type": ["string", "null"] },
"text_color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{3}{1,2}$"
},
"type": { "type": "string" },
"title": { "type": "string" },
"priority": { "type": ["integer", "null"] }
},
"additionalProperties": false
}
\ No newline at end of file
...@@ -10,7 +10,9 @@ ...@@ -10,7 +10,9 @@
"human_total_time_spent": { "type": ["string", "null"] }, "human_total_time_spent": { "type": ["string", "null"] },
"merge_error": { "type": ["string", "null"] }, "merge_error": { "type": ["string", "null"] },
"rebase_in_progress": { "type": "boolean" }, "rebase_in_progress": { "type": "boolean" },
"assignee_id": { "type": ["integer", "null"] } "assignee_id": { "type": ["integer", "null"] },
"subscribed": { "type": ["boolean", "null"] },
"participants": { "type": "array" }
}, },
"additionalProperties": false "additionalProperties": false
} }
...@@ -19,32 +19,7 @@ ...@@ -19,32 +19,7 @@
}, },
"labels": { "labels": {
"type": "array", "type": "array",
"items": { "items": { "$ref": "entities/label.json" }
"type": "object",
"required": [
"id",
"color",
"description",
"title",
"priority"
],
"properties": {
"id": { "type": "integer" },
"color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
},
"description": { "type": ["string", "null"] },
"text_color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
},
"type": { "type": "string" },
"title": { "type": "string" },
"priority": { "type": ["integer", "null"] }
},
"additionalProperties": false
}
}, },
"assignee": { "assignee": {
"id": { "type": "integer" }, "id": { "type": "integer" },
......
export const createComponentWithStore = (Component, store, propsData = {}) => new Component({
store,
propsData,
});
export default (Component, props = {}, el = null) => new Component({ export default (Component, props = {}, el = null) => new Component({
propsData: props, propsData: props,
}).$mount(el); }).$mount(el);
import $ from 'jquery';
import IssuableContext from '~/issuable_context';
describe('IssuableContext', () => {
describe('toggleHiddenParticipants', () => {
const event = jasmine.createSpyObj('event', ['preventDefault']);
beforeEach(() => {
spyOn($.fn, 'data').and.returnValue('data');
spyOn($.fn, 'text').and.returnValue('data');
});
afterEach(() => {
gl.lazyLoader = undefined;
});
it('calls loadCheck if lazyLoader is set', () => {
gl.lazyLoader = jasmine.createSpyObj('lazyLoader', ['loadCheck']);
IssuableContext.prototype.toggleHiddenParticipants(event);
expect(gl.lazyLoader.loadCheck).toHaveBeenCalled();
});
it('does not throw if lazyLoader is not defined', () => {
gl.lazyLoader = undefined;
const toggle = IssuableContext.prototype.toggleHiddenParticipants.bind(null, event);
expect(toggle).not.toThrow();
});
});
});
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import newBranchForm from '~/repo/components/new_branch_form.vue'; import newBranchForm from '~/repo/components/new_branch_form.vue';
import eventHub from '~/repo/event_hub'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import RepoStore from '~/repo/stores/repo_store'; import { resetStore } from '../helpers';
import createComponent from '../../helpers/vue_mount_component_helper';
describe('Multi-file editor new branch form', () => { describe('Multi-file editor new branch form', () => {
let vm; let vm;
...@@ -10,17 +10,17 @@ describe('Multi-file editor new branch form', () => { ...@@ -10,17 +10,17 @@ describe('Multi-file editor new branch form', () => {
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(newBranchForm); const Component = Vue.extend(newBranchForm);
RepoStore.currentBranch = 'master'; vm = createComponentWithStore(Component, store);
vm = createComponent(Component, { vm.$store.state.currentBranch = 'master';
currentBranch: RepoStore.currentBranch,
}); vm.$mount();
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
RepoStore.currentBranch = ''; resetStore(vm.$store);
}); });
describe('template', () => { describe('template', () => {
...@@ -48,6 +48,10 @@ describe('Multi-file editor new branch form', () => { ...@@ -48,6 +48,10 @@ describe('Multi-file editor new branch form', () => {
}); });
describe('submitNewBranch', () => { describe('submitNewBranch', () => {
beforeEach(() => {
spyOn(vm, 'createNewBranch').and.returnValue(Promise.resolve());
});
it('sets to loading', () => { it('sets to loading', () => {
vm.submitNewBranch(); vm.submitNewBranch();
...@@ -66,57 +70,45 @@ describe('Multi-file editor new branch form', () => { ...@@ -66,57 +70,45 @@ describe('Multi-file editor new branch form', () => {
}); });
}); });
it('emits an event with branchName', () => { it('calls createdNewBranch with branchName', () => {
spyOn(eventHub, '$emit');
vm.branchName = 'testing'; vm.branchName = 'testing';
vm.submitNewBranch(); vm.submitNewBranch();
expect(eventHub.$emit).toHaveBeenCalledWith('createNewBranch', 'testing'); expect(vm.createNewBranch).toHaveBeenCalledWith('testing');
}); });
}); });
describe('showErrorMessage', () => { describe('submitNewBranch with error', () => {
it('sets loading to false', () => { beforeEach(() => {
vm.loading = true; spyOn(vm, 'createNewBranch').and.returnValue(Promise.reject({
json: () => Promise.resolve({
vm.showErrorMessage(); message: 'error message',
}),
expect(vm.loading).toBeFalsy(); }));
});
it('creates flash element', () => {
vm.showErrorMessage('error message');
expect(vm.$el.querySelector('.flash-alert')).not.toBeNull();
expect(vm.$el.querySelector('.flash-alert').textContent.trim()).toBe('error message');
}); });
});
describe('createdNewBranch', () => { it('sets loading to false', (done) => {
it('set loading to false', () => {
vm.loading = true; vm.loading = true;
vm.createdNewBranch(); vm.submitNewBranch();
expect(vm.loading).toBeFalsy();
});
it('resets branch name', () => {
vm.branchName = 'testing';
vm.createdNewBranch(); setTimeout(() => {
expect(vm.loading).toBeFalsy();
expect(vm.branchName).toBe(''); done();
});
}); });
it('sets the dropdown toggle text', () => { it('creates flash element', (done) => {
vm.dropdownText = document.createElement('span'); vm.submitNewBranch();
vm.createdNewBranch('branch name'); setTimeout(() => {
expect(vm.$el.querySelector('.flash-alert')).not.toBeNull();
expect(vm.$el.querySelector('.flash-alert').textContent.trim()).toBe('error message');
expect(vm.dropdownText.textContent).toBe('branch name'); done();
});
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import newDropdown from '~/repo/components/new_dropdown/index.vue'; import newDropdown from '~/repo/components/new_dropdown/index.vue';
import RepoStore from '~/repo/stores/repo_store'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import RepoHelper from '~/repo/helpers/repo_helper'; import { resetStore } from '../../helpers';
import eventHub from '~/repo/event_hub';
import createComponent from '../../../helpers/vue_mount_component_helper';
describe('new dropdown component', () => { describe('new dropdown component', () => {
let vm; let vm;
...@@ -11,15 +10,17 @@ describe('new dropdown component', () => { ...@@ -11,15 +10,17 @@ describe('new dropdown component', () => {
beforeEach(() => { beforeEach(() => {
const component = Vue.extend(newDropdown); const component = Vue.extend(newDropdown);
vm = createComponent(component); vm = createComponentWithStore(component, store);
vm.$store.state.path = '';
vm.$mount();
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
RepoStore.files = []; resetStore(vm.$store);
RepoStore.openedFiles = [];
RepoStore.setViewToPreview();
}); });
it('renders new file and new directory links', () => { it('renders new file and new directory links', () => {
...@@ -67,6 +68,7 @@ describe('new dropdown component', () => { ...@@ -67,6 +68,7 @@ describe('new dropdown component', () => {
.catch(done.fail); .catch(done.fail);
}); });
}); });
<<<<<<< HEAD
describe('createEntryInStore', () => { describe('createEntryInStore', () => {
['tree', 'blob'].forEach((type) => { ['tree', 'blob'].forEach((type) => {
...@@ -222,4 +224,6 @@ describe('new dropdown component', () => { ...@@ -222,4 +224,6 @@ describe('new dropdown component', () => {
}); });
}); });
}); });
=======
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
}); });
import Vue from 'vue'; import Vue from 'vue';
import RepoStore from '~/repo/stores/repo_store'; import store from '~/repo/stores';
import modal from '~/repo/components/new_dropdown/modal.vue'; import modal from '~/repo/components/new_dropdown/modal.vue';
import eventHub from '~/repo/event_hub'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import createComponent from '../../../helpers/vue_mount_component_helper'; import { file, resetStore } from '../../helpers';
describe('new file modal component', () => { describe('new file modal component', () => {
const Component = Vue.extend(modal); const Component = Vue.extend(modal);
...@@ -11,18 +11,18 @@ describe('new file modal component', () => { ...@@ -11,18 +11,18 @@ describe('new file modal component', () => {
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
RepoStore.files = []; resetStore(vm.$store);
RepoStore.openedFiles = [];
RepoStore.setViewToPreview();
}); });
['tree', 'blob'].forEach((type) => { ['tree', 'blob'].forEach((type) => {
describe(type, () => { describe(type, () => {
beforeEach(() => { beforeEach(() => {
vm = createComponent(Component, { vm = createComponentWithStore(Component, store, {
type, type,
currentPath: RepoStore.path, path: '',
}); }).$mount();
vm.entryName = 'testing';
}); });
it(`sets modal title as ${type}`, () => { it(`sets modal title as ${type}`, () => {
...@@ -42,21 +42,160 @@ describe('new file modal component', () => { ...@@ -42,21 +42,160 @@ describe('new file modal component', () => {
expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`); expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`);
}); });
describe('createEntryInStore', () => {
it('calls createTempEntry', () => {
spyOn(vm, 'createTempEntry');
vm.createEntryInStore();
expect(vm.createTempEntry).toHaveBeenCalledWith({
name: 'testing',
type,
});
});
it('sets editMode to true', (done) => {
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.editMode).toBeTruthy();
done();
});
});
it('toggles blob view', (done) => {
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.currentBlobView).toBe('repo-editor');
done();
});
});
it('opens newly created file', (done) => {
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.openFiles.length).toBe(1);
expect(vm.$store.state.openFiles[0].name).toBe(type === 'blob' ? 'testing' : '.gitkeep');
done();
});
});
it(`creates ${type} in the current stores path`, (done) => {
vm.$store.state.path = 'app';
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.tree[0].path).toBe('app/testing');
expect(vm.$store.state.tree[0].name).toBe('testing');
if (type === 'tree') {
expect(vm.$store.state.tree[0].tree.length).toBe(1);
}
done();
});
});
if (type === 'blob') {
it('creates new file', (done) => {
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('testing');
expect(vm.$store.state.tree[0].type).toBe('blob');
expect(vm.$store.state.tree[0].tempFile).toBeTruthy();
done();
});
});
it('does not create temp file when file already exists', (done) => {
vm.$store.state.tree.push(file('testing', '1', type));
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('testing');
expect(vm.$store.state.tree[0].type).toBe('blob');
expect(vm.$store.state.tree[0].tempFile).toBeFalsy();
done();
});
});
} else {
it('creates new tree', () => {
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('testing');
expect(vm.$store.state.tree[0].type).toBe('tree');
expect(vm.$store.state.tree[0].tempFile).toBeTruthy();
expect(vm.$store.state.tree[0].tree.length).toBe(1);
expect(vm.$store.state.tree[0].tree[0].name).toBe('.gitkeep');
});
it('creates multiple trees when entryName has slashes', () => {
vm.entryName = 'app/test';
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('app');
expect(vm.$store.state.tree[0].tree[0].name).toBe('test');
expect(vm.$store.state.tree[0].tree[0].tree[0].name).toBe('.gitkeep');
});
it('creates tree in existing tree', () => {
vm.$store.state.tree.push(file('app', '1', 'tree'));
vm.entryName = 'app/test';
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('app');
expect(vm.$store.state.tree[0].tempFile).toBeFalsy();
expect(vm.$store.state.tree[0].tree[0].tempFile).toBeTruthy();
expect(vm.$store.state.tree[0].tree[0].name).toBe('test');
expect(vm.$store.state.tree[0].tree[0].tree[0].name).toBe('.gitkeep');
});
it('does not create new tree when already exists', () => {
vm.$store.state.tree.push(file('app', '1', 'tree'));
vm.entryName = 'app';
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('app');
expect(vm.$store.state.tree[0].tempFile).toBeFalsy();
expect(vm.$store.state.tree[0].tree.length).toBe(0);
});
}
});
}); });
}); });
it('focuses field on mount', () => { it('focuses field on mount', () => {
document.body.innerHTML += '<div class="js-test"></div>'; document.body.innerHTML += '<div class="js-test"></div>';
vm = createComponent(Component, { vm = createComponentWithStore(Component, store, {
type: 'tree', type: 'tree',
currentPath: RepoStore.path, path: '',
}, '.js-test'); }).$mount('.js-test');
expect(document.activeElement).toBe(vm.$refs.fieldName); expect(document.activeElement).toBe(vm.$refs.fieldName);
vm.$el.remove(); vm.$el.remove();
}); });
<<<<<<< HEAD
describe('createEntryInStore', () => { describe('createEntryInStore', () => {
it('emits createNewEntry event', () => { it('emits createNewEntry event', () => {
...@@ -77,4 +216,6 @@ describe('new file modal component', () => { ...@@ -77,4 +216,6 @@ describe('new file modal component', () => {
}); });
}); });
}); });
=======
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import service from '~/repo/services';
import repoCommitSection from '~/repo/components/repo_commit_section.vue'; import repoCommitSection from '~/repo/components/repo_commit_section.vue';
import RepoStore from '~/repo/stores/repo_store';
import RepoService from '~/repo/services/repo_service';
import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper'; import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper';
import { file, resetStore } from '../helpers';
describe('RepoCommitSection', () => { describe('RepoCommitSection', () => {
const branch = 'master'; let vm;
const projectUrl = 'projectUrl';
let changedFiles;
let openedFiles;
RepoStore.projectUrl = projectUrl; function createComponent() {
function createComponent(el) {
const RepoCommitSection = Vue.extend(repoCommitSection); const RepoCommitSection = Vue.extend(repoCommitSection);
return new RepoCommitSection().$mount(el); const comp = new RepoCommitSection({
store,
}).$mount();
comp.$store.state.currentBranch = 'master';
comp.$store.state.openFiles = [file(), file()];
comp.$store.state.openFiles.forEach(f => Object.assign(f, {
changed: true,
content: 'testing',
}));
return comp.$mount();
} }
beforeEach(() => { beforeEach(() => {
// Create a copy for each test because these can get modified directly vm = createComponent();
changedFiles = [{
id: 0,
changed: true,
url: `/namespace/${projectUrl}/blob/${branch}/dir/file0.ext`,
path: 'dir/file0.ext',
newContent: 'a',
}, {
id: 1,
changed: true,
url: `/namespace/${projectUrl}/blob/${branch}/dir/file1.ext`,
path: 'dir/file1.ext',
newContent: 'b',
}];
openedFiles = changedFiles.concat([{
id: 2,
url: `/namespace/${projectUrl}/blob/${branch}/dir/file2.ext`,
path: 'dir/file2.ext',
changed: false,
}]);
}); });
it('renders a commit section', () => { afterEach(() => {
RepoStore.isCommitable = true; vm.$destroy();
RepoStore.currentBranch = branch;
RepoStore.targetBranch = branch; resetStore(vm.$store);
RepoStore.openedFiles = openedFiles; });
const vm = createComponent(); it('renders a commit section', () => {
const changedFileElements = [...vm.$el.querySelectorAll('.changed-files > li')]; const changedFileElements = [...vm.$el.querySelectorAll('.changed-files > li')];
const commitMessage = vm.$el.querySelector('#commit-message'); const submitCommit = vm.$el.querySelector('.btn');
const submitCommit = vm.$refs.submitCommit;
const targetBranch = vm.$el.querySelector('.target-branch'); const targetBranch = vm.$el.querySelector('.target-branch');
expect(vm.$el.querySelector(':scope > form')).toBeTruthy(); expect(vm.$el.querySelector(':scope > form')).toBeTruthy();
...@@ -58,160 +45,70 @@ describe('RepoCommitSection', () => { ...@@ -58,160 +45,70 @@ describe('RepoCommitSection', () => {
expect(changedFileElements.length).toEqual(2); expect(changedFileElements.length).toEqual(2);
changedFileElements.forEach((changedFile, i) => { changedFileElements.forEach((changedFile, i) => {
expect(changedFile.textContent.trim()).toEqual(changedFiles[i].path); expect(changedFile.textContent.trim()).toEqual(vm.$store.getters.changedFiles[i].path);
}); });
expect(commitMessage.tagName).toEqual('TEXTAREA');
expect(commitMessage.name).toEqual('commit-message');
expect(submitCommit.type).toEqual('submit');
expect(submitCommit.disabled).toBeTruthy(); expect(submitCommit.disabled).toBeTruthy();
expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeFalsy(); expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeFalsy();
expect(vm.$el.querySelector('.commit-summary').textContent.trim()).toEqual('Commit 2 files'); expect(vm.$el.querySelector('.commit-summary').textContent.trim()).toEqual('Commit 2 files');
expect(targetBranch.querySelector(':scope > label').textContent.trim()).toEqual('Target branch'); expect(targetBranch.querySelector(':scope > label').textContent.trim()).toEqual('Target branch');
expect(targetBranch.querySelector('.help-block').textContent.trim()).toEqual(branch); expect(targetBranch.querySelector('.help-block').textContent.trim()).toEqual('master');
});
it('does not render if not isCommitable', () => {
RepoStore.isCommitable = false;
RepoStore.openedFiles = [{
id: 0,
changed: true,
}];
const vm = createComponent();
expect(vm.$el.innerHTML).toBeFalsy();
});
it('does not render if no changedFiles', () => {
RepoStore.isCommitable = true;
RepoStore.openedFiles = [];
const vm = createComponent();
expect(vm.$el.innerHTML).toBeFalsy();
}); });
describe('when submitting', () => { describe('when submitting', () => {
let el; let changedFiles;
let vm;
const projectId = 'projectId';
const commitMessage = 'commitMessage';
beforeEach((done) => {
RepoStore.isCommitable = true;
RepoStore.currentBranch = branch;
RepoStore.targetBranch = branch;
RepoStore.openedFiles = openedFiles;
RepoStore.projectId = projectId;
// We need to append to body to get form `submit` events working
// Otherwise we run into, "Form submission canceled because the form is not connected"
// See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm
el = document.createElement('div');
document.body.appendChild(el);
vm = createComponent(el);
vm.commitMessage = commitMessage;
spyOn(vm, 'tryCommit').and.callThrough();
spyOn(vm, 'redirectToNewMr').and.stub();
spyOn(vm, 'redirectToBranch').and.stub();
spyOn(RepoService, 'commitFiles').and.returnValue(Promise.resolve());
spyOn(RepoService, 'getBranch').and.returnValue(Promise.resolve({
commit: {
id: 1,
short_id: 1,
},
}));
// Wait for the vm data to be in place
Vue.nextTick(() => {
done();
});
});
afterEach(() => { beforeEach(() => {
vm.$destroy(); vm.commitMessage = 'testing';
el.remove(); changedFiles = JSON.parse(JSON.stringify(vm.$store.getters.changedFiles));
RepoStore.openedFiles = [];
});
it('shows commit message', () => { spyOn(service, 'commit').and.returnValue(Promise.resolve({
const commitMessageEl = vm.$el.querySelector('#commit-message'); short_id: '1',
expect(commitMessageEl.value).toBe(commitMessage); stats: {},
}));
}); });
it('allows you to submit', () => { it('allows you to submit', () => {
const submitCommit = vm.$refs.submitCommit; expect(vm.$el.querySelector('.btn').disabled).toBeTruthy();
expect(submitCommit.disabled).toBeFalsy();
}); });
it('shows commit submit and summary if commitMessage and spinner if submitCommitsLoading', (done) => { it('submits commit', (done) => {
const submitCommit = vm.$refs.submitCommit; vm.makeCommit();
submitCommit.click();
// Wait for the branch check to finish // Wait for the branch check to finish
getSetTimeoutPromise() getSetTimeoutPromise()
.then(() => Vue.nextTick()) .then(() => Vue.nextTick())
.then(() => { .then(() => {
expect(vm.tryCommit).toHaveBeenCalled(); const args = service.commit.calls.allArgs()[0];
expect(submitCommit.querySelector('.js-commit-loading-icon')).toBeTruthy(); const { commit_message, actions, branch: payloadBranch } = args[1];
expect(vm.redirectToBranch).toHaveBeenCalled();
const args = RepoService.commitFiles.calls.allArgs()[0];
const { commit_message, actions, branch: payloadBranch } = args[0];
expect(commit_message).toBe(commitMessage); expect(commit_message).toBe('testing');
expect(actions.length).toEqual(2); expect(actions.length).toEqual(2);
expect(payloadBranch).toEqual(branch); expect(payloadBranch).toEqual('master');
expect(actions[0].action).toEqual('update'); expect(actions[0].action).toEqual('update');
expect(actions[1].action).toEqual('update'); expect(actions[1].action).toEqual('update');
expect(actions[0].content).toEqual(openedFiles[0].newContent); expect(actions[0].content).toEqual(changedFiles[0].content);
expect(actions[1].content).toEqual(openedFiles[1].newContent); expect(actions[1].content).toEqual(changedFiles[1].content);
expect(actions[0].file_path).toEqual(openedFiles[0].path); expect(actions[0].file_path).toEqual(changedFiles[0].path);
expect(actions[1].file_path).toEqual(openedFiles[1].path); expect(actions[1].file_path).toEqual(changedFiles[1].path);
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
}); });
it('redirects to MR creation page if start new MR checkbox checked', (done) => { it('redirects to MR creation page if start new MR checkbox checked', (done) => {
spyOn(gl.utils, 'visitUrl');
vm.startNewMR = true; vm.startNewMR = true;
Vue.nextTick() vm.makeCommit();
.then(() => {
const submitCommit = vm.$refs.submitCommit; getSetTimeoutPromise()
submitCommit.click(); .then(() => Vue.nextTick())
})
// Wait for the branch check to finish
.then(() => getSetTimeoutPromise())
.then(() => { .then(() => {
expect(vm.redirectToNewMr).toHaveBeenCalled(); expect(gl.utils.visitUrl).toHaveBeenCalled();
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
}); });
}); });
describe('methods', () => {
describe('resetCommitState', () => {
it('should reset store vars and scroll to top', () => {
const vm = {
submitCommitsLoading: true,
changedFiles: new Array(10),
openedFiles: new Array(3),
commitMessage: 'commitMessage',
editMode: true,
};
repoCommitSection.methods.resetCommitState.call(vm);
expect(vm.submitCommitsLoading).toEqual(false);
expect(vm.changedFiles).toEqual([]);
expect(vm.commitMessage).toEqual('');
expect(vm.editMode).toEqual(false);
});
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoEditButton from '~/repo/components/repo_edit_button.vue'; import repoEditButton from '~/repo/components/repo_edit_button.vue';
import RepoStore from '~/repo/stores/repo_store'; import { file, resetStore } from '../helpers';
describe('RepoEditButton', () => { describe('RepoEditButton', () => {
function createComponent() { let vm;
beforeEach(() => {
const f = file();
const RepoEditButton = Vue.extend(repoEditButton); const RepoEditButton = Vue.extend(repoEditButton);
return new RepoEditButton().$mount(); vm = new RepoEditButton({
} store,
});
f.active = true;
vm.$store.dispatch('setInitialData', {
canCommit: true,
onTopOfBranch: true,
});
vm.$store.state.openFiles.push(f);
});
afterEach(() => { afterEach(() => {
RepoStore.openedFiles = []; vm.$destroy();
resetStore(vm.$store);
}); });
it('renders an edit button that toggles the view state', (done) => { it('renders an edit button', () => {
RepoStore.isCommitable = true; vm.$mount();
RepoStore.changedFiles = [];
RepoStore.binary = false; expect(vm.$el.querySelector('.btn')).not.toBeNull();
RepoStore.openedFiles = [{}, {}]; expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Edit');
});
const vm = createComponent(); it('renders edit button with cancel text', () => {
vm.$store.state.editMode = true;
expect(vm.$el.tagName).toEqual('BUTTON'); vm.$mount();
expect(vm.$el.textContent).toMatch('Edit');
spyOn(vm, 'editCancelClicked').and.callThrough(); expect(vm.$el.querySelector('.btn')).not.toBeNull();
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
});
vm.$el.click(); it('toggles edit mode on click', (done) => {
vm.$mount();
vm.$el.querySelector('.btn').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
Vue.nextTick(() => {
expect(vm.editCancelClicked).toHaveBeenCalled();
expect(vm.$el.textContent).toMatch('Cancel edit');
done(); done();
}); });
}); });
it('does not render if not isCommitable', () => { describe('discardPopupOpen', () => {
RepoStore.isCommitable = false; beforeEach(() => {
vm.$store.state.discardPopupOpen = true;
vm.$store.state.editMode = true;
vm.$store.state.openFiles[0].changed = true;
vm.$mount();
});
it('renders popup', () => {
expect(vm.$el.querySelector('.modal')).not.toBeNull();
});
it('removes all changed files', (done) => {
vm.$el.querySelector('.btn-warning').click();
const vm = createComponent(); vm.$nextTick(() => {
expect(vm.$store.getters.changedFiles.length).toBe(0);
expect(vm.$el.querySelector('.modal')).toBeNull();
expect(vm.$el.innerHTML).toBeUndefined(); done();
});
});
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import RepoStore from '~/repo/stores/repo_store'; import store from '~/repo/stores';
import repoEditor from '~/repo/components/repo_editor.vue'; import repoEditor from '~/repo/components/repo_editor.vue';
import { file, resetStore } from '../helpers';
describe('RepoEditor', () => { describe('RepoEditor', () => {
let vm;
beforeEach(() => { beforeEach(() => {
const f = file();
const RepoEditor = Vue.extend(repoEditor); const RepoEditor = Vue.extend(repoEditor);
this.vm = new RepoEditor().$mount(); vm = new RepoEditor({
store,
});
f.active = true;
f.tempFile = true;
vm.$store.state.openFiles.push(f);
vm.monaco = true;
vm.$mount();
}); });
afterEach(() => { afterEach(() => {
RepoStore.openedFiles = []; vm.$destroy();
resetStore(vm.$store);
}); });
it('renders an ide container', (done) => { it('renders an ide container', (done) => {
this.vm.openedFiles = ['idiidid'];
this.vm.binary = false;
Vue.nextTick(() => { Vue.nextTick(() => {
expect(this.vm.shouldHideEditor).toBe(false); expect(vm.shouldHideEditor).toBeFalsy();
expect(this.vm.$el.id).toEqual('ide');
expect(this.vm.$el.tagName).toBe('DIV');
done(); done();
}); });
}); });
describe('when there are no open files', () => {
it('does not render the ide', (done) => {
this.vm.openedFiles = [];
Vue.nextTick(() => {
expect(this.vm.shouldHideEditor).toBe(true);
expect(this.vm.$el.tagName).not.toBeDefined();
done();
});
});
});
describe('when open file is binary and not raw', () => { describe('when open file is binary and not raw', () => {
it('does not render the IDE', (done) => { it('does not render the IDE', (done) => {
this.vm.binary = true; vm.$store.getters.activeFile.binary = true;
this.vm.activeFile = {
raw: false,
};
Vue.nextTick(() => { Vue.nextTick(() => {
expect(this.vm.shouldHideEditor).toBe(true); expect(vm.shouldHideEditor).toBeTruthy();
expect(this.vm.$el.tagName).not.toBeDefined();
done(); done();
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoFileButtons from '~/repo/components/repo_file_buttons.vue'; import repoFileButtons from '~/repo/components/repo_file_buttons.vue';
import RepoStore from '~/repo/stores/repo_store'; import { file, resetStore } from '../helpers';
describe('RepoFileButtons', () => { describe('RepoFileButtons', () => {
const activeFile = { const activeFile = file();
extension: 'md', let vm;
url: 'url',
raw_path: 'raw_path',
blame_path: 'blame_path',
commits_path: 'commits_path',
permalink: 'permalink',
};
function createComponent() { function createComponent() {
const RepoFileButtons = Vue.extend(repoFileButtons); const RepoFileButtons = Vue.extend(repoFileButtons);
return new RepoFileButtons().$mount(); activeFile.rawPath = 'test';
activeFile.blamePath = 'test';
activeFile.commitsPath = 'test';
activeFile.active = true;
store.state.openFiles.push(activeFile);
return new RepoFileButtons({
store,
}).$mount();
} }
afterEach(() => { afterEach(() => {
RepoStore.openedFiles = []; vm.$destroy();
});
it('renders Raw, Blame, History, Permalink and Preview toggle', () => {
const activeFileLabel = 'activeFileLabel';
RepoStore.openedFiles = new Array(1);
RepoStore.activeFile = activeFile;
RepoStore.activeFileLabel = activeFileLabel;
RepoStore.editMode = true;
RepoStore.binary = false;
const vm = createComponent(); resetStore(vm.$store);
const raw = vm.$el.querySelector('.raw');
const blame = vm.$el.querySelector('.blame');
const history = vm.$el.querySelector('.history');
expect(raw.href).toMatch(`/${activeFile.raw_path}`);
expect(raw.textContent.trim()).toEqual('Raw');
expect(blame.href).toMatch(`/${activeFile.blame_path}`);
expect(blame.textContent.trim()).toEqual('Blame');
expect(history.href).toMatch(`/${activeFile.commits_path}`);
expect(history.textContent.trim()).toEqual('History');
expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink');
expect(vm.$el.querySelector('.preview').textContent.trim()).toEqual(activeFileLabel);
}); });
it('triggers rawPreviewToggle on preview click', () => { it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => {
RepoStore.openedFiles = new Array(1); vm = createComponent();
RepoStore.activeFile = activeFile;
RepoStore.editMode = true;
const vm = createComponent();
const preview = vm.$el.querySelector('.preview');
spyOn(vm, 'rawPreviewToggle');
preview.click();
expect(vm.rawPreviewToggle).toHaveBeenCalled();
});
it('does not render preview toggle if not canPreview', () => { vm.$nextTick(() => {
activeFile.extension = 'js'; const raw = vm.$el.querySelector('.raw');
RepoStore.openedFiles = new Array(1); const blame = vm.$el.querySelector('.blame');
RepoStore.activeFile = activeFile; const history = vm.$el.querySelector('.history');
const vm = createComponent(); expect(raw.href).toMatch(`/${activeFile.rawPath}`);
expect(raw.textContent.trim()).toEqual('Raw');
expect(blame.href).toMatch(`/${activeFile.blamePath}`);
expect(blame.textContent.trim()).toEqual('Blame');
expect(history.href).toMatch(`/${activeFile.commitsPath}`);
expect(history.textContent.trim()).toEqual('History');
expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink');
expect(vm.$el.querySelector('.preview')).toBeFalsy(); done();
});
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoFile from '~/repo/components/repo_file.vue'; import repoFile from '~/repo/components/repo_file.vue';
import RepoStore from '~/repo/stores/repo_store'; import { file, resetStore } from '../helpers';
import eventHub from '~/repo/event_hub';
import { file } from '../mock_data';
describe('RepoFile', () => { describe('RepoFile', () => {
const updated = 'updated'; const updated = 'updated';
const otherFile = { let vm;
id: 'test',
html: '<p class="file-content">html</p>',
pageTitle: 'otherpageTitle',
};
function createComponent(propsData) { function createComponent(propsData) {
const RepoFile = Vue.extend(repoFile); const RepoFile = Vue.extend(repoFile);
return new RepoFile({ return new RepoFile({
store,
propsData, propsData,
}).$mount(); }).$mount();
} }
beforeEach(() => { afterEach(() => {
RepoStore.openedFiles = []; resetStore(vm.$store);
}); });
it('renders link, icon, name and last commit details', () => { it('renders link, icon, name and last commit details', () => {
const RepoFile = Vue.extend(repoFile); const RepoFile = Vue.extend(repoFile);
const vm = new RepoFile({ vm = new RepoFile({
store,
propsData: { propsData: {
file: file(), file: file(),
}, },
...@@ -47,23 +44,17 @@ describe('RepoFile', () => { ...@@ -47,23 +44,17 @@ describe('RepoFile', () => {
}); });
it('does render if hasFiles is true and is loading tree', () => { it('does render if hasFiles is true and is loading tree', () => {
const vm = createComponent({ vm = createComponent({
file: file(), file: file(),
}); });
expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy(); expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy();
}); });
it('sets the document title correctly', () => {
RepoStore.setActiveFiles(otherFile);
expect(document.title.trim()).toEqual(otherFile.pageTitle);
});
it('renders a spinner if the file is loading', () => { it('renders a spinner if the file is loading', () => {
const f = file(); const f = file();
f.loading = true; f.loading = true;
const vm = createComponent({ vm = createComponent({
file: f, file: f,
}); });
...@@ -71,32 +62,34 @@ describe('RepoFile', () => { ...@@ -71,32 +62,34 @@ describe('RepoFile', () => {
expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${vm.file.level * 16}px`); expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${vm.file.level * 16}px`);
}); });
it('does not render commit message and datetime if mini', () => { it('does not render commit message and datetime if mini', (done) => {
RepoStore.openedFiles.push(file()); vm = createComponent({
const vm = createComponent({
file: file(), file: file(),
}); });
vm.$store.state.openFiles.push(vm.file);
expect(vm.$el.querySelector('.commit-message')).toBeFalsy(); vm.$nextTick(() => {
expect(vm.$el.querySelector('.commit-update')).toBeFalsy(); expect(vm.$el.querySelector('.commit-message')).toBeFalsy();
expect(vm.$el.querySelector('.commit-update')).toBeFalsy();
done();
});
}); });
it('fires linkClicked when the link is clicked', () => { it('fires clickedTreeRow when the link is clicked', () => {
const vm = createComponent({ vm = createComponent({
file: file(), file: file(),
}); });
spyOn(vm, 'linkClicked'); spyOn(vm, 'clickedTreeRow');
vm.$el.click(); vm.$el.click();
expect(vm.linkClicked).toHaveBeenCalledWith(vm.file); expect(vm.clickedTreeRow).toHaveBeenCalledWith(vm.file);
}); });
describe('submodule', () => { describe('submodule', () => {
let f; let f;
let vm;
beforeEach(() => { beforeEach(() => {
f = file('submodule name', '123456789'); f = file('submodule name', '123456789');
...@@ -119,20 +112,4 @@ describe('RepoFile', () => { ...@@ -119,20 +112,4 @@ describe('RepoFile', () => {
expect(vm.$el.querySelector('td').textContent.replace(/\s+/g, ' ')).toContain('submodule name @ 12345678'); expect(vm.$el.querySelector('td').textContent.replace(/\s+/g, ' ')).toContain('submodule name @ 12345678');
}); });
}); });
describe('methods', () => {
describe('linkClicked', () => {
it('$emits fileNameClicked with file obj', () => {
spyOn(eventHub, '$emit');
const vm = createComponent({
file: file(),
});
vm.linkClicked(vm.file);
expect(eventHub.$emit).toHaveBeenCalledWith('fileNameClicked', vm.file);
});
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import RepoStore from '~/repo/stores/repo_store'; import store from '~/repo/stores';
import repoLoadingFile from '~/repo/components/repo_loading_file.vue'; import repoLoadingFile from '~/repo/components/repo_loading_file.vue';
import { resetStore } from '../helpers';
describe('RepoLoadingFile', () => { describe('RepoLoadingFile', () => {
function createComponent(propsData) { let vm;
function createComponent() {
const RepoLoadingFile = Vue.extend(repoLoadingFile); const RepoLoadingFile = Vue.extend(repoLoadingFile);
return new RepoLoadingFile({ return new RepoLoadingFile({
propsData, store,
}).$mount(); }).$mount();
} }
...@@ -30,33 +33,30 @@ describe('RepoLoadingFile', () => { ...@@ -30,33 +33,30 @@ describe('RepoLoadingFile', () => {
} }
afterEach(() => { afterEach(() => {
RepoStore.openedFiles = []; vm.$destroy();
resetStore(vm.$store);
}); });
it('renders 3 columns of animated LoC', () => { it('renders 3 columns of animated LoC', () => {
const vm = createComponent({ vm = createComponent();
loading: {
tree: true,
},
hasFiles: false,
});
const columns = [...vm.$el.querySelectorAll('td')]; const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(3); expect(columns.length).toEqual(3);
assertColumns(columns); assertColumns(columns);
}); });
it('renders 1 column of animated LoC if isMini', () => { it('renders 1 column of animated LoC if isMini', (done) => {
RepoStore.openedFiles = new Array(1); vm = createComponent();
const vm = createComponent({ vm.$store.state.openFiles.push('test');
loading: {
tree: true,
},
hasFiles: false,
});
const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(1); vm.$nextTick(() => {
assertColumns(columns); const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(1);
assertColumns(columns);
done();
});
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue'; import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue';
import eventHub from '~/repo/event_hub'; import { resetStore } from '../helpers';
describe('RepoPrevDirectory', () => { describe('RepoPrevDirectory', () => {
function createComponent(propsData) { let vm;
const parentLink = 'parent';
function createComponent() {
const RepoPrevDirectory = Vue.extend(repoPrevDirectory); const RepoPrevDirectory = Vue.extend(repoPrevDirectory);
return new RepoPrevDirectory({ const comp = new RepoPrevDirectory({
propsData, store,
}).$mount();
}
it('renders a prev dir link', () => {
const prevUrl = 'prevUrl';
const vm = createComponent({
prevUrl,
}); });
const link = vm.$el.querySelector('a');
spyOn(vm, 'linkClicked'); comp.$store.state.parentTreeUrl = parentLink;
expect(link.href).toMatch(`/${prevUrl}`); return comp.$mount();
expect(link.textContent).toEqual('...'); }
beforeEach(() => {
vm = createComponent();
});
link.click(); afterEach(() => {
vm.$destroy();
expect(vm.linkClicked).toHaveBeenCalledWith(prevUrl); resetStore(vm.$store);
}); });
describe('methods', () => { it('renders a prev dir link', () => {
describe('linkClicked', () => { const link = vm.$el.querySelector('a');
it('$emits linkclicked with prevUrl', () => {
const prevUrl = 'prevUrl';
const vm = createComponent({
prevUrl,
});
spyOn(eventHub, '$emit'); expect(link.href).toMatch(`/${parentLink}`);
expect(link.textContent).toEqual('...');
});
vm.linkClicked(prevUrl); it('clicking row triggers getTreeData', () => {
spyOn(vm, 'getTreeData');
expect(eventHub.$emit).toHaveBeenCalledWith('goToPreviousDirectoryClicked', prevUrl); vm.$el.querySelector('td').click();
});
}); expect(vm.getTreeData).toHaveBeenCalledWith({ endpoint: parentLink });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoPreview from '~/repo/components/repo_preview.vue'; import repoPreview from '~/repo/components/repo_preview.vue';
import RepoStore from '~/repo/stores/repo_store'; import { file, resetStore } from '../helpers';
describe('RepoPreview', () => { describe('RepoPreview', () => {
let vm;
function createComponent() { function createComponent() {
const f = file();
const RepoPreview = Vue.extend(repoPreview); const RepoPreview = Vue.extend(repoPreview);
return new RepoPreview().$mount(); const comp = new RepoPreview({
store,
});
f.active = true;
f.html = 'test';
comp.$store.state.openFiles.push(f);
return comp.$mount();
} }
it('renders a div with the activeFile html', () => { afterEach(() => {
const activeFile = { vm.$destroy();
html: '<p class="file-content">html</p>',
}; resetStore(vm.$store);
RepoStore.activeFile = activeFile; });
const vm = createComponent(); it('renders a div with the activeFile html', () => {
vm = createComponent();
expect(vm.$el.tagName).toEqual('DIV'); expect(vm.$el.tagName).toEqual('DIV');
expect(vm.$el.innerHTML).toContain(activeFile.html); expect(vm.$el.innerHTML).toContain('test');
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import Helper from '~/repo/helpers/repo_helper'; import store from '~/repo/stores';
import RepoService from '~/repo/services/repo_service';
import RepoStore from '~/repo/stores/repo_store';
import repoSidebar from '~/repo/components/repo_sidebar.vue'; import repoSidebar from '~/repo/components/repo_sidebar.vue';
import { file } from '../mock_data'; import { file, resetStore } from '../helpers';
describe('RepoSidebar', () => { describe('RepoSidebar', () => {
let vm; let vm;
function createComponent() { beforeEach(() => {
const RepoSidebar = Vue.extend(repoSidebar); const RepoSidebar = Vue.extend(repoSidebar);
return new RepoSidebar().$mount(); vm = new RepoSidebar({
} store,
});
vm.$store.state.isRoot = true;
vm.$store.state.tree.push(file());
vm.$mount();
});
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
RepoStore.files = []; resetStore(vm.$store);
RepoStore.openedFiles = [];
}); });
it('renders a sidebar', () => { it('renders a sidebar', () => {
RepoStore.files = [file()];
RepoStore.openedFiles = [];
RepoStore.isRoot = true;
vm = createComponent();
const thead = vm.$el.querySelector('thead'); const thead = vm.$el.querySelector('thead');
const tbody = vm.$el.querySelector('tbody'); const tbody = vm.$el.querySelector('tbody');
...@@ -41,139 +40,36 @@ describe('RepoSidebar', () => { ...@@ -41,139 +40,36 @@ describe('RepoSidebar', () => {
expect(tbody.querySelector('.file')).toBeTruthy(); expect(tbody.querySelector('.file')).toBeTruthy();
}); });
it('does not render a thead, renders repo-file-options and sets sidebar-mini class if isMini', () => { it('does not render a thead, renders repo-file-options and sets sidebar-mini class if isMini', (done) => {
RepoStore.openedFiles = [{ vm.$store.state.openFiles.push(vm.$store.state.tree[0]);
id: 0,
}];
vm = createComponent();
expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy();
expect(vm.$el.querySelector('thead')).toBeTruthy();
expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy();
});
it('renders 5 loading files if tree is loading and not hasFiles', () => {
RepoStore.loading.tree = true;
RepoStore.files = [];
vm = createComponent();
expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5); Vue.nextTick(() => {
}); expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy();
expect(vm.$el.querySelector('thead')).toBeTruthy();
it('renders a prev directory if is not root', () => { expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy();
RepoStore.files = [file()];
RepoStore.isRoot = false;
RepoStore.loading.tree = false;
vm = createComponent();
expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy();
});
describe('flattendFiles', () => { done();
it('returns a flattend array of files', () => {
const f = file();
f.files.push(file('testing 123'));
const files = [f, file()];
vm = createComponent();
vm.files = files;
expect(vm.flattendFiles.length).toBe(3);
expect(vm.flattendFiles[1].name).toBe('testing 123');
}); });
}); });
describe('methods', () => { it('renders 5 loading files if tree is loading', (done) => {
describe('fileClicked', () => { vm.$store.state.tree = [];
it('should fetch data for new file', () => { vm.$store.state.loading = true;
spyOn(Helper, 'getContent').and.callThrough();
RepoStore.files = [file()];
RepoStore.isRoot = true;
vm = createComponent();
vm.fileClicked(RepoStore.files[0]);
expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[0]);
});
it('should not fetch data for already opened files', () => {
const f = file();
spyOn(Helper, 'getFileFromPath').and.returnValue(f);
spyOn(RepoStore, 'setActiveFiles');
vm = createComponent();
vm.fileClicked(f);
expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(f); Vue.nextTick(() => {
}); expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5);
it('should hide files in directory if already open', () => { done();
spyOn(Helper, 'setDirectoryToClosed').and.callThrough();
const f = file();
f.opened = true;
f.type = 'tree';
RepoStore.files = [f];
vm = createComponent();
vm.fileClicked(RepoStore.files[0]);
expect(Helper.setDirectoryToClosed).toHaveBeenCalledWith(RepoStore.files[0]);
});
describe('submodule', () => {
it('opens submodule project URL', () => {
spyOn(gl.utils, 'visitUrl');
const f = file();
f.type = 'submodule';
vm = createComponent();
vm.fileClicked(f);
expect(gl.utils.visitUrl).toHaveBeenCalledWith('url');
});
});
});
describe('goToPreviousDirectoryClicked', () => {
it('should hide files in directory if already open', () => {
const prevUrl = 'foo/bar';
vm = createComponent();
vm.goToPreviousDirectoryClicked(prevUrl);
expect(RepoService.url).toEqual(prevUrl);
});
}); });
});
describe('back button', () => { it('renders a prev directory if is not root', (done) => {
beforeEach(() => { vm.$store.state.isRoot = false;
const f = file();
const file2 = Object.assign({}, file());
file2.url = 'test';
RepoStore.files = [f, file2];
RepoStore.openedFiles = [];
RepoStore.isRoot = true;
vm = createComponent();
});
it('render previous file when using back button', () => {
spyOn(Helper, 'getContent').and.callThrough();
vm.fileClicked(RepoStore.files[1]);
expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[1]);
history.pushState({
key: Math.random(),
}, '', RepoStore.files[1].url);
const popEvent = document.createEvent('Event');
popEvent.initEvent('popstate', true, true);
window.dispatchEvent(popEvent);
expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(RepoStore.files[1].url); Vue.nextTick(() => {
expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy();
window.history.pushState({}, null, '/'); done();
});
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repo from '~/repo/components/repo.vue'; import repo from '~/repo/components/repo.vue';
import RepoStore from '~/repo/stores/repo_store'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import Service from '~/repo/services/repo_service'; import { file, resetStore } from '../helpers';
import eventHub from '~/repo/event_hub';
import createComponent from '../../helpers/vue_mount_component_helper';
describe('repo component', () => { describe('repo component', () => {
let vm; let vm;
...@@ -11,86 +10,26 @@ describe('repo component', () => { ...@@ -11,86 +10,26 @@ describe('repo component', () => {
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(repo); const Component = Vue.extend(repo);
RepoStore.currentBranch = 'master'; vm = createComponentWithStore(Component, store).$mount();
vm = createComponent(Component);
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
RepoStore.currentBranch = ''; resetStore(vm.$store);
}); });
describe('createNewBranch', () => { it('does not render panel right when no files open', () => {
beforeEach(() => { expect(vm.$el.querySelector('.panel-right')).toBeNull();
spyOn(history, 'pushState'); });
});
describe('success', () => {
beforeEach(() => {
spyOn(Service, 'createBranch').and.returnValue(Promise.resolve({
data: {
name: 'test',
},
}));
});
it('calls createBranch with branchName', () => {
eventHub.$emit('createNewBranch', 'test');
expect(Service.createBranch).toHaveBeenCalledWith({
branch: 'test',
ref: RepoStore.currentBranch,
});
});
it('pushes new history state', (done) => {
RepoStore.currentBranch = 'master';
spyOn(vm, 'getCurrentLocation').and.returnValue('http://test.com/master');
eventHub.$emit('createNewBranch', 'test');
setTimeout(() => {
expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'http://test.com/test');
done();
});
});
it('updates stores currentBranch', (done) => {
eventHub.$emit('createNewBranch', 'test');
setTimeout(() => {
expect(RepoStore.currentBranch).toBe('test');
done();
});
});
});
describe('failure', () => {
beforeEach(() => {
spyOn(Service, 'createBranch').and.returnValue(Promise.reject({
response: {
data: {
message: 'test',
},
},
}));
});
it('emits createNewBranchError event', (done) => {
spyOn(eventHub, '$emit').and.callThrough();
eventHub.$emit('createNewBranch', 'test'); it('renders panel right when files are open', (done) => {
vm.$store.state.tree.push(file());
setTimeout(() => { Vue.nextTick(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('createNewBranchError', 'test'); expect(vm.$el.querySelector('.panel-right')).toBeNull();
done(); done();
});
});
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoTab from '~/repo/components/repo_tab.vue'; import repoTab from '~/repo/components/repo_tab.vue';
import RepoStore from '~/repo/stores/repo_store'; import { file, resetStore } from '../helpers';
describe('RepoTab', () => { describe('RepoTab', () => {
let vm;
function createComponent(propsData) { function createComponent(propsData) {
const RepoTab = Vue.extend(repoTab); const RepoTab = Vue.extend(repoTab);
return new RepoTab({ return new RepoTab({
store,
propsData, propsData,
}).$mount(); }).$mount();
} }
afterEach(() => {
resetStore(vm.$store);
});
it('renders a close link and a name link', () => { it('renders a close link and a name link', () => {
const tab = { vm = createComponent({
url: 'url', tab: file(),
name: 'name',
};
const vm = createComponent({
tab,
}); });
vm.$store.state.openFiles.push(vm.tab);
const close = vm.$el.querySelector('.close-btn'); const close = vm.$el.querySelector('.close-btn');
const name = vm.$el.querySelector(`a[title="${tab.url}"]`); const name = vm.$el.querySelector(`a[title="${vm.tab.url}"]`);
spyOn(vm, 'closeTab');
spyOn(vm, 'tabClicked');
expect(close.querySelector('.fa-times')).toBeTruthy(); expect(close.querySelector('.fa-times')).toBeTruthy();
expect(name.textContent.trim()).toEqual(tab.name); expect(name.textContent.trim()).toEqual(vm.tab.name);
});
close.click(); it('calls setFileActive when clicking tab', () => {
name.click(); vm = createComponent({
tab: file(),
});
spyOn(vm, 'setFileActive');
vm.$el.click();
expect(vm.closeTab).toHaveBeenCalledWith(tab); expect(vm.setFileActive).toHaveBeenCalledWith(vm.tab);
expect(vm.tabClicked).toHaveBeenCalledWith(tab); });
it('calls closeFile when clicking close button', () => {
vm = createComponent({
tab: file(),
});
spyOn(vm, 'closeFile');
vm.$el.querySelector('.close-btn').click();
expect(vm.closeFile).toHaveBeenCalledWith({ file: vm.tab });
}); });
it('renders an fa-circle icon if tab is changed', () => { it('renders an fa-circle icon if tab is changed', () => {
const tab = { const tab = file();
url: 'url', tab.changed = true;
name: 'name', vm = createComponent({
changed: true,
};
const vm = createComponent({
tab, tab,
}); });
...@@ -50,38 +67,41 @@ describe('RepoTab', () => { ...@@ -50,38 +67,41 @@ describe('RepoTab', () => {
describe('methods', () => { describe('methods', () => {
describe('closeTab', () => { describe('closeTab', () => {
it('returns undefined and does not $emit if file is changed', () => { it('does not close tab if is changed', (done) => {
const tab = { const tab = file();
url: 'url', tab.changed = true;
name: 'name', tab.opened = true;
changed: true, vm = createComponent({
};
const vm = createComponent({
tab, tab,
}); });
vm.$store.state.openFiles.push(tab);
spyOn(RepoStore, 'removeFromOpenedFiles'); vm.$store.dispatch('setFileActive', tab);
vm.$el.querySelector('.close-btn').click(); vm.$el.querySelector('.close-btn').click();
expect(RepoStore.removeFromOpenedFiles).not.toHaveBeenCalled(); vm.$nextTick(() => {
expect(tab.opened).toBeTruthy();
done();
});
}); });
it('$emits tabclosed event with file obj', () => { it('closes tab when clicking close btn', (done) => {
const tab = { const tab = file('lose');
url: 'url', tab.opened = true;
name: 'name', vm = createComponent({
changed: false,
};
const vm = createComponent({
tab, tab,
}); });
vm.$store.state.openFiles.push(tab);
spyOn(RepoStore, 'removeFromOpenedFiles'); vm.$store.dispatch('setFileActive', tab);
vm.$el.querySelector('.close-btn').click(); vm.$el.querySelector('.close-btn').click();
expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(tab); vm.$nextTick(() => {
expect(tab.opened).toBeFalsy();
done();
});
}); });
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import RepoStore from '~/repo/stores/repo_store'; import store from '~/repo/stores';
import repoTabs from '~/repo/components/repo_tabs.vue'; import repoTabs from '~/repo/components/repo_tabs.vue';
import { file, resetStore } from '../helpers';
describe('RepoTabs', () => { describe('RepoTabs', () => {
const openedFiles = [{ const openedFiles = [file(), file()];
id: 0, let vm;
active: true,
}, {
id: 1,
}];
function createComponent() { function createComponent() {
const RepoTabs = Vue.extend(repoTabs); const RepoTabs = Vue.extend(repoTabs);
return new RepoTabs().$mount(); return new RepoTabs({
store,
}).$mount();
} }
afterEach(() => { afterEach(() => {
RepoStore.openedFiles = []; resetStore(vm.$store);
}); });
it('renders a list of tabs', () => { it('renders a list of tabs', (done) => {
RepoStore.openedFiles = openedFiles; vm = createComponent();
openedFiles[0].active = true;
vm.$store.state.openFiles = openedFiles;
const vm = createComponent(); vm.$nextTick(() => {
const tabs = [...vm.$el.querySelectorAll(':scope > li')]; const tabs = [...vm.$el.querySelectorAll(':scope > li')];
expect(vm.$el.id).toEqual('tabs'); expect(tabs.length).toEqual(3);
expect(tabs.length).toEqual(3); expect(tabs[0].classList.contains('active')).toBeTruthy();
expect(tabs[0].classList.contains('active')).toBeTruthy(); expect(tabs[1].classList.contains('active')).toBeFalsy();
expect(tabs[1].classList.contains('active')).toBeFalsy(); expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy();
expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy();
done();
});
}); });
}); });
import RepoHelper from '~/repo/helpers/repo_helper'; import { decorateData } from '~/repo/stores/utils';
import state from '~/repo/stores/state';
// eslint-disable-next-line import/prefer-default-export export const resetStore = (store) => {
export const file = (name = 'name', id = name) => RepoHelper.serializeRepoEntity('blob', { store.replaceState(state());
};
export const file = (name = 'name', id = name, type = '') => decorateData({
id, id,
type,
icon: 'icon', icon: 'icon',
url: 'url', url: 'url',
name, name,
path: name,
last_commit: { last_commit: {
id: '123', id: '123',
message: 'test', message: 'test',
......
import axios from 'axios';
import RepoService from '~/repo/services/repo_service';
import RepoStore from '~/repo/stores/repo_store';
import Api from '~/api';
describe('RepoService', () => {
it('has default json format param', () => {
expect(RepoService.options.params.format).toBe('json');
});
describe('buildParams', () => {
let newParams;
const url = 'url';
beforeEach(() => {
newParams = {};
spyOn(Object, 'assign').and.returnValue(newParams);
});
it('clones params', () => {
const params = RepoService.buildParams(url);
expect(Object.assign).toHaveBeenCalledWith({}, RepoService.options.params);
expect(params).toBe(newParams);
});
it('sets and returns viewer params to richif urlIsRichBlob is true', () => {
spyOn(RepoService, 'urlIsRichBlob').and.returnValue(true);
const params = RepoService.buildParams(url);
expect(params.viewer).toEqual('rich');
});
it('returns params urlIsRichBlob is false', () => {
spyOn(RepoService, 'urlIsRichBlob').and.returnValue(false);
const params = RepoService.buildParams(url);
expect(params.viewer).toBeUndefined();
});
it('calls urlIsRichBlob with the objects url prop if no url arg is provided', () => {
spyOn(RepoService, 'urlIsRichBlob');
RepoService.url = url;
RepoService.buildParams();
expect(RepoService.urlIsRichBlob).toHaveBeenCalledWith(url);
});
});
describe('urlIsRichBlob', () => {
it('returns true for md extension', () => {
const isRichBlob = RepoService.urlIsRichBlob('url.md');
expect(isRichBlob).toBeTruthy();
});
it('returns false for js extension', () => {
const isRichBlob = RepoService.urlIsRichBlob('url.js');
expect(isRichBlob).toBeFalsy();
});
});
describe('getContent', () => {
const params = {};
const url = 'url';
const requestPromise = Promise.resolve();
beforeEach(() => {
spyOn(RepoService, 'buildParams').and.returnValue(params);
spyOn(axios, 'get').and.returnValue(requestPromise);
});
it('calls buildParams and axios.get', () => {
const request = RepoService.getContent(url);
expect(RepoService.buildParams).toHaveBeenCalledWith(url);
expect(axios.get).toHaveBeenCalledWith(url, {
params,
});
expect(request).toBe(requestPromise);
});
it('uses object url prop if no url arg is provided', () => {
RepoService.url = url;
RepoService.getContent();
expect(axios.get).toHaveBeenCalledWith(url, {
params,
});
});
});
describe('getBase64Content', () => {
const url = 'url';
const response = { data: 'data' };
beforeEach(() => {
spyOn(RepoService, 'bufferToBase64');
spyOn(axios, 'get').and.returnValue(Promise.resolve(response));
});
it('calls axios.get and bufferToBase64 on completion', (done) => {
const request = RepoService.getBase64Content(url);
expect(axios.get).toHaveBeenCalledWith(url, {
responseType: 'arraybuffer',
});
expect(request).toEqual(jasmine.any(Promise));
request.then(() => {
expect(RepoService.bufferToBase64).toHaveBeenCalledWith(response.data);
done();
}).catch(done.fail);
});
});
describe('commitFiles', () => {
it('calls commitMultiple and .then commitFlash', (done) => {
const projectId = 'projectId';
const payload = {};
RepoStore.projectId = projectId;
spyOn(Api, 'commitMultiple').and.returnValue(Promise.resolve());
spyOn(RepoService, 'commitFlash');
const apiPromise = RepoService.commitFiles(payload);
expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, payload);
apiPromise.then(() => {
expect(RepoService.commitFlash).toHaveBeenCalled();
done();
}).catch(done.fail);
});
});
describe('commitFlash', () => {
it('calls Flash with data.message', () => {
const data = {
message: 'message',
};
spyOn(window, 'Flash');
RepoService.commitFlash(data);
expect(window.Flash).toHaveBeenCalledWith(data.message);
});
it('calls Flash with success string if short_id and stats', () => {
const data = {
short_id: 'short_id',
stats: {
additions: '4',
deletions: '5',
},
};
spyOn(window, 'Flash');
RepoService.commitFlash(data);
expect(window.Flash).toHaveBeenCalledWith(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice');
});
});
});
...@@ -109,12 +109,14 @@ const sidebarMockData = { ...@@ -109,12 +109,14 @@ const sidebarMockData = {
labels: [], labels: [],
web_url: '/root/some-project/issues/5', web_url: '/root/some-project/issues/5',
}, },
'/gitlab-org/gitlab-shell/issues/5/toggle_subscription': {},
}, },
}; };
export default { export default {
mediator: { mediator: {
endpoint: '/gitlab-org/gitlab-shell/issues/5.json', endpoint: '/gitlab-org/gitlab-shell/issues/5.json',
toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription',
moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move', moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move',
projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15', projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15',
editable: true, editable: true,
......
import Vue from 'vue';
import participants from '~/sidebar/components/participants/participants.vue';
import mountComponent from '../helpers/vue_mount_component_helper';
const PARTICIPANT = {
id: 1,
state: 'active',
username: 'marcene',
name: 'Allie Will',
web_url: 'foo.com',
avatar_url: 'gravatar.com/avatar/xxx',
};
const PARTICIPANT_LIST = [
PARTICIPANT,
{ ...PARTICIPANT, id: 2 },
{ ...PARTICIPANT, id: 3 },
];
describe('Participants', function () {
let vm;
let Participants;
beforeEach(() => {
Participants = Vue.extend(participants);
});
afterEach(() => {
vm.$destroy();
});
describe('collapsed sidebar state', () => {
it('shows loading spinner when loading', () => {
vm = mountComponent(Participants, {
loading: true,
});
expect(vm.$el.querySelector('.js-participants-collapsed-loading-icon')).toBeDefined();
});
it('shows participant count when given', () => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
});
const countEl = vm.$el.querySelector('.js-participants-collapsed-count');
expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`);
});
it('shows full participant count when there are hidden participants', () => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 1,
});
const countEl = vm.$el.querySelector('.js-participants-collapsed-count');
expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`);
});
});
describe('expanded sidebar state', () => {
it('shows loading spinner when loading', () => {
vm = mountComponent(Participants, {
loading: true,
});
expect(vm.$el.querySelector('.js-participants-expanded-loading-icon')).toBeDefined();
});
it('when only showing visible participants, shows an avatar only for each participant under the limit', (done) => {
const numberOfLessParticipants = 2;
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants,
});
vm.isShowingMoreParticipants = false;
Vue.nextTick()
.then(() => {
const participantEls = vm.$el.querySelectorAll('.js-participants-author');
expect(participantEls.length).toBe(numberOfLessParticipants);
})
.then(done)
.catch(done.fail);
});
it('when only showing all participants, each has an avatar', (done) => {
const numberOfLessParticipants = 2;
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants,
});
vm.isShowingMoreParticipants = true;
Vue.nextTick()
.then(() => {
const participantEls = vm.$el.querySelectorAll('.js-participants-author');
expect(participantEls.length).toBe(PARTICIPANT_LIST.length);
})
.then(done)
.catch(done.fail);
});
it('does not have more participants link when they can all be shown', () => {
const numberOfLessParticipants = 100;
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants,
});
const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants);
expect(moreParticipantLink).toBeNull();
});
it('when too many participants, has more participants link to show more', (done) => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
vm.isShowingMoreParticipants = false;
Vue.nextTick()
.then(() => {
const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
expect(moreParticipantLink.textContent.trim()).toBe('+ 1 more');
})
.then(done)
.catch(done.fail);
});
it('when too many participants and already showing them, has more participants link to show less', (done) => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
vm.isShowingMoreParticipants = true;
Vue.nextTick()
.then(() => {
const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
expect(moreParticipantLink.textContent.trim()).toBe('- show less');
})
.then(done)
.catch(done.fail);
});
it('clicking more participants link emits event', () => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
expect(vm.isShowingMoreParticipants).toBe(false);
moreParticipantLink.click();
expect(vm.isShowingMoreParticipants).toBe(true);
});
});
});
...@@ -57,8 +57,8 @@ describe('Sidebar mediator', () => { ...@@ -57,8 +57,8 @@ describe('Sidebar mediator', () => {
.then(() => { .then(() => {
expect(this.mediator.service.getProjectsAutocomplete).toHaveBeenCalledWith(searchTerm); expect(this.mediator.service.getProjectsAutocomplete).toHaveBeenCalledWith(searchTerm);
expect(this.mediator.store.setAutocompleteProjects).toHaveBeenCalled(); expect(this.mediator.store.setAutocompleteProjects).toHaveBeenCalled();
done();
}) })
.then(done)
.catch(done.fail); .catch(done.fail);
}); });
...@@ -72,8 +72,21 @@ describe('Sidebar mediator', () => { ...@@ -72,8 +72,21 @@ describe('Sidebar mediator', () => {
.then(() => { .then(() => {
expect(this.mediator.service.moveIssue).toHaveBeenCalledWith(moveToProjectId); expect(this.mediator.service.moveIssue).toHaveBeenCalledWith(moveToProjectId);
expect(gl.utils.visitUrl).toHaveBeenCalledWith('/root/some-project/issues/5'); expect(gl.utils.visitUrl).toHaveBeenCalledWith('/root/some-project/issues/5');
done();
}) })
.then(done)
.catch(done.fail);
});
it('toggle subscription', (done) => {
this.mediator.store.setSubscribedState(false);
spyOn(this.mediator.service, 'toggleSubscription').and.callThrough();
this.mediator.toggleSubscription()
.then(() => {
expect(this.mediator.service.toggleSubscription).toHaveBeenCalled();
expect(this.mediator.store.subscribed).toEqual(true);
})
.then(done)
.catch(done.fail); .catch(done.fail);
}); });
}); });
...@@ -7,6 +7,7 @@ describe('Sidebar service', () => { ...@@ -7,6 +7,7 @@ describe('Sidebar service', () => {
Vue.http.interceptors.push(Mock.sidebarMockInterceptor); Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
this.service = new SidebarService({ this.service = new SidebarService({
endpoint: '/gitlab-org/gitlab-shell/issues/5.json', endpoint: '/gitlab-org/gitlab-shell/issues/5.json',
toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription',
moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move', moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move',
projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15', projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15',
}); });
...@@ -23,6 +24,7 @@ describe('Sidebar service', () => { ...@@ -23,6 +24,7 @@ describe('Sidebar service', () => {
expect(resp).toBeDefined(); expect(resp).toBeDefined();
done(); done();
}) })
.then(done)
.catch(done.fail); .catch(done.fail);
}); });
...@@ -30,8 +32,8 @@ describe('Sidebar service', () => { ...@@ -30,8 +32,8 @@ describe('Sidebar service', () => {
this.service.update('issue[assignee_ids]', [1]) this.service.update('issue[assignee_ids]', [1])
.then((resp) => { .then((resp) => {
expect(resp).toBeDefined(); expect(resp).toBeDefined();
done();
}) })
.then(done)
.catch(done.fail); .catch(done.fail);
}); });
...@@ -39,8 +41,8 @@ describe('Sidebar service', () => { ...@@ -39,8 +41,8 @@ describe('Sidebar service', () => {
this.service.getProjectsAutocomplete() this.service.getProjectsAutocomplete()
.then((resp) => { .then((resp) => {
expect(resp).toBeDefined(); expect(resp).toBeDefined();
done();
}) })
.then(done)
.catch(done.fail); .catch(done.fail);
}); });
...@@ -48,8 +50,17 @@ describe('Sidebar service', () => { ...@@ -48,8 +50,17 @@ describe('Sidebar service', () => {
this.service.moveIssue(123) this.service.moveIssue(123)
.then((resp) => { .then((resp) => {
expect(resp).toBeDefined(); expect(resp).toBeDefined();
done();
}) })
.then(done)
.catch(done.fail);
});
it('toggles the subscription', (done) => {
this.service.toggleSubscription()
.then((resp) => {
expect(resp).toBeDefined();
})
.then(done)
.catch(done.fail); .catch(done.fail);
}); });
}); });
...@@ -2,21 +2,36 @@ import SidebarStore from '~/sidebar/stores/sidebar_store'; ...@@ -2,21 +2,36 @@ import SidebarStore from '~/sidebar/stores/sidebar_store';
import Mock from './mock_data'; import Mock from './mock_data';
import UsersMockHelper from '../helpers/user_mock_data_helper'; import UsersMockHelper from '../helpers/user_mock_data_helper';
describe('Sidebar store', () => { const ASSIGNEE = {
const assignee = { id: 2,
id: 2, name: 'gitlab user 2',
name: 'gitlab user 2', username: 'gitlab2',
username: 'gitlab2', avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', };
};
const ANOTHER_ASSINEE = {
const anotherAssignee = { id: 3,
id: 3, name: 'gitlab user 3',
name: 'gitlab user 3', username: 'gitlab3',
username: 'gitlab3', avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', };
};
const PARTICIPANT = {
id: 1,
state: 'active',
username: 'marcene',
name: 'Allie Will',
web_url: 'foo.com',
avatar_url: 'gravatar.com/avatar/xxx',
};
const PARTICIPANT_LIST = [
PARTICIPANT,
{ ...PARTICIPANT, id: 2 },
{ ...PARTICIPANT, id: 3 },
];
describe('Sidebar store', () => {
beforeEach(() => { beforeEach(() => {
this.store = new SidebarStore({ this.store = new SidebarStore({
currentUser: { currentUser: {
...@@ -40,23 +55,23 @@ describe('Sidebar store', () => { ...@@ -40,23 +55,23 @@ describe('Sidebar store', () => {
}); });
it('adds a new assignee', () => { it('adds a new assignee', () => {
this.store.addAssignee(assignee); this.store.addAssignee(ASSIGNEE);
expect(this.store.assignees.length).toEqual(1); expect(this.store.assignees.length).toEqual(1);
}); });
it('removes an assignee', () => { it('removes an assignee', () => {
this.store.removeAssignee(assignee); this.store.removeAssignee(ASSIGNEE);
expect(this.store.assignees.length).toEqual(0); expect(this.store.assignees.length).toEqual(0);
}); });
it('finds an existent assignee', () => { it('finds an existent assignee', () => {
let foundAssignee; let foundAssignee;
this.store.addAssignee(assignee); this.store.addAssignee(ASSIGNEE);
foundAssignee = this.store.findAssignee(assignee); foundAssignee = this.store.findAssignee(ASSIGNEE);
expect(foundAssignee).toBeDefined(); expect(foundAssignee).toBeDefined();
expect(foundAssignee).toEqual(assignee); expect(foundAssignee).toEqual(ASSIGNEE);
foundAssignee = this.store.findAssignee(anotherAssignee); foundAssignee = this.store.findAssignee(ANOTHER_ASSINEE);
expect(foundAssignee).toBeUndefined(); expect(foundAssignee).toBeUndefined();
}); });
...@@ -65,6 +80,28 @@ describe('Sidebar store', () => { ...@@ -65,6 +80,28 @@ describe('Sidebar store', () => {
expect(this.store.assignees.length).toEqual(0); expect(this.store.assignees.length).toEqual(0);
}); });
it('sets participants data', () => {
expect(this.store.participants.length).toEqual(0);
this.store.setParticipantsData({
participants: PARTICIPANT_LIST,
});
expect(this.store.isFetching.participants).toEqual(false);
expect(this.store.participants.length).toEqual(PARTICIPANT_LIST.length);
});
it('sets subcriptions data', () => {
expect(this.store.subscribed).toEqual(null);
this.store.setSubscriptionsData({
subscribed: true,
});
expect(this.store.isFetching.subscriptions).toEqual(false);
expect(this.store.subscribed).toEqual(true);
});
it('set assigned data', () => { it('set assigned data', () => {
const users = { const users = {
assignees: UsersMockHelper.createNumberRandomUsers(3), assignees: UsersMockHelper.createNumberRandomUsers(3),
...@@ -75,6 +112,14 @@ describe('Sidebar store', () => { ...@@ -75,6 +112,14 @@ describe('Sidebar store', () => {
expect(this.store.assignees.length).toEqual(3); expect(this.store.assignees.length).toEqual(3);
}); });
it('sets fetching state', () => {
expect(this.store.isFetching.participants).toEqual(true);
this.store.setFetchingState('participants', false);
expect(this.store.isFetching.participants).toEqual(false);
});
it('set time tracking data', () => { it('set time tracking data', () => {
this.store.setTimeTrackingData(Mock.time); this.store.setTimeTrackingData(Mock.time);
expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate); expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate);
...@@ -90,6 +135,14 @@ describe('Sidebar store', () => { ...@@ -90,6 +135,14 @@ describe('Sidebar store', () => {
expect(this.store.autocompleteProjects).toEqual(projects); expect(this.store.autocompleteProjects).toEqual(projects);
}); });
it('sets subscribed state', () => {
expect(this.store.subscribed).toEqual(null);
this.store.setSubscribedState(true);
expect(this.store.subscribed).toEqual(true);
});
it('set move to project ID', () => { it('set move to project ID', () => {
const projectId = 7; const projectId = 7;
this.store.setMoveToProjectId(projectId); this.store.setMoveToProjectId(projectId);
......
import Vue from 'vue';
import sidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import eventHub from '~/sidebar/event_hub';
import mountComponent from '../helpers/vue_mount_component_helper';
import Mock from './mock_data';
describe('Sidebar Subscriptions', function () {
let vm;
let SidebarSubscriptions;
beforeEach(() => {
SidebarSubscriptions = Vue.extend(sidebarSubscriptions);
// Setup the stores, services, etc
// eslint-disable-next-line no-new
new SidebarMediator(Mock.mediator);
});
afterEach(() => {
vm.$destroy();
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
});
it('calls the mediator toggleSubscription on event', () => {
spyOn(SidebarMediator.prototype, 'toggleSubscription').and.returnValue(Promise.resolve());
vm = mountComponent(SidebarSubscriptions, {});
eventHub.$emit('toggleSubscription');
expect(SidebarMediator.prototype.toggleSubscription).toHaveBeenCalled();
});
});
import Vue from 'vue';
import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
import mountComponent from '../helpers/vue_mount_component_helper';
describe('Subscriptions', function () {
let vm;
let Subscriptions;
beforeEach(() => {
Subscriptions = Vue.extend(subscriptions);
});
afterEach(() => {
vm.$destroy();
});
it('shows loading spinner when loading', () => {
vm = mountComponent(Subscriptions, {
loading: true,
subscribed: undefined,
});
expect(vm.$refs.loadingButton.loading).toBe(true);
expect(vm.$refs.loadingButton.label).toBeUndefined();
});
it('has "Subscribe" text when currently not subscribed', () => {
vm = mountComponent(Subscriptions, {
subscribed: false,
});
expect(vm.$refs.loadingButton.label).toBe('Subscribe');
});
it('has "Unsubscribe" text when currently not subscribed', () => {
vm = mountComponent(Subscriptions, {
subscribed: true,
});
expect(vm.$refs.loadingButton.label).toBe('Unsubscribe');
});
});
...@@ -304,4 +304,26 @@ describe Gitlab::Database do ...@@ -304,4 +304,26 @@ describe Gitlab::Database do
end end
end end
end end
describe '#sanitize_timestamp' do
let(:max_timestamp) { Time.at((1 << 31) - 1) }
subject { described_class.sanitize_timestamp(timestamp) }
context 'with a timestamp smaller than MAX_TIMESTAMP_VALUE' do
let(:timestamp) { max_timestamp - 10.years }
it 'returns the given timestamp' do
expect(subject).to eq(timestamp)
end
end
context 'with a timestamp larger than MAX_TIMESTAMP_VALUE' do
let(:timestamp) { max_timestamp + 1.second }
it 'returns MAX_TIMESTAMP_VALUE' do
expect(subject).to eq(max_timestamp)
end
end
end
end end
...@@ -6,6 +6,12 @@ describe Subscribable, 'Subscribable' do ...@@ -6,6 +6,12 @@ describe Subscribable, 'Subscribable' do
let(:user_1) { create(:user) } let(:user_1) { create(:user) }
describe '#subscribed?' do describe '#subscribed?' do
context 'without user' do
it 'returns false' do
expect(resource.subscribed?(nil, project)).to be_falsey
end
end
context 'without project' do context 'without project' do
it 'returns false when no subscription exists' do it 'returns false when no subscription exists' do
expect(resource.subscribed?(user_1)).to be_falsey expect(resource.subscribed?(user_1)).to be_falsey
......
...@@ -2,14 +2,93 @@ require 'rails_helper' ...@@ -2,14 +2,93 @@ require 'rails_helper'
describe MergeRequestDiffCommit do describe MergeRequestDiffCommit do
let(:merge_request) { create(:merge_request) } let(:merge_request) { create(:merge_request) }
subject { merge_request.commits.first } let(:project) { merge_request.project }
describe '#to_hash' do describe '#to_hash' do
subject { merge_request.commits.first }
it 'returns the same results as Commit#to_hash, except for parent_ids' do it 'returns the same results as Commit#to_hash, except for parent_ids' do
commit_from_repo = merge_request.project.repository.commit(subject.sha) commit_from_repo = project.repository.commit(subject.sha)
commit_from_repo_hash = commit_from_repo.to_hash.merge(parent_ids: []) commit_from_repo_hash = commit_from_repo.to_hash.merge(parent_ids: [])
expect(subject.to_hash).to eq(commit_from_repo_hash) expect(subject.to_hash).to eq(commit_from_repo_hash)
end end
end end
describe '.create_bulk' do
let(:sha_attribute) { Gitlab::Database::ShaAttribute.new }
let(:merge_request_diff_id) { merge_request.merge_request_diff.id }
let(:commits) do
[
project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e'),
project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d')
]
end
let(:rows) do
[
{
"message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
"authored_date": "2014-02-27T10:01:38.000+01:00".to_time,
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
"committed_date": "2014-02-27T10:01:38.000+01:00".to_time,
"committer_name": "Dmitriy Zaporozhets",
"committer_email": "dmitriy.zaporozhets@gmail.com",
"merge_request_diff_id": merge_request_diff_id,
"relative_order": 0,
"sha": sha_attribute.type_cast_for_database('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
},
{
"message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
"authored_date": "2014-02-27T09:57:31.000+01:00".to_time,
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
"committed_date": "2014-02-27T09:57:31.000+01:00".to_time,
"committer_name": "Dmitriy Zaporozhets",
"committer_email": "dmitriy.zaporozhets@gmail.com",
"merge_request_diff_id": merge_request_diff_id,
"relative_order": 1,
"sha": sha_attribute.type_cast_for_database('570e7b2abdd848b95f2f578043fc23bd6f6fd24d')
}
]
end
subject { described_class.create_bulk(merge_request_diff_id, commits) }
it 'inserts the commits into the database en masse' do
expect(Gitlab::Database).to receive(:bulk_insert)
.with(described_class.table_name, rows)
subject
end
context 'with dates larger than the DB limit' do
let(:commits) do
# This commit's date is "Sun Aug 17 07:12:55 292278994 +0000"
[project.commit('ba3343bc4fa403a8dfbfcab7fc1a8c29ee34bd69')]
end
let(:timestamp) { Time.at((1 << 31) - 1) }
let(:rows) do
[{
"message": "Weird commit date\n",
"authored_date": timestamp,
"author_name": "Alejandro Rodríguez",
"author_email": "alejorro70@gmail.com",
"committed_date": timestamp,
"committer_name": "Alejandro Rodríguez",
"committer_email": "alejorro70@gmail.com",
"merge_request_diff_id": merge_request_diff_id,
"relative_order": 0,
"sha": sha_attribute.type_cast_for_database('ba3343bc4fa403a8dfbfcab7fc1a8c29ee34bd69')
}]
end
it 'uses a sanitized date' do
expect(Gitlab::Database).to receive(:bulk_insert)
.with(described_class.table_name, rows)
subject
end
end
end
end end
...@@ -426,18 +426,23 @@ describe 'project routing' do ...@@ -426,18 +426,23 @@ describe 'project routing' do
end end
end end
# project_milestones GET /:project_id/milestones(.:format) milestones#index # project_milestones GET /:project_id/milestones(.:format) milestones#index
# POST /:project_id/milestones(.:format) milestones#create # POST /:project_id/milestones(.:format) milestones#create
# new_project_milestone GET /:project_id/milestones/new(.:format) milestones#new # new_project_milestone GET /:project_id/milestones/new(.:format) milestones#new
# edit_project_milestone GET /:project_id/milestones/:id/edit(.:format) milestones#edit # edit_project_milestone GET /:project_id/milestones/:id/edit(.:format) milestones#edit
# project_milestone GET /:project_id/milestones/:id(.:format) milestones#show # project_milestone GET /:project_id/milestones/:id(.:format) milestones#show
# PUT /:project_id/milestones/:id(.:format) milestones#update # PUT /:project_id/milestones/:id(.:format) milestones#update
# DELETE /:project_id/milestones/:id(.:format) milestones#destroy # DELETE /:project_id/milestones/:id(.:format) milestones#destroy
# promote_project_milestone POST /:project_id/milestones/:id/promote milestones#promote
describe Projects::MilestonesController, 'routing' do describe Projects::MilestonesController, 'routing' do
it_behaves_like 'RESTful project resources' do it_behaves_like 'RESTful project resources' do
let(:controller) { 'milestones' } let(:controller) { 'milestones' }
let(:actions) { [:index, :create, :new, :edit, :show, :update] } let(:actions) { [:index, :create, :new, :edit, :show, :update] }
end end
it 'to #promote' do
expect(post('/gitlab/gitlabhq/milestones/1/promote')).to route_to('projects/milestones#promote', namespace_id: 'gitlab', project_id: 'gitlabhq', id: "1")
end
end end
# project_labels GET /:project_id/labels(.:format) labels#index # project_labels GET /:project_id/labels(.:format) labels#index
......
require 'spec_helper'
describe IssueSerializer do
let(:resource) { create(:issue) }
let(:user) { create(:user) }
let(:json_entity) do
described_class.new(current_user: user)
.represent(resource, serializer: serializer)
.with_indifferent_access
end
context 'non-sidebar issue serialization' do
let(:serializer) { nil }
it 'matches issue json schema' do
expect(json_entity).to match_schema('entities/issue')
end
end
context 'sidebar issue serialization' do
let(:serializer) { 'sidebar' }
it 'matches sidebar issue json schema' do
expect(json_entity).to match_schema('entities/issue_sidebar')
end
end
end
...@@ -4,9 +4,13 @@ describe MergeRequestBasicSerializer do ...@@ -4,9 +4,13 @@ describe MergeRequestBasicSerializer do
let(:resource) { create(:merge_request) } let(:resource) { create(:merge_request) }
let(:user) { create(:user) } let(:user) { create(:user) }
subject { described_class.new.represent(resource) } let(:json_entity) do
described_class.new(current_user: user)
.represent(resource, serializer: 'basic')
.with_indifferent_access
end
it 'has important MergeRequest attributes' do it 'matches basic merge request json' do
expect(subject).to include(:merge_status) expect(json_entity).to match_schema('entities/merge_request_basic')
end end
end end
...@@ -9,11 +9,11 @@ describe MergeRequestSerializer do ...@@ -9,11 +9,11 @@ describe MergeRequestSerializer do
end end
describe '#represent' do describe '#represent' do
let(:opts) { { basic: basic } } let(:opts) { { serializer: serializer_entity } }
subject { serializer.represent(merge_request, basic: basic) } subject { serializer.represent(merge_request, serializer: serializer_entity) }
context 'when basic param is truthy' do context 'when passing basic serializer param' do
let(:basic) { true } let(:serializer_entity) { 'basic' }
it 'calls super class #represent with correct params' do it 'calls super class #represent with correct params' do
expect_any_instance_of(BaseSerializer).to receive(:represent) expect_any_instance_of(BaseSerializer).to receive(:represent)
...@@ -23,8 +23,8 @@ describe MergeRequestSerializer do ...@@ -23,8 +23,8 @@ describe MergeRequestSerializer do
end end
end end
context 'when basic param is falsy' do context 'when serializer param is falsy' do
let(:basic) { false } let(:serializer_entity) { nil }
it 'calls super class #represent with correct params' do it 'calls super class #represent with correct params' do
expect_any_instance_of(BaseSerializer).to receive(:represent) expect_any_instance_of(BaseSerializer).to receive(:represent)
......
require 'spec_helper'
describe Milestones::PromoteService do
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:user) { create(:user) }
let(:milestone_title) { 'project milestone' }
let(:milestone) { create(:milestone, project: project, title: milestone_title) }
let(:service) { described_class.new(project, user) }
describe '#execute' do
before do
group.add_master(user)
end
context 'validations' do
it 'raises error if milestone does not belong to a project' do
allow(milestone).to receive(:project_milestone?).and_return(false)
expect { service.execute(milestone) }.to raise_error(described_class::PromoteMilestoneError)
end
it 'raises error if project does not belong to a group' do
project.update(namespace: user.namespace)
expect { service.execute(milestone) }.to raise_error(described_class::PromoteMilestoneError)
end
end
context 'without duplicated milestone titles across projects' do
it 'promotes project milestone to group milestone' do
promoted_milestone = service.execute(milestone)
expect(promoted_milestone).to be_group_milestone
end
it 'sets issuables with new promoted milestone' do
issue = create(:issue, milestone: milestone, project: project)
merge_request = create(:merge_request, milestone: milestone, source_project: project)
promoted_milestone = service.execute(milestone)
expect(promoted_milestone).to be_group_milestone
expect(issue.reload.milestone).to eq(promoted_milestone)
expect(merge_request.reload.milestone).to eq(promoted_milestone)
end
end
context 'with duplicated milestone titles across projects' do
let(:project_2) { create(:project, namespace: group) }
let!(:milestone_2) { create(:milestone, project: project_2, title: milestone_title) }
it 'deletes project milestones with the same title' do
promoted_milestone = service.execute(milestone)
expect(promoted_milestone).to be_group_milestone
expect(promoted_milestone).to be_valid
expect(Milestone.exists?(milestone.id)).to be_falsy
expect(Milestone.exists?(milestone_2.id)).to be_falsy
end
it 'sets all issuables with new promoted milestone' do
issue = create(:issue, milestone: milestone, project: project)
issue_2 = create(:issue, milestone: milestone_2, project: project_2)
merge_request = create(:merge_request, milestone: milestone, source_project: project)
merge_request_2 = create(:merge_request, milestone: milestone_2, source_project: project_2)
promoted_milestone = service.execute(milestone)
expect(issue.reload.milestone).to eq(promoted_milestone)
expect(issue_2.reload.milestone).to eq(promoted_milestone)
expect(merge_request.reload.milestone).to eq(promoted_milestone)
expect(merge_request_2.reload.milestone).to eq(promoted_milestone)
end
end
end
end
require 'spec_helper'
require 'nokogiri'
describe 'shared/issuable/_participants.html.haml' do
let(:project) { create(:project) }
let(:participants) { create_list(:user, 100) }
before do
allow(view).to receive_messages(project: project,
participants: participants)
end
it 'renders lazy loaded avatars' do
render 'shared/issuable/participants'
html = Nokogiri::HTML(rendered)
avatars = html.css('.participants-author img')
avatars.each do |avatar|
expect(avatar[:class]).to include('lazy')
expect(avatar[:src]).to eql(LazyImageTagHelper.placeholder_image)
expect(avatar[:"data-src"]).to match('http://www.gravatar.com/avatar/')
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment