Commit b86c3a73 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'multi-file-editor-vuex' into 'master'

Move multi-file editor store to Vuex

See merge request gitlab-org/gitlab-ce!15046
parents 9f198dc3 f2f24f05
...@@ -16,6 +16,7 @@ const Api = { ...@@ -16,6 +16,7 @@ const Api = {
usersPath: '/api/:version/users.json', usersPath: '/api/:version/users.json',
commitPath: '/api/:version/projects/:id/repository/commits', commitPath: '/api/:version/projects/:id/repository/commits',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
group(groupId, callback) { group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath) const url = Api.buildUrl(Api.groupPath)
......
<script> <script>
import { mapState, mapActions } from 'vuex';
import flash, { hideFlash } from '../../flash'; import flash, { hideFlash } from '../../flash';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import eventHub from '../event_hub';
export default { export default {
components: { components: {
loadingIcon, loadingIcon,
}, },
props: {
currentBranch: {
type: String,
required: true,
},
},
data() { data() {
return { return {
branchName: '', branchName: '',
...@@ -20,11 +14,17 @@ ...@@ -20,11 +14,17 @@
}; };
}, },
computed: { computed: {
...mapState([
'currentBranch',
]),
btnDisabled() { btnDisabled() {
return this.loading || this.branchName === ''; return this.loading || this.branchName === '';
}, },
}, },
methods: { methods: {
...mapActions([
'createNewBranch',
]),
toggleDropdown() { toggleDropdown() {
this.$dropdown.dropdown('toggle'); this.$dropdown.dropdown('toggle');
}, },
...@@ -38,19 +38,21 @@ ...@@ -38,19 +38,21 @@
hideFlash(flashEl, false); hideFlash(flashEl, false);
} }
eventHub.$emit('createNewBranch', this.branchName); this.createNewBranch(this.branchName)
}, .then(() => {
showErrorMessage(message) { this.loading = false;
this.loading = false; this.branchName = '';
flash(message, 'alert', this.$el);
},
createdNewBranch(newBranchName) {
this.loading = false;
this.branchName = '';
if (this.dropdownText) { if (this.dropdownText) {
this.dropdownText.textContent = newBranchName; this.dropdownText.textContent = this.currentBranch;
} }
this.toggleDropdown();
})
.catch(res => res.json().then((data) => {
this.loading = false;
flash(data.message, 'alert', this.$el);
}));
}, },
}, },
created() { created() {
...@@ -59,15 +61,6 @@ ...@@ -59,15 +61,6 @@
// text element is outside Vue app // text element is outside Vue app
this.dropdownText = document.querySelector('.project-refs-form .dropdown-toggle-text'); this.dropdownText = document.querySelector('.project-refs-form .dropdown-toggle-text');
eventHub.$on('createNewBranchSuccess', this.createdNewBranch);
eventHub.$on('createNewBranchError', this.showErrorMessage);
eventHub.$on('toggleNewBranchDropdown', this.toggleDropdown);
},
destroyed() {
eventHub.$off('createNewBranchSuccess', this.createdNewBranch);
eventHub.$off('toggleNewBranchDropdown', this.toggleDropdown);
eventHub.$off('createNewBranchError', this.showErrorMessage);
}, },
}; };
</script> </script>
......
<script> <script>
import RepoStore from '../../stores/repo_store'; import { mapState } from 'vuex';
import RepoHelper from '../../helpers/repo_helper';
import eventHub from '../../event_hub';
import newModal from './modal.vue'; import newModal from './modal.vue';
import upload from './upload.vue'; import upload from './upload.vue';
...@@ -14,9 +12,13 @@ ...@@ -14,9 +12,13 @@
return { return {
openModal: false, openModal: false,
modalType: '', modalType: '',
currentPath: RepoStore.path,
}; };
}, },
computed: {
...mapState([
'path',
]),
},
methods: { methods: {
createNewItem(type) { createNewItem(type) {
this.modalType = type; this.modalType = type;
...@@ -25,19 +27,6 @@ ...@@ -25,19 +27,6 @@
toggleModalOpen() { toggleModalOpen() {
this.openModal = !this.openModal; this.openModal = !this.openModal;
}, },
createNewEntryInStore(options, openEditMode = true) {
RepoHelper.createNewEntry(options, openEditMode);
if (options.toggleModal) {
this.toggleModalOpen();
}
},
},
created() {
eventHub.$on('createNewEntry', this.createNewEntryInStore);
},
beforeDestroy() {
eventHub.$off('createNewEntry', this.createNewEntryInStore);
}, },
}; };
</script> </script>
...@@ -70,7 +59,7 @@ ...@@ -70,7 +59,7 @@
</li> </li>
<li> <li>
<upload <upload
:current-path="currentPath" :path="path"
/> />
</li> </li>
<li> <li>
...@@ -88,7 +77,7 @@ ...@@ -88,7 +77,7 @@
<new-modal <new-modal
v-if="openModal" v-if="openModal"
:type="modalType" :type="modalType"
:current-path="currentPath" :path="path"
@toggle="toggleModalOpen" @toggle="toggleModalOpen"
/> />
</div> </div>
......
<script> <script>
import { mapActions } from 'vuex';
import { __ } from '../../../locale'; import { __ } from '../../../locale';
import popupDialog from '../../../vue_shared/components/popup_dialog.vue'; import popupDialog from '../../../vue_shared/components/popup_dialog.vue';
import eventHub from '../../event_hub';
export default { export default {
props: { props: {
currentPath: { type: {
type: String, type: String,
required: true, required: true,
}, },
type: { path: {
type: String, type: String,
required: true, required: true,
}, },
}, },
data() { data() {
return { return {
entryName: this.currentPath !== '' ? `${this.currentPath}/` : '', entryName: this.path !== '' ? `${this.path}/` : '',
}; };
}, },
components: { components: {
popupDialog, popupDialog,
}, },
methods: { methods: {
...mapActions([
'createTempEntry',
]),
createEntryInStore() { createEntryInStore() {
eventHub.$emit('createNewEntry', { this.createTempEntry({
name: this.entryName, name: this.entryName.replace(new RegExp(`^${this.path}/`), ''),
type: this.type, type: this.type,
toggleModal: true,
}); });
this.toggleModalOpen();
}, },
toggleModalOpen() { toggleModalOpen() {
this.$emit('toggle'); this.$emit('toggle');
......
<script> <script>
import eventHub from '../../event_hub'; import { mapActions } from 'vuex';
export default { export default {
props: { props: {
currentPath: { path: {
type: String, type: String,
required: true, required: true,
}, },
}, },
methods: { methods: {
...mapActions([
'createTempEntry',
]),
createFile(target, file, isText) { createFile(target, file, isText) {
const { name } = file; const { name } = file;
const nameWithPath = `${this.currentPath !== '' ? `${this.currentPath}/` : ''}${name}`;
let { result } = target; let { result } = target;
if (!isText) { if (!isText) {
result = result.split('base64,')[1]; result = result.split('base64,')[1];
} }
eventHub.$emit('createNewEntry', { this.createTempEntry({
name: nameWithPath, name,
type: 'blob', type: 'blob',
content: result, content: result,
toggleModal: false,
base64: !isText, base64: !isText,
}, isText); });
}, },
readFile(file) { readFile(file) {
const reader = new FileReader(); const reader = new FileReader();
......
<script> <script>
import { mapState, mapGetters } from 'vuex';
import RepoSidebar from './repo_sidebar.vue'; import RepoSidebar from './repo_sidebar.vue';
import RepoCommitSection from './repo_commit_section.vue'; import RepoCommitSection from './repo_commit_section.vue';
import RepoTabs from './repo_tabs.vue'; import RepoTabs from './repo_tabs.vue';
import RepoFileButtons from './repo_file_buttons.vue'; import RepoFileButtons from './repo_file_buttons.vue';
import RepoPreview from './repo_preview.vue'; import RepoPreview from './repo_preview.vue';
import RepoMixin from '../mixins/repo_mixin'; import repoEditor from './repo_editor.vue';
import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
import Store from '../stores/repo_store';
import Helper from '../helpers/repo_helper';
import Service from '../services/repo_service';
import MonacoLoaderHelper from '../helpers/monaco_loader_helper';
import eventHub from '../event_hub';
export default { export default {
data() { computed: {
return Store; ...mapState([
'currentBlobView',
]),
...mapGetters([
'isCollapsed',
'changedFiles',
]),
}, },
mixins: [RepoMixin],
components: { components: {
RepoSidebar, RepoSidebar,
RepoTabs, RepoTabs,
RepoFileButtons, RepoFileButtons,
'repo-editor': MonacoLoaderHelper.repoEditorLoader, repoEditor,
RepoCommitSection, RepoCommitSection,
PopupDialog,
RepoPreview, RepoPreview,
}, },
created() {
eventHub.$on('createNewBranch', this.createNewBranch);
},
mounted() { mounted() {
Helper.getContent().catch(Helper.loadingError); const returnValue = 'Are you sure you want to lose unsaved changes?';
}, window.onbeforeunload = (e) => {
destroyed() { if (!this.changedFiles.length) return undefined;
eventHub.$off('createNewBranch', this.createNewBranch);
},
methods: {
getCurrentLocation() {
return location.href;
},
toggleDialogOpen(toggle) {
this.dialog.open = toggle;
},
dialogSubmitted(status) {
this.toggleDialogOpen(false);
this.dialog.status = status;
// remove tmp files
Helper.removeAllTmpFiles('openedFiles');
Helper.removeAllTmpFiles('files');
},
toggleBlobView: Store.toggleBlobView,
createNewBranch(branch) {
Service.createBranch({
branch,
ref: Store.currentBranch,
}).then((res) => {
const newBranchName = res.data.name;
const newUrl = this.getCurrentLocation().replace(Store.currentBranch, newBranchName);
Store.currentBranch = newBranchName;
history.pushState({ key: Helper.key }, '', newUrl);
eventHub.$emit('createNewBranchSuccess', newBranchName); Object.assign(e, {
eventHub.$emit('toggleNewBranchDropdown'); returnValue,
}).catch((err) => {
eventHub.$emit('createNewBranchError', err.response.data.message);
}); });
}, return returnValue;
};
}, },
}; };
</script> </script>
<template> <template>
<div class="repository-view"> <div class="repository-view">
<div class="tree-content-holder" :class="{'tree-content-holder-mini' : isMini}"> <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isCollapsed}">
<repo-sidebar/> <repo-sidebar/>
<div v-if="isMini" <div
class="panel-right" v-if="isCollapsed"
:class="{'edit-mode': editMode}"> class="panel-right"
>
<repo-tabs/> <repo-tabs/>
<component <component
:is="currentBlobView" :is="currentBlobView"
class="blob-viewer-container"/> />
<repo-file-buttons/> <repo-file-buttons/>
</div> </div>
</div> </div>
<repo-commit-section/> <repo-commit-section v-if="changedFiles.length" />
<popup-dialog
v-show="dialog.open"
:primary-button-label="__('Discard changes')"
kind="warning"
:title="__('Are you sure?')"
:text="__('Are you sure you want to discard your changes?')"
@toggle="toggleDialogOpen"
@submit="dialogSubmitted"
/>
</div> </div>
</template> </template>
<script> <script>
import Flash from '../../flash'; import { mapGetters, mapState, mapActions } from 'vuex';
import Store from '../stores/repo_store';
import RepoMixin from '../mixins/repo_mixin';
import Service from '../services/repo_service';
import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
import { visitUrl } from '../../lib/utils/url_utility'; import { n__ } from '../../locale';
export default { export default {
mixins: [RepoMixin],
data() {
return Store;
},
components: { components: {
PopupDialog, PopupDialog,
}, },
data() {
return {
showNewBranchDialog: false,
submitCommitsLoading: false,
startNewMR: false,
commitMessage: '',
};
},
computed: { computed: {
showCommitable() { ...mapState([
return this.isCommitable && this.changedFiles.length; 'currentBranch',
}, ]),
...mapGetters([
branchPaths() { 'changedFiles',
return this.changedFiles.map(f => f.path); ]),
}, commitButtonDisabled() {
cantCommitYet() {
return !this.commitMessage || this.submitCommitsLoading; return !this.commitMessage || this.submitCommitsLoading;
}, },
commitButtonText() {
filePluralize() { return n__('Commit %d file', 'Commit %d files', this.changedFiles.length);
return this.changedFiles.length > 1 ? 'files' : 'file';
}, },
}, },
methods: { methods: {
commitToNewBranch(status) { ...mapActions([
if (status) { 'checkCommitStatus',
this.showNewBranchDialog = false; 'commitChanges',
this.tryCommit(null, true, true); 'getTreeData',
} else { ]),
// reset the state makeCommit(newBranch = false) {
} const createNewBranch = newBranch || this.startNewMR;
},
makeCommit(newBranch) {
// see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
const commitMessage = this.commitMessage;
const actions = this.changedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update',
file_path: f.path,
content: f.newContent,
encoding: f.base64 ? 'base64' : 'text',
}));
const branch = newBranch ? `${this.currentBranch}-${this.currentShortHash}` : this.currentBranch;
const payload = { const payload = {
branch, branch: createNewBranch ? `${this.currentBranch}-${new Date().getTime().toString()}` : this.currentBranch,
commit_message: commitMessage, commit_message: this.commitMessage,
actions, actions: this.changedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update',
file_path: f.path,
content: f.content,
encoding: f.base64 ? 'base64' : 'text',
})),
start_branch: createNewBranch ? this.currentBranch : undefined,
}; };
if (newBranch) {
payload.start_branch = this.currentBranch; this.showNewBranchDialog = false;
} this.submitCommitsLoading = true;
Service.commitFiles(payload)
this.commitChanges({ payload, newMr: this.startNewMR })
.then(() => { .then(() => {
this.resetCommitState(); this.submitCommitsLoading = false;
if (this.startNewMR) { this.getTreeData();
this.redirectToNewMr(branch);
} else {
this.redirectToBranch(branch);
}
}) })
.catch(() => { .catch(() => {
Flash('An error occurred while committing your changes'); this.submitCommitsLoading = false;
}); });
}, },
tryCommit() {
tryCommit(e, skipBranchCheck = false, newBranch = false) {
this.submitCommitsLoading = true; this.submitCommitsLoading = true;
if (skipBranchCheck) { this.checkCommitStatus()
this.makeCommit(newBranch); .then((branchChanged) => {
} else { if (branchChanged) {
Store.setBranchHash() this.showNewBranchDialog = true;
.then(() => { } else {
if (Store.branchChanged) { this.makeCommit();
Store.showNewBranchDialog = true; }
return; })
} .catch(() => {
this.makeCommit(newBranch); this.submitCommitsLoading = false;
}) });
.catch(() => {
this.submitCommitsLoading = false;
Flash('An error occurred while committing your changes');
});
}
},
redirectToNewMr(branch) {
visitUrl(this.newMrTemplateUrl.replace('{{source_branch}}', branch));
},
redirectToBranch(branch) {
visitUrl(this.customBranchURL.replace('{{branch}}', branch));
},
resetCommitState() {
this.submitCommitsLoading = false;
this.openedFiles = this.openedFiles.map((file) => {
const f = file;
f.changed = false;
return f;
});
this.changedFiles = [];
this.commitMessage = '';
this.editMode = false;
window.scrollTo(0, 0);
}, },
}, },
}; };
</script> </script>
<template> <template>
<div <div id="commit-area">
v-if="showCommitable"
id="commit-area">
<popup-dialog <popup-dialog
v-if="showNewBranchDialog" v-if="showNewBranchDialog"
:primary-button-label="__('Create new branch')" :primary-button-label="__('Create new branch')"
kind="primary" kind="primary"
:title="__('Branch has changed')" :title="__('Branch has changed')"
:text="__('This branch has changed since you started editing. Would you like to create a new branch?')" :text="__('This branch has changed since you started editing. Would you like to create a new branch?')"
@submit="commitToNewBranch" @toggle="showNewBranchDialog = false"
@submit="makeCommit(true)"
/> />
<form <form
class="form-horizontal" class="form-horizontal"
@submit.prevent="tryCommit"> @submit.prevent="tryCommit()">
<fieldset> <fieldset>
<div class="form-group"> <div class="form-group">
<label class="col-md-4 control-label staged-files"> <label class="col-md-4 control-label staged-files">
...@@ -145,10 +103,10 @@ export default { ...@@ -145,10 +103,10 @@ export default {
<div class="col-md-6"> <div class="col-md-6">
<ul class="list-unstyled changed-files"> <ul class="list-unstyled changed-files">
<li <li
v-for="branchPath in branchPaths" v-for="(file, index) in changedFiles"
:key="branchPath"> :key="index">
<span class="help-block"> <span class="help-block">
{{branchPath}} {{ file.path }}
</span> </span>
</li> </li>
</ul> </ul>
...@@ -183,9 +141,8 @@ export default { ...@@ -183,9 +141,8 @@ export default {
</div> </div>
<div class="col-md-offset-4 col-md-6"> <div class="col-md-offset-4 col-md-6">
<button <button
ref="submitCommit"
type="submit" type="submit"
:disabled="cantCommitYet" :disabled="commitButtonDisabled"
class="btn btn-success"> class="btn btn-success">
<i <i
v-if="submitCommitsLoading" v-if="submitCommitsLoading"
...@@ -194,7 +151,7 @@ export default { ...@@ -194,7 +151,7 @@ export default {
aria-label="loading"> aria-label="loading">
</i> </i>
<span class="commit-summary"> <span class="commit-summary">
Commit {{changedFiles.length}} {{filePluralize}} {{ commitButtonText }}
</span> </span>
</button> </button>
</div> </div>
......
<script> <script>
import Store from '../stores/repo_store'; import { mapGetters, mapActions, mapState } from 'vuex';
import RepoMixin from '../mixins/repo_mixin'; import popupDialog from '../../vue_shared/components/popup_dialog.vue';
export default { export default {
data() { components: {
return Store; popupDialog,
}, },
mixins: [RepoMixin],
computed: { computed: {
...mapState([
'editMode',
'discardPopupOpen',
]),
...mapGetters([
'canEditFile',
]),
buttonLabel() { buttonLabel() {
return this.editMode ? this.__('Cancel edit') : this.__('Edit'); return this.editMode ? this.__('Cancel edit') : this.__('Edit');
}, },
showButton() {
return this.isCommitable &&
!this.activeFile.render_error &&
!this.binary &&
this.openedFiles.length;
},
}, },
methods: { methods: {
editCancelClicked() { ...mapActions([
if (this.changedFiles.length) { 'toggleEditMode',
this.dialog.open = true; 'closeDiscardPopup',
return; ]),
}
this.editMode = !this.editMode;
Store.toggleBlobView();
},
}, },
}; };
</script> </script>
<template> <template>
<button <div class="editable-mode">
v-if="showButton" <button
class="btn btn-default" v-if="canEditFile"
type="button" class="btn btn-default"
@click.prevent="editCancelClicked"> type="button"
<i @click.prevent="toggleEditMode()">
v-if="!editMode" <i
class="fa fa-pencil" v-if="!editMode"
aria-hidden="true"> class="fa fa-pencil"
</i> aria-hidden="true">
<span> </i>
{{buttonLabel}} <span>
</span> {{buttonLabel}}
</button> </span>
</button>
<popup-dialog
v-if="discardPopupOpen"
class="text-left"
:primary-button-label="__('Discard changes')"
kind="warning"
:title="__('Are you sure?')"
:text="__('Are you sure you want to discard your changes?')"
@toggle="closeDiscardPopup"
@submit="toggleEditMode(true)"
/>
</div>
</template> </template>
<script> <script>
/* global monaco */ /* global monaco */
import Store from '../stores/repo_store'; import { mapGetters, mapActions } from 'vuex';
import Service from '../services/repo_service'; import flash from '../../flash';
import Helper from '../helpers/repo_helper'; import monacoLoader from '../monaco_loader';
const RepoEditor = {
data() {
return Store;
},
export default {
destroyed() { destroyed() {
if (Helper.monacoInstance) { if (this.monacoInstance) {
Helper.monacoInstance.destroy(); this.monacoInstance.destroy();
} }
}, },
mounted() { mounted() {
Service.getRaw(this.activeFile) if (this.monaco) {
.then((rawResponse) => { this.initMonaco();
Store.blobRaw = rawResponse.data; } else {
Store.activeFile.plain = rawResponse.data; monacoLoader(['vs/editor/editor.main'], () => {
this.monaco = monaco;
const monacoInstance = Helper.monaco.editor.create(this.$el, {
model: null, this.initMonaco();
readOnly: false, });
contextmenu: true, }
scrollBeyondLastLine: false, },
}); methods: {
...mapActions([
'getRawFileData',
'changeFileContent',
]),
initMonaco() {
if (this.monacoInstance) {
this.monacoInstance.setModel(null);
}
Helper.monacoInstance = monacoInstance; this.getRawFileData(this.activeFile)
.then(() => {
if (!this.monacoInstance) {
this.monacoInstance = this.monaco.editor.create(this.$el, {
model: null,
readOnly: false,
contextmenu: true,
scrollBeyondLastLine: false,
});
this.addMonacoEvents(); this.languages = this.monaco.languages.getLanguages();
this.setupEditor(); this.addMonacoEvents();
}) }
.catch(Helper.loadingError);
},
methods: { this.setupEditor();
})
.catch(() => flash('Error setting up monaco. Please try again.'));
},
setupEditor() { setupEditor() {
this.showHide(); if (!this.activeFile) return;
const content = this.activeFile.content !== '' ? this.activeFile.content : this.activeFile.raw;
Helper.setMonacoModelFromLanguage(); const foundLang = this.languages.find(lang =>
}, lang.extensions && lang.extensions.indexOf(this.activeFileExtension) === 0,
);
const newModel = this.monaco.editor.createModel(
content, foundLang ? foundLang.id : 'plaintext',
);
showHide() { this.monacoInstance.setModel(newModel);
if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) {
this.$el.style.display = 'none';
} else {
this.$el.style.display = 'inline-block';
}
}, },
addMonacoEvents() { addMonacoEvents() {
Helper.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp); this.monacoInstance.onKeyUp(() => {
Helper.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this)); this.changeFileContent({
}, file: this.activeFile,
content: this.monacoInstance.getValue(),
onMonacoEditorKeysPressed() { });
Store.setActiveFileContents(Helper.monacoInstance.getValue()); });
},
onMonacoEditorMouseUp(e) {
if (!e.target.position) return;
const lineNumber = e.target.position.lineNumber;
if (e.target.element.classList.contains('line-numbers')) {
location.hash = `L${lineNumber}`;
Store.setActiveLine(lineNumber);
}
}, },
}, },
watch: { watch: {
dialog: { activeFile(oldVal, newVal) {
handler(obj) { if (newVal && !newVal.active) {
const newObj = obj; this.initMonaco();
if (newObj.status) {
newObj.status = false;
this.openedFiles = this.openedFiles.map((file) => {
const f = file;
if (f.active) {
this.blobRaw = f.plain;
}
f.changed = false;
delete f.newContent;
return f;
});
this.editMode = false;
Store.toggleBlobView();
}
},
deep: true,
},
blobRaw() {
if (Helper.monacoInstance) {
this.setupEditor();
}
},
activeLine() {
if (Helper.monacoInstance) {
Helper.monacoInstance.setPosition({
lineNumber: this.activeLine,
column: 1,
});
} }
}, },
}, },
computed: { computed: {
...mapGetters([
'activeFile',
'activeFileExtension',
]),
shouldHideEditor() { shouldHideEditor() {
return !this.openedFiles.length || (this.binary && !this.activeFile.raw); return this.activeFile.binary && !this.activeFile.raw;
}, },
}, },
}; };
export default RepoEditor;
</script> </script>
<template> <template>
<div id="ide" v-if='!shouldHideEditor'></div> <div
id="ide"
v-if='!shouldHideEditor'
class="blob-viewer-container blob-editor-container"
>
</div>
</template> </template>
<script> <script>
import { mapActions, mapGetters } from 'vuex';
import timeAgoMixin from '../../vue_shared/mixins/timeago'; import timeAgoMixin from '../../vue_shared/mixins/timeago';
import eventHub from '../event_hub';
import repoMixin from '../mixins/repo_mixin';
export default { export default {
mixins: [ mixins: [
repoMixin,
timeAgoMixin, timeAgoMixin,
], ],
props: { props: {
...@@ -15,13 +13,15 @@ ...@@ -15,13 +13,15 @@
}, },
}, },
computed: { computed: {
...mapGetters([
'isCollapsed',
]),
fileIcon() { fileIcon() {
const classObj = { return {
'fa-spinner fa-spin': this.file.loading, 'fa-spinner fa-spin': this.file.loading,
[this.file.icon]: !this.file.loading, [this.file.icon]: !this.file.loading,
'fa-folder-open': !this.file.loading && this.file.opened, 'fa-folder-open': !this.file.loading && this.file.opened,
}; };
return classObj;
}, },
levelIndentation() { levelIndentation() {
return { return {
...@@ -33,9 +33,9 @@ ...@@ -33,9 +33,9 @@
}, },
}, },
methods: { methods: {
linkClicked(file) { ...mapActions([
eventHub.$emit('fileNameClicked', file); 'clickedTreeRow',
}, ]),
}, },
}; };
</script> </script>
...@@ -43,7 +43,7 @@ ...@@ -43,7 +43,7 @@
<template> <template>
<tr <tr
class="file" class="file"
@click.prevent="linkClicked(file)"> @click.prevent="clickedTreeRow(file)">
<td> <td>
<i <i
class="fa fa-fw file-icon" class="fa fa-fw file-icon"
...@@ -71,7 +71,7 @@ ...@@ -71,7 +71,7 @@
</template> </template>
</td> </td>
<template v-if="!isMini"> <template v-if="!isCollapsed">
<td class="hidden-sm hidden-xs"> <td class="hidden-sm hidden-xs">
<a <a
@click.stop @click.stop
......
<script> <script>
import Store from '../stores/repo_store'; import { mapGetters } from 'vuex';
import Helper from '../helpers/repo_helper';
import RepoMixin from '../mixins/repo_mixin';
const RepoFileButtons = {
data() {
return Store;
},
mixins: [RepoMixin],
export default {
computed: { computed: {
...mapGetters([
'activeFile',
]),
showButtons() { showButtons() {
return this.activeFile.raw_path || return this.activeFile.rawPath ||
this.activeFile.blame_path || this.activeFile.blamePath ||
this.activeFile.commits_path || this.activeFile.commitsPath ||
this.activeFile.permalink; this.activeFile.permalink;
}, },
rawDownloadButtonLabel() { rawDownloadButtonLabel() {
return this.binary ? 'Download' : 'Raw'; return this.activeFile.binary ? 'Download' : 'Raw';
},
canPreview() {
return Helper.isRenderable();
}, },
}, },
methods: {
rawPreviewToggle: Store.toggleRawPreview,
},
}; };
export default RepoFileButtons;
</script> </script>
<template> <template>
...@@ -40,11 +25,11 @@ export default RepoFileButtons; ...@@ -40,11 +25,11 @@ export default RepoFileButtons;
class="repo-file-buttons" class="repo-file-buttons"
> >
<a <a
:href="activeFile.raw_path" :href="activeFile.rawPath"
target="_blank" target="_blank"
class="btn btn-default raw" class="btn btn-default raw"
rel="noopener noreferrer"> rel="noopener noreferrer">
{{rawDownloadButtonLabel}} {{ rawDownloadButtonLabel }}
</a> </a>
<div <div
...@@ -52,12 +37,12 @@ export default RepoFileButtons; ...@@ -52,12 +37,12 @@ export default RepoFileButtons;
role="group" role="group"
aria-label="File actions"> aria-label="File actions">
<a <a
:href="activeFile.blame_path" :href="activeFile.blamePath"
class="btn btn-default blame"> class="btn btn-default blame">
Blame Blame
</a> </a>
<a <a
:href="activeFile.commits_path" :href="activeFile.commitsPath"
class="btn btn-default history"> class="btn btn-default history">
History History
</a> </a>
...@@ -67,13 +52,5 @@ export default RepoFileButtons; ...@@ -67,13 +52,5 @@ export default RepoFileButtons;
Permalink Permalink
</a> </a>
</div> </div>
<a
v-if="canPreview"
href="#"
@click.prevent="rawPreviewToggle"
class="btn btn-default preview">
{{activeFileLabel}}
</a>
</div> </div>
</template> </template>
<script> <script>
import repoMixin from '../mixins/repo_mixin'; import { mapGetters } from 'vuex';
export default { export default {
mixins: [ computed: {
repoMixin, ...mapGetters([
], 'isCollapsed',
]),
},
methods: { methods: {
lineOfCode(n) { lineOfCode(n) {
return `skeleton-line-${n}`; return `skeleton-line-${n}`;
...@@ -28,7 +30,7 @@ ...@@ -28,7 +30,7 @@
</div> </div>
</div> </div>
</td> </td>
<template v-if="!isMini"> <template v-if="!isCollapsed">
<td <td
class="hidden-sm hidden-xs"> class="hidden-sm hidden-xs">
<div class="animation-container"> <div class="animation-container">
......
<script> <script>
import eventHub from '../event_hub'; import { mapGetters, mapState, mapActions } from 'vuex';
import repoMixin from '../mixins/repo_mixin';
export default { export default {
mixins: [
repoMixin,
],
props: {
prevUrl: {
type: String,
required: true,
},
},
computed: { computed: {
...mapState([
'parentTreeUrl',
]),
...mapGetters([
'isCollapsed',
]),
colSpanCondition() { colSpanCondition() {
return this.isMini ? undefined : 3; return this.isCollapsed ? undefined : 3;
}, },
}, },
methods: { methods: {
linkClicked(file) { ...mapActions([
eventHub.$emit('goToPreviousDirectoryClicked', file); 'getTreeData',
}, ]),
}, },
}; };
</script> </script>
...@@ -30,9 +26,9 @@ ...@@ -30,9 +26,9 @@
<td <td
:colspan="colSpanCondition" :colspan="colSpanCondition"
class="table-cell" class="table-cell"
@click.prevent="linkClicked(prevUrl)" @click.prevent="getTreeData({ endpoint: parentTreeUrl })"
> >
<a :href="prevUrl">...</a> <a :href="parentTreeUrl">...</a>
</td> </td>
</tr> </tr>
</template> </template>
<script> <script>
/* global LineHighlighter */ /* global LineHighlighter */
import { mapGetters } from 'vuex';
import Store from '../stores/repo_store';
export default { export default {
data() {
return Store;
},
computed: { computed: {
html() { ...mapGetters([
return this.activeFile.html; 'activeFile',
]),
renderErrorTooLarge() {
return this.activeFile.renderError === 'too_large';
}, },
}, },
methods: { methods: {
highlightFile() { highlightFile() {
$(this.$el).find('.file-content').syntaxHighlight(); $(this.$el).find('.file-content').syntaxHighlight();
}, },
highlightLine() {
if (Store.activeLine > -1) {
this.lineHighlighter.highlightHash(`#L${Store.activeLine}`);
}
},
}, },
mounted() { mounted() {
this.highlightFile(); this.highlightFile();
...@@ -29,24 +23,18 @@ export default { ...@@ -29,24 +23,18 @@ export default {
scrollFileHolder: true, scrollFileHolder: true,
}); });
}, },
watch: { updated() {
html() { this.$nextTick(() => {
this.$nextTick(() => { this.highlightFile();
this.highlightFile(); });
this.highlightLine();
});
},
activeLine() {
this.highlightLine();
},
}, },
}; };
</script> </script>
<template> <template>
<div> <div class="blob-viewer-container">
<div <div
v-if="!activeFile.render_error" v-if="!activeFile.renderError"
v-html="activeFile.html"> v-html="activeFile.html">
</div> </div>
<div <div
...@@ -57,17 +45,17 @@ export default { ...@@ -57,17 +45,17 @@ export default {
</p> </p>
</div> </div>
<div <div
v-else-if="activeFile.tooLarge" v-else-if="renderErrorTooLarge"
class="vertical-center render-error"> class="vertical-center render-error">
<p class="text-center"> <p class="text-center">
The source could not be displayed because it is too large. You can <a :href="activeFile.raw_path">download</a> it instead. The source could not be displayed because it is too large. You can <a :href="activeFile.rawPath" download>download</a> it instead.
</p> </p>
</div> </div>
<div <div
v-else v-else
class="vertical-center render-error"> class="vertical-center render-error">
<p class="text-center"> <p class="text-center">
The source could not be displayed because a rendering error occurred. You can <a :href="activeFile.raw_path">download</a> it instead. The source could not be displayed because a rendering error occurred. You can <a :href="activeFile.rawPath" download>download</a> it instead.
</p> </p>
</div> </div>
</div> </div>
......
<script> <script>
import _ from 'underscore'; import { mapState, mapGetters, mapActions } from 'vuex';
import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper';
import Store from '../stores/repo_store';
import eventHub from '../event_hub';
import RepoPreviousDirectory from './repo_prev_directory.vue'; import RepoPreviousDirectory from './repo_prev_directory.vue';
import RepoFile from './repo_file.vue'; import RepoFile from './repo_file.vue';
import RepoLoadingFile from './repo_loading_file.vue'; import RepoLoadingFile from './repo_loading_file.vue';
import RepoMixin from '../mixins/repo_mixin';
export default { export default {
mixins: [RepoMixin],
components: { components: {
'repo-previous-directory': RepoPreviousDirectory, 'repo-previous-directory': RepoPreviousDirectory,
'repo-file': RepoFile, 'repo-file': RepoFile,
'repo-loading-file': RepoLoadingFile, 'repo-loading-file': RepoLoadingFile,
}, },
created() { created() {
window.addEventListener('popstate', this.checkHistory); window.addEventListener('popstate', this.popHistoryState);
}, },
destroyed() { destroyed() {
eventHub.$off('fileNameClicked', this.fileClicked); window.removeEventListener('popstate', this.popHistoryState);
eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
window.removeEventListener('popstate', this.checkHistory);
}, },
mounted() { mounted() {
eventHub.$on('fileNameClicked', this.fileClicked); this.getTreeData();
eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
},
data() {
return Store;
}, },
computed: { computed: {
flattendFiles() { ...mapState([
const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)])); 'loading',
'isRoot',
return _.chain(this.files) ]),
.map(arr => [arr, mapFiles(arr)]) ...mapState({
.flatten() projectName(state) {
.value(); return state.project.name;
}, },
}),
...mapGetters([
'treeList',
'isCollapsed',
]),
}, },
methods: { methods: {
checkHistory() { ...mapActions([
let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1); 'getTreeData',
if (!selectedFile) { 'popHistoryState',
// Maybe it is not in the current tree but in the opened tabs ]),
selectedFile = Helper.getFileFromPath(location.pathname);
}
let lineNumber = null;
if (location.hash.indexOf('#L') > -1) lineNumber = Number(location.hash.substr(2));
if (selectedFile) {
if (selectedFile.url !== this.activeFile.url) {
this.fileClicked(selectedFile, lineNumber);
} else {
Store.setActiveLine(lineNumber);
}
} else {
// Not opened at all lets open new tab
this.fileClicked({
url: location.href,
}, lineNumber);
}
},
fileClicked(clickedFile, lineNumber) {
const file = clickedFile;
if (file.loading) return;
if (file.type === 'tree' && file.opened) {
Helper.setDirectoryToClosed(file);
Store.setActiveLine(lineNumber);
} else if (file.type === 'submodule') {
file.loading = true;
gl.utils.visitUrl(file.url);
} else {
const openFile = Helper.getFileFromPath(file.url);
if (openFile) {
Store.setActiveFiles(openFile);
Store.setActiveLine(lineNumber);
} else {
file.loading = true;
Service.url = file.url;
Helper.getContent(file)
.then(() => {
file.loading = false;
Helper.scrollTabsRight();
Store.setActiveLine(lineNumber);
})
.catch(Helper.loadingError);
}
}
},
goToPreviousDirectoryClicked(prevURL) {
Service.url = prevURL;
Helper.getContent(null, true)
.then(() => Helper.scrollTabsRight())
.catch(Helper.loadingError);
},
}, },
}; };
</script> </script>
<template> <template>
<div id="sidebar" :class="{'sidebar-mini' : isMini}"> <div id="sidebar" :class="{'sidebar-mini' : isCollapsed}">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th <th
v-if="isMini" v-if="isCollapsed"
class="repo-file-options title" class="repo-file-options title"
> >
<strong class="clgray"> <strong class="clgray">
...@@ -136,17 +71,16 @@ export default { ...@@ -136,17 +71,16 @@ export default {
</thead> </thead>
<tbody> <tbody>
<repo-previous-directory <repo-previous-directory
v-if="!isRoot && !loading.tree" v-if="!isRoot && treeList.length"
:prev-url="prevURL"
/> />
<repo-loading-file <repo-loading-file
v-if="!flattendFiles.length && loading.tree" v-if="!treeList.length && loading"
v-for="n in 5" v-for="n in 5"
:key="n" :key="n"
/> />
<repo-file <repo-file
v-for="file in flattendFiles" v-for="(file, index) in treeList"
:key="file.id" :key="index"
:file="file" :file="file"
/> />
</tbody> </tbody>
......
<script> <script>
import Store from '../stores/repo_store'; import { mapActions } from 'vuex';
const RepoTab = { export default {
props: { props: {
tab: { tab: {
type: Object, type: Object,
...@@ -11,7 +11,7 @@ const RepoTab = { ...@@ -11,7 +11,7 @@ const RepoTab = {
computed: { computed: {
closeLabel() { closeLabel() {
if (this.tab.changed) { if (this.tab.changed || this.tab.tempFile) {
return `${this.tab.name} changed`; return `${this.tab.name} changed`;
} }
return `Close ${this.tab.name}`; return `Close ${this.tab.name}`;
...@@ -26,29 +26,23 @@ const RepoTab = { ...@@ -26,29 +26,23 @@ const RepoTab = {
}, },
methods: { methods: {
tabClicked(file) { ...mapActions([
Store.setActiveFiles(file); 'setFileActive',
}, 'closeFile',
closeTab(file) { ]),
if (file.changed || file.tempFile) return;
Store.removeFromOpenedFiles(file);
},
}, },
}; };
export default RepoTab;
</script> </script>
<template> <template>
<li <li
:class="{ active : tab.active }" :class="{ active : tab.active }"
@click="tabClicked(tab)" @click="setFileActive(tab)"
> >
<button <button
type="button" type="button"
class="close-btn" class="close-btn"
@click.stop.prevent="closeTab(tab)" @click.stop.prevent="closeFile({ file: tab })"
:aria-label="closeLabel"> :aria-label="closeLabel">
<i <i
class="fa" class="fa"
...@@ -61,7 +55,7 @@ export default RepoTab; ...@@ -61,7 +55,7 @@ export default RepoTab;
href="#" href="#"
class="repo-tab" class="repo-tab"
:title="tab.url" :title="tab.url"
@click.prevent="tabClicked(tab)"> @click.prevent.stop="setFileActive(tab)">
{{tab.name}} {{tab.name}}
</a> </a>
</li> </li>
......
<script> <script>
import Store from '../stores/repo_store'; import { mapState } from 'vuex';
import RepoTab from './repo_tab.vue'; import RepoTab from './repo_tab.vue';
import RepoMixin from '../mixins/repo_mixin';
export default { export default {
mixins: [RepoMixin],
components: { components: {
'repo-tab': RepoTab, 'repo-tab': RepoTab,
}, },
data() { computed: {
return Store; ...mapState([
'openFiles',
]),
}, },
}; };
</script> </script>
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
class="list-unstyled" class="list-unstyled"
> >
<repo-tab <repo-tab
v-for="tab in openedFiles" v-for="tab in openFiles"
:key="tab.id" :key="tab.id"
:tab="tab" :tab="tab"
/> />
......
import Vue from 'vue';
export default new Vue();
/* global monaco */
import RepoEditor from '../components/repo_editor.vue';
import Store from '../stores/repo_store';
import Helper from '../helpers/repo_helper';
import monacoLoader from '../monaco_loader';
function repoEditorLoader() {
Store.monacoLoading = true;
return new Promise((resolve, reject) => {
monacoLoader(['vs/editor/editor.main'], () => {
Helper.monaco = monaco;
Store.monacoLoading = false;
resolve(RepoEditor);
}, () => {
Store.monacoLoading = false;
reject();
});
});
}
const MonacoLoaderHelper = {
repoEditorLoader,
};
export default MonacoLoaderHelper;
import Service from '../services/repo_service';
import Store from '../stores/repo_store';
import Flash from '../../flash';
const RepoHelper = {
monacoInstance: null,
getDefaultActiveFile() {
return {
id: '',
active: true,
binary: false,
extension: '',
html: '',
mime_type: '',
name: '',
plain: '',
size: 0,
url: '',
raw: false,
newContent: '',
changed: false,
loading: false,
};
},
key: '',
Time: window.performance
&& window.performance.now
? window.performance
: Date,
getFileExtension(fileName) {
return fileName.split('.').pop();
},
getLanguageIDForFile(file, langs) {
const ext = RepoHelper.getFileExtension(file.name);
const foundLang = RepoHelper.findLanguage(ext, langs);
return foundLang ? foundLang.id : 'plaintext';
},
setMonacoModelFromLanguage() {
RepoHelper.monacoInstance.setModel(null);
const languages = RepoHelper.monaco.languages.getLanguages();
const languageID = RepoHelper.getLanguageIDForFile(Store.activeFile, languages);
const newModel = RepoHelper.monaco.editor.createModel(Store.blobRaw, languageID);
RepoHelper.monacoInstance.setModel(newModel);
},
findLanguage(ext, langs) {
return langs.find(lang => lang.extensions && lang.extensions.indexOf(`.${ext}`) > -1);
},
setDirectoryOpen(tree, title) {
if (!tree) return;
Object.assign(tree, {
opened: true,
});
RepoHelper.updateHistoryEntry(tree.url, title);
Store.path = tree.path;
},
setDirectoryToClosed(entry) {
Object.assign(entry, {
opened: false,
files: [],
});
},
isRenderable() {
const okExts = ['md', 'svg'];
return okExts.indexOf(Store.activeFile.extension) > -1;
},
setBinaryDataAsBase64(file) {
Service.getBase64Content(file.raw_path)
.then((response) => {
Store.blobRaw = response;
file.base64 = response; // eslint-disable-line no-param-reassign
})
.catch(RepoHelper.loadingError);
},
getContent(treeOrFile, emptyFiles = false) {
let file = treeOrFile;
if (!Store.files.length) {
Store.loading.tree = true;
}
return Service.getContent()
.then((response) => {
const data = response.data;
if (response.headers && response.headers['page-title']) data.pageTitle = decodeURI(response.headers['page-title']);
if (data.path && !Store.isInitialRoot) {
Store.isRoot = data.path === '/';
Store.isInitialRoot = Store.isRoot;
}
if (file && file.type === 'blob') {
if (!file) file = data;
Store.binary = data.binary;
if (data.binary) {
// file might be undefined
RepoHelper.setBinaryDataAsBase64(data);
Store.setViewToPreview();
} else if (!Store.isPreviewView() && !data.render_error) {
Service.getRaw(data)
.then((rawResponse) => {
Store.blobRaw = rawResponse.data;
data.plain = rawResponse.data;
RepoHelper.setFile(data, file);
}).catch(RepoHelper.loadingError);
}
if (Store.isPreviewView()) {
RepoHelper.setFile(data, file);
}
} else {
Store.loading.tree = false;
RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
if (emptyFiles) {
Store.files = [];
}
this.addToDirectory(file, data);
Store.prevURL = Service.blobURLtoParentTree(Service.url);
}
}).catch(RepoHelper.loadingError);
},
addToDirectory(file, data) {
const tree = file || Store;
// TODO: Figure out why `popstate` is being trigger in the specs
if (!tree.files) return;
const files = tree.files.concat(this.dataToListOfFiles(data, file ? file.level + 1 : 0));
tree.files = files;
},
setFile(data, file) {
const newFile = data;
newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh.
if (newFile.render_error === 'too_large' || newFile.render_error === 'collapsed') {
newFile.tooLarge = true;
}
newFile.newContent = file.newContent ? file.newContent : '';
Store.addToOpenedFiles(newFile);
Store.setActiveFiles(newFile);
},
serializeRepoEntity(type, entity, level = 0) {
const {
id,
url,
name,
icon,
last_commit,
tree_url,
path,
tempFile,
active,
opened,
} = entity;
return {
id,
type,
name,
url,
tree_url,
path,
level,
tempFile,
icon: `fa-${icon}`,
files: [],
loading: false,
opened,
active,
// eslint-disable-next-line camelcase
lastCommit: last_commit ? {
url: `${Store.projectUrl}/commit/${last_commit.id}`,
message: last_commit.message,
updatedAt: last_commit.committed_date,
} : {},
};
},
scrollTabsRight() {
const tabs = document.getElementById('tabs');
if (!tabs) return;
tabs.scrollLeft = tabs.scrollWidth;
},
dataToListOfFiles(data, level) {
const { blobs, trees, submodules } = data;
return [
...trees.map(tree => RepoHelper.serializeRepoEntity('tree', tree, level)),
...submodules.map(submodule => RepoHelper.serializeRepoEntity('submodule', submodule, level)),
...blobs.map(blob => RepoHelper.serializeRepoEntity('blob', blob, level)),
];
},
genKey() {
return RepoHelper.Time.now().toFixed(3);
},
updateHistoryEntry(url, title) {
const history = window.history;
RepoHelper.key = RepoHelper.genKey();
if (document.location.pathname !== url) {
history.pushState({ key: RepoHelper.key }, '', url);
}
if (title) {
document.title = title;
}
},
findOpenedFileFromActive() {
return Store.openedFiles.find(openedFile => Store.activeFile.id === openedFile.id);
},
getFileFromPath(path) {
return Store.openedFiles.find(file => file.url === path);
},
loadingError() {
Flash('Unable to load this content at this time.');
},
openEditMode() {
Store.editMode = true;
Store.currentBlobView = 'repo-editor';
},
updateStorePath(path) {
Store.path = path;
},
findOrCreateEntry(type, tree, name) {
let exists = true;
let foundEntry = tree.files.find(dir => dir.type === type && dir.name === name);
if (!foundEntry) {
foundEntry = RepoHelper.serializeRepoEntity(type, {
id: name,
name,
path: tree.path ? `${tree.path}/${name}` : name,
icon: type === 'tree' ? 'folder' : 'file-text-o',
tempFile: true,
opened: true,
active: true,
}, tree.level !== undefined ? tree.level + 1 : 0);
exists = false;
tree.files.push(foundEntry);
}
return {
entry: foundEntry,
exists,
};
},
removeAllTmpFiles(storeFilesKey) {
Store[storeFilesKey] = Store[storeFilesKey].filter(f => !f.tempFile);
},
createNewEntry(options, openEditMode = true) {
const {
name,
type,
content = '',
base64 = false,
} = options;
const originalPath = Store.path;
let entryName = name;
if (entryName.indexOf(`${originalPath}/`) !== 0) {
this.updateStorePath('');
} else {
entryName = entryName.replace(`${originalPath}/`, '');
}
if (entryName === '') return;
const fileName = type === 'tree' ? '.gitkeep' : entryName;
let tree = Store;
if (type === 'tree') {
const dirNames = entryName.split('/');
dirNames.forEach((dirName) => {
if (dirName === '') return;
tree = this.findOrCreateEntry('tree', tree, dirName).entry;
});
}
if ((type === 'tree' && tree.tempFile) || type === 'blob') {
const file = this.findOrCreateEntry('blob', tree, fileName);
if (file.exists) {
Flash(`The name "${file.entry.name}" is already taken in this directory.`);
} else {
const { entry } = file;
entry.newContent = content;
entry.base64 = base64;
if (entry.base64) {
entry.render_error = true;
}
this.setFile(entry, entry);
if (openEditMode) {
this.openEditMode();
} else {
file.entry.render_error = 'asdsad';
}
}
}
this.updateStorePath(originalPath);
},
};
export default RepoHelper;
import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import { mapActions } from 'vuex';
import { convertPermissionToBoolean } from '../lib/utils/common_utils'; import { convertPermissionToBoolean } from '../lib/utils/common_utils';
import Service from './services/repo_service';
import Store from './stores/repo_store';
import Repo from './components/repo.vue'; import Repo from './components/repo.vue';
import RepoEditButton from './components/repo_edit_button.vue'; import RepoEditButton from './components/repo_edit_button.vue';
import newBranchForm from './components/new_branch_form.vue'; import newBranchForm from './components/new_branch_form.vue';
import newDropdown from './components/new_dropdown/index.vue'; import newDropdown from './components/new_dropdown/index.vue';
import store from './stores';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
function initDropdowns() {
$('.js-tree-ref-target-holder').hide();
}
function addEventsForNonVueEls() {
window.onbeforeunload = function confirmUnload(e) {
const hasChanged = Store.openedFiles
.some(file => file.changed);
if (!hasChanged) return undefined;
const event = e || window.event;
if (event) event.returnValue = 'Are you sure you want to lose unsaved changes?';
// For Safari
return 'Are you sure you want to lose unsaved changes?';
};
}
function setInitialStore(data) {
Store.service = Service;
Store.service.url = data.url;
Store.service.refsUrl = data.refsUrl;
Store.path = data.currentPath;
Store.projectId = data.projectId;
Store.projectName = data.projectName;
Store.projectUrl = data.projectUrl;
Store.canCommit = data.canCommit;
Store.onTopOfBranch = data.onTopOfBranch;
Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl);
Store.customBranchURL = decodeURIComponent(data.blobUrl);
Store.isRoot = convertPermissionToBoolean(data.root);
Store.isInitialRoot = convertPermissionToBoolean(data.root);
Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
Store.checkIsCommitable();
Store.setBranchHash();
}
function initRepo(el) { function initRepo(el) {
if (!el) return null;
return new Vue({ return new Vue({
el, el,
store,
components: { components: {
repo: Repo, repo: Repo,
}, },
methods: {
...mapActions([
'setInitialData',
]),
},
created() {
const data = el.dataset;
this.setInitialData({
project: {
id: data.projectId,
name: data.projectName,
url: data.projectUrl,
},
endpoints: {
rootEndpoint: data.url,
newMergeRequestUrl: data.newMergeRequestUrl,
rootUrl: data.rootUrl,
},
canCommit: convertPermissionToBoolean(data.canCommit),
onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch),
currentRef: data.ref,
path: data.currentPath,
currentBranch: data.currentBranch,
isRoot: convertPermissionToBoolean(data.root),
isInitialRoot: convertPermissionToBoolean(data.root),
});
},
render(createElement) { render(createElement) {
return createElement('repo'); return createElement('repo');
}, },
...@@ -59,15 +54,20 @@ function initRepo(el) { ...@@ -59,15 +54,20 @@ function initRepo(el) {
function initRepoEditButton(el) { function initRepoEditButton(el) {
return new Vue({ return new Vue({
el, el,
store,
components: { components: {
repoEditButton: RepoEditButton, repoEditButton: RepoEditButton,
}, },
render(createElement) {
return createElement('repo-edit-button');
},
}); });
} }
function initNewDropdown(el) { function initNewDropdown(el) {
return new Vue({ return new Vue({
el, el,
store,
components: { components: {
newDropdown, newDropdown,
}, },
...@@ -87,32 +87,20 @@ function initNewBranchForm() { ...@@ -87,32 +87,20 @@ function initNewBranchForm() {
components: { components: {
newBranchForm, newBranchForm,
}, },
store,
render(createElement) { render(createElement) {
return createElement('new-branch-form', { return createElement('new-branch-form');
props: {
currentBranch: Store.currentBranch,
},
});
}, },
}); });
} }
function initRepoBundle() { const repo = document.getElementById('repo');
const repo = document.getElementById('repo'); const editButton = document.querySelector('.editable-mode');
const editButton = document.querySelector('.editable-mode'); const newDropdownHolder = document.querySelector('.js-new-dropdown');
const newDropdownHolder = document.querySelector('.js-new-dropdown');
setInitialStore(repo.dataset);
addEventsForNonVueEls();
initDropdowns();
Vue.use(Translate);
initRepo(repo);
initRepoEditButton(editButton);
initNewBranchForm();
initNewDropdown(newDropdownHolder);
}
$(initRepoBundle); Vue.use(Translate);
export default initRepoBundle; initRepo(repo);
initRepoEditButton(editButton);
initNewBranchForm();
initNewDropdown(newDropdownHolder);
import Store from '../stores/repo_store';
const RepoMixin = {
computed: {
isMini() {
return !!Store.openedFiles.length;
},
changedFiles() {
const changedFileList = this.openedFiles
.filter(file => file.changed || file.tempFile);
return changedFileList;
},
},
};
export default RepoMixin;
import Vue from 'vue';
import VueResource from 'vue-resource';
import Api from '../../api';
Vue.use(VueResource);
export default {
getTreeData(endpoint) {
return Vue.http.get(endpoint, { params: { format: 'json' } });
},
getFileData(endpoint) {
return Vue.http.get(endpoint, { params: { format: 'json' } });
},
getRawFileData(file) {
if (file.tempFile) {
return Promise.resolve(file.content);
}
return Vue.http.get(file.rawPath, { params: { format: 'json' } })
.then(res => res.text());
},
getBranchData(projectId, currentBranch) {
return Api.branchSingle(projectId, currentBranch);
},
createBranch(projectId, payload) {
const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId);
return Vue.http.post(url, payload);
},
commit(projectId, payload) {
return Api.commitMultiple(projectId, payload);
},
};
import axios from 'axios';
import csrf from '../../lib/utils/csrf';
import Store from '../stores/repo_store';
import Api from '../../api';
import Helper from '../helpers/repo_helper';
axios.defaults.headers.common[csrf.headerKey] = csrf.token;
const RepoService = {
url: '',
options: {
params: {
format: 'json',
},
},
createBranchPath: '/api/:version/projects/:id/repository/branches',
richExtensionRegExp: /md/,
getRaw(file) {
if (file.tempFile) {
return Promise.resolve({
data: file.newContent ? file.newContent : '',
});
}
return axios.get(file.raw_path, {
// Stop Axios from parsing a JSON file into a JS object
transformResponse: [res => res],
});
},
buildParams(url = this.url) {
// shallow clone object without reference
const params = Object.assign({}, this.options.params);
if (this.urlIsRichBlob(url)) params.viewer = 'rich';
return params;
},
urlIsRichBlob(url = this.url) {
const extension = Helper.getFileExtension(url);
return this.richExtensionRegExp.test(extension);
},
getContent(url = this.url) {
const params = this.buildParams(url);
return axios.get(url, {
params,
});
},
getBase64Content(url = this.url) {
const request = axios.get(url, {
responseType: 'arraybuffer',
});
return request.then(response => this.bufferToBase64(response.data));
},
bufferToBase64(data) {
return new Buffer(data, 'binary').toString('base64');
},
blobURLtoParentTree(url) {
const urlArray = url.split('/');
urlArray.pop();
const blobIndex = urlArray.lastIndexOf('blob');
if (blobIndex > -1) urlArray[blobIndex] = 'tree';
return urlArray.join('/');
},
getBranch() {
return Api.branchSingle(Store.projectId, Store.currentBranch);
},
commitFiles(payload) {
return Api.commitMultiple(Store.projectId, payload)
.then(this.commitFlash);
},
createBranch(payload) {
const url = Api.buildUrl(this.createBranchPath)
.replace(':id', Store.projectId);
return axios.post(url, payload);
},
commitFlash(data) {
if (data.short_id && data.stats) {
window.Flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice');
} else {
window.Flash(data.message);
}
},
};
export default RepoService;
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,
});
};
.monaco-loader {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $black-transparent;
}
.modal.popup-dialog { .modal.popup-dialog {
display: block; display: block;
background-color: $black-transparent; background-color: $black-transparent;
...@@ -54,6 +45,7 @@ ...@@ -54,6 +45,7 @@
} }
.tree-content-holder { .tree-content-holder {
display: -webkit-flex;
display: flex; display: flex;
min-height: 300px; min-height: 300px;
} }
...@@ -63,7 +55,9 @@ ...@@ -63,7 +55,9 @@
} }
.panel-right { .panel-right {
display: -webkit-flex;
display: flex; display: flex;
-webkit-flex-direction: column;
flex-direction: column; flex-direction: column;
width: 80%; width: 80%;
height: 100%; height: 100%;
...@@ -81,10 +75,6 @@ ...@@ -81,10 +75,6 @@
text-decoration: underline; text-decoration: underline;
} }
} }
.cursor {
display: none !important;
}
} }
.blob-no-preview { .blob-no-preview {
...@@ -94,21 +84,12 @@ ...@@ -94,21 +84,12 @@
} }
} }
&.edit-mode { &.blob-editor-container {
.blob-viewer-container { overflow: hidden;
overflow: hidden;
}
.monaco-editor.vs {
.cursor {
background: $black;
border-color: $black;
display: block !important;
}
}
} }
.blob-viewer-container { .blob-viewer-container {
-webkit-flex: 1;
flex: 1; flex: 1;
overflow: auto; overflow: auto;
...@@ -138,6 +119,7 @@ ...@@ -138,6 +119,7 @@
} }
#tabs { #tabs {
position: relative;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
width: 100%; width: 100%;
...@@ -166,6 +148,10 @@ ...@@ -166,6 +148,10 @@
vertical-align: middle; vertical-align: middle;
text-decoration: none; text-decoration: none;
margin-right: 12px; margin-right: 12px;
&:focus {
outline: none;
}
} }
.close-btn { .close-btn {
...@@ -312,23 +298,3 @@ ...@@ -312,23 +298,3 @@
width: 100%; width: 100%;
} }
} }
@keyframes swipeRightAppear {
0% {
transform: scaleX(0.00);
}
100% {
transform: scaleX(1.00);
}
}
@keyframes swipeRightDissapear {
0% {
transform: scaleX(1.00);
}
100% {
transform: scaleX(0.00);
}
}
...@@ -2,14 +2,14 @@ ...@@ -2,14 +2,14 @@
.tree-ref-holder .tree-ref-holder
= render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true
- if show_new_repo? - if show_new_repo? && can_push_branch?(@project, @ref)
.js-new-dropdown .js-new-dropdown
- else - else
= render 'projects/tree/old_tree_header' = render 'projects/tree/old_tree_header'
.tree-controls .tree-controls
- if show_new_repo? - if show_new_repo?
= render 'shared/repo/editable_mode' .editable-mode
- else - else
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
......
#repo{ data: { root: @path.empty?.to_s, #repo{ data: { root: @path.empty?.to_s,
root_url: project_tree_path(project),
url: content_url, url: content_url,
current_branch: @ref,
ref: @commit.id,
project_name: project.name, project_name: project.name,
refs_url: refs_project_path(project, format: :json),
project_url: project_path(project), project_url: project_path(project),
project_id: project.id, project_id: project.id,
blob_url: namespace_project_blob_path(project.namespace, project, '{{branch}}'), new_merge_request_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '' }),
new_mr_template_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '{{source_branch}}' }),
can_commit: (!!can_push_branch?(project, @ref)).to_s, can_commit: (!!can_push_branch?(project, @ref)).to_s,
on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s, on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s,
current_path: @path } } current_path: @path } }
...@@ -28,8 +28,6 @@ feature 'Multi-file editor new file', :js do ...@@ -28,8 +28,6 @@ feature 'Multi-file editor new file', :js do
click_button('Create file') click_button('Create file')
end end
find('.inputarea').send_keys('file content')
fill_in('commit-message', with: 'commit message') fill_in('commit-message', with: 'commit message')
click_button('Commit 1 file') click_button('Commit 1 file')
......
export const createComponentWithStore = (Component, store, propsData = {}) => new Component({
store,
propsData,
});
export default (Component, props = {}, el = null) => new Component({ export default (Component, props = {}, el = null) => new Component({
propsData: props, propsData: props,
}).$mount(el); }).$mount(el);
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import newBranchForm from '~/repo/components/new_branch_form.vue'; import newBranchForm from '~/repo/components/new_branch_form.vue';
import eventHub from '~/repo/event_hub'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import RepoStore from '~/repo/stores/repo_store'; import { resetStore } from '../helpers';
import createComponent from '../../helpers/vue_mount_component_helper';
describe('Multi-file editor new branch form', () => { describe('Multi-file editor new branch form', () => {
let vm; let vm;
...@@ -10,17 +10,17 @@ describe('Multi-file editor new branch form', () => { ...@@ -10,17 +10,17 @@ describe('Multi-file editor new branch form', () => {
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(newBranchForm); const Component = Vue.extend(newBranchForm);
RepoStore.currentBranch = 'master'; vm = createComponentWithStore(Component, store);
vm = createComponent(Component, { vm.$store.state.currentBranch = 'master';
currentBranch: RepoStore.currentBranch,
}); vm.$mount();
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
RepoStore.currentBranch = ''; resetStore(vm.$store);
}); });
describe('template', () => { describe('template', () => {
...@@ -48,6 +48,10 @@ describe('Multi-file editor new branch form', () => { ...@@ -48,6 +48,10 @@ describe('Multi-file editor new branch form', () => {
}); });
describe('submitNewBranch', () => { describe('submitNewBranch', () => {
beforeEach(() => {
spyOn(vm, 'createNewBranch').and.returnValue(Promise.resolve());
});
it('sets to loading', () => { it('sets to loading', () => {
vm.submitNewBranch(); vm.submitNewBranch();
...@@ -66,57 +70,45 @@ describe('Multi-file editor new branch form', () => { ...@@ -66,57 +70,45 @@ describe('Multi-file editor new branch form', () => {
}); });
}); });
it('emits an event with branchName', () => { it('calls createdNewBranch with branchName', () => {
spyOn(eventHub, '$emit');
vm.branchName = 'testing'; vm.branchName = 'testing';
vm.submitNewBranch(); vm.submitNewBranch();
expect(eventHub.$emit).toHaveBeenCalledWith('createNewBranch', 'testing'); expect(vm.createNewBranch).toHaveBeenCalledWith('testing');
}); });
}); });
describe('showErrorMessage', () => { describe('submitNewBranch with error', () => {
it('sets loading to false', () => { beforeEach(() => {
vm.loading = true; spyOn(vm, 'createNewBranch').and.returnValue(Promise.reject({
json: () => Promise.resolve({
vm.showErrorMessage(); message: 'error message',
}),
expect(vm.loading).toBeFalsy(); }));
});
it('creates flash element', () => {
vm.showErrorMessage('error message');
expect(vm.$el.querySelector('.flash-alert')).not.toBeNull();
expect(vm.$el.querySelector('.flash-alert').textContent.trim()).toBe('error message');
}); });
});
describe('createdNewBranch', () => { it('sets loading to false', (done) => {
it('set loading to false', () => {
vm.loading = true; vm.loading = true;
vm.createdNewBranch(); vm.submitNewBranch();
expect(vm.loading).toBeFalsy();
});
it('resets branch name', () => {
vm.branchName = 'testing';
vm.createdNewBranch(); setTimeout(() => {
expect(vm.loading).toBeFalsy();
expect(vm.branchName).toBe(''); done();
});
}); });
it('sets the dropdown toggle text', () => { it('creates flash element', (done) => {
vm.dropdownText = document.createElement('span'); vm.submitNewBranch();
vm.createdNewBranch('branch name'); setTimeout(() => {
expect(vm.$el.querySelector('.flash-alert')).not.toBeNull();
expect(vm.$el.querySelector('.flash-alert').textContent.trim()).toBe('error message');
expect(vm.dropdownText.textContent).toBe('branch name'); done();
});
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import newDropdown from '~/repo/components/new_dropdown/index.vue'; import newDropdown from '~/repo/components/new_dropdown/index.vue';
import RepoStore from '~/repo/stores/repo_store'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import RepoHelper from '~/repo/helpers/repo_helper'; import { resetStore } from '../../helpers';
import eventHub from '~/repo/event_hub';
import createComponent from '../../../helpers/vue_mount_component_helper';
describe('new dropdown component', () => { describe('new dropdown component', () => {
let vm; let vm;
...@@ -11,15 +10,17 @@ describe('new dropdown component', () => { ...@@ -11,15 +10,17 @@ describe('new dropdown component', () => {
beforeEach(() => { beforeEach(() => {
const component = Vue.extend(newDropdown); const component = Vue.extend(newDropdown);
vm = createComponent(component); vm = createComponentWithStore(component, store);
vm.$store.state.path = '';
vm.$mount();
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
RepoStore.files = []; resetStore(vm.$store);
RepoStore.openedFiles = [];
RepoStore.setViewToPreview();
}); });
it('renders new file and new directory links', () => { it('renders new file and new directory links', () => {
...@@ -67,159 +68,4 @@ describe('new dropdown component', () => { ...@@ -67,159 +68,4 @@ describe('new dropdown component', () => {
.catch(done.fail); .catch(done.fail);
}); });
}); });
describe('createEntryInStore', () => {
['tree', 'blob'].forEach((type) => {
describe(type, () => {
it('closes modal after creating file', () => {
vm.openModal = true;
eventHub.$emit('createNewEntry', {
name: 'testing',
type,
toggleModal: true,
});
expect(vm.openModal).toBeFalsy();
});
it('sets editMode to true', () => {
eventHub.$emit('createNewEntry', {
name: 'testing',
type,
});
expect(RepoStore.editMode).toBeTruthy();
});
it('toggles blob view', () => {
eventHub.$emit('createNewEntry', {
name: 'testing',
type,
});
expect(RepoStore.isPreviewView()).toBeFalsy();
});
it('adds file into activeFiles', () => {
eventHub.$emit('createNewEntry', {
name: 'testing',
type,
});
expect(RepoStore.openedFiles.length).toBe(1);
});
it(`creates ${type} in the current stores path`, () => {
RepoStore.path = 'testing';
eventHub.$emit('createNewEntry', {
name: 'testing/app',
type,
});
expect(RepoStore.files[0].path).toBe('testing/app');
expect(RepoStore.files[0].name).toBe('app');
if (type === 'tree') {
expect(RepoStore.files[0].files.length).toBe(1);
}
RepoStore.path = '';
});
});
});
describe('file', () => {
it('creates new file', () => {
eventHub.$emit('createNewEntry', {
name: 'testing',
type: 'blob',
});
expect(RepoStore.files.length).toBe(1);
expect(RepoStore.files[0].name).toBe('testing');
expect(RepoStore.files[0].type).toBe('blob');
expect(RepoStore.files[0].tempFile).toBeTruthy();
});
it('does not create temp file when file already exists', () => {
RepoStore.files.push(RepoHelper.serializeRepoEntity('blob', {
name: 'testing',
}));
eventHub.$emit('createNewEntry', {
name: 'testing',
type: 'blob',
});
expect(RepoStore.files.length).toBe(1);
expect(RepoStore.files[0].name).toBe('testing');
expect(RepoStore.files[0].type).toBe('blob');
expect(RepoStore.files[0].tempFile).toBeUndefined();
});
});
describe('tree', () => {
it('creates new tree', () => {
eventHub.$emit('createNewEntry', {
name: 'testing',
type: 'tree',
});
expect(RepoStore.files.length).toBe(1);
expect(RepoStore.files[0].name).toBe('testing');
expect(RepoStore.files[0].type).toBe('tree');
expect(RepoStore.files[0].tempFile).toBeTruthy();
expect(RepoStore.files[0].files.length).toBe(1);
expect(RepoStore.files[0].files[0].name).toBe('.gitkeep');
});
it('creates multiple trees when entryName has slashes', () => {
eventHub.$emit('createNewEntry', {
name: 'app/test',
type: 'tree',
});
expect(RepoStore.files.length).toBe(1);
expect(RepoStore.files[0].name).toBe('app');
expect(RepoStore.files[0].files[0].name).toBe('test');
expect(RepoStore.files[0].files[0].files[0].name).toBe('.gitkeep');
});
it('creates tree in existing tree', () => {
RepoStore.files.push(RepoHelper.serializeRepoEntity('tree', {
name: 'app',
}));
eventHub.$emit('createNewEntry', {
name: 'app/test',
type: 'tree',
});
expect(RepoStore.files.length).toBe(1);
expect(RepoStore.files[0].name).toBe('app');
expect(RepoStore.files[0].tempFile).toBeUndefined();
expect(RepoStore.files[0].files[0].tempFile).toBeTruthy();
expect(RepoStore.files[0].files[0].name).toBe('test');
expect(RepoStore.files[0].files[0].files[0].name).toBe('.gitkeep');
});
it('does not create new tree when already exists', () => {
RepoStore.files.push(RepoHelper.serializeRepoEntity('tree', {
name: 'app',
}));
eventHub.$emit('createNewEntry', {
name: 'app',
type: 'tree',
});
expect(RepoStore.files.length).toBe(1);
expect(RepoStore.files[0].name).toBe('app');
expect(RepoStore.files[0].tempFile).toBeUndefined();
expect(RepoStore.files[0].files.length).toBe(0);
});
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import RepoStore from '~/repo/stores/repo_store'; import store from '~/repo/stores';
import modal from '~/repo/components/new_dropdown/modal.vue'; import modal from '~/repo/components/new_dropdown/modal.vue';
import eventHub from '~/repo/event_hub'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import createComponent from '../../../helpers/vue_mount_component_helper'; import { file, resetStore } from '../../helpers';
describe('new file modal component', () => { describe('new file modal component', () => {
const Component = Vue.extend(modal); const Component = Vue.extend(modal);
...@@ -11,18 +11,18 @@ describe('new file modal component', () => { ...@@ -11,18 +11,18 @@ describe('new file modal component', () => {
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
RepoStore.files = []; resetStore(vm.$store);
RepoStore.openedFiles = [];
RepoStore.setViewToPreview();
}); });
['tree', 'blob'].forEach((type) => { ['tree', 'blob'].forEach((type) => {
describe(type, () => { describe(type, () => {
beforeEach(() => { beforeEach(() => {
vm = createComponent(Component, { vm = createComponentWithStore(Component, store, {
type, type,
currentPath: RepoStore.path, path: '',
}); }).$mount();
vm.entryName = 'testing';
}); });
it(`sets modal title as ${type}`, () => { it(`sets modal title as ${type}`, () => {
...@@ -42,39 +42,157 @@ describe('new file modal component', () => { ...@@ -42,39 +42,157 @@ describe('new file modal component', () => {
expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`); expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`);
}); });
describe('createEntryInStore', () => {
it('calls createTempEntry', () => {
spyOn(vm, 'createTempEntry');
vm.createEntryInStore();
expect(vm.createTempEntry).toHaveBeenCalledWith({
name: 'testing',
type,
});
});
it('sets editMode to true', (done) => {
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.editMode).toBeTruthy();
done();
});
});
it('toggles blob view', (done) => {
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.currentBlobView).toBe('repo-editor');
done();
});
});
it('opens newly created file', (done) => {
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.openFiles.length).toBe(1);
expect(vm.$store.state.openFiles[0].name).toBe(type === 'blob' ? 'testing' : '.gitkeep');
done();
});
});
it(`creates ${type} in the current stores path`, (done) => {
vm.$store.state.path = 'app';
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.tree[0].path).toBe('app/testing');
expect(vm.$store.state.tree[0].name).toBe('testing');
if (type === 'tree') {
expect(vm.$store.state.tree[0].tree.length).toBe(1);
}
done();
});
});
if (type === 'blob') {
it('creates new file', (done) => {
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('testing');
expect(vm.$store.state.tree[0].type).toBe('blob');
expect(vm.$store.state.tree[0].tempFile).toBeTruthy();
done();
});
});
it('does not create temp file when file already exists', (done) => {
vm.$store.state.tree.push(file('testing', '1', type));
vm.createEntryInStore();
setTimeout(() => {
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('testing');
expect(vm.$store.state.tree[0].type).toBe('blob');
expect(vm.$store.state.tree[0].tempFile).toBeFalsy();
done();
});
});
} else {
it('creates new tree', () => {
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('testing');
expect(vm.$store.state.tree[0].type).toBe('tree');
expect(vm.$store.state.tree[0].tempFile).toBeTruthy();
expect(vm.$store.state.tree[0].tree.length).toBe(1);
expect(vm.$store.state.tree[0].tree[0].name).toBe('.gitkeep');
});
it('creates multiple trees when entryName has slashes', () => {
vm.entryName = 'app/test';
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('app');
expect(vm.$store.state.tree[0].tree[0].name).toBe('test');
expect(vm.$store.state.tree[0].tree[0].tree[0].name).toBe('.gitkeep');
});
it('creates tree in existing tree', () => {
vm.$store.state.tree.push(file('app', '1', 'tree'));
vm.entryName = 'app/test';
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('app');
expect(vm.$store.state.tree[0].tempFile).toBeFalsy();
expect(vm.$store.state.tree[0].tree[0].tempFile).toBeTruthy();
expect(vm.$store.state.tree[0].tree[0].name).toBe('test');
expect(vm.$store.state.tree[0].tree[0].tree[0].name).toBe('.gitkeep');
});
it('does not create new tree when already exists', () => {
vm.$store.state.tree.push(file('app', '1', 'tree'));
vm.entryName = 'app';
vm.createEntryInStore();
expect(vm.$store.state.tree.length).toBe(1);
expect(vm.$store.state.tree[0].name).toBe('app');
expect(vm.$store.state.tree[0].tempFile).toBeFalsy();
expect(vm.$store.state.tree[0].tree.length).toBe(0);
});
}
});
}); });
}); });
it('focuses field on mount', () => { it('focuses field on mount', () => {
document.body.innerHTML += '<div class="js-test"></div>'; document.body.innerHTML += '<div class="js-test"></div>';
vm = createComponent(Component, { vm = createComponentWithStore(Component, store, {
type: 'tree', type: 'tree',
currentPath: RepoStore.path, path: '',
}, '.js-test'); }).$mount('.js-test');
expect(document.activeElement).toBe(vm.$refs.fieldName); expect(document.activeElement).toBe(vm.$refs.fieldName);
vm.$el.remove(); vm.$el.remove();
}); });
describe('createEntryInStore', () => {
it('emits createNewEntry event', () => {
spyOn(eventHub, '$emit');
vm = createComponent(Component, {
type: 'tree',
currentPath: RepoStore.path,
});
vm.entryName = 'testing';
vm.createEntryInStore();
expect(eventHub.$emit).toHaveBeenCalledWith('createNewEntry', {
name: 'testing',
type: 'tree',
toggleModal: true,
});
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import upload from '~/repo/components/new_dropdown/upload.vue'; import upload from '~/repo/components/new_dropdown/upload.vue';
import eventHub from '~/repo/event_hub'; import store from '~/repo/stores';
import createComponent from '../../../helpers/vue_mount_component_helper'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { resetStore } from '../../helpers';
describe('new dropdown upload', () => { describe('new dropdown upload', () => {
let vm; let vm;
...@@ -9,13 +10,17 @@ describe('new dropdown upload', () => { ...@@ -9,13 +10,17 @@ describe('new dropdown upload', () => {
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(upload); const Component = Vue.extend(upload);
vm = createComponent(Component, { vm = createComponentWithStore(Component, store, {
currentPath: '', path: '',
}); });
vm.$mount();
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
resetStore(vm.$store);
}); });
describe('readFile', () => { describe('readFile', () => {
...@@ -56,45 +61,43 @@ describe('new dropdown upload', () => { ...@@ -56,45 +61,43 @@ describe('new dropdown upload', () => {
name: 'file', name: 'file',
}; };
beforeEach(() => { it('creates new file', (done) => {
spyOn(eventHub, '$emit');
});
it('emits createNewEntry event', () => {
vm.createFile(target, file, true); vm.createFile(target, file, true);
expect(eventHub.$emit).toHaveBeenCalledWith('createNewEntry', { vm.$nextTick(() => {
name: 'file', expect(vm.$store.state.tree.length).toBe(1);
type: 'blob', expect(vm.$store.state.tree[0].name).toBe(file.name);
content: 'content', expect(vm.$store.state.tree[0].content).toBe(target.result);
toggleModal: false,
base64: false, done();
}, true); });
}); });
it('createNewEntry event name contains current path', () => { it('creates new file in path', (done) => {
vm.currentPath = 'testing'; vm.$store.state.path = 'testing';
vm.createFile(target, file, true); vm.createFile(target, file, true);
expect(eventHub.$emit).toHaveBeenCalledWith('createNewEntry', { vm.$nextTick(() => {
name: 'testing/file', expect(vm.$store.state.tree.length).toBe(1);
type: 'blob', expect(vm.$store.state.tree[0].name).toBe(file.name);
content: 'content', expect(vm.$store.state.tree[0].content).toBe(target.result);
toggleModal: false, expect(vm.$store.state.tree[0].path).toBe(`testing/${file.name}`);
base64: false,
}, true); done();
});
}); });
it('splits content on base64 if binary', () => { it('splits content on base64 if binary', (done) => {
vm.createFile(binaryTarget, file, false); vm.createFile(binaryTarget, file, false);
expect(eventHub.$emit).toHaveBeenCalledWith('createNewEntry', { vm.$nextTick(() => {
name: 'file', expect(vm.$store.state.tree.length).toBe(1);
type: 'blob', expect(vm.$store.state.tree[0].name).toBe(file.name);
content: 'base64content', expect(vm.$store.state.tree[0].content).toBe(binaryTarget.result.split('base64,')[1]);
toggleModal: false, expect(vm.$store.state.tree[0].base64).toBe(true);
base64: true,
}, false); done();
});
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import service from '~/repo/services';
import repoCommitSection from '~/repo/components/repo_commit_section.vue'; import repoCommitSection from '~/repo/components/repo_commit_section.vue';
import RepoStore from '~/repo/stores/repo_store';
import RepoService from '~/repo/services/repo_service';
import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper'; import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper';
import { file, resetStore } from '../helpers';
describe('RepoCommitSection', () => { describe('RepoCommitSection', () => {
const branch = 'master'; let vm;
const projectUrl = 'projectUrl';
let changedFiles;
let openedFiles;
RepoStore.projectUrl = projectUrl; function createComponent() {
function createComponent(el) {
const RepoCommitSection = Vue.extend(repoCommitSection); const RepoCommitSection = Vue.extend(repoCommitSection);
return new RepoCommitSection().$mount(el); const comp = new RepoCommitSection({
store,
}).$mount();
comp.$store.state.currentBranch = 'master';
comp.$store.state.openFiles = [file(), file()];
comp.$store.state.openFiles.forEach(f => Object.assign(f, {
changed: true,
content: 'testing',
}));
return comp.$mount();
} }
beforeEach(() => { beforeEach(() => {
// Create a copy for each test because these can get modified directly vm = createComponent();
changedFiles = [{
id: 0,
changed: true,
url: `/namespace/${projectUrl}/blob/${branch}/dir/file0.ext`,
path: 'dir/file0.ext',
newContent: 'a',
}, {
id: 1,
changed: true,
url: `/namespace/${projectUrl}/blob/${branch}/dir/file1.ext`,
path: 'dir/file1.ext',
newContent: 'b',
}];
openedFiles = changedFiles.concat([{
id: 2,
url: `/namespace/${projectUrl}/blob/${branch}/dir/file2.ext`,
path: 'dir/file2.ext',
changed: false,
}]);
}); });
it('renders a commit section', () => { afterEach(() => {
RepoStore.isCommitable = true; vm.$destroy();
RepoStore.currentBranch = branch;
RepoStore.targetBranch = branch; resetStore(vm.$store);
RepoStore.openedFiles = openedFiles; });
const vm = createComponent(); it('renders a commit section', () => {
const changedFileElements = [...vm.$el.querySelectorAll('.changed-files > li')]; const changedFileElements = [...vm.$el.querySelectorAll('.changed-files > li')];
const commitMessage = vm.$el.querySelector('#commit-message'); const submitCommit = vm.$el.querySelector('.btn');
const submitCommit = vm.$refs.submitCommit;
const targetBranch = vm.$el.querySelector('.target-branch'); const targetBranch = vm.$el.querySelector('.target-branch');
expect(vm.$el.querySelector(':scope > form')).toBeTruthy(); expect(vm.$el.querySelector(':scope > form')).toBeTruthy();
...@@ -58,160 +45,70 @@ describe('RepoCommitSection', () => { ...@@ -58,160 +45,70 @@ describe('RepoCommitSection', () => {
expect(changedFileElements.length).toEqual(2); expect(changedFileElements.length).toEqual(2);
changedFileElements.forEach((changedFile, i) => { changedFileElements.forEach((changedFile, i) => {
expect(changedFile.textContent.trim()).toEqual(changedFiles[i].path); expect(changedFile.textContent.trim()).toEqual(vm.$store.getters.changedFiles[i].path);
}); });
expect(commitMessage.tagName).toEqual('TEXTAREA');
expect(commitMessage.name).toEqual('commit-message');
expect(submitCommit.type).toEqual('submit');
expect(submitCommit.disabled).toBeTruthy(); expect(submitCommit.disabled).toBeTruthy();
expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeFalsy(); expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeFalsy();
expect(vm.$el.querySelector('.commit-summary').textContent.trim()).toEqual('Commit 2 files'); expect(vm.$el.querySelector('.commit-summary').textContent.trim()).toEqual('Commit 2 files');
expect(targetBranch.querySelector(':scope > label').textContent.trim()).toEqual('Target branch'); expect(targetBranch.querySelector(':scope > label').textContent.trim()).toEqual('Target branch');
expect(targetBranch.querySelector('.help-block').textContent.trim()).toEqual(branch); expect(targetBranch.querySelector('.help-block').textContent.trim()).toEqual('master');
});
it('does not render if not isCommitable', () => {
RepoStore.isCommitable = false;
RepoStore.openedFiles = [{
id: 0,
changed: true,
}];
const vm = createComponent();
expect(vm.$el.innerHTML).toBeFalsy();
});
it('does not render if no changedFiles', () => {
RepoStore.isCommitable = true;
RepoStore.openedFiles = [];
const vm = createComponent();
expect(vm.$el.innerHTML).toBeFalsy();
}); });
describe('when submitting', () => { describe('when submitting', () => {
let el; let changedFiles;
let vm;
const projectId = 'projectId';
const commitMessage = 'commitMessage';
beforeEach((done) => {
RepoStore.isCommitable = true;
RepoStore.currentBranch = branch;
RepoStore.targetBranch = branch;
RepoStore.openedFiles = openedFiles;
RepoStore.projectId = projectId;
// We need to append to body to get form `submit` events working
// Otherwise we run into, "Form submission canceled because the form is not connected"
// See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm
el = document.createElement('div');
document.body.appendChild(el);
vm = createComponent(el);
vm.commitMessage = commitMessage;
spyOn(vm, 'tryCommit').and.callThrough();
spyOn(vm, 'redirectToNewMr').and.stub();
spyOn(vm, 'redirectToBranch').and.stub();
spyOn(RepoService, 'commitFiles').and.returnValue(Promise.resolve());
spyOn(RepoService, 'getBranch').and.returnValue(Promise.resolve({
commit: {
id: 1,
short_id: 1,
},
}));
// Wait for the vm data to be in place
Vue.nextTick(() => {
done();
});
});
afterEach(() => { beforeEach(() => {
vm.$destroy(); vm.commitMessage = 'testing';
el.remove(); changedFiles = JSON.parse(JSON.stringify(vm.$store.getters.changedFiles));
RepoStore.openedFiles = [];
});
it('shows commit message', () => { spyOn(service, 'commit').and.returnValue(Promise.resolve({
const commitMessageEl = vm.$el.querySelector('#commit-message'); short_id: '1',
expect(commitMessageEl.value).toBe(commitMessage); stats: {},
}));
}); });
it('allows you to submit', () => { it('allows you to submit', () => {
const submitCommit = vm.$refs.submitCommit; expect(vm.$el.querySelector('.btn').disabled).toBeTruthy();
expect(submitCommit.disabled).toBeFalsy();
}); });
it('shows commit submit and summary if commitMessage and spinner if submitCommitsLoading', (done) => { it('submits commit', (done) => {
const submitCommit = vm.$refs.submitCommit; vm.makeCommit();
submitCommit.click();
// Wait for the branch check to finish // Wait for the branch check to finish
getSetTimeoutPromise() getSetTimeoutPromise()
.then(() => Vue.nextTick()) .then(() => Vue.nextTick())
.then(() => { .then(() => {
expect(vm.tryCommit).toHaveBeenCalled(); const args = service.commit.calls.allArgs()[0];
expect(submitCommit.querySelector('.js-commit-loading-icon')).toBeTruthy(); const { commit_message, actions, branch: payloadBranch } = args[1];
expect(vm.redirectToBranch).toHaveBeenCalled();
const args = RepoService.commitFiles.calls.allArgs()[0];
const { commit_message, actions, branch: payloadBranch } = args[0];
expect(commit_message).toBe(commitMessage); expect(commit_message).toBe('testing');
expect(actions.length).toEqual(2); expect(actions.length).toEqual(2);
expect(payloadBranch).toEqual(branch); expect(payloadBranch).toEqual('master');
expect(actions[0].action).toEqual('update'); expect(actions[0].action).toEqual('update');
expect(actions[1].action).toEqual('update'); expect(actions[1].action).toEqual('update');
expect(actions[0].content).toEqual(openedFiles[0].newContent); expect(actions[0].content).toEqual(changedFiles[0].content);
expect(actions[1].content).toEqual(openedFiles[1].newContent); expect(actions[1].content).toEqual(changedFiles[1].content);
expect(actions[0].file_path).toEqual(openedFiles[0].path); expect(actions[0].file_path).toEqual(changedFiles[0].path);
expect(actions[1].file_path).toEqual(openedFiles[1].path); expect(actions[1].file_path).toEqual(changedFiles[1].path);
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
}); });
it('redirects to MR creation page if start new MR checkbox checked', (done) => { it('redirects to MR creation page if start new MR checkbox checked', (done) => {
spyOn(gl.utils, 'visitUrl');
vm.startNewMR = true; vm.startNewMR = true;
Vue.nextTick() vm.makeCommit();
.then(() => {
const submitCommit = vm.$refs.submitCommit; getSetTimeoutPromise()
submitCommit.click(); .then(() => Vue.nextTick())
})
// Wait for the branch check to finish
.then(() => getSetTimeoutPromise())
.then(() => { .then(() => {
expect(vm.redirectToNewMr).toHaveBeenCalled(); expect(gl.utils.visitUrl).toHaveBeenCalled();
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
}); });
}); });
describe('methods', () => {
describe('resetCommitState', () => {
it('should reset store vars and scroll to top', () => {
const vm = {
submitCommitsLoading: true,
changedFiles: new Array(10),
openedFiles: new Array(3),
commitMessage: 'commitMessage',
editMode: true,
};
repoCommitSection.methods.resetCommitState.call(vm);
expect(vm.submitCommitsLoading).toEqual(false);
expect(vm.changedFiles).toEqual([]);
expect(vm.commitMessage).toEqual('');
expect(vm.editMode).toEqual(false);
});
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoEditButton from '~/repo/components/repo_edit_button.vue'; import repoEditButton from '~/repo/components/repo_edit_button.vue';
import RepoStore from '~/repo/stores/repo_store'; import { file, resetStore } from '../helpers';
describe('RepoEditButton', () => { describe('RepoEditButton', () => {
function createComponent() { let vm;
beforeEach(() => {
const f = file();
const RepoEditButton = Vue.extend(repoEditButton); const RepoEditButton = Vue.extend(repoEditButton);
return new RepoEditButton().$mount(); vm = new RepoEditButton({
} store,
});
f.active = true;
vm.$store.dispatch('setInitialData', {
canCommit: true,
onTopOfBranch: true,
});
vm.$store.state.openFiles.push(f);
});
afterEach(() => { afterEach(() => {
RepoStore.openedFiles = []; vm.$destroy();
resetStore(vm.$store);
}); });
it('renders an edit button that toggles the view state', (done) => { it('renders an edit button', () => {
RepoStore.isCommitable = true; vm.$mount();
RepoStore.changedFiles = [];
RepoStore.binary = false; expect(vm.$el.querySelector('.btn')).not.toBeNull();
RepoStore.openedFiles = [{}, {}]; expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Edit');
});
const vm = createComponent(); it('renders edit button with cancel text', () => {
vm.$store.state.editMode = true;
expect(vm.$el.tagName).toEqual('BUTTON'); vm.$mount();
expect(vm.$el.textContent).toMatch('Edit');
spyOn(vm, 'editCancelClicked').and.callThrough(); expect(vm.$el.querySelector('.btn')).not.toBeNull();
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
});
vm.$el.click(); it('toggles edit mode on click', (done) => {
vm.$mount();
vm.$el.querySelector('.btn').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
Vue.nextTick(() => {
expect(vm.editCancelClicked).toHaveBeenCalled();
expect(vm.$el.textContent).toMatch('Cancel edit');
done(); done();
}); });
}); });
it('does not render if not isCommitable', () => { describe('discardPopupOpen', () => {
RepoStore.isCommitable = false; beforeEach(() => {
vm.$store.state.discardPopupOpen = true;
vm.$store.state.editMode = true;
vm.$store.state.openFiles[0].changed = true;
vm.$mount();
});
it('renders popup', () => {
expect(vm.$el.querySelector('.modal')).not.toBeNull();
});
it('removes all changed files', (done) => {
vm.$el.querySelector('.btn-warning').click();
const vm = createComponent(); vm.$nextTick(() => {
expect(vm.$store.getters.changedFiles.length).toBe(0);
expect(vm.$el.querySelector('.modal')).toBeNull();
expect(vm.$el.innerHTML).toBeUndefined(); done();
});
});
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import RepoStore from '~/repo/stores/repo_store'; import store from '~/repo/stores';
import repoEditor from '~/repo/components/repo_editor.vue'; import repoEditor from '~/repo/components/repo_editor.vue';
import { file, resetStore } from '../helpers';
describe('RepoEditor', () => { describe('RepoEditor', () => {
let vm;
beforeEach(() => { beforeEach(() => {
const f = file();
const RepoEditor = Vue.extend(repoEditor); const RepoEditor = Vue.extend(repoEditor);
this.vm = new RepoEditor().$mount(); vm = new RepoEditor({
store,
});
f.active = true;
f.tempFile = true;
vm.$store.state.openFiles.push(f);
vm.monaco = true;
vm.$mount();
}); });
afterEach(() => { afterEach(() => {
RepoStore.openedFiles = []; vm.$destroy();
resetStore(vm.$store);
}); });
it('renders an ide container', (done) => { it('renders an ide container', (done) => {
this.vm.openedFiles = ['idiidid'];
this.vm.binary = false;
Vue.nextTick(() => { Vue.nextTick(() => {
expect(this.vm.shouldHideEditor).toBe(false); expect(vm.shouldHideEditor).toBeFalsy();
expect(this.vm.$el.id).toEqual('ide');
expect(this.vm.$el.tagName).toBe('DIV');
done(); done();
}); });
}); });
describe('when there are no open files', () => {
it('does not render the ide', (done) => {
this.vm.openedFiles = [];
Vue.nextTick(() => {
expect(this.vm.shouldHideEditor).toBe(true);
expect(this.vm.$el.tagName).not.toBeDefined();
done();
});
});
});
describe('when open file is binary and not raw', () => { describe('when open file is binary and not raw', () => {
it('does not render the IDE', (done) => { it('does not render the IDE', (done) => {
this.vm.binary = true; vm.$store.getters.activeFile.binary = true;
this.vm.activeFile = {
raw: false,
};
Vue.nextTick(() => { Vue.nextTick(() => {
expect(this.vm.shouldHideEditor).toBe(true); expect(vm.shouldHideEditor).toBeTruthy();
expect(this.vm.$el.tagName).not.toBeDefined();
done(); done();
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoFileButtons from '~/repo/components/repo_file_buttons.vue'; import repoFileButtons from '~/repo/components/repo_file_buttons.vue';
import RepoStore from '~/repo/stores/repo_store'; import { file, resetStore } from '../helpers';
describe('RepoFileButtons', () => { describe('RepoFileButtons', () => {
const activeFile = { const activeFile = file();
extension: 'md', let vm;
url: 'url',
raw_path: 'raw_path',
blame_path: 'blame_path',
commits_path: 'commits_path',
permalink: 'permalink',
};
function createComponent() { function createComponent() {
const RepoFileButtons = Vue.extend(repoFileButtons); const RepoFileButtons = Vue.extend(repoFileButtons);
return new RepoFileButtons().$mount(); activeFile.rawPath = 'test';
activeFile.blamePath = 'test';
activeFile.commitsPath = 'test';
activeFile.active = true;
store.state.openFiles.push(activeFile);
return new RepoFileButtons({
store,
}).$mount();
} }
afterEach(() => { afterEach(() => {
RepoStore.openedFiles = []; vm.$destroy();
});
it('renders Raw, Blame, History, Permalink and Preview toggle', () => {
const activeFileLabel = 'activeFileLabel';
RepoStore.openedFiles = new Array(1);
RepoStore.activeFile = activeFile;
RepoStore.activeFileLabel = activeFileLabel;
RepoStore.editMode = true;
RepoStore.binary = false;
const vm = createComponent(); resetStore(vm.$store);
const raw = vm.$el.querySelector('.raw');
const blame = vm.$el.querySelector('.blame');
const history = vm.$el.querySelector('.history');
expect(raw.href).toMatch(`/${activeFile.raw_path}`);
expect(raw.textContent.trim()).toEqual('Raw');
expect(blame.href).toMatch(`/${activeFile.blame_path}`);
expect(blame.textContent.trim()).toEqual('Blame');
expect(history.href).toMatch(`/${activeFile.commits_path}`);
expect(history.textContent.trim()).toEqual('History');
expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink');
expect(vm.$el.querySelector('.preview').textContent.trim()).toEqual(activeFileLabel);
}); });
it('triggers rawPreviewToggle on preview click', () => { it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => {
RepoStore.openedFiles = new Array(1); vm = createComponent();
RepoStore.activeFile = activeFile;
RepoStore.editMode = true;
const vm = createComponent();
const preview = vm.$el.querySelector('.preview');
spyOn(vm, 'rawPreviewToggle');
preview.click();
expect(vm.rawPreviewToggle).toHaveBeenCalled();
});
it('does not render preview toggle if not canPreview', () => { vm.$nextTick(() => {
activeFile.extension = 'js'; const raw = vm.$el.querySelector('.raw');
RepoStore.openedFiles = new Array(1); const blame = vm.$el.querySelector('.blame');
RepoStore.activeFile = activeFile; const history = vm.$el.querySelector('.history');
const vm = createComponent(); expect(raw.href).toMatch(`/${activeFile.rawPath}`);
expect(raw.textContent.trim()).toEqual('Raw');
expect(blame.href).toMatch(`/${activeFile.blamePath}`);
expect(blame.textContent.trim()).toEqual('Blame');
expect(history.href).toMatch(`/${activeFile.commitsPath}`);
expect(history.textContent.trim()).toEqual('History');
expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink');
expect(vm.$el.querySelector('.preview')).toBeFalsy(); done();
});
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoFile from '~/repo/components/repo_file.vue'; import repoFile from '~/repo/components/repo_file.vue';
import RepoStore from '~/repo/stores/repo_store'; import { file, resetStore } from '../helpers';
import eventHub from '~/repo/event_hub';
import { file } from '../mock_data';
describe('RepoFile', () => { describe('RepoFile', () => {
const updated = 'updated'; const updated = 'updated';
const otherFile = { let vm;
id: 'test',
html: '<p class="file-content">html</p>',
pageTitle: 'otherpageTitle',
};
function createComponent(propsData) { function createComponent(propsData) {
const RepoFile = Vue.extend(repoFile); const RepoFile = Vue.extend(repoFile);
return new RepoFile({ return new RepoFile({
store,
propsData, propsData,
}).$mount(); }).$mount();
} }
beforeEach(() => { afterEach(() => {
RepoStore.openedFiles = []; resetStore(vm.$store);
}); });
it('renders link, icon, name and last commit details', () => { it('renders link, icon, name and last commit details', () => {
const RepoFile = Vue.extend(repoFile); const RepoFile = Vue.extend(repoFile);
const vm = new RepoFile({ vm = new RepoFile({
store,
propsData: { propsData: {
file: file(), file: file(),
}, },
...@@ -47,23 +44,17 @@ describe('RepoFile', () => { ...@@ -47,23 +44,17 @@ describe('RepoFile', () => {
}); });
it('does render if hasFiles is true and is loading tree', () => { it('does render if hasFiles is true and is loading tree', () => {
const vm = createComponent({ vm = createComponent({
file: file(), file: file(),
}); });
expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy(); expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy();
}); });
it('sets the document title correctly', () => {
RepoStore.setActiveFiles(otherFile);
expect(document.title.trim()).toEqual(otherFile.pageTitle);
});
it('renders a spinner if the file is loading', () => { it('renders a spinner if the file is loading', () => {
const f = file(); const f = file();
f.loading = true; f.loading = true;
const vm = createComponent({ vm = createComponent({
file: f, file: f,
}); });
...@@ -71,32 +62,34 @@ describe('RepoFile', () => { ...@@ -71,32 +62,34 @@ describe('RepoFile', () => {
expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${vm.file.level * 16}px`); expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${vm.file.level * 16}px`);
}); });
it('does not render commit message and datetime if mini', () => { it('does not render commit message and datetime if mini', (done) => {
RepoStore.openedFiles.push(file()); vm = createComponent({
const vm = createComponent({
file: file(), file: file(),
}); });
vm.$store.state.openFiles.push(vm.file);
expect(vm.$el.querySelector('.commit-message')).toBeFalsy(); vm.$nextTick(() => {
expect(vm.$el.querySelector('.commit-update')).toBeFalsy(); expect(vm.$el.querySelector('.commit-message')).toBeFalsy();
expect(vm.$el.querySelector('.commit-update')).toBeFalsy();
done();
});
}); });
it('fires linkClicked when the link is clicked', () => { it('fires clickedTreeRow when the link is clicked', () => {
const vm = createComponent({ vm = createComponent({
file: file(), file: file(),
}); });
spyOn(vm, 'linkClicked'); spyOn(vm, 'clickedTreeRow');
vm.$el.click(); vm.$el.click();
expect(vm.linkClicked).toHaveBeenCalledWith(vm.file); expect(vm.clickedTreeRow).toHaveBeenCalledWith(vm.file);
}); });
describe('submodule', () => { describe('submodule', () => {
let f; let f;
let vm;
beforeEach(() => { beforeEach(() => {
f = file('submodule name', '123456789'); f = file('submodule name', '123456789');
...@@ -119,20 +112,4 @@ describe('RepoFile', () => { ...@@ -119,20 +112,4 @@ describe('RepoFile', () => {
expect(vm.$el.querySelector('td').textContent.replace(/\s+/g, ' ')).toContain('submodule name @ 12345678'); expect(vm.$el.querySelector('td').textContent.replace(/\s+/g, ' ')).toContain('submodule name @ 12345678');
}); });
}); });
describe('methods', () => {
describe('linkClicked', () => {
it('$emits fileNameClicked with file obj', () => {
spyOn(eventHub, '$emit');
const vm = createComponent({
file: file(),
});
vm.linkClicked(vm.file);
expect(eventHub.$emit).toHaveBeenCalledWith('fileNameClicked', vm.file);
});
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import RepoStore from '~/repo/stores/repo_store'; import store from '~/repo/stores';
import repoLoadingFile from '~/repo/components/repo_loading_file.vue'; import repoLoadingFile from '~/repo/components/repo_loading_file.vue';
import { resetStore } from '../helpers';
describe('RepoLoadingFile', () => { describe('RepoLoadingFile', () => {
function createComponent(propsData) { let vm;
function createComponent() {
const RepoLoadingFile = Vue.extend(repoLoadingFile); const RepoLoadingFile = Vue.extend(repoLoadingFile);
return new RepoLoadingFile({ return new RepoLoadingFile({
propsData, store,
}).$mount(); }).$mount();
} }
...@@ -30,33 +33,30 @@ describe('RepoLoadingFile', () => { ...@@ -30,33 +33,30 @@ describe('RepoLoadingFile', () => {
} }
afterEach(() => { afterEach(() => {
RepoStore.openedFiles = []; vm.$destroy();
resetStore(vm.$store);
}); });
it('renders 3 columns of animated LoC', () => { it('renders 3 columns of animated LoC', () => {
const vm = createComponent({ vm = createComponent();
loading: {
tree: true,
},
hasFiles: false,
});
const columns = [...vm.$el.querySelectorAll('td')]; const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(3); expect(columns.length).toEqual(3);
assertColumns(columns); assertColumns(columns);
}); });
it('renders 1 column of animated LoC if isMini', () => { it('renders 1 column of animated LoC if isMini', (done) => {
RepoStore.openedFiles = new Array(1); vm = createComponent();
const vm = createComponent({ vm.$store.state.openFiles.push('test');
loading: {
tree: true,
},
hasFiles: false,
});
const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(1); vm.$nextTick(() => {
assertColumns(columns); const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(1);
assertColumns(columns);
done();
});
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue'; import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue';
import eventHub from '~/repo/event_hub'; import { resetStore } from '../helpers';
describe('RepoPrevDirectory', () => { describe('RepoPrevDirectory', () => {
function createComponent(propsData) { let vm;
const parentLink = 'parent';
function createComponent() {
const RepoPrevDirectory = Vue.extend(repoPrevDirectory); const RepoPrevDirectory = Vue.extend(repoPrevDirectory);
return new RepoPrevDirectory({ const comp = new RepoPrevDirectory({
propsData, store,
}).$mount();
}
it('renders a prev dir link', () => {
const prevUrl = 'prevUrl';
const vm = createComponent({
prevUrl,
}); });
const link = vm.$el.querySelector('a');
spyOn(vm, 'linkClicked'); comp.$store.state.parentTreeUrl = parentLink;
expect(link.href).toMatch(`/${prevUrl}`); return comp.$mount();
expect(link.textContent).toEqual('...'); }
beforeEach(() => {
vm = createComponent();
});
link.click(); afterEach(() => {
vm.$destroy();
expect(vm.linkClicked).toHaveBeenCalledWith(prevUrl); resetStore(vm.$store);
}); });
describe('methods', () => { it('renders a prev dir link', () => {
describe('linkClicked', () => { const link = vm.$el.querySelector('a');
it('$emits linkclicked with prevUrl', () => {
const prevUrl = 'prevUrl';
const vm = createComponent({
prevUrl,
});
spyOn(eventHub, '$emit'); expect(link.href).toMatch(`/${parentLink}`);
expect(link.textContent).toEqual('...');
});
vm.linkClicked(prevUrl); it('clicking row triggers getTreeData', () => {
spyOn(vm, 'getTreeData');
expect(eventHub.$emit).toHaveBeenCalledWith('goToPreviousDirectoryClicked', prevUrl); vm.$el.querySelector('td').click();
});
}); expect(vm.getTreeData).toHaveBeenCalledWith({ endpoint: parentLink });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repoPreview from '~/repo/components/repo_preview.vue'; import repoPreview from '~/repo/components/repo_preview.vue';
import RepoStore from '~/repo/stores/repo_store'; import { file, resetStore } from '../helpers';
describe('RepoPreview', () => { describe('RepoPreview', () => {
let vm;
function createComponent() { function createComponent() {
const f = file();
const RepoPreview = Vue.extend(repoPreview); const RepoPreview = Vue.extend(repoPreview);
return new RepoPreview().$mount(); const comp = new RepoPreview({
store,
});
f.active = true;
f.html = 'test';
comp.$store.state.openFiles.push(f);
return comp.$mount();
} }
it('renders a div with the activeFile html', () => { afterEach(() => {
const activeFile = { vm.$destroy();
html: '<p class="file-content">html</p>',
}; resetStore(vm.$store);
RepoStore.activeFile = activeFile; });
const vm = createComponent(); it('renders a div with the activeFile html', () => {
vm = createComponent();
expect(vm.$el.tagName).toEqual('DIV'); expect(vm.$el.tagName).toEqual('DIV');
expect(vm.$el.innerHTML).toContain(activeFile.html); expect(vm.$el.innerHTML).toContain('test');
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import Helper from '~/repo/helpers/repo_helper'; import store from '~/repo/stores';
import RepoService from '~/repo/services/repo_service';
import RepoStore from '~/repo/stores/repo_store';
import repoSidebar from '~/repo/components/repo_sidebar.vue'; import repoSidebar from '~/repo/components/repo_sidebar.vue';
import { file } from '../mock_data'; import { file, resetStore } from '../helpers';
describe('RepoSidebar', () => { describe('RepoSidebar', () => {
let vm; let vm;
function createComponent() { beforeEach(() => {
const RepoSidebar = Vue.extend(repoSidebar); const RepoSidebar = Vue.extend(repoSidebar);
return new RepoSidebar().$mount(); vm = new RepoSidebar({
} store,
});
vm.$store.state.isRoot = true;
vm.$store.state.tree.push(file());
vm.$mount();
});
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
RepoStore.files = []; resetStore(vm.$store);
RepoStore.openedFiles = [];
}); });
it('renders a sidebar', () => { it('renders a sidebar', () => {
RepoStore.files = [file()];
RepoStore.openedFiles = [];
RepoStore.isRoot = true;
vm = createComponent();
const thead = vm.$el.querySelector('thead'); const thead = vm.$el.querySelector('thead');
const tbody = vm.$el.querySelector('tbody'); const tbody = vm.$el.querySelector('tbody');
...@@ -41,139 +40,36 @@ describe('RepoSidebar', () => { ...@@ -41,139 +40,36 @@ describe('RepoSidebar', () => {
expect(tbody.querySelector('.file')).toBeTruthy(); expect(tbody.querySelector('.file')).toBeTruthy();
}); });
it('does not render a thead, renders repo-file-options and sets sidebar-mini class if isMini', () => { it('does not render a thead, renders repo-file-options and sets sidebar-mini class if isMini', (done) => {
RepoStore.openedFiles = [{ vm.$store.state.openFiles.push(vm.$store.state.tree[0]);
id: 0,
}];
vm = createComponent();
expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy();
expect(vm.$el.querySelector('thead')).toBeTruthy();
expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy();
});
it('renders 5 loading files if tree is loading and not hasFiles', () => {
RepoStore.loading.tree = true;
RepoStore.files = [];
vm = createComponent();
expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5); Vue.nextTick(() => {
}); expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy();
expect(vm.$el.querySelector('thead')).toBeTruthy();
it('renders a prev directory if is not root', () => { expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy();
RepoStore.files = [file()];
RepoStore.isRoot = false;
RepoStore.loading.tree = false;
vm = createComponent();
expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy();
});
describe('flattendFiles', () => { done();
it('returns a flattend array of files', () => {
const f = file();
f.files.push(file('testing 123'));
const files = [f, file()];
vm = createComponent();
vm.files = files;
expect(vm.flattendFiles.length).toBe(3);
expect(vm.flattendFiles[1].name).toBe('testing 123');
}); });
}); });
describe('methods', () => { it('renders 5 loading files if tree is loading', (done) => {
describe('fileClicked', () => { vm.$store.state.tree = [];
it('should fetch data for new file', () => { vm.$store.state.loading = true;
spyOn(Helper, 'getContent').and.callThrough();
RepoStore.files = [file()];
RepoStore.isRoot = true;
vm = createComponent();
vm.fileClicked(RepoStore.files[0]);
expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[0]);
});
it('should not fetch data for already opened files', () => {
const f = file();
spyOn(Helper, 'getFileFromPath').and.returnValue(f);
spyOn(RepoStore, 'setActiveFiles');
vm = createComponent();
vm.fileClicked(f);
expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(f); Vue.nextTick(() => {
}); expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5);
it('should hide files in directory if already open', () => { done();
spyOn(Helper, 'setDirectoryToClosed').and.callThrough();
const f = file();
f.opened = true;
f.type = 'tree';
RepoStore.files = [f];
vm = createComponent();
vm.fileClicked(RepoStore.files[0]);
expect(Helper.setDirectoryToClosed).toHaveBeenCalledWith(RepoStore.files[0]);
});
describe('submodule', () => {
it('opens submodule project URL', () => {
spyOn(gl.utils, 'visitUrl');
const f = file();
f.type = 'submodule';
vm = createComponent();
vm.fileClicked(f);
expect(gl.utils.visitUrl).toHaveBeenCalledWith('url');
});
});
});
describe('goToPreviousDirectoryClicked', () => {
it('should hide files in directory if already open', () => {
const prevUrl = 'foo/bar';
vm = createComponent();
vm.goToPreviousDirectoryClicked(prevUrl);
expect(RepoService.url).toEqual(prevUrl);
});
}); });
});
describe('back button', () => { it('renders a prev directory if is not root', (done) => {
beforeEach(() => { vm.$store.state.isRoot = false;
const f = file();
const file2 = Object.assign({}, file());
file2.url = 'test';
RepoStore.files = [f, file2];
RepoStore.openedFiles = [];
RepoStore.isRoot = true;
vm = createComponent();
});
it('render previous file when using back button', () => {
spyOn(Helper, 'getContent').and.callThrough();
vm.fileClicked(RepoStore.files[1]);
expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[1]);
history.pushState({
key: Math.random(),
}, '', RepoStore.files[1].url);
const popEvent = document.createEvent('Event');
popEvent.initEvent('popstate', true, true);
window.dispatchEvent(popEvent);
expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(RepoStore.files[1].url); Vue.nextTick(() => {
expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy();
window.history.pushState({}, null, '/'); done();
});
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores';
import repo from '~/repo/components/repo.vue'; import repo from '~/repo/components/repo.vue';
import RepoStore from '~/repo/stores/repo_store'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import Service from '~/repo/services/repo_service'; import { file, resetStore } from '../helpers';
import eventHub from '~/repo/event_hub';
import createComponent from '../../helpers/vue_mount_component_helper';
describe('repo component', () => { describe('repo component', () => {
let vm; let vm;
...@@ -11,86 +10,26 @@ describe('repo component', () => { ...@@ -11,86 +10,26 @@ describe('repo component', () => {
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(repo); const Component = Vue.extend(repo);
RepoStore.currentBranch = 'master'; vm = createComponentWithStore(Component, store).$mount();
vm = createComponent(Component);
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
RepoStore.currentBranch = ''; resetStore(vm.$store);
}); });
describe('createNewBranch', () => { it('does not render panel right when no files open', () => {
beforeEach(() => { expect(vm.$el.querySelector('.panel-right')).toBeNull();
spyOn(history, 'pushState'); });
});
describe('success', () => {
beforeEach(() => {
spyOn(Service, 'createBranch').and.returnValue(Promise.resolve({
data: {
name: 'test',
},
}));
});
it('calls createBranch with branchName', () => {
eventHub.$emit('createNewBranch', 'test');
expect(Service.createBranch).toHaveBeenCalledWith({
branch: 'test',
ref: RepoStore.currentBranch,
});
});
it('pushes new history state', (done) => {
RepoStore.currentBranch = 'master';
spyOn(vm, 'getCurrentLocation').and.returnValue('http://test.com/master');
eventHub.$emit('createNewBranch', 'test');
setTimeout(() => {
expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'http://test.com/test');
done();
});
});
it('updates stores currentBranch', (done) => {
eventHub.$emit('createNewBranch', 'test');
setTimeout(() => {
expect(RepoStore.currentBranch).toBe('test');
done();
});
});
});
describe('failure', () => {
beforeEach(() => {
spyOn(Service, 'createBranch').and.returnValue(Promise.reject({
response: {
data: {
message: 'test',
},
},
}));
});
it('emits createNewBranchError event', (done) => {
spyOn(eventHub, '$emit').and.callThrough();
eventHub.$emit('createNewBranch', 'test'); it('renders panel right when files are open', (done) => {
vm.$store.state.tree.push(file());
setTimeout(() => { Vue.nextTick(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('createNewBranchError', 'test'); expect(vm.$el.querySelector('.panel-right')).toBeNull();
done(); done();
});
});
}); });
}); });
}); });
import RepoHelper from '~/repo/helpers/repo_helper'; import { decorateData } from '~/repo/stores/utils';
import state from '~/repo/stores/state';
// eslint-disable-next-line import/prefer-default-export export const resetStore = (store) => {
export const file = (name = 'name', id = name) => RepoHelper.serializeRepoEntity('blob', { store.replaceState(state());
};
export const file = (name = 'name', id = name, type = '') => decorateData({
id, id,
type,
icon: 'icon', icon: 'icon',
url: 'url', url: 'url',
name, name,
path: name,
last_commit: { last_commit: {
id: '123', id: '123',
message: 'test', message: 'test',
......
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