summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorFelipe Artur <fcardozo@gitlab.com>2018-06-21 12:22:40 +0000
committerTim Zallmann <tzallmann@gitlab.com>2018-06-21 12:22:40 +0000
commit3e66795ef1ff1228906239763910b051d8afcc37 (patch)
treedf6424d9ec008f5d1da455f8465681b371c4a11e /app
parent14e35ac9b19d358d84e0cfd167f74036937285b6 (diff)
downloadgitlab-ce-3e66795ef1ff1228906239763910b051d8afcc37.tar.gz
Changes tab VUE refactoring
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/autosave.js4
-rw-r--r--app/assets/javascripts/awards_handler.js191
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js61
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js30
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js40
-rw-r--r--app/assets/javascripts/diffs/components/app.vue197
-rw-r--r--app/assets/javascripts/diffs/components/changed_files.vue184
-rw-r--r--app/assets/javascripts/diffs/components/changed_files_dropdown.vue124
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue55
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions_dropdown.vue165
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue38
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue39
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue191
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue254
-rw-r--r--app/assets/javascripts/diffs/components/diff_gutter_avatars.vue105
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue203
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue93
-rw-r--r--app/assets/javascripts/diffs/components/edit_button.vue42
-rw-r--r--app/assets/javascripts/diffs/components/hidden_files_warning.vue51
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue117
-rw-r--r--app/assets/javascripts/diffs/components/no_changes.vue49
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue224
-rw-r--r--app/assets/javascripts/diffs/constants.js24
-rw-r--r--app/assets/javascripts/diffs/index.js39
-rw-r--r--app/assets/javascripts/diffs/mixins/changed_files.js38
-rw-r--r--app/assets/javascripts/diffs/mixins/diff_content.js89
-rw-r--r--app/assets/javascripts/diffs/store/actions.js99
-rw-r--r--app/assets/javascripts/diffs/store/getters.js16
-rw-r--r--app/assets/javascripts/diffs/store/index.js11
-rw-r--r--app/assets/javascripts/diffs/store/modules/index.js25
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js11
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js85
-rw-r--r--app/assets/javascripts/diffs/store/utils.js172
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js139
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js7
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js21
-rw-r--r--app/assets/javascripts/merge_request.js1
-rw-r--r--app/assets/javascripts/merge_request_tabs.js11
-rw-r--r--app/assets/javascripts/mr_notes/index.js51
-rw-r--r--app/assets/javascripts/mr_notes/stores/actions.js7
-rw-r--r--app/assets/javascripts/mr_notes/stores/getters.js5
-rw-r--r--app/assets/javascripts/mr_notes/stores/index.js15
-rw-r--r--app/assets/javascripts/mr_notes/stores/modules/index.js12
-rw-r--r--app/assets/javascripts/mr_notes/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/mr_notes/stores/mutations.js7
-rw-r--r--app/assets/javascripts/notes.js253
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue51
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue125
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue19
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue20
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue9
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue13
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue53
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue11
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue207
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue51
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue82
-rw-r--r--app/assets/javascripts/notes/constants.js2
-rw-r--r--app/assets/javascripts/notes/index.js81
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js6
-rw-r--r--app/assets/javascripts/notes/mixins/noteable.js9
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js36
-rw-r--r--app/assets/javascripts/notes/services/notes_service.js6
-rw-r--r--app/assets/javascripts/notes/stores/actions.js41
-rw-r--r--app/assets/javascripts/notes/stores/getters.js71
-rw-r--r--app/assets/javascripts/notes/stores/index.js26
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js26
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js96
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js17
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js6
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js15
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue141
-rw-r--r--app/assets/stylesheets/framework/animations.scss9
-rw-r--r--app/assets/stylesheets/framework/blocks.scss1
-rw-r--r--app/assets/stylesheets/highlight/dark.scss4
-rw-r--r--app/assets/stylesheets/highlight/monokai.scss4
-rw-r--r--app/assets/stylesheets/highlight/solarized_dark.scss4
-rw-r--r--app/assets/stylesheets/highlight/solarized_light.scss4
-rw-r--r--app/assets/stylesheets/pages/diff.scss20
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss4
-rw-r--r--app/assets/stylesheets/pages/note_form.scss16
-rw-r--r--app/assets/stylesheets/pages/notes.scss62
-rw-r--r--app/controllers/concerns/issuable_actions.rb2
-rw-r--r--app/controllers/concerns/notes_actions.rb6
-rw-r--r--app/controllers/projects/blob_controller.rb44
-rw-r--r--app/controllers/projects/discussions_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb25
-rw-r--r--app/controllers/projects/merge_requests_controller.rb3
-rw-r--r--app/finders/notes_finder.rb2
-rw-r--r--app/helpers/merge_requests_helper.rb2
-rw-r--r--app/helpers/notes_helper.rb6
-rw-r--r--app/models/concerns/issuable.rb4
-rw-r--r--app/models/discussion.rb4
-rw-r--r--app/models/issue.rb4
-rw-r--r--app/models/merge_request.rb4
-rw-r--r--app/models/note.rb1
-rw-r--r--app/serializers/blob_entity.rb4
-rw-r--r--app/serializers/diff_file_entity.rb123
-rw-r--r--app/serializers/diffs_entity.rb65
-rw-r--r--app/serializers/diffs_serializer.rb3
-rw-r--r--app/serializers/discussion_entity.rb49
-rw-r--r--app/serializers/merge_request_diff_entity.rb46
-rw-r--r--app/serializers/merge_request_user_entity.rb24
-rw-r--r--app/serializers/merge_request_widget_entity.rb12
-rw-r--r--app/serializers/note_entity.rb28
-rw-r--r--app/views/projects/issues/_discussion.html.haml3
-rw-r--r--app/views/projects/merge_requests/show.html.haml35
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml7
-rw-r--r--app/views/shared/runners/show.html.haml14
115 files changed, 4513 insertions, 1009 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">
&middot;
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;
+ }
+}
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index d04eb192129..ba510968684 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -90,7 +90,7 @@ module IssuableActions
end
def discussions
- notes = issuable.notes
+ notes = issuable.discussion_notes
.inc_relations_for_view
.includes(:noteable)
.fresh
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 0c34e49206a..fe9a030cdf2 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -237,10 +237,6 @@ module NotesActions
def use_note_serializer?
return false if params['html']
- if noteable.is_a?(MergeRequest)
- cookies[:vue_mr_discussions] == 'true'
- else
- noteable.discussions_rendered_on_frontend?
- end
+ noteable.discussions_rendered_on_frontend?
end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index a8c0a68fc17..ebc61264b39 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -3,8 +3,8 @@ class Projects::BlobController < Projects::ApplicationController
include ExtractsPath
include CreatesCommit
include RendersBlob
+ include NotesHelper
include ActionView::Helpers::SanitizeHelper
-
prepend_before_action :authenticate_user!, only: [:edit]
before_action :set_request_format, only: [:edit, :show, :update]
@@ -93,6 +93,7 @@ class Projects::BlobController < Projects::ApplicationController
@lines = Gitlab::Highlight.highlight(@blob.path, @blob.data, repository: @repository).lines
@form = UnfoldForm.new(params)
+
@lines = @lines[@form.since - 1..@form.to - 1].map(&:html_safe)
if @form.bottom?
@@ -103,11 +104,50 @@ class Projects::BlobController < Projects::ApplicationController
@match_line = "@@ -#{line}+#{line} @@"
end
- render layout: false
+ # We can keep only 'render_diff_lines' from this conditional when
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/44988 is done
+ if rendered_for_merge_request?
+ render_diff_lines
+ else
+ render layout: false
+ end
end
private
+ # Converts a String array to Gitlab::Diff::Line array
+ def render_diff_lines
+ @lines.map! do |line|
+ # These are marked as context lines but are loaded from blobs.
+ # We also have context lines loaded from diffs in other places.
+ diff_line = Gitlab::Diff::Line.new(line, 'context', nil, nil, nil)
+ diff_line.rich_text = line
+ diff_line
+ end
+
+ add_match_line
+
+ render json: @lines
+ end
+
+ def add_match_line
+ return unless @form.unfold?
+
+ if @form.bottom? && @form.to < @blob.lines.size
+ old_pos = @form.to - @form.offset
+ new_pos = @form.to
+ elsif @form.since != 1
+ old_pos = new_pos = @form.since
+ end
+
+ # Match line is not needed when it reaches the top limit or bottom limit of the file.
+ return unless new_pos
+
+ @match_line = Gitlab::Diff::Line.new(@match_line, 'match', nil, old_pos, new_pos)
+
+ @form.bottom? ? @lines.push(@match_line) : @lines.unshift(@match_line)
+ end
+
def blob
@blob ||= @repository.blob_at(@commit.id, @path)
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index 8e86af43fee..78b9d53a780 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -21,7 +21,7 @@ class Projects::DiscussionsController < Projects::ApplicationController
def show
render json: {
- discussion_html: view_to_html_string('discussions/_diff_with_notes', discussion: discussion, expanded: true)
+ truncated_diff_lines: discussion.try(:truncated_diff_lines)
}
end
@@ -29,11 +29,6 @@ class Projects::DiscussionsController < Projects::ApplicationController
def render_discussion
if serialize_notes?
- # TODO - It is not needed to serialize notes when resolving
- # or unresolving discussions. We should remove this behavior
- # passing a parameter to DiscussionEntity to return an empty array
- # for notes.
- # Check issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/42853
prepare_notes_for_rendering(discussion.notes, merge_request)
render_json_with_discussions_serializer
else
@@ -44,7 +39,7 @@ class Projects::DiscussionsController < Projects::ApplicationController
def render_json_with_discussions_serializer
render json:
DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity)
- .represent(discussion, context: self)
+ .represent(discussion, context: self, render_truncated_diff_lines: true)
end
# Legacy method used to render discussions notes when not using Vue on views.
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index fe8525a488c..48e02581d54 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -9,17 +9,21 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
before_action :define_diff_comment_vars
def show
- @environment = @merge_request.environments_for(current_user).last
-
- render json: { html: view_to_html_string("projects/merge_requests/diffs/_diffs") }
+ render_diffs
end
def diff_for_path
- render_diff_for_path(@diffs)
+ render_diffs
end
private
+ def render_diffs
+ @environment = @merge_request.environments_for(current_user).last
+
+ render json: DiffsSerializer.new(current_user: current_user).represent(@diffs, additional_attributes)
+ end
+
def define_diff_vars
@merge_request_diffs = @merge_request.merge_request_diffs.viewable.order_id_desc
@compare = commit || find_merge_request_diff_compare
@@ -63,6 +67,19 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
end
+ def additional_attributes
+ {
+ environment: @environment,
+ merge_request: @merge_request,
+ merge_request_diff: @merge_request_diff,
+ merge_request_diffs: @merge_request_diffs,
+ start_version: @start_version,
+ start_sha: @start_sha,
+ commit: @commit,
+ latest_diff: @merge_request_diff&.latest?
+ }
+ end
+
def define_diff_comment_vars
@new_diff_note_attrs = {
noteable_type: 'MergeRequest',
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 38918b3cd52..a7c5f858c42 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -42,6 +42,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@noteable = @merge_request
@commits_count = @merge_request.commits_count
+ # TODO cleanup- Fatih Simon Create an issue to remove these after the refactoring
+ # we no longer render notes here. I see it will require a small frontend refactoring,
+ # since we gather some data from this collection.
@discussions = @merge_request.discussions
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 35f4ff2f62f..9b7a35fb3b5 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -83,7 +83,7 @@ class NotesFinder
when "personal_snippet"
PersonalSnippet.all
else
- raise 'invalid target_type'
+ raise "invalid target_type '#{noteable_type}'"
end
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 5ff06b3e0fc..82a7931c557 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -86,6 +86,8 @@ module MergeRequestsHelper
end
def version_index(merge_request_diff)
+ return nil if @merge_request_diffs.empty?
+
@merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff)
end
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index 7f67574a428..5459bb63397 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -174,11 +174,11 @@ module NotesHelper
discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved'
end
- def has_vue_discussions_cookie?
- cookies[:vue_mr_discussions] == 'true'
+ def rendered_for_merge_request?
+ params[:from_merge_request].present?
end
def serialize_notes?
- has_vue_discussions_cookie? && !params['html']
+ rendered_for_merge_request? || params['html'].nil?
end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 44150b37708..b93c1145f82 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -107,6 +107,10 @@ module Issuable
false
end
+ def etag_caching_enabled?
+ false
+ end
+
def has_multiple_assignees?
assignees.count > 1
end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index 92482a1a875..35a0ef00856 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -17,6 +17,10 @@ class Discussion
to: :first_note
+ def project_id
+ project&.id
+ end
+
def self.build(notes, context_noteable = nil)
notes.first.discussion_class(context_noteable).new(notes, context_noteable)
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index d136700836d..d3df2da14e2 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -308,6 +308,10 @@ class Issue < ActiveRecord::Base
end
end
+ def etag_caching_enabled?
+ true
+ end
+
def discussions_rendered_on_frontend?
true
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 4b78ba1029f..3df1130a6e2 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1124,6 +1124,10 @@ class MergeRequest < ActiveRecord::Base
true
end
+ def discussions_rendered_on_frontend?
+ true
+ end
+
def update_project_counter_caches
Projects::OpenMergeRequestsCountService.new(target_project).refresh_cache
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 41c04ae0571..abc40d9016e 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -384,6 +384,7 @@ class Note < ActiveRecord::Base
def expire_etag_cache
return unless noteable&.discussions_rendered_on_frontend?
+ return unless noteable&.etag_caching_enabled?
Gitlab::EtagCaching::Store.new.touch(etag_key)
end
diff --git a/app/serializers/blob_entity.rb b/app/serializers/blob_entity.rb
index ad039a2623d..b501fd5e964 100644
--- a/app/serializers/blob_entity.rb
+++ b/app/serializers/blob_entity.rb
@@ -3,11 +3,13 @@ class BlobEntity < Grape::Entity
expose :id, :path, :name, :mode
+ expose :readable_text?, as: :readable_text
+
expose :icon do |blob|
IconsHelper.file_type_icon_class('file', blob.mode, blob.name)
end
- expose :url do |blob|
+ expose :url, if: -> (*) { request.respond_to?(:ref) } do |blob|
project_blob_path(request.project, File.join(request.ref, blob.path))
end
end
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
index 6e68d275047..aa289a96975 100644
--- a/app/serializers/diff_file_entity.rb
+++ b/app/serializers/diff_file_entity.rb
@@ -1,25 +1,46 @@
class DiffFileEntity < Grape::Entity
+ include RequestAwareEntity
+ include BlobHelper
+ include CommitsHelper
include DiffHelper
include SubmoduleHelper
include BlobHelper
include IconsHelper
- include ActionView::Helpers::TagHelper
+ include TreeHelper
+ include ChecksCollaboration
+ include Gitlab::Utils::StrongMemoize
expose :submodule?, as: :submodule
expose :submodule_link do |diff_file|
- submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository).first
+ memoized_submodule_links(diff_file).first
+ end
+
+ expose :submodule_tree_url do |diff_file|
+ memoized_submodule_links(diff_file).last
end
- expose :blob_path do |diff_file|
- diff_file.blob.path
+ expose :blob, using: BlobEntity
+
+ expose :can_modify_blob do |diff_file|
+ merge_request = options[:merge_request]
+
+ if merge_request&.source_project && current_user
+ can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch)
+ else
+ false
+ end
end
- expose :blob_icon do |diff_file|
- blob_icon(diff_file.b_mode, diff_file.file_path)
+ expose :file_hash do |diff_file|
+ Digest::SHA1.hexdigest(diff_file.file_path)
end
expose :file_path
+ expose :too_large?, as: :too_large
+ expose :collapsed?, as: :collapsed
+ expose :new_file?, as: :new_file
+
expose :deleted_file?, as: :deleted_file
expose :renamed_file?, as: :renamed_file
expose :old_path
@@ -28,6 +49,36 @@ class DiffFileEntity < Grape::Entity
expose :a_mode
expose :b_mode
expose :text?, as: :text
+ expose :added_lines
+ expose :removed_lines
+ expose :diff_refs
+ expose :content_sha
+ expose :stored_externally?, as: :stored_externally
+ expose :external_storage
+
+ expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.text? && options[:merge_request] } do |diff_file|
+ merge_request = options[:merge_request]
+ project = merge_request.target_project
+
+ next unless project
+
+ diff_for_path_namespace_project_merge_request_path(
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: merge_request.iid,
+ old_path: diff_file.old_path,
+ new_path: diff_file.new_path,
+ file_identifier: diff_file.file_identifier
+ )
+ end
+
+ expose :formatted_external_url, if: -> (_, options) { options[:environment] } do |diff_file|
+ options[:environment].formatted_external_url
+ end
+
+ expose :external_url, if: -> (_, options) { options[:environment] } do |diff_file|
+ options[:environment].external_url_for(diff_file.new_path, diff_file.content_sha)
+ end
expose :old_path_html do |diff_file|
old_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
@@ -38,4 +89,64 @@ class DiffFileEntity < Grape::Entity
_, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
new_path
end
+
+ expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
+ merge_request = options[:merge_request]
+
+ options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {}
+
+ next unless merge_request.source_project
+
+ project_edit_blob_path(merge_request.source_project,
+ tree_join(merge_request.source_branch, diff_file.new_path),
+ options)
+ end
+
+ expose :view_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
+ merge_request = options[:merge_request]
+
+ project = merge_request.target_project
+
+ next unless project
+
+ project_blob_path(project, tree_join(diff_file.content_sha, diff_file.new_path))
+ end
+
+ expose :replaced_view_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
+ image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image'
+ image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha
+
+ merge_request = options[:merge_request]
+ project = merge_request.target_project
+
+ next unless project
+
+ project_blob_path(project, tree_join(diff_file.old_content_sha, diff_file.old_path)) if image_diff && image_replaced
+ end
+
+ expose :context_lines_path, if: -> (diff_file, _) { diff_file.text? } do |diff_file|
+ project_blob_diff_path(diff_file.repository.project, tree_join(diff_file.content_sha, diff_file.file_path))
+ end
+
+ # Used for inline diffs
+ expose :highlighted_diff_lines, if: -> (diff_file, _) { diff_file.text? } do |diff_file|
+ diff_file.diff_lines_for_serializer
+ end
+
+ # Used for parallel diffs
+ expose :parallel_diff_lines, if: -> (diff_file, _) { diff_file.text? }
+
+ def current_user
+ request.current_user
+ end
+
+ def memoized_submodule_links(diff_file)
+ strong_memoize(:submodule_links) do
+ if diff_file.submodule?
+ submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository)
+ else
+ []
+ end
+ end
+ end
end
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
new file mode 100644
index 00000000000..bb804e5347a
--- /dev/null
+++ b/app/serializers/diffs_entity.rb
@@ -0,0 +1,65 @@
+class DiffsEntity < Grape::Entity
+ include DiffHelper
+ include RequestAwareEntity
+
+ expose :real_size
+ expose :size
+
+ expose :branch_name do |diffs|
+ merge_request&.source_branch
+ end
+
+ expose :target_branch_name do |diffs|
+ merge_request&.target_branch
+ end
+
+ expose :commit do |diffs|
+ options[:commit]
+ end
+
+ expose :merge_request_diff, using: MergeRequestDiffEntity do |diffs|
+ options[:merge_request_diff]
+ end
+
+ expose :start_version, using: MergeRequestDiffEntity do |diffs|
+ options[:start_version]
+ end
+
+ expose :latest_diff do |diffs|
+ options[:latest_diff]
+ end
+
+ expose :latest_version_path, if: -> (*) { merge_request } do |diffs|
+ diffs_project_merge_request_path(merge_request&.project, merge_request)
+ end
+
+ expose :added_lines do |diffs|
+ diffs.diff_files.sum(&:added_lines)
+ end
+
+ expose :removed_lines do |diffs|
+ diffs.diff_files.sum(&:removed_lines)
+ end
+
+ expose :render_overflow_warning do |diffs|
+ render_overflow_warning?(diffs.diff_files)
+ end
+
+ expose :email_patch_path, if: -> (*) { merge_request } do |diffs|
+ merge_request_path(merge_request, format: :patch)
+ end
+
+ expose :plain_diff_path, if: -> (*) { merge_request } do |diffs|
+ merge_request_path(merge_request, format: :diff)
+ end
+
+ expose :diff_files, using: DiffFileEntity
+
+ expose :merge_request_diffs, using: MergeRequestDiffEntity, if: -> (_, options) { options[:merge_request_diffs]&.any? } do |diffs|
+ options[:merge_request_diffs]
+ end
+
+ def merge_request
+ options[:merge_request]
+ end
+end
diff --git a/app/serializers/diffs_serializer.rb b/app/serializers/diffs_serializer.rb
new file mode 100644
index 00000000000..6771e10c5ac
--- /dev/null
+++ b/app/serializers/diffs_serializer.rb
@@ -0,0 +1,3 @@
+class DiffsSerializer < BaseSerializer
+ entity DiffsEntity
+end
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
index 718fb35e62d..63f28133a64 100644
--- a/app/serializers/discussion_entity.rb
+++ b/app/serializers/discussion_entity.rb
@@ -1,16 +1,31 @@
class DiscussionEntity < Grape::Entity
include RequestAwareEntity
+ include NotesHelper
expose :id, :reply_id
+ expose :position, if: -> (d, _) { d.diff_discussion? }
+ expose :line_code, if: -> (d, _) { d.diff_discussion? }
expose :expanded?, as: :expanded
+ expose :active?, as: :active, if: -> (d, _) { d.diff_discussion? }
+ expose :project_id
expose :notes do |discussion, opts|
request.note_entity.represent(discussion.notes, opts)
end
+ expose :discussion_path do |discussion|
+ discussion_path(discussion)
+ end
+
expose :individual_note?, as: :individual_note
- expose :resolvable?, as: :resolvable
+ expose :resolvable do |discussion|
+ discussion.resolvable?
+ end
+
expose :resolved?, as: :resolved
+ expose :resolved_by_push?, as: :resolved_by_push
+ expose :resolved_by
+ expose :resolved_at
expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion|
resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id)
end
@@ -18,24 +33,17 @@ class DiscussionEntity < Grape::Entity
new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
end
- expose :diff_file, using: DiffFileEntity, if: -> (d, _) { defined? d.diff_file }
+ expose :diff_file, using: DiffFileEntity, if: -> (d, _) { d.diff_discussion? }
expose :diff_discussion?, as: :diff_discussion
- expose :truncated_diff_lines, if: -> (d, _) { (defined? d.diff_file) && d.diff_file.text? } do |discussion|
- options[:context].render_to_string(
- partial: "projects/diffs/line",
- collection: discussion.truncated_diff_lines,
- as: :line,
- locals: { diff_file: discussion.diff_file,
- discussion_expanded: true,
- plain: true },
- layout: false,
- formats: [:html]
- )
+ expose :truncated_diff_lines_path, if: -> (d, _) { !d.expanded? && !render_truncated_diff_lines? } do |discussion|
+ project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion)
end
- expose :image_diff_html, if: -> (d, _) { defined? d.diff_file } do |discussion|
+ expose :truncated_diff_lines, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) }
+
+ expose :image_diff_html, if: -> (d, _) { d.diff_discussion? && d.on_image? } do |discussion|
diff_file = discussion.diff_file
partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff'
options[:context].render_to_string(
@@ -47,4 +55,17 @@ class DiscussionEntity < Grape::Entity
formats: [:html]
)
end
+
+ expose :for_commit?, as: :for_commit
+ expose :commit_id
+
+ private
+
+ def render_truncated_diff_lines?
+ options[:render_truncated_diff_lines]
+ end
+
+ def current_user
+ request.current_user
+ end
end
diff --git a/app/serializers/merge_request_diff_entity.rb b/app/serializers/merge_request_diff_entity.rb
new file mode 100644
index 00000000000..32c761b45ac
--- /dev/null
+++ b/app/serializers/merge_request_diff_entity.rb
@@ -0,0 +1,46 @@
+class MergeRequestDiffEntity < Grape::Entity
+ include Gitlab::Routing
+ include GitHelper
+ include MergeRequestsHelper
+
+ expose :version_index do |merge_request_diff|
+ @merge_request_diffs = options[:merge_request_diffs]
+ diff = options[:merge_request_diff]
+
+ next unless diff.present?
+ next unless @merge_request_diffs.size > 1
+
+ version_index(merge_request_diff)
+ end
+
+ expose :created_at
+ expose :commits_count
+
+ expose :latest?, as: :latest
+
+ expose :short_commit_sha do |merge_request_diff|
+ short_sha(merge_request_diff.head_commit_sha)
+ end
+
+ expose :version_path do |merge_request_diff|
+ start_sha = options[:start_sha]
+ project = merge_request.target_project
+
+ next unless project
+
+ merge_request_version_path(project, merge_request, merge_request_diff, start_sha)
+ end
+
+ expose :compare_path do |merge_request_diff|
+ project = merge_request.target_project
+ diff = options[:merge_request_diff]
+
+ if project && diff
+ merge_request_version_path(project, merge_request, diff, merge_request_diff.head_commit_sha)
+ end
+ end
+
+ def merge_request
+ options[:merge_request]
+ end
+end
diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb
new file mode 100644
index 00000000000..33fc7b724d5
--- /dev/null
+++ b/app/serializers/merge_request_user_entity.rb
@@ -0,0 +1,24 @@
+class MergeRequestUserEntity < UserEntity
+ include RequestAwareEntity
+ include BlobHelper
+ include TreeHelper
+
+ expose :can_fork do |user|
+ can?(user, :fork_project, request.project) if project
+ end
+
+ expose :can_create_merge_request do |user|
+ project && can?(user, :create_merge_request_in, project)
+ end
+
+ expose :fork_path, if: -> (*) { project } do |user|
+ params = edit_blob_fork_params("Edit")
+ project_forks_path(project, namespace_key: user.namespace.id, continue: params)
+ end
+
+ def project
+ return false unless request.respond_to?(:project) && request.project
+
+ request.project
+ end
+end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 8260c6c7b84..0426afc1b4a 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -120,12 +120,12 @@ class MergeRequestWidgetEntity < IssuableEntity
presenter(merge_request).can_cherry_pick_on_current_merge_request?
end
- expose :can_create_note do |issue|
- can?(request.current_user, :create_note, issue.project)
+ expose :can_create_note do |merge_request|
+ can?(request.current_user, :create_note, merge_request)
end
- expose :can_update do |issue|
- can?(request.current_user, :update_issue, issue)
+ expose :can_update do |merge_request|
+ can?(request.current_user, :update_merge_request, merge_request)
end
end
@@ -209,6 +209,10 @@ class MergeRequestWidgetEntity < IssuableEntity
commit_change_content_project_merge_request_path(merge_request.project, merge_request)
end
+ expose :preview_note_path do |merge_request|
+ preview_markdown_path(merge_request.project, quick_actions_target_type: 'MergeRequest', quick_actions_target_id: merge_request.id)
+ end
+
expose :merge_commit_path do |merge_request|
if merge_request.merge_commit_sha
project_commit_path(merge_request.project, merge_request.merge_commit_sha)
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index 06d603b277e..ce0c31b5806 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -1,5 +1,6 @@
class NoteEntity < API::Entities::Note
include RequestAwareEntity
+ include NotesHelper
expose :type
@@ -15,16 +16,21 @@ class NoteEntity < API::Entities::Note
expose :current_user do
expose :can_edit do |note|
- Ability.allowed?(request.current_user, :admin_note, note)
+ can?(current_user, :admin_note, note)
end
expose :can_award_emoji do |note|
- Ability.allowed?(request.current_user, :award_emoji, note)
+ can?(current_user, :award_emoji, note)
+ end
+
+ expose :can_resolve do |note|
+ note.resolvable? && can?(current_user, :resolve_note, note)
end
end
expose :resolved?, as: :resolved
expose :resolvable?, as: :resolvable
+
expose :resolved_by, using: NoteUserEntity
expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
@@ -42,5 +48,23 @@ class NoteEntity < API::Entities::Note
new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note))
end
+ expose :noteable_note_url do |note|
+ noteable_note_url(note)
+ end
+
+ expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
+ resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id)
+ end
+
+ expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
+ new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id)
+ end
+
expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
+
+ private
+
+ def current_user
+ request.current_user
+ end
end
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 816f2fa816d..665968a64e1 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -8,5 +8,6 @@
%section.js-vue-notes-event
#js-vue-notes{ data: { notes_data: notes_data(@issue),
noteable_data: serialize_issuable(@issue),
- noteable_type: 'issue',
+ noteable_type: 'Issue',
+ target_type: 'issue',
current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 2f1877a15c2..4fe0ae17ec5 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -52,24 +52,7 @@
Changes
%span.badge.badge-pill= @merge_request.diff_size
- - if has_vue_discussions_cookie?
- #js-vue-discussion-counter
- - else
- #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true }
- %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
- %div
- .line-resolve-all{ "v-show" => "discussionCount > 0",
- ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
- %span.line-resolve-btn.is-disabled{ type: "button",
- ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
- %template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' }
- = render 'shared/icons/icon_status_success_solid.svg'
- %template{ 'v-else' => '' }
- = render 'shared/icons/icon_resolve_discussion.svg'
- %span.line-resolve-text
- {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved
- = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request
- = render "discussions/jump_to_next"
+ #js-vue-discussion-counter
.tab-content#diff-notes-app
#notes.notes.tab-pane.voting_notes
@@ -77,20 +60,20 @@
%section.col-md-12
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-discussion.js-vue-notes-event
- = render "projects/merge_requests/discussion"
- - if has_vue_discussions_cookie?
- #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request),
- noteable_data: serialize_issuable(@merge_request),
- noteable_type: 'merge_request',
- current_user_data: UserSerializer.new.represent(current_user).to_json} }
+ #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request),
+ noteable_data: serialize_issuable(@merge_request),
+ noteable_type: 'MergeRequest',
+ target_type: 'merge_request',
+ current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} }
#commits.commits.tab-pane
-# This tab is always loaded via AJAX
#pipelines.pipelines.tab-pane
- if @pipelines.any?
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
- #diffs.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked? } }
- -# This tab is always loaded via AJAX
+ #js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?,
+ endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
+ current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json } }
.mr-loading-status
= spinner
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index 526330f4e50..d4e8f30e458 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -59,7 +59,7 @@
.note-awards
= render 'award_emoji/awards_block', awardable: note, inline: false
- if note.system
- .system-note-commit-list-toggler
+ .system-note-commit-list-toggler.hide
Toggle commit list
%i.fa.fa-angle-down
- if note.attachment.url
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index b98d5339d2d..e0832fd9136 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -1,14 +1,13 @@
- issuable = @issue || @merge_request
- discussion_locked = issuable&.discussion_locked?
-- unless has_vue_discussions_cookie?
- %ul#notes-list.notes.main-notes-list.timeline
- = render "shared/notes/notes"
+%ul#notes-list.notes.main-notes-list.timeline
+ = render "shared/notes/notes"
= render 'shared/notes/edit_form', project: @project
- if can_create_note?
- %ul.notes.notes-form.timeline{ :class => ('hidden' if has_vue_discussions_cookie?) }
+ %ul.notes.notes-form.timeline
%li.timeline-entry
.timeline-entry-inner
.flash-container.timeline-content
diff --git a/app/views/shared/runners/show.html.haml b/app/views/shared/runners/show.html.haml
index e50b7fa68dd..96527fcb4f2 100644
--- a/app/views/shared/runners/show.html.haml
+++ b/app/views/shared/runners/show.html.haml
@@ -21,17 +21,17 @@
%th Value
%tr
%td Active
- %td= @runner.active? ? _('Yes') : _('No')
+ %td= @runner.active? ? 'Yes' : 'No'
%tr
%td Protected
- %td= @runner.ref_protected? ? _('Yes') : _('No')
+ %td= @runner.active? ? _('Yes') : _('No')
%tr
- %td= _('Can run untagged jobs')
- %td= @runner.run_untagged? ? _('Yes') : _('No')
+ %td Can run untagged jobs
+ %td= @runner.run_untagged? ? 'Yes' : 'No'
- unless @runner.group_type?
%tr
- %td= _('Locked to this project')
- %td= @runner.locked? ? _('Yes') : _('No')
+ %td Locked to this project
+ %td= @runner.locked? ? 'Yes' : 'No'
%tr
%td Tags
%td
@@ -60,7 +60,7 @@
%td Description
%td= @runner.description
%tr
- %td= _('Maximum job timeout')
+ %td Maximum job timeout
%td= @runner.maximum_timeout_human_readable
%tr
%td Last contact