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;
flash(message, 'alert', this.$el);
},
createdNewBranch(newBranchName) {
this.loading = false; this.loading = false;
this.branchName = ''; 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 Object.assign(e, {
Helper.removeAllTmpFiles('openedFiles'); returnValue,
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);
eventHub.$emit('toggleNewBranchDropdown');
}).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
v-if="isCollapsed"
class="panel-right" class="panel-right"
:class="{'edit-mode': editMode}"> >
<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() {
computed: { return {
showCommitable() { showNewBranchDialog: false,
return this.isCommitable && this.changedFiles.length; submitCommitsLoading: false,
}, startNewMR: false,
commitMessage: '',
branchPaths() { };
return this.changedFiles.map(f => f.path);
}, },
computed: {
cantCommitYet() { ...mapState([
'currentBranch',
]),
...mapGetters([
'changedFiles',
]),
commitButtonDisabled() {
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) => {
if (branchChanged) {
this.showNewBranchDialog = true;
} else { } else {
Store.setBranchHash() this.makeCommit();
.then(() => {
if (Store.branchChanged) {
Store.showNewBranchDialog = true;
return;
} }
this.makeCommit(newBranch);
}) })
.catch(() => { .catch(() => {
this.submitCommitsLoading = false; 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
v-if="canEditFile"
class="btn btn-default" class="btn btn-default"
type="button" type="button"
@click.prevent="editCancelClicked"> @click.prevent="toggleEditMode()">
<i <i
v-if="!editMode" v-if="!editMode"
class="fa fa-pencil" class="fa fa-pencil"
...@@ -46,5 +42,16 @@ export default { ...@@ -46,5 +42,16 @@ export default {
<span> <span>
{{buttonLabel}} {{buttonLabel}}
</span> </span>
</button> </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;
this.initMonaco();
});
}
},
methods: {
...mapActions([
'getRawFileData',
'changeFileContent',
]),
initMonaco() {
if (this.monacoInstance) {
this.monacoInstance.setModel(null);
}
const monacoInstance = Helper.monaco.editor.create(this.$el, { this.getRawFileData(this.activeFile)
.then(() => {
if (!this.monacoInstance) {
this.monacoInstance = this.monaco.editor.create(this.$el, {
model: null, model: null,
readOnly: false, readOnly: false,
contextmenu: true, contextmenu: true,
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
}); });
Helper.monacoInstance = monacoInstance; this.languages = this.monaco.languages.getLanguages();
this.addMonacoEvents(); this.addMonacoEvents();
}
this.setupEditor(); this.setupEditor();
}) })
.catch(Helper.loadingError); .catch(() => flash('Error setting up monaco. Please try again.'));
}, },
methods: {
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: {
dialog: {
handler(obj) {
const newObj = obj;
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();
}
}, },
watch: {
activeLine() { activeFile(oldVal, newVal) {
if (Helper.monacoInstance) { if (newVal && !newVal.active) {
Helper.monacoInstance.setPosition({ this.initMonaco();
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,16 +33,18 @@ module IssuablesHelper ...@@ -33,16 +33,18 @@ 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
end
serializer_klass
.new(current_user: current_user, project: issuable.project) .new(current_user: current_user, project: issuable.project)
.represent(issuable) .represent(issuable)
.to_json .to_json
end end
end
def template_dropdown_tag(issuable, &block) def template_dropdown_tag(issuable, &block)
title = selected_template(issuable) || "Choose a template" title = selected_template(issuable) || "Choose a template"
...@@ -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();
setTimeout(() => {
expect(vm.loading).toBeFalsy(); expect(vm.loading).toBeFalsy();
});
it('resets branch name', () => {
vm.branchName = 'testing';
vm.createdNewBranch();
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(el) { function createComponent() {
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;
RepoStore.openedFiles = openedFiles;
const vm = createComponent(); resetStore(vm.$store);
});
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]; expect(commit_message).toBe('testing');
const { commit_message, actions, branch: payloadBranch } = args[0];
expect(commit_message).toBe(commitMessage);
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');
expect(vm.$el.querySelector('.btn')).not.toBeNull();
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
});
spyOn(vm, 'editCancelClicked').and.callThrough(); it('toggles edit mode on click', (done) => {
vm.$mount();
vm.$el.click(); 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();
});
const vm = createComponent(); it('removes all changed files', (done) => {
vm.$el.querySelector('.btn-warning').click();
expect(vm.$el.innerHTML).toBeUndefined(); vm.$nextTick(() => {
expect(vm.$store.getters.changedFiles.length).toBe(0);
expect(vm.$el.querySelector('.modal')).toBeNull();
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,
}); });
afterEach(() => { f.active = true;
RepoStore.openedFiles = []; f.tempFile = true;
vm.$store.state.openFiles.push(f);
vm.monaco = true;
vm.$mount();
}); });
it('renders an ide container', (done) => { afterEach(() => {
this.vm.openedFiles = ['idiidid']; vm.$destroy();
this.vm.binary = false;
Vue.nextTick(() => { resetStore(vm.$store);
expect(this.vm.shouldHideEditor).toBe(false);
expect(this.vm.$el.id).toEqual('ide');
expect(this.vm.$el.tagName).toBe('DIV');
done();
});
}); });
describe('when there are no open files', () => { it('renders an ide container', (done) => {
it('does not render the ide', (done) => {
this.vm.openedFiles = [];
Vue.nextTick(() => { Vue.nextTick(() => {
expect(this.vm.shouldHideEditor).toBe(true); expect(vm.shouldHideEditor).toBeFalsy();
expect(this.vm.$el.tagName).not.toBeDefined();
done(); 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();
resetStore(vm.$store);
}); });
it('renders Raw, Blame, History, Permalink and Preview toggle', () => { it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => {
const activeFileLabel = 'activeFileLabel'; vm = createComponent();
RepoStore.openedFiles = new Array(1);
RepoStore.activeFile = activeFile;
RepoStore.activeFileLabel = activeFileLabel;
RepoStore.editMode = true;
RepoStore.binary = false;
const vm = createComponent(); vm.$nextTick(() => {
const raw = vm.$el.querySelector('.raw'); const raw = vm.$el.querySelector('.raw');
const blame = vm.$el.querySelector('.blame'); const blame = vm.$el.querySelector('.blame');
const history = vm.$el.querySelector('.history'); const history = vm.$el.querySelector('.history');
expect(raw.href).toMatch(`/${activeFile.raw_path}`); expect(raw.href).toMatch(`/${activeFile.rawPath}`);
expect(raw.textContent.trim()).toEqual('Raw'); expect(raw.textContent.trim()).toEqual('Raw');
expect(blame.href).toMatch(`/${activeFile.blame_path}`); expect(blame.href).toMatch(`/${activeFile.blamePath}`);
expect(blame.textContent.trim()).toEqual('Blame'); expect(blame.textContent.trim()).toEqual('Blame');
expect(history.href).toMatch(`/${activeFile.commits_path}`); expect(history.href).toMatch(`/${activeFile.commitsPath}`);
expect(history.textContent.trim()).toEqual('History'); expect(history.textContent.trim()).toEqual('History');
expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink'); expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink');
expect(vm.$el.querySelector('.preview').textContent.trim()).toEqual(activeFileLabel);
});
it('triggers rawPreviewToggle on preview click', () => {
RepoStore.openedFiles = new Array(1);
RepoStore.activeFile = activeFile;
RepoStore.editMode = true;
const vm = createComponent();
const preview = vm.$el.querySelector('.preview');
spyOn(vm, 'rawPreviewToggle'); done();
preview.click();
expect(vm.rawPreviewToggle).toHaveBeenCalled();
}); });
it('does not render preview toggle if not canPreview', () => {
activeFile.extension = 'js';
RepoStore.openedFiles = new Array(1);
RepoStore.activeFile = activeFile;
const vm = createComponent();
expect(vm.$el.querySelector('.preview')).toBeFalsy();
}); });
}); });
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);
vm.$nextTick(() => {
expect(vm.$el.querySelector('.commit-message')).toBeFalsy(); expect(vm.$el.querySelector('.commit-message')).toBeFalsy();
expect(vm.$el.querySelector('.commit-update')).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, vm.$nextTick(() => {
},
hasFiles: false,
});
const columns = [...vm.$el.querySelectorAll('td')]; const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(1); expect(columns.length).toEqual(1);
assertColumns(columns); 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'; expect(link.href).toMatch(`/${parentLink}`);
const vm = createComponent({ expect(link.textContent).toEqual('...');
prevUrl,
}); });
spyOn(eventHub, '$emit'); it('clicking row triggers getTreeData', () => {
spyOn(vm, 'getTreeData');
vm.linkClicked(prevUrl); vm.$el.querySelector('td').click();
expect(eventHub.$emit).toHaveBeenCalledWith('goToPreviousDirectoryClicked', prevUrl); 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');
}); });
}); });
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment