noteable_note.vue 13.6 KB
Newer Older
1
<script>
2
import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
Fatih Acet's avatar
Fatih Acet committed
3
import $ from 'jquery';
4
import { escape, isEmpty } from 'lodash';
5 6
import { mapGetters, mapActions } from 'vuex';
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
7
import createFlash from '~/flash';
8
import httpStatusCodes from '~/lib/utils/http_status';
9
import { truncateSha } from '~/lib/utils/text_utility';
10
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
11
import { __, s__, sprintf } from '../../locale';
Fatih Acet's avatar
Fatih Acet committed
12 13 14 15
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
16
import { renderMarkdown } from '../utils';
17 18 19 20 21
import {
  getStartLineNumber,
  getEndLineNumber,
  getLineClasses,
  commentLineOptions,
22
  formatLineRange,
23
} from './multiline_comment_utils';
24 25 26
import noteActions from './note_actions.vue';
import NoteBody from './note_body.vue';
import noteHeader from './note_header.vue';
27

Fatih Acet's avatar
Fatih Acet committed
28
export default {
Felipe Artur's avatar
Felipe Artur committed
29
  name: 'NoteableNote',
Fatih Acet's avatar
Fatih Acet committed
30
  components: {
31
    GlSprintf,
Fatih Acet's avatar
Fatih Acet committed
32 33 34
    userAvatarLink,
    noteHeader,
    noteActions,
35
    NoteBody,
36
    TimelineEntryItem,
Fatih Acet's avatar
Fatih Acet committed
37
  },
38 39 40
  directives: {
    SafeHtml,
  },
41
  mixins: [noteable, resolvable],
Fatih Acet's avatar
Fatih Acet committed
42 43 44 45
  props: {
    note: {
      type: Object,
      required: true,
Filipa Lacerda's avatar
Filipa Lacerda committed
46
    },
47 48 49 50 51
    line: {
      type: Object,
      required: false,
      default: null,
    },
52 53 54 55 56
    discussionFile: {
      type: Object,
      required: false,
      default: null,
    },
57 58 59 60 61
    helpPagePath: {
      type: String,
      required: false,
      default: '',
    },
62 63 64 65 66
    commit: {
      type: Object,
      required: false,
      default: () => null,
    },
67 68 69 70 71
    showReplyButton: {
      type: Boolean,
      required: false,
      default: false,
    },
72
    diffLines: {
73
      type: Array,
74 75 76
      required: false,
      default: null,
    },
77 78 79 80 81
    discussionRoot: {
      type: Boolean,
      required: false,
      default: false,
    },
82 83 84 85 86
    discussionResolvePath: {
      type: String,
      required: false,
      default: '',
    },
Fatih Acet's avatar
Fatih Acet committed
87 88 89 90 91 92 93
  },
  data() {
    return {
      isEditing: false,
      isDeleting: false,
      isRequesting: false,
      isResolving: false,
94
      commentLineStart: {},
95
      resolveAsThread: true,
Fatih Acet's avatar
Fatih Acet committed
96 97 98
    };
  },
  computed: {
99
    ...mapGetters('diffs', ['getDiffFileByHash']),
100
    ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']),
Fatih Acet's avatar
Fatih Acet committed
101 102
    author() {
      return this.note.author;
103
    },
Fatih Acet's avatar
Fatih Acet committed
104
    classNameBindings() {
105
      return {
Felipe Artur's avatar
Felipe Artur committed
106
        [`note-row-${this.note.id}`]: true,
Fatih Acet's avatar
Fatih Acet committed
107 108 109
        'is-editing': this.isEditing && !this.isRequesting,
        'is-requesting being-posted': this.isRequesting,
        'disabled-content': this.isDeleting,
Felipe Artur's avatar
Felipe Artur committed
110
        target: this.isTarget,
111
        'is-editable': this.note.current_user.can_edit,
112 113
      };
    },
Fatih Acet's avatar
Fatih Acet committed
114
    canReportAsAbuse() {
115
      return Boolean(this.note.report_abuse_path) && this.author.id !== this.getUserData.id;
116
    },
Fatih Acet's avatar
Fatih Acet committed
117 118
    noteAnchorId() {
      return `note_${this.note.id}`;
Filipa Lacerda's avatar
Filipa Lacerda committed
119
    },
Felipe Artur's avatar
Felipe Artur committed
120 121 122
    isTarget() {
      return this.targetNoteHash === this.noteAnchorId;
    },
123 124 125 126 127 128
    discussionId() {
      if (this.discussion) {
        return this.discussion.id;
      }
      return '';
    },
129
    actionText() {
130
      if (!this.commit) {
131
        return '';
132 133
      }

Sergiu Marton's avatar
Sergiu Marton committed
134
      // We need to do this to ensure we have the correct sentence order
135 136
      // when translating this as the sentence order may change from one
      // language to the next. See:
137
      // https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24427#note_133713771
138
      const { id, url } = this.commit;
139 140 141 142
      const commitLink = `<a class="commit-sha monospace" href="${escape(url)}">${truncateSha(
        id,
      )}</a>`;
      return sprintf(s__('MergeRequests|commented on commit %{commitLink}'), { commitLink }, false);
143
    },
144 145 146 147
    isDraft() {
      return this.note.isDraft;
    },
    canResolve() {
148
      if (!this.discussionRoot) return false;
149

150
      return this.note.current_user.can_resolve_discussion;
151
    },
152 153 154 155 156 157 158 159 160 161
    lineRange() {
      return this.note.position?.line_range;
    },
    startLineNumber() {
      return getStartLineNumber(this.lineRange);
    },
    endLineNumber() {
      return getEndLineNumber(this.lineRange);
    },
    showMultiLineComment() {
Justin Boyson's avatar
Justin Boyson committed
162 163 164 165 166 167
      if (
        !this.discussionRoot ||
        this.startLineNumber.length === 0 ||
        this.endLineNumber.length === 0
      )
        return false;
168

169
      return this.line && this.startLineNumber !== this.endLineNumber;
170 171
    },
    commentLineOptions() {
172 173
      const lines = this.diffFile[INLINE_DIFF_LINES_KEY].length;
      return commentLineOptions(lines, this.commentLineStart, this.line.line_code);
174 175
    },
    diffFile() {
176 177
      let fileResolvedFromAvailableSource;

178 179
      if (this.commentLineStart.line_code) {
        const lineCode = this.commentLineStart.line_code.split('_')[0];
180 181 182 183 184
        fileResolvedFromAvailableSource = this.getDiffFileByHash(lineCode);
      }

      if (!fileResolvedFromAvailableSource && this.discussionFile) {
        fileResolvedFromAvailableSource = this.discussionFile;
185 186
      }

187
      return fileResolvedFromAvailableSource || null;
188
    },
Fatih Acet's avatar
Fatih Acet committed
189 190
  },
  created() {
191 192 193 194 195 196 197 198 199 200 201
    const line = this.note.position?.line_range?.start || this.line;

    this.commentLineStart = line
      ? {
          line_code: line.line_code,
          type: line.type,
          old_line: line.old_line,
          new_line: line.new_line,
        }
      : {};

Fatih Acet's avatar
Fatih Acet committed
202 203
    eventHub.$on('enterEditMode', ({ noteId }) => {
      if (noteId === this.note.id) {
204
        this.isEditing = true;
205
        this.setSelectedCommentPositionHover();
Fatih Acet's avatar
Fatih Acet committed
206 207 208 209
        this.scrollToNoteIfNeeded($(this.$el));
      }
    });
  },
210

Felipe Artur's avatar
Felipe Artur committed
211 212 213 214 215 216
  mounted() {
    if (this.isTarget) {
      this.scrollToNoteIfNeeded($(this.$el));
    }
  },

Fatih Acet's avatar
Fatih Acet committed
217
  methods: {
218 219 220 221 222 223
    ...mapActions([
      'deleteNote',
      'removeNote',
      'updateNote',
      'toggleResolveNote',
      'scrollToNoteIfNeeded',
224
      'updateAssignees',
225
      'setSelectedCommentPositionHover',
Justin Boyson's avatar
Justin Boyson committed
226
      'updateDiscussionPosition',
227
    ]),
Fatih Acet's avatar
Fatih Acet committed
228 229
    editHandler() {
      this.isEditing = true;
230
      this.setSelectedCommentPositionHover();
231
      this.$emit('handleEdit');
Fatih Acet's avatar
Fatih Acet committed
232 233
    },
    deleteHandler() {
234 235 236 237 238 239 240
      const typeOfComment = this.note.isDraft ? __('pending comment') : __('comment');
      if (
        // eslint-disable-next-line no-alert
        window.confirm(
          sprintf(__('Are you sure you want to delete this %{typeOfComment}?'), { typeOfComment }),
        )
      ) {
Fatih Acet's avatar
Fatih Acet committed
241
        this.isDeleting = true;
Tim Zallmann's avatar
Tim Zallmann committed
242
        this.$emit('handleDeleteNote', this.note);
243

244 245
        if (this.note.isDraft) return;

Fatih Acet's avatar
Fatih Acet committed
246
        this.deleteNote(this.note)
247
          .then(() => {
Fatih Acet's avatar
Fatih Acet committed
248
            this.isDeleting = false;
249
          })
250
          .catch(() => {
251 252 253
            createFlash({
              message: __('Something went wrong while deleting your note. Please try again.'),
            });
Fatih Acet's avatar
Fatih Acet committed
254
            this.isDeleting = false;
255
          });
Fatih Acet's avatar
Fatih Acet committed
256 257
      }
    },
258 259 260 261 262 263 264 265
    updateSuccess() {
      this.isEditing = false;
      this.isRequesting = false;
      this.oldContent = null;
      $(this.$refs.noteBody.$el).renderGFM();
      this.$refs.noteBody.resetAutoSave();
      this.$emit('updateSuccess');
    },
266
    formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) {
267 268 269
      const position = {
        ...this.note.position,
      };
270

Justin Boyson's avatar
Justin Boyson committed
271
      if (this.discussionRoot && this.commentLineStart && this.line) {
272
        position.line_range = formatLineRange(this.commentLineStart, this.line);
Justin Boyson's avatar
Justin Boyson committed
273 274 275 276 277
        this.updateDiscussionPosition({
          discussionId: this.note.discussion_id,
          position,
        });
      }
278

279 280 281
      this.$emit('handleUpdateNote', {
        note: this.note,
        noteText,
282
        resolveDiscussion,
283
        position,
284 285
        callback: () => this.updateSuccess(),
      });
286 287 288

      if (this.isDraft) return;

Fatih Acet's avatar
Fatih Acet committed
289 290 291
      const data = {
        endpoint: this.note.path,
        note: {
Felipe Artur's avatar
Felipe Artur committed
292
          target_type: this.getNoteableData.targetType,
Fatih Acet's avatar
Fatih Acet committed
293
          target_id: this.note.noteable_id,
294
          note: { note: noteText },
Fatih Acet's avatar
Fatih Acet committed
295 296
        },
      };
297 298 299 300

      // Stringifying an empty object yields `{}` which breaks graphql queries
      // https://gitlab.com/gitlab-org/gitlab/-/issues/298827
      if (!isEmpty(position)) data.note.note.position = JSON.stringify(position);
Fatih Acet's avatar
Fatih Acet committed
301 302
      this.isRequesting = true;
      this.oldContent = this.note.note_html;
303
      // eslint-disable-next-line vue/no-mutating-props
304
      this.note.note_html = renderMarkdown(noteText);
Fatih Acet's avatar
Fatih Acet committed
305 306 307

      this.updateNote(data)
        .then(() => {
308
          this.updateSuccess();
Fatih Acet's avatar
Fatih Acet committed
309 310
          callback();
        })
311
        .catch((response) => {
312 313 314
          if (response.status === httpStatusCodes.GONE) {
            this.removeNote(this.note);
            this.updateSuccess();
Fatih Acet's avatar
Fatih Acet committed
315
            callback();
316 317 318
          } else {
            this.isRequesting = false;
            this.isEditing = true;
319
            this.setSelectedCommentPositionHover();
320 321
            this.$nextTick(() => {
              const msg = __('Something went wrong while editing your comment. Please try again.');
322 323 324 325
              createFlash({
                message: msg,
                parent: this.$el,
              });
326 327 328 329
              this.recoverNoteContent(noteText);
              callback();
            });
          }
Fatih Acet's avatar
Fatih Acet committed
330 331 332 333 334
        });
    },
    formCancelHandler(shouldConfirm, isDirty) {
      if (shouldConfirm && isDirty) {
        // eslint-disable-next-line no-alert
335
        if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) return;
Fatih Acet's avatar
Fatih Acet committed
336 337 338
      }
      this.$refs.noteBody.resetAutoSave();
      if (this.oldContent) {
339
        // eslint-disable-next-line vue/no-mutating-props
Fatih Acet's avatar
Fatih Acet committed
340 341 342 343
        this.note.note_html = this.oldContent;
        this.oldContent = null;
      }
      this.isEditing = false;
344
      this.$emit('cancelForm');
Fatih Acet's avatar
Fatih Acet committed
345 346 347 348
    },
    recoverNoteContent(noteText) {
      // we need to do this to prevent noteForm inconsistent content warning
      // this is something we intentionally do so we need to recover the content
349
      // eslint-disable-next-line vue/no-mutating-props
Fatih Acet's avatar
Fatih Acet committed
350
      this.note.note = noteText;
351 352 353 354
      const { noteBody } = this.$refs;
      if (noteBody) {
        noteBody.note.note = noteText;
      }
355
    },
