diff options
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', + }, + ], + ]); + }); + }); }); }); |