Commit e13aeab5 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch 'changes_tab_vue_refactoring' into 'master'

Changes tab VUE refactoring

Closes #42882

See merge request gitlab-org/gitlab-ce!17011
parents 14e35ac9 3e66795e
......@@ -31,8 +31,10 @@ export default class Autosave {
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0);
if (field) {
field.dispatchEvent(event);
}
}
save() {
if (!this.field.length) return;
......
This diff is collapsed.
/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, max-len */
/* global CommentsStore */
/* global ResolveService */
......@@ -41,54 +40,54 @@ const ResolveBtn = Vue.extend({
required: true,
},
},
data: function () {
data() {
return {
discussions: CommentsStore.state,
loading: false
loading: false,
};
},
computed: {
discussion: function () {
discussion() {
return this.discussions[this.discussionId];
},
note: function () {
note() {
return this.discussion ? this.discussion.getNote(this.noteId) : {};
},
buttonText: function () {
buttonText() {
if (this.isResolved) {
return `Resolved by ${this.resolvedByName}`;
} else if (this.canResolve) {
return 'Mark as resolved';
} else {
return 'Unable to resolve';
}
return 'Unable to resolve';
},
isResolved: function () {
isResolved() {
if (this.note) {
return this.note.resolved;
} else {
return false;
}
return false;
},
resolvedByName: function () {
resolvedByName() {
return this.note.resolved_by;
},
},
watch: {
'discussions': {
discussions: {
handler: 'updateTooltip',
deep: true
}
deep: true,
},
},
mounted: function () {
mounted() {
$(this.$refs.button).tooltip({
container: 'body'
container: 'body',
});
},
beforeDestroy: function () {
beforeDestroy() {
CommentsStore.delete(this.discussionId, this.noteId);
},
created: function () {
created() {
CommentsStore.create({
discussionId: this.discussionId,
noteId: this.noteId,
......@@ -101,43 +100,41 @@ const ResolveBtn = Vue.extend({
});
},
methods: {
updateTooltip: function () {
updateTooltip() {
this.$nextTick(() => {
$(this.$refs.button)
.tooltip('hide')
.tooltip('_fixTitle');
});
},
resolve: function () {
resolve() {
if (!this.canResolve) return;
let promise;
this.loading = true;
if (this.isResolved) {
promise = ResolveService
.unresolve(this.noteId);
promise = ResolveService.unresolve(this.noteId);
} else {
promise = ResolveService
.resolve(this.noteId);
promise = ResolveService.resolve(this.noteId);
}
promise
.then(resp => resp.json())
.then((data) => {
.then(data => {
this.loading = false;
const resolved_by = data ? data.resolved_by : null;
const resolvedBy = data ? data.resolved_by : null;
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolvedBy);
this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus();
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
this.updateTooltip();
})
.catch(() => new Flash('An error occurred when trying to resolve a comment. Please try again.'));
}
.catch(
() => new Flash('An error occurred when trying to resolve a comment. Please try again.'),
);
},
},
});
......
/* eslint-disable func-names, comma-dangle, new-cap, no-new */
/* global ResolveCount */
/* eslint-disable func-names, new-cap */
import $ from 'jquery';
import Vue from 'vue';
......@@ -15,12 +14,13 @@ import './components/resolve_count';
import './components/resolve_discussion_btn';
import './components/diff_note_avatars';
import './components/new_issue_for_discussion';
import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils';
export default () => {
const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box');
const projectPathHolder =
document.querySelector('.merge-request') || document.querySelector('.commit-box');
const projectPath = projectPathHolder.dataset.projectPath;
const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn';
const COMPONENT_SELECTOR =
'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn';
window.gl = window.gl || {};
window.gl.diffNoteApps = {};
......@@ -28,9 +28,9 @@ export default () => {
window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath);
gl.diffNotesCompileComponents = () => {
$('diff-note-avatars').each(function () {
$('diff-note-avatars').each(function() {
const tmp = Vue.extend({
template: $(this).get(0).outerHTML
template: $(this).get(0).outerHTML,
});
const tmpApp = new tmp().$mount();
......@@ -41,12 +41,12 @@ export default () => {
});
});
const $components = $(COMPONENT_SELECTOR).filter(function () {
const $components = $(COMPONENT_SELECTOR).filter(function() {
return $(this).closest('resolve-count').length !== 1;
});
if ($components) {
$components.each(function () {
$components.each(function() {
const $this = $(this);
const noteId = $this.attr(':note-id');
const discussionId = $this.attr(':discussion-id');
......@@ -54,7 +54,7 @@ export default () => {
if ($this.is('comment-and-resolve-btn') && !discussionId) return;
const tmp = Vue.extend({
template: $this.get(0).outerHTML
template: $this.get(0).outerHTML,
});
const tmpApp = new tmp().$mount();
......@@ -69,15 +69,5 @@ export default () => {
gl.diffNotesCompileComponents();
const resolveCountAppEl = document.querySelector('#resolve-count-app');
if (!hasVueMRDiscussionsCookie() && resolveCountAppEl) {
new Vue({
el: resolveCountAppEl,
components: {
'resolve-count': ResolveCount
},
});
}
$(window).trigger('resize.nav');
};
......@@ -8,8 +8,12 @@ window.gl = window.gl || {};
class ResolveServiceClass {
constructor(root) {
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`);
this.noteResource = Vue.resource(
`${root}/notes{/noteId}/resolve?html=true`,
);
this.discussionResource = Vue.resource(
`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`,
);
}
resolve(noteId) {
......@@ -33,7 +37,7 @@ class ResolveServiceClass {
promise
.then(resp => resp.json())
.then((data) => {
.then(data => {
discussion.loading = false;
const resolvedBy = data ? data.resolved_by : null;
......@@ -45,9 +49,13 @@ class ResolveServiceClass {
if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data);
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
})
.catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
.catch(
() =>
new Flash(
'An error occurred when trying to resolve a discussion. Please try again.',
),
);
}
resolveAll(mergeRequestId, discussionId) {
......@@ -55,10 +63,13 @@ class ResolveServiceClass {
discussion.loading = true;
return this.discussionResource.save({
return this.discussionResource.save(
{
mergeRequestId,
discussionId,
}, {});
},
{},
);
}
unResolveAll(mergeRequestId, discussionId) {
......@@ -66,10 +77,13 @@ class ResolveServiceClass {
discussion.loading = true;
return this.discussionResource.delete({
return this.discussionResource.delete(
{
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 Cookies from 'js-cookie';
import axios from './axios_utils';
import { getLocationHash } from './url_utility';
import { convertToCamelCase } from './text_utility';
import { isObject } from './type_utility';
export const getPagePath = (index = 0) => $('body').attr('data-page').split(':')[index];
export const getPagePath = (index = 0) => {
const page = $('body').attr('data-page') || '';
return page.split(':')[index];
};
export const isInGroupsPage = () => getPagePath() === 'groups';
......@@ -34,17 +38,18 @@ export const checkPageAndAction = (page, action) => {
export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInEpicPage = () => checkPageAndAction('epics', 'show');
export const isInNoteablePage = () => isInIssuePage() || isInMRPage();
export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions');
export const ajaxGet = url => axios.get(url, {
export const ajaxGet = url =>
axios
.get(url, {
params: { format: 'js' },
responseType: 'text',
}).then(({ data }) => {
})
.then(({ data }) => {
$.globalEval(data);
});
});
export const rstrip = (val) => {
export const rstrip = val => {
if (val) {
return val.replace(/\s+$/, '');
}
......@@ -60,7 +65,7 @@ export const disableButtonIfEmptyField = (fieldSelector, buttonSelector, eventNa
closestSubmit.disable();
}
// eslint-disable-next-line func-names
return field.on(eventName, function () {
return field.on(eventName, function() {
if (rstrip($(this).val()) === '') {
return closestSubmit.disable();
}
......@@ -79,7 +84,7 @@ export const handleLocationHash = () => {
const target = document.getElementById(hash) || document.getElementById(`user-content-${hash}`);
const fixedTabs = document.querySelector('.js-tabs-affix');
const fixedDiffStats = document.querySelector('.js-diff-files-changed.is-stuck');
const fixedDiffStats = document.querySelector('.js-diff-files-changed');
const fixedNav = document.querySelector('.navbar-gitlab');
let adjustment = 0;
......@@ -102,7 +107,7 @@ export const handleLocationHash = () => {
// Check if element scrolled into viewport from above or below
// Courtesy http://stackoverflow.com/a/7557433/414749
export const isInViewport = (el) => {
export const isInViewport = el => {
const rect = el.getBoundingClientRect();
return (
......@@ -113,13 +118,13 @@ export const isInViewport = (el) => {
);
};
export const parseUrl = (url) => {
export const parseUrl = url => {
const parser = document.createElement('a');
parser.href = url;
return parser;
};
export const parseUrlPathname = (url) => {
export const parseUrlPathname = url => {
const parsedUrl = parseUrl(url);
// parsedUrl.pathname will return an absolute path for Firefox and a relative path for IE11
// We have to make sure we always have an absolute path.
......@@ -128,10 +133,14 @@ export const parseUrlPathname = (url) => {
// We can trust that each param has one & since values containing & will be encoded
// Remove the first character of search as it is always ?
export const getUrlParamsArray = () => window.location.search.slice(1).split('&').map((param) => {
export const getUrlParamsArray = () =>
window.location.search
.slice(1)
.split('&')
.map(param => {
const split = param.split('=');
return [decodeURI(split[0]), split[1]].join('=');
});
});
export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
......@@ -141,18 +150,28 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// 3) Middle-click or Mouse Wheel Click (e.which is 2)
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
export const scrollToElement = (element) => {
export const contentTop = () => {
const perfBar = $('#js-peek').height() || 0;
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
const diffFilesChanged = $('.js-diff-files-changed').height() || 0;
return perfBar + mrTabsHeight + headerHeight + diffFilesChanged;
};
export const scrollToElement = element => {
let $el = element;
if (!(element instanceof $)) {
$el = $(element);
}
const top = $el.offset().top;
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
return $('body, html').animate({
scrollTop: top - mrTabsHeight - headerHeight,
}, 200);
return $('body, html').animate(
{
scrollTop: top - contentTop(),
},
200,
);
};
/**
......@@ -212,7 +231,8 @@ export const insertText = (target, text) => {
};
export const nodeMatchesSelector = (node, selector) => {
const matches = Element.prototype.matches ||
const matches =
Element.prototype.matches ||
Element.prototype.matchesSelector ||
Element.prototype.mozMatchesSelector ||
Element.prototype.msMatchesSelector ||
......@@ -241,10 +261,10 @@ export const nodeMatchesSelector = (node, selector) => {
this will take in the headers from an API response and normalize them
this way we don't run into production issues when nginx gives us lowercased header keys
*/
export const normalizeHeaders = (headers) => {
export const normalizeHeaders = headers => {
const upperCaseHeaders = {};
Object.keys(headers || {}).forEach((e) => {
Object.keys(headers || {}).forEach(e => {
upperCaseHeaders[e.toUpperCase()] = headers[e];
});
......@@ -255,11 +275,11 @@ export const normalizeHeaders = (headers) => {
this will take in the getAllResponseHeaders result and normalize them
this way we don't run into production issues when nginx gives us lowercased header keys
*/
export const normalizeCRLFHeaders = (headers) => {
export const normalizeCRLFHeaders = headers => {
const headersObject = {};
const headersArray = headers.split('\n');
headersArray.forEach((header) => {
headersArray.forEach(header => {
const keyValue = header.split(': ');
headersObject[keyValue[0]] = keyValue[1];
});
......@@ -295,9 +315,7 @@ export const parseIntPagination = paginationInformation => ({
export const parseQueryStringIntoObject = (query = '') => {
if (query === '') return {};
return query
.split('&')
.reduce((acc, element) => {
return query.split('&').reduce((acc, element) => {
const val = element.split('=');
Object.assign(acc, {
[val[0]]: decodeURIComponent(val[1]),
......@@ -312,9 +330,13 @@ export const parseQueryStringIntoObject = (query = '') => {
*
* @param {Object} params
*/
export const objectToQueryString = (params = {}) => Object.keys(params).map(param => `${param}=${params[param]}`).join('&');
export const objectToQueryString = (params = {}) =>
Object.keys(params)
.map(param => `${param}=${params[param]}`)
.join('&');
export const buildUrlWithCurrentLocation = param => (param ? `${window.location.pathname}${param}` : window.location.pathname);
export const buildUrlWithCurrentLocation = param =>
(param ? `${window.location.pathname}${param}` : window.location.pathname);
/**
* Based on the current location and the string parameters provided
......@@ -322,7 +344,7 @@ export const buildUrlWithCurrentLocation = param => (param ? `${window.location.
*
* @param {String} param
*/
export const historyPushState = (newUrl) => {
export const historyPushState = newUrl => {
window.history.pushState({}, document.title, newUrl);
};
......@@ -371,7 +393,7 @@ export const backOff = (fn, timeout = 60000) => {
let timeElapsed = 0;
return new Promise((resolve, reject) => {
const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg));
const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg));
const next = () => {
if (timeElapsed < timeout) {
......@@ -447,7 +469,8 @@ export const resetFavicon = () => {
};
export const setCiStatusFavicon = pageUrl =>
axios.get(pageUrl)
axios
.get(pageUrl)
.then(({ data }) => {
if (data && data.favicon) {
return setFaviconOverlay(data.favicon);
......@@ -469,26 +492,36 @@ export const spriteIcon = (icon, className = '') => {
* Reasoning for this method is to ensure consistent property
* naming conventions across JS code.
*/
export const convertObjectPropsToCamelCase = (obj = {}) => {
export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => {
if (obj === null) {
return {};
}
const initial = Array.isArray(obj) ? [] : {};
return Object.keys(obj).reduce((acc, prop) => {
const result = acc;
const val = obj[prop];
if (options.deep && (isObject(val) || Array.isArray(val))) {
result[convertToCamelCase(prop)] = convertObjectPropsToCamelCase(val, options);
} else {
result[convertToCamelCase(prop)] = obj[prop];
}
return acc;
}, {});
}, initial);
};
export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
export const imagePath = imgUrl =>
`${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => {
// Click a .js-select-on-focus field, select the contents
// Prevent a mouseup event from deselecting the input
$(selector).on('focusin', function selectOnFocusCallback() {
$(this).select().one('mouseup', (e) => {
$(this)
.select()
.one('mouseup', e => {
e.preventDefault();
});
});
......
import $ from 'jquery';
import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie } from './common_utils';
const isVueMRDiscussions = () => isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils';
export const addClassIfElementExists = (element, className) => {
if (element) {
......@@ -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();
*/
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
*
......@@ -98,3 +106,16 @@ export const convertToSentenceCase = string => {
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() {
if (window.mrTabs) {
window.mrTabs.unbindEvents();
}
window.mrTabs = new MergeRequestTabs(this.opts);
};
......
/* eslint-disable no-new, class-methods-use-this */
import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie';
import axios from './lib/utils/axios_utils';
import flash from './flash';
......@@ -8,6 +9,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
import initChangesDropdown from './init_changes_dropdown';
import bp from './breakpoints';
import { parseUrlPathname, handleLocationHash, isMetaClick } from './lib/utils/common_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { getLocationHash } from './lib/utils/url_utility';
import initDiscussionTab from './image_diff/init_discussion_tab';
import Diff from './diff';
......@@ -70,11 +72,13 @@ export default class MergeRequestTabs {
const navbar = document.querySelector('.navbar-gitlab');
const peek = document.getElementById('js-peek');
const paddingTop = 16;
this.commitsTab = document.querySelector('.tab-content .commits.tab-pane');
this.diffsLoaded = false;
this.pipelinesLoaded = false;
this.commitsLoaded = false;
this.fixedLayoutPref = null;
this.eventHub = new Vue();
this.setUrl = setUrl !== undefined ? setUrl : true;
this.setCurrentAction = this.setCurrentAction.bind(this);
......@@ -149,7 +153,9 @@ export default class MergeRequestTabs {
this.resetViewContainer();
this.destroyPipelinesView();
} else if (this.isDiffAction(action)) {
if (!isInVueNoteablePage()) {
this.loadDiff($target.attr('href'));
}
if (bp.getBreakpointSize() !== 'lg') {
this.shrinkView();
}
......@@ -157,6 +163,7 @@ export default class MergeRequestTabs {
this.expandViewContainer();
}
this.destroyPipelinesView();
this.commitsTab.classList.remove('active');
} else if (action === 'pipelines') {
this.resetViewContainer();
this.mountPipelinesView();
......@@ -172,6 +179,8 @@ export default class MergeRequestTabs {
if (this.setUrl) {
this.setCurrentAction(action);
}
this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction());
}
scrollToElement(container) {
......
import $ from 'jquery';
import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import initDiffsApp from '../diffs';
import notesApp from '../notes/components/notes_app.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() {
const mrShowNode = document.querySelector('.merge-request');
// eslint-disable-next-line no-new
new MergeRequest({
action: mrShowNode.dataset.mrAction,
});
// eslint-disable-next-line no-new
new Vue({
el: '#js-vue-mr-discussions',
name: 'MergeRequestDiscussions',
components: {
notesApp,
},
store,
data() {
const notesDataset = document.getElementById('js-vue-mr-discussions')
.dataset;
const notesDataset = document.getElementById('js-vue-mr-discussions').dataset;
const noteableData = JSON.parse(notesDataset.noteableData);
noteableData.noteableType = notesDataset.noteableType;
noteableData.targetType = notesDataset.targetType;
return {
noteableData,
......@@ -22,12 +34,42 @@ export default function initMrNotes() {
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) {
return createElement('notes-app', {
props: {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
shouldShow: this.activeTab === 'show',
},
});
},
......@@ -36,6 +78,7 @@ export default function initMrNotes() {
// eslint-disable-next-line no-new
new Vue({
el: '#js-vue-discussion-counter',
name: 'DiscussionCounter',
components: {
discussionCounter,
},
......@@ -44,4 +87,6 @@ export default function initMrNotes() {
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';
import Flash from '../../flash';
import Autosave from '../../autosave';
import TaskList from '../../task_list';
import {
capitalizeFirstCharacter,
convertToCamelCase,
} from '../../lib/utils/text_utility';
import { capitalizeFirstCharacter, convertToCamelCase, splitCamelCase } from '../../lib/utils/text_utility';
import * as constants from '../constants';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
......@@ -56,21 +53,23 @@ export default {
]),
...mapState(['isToggleStateButtonLoading']),
noteableDisplayName() {
return this.noteableType.replace(/_/g, ' ');
return splitCamelCase(this.noteableType).toLowerCase();
},
isLoggedIn() {
return this.getUserData.id;
},
commentButtonTitle() {
return this.noteType === constants.COMMENT
? 'Comment'
: 'Start discussion';
return this.noteType === constants.COMMENT ? '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() {
return (
this.openState === constants.OPENED ||
this.openState === constants.REOPENED
);
return this.openState === constants.OPENED || this.openState === constants.REOPENED;
},
canCreateNote() {
return this.getNoteableData.current_user.can_create_note;
......@@ -117,6 +116,9 @@ export default {
endpoint() {
return this.getNoteableData.create_note_path;
},
issuableTypeTitle() {
return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE ? 'merge request' : 'issue';
},
},
watch: {
note(newNote) {
......@@ -129,9 +131,7 @@ export default {
mounted() {
// jQuery is needed here because it is a custom event being dispatched with jQuery.
$(document).on('issuable:change', (e, isClosed) => {
this.toggleIssueLocalState(
isClosed ? constants.CLOSED : constants.REOPENED,
);
this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED);
});
this.initAutoSave();
......@@ -168,6 +168,7 @@ export default {
noteable_id: this.getNoteableData.id,
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.`;
this.toggleStateButtonLoading(false);
Flash(
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 },
),
);
......@@ -242,9 +241,7 @@ Please check your network connection and try again.`;
this.toggleStateButtonLoading(false);
Flash(
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 },
),
);
......@@ -281,9 +278,7 @@ Please check your network connection and try again.`;
},
initAutoSave() {
if (this.isLoggedIn) {
const noteableType = capitalizeFirstCharacter(
convertToCamelCase(this.noteableType),
);
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
this.autosave = new Autosave($(this.$refs.textarea), [
'Note',
......@@ -312,8 +307,8 @@ Please check your network connection and try again.`;
<div>
<note-signed-out-widget v-if="!isLoggedIn" />
<discussion-locked-widget
v-else-if="isLocked(getNoteableData) && !canCreateNote"
issuable-type="issue"
v-else-if="!canCreateNote"
:issuable-type="issuableTypeTitle"
/>
<ul
v-else-if="canCreateNote"
......@@ -357,7 +352,7 @@ Please check your network connection and try again.`;
v-model="note"
:disabled="isSubmitting"
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"
data-supports-quick-actions="true"
aria-label="Description"
......@@ -423,7 +418,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
<div class="description">
<strong>Start discussion</strong>
<p>
Discuss a specific suggestion or question.
{{ startDiscussionDescription }}
</p>
</div>
</button>
......
<script>
import $ from 'jquery';
import syntaxHighlight from '~/syntax_highlight';
import { mapState, mapActions } from 'vuex';
import imageDiffHelper from '~/image_diff/helpers/index';
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 {
components: {
DiffFileHeader,
SkeletonLoadingContainer,
},
props: {
discussion: {
......@@ -15,7 +17,24 @@ export default {
required: true,
},
},
data() {
return {
error: false,
};
},
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() {
return !this.diffFile.text;
},
......@@ -23,36 +42,46 @@ export default {
const { text } = this.diffFile;
return text ? 'text-file' : 'js-image-file';
},
diffRows() {
return $(this.discussion.truncatedDiffLines);
},
diffFile() {
return convertObjectPropsToCamelCase(this.discussion.diffFile);
return convertObjectPropsToCamelCase(this.discussion.diffFile, { deep: true });
},
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() {
if (this.isImageDiff) {
const canCreateNote = false;
const renderCommentBadge = true;
imageDiffHelper.initImageDiff(
this.$refs.fileHolder,
canCreateNote,
renderCommentBadge,
);
} else {
const fileHolder = $(this.$refs.fileHolder);
this.$nextTick(() => {
syntaxHighlight(fileHolder);
});
imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge);
} else if (!this.hasTruncatedDiffLines) {
this.fetchDiff();
}
},
methods: {
...mapActions(['fetchDiscussionDiffLines']),
rowTag(html) {
return html.outerHTML ? 'tr' : 'template';
},
fetchDiff() {
this.error = false;
this.fetchDiscussionDiffLines(this.discussion)
.then(this.highlight)
.catch(() => {
this.error = true;
});
},
},
};
</script>
......@@ -63,23 +92,59 @@ export default {
:class="diffFileClass"
class="diff-file file-holder"
>
<div class="js-file-title file-title file-title-flex-parent">
<diff-file-header
:diff-file="diffFile"
:current-user="currentUser"
:discussions-expanded="isDiscussionsExpanded"
:expanded="!isCollapsed"
/>
</div>
<div
v-if="diffFile.text"
class="diff-content code js-syntax-highlight"
:class="userColorScheme"
class="diff-content code"
>
<table>
<component
v-for="(html, index) in diffRows"
:is="rowTag(html)"
:class="html.className"
:key="index"
v-html="html.outerHTML"
/>
<tr
v-for="line in normalizedDiffLines"
:key="line.lineCode"
class="line_holder"
>
<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">
<td
class="notes_line"
......
<script>
import { mapGetters } from 'vuex';
import { mapActions, mapGetters } from 'vuex';
import resolveSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg';
......@@ -48,10 +48,14 @@ export default {
this.nextDiscussionSvg = nextDiscussionSvg;
},
methods: {
jumpToFirstDiscussion() {
const el = document.querySelector(
`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`,
);
...mapActions(['expandDiscussion']),
jumpToFirstUnresolvedDiscussion() {
const discussionId = this.firstUnresolvedDiscussionId;
if (!discussionId) {
return;
}
const el = document.querySelector(`[data-discussion-id="${discussionId}"]`);
const activeTab = window.mrTabs.currentAction;
if (activeTab === 'commits' || activeTab === 'pipelines') {
......@@ -59,6 +63,7 @@ export default {
}
if (el) {
this.expandDiscussion({ discussionId });
scrollToElement(el);
}
},
......@@ -97,7 +102,7 @@ export default {
<a
v-tooltip
:href="resolveAllDiscussionsIssuePath"
title="Resolve all discussions in new issue"
:title="s__('Resolve all discussions in new issue')"
data-container="body"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn">
<span v-html="mrIssueSvg"></span>
......@@ -112,7 +117,7 @@ export default {
title="Jump to first unresolved discussion"
data-container="body"
class="btn btn-default discussion-next-btn"
@click="jumpToFirstDiscussion">
@click="jumpToFirstUnresolvedDiscussion">
<span v-html="nextDiscussionSvg"></span>
</button>
</div>
......
......@@ -27,6 +27,10 @@ export default {
type: Number,
required: true,
},
noteUrl: {
type: String,
required: true,
},
accessLevel: {
type: String,
required: false,
......@@ -48,6 +52,11 @@ export default {
type: Boolean,
required: true,
},
canResolve: {
type: Boolean,
required: false,
default: false,
},
resolvable: {
type: Boolean,
required: false,
......@@ -125,7 +134,7 @@ export default {
{{ accessLevel }}
</span>
<div
v-if="resolvable"
v-if="canResolve"
class="note-actions-item">
<button
v-tooltip
......@@ -216,6 +225,15 @@ export default {
Report as abuse
</a>
</li>
<li>
<button
:data-clipboard-text="noteUrl"
type="button"
css-class="btn-default btn-transparent"
>
Copy link
</button>
</li>
<li v-if="canEdit">
<button
class="btn btn-transparent js-note-delete js-note-delete"
......
......@@ -40,7 +40,7 @@ export default {
this.initTaskList();
if (this.isEditing) {
this.initAutoSave(this.note.noteable_type);
this.initAutoSave(this.note);
}
},
updated() {
......@@ -49,7 +49,7 @@ export default {
if (this.isEditing) {
if (!this.autosave) {
this.initAutoSave(this.note.noteable_type);
this.initAutoSave(this.note);
} else {
this.setAutoSave();
}
......@@ -72,7 +72,7 @@ export default {
this.$emit('handleFormUpdate', note, parentElement, callback);
},
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelFormEdition', shouldConfirm, isDirty);
this.$emit('cancelForm', shouldConfirm, isDirty);
},
},
};
......@@ -93,7 +93,7 @@ export default {
:note-body="noteBody"
:note-id="note.id"
@handleFormUpdate="handleFormUpdate"
@cancelFormEdition="formCancelHandler"
@cancelForm="formCancelHandler"
/>
<textarea
v-if="canEdit"
......@@ -105,6 +105,7 @@ export default {
:edited-at="note.last_edited_at"
:edited-by="note.last_edited_by"
action-text="Edited"
class="note_edited_ago"
/>
<note-awards-list
v-if="note.award_emoji.length"
......
......@@ -11,14 +11,20 @@ export default {
type: String,
required: true,
},
actionDetailText: {
type: String,
required: false,
default: '',
},
editedAt: {
type: String,
required: true,
required: false,
default: null,
},
editedBy: {
type: Object,
required: false,
default: () => ({}),
default: null,
},
className: {
type: String,
......@@ -33,13 +39,14 @@ export default {
<div :class="className">
{{ actionText }}
<template v-if="editedBy">
{{ s__('ByAuthor|by') }}
by
<a
:href="editedBy.path"
class="js-vue-author author_link">
{{ editedBy.name }}
</a>
</template>
{{ actionDetailText }}
<time-ago-tooltip
:time="editedAt"
tooltip-placement="bottom"
......
......@@ -29,7 +29,7 @@ export default {
required: false,
default: 'Save comment',
},
note: {
discussion: {
type: Object,
required: false,
default: () => ({}),
......@@ -38,6 +38,11 @@ export default {
type: Boolean,
required: true,
},
lineCode: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -66,9 +71,7 @@ export default {
return this.getNotesDataByProp('markdownDocsPath');
},
quickActionsDocsPath() {
return !this.isEditing
? this.getNotesDataByProp('quickActionsDocsPath')
: undefined;
return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined;
},
currentUserId() {
return this.getUserDataByProp('id');
......@@ -95,24 +98,17 @@ export default {
const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true;
this.$emit(
'handleFormUpdate',
this.updatedNoteBody,
this.$refs.editNoteForm,
() => {
this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
this.isSubmitting = false;
if (shouldResolve) {
this.resolveHandler(beforeSubmitDiscussionState);
}
},
);
});
},
editMyLastNote() {
if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(
this.updatedNoteBody,
);
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', {
......@@ -123,11 +119,7 @@ export default {
},
cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed
this.$emit(
'cancelFormEdition',
shouldConfirm,
this.noteBody !== this.updatedNoteBody,
);
this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
},
},
};
......@@ -136,7 +128,7 @@ export default {
<template>
<div
ref="editNoteForm"
class="note-edit-form current-note-edit-form">
class="note-edit-form current-note-edit-form js-discussion-note-form">
<div
v-if="conflictWhileEditing"
class="js-conflict-edit-warning alert alert-danger">
......@@ -150,7 +142,10 @@ export default {
to ensure information is not lost.
</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
v-if="hasWarning(getNoteableData)"
......@@ -170,7 +165,7 @@ export default {
:data-supports-quick-actions="!isEditing"
v-model="updatedNoteBody"
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"
aria-label="Description"
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"
<button
:disabled="isDisabled"
type="button"
class="js-vue-issue-save btn btn-save"
class="js-vue-issue-save btn btn-save js-comment-button "
@click="handleUpdate()">
{{ saveButtonTitle }}
</button>
<button
v-if="note.resolvable"
v-if="discussion.resolvable"
class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
@click.prevent="handleUpdate(true)"
>
{{ resolveButtonTitle }}
</button>
<button
class="btn btn-cancel note-edit-cancel"
class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
type="button"
@click="cancelHandler()">
Cancel
......
......@@ -20,11 +20,6 @@ export default {
required: false,
default: '',
},
actionTextHtml: {
type: String,
required: false,
default: '',
},
noteId: {
type: Number,
required: true,
......@@ -88,10 +83,8 @@ export default {
<template v-if="actionText">
{{ actionText }}
</template>
<span
v-if="actionTextHtml"
class="system-note-message"
v-html="actionTextHtml">
<span class="system-note-message">
<slot></slot>
</span>
<span class="system-note-separator">
&middot;
......
......@@ -11,7 +11,7 @@ export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const ISSUE_NOTEABLE_TYPE = 'issue';
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 RESOLVE_NOTE_METHOD_NAME = 'post';
export const DESCRIPTION_TYPE = 'changed the description';
......
import Vue from 'vue';
import notesApp from './components/notes_app.vue';
import createStore from './stores';
document.addEventListener(
'DOMContentLoaded',
() =>
new Vue({
document.addEventListener('DOMContentLoaded', () => {
const store = createStore();
return new Vue({
el: '#js-vue-notes',
components: {
notesApp,
},
store,
data() {
const notesDataset = document.getElementById('js-vue-notes').dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData);
......@@ -16,6 +18,7 @@ document.addEventListener(
let currentUserData = {};
noteableData.noteableType = notesDataset.noteableType;
noteableData.targetType = notesDataset.targetType;
if (parsedUserData) {
currentUserData = {
......@@ -42,5 +45,5 @@ document.addEventListener(
},
});
},
}),
);
});
});
......@@ -4,11 +4,11 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default {
methods: {
initAutoSave(noteableType) {
initAutoSave(noteable) {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [
'Note',
capitalizeFirstCharacter(noteableType),
this.note.id,
capitalizeFirstCharacter(noteable.noteable_type),
noteable.id,
]);
},
resetAutoSave() {
......
import * as constants from '../constants';
export default {
props: {
note: {
type: Object,
required: true,
},
},
computed: {
noteableType() {
return constants.NOTEABLE_TYPE_MAPPING[this.note.noteable_type];
const note = this.discussion ? this.discussion.notes[0] : this.note;
return constants.NOTEABLE_TYPE_MAPPING[note.noteable_type];
},
},
};
......@@ -5,7 +5,7 @@ import * as constants from '../constants';
Vue.use(VueResource);
export default {
fetchNotes(endpoint) {
fetchDiscussions(endpoint) {
return Vue.http.get(endpoint);
},
deleteNote(endpoint) {
......@@ -22,9 +22,7 @@ export default {
},
toggleResolveNote(endpoint, isResolved) {
const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants;
const method = isResolved
? UNRESOLVE_NOTE_METHOD_NAME
: RESOLVE_NOTE_METHOD_NAME;
const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME;
return Vue.http[method](endpoint);
},
......
import * as actions from '../actions';
import * as getters from '../getters';
import mutations from '../mutations';
export default {
state: {
discussions: [],
targetNoteHash: null,
lastFetchedAt: null,
// View layer
isToggleStateButtonLoading: false,
// holds endpoints and permissions provided through haml
notesData: {
markdownDocsPath: '',
},
userData: {},
noteableData: {
current_user: {},
},
},
actions,
getters,
mutations,
};
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