356 357 358
    getLineClasses(lineNumber) {
      return getLineClasses(lineNumber);
    },
359 360 361
    assigneesUpdate(assignees) {
      this.updateAssignees(assignees);
    },
Fatih Acet's avatar
Fatih Acet committed
362 363
  },
};
364 365 366
</script>

<template>
367
  <timeline-entry-item
368
    :id="noteAnchorId"
369
    :class="classNameBindings"
370
    :data-award-url="note.toggle_award_path"
Felipe Artur's avatar
Felipe Artur committed
371
    :data-note-id="note.id"
372 373
    class="note note-wrapper"
    data-qa-selector="noteable_note_container"
Felipe Artur's avatar
Felipe Artur committed
374
  >
Justin Boyson's avatar
Justin Boyson committed
375 376 377
    <div
      v-if="showMultiLineComment"
      data-testid="multiline-comment"
378
      class="gl-mb-3 gl-text-gray-500 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
Justin Boyson's avatar
Justin Boyson committed
379 380 381 382 383 384 385 386 387
    >
      <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
        <template #startLine>
          <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
        </template>
        <template #endLine>
          <span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span>
        </template>
      </gl-sprintf>
388
    </div>
389
    <div class="timeline-icon">
390 391 392 393 394 395
      <user-avatar-link
        :link-href="author.path"
        :img-src="author.avatar_url"
        :img-alt="author.name"
        :img-size="40"
      >
