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 = {
usersPath: '/api/:version/users.json',
commitPath: '/api/:version/projects/:id/repository/commits',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath)
......
......@@ -2,11 +2,8 @@ import Cookies from 'js-cookie';
import bp from './breakpoints';
import UsersSelect from './users_select';
const PARTICIPANTS_ROW_COUNT = 7;
export default class IssuableContext {
constructor(currentUser) {
this.initParticipants();
this.userSelect = new UsersSelect(currentUser);
$('select.select2').select2({
......@@ -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>
import { mapState, mapActions } from 'vuex';
import flash, { hideFlash } from '../../flash';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import eventHub from '../event_hub';
export default {
components: {
loadingIcon,
},
props: {
currentBranch: {
type: String,
required: true,
},
},
data() {
return {
branchName: '',
......@@ -20,11 +14,17 @@
};
},
computed: {
...mapState([
'currentBranch',
]),
btnDisabled() {
return this.loading || this.branchName === '';
},
},
methods: {
...mapActions([
'createNewBranch',
]),
toggleDropdown() {
this.$dropdown.dropdown('toggle');
},
......@@ -38,19 +38,21 @@
hideFlash(flashEl, false);
}
eventHub.$emit('createNewBranch', this.branchName);
},
showErrorMessage(message) {
this.loading = false;
flash(message, 'alert', this.$el);
},
createdNewBranch(newBranchName) {
this.loading = false;
this.branchName = '';
this.createNewBranch(this.branchName)
.then(() => {
this.loading = false;
this.branchName = '';
if (this.dropdownText) {
this.dropdownText.textContent = newBranchName;
}
if (this.dropdownText) {
this.dropdownText.textContent = this.currentBranch;
}
this.toggleDropdown();
})
.catch(res => res.json().then((data) => {
this.loading = false;
flash(data.message, 'alert', this.$el);
}));
},
},
created() {
......@@ -59,15 +61,6 @@
// text element is outside Vue app
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>
import RepoStore from '../../stores/repo_store';
import RepoHelper from '../../helpers/repo_helper';
import eventHub from '../../event_hub';
import { mapState } from 'vuex';
import newModal from './modal.vue';
import upload from './upload.vue';
......@@ -14,9 +12,13 @@
return {
openModal: false,
modalType: '',
currentPath: RepoStore.path,
};
},
computed: {
...mapState([
'path',
]),
},
methods: {
createNewItem(type) {
this.modalType = type;
......@@ -25,6 +27,7 @@
toggleModalOpen() {
this.openModal = !this.openModal;
},
<<<<<<< HEAD
createNewEntryInStore(options, openEditMode = true) {
RepoHelper.createNewEntry(options, openEditMode);
......@@ -38,6 +41,8 @@
},
beforeDestroy() {
eventHub.$off('createNewEntry', this.createNewEntryInStore);
=======
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
},
};
</script>
......@@ -88,7 +93,7 @@
<new-modal
v-if="openModal"
:type="modalType"
:current-path="currentPath"
:path="path"
@toggle="toggleModalOpen"
/>
</div>
......
<script>
import { mapActions } from 'vuex';
import { __ } from '../../../locale';
import popupDialog from '../../../vue_shared/components/popup_dialog.vue';
import eventHub from '../../event_hub';
export default {
props: {
currentPath: {
path: {
type: String,
required: true,
},
......@@ -16,19 +16,31 @@
},
data() {
return {
entryName: this.currentPath !== '' ? `${this.currentPath}/` : '',
entryName: this.path !== '' ? `${this.path}/` : '',
};
},
components: {
popupDialog,
},
methods: {
...mapActions([
'createTempEntry',
]),
createEntryInStore() {
<<<<<<< HEAD
eventHub.$emit('createNewEntry', {
name: this.entryName,
type: this.type,
toggleModal: true,
});
=======
this.createTempEntry({
name: this.entryName.replace(new RegExp(`^${this.path}/`), ''),
type: this.type,
});
this.toggleModalOpen();
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
},
toggleModalOpen() {
this.$emit('toggle');
......
<script>
import { mapState, mapGetters } from 'vuex';
import RepoSidebar from './repo_sidebar.vue';
import RepoCommitSection from './repo_commit_section.vue';
import RepoTabs from './repo_tabs.vue';
import RepoFileButtons from './repo_file_buttons.vue';
import RepoPreview from './repo_preview.vue';
import RepoMixin from '../mixins/repo_mixin';
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';
import repoEditor from './repo_editor.vue';
export default {
data() {
return Store;
computed: {
...mapState([
'currentBlobView',
]),
...mapGetters([
'isCollapsed',
'changedFiles',
]),
},
mixins: [RepoMixin],
components: {
RepoSidebar,
RepoTabs,
RepoFileButtons,
'repo-editor': MonacoLoaderHelper.repoEditorLoader,
repoEditor,
RepoCommitSection,
PopupDialog,
RepoPreview,
},
created() {
eventHub.$on('createNewBranch', this.createNewBranch);
},
mounted() {
Helper.getContent().catch(Helper.loadingError);
},
destroyed() {
eventHub.$off('createNewBranch', this.createNewBranch);
},
methods: {
getCurrentLocation() {
return location.href;
},
toggleDialogOpen(toggle) {
this.dialog.open = toggle;
},
dialogSubmitted(status) {
this.toggleDialogOpen(false);
this.dialog.status = status;
// remove tmp files
Helper.removeAllTmpFiles('openedFiles');
Helper.removeAllTmpFiles('files');
},
toggleBlobView: Store.toggleBlobView,
createNewBranch(branch) {
Service.createBranch({
branch,
ref: Store.currentBranch,
}).then((res) => {
const newBranchName = res.data.name;
const newUrl = this.getCurrentLocation().replace(Store.currentBranch, newBranchName);
Store.currentBranch = newBranchName;
history.pushState({ key: Helper.key }, '', newUrl);
const returnValue = 'Are you sure you want to lose unsaved changes?';
window.onbeforeunload = (e) => {
if (!this.changedFiles.length) return undefined;
eventHub.$emit('createNewBranchSuccess', newBranchName);
eventHub.$emit('toggleNewBranchDropdown');
}).catch((err) => {
eventHub.$emit('createNewBranchError', err.response.data.message);
Object.assign(e, {
returnValue,
});
},
return returnValue;
};
},
};
</script>
<template>
<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/>
<div v-if="isMini"
class="panel-right"
:class="{'edit-mode': editMode}">
<div
v-if="isCollapsed"
class="panel-right"
>
<repo-tabs/>
<component
:is="currentBlobView"
class="blob-viewer-container"/>
/>
<repo-file-buttons/>
</div>
</div>
<repo-commit-section/>
<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"
/>
<repo-commit-section v-if="changedFiles.length" />
</div>
</template>
<script>
import Flash from '../../flash';
import Store from '../stores/repo_store';
import RepoMixin from '../mixins/repo_mixin';
import Service from '../services/repo_service';
import { mapGetters, mapState, mapActions } from 'vuex';
import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
import { visitUrl } from '../../lib/utils/url_utility';
import { n__ } from '../../locale';
export default {
mixins: [RepoMixin],
data() {
return Store;
},
components: {
PopupDialog,
},
data() {
return {
showNewBranchDialog: false,
submitCommitsLoading: false,
startNewMR: false,
commitMessage: '',
};
},
computed: {
showCommitable() {
return this.isCommitable && this.changedFiles.length;
},
branchPaths() {
return this.changedFiles.map(f => f.path);
},
cantCommitYet() {
...mapState([
'currentBranch',
]),
...mapGetters([
'changedFiles',
]),
commitButtonDisabled() {
return !this.commitMessage || this.submitCommitsLoading;
},
filePluralize() {
return this.changedFiles.length > 1 ? 'files' : 'file';
commitButtonText() {
return n__('Commit %d file', 'Commit %d files', this.changedFiles.length);
},
},
methods: {
commitToNewBranch(status) {
if (status) {
this.showNewBranchDialog = false;
this.tryCommit(null, true, true);
} else {
// reset the state
}
},
...mapActions([
'checkCommitStatus',
'commitChanges',
'getTreeData',
]),
makeCommit(newBranch = false) {
const createNewBranch = newBranch || this.startNewMR;
<<<<<<< HEAD
makeCommit(newBranch) {
// see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
const commitMessage = this.commitMessage;
......@@ -55,88 +49,65 @@ export default {
encoding: f.base64 ? 'base64' : 'text',
}));
const branch = newBranch ? `${this.currentBranch}-${this.currentShortHash}` : this.currentBranch;
=======
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
const payload = {
branch,
commit_message: commitMessage,
actions,
branch: createNewBranch ? `${this.currentBranch}-${new Date().getTime().toString()}` : this.currentBranch,
commit_message: this.commitMessage,
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;
}
Service.commitFiles(payload)
this.showNewBranchDialog = false;
this.submitCommitsLoading = true;
this.commitChanges({ payload, newMr: this.startNewMR })
.then(() => {
this.resetCommitState();
if (this.startNewMR) {
this.redirectToNewMr(branch);
} else {
this.redirectToBranch(branch);
}
this.submitCommitsLoading = false;
this.getTreeData();
})
.catch(() => {
Flash('An error occurred while committing your changes');
this.submitCommitsLoading = false;
});
},
tryCommit(e, skipBranchCheck = false, newBranch = false) {
tryCommit() {
this.submitCommitsLoading = true;
if (skipBranchCheck) {
this.makeCommit(newBranch);
} else {
Store.setBranchHash()
.then(() => {
if (Store.branchChanged) {
Store.showNewBranchDialog = true;
return;
}
this.makeCommit(newBranch);
})
.catch(() => {
this.submitCommitsLoading = false;
Flash('An error occurred while committing your changes');
});
}
},
redirectToNewMr(branch) {
visitUrl(this.newMrTemplateUrl.replace('{{source_branch}}', branch));
},
redirectToBranch(branch) {
visitUrl(this.customBranchURL.replace('{{branch}}', branch));
},
resetCommitState() {
this.submitCommitsLoading = false;
this.openedFiles = this.openedFiles.map((file) => {
const f = file;
f.changed = false;
return f;
});
this.changedFiles = [];
this.commitMessage = '';
this.editMode = false;
window.scrollTo(0, 0);
this.checkCommitStatus()
.then((branchChanged) => {
if (branchChanged) {
this.showNewBranchDialog = true;
} else {
this.makeCommit();
}
})
.catch(() => {
this.submitCommitsLoading = false;
});
},
},
};
</script>
<template>
<div
v-if="showCommitable"
id="commit-area">
<div id="commit-area">
<popup-dialog
v-if="showNewBranchDialog"
:primary-button-label="__('Create new branch')"
kind="primary"
:title="__('Branch has changed')"
: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
class="form-horizontal"
@submit.prevent="tryCommit">
@submit.prevent="tryCommit()">
<fieldset>
<div class="form-group">
<label class="col-md-4 control-label staged-files">
......@@ -145,10 +116,10 @@ export default {
<div class="col-md-6">
<ul class="list-unstyled changed-files">
<li
v-for="branchPath in branchPaths"
:key="branchPath">
v-for="(file, index) in changedFiles"
:key="index">
<span class="help-block">
{{branchPath}}
{{ file.path }}
</span>
</li>
</ul>
......@@ -183,9 +154,8 @@ export default {
</div>
<div class="col-md-offset-4 col-md-6">
<button
ref="submitCommit"
type="submit"
:disabled="cantCommitYet"
:disabled="commitButtonDisabled"
class="btn btn-success">
<i
v-if="submitCommitsLoading"
......@@ -194,7 +164,7 @@ export default {
aria-label="loading">
</i>
<span class="commit-summary">
Commit {{changedFiles.length}} {{filePluralize}}
{{ commitButtonText }}
</span>
</button>
</div>
......
<script>
import Store from '../stores/repo_store';
import RepoMixin from '../mixins/repo_mixin';
import { mapGetters, mapActions, mapState } from 'vuex';
import popupDialog from '../../vue_shared/components/popup_dialog.vue';
export default {
data() {
return Store;
components: {
popupDialog,
},
mixins: [RepoMixin],
computed: {
...mapState([
'editMode',
'discardPopupOpen',
]),
...mapGetters([
'canEditFile',
]),
buttonLabel() {
return this.editMode ? this.__('Cancel edit') : this.__('Edit');
},
showButton() {
return this.isCommitable &&
!this.activeFile.render_error &&
!this.binary &&
this.openedFiles.length;
},
},
methods: {
editCancelClicked() {
if (this.changedFiles.length) {
this.dialog.open = true;
return;
}
this.editMode = !this.editMode;
Store.toggleBlobView();
},
...mapActions([
'toggleEditMode',
'closeDiscardPopup',
]),
},
};
</script>
<template>
<button
v-if="showButton"
class="btn btn-default"
type="button"
@click.prevent="editCancelClicked">
<i
v-if="!editMode"
class="fa fa-pencil"
aria-hidden="true">
</i>
<span>
{{buttonLabel}}
</span>
</button>
<div class="editable-mode">
<button
v-if="canEditFile"
class="btn btn-default"
type="button"
@click.prevent="toggleEditMode()">
<i
v-if="!editMode"
class="fa fa-pencil"
aria-hidden="true">
</i>
<span>
{{buttonLabel}}
</span>
</button>
<popup-dialog
v-if="discardPopupOpen"
class="text-left"
:primary-button-label="__('Discard changes')"
kind="warning"
:title="__('Are you sure?')"
:text="__('Are you sure you want to discard your changes?')"
@toggle="closeDiscardPopup"
@submit="toggleEditMode(true)"
/>
</div>
</template>
<script>
/* global monaco */
import Store from '../stores/repo_store';
import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper';
const RepoEditor = {
data() {
return Store;
},
import { mapGetters, mapActions } from 'vuex';
import flash from '../../flash';
import monacoLoader from '../monaco_loader';
export default {
destroyed() {
if (Helper.monacoInstance) {
Helper.monacoInstance.destroy();
if (this.monacoInstance) {
this.monacoInstance.destroy();
}
},
mounted() {
Service.getRaw(this.activeFile)
.then((rawResponse) => {
Store.blobRaw = rawResponse.data;
Store.activeFile.plain = rawResponse.data;
const monacoInstance = Helper.monaco.editor.create(this.$el, {
model: null,
readOnly: false,
contextmenu: true,
scrollBeyondLastLine: false,
});
if (this.monaco) {
this.initMonaco();
} else {
monacoLoader(['vs/editor/editor.main'], () => {
this.monaco = monaco;
this.initMonaco();
});
}
},
methods: {
...mapActions([
'getRawFileData',
'changeFileContent',
]),
initMonaco() {
if (this.monacoInstance) {
this.monacoInstance.setModel(null);
}
Helper.monacoInstance = monacoInstance;
this.getRawFileData(this.activeFile)
.then(() => {
if (!this.monacoInstance) {
this.monacoInstance = this.monaco.editor.create(this.$el, {
model: null,
readOnly: false,
contextmenu: true,
scrollBeyondLastLine: false,
});
this.addMonacoEvents();
this.languages = this.monaco.languages.getLanguages();
this.setupEditor();
})
.catch(Helper.loadingError);
},
this.addMonacoEvents();
}
methods: {
this.setupEditor();
})
.catch(() => flash('Error setting up monaco. Please try again.'));
},
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() {
if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) {
this.$el.style.display = 'none';
} else {
this.$el.style.display = 'inline-block';
}
this.monacoInstance.setModel(newModel);
},
addMonacoEvents() {
Helper.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp);
Helper.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this));
},
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);
}
this.monacoInstance.onKeyUp(() => {
this.changeFileContent({
file: this.activeFile,
content: this.monacoInstance.getValue(),
});
});
},
},
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();
}
},
activeLine() {
if (Helper.monacoInstance) {
Helper.monacoInstance.setPosition({
lineNumber: this.activeLine,
column: 1,
});
activeFile(oldVal, newVal) {
if (newVal && !newVal.active) {
this.initMonaco();
}
},
},
computed: {
...mapGetters([
'activeFile',
'activeFileExtension',
]),
shouldHideEditor() {
return !this.openedFiles.length || (this.binary && !this.activeFile.raw);
return this.activeFile.binary && !this.activeFile.raw;
},
},
};
export default RepoEditor;
</script>
<template>
<div id="ide" v-if='!shouldHideEditor'></div>
<div
id="ide"
v-if='!shouldHideEditor'
class="blob-viewer-container blob-editor-container"
>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import timeAgoMixin from '../../vue_shared/mixins/timeago';
import eventHub from '../event_hub';
import repoMixin from '../mixins/repo_mixin';
export default {
mixins: [
repoMixin,
timeAgoMixin,
],
props: {
......@@ -15,13 +13,15 @@
},
},
computed: {
...mapGetters([
'isCollapsed',
]),
fileIcon() {
const classObj = {
return {
'fa-spinner fa-spin': this.file.loading,
[this.file.icon]: !this.file.loading,
'fa-folder-open': !this.file.loading && this.file.opened,
};
return classObj;
},
levelIndentation() {
return {
......@@ -33,9 +33,9 @@
},
},
methods: {
linkClicked(file) {
eventHub.$emit('fileNameClicked', file);
},
...mapActions([
'clickedTreeRow',
]),
},
};
</script>
......@@ -43,7 +43,7 @@
<template>
<tr
class="file"
@click.prevent="linkClicked(file)">
@click.prevent="clickedTreeRow(file)">
<td>
<i
class="fa fa-fw file-icon"
......@@ -71,7 +71,7 @@
</template>
</td>
<template v-if="!isMini">
<template v-if="!isCollapsed">
<td class="hidden-sm hidden-xs">
<a
@click.stop
......
<script>
import Store from '../stores/repo_store';
import Helper from '../helpers/repo_helper';
import RepoMixin from '../mixins/repo_mixin';
const RepoFileButtons = {
data() {
return Store;
},
mixins: [RepoMixin],
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters([
'activeFile',
]),
showButtons() {
return this.activeFile.raw_path ||
this.activeFile.blame_path ||
this.activeFile.commits_path ||
return this.activeFile.rawPath ||
this.activeFile.blamePath ||
this.activeFile.commitsPath ||
this.activeFile.permalink;
},
rawDownloadButtonLabel() {
return this.binary ? 'Download' : 'Raw';
},
canPreview() {
return Helper.isRenderable();
return this.activeFile.binary ? 'Download' : 'Raw';
},
},
methods: {
rawPreviewToggle: Store.toggleRawPreview,
},
};
export default RepoFileButtons;
</script>
<template>
......@@ -40,11 +25,11 @@ export default RepoFileButtons;
class="repo-file-buttons"
>
<a
:href="activeFile.raw_path"
:href="activeFile.rawPath"
target="_blank"
class="btn btn-default raw"
rel="noopener noreferrer">
{{rawDownloadButtonLabel}}
{{ rawDownloadButtonLabel }}
</a>
<div
......@@ -52,12 +37,12 @@ export default RepoFileButtons;
role="group"
aria-label="File actions">
<a
:href="activeFile.blame_path"
:href="activeFile.blamePath"
class="btn btn-default blame">
Blame
</a>
<a
:href="activeFile.commits_path"
:href="activeFile.commitsPath"
class="btn btn-default history">
History
</a>
......@@ -67,13 +52,5 @@ export default RepoFileButtons;
Permalink
</a>
</div>
<a
v-if="canPreview"
href="#"
@click.prevent="rawPreviewToggle"
class="btn btn-default preview">
{{activeFileLabel}}
</a>
</div>
</template>
<script>
import repoMixin from '../mixins/repo_mixin';
import { mapGetters } from 'vuex';
export default {
mixins: [
repoMixin,
],
computed: {
...mapGetters([
'isCollapsed',
]),
},
methods: {
lineOfCode(n) {
return `skeleton-line-${n}`;
......@@ -28,7 +30,7 @@
</div>
</div>
</td>
<template v-if="!isMini">
<template v-if="!isCollapsed">
<td
class="hidden-sm hidden-xs">
<div class="animation-container">
......
<script>
import eventHub from '../event_hub';
import repoMixin from '../mixins/repo_mixin';
import { mapGetters, mapState, mapActions } from 'vuex';
export default {
mixins: [
repoMixin,
],
props: {
prevUrl: {
type: String,
required: true,
},
},
computed: {
...mapState([
'parentTreeUrl',
]),
...mapGetters([
'isCollapsed',
]),
colSpanCondition() {
return this.isMini ? undefined : 3;
return this.isCollapsed ? undefined : 3;
},
},
methods: {
linkClicked(file) {
eventHub.$emit('goToPreviousDirectoryClicked', file);
},
...mapActions([
'getTreeData',
]),
},
};
</script>
......@@ -30,9 +26,9 @@
<td
:colspan="colSpanCondition"
class="table-cell"
@click.prevent="linkClicked(prevUrl)"
@click.prevent="getTreeData({ endpoint: parentTreeUrl })"
>
<a :href="prevUrl">...</a>
<a :href="parentTreeUrl">...</a>
</td>
</tr>
</template>
<script>
/* global LineHighlighter */
import Store from '../stores/repo_store';
import { mapGetters } from 'vuex';
export default {
data() {
return Store;
},
computed: {
html() {
return this.activeFile.html;
...mapGetters([
'activeFile',
]),
renderErrorTooLarge() {
return this.activeFile.renderError === 'too_large';
},
},
methods: {
highlightFile() {
$(this.$el).find('.file-content').syntaxHighlight();
},
highlightLine() {
if (Store.activeLine > -1) {
this.lineHighlighter.highlightHash(`#L${Store.activeLine}`);
}
},
},
mounted() {
this.highlightFile();
......@@ -29,24 +23,18 @@ export default {
scrollFileHolder: true,
});
},
watch: {
html() {
this.$nextTick(() => {
this.highlightFile();
this.highlightLine();
});
},
activeLine() {
this.highlightLine();
},
updated() {
this.$nextTick(() => {
this.highlightFile();
});
},
};
</script>
<template>
<div>
<div class="blob-viewer-container">
<div
v-if="!activeFile.render_error"
v-if="!activeFile.renderError"
v-html="activeFile.html">
</div>
<div
......@@ -57,17 +45,21 @@ export default {
</p>
</div>
<div
<<<<<<< HEAD
v-else-if="activeFile.tooLarge"
=======
v-else-if="renderErrorTooLarge"
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
class="vertical-center render-error">
<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>
</div>
<div
v-else
class="vertical-center render-error">
<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>
</div>
</div>
......
<script>
import _ from 'underscore';
import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper';
import Store from '../stores/repo_store';
import eventHub from '../event_hub';
import { mapState, mapGetters, mapActions } from 'vuex';
import RepoPreviousDirectory from './repo_prev_directory.vue';
import RepoFile from './repo_file.vue';
import RepoLoadingFile from './repo_loading_file.vue';
import RepoMixin from '../mixins/repo_mixin';
export default {
mixins: [RepoMixin],
components: {
'repo-previous-directory': RepoPreviousDirectory,
'repo-file': RepoFile,
'repo-loading-file': RepoLoadingFile,
},
created() {
window.addEventListener('popstate', this.checkHistory);
window.addEventListener('popstate', this.popHistoryState);
},
destroyed() {
eventHub.$off('fileNameClicked', this.fileClicked);
eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
window.removeEventListener('popstate', this.checkHistory);
window.removeEventListener('popstate', this.popHistoryState);
},
mounted() {
eventHub.$on('fileNameClicked', this.fileClicked);
eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
},
data() {
return Store;
this.getTreeData();
},
computed: {
flattendFiles() {
const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)]));
return _.chain(this.files)
.map(arr => [arr, mapFiles(arr)])
.flatten()
.value();
},
...mapState([
'loading',
'isRoot',
]),
...mapState({
projectName(state) {
return state.project.name;
},
}),
...mapGetters([
'treeList',
'isCollapsed',
]),
},
methods: {
checkHistory() {
let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1);
if (!selectedFile) {
// 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);
},
...mapActions([
'getTreeData',
'popHistoryState',
]),
},
};
</script>
<template>
<div id="sidebar" :class="{'sidebar-mini' : isMini}">
<div id="sidebar" :class="{'sidebar-mini' : isCollapsed}">
<table class="table">
<thead>
<tr>
<th
v-if="isMini"
v-if="isCollapsed"
class="repo-file-options title"
>
<strong class="clgray">
......@@ -136,17 +71,16 @@ export default {
</thead>
<tbody>
<repo-previous-directory
v-if="!isRoot && !loading.tree"
:prev-url="prevURL"
v-if="!isRoot && treeList.length"
/>
<repo-loading-file
v-if="!flattendFiles.length && loading.tree"
v-if="!treeList.length && loading"
v-for="n in 5"
:key="n"
/>
<repo-file
v-for="file in flattendFiles"
:key="file.id"
v-for="(file, index) in treeList"
:key="index"
:file="file"
/>
</tbody>
......
<script>
import Store from '../stores/repo_store';
import { mapActions } from 'vuex';
const RepoTab = {
export default {
props: {
tab: {
type: Object,
......@@ -11,7 +11,7 @@ const RepoTab = {
computed: {
closeLabel() {
if (this.tab.changed) {
if (this.tab.changed || this.tab.tempFile) {
return `${this.tab.name} changed`;
}
return `Close ${this.tab.name}`;
......@@ -26,29 +26,23 @@ const RepoTab = {
},
methods: {
tabClicked(file) {
Store.setActiveFiles(file);
},
closeTab(file) {
if (file.changed || file.tempFile) return;
Store.removeFromOpenedFiles(file);
},
...mapActions([
'setFileActive',
'closeFile',
]),
},
};
export default RepoTab;
</script>
<template>
<li
:class="{ active : tab.active }"
@click="tabClicked(tab)"
@click="setFileActive(tab)"
>
<button
type="button"
class="close-btn"
@click.stop.prevent="closeTab(tab)"
@click.stop.prevent="closeFile({ file: tab })"
:aria-label="closeLabel">
<i
class="fa"
......@@ -61,7 +55,7 @@ export default RepoTab;
href="#"
class="repo-tab"
:title="tab.url"
@click.prevent="tabClicked(tab)">
@click.prevent.stop="setFileActive(tab)">
{{tab.name}}
</a>
</li>
......
<script>
import Store from '../stores/repo_store';
import { mapState } from 'vuex';
import RepoTab from './repo_tab.vue';
import RepoMixin from '../mixins/repo_mixin';
export default {
mixins: [RepoMixin],
components: {
'repo-tab': RepoTab,
},
data() {
return Store;
computed: {
...mapState([
'openFiles',
]),
},
};
</script>
......@@ -20,7 +20,7 @@
class="list-unstyled"
>
<repo-tab
v-for="tab in openedFiles"
v-for="tab in openFiles"
:key="tab.id"
: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 { mapActions } from 'vuex';
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 RepoEditButton from './components/repo_edit_button.vue';
import newBranchForm from './components/new_branch_form.vue';
import newDropdown from './components/new_dropdown/index.vue';
import store from './stores';
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) {
if (!el) return null;
return new Vue({
el,
store,
components: {
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) {
return createElement('repo');
},
......@@ -59,15 +54,20 @@ function initRepo(el) {
function initRepoEditButton(el) {
return new Vue({
el,
store,
components: {
repoEditButton: RepoEditButton,
},
render(createElement) {
return createElement('repo-edit-button');
},
});
}
function initNewDropdown(el) {
return new Vue({
el,
store,
components: {
newDropdown,
},
......@@ -87,32 +87,20 @@ function initNewBranchForm() {
components: {
newBranchForm,
},
store,
render(createElement) {
return createElement('new-branch-form', {
props: {
currentBranch: Store.currentBranch,
},
});
return createElement('new-branch-form');
},
});
}
function initRepoBundle() {
const repo = document.getElementById('repo');
const editButton = document.querySelector('.editable-mode');
const newDropdownHolder = document.querySelector('.js-new-dropdown');
setInitialStore(repo.dataset);
addEventsForNonVueEls();
initDropdowns();
Vue.use(Translate);
initRepo(repo);
initRepoEditButton(editButton);
initNewBranchForm();
initNewDropdown(newDropdownHolder);
}
const repo = document.getElementById('repo');
const editButton = document.querySelector('.editable-mode');
const newDropdownHolder = document.querySelector('.js-new-dropdown');
$(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 {
constructor(endpointMap) {
if (!SidebarService.singleton) {
this.endpoint = endpointMap.endpoint;
this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint;
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
......@@ -36,6 +37,10 @@ export default class SidebarService {
});
}
toggleSubscription() {
return Vue.http.post(this.toggleSubscriptionEndpoint);
}
moveIssue(moveToProjectId) {
return Vue.http.post(this.moveIssueEndpoint, {
move_to_project_id: moveToProjectId,
......
......@@ -4,6 +4,8 @@ import SidebarAssignees from './components/assignees/sidebar_assignees';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
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 Mediator from './sidebar_mediator';
......@@ -49,6 +51,36 @@ function mountLockComponent(mediator) {
}).$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() {
const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
const mediator = new Mediator(sidebarOptions);
......@@ -63,6 +95,8 @@ function domContentLoaded() {
mountConfidentialComponent(mediator);
mountLockComponent(mediator);
mountParticipantsComponent();
mountSubscriptionsComponent();
new SidebarMoveIssue(
mediator,
......
......@@ -8,6 +8,7 @@ export default class SidebarMediator {
this.store = new Store(options);
this.service = new Service({
endpoint: options.endpoint,
toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint,
moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
});
......@@ -39,10 +40,25 @@ export default class SidebarMediator {
.then((data) => {
this.store.setAssigneeData(data);
this.store.setTimeTrackingData(data);
this.store.setParticipantsData(data);
this.store.setSubscriptionsData(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) {
return this.service.getProjectsAutocomplete(searchTerm)
.then(response => response.json())
......
......@@ -12,10 +12,14 @@ export default class SidebarStore {
this.assignees = [];
this.isFetching = {
assignees: true,
participants: true,
subscriptions: true,
};
this.autocompleteProjects = [];
this.moveToProjectId = 0;
this.isLockDialogOpen = false;
this.participants = [];
this.subscribed = null;
SidebarStore.singleton = this;
}
......@@ -37,6 +41,20 @@ export default class SidebarStore {
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) {
if (!this.findAssignee(assignee)) {
this.assignees.push(assignee);
......@@ -61,6 +79,10 @@ export default class SidebarStore {
this.autocompleteProjects = projects;
}
setSubscribedState(subscribed) {
this.subscribed = subscribed;
}
setMoveToProjectId(moveToProjectId) {
this.moveToProjectId = moveToProjectId;
}
......
......@@ -11,7 +11,7 @@ export default class MRWidgetService {
this.removeWIPResource = Vue.resource(endpoints.removeWIPPath);
this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath);
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);
}
......
......@@ -542,7 +542,9 @@
}
.participants-list {
margin: -5px;
display: flex;
flex-wrap: wrap;
margin: -7px;
}
.user-list {
......@@ -552,7 +554,7 @@
.participants-author {
display: inline-block;
padding: 5px;
padding: 7px;
&:nth-of-type(7n) {
padding-right: 0;
......
.monaco-loader {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $black-transparent;
}
.modal.popup-dialog {
display: block;
background-color: $black-transparent;
......@@ -54,6 +45,7 @@
}
.tree-content-holder {
display: -webkit-flex;
display: flex;
min-height: 300px;
}
......@@ -63,7 +55,9 @@
}
.panel-right {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
width: 80%;
height: 100%;
......@@ -81,10 +75,6 @@
text-decoration: underline;
}
}
.cursor {
display: none !important;
}
}
.blob-no-preview {
......@@ -94,21 +84,12 @@
}
}
&.edit-mode {
.blob-viewer-container {
overflow: hidden;
}
.monaco-editor.vs {
.cursor {
background: $black;
border-color: $black;
display: block !important;
}
}
&.blob-editor-container {
overflow: hidden;
}
.blob-viewer-container {
-webkit-flex: 1;
flex: 1;
overflow: auto;
......@@ -138,6 +119,7 @@
}
#tabs {
position: relative;
flex-shrink: 0;
display: flex;
width: 100%;
......@@ -166,6 +148,10 @@
vertical-align: middle;
text-decoration: none;
margin-right: 12px;
&:focus {
outline: none;
}
}
.close-btn {
......@@ -312,23 +298,3 @@
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
respond_to do |format|
format.html
format.json do
render json: serializer.represent(@issue)
render json: serializer.represent(@issue, serializer: params[:serializer])
end
end
end
......
......@@ -85,7 +85,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
format.json do
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
format.patch do
......
......@@ -2,13 +2,13 @@ class Projects::MilestonesController < Projects::ApplicationController
include MilestoneActions
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
before_action :authorize_read_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
......@@ -74,6 +74,14 @@ class Projects::MilestonesController < Projects::ApplicationController
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
return access_denied! unless can?(current_user, :admin_milestone, @project)
......
......@@ -33,15 +33,17 @@ module IssuablesHelper
end
def serialize_issuable(issuable)
case issuable
when Issue
IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json
when MergeRequest
MergeRequestSerializer
.new(current_user: current_user, project: issuable.project)
.represent(issuable)
.to_json
end
serializer_klass = case issuable
when Issue
IssueSerializer
when MergeRequest
MergeRequestSerializer
end
serializer_klass
.new(current_user: current_user, project: issuable.project)
.represent(issuable)
.to_json
end
def template_dropdown_tag(issuable, &block)
......@@ -358,7 +360,8 @@ module IssuablesHelper
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),
projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id),
editable: can_edit_issuable,
......
......@@ -13,6 +13,8 @@ module Subscribable
end
def subscribed?(user, project = nil)
return false unless user
if subscription = subscriptions.find_by(user: user, project: project)
subscription.subscribed
else
......
......@@ -17,7 +17,9 @@ class MergeRequestDiffCommit < ActiveRecord::Base
commit_hash.merge(
merge_request_diff_id: merge_request_diff_id,
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
......
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
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
class IssueSidebarEntity < IssuableSidebarEntity
expose :assignees, using: API::Entities::UserBasic
end
class MergeRequestBasicEntity < Grape::Entity
class MergeRequestBasicEntity < IssuableSidebarEntity
expose :assignee_id
expose :merge_status
expose :merge_error
expose :state
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
end
......@@ -3,7 +3,14 @@ class MergeRequestSerializer < BaseSerializer
# to serialize the `merge_request` 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 = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity
entity =
case opts[:serializer]
when 'basic', 'sidebar'
MergeRequestBasicEntity
else
MergeRequestEntity
end
super(merge_request, opts, entity)
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 @@
= milestone_date_range(@milestone)
.milestone-buttons
- 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?
= link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
- 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 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
Delete
......@@ -40,6 +44,7 @@
.detail-page-description.milestone-detail
%h2.title
= markdown_field(@milestone, :title)
%div
- if @milestone.description.present?
.description
......
......@@ -2,14 +2,14 @@
.tree-ref-holder
= 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
- else
= render 'projects/tree/old_tree_header'
.tree-controls
- if show_new_repo?
= render 'shared/repo/editable_mode'
.editable-mode
- else
= lock_file_link(html_options: { class: 'btn path-lock' })
= 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 @@
%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
= render "shared/issuable/participants", participants: issuable.participants(current_user)
.js-sidebar-participants-entry-point
- if current_user
- subscribed = issuable.subscribed?(current_user, @project)
.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'
.js-sidebar-subscriptions-entry-point
- project_ref = cross_project_reference(@project, issuable)
.block.project-reference
......
......@@ -49,6 +49,13 @@
= link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do
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 project_milestone_path(milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do
Delete
#repo{ data: { root: @path.empty?.to_s,
root_url: project_tree_path(project),
url: content_url,
current_branch: @ref,
ref: @commit.id,
project_name: project.name,
refs_url: refs_project_path(project, format: :json),
project_url: project_path(project),
project_id: project.id,
blob_url: namespace_project_blob_path(project.namespace, project, '{{branch}}'),
new_mr_template_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '{{source_branch}}' }),
new_merge_request_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '' }),
can_commit: (!!can_push_branch?(project, @ref)).to_s,
on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s,
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
resources :milestones, constraints: { id: /\d+/ } do
member do
post :promote
put :sort_issues
put :sort_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
## 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
......
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
end
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
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
step 'I click link "Closed"' do
......
......@@ -4,6 +4,10 @@ module Gitlab
# https://www.postgresql.org/docs/9.2/static/datatype-numeric.html
# http://dev.mysql.com/doc/refman/5.7/en/integer-types.html
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
ActiveRecord::Base.configurations[Rails.env]
......@@ -124,6 +128,10 @@ module Gitlab
EOF
end
def self.sanitize_timestamp(timestamp)
MAX_TIMESTAMP_VALUE > timestamp ? timestamp : MAX_TIMESTAMP_VALUE.dup
end
# pool_size - The size of the DB pool.
# host - An optional host name to use instead of the default one.
def self.create_connection_pool(pool_size, host = nil)
......
......@@ -83,15 +83,15 @@ describe Projects::MergeRequestsController do
end
describe 'as json' do
context 'with basic param' do
context 'with basic serializer param' 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')
end
end
context 'without basic param' do
context 'without basic serializer param' do
it 'renders the merge request in the json format' do
go(format: :json)
......
......@@ -86,4 +86,32 @@ describe Projects::MilestonesController do
expect(last_note).to eq('removed milestone')
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
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
end
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
it 'does not allow dragging' do
......
......@@ -13,7 +13,7 @@ describe 'User manages subscription', :js do
end
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')
......
......@@ -28,8 +28,6 @@ feature 'Multi-file editor new file', :js do
click_button('Create file')
end
find('.inputarea').send_keys('file content')
fill_in('commit-message', with: 'commit message')
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 @@
"human_total_time_spent": { "type": ["string", "null"] },
"merge_error": { "type": ["string", "null"] },
"rebase_in_progress": { "type": "boolean" },
"assignee_id": { "type": ["integer", "null"] }
"assignee_id": { "type": ["integer", "null"] },
"subscribed": { "type": ["boolean", "null"] },
"participants": { "type": "array" }
},
"additionalProperties": false
}
......@@ -19,32 +19,7 @@
},
"labels": {
"type": "array",
"items": {
"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
}
"items": { "$ref": "entities/label.json" }
},
"assignee": {
"id": { "type": "integer" },
......
export const createComponentWithStore = (Component, store, propsData = {}) => new Component({
store,
propsData,
});
export default (Component, props = {}, el = null) => new Component({
propsData: props,
}).$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 store from '~/repo/stores';
import newBranchForm from '~/repo/components/new_branch_form.vue';
import eventHub from '~/repo/event_hub';
import RepoStore from '~/repo/stores/repo_store';
import createComponent from '../../helpers/vue_mount_component_helper';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { resetStore } from '../helpers';
describe('Multi-file editor new branch form', () => {
let vm;
......@@ -10,17 +10,17 @@ describe('Multi-file editor new branch form', () => {
beforeEach(() => {
const Component = Vue.extend(newBranchForm);
RepoStore.currentBranch = 'master';
vm = createComponentWithStore(Component, store);
vm = createComponent(Component, {
currentBranch: RepoStore.currentBranch,
});
vm.$store.state.currentBranch = 'master';
vm.$mount();
});
afterEach(() => {
vm.$destroy();
RepoStore.currentBranch = '';
resetStore(vm.$store);
});
describe('template', () => {
......@@ -48,6 +48,10 @@ describe('Multi-file editor new branch form', () => {
});
describe('submitNewBranch', () => {
beforeEach(() => {
spyOn(vm, 'createNewBranch').and.returnValue(Promise.resolve());
});
it('sets to loading', () => {
vm.submitNewBranch();
......@@ -66,57 +70,45 @@ describe('Multi-file editor new branch form', () => {
});
});
it('emits an event with branchName', () => {
spyOn(eventHub, '$emit');
it('calls createdNewBranch with branchName', () => {
vm.branchName = 'testing';
vm.submitNewBranch();
expect(eventHub.$emit).toHaveBeenCalledWith('createNewBranch', 'testing');
expect(vm.createNewBranch).toHaveBeenCalledWith('testing');
});
});
describe('showErrorMessage', () => {
it('sets loading to false', () => {
vm.loading = true;
vm.showErrorMessage();
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('submitNewBranch with error', () => {
beforeEach(() => {
spyOn(vm, 'createNewBranch').and.returnValue(Promise.reject({
json: () => Promise.resolve({
message: 'error message',
}),
}));
});
});
describe('createdNewBranch', () => {
it('set loading to false', () => {
it('sets loading to false', (done) => {
vm.loading = true;
vm.createdNewBranch();
expect(vm.loading).toBeFalsy();
});
it('resets branch name', () => {
vm.branchName = 'testing';
vm.submitNewBranch();
vm.createdNewBranch();
setTimeout(() => {
expect(vm.loading).toBeFalsy();
expect(vm.branchName).toBe('');
done();
});
});
it('sets the dropdown toggle text', () => {
vm.dropdownText = document.createElement('span');
it('creates flash element', (done) => {
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 store from '~/repo/stores';
import newDropdown from '~/repo/components/new_dropdown/index.vue';
import RepoStore from '~/repo/stores/repo_store';
import RepoHelper from '~/repo/helpers/repo_helper';
import eventHub from '~/repo/event_hub';
import createComponent from '../../../helpers/vue_mount_component_helper';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { resetStore } from '../../helpers';
describe('new dropdown component', () => {
let vm;
......@@ -11,15 +10,17 @@ describe('new dropdown component', () => {
beforeEach(() => {
const component = Vue.extend(newDropdown);
vm = createComponent(component);
vm = createComponentWithStore(component, store);
vm.$store.state.path = '';
vm.$mount();
});
afterEach(() => {
vm.$destroy();
RepoStore.files = [];
RepoStore.openedFiles = [];
RepoStore.setViewToPreview();
resetStore(vm.$store);
});
it('renders new file and new directory links', () => {
......@@ -67,6 +68,7 @@ describe('new dropdown component', () => {
.catch(done.fail);
});
});
<<<<<<< HEAD
describe('createEntryInStore', () => {
['tree', 'blob'].forEach((type) => {
......@@ -222,4 +224,6 @@ describe('new dropdown component', () => {
});
});
});
=======
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
});
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 eventHub from '~/repo/event_hub';
import createComponent from '../../../helpers/vue_mount_component_helper';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { file, resetStore } from '../../helpers';
describe('new file modal component', () => {
const Component = Vue.extend(modal);
......@@ -11,18 +11,18 @@ describe('new file modal component', () => {
afterEach(() => {
vm.$destroy();
RepoStore.files = [];
RepoStore.openedFiles = [];
RepoStore.setViewToPreview();
resetStore(vm.$store);
});
['tree', 'blob'].forEach((type) => {
describe(type, () => {
beforeEach(() => {
vm = createComponent(Component, {
vm = createComponentWithStore(Component, store, {
type,
currentPath: RepoStore.path,
});
path: '',
}).$mount();
vm.entryName = 'testing';
});
it(`sets modal title as ${type}`, () => {
......@@ -42,21 +42,160 @@ describe('new file modal component', () => {
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', () => {
document.body.innerHTML += '<div class="js-test"></div>';
vm = createComponent(Component, {
vm = createComponentWithStore(Component, store, {
type: 'tree',
currentPath: RepoStore.path,
}, '.js-test');
path: '',
}).$mount('.js-test');
expect(document.activeElement).toBe(vm.$refs.fieldName);
vm.$el.remove();
});
<<<<<<< HEAD
describe('createEntryInStore', () => {
it('emits createNewEntry event', () => {
......@@ -77,4 +216,6 @@ describe('new file modal component', () => {
});
});
});
=======
>>>>>>> e24d1890aea9c550e02d9145f50e8e1ae153a3a3
});
import Vue from 'vue';
import store from '~/repo/stores';
import service from '~/repo/services';
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 { file, resetStore } from '../helpers';
describe('RepoCommitSection', () => {
const branch = 'master';
const projectUrl = 'projectUrl';
let changedFiles;
let openedFiles;
let vm;
RepoStore.projectUrl = projectUrl;
function createComponent(el) {
function createComponent() {
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(() => {
// Create a copy for each test because these can get modified directly
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,
}]);
vm = createComponent();
});
it('renders a commit section', () => {
RepoStore.isCommitable = true;
RepoStore.currentBranch = branch;
RepoStore.targetBranch = branch;
RepoStore.openedFiles = openedFiles;
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
const vm = createComponent();
it('renders a commit section', () => {
const changedFileElements = [...vm.$el.querySelectorAll('.changed-files > li')];
const commitMessage = vm.$el.querySelector('#commit-message');
const submitCommit = vm.$refs.submitCommit;
const submitCommit = vm.$el.querySelector('.btn');
const targetBranch = vm.$el.querySelector('.target-branch');
expect(vm.$el.querySelector(':scope > form')).toBeTruthy();
......@@ -58,160 +45,70 @@ describe('RepoCommitSection', () => {
expect(changedFileElements.length).toEqual(2);
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.querySelector('.fa-spinner.fa-spin')).toBeFalsy();
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('.help-block').textContent.trim()).toEqual(branch);
});
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();
expect(targetBranch.querySelector('.help-block').textContent.trim()).toEqual('master');
});
describe('when submitting', () => {
let el;
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();
});
});
let changedFiles;
afterEach(() => {
vm.$destroy();
el.remove();
RepoStore.openedFiles = [];
});
beforeEach(() => {
vm.commitMessage = 'testing';
changedFiles = JSON.parse(JSON.stringify(vm.$store.getters.changedFiles));
it('shows commit message', () => {
const commitMessageEl = vm.$el.querySelector('#commit-message');
expect(commitMessageEl.value).toBe(commitMessage);
spyOn(service, 'commit').and.returnValue(Promise.resolve({
short_id: '1',
stats: {},
}));
});
it('allows you to submit', () => {
const submitCommit = vm.$refs.submitCommit;
expect(submitCommit.disabled).toBeFalsy();
expect(vm.$el.querySelector('.btn').disabled).toBeTruthy();
});
it('shows commit submit and summary if commitMessage and spinner if submitCommitsLoading', (done) => {
const submitCommit = vm.$refs.submitCommit;
submitCommit.click();
it('submits commit', (done) => {
vm.makeCommit();
// Wait for the branch check to finish
getSetTimeoutPromise()
.then(() => Vue.nextTick())
.then(() => {
expect(vm.tryCommit).toHaveBeenCalled();
expect(submitCommit.querySelector('.js-commit-loading-icon')).toBeTruthy();
expect(vm.redirectToBranch).toHaveBeenCalled();
const args = RepoService.commitFiles.calls.allArgs()[0];
const { commit_message, actions, branch: payloadBranch } = args[0];
const args = service.commit.calls.allArgs()[0];
const { commit_message, actions, branch: payloadBranch } = args[1];
expect(commit_message).toBe(commitMessage);
expect(commit_message).toBe('testing');
expect(actions.length).toEqual(2);
expect(payloadBranch).toEqual(branch);
expect(payloadBranch).toEqual('master');
expect(actions[0].action).toEqual('update');
expect(actions[1].action).toEqual('update');
expect(actions[0].content).toEqual(openedFiles[0].newContent);
expect(actions[1].content).toEqual(openedFiles[1].newContent);
expect(actions[0].file_path).toEqual(openedFiles[0].path);
expect(actions[1].file_path).toEqual(openedFiles[1].path);
expect(actions[0].content).toEqual(changedFiles[0].content);
expect(actions[1].content).toEqual(changedFiles[1].content);
expect(actions[0].file_path).toEqual(changedFiles[0].path);
expect(actions[1].file_path).toEqual(changedFiles[1].path);
})
.then(done)
.catch(done.fail);
});
it('redirects to MR creation page if start new MR checkbox checked', (done) => {
spyOn(gl.utils, 'visitUrl');
vm.startNewMR = true;
Vue.nextTick()
.then(() => {
const submitCommit = vm.$refs.submitCommit;
submitCommit.click();
})
// Wait for the branch check to finish
.then(() => getSetTimeoutPromise())
vm.makeCommit();
getSetTimeoutPromise()
.then(() => Vue.nextTick())
.then(() => {
expect(vm.redirectToNewMr).toHaveBeenCalled();
expect(gl.utils.visitUrl).toHaveBeenCalled();
})
.then(done)
.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 store from '~/repo/stores';
import repoEditButton from '~/repo/components/repo_edit_button.vue';
import RepoStore from '~/repo/stores/repo_store';
import { file, resetStore } from '../helpers';
describe('RepoEditButton', () => {
function createComponent() {
let vm;
beforeEach(() => {
const f = file();
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(() => {
RepoStore.openedFiles = [];
vm.$destroy();
resetStore(vm.$store);
});
it('renders an edit button that toggles the view state', (done) => {
RepoStore.isCommitable = true;
RepoStore.changedFiles = [];
RepoStore.binary = false;
RepoStore.openedFiles = [{}, {}];
it('renders an edit button', () => {
vm.$mount();
expect(vm.$el.querySelector('.btn')).not.toBeNull();
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');
expect(vm.$el.textContent).toMatch('Edit');
vm.$mount();
spyOn(vm, 'editCancelClicked').and.callThrough();
expect(vm.$el.querySelector('.btn')).not.toBeNull();
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
});
vm.$el.click();
it('toggles edit mode on click', (done) => {
vm.$mount();
vm.$el.querySelector('.btn').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
Vue.nextTick(() => {
expect(vm.editCancelClicked).toHaveBeenCalled();
expect(vm.$el.textContent).toMatch('Cancel edit');
done();
});
});
it('does not render if not isCommitable', () => {
RepoStore.isCommitable = false;
describe('discardPopupOpen', () => {
beforeEach(() => {
vm.$store.state.discardPopupOpen = true;
vm.$store.state.editMode = true;
vm.$store.state.openFiles[0].changed = true;
vm.$mount();
});
it('renders popup', () => {
expect(vm.$el.querySelector('.modal')).not.toBeNull();
});
it('removes all changed files', (done) => {
vm.$el.querySelector('.btn-warning').click();
const vm = createComponent();
vm.$nextTick(() => {
expect(vm.$store.getters.changedFiles.length).toBe(0);
expect(vm.$el.querySelector('.modal')).toBeNull();
expect(vm.$el.innerHTML).toBeUndefined();
done();
});
});
});
});
import Vue from 'vue';
import RepoStore from '~/repo/stores/repo_store';
import store from '~/repo/stores';
import repoEditor from '~/repo/components/repo_editor.vue';
import { file, resetStore } from '../helpers';
describe('RepoEditor', () => {
let vm;
beforeEach(() => {
const f = file();
const RepoEditor = Vue.extend(repoEditor);
this.vm = new RepoEditor().$mount();
vm = new RepoEditor({
store,
});
f.active = true;
f.tempFile = true;
vm.$store.state.openFiles.push(f);
vm.monaco = true;
vm.$mount();
});
afterEach(() => {
RepoStore.openedFiles = [];
vm.$destroy();
resetStore(vm.$store);
});
it('renders an ide container', (done) => {
this.vm.openedFiles = ['idiidid'];
this.vm.binary = false;
Vue.nextTick(() => {
expect(this.vm.shouldHideEditor).toBe(false);
expect(this.vm.$el.id).toEqual('ide');
expect(this.vm.$el.tagName).toBe('DIV');
expect(vm.shouldHideEditor).toBeFalsy();
done();
});
});
describe('when there are no open files', () => {
it('does not render the ide', (done) => {
this.vm.openedFiles = [];
Vue.nextTick(() => {
expect(this.vm.shouldHideEditor).toBe(true);
expect(this.vm.$el.tagName).not.toBeDefined();
done();
});
});
});
describe('when open file is binary and not raw', () => {
it('does not render the IDE', (done) => {
this.vm.binary = true;
this.vm.activeFile = {
raw: false,
};
vm.$store.getters.activeFile.binary = true;
Vue.nextTick(() => {
expect(this.vm.shouldHideEditor).toBe(true);
expect(this.vm.$el.tagName).not.toBeDefined();
expect(vm.shouldHideEditor).toBeTruthy();
done();
});
});
......
import Vue from 'vue';
import store from '~/repo/stores';
import repoFileButtons from '~/repo/components/repo_file_buttons.vue';
import RepoStore from '~/repo/stores/repo_store';
import { file, resetStore } from '../helpers';
describe('RepoFileButtons', () => {
const activeFile = {
extension: 'md',
url: 'url',
raw_path: 'raw_path',
blame_path: 'blame_path',
commits_path: 'commits_path',
permalink: 'permalink',
};
const activeFile = file();
let vm;
function createComponent() {
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(() => {
RepoStore.openedFiles = [];
});
it('renders Raw, Blame, History, Permalink and Preview toggle', () => {
const activeFileLabel = 'activeFileLabel';
RepoStore.openedFiles = new Array(1);
RepoStore.activeFile = activeFile;
RepoStore.activeFileLabel = activeFileLabel;
RepoStore.editMode = true;
RepoStore.binary = false;
vm.$destroy();
const vm = createComponent();
const raw = vm.$el.querySelector('.raw');
const blame = vm.$el.querySelector('.blame');
const history = vm.$el.querySelector('.history');
expect(raw.href).toMatch(`/${activeFile.raw_path}`);
expect(raw.textContent.trim()).toEqual('Raw');
expect(blame.href).toMatch(`/${activeFile.blame_path}`);
expect(blame.textContent.trim()).toEqual('Blame');
expect(history.href).toMatch(`/${activeFile.commits_path}`);
expect(history.textContent.trim()).toEqual('History');
expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink');
expect(vm.$el.querySelector('.preview').textContent.trim()).toEqual(activeFileLabel);
resetStore(vm.$store);
});
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');
preview.click();
expect(vm.rawPreviewToggle).toHaveBeenCalled();
});
it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => {
vm = createComponent();
it('does not render preview toggle if not canPreview', () => {
activeFile.extension = 'js';
RepoStore.openedFiles = new Array(1);
RepoStore.activeFile = activeFile;
vm.$nextTick(() => {
const raw = vm.$el.querySelector('.raw');
const blame = vm.$el.querySelector('.blame');
const history = vm.$el.querySelector('.history');
const vm = createComponent();
expect(raw.href).toMatch(`/${activeFile.rawPath}`);
expect(raw.textContent.trim()).toEqual('Raw');
expect(blame.href).toMatch(`/${activeFile.blamePath}`);
expect(blame.textContent.trim()).toEqual('Blame');
expect(history.href).toMatch(`/${activeFile.commitsPath}`);
expect(history.textContent.trim()).toEqual('History');
expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink');
expect(vm.$el.querySelector('.preview')).toBeFalsy();
done();
});
});
});
import Vue from 'vue';
import store from '~/repo/stores';
import repoFile from '~/repo/components/repo_file.vue';
import RepoStore from '~/repo/stores/repo_store';
import eventHub from '~/repo/event_hub';
import { file } from '../mock_data';
import { file, resetStore } from '../helpers';
describe('RepoFile', () => {
const updated = 'updated';
const otherFile = {
id: 'test',
html: '<p class="file-content">html</p>',
pageTitle: 'otherpageTitle',
};
let vm;
function createComponent(propsData) {
const RepoFile = Vue.extend(repoFile);
return new RepoFile({
store,
propsData,
}).$mount();
}
beforeEach(() => {
RepoStore.openedFiles = [];
afterEach(() => {
resetStore(vm.$store);
});
it('renders link, icon, name and last commit details', () => {
const RepoFile = Vue.extend(repoFile);
const vm = new RepoFile({
vm = new RepoFile({
store,
propsData: {
file: file(),
},
......@@ -47,23 +44,17 @@ describe('RepoFile', () => {
});
it('does render if hasFiles is true and is loading tree', () => {
const vm = createComponent({
vm = createComponent({
file: file(),
});
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', () => {
const f = file();
f.loading = true;
const vm = createComponent({
vm = createComponent({
file: f,
});
......@@ -71,32 +62,34 @@ describe('RepoFile', () => {
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', () => {
RepoStore.openedFiles.push(file());
const vm = createComponent({
it('does not render commit message and datetime if mini', (done) => {
vm = createComponent({
file: file(),
});
vm.$store.state.openFiles.push(vm.file);
expect(vm.$el.querySelector('.commit-message')).toBeFalsy();
expect(vm.$el.querySelector('.commit-update')).toBeFalsy();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.commit-message')).toBeFalsy();
expect(vm.$el.querySelector('.commit-update')).toBeFalsy();
done();
});
});
it('fires linkClicked when the link is clicked', () => {
const vm = createComponent({
it('fires clickedTreeRow when the link is clicked', () => {
vm = createComponent({
file: file(),
});
spyOn(vm, 'linkClicked');
spyOn(vm, 'clickedTreeRow');
vm.$el.click();
expect(vm.linkClicked).toHaveBeenCalledWith(vm.file);
expect(vm.clickedTreeRow).toHaveBeenCalledWith(vm.file);
});
describe('submodule', () => {
let f;
let vm;
beforeEach(() => {
f = file('submodule name', '123456789');
......@@ -119,20 +112,4 @@ describe('RepoFile', () => {
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 RepoStore from '~/repo/stores/repo_store';
import store from '~/repo/stores';
import repoLoadingFile from '~/repo/components/repo_loading_file.vue';
import { resetStore } from '../helpers';
describe('RepoLoadingFile', () => {
function createComponent(propsData) {
let vm;
function createComponent() {
const RepoLoadingFile = Vue.extend(repoLoadingFile);
return new RepoLoadingFile({
propsData,
store,
}).$mount();
}
......@@ -30,33 +33,30 @@ describe('RepoLoadingFile', () => {
}
afterEach(() => {
RepoStore.openedFiles = [];
vm.$destroy();
resetStore(vm.$store);
});
it('renders 3 columns of animated LoC', () => {
const vm = createComponent({
loading: {
tree: true,
},
hasFiles: false,
});
vm = createComponent();
const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(3);
assertColumns(columns);
});
it('renders 1 column of animated LoC if isMini', () => {
RepoStore.openedFiles = new Array(1);
const vm = createComponent({
loading: {
tree: true,
},
hasFiles: false,
});
const columns = [...vm.$el.querySelectorAll('td')];
it('renders 1 column of animated LoC if isMini', (done) => {
vm = createComponent();
vm.$store.state.openFiles.push('test');
expect(columns.length).toEqual(1);
assertColumns(columns);
vm.$nextTick(() => {
const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(1);
assertColumns(columns);
done();
});
});
});
import Vue from 'vue';
import store from '~/repo/stores';
import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue';
import eventHub from '~/repo/event_hub';
import { resetStore } from '../helpers';
describe('RepoPrevDirectory', () => {
function createComponent(propsData) {
let vm;
const parentLink = 'parent';
function createComponent() {
const RepoPrevDirectory = Vue.extend(repoPrevDirectory);
return new RepoPrevDirectory({
propsData,
}).$mount();
}
it('renders a prev dir link', () => {
const prevUrl = 'prevUrl';
const vm = createComponent({
prevUrl,
const comp = new RepoPrevDirectory({
store,
});
const link = vm.$el.querySelector('a');
spyOn(vm, 'linkClicked');
comp.$store.state.parentTreeUrl = parentLink;
expect(link.href).toMatch(`/${prevUrl}`);
expect(link.textContent).toEqual('...');
return comp.$mount();
}
beforeEach(() => {
vm = createComponent();
});
link.click();
afterEach(() => {
vm.$destroy();
expect(vm.linkClicked).toHaveBeenCalledWith(prevUrl);
resetStore(vm.$store);
});
describe('methods', () => {
describe('linkClicked', () => {
it('$emits linkclicked with prevUrl', () => {
const prevUrl = 'prevUrl';
const vm = createComponent({
prevUrl,
});
it('renders a prev dir link', () => {
const link = vm.$el.querySelector('a');
spyOn(eventHub, '$emit');
expect(link.href).toMatch(`/${parentLink}`);
expect(link.textContent).toEqual('...');
});
vm.linkClicked(prevUrl);
it('clicking row triggers getTreeData', () => {
spyOn(vm, 'getTreeData');
expect(eventHub.$emit).toHaveBeenCalledWith('goToPreviousDirectoryClicked', prevUrl);
});
});
vm.$el.querySelector('td').click();
expect(vm.getTreeData).toHaveBeenCalledWith({ endpoint: parentLink });
});
});
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