diff options
Diffstat (limited to 'app/assets')
47 files changed, 1345 insertions, 462 deletions
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 0f28bd233ac..0da872db7e5 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -3,10 +3,10 @@ import AccessorUtilities from './lib/utils/accessor'; export default class Autosave { - constructor(field, key, resource) { + constructor(field, key) { this.field = field; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); - this.resource = resource; if (key.join != null) { key = key.join('/'); } @@ -17,31 +17,27 @@ export default class Autosave { } restore() { - var text; - if (!this.isLocalStorageAvailable) return; + if (!this.field.length) return; - text = window.localStorage.getItem(this.key); + const text = window.localStorage.getItem(this.key); if ((text != null ? text.length : void 0) > 0) { this.field.val(text); } - if (!this.resource && this.resource !== 'issue') { - this.field.trigger('input'); - } else { - // v-model does not update with jQuery trigger - // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 - const event = new Event('change', { bubbles: true, cancelable: false }); - const field = this.field.get(0); - if (field) { - field.dispatchEvent(event); - } - } + + this.field.trigger('input'); + // v-model does not update with jQuery trigger + // 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); } save() { - var text; - text = this.field.val(); + if (!this.field.length) return; + + const text = this.field.val(); if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { return window.localStorage.setItem(this.key, text); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 9456edebccb..26e62732b33 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import Cookies from 'js-cookie'; import { __ } from './locale'; -import { isInIssuePage, updateTooltipTitle } from './lib/utils/common_utils'; +import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -239,9 +239,9 @@ class AwardsHandler { } addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { - const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length; + const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length; - if (isInIssuePage() && !isMainAwardsBlock) { + if (this.isInVueNoteablePage() && !isMainAwardsBlock) { const id = votesBlock.attr('id').replace('note_', ''); this.hideMenuElement($('.emoji-menu')); @@ -293,8 +293,16 @@ class AwardsHandler { } } + isVueMRDiscussions() { + return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); + } + + isInVueNoteablePage() { + return isInIssuePage() || this.isVueMRDiscussions(); + } + getVotesBlock() { - if (isInIssuePage()) { + if (this.isInVueNoteablePage()) { const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); if ($el.length) { diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index add24303e7b..9501e35b178 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -6,7 +6,7 @@ import { __ } from '../../locale'; import Sidebar from '../../right_sidebar'; import eventHub from '../../sidebar/event_hub'; import assigneeTitle from '../../sidebar/components/assignees/assignee_title'; -import assignees from '../../sidebar/components/assignees/assignees'; +import assignees from '../../sidebar/components/assignees/assignees.vue'; import DueDateSelectors from '../../due_date_select'; import './sidebar/remove_issue'; import IssuableContext from '../../issuable_context'; diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index e77910a83d4..fadc34959e1 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({ } $.scrollTo($target, { - offset: 0 + offset: -150 }); } }, diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 20ddcbfb8bd..cc9192deae3 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({ CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); this.discussion.updateHeadline(data); gl.mrWidget.checkStatus(); + document.dispatchEvent(new CustomEvent('refreshVueNotes')); this.updateTooltip(); }) diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index 679057e787c..5f49609fe88 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -14,6 +14,7 @@ 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'); @@ -67,12 +68,14 @@ export default () => { gl.diffNotesCompileComponents(); - new Vue({ - el: '#resolve-count-app', - components: { - 'resolve-count': ResolveCount - }, - }); + if (!hasVueMRDiscussionsCookie()) { + new Vue({ + el: '#resolve-count-app', + 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 96fe23640af..d16f9297de1 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -8,8 +8,8 @@ window.gl = window.gl || {}; class ResolveServiceClass { constructor(root) { - this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); - this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); + 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) { @@ -45,6 +45,7 @@ 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.')); } diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 89a246f56cf..9b46bbf83da 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -21,7 +21,7 @@ export default class LabelsSelect { } $els.each(function(i, dropdown) { - var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; + var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; $dropdown = $(dropdown); $dropdownContainer = $dropdown.closest('.labels-filter'); $toggleText = $dropdown.find('.dropdown-toggle-text'); @@ -53,13 +53,6 @@ export default class LabelsSelect { .map(function () { return this.value; }).get(); - if (issueUpdateURL != null) { - issueURLSplit = issueUpdateURL.split('/'); - } - if (issueUpdateURL) { - labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>'); - labelNoneHTMLTemplate = '<span class="no-value">None</span>'; - } const handleClick = options.handleClick; $sidebarLabelTooltip.tooltip(); @@ -91,14 +84,17 @@ export default class LabelsSelect { $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); - data.issueURLSplit = issueURLSplit; + data.issueUpdateURL = issueUpdateURL; labelCount = 0; - if (data.labels.length) { - template = labelHTMLTemplate(data); + if (data.labels.length && issueUpdateURL) { + template = LabelsSelect.getLabelTemplate({ + labels: data.labels, + issueUpdateURL, + }); labelCount = data.labels.length; } else { - template = labelNoneHTMLTemplate; + template = '<span class="no-value">None</span>'; } $value.removeAttr('style').html(template); $sidebarCollapsedValue.text(labelCount); @@ -418,6 +414,26 @@ export default class LabelsSelect { this.bindEvents(); } + static getLabelTemplate(tplData) { + // We could use ES6 template string here + // and properly indent markup for readability + // but that also introduces unintended white-space + // so best approach is to use traditional way of + // concatenation + // see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays + const tpl = _.template([ + '<% _.each(labels, function(label){ %>', + '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">', + '<span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">', + '<%- label.title %>', + '</span>', + '</a>', + '<% }); %>', + ].join('')); + + return tpl(tplData); + } + bindEvents() { return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue); } diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 017f3b986fd..e741789fbb6 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -1,3 +1,5 @@ +import jQuery from 'jquery'; +import Cookies from 'js-cookie'; import axios from './axios_utils'; import { getLocationHash } from './url_utility'; import { convertToCamelCase } from './text_utility'; @@ -22,13 +24,18 @@ export const getGroupSlug = () => { return null; }; -export const isInIssuePage = () => { - const page = getPagePath(1); - const action = getPagePath(2); +export const checkPageAndAction = (page, action) => { + const pagePath = getPagePath(1); + const actionPath = getPagePath(2); - return page === 'issues' && action === 'show'; + return pagePath === page && actionPath === action; }; +export const isInIssuePage = () => checkPageAndAction('issues', 'show'); +export const isInMRPage = () => checkPageAndAction('merge_requests', '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', @@ -133,7 +140,11 @@ 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 = ($el) => { +export const scrollToElement = (element) => { + let $el = element; + if (!(element instanceof jQuery)) { + $el = $(element); + } const top = $el.offset().top; const mrTabsHeight = $('.merge-request-tabs').height() || 0; const headerHeight = $('.navbar-gitlab').height() || 0; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 94d03621bff..c0ce0786518 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) { return `${text[0].toUpperCase()}${text.slice(1)}`; } +export function camelCase(str) { + return str.replace(/_+([a-z])/gi, ($1, $2) => $2.toUpperCase()); +} + +export function camelCaseKeys(obj = {}) { + return Object.keys(obj).reduce((acc, key) => { + const camelKey = camelCase(key); + return { + ...acc, + [camelKey]: obj[key], + }; + }, {}); +} + /** * Replaces all html tags from a string with the given replacement. * diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index b4b3c15108d..66b258839ae 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -12,7 +12,7 @@ import './components/inline_conflict_lines'; import './components/parallel_conflict_lines'; import syntaxHighlight from '../syntax_highlight'; -$(() => { +export default function initMergeConflicts() { const INTERACTIVE_RESOLVE_MODE = 'interactive'; const conflictsEl = document.querySelector('#conflicts'); const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore; @@ -91,4 +91,4 @@ $(() => { } } }); -}); +} diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 41971e92ec0..46789e324c2 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -241,6 +241,10 @@ export default class MergeRequestTabs { return newState; } + getCurrentAction() { + return this.currentAction; + } + loadCommits(source) { if (this.commitsLoaded) { return; diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js new file mode 100644 index 00000000000..f4cba998fa7 --- /dev/null +++ b/app/assets/javascripts/mr_notes/index.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import notesApp from '../notes/components/notes_app.vue'; +import discussionCounter from '../notes/components/discussion_counter.vue'; +import store from '../notes/stores'; + +document.addEventListener('DOMContentLoaded', () => { + new Vue({ // eslint-disable-line + el: '#js-vue-mr-discussions', + components: { + notesApp, + }, + data() { + const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; + return { + noteableData: JSON.parse(notesDataset.noteableData), + currentUserData: JSON.parse(notesDataset.currentUserData), + notesData: JSON.parse(notesDataset.notesData), + }; + }, + render(createElement) { + return createElement('notes-app', { + props: { + noteableData: this.noteableData, + notesData: this.notesData, + userData: this.currentUserData, + }, + }); + }, + }); + + new Vue({ // eslint-disable-line + el: '#js-vue-discussion-counter', + components: { + discussionCounter, + }, + store, + render(createElement) { + return createElement('discussion-counter'); + }, + }); +}); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index f17b432cffd..c640003d958 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -24,7 +24,7 @@ import GLForm from './gl_form'; import loadAwardsHandler from './awards_handler'; import Autosave from './autosave'; import TaskList from './task_list'; -import { isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils'; +import { isInViewport, getPagePath, scrollToElement, isMetaKey, hasVueMRDiscussionsCookie } from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; import { localTimeAgo } from './lib/utils/datetime_utility'; @@ -44,6 +44,10 @@ export default class Notes { } } + static getInstance() { + return this.instance; + } + constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { this.updateTargetButtons = this.updateTargetButtons.bind(this); this.updateComment = this.updateComment.bind(this); @@ -102,67 +106,77 @@ export default class Notes { } addBinding() { + this.$wrapperEl = hasVueMRDiscussionsCookie() ? $(document).find('.diffs') : $(document); + // Edit note link - $(document).on('click', '.js-note-edit', this.showEditForm.bind(this)); - $(document).on('click', '.note-edit-cancel', this.cancelEdit); + this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this)); + this.$wrapperEl.on('click', '.note-edit-cancel', this.cancelEdit); // Reopen and close actions for Issue/MR combined with note form submit - $(document).on('click', '.js-comment-submit-button', this.postComment); - $(document).on('click', '.js-comment-save-button', this.updateComment); - $(document).on('keyup input', '.js-note-text', this.updateTargetButtons); + 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); // resolve a discussion - $(document).on('click', '.js-comment-resolve-button', this.postComment); + this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) - $(document).on('click', '.js-note-delete', this.removeNote); + this.$wrapperEl.on('click', '.js-note-delete', this.removeNote); // delete note attachment - $(document).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 - $(document).on('click', '.js-note-discard', this.resetMainTargetForm); + this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm); // update the file name when an attachment is selected - $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment); + this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment); // reply to diff/discussion notes - $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); + this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); // add diff note - $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote); + this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote); // add diff note for images - $(document).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 - $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); + this.$wrapperEl.on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); // toggle commit list - $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList); + this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList); // fetch notes when tab becomes visible - $(document).on('visibilitychange', this.visibilityChange); + this.$wrapperEl.on('visibilitychange', this.visibilityChange); // when issue status changes, we need to refresh data - $(document).on('issuable:change', this.refresh); + this.$wrapperEl.on('issuable:change', this.refresh); // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. - $(document).on('ajax:success', '.js-main-target-form', this.addNote); - $(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); - $(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); - $(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); + 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:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); // when a key is clicked on the notes - $(document).on('keydown', '.js-note-text', this.keydownNoteText); + this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText); // When the URL fragment/hash has changed, `#note_xxx` - return $(window).on('hashchange', this.onHashChange); + $(window).on('hashchange', this.onHashChange); + this.boundGetContent = this.getContent.bind(this); + document.addEventListener('refreshLegacyNotes', this.boundGetContent); + this.eventsBound = true; } cleanBinding() { - $(document).off('click', '.js-note-edit'); - $(document).off('click', '.note-edit-cancel'); - $(document).off('click', '.js-note-delete'); - $(document).off('click', '.js-note-attachment-delete'); - $(document).off('click', '.js-discussion-reply-button'); - $(document).off('click', '.js-add-diff-note-button'); - $(document).off('click', '.js-add-image-diff-note-button'); - $(document).off('visibilitychange'); - $(document).off('keyup input', '.js-note-text'); - $(document).off('click', '.js-note-target-reopen'); - $(document).off('click', '.js-note-target-close'); - $(document).off('click', '.js-note-discard'); - $(document).off('keydown', '.js-note-text'); - $(document).off('click', '.js-comment-resolve-button'); - $(document).off('click', '.system-note-commit-list-toggler'); - $(document).off('ajax:success', '.js-main-target-form'); - $(document).off('ajax:success', '.js-discussion-note-form'); - $(document).off('ajax:complete', '.js-main-target-form'); + if (!this.eventsBound) { + return; + } + + this.$wrapperEl.off('click', '.js-note-edit'); + this.$wrapperEl.off('click', '.note-edit-cancel'); + this.$wrapperEl.off('click', '.js-note-delete'); + this.$wrapperEl.off('click', '.js-note-attachment-delete'); + this.$wrapperEl.off('click', '.js-discussion-reply-button'); + this.$wrapperEl.off('click', '.js-add-diff-note-button'); + this.$wrapperEl.off('click', '.js-add-image-diff-note-button'); + this.$wrapperEl.off('visibilitychange'); + this.$wrapperEl.off('keyup input', '.js-note-text'); + this.$wrapperEl.off('click', '.js-note-target-reopen'); + this.$wrapperEl.off('click', '.js-note-target-close'); + this.$wrapperEl.off('click', '.js-note-discard'); + this.$wrapperEl.off('keydown', '.js-note-text'); + this.$wrapperEl.off('click', '.js-comment-resolve-button'); + this.$wrapperEl.off('click', '.system-note-commit-list-toggler'); + 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); } @@ -252,8 +266,10 @@ export default class Notes { if (this.refreshing) { return; } + this.refreshing = true; - axios.get(this.notes_url, { + + axios.get(`${this.notes_url}?html=true`, { headers: { 'X-Last-Fetched-At': this.last_fetched_at, }, @@ -350,7 +366,7 @@ export default class Notes { } if (!noteEntity.valid) { - if (noteEntity.errors.commands_only) { + if (noteEntity.errors && noteEntity.errors.commands_only) { if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) { $notesList.find('.system-note.being-posted').remove(); @@ -363,6 +379,10 @@ export default class Notes { const $note = $notesList.find(`#note_${noteEntity.id}`); if (Notes.isNewNote(noteEntity, this.note_ids)) { + if (hasVueMRDiscussionsCookie()) { + return; + } + this.note_ids.push(noteEntity.id); if ($notesList.length) { @@ -399,6 +419,8 @@ export default class Notes { this.setupNewNote($updatedNote); } } + + Notes.refreshVueNotes(); } isParallelView() { @@ -406,12 +428,11 @@ export default class Notes { } /** - * Render note in discussion area. - * - * Note: for rendering inline notes use renderDiscussionNote + * Render note in discussion area. To render inline notes use renderDiscussionNote. */ renderDiscussionNote(noteEntity, $form) { var discussionContainer, form, row, lineType, diffAvatarContainer; + if (!Notes.isNewNote(noteEntity, this.note_ids)) { return; } @@ -452,7 +473,9 @@ export default class Notes { // 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) { - Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); + if (!hasVueMRDiscussionsCookie()) { + Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); + } } } else { // append new note to all matching discussions @@ -634,7 +657,6 @@ export default class Notes { var $noteEntityEl, $note_li; // Convert returned HTML to a jQuery object so we can modify it further $noteEntityEl = $(noteEntity.html); - $noteEntityEl.addClass('fade-in-full'); this.revertNoteEditForm($targetNote); $noteEntityEl.renderGFM(); // Find the note's `li` element by ID and replace it with the updated HTML @@ -730,7 +752,7 @@ export default class Notes { var selector = this.getEditFormSelector($target); var $editForm = $(selector); - $editForm.insertBefore('.notes-form'); + $editForm.insertBefore('.diffs'); $editForm.find('.js-comment-save-button').enable(); $editForm.find('.js-finish-edit-warning').hide(); } @@ -746,7 +768,8 @@ export default class Notes { } removeNoteEditForm($note) { - var form = $note.find('.current-note-edit-form'); + var form = $note.find('.diffs .current-note-edit-form'); + $note.removeClass('is-editing'); form.removeClass('current-note-edit-form'); form.find('.js-finish-edit-warning').hide(); @@ -818,6 +841,7 @@ export default class Notes { }; })(this)); + Notes.refreshVueNotes(); Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); } @@ -1157,7 +1181,7 @@ export default class Notes { this.glForm = new GLForm($editForm.find('form'), this.enableGFM); $editForm.find('form') - .attr('action', postUrl) + .attr('action', `${postUrl}?html=true`) .attr('data-remote', 'true'); $editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-type').val(targetType); @@ -1280,6 +1304,10 @@ export default class Notes { return $updatedNote; } + static refreshVueNotes() { + document.dispatchEvent(new CustomEvent('refreshVueNotes')); + } + /** * Get data from Form attributes to use for saving/submitting comment. */ @@ -1481,7 +1509,7 @@ export default class Notes { /* eslint-disable promise/catch-or-return */ // Make request to submit comment on server - axios.post(formAction, formData) + axios.post(`${formAction}?html=true`, formData) .then((res) => { const note = res.data; @@ -1546,6 +1574,8 @@ export default class Notes { if ($notesContainer.length) { $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 this.addNote($form, note); @@ -1627,7 +1657,7 @@ export default class Notes { /* eslint-disable promise/catch-or-return */ // Make request to update comment on server - axios.post(formAction, formData) + axios.post(`${formAction}?html=true`, formData) .then(({ data }) => { // Submission successful! render final note element this.updateNote(data, $editingNote); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index df796050e0d..b85c1a6ad72 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -2,10 +2,11 @@ import { mapActions, mapGetters } from 'vuex'; import _ from 'underscore'; import Autosize from 'autosize'; - import { __ } from '~/locale'; + 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 * as constants from '../constants'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -29,6 +30,12 @@ mixins: [ issuableStateMixin, ], + props: { + noteableType: { + type: String, + required: true, + }, + }, data() { return { note: '', @@ -43,37 +50,51 @@ 'getUserData', 'getNoteableData', 'getNotesData', - 'issueState', + 'openState', ]), + noteableDisplayName() { + return this.noteableType.replace(/_/g, ' '); + }, isLoggedIn() { return this.getUserData.id; }, commentButtonTitle() { return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; }, - isIssueOpen() { - return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; + isOpen() { + return this.openState === constants.OPENED || this.openState === constants.REOPENED; }, canCreateNote() { return this.getNoteableData.current_user.can_create_note; }, issueActionButtonTitle() { - if (this.note.length) { - const actionText = this.isIssueOpen ? 'close' : 'reopen'; + const openOrClose = this.isOpen ? 'close' : 'reopen'; - return this.noteType === constants.COMMENT ? - `Comment & ${actionText} issue` : - `Start discussion & ${actionText} issue`; + if (this.note.length) { + return sprintf( + __('%{actionText} & %{openOrClose} %{noteable}'), + { + actionText: this.commentButtonTitle, + openOrClose, + noteable: this.noteableDisplayName, + }, + ); } - return this.isIssueOpen ? 'Close issue' : 'Reopen issue'; + return sprintf( + __('%{openOrClose} %{noteable}'), + { + openOrClose: capitalizeFirstCharacter(openOrClose), + noteable: this.noteableDisplayName, + }, + ); }, actionButtonClassNames() { return { - 'btn-reopen': !this.isIssueOpen, - 'btn-close': this.isIssueOpen, - 'js-note-target-close': this.isIssueOpen, - 'js-note-target-reopen': !this.isIssueOpen, + 'btn-reopen': !this.isOpen, + 'btn-close': this.isOpen, + 'js-note-target-close': this.isOpen, + 'js-note-target-reopen': !this.isOpen, }; }, markdownDocsPath() { @@ -138,7 +159,7 @@ flashContainer: this.$el, data: { note: { - noteable_type: constants.NOTEABLE_TYPE, + noteable_type: this.noteableType, noteable_id: this.getNoteableData.id, note: this.note, }, @@ -193,19 +214,29 @@ Please check your network connection and try again.`; this.isSubmitting = false; }, toggleIssueState() { - if (this.isIssueOpen) { + if (this.isOpen) { this.closeIssue() .then(() => this.enableButton()) .catch(() => { this.enableButton(); - Flash(__('Something went wrong while closing the issue. Please try again later')); + Flash( + sprintf( + __('Something went wrong while closing the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, + ), + ); }); } else { this.reopenIssue() .then(() => this.enableButton()) .catch(() => { this.enableButton(); - Flash(__('Something went wrong while reopening the issue. Please try again later')); + Flash( + sprintf( + __('Something went wrong while reopening the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, + ), + ); }); } }, @@ -221,7 +252,6 @@ Please check your network connection and try again.`; this.$refs.markdownField.previewMarkdown = false; } - // reset autostave this.autosave.reset(); }, setNoteType(type) { @@ -240,10 +270,11 @@ Please check your network connection and try again.`; }, initAutoSave() { if (this.isLoggedIn) { + const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); + this.autosave = new Autosave( $(this.$refs.textarea), - ['Note', 'Issue', this.getNoteableData.id], - 'issue', + ['Note', noteableType, this.getNoteableData.id], ); } }, @@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" :disabled="isSubmitButtonDisabled" class="btn btn-create comment-btn js-comment-button js-comment-submit-button" type="submit"> - {{ commentButtonTitle }} + {{ __(commentButtonTitle) }} </button> <button :disabled="isSubmitButtonDisabled" @@ -359,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" <div class="description"> <strong>Comment</strong> <p> - Add a general comment to this issue. + Add a general comment to this {{ noteableDisplayName }}. </p> </div> </button> diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue new file mode 100644 index 00000000000..fe5baa3537f --- /dev/null +++ b/app/assets/javascripts/notes/components/diff_file_header.vue @@ -0,0 +1,92 @@ +<script> + import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + import Icon from '~/vue_shared/components/icon.vue'; + + export default { + components: { + ClipboardButton, + Icon, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + }, + computed: { + titleTag() { + return this.diffFile.discussionPath ? 'a' : 'span'; + }, + }, + }; +</script> + +<template> + <div class="file-header-content"> + <div + v-if="diffFile.submodule" + > + <span> + <icon name="archive" /> + <strong + v-html="diffFile.submoduleLink" + class="file-title-name" + ></strong> + <clipboard-button + title="Copy file path to clipboard" + :text="diffFile.submoduleLink" + /> + </span> + </div> + <template v-else> + <component + ref="titleWrapper" + :is="titleTag" + :href="diffFile.discussionPath" + > + <span v-html="diffFile.blobIcon"></span> + <span v-if="diffFile.renamedFile"> + <strong + class="file-title-name has-tooltip" + :title="diffFile.oldPath" + data-container="body" + > + {{ diffFile.oldPath }} + </strong> + → + <strong + class="file-title-name has-tooltip" + :title="diffFile.newPath" + data-container="body" + > + {{ diffFile.newPath }} + </strong> + </span> + + <strong + v-else + class="file-title-name has-tooltip" + :title="diffFile.oldPath" + data-container="body" + > + {{ diffFile.filePath }} + <span v-if="diffFile.deletedFile"> + deleted + </span> + </strong> + </component> + + <clipboard-button + title="Copy file path to clipboard" + :text="diffFile.filePath" + /> + + <small + v-if="diffFile.modeChanged" + ref="fileMode" + > + {{ diffFile.aMode }} → {{ diffFile.bMode }} + </small> + </template> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue new file mode 100644 index 00000000000..75a32709ad5 --- /dev/null +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -0,0 +1,96 @@ +<script> + import syntaxHighlight from '~/syntax_highlight'; + import imageDiffHelper from '~/image_diff/helpers/index'; + import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + import DiffFileHeader from './diff_file_header.vue'; + + export default { + components: { + DiffFileHeader, + }, + props: { + discussion: { + type: Object, + required: true, + }, + }, + computed: { + isImageDiff() { + return !this.diffFile.text; + }, + diffFileClass() { + const { text } = this.diffFile; + return text ? 'text-file' : 'js-image-file'; + }, + diffRows() { + return $(this.discussion.truncatedDiffLines); + }, + diffFile() { + return convertObjectPropsToCamelCase(this.discussion.diffFile); + }, + imageDiffHtml() { + return this.discussion.imageDiffHtml; + }, + }, + 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); + }); + } + }, + methods: { + rowTag(html) { + return html.outerHTML ? 'tr' : 'template'; + }, + }, + }; +</script> + +<template> + <div + ref="fileHolder" + class="diff-file file-holder" + :class="diffFileClass" + > + <div class="js-file-title file-title file-title-flex-parent"> + <diff-file-header + :diff-file="diffFile" + /> + </div> + <div + v-if="diffFile.text" + class="diff-content code js-syntax-highlight" + > + <table> + <component + :is="rowTag(html)" + :class="html.className" + v-for="(html, index) in diffRows" + v-html="html.outerHTML" + :key="index" + /> + <tr class="notes_holder"> + <td + class="notes_line" + colspan="2" + ></td> + <td class="notes_content"> + <slot></slot> + </td> + </tr> + </table> + </div> + <div + v-else + > + <div v-html="imageDiffHtml"></div> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue new file mode 100644 index 00000000000..0158f58b569 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -0,0 +1,119 @@ +<script> + import { 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'; + import nextDiscussionSvg from 'icons/_next_discussion.svg'; + import { pluralize } from '../../lib/utils/text_utility'; + import { scrollToElement } from '../../lib/utils/common_utils'; + import tooltip from '../../vue_shared/directives/tooltip'; + + export default { + directives: { + tooltip, + }, + computed: { + ...mapGetters([ + 'getUserData', + 'getNoteableData', + 'discussionCount', + 'unresolvedDiscussions', + 'resolvedDiscussionCount', + ]), + isLoggedIn() { + return this.getUserData.id; + }, + hasNextButton() { + return this.isLoggedIn && !this.allResolved; + }, + countText() { + return pluralize('discussion', this.discussionCount); + }, + allResolved() { + return this.resolvedDiscussionCount === this.discussionCount; + }, + resolveAllDiscussionsIssuePath() { + return this.getNoteableData.create_issue_to_resolve_discussions_path; + }, + firstUnresolvedDiscussionId() { + const item = this.unresolvedDiscussions[0] || {}; + + return item.id; + }, + }, + created() { + this.resolveSvg = resolveSvg; + this.resolvedSvg = resolvedSvg; + this.mrIssueSvg = mrIssueSvg; + this.nextDiscussionSvg = nextDiscussionSvg; + }, + methods: { + jumpToFirstDiscussion() { + const el = document.querySelector(`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`); + const activeTab = window.mrTabs.currentAction; + + if (activeTab === 'commits' || activeTab === 'pipelines') { + window.mrTabs.activateTab('show'); + } + + if (el) { + scrollToElement(el); + } + }, + }, + }; +</script> + +<template> + <div class="line-resolve-all-container prepend-top-10"> + <div> + <div + v-if="discussionCount > 0" + :class="{ 'has-next-btn': hasNextButton }" + class="line-resolve-all"> + <span + :class="{ 'is-active': allResolved }" + class="line-resolve-btn is-disabled" + type="button"> + <span + v-if="allResolved" + v-html="resolvedSvg" + ></span> + <span + v-else + v-html="resolveSvg" + ></span> + </span> + <span class=".line-resolve-text"> + {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved + </span> + </div> + <div + v-if="resolveAllDiscussionsIssuePath && !allResolved" + class="btn-group" + role="group"> + <a + :href="resolveAllDiscussionsIssuePath" + v-tooltip + title="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> + </a> + </div> + <div + v-if="isLoggedIn && !allResolved" + class="btn-group" + role="group"> + <button + @click="jumpToFirstDiscussion" + v-tooltip + title="Jump to first unresolved discussion" + data-container="body" + class="btn btn-default discussion-next-btn"> + <span v-html="nextDiscussionSvg"></span> + </button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 46ffb60aa60..c26aa6fa15d 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -4,6 +4,8 @@ import emojiSmile from 'icons/_emoji_smile.svg'; import emojiSmiley from 'icons/_emoji_smiley.svg'; import editSvg from 'icons/_icon_pencil.svg'; + import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg'; + import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg'; import ellipsisSvg from 'icons/_ellipsis_v.svg'; import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; @@ -42,6 +44,26 @@ type: Boolean, required: true, }, + resolvable: { + type: Boolean, + required: false, + default: false, + }, + isResolved: { + type: Boolean, + required: false, + default: false, + }, + isResolving: { + type: Boolean, + required: false, + default: false, + }, + resolvedBy: { + type: Object, + required: false, + default: () => ({}), + }, canReportAsAbuse: { type: Boolean, required: true, @@ -63,6 +85,15 @@ currentUserId() { return this.getUserDataByProp('id'); }, + resolveButtonTitle() { + let title = 'Mark as resolved'; + + if (this.resolvedBy) { + title = `Resolved by ${this.resolvedBy.name}`; + } + + return title; + }, }, created() { this.emojiSmiling = emojiSmiling; @@ -70,6 +101,8 @@ this.emojiSmiley = emojiSmiley; this.editSvg = editSvg; this.ellipsisSvg = ellipsisSvg; + this.resolveDiscussionSvg = resolveDiscussionSvg; + this.resolvedDiscussionSvg = resolvedDiscussionSvg; }, methods: { onEdit() { @@ -78,6 +111,9 @@ onDelete() { this.$emit('handleDelete'); }, + onResolve() { + this.$emit('handleResolve'); + }, }, }; </script> @@ -90,6 +126,31 @@ {{ accessLevel }} </span> <div + v-if="resolvable" + class="note-actions-item"> + <button + v-tooltip + @click="onResolve" + :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" + :title="resolveButtonTitle" + :aria-label="resolveButtonTitle" + type="button" + class="line-resolve-btn note-action-button"> + <template v-if="!isResolving"> + <div + v-if="isResolved" + v-html="resolvedDiscussionSvg"></div> + <div + v-else + v-html="resolveDiscussionSvg"></div> + </template> + <loading-icon + v-else + :inline="true" + /> + </button> + </div> + <div v-if="canAddAwardEmoji" class="note-actions-item"> <a diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 2d7cd30115d..ca12df9db64 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -41,7 +41,7 @@ this.initTaskList(); if (this.isEditing) { - this.initAutoSave(); + this.initAutoSave(this.note.noteable_type); } }, updated() { @@ -50,7 +50,7 @@ if (this.isEditing) { if (!this.autosave) { - this.initAutoSave(); + this.initAutoSave(this.note.noteable_type); } else { this.setAutoSave(); } diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index d382a9bb642..1a13fdbeb7c 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,9 +1,10 @@ <script> - import { mapGetters } from 'vuex'; + import { mapGetters, mapActions } from 'vuex'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; import issuableStateMixin from '../mixins/issuable_state'; + import resolvable from '../mixins/resolvable'; export default { name: 'IssueNoteForm', @@ -13,6 +14,7 @@ }, mixins: [ issuableStateMixin, + resolvable, ], props: { noteBody: { @@ -30,7 +32,7 @@ required: false, default: 'Save comment', }, - discussion: { + note: { type: Object, required: false, default: () => ({}), @@ -42,9 +44,11 @@ }, data() { return { - note: this.noteBody, + updatedNoteBody: this.noteBody, conflictWhileEditing: false, isSubmitting: false, + isResolving: false, + resolveAsThread: true, }; }, computed: { @@ -71,13 +75,13 @@ return this.getUserDataByProp('id'); }, isDisabled() { - return !this.note.length || this.isSubmitting; + return !this.updatedNoteBody.length || this.isSubmitting; }, }, watch: { noteBody() { - if (this.note === this.noteBody) { - this.note = this.noteBody; + if (this.updatedNoteBody === this.noteBody) { + this.updatedNoteBody = this.noteBody; } else { this.conflictWhileEditing = true; } @@ -87,16 +91,24 @@ this.$refs.textarea.focus(); }, methods: { - handleUpdate() { + ...mapActions([ + 'toggleResolveNote', + ]), + handleUpdate(shouldResolve) { + const beforeSubmitDiscussionState = this.discussionResolved; this.isSubmitting = true; - this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => { + this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => { this.isSubmitting = false; + + if (shouldResolve) { + this.resolveHandler(beforeSubmitDiscussionState); + } }); }, editMyLastNote() { - if (this.note === '') { - const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); + if (this.updatedNoteBody === '') { + const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody); if (lastNoteInDiscussion) { eventHub.$emit('enterEditMode', { @@ -107,7 +119,7 @@ }, cancelHandler(shouldConfirm = false) { // Sends information about confirm message and if the textarea has changed - this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note); + this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.updatedNoteBody); }, }, }; @@ -150,7 +162,7 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" :data-supports-quick-actions="!isEditing" aria-label="Description" - v-model="note" + v-model="updatedNoteBody" ref="textarea" slot="textarea" placeholder="Write a comment or drag your files here..." @@ -169,6 +181,13 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" {{ saveButtonTitle }} </button> <button + v-if="note.resolvable" + @click.prevent="handleUpdate(true)" + class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" + > + {{ resolveButtonTitle }} + </button> + <button @click="cancelHandler()" class="btn btn-cancel note-edit-cancel" type="button"> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 5b255d4a710..4743d95b951 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -34,15 +34,15 @@ required: false, default: false, }, - }, - data() { - return { - isExpanded: true, - }; + expanded: { + type: Boolean, + required: false, + default: true, + }, }, computed: { toggleChevronClass() { - return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down'; + return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; }, noteTimestampLink() { return `#note_${this.noteId}`; @@ -53,7 +53,6 @@ 'setTargetNoteHash', ]), handleToggle() { - this.isExpanded = !this.isExpanded; this.$emit('toggleHandler'); }, updateTargetNoteHash() { diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 98a06c5fc71..76bb53eaf2f 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,5 +1,7 @@ <script> import { mapActions, mapGetters } from 'vuex'; + import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; + import nextDiscussionsSvg from 'icons/_next_discussion.svg'; import Flash from '../../flash'; import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -8,13 +10,19 @@ import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteEditedText from './note_edited_text.vue'; import noteForm from './note_form.vue'; + import diffWithNote from './diff_with_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; 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 { components: { noteableNote, + diffWithNote, userAvatarLink, noteHeader, noteSignedOutWidget, @@ -23,8 +31,13 @@ placeholderNote, placeholderSystemNote, }, + directives: { + tooltip, + }, mixins: [ autosave, + noteable, + resolvable, ], props: { note: { @@ -35,14 +48,25 @@ data() { return { isReplying: false, + isResolving: false, + resolveAsThread: true, }; }, computed: { ...mapGetters([ 'getNoteableData', + 'discussionCount', + 'resolvedDiscussionCount', + 'unresolvedDiscussions', ]), discussion() { - return this.note.notes[0]; + 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, + }; }, author() { return this.discussion.author; @@ -71,26 +95,40 @@ return null; }, + hasUnresolvedDiscussion() { + return this.unresolvedDiscussions.length > 0; + }, + wrapperComponent() { + return (this.discussion.diffDiscussion && this.discussion.diffFile) ? diffWithNote : 'div'; + }, + wrapperClass() { + return this.isDiffDiscussion ? '' : 'panel panel-default'; + }, }, mounted() { if (this.isReplying) { - this.initAutoSave(); + this.initAutoSave(this.discussion.noteable_type); } }, updated() { if (this.isReplying) { if (!this.autosave) { - this.initAutoSave(); + this.initAutoSave(this.discussion.noteable_type); } else { this.setAutoSave(); } } }, + created() { + this.resolveDiscussionsSvg = resolveDiscussionsSvg; + this.nextDiscussionsSvg = nextDiscussionsSvg; + }, methods: { ...mapActions([ 'saveNote', 'toggleDiscussion', 'removePlaceholderNotes', + 'toggleResolveNote', ]), componentName(note) { if (note.isPlaceholderNote) { @@ -103,7 +141,7 @@ return noteableNote; }, componentData(note) { - return note.isPlaceholderNote ? note.notes[0] : note; + return note.isPlaceholderNote ? this.note.notes[0] : note; }, toggleDiscussionHandler() { this.toggleDiscussion({ discussionId: this.note.id }); @@ -128,7 +166,7 @@ flashContainer: this.$el, data: { in_reply_to_discussion_id: this.note.reply_id, - target_type: 'issue', + target_type: this.noteableType, target_id: this.discussion.noteable_id, note: { note: noteText }, }, @@ -152,12 +190,27 @@ Please check your network connection and try again.`; }); }); }, + jumpToDiscussion() { + const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); + const index = unresolvedIds.indexOf(this.note.id); + + if (index >= 0 && index !== unresolvedIds.length) { + const nextId = unresolvedIds[index + 1]; + const el = document.querySelector(`[data-discussion-id="${nextId}"]`); + + if (el) { + scrollToElement(el); + } + } + }, }, }; </script> <template> - <li class="note note-discussion timeline-entry"> + <li + :data-discussion-id="note.id" + class="note note-discussion timeline-entry"> <div class="timeline-entry-inner"> <div class="timeline-icon"> <user-avatar-link @@ -175,6 +228,7 @@ Please check your network connection and try again.`; :created-at="discussion.created_at" :note-id="discussion.id" :include-toggle="true" + :expanded="note.expanded" @toggleHandler="toggleDiscussionHandler" action-text="started a discussion" class="discussion" @@ -187,43 +241,103 @@ Please check your network connection and try again.`; class-name="discussion-headline-light js-discussion-headline" /> </div> - </div> - <div - v-if="note.expanded" - class="discussion-body"> - <div class="panel panel-default"> - <div class="discussion-notes"> - <ul class="notes"> - <component - v-for="note in note.notes" - :is="componentName(note)" - :note="componentData(note)" - :key="note.id" - /> - </ul> - <div - :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder"> - <button - v-if="canReply && !isReplying" - @click="showReplyForm" - type="button" - class="js-vue-discussion-reply btn btn-text-field" - title="Add a reply"> - Reply... - </button> - <note-form - v-if="isReplying" - save-button-title="Comment" - :discussion="note" - :is-editing="false" - @handleFormUpdate="saveReply" - @cancelFormEdition="cancelReplyForm" - ref="noteForm" - /> - <note-signed-out-widget v-if="!canReply" /> + <div + v-if="note.expanded" + class="discussion-body"> + <component + :is="wrapperComponent" + :discussion="discussion" + :class="wrapperClass" + > + <div class="discussion-notes"> + <ul class="notes"> + <component + v-for="note in note.notes" + :is="componentName(note)" + :note="componentData(note)" + :key="note.id" + /> + </ul> + <div + :class="{ 'is-replying': isReplying }" + class="discussion-reply-holder"> + <template v-if="!isReplying && canReply"> + <div + class="btn-group-justified discussion-with-resolve-btn" + role="group"> + <div + class="btn-group" + role="group"> + <button + @click="showReplyForm" + type="button" + class="js-vue-discussion-reply btn btn-text-field" + title="Add a reply">Reply...</button> + </div> + <div + v-if="note.resolvable" + class="btn-group" + role="group"> + <button + @click="resolveHandler()" + type="button" + class="btn btn-default" + > + <i + v-if="isResolving" + aria-hidden="true" + class="fa fa-spinner fa-spin" + ></i> + {{ resolveButtonTitle }} + </button> + </div> + <div + class="btn-group discussion-actions" + role="group"> + <div + v-if="note.resolvable && !discussionResolved" + class="btn-group" + role="group"> + <a + :href="note.resolve_with_issue_path" + v-tooltip + 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" + class="btn-group" + role="group"> + <button + @click="jumpToDiscussion" + v-tooltip + class="btn btn-default discussion-next-btn" + title="Jump to next unresolved discussion" + data-container="body" + > + <span v-html="nextDiscussionsSvg"></span> + </button> + </div> + </div> + </div> + </template> + <note-form + v-if="isReplying" + save-button-title="Comment" + :note="note" + :is-editing="false" + @handleFormUpdate="saveReply" + @cancelFormEdition="cancelReplyForm" + ref="noteForm" /> + <note-signed-out-widget v-if="!canReply" /> + </div> </div> - </div> + </component> </div> </div> </div> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 045077de383..4d17bd5acc2 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -7,6 +7,8 @@ import noteActions from './note_actions.vue'; import noteBody from './note_body.vue'; import eventHub from '../event_hub'; + import noteable from '../mixins/noteable'; + import resolvable from '../mixins/resolvable'; export default { components: { @@ -15,6 +17,10 @@ noteActions, noteBody, }, + mixins: [ + noteable, + resolvable, + ], props: { note: { type: Object, @@ -26,6 +32,7 @@ isEditing: false, isDeleting: false, isRequesting: false, + isResolving: false, }; }, computed: { @@ -65,6 +72,7 @@ ...mapActions([ 'deleteNote', 'updateNote', + 'toggleResolveNote', 'scrollToNoteIfNeeded', ]), editHandler() { @@ -89,7 +97,7 @@ const data = { endpoint: this.note.path, note: { - target_type: 'issue', + target_type: this.noteableType, target_id: this.note.noteable_id, note: { note: noteText }, }, @@ -134,7 +142,7 @@ // 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 = noteText; + this.$refs.noteBody.$refs.noteForm.note.note = noteText; }, }, }; @@ -171,8 +179,13 @@ :can-delete="note.current_user.can_edit" :can-report-as-abuse="canReportAsAbuse" :report-abuse-path="note.report_abuse_path" + :resolvable="note.resolvable" + :is-resolved="note.resolved" + :is-resolving="isResolving" + :resolved-by="note.resolved_by" @handleEdit="editHandler" @handleDelete="deleteHandler" + @handleResolve="resolveHandler" /> </div> <note-body diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 92db4830704..74afed5560b 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -11,6 +11,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; export default { name: 'NotesApp', @@ -48,7 +49,24 @@ ...mapGetters([ 'notes', 'getNotesDataByProp', + 'discussionCount', ]), + noteableType() { + // FIXME -- @fatihacet Get this from JSON data. + const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; + + return this.noteableData.merge_params ? MERGE_REQUEST_NOTEABLE_TYPE : ISSUE_NOTEABLE_TYPE; + }, + allNotes() { + if (this.isLoading) { + const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0; + + return new Array(totalNotes).fill({ + isSkeletonNote: true, + }); + } + return this.notes; + }, }, created() { this.setNotesData(this.notesData); @@ -67,6 +85,10 @@ this.actionToggleAward({ awardName, noteId }); }); } + document.addEventListener('refreshVueNotes', this.fetchNotes); + }, + beforeDestroy() { + document.removeEventListener('refreshVueNotes', this.fetchNotes); }, methods: { ...mapActions({ @@ -81,6 +103,9 @@ setTargetNoteHash: 'setTargetNoteHash', }), getComponentName(note) { + if (note.isSkeletonNote) { + return skeletonLoadingContainer; + } if (note.isPlaceholderNote) { if (note.placeholderType === constants.SYSTEM_NOTE) { return placeholderSystemNote; @@ -109,9 +134,14 @@ }); }, initPolling() { + if (this.isPollingInitialized) { + return; + } + this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); this.poll(); + this.isPollingInitialized = true; }, checkLocationHash() { const hash = getLocationHash(); @@ -128,25 +158,20 @@ <template> <div id="notes"> - <div - v-if="isLoading" - class="js-loading loading"> - <loading-icon /> - </div> - <ul - v-if="!isLoading" id="notes-list" class="notes main-notes-list timeline"> <component - v-for="note in notes" + v-for="note in allNotes" :is="getComponentName(note)" :note="getComponentData(note)" :key="note.id" /> </ul> - <comment-form /> + <comment-form + :noteable-type="noteableType" + /> </div> </template> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index a6961063c01..f4f407ffd8a 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -1,4 +1,5 @@ export const DISCUSSION_NOTE = 'DiscussionNote'; +export const DIFF_NOTE = 'DiffNote'; export const DISCUSSION = 'discussion'; export const NOTE = 'note'; export const SYSTEM_NOTE = 'systemNote'; @@ -8,4 +9,7 @@ export const REOPENED = 'reopened'; export const CLOSED = 'closed'; export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; -export const NOTEABLE_TYPE = 'Issue'; +export const ISSUE_NOTEABLE_TYPE = 'issue'; +export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; +export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; +export const RESOLVE_NOTE_METHOD_NAME = 'post'; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 48e7cfddb63..545bf2c99a7 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -20,17 +20,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ return { noteableData: JSON.parse(notesDataset.noteableData), currentUserData, - notesData: { - lastFetchedAt: notesDataset.lastFetchedAt, - discussionsPath: notesDataset.discussionsPath, - newSessionPath: notesDataset.newSessionPath, - registerPath: notesDataset.registerPath, - notesPath: notesDataset.notesPath, - markdownDocsPath: notesDataset.markdownDocsPath, - quickActionsDocsPath: notesDataset.quickActionsDocsPath, - closeIssuePath: notesDataset.closeIssuePath, - reopenIssuePath: notesDataset.reopenIssuePath, - }, + notesData: JSON.parse(notesDataset.notesData), }; }, render(createElement) { diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index a008171beda..a3d897f2f12 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -1,9 +1,10 @@ import Autosave from '../../autosave'; +import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; export default { methods: { - initAutoSave() { - this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue'); + initAutoSave(noteableType) { + this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]); }, resetAutoSave() { this.autosave.reset(); diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js new file mode 100644 index 00000000000..0da4ff49f08 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/noteable.js @@ -0,0 +1,22 @@ +import * as constants from '../constants'; + +export default { + props: { + note: { + type: Object, + required: true, + }, + }, + computed: { + noteableType() { + switch (this.note.noteable_type) { + case 'MergeRequest': + return constants.MERGE_REQUEST_NOTEABLE_TYPE; + case 'Issue': + return constants.ISSUE_NOTEABLE_TYPE; + default: + return ''; + } + }, + }, +}; diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js new file mode 100644 index 00000000000..ab1ae115e52 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -0,0 +1,50 @@ +import Flash from '~/flash'; +import { __ } from '~/locale'; + +export default { + props: { + note: { + type: Object, + required: true, + }, + }, + computed: { + discussionResolved() { + const { notes, resolved } = this.note; + + if (notes) { // Decide resolved state using store. Only valid for discussions. + return notes.every(note => note.resolved && !note.system); + } + + return resolved; + }, + resolveButtonTitle() { + if (this.updatedNoteBody) { + if (this.discussionResolved) { + return __('Comment and unresolve discussion'); + } + + return __('Comment and 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; + + this.toggleResolveNote({ endpoint, isResolved, discussion }) + .then(() => { + this.isResolving = false; + }) + .catch(() => { + this.isResolving = false; + 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 b8e7ffc8c46..4766351dfc5 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueResource from 'vue-resource'; +import * as constants from '../constants'; Vue.use(VueResource); @@ -19,6 +20,12 @@ export default { createNewNote(endpoint, data) { return Vue.http.post(endpoint, data, { emulateJSON: true }); }, + toggleResolveNote(endpoint, isResolved) { + const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants; + const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME; + + return Vue.http[method](endpoint); + }, poll(data = {}) { const { endpoint, lastFetchedAt } = data; const options = { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 4c846d69b86..42fc2a131b8 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES); +export const toggleResolveNote = ({ commit }, { endpoint, isResolved, discussion }) => service + .toggleResolveNote(endpoint, isResolved) + .then(res => res.json()) + .then((res) => { + const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE; + + commit(mutationType, res); + }); + export const closeIssue = ({ commit, dispatch, state }) => service - .toggleIssueState(state.notesData.closeIssuePath) + .toggleIssueState(state.notesData.closePath) .then(res => res.json()) .then((data) => { commit(types.CLOSE_ISSUE); @@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service }); export const reopenIssue = ({ commit, dispatch, state }) => service - .toggleIssueState(state.notesData.reopenIssuePath) + .toggleIssueState(state.notesData.reopenPath) .then(res => res.json()) .then((data) => { commit(types.REOPEN_ISSUE); @@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service export const emitStateChangedEvent = ({ commit, getters }, data) => { const event = new CustomEvent('issuable_vue_app:change', { detail: { data, - isClosed: getters.issueState === constants.CLOSED, + isClosed: getters.openState === constants.CLOSED, } }); document.dispatchEvent(event); @@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => { resp.notes.forEach((note) => { if (notesById[note.id]) { commit(types.UPDATE_NOTE, note); - } else if (note.type === constants.DISCUSSION_NOTE) { + } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); if (discussion) { diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 82024104d73..e6180101c58 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop]; export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; -export const issueState = state => state.noteableData.state; +export const openState = state => state.noteableData.state; export const getUserData = state => state.userData || {}; export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; @@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten( 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); + + return discussions.length; +}; + +export const unresolvedDiscussions = (state, getters) => { + const resolvedMap = getters.resolvedDiscussionsById; + + return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]); +}; + +export const resolvedDiscussionsById = (state) => { + const map = {}; + + state.notes.forEach((n) => { + if (n.notes) { + const resolved = n.notes.every(note => note.resolved && !note.system); + + if (resolved) { + map[n.id] = n; + } + } + }); + + return map; +}; + +export const resolvedDiscussionCount = (state, getters) => { + const resolvedMap = getters.resolvedDiscussionsById; + + return Object.keys(resolvedMap).length; +}; diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 6d7c3bbae0f..da1b5a9e51a 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -12,6 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; export const TOGGLE_AWARD = 'TOGGLE_AWARD'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const UPDATE_NOTE = 'UPDATE_NOTE'; +export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; // 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 b3f66578c9a..963b40be3fd 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -1,22 +1,32 @@ import * as utils from './utils'; import * as types from './mutation_types'; import * as constants from '../constants'; +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); if (!exists) { const noteData = { expanded: true, id: discussion_id, - individual_note: !(type === constants.DISCUSSION_NOTE), + individual_note: !isDiscussion, notes: [note], reply_id: discussion_id, }; + if (isDiscussion && isInMRPage()) { + noteData.resolvable = note.resolvable; + noteData.resolved = false; + noteData.resolve_path = note.resolve_path; + noteData.resolve_with_issue_path = note.resolve_with_issue_path; + } + state.notes.push(noteData); + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); } }, @@ -25,6 +35,7 @@ export default { if (noteObj) { noteObj.notes.push(note); + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); } }, @@ -41,6 +52,8 @@ export default { state.notes.splice(state.notes.indexOf(noteObj), 1); } } + + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.REMOVE_PLACEHOLDER_NOTES](state) { @@ -77,15 +90,19 @@ export default { const notes = []; notesData.forEach((note) => { + const nn = Object.assign({}, note); + // To support legacy notes, should be very rare case. if (note.individual_note && note.notes.length > 1) { note.notes.forEach((n) => { - const nn = Object.assign({}, note); nn.notes = [n]; // override notes array to only have one item to mimick individual_note notes.push(nn); }); } else { - notes.push(note); + const oldNote = utils.findNoteObjectById(state.notes, note.id); + nn.expanded = oldNote ? oldNote.expanded : note.expanded; + + notes.push(nn); } }); @@ -134,6 +151,8 @@ export default { user: { id, name, username }, }); } + + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.TOGGLE_DISCUSSION](state, { discussionId }) { @@ -151,6 +170,24 @@ 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) => { + 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')); }, [types.CLOSE_ISSUE](state) { diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index 6074115e855..275263a2aaa 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -28,4 +28,3 @@ export const getQuickActionText = (note) => { export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); - diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js index b92aef53510..28641104c58 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js @@ -1,3 +1,7 @@ import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import initMergeConflicts from '~/merge_conflicts/merge_conflicts_bundle'; -document.addEventListener('DOMContentLoaded', initSidebarBundle); +document.addEventListener('DOMContentLoaded', () => { + initSidebarBundle(); + initMergeConflicts(); +}); diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/show/index.js deleted file mode 100644 index b92aef53510..00000000000 --- a/app/assets/javascripts/pages/projects/merge_requests/conflicts/show/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initSidebarBundle from '~/sidebar/sidebar_bundle'; - -document.addEventListener('DOMContentLoaded', initSidebarBundle); 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 b7b6b0b5364..b48ba1ef95e 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -22,7 +22,6 @@ document.addEventListener('DOMContentLoaded', () => { initPipelines(); const mrShowNode = document.querySelector('.merge-request'); - window.mergeRequest = new MergeRequest({ action: mrShowNode.dataset.mrAction, }); diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js deleted file mode 100644 index 643877b9d47..00000000000 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.js +++ /dev/null @@ -1,224 +0,0 @@ -export default { - name: 'Assignees', - data() { - return { - defaultRenderCount: 5, - defaultMaxCounter: 99, - showLess: true, - }; - }, - props: { - rootPath: { - type: String, - required: true, - }, - users: { - type: Array, - required: true, - }, - editable: { - type: Boolean, - required: true, - }, - }, - computed: { - firstUser() { - return this.users[0]; - }, - hasMoreThanTwoAssignees() { - return this.users.length > 2; - }, - hasMoreThanOneAssignee() { - return this.users.length > 1; - }, - hasAssignees() { - return this.users.length > 0; - }, - hasNoUsers() { - return !this.users.length; - }, - hasOneUser() { - return this.users.length === 1; - }, - renderShowMoreSection() { - return this.users.length > this.defaultRenderCount; - }, - numberOfHiddenAssignees() { - return this.users.length - this.defaultRenderCount; - }, - isHiddenAssignees() { - return this.numberOfHiddenAssignees > 0; - }, - hiddenAssigneesLabel() { - return `+ ${this.numberOfHiddenAssignees} more`; - }, - collapsedTooltipTitle() { - const maxRender = Math.min(this.defaultRenderCount, this.users.length); - const renderUsers = this.users.slice(0, maxRender); - const names = renderUsers.map(u => u.name); - - if (this.users.length > maxRender) { - names.push(`+ ${this.users.length - maxRender} more`); - } - - return names.join(', '); - }, - sidebarAvatarCounter() { - let counter = `+${this.users.length - 1}`; - - if (this.users.length > this.defaultMaxCounter) { - counter = `${this.defaultMaxCounter}+`; - } - - return counter; - }, - }, - methods: { - assignSelf() { - this.$emit('assign-self'); - }, - toggleShowLess() { - this.showLess = !this.showLess; - }, - renderAssignee(index) { - return !this.showLess || (index < this.defaultRenderCount && this.showLess); - }, - avatarUrl(user) { - return user.avatar || user.avatar_url || gon.default_avatar_url; - }, - assigneeUrl(user) { - return `${this.rootPath}${user.username}`; - }, - assigneeAlt(user) { - return `${user.name}'s avatar`; - }, - assigneeUsername(user) { - return `@${user.username}`; - }, - shouldRenderCollapsedAssignee(index) { - const firstTwo = this.users.length <= 2 && index <= 2; - - return index === 0 || firstTwo; - }, - }, - template: ` - <div> - <div - class="sidebar-collapsed-icon sidebar-collapsed-user" - :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" - data-container="body" - data-placement="left" - :title="collapsedTooltipTitle" - > - <i - v-if="hasNoUsers" - aria-label="No Assignee" - class="fa fa-user" - /> - <button - type="button" - class="btn-link" - v-for="(user, index) in users" - v-if="shouldRenderCollapsedAssignee(index)" - > - <img - width="24" - class="avatar avatar-inline s24" - :alt="assigneeAlt(user)" - :src="avatarUrl(user)" - /> - <span class="author"> - {{ user.name }} - </span> - </button> - <button - v-if="hasMoreThanTwoAssignees" - class="btn-link" - type="button" - > - <span - class="avatar-counter sidebar-avatar-counter" - > - {{ sidebarAvatarCounter }} - </span> - </button> - </div> - <div class="value hide-collapsed"> - <template v-if="hasNoUsers"> - <span class="assign-yourself no-value"> - No assignee - <template v-if="editable"> - - - <button - type="button" - class="btn-link" - @click="assignSelf" - > - assign yourself - </button> - </template> - </span> - </template> - <template v-else-if="hasOneUser"> - <a - class="author_link bold" - :href="assigneeUrl(firstUser)" - > - <img - width="32" - class="avatar avatar-inline s32" - :alt="assigneeAlt(firstUser)" - :src="avatarUrl(firstUser)" - /> - <span class="author"> - {{ firstUser.name }} - </span> - <span class="username"> - {{ assigneeUsername(firstUser) }} - </span> - </a> - </template> - <template v-else> - <div class="user-list"> - <div - class="user-item" - v-for="(user, index) in users" - v-if="renderAssignee(index)" - > - <a - class="user-link has-tooltip" - data-placement="bottom" - :href="assigneeUrl(user)" - :data-title="user.name" - > - <img - width="32" - class="avatar avatar-inline s32" - :alt="assigneeAlt(user)" - :src="avatarUrl(user)" - /> - </a> - </div> - </div> - <div - v-if="renderShowMoreSection" - class="user-list-more" - > - <button - type="button" - class="btn-link" - @click="toggleShowLess" - > - <template v-if="showLess"> - {{ hiddenAssigneesLabel }} - </template> - <template v-else> - - show less - </template> - </button> - </div> - </template> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue new file mode 100644 index 00000000000..73d53dda6fd --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -0,0 +1,231 @@ +<script> +export default { + name: 'Assignees', + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + data() { + return { + defaultRenderCount: 5, + defaultMaxCounter: 99, + showLess: true, + }; + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasMoreThanTwoAssignees() { + return this.users.length > 2; + }, + hasMoreThanOneAssignee() { + return this.users.length > 1; + }, + hasAssignees() { + return this.users.length > 0; + }, + hasNoUsers() { + return !this.users.length; + }, + hasOneUser() { + return this.users.length === 1; + }, + renderShowMoreSection() { + return this.users.length > this.defaultRenderCount; + }, + numberOfHiddenAssignees() { + return this.users.length - this.defaultRenderCount; + }, + isHiddenAssignees() { + return this.numberOfHiddenAssignees > 0; + }, + hiddenAssigneesLabel() { + return `+ ${this.numberOfHiddenAssignees} more`; + }, + collapsedTooltipTitle() { + const maxRender = Math.min(this.defaultRenderCount, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (this.users.length > maxRender) { + names.push(`+ ${this.users.length - maxRender} more`); + } + + return names.join(', '); + }, + sidebarAvatarCounter() { + let counter = `+${this.users.length - 1}`; + + if (this.users.length > this.defaultMaxCounter) { + counter = `${this.defaultMaxCounter}+`; + } + + return counter; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + toggleShowLess() { + this.showLess = !this.showLess; + }, + renderAssignee(index) { + return !this.showLess || (index < this.defaultRenderCount && this.showLess); + }, + avatarUrl(user) { + return user.avatar || user.avatar_url || gon.default_avatar_url; + }, + assigneeUrl(user) { + return `${this.rootPath}${user.username}`; + }, + assigneeAlt(user) { + return `${user.name}'s avatar`; + }, + assigneeUsername(user) { + return `@${user.username}`; + }, + shouldRenderCollapsedAssignee(index) { + const firstTwo = this.users.length <= 2 && index <= 2; + + return index === 0 || firstTwo; + }, + }, +}; +</script> + +<template> + <div> + <div + class="sidebar-collapsed-icon sidebar-collapsed-user" + :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" + data-container="body" + data-placement="left" + :title="collapsedTooltipTitle" + > + <i + v-if="hasNoUsers" + aria-label="No Assignee" + class="fa fa-user" + > + </i> + <button + type="button" + class="btn-link" + v-for="(user, index) in users" + v-if="shouldRenderCollapsedAssignee(index)" + :key="user.id" + > + <img + width="24" + class="avatar avatar-inline s24" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + <span class="author"> + {{ user.name }} + </span> + </button> + <button + v-if="hasMoreThanTwoAssignees" + class="btn-link" + type="button" + > + <span + class="avatar-counter sidebar-avatar-counter" + > + {{ sidebarAvatarCounter }} + </span> + </button> + </div> + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value"> + No assignee + <template v-if="editable"> + - + <button + type="button" + class="btn-link" + @click="assignSelf" + > + assign yourself + </button> + </template> + </span> + </template> + <template v-else-if="hasOneUser"> + <a + class="author_link bold" + :href="assigneeUrl(firstUser)" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(firstUser)" + :src="avatarUrl(firstUser)" + /> + <span class="author"> + {{ firstUser.name }} + </span> + <span class="username"> + {{ assigneeUsername(firstUser) }} + </span> + </a> + </template> + <template v-else> + <div class="user-list"> + <div + class="user-item" + v-for="(user, index) in users" + v-if="renderAssignee(index)" + :key="user.id" + > + <a + class="user-link has-tooltip" + data-placement="bottom" + :href="assigneeUrl(user)" + :data-title="user.name" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + </a> + </div> + </div> + <div + v-if="renderShowMoreSection" + class="user-list-more" + > + <button + type="button" + class="btn-link" + @click="toggleShowLess" + > + <template v-if="showLess"> + {{ hiddenAssigneesLabel }} + </template> + <template v-else> + - show less + </template> + </button> + </div> + </template> + </div> + </div> +</template> + diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js index 9e47039d920..8269fe1281d 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js @@ -1,6 +1,6 @@ import Flash from '../../../flash'; import AssigneeTitle from './assignee_title'; -import Assignees from './assignees'; +import Assignees from './assignees.vue'; import Store from '../../stores/sidebar_store'; import eventHub from '../../event_hub'; @@ -28,8 +28,8 @@ export default { }, }, components: { - 'assignee-title': AssigneeTitle, - assignees: Assignees, + AssigneeTitle, + Assignees, }, methods: { assignSelf() { diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 31d9b9d9c48..e855ec3c098 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -1,8 +1,8 @@ <script> - import tooltip from '../directives/tooltip'; /** * Falls back to the code used in `copy_to_clipboard.js` */ + import tooltip from '../directives/tooltip'; export default { name: 'ClipboardButton', diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue index b48828ae81f..3d39b3ab173 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue @@ -11,14 +11,12 @@ default: false, required: false, }, - isConfidential: { type: Boolean, default: false, required: false, }, }, - computed: { warningIcon() { if (this.isConfidential) return 'eye-slash'; @@ -26,7 +24,6 @@ return ''; }, - isLockedAndConfidential() { return this.isConfidential && this.isLocked; }, diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue new file mode 100644 index 00000000000..80e3db52cb0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -0,0 +1,24 @@ +<template> + <li class="timeline-entry note"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + </div> + <div class="timeline-content"> + <div class="note-header"></div> + <div class="note-body"> + <skeleton-loading-container /> + </div> + </div> + </div> + </li> +</template> + +<script> + import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; + + export default { + components: { + skeletonLoadingContainer, + }, + }; +</script> diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 0cf67734237..4c9732c26d9 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -103,6 +103,7 @@ .issuable-show-labels { a { margin-bottom: 5px; + margin-right: 5px; display: inline-block; .color-label { @@ -116,6 +117,12 @@ } &.has-labels { + // this font size is a fix to + // prevent unintended spacing between labels + // which shows up when rendering markup has white-space + // characters present. + // see: https://css-tricks.com/fighting-the-space-between-inline-block-elements/#article-header-id-3 + font-size: 0; margin-bottom: -5px; } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 26e6e8688b6..3c565837383 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -723,7 +723,7 @@ ul.notes { .line-resolve-all { vertical-align: middle; display: inline-block; - padding: 5px 10px 6px; + padding: 6px 10px; background-color: $gray-light; border: 1px solid $border-color; border-radius: $border-radius-default; |