diff options
Diffstat (limited to 'app/assets/javascripts/notes/components')
10 files changed, 482 insertions, 545 deletions
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 884ccca7bde..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); }); } }, @@ -309,137 +315,135 @@ Please check your network connection and try again.`; <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> + <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 + 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 + > + <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" + 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" > - Discard draft + <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 8e8bd150647..af821df0fd2 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -4,7 +4,9 @@ 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/ui'; -import { trimFirstCharOfLineContent, getDiffMode } from '~/diffs/store/utils'; +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,31 +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" class="diff-content code"> + <div + 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"></td> - </tr> + <template v-if="hasTruncatedDiffLines"> + <tr + v-for="line in discussion.truncated_diff_lines" + v-once + :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="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 + {{ error }} Unable to load the diff <button class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button" @click="fetchDiff" @@ -131,17 +114,17 @@ export default { <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 ee79ecbf9b3..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,7 +49,7 @@ 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"> <span @@ -61,15 +60,15 @@ 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"> <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" > <icon name="issue-new" /> @@ -77,9 +76,8 @@ export default { </div> <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" > diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 9a5817890c9..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/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,10 +118,10 @@ export default { <template> <div class="note-actions"> - <span v-if="accessLevel" class="note-role user-access-role"> {{ accessLevel }} </span> + <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" @@ -138,12 +137,10 @@ export default { </div> <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" > @@ -158,12 +155,10 @@ export default { </div> <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" /> @@ -171,12 +166,10 @@ export default { </div> <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" /> @@ -184,19 +177,17 @@ export default { </div> <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" /> </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 @@ -213,7 +204,7 @@ export default { type="button" @click.prevent="onDelete" > - <span class="text-danger"> {{ __('Delete comment') }} </span> + <span class="text-danger">{{ __('Delete comment') }}</span> </button> </li> </ul> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 4aba2e65edb..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,21 +167,19 @@ 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);" > <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"> <button - v-tooltip + v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByMe }" class="award-control btn js-add-award" title="Add reaction" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index ad58267b533..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, }; }, @@ -149,7 +155,7 @@ export default { <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 + <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> @@ -174,22 +180,20 @@ 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> + ></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 " + class="js-vue-issue-save btn btn-success js-comment-button" @click="handleUpdate();" > {{ saveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 8b7450783c9..e1a58e7cb26 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -73,7 +73,7 @@ export default { {{ __('Toggle discussion') }} </button> </div> - <a v-if="hasAuthor" :href="author.path"> + <a v-if="hasAuthor" v-once :href="author.path"> <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> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 29740ddf6ae..f4991a41325 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,24 @@ export default { }, }, data() { + const { diff_discussion: isDiffDiscussion, resolved } = this.discussion; + return { isReplying: false, isResolving: false, resolveAsThread: true, - isRepliesToggledByUser: false, + isRepliesCollapsed: Boolean(!isDiffDiscussion && resolved), }; }, computed: { ...mapGetters([ 'getNoteableData', - 'discussionCount', - 'resolvedDiscussionCount', - 'allDiscussions', - 'unresolvedDiscussionsIdsByDiff', - 'unresolvedDiscussionsIdsByDate', - 'unresolvedDiscussions', - 'unresolvedDiscussionsIdsOrdered', 'nextUnresolvedDiscussionId', - 'isLastUnresolvedDiscussion', + 'unresolvedDiscussionsCount', + 'hasUnresolvedDiscussions', ]), - 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 +119,13 @@ export default { return null; }, resolvedText() { - return this.transformedDiscussion.resolved_by_push ? 'Automatically resolved' : 'Resolved'; - }, - hasMultipleUnresolvedDiscussions() { - return this.unresolvedDiscussions.length > 1; - }, - showJumpToNextDiscussion() { - return ( - this.hasMultipleUnresolvedDiscussions && - !this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder) - ); + return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); }, 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 +137,6 @@ export default { return {}; }, - wrapperClass() { - return this.isDiffDiscussion ? '' : 'card discussion-wrapper'; - }, componentClassName() { if (this.shouldRenderDiffs) { if (!this.lastUpdatedAt && !this.discussion.resolved) { @@ -183,19 +147,40 @@ 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 isExpanded || this.alwaysExpanded || isResolvedNonDiffDiscussion; + return expanded || this.alwaysExpanded || isResolvedNonDiffDiscussion; }, - isRepliesCollapsed() { - const { discussion, isRepliesToggledByUser } = this; - const { resolved, notes } = discussion; - const hasReplies = notes.length > 1; + actionText() { + const commitId = this.discussion.commit_id ? truncateSha(this.discussion.commit_id) : ''; + const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; + const linkEnd = '</a>'; + + let text = s__('MergeRequests|started a discussion'); - return ( - (!discussion.diff_discussion && resolved && hasReplies && !isRepliesToggledByUser) || false + 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, ); }, }, @@ -204,7 +189,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(); @@ -242,7 +227,7 @@ export default { this.toggleDiscussion({ discussionId: this.discussion.id }); }, toggleReplies() { - this.isRepliesToggledByUser = !this.isRepliesToggledByUser; + this.isRepliesCollapsed = !this.isRepliesCollapsed; }, showReplyForm() { this.isReplying = true; @@ -311,181 +296,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 mr-sm-2 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 mr-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="hasUnresolvedDiscussions" 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 c2e49f8b23f..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,61 +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 79ece036e69..6e6efb04753 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -22,6 +22,7 @@ export default { commentForm, placeholderNote, placeholderSystemNote, + skeletonLoadingContainer, }, props: { noteableData: { @@ -59,7 +60,6 @@ export default { 'isNotesFetched', 'discussions', 'getNotesDataByProp', - 'discussionCount', 'isLoading', 'commentsDisabled', ]), @@ -109,39 +109,22 @@ export default { this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'))); }, 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,31 +164,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)" - /> + <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 |