diff options
author | Felipe Artur <fcardozo@gitlab.com> | 2018-06-21 12:22:40 +0000 |
---|---|---|
committer | Tim Zallmann <tzallmann@gitlab.com> | 2018-06-21 12:22:40 +0000 |
commit | 3e66795ef1ff1228906239763910b051d8afcc37 (patch) | |
tree | df6424d9ec008f5d1da455f8465681b371c4a11e /app/assets | |
parent | 14e35ac9b19d358d84e0cfd167f74036937285b6 (diff) | |
download | gitlab-ce-3e66795ef1ff1228906239763910b051d8afcc37.tar.gz |
Changes tab VUE refactoring
Diffstat (limited to 'app/assets')
87 files changed, 4071 insertions, 920 deletions
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 959e6289502..fa00a3cf386 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 eb0f06efab4..70f20c5c7cf 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -11,7 +11,8 @@ import axios from './lib/utils/axios_utils'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; -const requestAnimationFrame = window.requestAnimationFrame || +const requestAnimationFrame = + window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.setTimeout; @@ -37,21 +38,28 @@ class AwardsHandler { this.emoji = emoji; this.eventListeners = []; // If the user shows intent let's pre-build the menu - this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => { - const $menu = $('.emoji-menu'); - if ($menu.length === 0) { - requestAnimationFrame(() => { - this.createEmojiMenu(); - }); - } - }); - this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => { + this.registerEventListener( + 'one', + $(document), + 'mouseenter focus', + '.js-add-award', + 'mouseenter focus', + () => { + const $menu = $('.emoji-menu'); + if ($menu.length === 0) { + requestAnimationFrame(() => { + this.createEmojiMenu(); + }); + } + }, + ); + this.registerEventListener('on', $(document), 'click', '.js-add-award', e => { e.stopPropagation(); e.preventDefault(); this.showEmojiMenu($(e.currentTarget)); }); - this.registerEventListener('on', $('html'), 'click', (e) => { + this.registerEventListener('on', $('html'), 'click', e => { const $target = $(e.target); if (!$target.closest('.emoji-menu').length) { $('.js-awards-block.current').removeClass('current'); @@ -61,12 +69,14 @@ class AwardsHandler { } } }); - this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', (e) => { + this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', e => { e.preventDefault(); const $target = $(e.currentTarget); const $glEmojiElement = $target.find('gl-emoji'); const $spriteIconElement = $target.find('.icon'); - const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name'); + const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data( + 'name', + ); $target.closest('.js-awards-block').addClass('current'); this.addAward(this.getVotesBlock(), this.getAwardUrl(), emojiName); @@ -83,7 +93,10 @@ class AwardsHandler { showEmojiMenu($addBtn) { if ($addBtn.hasClass('js-note-emoji')) { - $addBtn.closest('.note').find('.js-awards-block').addClass('current'); + $addBtn + .closest('.note') + .find('.js-awards-block') + .addClass('current'); } else { $addBtn.closest('.js-awards-block').addClass('current'); } @@ -177,32 +190,38 @@ class AwardsHandler { const remainingCategories = Object.keys(categoryMap).slice(1); const allCategoriesAddedPromise = remainingCategories.reduce( (promiseChain, categoryNameKey) => - promiseChain.then(() => - new Promise((resolve) => { - const emojisInCategory = categoryMap[categoryNameKey]; - const categoryMarkup = this.renderCategory( - categoryLabelMap[categoryNameKey], - emojisInCategory, - ); - requestAnimationFrame(() => { - emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup); - resolve(); - }); - }), - ), + promiseChain.then( + () => + new Promise(resolve => { + const emojisInCategory = categoryMap[categoryNameKey]; + const categoryMarkup = this.renderCategory( + categoryLabelMap[categoryNameKey], + emojisInCategory, + ); + requestAnimationFrame(() => { + emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup); + resolve(); + }); + }), + ), Promise.resolve(), ); - allCategoriesAddedPromise.then(() => { - // Used for tests - // We check for the menu in case it was destroyed in the meantime - if (menu) { - menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish')); - } - }).catch((err) => { - emojiContentElement.insertAdjacentHTML('beforeend', '<p>We encountered an error while adding the remaining categories</p>'); - throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`); - }); + allCategoriesAddedPromise + .then(() => { + // Used for tests + // We check for the menu in case it was destroyed in the meantime + if (menu) { + menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish')); + } + }) + .catch(err => { + emojiContentElement.insertAdjacentHTML( + 'beforeend', + '<p>We encountered an error while adding the remaining categories</p>', + ); + throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`); + }); } renderCategory(name, emojiList, opts = {}) { @@ -211,7 +230,9 @@ class AwardsHandler { ${name} </h5> <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}"> - ${emojiList.map(emojiName => ` + ${emojiList + .map( + emojiName => ` <li class="emoji-menu-list-item"> <button class="emoji-menu-btn text-center js-emoji-btn" type="button"> ${this.emoji.glEmojiTag(emojiName, { @@ -219,7 +240,9 @@ class AwardsHandler { })} </button> </li> - `).join('\n')} + `, + ) + .join('\n')} </ul> `; } @@ -232,7 +255,7 @@ class AwardsHandler { top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`, }; if (position === 'right') { - css.left = `${($addBtn.offset().left - $menu.outerWidth()) + 20}px`; + css.left = `${$addBtn.offset().left - $menu.outerWidth() + 20}px`; $menu.addClass('is-aligned-right'); } else { css.left = `${$addBtn.offset().left}px`; @@ -416,7 +439,10 @@ class AwardsHandler { </button> `; const $emojiButton = $(buttonHtml); - $emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('name', emojiName); + $emojiButton + .insertBefore(votesBlock.find('.js-award-holder')) + .find('.emoji-icon') + .data('name', emojiName); this.animateEmoji($emojiButton); $('.award-control').tooltip(); votesBlock.removeClass('current'); @@ -426,7 +452,7 @@ class AwardsHandler { const className = 'pulse animated once short'; $emoji.addClass(className); - this.registerEventListener('on', $emoji, animationEndEventString, (e) => { + this.registerEventListener('on', $emoji, animationEndEventString, e => { $(e.currentTarget).removeClass(className); }); } @@ -444,15 +470,16 @@ class AwardsHandler { if (this.isUserAuthored($emojiButton)) { this.userAuthored($emojiButton); } else { - axios.post(awardUrl, { - name: emoji, - }) - .then(({ data }) => { - if (data.ok) { - callback(); - } - }) - .catch(() => flash(__('Something went wrong on our end.'))); + axios + .post(awardUrl, { + name: emoji, + }) + .then(({ data }) => { + if (data.ok) { + callback(); + } + }) + .catch(() => flash(__('Something went wrong on our end.'))); } } @@ -486,26 +513,33 @@ class AwardsHandler { } getFrequentlyUsedEmojis() { - return this.frequentlyUsedEmojis || (() => { - const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(',')); - this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter( - inputName => this.emoji.isEmojiNameValid(inputName), - ); - - return this.frequentlyUsedEmojis; - })(); + return ( + this.frequentlyUsedEmojis || + (() => { + const frequentlyUsedEmojis = _.uniq( + (Cookies.get('frequently_used_emojis') || '').split(','), + ); + this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(inputName => + this.emoji.isEmojiNameValid(inputName), + ); + + return this.frequentlyUsedEmojis; + })() + ); } setupSearch() { const $search = $('.js-emoji-menu-search'); - this.registerEventListener('on', $search, 'input', (e) => { - const term = $(e.target).val().trim(); + this.registerEventListener('on', $search, 'input', e => { + const term = $(e.target) + .val() + .trim(); this.searchEmojis(term); }); const $menu = $('.emoji-menu'); - this.registerEventListener('on', $menu, transitionEndEventString, (e) => { + this.registerEventListener('on', $menu, transitionEndEventString, e => { if (e.target === e.currentTarget) { // Clear the search this.searchEmojis(''); @@ -523,19 +557,26 @@ class AwardsHandler { // Generate a search result block const h5 = $('<h5 class="emoji-search-title"/>').text('Search results'); const foundEmojis = this.findMatchingEmojiElements(term).show(); - const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis); + const ul = $('<ul>') + .addClass('emoji-menu-list emoji-menu-search') + .append(foundEmojis); $('.emoji-menu-content ul, .emoji-menu-content h5').hide(); - $('.emoji-menu-content').append(h5).append(ul); + $('.emoji-menu-content') + .append(h5) + .append(ul); } else { - $('.emoji-menu-content').children().show(); + $('.emoji-menu-content') + .children() + .show(); } } findMatchingEmojiElements(query) { const emojiMatches = this.emoji.filterEmojiNamesByAlias(query); const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]'); - const $matchingElements = $emojiElements - .filter((i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0); + const $matchingElements = $emojiElements.filter( + (i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0, + ); return $matchingElements.closest('li').clone(); } @@ -550,16 +591,13 @@ class AwardsHandler { $emojiMenu.addClass(IS_RENDERED); // enqueues animation as a microtask, so it begins ASAP once IS_RENDERED added - return Promise.resolve() - .then(() => $emojiMenu.addClass(IS_VISIBLE)); + return Promise.resolve().then(() => $emojiMenu.addClass(IS_VISIBLE)); } hideMenuElement($emojiMenu) { - $emojiMenu.on(transitionEndEventString, (e) => { + $emojiMenu.on(transitionEndEventString, e => { if (e.currentTarget === e.target) { - $emojiMenu - .removeClass(IS_RENDERED) - .off(transitionEndEventString); + $emojiMenu.removeClass(IS_RENDERED).off(transitionEndEventString); } }); @@ -567,7 +605,7 @@ class AwardsHandler { } destroy() { - this.eventListeners.forEach((entry) => { + this.eventListeners.forEach(entry => { entry.element.off.call(entry.element, ...entry.args); }); $('.emoji-menu').remove(); @@ -577,8 +615,9 @@ class AwardsHandler { let awardsHandlerPromise = null; export default function loadAwardsHandler(reload = false) { if (!awardsHandlerPromise || reload) { - awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji') - .then(Emoji => new AwardsHandler(Emoji)); + awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then( + Emoji => new AwardsHandler(Emoji), + ); } return awardsHandlerPromise; } diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 07f3be29090..a69b34b0db8 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 */ @@ -41,54 +40,54 @@ const ResolveBtn = Vue.extend({ required: true, }, }, - data: function () { + data() { return { discussions: CommentsStore.state, - loading: false + loading: false, }; }, computed: { - discussion: function () { + discussion() { return this.discussions[this.discussionId]; }, - note: function () { + note() { return this.discussion ? this.discussion.getNote(this.noteId) : {}; }, - buttonText: function () { + buttonText() { if (this.isResolved) { return `Resolved by ${this.resolvedByName}`; } else if (this.canResolve) { return 'Mark as resolved'; - } else { - return 'Unable to resolve'; } + + return 'Unable to resolve'; }, - isResolved: function () { + isResolved() { if (this.note) { return this.note.resolved; - } else { - return false; } + + return false; }, - resolvedByName: function () { + resolvedByName() { return this.note.resolved_by; }, }, watch: { - 'discussions': { + discussions: { handler: 'updateTooltip', - deep: true - } + deep: true, + }, }, - mounted: function () { + mounted() { $(this.$refs.button).tooltip({ - container: 'body' + container: 'body', }); }, - beforeDestroy: function () { + beforeDestroy() { CommentsStore.delete(this.discussionId, this.noteId); }, - created: function () { + created() { CommentsStore.create({ discussionId: this.discussionId, noteId: this.noteId, @@ -101,43 +100,41 @@ const ResolveBtn = Vue.extend({ }); }, methods: { - updateTooltip: function () { + updateTooltip() { this.$nextTick(() => { $(this.$refs.button) .tooltip('hide') .tooltip('_fixTitle'); }); }, - resolve: function () { + resolve() { if (!this.canResolve) return; let promise; this.loading = true; if (this.isResolved) { - promise = ResolveService - .unresolve(this.noteId); + promise = ResolveService.unresolve(this.noteId); } else { - promise = ResolveService - .resolve(this.noteId); + promise = ResolveService.resolve(this.noteId); } promise .then(resp => resp.json()) - .then((data) => { + .then(data => { this.loading = false; - const resolved_by = data ? data.resolved_by : null; + const resolvedBy = data ? data.resolved_by : null; - CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); + CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolvedBy); this.discussion.updateHeadline(data); gl.mrWidget.checkStatus(); - document.dispatchEvent(new CustomEvent('refreshVueNotes')); - this.updateTooltip(); }) - .catch(() => new Flash('An error occurred when trying to resolve a comment. Please try again.')); - } + .catch( + () => new Flash('An error occurred when trying to resolve a comment. Please try again.'), + ); + }, }, }); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index 18fceac6368..a9800a11644 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -1,5 +1,4 @@ -/* eslint-disable func-names, comma-dangle, new-cap, no-new */ -/* global ResolveCount */ +/* eslint-disable func-names, new-cap */ import $ from 'jquery'; import Vue from 'vue'; @@ -15,12 +14,13 @@ import './components/resolve_count'; import './components/resolve_discussion_btn'; import './components/diff_note_avatars'; import './components/new_issue_for_discussion'; -import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils'; export default () => { - const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box'); + const projectPathHolder = + document.querySelector('.merge-request') || document.querySelector('.commit-box'); const projectPath = projectPathHolder.dataset.projectPath; - const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn'; + const COMPONENT_SELECTOR = + 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn'; window.gl = window.gl || {}; window.gl.diffNoteApps = {}; @@ -28,9 +28,9 @@ export default () => { window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath); gl.diffNotesCompileComponents = () => { - $('diff-note-avatars').each(function () { + $('diff-note-avatars').each(function() { const tmp = Vue.extend({ - template: $(this).get(0).outerHTML + template: $(this).get(0).outerHTML, }); const tmpApp = new tmp().$mount(); @@ -41,12 +41,12 @@ export default () => { }); }); - const $components = $(COMPONENT_SELECTOR).filter(function () { + const $components = $(COMPONENT_SELECTOR).filter(function() { return $(this).closest('resolve-count').length !== 1; }); if ($components) { - $components.each(function () { + $components.each(function() { const $this = $(this); const noteId = $this.attr(':note-id'); const discussionId = $this.attr(':discussion-id'); @@ -54,7 +54,7 @@ export default () => { if ($this.is('comment-and-resolve-btn') && !discussionId) return; const tmp = Vue.extend({ - template: $this.get(0).outerHTML + template: $this.get(0).outerHTML, }); const tmpApp = new tmp().$mount(); @@ -69,15 +69,5 @@ export default () => { gl.diffNotesCompileComponents(); - const resolveCountAppEl = document.querySelector('#resolve-count-app'); - if (!hasVueMRDiscussionsCookie() && resolveCountAppEl) { - new Vue({ - el: resolveCountAppEl, - components: { - 'resolve-count': ResolveCount - }, - }); - } - $(window).trigger('resize.nav'); }; 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..82ca10f4163 --- /dev/null +++ b/app/assets/javascripts/diffs/components/app.vue @@ -0,0 +1,197 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; +import CompareVersions from './compare_versions.vue'; +import ChangedFiles from './changed_files.vue'; +import DiffFile from './diff_file.vue'; +import NoChanges from './no_changes.vue'; +import HiddenFilesWarning from './hidden_files_warning.vue'; + +export default { + name: 'DiffsApp', + components: { + Icon, + LoadingIcon, + CompareVersions, + ChangedFiles, + DiffFile, + NoChanges, + HiddenFilesWarning, + }, + props: { + endpoint: { + type: String, + required: true, + }, + shouldShow: { + type: Boolean, + required: false, + default: false, + }, + currentUser: { + type: Object, + required: true, + }, + }, + data() { + return { + activeFile: '', + }; + }, + computed: { + ...mapState({ + isLoading: state => state.diffs.isLoading, + diffFiles: state => state.diffs.diffFiles, + diffViewType: state => state.diffs.diffViewType, + mergeRequestDiffs: state => state.diffs.mergeRequestDiffs, + mergeRequestDiff: state => state.diffs.mergeRequestDiff, + latestVersionPath: state => state.diffs.latestVersionPath, + startVersion: state => state.diffs.startVersion, + commit: state => state.diffs.commit, + targetBranchName: state => state.diffs.targetBranchName, + renderOverflowWarning: state => state.diffs.renderOverflowWarning, + numTotalFiles: state => state.diffs.realSize, + numVisibleFiles: state => state.diffs.size, + plainDiffPath: state => state.diffs.plainDiffPath, + emailPatchPath: state => state.diffs.emailPatchPath, + }), + ...mapGetters(['isParallelView']), + targetBranch() { + return { + branchName: this.targetBranchName, + versionIndex: -1, + path: '', + }; + }, + notAllCommentsDisplayed() { + if (this.commit) { + return __('Only comments from the following commit are shown below'); + } else if (this.startVersion) { + return __( + "Not all comments are displayed because you're comparing two versions of the diff.", + ); + } + return __( + "Not all comments are displayed because you're viewing an old version of the diff.", + ); + }, + showLatestVersion() { + if (this.commit) { + return __('Show latest version of the diff'); + } + return __('Show latest version'); + }, + }, + watch: { + diffViewType() { + this.adjustView(); + }, + shouldShow() { + this.adjustView(); + }, + }, + mounted() { + this.setEndpoint(this.endpoint); + this + .fetchDiffFiles() + .catch(() => { + createFlash(__('Something went wrong on our end. Please try again!')); + }); + }, + created() { + this.adjustView(); + }, + methods: { + ...mapActions(['setEndpoint', 'fetchDiffFiles']), + setActive(filePath) { + this.activeFile = filePath; + }, + unsetActive(filePath) { + if (this.activeFile === filePath) { + this.activeFile = ''; + } + }, + adjustView() { + if (this.shouldShow && this.isParallelView) { + window.mrTabs.expandViewContainer(); + } else { + window.mrTabs.resetViewContainer(); + } + }, + }, +}; +</script> + +<template> + <div v-if="shouldShow"> + <div + v-if="isLoading" + class="loading" + > + <loading-icon /> + </div> + <div + v-else + id="diffs" + :class="{ active: shouldShow }" + class="diffs tab-pane" + > + <compare-versions + v-if="!commit && mergeRequestDiffs.length > 1" + :merge-request-diffs="mergeRequestDiffs" + :merge-request-diff="mergeRequestDiff" + :start-version="startVersion" + :target-branch="targetBranch" + /> + + <hidden-files-warning + v-if="renderOverflowWarning" + :visible="numVisibleFiles" + :total="numTotalFiles" + :plain-diff-path="plainDiffPath" + :email-patch-path="emailPatchPath" + /> + + <div + v-if="commit || startVersion || (mergeRequestDiff && !mergeRequestDiff.latest)" + class="mr-version-controls" + > + <div class="content-block comments-disabled-notif clearfix"> + <i class="fa fa-info-circle"></i> + {{ notAllCommentsDisplayed }} + <div class="pull-right"> + <a + :href="latestVersionPath" + class="btn btn-sm" + > + {{ showLatestVersion }} + </a> + </div> + </div> + </div> + + <changed-files + :diff-files="diffFiles" + :active-file="activeFile" + /> + + <div + v-if="diffFiles.length > 0" + class="files" + > + <diff-file + v-for="file in diffFiles" + :key="file.newPath" + :file="file" + :current-user="currentUser" + @setActive="setActive(file.filePath)" + @unsetActive="unsetActive(file.filePath)" + /> + </div> + <no-changes v-else /> + </div> + </div> +</template> 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..c5ef9fefc2f --- /dev/null +++ b/app/assets/javascripts/diffs/components/changed_files.vue @@ -0,0 +1,184 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { pluralize } from '~/lib/utils/text_utility'; +import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; +import { contentTop } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import ChangedFilesDropdown from './changed_files_dropdown.vue'; +import changedFilesMixin from '../mixins/changed_files'; + +export default { + components: { + Icon, + ChangedFilesDropdown, + ClipboardButton, + }, + mixins: [changedFilesMixin], + props: { + activeFile: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isStuck: false, + maxWidth: 'auto', + offsetTop: 0, + }; + }, + computed: { + ...mapGetters(['isInlineView', 'isParallelView', 'areAllFilesCollapsed']), + sumAddedLines() { + return this.sumValues('addedLines'); + }, + sumRemovedLines() { + return this.sumValues('removedLines'); + }, + whitespaceVisible() { + return !getParameterValues('w')[0]; + }, + toggleWhitespaceText() { + if (this.whitespaceVisible) { + return __('Hide whitespace changes'); + } + return __('Show whitespace changes'); + }, + toggleWhitespacePath() { + if (this.whitespaceVisible) { + return mergeUrlParams({ w: 1 }, window.location.href); + } + + return mergeUrlParams({ w: 0 }, window.location.href); + }, + top() { + return `${this.offsetTop}px`; + }, + }, + created() { + document.addEventListener('scroll', this.handleScroll); + this.offsetTop = contentTop(); + }, + beforeDestroy() { + document.removeEventListener('scroll', this.handleScroll); + }, + methods: { + ...mapActions(['setInlineDiffViewType', 'setParallelDiffViewType', 'expandAllFiles']), + pluralize, + handleScroll() { + if (!this.updating) { + requestAnimationFrame(this.updateIsStuck); + this.updating = true; + } + }, + updateIsStuck() { + if (!this.$refs.wrapper) { + return; + } + + const scrollPosition = window.scrollY; + + this.isStuck = scrollPosition + this.offsetTop >= this.$refs.placeholder.offsetTop; + this.updating = false; + }, + sumValues(key) { + return this.diffFiles.reduce((total, file) => total + file[key], 0); + }, + }, +}; +</script> + +<template> + <span> + <div ref="placeholder"></div> + <div + ref="wrapper" + :style="{ top }" + :class="{'is-stuck': isStuck}" + class="content-block oneline-block diff-files-changed diff-files-changed-merge-request + files-changed js-diff-files-changed" + > + <div class="files-changed-inner"> + <div + class="inline-parallel-buttons d-none d-md-block" + > + <a + v-if="areAllFilesCollapsed" + class="btn btn-default" + @click="expandAllFiles" + > + {{ __('Expand all') }} + </a> + <a + :href="toggleWhitespacePath" + class="btn btn-default" + > + {{ toggleWhitespaceText }} + </a> + <div class="btn-group"> + <button + id="inline-diff-btn" + :class="{ active: isInlineView }" + type="button" + class="btn js-inline-diff-button" + data-view-type="inline" + @click="setInlineDiffViewType" + > + {{ __('Inline') }} + </button> + <button + id="parallel-diff-btn" + :class="{ active: isParallelView }" + type="button" + class="btn js-parallel-diff-button" + data-view-type="parallel" + @click="setParallelDiffViewType" + > + {{ __('Side-by-side') }} + </button> + </div> + </div> + + <div class="commit-stat-summary dropdown"> + <changed-files-dropdown + :diff-files="diffFiles" + /> + + <span + v-show="activeFile" + class="prepend-left-5" + > + <strong class="prepend-right-5"> + {{ truncatedDiffPath(activeFile) }} + </strong> + <clipboard-button + :text="activeFile" + :title="s__('Copy file name to clipboard')" + tooltip-placement="bottom" + tooltip-container="body" + class="btn btn-default btn-transparent btn-clipboard" + /> + </span> + + <span + v-show="!isStuck" + id="diff-stats" + class="diff-stats-additions-deletions-expanded" + > + with + <strong class="cgreen"> + {{ pluralize(`${sumAddedLines} addition`, sumAddedLines) }} + </strong> + and + <strong class="cred"> + {{ pluralize(`${sumRemovedLines} deletion`, sumRemovedLines) }} + </strong> + </span> + </div> + </div> + </div> + </span> +</template> diff --git a/app/assets/javascripts/diffs/components/changed_files_dropdown.vue b/app/assets/javascripts/diffs/components/changed_files_dropdown.vue new file mode 100644 index 00000000000..f224b9dd246 --- /dev/null +++ b/app/assets/javascripts/diffs/components/changed_files_dropdown.vue @@ -0,0 +1,124 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import changedFilesMixin from '../mixins/changed_files'; + +export default { + components: { + Icon, + }, + mixins: [changedFilesMixin], + data() { + return { + searchText: '', + }; + }, + computed: { + filteredDiffFiles() { + return this.diffFiles.filter(file => + file.filePath.toLowerCase().includes(this.searchText.toLowerCase()), + ); + }, + }, + methods: { + clearSearch() { + this.searchText = ''; + }, + }, +}; +</script> + +<template> + <span> + Showing + <button + class="diff-stats-summary-toggler" + data-toggle="dropdown" + type="button" + aria-expanded="false" + > + <span> + {{ n__('%d changed file', '%d changed files', diffFiles.length) }} + </span> + <icon + :size="8" + name="chevron-down" + /> + </button> + <div class="dropdown-menu diff-file-changes"> + <div class="dropdown-input"> + <input + v-model="searchText" + type="search" + class="dropdown-input-field" + placeholder="Search files" + autocomplete="off" + /> + <i + v-if="searchText.length === 0" + aria-hidden="true" + data-hidden="true" + class="fa fa-search dropdown-input-search"> + </i> + <i + v-else + role="button" + class="fa fa-times dropdown-input-search" + @click="clearSearch" + ></i> + </div> + <ul> + <li + v-for="diffFile in filteredDiffFiles" + :key="diffFile.name" + > + <a + :href="`#${diffFile.fileHash}`" + :title="diffFile.newPath" + class="diff-changed-file" + > + <icon + :name="fileChangedIcon(diffFile)" + :size="16" + :class="fileChangedClass(diffFile)" + class="diff-file-changed-icon append-right-8" + /> + <span class="diff-changed-file-content append-right-8"> + <strong + v-if="diffFile.blob && diffFile.blob.name" + class="diff-changed-file-name" + > + {{ diffFile.blob.name }} + </strong> + <strong + v-else + class="diff-changed-blank-file-name" + > + {{ s__('Diffs|No file name available') }} + </strong> + <span class="diff-changed-file-path prepend-top-5"> + {{ truncatedDiffPath(diffFile.blob.path) }} + </span> + </span> + <span class="diff-changed-stats"> + <span class="cgreen"> + +{{ diffFile.addedLines }} + </span> + <span class="cred"> + -{{ diffFile.removedLines }} + </span> + </span> + </a> + </li> + + <li + v-show="filteredDiffFiles.length === 0" + class="dropdown-menu-empty-item" + > + <a> + {{ __('No files found') }} + </a> + </li> + </ul> + </div> + </span> +</template> 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..1c9ad8e77f1 --- /dev/null +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -0,0 +1,55 @@ +<script> +import CompareVersionsDropdown from './compare_versions_dropdown.vue'; + +export default { + components: { + CompareVersionsDropdown, + }, + props: { + mergeRequestDiffs: { + type: Array, + required: true, + }, + mergeRequestDiff: { + type: Object, + required: true, + }, + startVersion: { + type: Object, + required: false, + default: null, + }, + targetBranch: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + comparableDiffs() { + return this.mergeRequestDiffs.slice(1); + }, + }, +}; +</script> + +<template> + <div class="mr-version-controls"> + <div class="mr-version-menus-container content-block"> + Changes between + <compare-versions-dropdown + :other-versions="mergeRequestDiffs" + :merge-request-version="mergeRequestDiff" + :show-commit-count="true" + class="mr-version-dropdown" + /> + and + <compare-versions-dropdown + :other-versions="comparableDiffs" + :start-version="startVersion" + :target-branch="targetBranch" + class="mr-version-compare-dropdown" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue new file mode 100644 index 00000000000..96cccb49378 --- /dev/null +++ b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue @@ -0,0 +1,165 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { n__, __ } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + Icon, + TimeAgo, + }, + props: { + otherVersions: { + type: Array, + required: false, + default: () => [], + }, + mergeRequestVersion: { + type: Object, + required: false, + default: null, + }, + startVersion: { + type: Object, + required: false, + default: null, + }, + targetBranch: { + type: Object, + required: false, + default: null, + }, + showCommitCount: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + baseVersion() { + return { + name: 'hii', + versionIndex: -1, + }; + }, + targetVersions() { + if (this.mergeRequestVersion) { + return this.otherVersions; + } + return [...this.otherVersions, this.targetBranch]; + }, + selectedVersionName() { + const selectedVersion = this.startVersion || this.targetBranch || this.mergeRequestVersion; + return this.versionName(selectedVersion); + }, + }, + methods: { + commitsText(version) { + return n__( + `${version.commitsCount} commit,`, + `${version.commitsCount} commits,`, + version.commitsCount, + ); + }, + href(version) { + if (this.showCommitCount) { + return version.versionPath; + } + return version.comparePath; + }, + versionName(version) { + if (this.isLatest(version)) { + return __('latest version'); + } + if (this.targetBranch && (this.isBase(version) || !version)) { + return this.targetBranch.branchName; + } + return `version ${version.versionIndex}`; + }, + isActive(version) { + if (!version) { + return false; + } + + if (this.targetBranch) { + return ( + (this.isBase(version) && !this.startVersion) || + (this.startVersion && this.startVersion.versionIndex === version.versionIndex) + ); + } + + return version.versionIndex === this.mergeRequestVersion.versionIndex; + }, + isBase(version) { + if (!version || !this.targetBranch) { + return false; + } + return version.versionIndex === -1; + }, + isLatest(version) { + return ( + this.mergeRequestVersion && version.versionIndex === this.targetVersions[0].versionIndex + ); + }, + }, +}; +</script> + +<template> + <span class="dropdown inline"> + <a + class="dropdown-toggle btn btn-default" + data-toggle="dropdown" + aria-expanded="false" + > + <span> + {{ selectedVersionName }} + </span> + <Icon + :size="12" + name="angle-down" + /> + </a> + <div class="dropdown-menu dropdown-select dropdown-menu-selectable"> + <div class="dropdown-content"> + <ul> + <li + v-for="version in targetVersions" + :key="version.id" + > + <a + :class="{ 'is-active': isActive(version) }" + :href="href(version)" + > + <div> + <strong> + {{ versionName(version) }} + <template v-if="isBase(version)"> + (base) + </template> + </strong> + </div> + <div> + <small class="commit-sha"> + {{ version.truncatedCommitSha }} + </small> + </div> + <div> + <small> + <template v-if="showCommitCount"> + {{ commitsText(version) }} + </template> + <time-ago + v-if="version.createdAt" + :time="version.createdAt" + class="js-timeago js-timeago-render" + /> + </small> + </div> + </a> + </li> + </ul> + </div> + </div> + </span> +</template> 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..adcd22f7876 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -0,0 +1,38 @@ +<script> +import { mapGetters } from 'vuex'; +import InlineDiffView from './inline_diff_view.vue'; +import ParallelDiffView from './parallel_diff_view.vue'; + +export default { + components: { + InlineDiffView, + ParallelDiffView, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + }, + computed: { + ...mapGetters(['isInlineView', 'isParallelView']), + }, +}; +</script> + +<template> + <div class="diff-content"> + <div class="diff-viewer"> + <inline-diff-view + v-if="isInlineView" + :diff-file="diffFile" + :diff-lines="diffFile.highlightedDiffLines || []" + /> + <parallel-diff-view + v-if="isParallelView" + :diff-file="diffFile" + :diff-lines="diffFile.parallelDiffLines || []" + /> + </div> + </div> +</template> 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..39d535036f6 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -0,0 +1,39 @@ +<script> +import noteableDiscussion from '../../notes/components/noteable_discussion.vue'; + +export default { + components: { + noteableDiscussion, + }, + props: { + discussions: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <div + v-if="discussions.length" + > + <div + v-for="discussion in discussions" + :key="discussion.id" + class="discussion-notes diff-discussions" + > + <ul + :data-discussion-id="discussion.id" + class="notes" + > + <noteable-discussion + :discussion="discussion" + :render-header="false" + :render-diff-file="false" + :always-expanded="true" + /> + </ul> + </div> + </div> +</template> 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..108eefdac5f --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -0,0 +1,191 @@ +<script> +import { mapActions } from 'vuex'; +import _ from 'underscore'; +import { __, sprintf } from '~/locale'; +import createFlash from '~/flash'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import DiffFileHeader from './diff_file_header.vue'; +import DiffContent from './diff_content.vue'; + +export default { + components: { + DiffFileHeader, + DiffContent, + LoadingIcon, + }, + props: { + file: { + type: Object, + required: true, + }, + currentUser: { + type: Object, + required: true, + }, + }, + data() { + return { + isActive: false, + isLoadingCollapsedDiff: false, + forkMessageVisible: false, + }; + }, + computed: { + isDiscussionsExpanded() { + return true; // TODO: @fatihacet - Fix this. + }, + isCollapsed() { + return this.file.collapsed || false; + }, + viewBlobLink() { + return sprintf( + __('You can %{linkStart}view the blob%{linkEnd} instead.'), + { + linkStart: `<a href="${_.escape(this.file.viewPath)}">`, + linkEnd: '</a>', + }, + false, + ); + }, + }, + mounted() { + document.addEventListener('scroll', this.handleScroll); + }, + beforeDestroy() { + document.removeEventListener('scroll', this.handleScroll); + }, + methods: { + ...mapActions(['loadCollapsedDiff']), + handleToggle() { + const { collapsed, highlightedDiffLines, parallelDiffLines } = this.file; + + if (collapsed && !highlightedDiffLines && !parallelDiffLines.length) { + this.handleLoadCollapsedDiff(); + } else { + this.file.collapsed = !this.file.collapsed; + } + }, + handleScroll() { + if (!this.updating) { + requestAnimationFrame(this.scrollUpdate.bind(this)); + this.updating = true; + } + }, + scrollUpdate() { + const header = document.querySelector('.js-diff-files-changed'); + if (!header) { + this.updating = false; + return; + } + + const { top, bottom } = this.$el.getBoundingClientRect(); + const { top: topOfFixedHeader, bottom: bottomOfFixedHeader } = header.getBoundingClientRect(); + + const headerOverlapsContent = top < topOfFixedHeader && bottom > bottomOfFixedHeader; + const fullyAboveHeader = bottom < bottomOfFixedHeader; + const fullyBelowHeader = top > topOfFixedHeader; + + if (headerOverlapsContent && !this.isActive) { + this.$emit('setActive'); + this.isActive = true; + } else if (this.isActive && (fullyAboveHeader || fullyBelowHeader)) { + this.$emit('unsetActive'); + this.isActive = false; + } + + this.updating = false; + }, + handleLoadCollapsedDiff() { + this.isLoadingCollapsedDiff = true; + + this.loadCollapsedDiff(this.file) + .then(() => { + this.isLoadingCollapsedDiff = false; + this.file.collapsed = false; + }) + .catch(() => { + this.isLoadingCollapsedDiff = false; + createFlash(__('Something went wrong on our end. Please try again!')); + }); + }, + showForkMessage() { + this.forkMessageVisible = true; + }, + hideForkMessage() { + this.forkMessageVisible = false; + }, + }, +}; +</script> + +<template> + <div + :id="file.fileHash" + class="diff-file file-holder" + > + <diff-file-header + :current-user="currentUser" + :diff-file="file" + :collapsible="true" + :expanded="!isCollapsed" + :discussions-expanded="isDiscussionsExpanded" + :add-merge-request-buttons="true" + class="js-file-title file-title" + @toggleFile="handleToggle" + @showForkMessage="showForkMessage" + /> + + <div + v-if="forkMessageVisible" + class="js-file-fork-suggestion-section file-fork-suggestion"> + <span class="file-fork-suggestion-note"> + You're not allowed to <span class="js-file-fork-suggestion-section-action">edit</span> + files in this project directly. Please fork this project, + make your changes there, and submit a merge request. + </span> + <a + :href="file.forkPath" + class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" + > + Fork + </a> + <button + class="js-cancel-fork-suggestion-button btn btn-grouped" + type="button" + @click="hideForkMessage" + > + Cancel + </button> + </div> + + <diff-content + v-show="!isCollapsed" + :class="{ hidden: isCollapsed || file.tooLarge }" + :diff-file="file" + /> + <loading-icon + v-if="isLoadingCollapsedDiff" + class="diff-content loading" + /> + <div + v-show="isCollapsed && !isLoadingCollapsedDiff && !file.tooLarge" + class="nothing-here-block diff-collapsed" + > + {{ __('This diff is collapsed.') }} + <a + class="click-to-expand js-click-to-expand" + href="#" + @click.prevent="handleToggle" + > + {{ __('Click to expand it.') }} + </a> + </div> + <div + v-if="file.tooLarge" + class="nothing-here-block diff-collapsed js-too-large-diff" + > + {{ __('This source diff could not be displayed because it is too large.') }} + <span v-html="viewBlobLink"></span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue new file mode 100644 index 00000000000..6bad389f778 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -0,0 +1,254 @@ +<script> +import _ from 'underscore'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import Tooltip from '~/vue_shared/directives/tooltip'; +import { truncateSha } from '~/lib/utils/text_utility'; +import { __, s__, sprintf } from '~/locale'; +import EditButton from './edit_button.vue'; + +export default { + components: { + ClipboardButton, + EditButton, + Icon, + }, + directives: { + Tooltip, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + collapsible: { + type: Boolean, + required: false, + default: false, + }, + addMergeRequestButtons: { + type: Boolean, + required: false, + default: false, + }, + expanded: { + type: Boolean, + required: false, + default: true, + }, + discussionsExpanded: { + type: Boolean, + required: false, + default: true, + }, + currentUser: { + type: Object, + required: true, + }, + }, + data() { + return { + blobForkSuggestion: null, + }; + }, + computed: { + icon() { + if (this.diffFile.submodule) { + return 'archive'; + } + + return this.diffFile.blob.icon; + }, + titleLink() { + if (this.diffFile.submodule) { + return this.diffFile.submoduleTreeUrl || this.diffFile.submoduleLink; + } + + return `#${this.diffFile.fileHash}`; + }, + filePath() { + if (this.diffFile.submodule) { + return `${this.diffFile.filePath} @ ${truncateSha(this.diffFile.blob.id)}`; + } + + if (this.diffFile.deletedFile) { + return sprintf(__('%{filePath} deleted'), { filePath: this.diffFile.filePath }, false); + } + + return this.diffFile.filePath; + }, + titleTag() { + return this.diffFile.fileHash ? 'a' : 'span'; + }, + isUsingLfs() { + return this.diffFile.storedExternally && this.diffFile.externalStorage === 'lfs'; + }, + collapseIcon() { + return this.expanded ? 'chevron-down' : 'chevron-right'; + }, + isDiscussionsExpanded() { + return this.discussionsExpanded && this.expanded; + }, + viewFileButtonText() { + const truncatedContentSha = _.escape(truncateSha(this.diffFile.contentSha)); + return sprintf( + s__('MergeRequests|View file @ %{commitId}'), + { + commitId: `<span class="commit-sha">${truncatedContentSha}</span>`, + }, + false, + ); + }, + viewReplacedFileButtonText() { + const truncatedBaseSha = _.escape(truncateSha(this.diffFile.diffRefs.baseSha)); + return sprintf( + s__('MergeRequests|View replaced file @ %{commitId}'), + { + commitId: `<span class="commit-sha">${truncatedBaseSha}</span>`, + }, + false, + ); + }, + }, + methods: { + handleToggle(e, checkTarget) { + if (!checkTarget || e.target === this.$refs.header) { + this.$emit('toggleFile'); + } + }, + showForkMessage() { + this.$emit('showForkMessage'); + }, + }, +}; +</script> + +<template> + <div + ref="header" + class="js-file-title file-title file-title-flex-parent" + @click="handleToggle($event, true)" + > + <div class="file-header-content"> + <icon + v-if="collapsible" + :name="collapseIcon" + :size="16" + aria-hidden="true" + class="diff-toggle-caret" + @click.stop="handleToggle" + /> + <a + ref="titleWrapper" + :href="titleLink" + > + <i + :class="`fa-${icon}`" + class="fa fa-fw" + aria-hidden="true" + ></i> + <span v-if="diffFile.renamedFile"> + <strong + v-tooltip + :title="diffFile.oldPath" + class="file-title-name" + data-container="body" + > + {{ diffFile.oldPath }} + </strong> + → + <strong + v-tooltip + :title="diffFile.newPath" + class="file-title-name" + data-container="body" + > + {{ diffFile.newPath }} + </strong> + </span> + + <strong + v-tooltip + v-else + :title="filePath" + class="file-title-name" + data-container="body" + > + {{ filePath }} + </strong> + </a> + + <clipboard-button + :title="__('Copy file path to clipboard')" + :text="diffFile.filePath" + css-class="btn-default btn-transparent btn-clipboard" + /> + + <small + v-if="diffFile.modeChanged" + ref="fileMode" + > + {{ diffFile.aMode }} → {{ diffFile.bMode }} + </small> + + <span + v-if="isUsingLfs" + class="label label-lfs append-right-5" + > + {{ __('LFS') }} + </span> + </div> + + <div + v-if="!diffFile.submodule && addMergeRequestButtons" + class="file-actions d-none d-md-block" + > + <template + v-if="diffFile.blob && diffFile.blob.readableText" + > + <button + :class="{ active: isDiscussionsExpanded }" + :title="s__('MergeRequests|Toggle comments for this file')" + class="btn js-toggle-diff-comments" + type="button" + > + <icon name="comment" /> + </button> + + <edit-button + v-if="!diffFile.deletedFile" + :current-user="currentUser" + :edit-path="diffFile.editPath" + :can-modify-blob="diffFile.canModifyBlob" + @showForkMessage="showForkMessage" + /> + </template> + + <a + v-if="diffFile.replacedViewPath" + :href="diffFile.replacedViewPath" + class="btn view-file js-view-file" + v-html="viewReplacedFileButtonText" + > + </a> + <a + :href="diffFile.viewPath" + class="btn view-file js-view-file" + v-html="viewFileButtonText" + > + </a> + + <a + v-tooltip + v-if="diffFile.externalUrl" + :href="diffFile.externalUrl" + :title="`View on ${diffFile.formattedExternalUrl}`" + target="_blank" + rel="noopener noreferrer" + class="btn btn-file-option" + > + <icon name="external-link" /> + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue new file mode 100644 index 00000000000..3193b18becb --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -0,0 +1,105 @@ +<script> +import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { pluralize, truncate } from '~/lib/utils/text_utility'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants'; + +export default { + directives: { + tooltip, + }, + components: { + Icon, + UserAvatarImage, + }, + props: { + discussions: { + type: Array, + required: true, + }, + }, + computed: { + discussionsExpanded() { + return this.discussions.every(discussion => discussion.expanded); + }, + allDiscussions() { + return this.discussions.reduce((acc, note) => acc.concat(note.notes), []); + }, + notesInGutter() { + return this.allDiscussions.slice(0, COUNT_OF_AVATARS_IN_GUTTER).map(n => ({ + note: n.note, + author: n.author, + })); + }, + moreCount() { + return this.allDiscussions.length - this.notesInGutter.length; + }, + moreText() { + if (this.moreCount === 0) { + return ''; + } + + return pluralize(`${this.moreCount} more comment`, this.moreCount); + }, + }, + methods: { + ...mapActions(['toggleDiscussion']), + getTooltipText(noteData) { + let note = noteData.note; + + if (note.length > LENGTH_OF_AVATAR_TOOLTIP) { + note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP); + } + + return `${noteData.author.name}: ${note}`; + }, + toggleDiscussions() { + this.discussions.forEach(discussion => { + this.toggleDiscussion({ + discussionId: discussion.id, + }); + }); + }, + }, +}; +</script> + +<template> + <div class="diff-comment-avatar-holders"> + <button + v-if="discussionsExpanded" + type="button" + aria-label="Show comments" + class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button" + @click="toggleDiscussions" + > + <icon + :size="12" + name="collapse" + /> + </button> + <template v-else> + <user-avatar-image + v-for="note in notesInGutter" + :key="note.id" + :img-src="note.author.avatar_url" + :tooltip-text="getTooltipText(note)" + :size="19" + class="diff-comment-avatar js-diff-comment-avatar" + @click.native="toggleDiscussions" + /> + <span + v-tooltip + v-if="moreText" + :title="moreText" + class="diff-comments-more-count has-tooltip js-diff-comment-avatar js-diff-comment-plus" + data-container="body" + data-placement="top" + role="button" + @click="toggleDiscussions" + >+{{ moreCount }}</span> + </template> + </div> +</template> 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..05dca0cdd9a --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -0,0 +1,203 @@ +<script> +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { mapState, mapGetters, mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import DiffGutterAvatars from './diff_gutter_avatars.vue'; +import { + MATCH_LINE_TYPE, + CONTEXT_LINE_TYPE, + OLD_NO_NEW_LINE_TYPE, + NEW_NO_NEW_LINE_TYPE, + LINE_POSITION_RIGHT, + UNFOLD_COUNT, +} from '../constants'; +import * as utils from '../store/utils'; + +export default { + components: { + DiffGutterAvatars, + Icon, + }, + props: { + fileHash: { + type: String, + required: true, + }, + contextLinesPath: { + type: String, + required: true, + }, + lineType: { + type: String, + required: false, + default: '', + }, + lineNumber: { + type: Number, + required: false, + default: 0, + }, + lineCode: { + type: String, + required: false, + default: '', + }, + linePosition: { + type: String, + required: false, + default: '', + }, + metaData: { + type: Object, + required: false, + default: () => ({}), + }, + showCommentButton: { + type: Boolean, + required: false, + default: false, + }, + isBottom: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState({ + diffViewType: state => state.diffs.diffViewType, + diffFiles: state => state.diffs.diffFiles, + }), + ...mapGetters(['isLoggedIn', 'discussionsByLineCode']), + isMatchLine() { + return this.lineType === MATCH_LINE_TYPE; + }, + isContextLine() { + return this.lineType === CONTEXT_LINE_TYPE; + }, + isMetaLine() { + return this.lineType === OLD_NO_NEW_LINE_TYPE || this.lineType === NEW_NO_NEW_LINE_TYPE; + }, + lineHref() { + return this.lineCode ? `#${this.lineCode}` : '#'; + }, + shouldShowCommentButton() { + return ( + this.isLoggedIn && + this.showCommentButton && + !this.isMatchLine && + !this.isContextLine && + !this.hasDiscussions && + !this.isMetaLine + ); + }, + discussions() { + return this.discussionsByLineCode[this.lineCode] || []; + }, + hasDiscussions() { + return this.discussions.length > 0; + }, + shouldShowAvatarsOnGutter() { + let render = this.hasDiscussions && this.showCommentButton; + + if (!this.lineType && this.linePosition === LINE_POSITION_RIGHT) { + render = false; + } + + return render; + }, + }, + methods: { + ...mapActions(['loadMoreLines']), + handleCommentButton() { + this.$emit('showCommentForm', { lineCode: this.lineCode }); + }, + handleLoadMoreLines() { + if (this.isRequesting) { + return; + } + + this.isRequesting = true; + const endpoint = this.contextLinesPath; + const oldLineNumber = this.metaData.oldPos || 0; + const newLineNumber = this.metaData.newPos || 0; + const offset = newLineNumber - oldLineNumber; + const bottom = this.isBottom; + const fileHash = this.fileHash; + const view = this.diffViewType; + let unfold = true; + let lineNumber = newLineNumber - 1; + let since = lineNumber - UNFOLD_COUNT; + let to = lineNumber; + + if (bottom) { + lineNumber = newLineNumber + 1; + since = lineNumber; + to = lineNumber + UNFOLD_COUNT; + } else { + const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash); + const indexForInline = utils.findIndexInInlineLines(diffFile.highlightedDiffLines, { + oldLineNumber, + newLineNumber, + }); + const prevLine = diffFile.highlightedDiffLines[indexForInline - 2]; + const prevLineNumber = (prevLine && prevLine.newLine) || 0; + + if (since <= prevLineNumber + 1) { + since = prevLineNumber + 1; + unfold = false; + } + } + + const params = { since, to, bottom, offset, unfold, view }; + const lineNumbers = { oldLineNumber, newLineNumber }; + this.loadMoreLines({ endpoint, params, lineNumbers, fileHash }) + .then(() => { + this.isRequesting = false; + }) + .catch(() => { + createFlash(s__('Diffs|Something went wrong while fetching diff lines.')); + this.isRequesting = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <span + v-if="isMatchLine" + class="context-cell" + role="button" + @click="handleLoadMoreLines" + >...</span> + <template + v-else + > + <button + v-show="shouldShowCommentButton" + type="button" + class="add-diff-note js-add-diff-note-button" + title="Add a comment to this line" + @click="handleCommentButton" + > + <icon + :size="12" + name="comment" + /> + </button> + <a + v-if="lineNumber" + :data-linenumber="lineNumber" + :href="lineHref" + > + </a> + <diff-gutter-avatars + v-if="shouldShowAvatarsOnGutter" + :discussions="discussions" + /> + </template> + </div> +</template> 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..86f5e98194d --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -0,0 +1,93 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import noteForm from '../../notes/components/note_form.vue'; +import { getNoteFormData } from '../store/utils'; + +export default { + components: { + noteForm, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + line: { + type: Object, + required: true, + }, + position: { + type: String, + required: false, + default: '', + }, + noteTargetLine: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState({ + noteableData: state => state.notes.noteableData, + diffViewType: state => state.diffs.diffViewType, + }), + ...mapGetters(['noteableType', 'getNotesDataByProp']), + }, + methods: { + ...mapActions(['cancelCommentForm', 'saveNote', 'fetchDiscussions']), + handleCancelCommentForm() { + this.cancelCommentForm({ + lineCode: this.line.lineCode, + }); + }, + handleSaveNote(note) { + const postData = getNoteFormData({ + note, + noteableData: this.noteableData, + noteableType: this.noteableType, + noteTargetLine: this.noteTargetLine, + diffViewType: this.diffViewType, + diffFile: this.diffFile, + linePosition: this.position, + }); + + this.saveNote(postData) + .then(() => { + const endpoint = this.getNotesDataByProp('discussionsPath'); + + this.fetchDiscussions(endpoint) + .then(() => { + this.handleCancelCommentForm(); + }) + .catch(() => { + createFlash(s__('MergeRequests|Updating discussions failed')); + }); + }) + .catch(() => { + createFlash(s__('MergeRequests|Saving the comment failed')); + }); + }, + }, +}; +</script> + +<template> + <div + class="content discussion-form discussion-form-container discussion-notes" + > + <note-form + :is-editing="true" + :line-code="line.lineCode" + save-button-title="Comment" + class="diff-comment-form" + @cancelForm="handleCancelCommentForm" + @handleFormUpdate="handleSaveNote" + /> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue new file mode 100644 index 00000000000..ebf90631d76 --- /dev/null +++ b/app/assets/javascripts/diffs/components/edit_button.vue @@ -0,0 +1,42 @@ +<script> +export default { + props: { + editPath: { + type: String, + required: true, + }, + currentUser: { + type: Object, + required: true, + }, + canModifyBlob: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + handleEditClick(evt) { + if (!this.currentUser || this.canModifyBlob) { + // if we can Edit, do default Edit button behavior + return; + } + + if (this.currentUser.canFork && this.currentUser.canCreateMergeRequest) { + evt.preventDefault(); + this.$emit('showForkMessage'); + } + }, + }, +}; +</script> + +<template> + <a + :href="editPath" + class="btn btn-default js-edit-blob" + @click="handleEditClick" + > + Edit + </a> +</template> diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue new file mode 100644 index 00000000000..017dcfcc357 --- /dev/null +++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue @@ -0,0 +1,51 @@ +<script> +export default { + props: { + total: { + type: String, + required: true, + }, + visible: { + type: Number, + required: true, + }, + plainDiffPath: { + type: String, + required: true, + }, + emailPatchPath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="alert alert-warning"> + <h4> + {{ __('Too many changes to show.') }} + <div class="pull-right"> + <a + :href="plainDiffPath" + class="btn btn-sm" + > + {{ __('Plain diff') }} + </a> + <a + :href="emailPatchPath" + class="btn btn-sm" + > + {{ __('Email patch') }} + </a> + </div> + </h4> + <p> + To preserve performance only + <strong> + {{ visible }} of {{ total }} + </strong> + files are displayed. + </p> + </div> +</template> 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..0ed3dc7f3ad --- /dev/null +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -0,0 +1,117 @@ +<script> +import diffContentMixin from '../mixins/diff_content'; +import { + MATCH_LINE_TYPE, + CONTEXT_LINE_TYPE, + OLD_NO_NEW_LINE_TYPE, + NEW_NO_NEW_LINE_TYPE, + LINE_HOVER_CLASS_NAME, + LINE_UNFOLD_CLASS_NAME, +} from '../constants'; + +export default { + mixins: [diffContentMixin], + methods: { + handleMouse(lineCode, isOver) { + this.hoveredLineCode = isOver ? lineCode : null; + }, + getLineClass(line) { + const isSameLine = this.hoveredLineCode && this.hoveredLineCode === line.lineCode; + const isMatchLine = line.type === MATCH_LINE_TYPE; + const isContextLine = line.type === CONTEXT_LINE_TYPE; + const isMetaLine = line.type === OLD_NO_NEW_LINE_TYPE || line.type === NEW_NO_NEW_LINE_TYPE; + + return { + [line.type]: line.type, + [LINE_UNFOLD_CLASS_NAME]: isMatchLine, + [LINE_HOVER_CLASS_NAME]: + this.isLoggedIn && isSameLine && !isMatchLine && !isContextLine && !isMetaLine, + }; + }, + }, +}; +</script> + +<template> + <table + :class="userColorScheme" + :data-commit-id="commitId" + class="code diff-wrap-lines js-syntax-highlight text-file"> + <tbody> + <template + v-for="(line, index) in normalizedDiffLines" + > + <tr + :id="line.lineCode || `${fileHash}_${line.oldLine}_${line.newLine}`" + :key="line.lineCode" + :class="getRowClass(line)" + class="line_holder" + @mouseover="handleMouse(line.lineCode, true)" + @mouseout="handleMouse(line.lineCode, false)" + > + <td + :class="getLineClass(line)" + class="diff-line-num old_line" + > + <diff-line-gutter-content + :file-hash="fileHash" + :line-type="line.type" + :line-code="line.lineCode" + :line-number="line.oldLine" + :meta-data="line.metaData" + :show-comment-button="true" + :context-lines-path="diffFile.contextLinesPath" + :is-bottom="index + 1 === diffLinesLength" + @showCommentForm="handleShowCommentForm" + /> + </td> + <td + :class="getLineClass(line)" + class="diff-line-num new_line" + > + <diff-line-gutter-content + :file-hash="fileHash" + :line-type="line.type" + :line-code="line.lineCode" + :line-number="line.newLine" + :meta-data="line.metaData" + :is-bottom="index + 1 === diffLinesLength" + :context-lines-path="diffFile.contextLinesPath" + /> + </td> + <td + :class="line.type" + class="line_content" + v-html="line.richText" + > + </td> + </tr> + <tr + v-if="isDiscussionExpanded(line.lineCode) || diffLineCommentForms[line.lineCode]" + :key="index" + :class="discussionsByLineCode[line.lineCode] ? '' : 'js-temp-notes-holder'" + class="notes_holder" + > + <td + class="notes_line" + colspan="2" + ></td> + <td class="notes_content"> + <div class="content"> + <diff-discussions + :discussions="discussionsByLineCode[line.lineCode] || []" + /> + <diff-line-note-form + v-if="diffLineCommentForms[line.lineCode]" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line" + :note-target-line="diffLines[index]" + /> + </div> + </td> + </tr> + </template> + </tbody> + </table> +</template> diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue new file mode 100644 index 00000000000..d817157fbcd --- /dev/null +++ b/app/assets/javascripts/diffs/components/no_changes.vue @@ -0,0 +1,49 @@ +<script> +import { mapState } from 'vuex'; +import emptyImage from '~/../../views/shared/icons/_mr_widget_empty_state.svg'; + +export default { + data() { + return { + emptyImage, + }; + }, + computed: { + ...mapState({ + sourceBranch: state => state.notes.noteableData.source_branch, + targetBranch: state => state.notes.noteableData.target_branch, + newBlobPath: state => state.notes.noteableData.new_blob_path, + }), + }, +}; +</script> + +<template> + <div + class="row empty-state nothing-here-block" + > + <div class="col-xs-12"> + <div class="svg-content"> + <span + v-html="emptyImage" + ></span> + </div> + </div> + <div class="col-xs-12"> + <div class="text-content text-center"> + No changes between + <span class="ref-name">{{ sourceBranch }}</span> + and + <span class="ref-name">{{ targetBranch }}</span> + <div class="text-center"> + <a + :href="newBlobPath" + class="btn btn-save" + > + {{ __('Create commit') }} + </a> + </div> + </div> + </div> + </div> +</template> 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..2ddf8e6c6ed --- /dev/null +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -0,0 +1,224 @@ +<script> +import diffContentMixin from '../mixins/diff_content'; +import { + EMPTY_CELL_TYPE, + MATCH_LINE_TYPE, + CONTEXT_LINE_TYPE, + OLD_NO_NEW_LINE_TYPE, + NEW_NO_NEW_LINE_TYPE, + LINE_HOVER_CLASS_NAME, + LINE_UNFOLD_CLASS_NAME, + LINE_POSITION_RIGHT, +} from '../constants'; + +export default { + mixins: [diffContentMixin], + computed: { + parallelDiffLines() { + return this.normalizedDiffLines.map(line => { + if (!line.left) { + Object.assign(line, { left: { type: EMPTY_CELL_TYPE } }); + } else if (!line.right) { + Object.assign(line, { right: { type: EMPTY_CELL_TYPE } }); + } + + return line; + }); + }, + }, + methods: { + hasDiscussion(line) { + const discussions = this.discussionsByLineCode; + const hasDiscussion = discussions[line.left.lineCode] || discussions[line.right.lineCode]; + + return hasDiscussion; + }, + getClassName(line, position) { + const { type, lineCode } = line[position]; + const isMatchLine = type === MATCH_LINE_TYPE; + const isContextLine = !isMatchLine && type !== EMPTY_CELL_TYPE && type !== CONTEXT_LINE_TYPE; + const isMetaLine = type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE; + const isSameLine = this.hoveredLineCode && this.hoveredLineCode === lineCode; + const isSameSection = position === this.hoveredSection; + + return { + [type]: type, + [LINE_UNFOLD_CLASS_NAME]: isMatchLine, + [LINE_HOVER_CLASS_NAME]: + this.isLoggedIn && isContextLine && isSameLine && isSameSection && !isMetaLine, + }; + }, + handleMouse(e, line, isHover) { + if (isHover) { + const cell = e.target.closest('td'); + + if (this.$refs.leftLines.indexOf(cell) > -1) { + this.hoveredLineCode = line.left.lineCode; + this.hoveredSection = 'left'; + } else if (this.$refs.rightLines.indexOf(cell) > -1) { + this.hoveredLineCode = line.right.lineCode; + this.hoveredSection = 'right'; + } + } else { + this.hoveredLineCode = null; + this.hoveredSection = null; + } + }, + shouldRenderDiscussionsRow(line) { + const hasDiscussion = this.hasDiscussion(line) && this.hasAnyExpandedDiscussion(line); + const hasCommentFormOnLeft = this.diffLineCommentForms[line.left.lineCode]; + const hasCommentFormOnRight = this.diffLineCommentForms[line.right.lineCode]; + + return hasDiscussion || hasCommentFormOnLeft || hasCommentFormOnRight; + }, + shouldRenderDiscussions(line, position) { + const { lineCode } = line[position]; + let render = this.discussionsByLineCode[lineCode] && this.isDiscussionExpanded(lineCode); + + // Avoid rendering context line discussions on the right side in parallel view + if (position === LINE_POSITION_RIGHT) { + render = render && line.right.type; + } + + return render; + }, + hasAnyExpandedDiscussion(line) { + const isLeftExpanded = this.isDiscussionExpanded(line.left.lineCode); + const isRightExpanded = this.isDiscussionExpanded(line.right.lineCode); + + return isLeftExpanded || isRightExpanded; + }, + getLineCode(line, side) { + const lineCode = side.lineCode; + if (lineCode) { + return lineCode; + } + + return `${this.fileHash}_${line.left.oldLine}_${line.right.newLine}`; + }, + }, +}; +</script> + +<template> + <div + :class="userColorScheme" + :data-commit-id="commitId" + class="code diff-wrap-lines js-syntax-highlight text-file"> + <table> + <tbody> + <template + v-for="(line, index) in parallelDiffLines" + > + <tr + :key="index" + :class="getRowClass(line)" + class="line_holder parallel" + @mouseover="handleMouse($event, line, true)" + @mouseout="handleMouse($event, line, false)" + > + <td + ref="leftLines" + :class="getClassName(line, 'left')" + class="diff-line-num old_line" + > + <diff-line-gutter-content + :file-hash="fileHash" + :line-type="line.left.type" + :line-code="line.left.lineCode" + :line-number="line.left.oldLine" + :meta-data="line.left.metaData" + :show-comment-button="true" + :context-lines-path="diffFile.contextLinesPath" + :is-bottom="index + 1 === diffLinesLength" + line-position="left" + @showCommentForm="handleShowCommentForm" + /> + </td> + <td + ref="leftLines" + :class="getClassName(line, 'left')" + :id="getLineCode(line, line.left)" + class="line_content parallel left-side" + v-html="line.left.richText" + > + </td> + <td + ref="rightLines" + :class="getClassName(line, 'right')" + class="diff-line-num new_line" + > + <diff-line-gutter-content + :file-hash="fileHash" + :line-type="line.right.type" + :line-code="line.right.lineCode" + :line-number="line.right.newLine" + :meta-data="line.right.metaData" + :show-comment-button="true" + :context-lines-path="diffFile.contextLinesPath" + :is-bottom="index + 1 === diffLinesLength" + line-position="right" + @showCommentForm="handleShowCommentForm" + /> + </td> + <td + ref="rightLines" + :class="getClassName(line, 'right')" + :id="getLineCode(line, line.right)" + class="line_content parallel right-side" + v-html="line.right.richText" + > + </td> + </tr> + <tr + v-if="shouldRenderDiscussionsRow(line)" + :key="line.left.lineCode || line.right.lineCode" + :class="hasDiscussion(line) ? '' : 'js-temp-notes-holder'" + class="notes_holder" + > + <td class="notes_line old"></td> + <td class="notes_content parallel old"> + <div + v-if="shouldRenderDiscussions(line, 'left')" + class="content" + > + <diff-discussions + :discussions="discussionsByLineCode[line.left.lineCode]" + /> + </div> + <diff-line-note-form + v-if="diffLineCommentForms[line.left.lineCode] && + diffLineCommentForms[line.left.lineCode]" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line.left" + :note-target-line="diffLines[index].left" + position="left" + /> + </td> + <td class="notes_line new"></td> + <td class="notes_content parallel new"> + <div + v-if="shouldRenderDiscussions(line, 'right')" + class="content" + > + <diff-discussions + :discussions="discussionsByLineCode[line.right.lineCode]" + /> + </div> + <diff-line-note-form + v-if="diffLineCommentForms[line.right.lineCode] && + diffLineCommentForms[line.right.lineCode] && line.right.type" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line.right" + :note-target-line="diffLines[index].right" + position="right" + /> + </td> + </tr> + </template> + </tbody> + </table> + </div> +</template> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js new file mode 100644 index 00000000000..1a7478b307e --- /dev/null +++ b/app/assets/javascripts/diffs/constants.js @@ -0,0 +1,24 @@ +export const INLINE_DIFF_VIEW_TYPE = 'inline'; +export const PARALLEL_DIFF_VIEW_TYPE = 'parallel'; +export const MATCH_LINE_TYPE = 'match'; +export const OLD_NO_NEW_LINE_TYPE = 'old-nonewline'; +export const NEW_NO_NEW_LINE_TYPE = 'new-nonewline'; +export const CONTEXT_LINE_TYPE = 'context'; +export const EMPTY_CELL_TYPE = 'empty-cell'; +export const COMMENT_FORM_TYPE = 'commentForm'; +export const DIFF_NOTE_TYPE = 'DiffNote'; +export const NEW_LINE_TYPE = 'new'; +export const OLD_LINE_TYPE = 'old'; +export const TEXT_DIFF_POSITION_TYPE = 'text'; + +export const LINE_POSITION_LEFT = 'left'; +export const LINE_POSITION_RIGHT = 'right'; + +export const DIFF_VIEW_COOKIE_NAME = 'diff_view'; +export const LINE_HOVER_CLASS_NAME = 'is-over'; +export const LINE_UNFOLD_CLASS_NAME = 'unfold js-unfold'; +export const CONTEXT_LINE_CLASS_NAME = 'diff-expanded'; + +export const UNFOLD_COUNT = 20; +export const COUNT_OF_AVATARS_IN_GUTTER = 3; +export const LENGTH_OF_AVATAR_TOOLTIP = 17; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js new file mode 100644 index 00000000000..f6840f87034 --- /dev/null +++ b/app/assets/javascripts/diffs/index.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import { mapState } from 'vuex'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import diffsApp from './components/app.vue'; + +export default function initDiffsApp(store) { + return new Vue({ + el: '#js-diffs-app', + name: 'MergeRequestDiffs', + components: { + diffsApp, + }, + store, + data() { + const { dataset } = document.querySelector(this.$options.el); + + return { + endpoint: dataset.endpoint, + currentUser: convertObjectPropsToCamelCase(JSON.parse(dataset.currentUserData), { + deep: true, + }), + }; + }, + computed: { + ...mapState({ + activeTab: state => state.page.activeTab, + }), + }, + render(createElement) { + return createElement('diffs-app', { + props: { + endpoint: this.endpoint, + currentUser: this.currentUser, + shouldShow: this.activeTab === 'diffs', + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/diffs/mixins/changed_files.js b/app/assets/javascripts/diffs/mixins/changed_files.js new file mode 100644 index 00000000000..da1339f0ffa --- /dev/null +++ b/app/assets/javascripts/diffs/mixins/changed_files.js @@ -0,0 +1,38 @@ +export default { + props: { + diffFiles: { + type: Array, + required: true, + }, + }, + methods: { + fileChangedIcon(diffFile) { + if (diffFile.deletedFile) { + return 'file-deletion'; + } else if (diffFile.newFile) { + return 'file-addition'; + } + return 'file-modified'; + }, + fileChangedClass(diffFile) { + if (diffFile.deletedFile) { + return 'cred'; + } else if (diffFile.newFile) { + return 'cgreen'; + } + + return ''; + }, + truncatedDiffPath(path) { + const maxLength = 60; + + if (path.length > maxLength) { + const start = path.length - maxLength; + const end = start + maxLength; + return `...${path.slice(start, end)}`; + } + + return path; + }, + }, +}; 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..bef06ad2b52 --- /dev/null +++ b/app/assets/javascripts/diffs/mixins/diff_content.js @@ -0,0 +1,89 @@ +import { mapState, mapGetters, mapActions } from 'vuex'; +import diffDiscussions from '../components/diff_discussions.vue'; +import diffLineGutterContent from '../components/diff_line_gutter_content.vue'; +import diffLineNoteForm from '../components/diff_line_note_form.vue'; +import { trimFirstCharOfLineContent } from '../store/utils'; +import { CONTEXT_LINE_TYPE, CONTEXT_LINE_CLASS_NAME } from '../constants'; + +export default { + props: { + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + }, + data() { + return { + hoveredLineCode: null, + hoveredSection: null, + }; + }, + components: { + diffDiscussions, + diffLineNoteForm, + diffLineGutterContent, + }, + computed: { + ...mapState({ + diffLineCommentForms: state => state.diffs.diffLineCommentForms, + }), + ...mapGetters(['discussionsByLineCode', 'isLoggedIn', 'commit']), + commitId() { + return this.commit && this.commit.id; + }, + userColorScheme() { + return window.gon.user_color_scheme; + }, + normalizedDiffLines() { + return this.diffLines.map(line => { + if (line.richText) { + return this.trimFirstChar(line); + } + + if (line.left) { + Object.assign(line, { left: this.trimFirstChar(line.left) }); + } + + if (line.right) { + Object.assign(line, { right: this.trimFirstChar(line.right) }); + } + + return line; + }); + }, + diffLinesLength() { + return this.normalizedDiffLines.length; + }, + fileHash() { + return this.diffFile.fileHash; + }, + }, + methods: { + ...mapActions(['showCommentForm', 'cancelCommentForm']), + getRowClass(line) { + const isContextLine = line.left + ? line.left.type === CONTEXT_LINE_TYPE + : line.type === CONTEXT_LINE_TYPE; + + return { + [line.type]: line.type, + [CONTEXT_LINE_CLASS_NAME]: isContextLine, + }; + }, + trimFirstChar(line) { + return trimFirstCharOfLineContent(line); + }, + handleShowCommentForm(params) { + this.showCommentForm({ lineCode: params.lineCode }); + }, + isDiscussionExpanded(lineCode) { + const discussions = this.discussionsByLineCode[lineCode]; + + return discussions ? discussions.every(discussion => discussion.expanded) : false; + }, + }, +}; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js new file mode 100644 index 00000000000..f8089b314d3 --- /dev/null +++ b/app/assets/javascripts/diffs/store/actions.js @@ -0,0 +1,99 @@ +import Vue from 'vue'; +import axios from '~/lib/utils/axios_utils'; +import Cookies from 'js-cookie'; +import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import * as types from './mutation_types'; +import { + PARALLEL_DIFF_VIEW_TYPE, + INLINE_DIFF_VIEW_TYPE, + DIFF_VIEW_COOKIE_NAME, +} from '../constants'; + +export const setEndpoint = ({ commit }, endpoint) => { + commit(types.SET_ENDPOINT, endpoint); +}; + +export const setLoadingState = ({ commit }, state) => { + commit(types.SET_LOADING, state); +}; + +export const fetchDiffFiles = ({ state, commit }) => { + commit(types.SET_LOADING, true); + + return axios + .get(state.endpoint) + .then(res => { + commit(types.SET_LOADING, false); + commit(types.SET_MERGE_REQUEST_DIFFS, res.data.merge_request_diffs || []); + commit(types.SET_DIFF_DATA, res.data); + return Vue.nextTick(); + }) + .then(handleLocationHash); +}; + +export const setInlineDiffViewType = ({ commit }) => { + commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE); + + Cookies.set(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE); + const url = mergeUrlParams({ view: INLINE_DIFF_VIEW_TYPE }, window.location.href); + historyPushState(url); +}; + +export const setParallelDiffViewType = ({ commit }) => { + commit(types.SET_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE); + + Cookies.set(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE); + const url = mergeUrlParams({ view: PARALLEL_DIFF_VIEW_TYPE }, window.location.href); + historyPushState(url); +}; + +export const showCommentForm = ({ commit }, params) => { + commit(types.ADD_COMMENT_FORM_LINE, params); +}; + +export const cancelCommentForm = ({ commit }, params) => { + commit(types.REMOVE_COMMENT_FORM_LINE, params); +}; + +export const loadMoreLines = ({ commit }, options) => { + const { endpoint, params, lineNumbers, fileHash } = options; + + params.from_merge_request = true; + + return axios.get(endpoint, { params }).then(res => { + const contextLines = res.data || []; + + commit(types.ADD_CONTEXT_LINES, { + lineNumbers, + contextLines, + params, + fileHash, + }); + }); +}; + +export const loadCollapsedDiff = ({ commit }, file) => + axios.get(file.loadCollapsedDiffUrl).then(res => { + commit(types.ADD_COLLAPSED_DIFFS, { + file, + data: res.data, + }); + }); + +export const expandAllFiles = ({ commit }) => { + commit(types.EXPAND_ALL_FILES); +}; + +export default { + setEndpoint, + setLoadingState, + fetchDiffFiles, + setInlineDiffViewType, + setParallelDiffViewType, + showCommentForm, + cancelCommentForm, + loadMoreLines, + loadCollapsedDiff, + expandAllFiles, +}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js new file mode 100644 index 00000000000..66d0f47d102 --- /dev/null +++ b/app/assets/javascripts/diffs/store/getters.js @@ -0,0 +1,16 @@ +import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants'; + +export default { + isParallelView(state) { + return state.diffViewType === PARALLEL_DIFF_VIEW_TYPE; + }, + isInlineView(state) { + return state.diffViewType === INLINE_DIFF_VIEW_TYPE; + }, + areAllFilesCollapsed(state) { + return state.diffFiles.every(file => file.collapsed); + }, + commit(state) { + return state.commit; + }, +}; diff --git a/app/assets/javascripts/diffs/store/index.js b/app/assets/javascripts/diffs/store/index.js new file mode 100644 index 00000000000..e6aa8f5b12a --- /dev/null +++ b/app/assets/javascripts/diffs/store/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import diffsModule from './modules'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + modules: { + diffs: diffsModule, + }, +}); diff --git a/app/assets/javascripts/diffs/store/modules/index.js b/app/assets/javascripts/diffs/store/modules/index.js new file mode 100644 index 00000000000..882a098c977 --- /dev/null +++ b/app/assets/javascripts/diffs/store/modules/index.js @@ -0,0 +1,25 @@ +import Cookies from 'js-cookie'; +import { getParameterValues } from '~/lib/utils/url_utility'; +import actions from '../actions'; +import getters from '../getters'; +import mutations from '../mutations'; +import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; + +const viewTypeFromQueryString = getParameterValues('view')[0]; +const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); +const defaultViewType = INLINE_DIFF_VIEW_TYPE; + +export default { + state: { + isLoading: true, + endpoint: '', + commit: null, + diffFiles: [], + mergeRequestDiffs: [], + diffLineCommentForms: {}, + diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, + }, + getters, + actions, + mutations, +}; 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..a65b205b8e7 --- /dev/null +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -0,0 +1,11 @@ +export const SET_ENDPOINT = 'SET_ENDPOINT'; +export const SET_LOADING = 'SET_LOADING'; +export const SET_DIFF_DATA = 'SET_DIFF_DATA'; +export const SET_DIFF_FILES = 'SET_DIFF_FILES'; +export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE'; +export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS'; +export const ADD_COMMENT_FORM_LINE = 'ADD_COMMENT_FORM_LINE'; +export const REMOVE_COMMENT_FORM_LINE = 'REMOVE_COMMENT_FORM_LINE'; +export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES'; +export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS'; +export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js new file mode 100644 index 00000000000..fd9ea73e33d --- /dev/null +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -0,0 +1,85 @@ +import Vue from 'vue'; +import _ from 'underscore'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { findDiffFile, addLineReferences, removeMatchLine, addContextLines } from './utils'; +import * as types from './mutation_types'; + +export default { + [types.SET_ENDPOINT](state, endpoint) { + Object.assign(state, { endpoint }); + }, + + [types.SET_LOADING](state, isLoading) { + Object.assign(state, { isLoading }); + }, + + [types.SET_DIFF_DATA](state, data) { + Object.assign(state, { + ...convertObjectPropsToCamelCase(data, { deep: true }), + }); + }, + + [types.SET_DIFF_FILES](state, diffFiles) { + Object.assign(state, { + diffFiles: convertObjectPropsToCamelCase(diffFiles, { deep: true }), + }); + }, + + [types.SET_MERGE_REQUEST_DIFFS](state, mergeRequestDiffs) { + Object.assign(state, { + mergeRequestDiffs: convertObjectPropsToCamelCase(mergeRequestDiffs, { deep: true }), + }); + }, + + [types.SET_DIFF_VIEW_TYPE](state, diffViewType) { + Object.assign(state, { diffViewType }); + }, + + [types.ADD_COMMENT_FORM_LINE](state, { lineCode }) { + Vue.set(state.diffLineCommentForms, lineCode, true); + }, + + [types.REMOVE_COMMENT_FORM_LINE](state, { lineCode }) { + Vue.delete(state.diffLineCommentForms, lineCode); + }, + + [types.ADD_CONTEXT_LINES](state, options) { + const { lineNumbers, contextLines, fileHash } = options; + const { bottom } = options.params; + const diffFile = findDiffFile(state.diffFiles, fileHash); + const { highlightedDiffLines, parallelDiffLines } = diffFile; + + removeMatchLine(diffFile, lineNumbers, bottom); + const lines = addLineReferences(contextLines, lineNumbers, bottom); + addContextLines({ + inlineLines: highlightedDiffLines, + parallelLines: parallelDiffLines, + contextLines: lines, + bottom, + lineNumbers, + }); + }, + + [types.ADD_COLLAPSED_DIFFS](state, { file, data }) { + const normalizedData = convertObjectPropsToCamelCase(data, { deep: true }); + const [newFileData] = normalizedData.diffFiles.filter(f => f.fileHash === file.fileHash); + + if (newFileData) { + const index = _.findIndex(state.diffFiles, f => f.fileHash === file.fileHash); + state.diffFiles.splice(index, 1, newFileData); + } + }, + + [types.EXPAND_ALL_FILES](state) { + const diffFiles = []; + + state.diffFiles.forEach((file) => { + diffFiles.push({ + ...file, + collapsed: false, + }); + }); + + Object.assign(state, { diffFiles }); + }, +}; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js new file mode 100644 index 00000000000..da7ae16aaf1 --- /dev/null +++ b/app/assets/javascripts/diffs/store/utils.js @@ -0,0 +1,172 @@ +import _ from 'underscore'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { + LINE_POSITION_LEFT, + LINE_POSITION_RIGHT, + TEXT_DIFF_POSITION_TYPE, + DIFF_NOTE_TYPE, + NEW_LINE_TYPE, + OLD_LINE_TYPE, + MATCH_LINE_TYPE, +} from '../constants'; + +export function findDiffFile(files, hash) { + return files.filter(file => file.fileHash === hash)[0]; +} + +export const getReversePosition = linePosition => { + if (linePosition === LINE_POSITION_RIGHT) { + return LINE_POSITION_LEFT; + } + + return LINE_POSITION_RIGHT; +}; + +export function getNoteFormData(params) { + const { + note, + noteableType, + noteableData, + diffFile, + noteTargetLine, + diffViewType, + linePosition, + } = params; + + const position = JSON.stringify({ + base_sha: diffFile.diffRefs.baseSha, + start_sha: diffFile.diffRefs.startSha, + head_sha: diffFile.diffRefs.headSha, + old_path: diffFile.oldPath, + new_path: diffFile.newPath, + position_type: TEXT_DIFF_POSITION_TYPE, + old_line: noteTargetLine.oldLine, + new_line: noteTargetLine.newLine, + }); + + const postData = { + view: diffViewType, + line_type: linePosition === LINE_POSITION_RIGHT ? NEW_LINE_TYPE : OLD_LINE_TYPE, + merge_request_diff_head_sha: diffFile.diffRefs.headSha, + in_reply_to_discussion_id: '', + note_project_id: '', + target_type: noteableData.targetType, + target_id: noteableData.id, + note: { + note, + position, + noteable_type: noteableType, + noteable_id: noteableData.id, + commit_id: '', + type: DIFF_NOTE_TYPE, + line_code: noteTargetLine.lineCode, + }, + }; + + return { + endpoint: noteableData.create_note_path, + data: postData, + }; +} + +export const findIndexInInlineLines = (lines, lineNumbers) => { + const { oldLineNumber, newLineNumber } = lineNumbers; + + return _.findIndex( + lines, + line => line.oldLine === oldLineNumber && line.newLine === newLineNumber, + ); +}; + +export const findIndexInParallelLines = (lines, lineNumbers) => { + const { oldLineNumber, newLineNumber } = lineNumbers; + + return _.findIndex( + lines, + line => + line.left && + line.right && + line.left.oldLine === oldLineNumber && + line.right.newLine === newLineNumber, + ); +}; + +export function removeMatchLine(diffFile, lineNumbers, bottom) { + const indexForInline = findIndexInInlineLines(diffFile.highlightedDiffLines, lineNumbers); + const indexForParallel = findIndexInParallelLines(diffFile.parallelDiffLines, lineNumbers); + const factor = bottom ? 1 : -1; + + diffFile.highlightedDiffLines.splice(indexForInline + factor, 1); + diffFile.parallelDiffLines.splice(indexForParallel + factor, 1); +} + +export function addLineReferences(lines, lineNumbers, bottom) { + const { oldLineNumber, newLineNumber } = lineNumbers; + const lineCount = lines.length; + let matchLineIndex = -1; + + const linesWithNumbers = lines.map((l, index) => { + const line = convertObjectPropsToCamelCase(l); + + if (line.type === MATCH_LINE_TYPE) { + matchLineIndex = index; + } else { + Object.assign(line, { + oldLine: bottom ? oldLineNumber + index + 1 : oldLineNumber + index - lineCount, + newLine: bottom ? newLineNumber + index + 1 : newLineNumber + index - lineCount, + }); + } + + return line; + }); + + if (matchLineIndex > -1) { + const line = linesWithNumbers[matchLineIndex]; + const targetLine = bottom + ? linesWithNumbers[matchLineIndex - 1] + : linesWithNumbers[matchLineIndex + 1]; + + Object.assign(line, { + metaData: { + oldPos: targetLine.oldLine, + newPos: targetLine.newLine, + }, + }); + } + + return linesWithNumbers; +} + +export function addContextLines(options) { + const { inlineLines, parallelLines, contextLines, lineNumbers } = options; + const normalizedParallelLines = contextLines.map(line => ({ + left: line, + right: line, + })); + + if (options.bottom) { + inlineLines.push(...contextLines); + parallelLines.push(...normalizedParallelLines); + } else { + const inlineIndex = findIndexInInlineLines(inlineLines, lineNumbers); + const parallelIndex = findIndexInParallelLines(parallelLines, lineNumbers); + inlineLines.splice(inlineIndex, 0, ...contextLines); + parallelLines.splice(parallelIndex, 0, ...normalizedParallelLines); + } +} + +export function trimFirstCharOfLineContent(line) { + if (!line.richText) { + return line; + } + + const firstChar = line.richText.charAt(0); + + if (firstChar === ' ' || firstChar === '+' || firstChar === '-') { + Object.assign(line, { + richText: line.richText.substring(1), + }); + } + + return line; +} diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index d0b0e5e1ba1..68f92c7f08a 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -1,10 +1,14 @@ import $ from 'jquery'; -import Cookies from 'js-cookie'; import axios from './axios_utils'; import { getLocationHash } from './url_utility'; import { convertToCamelCase } from './text_utility'; +import { isObject } from './type_utility'; -export const getPagePath = (index = 0) => $('body').attr('data-page').split(':')[index]; +export const getPagePath = (index = 0) => { + const page = $('body').attr('data-page') || ''; + + return page.split(':')[index]; +}; export const isInGroupsPage = () => getPagePath() === 'groups'; @@ -34,17 +38,18 @@ export const checkPageAndAction = (page, action) => { export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInEpicPage = () => checkPageAndAction('epics', 'show'); -export const isInNoteablePage = () => isInIssuePage() || isInMRPage(); -export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions'); - -export const ajaxGet = url => axios.get(url, { - params: { format: 'js' }, - responseType: 'text', -}).then(({ data }) => { - $.globalEval(data); -}); -export const rstrip = (val) => { +export const ajaxGet = url => + axios + .get(url, { + params: { format: 'js' }, + responseType: 'text', + }) + .then(({ data }) => { + $.globalEval(data); + }); + +export const rstrip = val => { if (val) { return val.replace(/\s+$/, ''); } @@ -60,7 +65,7 @@ export const disableButtonIfEmptyField = (fieldSelector, buttonSelector, eventNa closestSubmit.disable(); } // eslint-disable-next-line func-names - return field.on(eventName, function () { + return field.on(eventName, function() { if (rstrip($(this).val()) === '') { return closestSubmit.disable(); } @@ -79,7 +84,7 @@ export const handleLocationHash = () => { const target = document.getElementById(hash) || document.getElementById(`user-content-${hash}`); const fixedTabs = document.querySelector('.js-tabs-affix'); - const fixedDiffStats = document.querySelector('.js-diff-files-changed.is-stuck'); + const fixedDiffStats = document.querySelector('.js-diff-files-changed'); const fixedNav = document.querySelector('.navbar-gitlab'); let adjustment = 0; @@ -102,7 +107,7 @@ export const handleLocationHash = () => { // Check if element scrolled into viewport from above or below // Courtesy http://stackoverflow.com/a/7557433/414749 -export const isInViewport = (el) => { +export const isInViewport = el => { const rect = el.getBoundingClientRect(); return ( @@ -113,13 +118,13 @@ export const isInViewport = (el) => { ); }; -export const parseUrl = (url) => { +export const parseUrl = url => { const parser = document.createElement('a'); parser.href = url; return parser; }; -export const parseUrlPathname = (url) => { +export const parseUrlPathname = url => { const parsedUrl = parseUrl(url); // parsedUrl.pathname will return an absolute path for Firefox and a relative path for IE11 // We have to make sure we always have an absolute path. @@ -128,10 +133,14 @@ export const parseUrlPathname = (url) => { // We can trust that each param has one & since values containing & will be encoded // Remove the first character of search as it is always ? -export const getUrlParamsArray = () => window.location.search.slice(1).split('&').map((param) => { - const split = param.split('='); - return [decodeURI(split[0]), split[1]].join('='); -}); +export const getUrlParamsArray = () => + window.location.search + .slice(1) + .split('&') + .map(param => { + const split = param.split('='); + return [decodeURI(split[0]), split[1]].join('='); + }); export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; @@ -141,18 +150,28 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; // 3) Middle-click or Mouse Wheel Click (e.which is 2) export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2; -export const scrollToElement = (element) => { +export const contentTop = () => { + const perfBar = $('#js-peek').height() || 0; + const mrTabsHeight = $('.merge-request-tabs').height() || 0; + const headerHeight = $('.navbar-gitlab').height() || 0; + const diffFilesChanged = $('.js-diff-files-changed').height() || 0; + + return perfBar + mrTabsHeight + headerHeight + diffFilesChanged; +}; + +export const scrollToElement = element => { let $el = element; if (!(element instanceof $)) { $el = $(element); } const top = $el.offset().top; - const mrTabsHeight = $('.merge-request-tabs').height() || 0; - const headerHeight = $('.navbar-gitlab').height() || 0; - return $('body, html').animate({ - scrollTop: top - mrTabsHeight - headerHeight, - }, 200); + return $('body, html').animate( + { + scrollTop: top - contentTop(), + }, + 200, + ); }; /** @@ -212,7 +231,8 @@ export const insertText = (target, text) => { }; export const nodeMatchesSelector = (node, selector) => { - const matches = Element.prototype.matches || + const matches = + Element.prototype.matches || Element.prototype.matchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.msMatchesSelector || @@ -241,10 +261,10 @@ export const nodeMatchesSelector = (node, selector) => { this will take in the headers from an API response and normalize them this way we don't run into production issues when nginx gives us lowercased header keys */ -export const normalizeHeaders = (headers) => { +export const normalizeHeaders = headers => { const upperCaseHeaders = {}; - Object.keys(headers || {}).forEach((e) => { + Object.keys(headers || {}).forEach(e => { upperCaseHeaders[e.toUpperCase()] = headers[e]; }); @@ -255,11 +275,11 @@ export const normalizeHeaders = (headers) => { this will take in the getAllResponseHeaders result and normalize them this way we don't run into production issues when nginx gives us lowercased header keys */ -export const normalizeCRLFHeaders = (headers) => { +export const normalizeCRLFHeaders = headers => { const headersObject = {}; const headersArray = headers.split('\n'); - headersArray.forEach((header) => { + headersArray.forEach(header => { const keyValue = header.split(': '); headersObject[keyValue[0]] = keyValue[1]; }); @@ -295,15 +315,13 @@ export const parseIntPagination = paginationInformation => ({ export const parseQueryStringIntoObject = (query = '') => { if (query === '') return {}; - return query - .split('&') - .reduce((acc, element) => { - const val = element.split('='); - Object.assign(acc, { - [val[0]]: decodeURIComponent(val[1]), - }); - return acc; - }, {}); + return query.split('&').reduce((acc, element) => { + const val = element.split('='); + Object.assign(acc, { + [val[0]]: decodeURIComponent(val[1]), + }); + return acc; + }, {}); }; /** @@ -312,9 +330,13 @@ export const parseQueryStringIntoObject = (query = '') => { * * @param {Object} params */ -export const objectToQueryString = (params = {}) => Object.keys(params).map(param => `${param}=${params[param]}`).join('&'); +export const objectToQueryString = (params = {}) => + Object.keys(params) + .map(param => `${param}=${params[param]}`) + .join('&'); -export const buildUrlWithCurrentLocation = param => (param ? `${window.location.pathname}${param}` : window.location.pathname); +export const buildUrlWithCurrentLocation = param => + (param ? `${window.location.pathname}${param}` : window.location.pathname); /** * Based on the current location and the string parameters provided @@ -322,7 +344,7 @@ export const buildUrlWithCurrentLocation = param => (param ? `${window.location. * * @param {String} param */ -export const historyPushState = (newUrl) => { +export const historyPushState = newUrl => { window.history.pushState({}, document.title, newUrl); }; @@ -371,7 +393,7 @@ export const backOff = (fn, timeout = 60000) => { let timeElapsed = 0; return new Promise((resolve, reject) => { - const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg)); + const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); const next = () => { if (timeElapsed < timeout) { @@ -447,7 +469,8 @@ export const resetFavicon = () => { }; export const setCiStatusFavicon = pageUrl => - axios.get(pageUrl) + axios + .get(pageUrl) .then(({ data }) => { if (data && data.favicon) { return setFaviconOverlay(data.favicon); @@ -469,28 +492,38 @@ 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}`; +export const imagePath = imgUrl => + `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`; export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => { // Click a .js-select-on-focus field, select the contents // Prevent a mouseup event from deselecting the input $(selector).on('focusin', function selectOnFocusCallback() { - $(this).select().one('mouseup', (e) => { - e.preventDefault(); - }); + $(this) + .select() + .one('mouseup', e => { + e.preventDefault(); + }); }); }; diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 914de9de940..6f42382246d 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -1,7 +1,4 @@ -import $ from 'jquery'; -import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie } from './common_utils'; - -const isVueMRDiscussions = () => isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); +import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils'; export const addClassIfElementExists = (element, className) => { if (element) { @@ -9,4 +6,4 @@ export const addClassIfElementExists = (element, className) => { } }; -export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isVueMRDiscussions(); +export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isInMRPage(); diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 5e786ee6935..5f25c6ce1ae 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -58,6 +58,14 @@ export const slugify = str => str.trim().toLowerCase(); export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`; /** + * Truncate SHA to 8 characters + * + * @param {String} sha + * @returns {String} + */ +export const truncateSha = sha => sha.substr(0, 8); + +/** * Capitalizes first character * * @param {String} text @@ -98,3 +106,16 @@ export const convertToSentenceCase = string => { return splitWord.join(' '); }; + +/** + * Splits camelCase or PascalCase words + * e.g. HelloWorld => Hello World + * + * @param {*} string +*/ +export const splitCamelCase = string => ( + string + .replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2') + .replace(/([a-z\d])([A-Z])/g, '$1 $2') + .trim() +); diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 83decc1d298..7bf2c56dd5d 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 65ab41559be..83d326ef68f 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'; @@ -8,6 +9,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; import initChangesDropdown from './init_changes_dropdown'; import bp from './breakpoints'; import { parseUrlPathname, handleLocationHash, isMetaClick } from './lib/utils/common_utils'; +import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { getLocationHash } from './lib/utils/url_utility'; import initDiscussionTab from './image_diff/init_discussion_tab'; import Diff from './diff'; @@ -70,11 +72,13 @@ export default class MergeRequestTabs { const navbar = document.querySelector('.navbar-gitlab'); const peek = document.getElementById('js-peek'); const paddingTop = 16; + this.commitsTab = document.querySelector('.tab-content .commits.tab-pane'); this.diffsLoaded = false; this.pipelinesLoaded = false; this.commitsLoaded = false; this.fixedLayoutPref = null; + this.eventHub = new Vue(); this.setUrl = setUrl !== undefined ? setUrl : true; this.setCurrentAction = this.setCurrentAction.bind(this); @@ -149,7 +153,9 @@ export default class MergeRequestTabs { this.resetViewContainer(); this.destroyPipelinesView(); } else if (this.isDiffAction(action)) { - this.loadDiff($target.attr('href')); + if (!isInVueNoteablePage()) { + this.loadDiff($target.attr('href')); + } if (bp.getBreakpointSize() !== 'lg') { this.shrinkView(); } @@ -157,6 +163,7 @@ export default class MergeRequestTabs { this.expandViewContainer(); } this.destroyPipelinesView(); + this.commitsTab.classList.remove('active'); } else if (action === 'pipelines') { this.resetViewContainer(); this.mountPipelinesView(); @@ -172,6 +179,8 @@ export default class MergeRequestTabs { if (this.setUrl) { this.setCurrentAction(action); } + + this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); } scrollToElement(container) { diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index e3c5bf06b3d..3c0c9995cc2 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -1,20 +1,32 @@ +import $ from 'jquery'; import Vue from 'vue'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import initDiffsApp from '../diffs'; import notesApp from '../notes/components/notes_app.vue'; import discussionCounter from '../notes/components/discussion_counter.vue'; -import store from '../notes/stores'; +import store from './stores'; +import MergeRequest from '../merge_request'; export default function initMrNotes() { + const mrShowNode = document.querySelector('.merge-request'); + // eslint-disable-next-line no-new + new MergeRequest({ + action: mrShowNode.dataset.mrAction, + }); + // eslint-disable-next-line no-new new Vue({ el: '#js-vue-mr-discussions', + name: 'MergeRequestDiscussions', components: { notesApp, }, + store, data() { - const notesDataset = document.getElementById('js-vue-mr-discussions') - .dataset; + const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; const noteableData = JSON.parse(notesDataset.noteableData); noteableData.noteableType = notesDataset.noteableType; + noteableData.targetType = notesDataset.targetType; return { noteableData, @@ -22,12 +34,42 @@ export default function initMrNotes() { notesData: JSON.parse(notesDataset.notesData), }; }, + computed: { + ...mapGetters(['discussionTabCounter']), + ...mapState({ + activeTab: state => state.page.activeTab, + }), + }, + watch: { + discussionTabCounter() { + this.updateDiscussionTabCounter(); + }, + }, + mounted() { + this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'); + this.setActiveTab(window.mrTabs.getCurrentAction()); + + window.mrTabs.eventHub.$on('MergeRequestTabChange', tab => { + this.setActiveTab(tab); + }); + $(document).on('visibilitychange', this.updateDiscussionTabCounter); + }, + beforeDestroy() { + $(document).off('visibilitychange', this.updateDiscussionTabCounter); + }, + methods: { + ...mapActions(['setActiveTab']), + updateDiscussionTabCounter() { + this.notesCountBadge.text(this.discussionTabCounter); + }, + }, render(createElement) { return createElement('notes-app', { props: { noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, + shouldShow: this.activeTab === 'show', }, }); }, @@ -36,6 +78,7 @@ export default function initMrNotes() { // eslint-disable-next-line no-new new Vue({ el: '#js-vue-discussion-counter', + name: 'DiscussionCounter', components: { discussionCounter, }, @@ -44,4 +87,6 @@ export default function initMrNotes() { return createElement('discussion-counter'); }, }); + + initDiffsApp(store); } 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..b10e9f9f9f1 --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/getters.js @@ -0,0 +1,5 @@ +export default { + isLoggedIn(state, getters) { + return !!getters.getUserData.id; + }, +}; 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..dd2019001db --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import notesModule from '~/notes/stores/modules'; +import diffsModule from '~/diffs/store/modules'; +import mrPageModule from './modules'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + modules: { + page: mrPageModule, + notes: notesModule, + diffs: diffsModule, + }, +}); diff --git a/app/assets/javascripts/mr_notes/stores/modules/index.js b/app/assets/javascripts/mr_notes/stores/modules/index.js new file mode 100644 index 00000000000..660081f76c8 --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/modules/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 d23939c00e1..2f752d2dcd6 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -30,7 +30,7 @@ import { getPagePath, scrollToElement, isMetaKey, - hasVueMRDiscussionsCookie, + isInMRPage, } from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; import { localTimeAgo } from './lib/utils/datetime_utility'; @@ -45,21 +45,9 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; export default class Notes { - static initialize( - notes_url, - note_ids, - last_fetched_at, - view, - enableGFM = true, - ) { + static initialize(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { if (!this.instance) { - this.instance = new Notes( - notes_url, - note_ids, - last_fetched_at, - view, - enableGFM, - ); + this.instance = new Notes(notes_url, note_ids, last_fetched_at, view, enableGFM); } } @@ -102,9 +90,7 @@ export default class Notes { this.basePollingInterval = 15000; this.maxPollingSteps = 4; - this.$wrapperEl = hasVueMRDiscussionsCookie() - ? $(document).find('.diffs') - : $(document); + this.$wrapperEl = isInMRPage() ? $(document).find('.diffs') : $(document); this.cleanBinding(); this.addBinding(); this.setPollingInterval(); @@ -144,55 +130,27 @@ export default class Notes { // Reopen and close actions for Issue/MR combined with note form submit this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment); this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment); - this.$wrapperEl.on( - 'keyup input', - '.js-note-text', - this.updateTargetButtons, - ); + this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons); // resolve a discussion this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) this.$wrapperEl.on('click', '.js-note-delete', this.removeNote); // delete note attachment - this.$wrapperEl.on( - 'click', - '.js-note-attachment-delete', - this.removeAttachment, - ); + this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment); // reset main target form when clicking discard this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm); // update the file name when an attachment is selected - this.$wrapperEl.on( - 'change', - '.js-note-attachment-input', - this.updateFormAttachment, - ); + this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment); // reply to diff/discussion notes - this.$wrapperEl.on( - 'click', - '.js-discussion-reply-button', - this.onReplyToDiscussionNote, - ); + this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); // add diff note this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote); // add diff note for images - this.$wrapperEl.on( - 'click', - '.js-add-image-diff-note-button', - this.onAddImageDiffNote, - ); + this.$wrapperEl.on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote); // hide diff note form - this.$wrapperEl.on( - 'click', - '.js-close-discussion-note-form', - this.cancelDiscussionForm, - ); + this.$wrapperEl.on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); // toggle commit list - this.$wrapperEl.on( - 'click', - '.system-note-commit-list-toggler', - this.toggleCommitList, - ); + this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList); this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff); this.$wrapperEl.on('click', '.js-toggle-lazy-diff-retry-button', this.onClickRetryLazyLoad.bind(this)); @@ -203,16 +161,8 @@ export default class Notes { this.$wrapperEl.on('issuable:change', this.refresh); // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote); - this.$wrapperEl.on( - 'ajax:success', - '.js-discussion-note-form', - this.addDiscussionNote, - ); - this.$wrapperEl.on( - 'ajax:success', - '.js-main-target-form', - this.resetMainTargetForm, - ); + this.$wrapperEl.on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); + this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); this.$wrapperEl.on( 'ajax:complete', '.js-main-target-form', @@ -222,8 +172,6 @@ export default class Notes { this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText); // 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); } cleanBinding() { @@ -247,21 +195,14 @@ 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); } static initCommentTypeToggle(form) { - const dropdownTrigger = form.querySelector( - '.js-comment-type-dropdown .dropdown-toggle', - ); - const dropdownList = form.querySelector( - '.js-comment-type-dropdown .dropdown-menu', - ); + const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle'); + const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu'); const noteTypeInput = form.querySelector('#note_type'); - const submitButton = form.querySelector( - '.js-comment-type-dropdown .js-comment-submit-button', - ); + const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button'); const closeButton = form.querySelector('.js-note-target-close'); const reopenButton = form.querySelector('.js-note-target-reopen'); @@ -297,9 +238,7 @@ export default class Notes { return; } myLastNote = $( - `li.note[data-author-id='${ - gon.current_user_id - }'][data-editable]:last`, + `li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, .notes_holder, #notes'), ); if (myLastNote.length) { @@ -396,8 +335,7 @@ export default class Notes { if (shouldReset == null) { shouldReset = true; } - nthInterval = - this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); + nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); if (shouldReset) { this.pollingInterval = this.basePollingInterval; } else if (this.pollingInterval < nthInterval) { @@ -418,10 +356,7 @@ export default class Notes { loadAwardsHandler() .then(awardsHandler => { - awardsHandler.addAwardToEmojiBar( - votesBlock, - noteEntity.commands_changes.emoji_award, - ); + awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); awardsHandler.scrollToAwards(); }) .catch(() => { @@ -471,17 +406,10 @@ export default class Notes { if (!noteEntity.valid) { if (noteEntity.errors && noteEntity.errors.commands_only) { - if ( - noteEntity.commands_changes && - Object.keys(noteEntity.commands_changes).length > 0 - ) { + if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) { $notesList.find('.system-note.being-posted').remove(); } - this.addFlash( - noteEntity.errors.commands_only, - 'notice', - this.parentTimeline.get(0), - ); + this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline.get(0)); this.refresh(); } return; @@ -489,7 +417,7 @@ export default class Notes { const $note = $notesList.find(`#note_${noteEntity.id}`); if (Notes.isNewNote(noteEntity, this.note_ids)) { - if (hasVueMRDiscussionsCookie()) { + if (isInMRPage()) { return; } @@ -517,8 +445,7 @@ export default class Notes { // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way const sanitizedNoteNote = normalizeNewlines(noteEntity.note); const isTextareaUntouched = - currentContent === initialContent || - currentContent === sanitizedNoteNote; + currentContent === initialContent || currentContent === sanitizedNoteNote; if (isEditing && isTextareaUntouched) { $textarea.val(noteEntity.note); @@ -531,8 +458,6 @@ export default class Notes { this.setupNewNote($updatedNote); } } - - Notes.refreshVueNotes(); } isParallelView() { @@ -550,13 +475,7 @@ export default class Notes { } this.note_ids.push(noteEntity.id); - form = - $form || - $( - `.js-discussion-note-form[data-discussion-id="${ - noteEntity.discussion_id - }"]`, - ); + form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); row = form.length || !noteEntity.discussion_line_code ? form.closest('tr') @@ -572,9 +491,7 @@ export default class Notes { .first() .find('.js-avatar-container.' + lineType + '_line'); // is this the first note of discussion? - discussionContainer = $( - `.notes[data-discussion-id="${noteEntity.discussion_id}"]`, - ); + discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); if (!discussionContainer.length) { discussionContainer = form.closest('.discussion').find('.notes'); } @@ -582,18 +499,12 @@ export default class Notes { if (noteEntity.diff_discussion_html) { var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); - if ( - !this.isParallelView() || - row.hasClass('js-temp-notes-holder') || - noteEntity.on_image - ) { + if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) { // insert the note and the reply button after the temp row row.after($discussion); } else { // Merge new discussion HTML in - var $notes = $discussion.find( - `.notes[data-discussion-id="${noteEntity.discussion_id}"]`, - ); + var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); var contentContainerClass = '.' + $notes @@ -606,29 +517,15 @@ export default class Notes { .find(contentContainerClass + ' .content') .append($notes.closest('.content').children()); } - } - // Init discussion on 'Discussion' page if it is merge request page - const page = $('body').attr('data-page'); - if ( - (page && page.indexOf('projects:merge_request') !== -1) || - !noteEntity.diff_discussion_html - ) { - if (!hasVueMRDiscussionsCookie()) { - Notes.animateAppendNote( - noteEntity.discussion_html, - $('.main-notes-list'), - ); - } + } else { + Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); } } else { // append new note to all matching discussions Notes.animateAppendNote(noteEntity.html, discussionContainer); } - if ( - typeof gl.diffNotesCompileComponents !== 'undefined' && - noteEntity.discussion_resolvable - ) { + if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { gl.diffNotesCompileComponents(); this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); @@ -938,9 +835,7 @@ export default class Notes { form.removeClass('current-note-edit-form'); form.find('.js-finish-edit-warning').hide(); // Replace markdown textarea text with original note text. - return form - .find('.js-note-text') - .val(form.find('form.edit-note').data('originalNote')); + return form.find('.js-note-text').val(form.find('form.edit-note').data('originalNote')); } /** @@ -988,21 +883,15 @@ export default class Notes { // The notes tr can contain multiple lists of notes, like on the parallel diff // notesTr does not exist for image diffs - if ( - notesTr.find('.discussion-notes').length > 1 || - notesTr.length === 0 - ) { + if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { const $diffFile = $notes.closest('.diff-file'); if ($diffFile.length > 0) { - const removeBadgeEvent = new CustomEvent( - 'removeBadge.imageDiff', - { - detail: { - // badgeNumber's start with 1 and index starts with 0 - badgeNumber: $notes.index() + 1, - }, + const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { + detail: { + // badgeNumber's start with 1 and index starts with 0 + badgeNumber: $notes.index() + 1, }, - ); + }); $diffFile[0].dispatchEvent(removeBadgeEvent); } @@ -1016,7 +905,6 @@ export default class Notes { })(this), ); - Notes.refreshVueNotes(); Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); } @@ -1032,7 +920,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(); } /** @@ -1106,9 +994,7 @@ export default class Notes { form.find('.js-note-new-discussion').remove(); this.setupNoteForm(form); - form - .removeClass('js-main-target-form') - .addClass('discussion-form js-discussion-note-form'); + form.removeClass('js-main-target-form').addClass('discussion-form js-discussion-note-form'); if (typeof gl.diffNotesCompileComponents !== 'undefined') { var $commentBtn = form.find('comment-and-resolve-btn'); @@ -1118,9 +1004,7 @@ export default class Notes { } form.find('.js-note-text').focus(); - form - .find('.js-comment-resolve-button') - .attr('data-discussion-id', discussionID); + form.find('.js-comment-resolve-button').attr('data-discussion-id', discussionID); } /** @@ -1153,9 +1037,7 @@ export default class Notes { // Setup comment form let newForm; - const $noteContainer = $link - .closest('.diff-viewer') - .find('.note-container'); + const $noteContainer = $link.closest('.diff-viewer').find('.note-container'); const $form = $noteContainer.find('> .discussion-form'); if ($form.length === 0) { @@ -1224,9 +1106,7 @@ export default class Notes { notesContent = targetRow.find(notesContentSelector); addForm = true; } else { - const isCurrentlyShown = targetRow - .find('.content:not(:empty)') - .is(':visible'); + const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible'); const isForced = forceShow === true || forceShow === false; const showNow = forceShow === true || (!isCurrentlyShown && !isForced); @@ -1391,9 +1271,7 @@ export default class Notes { if ($note.find('.js-conflict-edit-warning').length === 0) { const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger"> This comment has changed since you started editing, please review the - <a href="#note_${ - noteEntity.id - }" target="_blank" rel="noopener noreferrer"> + <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer"> updated comment </a> to ensure information is not lost @@ -1403,9 +1281,7 @@ export default class Notes { } updateNotesCount(updateCount) { - return this.notesCountBadge.text( - parseInt(this.notesCountBadge.text(), 10) + updateCount, - ); + return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); } static renderPlaceholderComponent($container) { @@ -1482,9 +1358,7 @@ export default class Notes { toggleCommitList(e) { const $element = $(e.currentTarget); - const $closestSystemCommitList = $element.siblings( - '.system-note-commit-list', - ); + const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); $element .find('.fa') @@ -1517,9 +1391,7 @@ export default class Notes { $systemNote.find('.note-text').addClass('system-note-commit-list'); $systemNote.find('.system-note-commit-list-toggler').show(); } else { - $systemNote - .find('.note-text') - .addClass('system-note-commit-list hide-shade'); + $systemNote.find('.note-text').addClass('system-note-commit-list hide-shade'); } }); } @@ -1590,10 +1462,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. */ @@ -1752,15 +1620,8 @@ export default class Notes { .attr('id') === 'discussion'; const isMainForm = $form.hasClass('js-main-target-form'); const isDiscussionForm = $form.hasClass('js-discussion-note-form'); - const isDiscussionResolve = $submitBtn.hasClass( - 'js-comment-resolve-button', - ); - const { - formData, - formContent, - formAction, - formContentOriginal, - } = this.getFormData($form); + const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); + const { formData, formContent, formAction, formContentOriginal } = this.getFormData($form); let noteUniqueId; let systemNoteUniqueId; let hasQuickActions = false; @@ -1847,9 +1708,7 @@ export default class Notes { // Reset cached commands list when command is applied if (hasQuickActions) { - $form - .find('textarea.js-note-text') - .trigger('clear-commands-cache.atwho'); + $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho'); } // Clear previous form errors @@ -1894,12 +1753,8 @@ export default class Notes { // append flash-container to the Notes list if ($notesContainer.length) { - $notesContainer.append( - '<div class="flash-container" style="display: none;"></div>', - ); + $notesContainer.append('<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 @@ -1933,9 +1788,7 @@ export default class Notes { // Show form again on UI on failure if (isDiscussionForm && $notesContainer.length) { - const replyButton = $notesContainer - .parent() - .find('.js-discussion-reply-button'); + const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); this.replyToDiscussionNote(replyButton[0]); $form = $notesContainer.parent().find('form'); } @@ -1978,9 +1831,7 @@ export default class Notes { // Show updated comment content temporarily $noteBodyText.html(formContent); - $editingNote - .removeClass('is-editing fade-in-full') - .addClass('being-posted fade-in-half'); + $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); $editingNote .find('.note-headline-meta a') .html( diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index ad6dd3d9a09..c6a524f68cb 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -7,10 +7,7 @@ import { __, sprintf } from '~/locale'; import Flash from '../../flash'; import Autosave from '../../autosave'; import TaskList from '../../task_list'; -import { - capitalizeFirstCharacter, - convertToCamelCase, -} from '../../lib/utils/text_utility'; +import { capitalizeFirstCharacter, convertToCamelCase, splitCamelCase } from '../../lib/utils/text_utility'; import * as constants from '../constants'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -56,21 +53,23 @@ export default { ]), ...mapState(['isToggleStateButtonLoading']), noteableDisplayName() { - return this.noteableType.replace(/_/g, ' '); + return splitCamelCase(this.noteableType).toLowerCase(); }, isLoggedIn() { return this.getUserData.id; }, commentButtonTitle() { - return this.noteType === constants.COMMENT - ? 'Comment' - : 'Start discussion'; + return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; + }, + startDiscussionDescription() { + let text = 'Discuss a specific suggestion or question'; + if (this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE) { + text += ' that needs to be resolved'; + } + return `${text}.`; }, isOpen() { - return ( - this.openState === constants.OPENED || - this.openState === constants.REOPENED - ); + return this.openState === constants.OPENED || this.openState === constants.REOPENED; }, canCreateNote() { return this.getNoteableData.current_user.can_create_note; @@ -117,6 +116,9 @@ export default { endpoint() { return this.getNoteableData.create_note_path; }, + issuableTypeTitle() { + return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE ? 'merge request' : 'issue'; + }, }, watch: { note(newNote) { @@ -129,9 +131,7 @@ export default { mounted() { // jQuery is needed here because it is a custom event being dispatched with jQuery. $(document).on('issuable:change', (e, isClosed) => { - this.toggleIssueLocalState( - isClosed ? constants.CLOSED : constants.REOPENED, - ); + this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED); }); this.initAutoSave(); @@ -168,6 +168,7 @@ export default { noteable_id: this.getNoteableData.id, note: this.note, }, + merge_request_diff_head_sha: this.getNoteableData.diff_head_sha, }, }; @@ -227,9 +228,7 @@ Please check your network connection and try again.`; this.toggleStateButtonLoading(false); Flash( sprintf( - __( - 'Something went wrong while closing the %{issuable}. Please try again later', - ), + __('Something went wrong while closing the %{issuable}. Please try again later'), { issuable: this.noteableDisplayName }, ), ); @@ -242,9 +241,7 @@ Please check your network connection and try again.`; this.toggleStateButtonLoading(false); Flash( sprintf( - __( - 'Something went wrong while reopening the %{issuable}. Please try again later', - ), + __('Something went wrong while reopening the %{issuable}. Please try again later'), { issuable: this.noteableDisplayName }, ), ); @@ -281,9 +278,7 @@ Please check your network connection and try again.`; }, initAutoSave() { if (this.isLoggedIn) { - const noteableType = capitalizeFirstCharacter( - convertToCamelCase(this.noteableType), - ); + const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); this.autosave = new Autosave($(this.$refs.textarea), [ 'Note', @@ -312,8 +307,8 @@ Please check your network connection and try again.`; <div> <note-signed-out-widget v-if="!isLoggedIn" /> <discussion-locked-widget - v-else-if="isLocked(getNoteableData) && !canCreateNote" - issuable-type="issue" + v-else-if="!canCreateNote" + :issuable-type="issuableTypeTitle" /> <ul v-else-if="canCreateNote" @@ -357,7 +352,7 @@ Please check your network connection and try again.`; v-model="note" :disabled="isSubmitting" name="note[note]" - class="note-textarea js-vue-comment-form + class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area js-vue-textarea" data-supports-quick-actions="true" aria-label="Description" @@ -423,7 +418,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" <div class="description"> <strong>Start discussion</strong> <p> - Discuss a specific suggestion or question. + {{ startDiscussionDescription }} </p> </div> </button> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index cafb28910eb..d321f2ce15e 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,13 +1,15 @@ <script> -import $ from 'jquery'; -import syntaxHighlight from '~/syntax_highlight'; +import { mapState, mapActions } from 'vuex'; import imageDiffHelper from '~/image_diff/helpers/index'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import DiffFileHeader from './diff_file_header.vue'; +import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; export default { components: { DiffFileHeader, + SkeletonLoadingContainer, }, props: { discussion: { @@ -15,7 +17,24 @@ export default { required: true, }, }, + data() { + return { + error: false, + }; + }, computed: { + ...mapState({ + noteableData: state => state.notes.noteableData, + }), + hasTruncatedDiffLines() { + return this.discussion.truncatedDiffLines && this.discussion.truncatedDiffLines.length !== 0; + }, + isDiscussionsExpanded() { + return true; // TODO: @fatihacet - Fix this. + }, + isCollapsed() { + return this.diffFile.collapsed || false; + }, isImageDiff() { return !this.diffFile.text; }, @@ -23,36 +42,46 @@ export default { const { text } = this.diffFile; return text ? 'text-file' : 'js-image-file'; }, - diffRows() { - return $(this.discussion.truncatedDiffLines); - }, diffFile() { - return convertObjectPropsToCamelCase(this.discussion.diffFile); + return convertObjectPropsToCamelCase(this.discussion.diffFile, { deep: true }); }, imageDiffHtml() { return this.discussion.imageDiffHtml; }, + currentUser() { + return this.noteableData.current_user; + }, + userColorScheme() { + return window.gon.user_color_scheme; + }, + normalizedDiffLines() { + const lines = this.discussion.truncatedDiffLines || []; + + return lines.map(line => trimFirstCharOfLineContent(convertObjectPropsToCamelCase(line))); + }, }, mounted() { if (this.isImageDiff) { const canCreateNote = false; const renderCommentBadge = true; - imageDiffHelper.initImageDiff( - this.$refs.fileHolder, - canCreateNote, - renderCommentBadge, - ); - } else { - const fileHolder = $(this.$refs.fileHolder); - this.$nextTick(() => { - syntaxHighlight(fileHolder); - }); + imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge); + } else if (!this.hasTruncatedDiffLines) { + this.fetchDiff(); } }, methods: { + ...mapActions(['fetchDiscussionDiffLines']), rowTag(html) { return html.outerHTML ? 'tr' : 'template'; }, + fetchDiff() { + this.error = false; + this.fetchDiscussionDiffLines(this.discussion) + .then(this.highlight) + .catch(() => { + this.error = true; + }); + }, }, }; </script> @@ -63,23 +92,59 @@ export default { :class="diffFileClass" class="diff-file file-holder" > - <div class="js-file-title file-title file-title-flex-parent"> - <diff-file-header - :diff-file="diffFile" - /> - </div> + <diff-file-header + :diff-file="diffFile" + :current-user="currentUser" + :discussions-expanded="isDiscussionsExpanded" + :expanded="!isCollapsed" + /> <div v-if="diffFile.text" - class="diff-content code js-syntax-highlight" + :class="userColorScheme" + class="diff-content code" > <table> - <component - v-for="(html, index) in diffRows" - :is="rowTag(html)" - :class="html.className" - :key="index" - v-html="html.outerHTML" - /> + <tr + v-for="line in normalizedDiffLines" + :key="line.lineCode" + class="line_holder" + > + <td class="diff-line-num old_line">{{ line.oldLine }}</td> + <td class="diff-line-num new_line">{{ line.newLine }}</td> + <td + :class="line.type" + class="line_content" + v-html="line.richText" + > + </td> + </tr> + <tr + v-if="!hasTruncatedDiffLines" + class="line_holder line-holder-placeholder" + > + <td class="old_line diff-line-num"></td> + <td class="new_line diff-line-num"></td> + <td + v-if="error" + class="js-error-lazy-load-diff diff-loading-error-block" + > + Unable to load the diff + <button + class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button" + @click="fetchDiff" + > + Try again + </button> + </td> + <td + v-else + class="line_content js-success-lazy-load" + > + <span></span> + <skeleton-loading-container /> + <span></span> + </td> + </tr> <tr class="notes_holder"> <td class="notes_line" diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 68e17ac8055..6385b75e557 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; import resolveSvg from 'icons/_icon_resolve_discussion.svg'; import resolvedSvg from 'icons/_icon_status_success_solid.svg'; import mrIssueSvg from 'icons/_icon_mr_issue.svg'; @@ -48,10 +48,14 @@ export default { this.nextDiscussionSvg = nextDiscussionSvg; }, methods: { - jumpToFirstDiscussion() { - const el = document.querySelector( - `[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`, - ); + ...mapActions(['expandDiscussion']), + jumpToFirstUnresolvedDiscussion() { + const discussionId = this.firstUnresolvedDiscussionId; + if (!discussionId) { + return; + } + + const el = document.querySelector(`[data-discussion-id="${discussionId}"]`); const activeTab = window.mrTabs.currentAction; if (activeTab === 'commits' || activeTab === 'pipelines') { @@ -59,6 +63,7 @@ export default { } if (el) { + this.expandDiscussion({ discussionId }); scrollToElement(el); } }, @@ -97,7 +102,7 @@ export default { <a v-tooltip :href="resolveAllDiscussionsIssuePath" - title="Resolve all discussions in new issue" + :title="s__('Resolve all discussions in new issue')" data-container="body" class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"> <span v-html="mrIssueSvg"></span> @@ -112,7 +117,7 @@ export default { title="Jump to first unresolved discussion" data-container="body" class="btn btn-default discussion-next-btn" - @click="jumpToFirstDiscussion"> + @click="jumpToFirstUnresolvedDiscussion"> <span v-html="nextDiscussionSvg"></span> </button> </div> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 0bf4258a257..cdbbb342331 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -27,6 +27,10 @@ export default { type: Number, required: true, }, + noteUrl: { + type: String, + required: true, + }, accessLevel: { type: String, required: false, @@ -48,6 +52,11 @@ export default { type: Boolean, required: true, }, + canResolve: { + type: Boolean, + required: false, + default: false, + }, resolvable: { type: Boolean, required: false, @@ -125,7 +134,7 @@ export default { {{ accessLevel }} </span> <div - v-if="resolvable" + v-if="canResolve" class="note-actions-item"> <button v-tooltip @@ -216,6 +225,15 @@ export default { Report as abuse </a> </li> + <li> + <button + :data-clipboard-text="noteUrl" + type="button" + css-class="btn-default btn-transparent" + > + Copy link + </button> + </li> <li v-if="canEdit"> <button class="btn btn-transparent js-note-delete js-note-delete" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 864edcd2ec6..d2db68df98e 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -40,7 +40,7 @@ export default { this.initTaskList(); if (this.isEditing) { - this.initAutoSave(this.note.noteable_type); + this.initAutoSave(this.note); } }, updated() { @@ -49,7 +49,7 @@ export default { if (this.isEditing) { if (!this.autosave) { - this.initAutoSave(this.note.noteable_type); + this.initAutoSave(this.note); } else { this.setAutoSave(); } @@ -72,7 +72,7 @@ export default { this.$emit('handleFormUpdate', note, parentElement, callback); }, formCancelHandler(shouldConfirm, isDirty) { - this.$emit('cancelFormEdition', shouldConfirm, isDirty); + this.$emit('cancelForm', shouldConfirm, isDirty); }, }, }; @@ -93,7 +93,7 @@ export default { :note-body="noteBody" :note-id="note.id" @handleFormUpdate="handleFormUpdate" - @cancelFormEdition="formCancelHandler" + @cancelForm="formCancelHandler" /> <textarea v-if="canEdit" @@ -105,6 +105,7 @@ export default { :edited-at="note.last_edited_at" :edited-by="note.last_edited_by" action-text="Edited" + class="note_edited_ago" /> <note-awards-list v-if="note.award_emoji.length" diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index 2dc39d1a186..391bb2ae179 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -11,14 +11,20 @@ export default { type: String, required: true, }, + actionDetailText: { + type: String, + required: false, + default: '', + }, editedAt: { type: String, - required: true, + required: false, + default: null, }, editedBy: { type: Object, required: false, - default: () => ({}), + default: null, }, className: { type: String, @@ -33,13 +39,14 @@ export default { <div :class="className"> {{ actionText }} <template v-if="editedBy"> - {{ s__('ByAuthor|by') }} + by <a :href="editedBy.path" class="js-vue-author author_link"> {{ editedBy.name }} </a> </template> + {{ actionDetailText }} <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 7254ef3357d..a62696b39b4 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -29,7 +29,7 @@ export default { required: false, default: 'Save comment', }, - note: { + discussion: { type: Object, required: false, default: () => ({}), @@ -38,6 +38,11 @@ export default { type: Boolean, required: true, }, + lineCode: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -66,9 +71,7 @@ export default { return this.getNotesDataByProp('markdownDocsPath'); }, quickActionsDocsPath() { - return !this.isEditing - ? this.getNotesDataByProp('quickActionsDocsPath') - : undefined; + return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined; }, currentUserId() { return this.getUserDataByProp('id'); @@ -95,24 +98,17 @@ export default { const beforeSubmitDiscussionState = this.discussionResolved; this.isSubmitting = true; - this.$emit( - 'handleFormUpdate', - this.updatedNoteBody, - this.$refs.editNoteForm, - () => { - this.isSubmitting = false; + this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => { + this.isSubmitting = false; - if (shouldResolve) { - this.resolveHandler(beforeSubmitDiscussionState); - } - }, - ); + if (shouldResolve) { + this.resolveHandler(beforeSubmitDiscussionState); + } + }); }, editMyLastNote() { if (this.updatedNoteBody === '') { - const lastNoteInDiscussion = this.getDiscussionLastNote( - this.updatedNoteBody, - ); + const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); if (lastNoteInDiscussion) { eventHub.$emit('enterEditMode', { @@ -123,11 +119,7 @@ export default { }, cancelHandler(shouldConfirm = false) { // Sends information about confirm message and if the textarea has changed - this.$emit( - 'cancelFormEdition', - shouldConfirm, - this.noteBody !== this.updatedNoteBody, - ); + this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody); }, }, }; @@ -136,7 +128,7 @@ export default { <template> <div ref="editNoteForm" - class="note-edit-form current-note-edit-form"> + class="note-edit-form current-note-edit-form js-discussion-note-form"> <div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger"> @@ -150,7 +142,10 @@ export default { to ensure information is not lost. </div> <div class="flash-container timeline-content"></div> - <form class="edit-note common-note-form js-quick-submit gfm-form"> + <form + :data-line-code="lineCode" + class="edit-note common-note-form js-quick-submit gfm-form" + > <issue-warning v-if="hasWarning(getNoteableData)" @@ -170,7 +165,7 @@ export default { :data-supports-quick-actions="!isEditing" v-model="updatedNoteBody" name="note[note]" - class="note-textarea js-gfm-input + class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" aria-label="Description" placeholder="Write a comment or drag your files here…" @@ -184,19 +179,19 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" <button :disabled="isDisabled" type="button" - class="js-vue-issue-save btn btn-save" + class="js-vue-issue-save btn btn-save js-comment-button " @click="handleUpdate()"> {{ saveButtonTitle }} </button> <button - v-if="note.resolvable" + v-if="discussion.resolvable" class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" @click.prevent="handleUpdate(true)" > {{ resolveButtonTitle }} </button> <button - class="btn btn-cancel note-edit-cancel" + class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" type="button" @click="cancelHandler()"> Cancel diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index ffe3ba9c805..ee3580895df 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -20,11 +20,6 @@ export default { required: false, default: '', }, - actionTextHtml: { - type: String, - required: false, - default: '', - }, noteId: { type: Number, required: true, @@ -88,10 +83,8 @@ export default { <template v-if="actionText"> {{ actionText }} </template> - <span - v-if="actionTextHtml" - class="system-note-message" - v-html="actionTextHtml"> + <span class="system-note-message"> + <slot></slot> </span> <span class="system-note-separator"> · diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 3f865431155..bee635398b3 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,7 +1,11 @@ <script> +import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; import nextDiscussionsSvg from 'icons/_next_discussion.svg'; +import { convertObjectPropsToCamelCase, scrollToElement } from '~/lib/utils/common_utils'; +import { truncateSha } from '~/lib/utils/text_utility'; +import systemNote from '~/vue_shared/components/notes/system_note.vue'; import Flash from '../../flash'; import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -17,9 +21,9 @@ import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import tooltip from '../../vue_shared/directives/tooltip'; -import { scrollToElement } from '../../lib/utils/common_utils'; export default { + name: 'NoteableDiscussion', components: { noteableNote, diffWithNote, @@ -30,16 +34,32 @@ export default { noteForm, placeholderNote, placeholderSystemNote, + systemNote, }, directives: { tooltip, }, mixins: [autosave, noteable, resolvable], props: { - note: { + discussion: { 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 { @@ -53,19 +73,27 @@ export default { 'getNoteableData', 'discussionCount', 'resolvedDiscussionCount', + 'allDiscussions', 'unresolvedDiscussions', ]), - discussion() { + transformedDiscussion() { return { - ...this.note.notes[0], - truncatedDiffLines: this.note.truncated_diff_lines, - diffFile: this.note.diff_file, - diffDiscussion: this.note.diff_discussion, - imageDiffHtml: this.note.image_diff_html, + ...this.discussion.notes[0], + truncatedDiffLines: this.discussion.truncated_diff_lines || [], + truncatedDiffLinesPath: this.discussion.truncated_diff_lines_path, + diffFile: this.discussion.diff_file, + diffDiscussion: this.discussion.diff_discussion, + imageDiffHtml: this.discussion.image_diff_html, + active: this.discussion.active, + discussionPath: this.discussion.discussion_path, + resolved: this.discussion.resolved, + resolvedBy: this.discussion.resolved_by, + resolvedByPush: this.discussion.resolved_by_push, + resolvedAt: this.discussion.resolved_at, }; }, author() { - return this.discussion.author; + return this.transformedDiscussion.author; }, canReply() { return this.getNoteableData.current_user.can_create_note; @@ -74,7 +102,7 @@ export default { return this.getNoteableData.create_note_path; }, lastUpdatedBy() { - const { notes } = this.note; + const { notes } = this.discussion; if (notes.length > 1) { return notes[notes.length - 1].author; @@ -83,7 +111,7 @@ export default { return null; }, lastUpdatedAt() { - const { notes } = this.note; + const { notes } = this.discussion; if (notes.length > 1) { return notes[notes.length - 1].created_at; @@ -91,27 +119,40 @@ export default { return null; }, - hasUnresolvedDiscussion() { - return this.unresolvedDiscussions.length > 0; + resolvedText() { + return this.transformedDiscussion.resolvedByPush ? 'Automatically resolved' : 'Resolved'; + }, + hasMultipleUnresolvedDiscussions() { + return this.unresolvedDiscussions.length > 1; + }, + shouldRenderDiffs() { + const { diffDiscussion, diffFile } = this.transformedDiscussion; + + return diffDiscussion && diffFile && this.renderDiffFile; }, wrapperComponent() { - return this.discussion.diffDiscussion && this.discussion.diffFile - ? diffWithNote - : 'div'; + return this.shouldRenderDiffs ? diffWithNote : 'div'; + }, + wrapperComponentProps() { + if (this.shouldRenderDiffs) { + return { discussion: convertObjectPropsToCamelCase(this.discussion) }; + } + + return {}; }, wrapperClass() { - return this.isDiffDiscussion ? '' : 'card'; + return this.isDiffDiscussion ? '' : 'card discussion-wrapper'; }, }, mounted() { if (this.isReplying) { - this.initAutoSave(this.discussion.noteable_type); + this.initAutoSave(this.transformedDiscussion); } }, updated() { if (this.isReplying) { if (!this.autosave) { - this.initAutoSave(this.discussion.noteable_type); + this.initAutoSave(this.transformedDiscussion); } else { this.setAutoSave(); } @@ -127,7 +168,9 @@ export default { 'toggleDiscussion', 'removePlaceholderNotes', 'toggleResolveNote', + 'expandDiscussion', ]), + truncateSha, componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -136,23 +179,25 @@ export default { return placeholderNote; } + if (note.system) { + return systemNote; + } + return noteableNote; }, componentData(note) { - return note.isPlaceholderNote ? this.note.notes[0] : note; + return note.isPlaceholderNote ? this.discussion.notes[0] : note; }, toggleDiscussionHandler() { - this.toggleDiscussion({ discussionId: this.note.id }); + this.toggleDiscussion({ discussionId: this.discussion.id }); }, showReplyForm() { this.isReplying = true; }, 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 (!window.confirm(msg)) { + if (!window.confirm('Are you sure you want to cancel creating this comment?')) { return; } } @@ -161,18 +206,23 @@ export default { this.isReplying = false; }, saveReply(noteText, form, callback) { + const postData = { + in_reply_to_discussion_id: this.discussion.reply_id, + target_type: this.getNoteableData.targetType, + note: { note: noteText }, + }; + + if (this.discussion.for_commit) { + postData.note_project_id = this.discussion.project_id; + } + const replyData = { endpoint: this.newNotePath, flashContainer: this.$el, - data: { - in_reply_to_discussion_id: this.note.reply_id, - target_type: this.noteableType, - target_id: this.discussion.noteable_id, - note: { note: noteText }, - }, + data: postData, }; - this.isReplying = false; + this.isReplying = false; this.saveNote(replyData) .then(() => { this.resetAutoSave(); @@ -190,15 +240,19 @@ Please check your network connection and try again.`; }); }); }, - jumpToDiscussion() { + jumpToNextDiscussion() { + const discussionIds = this.allDiscussions.map(d => d.id); const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); - const index = unresolvedIds.indexOf(this.note.id); + const currentIndex = discussionIds.indexOf(this.discussion.id); + const remainingAfterCurrent = discussionIds.slice(currentIndex + 1); + const nextIndex = _.findIndex(remainingAfterCurrent, id => unresolvedIds.indexOf(id) > -1); - if (index >= 0 && index !== unresolvedIds.length) { - const nextId = unresolvedIds[index + 1]; + if (nextIndex > -1) { + const nextId = remainingAfterCurrent[nextIndex]; const el = document.querySelector(`[data-discussion-id="${nextId}"]`); if (el) { + this.expandDiscussion({ discussionId: nextId }); scrollToElement(el); } } @@ -208,9 +262,7 @@ Please check your network connection and try again.`; </script> <template> - <li - :data-discussion-id="note.id" - class="note note-discussion timeline-entry"> + <li class="note note-discussion timeline-entry"> <div class="timeline-entry-inner"> <div class="timeline-icon"> <user-avatar-link @@ -221,20 +273,52 @@ Please check your network connection and try again.`; /> </div> <div class="timeline-content"> - <div class="discussion"> - <div class="discussion-header"> + <div + :data-discussion-id="transformedDiscussion.discussion_id" + class="discussion js-discussion-container" + > + <div + v-if="renderHeader" + class="discussion-header" + > <note-header :author="author" - :created-at="discussion.created_at" - :note-id="discussion.id" + :created-at="transformedDiscussion.created_at" + :note-id="transformedDiscussion.id" :include-toggle="true" - :expanded="note.expanded" - action-text="started a discussion" - class="discussion" + :expanded="discussion.expanded" @toggleHandler="toggleDiscussionHandler" + > + <template v-if="transformedDiscussion.diffDiscussion"> + started a discussion on + <a :href="transformedDiscussion.discussionPath"> + <template v-if="transformedDiscussion.active"> + the diff + </template> + <template v-else> + an old version of the diff + </template> + </a> + </template> + <template v-else-if="discussion.for_commit"> + started a discussion on commit + <a :href="discussion.discussion_path"> + {{ truncateSha(discussion.commit_id) }} + </a> + </template> + <template v-else> + started a discussion + </template> + </note-header> + <note-edited-text + v-if="transformedDiscussion.resolved" + :edited-at="transformedDiscussion.resolvedAt" + :edited-by="transformedDiscussion.resolvedBy" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline" /> <note-edited-text - v-if="lastUpdatedAt" + v-else-if="lastUpdatedAt" :edited-at="lastUpdatedAt" :edited-by="lastUpdatedBy" action-text="Last updated" @@ -242,17 +326,17 @@ Please check your network connection and try again.`; /> </div> <div - v-if="note.expanded" + v-if="discussion.expanded || alwaysExpanded" class="discussion-body"> <component :is="wrapperComponent" - :discussion="discussion" + v-bind="wrapperComponentProps" :class="wrapperClass" > <div class="discussion-notes"> <ul class="notes"> <component - v-for="note in note.notes" + v-for="note in discussion.notes" :is="componentName(note)" :note="componentData(note)" :key="note.id" @@ -260,27 +344,28 @@ Please check your network connection and try again.`; </ul> <div :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder"> + class="discussion-reply-holder" + > <template v-if="!isReplying && canReply"> <div class="btn-group d-flex discussion-with-resolve-btn" role="group"> <div - class="btn-group" + class="btn-group w-100" role="group"> <button type="button" - class="js-vue-discussion-reply btn btn-text-field" + class="js-vue-discussion-reply btn btn-text-field mr-2" title="Add a reply" @click="showReplyForm">Reply...</button> </div> <div - v-if="note.resolvable" + v-if="discussion.resolvable" class="btn-group" role="group"> <button type="button" - class="btn btn-default" + class="btn btn-default mr-2" @click="resolveHandler()" > <i @@ -292,7 +377,7 @@ Please check your network connection and try again.`; </button> </div> <div - v-if="note.resolvable" + v-if="discussion.resolvable" class="btn-group discussion-actions" role="group" > @@ -302,17 +387,17 @@ Please check your network connection and try again.`; role="group"> <a v-tooltip - :href="note.resolve_with_issue_path" + :href="discussion.resolve_with_issue_path" + :title="s__('MergeRequests|Resolve this discussion in a new issue')" class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" - title="Resolve this discussion in a new issue" data-container="body" > <span v-html="resolveDiscussionsSvg"></span> </a> </div> <div - v-if="hasUnresolvedDiscussion" + v-if="hasMultipleUnresolvedDiscussions" class="btn-group" role="group"> <button @@ -320,7 +405,7 @@ Please check your network connection and try again.`; class="btn btn-default discussion-next-btn" title="Jump to next unresolved discussion" data-container="body" - @click="jumpToDiscussion" + @click="jumpToNextDiscussion" > <span v-html="nextDiscussionsSvg"></span> </button> @@ -331,11 +416,11 @@ Please check your network connection and try again.`; <note-form v-if="isReplying" ref="noteForm" - :note="note" + :discussion="discussion" :is-editing="false" save-button-title="Comment" @handleFormUpdate="saveReply" - @cancelFormEdition="cancelReplyForm" /> + @cancelForm="cancelReplyForm" /> <note-signed-out-widget v-if="!canReply" /> </div> </div> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 9225a6b1a7c..4ebeb5599f2 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -12,6 +12,7 @@ import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; export default { + name: 'NoteableNote', components: { userAvatarLink, noteHeader, @@ -34,26 +35,31 @@ export default { }; }, computed: { - ...mapGetters(['targetNoteHash', 'getUserData']), + ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData']), author() { return this.note.author; }, classNameBindings() { return { + [`note-row-${this.note.id}`]: true, 'is-editing': this.isEditing && !this.isRequesting, 'is-requesting being-posted': this.isRequesting, 'disabled-content': this.isDeleting, - target: this.targetNoteHash === this.noteAnchorId, + target: this.isTarget, }; }, + canResolve() { + return this.note.resolvable && !!this.getUserData.id; + }, canReportAsAbuse() { - return ( - this.note.report_abuse_path && this.author.id !== this.getUserData.id - ); + return this.note.report_abuse_path && this.author.id !== this.getUserData.id; }, noteAnchorId() { return `note_${this.note.id}`; }, + isTarget() { + return this.targetNoteHash === this.noteAnchorId; + }, }, created() { @@ -65,13 +71,14 @@ export default { }); }, + mounted() { + if (this.isTarget) { + this.scrollToNoteIfNeeded($(this.$el)); + } + }, + methods: { - ...mapActions([ - 'deleteNote', - 'updateNote', - 'toggleResolveNote', - 'scrollToNoteIfNeeded', - ]), + ...mapActions(['deleteNote', 'updateNote', 'toggleResolveNote', 'scrollToNoteIfNeeded']), editHandler() { this.isEditing = true; }, @@ -85,9 +92,7 @@ export default { this.isDeleting = false; }) .catch(() => { - Flash( - 'Something went wrong while deleting your note. Please try again.', - ); + Flash('Something went wrong while deleting your note. Please try again.'); this.isDeleting = false; }); } @@ -96,7 +101,7 @@ export default { const data = { endpoint: this.note.path, note: { - target_type: this.noteableType, + target_type: this.getNoteableData.targetType, target_id: this.note.noteable_id, note: { note: noteText }, }, @@ -118,8 +123,7 @@ export default { this.isRequesting = false; this.isEditing = true; this.$nextTick(() => { - const msg = - 'Something went wrong while editing your comment. Please try again.'; + const msg = 'Something went wrong while editing your comment. Please try again.'; Flash(msg, 'alert', this.$el); this.recoverNoteContent(noteText); callback(); @@ -129,8 +133,7 @@ export default { formCancelHandler(shouldConfirm, isDirty) { if (shouldConfirm && isDirty) { // eslint-disable-next-line no-alert - if (!window.confirm('Are you sure you want to cancel editing this comment?')) - return; + if (!window.confirm('Are you sure you want to cancel editing this comment?')) return; } this.$refs.noteBody.resetAutoSave(); if (this.oldContent) { @@ -143,7 +146,7 @@ export default { // we need to do this to prevent noteForm inconsistent content warning // this is something we intentionally do so we need to recover the content this.note.note = noteText; - this.$refs.noteBody.$refs.noteForm.note.note = noteText; + this.$refs.noteBody.note.note = noteText; }, }, }; @@ -154,7 +157,9 @@ export default { :id="noteAnchorId" :class="classNameBindings" :data-award-url="note.toggle_award_path" - class="note timeline-entry"> + :data-note-id="note.id" + class="note timeline-entry" + > <div class="timeline-entry-inner"> <div class="timeline-icon"> <user-avatar-link @@ -174,11 +179,13 @@ export default { <note-actions :author-id="author.id" :note-id="note.id" + :note-url="note.noteable_note_url" :access-level="note.human_access" :can-edit="note.current_user.can_edit" :can-award-emoji="note.current_user.can_award_emoji" :can-delete="note.current_user.can_edit" :can-report-as-abuse="canReportAsAbuse" + :can-resolve="note.current_user.can_resolve" :report-abuse-path="note.report_abuse_path" :resolvable="note.resolvable" :is-resolved="note.resolved" @@ -195,7 +202,7 @@ export default { :can-edit="note.current_user.can_edit" :is-editing="isEditing" @handleFormUpdate="formUpdateHandler" - @cancelFormEdition="formCancelHandler" + @cancelForm="formCancelHandler" /> </div> </div> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index ebfc827ac57..17b5e8d1ae8 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,9 +1,7 @@ <script> -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,19 +37,23 @@ export default { required: false, default: () => ({}), }, + shouldShow: { + type: Boolean, + required: false, + default: true, + }, }, - store, data() { return { isLoading: true, }; }, computed: { - ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']), + ...mapGetters(['discussions', 'getNotesDataByProp', 'discussionCount']), noteableType() { return this.noteableData.noteableType; }, - allNotes() { + allDiscussions() { if (this.isLoading) { const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0; @@ -59,36 +61,29 @@ export default { isSkeletonNote: true, }); } - return this.notes; + return this.discussions; }, }, created() { this.setNotesData(this.notesData); this.setNoteableData(this.noteableData); this.setUserData(this.userData); + this.setTargetNoteHash(getLocationHash()); }, mounted() { this.fetchNotes(); - const parentElement = this.$el.parentElement; - if ( - parentElement && - parentElement.classList.contains('js-vue-notes-event') - ) { + if (parentElement && parentElement.classList.contains('js-vue-notes-event')) { parentElement.addEventListener('toggleAward', event => { const { awardName, noteId } = event.detail; this.actionToggleAward({ awardName, noteId }); }); } - document.addEventListener('refreshVueNotes', this.fetchNotes); - }, - beforeDestroy() { - document.removeEventListener('refreshVueNotes', this.fetchNotes); }, methods: { ...mapActions({ - actionFetchNotes: 'fetchNotes', + fetchDiscussions: 'fetchDiscussions', poll: 'poll', actionToggleAward: 'toggleAward', scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', @@ -97,28 +92,31 @@ export default { setUserData: 'setUserData', setLastFetchedAt: 'setLastFetchedAt', setTargetNoteHash: 'setTargetNoteHash', + toggleDiscussion: 'toggleDiscussion', }), - getComponentName(note) { - if (note.isSkeletonNote) { + getComponentName(discussion) { + if (discussion.isSkeletonNote) { return skeletonLoadingContainer; } - if (note.isPlaceholderNote) { - if (note.placeholderType === constants.SYSTEM_NOTE) { + if (discussion.isPlaceholderNote) { + if (discussion.placeholderType === constants.SYSTEM_NOTE) { return placeholderSystemNote; } return placeholderNote; - } else if (note.individual_note) { - return note.notes[0].system ? systemNote : noteableNote; + } else if (discussion.individual_note) { + return discussion.notes[0].system ? systemNote : noteableNote; } return noteableDiscussion; }, - getComponentData(note) { - return note.individual_note ? note.notes[0] : note; + getComponentData(discussion) { + return discussion.individual_note ? { note: discussion.notes[0] } : { discussion }; }, fetchNotes() { - return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath')) - .then(() => this.initPolling()) + return this.fetchDiscussions(this.getNotesDataByProp('discussionsPath')) + .then(() => { + this.initPolling(); + }) .then(() => { this.isLoading = false; }) @@ -126,9 +124,7 @@ export default { .then(() => this.checkLocationHash()) .catch(() => { this.isLoading = false; - Flash( - 'Something went wrong while fetching comments. Please try again.', - ); + Flash('Something went wrong while fetching comments. Please try again.'); }); }, initPolling() { @@ -143,11 +139,19 @@ export default { }, checkLocationHash() { const hash = getLocationHash(); - const element = document.getElementById(hash); + const noteId = hash && hash.replace(/^note_/, ''); - if (hash && element) { - this.setTargetNoteHash(hash); - this.scrollToNoteIfNeeded($(element)); + if (noteId) { + this.discussions.forEach(discussion => { + if (discussion.notes) { + discussion.notes.forEach(note => { + if (`${note.id}` === `${noteId}`) { + // FIXME: this modifies the store state without using a mutation/action + Object.assign(discussion, { expanded: true }); + } + }); + } + }); } }, }, @@ -155,16 +159,18 @@ export default { </script> <template> - <div id="notes"> + <div + v-if="shouldShow" + id="notes"> <ul id="notes-list" class="notes main-notes-list timeline"> <component - v-for="note in allNotes" - :is="getComponentName(note)" - :note="getComponentData(note)" - :key="note.id" + v-for="discussion in allDiscussions" + :is="getComponentName(discussion)" + v-bind="getComponentData(discussion)" + :key="discussion.id" /> </ul> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 5b5b1e89058..2c3e07c0506 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -11,7 +11,7 @@ export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; export const ISSUE_NOTEABLE_TYPE = 'issue'; export const EPIC_NOTEABLE_TYPE = 'epic'; -export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; +export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; export const DESCRIPTION_TYPE = 'changed the description'; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index e4121f151db..eed3a82854d 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,46 +1,49 @@ import Vue from 'vue'; import notesApp from './components/notes_app.vue'; +import createStore from './stores'; -document.addEventListener( - 'DOMContentLoaded', - () => - new Vue({ - el: '#js-vue-notes', - components: { - notesApp, - }, - data() { - const notesDataset = document.getElementById('js-vue-notes').dataset; - const parsedUserData = JSON.parse(notesDataset.currentUserData); - const noteableData = JSON.parse(notesDataset.noteableData); - let currentUserData = {}; +document.addEventListener('DOMContentLoaded', () => { + const store = createStore(); - noteableData.noteableType = notesDataset.noteableType; + return new Vue({ + el: '#js-vue-notes', + components: { + notesApp, + }, + store, + data() { + const notesDataset = document.getElementById('js-vue-notes').dataset; + const parsedUserData = JSON.parse(notesDataset.currentUserData); + const noteableData = JSON.parse(notesDataset.noteableData); + let currentUserData = {}; - if (parsedUserData) { - currentUserData = { - id: parsedUserData.id, - name: parsedUserData.name, - username: parsedUserData.username, - avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, - path: parsedUserData.path, - }; - } + noteableData.noteableType = notesDataset.noteableType; + noteableData.targetType = notesDataset.targetType; - return { - noteableData, - currentUserData, - notesData: JSON.parse(notesDataset.notesData), + if (parsedUserData) { + currentUserData = { + id: parsedUserData.id, + name: parsedUserData.name, + username: parsedUserData.username, + avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, + path: parsedUserData.path, }; - }, - render(createElement) { - return createElement('notes-app', { - props: { - noteableData: this.noteableData, - notesData: this.notesData, - userData: this.currentUserData, - }, - }); - }, - }), -); + } + + return { + noteableData, + currentUserData, + notesData: JSON.parse(notesDataset.notesData), + }; + }, + render(createElement) { + return createElement('notes-app', { + props: { + noteableData: this.noteableData, + notesData: this.notesData, + userData: this.currentUserData, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index 3dff715905f..36cc8d5d056 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -4,11 +4,11 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; export default { methods: { - initAutoSave(noteableType) { + initAutoSave(noteable) { this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [ 'Note', - capitalizeFirstCharacter(noteableType), - this.note.id, + capitalizeFirstCharacter(noteable.noteable_type), + noteable.id, ]); }, resetAutoSave() { diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js index b68543d71c8..bf1cd6fe5a8 100644 --- a/app/assets/javascripts/notes/mixins/noteable.js +++ b/app/assets/javascripts/notes/mixins/noteable.js @@ -1,15 +1,10 @@ import * as constants from '../constants'; export default { - props: { - note: { - type: Object, - required: true, - }, - }, computed: { noteableType() { - return constants.NOTEABLE_TYPE_MAPPING[this.note.noteable_type]; + const note = this.discussion ? this.discussion.notes[0] : this.note; + return constants.NOTEABLE_TYPE_MAPPING[note.noteable_type]; }, }, }; diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index f79049b85f6..cd8394e0619 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -2,42 +2,39 @@ import Flash from '~/flash'; import { __ } from '~/locale'; export default { - props: { - note: { - type: Object, - required: true, - }, - }, computed: { discussionResolved() { - const { notes, resolved } = this.note; + if (this.discussion) { + const { notes, resolved } = this.discussion; + + if (notes) { + // Decide resolved state using store. Only valid for discussions. + return notes.filter(note => !note.system).every(note => note.resolved); + } - if (notes) { - // Decide resolved state using store. Only valid for discussions. - return notes.every(note => note.resolved && !note.system); + return resolved; } - return resolved; + return this.note.resolved; }, resolveButtonTitle() { if (this.updatedNoteBody) { if (this.discussionResolved) { - return __('Comment and unresolve discussion'); + return __('Comment & unresolve discussion'); } - return __('Comment and resolve discussion'); + return __('Comment & resolve discussion'); } - return this.discussionResolved - ? __('Unresolve discussion') - : __('Resolve discussion'); + + return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion'); }, }, 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; + const endpoint = discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`; this.toggleResolveNote({ endpoint, isResolved, discussion }) .then(() => { @@ -45,9 +42,8 @@ export default { }) .catch(() => { this.isResolving = false; - const msg = __( - 'Something went wrong while resolving this discussion. Please try again.', - ); + + const msg = __('Something went wrong while resolving this discussion. Please try again.'); Flash(msg, 'alert', this.$el); }); }, diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index 7c623aac6ed..ee7628840cf 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -5,7 +5,7 @@ import * as constants from '../constants'; Vue.use(VueResource); export default { - fetchNotes(endpoint) { + fetchDiscussions(endpoint) { return Vue.http.get(endpoint); }, deleteNote(endpoint) { @@ -22,9 +22,7 @@ export default { }, toggleResolveNote(endpoint, isResolved) { const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants; - const method = isResolved - ? UNRESOLVE_NOTE_METHOD_NAME - : RESOLVE_NOTE_METHOD_NAME; + const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME; return Vue.http[method](endpoint); }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index b2222476924..0a40b48257f 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import axios from '~/lib/utils/axios_utils'; import Visibility from 'visibilityjs'; import Flash from '../../flash'; import Poll from '../../lib/utils/poll'; @@ -12,20 +13,29 @@ import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; let eTagPoll; +export const expandDiscussion = ({ commit }, data) => commit(types.EXPAND_DISCUSSION, data); + 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 setInitialNotes = ({ commit }, discussions) => + commit(types.SET_INITIAL_DISCUSSIONS, discussions); + export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data); + export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); -export const fetchNotes = ({ commit }, path) => +export const fetchDiscussions = ({ commit }, path) => service - .fetchNotes(path) + .fetchDiscussions(path) .then(res => res.json()) - .then(res => { - commit(types.SET_INITIAL_NOTES, res); + .then(discussions => { + commit(types.SET_INITIAL_DISCUSSIONS, discussions); }); export const deleteNote = ({ commit }, note) => @@ -121,7 +131,8 @@ export const toggleIssueLocalState = ({ commit }, newState) => { }; export const saveNote = ({ commit, dispatch }, noteData) => { - const { note } = noteData.data.note; + // For MR discussuions we need to post as `note[note]` and issue we use `note.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; @@ -192,7 +203,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }); }; -const pollSuccessCallBack = (resp, commit, state, getters) => { +const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { if (resp.notes && resp.notes.length) { const { notesById } = getters; @@ -200,10 +211,12 @@ const pollSuccessCallBack = (resp, commit, state, getters) => { if (notesById[note.id]) { commit(types.UPDATE_NOTE, note); } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { - const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); + const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id); if (discussion) { commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); + } else if (note.type === constants.DIFF_NOTE) { + dispatch('fetchDiscussions', state.notesData.discussionsPath); } else { commit(types.ADD_NEW_NOTE, note); } @@ -218,13 +231,13 @@ const pollSuccessCallBack = (resp, commit, state, getters) => { return resp; }; -export const poll = ({ commit, state, getters }) => { +export const poll = ({ commit, state, getters, dispatch }) => { eTagPoll = new Poll({ resource: service, method: 'poll', data: state, successCallback: resp => - resp.json().then(data => pollSuccessCallBack(data, commit, state, getters)), + resp.json().then(data => pollSuccessCallBack(data, commit, state, getters, dispatch)), errorCallback: () => Flash('Something went wrong while fetching latest comments.'), }); @@ -285,5 +298,13 @@ export const scrollToNoteIfNeeded = (context, el) => { } }; +export const fetchDiscussionDiffLines = ({ commit }, discussion) => + axios.get(discussion.truncatedDiffLinesPath).then(({ data }) => { + commit(types.SET_DISCUSSION_DIFF_LINES, { + discussionId: discussion.id, + diffLines: data.truncated_diff_lines, + }); + }); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index bc373e0d0fc..ab28bb48e9e 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -1,58 +1,89 @@ import _ from 'underscore'; +import * as constants from '../constants'; import { collapseSystemNotes } from './collapse_utils'; -export const notes = state => collapseSystemNotes(state.notes); +export const discussions = state => collapseSystemNotes(state.discussions); 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]; + +export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; export const notesById = state => - state.notes.reduce((acc, note) => { + state.discussions.reduce((acc, note) => { note.notes.every(n => Object.assign(acc, { [n.id]: n })); return acc; }, {}); +export const discussionsByLineCode = state => + state.discussions.reduce((acc, note) => { + if (note.diff_discussion && note.line_code && note.resolvable) { + // For context about 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, EPIC_NOTEABLE_TYPE } = constants; + + if (state.noteableData.noteableType === EPIC_NOTEABLE_TYPE) { + return EPIC_NOTEABLE_TYPE; + } + + 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 && - note.author && - note.author.id === state.userData.id; + !note.system && state.userData && note.author && note.author.id === state.userData.id; export const getCurrentUserLastNote = state => - _.flatten( - reverseNotes(state.notes).map(note => reverseNotes(note.notes)), - ).find(el => isLastNote(el, state)); + _.flatten(reverseNotes(state.discussions).map(note => reverseNotes(note.notes))).find(el => + isLastNote(el, state), + ); export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes).find(el => isLastNote(el, state)); export const discussionCount = state => { - const discussions = state.notes.filter(n => !n.individual_note); + const filteredDiscussions = state.discussions.filter(n => !n.individual_note && n.resolvable); - return discussions.length; + return filteredDiscussions.length; }; export const unresolvedDiscussions = (state, getters) => { const resolvedMap = getters.resolvedDiscussionsById; - return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]); + return state.discussions.filter(n => !n.individual_note && !resolvedMap[n.id]); +}; + +export const allDiscussions = (state, getters) => { + const resolved = getters.resolvedDiscussionsById; + const unresolved = getters.unresolvedDiscussions; + + return Object.values(resolved).concat(unresolved); }; export const resolvedDiscussionsById = state => { const map = {}; - state.notes.forEach(n => { + state.discussions.forEach(n => { if (n.notes) { const resolved = n.notes.every(note => note.resolved && !note.system); @@ -71,5 +102,15 @@ export const resolvedDiscussionCount = (state, getters) => { return Object.keys(resolvedMap).length; }; +export const discussionTabCounter = state => { + let all = []; + + state.discussions.forEach(discussion => { + all = all.concat(discussion.notes.filter(note => !note.system && !note.placeholder)); + }); + + return all.length; +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js index 9ed19bf171e..0f48b8880f4 100644 --- a/app/assets/javascripts/notes/stores/index.js +++ b/app/assets/javascripts/notes/stores/index.js @@ -3,24 +3,14 @@ import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; import mutations from './mutations'; +import module from './modules'; Vue.use(Vuex); -export default new Vuex.Store({ - state: { - notes: [], - targetNoteHash: null, - lastFetchedAt: null, - - // View layer - isToggleStateButtonLoading: false, - - // holds endpoints and permissions provided through haml - notesData: {}, - userData: {}, - noteableData: {}, - }, - actions, - getters, - mutations, -}); +export default () => + new Vuex.Store({ + state: module.state, + actions, + getters, + mutations, + }); diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js new file mode 100644 index 00000000000..a978490c009 --- /dev/null +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -0,0 +1,26 @@ +import * as actions from '../actions'; +import * as getters from '../getters'; +import mutations from '../mutations'; + +export default { + state: { + discussions: [], + targetNoteHash: null, + lastFetchedAt: null, + + // View layer + isToggleStateButtonLoading: false, + + // holds endpoints and permissions provided through haml + notesData: { + markdownDocsPath: '', + }, + userData: {}, + noteableData: { + current_user: {}, + }, + }, + actions, + getters, + mutations, +}; diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index b455e23ecde..caead4cb860 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -1,11 +1,12 @@ export const ADD_NEW_NOTE = 'ADD_NEW_NOTE'; export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION'; export const DELETE_NOTE = 'DELETE_NOTE'; +export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION'; export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES'; export const SET_NOTES_DATA = 'SET_NOTES_DATA'; export const SET_NOTEABLE_DATA = 'SET_NOTEABLE_DATA'; export const SET_USER_DATA = 'SET_USER_DATA'; -export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES'; +export const SET_INITIAL_DISCUSSIONS = 'SET_INITIAL_DISCUSSIONS'; export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT'; export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH'; export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; @@ -13,6 +14,7 @@ export const TOGGLE_AWARD = 'TOGGLE_AWARD'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const UPDATE_NOTE = 'UPDATE_NOTE'; export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; +export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index c8edc06349f..ea165709e61 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -6,8 +6,8 @@ import { isInMRPage } from '../../lib/utils/common_utils'; export default { [types.ADD_NEW_NOTE](state, note) { const { discussion_id, type } = note; - const [exists] = state.notes.filter(n => n.id === note.discussion_id); - const isDiscussion = type === constants.DISCUSSION_NOTE; + const [exists] = state.discussions.filter(n => n.id === note.discussion_id); + const isDiscussion = type === constants.DISCUSSION_NOTE || type === constants.DIFF_NOTE; if (!exists) { const noteData = { @@ -25,42 +25,44 @@ export default { noteData.resolve_with_issue_path = note.resolve_with_issue_path; } - state.notes.push(noteData); - document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); + state.discussions.push(noteData); } }, [types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) { - const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); if (noteObj) { noteObj.notes.push(note); - document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); } }, [types.DELETE_NOTE](state, note) { - const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); if (noteObj.individual_note) { - state.notes.splice(state.notes.indexOf(noteObj), 1); + state.discussions.splice(state.discussions.indexOf(noteObj), 1); } else { const comment = utils.findNoteObjectById(noteObj.notes, note.id); noteObj.notes.splice(noteObj.notes.indexOf(comment), 1); if (!noteObj.notes.length) { - state.notes.splice(state.notes.indexOf(noteObj), 1); + state.discussions.splice(state.discussions.indexOf(noteObj), 1); } } + }, + + [types.EXPAND_DISCUSSION](state, { discussionId }) { + const discussion = utils.findNoteObjectById(state.discussions, discussionId); - document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); + discussion.expanded = true; }, [types.REMOVE_PLACEHOLDER_NOTES](state) { - const { notes } = state; + const { discussions } = state; - for (let i = notes.length - 1; i >= 0; i -= 1) { - const note = notes[i]; + for (let i = discussions.length - 1; i >= 0; i -= 1) { + const note = discussions[i]; const children = note.notes; if (children.length && !note.individual_note) { @@ -72,7 +74,7 @@ export default { } } else if (note.isPlaceholderNote) { // remove placeholders from state root - notes.splice(i, 1); + discussions.splice(i, 1); } } }, @@ -88,29 +90,29 @@ export default { [types.SET_USER_DATA](state, data) { Object.assign(state, { userData: data }); }, - [types.SET_INITIAL_NOTES](state, notesData) { - const notes = []; + [types.SET_INITIAL_DISCUSSIONS](state, discussionsData) { + const discussions = []; - notesData.forEach(note => { + discussionsData.forEach(discussion => { // To support legacy notes, should be very rare case. - if (note.individual_note && note.notes.length > 1) { - note.notes.forEach(n => { - notes.push({ - ...note, + if (discussion.individual_note && discussion.notes.length > 1) { + discussion.notes.forEach(n => { + discussions.push({ + ...discussion, notes: [n], // override notes array to only have one item to mimick individual_note }); }); } else { - const oldNote = utils.findNoteObjectById(state.notes, note.id); + const oldNote = utils.findNoteObjectById(state.discussions, discussion.id); - notes.push({ - ...note, - expanded: oldNote ? oldNote.expanded : note.expanded, + discussions.push({ + ...discussion, + expanded: oldNote ? oldNote.expanded : discussion.expanded, }); } }); - Object.assign(state, { notes }); + Object.assign(state, { discussions }); }, [types.SET_LAST_FETCHED_AT](state, fetchedAt) { @@ -122,17 +124,17 @@ export default { }, [types.SHOW_PLACEHOLDER_NOTE](state, data) { - let notesArr = state.notes; - if (data.replyId) { - notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes; + let notesArr = state.discussions; + + const existingDiscussion = utils.findNoteObjectById(notesArr, data.replyId); + if (existingDiscussion) { + notesArr = existingDiscussion.notes; } notesArr.push({ individual_note: true, isPlaceholderNote: true, - placeholderType: data.isSystemNote - ? constants.SYSTEM_NOTE - : constants.NOTE, + placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE, notes: [ { body: data.noteBody, @@ -151,28 +153,23 @@ export default { if (hasEmojiAwardedByCurrentUser.length) { // If current user has awarded this emoji, remove it. - note.award_emoji.splice( - note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), - 1, - ); + note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1); } else { note.award_emoji.push({ name: awardName, user: { id, name, username }, }); } - - document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.TOGGLE_DISCUSSION](state, { discussionId }) { - const discussion = utils.findNoteObjectById(state.notes, discussionId); + const discussion = utils.findNoteObjectById(state.discussions, discussionId); discussion.expanded = !discussion.expanded; }, [types.UPDATE_NOTE](state, note) { - const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); if (noteObj.individual_note) { noteObj.notes.splice(0, 1, note); @@ -180,24 +177,20 @@ 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) { const note = noteData; let index = 0; - state.notes.forEach((n, i) => { + state.discussions.forEach((n, i) => { if (n.id === note.id) { index = i; } }); note.expanded = true; // override expand flag to prevent collapse - state.notes.splice(index, 1, note); - - document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); + state.discussions.splice(index, 1, note); }, [types.CLOSE_ISSUE](state) { @@ -211,4 +204,15 @@ export default { [types.TOGGLE_STATE_BUTTON_LOADING](state, value) { Object.assign(state, { isToggleStateButtonLoading: value }); }, + + [types.SET_DISCUSSION_DIFF_LINES](state, { discussionId, diffLines }) { + const discussion = utils.findNoteObjectById(state.discussions, discussionId); + const index = state.discussions.indexOf(discussion); + + const discussionWithDiffLines = Object.assign({}, discussion, { + truncated_diff_lines: diffLines, + }); + + state.discussions.splice(index, 1, discussionWithDiffLines); + }, }; 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/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index e5b2827b50c..f61f4db78d5 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,4 +1,3 @@ -import { hasVueMRDiscussionsCookie } from '~/lib/utils/common_utils'; import initMrNotes from '~/mr_notes'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; import initShow from '../init_merge_request_show'; @@ -6,8 +5,5 @@ import initShow from '../init_merge_request_show'; document.addEventListener('DOMContentLoaded', () => { initShow(); initSidebarBundle(); - - if (hasVueMRDiscussionsCookie()) { - initMrNotes(); - } + initMrNotes(); }); diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index 193788f754f..e9451be31fd 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -9,12 +9,10 @@ export default class ShortcutsIssuable extends Shortcuts { constructor(isMergeRequest) { super(); - this.$replyField = isMergeRequest ? $('.js-main-target-form #note_note') : $('.js-main-target-form .js-vue-comment-form'); - Mousetrap.bind('a', () => ShortcutsIssuable.openSidebarDropdown('assignee')); Mousetrap.bind('m', () => ShortcutsIssuable.openSidebarDropdown('milestone')); Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels')); - Mousetrap.bind('r', this.replyWithSelectedText.bind(this)); + Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText); Mousetrap.bind('e', ShortcutsIssuable.editIssue); if (isMergeRequest) { @@ -24,11 +22,16 @@ export default class ShortcutsIssuable extends Shortcuts { } } - replyWithSelectedText() { + static replyWithSelectedText() { + const $replyField = $('.js-main-target-form .js-vue-comment-form'); const documentFragment = window.gl.utils.getSelectedFragment(); + if (!$replyField.length) { + return false; + } + if (!documentFragment) { - this.$replyField.focus(); + $replyField.focus(); return false; } @@ -39,21 +42,22 @@ export default class ShortcutsIssuable extends Shortcuts { return false; } - const quote = _.map(selected.split('\n'), val => `${(`> ${val}`).trim()}\n`); + const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`); // If replyField already has some content, add a newline before our quote - const separator = (this.$replyField.val().trim() !== '' && '\n\n') || ''; - this.$replyField.val((a, current) => `${current}${separator}${quote.join('')}\n`) + const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; + $replyField + .val((a, current) => `${current}${separator}${quote.join('')}\n`) .trigger('input') .trigger('change'); // Trigger autosize const event = document.createEvent('Event'); event.initEvent('autosize:update', true, false); - this.$replyField.get(0).dispatchEvent(event); + $replyField.get(0).dispatchEvent(event); // Focus the input field - this.$replyField.focus(); + $replyField.focus(); return false; } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js new file mode 100644 index 00000000000..bf8628d18a6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js @@ -0,0 +1,15 @@ +/* +The squash-before-merge button is EE only, but it's located right in the middle +of the readyToMerge state component template. + +If we didn't declare this component in CE, we'd need to maintain a separate copy +of the readyToMergeState template in EE, which is pretty big and likely to change. + +Instead, in CE, we declare the component, but it's hidden and is configured to do nothing. +In EE, the configuration extends this object to add a functioning squash-before-merge +button. +*/ + +export default { + template: '', +}; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 05e8ed2da2c..7d26390d9bc 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -150,7 +150,7 @@ </div> <div v-show="previewMarkdown" - class="md md-preview-holder md-preview" + class="md md-preview-holder md-preview js-vue-md-preview" > <div ref="markdown-preview" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index ee3628b1e3f..83171ae50b8 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -71,7 +71,7 @@ class="md-header-tab" > <a - class="js-preview-link" + class="js-preview-link js-md-preview-button" href="#md-preview-holder" tabindex="-1" @click.prevent="previewMarkdownTab($event)" diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index 80e3db52cb0..2eb6c20b2c0 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -14,11 +14,12 @@ </template> <script> - import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; - export default { - components: { - skeletonLoadingContainer, - }, - }; +export default { + name: 'SkeletonNote', + components: { + skeletonLoadingContainer, + }, +}; </script> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index aac10f84087..2122d0a508e 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -1,51 +1,75 @@ <script> - /** - * Common component to render a system note, icon and user information. - * - * This component needs to be used with a vuex store. - * That vuex store needs to have a `targetNoteHash` getter - * - * @example - * <system-note - * :note="{ - * id: String, - * author: Object, - * createdAt: String, - * note_html: String, - * system_note_icon_name: String - * }" - * /> - */ - import { mapGetters } from 'vuex'; - import noteHeader from '~/notes/components/note_header.vue'; - import { spriteIcon } from '../../../lib/utils/common_utils'; +/** + * Common component to render a system note, icon and user information. + * + * This component needs to be used with a vuex store. + * That vuex store needs to have a `targetNoteHash` getter + * + * @example + * <system-note + * :note="{ + * id: String, + * author: Object, + * createdAt: String, + * note_html: String, + * system_note_icon_name: String + * }" + * /> + */ +import $ from 'jquery'; +import { mapGetters } from 'vuex'; +import noteHeader from '~/notes/components/note_header.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { spriteIcon } from '../../../lib/utils/common_utils'; - export default { - name: 'SystemNote', - components: { - noteHeader, +const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; + +export default { + name: 'SystemNote', + components: { + Icon, + noteHeader, + }, + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + expanded: false, + }; + }, + computed: { + ...mapGetters(['targetNoteHash']), + noteAnchorId() { + return `note_${this.note.id}`; + }, + isTargetNote() { + return this.targetNoteHash === this.noteAnchorId; }, - props: { - note: { - type: Object, - required: true, - }, + iconHtml() { + return spriteIcon(this.note.system_note_icon_name); }, - computed: { - ...mapGetters([ - 'targetNoteHash', - ]), - noteAnchorId() { - return `note_${this.note.id}`; - }, - isTargetNote() { - return this.targetNoteHash === this.noteAnchorId; - }, - iconHtml() { - return spriteIcon(this.note.system_note_icon_name); - }, + toggleIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; }, - }; + // following 2 methods taken from code in `collapseLongCommitList` of notes.js: + actionTextHtml() { + return $(this.note.note_html) + .unwrap() + .html(); + }, + hasMoreCommits() { + return ( + $(this.note.note_html) + .filter('ul') + .children().length > MAX_VISIBLE_COMMIT_LIST_COUNT + ); + }, + }, +}; </script> <template> @@ -64,8 +88,35 @@ :author="note.author" :created-at="note.created_at" :note-id="note.id" - :action-text-html="note.note_html" - /> + > + <span v-html="actionTextHtml"></span> + </note-header> + </div> + <div class="note-body"> + <div + :class="{ + 'system-note-commit-list': hasMoreCommits, + 'hide-shade': expanded + }" + class="note-text" + v-html="note.note_html" + ></div> + <div + v-if="hasMoreCommits" + class="flex-list" + > + <div + class="system-note-commit-list-toggler flex-row" + @click="expanded = !expanded" + > + <Icon + :name="toggleIcon" + :size="8" + class="append-right-5" + /> + <span>Toggle commit list</span> + </div> + </div> </div> </div> </div> diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 14cd32da9eb..549a8730301 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -251,3 +251,12 @@ $skeleton-line-widths: ( transform: translateX(468px); } } + +.slide-down-enter-active { + transition: transform 0.2s; +} + +.slide-down-enter, +.slide-down-leave-to { + transform: translateY(-30%); +} diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 0de05548c68..1d4828be223 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -13,6 +13,7 @@ &.diff-collapsed { padding: 5px; + line-height: 34px; .click-to-expand { cursor: pointer; diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index f0ac9b46f91..604f806dc58 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -111,7 +111,9 @@ $dark-il: #de935f; // Diff line .line_holder { - &.match .line_content { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { @include dark-diff-match-line; } diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index eba7919ada9..8e2720511da 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -111,7 +111,9 @@ $monokai-gi: #a6e22e; // Diff line .line_holder { - &.match .line_content { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { @include dark-diff-match-line; } diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index ba53ef0352b..cd1f0f6650f 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -115,7 +115,9 @@ $solarized-dark-il: #2aa198; // Diff line .line_holder { - &.match .line_content { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { @include dark-diff-match-line; } diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index e9fccf1b58a..09c3ea36414 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -122,7 +122,9 @@ $solarized-light-il: #2aa198; // Diff line .line_holder { - &.match .line_content { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { @include matchLine; } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index fbc97ec0c95..65add153606 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -24,6 +24,10 @@ color: $gl-text-color; border-radius: 0 0 3px 3px; + .code { + padding: 0; + } + .unfold { cursor: pointer; } @@ -77,6 +81,12 @@ span { white-space: pre-wrap; + + &.context-cell { + display: inline-block; + width: 100%; + height: 100%; + } } .line { @@ -677,21 +687,21 @@ } @include media-breakpoint-up(sm) { - position: -webkit-sticky; - position: sticky; top: 24px; background-color: $white-light; - z-index: 190; &.diff-files-changed-merge-request { - top: 76px; + position: sticky; + top: 90px; + z-index: 190; + margin: $gl-padding 0; + padding: 0; } &.is-stuck { padding-top: 0; padding-bottom: 0; border-bottom: 1px solid $white-dark; - transform: translateY(16px); .diff-stats-additions-deletions-expanded, .inline-parallel-buttons { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 596d3aa171c..d96ba2107d1 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -600,14 +600,12 @@ position: relative; background: $gray-light; color: $gl-text-color; - z-index: 199; .mr-version-menus-container { - display: -webkit-flex; display: flex; - -webkit-align-items: center; align-items: center; padding: 16px; + z-index: 199; } .content-block { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 3849a04db5d..5e5696b1602 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -247,22 +247,6 @@ } .discussion-with-resolve-btn { - display: table; - width: 100%; - border-collapse: separate; - table-layout: auto; - - .btn-group { - display: table-cell; - float: none; - width: 1%; - - &:first-child { - width: 100%; - padding-right: 5px; - } - } - .discussion-actions { display: table; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 299eda53140..dcc42117861 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -3,9 +3,17 @@ */ @-webkit-keyframes targe3-note { - from { background: $note-targe3-outside; } - 50% { background: $note-targe3-inside; } - to { background: $note-targe3-outside; } + from { + background: $note-targe3-outside; + } + + 50% { + background: $note-targe3-inside; + } + + to { + background: $note-targe3-outside; + } } ul.notes { @@ -33,10 +41,12 @@ ul.notes { .diff-content { overflow: visible; + padding: 0; } } - > li { // .timeline-entry + > li { + // .timeline-entry padding: 0; display: block; position: relative; @@ -153,7 +163,6 @@ ul.notes { } .note-header { - @include notes-media('max', map-get($grid-breakpoints, xs)) { .inline { display: block; @@ -245,7 +254,6 @@ ul.notes { .system-note-commit-list-toggler { color: $gl-link-color; - display: none; padding: 10px 0 0; cursor: pointer; position: relative; @@ -665,7 +673,6 @@ ul.notes { background-color: $white-light; } - a { color: $gl-link-color; } @@ -771,3 +778,44 @@ ul.notes { height: auto; } } + +// Vue refactored diff discussion adjustments +.files { + .diff-discussions { + .note-discussion.timeline-entry { + padding-left: 0; + + &:last-child { + border-bottom: 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; + } + + .add-diff-note svg { + margin-top: 4px; + } +} |