396 397 398
        <template #avatar-badge>
          <slot name="avatar-badge"></slot>
        </template>
399 400 401 402
      </user-avatar-link>
    </div>
    <div class="timeline-content">
      <div class="note-header">
403 404 405 406 407 408
        <note-header
          :author="author"
          :created-at="note.created_at"
          :note-id="note.id"
          :is-confidential="note.confidential"
        >
409 410 411
          <template #note-header-info>
            <slot name="note-header-info"></slot>
          </template>
412
          <span v-if="commit" v-safe-html="actionText"></span>
413
          <span v-else-if="note.created_at" class="d-none d-sm-inline">&middot;</span>
414
        </note-header>
415
        <note-actions
416
          :author="author"
417 418 419 420
          :author-id="author.id"
          :note-id="note.id"
          :note-url="note.noteable_note_url"
          :access-level="note.human_access"
421 422 423 424
          :is-contributor="note.is_contributor"
          :is-author="note.is_noteable_author"
          :project-name="note.project_name"
          :noteable-type="note.noteable_type"
425
          :show-reply="showReplyButton"
426
          :can-edit="note.current_user.can_edit"
427 428 429
          :can-award-emoji="note.current_user.can_award_emoji"
          :can-delete="note.current_user.can_edit"
          :can-report-as-abuse="canReportAsAbuse"
