diff options
Diffstat (limited to 'app/assets/javascripts/notes')
24 files changed, 727 insertions, 909 deletions
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 10e80883c00..ce56beb1e6b 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -4,6 +4,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import _ from 'underscore'; import Autosize from 'autosize'; import { __, sprintf } from '~/locale'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import Autosave from '../../autosave'; import { @@ -30,6 +31,7 @@ export default { markdownField, userAvatarLink, loadingButton, + TimelineEntryItem, }, mixins: [issuableStateMixin], props: { @@ -245,15 +247,19 @@ Please check your network connection and try again.`; } else { this.reopenIssue() .then(() => this.enableButton()) - .catch(() => { + .catch(({ data }) => { this.enableButton(); this.toggleStateButtonLoading(false); - Flash( - sprintf( - __('Something went wrong while reopening the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, - ), + let errorMessage = sprintf( + __('Something went wrong while reopening the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, ); + + if (data) { + errorMessage = Object.values(data).join('\n'); + } + + Flash(errorMessage); }); } }, @@ -308,152 +314,136 @@ Please check your network connection and try again.`; <template> <div> <note-signed-out-widget v-if="!isLoggedIn" /> - <discussion-locked-widget - v-else-if="!canCreateNote" - :issuable-type="issuableTypeTitle" - /> - <div - v-else-if="canCreateNote" - class="notes notes-form timeline"> - <div class="timeline-entry note-form"> - <div class="timeline-entry-inner"> - <div class="flash-container error-alert timeline-content"></div> - <div class="timeline-icon d-none d-sm-none d-md-block"> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - /> - </div> - <div class="timeline-content timeline-content-form"> - <form - ref="commentForm" - class="new-note common-note-form gfm-form js-main-target-form" - > - - <div class="error-alert"></div> + <discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="issuableTypeTitle" /> + <ul v-else-if="canCreateNote" class="notes notes-form timeline"> + <timeline-entry-item class="note-form"> + <div class="flash-container error-alert timeline-content"></div> + <div class="timeline-icon d-none d-sm-none d-md-block"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content timeline-content-form"> + <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> + <div class="error-alert"></div> - <issue-warning - v-if="hasWarning(getNoteableData)" - :is-locked="isLocked(getNoteableData)" - :is-confidential="isConfidential(getNoteableData)" - /> + <issue-warning + v-if="hasWarning(getNoteableData)" + :is-locked="isLocked(getNoteableData)" + :is-confidential="isConfidential(getNoteableData)" + /> - <markdown-field - ref="markdownField" - :markdown-preview-path="markdownPreviewPath" - :markdown-docs-path="markdownDocsPath" - :quick-actions-docs-path="quickActionsDocsPath" - :markdown-version="markdownVersion" - :add-spacing-classes="false"> - <textarea - id="note-body" - ref="textarea" - slot="textarea" - v-model="note" - :disabled="isSubmitting" - name="note[note]" - class="note-textarea js-vue-comment-form js-note-text + <markdown-field + ref="markdownField" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :markdown-version="markdownVersion" + :add-spacing-classes="false" + > + <textarea + id="note-body" + ref="textarea" + slot="textarea" + v-model="note" + :disabled="isSubmitting" + name="note[note]" + class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" - data-supports-quick-actions="true" - aria-label="Description" - placeholder="Write a comment or drag your files here…" - @keydown.up="editCurrentUserLastNote()" - @keydown.meta.enter="handleSave()" - @keydown.ctrl.enter="handleSave()"> - </textarea> - </markdown-field> - <div class="note-form-actions"> - <div - class="float-left btn-group -append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"> - <button - :disabled="isSubmitButtonDisabled" - class="btn btn-create comment-btn js-comment-button js-comment-submit-button + data-supports-quick-actions="true" + aria-label="Description" + placeholder="Write a comment or drag your files here…" + @keydown.up="editCurrentUserLastNote();" + @keydown.meta.enter="handleSave();" + @keydown.ctrl.enter="handleSave();" + > + </textarea> + </markdown-field> + <div class="note-form-actions"> + <div + class="float-left btn-group +append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" + > + <button + :disabled="isSubmitButtonDisabled" + class="btn btn-create comment-btn js-comment-button js-comment-submit-button qa-comment-button" - type="submit" - @click.prevent="handleSave()"> - {{ __(commentButtonTitle) }} - </button> - <button - :disabled="isSubmitButtonDisabled" - name="button" - type="button" - class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" - data-display="static" - data-toggle="dropdown" - aria-label="Open comment type dropdown"> - <i - aria-hidden="true" - class="fa fa-caret-down toggle-icon"> - </i> - </button> - - <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> - <li :class="{ 'droplab-item-selected': noteType === 'comment' }"> - <button - type="button" - class="btn btn-transparent" - @click.prevent="setNoteType('comment')"> - <i - aria-hidden="true" - class="fa fa-check icon"> - </i> - <div class="description"> - <strong>Comment</strong> - <p> - Add a general comment to this {{ noteableDisplayName }}. - </p> - </div> - </button> - </li> - <li class="divider droplab-item-ignore"></li> - <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> - <button - type="button" - class="btn btn-transparent qa-discussion-option" - @click.prevent="setNoteType('discussion')"> - <i - aria-hidden="true" - class="fa fa-check icon"> - </i> - <div class="description"> - <strong>Start discussion</strong> - <p> - {{ startDiscussionDescription }} - </p> - </div> - </button> - </li> - </ul> - </div> - - <loading-button - v-if="canUpdateIssue" - :loading="isToggleStateButtonLoading" - :container-class="[ - actionButtonClassNames, - 'btn btn-comment btn-comment-and-close js-action-button' - ]" - :disabled="isToggleStateButtonLoading || isSubmitting" - :label="issueActionButtonTitle" - @click="handleSave(true)" - /> - + type="submit" + @click.prevent="handleSave();" + > + {{ __(commentButtonTitle) }} + </button> <button - v-if="note.length" + :disabled="isSubmitButtonDisabled" + name="button" type="button" - class="btn btn-cancel js-note-discard" - @click="discard"> - Discard draft + class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" + data-display="static" + data-toggle="dropdown" + aria-label="Open comment type dropdown" + > + <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i> </button> + + <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> + <li :class="{ 'droplab-item-selected': noteType === 'comment' }"> + <button + type="button" + class="btn btn-transparent" + @click.prevent="setNoteType('comment');" + > + <i aria-hidden="true" class="fa fa-check icon"> </i> + <div class="description"> + <strong>Comment</strong> + <p>Add a general comment to this {{ noteableDisplayName }}.</p> + </div> + </button> + </li> + <li class="divider droplab-item-ignore"></li> + <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> + <button + type="button" + class="btn btn-transparent qa-discussion-option" + @click.prevent="setNoteType('discussion');" + > + <i aria-hidden="true" class="fa fa-check icon"> </i> + <div class="description"> + <strong>Start discussion</strong> + <p>{{ startDiscussionDescription }}</p> + </div> + </button> + </li> + </ul> </div> - </form> - </div> + + <loading-button + v-if="canUpdateIssue" + :loading="isToggleStateButtonLoading" + :container-class="[ + actionButtonClassNames, + 'btn btn-comment btn-comment-and-close js-action-button', + ]" + :disabled="isToggleStateButtonLoading || isSubmitting" + :label="issueActionButtonTitle" + @click="handleSave(true);" + /> + + <button + v-if="note.length" + type="button" + class="btn btn-cancel js-note-discard" + @click="discard" + > + Discard draft + </button> + </div> + </form> </div> - </div> - </div> + </timeline-entry-item> + </ul> </div> </template> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 080161dfbba..af821df0fd2 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -3,8 +3,10 @@ import { mapState, mapActions } from 'vuex'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; -import { GlSkeletonLoading } from '@gitlab-org/gitlab-ui'; -import { trimFirstCharOfLineContent, getDiffMode } from '~/diffs/store/utils'; +import { GlSkeletonLoading } from '@gitlab/ui'; +import { getDiffMode } from '~/diffs/store/utils'; + +const FIRST_CHAR_REGEX = /^(\+|-| )/; export default { components: { @@ -26,46 +28,16 @@ export default { }, computed: { ...mapState({ - noteableData: state => state.notes.noteableData, projectPath: state => state.diffs.projectPath, }), diffMode() { - return getDiffMode(this.diffFile); + return getDiffMode(this.discussion.diff_file); }, hasTruncatedDiffLines() { return ( this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0 ); }, - isDiscussionsExpanded() { - return true; // TODO: @fatihacet - Fix this. - }, - isCollapsed() { - return this.diffFile.collapsed || false; - }, - isImageDiff() { - return !this.diffFile.text; - }, - diffFileClass() { - const { text } = this.diffFile; - return text ? 'text-file' : 'js-image-file'; - }, - diffFile() { - return this.discussion.diff_file; - }, - imageDiffHtml() { - return this.discussion.image_diff_html; - }, - userColorScheme() { - return window.gon.user_color_scheme; - }, - normalizedDiffLines() { - if (this.discussion.truncated_diff_lines) { - return this.discussion.truncated_diff_lines.map(line => trimFirstCharOfLineContent(line)); - } - - return []; - }, }, mounted() { if (!this.hasTruncatedDiffLines) { @@ -74,9 +46,6 @@ export default { }, methods: { ...mapActions(['fetchDiscussionDiffLines']), - rowTag(html) { - return html.outerHTML ? 'tr' : 'template'; - }, fetchDiff() { this.error = false; this.fetchDiscussionDiffLines(this.discussion) @@ -85,54 +54,45 @@ export default { this.error = true; }); }, + trimChar(line) { + return line.replace(FIRST_CHAR_REGEX, ''); + }, }, + userColorSchemeClass: window.gon.user_color_scheme, }; </script> <template> - <div - ref="fileHolder" - :class="diffFileClass" - class="diff-file file-holder" - > + <div :class="{ 'text-file': discussion.diff_file.text }" class="diff-file file-holder"> <diff-file-header :discussion-path="discussion.discussion_path" - :diff-file="diffFile" + :diff-file="discussion.diff_file" :can-current-user-fork="false" - :discussions-expanded="isDiscussionsExpanded" - :expanded="!isCollapsed" + :expanded="!discussion.diff_file.collapsed" /> <div - v-if="diffFile.text" - :class="userColorScheme" + v-if="discussion.diff_file.text" + :class="$options.userColorSchemeClass" class="diff-content code" > <table> - <tr - v-for="line in normalizedDiffLines" - :key="line.line_code" - class="line_holder" - > - <td class="diff-line-num old_line">{{ line.old_line }}</td> - <td class="diff-line-num new_line">{{ line.new_line }}</td> - <td - :class="line.type" - class="line_content" - v-html="line.rich_text" + <template v-if="hasTruncatedDiffLines"> + <tr + v-for="line in discussion.truncated_diff_lines" + v-once + :key="line.line_code" + class="line_holder" > - </td> - </tr> - <tr - v-if="!hasTruncatedDiffLines" - class="line_holder line-holder-placeholder" - > + <td class="diff-line-num old_line">{{ line.old_line }}</td> + <td class="diff-line-num new_line">{{ line.new_line }}</td> + <td :class="line.type" class="line_content" v-html="trimChar(line.rich_text)"></td> + </tr> + </template> + <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 + <td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block"> + {{ error }} Unable to load the diff <button class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button" @click="fetchDiff" @@ -140,41 +100,31 @@ export default { Try again </button> </td> - <td - v-else - class="line_content js-success-lazy-load" - > + <td v-else class="line_content js-success-lazy-load"> <span></span> <gl-skeleton-loading /> <span></span> </td> </tr> <tr class="notes_holder"> - <td - class="notes_content" - colspan="3" - > - <slot></slot> - </td> + <td class="notes_content" colspan="3"><slot></slot></td> </tr> </table> </div> - <div - v-else - > + <div v-else> <diff-viewer :diff-mode="diffMode" - :new-path="diffFile.new_path" - :new-sha="diffFile.diff_refs.head_sha" - :old-path="diffFile.old_path" - :old-sha="diffFile.diff_refs.base_sha" - :file-hash="diffFile.file_hash" + :new-path="discussion.diff_file.new_path" + :new-sha="discussion.diff_file.diff_refs.head_sha" + :old-path="discussion.diff_file.old_path" + :old-sha="discussion.diff_file.diff_refs.base_sha" + :file-hash="discussion.diff_file.file_hash" :project-path="projectPath" > <image-diff-overlay slot="image-overlay" :discussions="discussion" - :file-hash="diffFile.file_hash" + :file-hash="discussion.diff_file.file_hash" :show-comment-icon="true" :should-toggle-discussion="false" badge-class="image-comment-badge" diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index a4d76a70696..c7cfc0f0f3b 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,13 +1,12 @@ <script> import { mapActions, mapGetters } from 'vuex'; +import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; -import { pluralize } from '../../lib/utils/text_utility'; import discussionNavigation from '../mixins/discussion_navigation'; -import tooltip from '../../vue_shared/directives/tooltip'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { Icon, @@ -17,9 +16,9 @@ export default { ...mapGetters([ 'getUserData', 'getNoteableData', - 'discussionCount', + 'resolvableDiscussionsCount', 'firstUnresolvedDiscussionId', - 'resolvedDiscussionCount', + 'unresolvedDiscussionsCount', ]), isLoggedIn() { return this.getUserData.id; @@ -27,15 +26,15 @@ export default { hasNextButton() { return this.isLoggedIn && !this.allResolved; }, - countText() { - return pluralize('discussion', this.discussionCount); - }, allResolved() { - return this.resolvedDiscussionCount === this.discussionCount; + return this.unresolvedDiscussionsCount === 0; }, resolveAllDiscussionsIssuePath() { return this.getNoteableData.create_issue_to_resolve_discussions_path; }, + resolvedDiscussionsCount() { + return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount; + }, }, methods: { ...mapActions(['expandDiscussion']), @@ -50,13 +49,9 @@ export default { </script> <template> - <div - v-if="discussionCount > 0" - class="line-resolve-all-container prepend-top-8"> + <div v-if="resolvableDiscussionsCount > 0" class="line-resolve-all-container prepend-top-8"> <div> - <div - :class="{ 'has-next-btn': hasNextButton }" - class="line-resolve-all"> + <div :class="{ 'has-next-btn': hasNextButton }" class="line-resolve-all"> <span :class="{ 'is-active': allResolved }" class="line-resolve-btn is-disabled" @@ -65,32 +60,27 @@ export default { <icon name="check-circle" /> </span> <span class="line-resolve-text"> - {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved + {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }} + {{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }} </span> </div> - <div - v-if="resolveAllDiscussionsIssuePath && !allResolved" - class="btn-group" - role="group"> + <div v-if="resolveAllDiscussionsIssuePath && !allResolved" class="btn-group" role="group"> <a - v-tooltip + v-gl-tooltip :href="resolveAllDiscussionsIssuePath" :title="s__('Resolve all discussions in new issue')" - data-container="body" - class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"> + class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" + > <icon name="issue-new" /> </a> </div> - <div - v-if="isLoggedIn && !allResolved" - class="btn-group" - role="group"> + <div v-if="isLoggedIn && !allResolved" class="btn-group" role="group"> <button - v-tooltip + v-gl-tooltip title="Jump to first unresolved discussion" - data-container="body" class="btn btn-default discussion-next-btn" - @click="jumpToFirstUnresolvedDiscussion"> + @click="jumpToFirstUnresolvedDiscussion" + > <icon name="comment-next" /> </button> </div> diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index affa2d1b574..86c114a761a 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -64,30 +64,24 @@ export default { data-toggle="dropdown" aria-expanded="false" > - {{ currentFilter.title }} - <icon name="chevron-down" /> + {{ currentFilter.title }} <icon name="chevron-down" /> </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-right" - aria-labelledby="discussion-filter-dropdown"> + aria-labelledby="discussion-filter-dropdown" + > <div class="dropdown-content"> <ul> - <li - v-for="filter in filters" - :key="filter.value" - > + <li v-for="filter in filters" :key="filter.value"> <button :class="{ 'is-active': filter.value === currentValue }" class="qa-filter-options" type="button" - @click="selectFilter(filter.value)" + @click="selectFilter(filter.value);" > {{ filter.title }} </button> - <div - v-if="filter.value === defaultValue" - class="dropdown-divider" - ></div> + <div v-if="filter.value === defaultValue" class="dropdown-divider"></div> </li> </ul> </div> diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue index de0a5f8489b..c469a6b7bcd 100644 --- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue +++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue @@ -13,11 +13,7 @@ export default { <template> <div class="disabled-comment text-center"> <span class="issuable-note-warning inline"> - <icon - :size="16" - name="lock" - class="icon" - /> + <icon :size="16" name="lock" class="icon" /> <span> This {{ issuableDisplayName }} is locked. Only <b>project members</b> can comment. </span> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index f7a61fbfcd4..d99694b06e9 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,8 +1,7 @@ <script> import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; -import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; +import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; export default { name: 'NoteActions', @@ -11,7 +10,7 @@ export default { GlLoadingIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { authorId: { @@ -119,116 +118,76 @@ export default { <template> <div class="note-actions"> - <span - v-if="accessLevel" - class="note-role user-access-role"> - {{ accessLevel }} - </span> - <div - v-if="canResolve" - class="note-actions-item"> + <span v-if="accessLevel" class="note-role user-access-role">{{ accessLevel }}</span> + <div v-if="canResolve" class="note-actions-item"> <button - v-tooltip + v-gl-tooltip :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" :title="resolveButtonTitle" :aria-label="resolveButtonTitle" type="button" class="line-resolve-btn note-action-button" - @click="onResolve"> + @click="onResolve" + > <template v-if="!isResolving"> <icon name="check-circle" /> </template> - <gl-loading-icon - v-else - inline - /> + <gl-loading-icon v-else inline /> </button> </div> - <div - v-if="canAwardEmoji" - class="note-actions-item"> + <div v-if="canAwardEmoji" class="note-actions-item"> <a - v-tooltip + v-gl-tooltip.bottom :class="{ 'js-user-authored': isAuthoredByCurrentUser }" class="note-action-button note-emoji-button js-add-award js-note-emoji" data-position="right" - data-placement="bottom" - data-container="body" href="#" title="Add reaction" > - <gl-loading-icon inline/> + <gl-loading-icon inline /> <icon css-classes="link-highlight award-control-icon-neutral" name="emoji_slightly_smiling_face" /> - <icon - css-classes="link-highlight award-control-icon-positive" - name="emoji_smiley" - /> - <icon - css-classes="link-highlight award-control-icon-super-positive" - name="emoji_smiley" - /> + <icon css-classes="link-highlight award-control-icon-positive" name="emoji_smiley" /> + <icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" /> </a> </div> - <div - v-if="canEdit" - class="note-actions-item"> + <div v-if="canEdit" class="note-actions-item"> <button - v-tooltip + v-gl-tooltip.bottom type="button" title="Edit comment" class="note-action-button js-note-edit btn btn-transparent" - data-container="body" - data-placement="bottom" - @click="onEdit"> - <icon - name="pencil" - css-classes="link-highlight" - /> + @click="onEdit" + > + <icon name="pencil" css-classes="link-highlight" /> </button> </div> - <div - v-if="showDeleteAction" - class="note-actions-item" - > + <div v-if="showDeleteAction" class="note-actions-item"> <button - v-tooltip + v-gl-tooltip.bottom type="button" title="Delete comment" class="note-action-button js-note-delete btn btn-transparent" - data-container="body" - data-placement="bottom" @click="onDelete" > - <icon - name="remove" - class="link-highlight" - /> + <icon name="remove" class="link-highlight" /> </button> </div> - <div - v-else-if="shouldShowActionsDropdown" - class="dropdown more-actions note-actions-item"> + <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item"> <button - v-tooltip + v-gl-tooltip.bottom type="button" title="More actions" class="note-action-button more-actions-toggle btn btn-transparent" data-toggle="dropdown" - data-container="body" - data-placement="bottom"> - <icon - css-classes="icon" - name="ellipsis_v" - /> + > + <icon css-classes="icon" name="ellipsis_v" /> </button> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <li v-if="canReportAsAbuse"> - <a :href="reportAbusePath"> - {{ __('Report abuse to GitLab') }} - </a> + <a :href="reportAbusePath">{{ __('Report abuse to GitLab') }}</a> </li> <li v-if="noteUrl"> <button @@ -243,10 +202,9 @@ export default { <button class="btn btn-transparent js-note-delete js-note-delete" type="button" - @click.prevent="onDelete"> - <span class="text-danger"> - {{ __('Delete comment') }} - </span> + @click.prevent="onDelete" + > + <span class="text-danger">{{ __('Delete comment') }}</span> </button> </li> </ul> diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue index 34ecbd00c63..b6d8c831e2e 100644 --- a/app/assets/javascripts/notes/components/note_attachment.vue +++ b/app/assets/javascripts/notes/components/note_attachment.vue @@ -12,27 +12,12 @@ export default { <template> <div class="note-attachment"> - <a - v-if="attachment.image" - :href="attachment.url" - target="_blank" - rel="noopener noreferrer"> - <img - :src="attachment.url" - class="note-image-attach" - /> + <a v-if="attachment.image" :href="attachment.url" target="_blank" rel="noopener noreferrer"> + <img :src="attachment.url" class="note-image-attach" /> </a> <div class="attachment"> - <a - v-if="attachment.url" - :href="attachment.url" - target="_blank" - rel="noopener noreferrer"> - <i - class="fa fa-paperclip" - aria-hidden="true"> - </i> - {{ attachment.filename }} + <a v-if="attachment.url" :href="attachment.url" target="_blank" rel="noopener noreferrer"> + <i class="fa fa-paperclip" aria-hidden="true"> </i> {{ attachment.filename }} </a> </div> </div> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 401bcfabbe4..3d60eb02db8 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,16 +1,16 @@ <script> import { mapActions, mapGetters } from 'vuex'; +import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import { glEmojiTag } from '../../emoji'; -import tooltip from '../../vue_shared/directives/tooltip'; export default { components: { Icon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { awards: { @@ -167,31 +167,27 @@ export default { <button v-for="(awardList, awardName, index) in groupedAwards" :key="index" - v-tooltip + v-gl-tooltip.bottom="{ boundary: 'viewport' }" :class="getAwardClassBindings(awardList)" :title="awardTitle(awardList)" class="btn award-control" - data-boundary="viewport" - data-placement="bottom" type="button" - @click="handleAward(awardName)"> + @click="handleAward(awardName);" + > <span v-html="getAwardHTML(awardName)"></span> - <span class="award-control-text js-counter"> - {{ awardList.length }} - </span> + <span class="award-control-text js-counter">{{ awardList.length }}</span> </button> - <div - v-if="canAwardEmoji" - class="award-menu-holder"> + <div v-if="canAwardEmoji" class="award-menu-holder"> <button - v-tooltip + v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByMe }" class="award-control btn js-add-award" title="Add reaction" aria-label="Add reaction" data-boundary="viewport" data-placement="bottom" - type="button"> + type="button" + > <span class="award-control-icon award-control-icon-neutral"> <icon name="emoji_slightly_smiling_face" /> </span> @@ -203,7 +199,8 @@ export default { </span> <i aria-hidden="true" - class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i> + class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" + ></i> </button> </div> </div> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 9375627359c..c0bee600181 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -67,13 +67,8 @@ export default { </script> <template> - <div - ref="note-body" - :class="{ 'js-task-list-container': canEdit }" - class="note-body"> - <div - class="note-text md" - v-html="note.note_html"></div> + <div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body"> + <div class="note-text md" v-html="note.note_html"></div> <note-form v-if="isEditing" ref="noteForm" @@ -88,7 +83,8 @@ export default { v-if="canEdit" v-model="note.note" :data-update-url="note.path" - class="hidden js-task-list-field"></textarea> + class="hidden js-task-list-field" + ></textarea> <note-edited-text v-if="note.last_edited_at" :edited-at="note.last_edited_at" @@ -104,9 +100,6 @@ export default { :toggle-award-path="note.toggle_award_path" :can-award-emoji="note.current_user.can_award_emoji" /> - <note-attachment - v-if="note.attachment" - :attachment="note.attachment" - /> + <note-attachment v-if="note.attachment" :attachment="note.attachment" /> </div> </template> diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index d848335022f..15ce49d7c31 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -40,16 +40,11 @@ export default { {{ actionText }} <template v-if="editedBy"> by - <a - :href="editedBy.path" - class="js-vue-author author-link"> + <a :href="editedBy.path" :data-user-id="editedBy.id" class="js-user-link author-link"> {{ editedBy.name }} </a> </template> {{ actionDetailText }} - <time-ago-tooltip - :time="editedAt" - tooltip-placement="bottom" - /> + <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" /> </div> </template> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 31ee8fed984..95164183ccb 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -48,13 +48,19 @@ export default { required: false, default: '', }, + resolveDiscussion: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { updatedNoteBody: this.noteBody, conflictWhileEditing: false, isSubmitting: false, - isResolving: false, + isResolving: this.resolveDiscussion, + isUnresolving: !this.resolveDiscussion, resolveAsThread: true, }; }, @@ -146,27 +152,14 @@ export default { </script> <template> - <div - ref="editNoteForm" - class="note-edit-form current-note-edit-form js-discussion-note-form"> - <div - v-if="conflictWhileEditing" - class="js-conflict-edit-warning alert alert-danger"> + <div ref="editNoteForm" class="note-edit-form current-note-edit-form js-discussion-note-form"> + <div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger"> This comment has changed since you started editing, please review the - <a - :href="noteHash" - target="_blank" - rel="noopener noreferrer"> - updated comment - </a> - to ensure information is not lost. + <a :href="noteHash" target="_blank" rel="noopener noreferrer">updated comment</a> to ensure + information is not lost. </div> <div class="flash-container timeline-content"></div> - <form - :data-line-code="lineCode" - 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)" :is-locked="isLocked(getNoteableData)" @@ -178,7 +171,8 @@ export default { :markdown-docs-path="markdownDocsPath" :markdown-version="markdownVersion" :quick-actions-docs-path="quickActionsDocsPath" - :add-spacing-classes="false"> + :add-spacing-classes="false" + > <textarea id="note_note" ref="textarea" @@ -186,35 +180,36 @@ export default { v-model="updatedNoteBody" :data-supports-quick-actions="!isEditing" name="note[note]" - class="note-textarea js-gfm-input js-note-text -js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" + class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" aria-label="Description" placeholder="Write a comment or drag your files here…" - @keydown.meta.enter="handleUpdate()" - @keydown.ctrl.enter="handleUpdate()" - @keydown.up="editMyLastNote()" - @keydown.esc="cancelHandler(true)"> - </textarea> + @keydown.meta.enter="handleUpdate();" + @keydown.ctrl.enter="handleUpdate();" + @keydown.up="editMyLastNote();" + @keydown.esc="cancelHandler(true);" + ></textarea> </markdown-field> <div class="note-form-actions clearfix"> <button :disabled="isDisabled" type="button" - class="js-vue-issue-save btn btn-success js-comment-button " - @click="handleUpdate()"> + class="js-vue-issue-save btn btn-success js-comment-button" + @click="handleUpdate();" + > {{ saveButtonTitle }} </button> <button v-if="discussion.resolvable" class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" - @click.prevent="handleUpdate(true)" + @click.prevent="handleUpdate(true);" > {{ resolveButtonTitle }} </button> <button class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" type="button" - @click="cancelHandler()"> + @click="cancelHandler();" + > Cancel </button> </div> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index dd7313d7b10..7b39901024d 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -63,44 +63,33 @@ export default { <template> <div class="note-header-info"> - <div - v-if="includeToggle" - class="discussion-actions"> + <div v-if="includeToggle" class="discussion-actions"> <button class="note-action-button discussion-toggle-button js-vue-toggle-button" type="button" - @click="handleToggle"> - <i - :class="toggleChevronClass" - class="fa" - aria-hidden="true"> - </i> + @click="handleToggle" + > + <i :class="toggleChevronClass" class="fa" aria-hidden="true"> </i> {{ __('Toggle discussion') }} </button> </div> <a v-if="hasAuthor" + v-once :href="author.path" + class="js-user-link" + :data-user-id="author.id" + :data-username="author.username" > <span class="note-header-author-name">{{ author.name }}</span> - <span - v-if="author.status_tooltip_html" - v-html="author.status_tooltip_html"></span> - <span class="note-headline-light"> - @{{ author.username }} - </span> + <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> + <span class="note-headline-light"> @{{ author.username }} </span> </a> - <span v-else> - {{ __('A deleted user') }} - </span> + <span v-else> {{ __('A deleted user') }} </span> <span class="note-headline-light"> <span class="note-headline-meta"> - <span class="system-note-message"> - <slot></slot> - </span> - <template - v-if="createdAt" - > + <span class="system-note-message"> <slot></slot> </span> + <template v-if="createdAt"> <span class="system-note-separator"> <template v-if="actionText"> {{ actionText }} @@ -109,11 +98,9 @@ export default { <a :href="noteTimestampLink" class="note-timestamp system-note-separator" - @click="updateTargetNoteHash"> - <time-ago-tooltip - :time="createdAt" - tooltip-placement="bottom" - /> + @click="updateTargetNoteHash" + > + <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" /> </a> </template> <i diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue index 91f7c269757..e3eb92956b1 100644 --- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue +++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue @@ -16,10 +16,6 @@ export default { <template> <div class="disabled-comment text-center"> - Please - <a :href="registerLink">register</a> - or - <a :href="signInLink">sign in</a> - to reply + Please <a :href="registerLink">register</a> or <a :href="signInLink">sign in</a> to reply </div> </template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 7740967ccd5..5c9a28b8512 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,9 +1,12 @@ <script> +import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; +import { GlTooltipDirective } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; -import { s__ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import systemNote from '~/vue_shared/components/notes/system_note.vue'; import icon from '~/vue_shared/components/icon.vue'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -20,14 +23,12 @@ import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; -import tooltip from '../../vue_shared/directives/tooltip'; export default { name: 'NoteableDiscussion', components: { icon, noteableNote, - diffWithNote, userAvatarLink, noteHeader, noteSignedOutWidget, @@ -37,9 +38,10 @@ export default { placeholderNote, placeholderSystemNote, systemNote, + TimelineEntryItem, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, mixins: [autosave, noteable, resolvable, discussionNavigation], props: { @@ -64,43 +66,25 @@ export default { }, }, data() { + const { diff_discussion: isDiffDiscussion, resolved } = this.discussion; + return { isReplying: false, isResolving: false, resolveAsThread: true, - isRepliesCollapsed: (!this.discussion.diff_discussion && this.discussion.resolved) || false, + isRepliesCollapsed: Boolean(!isDiffDiscussion && resolved), }; }, computed: { ...mapGetters([ 'getNoteableData', - 'discussionCount', - 'resolvedDiscussionCount', - 'allDiscussions', - 'unresolvedDiscussionsIdsByDiff', - 'unresolvedDiscussionsIdsByDate', - 'unresolvedDiscussions', - 'unresolvedDiscussionsIdsOrdered', 'nextUnresolvedDiscussionId', - 'isLastUnresolvedDiscussion', + 'unresolvedDiscussionsCount', + 'hasUnresolvedDiscussions', + 'showJumpToNextDiscussion', ]), - transformedDiscussion() { - return { - ...this.discussion.notes[0], - truncated_diff_lines: this.discussion.truncated_diff_lines || [], - truncated_diff_lines_path: this.discussion.truncated_diff_lines_path, - diff_file: this.discussion.diff_file, - diff_discussion: this.discussion.diff_discussion, - active: this.discussion.active, - discussion_path: this.discussion.discussion_path, - resolved: this.discussion.resolved, - resolved_by: this.discussion.resolved_by, - resolved_by_push: this.discussion.resolved_by_push, - resolved_at: this.discussion.resolved_at, - }; - }, author() { - return this.transformedDiscussion.author; + return this.initialDiscussion.author; }, canReply() { return this.getNoteableData.current_user.can_create_note; @@ -136,29 +120,19 @@ export default { return null; }, resolvedText() { - return this.transformedDiscussion.resolved_by_push ? 'Automatically resolved' : 'Resolved'; + return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); }, - hasMultipleUnresolvedDiscussions() { - return this.unresolvedDiscussions.length > 1; - }, - showJumpToNextDiscussion() { - return ( - this.hasMultipleUnresolvedDiscussions && - !this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder) + shouldShowJumpToNextDiscussion() { + return this.showJumpToNextDiscussion( + this.discussion.id, + this.discussionsByDiffOrder ? 'diff' : 'discussion', ); }, shouldRenderDiffs() { - return ( - this.transformedDiscussion.diff_discussion && - this.transformedDiscussion.diff_file && - this.renderDiffFile - ); + return this.discussion.diff_discussion && this.renderDiffFile; }, shouldGroupReplies() { - return !this.shouldRenderDiffs && !this.transformedDiscussion.diff_discussion; - }, - shouldRenderHeader() { - return this.shouldRenderDiffs; + return !this.shouldRenderDiffs && !this.discussion.diff_discussion; }, wrapperComponent() { return this.shouldRenderDiffs ? diffWithNote : 'div'; @@ -170,9 +144,6 @@ export default { return {}; }, - wrapperClass() { - return this.isDiffDiscussion ? '' : 'card discussion-wrapper'; - }, componentClassName() { if (this.shouldRenderDiffs) { if (!this.lastUpdatedAt && !this.discussion.resolved) { @@ -183,11 +154,45 @@ export default { return ''; }, shouldShowDiscussions() { - const isExpanded = this.discussion.expanded; - const { resolved } = this.transformedDiscussion; - const isResolvedNonDiffDiscussion = !this.transformedDiscussion.diff_discussion && resolved; + const { expanded, resolved } = this.discussion; + const isResolvedNonDiffDiscussion = !this.discussion.diff_discussion && resolved; + + return expanded || this.alwaysExpanded || isResolvedNonDiffDiscussion; + }, + actionText() { + const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; + const linkEnd = '</a>'; + + let { commit_id: commitId } = this.discussion; + if (commitId) { + commitId = `<span class="commit-sha">${truncateSha(commitId)}</span>`; + } - return isExpanded || this.alwaysExpanded || isResolvedNonDiffDiscussion; + let text = s__('MergeRequests|started a discussion'); + + if (this.discussion.for_commit) { + text = s__( + 'MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}', + ); + } else if (this.discussion.diff_discussion) { + if (this.discussion.active) { + text = s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}'); + } else { + text = s__( + 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}', + ); + } + } + + return sprintf( + text, + { + commitId, + linkStart, + linkEnd, + }, + false, + ); }, }, watch: { @@ -195,7 +200,7 @@ export default { if (this.isReplying) { this.$nextTick(() => { // Pass an extra key to separate reply and note edit forms - this.initAutoSave(this.transformedDiscussion, ['Reply']); + this.initAutoSave({ ...this.initialDiscussion, ...this.discussion }, ['Reply']); }); } else { this.disposeAutoSave(); @@ -302,210 +307,156 @@ Please check your network connection and try again.`; </script> <template> - <li - class="note note-discussion timeline-entry" - :class="componentClassName" - > - <div class="timeline-entry-inner"> - <div class="timeline-content"> - <div - :data-discussion-id="transformedDiscussion.discussion_id" - class="discussion js-discussion-container" - > - <div - v-if="shouldRenderHeader" - class="discussion-header note-wrapper" - > - <div class="timeline-icon"> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - /> - </div> - <note-header - :author="author" - :created-at="transformedDiscussion.created_at" - :note-id="transformedDiscussion.id" - :include-toggle="true" - :expanded="discussion.expanded" - @toggleHandler="toggleDiscussionHandler" - > - <template v-if="transformedDiscussion.diff_discussion"> - started a discussion on - <a :href="transformedDiscussion.discussion_path"> - <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.resolved_at" - :edited-by="transformedDiscussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline" - /> - <note-edited-text - v-else-if="lastUpdatedAt" - :edited-at="lastUpdatedAt" - :edited-by="lastUpdatedBy" - action-text="Last updated" - class-name="discussion-headline-light js-discussion-headline" + <timeline-entry-item class="note note-discussion" :class="componentClassName"> + <div class="timeline-content"> + <div :data-discussion-id="discussion.id" class="discussion js-discussion-container"> + <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper"> + <div v-once class="timeline-icon"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" /> </div> - <div - v-if="shouldShowDiscussions" - class="discussion-body"> - <component - :is="wrapperComponent" - v-bind="wrapperComponentProps" - :class="wrapperClass" - > - <div class="discussion-notes"> - <ul class="notes"> - <template v-if="shouldGroupReplies"> - <component - :is="componentName(initialDiscussion)" - :note="componentData(initialDiscussion)" - @handleDeleteNote="deleteNoteHandler" - > - <slot - slot="avatar-badge" - name="avatar-badge" - > - </slot> - </component> - <toggle-replies-widget - v-if="hasReplies" - :collapsed="isRepliesCollapsed" - :replies="replies" - @toggle="toggleReplies" - /> - <template v-if="!isRepliesCollapsed"> - <component - :is="componentName(note)" - v-for="note in replies" - :key="note.id" - :note="componentData(note)" - @handleDeleteNote="deleteNoteHandler" - /> - </template> - </template> - <template v-else> + <note-header + :author="author" + :created-at="initialDiscussion.created_at" + :note-id="initialDiscussion.id" + :include-toggle="true" + :expanded="discussion.expanded" + @toggleHandler="toggleDiscussionHandler" + > + <span v-html="actionText"></span> + </note-header> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline" + /> + <note-edited-text + v-else-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + action-text="Last updated" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> + <div v-if="shouldShowDiscussions" class="discussion-body"> + <component + :is="wrapperComponent" + v-bind="wrapperComponentProps" + class="card discussion-wrapper" + > + <div class="discussion-notes"> + <ul class="notes"> + <template v-if="shouldGroupReplies"> + <component + :is="componentName(initialDiscussion)" + :note="componentData(initialDiscussion)" + @handleDeleteNote="deleteNoteHandler" + > + <slot slot="avatar-badge" name="avatar-badge"></slot> + </component> + <toggle-replies-widget + v-if="hasReplies" + :collapsed="isRepliesCollapsed" + :replies="replies" + @toggle="toggleReplies" + /> + <template v-if="!isRepliesCollapsed"> <component :is="componentName(note)" - v-for="(note, index) in discussion.notes" + v-for="note in replies" :key="note.id" :note="componentData(note)" @handleDeleteNote="deleteNoteHandler" - > - <slot - v-if="index === 0" - slot="avatar-badge" - name="avatar-badge" - > - </slot> - </component> + /> </template> - </ul> - <div - v-if="!isRepliesCollapsed" - :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder" - > - <template v-if="!isReplying && canReply"> - <div class="discussion-with-resolve-btn"> + </template> + <template v-else> + <component + :is="componentName(note)" + v-for="(note, index) in discussion.notes" + :key="note.id" + :note="componentData(note)" + @handleDeleteNote="deleteNoteHandler" + > + <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> + </component> + </template> + </ul> + <div + v-if="!isRepliesCollapsed" + :class="{ 'is-replying': isReplying }" + class="discussion-reply-holder" + > + <template v-if="!isReplying && canReply"> + <div class="discussion-with-resolve-btn"> + <button + type="button" + class="js-vue-discussion-reply btn btn-text-field qa-discussion-reply" + title="Add a reply" + @click="showReplyForm" + > + Reply... + </button> + <div v-if="discussion.resolvable"> <button type="button" - class="js-vue-discussion-reply btn btn-text-field mr-sm-2 qa-discussion-reply" - title="Add a reply" - @click="showReplyForm" + class="btn btn-default ml-sm-2" + @click="resolveHandler();" > - Reply... + <i v-if="isResolving" aria-hidden="true" class="fa fa-spinner fa-spin"></i> + {{ resolveButtonTitle }} </button> - <div v-if="discussion.resolvable"> + </div> + <div + v-if="discussion.resolvable" + class="btn-group discussion-actions ml-sm-2" + role="group" + > + <div v-if="!discussionResolved" class="btn-group" role="group"> + <a + v-gl-tooltip + :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" + > + <icon name="issue-new" /> + </a> + </div> + <div v-if="shouldShowJumpToNextDiscussion" class="btn-group" role="group"> <button - type="button" - class="btn btn-default mr-sm-2" - @click="resolveHandler()" + v-gl-tooltip + class="btn btn-default discussion-next-btn" + title="Jump to next unresolved discussion" + @click="jumpToNextDiscussion" > - <i - v-if="isResolving" - aria-hidden="true" - class="fa fa-spinner fa-spin" - ></i> - {{ resolveButtonTitle }} + <icon name="comment-next" /> </button> </div> - <div - v-if="discussion.resolvable" - class="btn-group discussion-actions ml-sm-2" - role="group" - > - <div - v-if="!discussionResolved" - class="btn-group" - role="group"> - <a - v-tooltip - :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" - data-container="body" - > - <icon name="issue-new" /> - </a> - </div> - <div - v-if="showJumpToNextDiscussion" - class="btn-group" - role="group"> - <button - v-tooltip - class="btn btn-default discussion-next-btn" - title="Jump to next unresolved discussion" - data-container="body" - @click="jumpToNextDiscussion" - > - <icon name="comment-next" /> - </button> - </div> - </div> </div> - </template> - <note-form - v-if="isReplying" - ref="noteForm" - :discussion="discussion" - :is-editing="false" - save-button-title="Comment" - @handleFormUpdate="saveReply" - @cancelForm="cancelReplyForm" - /> - <note-signed-out-widget v-if="!canReply" /> - </div> + </div> + </template> + <note-form + v-if="isReplying" + ref="noteForm" + :discussion="discussion" + :is-editing="false" + save-button-title="Comment" + @handleFormUpdate="saveReply" + @cancelForm="cancelReplyForm" + /> + <note-signed-out-widget v-if="!canReply" /> </div> - </component> - </div> + </div> + </component> </div> </div> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 9ab91e2abe5..a17be51353e 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -2,6 +2,7 @@ import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import { escape } from 'underscore'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteHeader from './note_header.vue'; @@ -18,6 +19,7 @@ export default { noteHeader, noteActions, noteBody, + TimelineEntryItem, }, mixins: [noteable, resolvable], props: { @@ -169,65 +171,60 @@ export default { </script> <template> - <li + <timeline-entry-item :id="noteAnchorId" :class="classNameBindings" :data-award-url="note.toggle_award_path" :data-note-id="note.id" - class="note timeline-entry note-wrapper" + class="note note-wrapper" > - <div class="timeline-entry-inner"> - <div class="timeline-icon"> - <user-avatar-link - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - > - <slot - slot="avatar-badge" - name="avatar-badge" - > - </slot> - </user-avatar-link> - </div> - <div class="timeline-content"> - <div class="note-header"> - <note-header - :author="author" - :created-at="note.created_at" - :note-id="note.id" - action-text="commented" - /> - <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" - :is-resolving="isResolving" - :resolved-by="note.resolved_by" - @handleEdit="editHandler" - @handleDelete="deleteHandler" - @handleResolve="resolveHandler" - /> - </div> - <note-body - ref="noteBody" - :note="note" + <div v-once class="timeline-icon"> + <user-avatar-link + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + > + <slot slot="avatar-badge" name="avatar-badge"> </slot> + </user-avatar-link> + </div> + <div class="timeline-content"> + <div class="note-header"> + <note-header + v-once + :author="author" + :created-at="note.created_at" + :note-id="note.id" + action-text="commented" + /> + <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" - :is-editing="isEditing" - @handleFormUpdate="formUpdateHandler" - @cancelForm="formCancelHandler" + :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" + :is-resolving="isResolving" + :resolved-by="note.resolved_by" + @handleEdit="editHandler" + @handleDelete="deleteHandler" + @handleResolve="resolveHandler" /> </div> + <note-body + ref="noteBody" + :note="note" + :can-edit="note.current_user.can_edit" + :is-editing="isEditing" + @handleFormUpdate="formUpdateHandler" + @cancelForm="formCancelHandler" + /> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 69ddfd751e0..445d3267a3f 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -12,6 +12,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note. import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; +import initUserPopovers from '../../user_popovers'; export default { name: 'NotesApp', @@ -22,6 +23,7 @@ export default { commentForm, placeholderNote, placeholderSystemNote, + skeletonLoadingContainer, }, props: { noteableData: { @@ -59,7 +61,6 @@ export default { 'isNotesFetched', 'discussions', 'getNotesDataByProp', - 'discussionCount', 'isLoading', 'commentsDisabled', ]), @@ -106,42 +107,28 @@ export default { } }, updated() { - this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'))); + this.$nextTick(() => { + highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')); + initUserPopovers(this.$el.querySelectorAll('.js-user-link')); + }); }, methods: { - ...mapActions({ - setLoadingState: 'setLoadingState', - fetchDiscussions: 'fetchDiscussions', - poll: 'poll', - actionToggleAward: 'toggleAward', - scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', - setNotesData: 'setNotesData', - setNoteableData: 'setNoteableData', - setUserData: 'setUserData', - setLastFetchedAt: 'setLastFetchedAt', - setTargetNoteHash: 'setTargetNoteHash', - toggleDiscussion: 'toggleDiscussion', - setNotesFetchedState: 'setNotesFetchedState', - startTaskList: 'startTaskList', - }), - getComponentName(discussion) { - if (discussion.isSkeletonNote) { - return skeletonLoadingContainer; - } - if (discussion.isPlaceholderNote) { - if (discussion.placeholderType === constants.SYSTEM_NOTE) { - return placeholderSystemNote; - } - return placeholderNote; - } else if (discussion.individual_note) { - return discussion.notes[0].system ? systemNote : noteableNote; - } - - return noteableDiscussion; - }, - getComponentData(discussion) { - return discussion.individual_note ? { note: discussion.notes[0] } : { discussion }; - }, + ...mapActions([ + 'setLoadingState', + 'fetchDiscussions', + 'poll', + 'toggleAward', + 'scrollToNoteIfNeeded', + 'setNotesData', + 'setNoteableData', + 'setUserData', + 'setLastFetchedAt', + 'setTargetNoteHash', + 'toggleDiscussion', + 'setNotesFetchedState', + 'expandDiscussion', + 'startTaskList', + ]), fetchNotes() { if (this.isFetching) return null; @@ -181,37 +168,46 @@ export default { const noteId = hash && hash.replace(/^note_/, ''); 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 }); - } - }); - } - }); + const discussion = this.discussions.find(d => d.notes.some(({ id }) => id === noteId)); + + if (discussion) { + this.expandDiscussion({ discussionId: discussion.id }); + } } }, }, + systemNote: constants.SYSTEM_NOTE, }; </script> <template> - <div - v-show="shouldShow" - id="notes" - > - <ul - id="notes-list" - class="notes main-notes-list timeline" - > - <component - :is="getComponentName(discussion)" - v-for="discussion in allDiscussions" - :key="discussion.id" - v-bind="getComponentData(discussion)" - /> + <div v-show="shouldShow" id="notes"> + <ul id="notes-list" class="notes main-notes-list timeline"> + <template v-for="discussion in allDiscussions"> + <skeleton-loading-container v-if="discussion.isSkeletonNote" :key="discussion.id" /> + <template v-else-if="discussion.isPlaceholderNote"> + <placeholder-system-note + v-if="discussion.placeholderType === $options.systemNote" + :key="discussion.id" + :note="discussion.notes[0]" + /> + <placeholder-note v-else :key="discussion.id" :note="discussion.notes[0]" /> + </template> + <template v-else-if="discussion.individual_note"> + <system-note + v-if="discussion.notes[0].system" + :key="discussion.id" + :note="discussion.notes[0]" + /> + <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" /> + </template> + <noteable-discussion + v-else + :key="discussion.id" + :discussion="discussion" + :render-diff-file="true" + /> + </template> </ul> <comment-form diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index 78ecbbb9247..72a8ff28466 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -42,15 +42,9 @@ export default { </script> <template> - <li - :class="className" - class="replies-toggle" - > + <li :class="className" class="replies-toggle js-toggle-replies"> <template v-if="collapsed"> - <icon - name="chevron-right" - @click.native="toggle" - /> + <icon name="chevron-right" @click.native="toggle" /> <div> <user-avatar-link v-for="author in uniqueAuthors" @@ -63,32 +57,17 @@ export default { tooltip-placement="bottom" /> </div> - <button - class="btn btn-link js-replies-text" - type="button" - @click="toggle" - > + <button class="btn btn-link js-replies-text" type="button" @click="toggle"> {{ replies.length }} {{ n__('reply', 'replies', replies.length) }} </button> {{ __('Last reply by') }} - <a - :href="lastReply.author.path" - class="btn btn-link author-link" - > + <a :href="lastReply.author.path" class="btn btn-link author-link"> {{ lastReply.author.name }} </a> - <time-ago-tooltip - :time="lastReply.created_at" - tooltip-placement="bottom" - /> + <time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" /> </template> - <span - v-else - class="collapse-replies-btn js-collapse-replies" - @click="toggle" - > - <icon name="chevron-down" /> - {{ s__('Notes|Collapse replies') }} + <span v-else class="collapse-replies-btn js-collapse-replies" @click="toggle"> + <icon name="chevron-down" /> {{ s__('Notes|Collapse replies') }} </span> </li> </template> diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index f7c4deee1f8..3d89d907777 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,29 +1,56 @@ import { scrollToElement } from '~/lib/utils/common_utils'; +import eventHub from '../../notes/event_hub'; export default { methods: { - jumpToDiscussion(id) { - if (id) { - const activeTab = window.mrTabs.currentAction; - const selector = - activeTab === 'diffs' - ? `ul.notes[data-discussion-id="${id}"]` - : `div.discussion[data-discussion-id="${id}"]`; - const el = document.querySelector(selector); + diffsJump(id) { + const selector = `ul.notes[data-discussion-id="${id}"]`; - if (activeTab === 'commits' || activeTab === 'pipelines') { - window.mrTabs.activateTab('show'); - } + eventHub.$once('scrollToDiscussion', () => { + const el = document.querySelector(selector); if (el) { - this.expandDiscussion({ discussionId: id }); - scrollToElement(el); + return true; } + + return false; + }); + + this.expandDiscussion({ discussionId: id }); + }, + discussionJump(id) { + const selector = `div.discussion[data-discussion-id="${id}"]`; + + const el = document.querySelector(selector); + + this.expandDiscussion({ discussionId: id }); + + if (el) { + scrollToElement(el); + + return true; } return false; }, + jumpToDiscussion(id) { + if (id) { + const activeTab = window.mrTabs.currentAction; + + if (activeTab === 'diffs') { + this.diffsJump(id); + } else if (activeTab === 'commits' || activeTab === 'pipelines') { + window.mrTabs.eventHub.$once('MergeRequestTabChange', () => { + setTimeout(() => this.discussionJump(id), 0); + }); + + window.mrTabs.tabShown('show'); + } else { + this.discussionJump(id); + } + } + }, }, }; diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index cd8394e0619..8edf3d088bb 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -36,7 +36,7 @@ export default { const discussion = this.resolveAsThread; const endpoint = discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`; - this.toggleResolveNote({ endpoint, isResolved, discussion }) + return this.toggleResolveNote({ endpoint, isResolved, discussion }) .then(() => { this.isResolving = false; }) diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 5b2f0540020..4716ab52333 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -11,13 +11,19 @@ import * as constants from '../constants'; import service from '../services/notes_service'; import loadAwardsHandler from '../../awards_handler'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; -import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; +import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; import { __ } from '~/locale'; let eTagPoll; -export const expandDiscussion = ({ commit }, data) => commit(types.EXPAND_DISCUSSION, data); +export const expandDiscussion = ({ commit, dispatch }, data) => { + if (data.discussionId) { + dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true }); + } + + commit(types.EXPAND_DISCUSSION, data); +}; export const collapseDiscussion = ({ commit }, data) => commit(types.COLLAPSE_DISCUSSION, data); @@ -39,12 +45,13 @@ export const setNotesFetchedState = ({ commit }, state) => export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); -export const fetchDiscussions = ({ commit }, { path, filter }) => +export const fetchDiscussions = ({ commit, dispatch }, { path, filter }) => service .fetchDiscussions(path, filter) .then(res => res.json()) .then(discussions => { commit(types.SET_INITIAL_DISCUSSIONS, discussions); + dispatch('updateResolvableDiscussonsCounts'); }); export const updateDiscussion = ({ commit, state }, discussion) => { @@ -53,11 +60,18 @@ export const updateDiscussion = ({ commit, state }, discussion) => { return utils.findNoteObjectById(state.discussions, discussion.id); }; -export const deleteNote = ({ commit, dispatch }, note) => +export const deleteNote = ({ commit, dispatch, state }, note) => service.deleteNote(note.path).then(() => { + const discussion = state.discussions.find(({ id }) => id === note.discussion_id); + commit(types.DELETE_NOTE, note); dispatch('updateMergeRequestWidget'); + dispatch('updateResolvableDiscussonsCounts'); + + if (isInMRPage()) { + dispatch('diffs/removeDiscussionsFromDiff', discussion); + } }); export const updateNote = ({ commit, dispatch }, { endpoint, note }) => @@ -89,6 +103,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) => dispatch('updateMergeRequestWidget'); dispatch('startTaskList'); + dispatch('updateResolvableDiscussonsCounts'); } return res; }); @@ -104,6 +119,8 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, commit(mutationType, res); + dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateMergeRequestWidget'); }); @@ -385,5 +402,8 @@ export const startTaskList = ({ dispatch }) => }), ); +export const updateResolvableDiscussonsCounts = ({ commit }) => + commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS); + // 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 8df95c279eb..0ffc0cb2593 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -53,43 +53,41 @@ export const getCurrentUserLastNote = state => export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes).find(el => isLastNote(el, state)); -export const discussionCount = state => { - const filteredDiscussions = state.discussions.filter(n => !n.individual_note && n.resolvable); +export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCount; +export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount; +export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions; - return filteredDiscussions.length; -}; - -export const unresolvedDiscussions = (state, getters) => { - const resolvedMap = getters.resolvedDiscussionsById; - - return state.discussions.filter(n => !n.individual_note && !resolvedMap[n.id]); -}; +export const showJumpToNextDiscussion = (state, getters) => (discussionId, mode = 'discussion') => { + const orderedDiffs = + mode !== 'discussion' + ? getters.unresolvedDiscussionsIdsByDiff + : getters.unresolvedDiscussionsIdsByDate; -export const allDiscussions = (state, getters) => { - const resolved = getters.resolvedDiscussionsById; - const unresolved = getters.unresolvedDiscussions; + const indexOf = orderedDiffs.indexOf(discussionId); - return Object.values(resolved).concat(unresolved); + return indexOf !== -1 && indexOf < orderedDiffs.length - 1; }; export const isDiscussionResolved = (state, getters) => discussionId => getters.resolvedDiscussionsById[discussionId] !== undefined; -export const allResolvableDiscussions = (state, getters) => - getters.allDiscussions.filter(d => !d.individual_note && d.resolvable); +export const allResolvableDiscussions = state => + state.discussions.filter(d => !d.individual_note && d.resolvable); export const resolvedDiscussionsById = state => { const map = {}; - state.discussions.filter(d => d.resolvable).forEach(n => { - if (n.notes) { - const resolved = n.notes.filter(note => note.resolvable).every(note => note.resolved); + state.discussions + .filter(d => d.resolvable) + .forEach(n => { + if (n.notes) { + const resolved = n.notes.filter(note => note.resolvable).every(note => note.resolved); - if (resolved) { - map[n.id] = n; + if (resolved) { + map[n.id] = n; + } } - } - }); + }); return map; }; @@ -117,7 +115,7 @@ export const unresolvedDiscussionsIdsByDate = (state, getters) => // line numbers. export const unresolvedDiscussionsIdsByDiff = (state, getters) => getters.allResolvableDiscussions - .filter(d => !d.resolved) + .filter(d => !d.resolved && d.active) .sort((a, b) => { if (!a.diff_file || !b.diff_file) { return 0; @@ -145,15 +143,12 @@ 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; -}; +export const discussionTabCounter = state => + state.discussions.reduce( + (acc, discussion) => + acc + discussion.notes.filter(note => !note.system && !note.placeholder).length, + 0, + ); // Returns the list of discussion IDs ordered according to given parameter // @param {Boolean} diffOrder - is ordered by diff? @@ -180,8 +175,10 @@ export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, dif export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => { const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder); const currentIndex = idsOrdered.indexOf(discussionId); + const slicedIds = idsOrdered.slice(currentIndex + 1, currentIndex + 2); - return idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0]; + // Get the first ID if there is none after the currentIndex + return slicedIds.length ? idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0] : idsOrdered[0]; }; // @param {Boolean} diffOrder - is ordered by diff? diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 8aea269ea7d..b5fe8bdb1d3 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -22,6 +22,9 @@ export default () => ({ current_user: {}, }, commentsDisabled: false, + resolvableDiscussionsCount: 0, + unresolvedDiscussionsCount: 0, + hasUnresolvedDiscussions: false, }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index dfbf3b7b34b..9c68ab67a8c 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -21,6 +21,7 @@ export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; +export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS'; // 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 f6054e0be87..39ff0ff73d7 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -22,8 +22,10 @@ export default { if (isDiscussion && isInMRPage()) { noteData.resolvable = note.resolvable; noteData.resolved = false; + noteData.active = true; noteData.resolve_path = note.resolve_path; noteData.resolve_with_issue_path = note.resolve_with_issue_path; + noteData.diff_discussion = false; } state.discussions.push(noteData); @@ -97,33 +99,36 @@ export default { }, [types.SET_INITIAL_DISCUSSIONS](state, discussionsData) { - const discussions = []; + const discussions = discussionsData.reduce((acc, d) => { + const discussion = { ...d }; + const diffData = {}; - discussionsData.forEach(discussion => { if (discussion.diff_file) { - Object.assign(discussion, { - file_hash: discussion.diff_file.file_hash, - truncated_diff_lines: discussion.truncated_diff_lines || [], - }); + diffData.file_hash = discussion.diff_file.file_hash; + diffData.truncated_diff_lines = discussion.truncated_diff_lines || []; } // To support legacy notes, should be very rare case. if (discussion.individual_note && discussion.notes.length > 1) { discussion.notes.forEach(n => { - discussions.push({ + acc.push({ ...discussion, + ...diffData, notes: [n], // override notes array to only have one item to mimick individual_note }); }); } else { const oldNote = utils.findNoteObjectById(state.discussions, discussion.id); - discussions.push({ + acc.push({ ...discussion, + ...diffData, expanded: oldNote ? oldNote.expanded : discussion.expanded, }); } - }); + + return acc; + }, []); Object.assign(state, { discussions }); }, @@ -174,9 +179,11 @@ export default { } }, - [types.TOGGLE_DISCUSSION](state, { discussionId }) { + [types.TOGGLE_DISCUSSION](state, { discussionId, forceExpanded = null }) { const discussion = utils.findNoteObjectById(state.discussions, discussionId); - Object.assign(discussion, { expanded: !discussion.expanded }); + Object.assign(discussion, { + expanded: forceExpanded === null ? !discussion.expanded : forceExpanded, + }); }, [types.UPDATE_NOTE](state, note) { @@ -195,7 +202,9 @@ export default { const selectedDiscussion = state.discussions.find(disc => disc.id === note.id); note.expanded = true; // override expand flag to prevent collapse if (note.diff_file) { - Object.assign(note, { file_hash: note.diff_file.file_hash }); + Object.assign(note, { + file_hash: note.diff_file.file_hash, + }); } Object.assign(selectedDiscussion, { ...note }); }, @@ -229,4 +238,16 @@ export default { [types.DISABLE_COMMENTS](state, value) { state.commentsDisabled = value; }, + [types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS](state) { + state.resolvableDiscussionsCount = state.discussions.filter( + discussion => !discussion.individual_note && discussion.resolvable, + ).length; + state.unresolvedDiscussionsCount = state.discussions.filter( + discussion => + !discussion.individual_note && + discussion.resolvable && + discussion.notes.some(note => note.resolvable && !note.resolved), + ).length; + state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1; + }, }; |