summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/autosave.js4
-rw-r--r--app/assets/javascripts/awards_handler.js8
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js61
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js40
-rw-r--r--app/assets/javascripts/diffs/components/app.vue83
-rw-r--r--app/assets/javascripts/diffs/components/changed_files.vue259
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue38
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue45
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue34
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue75
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue78
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue85
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue107
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue181
-rw-r--r--app/assets/javascripts/diffs/constants.js13
-rw-r--r--app/assets/javascripts/diffs/index.js27
-rw-r--r--app/assets/javascripts/diffs/mixins/diff_content.js76
-rw-r--r--app/assets/javascripts/diffs/store/actions.js49
-rw-r--r--app/assets/javascripts/diffs/store/getters.js10
-rw-r--r--app/assets/javascripts/diffs/store/index.js17
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js6
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js112
-rw-r--r--app/assets/javascripts/diffs/store/utils.js83
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js16
-rw-r--r--app/assets/javascripts/merge_request.js1
-rw-r--r--app/assets/javascripts/merge_request_tabs.js13
-rw-r--r--app/assets/javascripts/mr_notes/index.js67
-rw-r--r--app/assets/javascripts/mr_notes/stores/actions.js7
-rw-r--r--app/assets/javascripts/mr_notes/stores/getters.js5
-rw-r--r--app/assets/javascripts/mr_notes/stores/index.js12
-rw-r--r--app/assets/javascripts/mr_notes/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/mr_notes/stores/mutations.js7
-rw-r--r--app/assets/javascripts/notes.js14
-rw-r--r--app/assets/javascripts/notes/components/diff_file_header.vue39
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue43
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue5
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue30
-rw-r--r--app/assets/javascripts/notes/index.js12
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js6
-rw-r--r--app/assets/javascripts/notes/stores/actions.js8
-rw-r--r--app/assets/javascripts/notes/stores/getters.js27
-rw-r--r--app/assets/javascripts/notes/stores/index.js8
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js10
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js17
-rw-r--r--app/assets/stylesheets/pages/notes.scss32
-rw-r--r--app/views/projects/merge_requests/show.html.haml6
-rw-r--r--spec/javascripts/diffs/components/changed_files_spec.js69
-rw-r--r--spec/javascripts/helpers/index.js9
-rw-r--r--spec/javascripts/lib/utils/common_utils_spec.js70
52 files changed, 1896 insertions, 139 deletions
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 0da872db7e5..3153f5156db 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -31,7 +31,9 @@ export default class Autosave {
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0);
- field.dispatchEvent(event);
+ if (field) {
+ field.dispatchEvent(event);
+ }
}
save() {
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 6da33a26e58..06e2ac66152 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -4,7 +4,7 @@ import $ from 'jquery';
import _ from 'underscore';
import Cookies from 'js-cookie';
import { __ } from './locale';
-import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils';
+import { isInIssuePage, isInMRPage, updateTooltipTitle } from './lib/utils/common_utils';
import flash from './flash';
import axios from './lib/utils/axios_utils';
@@ -295,12 +295,8 @@ class AwardsHandler {
}
}
- isVueMRDiscussions() {
- return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
- }
-
isInVueNoteablePage() {
- return isInIssuePage() || this.isVueMRDiscussions();
+ return isInIssuePage() || isInMRPage();
}
getVotesBlock() {
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js
index df4c72ba0ed..08e3a6302cf 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js
@@ -1,4 +1,3 @@
-/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, max-len */
/* global CommentsStore */
/* global ResolveService */
@@ -17,26 +16,26 @@ const ResolveBtn = Vue.extend({
authorAvatar: String,
noteTruncated: String,
},
- data: function () {
+ data: function() {
return {
discussions: CommentsStore.state,
- loading: false
+ loading: false,
};
},
watch: {
- 'discussions': {
+ discussions: {
handler: 'updateTooltip',
- deep: true
- }
+ deep: true,
+ },
},
computed: {
- discussion: function () {
+ discussion: function() {
return this.discussions[this.discussionId];
},
- note: function () {
+ note: function() {
return this.discussion ? this.discussion.getNote(this.noteId) : {};
},
- buttonText: function () {
+ buttonText: function() {
if (this.isResolved) {
return `Resolved by ${this.resolvedByName}`;
} else if (this.canResolve) {
@@ -45,65 +44,71 @@ const ResolveBtn = Vue.extend({
return 'Unable to resolve';
}
},
- isResolved: function () {
+ isResolved: function() {
if (this.note) {
return this.note.resolved;
} else {
return false;
}
},
- resolvedByName: function () {
+ resolvedByName: function() {
return this.note.resolved_by;
},
},
methods: {
- updateTooltip: function () {
+ updateTooltip: function() {
this.$nextTick(() => {
$(this.$refs.button)
.tooltip('hide')
.tooltip('fixTitle');
});
},
- resolve: function () {
+ resolve: function() {
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;
- CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
+ CommentsStore.update(
+ this.discussionId,
+ this.noteId,
+ !this.isResolved,
+ resolved_by,
+ );
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.',
+ ),
+ );
+ },
},
- mounted: function () {
+ mounted: function() {
$(this.$refs.button).tooltip({
- container: 'body'
+ container: 'body',
});
},
- beforeDestroy: function () {
+ beforeDestroy: function() {
CommentsStore.delete(this.discussionId, this.noteId);
},
- created: function () {
+ created: function() {
CommentsStore.create({
discussionId: this.discussionId,
noteId: this.noteId,
@@ -114,7 +119,7 @@ const ResolveBtn = Vue.extend({
authorAvatar: this.authorAvatar,
noteTruncated: this.noteTruncated,
});
- }
+ },
});
Vue.component('resolve-btn', ResolveBtn);
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
index d16f9297de1..0b3568e432d 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -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({
- mergeRequestId,
- discussionId,
- }, {});
+ return this.discussionResource.save(
+ {
+ mergeRequestId,
+ discussionId,
+ },
+ {},
+ );
}
unResolveAll(mergeRequestId, discussionId) {
@@ -66,10 +77,13 @@ class ResolveServiceClass {
discussion.loading = true;
- return this.discussionResource.delete({
- mergeRequestId,
- discussionId,
- }, {});
+ return this.discussionResource.delete(
+ {
+ mergeRequestId,
+ discussionId,
+ },
+ {},
+ );
}
}
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
new file mode 100644
index 00000000000..fde261a4aa7
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -0,0 +1,83 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+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';
+
+export default {
+ name: 'DiffsApp',
+ components: {
+ loadingIcon,
+ compareVersions,
+ changedFiles,
+ diffFile,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ shouldShow: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ activeFile: '',
+ };
+ },
+ computed: {
+ ...mapState({
+ isLoading: state => state.diffs.isLoading,
+ diffFiles: state => state.diffs.diffFiles,
+ }),
+ },
+ mounted() {
+ this.setEndpoint(this.endpoint);
+ this.fetchDiffFiles(); // TODO: @fatihacet Error handling
+ },
+ methods: {
+ ...mapActions(['setEndpoint', 'fetchDiffFiles']),
+ setActive(filePath) {
+ this.activeFile = filePath;
+ },
+ unsetActive(filePath) {
+ if (this.activeFile === filePath) {
+ this.activeFile = '';
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="shouldShow">
+ <loading-icon
+ v-if="isLoading"
+ size="3"
+ />
+ <div
+ v-else
+ id="diffs"
+ class="diffs tab-pane"
+ >
+ <compare-versions />
+ <changed-files
+ :diff-files="diffFiles"
+ :active-file="activeFile"
+ />
+ <div class="files">
+ <diff-file
+ @setActive="setActive(file.filePath)"
+ @unsetActive="unsetActive(file.filePath)"
+ v-for="file in diffFiles"
+ :key="file.newPath"
+ :file="file"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/changed_files.vue b/app/assets/javascripts/diffs/components/changed_files.vue
new file mode 100644
index 00000000000..4bab49bfca5
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/changed_files.vue
@@ -0,0 +1,259 @@
+<script>
+import { mapGetters, mapActions } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ },
+ props: {
+ diffFiles: {
+ type: Array,
+ required: true,
+ },
+ activeFile: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ searchText: '',
+ isStuck: false,
+ showCurrentDiffTitle: false,
+ };
+ },
+ computed: {
+ ...mapGetters(['isInlineView', 'isParallelView']),
+ sumAddedLines() {
+ return this.sumValues('addedLines');
+ },
+ sumRemovedLines() {
+ return this.sumValues('removedLines');
+ },
+ filteredDiffFiles() {
+ return this.diffFiles.filter(file =>
+ file.filePath.toLowerCase().includes(this.searchText.toLowerCase()),
+ );
+ },
+ stickyClass() {
+ return this.isStuck ? 'is-stuck' : '';
+ },
+ },
+ mounted() {
+ if (
+ typeof CSS === 'undefined' ||
+ !CSS.supports('(position: -webkit-sticky) or (position: sticky)')
+ )
+ return;
+
+ document.addEventListener('scroll', this.handleScroll.bind(this), {
+ passive: true,
+ });
+ },
+ methods: {
+ ...mapActions(['setDiffViewType']),
+ handleScroll() {
+ if (!this.$refs.stickyBar) return;
+
+ const barPosition = this.$refs.stickyBar.offsetTop;
+ const scrollPosition = window.scrollY;
+
+ const top = Math.floor(barPosition - scrollPosition);
+
+ this.isStuck = top < 112;
+ this.showCurrentDiffTitle = top < 0;
+ },
+ sumValues(key) {
+ return this.diffFiles.reduce((total, file) => total + file[key], 0);
+ },
+ 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;
+
+ return path.length > maxLength ? `...${path.slice(0, maxLength)}` : path;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="diffFiles.length > 0"
+ ref="stickyBar"
+ class="content-block oneline-block diff-files-changed diff-files-changed-merge-request
+ files-changed js-diff-files-changed"
+ :class="stickyClass"
+ >
+ <div class="files-changed-inner">
+ <div
+ v-show="!showCurrentDiffTitle"
+ class="inline-parallel-buttons hidden-xs hidden-sm"
+ >
+ <a
+ class="hidden-xs btn btn-default"
+ href="/fatihacet/test/merge_requests/5/diffs?w=1&TODO"
+ >
+ {{ __('Hide whitespace changes') }}
+ </a>
+ <div class="btn-group">
+ <a
+ @click.prevent="setDiffViewType()"
+ :class="{ active: isInlineView }"
+ id="inline-diff-btn"
+ class="btn"
+ data-view-type="inline"
+ href="#"
+ >
+ {{ __('Inline') }}
+ </a>
+ <a
+ @click.prevent="setDiffViewType(true)"
+ :class="{ active: isParallelView }"
+ id="parallel-diff-btn"
+ class="btn"
+ data-view-type="parallel"
+ href="#"
+ >
+ {{ __('Side-by-side') }}
+ </a>
+ </div>
+ </div>
+
+ <div class="commit-stat-summary dropdown">
+ Showing
+ <button
+ class="diff-stats-summary-toggler js-diff-stats-dropdown"
+ data-toggle="dropdown"
+ type="button"
+ aria-expanded="false"
+ >
+ <span>
+ {{ n__('%d changed file', '%d changed files', diffFiles.length) }}
+ </span>
+ <icon
+ name="chevron-down"
+ :size="8"
+ />
+ </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"
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-times dropdown-input-search"
+ @click="searchText = ''"
+ ></i>
+ </div>
+ <ul>
+ <li
+ v-for="diffFile in filteredDiffFiles"
+ :key="diffFile.name"
+ >
+ <a
+ class="diff-changed-file"
+ :href="`#${diffFile.fileHash}`"
+ :title="diffFile.newPath"
+ >
+ <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.blobName"
+ class="diff-changed-file-name"
+ >
+ {{ diffFile.blobName }}
+ </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.blobPath) }}
+ </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 href="javascript:void(0)">
+ No files found
+ </a>
+ </li>
+ </ul>
+ </div>
+
+ <span
+ v-show="!isStuck"
+ class="diff-stats-additions-deletions-expanded"
+ id="diff-stats"
+ >
+ with
+ <strong class="cgreen">
+ {{ n__('%d addition', '%d additions', sumAddedLines) }}
+ </strong>
+ and
+ <strong class="cred">
+ {{ n__('%d deletion', '%d deletions', sumRemovedLines) }}
+ </strong>
+ </span>
+
+ <span
+ v-show="activeFile"
+ class="prepend-left-5"
+ >
+ {{ truncatedDiffPath(activeFile) }}
+ </span>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
new file mode 100644
index 00000000000..3a1a1251add
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -0,0 +1,38 @@
+<template>
+ <div class="mr-version-controls">
+ <div class="mr-version-menus-container content-block">
+ Changes between
+ <span class="dropdown inline mr-version-dropdown">
+ <a
+ class="dropdown-toggle btn btn-default"
+ data-toggle="dropdown"
+ aria-expanded="false"
+ >
+ <span>
+ latest version
+ </span>
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-caret-down"
+ ></i>
+ </a>
+ </span>
+ and
+ <span class="dropdown inline mr-version-compare-dropdown">
+ <a
+ class="btn btn-default dropdown-toggle"
+ data-toggle="dropdown"
+ aria-expanded="false"
+ >
+ <span class="ref-name">master</span>
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-caret-down"
+ ></i>
+ </a>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
new file mode 100644
index 00000000000..6313ee874b0
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -0,0 +1,45 @@
+<script>
+import { mapState } from 'vuex';
+import inlineDiffView from './inline_diff_view.vue';
+import parallelDiffView from './parallel_diff_view.vue';
+import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
+
+export default {
+ components: {
+ inlineDiffView,
+ parallelDiffView,
+ },
+ props: {
+ diffFile: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState({
+ diffViewType: state => state.diffs.diffViewType,
+ }),
+ },
+ created() {
+ this.INLINE_DIFF_VIEW_TYPE = INLINE_DIFF_VIEW_TYPE;
+ this.PARALLEL_DIFF_VIEW_TYPE = PARALLEL_DIFF_VIEW_TYPE;
+ },
+};
+</script>
+
+<template>
+ <div class="diff-content">
+ <div class="diff-viewer">
+ <inline-diff-view
+ v-if="diffViewType === INLINE_DIFF_VIEW_TYPE"
+ :diff-file="diffFile"
+ :diff-lines="diffFile.highlightedDiffLines || []"
+ />
+ <parallel-diff-view
+ v-if="diffViewType === PARALLEL_DIFF_VIEW_TYPE"
+ :diff-file="diffFile"
+ :diff-lines="diffFile.parallelDiffLines || []"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
new file mode 100644
index 00000000000..18377978593
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -0,0 +1,34 @@
+<script>
+import noteableDiscussion from '../../notes/components/noteable_discussion.vue';
+
+export default {
+ components: {
+ noteableDiscussion,
+ },
+ props: {
+ notes: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-for="notesArr in notes"
+ :key="notesArr.id"
+ class="discussion-notes diff-discussions"
+ >
+ <ul class="notes">
+ <noteable-discussion
+ :note="notesArr"
+ :render-header="false"
+ :render-diff-file="false"
+ :always-expanded="true"
+ />
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
new file mode 100644
index 00000000000..a884594c202
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -0,0 +1,75 @@
+<script>
+import diffFileHeader from '../../notes/components/diff_file_header.vue';
+import diffContent from './diff_content.vue';
+
+export default {
+ components: {
+ diffFileHeader,
+ diffContent,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isExpanded: true,
+ };
+ },
+ mounted() {
+ document.addEventListener('scroll', () => {
+ const { top, bottom } = this.$el.getBoundingClientRect();
+
+ const topOfFixedHeader = 100;
+ const bottomOfFixedHeader = 120;
+
+ if (top < topOfFixedHeader && bottom > bottomOfFixedHeader) {
+ this.$emit('setActive');
+ }
+
+ if (top > bottomOfFixedHeader || bottom < bottomOfFixedHeader) {
+ this.$emit('unsetActive');
+ }
+ });
+ },
+ methods: {
+ handleToggle() {
+ this.isExpanded = !this.isExpanded;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="diff-file file-holder"
+ :id="file.fileHash"
+ >
+ <diff-file-header
+ :diff-file="file"
+ :collapsible="true"
+ :add-merge-request-buttons="true"
+ @toggleFile="handleToggle"
+ class="js-file-title file-title"
+ />
+ <diff-content
+ v-if="isExpanded"
+ :diff-file="file"
+ />
+ <div
+ v-else
+ class="nothing-here-block diff-collapsed"
+ >
+ This diff is collapsed.
+ <a
+ @click.prevent="handleToggle"
+ class="click-to-expand"
+ href="#"
+ >
+ Click to expand it.
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
new file mode 100644
index 00000000000..d8c57b8a629
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
@@ -0,0 +1,78 @@
+<script>
+import { MATCH_LINE_TYPE } from '../constants';
+
+export default {
+ props: {
+ 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: '',
+ },
+ showCommentButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ isMatchLine() {
+ return this.lineType === MATCH_LINE_TYPE;
+ },
+ getLineHref() {
+ return `#${this.lineCode}`;
+ },
+ },
+ methods: {
+ handleCommentButton() {
+ this.$emit('showCommentForm', {
+ lineCode: this.lineCode,
+ linePosition: this.linePosition,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <span v-if="isMatchLine">...</span>
+ <template
+ v-else
+ >
+ <button
+ v-if="showCommentButton"
+ @click="handleCommentButton"
+ type="button"
+ class="add-diff-note js-add-diff-note-button"
+ title="Add a comment to this line"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-comment-o"
+ >
+ </i>
+ </button>
+ <a
+ v-if="lineNumber"
+ :data-linenumber="lineNumber"
+ :href="getLineHref"
+ >
+ </a>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
new file mode 100644
index 00000000000..fe6beeb184a
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -0,0 +1,85 @@
+<script>
+import { mapState, mapGetters, mapActions } from 'vuex';
+import noteForm from '../../notes/components/note_form.vue';
+import * as utils 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', 'fetchNotes']),
+ handleCancelCommentForm() {
+ const { diffLines, line, position } = this;
+
+ this.cancelCommentForm({
+ linePosition: position,
+ diffLines,
+ formId: line.id,
+ });
+ },
+ handleSaveNote(note) {
+ const postData = utils.getNoteFormData({
+ note,
+ noteableData: this.noteableData,
+ noteableType: this.noteableType,
+ noteTargetLine: this.noteTargetLine,
+ diffViewType: this.diffViewType,
+ diffFile: this.diffFile,
+ linePosition: this.position,
+ });
+
+ // FIXME: @fatihacet -- This should be fixed, no need to fetchNotes again
+ this.saveNote(postData).then(() => {
+ const endpoint = this.getNotesDataByProp('discussionsPath');
+
+ this.fetchNotes(endpoint).then(() => {
+ this.handleCancelCommentForm();
+ });
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="content discussion-form js-discussion-note-form discussion-form-container">
+ <note-form
+ :is-editing="true"
+ save-button-title="Comment"
+ class="diff-comment-form"
+ @cancelForm="handleCancelCommentForm"
+ @handleFormUpdate="handleSaveNote"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
new file mode 100644
index 00000000000..32de9554ce5
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -0,0 +1,107 @@
+<script>
+import diffContentMixin from '../mixins/diff_content';
+import { MATCH_LINE_TYPE, LINE_HOVER_CLASS_NAME } from '../constants';
+
+export default {
+ mixins: [diffContentMixin],
+ methods: {
+ handleMouse(lineCode, isOver) {
+ this.hoveredLineCode = isOver ? lineCode : null;
+ },
+ getLineClass(line) {
+ const isSameLine = this.hoveredLineCode === line.lineCode;
+ const isMatchLine = line.type === MATCH_LINE_TYPE;
+
+ return {
+ [line.type]: true,
+ [LINE_HOVER_CLASS_NAME]: isSameLine && !isMatchLine,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <table
+ :class="userColorScheme"
+ class="code diff-wrap-lines js-syntax-highlight text-file">
+ <tbody>
+ <template
+ v-for="(line, index) in normalizedDiffLines"
+ >
+ <tr
+ :id="line.lineCode"
+ :key="line.lineCode"
+ :class="line.type"
+ 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
+ :line-type="line.type"
+ :line-code="line.lineCode"
+ :line-number="line.oldLine"
+ :show-comment-button="true"
+ @showCommentForm="handleShowCommentForm"
+ />
+ </td>
+ <td
+ :class="getLineClass(line)"
+ class="diff-line-num new_line"
+ >
+ <diff-line-gutter-content
+ :line-type="line.type"
+ :line-code="line.lineCode"
+ :line-number="line.newLine"
+ />
+ </td>
+ <td
+ :class="line.type"
+ class="line_content"
+ v-html="line.richText"
+ >
+ </td>
+ </tr>
+ <tr
+ v-if="discussionsByLineCode[line.lineCode]"
+ :key="discussionsByLineCode[line.lineCode].id"
+ class="notes_holder"
+ >
+ <td
+ class="notes_line"
+ colspan="2"
+ ></td>
+ <td class="notes_content">
+ <div class="content">
+ <diff-discussions
+ :notes="discussionsByLineCode[line.lineCode]"
+ />
+ </div>
+ </td>
+ </tr>
+ <tr
+ v-if="line.type === 'commentForm'"
+ :key="line.id"
+ class="notes_holder js-temp-notes-holder"
+ >
+ <td
+ class="notes_line"
+ colspan="2"
+ ></td>
+ <td class="notes_content">
+ <diff-line-note-form
+ :diff-file="diffFile"
+ :diff-lines="diffLines"
+ :line="line"
+ :note-target-line="diffLines[index - 1]"
+ />
+ </td>
+ </tr>
+ </template>
+ </tbody>
+ </table>
+</template>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
new file mode 100644
index 00000000000..922f8e58dec
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -0,0 +1,181 @@
+<script>
+import diffContentMixin from '../mixins/diff_content';
+import {
+ EMPTY_CELL_TYPE,
+ MATCH_LINE_TYPE,
+ LINE_HOVER_CLASS_NAME,
+} 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;
+
+ return (
+ discussions[line.left.lineCode] || discussions[line.right.lineCode]
+ );
+ },
+ getClassName(line, position) {
+ const { type, lineCode } = line[position];
+ const isContextLine =
+ type !== MATCH_LINE_TYPE && type !== EMPTY_CELL_TYPE;
+ const isSameLine = this.hoveredLineCode === lineCode;
+ const isSameSection = position === this.hoveredSection;
+
+ return {
+ [type]: type,
+ [LINE_HOVER_CLASS_NAME]: isContextLine && isSameLine && isSameSection,
+ };
+ },
+ handleMouse(e, line, isHover) {
+ const cell = e.target.closest('td');
+
+ if (isHover) {
+ 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;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="userColorScheme"
+ class="code diff-wrap-lines js-syntax-highlight text-file">
+ <table>
+ <tbody>
+ <template
+ v-for="(line, index) in parallelDiffLines"
+ >
+ <tr
+ :key="index"
+ class="line_holder parallel"
+ @mouseover="handleMouse($event, line, true)"
+ @mouseout="handleMouse($event, line, false)"
+ >
+ <td
+ :class="getClassName(line, 'left')"
+ ref="leftLines"
+ class="diff-line-num old_line"
+ >
+ <diff-line-gutter-content
+ :line-type="line.left.type"
+ :line-code="line.left.lineCode"
+ :line-number="line.left.oldLine"
+ :show-comment-button="true"
+ line-position="left"
+ @showCommentForm="handleShowCommentForm"
+ />
+ </td>
+ <td
+ :class="getClassName(line, 'left')"
+ ref="leftLines"
+ v-html="line.left.richText"
+ class="line_content parallel left-side"
+ >
+ </td>
+ <td
+ :class="getClassName(line, 'right')"
+ ref="rightLines"
+ class="diff-line-num new_line"
+ >
+ <diff-line-gutter-content
+ :line-type="line.right.type"
+ :line-code="line.right.lineCode"
+ :line-number="line.right.newLine"
+ :show-comment-button="true"
+ line-position="right"
+ @showCommentForm="handleShowCommentForm"
+ />
+ </td>
+ <td
+ :class="getClassName(line, 'right')"
+ ref="rightLines"
+ v-html="line.right.richText"
+ class="line_content parallel right-side"
+ >
+ </td>
+ </tr>
+ <tr
+ v-if="hasDiscussion(line)"
+ :key="line.left.lineCode || line.right.lineCode"
+ class="notes_holder"
+ >
+ <td class="notes_line old"></td>
+ <td class="notes_content parallel old">
+ <div
+ v-if="discussionsByLineCode[line.left.lineCode]"
+ class="content"
+ >
+ <diff-discussions
+ :notes="discussionsByLineCode[line.left.lineCode]"
+ />
+ </div>
+ </td>
+ <td class="notes_line new"></td>
+ <td class="notes_content parallel new">
+ <div
+ v-if="discussionsByLineCode[line.right.lineCode] && line.right.type"
+ class="content"
+ >
+ <diff-discussions
+ :notes="discussionsByLineCode[line.right.lineCode]"
+ />
+ </div>
+ </td>
+ </tr>
+ <tr
+ v-if="line.left.type === 'commentForm' || line.right.type === 'commentForm'"
+ :key="line.id"
+ class="notes_holder js-temp-notes-holder">
+ <td class="notes_line old"></td>
+ <td class="notes_content parallel old">
+ <diff-line-note-form
+ v-if="line.left.type === 'commentForm'"
+ :diff-file="diffFile"
+ :diff-lines="diffLines"
+ :line="line.left"
+ :note-target-line="diffLines[index - 1].left"
+ position="left"
+ />
+ </td>
+ <td class="notes_line new"></td>
+ <td class="notes_content parallel new">
+ <diff-line-note-form
+ v-if="line.right.type === 'commentForm'"
+ :diff-file="diffFile"
+ :diff-lines="diffLines"
+ :line="line.right"
+ :note-target-line="diffLines[index - 1].right"
+ position="right"
+ />
+ </td>
+ </tr>
+ </template>
+ </tbody>
+ </table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
new file mode 100644
index 00000000000..fa8cccbc46c
--- /dev/null
+++ b/app/assets/javascripts/diffs/constants.js
@@ -0,0 +1,13 @@
+export const INLINE_DIFF_VIEW_TYPE = 'inline';
+export const PARALLEL_DIFF_VIEW_TYPE = 'parallel';
+export const MATCH_LINE_TYPE = 'match';
+export const DIFF_VIEW_COOKIE_NAME = 'diff_view';
+export const EMPTY_CELL_TYPE = 'empty-cell';
+export const LINE_HOVER_CLASS_NAME = 'is-over';
+export const COMMENT_FORM_TYPE = 'commentForm';
+export const LINE_POSITION_LEFT = 'left';
+export const LINE_POSITION_RIGHT = 'right';
+export const TEXT_DIFF_POSITION_TYPE = 'text';
+export const DIFF_NOTE_TYPE = 'DiffNote';
+export const NEW_LINE_TYPE = 'new';
+export const OLD_LINE_TYPE = 'old';
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
new file mode 100644
index 00000000000..f0038091aa5
--- /dev/null
+++ b/app/assets/javascripts/diffs/index.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import diffsApp from './components/app.vue';
+
+document.addEventListener(
+ 'DOMContentLoaded',
+ () =>
+ new Vue({
+ el: '#js-diffs-app',
+ components: {
+ diffsApp,
+ },
+ data() {
+ const dataset = document.querySelector(this.$options.el).dataset;
+
+ return {
+ endpoint: dataset.path,
+ };
+ },
+ render(createElement) {
+ return createElement('diffs-app', {
+ props: {
+ endpoint: this.endpoint,
+ },
+ });
+ },
+ }),
+);
diff --git a/app/assets/javascripts/diffs/mixins/diff_content.js b/app/assets/javascripts/diffs/mixins/diff_content.js
new file mode 100644
index 00000000000..11db6a10a3e
--- /dev/null
+++ b/app/assets/javascripts/diffs/mixins/diff_content.js
@@ -0,0 +1,76 @@
+import { 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';
+
+export default {
+ props: {
+ diffFile: {
+ type: Object,
+ required: true,
+ },
+ diffLines: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ hoveredLineCode: undefined,
+ hoveredSection: undefined,
+ };
+ },
+ components: {
+ diffDiscussions,
+ diffLineNoteForm,
+ diffLineGutterContent,
+ },
+ computed: {
+ ...mapGetters(['discussionsByLineCode']),
+ 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;
+ });
+ },
+ },
+ methods: {
+ ...mapActions(['showCommentForm', 'cancelCommentForm']),
+ trimFirstChar(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;
+ },
+ handleShowCommentForm({ lineCode, linePosition }) {
+ this.showCommentForm({
+ diffLines: this.diffLines,
+ lineCode,
+ linePosition,
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
new file mode 100644
index 00000000000..321967a5479
--- /dev/null
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+import Cookies from 'js-cookie';
+import { handleLocationHash } from '~/lib/utils/common_utils';
+import * as types from './mutation_types';
+import {
+ PARALLEL_DIFF_VIEW_TYPE,
+ INLINE_DIFF_VIEW_TYPE,
+ DIFF_VIEW_COOKIE_NAME,
+} from '../constants';
+
+Vue.use(VueResource);
+
+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 Vue.http
+ .get(state.endpoint)
+ .then(res => res.json())
+ .then(res => {
+ commit(types.SET_LOADING, false);
+ commit(types.SET_DIFF_FILES, res.diff_files);
+ return Vue.nextTick();
+ })
+ .then(handleLocationHash);
+};
+
+export const setDiffViewType = ({ commit }, isParallel) => {
+ const type = isParallel ? PARALLEL_DIFF_VIEW_TYPE : INLINE_DIFF_VIEW_TYPE;
+
+ commit(types.SET_DIFF_VIEW_TYPE, type);
+ Cookies.set(DIFF_VIEW_COOKIE_NAME, type);
+};
+
+export const showCommentForm = ({ commit }, params) => {
+ commit(types.ADD_COMMENT_FORM_LINE, params);
+};
+
+export const cancelCommentForm = ({ commit }, params) => {
+ commit(types.REMOVE_COMMENT_FORM_LINE, params);
+};
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
new file mode 100644
index 00000000000..ec4ea070209
--- /dev/null
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -0,0 +1,10 @@
+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;
+ },
+};
diff --git a/app/assets/javascripts/diffs/store/index.js b/app/assets/javascripts/diffs/store/index.js
new file mode 100644
index 00000000000..a25851e2f79
--- /dev/null
+++ b/app/assets/javascripts/diffs/store/index.js
@@ -0,0 +1,17 @@
+import Cookies from 'js-cookie';
+import * as actions from './actions';
+import getters from './getters';
+import mutations from './mutations';
+import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../constants';
+
+export default {
+ state: {
+ isLoading: true,
+ endpoint: '',
+ diffFiles: [],
+ diffViewType: Cookies.get(DIFF_VIEW_COOKIE_NAME) || INLINE_DIFF_VIEW_TYPE,
+ },
+ getters,
+ actions,
+ mutations,
+};
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
new file mode 100644
index 00000000000..cec771fc04e
--- /dev/null
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -0,0 +1,6 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+export const SET_LOADING = 'SET_LOADING';
+export const SET_DIFF_FILES = 'SET_DIFF_FILES';
+export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE';
+export const ADD_COMMENT_FORM_LINE = 'ADD_COMMENT_FORM_LINE';
+export const REMOVE_COMMENT_FORM_LINE = 'REMOVE_COMMENT_FORM_LINE';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
new file mode 100644
index 00000000000..d83a966fed9
--- /dev/null
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -0,0 +1,112 @@
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import * as utils from './utils';
+import * as types from './mutation_types';
+import * as constants from '../constants';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ Object.assign(state, { endpoint });
+ },
+
+ [types.SET_LOADING](state, loadingState) {
+ Object.assign(state, { isLoading: loadingState });
+ },
+
+ [types.SET_DIFF_FILES](state, diffFiles) {
+ Object.assign(state, {
+ diffFiles: convertObjectPropsToCamelCase(diffFiles, {
+ deep: true,
+ }),
+ });
+ },
+
+ [types.SET_DIFF_VIEW_TYPE](state, type) {
+ Object.assign(state, { diffViewType: type });
+ },
+
+ [types.ADD_COMMENT_FORM_LINE](state, { diffLines, lineCode, linePosition }) {
+ const index = utils.findDiffLineIndex({
+ diffLines,
+ lineCode,
+ linePosition,
+ });
+ const commentFormType = constants.COMMENT_FORM_TYPE;
+
+ if (!diffLines[index]) {
+ return;
+ }
+
+ const item = linePosition
+ ? diffLines[index][linePosition]
+ : diffLines[index];
+
+ if (!item) {
+ return;
+ }
+
+ // We add forms as another diff line so they have to have a unique id
+ // We later use this id to remove the form from diff lines
+ const id = `${item.lineCode}_CommentForm_${linePosition || ''}`;
+ const targetIndex = index + 1;
+ const targetLine = diffLines[targetIndex];
+ const atTargetIndex = linePosition ? targetLine[linePosition] : targetLine;
+
+ // We already have comment form for target line
+ if (atTargetIndex && atTargetIndex.id === id) {
+ return;
+ }
+
+ // Unique comment form object as a diff line
+ const formObj = {
+ id,
+ type: commentFormType,
+ };
+
+ if (linePosition) {
+ // linePosition is only valid for Parallel mode
+ // Create the final lineObj which will represent the forms as a line
+ // Restore old form in opposite position so we can rerender it
+ const reversePosition = utils.getReversePosition(linePosition);
+ const reverseObj = targetLine[reversePosition];
+ const lineObj = {
+ [linePosition]: formObj,
+ [reversePosition]:
+ reverseObj.type === commentFormType ? reverseObj : {},
+ };
+
+ // Check if there is any comment form on the target position
+ // If we have, we should to remove it because above lineObj should be final version
+ const { left, right } = targetLine;
+ const hasAlreadyForm =
+ left.type === commentFormType || right.type === commentFormType;
+ const spliceCount = hasAlreadyForm ? 1 : 0;
+
+ diffLines.splice(targetIndex, spliceCount, lineObj);
+ } else {
+ diffLines.splice(targetIndex, 0, formObj);
+ }
+ },
+
+ [types.REMOVE_COMMENT_FORM_LINE](state, { diffLines, formId, linePosition }) {
+ const index = utils.findDiffLineIndex({ diffLines, formId, linePosition });
+
+ if (index > -1) {
+ if (linePosition) {
+ const reversePosition = utils.getReversePosition(linePosition);
+ const line = diffLines[index];
+ const reverse = line[reversePosition];
+ const shouldRemove = reverse.type !== constants.COMMENT_FORM_TYPE;
+
+ if (shouldRemove) {
+ diffLines.splice(index, 1);
+ } else {
+ Object.assign(line, {
+ [linePosition]: {},
+ });
+ }
+ } else {
+ diffLines.splice(index, 1);
+ }
+ }
+ },
+};
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
new file mode 100644
index 00000000000..9aa44cd4fdb
--- /dev/null
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -0,0 +1,83 @@
+import _ from 'underscore';
+import {
+ LINE_POSITION_LEFT,
+ LINE_POSITION_RIGHT,
+ TEXT_DIFF_POSITION_TYPE,
+ DIFF_NOTE_TYPE,
+ NEW_LINE_TYPE,
+ OLD_LINE_TYPE,
+} from '../constants';
+
+export const findDiffLineIndex = options => {
+ const { diffLines, lineCode, linePosition, formId } = options;
+
+ return _.findIndex(diffLines, l => {
+ const line = linePosition ? l[linePosition] : l;
+
+ if (!line) {
+ return null;
+ }
+
+ if (formId) {
+ return line.id === formId;
+ }
+
+ return line.lineCode === lineCode;
+ });
+};
+
+export const getReversePosition = linePosition => {
+ if (linePosition === LINE_POSITION_RIGHT) {
+ return LINE_POSITION_LEFT;
+ }
+
+ return LINE_POSITION_RIGHT;
+};
+
+export const getNoteFormData = params => {
+ const {
+ note,
+ noteableType,
+ noteableData,
+ diffFile,
+ noteTargetLine,
+ diffViewType,
+ linePosition,
+ } = params;
+
+ // TODO: Discuss with @felipe_arthur to remove this JSON.stringify
+ 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,
+ });
+
+ // TODO: @fatihacet - Double check empty strings
+ 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: noteableType,
+ target_id: noteableData.id,
+ 'note[noteable_type]': noteableType,
+ 'note[noteable_id]': noteableData.id,
+ 'note[commit_id]': '',
+ 'note[type]': DIFF_NOTE_TYPE,
+ 'note[line_code]': noteTargetLine.lineCode,
+ 'note[note]': note,
+ 'note[position]': position,
+ };
+
+ return {
+ endpoint: noteableData.create_note_path,
+ data: postData,
+ };
+};
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 0830ebe9e4e..081c8c48365 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -3,6 +3,7 @@ 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];
@@ -78,7 +79,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;
@@ -422,17 +423,24 @@ 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];
- result[convertToCamelCase(prop)] = 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}`;
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index d8222ebec63..d8960c212b4 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -49,6 +49,7 @@ MergeRequest.prototype.initTabs = function() {
if (window.mrTabs) {
window.mrTabs.unbindEvents();
}
+
window.mrTabs = new MergeRequestTabs(this.opts);
};
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index e77318fef46..a98b0b1c0b1 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,6 +1,7 @@
/* 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';
@@ -11,6 +12,7 @@ import {
parseUrlPathname,
handleLocationHash,
isMetaClick,
+ hasVueMRDiscussionsCookie,
} from './lib/utils/common_utils';
import { getLocationHash } from './lib/utils/url_utility';
import initDiscussionTab from './image_diff/init_discussion_tab';
@@ -80,6 +82,7 @@ export default class MergeRequestTabs {
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);
@@ -156,7 +159,10 @@ export default class MergeRequestTabs {
this.resetViewContainer();
this.destroyPipelinesView();
} else if (this.isDiffAction(action)) {
- this.loadDiff($target.attr('href'));
+ if (!hasVueMRDiscussionsCookie()) {
+ this.loadDiff($target.attr('href'));
+ }
+
if (bp.getBreakpointSize() !== 'lg') {
this.shrinkView();
}
@@ -164,6 +170,7 @@ export default class MergeRequestTabs {
this.expandViewContainer();
}
this.destroyPipelinesView();
+ $('.tab-content .commits.tab-pane').removeClass('active');
} else if (action === 'pipelines') {
this.resetViewContainer();
this.mountPipelinesView();
@@ -179,6 +186,8 @@ export default class MergeRequestTabs {
if (this.setUrl) {
this.setCurrentAction(action);
}
+
+ this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction());
}
scrollToElement(container) {
@@ -291,6 +300,8 @@ export default class MergeRequestTabs {
pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
}
+ // TODO: @fatihacet
+ // Delete this method later. It's not being called anymore but here for reference for refactor.
loadDiff(source) {
if (this.diffsLoaded) {
document.dispatchEvent(new CustomEvent('scroll'));
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index 096c4ef5f31..33492f7be65 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -1,30 +1,66 @@
import Vue from 'vue';
+import Vuex, { mapActions, mapGetters } from 'vuex';
import notesApp from '../notes/components/notes_app.vue';
+import diffsApp from '../diffs/components/app.vue';
import discussionCounter from '../notes/components/discussion_counter.vue';
-import store from '../notes/stores';
+import notesStoreConfig from '../notes/stores';
+import diffsStoreConfig from '../diffs/store';
+import mrPageStoreConfig from './stores';
+import MergeRequest from '../merge_request';
+
+Vue.use(Vuex);
+
+const store = new Vuex.Store({
+ modules: {
+ page: mrPageStoreConfig,
+ notes: notesStoreConfig,
+ },
+});
export default function initMrNotes() {
+ const mrShowNode = document.querySelector('.merge-request');
+ window.mergeRequest = 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;
+
return {
noteableData: JSON.parse(notesDataset.noteableData),
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
};
},
+ computed: {
+ ...mapGetters(['activeTab']),
+ },
+ mounted() {
+ this.setActiveTab(window.mrTabs.getCurrentAction());
+
+ window.mrTabs.eventHub.$on('MergeRequestTabChange', tab => {
+ this.setActiveTab(tab);
+ });
+ },
+ methods: {
+ ...mapActions(['setActiveTab']),
+ },
render(createElement) {
return createElement('notes-app', {
props: {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
+ shouldShow: this.activeTab === 'show',
},
});
},
@@ -33,6 +69,7 @@ export default function initMrNotes() {
// eslint-disable-next-line no-new
new Vue({
el: '#js-vue-discussion-counter',
+ name: 'DiscussionCounter',
components: {
discussionCounter,
},
@@ -41,4 +78,32 @@ export default function initMrNotes() {
return createElement('discussion-counter');
},
});
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-diffs-app',
+ name: 'DiffsApp',
+ components: {
+ diffsApp,
+ },
+ store,
+ data() {
+ const { dataset } = document.querySelector(this.$options.el);
+
+ return {
+ endpoint: dataset.endpoint,
+ };
+ },
+ computed: {
+ ...mapGetters(['activeTab']),
+ },
+ render(createElement) {
+ return createElement('diffs-app', {
+ props: {
+ endpoint: this.endpoint,
+ shouldShow: this.activeTab === 'diffs',
+ },
+ });
+ },
+ });
}
diff --git a/app/assets/javascripts/mr_notes/stores/actions.js b/app/assets/javascripts/mr_notes/stores/actions.js
new file mode 100644
index 00000000000..426c6a00d5e
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/actions.js
@@ -0,0 +1,7 @@
+import types from './mutation_types';
+
+export default {
+ setActiveTab({ commit }, tab) {
+ commit(types.SET_ACTIVE_TAB, tab);
+ },
+};
diff --git a/app/assets/javascripts/mr_notes/stores/getters.js b/app/assets/javascripts/mr_notes/stores/getters.js
new file mode 100644
index 00000000000..c68a84ce7af
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/getters.js
@@ -0,0 +1,5 @@
+export default {
+ activeTab(state) {
+ return state.activeTab;
+ },
+};
diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js
new file mode 100644
index 00000000000..3284bc08c70
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/index.js
@@ -0,0 +1,12 @@
+import actions from './actions';
+import getters from './getters';
+import mutations from './mutations';
+
+export default {
+ state: {
+ activeTab: null,
+ },
+ actions,
+ getters,
+ mutations,
+};
diff --git a/app/assets/javascripts/mr_notes/stores/mutation_types.js b/app/assets/javascripts/mr_notes/stores/mutation_types.js
new file mode 100644
index 00000000000..105104361cf
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/mutation_types.js
@@ -0,0 +1,3 @@
+export default {
+ SET_ACTIVE_TAB: 'SET_ACTIVE_TAB',
+};
diff --git a/app/assets/javascripts/mr_notes/stores/mutations.js b/app/assets/javascripts/mr_notes/stores/mutations.js
new file mode 100644
index 00000000000..8175aa9488f
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/mutations.js
@@ -0,0 +1,7 @@
+import types from './mutation_types';
+
+export default {
+ [types.SET_ACTIVE_TAB](state, tab) {
+ Object.assign(state, { activeTab: tab });
+ },
+};
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index b0573510ff9..b37b30efdc0 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -224,7 +224,7 @@ export default class Notes {
// When the URL fragment/hash has changed, `#note_xxx`
$(window).on('hashchange', this.onHashChange);
this.boundGetContent = this.getContent.bind(this);
- document.addEventListener('refreshLegacyNotes', this.boundGetContent);
+ this.eventsBound = true;
}
cleanBinding() {
@@ -247,7 +247,6 @@ export default class Notes {
this.$wrapperEl.off('ajax:success', '.js-main-target-form');
this.$wrapperEl.off('ajax:success', '.js-discussion-note-form');
this.$wrapperEl.off('ajax:complete', '.js-main-target-form');
- document.removeEventListener('refreshLegacyNotes', this.boundGetContent);
$(window).off('hashchange', this.onHashChange);
}
@@ -531,8 +530,6 @@ export default class Notes {
this.setupNewNote($updatedNote);
}
}
-
- Notes.refreshVueNotes();
}
isParallelView() {
@@ -1015,7 +1012,6 @@ export default class Notes {
})(this),
);
- Notes.refreshVueNotes();
Notes.checkMergeRequestStatus();
return this.updateNotesCount(-1);
}
@@ -1031,7 +1027,7 @@ export default class Notes {
$note.find('.note-attachment').remove();
$note.find('.note-body > .note-text').show();
$note.find('.note-header').show();
- return $note.find('.current-note-edit-form').remove();
+ return $note.find('.diffs .current-note-edit-form').remove();
}
/**
@@ -1575,10 +1571,6 @@ export default class Notes {
return $updatedNote;
}
- static refreshVueNotes() {
- document.dispatchEvent(new CustomEvent('refreshVueNotes'));
- }
-
/**
* Get data from Form attributes to use for saving/submitting comment.
*/
@@ -1884,8 +1876,6 @@ export default class Notes {
'<div class="flash-container" style="display: none;"></div>',
);
}
-
- Notes.refreshVueNotes();
} else if (isMainForm) {
// Check if this was main thread comment
// Show final note element on UI and perform form and action buttons cleanup
diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue
index 94d9dc69964..3e09391a9ac 100644
--- a/app/assets/javascripts/notes/components/diff_file_header.vue
+++ b/app/assets/javascripts/notes/components/diff_file_header.vue
@@ -12,17 +12,48 @@ export default {
type: Object,
required: true,
},
+ collapsible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ addMergeRequestButtons: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
titleTag() {
- return this.diffFile.discussionPath ? 'a' : 'span';
+ return this.diffFile.fileHash ? 'a' : 'span';
+ },
+ },
+ methods: {
+ handleToggle(e, checkTarget) {
+ if (checkTarget) {
+ if (e.target === this.$refs.header) {
+ this.$emit('toggleFile');
+ }
+ } else {
+ this.$emit('toggleFile');
+ }
},
+ noop() {},
},
};
</script>
<template>
- <div class="file-header-content">
+ <div
+ @click="handleToggle($event, true)"
+ ref="header"
+ class="file-header-content"
+ >
+ <i
+ v-if="collapsible"
+ @click.stop="handleToggle"
+ class="fa diff-toggle-caret fa-fw fa-caret-down"
+ ></i>
<div
v-if="diffFile.submodule"
>
@@ -33,8 +64,8 @@ export default {
class="file-title-name"
></strong>
<clipboard-button
- title="Copy file path to clipboard"
:text="diffFile.submoduleLink"
+ title="Copy file path to clipboard"
css-class="btn-default btn-transparent btn-clipboard"
/>
</span>
@@ -43,7 +74,7 @@ export default {
<component
ref="titleWrapper"
:is="titleTag"
- :href="diffFile.discussionPath"
+ :href="`#${diffFile.fileHash}`"
>
<span v-html="diffFile.blobIcon"></span>
<span v-if="diffFile.renamedFile">
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index d492d1cd001..cbe4774a360 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -86,7 +86,7 @@ export default {
v-html="resolveSvg"
></span>
</span>
- <span class=".line-resolve-text">
+ <span class="line-resolve-text">
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
</span>
</div>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 069f94c5845..1bd0a1bd8f3 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -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);
},
},
};
@@ -90,7 +90,7 @@ export default {
v-if="isEditing"
ref="noteForm"
@handleFormUpdate="handleFormUpdate"
- @cancelFormEdition="formCancelHandler"
+ @cancelForm="formCancelHandler"
:is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index c59a2e7a406..da50fcb70e8 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -124,7 +124,7 @@ export default {
cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed
this.$emit(
- 'cancelFormEdition',
+ 'cancelForm',
shouldConfirm,
this.noteBody !== this.updatedNoteBody,
);
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index cf579c5d4dc..b4698becce7 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -40,6 +40,21 @@ export default {
type: Object,
required: true,
},
+ renderHeader: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ renderDiffFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ alwaysExpanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -95,12 +110,17 @@ export default {
return this.unresolvedDiscussions.length > 0;
},
wrapperComponent() {
- return this.discussion.diffDiscussion && this.discussion.diffFile
- ? diffWithNote
- : 'div';
+ const shouldRenderDiffs =
+ this.discussion.diffDiscussion &&
+ this.discussion.diffFile &&
+ this.renderDiffFile;
+
+ return shouldRenderDiffs ? diffWithNote : 'div';
},
wrapperClass() {
- return this.isDiffDiscussion ? '' : 'panel panel-default';
+ return this.isDiffDiscussion
+ ? ''
+ : 'panel panel-default discussion-wrapper';
},
},
mounted() {
@@ -149,10 +169,10 @@ export default {
},
cancelReplyForm(shouldConfirm) {
if (shouldConfirm && this.$refs.noteForm.isDirty) {
- const msg = 'Are you sure you want to cancel creating this comment?';
-
// eslint-disable-next-line no-alert
- if (!confirm(msg)) {
+ if (
+ !confirm('Are you sure you want to cancel creating this comment?')
+ ) {
return;
}
}
@@ -222,7 +242,10 @@ Please check your network connection and try again.`;
</div>
<div class="timeline-content">
<div class="discussion">
- <div class="discussion-header">
+ <div
+ v-if="renderHeader"
+ class="discussion-header"
+ >
<note-header
:author="author"
:created-at="discussion.created_at"
@@ -242,7 +265,7 @@ Please check your network connection and try again.`;
/>
</div>
<div
- v-if="note.expanded"
+ v-if="note.expanded || alwaysExpanded"
class="discussion-body">
<component
:is="wrapperComponent"
@@ -332,7 +355,7 @@ Please check your network connection and try again.`;
:note="note"
:is-editing="false"
@handleFormUpdate="saveReply"
- @cancelFormEdition="cancelReplyForm"
+ @cancelForm="cancelReplyForm"
ref="noteForm" />
<note-signed-out-widget v-if="!canReply" />
</div>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 3554027d2b4..730983ca1fd 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -154,7 +154,8 @@ export default {
class="note timeline-entry"
:id="noteAnchorId"
:class="classNameBindings"
- :data-award-url="note.toggle_award_path">
+ :data-award-url="note.toggle_award_path"
+ >
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
@@ -194,7 +195,7 @@ export default {
:can-edit="note.current_user.can_edit"
:is-editing="isEditing"
@handleFormUpdate="formUpdateHandler"
- @cancelFormEdition="formCancelHandler"
+ @cancelForm="formCancelHandler"
ref="noteBody"
/>
</div>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index a90c6d6381d..908a79bcee1 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -3,7 +3,6 @@ import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
import { getLocationHash } from '../../lib/utils/url_utility';
import Flash from '../../flash';
-import store from '../stores/';
import * as constants from '../constants';
import noteableNote from './noteable_note.vue';
import noteableDiscussion from './noteable_discussion.vue';
@@ -39,23 +38,24 @@ export default {
required: false,
default: () => ({}),
},
+ shouldShow: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
- store,
data() {
return {
isLoading: true,
};
},
computed: {
- ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']),
- noteableType() {
- // FIXME -- @fatihacet Get this from JSON data.
- const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants;
-
- return this.noteableData.merge_params
- ? MERGE_REQUEST_NOTEABLE_TYPE
- : ISSUE_NOTEABLE_TYPE;
- },
+ ...mapGetters([
+ 'notes',
+ 'getNotesDataByProp',
+ 'discussionCount',
+ 'noteableType',
+ ]),
allNotes() {
if (this.isLoading) {
const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0;
@@ -86,10 +86,6 @@ export default {
this.actionToggleAward({ awardName, noteId });
});
}
- document.addEventListener('refreshVueNotes', this.fetchNotes);
- },
- beforeDestroy() {
- document.removeEventListener('refreshVueNotes', this.fetchNotes);
},
methods: {
...mapActions({
@@ -160,7 +156,9 @@ export default {
</script>
<template>
- <div id="notes">
+ <div
+ v-if="shouldShow"
+ id="notes">
<ul
id="notes-list"
class="notes main-notes-list timeline">
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index f90775d0157..66897e6a0db 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,5 +1,15 @@
import Vue from 'vue';
+import Vuex from 'vuex';
import notesApp from './components/notes_app.vue';
+import storeConfig from './stores';
+
+Vue.use(Vuex);
+
+const store = new Vuex.Store({
+ modules: {
+ notes: storeConfig,
+ },
+});
document.addEventListener(
'DOMContentLoaded',
@@ -9,11 +19,11 @@ document.addEventListener(
components: {
notesApp,
},
+ store,
data() {
const notesDataset = document.getElementById('js-vue-notes').dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData);
let currentUserData = {};
-
if (parsedUserData) {
currentUserData = {
id: parsedUserData.id,
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index f79049b85f6..a7684cc5659 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -35,9 +35,13 @@ export default {
methods: {
resolveHandler(resolvedState = false) {
this.isResolving = true;
- const endpoint = this.note.resolve_path || `${this.note.path}/resolve`;
const isResolved = this.discussionResolved || resolvedState;
const discussion = this.resolveAsThread;
+ let endpoint = `${this.note.path}/resolve`;
+
+ if (discussion) {
+ endpoint = this.note.resolve_path;
+ }
this.toggleResolveNote({ endpoint, isResolved, discussion })
.then(() => {
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 244a6980b5a..c2ce453d34a 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -14,16 +14,22 @@ let eTagPoll;
export const setNotesData = ({ commit }, data) =>
commit(types.SET_NOTES_DATA, data);
+
export const setNoteableData = ({ commit }, data) =>
commit(types.SET_NOTEABLE_DATA, data);
+
export const setUserData = ({ commit }, data) =>
commit(types.SET_USER_DATA, data);
+
export const setLastFetchedAt = ({ commit }, data) =>
commit(types.SET_LAST_FETCHED_AT, data);
+
export const setInitialNotes = ({ commit }, data) =>
commit(types.SET_INITIAL_NOTES, data);
+
export const setTargetNoteHash = ({ commit }, data) =>
commit(types.SET_TARGET_NOTE_HASH, data);
+
export const toggleDiscussion = ({ commit }, data) =>
commit(types.TOGGLE_DISCUSSION, data);
@@ -134,7 +140,7 @@ export const toggleIssueLocalState = ({ commit }, newState) => {
};
export const saveNote = ({ commit, dispatch }, noteData) => {
- const { note } = noteData.data.note;
+ const note = noteData.data['note[note]'] || noteData.data.note.note;
let placeholderText = note;
const hasQuickActions = utils.hasQuickActions(placeholderText);
const replyId = noteData.data.in_reply_to_discussion_id;
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index f89591a54d6..718634595e3 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -1,16 +1,22 @@
import _ from 'underscore';
+import * as constants from '../constants';
export const notes = state => state.notes;
+
export const targetNoteHash = state => state.targetNoteHash;
export const getNotesData = state => state.notesData;
+
export const getNotesDataByProp = state => prop => state.notesData[prop];
export const getNoteableData = state => state.noteableData;
+
export const getNoteableDataByProp = state => prop => state.noteableData[prop];
+
export const openState = state => state.noteableData.state;
export const getUserData = state => state.userData || {};
+
export const getUserDataByProp = state => prop =>
state.userData && state.userData[prop];
@@ -20,7 +26,28 @@ export const notesById = state =>
return acc;
}, {});
+export const discussionsByLineCode = state =>
+ state.notes.reduce((acc, note) => {
+ if (note.diff_discussion) {
+ // For context line notes, there might be multiple notes with the same line code
+ const items = acc[note.line_code] || [];
+ items.push(note);
+
+ Object.assign(acc, { [note.line_code]: items });
+ }
+ return acc;
+ }, {});
+
+export const noteableType = state => {
+ const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants;
+
+ return state.noteableData.merge_params
+ ? MERGE_REQUEST_NOTEABLE_TYPE
+ : ISSUE_NOTEABLE_TYPE;
+};
+
const reverseNotes = array => array.slice(0).reverse();
+
const isLastNote = (note, state) =>
!note.system &&
state.userData &&
diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js
index 9ed19bf171e..f9805bc4d1e 100644
--- a/app/assets/javascripts/notes/stores/index.js
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -1,12 +1,8 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
-Vue.use(Vuex);
-
-export default new Vuex.Store({
+export default {
state: {
notes: [],
targetNoteHash: null,
@@ -23,4 +19,4 @@ export default new Vuex.Store({
actions,
getters,
mutations,
-});
+};
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index c8edc06349f..356f261bea5 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -26,7 +26,6 @@ export default {
}
state.notes.push(noteData);
- document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}
},
@@ -35,7 +34,6 @@ export default {
if (noteObj) {
noteObj.notes.push(note);
- document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}
},
@@ -52,8 +50,6 @@ export default {
state.notes.splice(state.notes.indexOf(noteObj), 1);
}
}
-
- document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.REMOVE_PLACEHOLDER_NOTES](state) {
@@ -161,8 +157,6 @@ export default {
user: { id, name, username },
});
}
-
- document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.TOGGLE_DISCUSSION](state, { discussionId }) {
@@ -180,8 +174,6 @@ export default {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
}
-
- // document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.UPDATE_DISCUSSION](state, noteData) {
@@ -196,8 +188,6 @@ export default {
note.expanded = true; // override expand flag to prevent collapse
state.notes.splice(index, 1, note);
-
- document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.CLOSE_ISSUE](state) {
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index 28d8761b502..26ead75cec4 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -1,30 +1,15 @@
-import MergeRequest from '~/merge_request';
import ZenMode from '~/zen_mode';
-import initNotes from '~/init_notes';
import initIssuableSidebar from '~/init_issuable_sidebar';
-import initDiffNotes from '~/diff_notes/diff_notes_bundle';
import ShortcutsIssuable from '~/shortcuts_issuable';
-import Diff from '~/diff';
import { handleLocationHash } from '~/lib/utils/common_utils';
import howToMerge from '~/how_to_merge';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
import initWidget from '../../../vue_merge_request_widget';
-export default function () {
- new Diff(); // eslint-disable-line no-new
+export default function() {
new ZenMode(); // eslint-disable-line no-new
-
initIssuableSidebar();
- initNotes();
- initDiffNotes();
initPipelines();
-
- const mrShowNode = document.querySelector('.merge-request');
-
- window.mergeRequest = new MergeRequest({
- action: mrShowNode.dataset.mrAction,
- });
-
new ShortcutsIssuable(true); // eslint-disable-line no-new
handleLocationHash();
howToMerge();
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 81e98f358a8..a698a11fb9b 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -776,3 +776,35 @@ ul.notes {
.line-resolve-text {
vertical-align: middle;
}
+
+// Vue refactored diff discussion adjustments
+.files {
+ .diff-discussions {
+ .note-discussion.timeline-entry {
+ padding-left: 0;
+
+ & > .timeline-entry-inner {
+ padding: 0;
+
+ & > .timeline-content {
+ margin-left: 0;
+ }
+
+ & > .timeline-icon {
+ display: none;
+ }
+ }
+ .discussion-body {
+ padding-top: 0;
+
+ .discussion-wrapper {
+ border-color: transparent;
+ }
+ }
+ }
+ }
+
+ .diff-comment-form {
+ display: block;
+ }
+}
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 9866cc716ee..112f27ef513 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -87,8 +87,10 @@
#pipelines.pipelines.tab-pane
- if @pipelines.any?
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
- #diffs.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked? } }
- -# This tab is always loaded via AJAX
+ #js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?, endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json') } }
+
+ - unless has_vue_discussions_cookie? # TODO: @fatihacet This should be deleted after refactor
+ #diffs
.mr-loading-status
= spinner
diff --git a/spec/javascripts/diffs/components/changed_files_spec.js b/spec/javascripts/diffs/components/changed_files_spec.js
new file mode 100644
index 00000000000..2510b123886
--- /dev/null
+++ b/spec/javascripts/diffs/components/changed_files_spec.js
@@ -0,0 +1,69 @@
+import Vue from 'vue';
+import { mountComponent } from 'spec/helpers';
+import ChangedFiles from '~/diffs/components/changed_files.vue';
+
+const vueMatchers = {
+ toContainText() {
+ return {
+ compare(vm, text) {
+ const result = {
+ pass: vm.$el.innerText.includes(text),
+ };
+ return result;
+ },
+ };
+ },
+ toRender() {
+ return {
+ compare(vm) {
+ const result = {
+ pass: vm.$el.nodeType !== Node.COMMENT_NODE,
+ };
+ return result;
+ },
+ };
+ },
+};
+
+describe('ChangedFiles', () => {
+ const Component = Vue.extend(ChangedFiles);
+
+ beforeEach(() => {
+ jasmine.addMatchers(vueMatchers);
+ });
+
+ describe('with no changed files', () => {
+ const props = {
+ diffFiles: [],
+ };
+
+ it('does not render', () => {
+ const vm = mountComponent(Component, props);
+
+ expect(vm).not.toRender();
+ });
+ });
+
+ describe('with single file added', () => {
+ let vm;
+ const props = {
+ diffFiles: [{
+ addedLines: 10,
+ removedLines: 20,
+ }],
+ };
+
+ beforeEach(() => {
+ vm = mountComponent(Component, props);
+ });
+
+ it('shows files changes', () => {
+ expect(vm).toContainText('1 changed file');
+ });
+
+ it('shows file additions and deletions', () => {
+ expect(vm).toContainText('10 additions');
+ expect(vm).toContainText('20 deletions');
+ });
+ });
+});
diff --git a/spec/javascripts/helpers/index.js b/spec/javascripts/helpers/index.js
new file mode 100644
index 00000000000..f15b0532a2b
--- /dev/null
+++ b/spec/javascripts/helpers/index.js
@@ -0,0 +1,9 @@
+import mountComponent from './vue_mount_component_helper';
+
+export {
+ mountComponent,
+};
+
+export default {
+ mountComponent,
+};
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js
index 27f06573432..bf18d9eff69 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js
+++ b/spec/javascripts/lib/utils/common_utils_spec.js
@@ -523,5 +523,75 @@ describe('common_utils', () => {
expect(Object.keys(commonUtils.convertObjectPropsToCamelCase()).length).toBe(0);
expect(Object.keys(commonUtils.convertObjectPropsToCamelCase({})).length).toBe(0);
});
+
+ it('does not deep-convert by default', () => {
+ const obj = {
+ snake_key: {
+ child_snake_key: 'value',
+ },
+ };
+
+ expect(
+ commonUtils.convertObjectPropsToCamelCase(obj),
+ ).toEqual({
+ snakeKey: {
+ child_snake_key: 'value',
+ },
+ });
+ });
+
+ describe('deep: true', () => {
+ it('converts object with child objects', () => {
+ const obj = {
+ snake_key: {
+ child_snake_key: 'value',
+ },
+ };
+
+ expect(
+ commonUtils.convertObjectPropsToCamelCase(obj, { deep: true }),
+ ).toEqual({
+ snakeKey: {
+ childSnakeKey: 'value',
+ },
+ });
+ });
+
+ it('converts array with child objects', () => {
+ const arr = [
+ {
+ child_snake_key: 'value',
+ },
+ ];
+
+ expect(
+ commonUtils.convertObjectPropsToCamelCase(arr, { deep: true }),
+ ).toEqual([
+ {
+ childSnakeKey: 'value',
+ },
+ ]);
+ });
+
+ it('converts array with child arrays', () => {
+ const arr = [
+ [
+ {
+ child_snake_key: 'value',
+ },
+ ],
+ ];
+
+ expect(
+ commonUtils.convertObjectPropsToCamelCase(arr, { deep: true }),
+ ).toEqual([
+ [
+ {
+ childSnakeKey: 'value',
+ },
+ ],
+ ]);
+ });
+ });
});
});