Skip to content
Snippets Groups Projects
Commit 3e66795e authored by Felipe Artur's avatar Felipe Artur Committed by Tim Zallmann
Browse files

Changes tab VUE refactoring

parent 14e35ac9
No related branches found
No related tags found
No related merge requests found
Showing
with 2042 additions and 142 deletions
Loading
Loading
@@ -31,7 +31,9 @@ export default class Autosave {
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0);
field.dispatchEvent(event);
if (field) {
field.dispatchEvent(event);
}
}
 
save() {
Loading
Loading
Loading
Loading
@@ -11,7 +11,8 @@ import axios from './lib/utils/axios_utils';
 
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
const requestAnimationFrame = window.requestAnimationFrame ||
const requestAnimationFrame =
window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.setTimeout;
Loading
Loading
@@ -37,21 +38,28 @@ class AwardsHandler {
this.emoji = emoji;
this.eventListeners = [];
// If the user shows intent let's pre-build the menu
this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => {
const $menu = $('.emoji-menu');
if ($menu.length === 0) {
requestAnimationFrame(() => {
this.createEmojiMenu();
});
}
});
this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => {
this.registerEventListener(
'one',
$(document),
'mouseenter focus',
'.js-add-award',
'mouseenter focus',
() => {
const $menu = $('.emoji-menu');
if ($menu.length === 0) {
requestAnimationFrame(() => {
this.createEmojiMenu();
});
}
},
);
this.registerEventListener('on', $(document), 'click', '.js-add-award', e => {
e.stopPropagation();
e.preventDefault();
this.showEmojiMenu($(e.currentTarget));
});
 
this.registerEventListener('on', $('html'), 'click', (e) => {
this.registerEventListener('on', $('html'), 'click', e => {
const $target = $(e.target);
if (!$target.closest('.emoji-menu').length) {
$('.js-awards-block.current').removeClass('current');
Loading
Loading
@@ -61,12 +69,14 @@ class AwardsHandler {
}
}
});
this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', (e) => {
this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', e => {
e.preventDefault();
const $target = $(e.currentTarget);
const $glEmojiElement = $target.find('gl-emoji');
const $spriteIconElement = $target.find('.icon');
const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data(
'name',
);
 
$target.closest('.js-awards-block').addClass('current');
this.addAward(this.getVotesBlock(), this.getAwardUrl(), emojiName);
Loading
Loading
@@ -83,7 +93,10 @@ class AwardsHandler {
 
showEmojiMenu($addBtn) {
if ($addBtn.hasClass('js-note-emoji')) {
$addBtn.closest('.note').find('.js-awards-block').addClass('current');
$addBtn
.closest('.note')
.find('.js-awards-block')
.addClass('current');
} else {
$addBtn.closest('.js-awards-block').addClass('current');
}
Loading
Loading
@@ -177,32 +190,38 @@ class AwardsHandler {
const remainingCategories = Object.keys(categoryMap).slice(1);
const allCategoriesAddedPromise = remainingCategories.reduce(
(promiseChain, categoryNameKey) =>
promiseChain.then(() =>
new Promise((resolve) => {
const emojisInCategory = categoryMap[categoryNameKey];
const categoryMarkup = this.renderCategory(
categoryLabelMap[categoryNameKey],
emojisInCategory,
);
requestAnimationFrame(() => {
emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup);
resolve();
});
}),
),
promiseChain.then(
() =>
new Promise(resolve => {
const emojisInCategory = categoryMap[categoryNameKey];
const categoryMarkup = this.renderCategory(
categoryLabelMap[categoryNameKey],
emojisInCategory,
);
requestAnimationFrame(() => {
emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup);
resolve();
});
}),
),
Promise.resolve(),
);
 
allCategoriesAddedPromise.then(() => {
// Used for tests
// We check for the menu in case it was destroyed in the meantime
if (menu) {
menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish'));
}
}).catch((err) => {
emojiContentElement.insertAdjacentHTML('beforeend', '<p>We encountered an error while adding the remaining categories</p>');
throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`);
});
allCategoriesAddedPromise
.then(() => {
// Used for tests
// We check for the menu in case it was destroyed in the meantime
if (menu) {
menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish'));
}
})
.catch(err => {
emojiContentElement.insertAdjacentHTML(
'beforeend',
'<p>We encountered an error while adding the remaining categories</p>',
);
throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`);
});
}
 
renderCategory(name, emojiList, opts = {}) {
Loading
Loading
@@ -211,7 +230,9 @@ class AwardsHandler {
${name}
</h5>
<ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}">
${emojiList.map(emojiName => `
${emojiList
.map(
emojiName => `
<li class="emoji-menu-list-item">
<button class="emoji-menu-btn text-center js-emoji-btn" type="button">
${this.emoji.glEmojiTag(emojiName, {
Loading
Loading
@@ -219,7 +240,9 @@ class AwardsHandler {
})}
</button>
</li>
`).join('\n')}
`,
)
.join('\n')}
</ul>
`;
}
Loading
Loading
@@ -232,7 +255,7 @@ class AwardsHandler {
top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`,
};
if (position === 'right') {
css.left = `${($addBtn.offset().left - $menu.outerWidth()) + 20}px`;
css.left = `${$addBtn.offset().left - $menu.outerWidth() + 20}px`;
$menu.addClass('is-aligned-right');
} else {
css.left = `${$addBtn.offset().left}px`;
Loading
Loading
@@ -416,7 +439,10 @@ class AwardsHandler {
</button>
`;
const $emojiButton = $(buttonHtml);
$emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('name', emojiName);
$emojiButton
.insertBefore(votesBlock.find('.js-award-holder'))
.find('.emoji-icon')
.data('name', emojiName);
this.animateEmoji($emojiButton);
$('.award-control').tooltip();
votesBlock.removeClass('current');
Loading
Loading
@@ -426,7 +452,7 @@ class AwardsHandler {
const className = 'pulse animated once short';
$emoji.addClass(className);
 
this.registerEventListener('on', $emoji, animationEndEventString, (e) => {
this.registerEventListener('on', $emoji, animationEndEventString, e => {
$(e.currentTarget).removeClass(className);
});
}
Loading
Loading
@@ -444,15 +470,16 @@ class AwardsHandler {
if (this.isUserAuthored($emojiButton)) {
this.userAuthored($emojiButton);
} else {
axios.post(awardUrl, {
name: emoji,
})
.then(({ data }) => {
if (data.ok) {
callback();
}
})
.catch(() => flash(__('Something went wrong on our end.')));
axios
.post(awardUrl, {
name: emoji,
})
.then(({ data }) => {
if (data.ok) {
callback();
}
})
.catch(() => flash(__('Something went wrong on our end.')));
}
}
 
Loading
Loading
@@ -486,26 +513,33 @@ class AwardsHandler {
}
 
getFrequentlyUsedEmojis() {
return this.frequentlyUsedEmojis || (() => {
const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(','));
this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(
inputName => this.emoji.isEmojiNameValid(inputName),
);
return this.frequentlyUsedEmojis;
})();
return (
this.frequentlyUsedEmojis ||
(() => {
const frequentlyUsedEmojis = _.uniq(
(Cookies.get('frequently_used_emojis') || '').split(','),
);
this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(inputName =>
this.emoji.isEmojiNameValid(inputName),
);
return this.frequentlyUsedEmojis;
})()
);
}
 
setupSearch() {
const $search = $('.js-emoji-menu-search');
 
this.registerEventListener('on', $search, 'input', (e) => {
const term = $(e.target).val().trim();
this.registerEventListener('on', $search, 'input', e => {
const term = $(e.target)
.val()
.trim();
this.searchEmojis(term);
});
 
const $menu = $('.emoji-menu');
this.registerEventListener('on', $menu, transitionEndEventString, (e) => {
this.registerEventListener('on', $menu, transitionEndEventString, e => {
if (e.target === e.currentTarget) {
// Clear the search
this.searchEmojis('');
Loading
Loading
@@ -523,19 +557,26 @@ class AwardsHandler {
// Generate a search result block
const h5 = $('<h5 class="emoji-search-title"/>').text('Search results');
const foundEmojis = this.findMatchingEmojiElements(term).show();
const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis);
const ul = $('<ul>')
.addClass('emoji-menu-list emoji-menu-search')
.append(foundEmojis);
$('.emoji-menu-content ul, .emoji-menu-content h5').hide();
$('.emoji-menu-content').append(h5).append(ul);
$('.emoji-menu-content')
.append(h5)
.append(ul);
} else {
$('.emoji-menu-content').children().show();
$('.emoji-menu-content')
.children()
.show();
}
}
 
findMatchingEmojiElements(query) {
const emojiMatches = this.emoji.filterEmojiNamesByAlias(query);
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements
.filter((i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0);
const $matchingElements = $emojiElements.filter(
(i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0,
);
return $matchingElements.closest('li').clone();
}
 
Loading
Loading
@@ -550,16 +591,13 @@ class AwardsHandler {
$emojiMenu.addClass(IS_RENDERED);
 
// enqueues animation as a microtask, so it begins ASAP once IS_RENDERED added
return Promise.resolve()
.then(() => $emojiMenu.addClass(IS_VISIBLE));
return Promise.resolve().then(() => $emojiMenu.addClass(IS_VISIBLE));
}
 
hideMenuElement($emojiMenu) {
$emojiMenu.on(transitionEndEventString, (e) => {
$emojiMenu.on(transitionEndEventString, e => {
if (e.currentTarget === e.target) {
$emojiMenu
.removeClass(IS_RENDERED)
.off(transitionEndEventString);
$emojiMenu.removeClass(IS_RENDERED).off(transitionEndEventString);
}
});
 
Loading
Loading
@@ -567,7 +605,7 @@ class AwardsHandler {
}
 
destroy() {
this.eventListeners.forEach((entry) => {
this.eventListeners.forEach(entry => {
entry.element.off.call(entry.element, ...entry.args);
});
$('.emoji-menu').remove();
Loading
Loading
@@ -577,8 +615,9 @@ class AwardsHandler {
let awardsHandlerPromise = null;
export default function loadAwardsHandler(reload = false) {
if (!awardsHandlerPromise || reload) {
awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji')
.then(Emoji => new AwardsHandler(Emoji));
awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then(
Emoji => new AwardsHandler(Emoji),
);
}
return awardsHandlerPromise;
}
/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, max-len */
/* global CommentsStore */
/* global ResolveService */
 
Loading
Loading
@@ -41,54 +40,54 @@ const ResolveBtn = Vue.extend({
required: true,
},
},
data: function () {
data() {
return {
discussions: CommentsStore.state,
loading: false
loading: false,
};
},
computed: {
discussion: function () {
discussion() {
return this.discussions[this.discussionId];
},
note: function () {
note() {
return this.discussion ? this.discussion.getNote(this.noteId) : {};
},
buttonText: function () {
buttonText() {
if (this.isResolved) {
return `Resolved by ${this.resolvedByName}`;
} else if (this.canResolve) {
return 'Mark as resolved';
} else {
return 'Unable to resolve';
}
return 'Unable to resolve';
},
isResolved: function () {
isResolved() {
if (this.note) {
return this.note.resolved;
} else {
return false;
}
return false;
},
resolvedByName: function () {
resolvedByName() {
return this.note.resolved_by;
},
},
watch: {
'discussions': {
discussions: {
handler: 'updateTooltip',
deep: true
}
deep: true,
},
},
mounted: function () {
mounted() {
$(this.$refs.button).tooltip({
container: 'body'
container: 'body',
});
},
beforeDestroy: function () {
beforeDestroy() {
CommentsStore.delete(this.discussionId, this.noteId);
},
created: function () {
created() {
CommentsStore.create({
discussionId: this.discussionId,
noteId: this.noteId,
Loading
Loading
@@ -101,43 +100,41 @@ const ResolveBtn = Vue.extend({
});
},
methods: {
updateTooltip: function () {
updateTooltip() {
this.$nextTick(() => {
$(this.$refs.button)
.tooltip('hide')
.tooltip('_fixTitle');
});
},
resolve: function () {
resolve() {
if (!this.canResolve) return;
 
let promise;
this.loading = true;
 
if (this.isResolved) {
promise = ResolveService
.unresolve(this.noteId);
promise = ResolveService.unresolve(this.noteId);
} else {
promise = ResolveService
.resolve(this.noteId);
promise = ResolveService.resolve(this.noteId);
}
 
promise
.then(resp => resp.json())
.then((data) => {
.then(data => {
this.loading = false;
 
const resolved_by = data ? data.resolved_by : null;
const resolvedBy = data ? data.resolved_by : null;
 
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolvedBy);
this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus();
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
this.updateTooltip();
})
.catch(() => new Flash('An error occurred when trying to resolve a comment. Please try again.'));
}
.catch(
() => new Flash('An error occurred when trying to resolve a comment. Please try again.'),
);
},
},
});
 
Loading
Loading
/* eslint-disable func-names, comma-dangle, new-cap, no-new */
/* global ResolveCount */
/* eslint-disable func-names, new-cap */
 
import $ from 'jquery';
import Vue from 'vue';
Loading
Loading
@@ -15,12 +14,13 @@ import './components/resolve_count';
import './components/resolve_discussion_btn';
import './components/diff_note_avatars';
import './components/new_issue_for_discussion';
import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils';
 
export default () => {
const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box');
const projectPathHolder =
document.querySelector('.merge-request') || document.querySelector('.commit-box');
const projectPath = projectPathHolder.dataset.projectPath;
const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn';
const COMPONENT_SELECTOR =
'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn';
 
window.gl = window.gl || {};
window.gl.diffNoteApps = {};
Loading
Loading
@@ -28,9 +28,9 @@ export default () => {
window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath);
 
gl.diffNotesCompileComponents = () => {
$('diff-note-avatars').each(function () {
$('diff-note-avatars').each(function() {
const tmp = Vue.extend({
template: $(this).get(0).outerHTML
template: $(this).get(0).outerHTML,
});
const tmpApp = new tmp().$mount();
 
Loading
Loading
@@ -41,12 +41,12 @@ export default () => {
});
});
 
const $components = $(COMPONENT_SELECTOR).filter(function () {
const $components = $(COMPONENT_SELECTOR).filter(function() {
return $(this).closest('resolve-count').length !== 1;
});
 
if ($components) {
$components.each(function () {
$components.each(function() {
const $this = $(this);
const noteId = $this.attr(':note-id');
const discussionId = $this.attr(':discussion-id');
Loading
Loading
@@ -54,7 +54,7 @@ export default () => {
if ($this.is('comment-and-resolve-btn') && !discussionId) return;
 
const tmp = Vue.extend({
template: $this.get(0).outerHTML
template: $this.get(0).outerHTML,
});
const tmpApp = new tmp().$mount();
 
Loading
Loading
@@ -69,15 +69,5 @@ export default () => {
 
gl.diffNotesCompileComponents();
 
const resolveCountAppEl = document.querySelector('#resolve-count-app');
if (!hasVueMRDiscussionsCookie() && resolveCountAppEl) {
new Vue({
el: resolveCountAppEl,
components: {
'resolve-count': ResolveCount
},
});
}
$(window).trigger('resize.nav');
};
Loading
Loading
@@ -8,8 +8,12 @@ window.gl = window.gl || {};
 
class ResolveServiceClass {
constructor(root) {
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`);
this.noteResource = Vue.resource(
`${root}/notes{/noteId}/resolve?html=true`,
);
this.discussionResource = Vue.resource(
`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`,
);
}
 
resolve(noteId) {
Loading
Loading
@@ -33,7 +37,7 @@ class ResolveServiceClass {
 
promise
.then(resp => resp.json())
.then((data) => {
.then(data => {
discussion.loading = false;
const resolvedBy = data ? data.resolved_by : null;
 
Loading
Loading
@@ -45,9 +49,13 @@ class ResolveServiceClass {
 
if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data);
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
})
.catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
.catch(
() =>
new Flash(
'An error occurred when trying to resolve a discussion. Please try again.',
),
);
}
 
resolveAll(mergeRequestId, discussionId) {
Loading
Loading
@@ -55,10 +63,13 @@ class ResolveServiceClass {
 
discussion.loading = true;
 
return this.discussionResource.save({
mergeRequestId,
discussionId,
}, {});
return this.discussionResource.save(
{
mergeRequestId,
discussionId,
},
{},
);
}
 
unResolveAll(mergeRequestId, discussionId) {
Loading
Loading
@@ -66,10 +77,13 @@ class ResolveServiceClass {
 
discussion.loading = true;
 
return this.discussionResource.delete({
mergeRequestId,
discussionId,
}, {});
return this.discussionResource.delete(
{
mergeRequestId,
discussionId,
},
{},
);
}
}
 
Loading
Loading
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
import createFlash from '~/flash';
import LoadingIcon from '../../vue_shared/components/loading_icon.vue';
import CompareVersions from './compare_versions.vue';
import ChangedFiles from './changed_files.vue';
import DiffFile from './diff_file.vue';
import NoChanges from './no_changes.vue';
import HiddenFilesWarning from './hidden_files_warning.vue';
export default {
name: 'DiffsApp',
components: {
Icon,
LoadingIcon,
CompareVersions,
ChangedFiles,
DiffFile,
NoChanges,
HiddenFilesWarning,
},
props: {
endpoint: {
type: String,
required: true,
},
shouldShow: {
type: Boolean,
required: false,
default: false,
},
currentUser: {
type: Object,
required: true,
},
},
data() {
return {
activeFile: '',
};
},
computed: {
...mapState({
isLoading: state => state.diffs.isLoading,
diffFiles: state => state.diffs.diffFiles,
diffViewType: state => state.diffs.diffViewType,
mergeRequestDiffs: state => state.diffs.mergeRequestDiffs,
mergeRequestDiff: state => state.diffs.mergeRequestDiff,
latestVersionPath: state => state.diffs.latestVersionPath,
startVersion: state => state.diffs.startVersion,
commit: state => state.diffs.commit,
targetBranchName: state => state.diffs.targetBranchName,
renderOverflowWarning: state => state.diffs.renderOverflowWarning,
numTotalFiles: state => state.diffs.realSize,
numVisibleFiles: state => state.diffs.size,
plainDiffPath: state => state.diffs.plainDiffPath,
emailPatchPath: state => state.diffs.emailPatchPath,
}),
...mapGetters(['isParallelView']),
targetBranch() {
return {
branchName: this.targetBranchName,
versionIndex: -1,
path: '',
};
},
notAllCommentsDisplayed() {
if (this.commit) {
return __('Only comments from the following commit are shown below');
} else if (this.startVersion) {
return __(
"Not all comments are displayed because you're comparing two versions of the diff.",
);
}
return __(
"Not all comments are displayed because you're viewing an old version of the diff.",
);
},
showLatestVersion() {
if (this.commit) {
return __('Show latest version of the diff');
}
return __('Show latest version');
},
},
watch: {
diffViewType() {
this.adjustView();
},
shouldShow() {
this.adjustView();
},
},
mounted() {
this.setEndpoint(this.endpoint);
this
.fetchDiffFiles()
.catch(() => {
createFlash(__('Something went wrong on our end. Please try again!'));
});
},
created() {
this.adjustView();
},
methods: {
...mapActions(['setEndpoint', 'fetchDiffFiles']),
setActive(filePath) {
this.activeFile = filePath;
},
unsetActive(filePath) {
if (this.activeFile === filePath) {
this.activeFile = '';
}
},
adjustView() {
if (this.shouldShow && this.isParallelView) {
window.mrTabs.expandViewContainer();
} else {
window.mrTabs.resetViewContainer();
}
},
},
};
</script>
<template>
<div v-if="shouldShow">
<div
v-if="isLoading"
class="loading"
>
<loading-icon />
</div>
<div
v-else
id="diffs"
:class="{ active: shouldShow }"
class="diffs tab-pane"
>
<compare-versions
v-if="!commit && mergeRequestDiffs.length > 1"
:merge-request-diffs="mergeRequestDiffs"
:merge-request-diff="mergeRequestDiff"
:start-version="startVersion"
:target-branch="targetBranch"
/>
<hidden-files-warning
v-if="renderOverflowWarning"
:visible="numVisibleFiles"
:total="numTotalFiles"
:plain-diff-path="plainDiffPath"
:email-patch-path="emailPatchPath"
/>
<div
v-if="commit || startVersion || (mergeRequestDiff && !mergeRequestDiff.latest)"
class="mr-version-controls"
>
<div class="content-block comments-disabled-notif clearfix">
<i class="fa fa-info-circle"></i>
{{ notAllCommentsDisplayed }}
<div class="pull-right">
<a
:href="latestVersionPath"
class="btn btn-sm"
>
{{ showLatestVersion }}
</a>
</div>
</div>
</div>
<changed-files
:diff-files="diffFiles"
:active-file="activeFile"
/>
<div
v-if="diffFiles.length > 0"
class="files"
>
<diff-file
v-for="file in diffFiles"
:key="file.newPath"
:file="file"
:current-user="currentUser"
@setActive="setActive(file.filePath)"
@unsetActive="unsetActive(file.filePath)"
/>
</div>
<no-changes v-else />
</div>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import { pluralize } from '~/lib/utils/text_utility';
import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility';
import { contentTop } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import ChangedFilesDropdown from './changed_files_dropdown.vue';
import changedFilesMixin from '../mixins/changed_files';
export default {
components: {
Icon,
ChangedFilesDropdown,
ClipboardButton,
},
mixins: [changedFilesMixin],
props: {
activeFile: {
type: String,
required: false,
default: '',
},
},
data() {
return {
isStuck: false,
maxWidth: 'auto',
offsetTop: 0,
};
},
computed: {
...mapGetters(['isInlineView', 'isParallelView', 'areAllFilesCollapsed']),
sumAddedLines() {
return this.sumValues('addedLines');
},
sumRemovedLines() {
return this.sumValues('removedLines');
},
whitespaceVisible() {
return !getParameterValues('w')[0];
},
toggleWhitespaceText() {
if (this.whitespaceVisible) {
return __('Hide whitespace changes');
}
return __('Show whitespace changes');
},
toggleWhitespacePath() {
if (this.whitespaceVisible) {
return mergeUrlParams({ w: 1 }, window.location.href);
}
return mergeUrlParams({ w: 0 }, window.location.href);
},
top() {
return `${this.offsetTop}px`;
},
},
created() {
document.addEventListener('scroll', this.handleScroll);
this.offsetTop = contentTop();
},
beforeDestroy() {
document.removeEventListener('scroll', this.handleScroll);
},
methods: {
...mapActions(['setInlineDiffViewType', 'setParallelDiffViewType', 'expandAllFiles']),
pluralize,
handleScroll() {
if (!this.updating) {
requestAnimationFrame(this.updateIsStuck);
this.updating = true;
}
},
updateIsStuck() {
if (!this.$refs.wrapper) {
return;
}
const scrollPosition = window.scrollY;
this.isStuck = scrollPosition + this.offsetTop >= this.$refs.placeholder.offsetTop;
this.updating = false;
},
sumValues(key) {
return this.diffFiles.reduce((total, file) => total + file[key], 0);
},
},
};
</script>
<template>
<span>
<div ref="placeholder"></div>
<div
ref="wrapper"
:style="{ top }"
:class="{'is-stuck': isStuck}"
class="content-block oneline-block diff-files-changed diff-files-changed-merge-request
files-changed js-diff-files-changed"
>
<div class="files-changed-inner">
<div
class="inline-parallel-buttons d-none d-md-block"
>
<a
v-if="areAllFilesCollapsed"
class="btn btn-default"
@click="expandAllFiles"
>
{{ __('Expand all') }}
</a>
<a
:href="toggleWhitespacePath"
class="btn btn-default"
>
{{ toggleWhitespaceText }}
</a>
<div class="btn-group">
<button
id="inline-diff-btn"
:class="{ active: isInlineView }"
type="button"
class="btn js-inline-diff-button"
data-view-type="inline"
@click="setInlineDiffViewType"
>
{{ __('Inline') }}
</button>
<button
id="parallel-diff-btn"
:class="{ active: isParallelView }"
type="button"
class="btn js-parallel-diff-button"
data-view-type="parallel"
@click="setParallelDiffViewType"
>
{{ __('Side-by-side') }}
</button>
</div>
</div>
<div class="commit-stat-summary dropdown">
<changed-files-dropdown
:diff-files="diffFiles"
/>
<span
v-show="activeFile"
class="prepend-left-5"
>
<strong class="prepend-right-5">
{{ truncatedDiffPath(activeFile) }}
</strong>
<clipboard-button
:text="activeFile"
:title="s__('Copy file name to clipboard')"
tooltip-placement="bottom"
tooltip-container="body"
class="btn btn-default btn-transparent btn-clipboard"
/>
</span>
<span
v-show="!isStuck"
id="diff-stats"
class="diff-stats-additions-deletions-expanded"
>
with
<strong class="cgreen">
{{ pluralize(`${sumAddedLines} addition`, sumAddedLines) }}
</strong>
and
<strong class="cred">
{{ pluralize(`${sumRemovedLines} deletion`, sumRemovedLines) }}
</strong>
</span>
</div>
</div>
</div>
</span>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
import changedFilesMixin from '../mixins/changed_files';
export default {
components: {
Icon,
},
mixins: [changedFilesMixin],
data() {
return {
searchText: '',
};
},
computed: {
filteredDiffFiles() {
return this.diffFiles.filter(file =>
file.filePath.toLowerCase().includes(this.searchText.toLowerCase()),
);
},
},
methods: {
clearSearch() {
this.searchText = '';
},
},
};
</script>
<template>
<span>
Showing
<button
class="diff-stats-summary-toggler"
data-toggle="dropdown"
type="button"
aria-expanded="false"
>
<span>
{{ n__('%d changed file', '%d changed files', diffFiles.length) }}
</span>
<icon
:size="8"
name="chevron-down"
/>
</button>
<div class="dropdown-menu diff-file-changes">
<div class="dropdown-input">
<input
v-model="searchText"
type="search"
class="dropdown-input-field"
placeholder="Search files"
autocomplete="off"
/>
<i
v-if="searchText.length === 0"
aria-hidden="true"
data-hidden="true"
class="fa fa-search dropdown-input-search">
</i>
<i
v-else
role="button"
class="fa fa-times dropdown-input-search"
@click="clearSearch"
></i>
</div>
<ul>
<li
v-for="diffFile in filteredDiffFiles"
:key="diffFile.name"
>
<a
:href="`#${diffFile.fileHash}`"
:title="diffFile.newPath"
class="diff-changed-file"
>
<icon
:name="fileChangedIcon(diffFile)"
:size="16"
:class="fileChangedClass(diffFile)"
class="diff-file-changed-icon append-right-8"
/>
<span class="diff-changed-file-content append-right-8">
<strong
v-if="diffFile.blob && diffFile.blob.name"
class="diff-changed-file-name"
>
{{ diffFile.blob.name }}
</strong>
<strong
v-else
class="diff-changed-blank-file-name"
>
{{ s__('Diffs|No file name available') }}
</strong>
<span class="diff-changed-file-path prepend-top-5">
{{ truncatedDiffPath(diffFile.blob.path) }}
</span>
</span>
<span class="diff-changed-stats">
<span class="cgreen">
+{{ diffFile.addedLines }}
</span>
<span class="cred">
-{{ diffFile.removedLines }}
</span>
</span>
</a>
</li>
<li
v-show="filteredDiffFiles.length === 0"
class="dropdown-menu-empty-item"
>
<a>
{{ __('No files found') }}
</a>
</li>
</ul>
</div>
</span>
</template>
<script>
import CompareVersionsDropdown from './compare_versions_dropdown.vue';
export default {
components: {
CompareVersionsDropdown,
},
props: {
mergeRequestDiffs: {
type: Array,
required: true,
},
mergeRequestDiff: {
type: Object,
required: true,
},
startVersion: {
type: Object,
required: false,
default: null,
},
targetBranch: {
type: Object,
required: false,
default: null,
},
},
computed: {
comparableDiffs() {
return this.mergeRequestDiffs.slice(1);
},
},
};
</script>
<template>
<div class="mr-version-controls">
<div class="mr-version-menus-container content-block">
Changes between
<compare-versions-dropdown
:other-versions="mergeRequestDiffs"
:merge-request-version="mergeRequestDiff"
:show-commit-count="true"
class="mr-version-dropdown"
/>
and
<compare-versions-dropdown
:other-versions="comparableDiffs"
:start-version="startVersion"
:target-branch="targetBranch"
class="mr-version-compare-dropdown"
/>
</div>
</div>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
import { n__, __ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
Icon,
TimeAgo,
},
props: {
otherVersions: {
type: Array,
required: false,
default: () => [],
},
mergeRequestVersion: {
type: Object,
required: false,
default: null,
},
startVersion: {
type: Object,
required: false,
default: null,
},
targetBranch: {
type: Object,
required: false,
default: null,
},
showCommitCount: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
baseVersion() {
return {
name: 'hii',
versionIndex: -1,
};
},
targetVersions() {
if (this.mergeRequestVersion) {
return this.otherVersions;
}
return [...this.otherVersions, this.targetBranch];
},
selectedVersionName() {
const selectedVersion = this.startVersion || this.targetBranch || this.mergeRequestVersion;
return this.versionName(selectedVersion);
},
},
methods: {
commitsText(version) {
return n__(
`${version.commitsCount} commit,`,
`${version.commitsCount} commits,`,
version.commitsCount,
);
},
href(version) {
if (this.showCommitCount) {
return version.versionPath;
}
return version.comparePath;
},
versionName(version) {
if (this.isLatest(version)) {
return __('latest version');
}
if (this.targetBranch && (this.isBase(version) || !version)) {
return this.targetBranch.branchName;
}
return `version ${version.versionIndex}`;
},
isActive(version) {
if (!version) {
return false;
}
if (this.targetBranch) {
return (
(this.isBase(version) && !this.startVersion) ||
(this.startVersion && this.startVersion.versionIndex === version.versionIndex)
);
}
return version.versionIndex === this.mergeRequestVersion.versionIndex;
},
isBase(version) {
if (!version || !this.targetBranch) {
return false;
}
return version.versionIndex === -1;
},
isLatest(version) {
return (
this.mergeRequestVersion && version.versionIndex === this.targetVersions[0].versionIndex
);
},
},
};
</script>
<template>
<span class="dropdown inline">
<a
class="dropdown-toggle btn btn-default"
data-toggle="dropdown"
aria-expanded="false"
>
<span>
{{ selectedVersionName }}
</span>
<Icon
:size="12"
name="angle-down"
/>
</a>
<div class="dropdown-menu dropdown-select dropdown-menu-selectable">
<div class="dropdown-content">
<ul>
<li
v-for="version in targetVersions"
:key="version.id"
>
<a
:class="{ 'is-active': isActive(version) }"
:href="href(version)"
>
<div>
<strong>
{{ versionName(version) }}
<template v-if="isBase(version)">
(base)
</template>
</strong>
</div>
<div>
<small class="commit-sha">
{{ version.truncatedCommitSha }}
</small>
</div>
<div>
<small>
<template v-if="showCommitCount">
{{ commitsText(version) }}
</template>
<time-ago
v-if="version.createdAt"
:time="version.createdAt"
class="js-timeago js-timeago-render"
/>
</small>
</div>
</a>
</li>
</ul>
</div>
</div>
</span>
</template>
<script>
import { mapGetters } from 'vuex';
import InlineDiffView from './inline_diff_view.vue';
import ParallelDiffView from './parallel_diff_view.vue';
export default {
components: {
InlineDiffView,
ParallelDiffView,
},
props: {
diffFile: {
type: Object,
required: true,
},
},
computed: {
...mapGetters(['isInlineView', 'isParallelView']),
},
};
</script>
<template>
<div class="diff-content">
<div class="diff-viewer">
<inline-diff-view
v-if="isInlineView"
:diff-file="diffFile"
:diff-lines="diffFile.highlightedDiffLines || []"
/>
<parallel-diff-view
v-if="isParallelView"
:diff-file="diffFile"
:diff-lines="diffFile.parallelDiffLines || []"
/>
</div>
</div>
</template>
<script>
import noteableDiscussion from '../../notes/components/noteable_discussion.vue';
export default {
components: {
noteableDiscussion,
},
props: {
discussions: {
type: Array,
required: true,
},
},
};
</script>
<template>
<div
v-if="discussions.length"
>
<div
v-for="discussion in discussions"
:key="discussion.id"
class="discussion-notes diff-discussions"
>
<ul
:data-discussion-id="discussion.id"
class="notes"
>
<noteable-discussion
:discussion="discussion"
:render-header="false"
:render-diff-file="false"
:always-expanded="true"
/>
</ul>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import _ from 'underscore';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
export default {
components: {
DiffFileHeader,
DiffContent,
LoadingIcon,
},
props: {
file: {
type: Object,
required: true,
},
currentUser: {
type: Object,
required: true,
},
},
data() {
return {
isActive: false,
isLoadingCollapsedDiff: false,
forkMessageVisible: false,
};
},
computed: {
isDiscussionsExpanded() {
return true; // TODO: @fatihacet - Fix this.
},
isCollapsed() {
return this.file.collapsed || false;
},
viewBlobLink() {
return sprintf(
__('You can %{linkStart}view the blob%{linkEnd} instead.'),
{
linkStart: `<a href="${_.escape(this.file.viewPath)}">`,
linkEnd: '</a>',
},
false,
);
},
},
mounted() {
document.addEventListener('scroll', this.handleScroll);
},
beforeDestroy() {
document.removeEventListener('scroll', this.handleScroll);
},
methods: {
...mapActions(['loadCollapsedDiff']),
handleToggle() {
const { collapsed, highlightedDiffLines, parallelDiffLines } = this.file;
if (collapsed && !highlightedDiffLines && !parallelDiffLines.length) {
this.handleLoadCollapsedDiff();
} else {
this.file.collapsed = !this.file.collapsed;
}
},
handleScroll() {
if (!this.updating) {
requestAnimationFrame(this.scrollUpdate.bind(this));
this.updating = true;
}
},
scrollUpdate() {
const header = document.querySelector('.js-diff-files-changed');
if (!header) {
this.updating = false;
return;
}
const { top, bottom } = this.$el.getBoundingClientRect();
const { top: topOfFixedHeader, bottom: bottomOfFixedHeader } = header.getBoundingClientRect();
const headerOverlapsContent = top < topOfFixedHeader && bottom > bottomOfFixedHeader;
const fullyAboveHeader = bottom < bottomOfFixedHeader;
const fullyBelowHeader = top > topOfFixedHeader;
if (headerOverlapsContent && !this.isActive) {
this.$emit('setActive');
this.isActive = true;
} else if (this.isActive && (fullyAboveHeader || fullyBelowHeader)) {
this.$emit('unsetActive');
this.isActive = false;
}
this.updating = false;
},
handleLoadCollapsedDiff() {
this.isLoadingCollapsedDiff = true;
this.loadCollapsedDiff(this.file)
.then(() => {
this.isLoadingCollapsedDiff = false;
this.file.collapsed = false;
})
.catch(() => {
this.isLoadingCollapsedDiff = false;
createFlash(__('Something went wrong on our end. Please try again!'));
});
},
showForkMessage() {
this.forkMessageVisible = true;
},
hideForkMessage() {
this.forkMessageVisible = false;
},
},
};
</script>
<template>
<div
:id="file.fileHash"
class="diff-file file-holder"
>
<diff-file-header
:current-user="currentUser"
:diff-file="file"
:collapsible="true"
:expanded="!isCollapsed"
:discussions-expanded="isDiscussionsExpanded"
:add-merge-request-buttons="true"
class="js-file-title file-title"
@toggleFile="handleToggle"
@showForkMessage="showForkMessage"
/>
<div
v-if="forkMessageVisible"
class="js-file-fork-suggestion-section file-fork-suggestion">
<span class="file-fork-suggestion-note">
You're not allowed to <span class="js-file-fork-suggestion-section-action">edit</span>
files in this project directly. Please fork this project,
make your changes there, and submit a merge request.
</span>
<a
:href="file.forkPath"
class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success"
>
Fork
</a>
<button
class="js-cancel-fork-suggestion-button btn btn-grouped"
type="button"
@click="hideForkMessage"
>
Cancel
</button>
</div>
<diff-content
v-show="!isCollapsed"
:class="{ hidden: isCollapsed || file.tooLarge }"
:diff-file="file"
/>
<loading-icon
v-if="isLoadingCollapsedDiff"
class="diff-content loading"
/>
<div
v-show="isCollapsed && !isLoadingCollapsedDiff && !file.tooLarge"
class="nothing-here-block diff-collapsed"
>
{{ __('This diff is collapsed.') }}
<a
class="click-to-expand js-click-to-expand"
href="#"
@click.prevent="handleToggle"
>
{{ __('Click to expand it.') }}
</a>
</div>
<div
v-if="file.tooLarge"
class="nothing-here-block diff-collapsed js-too-large-diff"
>
{{ __('This source diff could not be displayed because it is too large.') }}
<span v-html="viewBlobLink"></span>
</div>
</div>
</template>
<script>
import _ from 'underscore';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import Tooltip from '~/vue_shared/directives/tooltip';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
import EditButton from './edit_button.vue';
export default {
components: {
ClipboardButton,
EditButton,
Icon,
},
directives: {
Tooltip,
},
props: {
diffFile: {
type: Object,
required: true,
},
collapsible: {
type: Boolean,
required: false,
default: false,
},
addMergeRequestButtons: {
type: Boolean,
required: false,
default: false,
},
expanded: {
type: Boolean,
required: false,
default: true,
},
discussionsExpanded: {
type: Boolean,
required: false,
default: true,
},
currentUser: {
type: Object,
required: true,
},
},
data() {
return {
blobForkSuggestion: null,
};
},
computed: {
icon() {
if (this.diffFile.submodule) {
return 'archive';
}
return this.diffFile.blob.icon;
},
titleLink() {
if (this.diffFile.submodule) {
return this.diffFile.submoduleTreeUrl || this.diffFile.submoduleLink;
}
return `#${this.diffFile.fileHash}`;
},
filePath() {
if (this.diffFile.submodule) {
return `${this.diffFile.filePath} @ ${truncateSha(this.diffFile.blob.id)}`;
}
if (this.diffFile.deletedFile) {
return sprintf(__('%{filePath} deleted'), { filePath: this.diffFile.filePath }, false);
}
return this.diffFile.filePath;
},
titleTag() {
return this.diffFile.fileHash ? 'a' : 'span';
},
isUsingLfs() {
return this.diffFile.storedExternally && this.diffFile.externalStorage === 'lfs';
},
collapseIcon() {
return this.expanded ? 'chevron-down' : 'chevron-right';
},
isDiscussionsExpanded() {
return this.discussionsExpanded && this.expanded;
},
viewFileButtonText() {
const truncatedContentSha = _.escape(truncateSha(this.diffFile.contentSha));
return sprintf(
s__('MergeRequests|View file @ %{commitId}'),
{
commitId: `<span class="commit-sha">${truncatedContentSha}</span>`,
},
false,
);
},
viewReplacedFileButtonText() {
const truncatedBaseSha = _.escape(truncateSha(this.diffFile.diffRefs.baseSha));
return sprintf(
s__('MergeRequests|View replaced file @ %{commitId}'),
{
commitId: `<span class="commit-sha">${truncatedBaseSha}</span>`,
},
false,
);
},
},
methods: {
handleToggle(e, checkTarget) {
if (!checkTarget || e.target === this.$refs.header) {
this.$emit('toggleFile');
}
},
showForkMessage() {
this.$emit('showForkMessage');
},
},
};
</script>
<template>
<div
ref="header"
class="js-file-title file-title file-title-flex-parent"
@click="handleToggle($event, true)"
>
<div class="file-header-content">
<icon
v-if="collapsible"
:name="collapseIcon"
:size="16"
aria-hidden="true"
class="diff-toggle-caret"
@click.stop="handleToggle"
/>
<a
ref="titleWrapper"
:href="titleLink"
>
<i
:class="`fa-${icon}`"
class="fa fa-fw"
aria-hidden="true"
></i>
<span v-if="diffFile.renamedFile">
<strong
v-tooltip
:title="diffFile.oldPath"
class="file-title-name"
data-container="body"
>
{{ diffFile.oldPath }}
</strong>
<strong
v-tooltip
:title="diffFile.newPath"
class="file-title-name"
data-container="body"
>
{{ diffFile.newPath }}
</strong>
</span>
<strong
v-tooltip
v-else
:title="filePath"
class="file-title-name"
data-container="body"
>
{{ filePath }}
</strong>
</a>
<clipboard-button
:title="__('Copy file path to clipboard')"
:text="diffFile.filePath"
css-class="btn-default btn-transparent btn-clipboard"
/>
<small
v-if="diffFile.modeChanged"
ref="fileMode"
>
{{ diffFile.aMode }} → {{ diffFile.bMode }}
</small>
<span
v-if="isUsingLfs"
class="label label-lfs append-right-5"
>
{{ __('LFS') }}
</span>
</div>
<div
v-if="!diffFile.submodule && addMergeRequestButtons"
class="file-actions d-none d-md-block"
>
<template
v-if="diffFile.blob && diffFile.blob.readableText"
>
<button
:class="{ active: isDiscussionsExpanded }"
:title="s__('MergeRequests|Toggle comments for this file')"
class="btn js-toggle-diff-comments"
type="button"
>
<icon name="comment" />
</button>
<edit-button
v-if="!diffFile.deletedFile"
:current-user="currentUser"
:edit-path="diffFile.editPath"
:can-modify-blob="diffFile.canModifyBlob"
@showForkMessage="showForkMessage"
/>
</template>
<a
v-if="diffFile.replacedViewPath"
:href="diffFile.replacedViewPath"
class="btn view-file js-view-file"
v-html="viewReplacedFileButtonText"
>
</a>
<a
:href="diffFile.viewPath"
class="btn view-file js-view-file"
v-html="viewFileButtonText"
>
</a>
<a
v-tooltip
v-if="diffFile.externalUrl"
:href="diffFile.externalUrl"
:title="`View on ${diffFile.formattedExternalUrl}`"
target="_blank"
rel="noopener noreferrer"
class="btn btn-file-option"
>
<icon name="external-link" />
</a>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import { pluralize, truncate } from '~/lib/utils/text_utility';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants';
export default {
directives: {
tooltip,
},
components: {
Icon,
UserAvatarImage,
},
props: {
discussions: {
type: Array,
required: true,
},
},
computed: {
discussionsExpanded() {
return this.discussions.every(discussion => discussion.expanded);
},
allDiscussions() {
return this.discussions.reduce((acc, note) => acc.concat(note.notes), []);
},
notesInGutter() {
return this.allDiscussions.slice(0, COUNT_OF_AVATARS_IN_GUTTER).map(n => ({
note: n.note,
author: n.author,
}));
},
moreCount() {
return this.allDiscussions.length - this.notesInGutter.length;
},
moreText() {
if (this.moreCount === 0) {
return '';
}
return pluralize(`${this.moreCount} more comment`, this.moreCount);
},
},
methods: {
...mapActions(['toggleDiscussion']),
getTooltipText(noteData) {
let note = noteData.note;
if (note.length > LENGTH_OF_AVATAR_TOOLTIP) {
note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP);
}
return `${noteData.author.name}: ${note}`;
},
toggleDiscussions() {
this.discussions.forEach(discussion => {
this.toggleDiscussion({
discussionId: discussion.id,
});
});
},
},
};
</script>
<template>
<div class="diff-comment-avatar-holders">
<button
v-if="discussionsExpanded"
type="button"
aria-label="Show comments"
class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button"
@click="toggleDiscussions"
>
<icon
:size="12"
name="collapse"
/>
</button>
<template v-else>
<user-avatar-image
v-for="note in notesInGutter"
:key="note.id"
:img-src="note.author.avatar_url"
:tooltip-text="getTooltipText(note)"
:size="19"
class="diff-comment-avatar js-diff-comment-avatar"
@click.native="toggleDiscussions"
/>
<span
v-tooltip
v-if="moreText"
:title="moreText"
class="diff-comments-more-count has-tooltip js-diff-comment-avatar js-diff-comment-plus"
data-container="body"
data-placement="top"
role="button"
@click="toggleDiscussions"
>+{{ moreCount }}</span>
</template>
</div>
</template>
<script>
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { mapState, mapGetters, mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
import {
MATCH_LINE_TYPE,
CONTEXT_LINE_TYPE,
OLD_NO_NEW_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
LINE_POSITION_RIGHT,
UNFOLD_COUNT,
} from '../constants';
import * as utils from '../store/utils';
export default {
components: {
DiffGutterAvatars,
Icon,
},
props: {
fileHash: {
type: String,
required: true,
},
contextLinesPath: {
type: String,
required: true,
},
lineType: {
type: String,
required: false,
default: '',
},
lineNumber: {
type: Number,
required: false,
default: 0,
},
lineCode: {
type: String,
required: false,
default: '',
},
linePosition: {
type: String,
required: false,
default: '',
},
metaData: {
type: Object,
required: false,
default: () => ({}),
},
showCommentButton: {
type: Boolean,
required: false,
default: false,
},
isBottom: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState({
diffViewType: state => state.diffs.diffViewType,
diffFiles: state => state.diffs.diffFiles,
}),
...mapGetters(['isLoggedIn', 'discussionsByLineCode']),
isMatchLine() {
return this.lineType === MATCH_LINE_TYPE;
},
isContextLine() {
return this.lineType === CONTEXT_LINE_TYPE;
},
isMetaLine() {
return this.lineType === OLD_NO_NEW_LINE_TYPE || this.lineType === NEW_NO_NEW_LINE_TYPE;
},
lineHref() {
return this.lineCode ? `#${this.lineCode}` : '#';
},
shouldShowCommentButton() {
return (
this.isLoggedIn &&
this.showCommentButton &&
!this.isMatchLine &&
!this.isContextLine &&
!this.hasDiscussions &&
!this.isMetaLine
);
},
discussions() {
return this.discussionsByLineCode[this.lineCode] || [];
},
hasDiscussions() {
return this.discussions.length > 0;
},
shouldShowAvatarsOnGutter() {
let render = this.hasDiscussions && this.showCommentButton;
if (!this.lineType && this.linePosition === LINE_POSITION_RIGHT) {
render = false;
}
return render;
},
},
methods: {
...mapActions(['loadMoreLines']),
handleCommentButton() {
this.$emit('showCommentForm', { lineCode: this.lineCode });
},
handleLoadMoreLines() {
if (this.isRequesting) {
return;
}
this.isRequesting = true;
const endpoint = this.contextLinesPath;
const oldLineNumber = this.metaData.oldPos || 0;
const newLineNumber = this.metaData.newPos || 0;
const offset = newLineNumber - oldLineNumber;
const bottom = this.isBottom;
const fileHash = this.fileHash;
const view = this.diffViewType;
let unfold = true;
let lineNumber = newLineNumber - 1;
let since = lineNumber - UNFOLD_COUNT;
let to = lineNumber;
if (bottom) {
lineNumber = newLineNumber + 1;
since = lineNumber;
to = lineNumber + UNFOLD_COUNT;
} else {
const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash);
const indexForInline = utils.findIndexInInlineLines(diffFile.highlightedDiffLines, {
oldLineNumber,
newLineNumber,
});
const prevLine = diffFile.highlightedDiffLines[indexForInline - 2];
const prevLineNumber = (prevLine && prevLine.newLine) || 0;
if (since <= prevLineNumber + 1) {
since = prevLineNumber + 1;
unfold = false;
}
}
const params = { since, to, bottom, offset, unfold, view };
const lineNumbers = { oldLineNumber, newLineNumber };
this.loadMoreLines({ endpoint, params, lineNumbers, fileHash })
.then(() => {
this.isRequesting = false;
})
.catch(() => {
createFlash(s__('Diffs|Something went wrong while fetching diff lines.'));
this.isRequesting = false;
});
},
},
};
</script>
<template>
<div>
<span
v-if="isMatchLine"
class="context-cell"
role="button"
@click="handleLoadMoreLines"
>...</span>
<template
v-else
>
<button
v-show="shouldShowCommentButton"
type="button"
class="add-diff-note js-add-diff-note-button"
title="Add a comment to this line"
@click="handleCommentButton"
>
<icon
:size="12"
name="comment"
/>
</button>
<a
v-if="lineNumber"
:data-linenumber="lineNumber"
:href="lineHref"
>
</a>
<diff-gutter-avatars
v-if="shouldShowAvatarsOnGutter"
:discussions="discussions"
/>
</template>
</div>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import noteForm from '../../notes/components/note_form.vue';
import { getNoteFormData } from '../store/utils';
export default {
components: {
noteForm,
},
props: {
diffFile: {
type: Object,
required: true,
},
diffLines: {
type: Array,
required: true,
},
line: {
type: Object,
required: true,
},
position: {
type: String,
required: false,
default: '',
},
noteTargetLine: {
type: Object,
required: true,
},
},
computed: {
...mapState({
noteableData: state => state.notes.noteableData,
diffViewType: state => state.diffs.diffViewType,
}),
...mapGetters(['noteableType', 'getNotesDataByProp']),
},
methods: {
...mapActions(['cancelCommentForm', 'saveNote', 'fetchDiscussions']),
handleCancelCommentForm() {
this.cancelCommentForm({
lineCode: this.line.lineCode,
});
},
handleSaveNote(note) {
const postData = getNoteFormData({
note,
noteableData: this.noteableData,
noteableType: this.noteableType,
noteTargetLine: this.noteTargetLine,
diffViewType: this.diffViewType,
diffFile: this.diffFile,
linePosition: this.position,
});
this.saveNote(postData)
.then(() => {
const endpoint = this.getNotesDataByProp('discussionsPath');
this.fetchDiscussions(endpoint)
.then(() => {
this.handleCancelCommentForm();
})
.catch(() => {
createFlash(s__('MergeRequests|Updating discussions failed'));
});
})
.catch(() => {
createFlash(s__('MergeRequests|Saving the comment failed'));
});
},
},
};
</script>
<template>
<div
class="content discussion-form discussion-form-container discussion-notes"
>
<note-form
:is-editing="true"
:line-code="line.lineCode"
save-button-title="Comment"
class="diff-comment-form"
@cancelForm="handleCancelCommentForm"
@handleFormUpdate="handleSaveNote"
/>
</div>
</template>
<script>
export default {
props: {
editPath: {
type: String,
required: true,
},
currentUser: {
type: Object,
required: true,
},
canModifyBlob: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
handleEditClick(evt) {
if (!this.currentUser || this.canModifyBlob) {
// if we can Edit, do default Edit button behavior
return;
}
if (this.currentUser.canFork && this.currentUser.canCreateMergeRequest) {
evt.preventDefault();
this.$emit('showForkMessage');
}
},
},
};
</script>
<template>
<a
:href="editPath"
class="btn btn-default js-edit-blob"
@click="handleEditClick"
>
Edit
</a>
</template>
<script>
export default {
props: {
total: {
type: String,
required: true,
},
visible: {
type: Number,
required: true,
},
plainDiffPath: {
type: String,
required: true,
},
emailPatchPath: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="alert alert-warning">
<h4>
{{ __('Too many changes to show.') }}
<div class="pull-right">
<a
:href="plainDiffPath"
class="btn btn-sm"
>
{{ __('Plain diff') }}
</a>
<a
:href="emailPatchPath"
class="btn btn-sm"
>
{{ __('Email patch') }}
</a>
</div>
</h4>
<p>
To preserve performance only
<strong>
{{ visible }} of {{ total }}
</strong>
files are displayed.
</p>
</div>
</template>
<script>
import diffContentMixin from '../mixins/diff_content';
import {
MATCH_LINE_TYPE,
CONTEXT_LINE_TYPE,
OLD_NO_NEW_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
LINE_HOVER_CLASS_NAME,
LINE_UNFOLD_CLASS_NAME,
} from '../constants';
export default {
mixins: [diffContentMixin],
methods: {
handleMouse(lineCode, isOver) {
this.hoveredLineCode = isOver ? lineCode : null;
},
getLineClass(line) {
const isSameLine = this.hoveredLineCode && this.hoveredLineCode === line.lineCode;
const isMatchLine = line.type === MATCH_LINE_TYPE;
const isContextLine = line.type === CONTEXT_LINE_TYPE;
const isMetaLine = line.type === OLD_NO_NEW_LINE_TYPE || line.type === NEW_NO_NEW_LINE_TYPE;
return {
[line.type]: line.type,
[LINE_UNFOLD_CLASS_NAME]: isMatchLine,
[LINE_HOVER_CLASS_NAME]:
this.isLoggedIn && isSameLine && !isMatchLine && !isContextLine && !isMetaLine,
};
},
},
};
</script>
<template>
<table
:class="userColorScheme"
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file">
<tbody>
<template
v-for="(line, index) in normalizedDiffLines"
>
<tr
:id="line.lineCode || `${fileHash}_${line.oldLine}_${line.newLine}`"
:key="line.lineCode"
:class="getRowClass(line)"
class="line_holder"
@mouseover="handleMouse(line.lineCode, true)"
@mouseout="handleMouse(line.lineCode, false)"
>
<td
:class="getLineClass(line)"
class="diff-line-num old_line"
>
<diff-line-gutter-content
:file-hash="fileHash"
:line-type="line.type"
:line-code="line.lineCode"
:line-number="line.oldLine"
:meta-data="line.metaData"
:show-comment-button="true"
:context-lines-path="diffFile.contextLinesPath"
:is-bottom="index + 1 === diffLinesLength"
@showCommentForm="handleShowCommentForm"
/>
</td>
<td
:class="getLineClass(line)"
class="diff-line-num new_line"
>
<diff-line-gutter-content
:file-hash="fileHash"
:line-type="line.type"
:line-code="line.lineCode"
:line-number="line.newLine"
:meta-data="line.metaData"
:is-bottom="index + 1 === diffLinesLength"
:context-lines-path="diffFile.contextLinesPath"
/>
</td>
<td
:class="line.type"
class="line_content"
v-html="line.richText"
>
</td>
</tr>
<tr
v-if="isDiscussionExpanded(line.lineCode) || diffLineCommentForms[line.lineCode]"
:key="index"
:class="discussionsByLineCode[line.lineCode] ? '' : 'js-temp-notes-holder'"
class="notes_holder"
>
<td
class="notes_line"
colspan="2"
></td>
<td class="notes_content">
<div class="content">
<diff-discussions
:discussions="discussionsByLineCode[line.lineCode] || []"
/>
<diff-line-note-form
v-if="diffLineCommentForms[line.lineCode]"
:diff-file="diffFile"
:diff-lines="diffLines"
:line="line"
:note-target-line="diffLines[index]"
/>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</template>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment