Commit 3e66795e authored by Felipe Artur's avatar Felipe Artur Committed by Tim Zallmann

Changes tab VUE refactoring

parent 14e35ac9
...@@ -31,7 +31,9 @@ export default class Autosave { ...@@ -31,7 +31,9 @@ export default class Autosave {
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false }); const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0); const field = this.field.get(0);
field.dispatchEvent(event); if (field) {
field.dispatchEvent(event);
}
} }
save() { save() {
......
This diff is collapsed.
/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, max-len */
/* global CommentsStore */ /* global CommentsStore */
/* global ResolveService */ /* global ResolveService */
...@@ -41,54 +40,54 @@ const ResolveBtn = Vue.extend({ ...@@ -41,54 +40,54 @@ const ResolveBtn = Vue.extend({
required: true, required: true,
}, },
}, },
data: function () { data() {
return { return {
discussions: CommentsStore.state, discussions: CommentsStore.state,
loading: false loading: false,
}; };
}, },
computed: { computed: {
discussion: function () { discussion() {
return this.discussions[this.discussionId]; return this.discussions[this.discussionId];
}, },
note: function () { note() {
return this.discussion ? this.discussion.getNote(this.noteId) : {}; return this.discussion ? this.discussion.getNote(this.noteId) : {};
}, },
buttonText: function () { buttonText() {
if (this.isResolved) { if (this.isResolved) {
return `Resolved by ${this.resolvedByName}`; return `Resolved by ${this.resolvedByName}`;
} else if (this.canResolve) { } else if (this.canResolve) {
return 'Mark as resolved'; return 'Mark as resolved';
} else {
return 'Unable to resolve';
} }
return 'Unable to resolve';
}, },
isResolved: function () { isResolved() {
if (this.note) { if (this.note) {
return this.note.resolved; return this.note.resolved;
} else {
return false;
} }
return false;
}, },
resolvedByName: function () { resolvedByName() {
return this.note.resolved_by; return this.note.resolved_by;
}, },
}, },
watch: { watch: {
'discussions': { discussions: {
handler: 'updateTooltip', handler: 'updateTooltip',
deep: true deep: true,
} },
}, },
mounted: function () { mounted() {
$(this.$refs.button).tooltip({ $(this.$refs.button).tooltip({
container: 'body' container: 'body',
}); });
}, },
beforeDestroy: function () { beforeDestroy() {
CommentsStore.delete(this.discussionId, this.noteId); CommentsStore.delete(this.discussionId, this.noteId);
}, },
created: function () { created() {
CommentsStore.create({ CommentsStore.create({
discussionId: this.discussionId, discussionId: this.discussionId,
noteId: this.noteId, noteId: this.noteId,
...@@ -101,43 +100,41 @@ const ResolveBtn = Vue.extend({ ...@@ -101,43 +100,41 @@ const ResolveBtn = Vue.extend({
}); });
}, },
methods: { methods: {
updateTooltip: function () { updateTooltip() {
this.$nextTick(() => { this.$nextTick(() => {
$(this.$refs.button) $(this.$refs.button)
.tooltip('hide') .tooltip('hide')
.tooltip('_fixTitle'); .tooltip('_fixTitle');
}); });
}, },
resolve: function () { resolve() {
if (!this.canResolve) return; if (!this.canResolve) return;
let promise; let promise;
this.loading = true; this.loading = true;
if (this.isResolved) { if (this.isResolved) {
promise = ResolveService promise = ResolveService.unresolve(this.noteId);
.unresolve(this.noteId);
} else { } else {
promise = ResolveService promise = ResolveService.resolve(this.noteId);
.resolve(this.noteId);
} }
promise promise
.then(resp => resp.json()) .then(resp => resp.json())
.then((data) => { .then(data => {
this.loading = false; 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); this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus(); gl.mrWidget.checkStatus();
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
this.updateTooltip(); 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.'),
);
},
}, },
}); });
......
/* eslint-disable func-names, comma-dangle, new-cap, no-new */ /* eslint-disable func-names, new-cap */
/* global ResolveCount */
import $ from 'jquery'; import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
...@@ -15,12 +14,13 @@ import './components/resolve_count'; ...@@ -15,12 +14,13 @@ import './components/resolve_count';
import './components/resolve_discussion_btn'; import './components/resolve_discussion_btn';
import './components/diff_note_avatars'; import './components/diff_note_avatars';
import './components/new_issue_for_discussion'; import './components/new_issue_for_discussion';
import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils';
export default () => { 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 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 = window.gl || {};
window.gl.diffNoteApps = {}; window.gl.diffNoteApps = {};
...@@ -28,9 +28,9 @@ export default () => { ...@@ -28,9 +28,9 @@ export default () => {
window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath); window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath);
gl.diffNotesCompileComponents = () => { gl.diffNotesCompileComponents = () => {
$('diff-note-avatars').each(function () { $('diff-note-avatars').each(function() {
const tmp = Vue.extend({ const tmp = Vue.extend({
template: $(this).get(0).outerHTML template: $(this).get(0).outerHTML,
}); });
const tmpApp = new tmp().$mount(); const tmpApp = new tmp().$mount();
...@@ -41,12 +41,12 @@ export default () => { ...@@ -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; return $(this).closest('resolve-count').length !== 1;
}); });
if ($components) { if ($components) {
$components.each(function () { $components.each(function() {
const $this = $(this); const $this = $(this);
const noteId = $this.attr(':note-id'); const noteId = $this.attr(':note-id');
const discussionId = $this.attr(':discussion-id'); const discussionId = $this.attr(':discussion-id');
...@@ -54,7 +54,7 @@ export default () => { ...@@ -54,7 +54,7 @@ export default () => {
if ($this.is('comment-and-resolve-btn') && !discussionId) return; if ($this.is('comment-and-resolve-btn') && !discussionId) return;
const tmp = Vue.extend({ const tmp = Vue.extend({
template: $this.get(0).outerHTML template: $this.get(0).outerHTML,
}); });
const tmpApp = new tmp().$mount(); const tmpApp = new tmp().$mount();
...@@ -69,15 +69,5 @@ export default () => { ...@@ -69,15 +69,5 @@ export default () => {
gl.diffNotesCompileComponents(); gl.diffNotesCompileComponents();
const resolveCountAppEl = document.querySelector('#resolve-count-app');
if (!hasVueMRDiscussionsCookie() && resolveCountAppEl) {
new Vue({
el: resolveCountAppEl,
components: {
'resolve-count': ResolveCount
},
});
}
$(window).trigger('resize.nav'); $(window).trigger('resize.nav');
}; };
...@@ -8,8 +8,12 @@ window.gl = window.gl || {}; ...@@ -8,8 +8,12 @@ window.gl = window.gl || {};
class ResolveServiceClass { class ResolveServiceClass {
constructor(root) { constructor(root) {
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`); this.noteResource = Vue.resource(
this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`); `${root}/notes{/noteId}/resolve?html=true`,
);
this.discussionResource = Vue.resource(
`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`,
);
} }
resolve(noteId) { resolve(noteId) {
...@@ -33,7 +37,7 @@ class ResolveServiceClass { ...@@ -33,7 +37,7 @@ class ResolveServiceClass {
promise promise
.then(resp => resp.json()) .then(resp => resp.json())
.then((data) => { .then(data => {
discussion.loading = false; discussion.loading = false;
const resolvedBy = data ? data.resolved_by : null; const resolvedBy = data ? data.resolved_by : null;
...@@ -45,9 +49,13 @@ class ResolveServiceClass { ...@@ -45,9 +49,13 @@ class ResolveServiceClass {
if (gl.mrWidget) gl.mrWidget.checkStatus(); if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data); 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) { resolveAll(mergeRequestId, discussionId) {
...@@ -55,10 +63,13 @@ class ResolveServiceClass { ...@@ -55,10 +63,13 @@ class ResolveServiceClass {
discussion.loading = true; discussion.loading = true;
return this.discussionResource.save({ return this.discussionResource.save(
mergeRequestId, {
discussionId, mergeRequestId,
}, {}); discussionId,
},
{},
);
} }
unResolveAll(mergeRequestId, discussionId) { unResolveAll(mergeRequestId, discussionId) {
...@@ -66,10 +77,13 @@ class ResolveServiceClass { ...@@ -66,10 +77,13 @@ class ResolveServiceClass {
discussion.loading = true; discussion.loading = true;
return this.discussionResource.delete({ return this.discussionResource.delete(
mergeRequestId, {
discussionId, mergeRequestId,
}, {}); discussionId,
},
{},
);
} }
} }
......
<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>
<script>
import { mapState } from 'vuex';
import emptyImage from '~/../../views/shared/icons/_mr_widget_empty_state.svg';
export default {
data() {
return {
emptyImage,
};
},
computed: {
...mapState({
sourceBranch: state => state.notes.noteableData.source_branch,
targetBranch: state => state.notes.noteableData.target_branch,
newBlobPath: state => state.notes.noteableData.new_blob_path,
}),
},
};
</script>
<template>
<div
class="row empty-state nothing-here-block"
>
<div class="col-xs-12">
<div class="svg-content">
<span
v-html="emptyImage"
></span>
</div>
</div>
<div class="col-xs-12">
<div class="text-content text-center">
No changes between
<span class="ref-name">{{ sourceBranch }}</span>
and
<span class="ref-name">{{ targetBranch }}</span>
<div class="text-center">
<a
:href="newBlobPath"
class="btn btn-save"
>
{{ __('Create commit') }}
</a>
</div>
</div>
</div>
</div>
</template>
<script>
import diffContentMixin from '../mixins/diff_content';
import {
EMPTY_CELL_TYPE,
MATCH_LINE_TYPE,
CONTEXT_LINE_TYPE,
OLD_NO_NEW_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
LINE_HOVER_CLASS_NAME,
LINE_UNFOLD_CLASS_NAME,
LINE_POSITION_RIGHT,
} from '../constants';
export default {
mixins: [diffContentMixin],
computed: {
parallelDiffLines() {
return this.normalizedDiffLines.map(line => {
if (!line.left) {
Object.assign(line, { left: { type: EMPTY_CELL_TYPE } });
} else if (!line.right) {
Object.assign(line, { right: { type: EMPTY_CELL_TYPE } });
}
return line;
});
},
},
methods: {
hasDiscussion(line) {
const discussions = this.discussionsByLineCode;
const hasDiscussion = discussions[line.left.lineCode] || discussions[line.right.lineCode];
return hasDiscussion;
},
getClassName(line, position) {
const { type, lineCode } = line[position];
const isMatchLine = type === MATCH_LINE_TYPE;
const isContextLine = !isMatchLine && type !== EMPTY_CELL_TYPE && type !== CONTEXT_LINE_TYPE;
const isMetaLine = type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE;
const isSameLine = this.hoveredLineCode && this.hoveredLineCode === lineCode;
const isSameSection = position === this.hoveredSection;
return {
[type]: type,
[LINE_UNFOLD_CLASS_NAME]: isMatchLine,
[LINE_HOVER_CLASS_NAME]:
this.isLoggedIn && isContextLine && isSameLine && isSameSection && !isMetaLine,
};
},
handleMouse(e, line, isHover) {
if (isHover) {
const cell = e.target.closest('td');
if (this.$refs.leftLines.indexOf(cell) > -1) {
this.hoveredLineCode = line.left.lineCode;
this.hoveredSection = 'left';
} else if (this.$refs.rightLines.indexOf(cell) > -1) {
this.hoveredLineCode = line.right.lineCode;
this.hoveredSection = 'right';
}
} else {
this.hoveredLineCode = null;
this.hoveredSection = null;
}
},
shouldRenderDiscussionsRow(line) {
const hasDiscussion = this.hasDiscussion(line) && this.hasAnyExpandedDiscussion(line);
const hasCommentFormOnLeft = this.diffLineCommentForms[line.left.lineCode];
const hasCommentFormOnRight = this.diffLineCommentForms[line.right.lineCode];
return hasDiscussion || hasCommentFormOnLeft || hasCommentFormOnRight;
},
shouldRenderDiscussions(line, position) {
const { lineCode } = line[position];
let render = this.discussionsByLineCode[lineCode] && this.isDiscussionExpanded(lineCode);
// Avoid rendering context line discussions on the right side in parallel view
if (position === LINE_POSITION_RIGHT) {
render = render && line.right.type;
}
return render;
},
hasAnyExpandedDiscussion(line) {
const isLeftExpanded = this.isDiscussionExpanded(line.left.lineCode);
const isRightExpanded = this.isDiscussionExpanded(line.right.lineCode);
return isLeftExpanded || isRightExpanded;
},
getLineCode(line, side) {
const lineCode = side.lineCode;
if (lineCode) {
return lineCode;
}
return `${this.fileHash}_${line.left.oldLine}_${line.right.newLine}`;
},
},
};
</script>
<template>
<div
:class="userColorScheme"
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file">
<table>
<tbody>
<template
v-for="(line, index) in parallelDiffLines"
>
<tr
:key="index"
:class="getRowClass(line)"
class="line_holder parallel"
@mouseover="handleMouse($event, line, true)"
@mouseout="handleMouse($event, line, false)"
>
<td
ref="leftLines"
:class="getClassName(line, 'left')"
class="diff-line-num old_line"
>
<diff-line-gutter-content
:file-hash="fileHash"
:line-type="line.left.type"
:line-code="line.left.lineCode"
:line-number="line.left.oldLine"
:meta-data="line.left.metaData"
:show-comment-button="true"
:context-lines-path="diffFile.contextLinesPath"
:is-bottom="index + 1 === diffLinesLength"
line-position="left"
@showCommentForm="handleShowCommentForm"
/>
</td>
<td
ref="leftLines"
:class="getClassName(line, 'left')"
:id="getLineCode(line, line.left)"
class="line_content parallel left-side"
v-html="line.left.richText"
>
</td>
<td
ref="rightLines"
:class="getClassName(line, 'right')"
class="diff-line-num new_line"
>
<diff-line-gutter-content
:file-hash="fileHash"
:line-type="line.right.type"
:line-code="line.right.lineCode"
:line-number="line.right.newLine"
:meta-data="line.right.metaData"
:show-comment-button="true"
:context-lines-path="diffFile.contextLinesPath"
:is-bottom="index + 1 === diffLinesLength"
line-position="right"
@showCommentForm="handleShowCommentForm"
/>
</td>
<td
ref="rightLines"
:class="getClassName(line, 'right')"
:id="getLineCode(line, line.right)"
class="line_content parallel right-side"
v-html="line.right.richText"
>
</td>
</tr>
<tr
v-if="shouldRenderDiscussionsRow(line)"
:key="line.left.lineCode || line.right.lineCode"
:class="hasDiscussion(line) ? '' : 'js-temp-notes-holder'"
class="notes_holder"
>
<td class="notes_line old"></td>
<td class="notes_content parallel old">
<div
v-if="shouldRenderDiscussions(line, 'left')"
class="content"
>
<diff-discussions
:discussions="discussionsByLineCode[line.left.lineCode]"
/>
</div>
<diff-line-note-form
v-if="diffLineCommentForms[line.left.lineCode] &&
diffLineCommentForms[line.left.lineCode]"
:diff-file="diffFile"
:diff-lines="diffLines"
:line="line.left"
:note-target-line="diffLines[index].left"
position="left"
/>
</td>
<td class="notes_line new"></td>
<td class="notes_content parallel new">
<div
v-if="shouldRenderDiscussions(line, 'right')"
class="content"
>
<diff-discussions
:discussions="discussionsByLineCode[line.right.lineCode]"
/>
</div>
<diff-line-note-form
v-if="diffLineCommentForms[line.right.lineCode] &&
diffLineCommentForms[line.right.lineCode] && line.right.type"
:diff-file="diffFile"
:diff-lines="diffLines"
:line="line.right"
:note-target-line="diffLines[index].right"
position="right"
/>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
export const INLINE_DIFF_VIEW_TYPE = 'inline';
export const PARALLEL_DIFF_VIEW_TYPE = 'parallel';
export const MATCH_LINE_TYPE = 'match';
export const OLD_NO_NEW_LINE_TYPE = 'old-nonewline';
export const NEW_NO_NEW_LINE_TYPE = 'new-nonewline';
export const CONTEXT_LINE_TYPE = 'context';
export const EMPTY_CELL_TYPE = 'empty-cell';
export const COMMENT_FORM_TYPE = 'commentForm';
export const DIFF_NOTE_TYPE = 'DiffNote';
export const NEW_LINE_TYPE = 'new';
export const OLD_LINE_TYPE = 'old';
export const TEXT_DIFF_POSITION_TYPE = 'text';
export const LINE_POSITION_LEFT = 'left';
export const LINE_POSITION_RIGHT = 'right';
export const DIFF_VIEW_COOKIE_NAME = 'diff_view';
export const LINE_HOVER_CLASS_NAME = 'is-over';
export const LINE_UNFOLD_CLASS_NAME = 'unfold js-unfold';
export const CONTEXT_LINE_CLASS_NAME = 'diff-expanded';
export const UNFOLD_COUNT = 20;
export const COUNT_OF_AVATARS_IN_GUTTER = 3;
export const LENGTH_OF_AVATAR_TOOLTIP = 17;
import Vue from 'vue';
import { mapState } from 'vuex';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import diffsApp from './components/app.vue';
export default function initDiffsApp(store) {
return new Vue({
el: '#js-diffs-app',
name: 'MergeRequestDiffs',
components: {
diffsApp,
},
store,
data() {
const { dataset } = document.querySelector(this.$options.el);
return {
endpoint: dataset.endpoint,
currentUser: convertObjectPropsToCamelCase(JSON.parse(dataset.currentUserData), {
deep: true,
}),
};
},
computed: {
...mapState({
activeTab: state => state.page.activeTab,
}),
},
render(createElement) {
return createElement('diffs-app', {
props: {
endpoint: this.endpoint,
currentUser: this.currentUser,
shouldShow: this.activeTab === 'diffs',
},
});
},
});
}
export default {
props: {
diffFiles: {
type: Array,
required: true,
},
},
methods: {
fileChangedIcon(diffFile) {
if (diffFile.deletedFile) {
return 'file-deletion';
} else if (diffFile.newFile) {
return 'file-addition';
}
return 'file-modified';
},
fileChangedClass(diffFile) {
if (diffFile.deletedFile) {
return 'cred';
} else if (diffFile.newFile) {
return 'cgreen';
}
return '';
},
truncatedDiffPath(path) {
const maxLength = 60;
if (path.length > maxLength) {
const start = path.length - maxLength;
const end = start + maxLength;
return `...${path.slice(start, end)}`;
}
return path;
},
},
};
import { mapState, mapGetters, mapActions } from 'vuex';
import diffDiscussions from '../components/diff_discussions.vue';
import diffLineGutterContent from '../components/diff_line_gutter_content.vue';
import diffLineNoteForm from '../components/diff_line_note_form.vue';
import { trimFirstCharOfLineContent } from '../store/utils';
import { CONTEXT_LINE_TYPE, CONTEXT_LINE_CLASS_NAME } from '../constants';
export default {
props: {
diffFile: {
type: Object,
required: true,
},
diffLines: {
type: Array,
required: true,
},
},
data() {
return {
hoveredLineCode: null,
hoveredSection: null,
};
},
components: {
diffDiscussions,
diffLineNoteForm,
diffLineGutterContent,
},
computed: {
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
...mapGetters(['discussionsByLineCode', 'isLoggedIn', 'commit']),
commitId() {
return this.commit && this.commit.id;
},
userColorScheme() {
return window.gon.user_color_scheme;
},
normalizedDiffLines() {
return this.diffLines.map(line => {
if (line.richText) {
return this.trimFirstChar(line);
}
if (line.left) {
Object.assign(line, { left: this.trimFirstChar(line.left) });
}
if (line.right) {
Object.assign(line, { right: this.trimFirstChar(line.right) });
}
return line;
});
},
diffLinesLength() {
return this.normalizedDiffLines.length;
},
fileHash() {
return this.diffFile.fileHash;
},
},
methods: {
...mapActions(['showCommentForm', 'cancelCommentForm']),
getRowClass(line) {
const isContextLine = line.left
? line.left.type === CONTEXT_LINE_TYPE
: line.type === CONTEXT_LINE_TYPE;
return {
[line.type]: line.type,
[CONTEXT_LINE_CLASS_NAME]: isContextLine,
};
},
trimFirstChar(line) {
return trimFirstCharOfLineContent(line);
},
handleShowCommentForm(params) {
this.showCommentForm({ lineCode: params.lineCode });
},
isDiscussionExpanded(lineCode) {
const discussions = this.discussionsByLineCode[lineCode];
return discussions ? discussions.every(discussion => discussion.expanded) : false;
},
},
};
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie';
import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import * as types from './mutation_types';
import {
PARALLEL_DIFF_VIEW_TYPE,
INLINE_DIFF_VIEW_TYPE,
DIFF_VIEW_COOKIE_NAME,
} from '../constants';
export const setEndpoint = ({ commit }, endpoint) => {
commit(types.SET_ENDPOINT, endpoint);
};
export const setLoadingState = ({ commit }, state) => {
commit(types.SET_LOADING, state);
};
export const fetchDiffFiles = ({ state, commit }) => {
commit(types.SET_LOADING, true);
return axios
.get(state.endpoint)
.then(res => {
commit(types.SET_LOADING, false);
commit(types.SET_MERGE_REQUEST_DIFFS, res.data.merge_request_diffs || []);
commit(types.SET_DIFF_DATA, res.data);
return Vue.nextTick();
})
.then(handleLocationHash);
};
export const setInlineDiffViewType = ({ commit }) => {
commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE);
Cookies.set(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE);
const url = mergeUrlParams({ view: INLINE_DIFF_VIEW_TYPE }, window.location.href);
historyPushState(url);
};
export const setParallelDiffViewType = ({ commit }) => {
commit(types.SET_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE);
Cookies.set(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE);
const url = mergeUrlParams({ view: PARALLEL_DIFF_VIEW_TYPE }, window.location.href);
historyPushState(url);
};
export const showCommentForm = ({ commit }, params) => {
commit(types.ADD_COMMENT_FORM_LINE, params);
};
export const cancelCommentForm = ({ commit }, params) => {
commit(types.REMOVE_COMMENT_FORM_LINE, params);
};
export const loadMoreLines = ({ commit }, options) => {
const { endpoint, params, lineNumbers, fileHash } = options;
params.from_merge_request = true;
return axios.get(endpoint, { params }).then(res => {
const contextLines = res.data || [];
commit(types.ADD_CONTEXT_LINES, {
lineNumbers,
contextLines,
params,
fileHash,
});
});
};
export const loadCollapsedDiff = ({ commit }, file) =>
axios.get(file.loadCollapsedDiffUrl).then(res => {
commit(types.ADD_COLLAPSED_DIFFS, {
file,
data: res.data,
});
});
export const expandAllFiles = ({ commit }) => {
commit(types.EXPAND_ALL_FILES);
};
export default {
setEndpoint,
setLoadingState,
fetchDiffFiles,
setInlineDiffViewType,
setParallelDiffViewType,
showCommentForm,
cancelCommentForm,
loadMoreLines,
loadCollapsedDiff,
expandAllFiles,
};
import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants';
export default {
isParallelView(state) {
return state.diffViewType === PARALLEL_DIFF_VIEW_TYPE;
},
isInlineView(state) {
return state.diffViewType === INLINE_DIFF_VIEW_TYPE;
},
areAllFilesCollapsed(state) {
return state.diffFiles.every(file => file.collapsed);
},
commit(state) {
return state.commit;
},
};
import Vue from 'vue';
import Vuex from 'vuex';
import diffsModule from './modules';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
diffs: diffsModule,
},
});
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
import actions from '../actions';
import getters from '../getters';
import mutations from '../mutations';
import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
const viewTypeFromQueryString = getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
export default {
state: {
isLoading: true,
endpoint: '',
commit: null,
diffFiles: [],
mergeRequestDiffs: [],
diffLineCommentForms: {},
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
},
getters,
actions,
mutations,
};
export const SET_ENDPOINT = 'SET_ENDPOINT';
export const SET_LOADING = 'SET_LOADING';
export const SET_DIFF_DATA = 'SET_DIFF_DATA';
export const SET_DIFF_FILES = 'SET_DIFF_FILES';
export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE';
export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS';
export const ADD_COMMENT_FORM_LINE = 'ADD_COMMENT_FORM_LINE';
export const REMOVE_COMMENT_FORM_LINE = 'REMOVE_COMMENT_FORM_LINE';
export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES';
export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS';
export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES';
import Vue from 'vue';
import _ from 'underscore';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { findDiffFile, addLineReferences, removeMatchLine, addContextLines } from './utils';
import * as types from './mutation_types';
export default {
[types.SET_ENDPOINT](state, endpoint) {
Object.assign(state, { endpoint });
},
[types.SET_LOADING](state, isLoading) {
Object.assign(state, { isLoading });
},
[types.SET_DIFF_DATA](state, data) {
Object.assign(state, {
...convertObjectPropsToCamelCase(data, { deep: true }),
});
},
[types.SET_DIFF_FILES](state, diffFiles) {
Object.assign(state, {
diffFiles: convertObjectPropsToCamelCase(diffFiles, { deep: true }),
});
},
[types.SET_MERGE_REQUEST_DIFFS](state, mergeRequestDiffs) {
Object.assign(state, {
mergeRequestDiffs: convertObjectPropsToCamelCase(mergeRequestDiffs, { deep: true }),
});
},
[types.SET_DIFF_VIEW_TYPE](state, diffViewType) {
Object.assign(state, { diffViewType });
},
[types.ADD_COMMENT_FORM_LINE](state, { lineCode }) {
Vue.set(state.diffLineCommentForms, lineCode, true);
},
[types.REMOVE_COMMENT_FORM_LINE](state, { lineCode }) {
Vue.delete(state.diffLineCommentForms, lineCode);
},
[types.ADD_CONTEXT_LINES](state, options) {
const { lineNumbers, contextLines, fileHash } = options;
const { bottom } = options.params;
const diffFile = findDiffFile(state.diffFiles, fileHash);
const { highlightedDiffLines, parallelDiffLines } = diffFile;
removeMatchLine(diffFile, lineNumbers, bottom);
const lines = addLineReferences(contextLines, lineNumbers, bottom);
addContextLines({
inlineLines: highlightedDiffLines,
parallelLines: parallelDiffLines,
contextLines: lines,
bottom,
lineNumbers,
});
},
[types.ADD_COLLAPSED_DIFFS](state, { file, data }) {
const normalizedData = convertObjectPropsToCamelCase(data, { deep: true });
const [newFileData] = normalizedData.diffFiles.filter(f => f.fileHash === file.fileHash);
if (newFileData) {
const index = _.findIndex(state.diffFiles, f => f.fileHash === file.fileHash);
state.diffFiles.splice(index, 1, newFileData);
}
},
[types.EXPAND_ALL_FILES](state) {
const diffFiles = [];
state.diffFiles.forEach((file) => {
diffFiles.push({
...file,
collapsed: false,
});
});
Object.assign(state, { diffFiles });
},
};
import _ from 'underscore';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
LINE_POSITION_LEFT,
LINE_POSITION_RIGHT,
TEXT_DIFF_POSITION_TYPE,
DIFF_NOTE_TYPE,
NEW_LINE_TYPE,
OLD_LINE_TYPE,
MATCH_LINE_TYPE,
} from '../constants';
export function findDiffFile(files, hash) {
return files.filter(file => file.fileHash === hash)[0];
}
export const getReversePosition = linePosition => {
if (linePosition === LINE_POSITION_RIGHT) {
return LINE_POSITION_LEFT;
}
return LINE_POSITION_RIGHT;
};
export function getNoteFormData(params) {
const {
note,
noteableType,
noteableData,
diffFile,
noteTargetLine,
diffViewType,
linePosition,
} = params;
const position = JSON.stringify({
base_sha: diffFile.diffRefs.baseSha,
start_sha: diffFile.diffRefs.startSha,
head_sha: diffFile.diffRefs.headSha,
old_path: diffFile.oldPath,
new_path: diffFile.newPath,
position_type: TEXT_DIFF_POSITION_TYPE,
old_line: noteTargetLine.oldLine,
new_line: noteTargetLine.newLine,
});
const postData = {
view: diffViewType,
line_type: linePosition === LINE_POSITION_RIGHT ? NEW_LINE_TYPE : OLD_LINE_TYPE,
merge_request_diff_head_sha: diffFile.diffRefs.headSha,
in_reply_to_discussion_id: '',
note_project_id: '',
target_type: noteableData.targetType,
target_id: noteableData.id,
note: {
note,
position,
noteable_type: noteableType,
noteable_id: noteableData.id,
commit_id: '',
type: DIFF_NOTE_TYPE,
line_code: noteTargetLine.lineCode,
},
};
return {
endpoint: noteableData.create_note_path,
data: postData,
};
}
export const findIndexInInlineLines = (lines, lineNumbers) => {
const { oldLineNumber, newLineNumber } = lineNumbers;
return _.findIndex(
lines,
line => line.oldLine === oldLineNumber && line.newLine === newLineNumber,
);
};
export const findIndexInParallelLines = (lines, lineNumbers) => {
const { oldLineNumber, newLineNumber } = lineNumbers;
return _.findIndex(
lines,
line =>
line.left &&
line.right &&
line.left.oldLine === oldLineNumber &&
line.right.newLine === newLineNumber,
);
};
export function removeMatchLine(diffFile, lineNumbers, bottom) {
const indexForInline = findIndexInInlineLines(diffFile.highlightedDiffLines, lineNumbers);
const indexForParallel = findIndexInParallelLines(diffFile.parallelDiffLines, lineNumbers);
const factor = bottom ? 1 : -1;
diffFile.highlightedDiffLines.splice(indexForInline + factor, 1);
diffFile.parallelDiffLines.splice(indexForParallel + factor, 1);
}
export function addLineReferences(lines, lineNumbers, bottom) {
const { oldLineNumber, newLineNumber } = lineNumbers;
const lineCount = lines.length;
let matchLineIndex = -1;
const linesWithNumbers = lines.map((l, index) => {
const line = convertObjectPropsToCamelCase(l);
if (line.type === MATCH_LINE_TYPE) {
matchLineIndex = index;
} else {
Object.assign(line, {
oldLine: bottom ? oldLineNumber + index + 1 : oldLineNumber + index - lineCount,
newLine: bottom ? newLineNumber + index + 1 : newLineNumber + index - lineCount,
});
}
return line;
});
if (matchLineIndex > -1) {
const line = linesWithNumbers[matchLineIndex];
const targetLine = bottom
? linesWithNumbers[matchLineIndex - 1]
: linesWithNumbers[matchLineIndex + 1];
Object.assign(line, {
metaData: {
oldPos: targetLine.oldLine,
newPos: targetLine.newLine,
},
});
}
return linesWithNumbers;
}
export function addContextLines(options) {
const { inlineLines, parallelLines, contextLines, lineNumbers } = options;
const normalizedParallelLines = contextLines.map(line => ({
left: line,
right: line,
}));
if (options.bottom) {
inlineLines.push(...contextLines);
parallelLines.push(...normalizedParallelLines);
} else {
const inlineIndex = findIndexInInlineLines(inlineLines, lineNumbers);
const parallelIndex = findIndexInParallelLines(parallelLines, lineNumbers);
inlineLines.splice(inlineIndex, 0, ...contextLines);
parallelLines.splice(parallelIndex, 0, ...normalizedParallelLines);
}
}
export function trimFirstCharOfLineContent(line) {
if (!line.richText) {
return line;
}
const firstChar = line.richText.charAt(0);
if (firstChar === ' ' || firstChar === '+' || firstChar === '-') {
Object.assign(line, {
richText: line.richText.substring(1),
});
}
return line;
}
import $ from 'jquery'; import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils';
import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie } from './common_utils';
const isVueMRDiscussions = () => isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
export const addClassIfElementExists = (element, className) => { export const addClassIfElementExists = (element, className) => {
if (element) { if (element) {
...@@ -9,4 +6,4 @@ export const addClassIfElementExists = (element, className) => { ...@@ -9,4 +6,4 @@ export const addClassIfElementExists = (element, className) => {
} }
}; };
export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isVueMRDiscussions(); export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isInMRPage();
...@@ -57,6 +57,14 @@ export const slugify = str => str.trim().toLowerCase(); ...@@ -57,6 +57,14 @@ export const slugify = str => str.trim().toLowerCase();
*/ */
export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`; export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`;
/**
* Truncate SHA to 8 characters
*
* @param {String} sha
* @returns {String}
*/
export const truncateSha = sha => sha.substr(0, 8);
/** /**
* Capitalizes first character * Capitalizes first character
* *
...@@ -98,3 +106,16 @@ export const convertToSentenceCase = string => { ...@@ -98,3 +106,16 @@ export const convertToSentenceCase = string => {
return splitWord.join(' '); return splitWord.join(' ');
}; };
/**
* Splits camelCase or PascalCase words
* e.g. HelloWorld => Hello World
*
* @param {*} string
*/
export const splitCamelCase = string => (
string
.replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2')
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
.trim()
);
...@@ -49,6 +49,7 @@ MergeRequest.prototype.initTabs = function() { ...@@ -49,6 +49,7 @@ MergeRequest.prototype.initTabs = function() {
if (window.mrTabs) { if (window.mrTabs) {
window.mrTabs.unbindEvents(); window.mrTabs.unbindEvents();
} }
window.mrTabs = new MergeRequestTabs(this.opts); window.mrTabs = new MergeRequestTabs(this.opts);
}; };
......
/* eslint-disable no-new, class-methods-use-this */ /* eslint-disable no-new, class-methods-use-this */
import $ from 'jquery'; import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import flash from './flash'; import flash from './flash';
...@@ -8,6 +9,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; ...@@ -8,6 +9,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
import initChangesDropdown from './init_changes_dropdown'; import initChangesDropdown from './init_changes_dropdown';
import bp from './breakpoints'; import bp from './breakpoints';
import { parseUrlPathname, handleLocationHash, isMetaClick } from './lib/utils/common_utils'; import { parseUrlPathname, handleLocationHash, isMetaClick } from './lib/utils/common_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { getLocationHash } from './lib/utils/url_utility'; import { getLocationHash } from './lib/utils/url_utility';
import initDiscussionTab from './image_diff/init_discussion_tab'; import initDiscussionTab from './image_diff/init_discussion_tab';
import Diff from './diff'; import Diff from './diff';
...@@ -70,11 +72,13 @@ export default class MergeRequestTabs { ...@@ -70,11 +72,13 @@ export default class MergeRequestTabs {
const navbar = document.querySelector('.navbar-gitlab'); const navbar = document.querySelector('.navbar-gitlab');
const peek = document.getElementById('js-peek'); const peek = document.getElementById('js-peek');
const paddingTop = 16; const paddingTop = 16;
this.commitsTab = document.querySelector('.tab-content .commits.tab-pane');
this.diffsLoaded = false; this.diffsLoaded = false;
this.pipelinesLoaded = false; this.pipelinesLoaded = false;
this.commitsLoaded = false; this.commitsLoaded = false;
this.fixedLayoutPref = null; this.fixedLayoutPref = null;
this.eventHub = new Vue();
this.setUrl = setUrl !== undefined ? setUrl : true; this.setUrl = setUrl !== undefined ? setUrl : true;
this.setCurrentAction = this.setCurrentAction.bind(this); this.setCurrentAction = this.setCurrentAction.bind(this);
...@@ -149,7 +153,9 @@ export default class MergeRequestTabs { ...@@ -149,7 +153,9 @@ export default class MergeRequestTabs {
this.resetViewContainer(); this.resetViewContainer();
this.destroyPipelinesView(); this.destroyPipelinesView();
} else if (this.isDiffAction(action)) { } else if (this.isDiffAction(action)) {
this.loadDiff($target.attr('href')); if (!isInVueNoteablePage()) {
this.loadDiff($target.attr('href'));
}
if (bp.getBreakpointSize() !== 'lg') { if (bp.getBreakpointSize() !== 'lg') {
this.shrinkView(); this.shrinkView();
} }
...@@ -157,6 +163,7 @@ export default class MergeRequestTabs { ...@@ -157,6 +163,7 @@ export default class MergeRequestTabs {
this.expandViewContainer(); this.expandViewContainer();
} }
this.destroyPipelinesView(); this.destroyPipelinesView();
this.commitsTab.classList.remove('active');
} else if (action === 'pipelines') { } else if (action === 'pipelines') {
this.resetViewContainer(); this.resetViewContainer();
this.mountPipelinesView(); this.mountPipelinesView();
...@@ -172,6 +179,8 @@ export default class MergeRequestTabs { ...@@ -172,6 +179,8 @@ export default class MergeRequestTabs {
if (this.setUrl) { if (this.setUrl) {
this.setCurrentAction(action); this.setCurrentAction(action);
} }
this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction());
} }
scrollToElement(container) { scrollToElement(container) {
......
import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import initDiffsApp from '../diffs';
import notesApp from '../notes/components/notes_app.vue'; import notesApp from '../notes/components/notes_app.vue';
import discussionCounter from '../notes/components/discussion_counter.vue'; import discussionCounter from '../notes/components/discussion_counter.vue';
import store from '../notes/stores'; import store from './stores';
import MergeRequest from '../merge_request';
export default function initMrNotes() { export default function initMrNotes() {
const mrShowNode = document.querySelector('.merge-request');
// eslint-disable-next-line no-new
new MergeRequest({
action: mrShowNode.dataset.mrAction,
});
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: '#js-vue-mr-discussions', el: '#js-vue-mr-discussions',
name: 'MergeRequestDiscussions',
components: { components: {
notesApp, notesApp,
}, },
store,
data() { data() {
const notesDataset = document.getElementById('js-vue-mr-discussions') const notesDataset = document.getElementById('js-vue-mr-discussions').dataset;
.dataset;
const noteableData = JSON.parse(notesDataset.noteableData); const noteableData = JSON.parse(notesDataset.noteableData);
noteableData.noteableType = notesDataset.noteableType; noteableData.noteableType = notesDataset.noteableType;
noteableData.targetType = notesDataset.targetType;
return { return {
noteableData, noteableData,
...@@ -22,12 +34,42 @@ export default function initMrNotes() { ...@@ -22,12 +34,42 @@ export default function initMrNotes() {
notesData: JSON.parse(notesDataset.notesData), notesData: JSON.parse(notesDataset.notesData),
}; };
}, },
computed: {
...mapGetters(['discussionTabCounter']),
...mapState({
activeTab: state => state.page.activeTab,
}),
},
watch: {
discussionTabCounter() {
this.updateDiscussionTabCounter();
},
},
mounted() {
this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge');
this.setActiveTab(window.mrTabs.getCurrentAction());
window.mrTabs.eventHub.$on('MergeRequestTabChange', tab => {
this.setActiveTab(tab);
});
$(document).on('visibilitychange', this.updateDiscussionTabCounter);
},
beforeDestroy() {
$(document).off('visibilitychange', this.updateDiscussionTabCounter);
},
methods: {
...mapActions(['setActiveTab']),
updateDiscussionTabCounter() {
this.notesCountBadge.text(this.discussionTabCounter);
},
},
render(createElement) { render(createElement) {
return createElement('notes-app', { return createElement('notes-app', {
props: { props: {
noteableData: this.noteableData, noteableData: this.noteableData,
notesData: this.notesData, notesData: this.notesData,
userData: this.currentUserData, userData: this.currentUserData,
shouldShow: this.activeTab === 'show',
}, },
}); });
}, },
...@@ -36,6 +78,7 @@ export default function initMrNotes() { ...@@ -36,6 +78,7 @@ export default function initMrNotes() {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: '#js-vue-discussion-counter', el: '#js-vue-discussion-counter',
name: 'DiscussionCounter',
components: { components: {
discussionCounter, discussionCounter,
}, },
...@@ -44,4 +87,6 @@ export default function initMrNotes() { ...@@ -44,4 +87,6 @@ export default function initMrNotes() {
return createElement('discussion-counter'); return createElement('discussion-counter');
}, },
}); });
initDiffsApp(store);
} }
import types from './mutation_types';
export default {
setActiveTab({ commit }, tab) {
commit(types.SET_ACTIVE_TAB, tab);
},
};
export default {
isLoggedIn(state, getters) {
return !!getters.getUserData.id;
},
};
import Vue from 'vue';
import Vuex from 'vuex';
import notesModule from '~/notes/stores/modules';
import diffsModule from '~/diffs/store/modules';
import mrPageModule from './modules';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
page: mrPageModule,
notes: notesModule,
diffs: diffsModule,
},
});
import actions from '../actions';
import getters from '../getters';
import mutations from '../mutations';
export default {
state: {
activeTab: null,
},
actions,
getters,
mutations,
};
export default {
SET_ACTIVE_TAB: 'SET_ACTIVE_TAB',
};
import types from './mutation_types';
export default {
[types.SET_ACTIVE_TAB](state, tab) {
Object.assign(state, { activeTab: tab });
},
};
This diff is collapsed.
...@@ -7,10 +7,7 @@ import { __, sprintf } from '~/locale'; ...@@ -7,10 +7,7 @@ import { __, sprintf } from '~/locale';
import Flash from '../../flash'; import Flash from '../../flash';
import Autosave from '../../autosave'; import Autosave from '../../autosave';
import TaskList from '../../task_list'; import TaskList from '../../task_list';
import { import { capitalizeFirstCharacter, convertToCamelCase, splitCamelCase } from '../../lib/utils/text_utility';
capitalizeFirstCharacter,
convertToCamelCase,
} from '../../lib/utils/text_utility';
import * as constants from '../constants'; import * as constants from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
...@@ -56,21 +53,23 @@ export default { ...@@ -56,21 +53,23 @@ export default {
]), ]),
...mapState(['isToggleStateButtonLoading']), ...mapState(['isToggleStateButtonLoading']),
noteableDisplayName() { noteableDisplayName() {
return this.noteableType.replace(/_/g, ' '); return splitCamelCase(this.noteableType).toLowerCase();
}, },
isLoggedIn() { isLoggedIn() {
return this.getUserData.id; return this.getUserData.id;
}, },
commentButtonTitle() { commentButtonTitle() {
return this.noteType === constants.COMMENT return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
? 'Comment' },
: 'Start discussion'; startDiscussionDescription() {
let text = 'Discuss a specific suggestion or question';
if (this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE) {
text += ' that needs to be resolved';
}
return `${text}.`;
}, },
isOpen() { isOpen() {
return ( return this.openState === constants.OPENED || this.openState === constants.REOPENED;
this.openState === constants.OPENED ||
this.openState === constants.REOPENED
);
}, },
canCreateNote() { canCreateNote() {
return this.getNoteableData.current_user.can_create_note; return this.getNoteableData.current_user.can_create_note;
...@@ -117,6 +116,9 @@ export default { ...@@ -117,6 +116,9 @@ export default {
endpoint() { endpoint() {
return this.getNoteableData.create_note_path; return this.getNoteableData.create_note_path;
}, },
issuableTypeTitle() {
return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE ? 'merge request' : 'issue';
},
}, },
watch: { watch: {
note(newNote) { note(newNote) {
...@@ -129,9 +131,7 @@ export default { ...@@ -129,9 +131,7 @@ export default {
mounted() { mounted() {
// jQuery is needed here because it is a custom event being dispatched with jQuery. // jQuery is needed here because it is a custom event being dispatched with jQuery.
$(document).on('issuable:change', (e, isClosed) => { $(document).on('issuable:change', (e, isClosed) => {
this.toggleIssueLocalState( this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED);
isClosed ? constants.CLOSED : constants.REOPENED,
);
}); });
this.initAutoSave(); this.initAutoSave();
...@@ -168,6 +168,7 @@ export default { ...@@ -168,6 +168,7 @@ export default {
noteable_id: this.getNoteableData.id, noteable_id: this.getNoteableData.id,
note: this.note, note: this.note,
}, },
merge_request_diff_head_sha: this.getNoteableData.diff_head_sha,
}, },
}; };
...@@ -227,9 +228,7 @@ Please check your network connection and try again.`; ...@@ -227,9 +228,7 @@ Please check your network connection and try again.`;
this.toggleStateButtonLoading(false); this.toggleStateButtonLoading(false);
Flash( Flash(
sprintf( sprintf(
__( __('Something went wrong while closing the %{issuable}. Please try again later'),
'Something went wrong while closing the %{issuable}. Please try again later',
),
{ issuable: this.noteableDisplayName }, { issuable: this.noteableDisplayName },
), ),
); );
...@@ -242,9 +241,7 @@ Please check your network connection and try again.`; ...@@ -242,9 +241,7 @@ Please check your network connection and try again.`;
this.toggleStateButtonLoading(false); this.toggleStateButtonLoading(false);
Flash( Flash(
sprintf( sprintf(
__( __('Something went wrong while reopening the %{issuable}. Please try again later'),
'Something went wrong while reopening the %{issuable}. Please try again later',
),
{ issuable: this.noteableDisplayName }, { issuable: this.noteableDisplayName },
), ),
); );
...@@ -281,9 +278,7 @@ Please check your network connection and try again.`; ...@@ -281,9 +278,7 @@ Please check your network connection and try again.`;
}, },
initAutoSave() { initAutoSave() {
if (this.isLoggedIn) { if (this.isLoggedIn) {
const noteableType = capitalizeFirstCharacter( const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
convertToCamelCase(this.noteableType),
);
this.autosave = new Autosave($(this.$refs.textarea), [ this.autosave = new Autosave($(this.$refs.textarea), [
'Note', 'Note',
...@@ -312,8 +307,8 @@ Please check your network connection and try again.`; ...@@ -312,8 +307,8 @@ Please check your network connection and try again.`;
<div> <div>
<note-signed-out-widget v-if="!isLoggedIn" /> <note-signed-out-widget v-if="!isLoggedIn" />
<discussion-locked-widget <discussion-locked-widget
v-else-if="isLocked(getNoteableData) && !canCreateNote" v-else-if="!canCreateNote"
issuable-type="issue" :issuable-type="issuableTypeTitle"
/> />
<ul <ul
v-else-if="canCreateNote" v-else-if="canCreateNote"
...@@ -357,7 +352,7 @@ Please check your network connection and try again.`; ...@@ -357,7 +352,7 @@ Please check your network connection and try again.`;
v-model="note" v-model="note"
:disabled="isSubmitting" :disabled="isSubmitting"
name="note[note]" name="note[note]"
class="note-textarea js-vue-comment-form class="note-textarea js-vue-comment-form js-note-text
js-gfm-input js-autosize markdown-area js-vue-textarea" js-gfm-input js-autosize markdown-area js-vue-textarea"
data-supports-quick-actions="true" data-supports-quick-actions="true"
aria-label="Description" aria-label="Description"
...@@ -423,7 +418,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ...@@ -423,7 +418,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
<div class="description"> <div class="description">
<strong>Start discussion</strong> <strong>Start discussion</strong>
<p> <p>
Discuss a specific suggestion or question. {{ startDiscussionDescription }}
</p> </p>
</div> </div>
</button> </button>
......
<script> <script>
import $ from 'jquery'; import { mapState, mapActions } from 'vuex';
import syntaxHighlight from '~/syntax_highlight';
import imageDiffHelper from '~/image_diff/helpers/index'; import imageDiffHelper from '~/image_diff/helpers/index';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import DiffFileHeader from './diff_file_header.vue'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import { trimFirstCharOfLineContent } from '~/diffs/store/utils';
export default { export default {
components: { components: {
DiffFileHeader, DiffFileHeader,
SkeletonLoadingContainer,
}, },
props: { props: {
discussion: { discussion: {
...@@ -15,7 +17,24 @@ export default { ...@@ -15,7 +17,24 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
error: false,
};
},
computed: { computed: {
...mapState({
noteableData: state => state.notes.noteableData,
}),
hasTruncatedDiffLines() {
return this.discussion.truncatedDiffLines && this.discussion.truncatedDiffLines.length !== 0;
},
isDiscussionsExpanded() {
return true; // TODO: @fatihacet - Fix this.
},
isCollapsed() {
return this.diffFile.collapsed || false;
},
isImageDiff() { isImageDiff() {
return !this.diffFile.text; return !this.diffFile.text;
}, },
...@@ -23,36 +42,46 @@ export default { ...@@ -23,36 +42,46 @@ export default {
const { text } = this.diffFile; const { text } = this.diffFile;
return text ? 'text-file' : 'js-image-file'; return text ? 'text-file' : 'js-image-file';
}, },
diffRows() {
return $(this.discussion.truncatedDiffLines);
},
diffFile() { diffFile() {
return convertObjectPropsToCamelCase(this.discussion.diffFile); return convertObjectPropsToCamelCase(this.discussion.diffFile, { deep: true });
}, },
imageDiffHtml() { imageDiffHtml() {
return this.discussion.imageDiffHtml; return this.discussion.imageDiffHtml;
}, },
currentUser() {
return this.noteableData.current_user;
},
userColorScheme() {
return window.gon.user_color_scheme;
},
normalizedDiffLines() {
const lines = this.discussion.truncatedDiffLines || [];
return lines.map(line => trimFirstCharOfLineContent(convertObjectPropsToCamelCase(line)));
},
}, },
mounted() { mounted() {
if (this.isImageDiff) { if (this.isImageDiff) {
const canCreateNote = false; const canCreateNote = false;
const renderCommentBadge = true; const renderCommentBadge = true;
imageDiffHelper.initImageDiff( imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge);
this.$refs.fileHolder, } else if (!this.hasTruncatedDiffLines) {
canCreateNote, this.fetchDiff();
renderCommentBadge,
);
} else {
const fileHolder = $(this.$refs.fileHolder);
this.$nextTick(() => {
syntaxHighlight(fileHolder);
});
} }
}, },
methods: { methods: {
...mapActions(['fetchDiscussionDiffLines']),
rowTag(html) { rowTag(html) {
return html.outerHTML ? 'tr' : 'template'; return html.outerHTML ? 'tr' : 'template';
}, },
fetchDiff() {
this.error = false;
this.fetchDiscussionDiffLines(this.discussion)
.then(this.highlight)
.catch(() => {
this.error = true;
});
},
}, },
}; };
</script> </script>
...@@ -63,23 +92,59 @@ export default { ...@@ -63,23 +92,59 @@ export default {
:class="diffFileClass" :class="diffFileClass"
class="diff-file file-holder" class="diff-file file-holder"
> >
<div class="js-file-title file-title file-title-flex-parent"> <diff-file-header
<diff-file-header :diff-file="diffFile"
:diff-file="diffFile" :current-user="currentUser"
/> :discussions-expanded="isDiscussionsExpanded"
</div> :expanded="!isCollapsed"
/>
<div <div
v-if="diffFile.text" v-if="diffFile.text"
class="diff-content code js-syntax-highlight" :class="userColorScheme"
class="diff-content code"
> >
<table> <table>
<component <tr
v-for="(html, index) in diffRows" v-for="line in normalizedDiffLines"
:is="rowTag(html)" :key="line.lineCode"
:class="html.className" class="line_holder"
:key="index" >
v-html="html.outerHTML" <td class="diff-line-num old_line">{{ line.oldLine }}</td>
/> <td class="diff-line-num new_line">{{ line.newLine }}</td>
<td
:class="line.type"
class="line_content"
v-html="line.richText"
>
</td>
</tr>
<tr
v-if="!hasTruncatedDiffLines"
class="line_holder line-holder-placeholder"
>
<td class="old_line diff-line-num"></td>
<td class="new_line diff-line-num"></td>
<td
v-if="error"
class="js-error-lazy-load-diff diff-loading-error-block"
>
Unable to load the diff
<button
class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button"
@click="fetchDiff"
>
Try again
</button>
</td>
<td
v-else
class="line_content js-success-lazy-load"
>
<span></span>
<skeleton-loading-container />
<span></span>
</td>
</tr>
<tr class="notes_holder"> <tr class="notes_holder">
<td <td
class="notes_line" class="notes_line"
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import resolveSvg from 'icons/_icon_resolve_discussion.svg'; import resolveSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedSvg from 'icons/_icon_status_success_solid.svg'; import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg'; import mrIssueSvg from 'icons/_icon_mr_issue.svg';
...@@ -48,10 +48,14 @@ export default { ...@@ -48,10 +48,14 @@ export default {
this.nextDiscussionSvg = nextDiscussionSvg; this.nextDiscussionSvg = nextDiscussionSvg;
}, },
methods: { methods: {
jumpToFirstDiscussion() { ...mapActions(['expandDiscussion']),
const el = document.querySelector( jumpToFirstUnresolvedDiscussion() {
`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`, const discussionId = this.firstUnresolvedDiscussionId;
); if (!discussionId) {
return;
}
const el = document.querySelector(`[data-discussion-id="${discussionId}"]`);
const activeTab = window.mrTabs.currentAction; const activeTab = window.mrTabs.currentAction;
if (activeTab === 'commits' || activeTab === 'pipelines') { if (activeTab === 'commits' || activeTab === 'pipelines') {
...@@ -59,6 +63,7 @@ export default { ...@@ -59,6 +63,7 @@ export default {
} }
if (el) { if (el) {
this.expandDiscussion({ discussionId });
scrollToElement(el); scrollToElement(el);
} }
}, },
...@@ -97,7 +102,7 @@ export default { ...@@ -97,7 +102,7 @@ export default {
<a <a
v-tooltip v-tooltip
:href="resolveAllDiscussionsIssuePath" :href="resolveAllDiscussionsIssuePath"
title="Resolve all discussions in new issue" :title="s__('Resolve all discussions in new issue')"
data-container="body" data-container="body"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"> class="new-issue-for-discussion btn btn-default discussion-create-issue-btn">
<span v-html="mrIssueSvg"></span> <span v-html="mrIssueSvg"></span>
...@@ -112,7 +117,7 @@ export default { ...@@ -112,7 +117,7 @@ export default {
title="Jump to first unresolved discussion" title="Jump to first unresolved discussion"
data-container="body" data-container="body"
class="btn btn-default discussion-next-btn" class="btn btn-default discussion-next-btn"
@click="jumpToFirstDiscussion"> @click="jumpToFirstUnresolvedDiscussion">
<span v-html="nextDiscussionSvg"></span> <span v-html="nextDiscussionSvg"></span>
</button> </button>
</div> </div>
......
...@@ -27,6 +27,10 @@ export default { ...@@ -27,6 +27,10 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
noteUrl: {
type: String,
required: true,
},
accessLevel: { accessLevel: {
type: String, type: String,
required: false, required: false,
...@@ -48,6 +52,11 @@ export default { ...@@ -48,6 +52,11 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
canResolve: {
type: Boolean,
required: false,
default: false,
},
resolvable: { resolvable: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -125,7 +134,7 @@ export default { ...@@ -125,7 +134,7 @@ export default {
{{ accessLevel }} {{ accessLevel }}
</span> </span>
<div <div
v-if="resolvable" v-if="canResolve"
class="note-actions-item"> class="note-actions-item">
<button <button
v-tooltip v-tooltip
...@@ -216,6 +225,15 @@ export default { ...@@ -216,6 +225,15 @@ export default {
Report as abuse Report as abuse
</a> </a>
</li> </li>
<li>
<button
:data-clipboard-text="noteUrl"
type="button"
css-class="btn-default btn-transparent"
>
Copy link
</button>
</li>
<li v-if="canEdit"> <li v-if="canEdit">
<button <button
class="btn btn-transparent js-note-delete js-note-delete" class="btn btn-transparent js-note-delete js-note-delete"
......
...@@ -40,7 +40,7 @@ export default { ...@@ -40,7 +40,7 @@ export default {
this.initTaskList(); this.initTaskList();
if (this.isEditing) { if (this.isEditing) {
this.initAutoSave(this.note.noteable_type); this.initAutoSave(this.note);
} }
}, },
updated() { updated() {
...@@ -49,7 +49,7 @@ export default { ...@@ -49,7 +49,7 @@ export default {
if (this.isEditing) { if (this.isEditing) {
if (!this.autosave) { if (!this.autosave) {
this.initAutoSave(this.note.noteable_type); this.initAutoSave(this.note);
} else { } else {
this.setAutoSave(); this.setAutoSave();
} }
...@@ -72,7 +72,7 @@ export default { ...@@ -72,7 +72,7 @@ export default {
this.$emit('handleFormUpdate', note, parentElement, callback); this.$emit('handleFormUpdate', note, parentElement, callback);
}, },
formCancelHandler(shouldConfirm, isDirty) { formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelFormEdition', shouldConfirm, isDirty); this.$emit('cancelForm', shouldConfirm, isDirty);
}, },
}, },
}; };
...@@ -93,7 +93,7 @@ export default { ...@@ -93,7 +93,7 @@ export default {
:note-body="noteBody" :note-body="noteBody"
:note-id="note.id" :note-id="note.id"
@handleFormUpdate="handleFormUpdate" @handleFormUpdate="handleFormUpdate"
@cancelFormEdition="formCancelHandler" @cancelForm="formCancelHandler"
/> />
<textarea <textarea
v-if="canEdit" v-if="canEdit"
...@@ -105,6 +105,7 @@ export default { ...@@ -105,6 +105,7 @@ export default {
:edited-at="note.last_edited_at" :edited-at="note.last_edited_at"
:edited-by="note.last_edited_by" :edited-by="note.last_edited_by"
action-text="Edited" action-text="Edited"
class="note_edited_ago"
/> />
<note-awards-list <note-awards-list
v-if="note.award_emoji.length" v-if="note.award_emoji.length"
......
...@@ -11,14 +11,20 @@ export default { ...@@ -11,14 +11,20 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
actionDetailText: {
type: String,
required: false,
default: '',
},
editedAt: { editedAt: {
type: String, type: String,
required: true, required: false,
default: null,
}, },
editedBy: { editedBy: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: null,
}, },
className: { className: {
type: String, type: String,
...@@ -33,13 +39,14 @@ export default { ...@@ -33,13 +39,14 @@ export default {
<div :class="className"> <div :class="className">
{{ actionText }} {{ actionText }}
<template v-if="editedBy"> <template v-if="editedBy">
{{ s__('ByAuthor|by') }} by
<a <a
:href="editedBy.path" :href="editedBy.path"
class="js-vue-author author_link"> class="js-vue-author author_link">
{{ editedBy.name }} {{ editedBy.name }}
</a> </a>
</template> </template>
{{ actionDetailText }}
<time-ago-tooltip <time-ago-tooltip
:time="editedAt" :time="editedAt"
tooltip-placement="bottom" tooltip-placement="bottom"
......
...@@ -29,7 +29,7 @@ export default { ...@@ -29,7 +29,7 @@ export default {
required: false, required: false,
default: 'Save comment', default: 'Save comment',
}, },
note: { discussion: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: () => ({}),
...@@ -38,6 +38,11 @@ export default { ...@@ -38,6 +38,11 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
lineCode: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
...@@ -66,9 +71,7 @@ export default { ...@@ -66,9 +71,7 @@ export default {
return this.getNotesDataByProp('markdownDocsPath'); return this.getNotesDataByProp('markdownDocsPath');
}, },
quickActionsDocsPath() { quickActionsDocsPath() {
return !this.isEditing return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined;
? this.getNotesDataByProp('quickActionsDocsPath')
: undefined;
}, },
currentUserId() { currentUserId() {
return this.getUserDataByProp('id'); return this.getUserDataByProp('id');
...@@ -95,24 +98,17 @@ export default { ...@@ -95,24 +98,17 @@ export default {
const beforeSubmitDiscussionState = this.discussionResolved; const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true; this.isSubmitting = true;
this.$emit( this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
'handleFormUpdate', this.isSubmitting = false;
this.updatedNoteBody,
this.$refs.editNoteForm,
() => {
this.isSubmitting = false;
if (shouldResolve) { if (shouldResolve) {
this.resolveHandler(beforeSubmitDiscussionState); this.resolveHandler(beforeSubmitDiscussionState);
} }
}, });
);
}, },
editMyLastNote() { editMyLastNote() {
if (this.updatedNoteBody === '') { if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote( const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
this.updatedNoteBody,
);
if (lastNoteInDiscussion) { if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', { eventHub.$emit('enterEditMode', {
...@@ -123,11 +119,7 @@ export default { ...@@ -123,11 +119,7 @@ export default {
}, },
cancelHandler(shouldConfirm = false) { cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed // Sends information about confirm message and if the textarea has changed
this.$emit( this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
'cancelFormEdition',
shouldConfirm,
this.noteBody !== this.updatedNoteBody,
);
}, },
}, },
}; };
...@@ -136,7 +128,7 @@ export default { ...@@ -136,7 +128,7 @@ export default {
<template> <template>
<div <div
ref="editNoteForm" ref="editNoteForm"
class="note-edit-form current-note-edit-form"> class="note-edit-form current-note-edit-form js-discussion-note-form">
<div <div
v-if="conflictWhileEditing" v-if="conflictWhileEditing"
class="js-conflict-edit-warning alert alert-danger"> class="js-conflict-edit-warning alert alert-danger">
...@@ -150,7 +142,10 @@ export default { ...@@ -150,7 +142,10 @@ export default {
to ensure information is not lost. to ensure information is not lost.
</div> </div>
<div class="flash-container timeline-content"></div> <div class="flash-container timeline-content"></div>
<form class="edit-note common-note-form js-quick-submit gfm-form"> <form
:data-line-code="lineCode"
class="edit-note common-note-form js-quick-submit gfm-form"
>
<issue-warning <issue-warning
v-if="hasWarning(getNoteableData)" v-if="hasWarning(getNoteableData)"
...@@ -170,7 +165,7 @@ export default { ...@@ -170,7 +165,7 @@ export default {
:data-supports-quick-actions="!isEditing" :data-supports-quick-actions="!isEditing"
v-model="updatedNoteBody" v-model="updatedNoteBody"
name="note[note]" name="note[note]"
class="note-textarea js-gfm-input class="note-textarea js-gfm-input js-note-text
js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
aria-label="Description" aria-label="Description"
placeholder="Write a comment or drag your files here…" placeholder="Write a comment or drag your files here…"
...@@ -184,19 +179,19 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" ...@@ -184,19 +179,19 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
<button <button
:disabled="isDisabled" :disabled="isDisabled"
type="button" type="button"
class="js-vue-issue-save btn btn-save" class="js-vue-issue-save btn btn-save js-comment-button "
@click="handleUpdate()"> @click="handleUpdate()">
{{ saveButtonTitle }} {{ saveButtonTitle }}
</button> </button>
<button <button
v-if="note.resolvable" v-if="discussion.resolvable"
class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
@click.prevent="handleUpdate(true)" @click.prevent="handleUpdate(true)"
> >
{{ resolveButtonTitle }} {{ resolveButtonTitle }}
</button> </button>
<button <button
class="btn btn-cancel note-edit-cancel" class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
type="button" type="button"
@click="cancelHandler()"> @click="cancelHandler()">
Cancel Cancel
......
...@@ -20,11 +20,6 @@ export default { ...@@ -20,11 +20,6 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
actionTextHtml: {
type: String,
required: false,
default: '',
},
noteId: { noteId: {
type: Number, type: Number,
required: true, required: true,
...@@ -88,10 +83,8 @@ export default { ...@@ -88,10 +83,8 @@ export default {
<template v-if="actionText"> <template v-if="actionText">
{{ actionText }} {{ actionText }}
</template> </template>
<span <span class="system-note-message">
v-if="actionTextHtml" <slot></slot>
class="system-note-message"
v-html="actionTextHtml">
</span> </span>
<span class="system-note-separator"> <span class="system-note-separator">
&middot; &middot;
......
...@@ -11,7 +11,7 @@ export const EMOJI_THUMBSUP = 'thumbsup'; ...@@ -11,7 +11,7 @@ export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown'; export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const ISSUE_NOTEABLE_TYPE = 'issue'; export const ISSUE_NOTEABLE_TYPE = 'issue';
export const EPIC_NOTEABLE_TYPE = 'epic'; export const EPIC_NOTEABLE_TYPE = 'epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post'; export const RESOLVE_NOTE_METHOD_NAME = 'post';
export const DESCRIPTION_TYPE = 'changed the description'; export const DESCRIPTION_TYPE = 'changed the description';
......
This diff is collapsed.
...@@ -4,11 +4,11 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; ...@@ -4,11 +4,11 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default { export default {
methods: { methods: {
initAutoSave(noteableType) { initAutoSave(noteable) {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [ this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [
'Note', 'Note',
capitalizeFirstCharacter(noteableType), capitalizeFirstCharacter(noteable.noteable_type),
this.note.id, noteable.id,
]); ]);
}, },
resetAutoSave() { resetAutoSave() {
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment