noteable_note.vue 12.9 KB
Newer Older
1
<script>
Fatih Acet's avatar
Fatih Acet committed
2 3
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
4
import { escape } from 'lodash';
5 6
import { GlSprintf } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
7
import { truncateSha } from '~/lib/utils/text_utility';
8
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
9
import { __, s__, sprintf } from '../../locale';
Fatih Acet's avatar
Fatih Acet committed
10 11 12 13
import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import noteHeader from './note_header.vue';
import noteActions from './note_actions.vue';
14
import NoteBody from './note_body.vue';
Fatih Acet's avatar
Fatih Acet committed
15 16 17
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
18
import httpStatusCodes from '~/lib/utils/http_status';
19 20 21 22 23
import {
  getStartLineNumber,
  getEndLineNumber,
  getLineClasses,
  commentLineOptions,
24
  formatLineRange,
25 26
} from './multiline_comment_utils';
import MultilineCommentForm from './multiline_comment_form.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,
37
    MultilineCommentForm,
Fatih Acet's avatar
Fatih Acet committed
38
  },
39
  mixins: [noteable, resolvable, glFeatureFlagsMixin()],
Fatih Acet's avatar
Fatih Acet committed
40 41 42 43
  props: {
    note: {
      type: Object,
      required: true,
Filipa Lacerda's avatar
Filipa Lacerda committed
44
    },
45 46 47 48 49 50 51 52 53 54
    line: {
      type: Object,
      required: false,
      default: null,
    },
    helpPagePath: {
      type: String,
      required: false,
      default: '',
    },
55 56 57 58 59
    commit: {
      type: Object,
      required: false,
      default: () => null,
    },
60 61 62 63 64
    showReplyButton: {
      type: Boolean,
      required: false,
      default: false,
    },
65
    diffLines: {
66
      type: Array,
67 68 69
      required: false,
      default: null,
    },
70 71 72 73 74
    discussionRoot: {
      type: Boolean,
      required: false,
      default: false,
    },
Fatih Acet's avatar
Fatih Acet committed
75 76 77 78 79 80 81
  },
  data() {
    return {
      isEditing: false,
      isDeleting: false,
      isRequesting: false,
      isResolving: false,
82
      commentLineStart: {},
Fatih Acet's avatar
Fatih Acet committed
83 84 85
    };
  },
  computed: {
86
    ...mapGetters('diffs', ['getDiffFileByHash']),
87
    ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']),
Fatih Acet's avatar
Fatih Acet committed
88 89
    author() {
      return this.note.author;
90
    },
Fatih Acet's avatar
Fatih Acet committed
91
    classNameBindings() {
92
      return {
Felipe Artur's avatar
Felipe Artur committed
93
        [`note-row-${this.note.id}`]: true,
Fatih Acet's avatar
Fatih Acet committed
94 95 96
        'is-editing': this.isEditing && !this.isRequesting,
        'is-requesting being-posted': this.isRequesting,
        'disabled-content': this.isDeleting,
Felipe Artur's avatar
Felipe Artur committed
97
        target: this.isTarget,
98
        'is-editable': this.note.current_user.can_edit,
99 100
      };
    },
Fatih Acet's avatar
Fatih Acet committed
101
    canReportAsAbuse() {
102
      return Boolean(this.note.report_abuse_path) && this.author.id !== this.getUserData.id;
103
    },
Fatih Acet's avatar
Fatih Acet committed
104 105
    noteAnchorId() {
      return `note_${this.note.id}`;
Filipa Lacerda's avatar
Filipa Lacerda committed
106
    },
Felipe Artur's avatar
Felipe Artur committed
107 108 109
    isTarget() {
      return this.targetNoteHash === this.noteAnchorId;
    },
110 111 112 113 114 115
    discussionId() {
      if (this.discussion) {
        return this.discussion.id;
      }
      return '';
    },
116
    actionText() {
117
      if (!this.commit) {
118
        return '';
119 120
      }

Sergiu Marton's avatar
Sergiu Marton committed
121
      // We need to do this to ensure we have the correct sentence order
122 123
      // when translating this as the sentence order may change from one
      // language to the next. See:
124
      // https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24427#note_133713771
125
      const { id, url } = this.commit;
126 127 128 129
      const commitLink = `<a class="commit-sha monospace" href="${escape(url)}">${truncateSha(
        id,
      )}</a>`;
      return sprintf(s__('MergeRequests|commented on commit %{commitLink}'), { commitLink }, false);
130
    },
131 132 133 134 135 136 137 138 139
    isDraft() {
      return this.note.isDraft;
    },
    canResolve() {
      return (
        this.note.current_user.can_resolve ||
        (this.note.isDraft && this.note.discussion_id !== null)
      );
    },
140 141 142 143 144 145 146 147 148 149
    lineRange() {
      return this.note.position?.line_range;
    },
    startLineNumber() {
      return getStartLineNumber(this.lineRange);
    },
    endLineNumber() {
      return getEndLineNumber(this.lineRange);
    },
    showMultiLineComment() {
150
      if (!this.glFeatures.multilineComments || !this.discussionRoot) return false;
151 152
      if (this.isEditing) return true;

153
      return this.line && this.startLineNumber !== this.endLineNumber;
154
    },
155 156 157
    showMultilineCommentForm() {
      return Boolean(this.isEditing && this.note.position && this.diffFile && this.line);
    },
158
    commentLineOptions() {
159 160 161 162 163 164 165 166 167 168 169
      const sideA = this.line.type === 'new' ? 'right' : 'left';
      const sideB = sideA === 'left' ? 'right' : 'left';
      const lines = this.diffFile.highlighted_diff_lines.length
        ? this.diffFile.highlighted_diff_lines
        : this.diffFile.parallel_diff_lines.map(l => l[sideA] || l[sideB]);
      return commentLineOptions(lines, this.commentLineStart, this.line.line_code, sideA);
    },
    diffFile() {
      if (this.commentLineStart.line_code) {
        const lineCode = this.commentLineStart.line_code.split('_')[0];
        return this.getDiffFileByHash(lineCode);
170 171
      }

172
      return null;
173
    },
Fatih Acet's avatar
Fatih Acet committed
174 175
  },
  created() {
176 177 178 179 180 181 182 183 184 185 186
    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
187 188
    eventHub.$on('enterEditMode', ({ noteId }) => {
      if (noteId === this.note.id) {
189
        this.isEditing = true;
190
        this.setSelectedCommentPositionHover();
Fatih Acet's avatar
Fatih Acet committed
191 192 193 194
        this.scrollToNoteIfNeeded($(this.$el));
      }
    });
  },
195

Felipe Artur's avatar
Felipe Artur committed
196 197 198 199 200 201
  mounted() {
    if (this.isTarget) {
      this.scrollToNoteIfNeeded($(this.$el));
    }
  },

Fatih Acet's avatar
Fatih Acet committed
202
  methods: {
203 204 205 206 207 208
    ...mapActions([
      'deleteNote',
      'removeNote',
      'updateNote',
      'toggleResolveNote',
      'scrollToNoteIfNeeded',
209
      'updateAssignees',
210
      'setSelectedCommentPositionHover',
211
    ]),
Fatih Acet's avatar
Fatih Acet committed
212 213
    editHandler() {
      this.isEditing = true;
214
      this.setSelectedCommentPositionHover();
215
      this.$emit('handleEdit');
Fatih Acet's avatar
Fatih Acet committed
216 217
    },
    deleteHandler() {
218 219 220 221 222 223 224
      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
225
        this.isDeleting = true;
Tim Zallmann's avatar
Tim Zallmann committed
226
        this.$emit('handleDeleteNote', this.note);
227

228 229
        if (this.note.isDraft) return;

Fatih Acet's avatar
Fatih Acet committed
230
        this.deleteNote(this.note)
231
          .then(() => {
Fatih Acet's avatar
Fatih Acet committed
232
            this.isDeleting = false;
233
          })
234
          .catch(() => {
235
            Flash(__('Something went wrong while deleting your note. Please try again.'));
Fatih Acet's avatar
Fatih Acet committed
236
            this.isDeleting = false;
237
          });
Fatih Acet's avatar
Fatih Acet committed
238 239
      }
    },
240 241 242 243 244 245 246 247
    updateSuccess() {
      this.isEditing = false;
      this.isRequesting = false;
      this.oldContent = null;
      $(this.$refs.noteBody.$el).renderGFM();
      this.$refs.noteBody.resetAutoSave();
      this.$emit('updateSuccess');
    },
248
    formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) {
249 250 251
      const position = {
        ...this.note.position,
      };
252 253 254 255

      if (this.commentLineStart && this.line)
        position.line_range = formatLineRange(this.commentLineStart, this.line);

256 257 258
      this.$emit('handleUpdateNote', {
        note: this.note,
        noteText,
259
        resolveDiscussion,
260
        position,
261 262
        callback: () => this.updateSuccess(),
      });
263 264 265

      if (this.isDraft) return;

Fatih Acet's avatar
Fatih Acet committed
266 267 268
      const data = {
        endpoint: this.note.path,
        note: {
Felipe Artur's avatar
Felipe Artur committed
269
          target_type: this.getNoteableData.targetType,
Fatih Acet's avatar
Fatih Acet committed
270
          target_id: this.note.noteable_id,
271
          note: { note: noteText, position: JSON.stringify(position) },
Fatih Acet's avatar
Fatih Acet committed
272 273 274 275 276 277 278 279
        },
      };
      this.isRequesting = true;
      this.oldContent = this.note.note_html;
      this.note.note_html = escape(noteText);

      this.updateNote(data)
        .then(() => {
280
          this.updateSuccess();
Fatih Acet's avatar
Fatih Acet committed
281 282
          callback();
        })
283 284 285 286
        .catch(response => {
          if (response.status === httpStatusCodes.GONE) {
            this.removeNote(this.note);
            this.updateSuccess();
Fatih Acet's avatar
Fatih Acet committed
287
            callback();
288 289 290
          } else {
            this.isRequesting = false;
            this.isEditing = true;
291
            this.setSelectedCommentPositionHover();
292 293 294 295 296 297 298
            this.$nextTick(() => {
              const msg = __('Something went wrong while editing your comment. Please try again.');
              Flash(msg, 'alert', this.$el);
              this.recoverNoteContent(noteText);
              callback();
            });
          }
Fatih Acet's avatar
Fatih Acet committed
299 300 301 302 303
        });
    },
    formCancelHandler(shouldConfirm, isDirty) {
      if (shouldConfirm && isDirty) {
        // eslint-disable-next-line no-alert
304
        if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) return;
Fatih Acet's avatar
Fatih Acet committed
305 306 307 308 309 310 311
      }
      this.$refs.noteBody.resetAutoSave();
      if (this.oldContent) {
        this.note.note_html = this.oldContent;
        this.oldContent = null;
      }
      this.isEditing = false;