430
          :can-resolve="canResolve"
431
          :report-abuse-path="note.report_abuse_path"
432 433
          :resolvable="note.resolvable || note.isDraft"
          :is-resolved="note.resolved || note.resolve_discussion"
434 435
          :is-resolving="isResolving"
          :resolved-by="note.resolved_by"
436 437 438
          :is-draft="note.isDraft"
          :resolve-discussion="note.isDraft && note.resolve_discussion"
          :discussion-id="discussionId"
Phil Hughes's avatar
Phil Hughes committed
439
          :award-path="note.toggle_award_path"
440 441 442
          @handleEdit="editHandler"
          @handleDelete="deleteHandler"
          @handleResolve="resolveHandler"
443
          @startReplying="$emit('startReplying')"
444
          @updateAssignees="assigneesUpdate"
Filipa Lacerda's avatar
Filipa Lacerda committed
445
        />
446
      </div>
447 448 449 450 451 452
      <div class="timeline-discussion-body">
        <slot name="discussion-resolved-text"></slot>
        <note-body
          ref="noteBody"
          :note="note"
          :line="line"
453
          :file="diffFile"
454 455 456 457 458 459 460
          :can-edit="note.current_user.can_edit"
          :is-editing="isEditing"
          :help-page-path="helpPagePath"
          @handleFormUpdate="formUpdateHandler"
          @cancelForm="formCancelHandler"
        />
      </div>
461
    </div>
462
  </timeline-entry-item>
463
</template>