312
      this.$emit('cancelForm');
Fatih Acet's avatar
Fatih Acet committed
313 314 315 316 317
    },
    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
      this.note.note = noteText;
318 319 320 321
      const { noteBody } = this.$refs;
      if (noteBody) {
        noteBody.note.note = noteText;
      }
322
    },
323 324 325
    getLineClasses(lineNumber) {
      return getLineClasses(lineNumber);
    },
326 327 328
    assigneesUpdate(assignees) {
      this.updateAssignees(assignees);
    },
Fatih Acet's avatar
Fatih Acet committed
329 330
  },
};
331 332 333
</script>

<template>
334
  <timeline-entry-item
335
    :id="noteAnchorId"
336
    :class="classNameBindings"
337
    :data-award-url="note.toggle_award_path"
Felipe Artur's avatar
Felipe Artur committed
338
    :data-note-id="note.id"
Sanad Liaquat's avatar
Sanad Liaquat committed
339
    class="note note-wrapper qa-noteable-note-item"
Felipe Artur's avatar
Felipe Artur committed
340
  >
341 342
    <div v-if="showMultiLineComment" data-testid="multiline-comment">
      <multiline-comment-form
343
        v-if="showMultilineCommentForm"
344 345 346 347
        v-model="commentLineStart"
        :line="line"
        :comment-line-options="commentLineOptions"
        :line-range="note.position.line_range"
348
        class="gl-mb-3 gl-text-gray-700 gl-pb-3"
349
      />
350 351 352 353
      <div
        v-else
        class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
      >
354 355 356 357 358 359 360 361 362 363
        <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>
      </div>
    </div>
364 365 366 367 368 369 370
    <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"
      >
371
        <slot slot="avatar-badge" name="avatar-badge"></slot>
372 373 374 375
      </user-avatar-link>
    </div>
    <div class="timeline-content">
      <div class="note-header">
376 377 378 379 380 381 382
        <note-header
          v-once
          :author="author"
          :created-at="note.created_at"
          :note-id="note.id"
          :is-confidential="note.confidential"
        >
383
          <slot slot="note-header-info" name="note-header-info"></slot>
384
          <span v-if="commit" v-html="actionText"></span>
385
          <span v-else-if="note.created_at" class="d-none d-sm-inline">&middot;</span>
386
        </note-header>
387
        <note-actions
388
          :author="author"
389 390 391 392
          :author-id="author.id"
          :note-id="note.id"
          :note-url="note.noteable_note_url"
          :access-level="note.human_access"
393
          :show-reply="showReplyButton"
394
          :can-edit="note.current_user.can_edit"
395 396 397
          :can-award-emoji="note.current_user.can_award_emoji"
          :can-delete="note.current_user.can_edit"
          :can-report-as-abuse="canReportAsAbuse"
398
          :can-resolve="canResolve"
399
          :report-abuse-path="note.report_abuse_path"
400 401
          :resolvable="note.resolvable || note.isDraft"
          :is-resolved="note.resolved || note.resolve_discussion"
402 403
          :is-resolving="isResolving"
          :resolved-by="note.resolved_by"
404 405 406
          :is-draft="note.isDraft"
          :resolve-discussion="note.isDraft && note.resolve_discussion"
          :discussion-id="discussionId"
407 408 409
          @handleEdit="editHandler"
          @handleDelete="deleteHandler"
          @handleResolve="resolveHandler"
410
          @startReplying="$emit('startReplying')"
411
          @updateAssignees="assigneesUpdate"
Filipa Lacerda's avatar
Filipa Lacerda committed
412
        />
413
      </div>
414 415 416 417 418 419 420 421 422 423 424 425 426
      <div class="timeline-discussion-body">
        <slot name="discussion-resolved-text"></slot>
        <note-body
          ref="noteBody"
          :note="note"
          :line="line"
          :can-edit="note.current_user.can_edit"
          :is-editing="isEditing"
          :help-page-path="helpPagePath"
          @handleFormUpdate="formUpdateHandler"
          @cancelForm="formCancelHandler"
        />
      </div>
427
    </div>
428
  </timeline-entry-item>
429
</template>