gitlab-org--gitlab-foss/app/assets/javascripts/notes/components/comment_form.vue

441 lines
15 KiB
Vue

<script>
import { GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
import Autosize from 'autosize';
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import Autosave from '~/autosave';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { deprecatedCreateFlash as Flash } from '~/flash';
import {
capitalizeFirstCharacter,
convertToCamelCase,
splitCamelCase,
slugifyWithUnderscore,
} from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import * as constants from '../constants';
import eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
import discussionLockedWidget from './discussion_locked_widget.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue';
export default {
name: 'CommentForm',
components: {
noteSignedOutWidget,
discussionLockedWidget,
markdownField,
GlButton,
TimelineEntryItem,
GlIcon,
CommentFieldLayout,
GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin(), issuableStateMixin],
props: {
noteableType: {
type: String,
required: true,
},
},
data() {
return {
note: '',
noteType: constants.COMMENT,
noteIsConfidential: false,
isSubmitting: false,
};
},
computed: {
...mapGetters([
'getCurrentUserLastNote',
'getUserData',
'getNoteableData',
'getNoteableDataByProp',
'getNotesData',
'openState',
]),
...mapState(['isToggleStateButtonLoading']),
noteableDisplayName() {
return splitCamelCase(this.noteableType).toLowerCase();
},
isLoggedIn() {
return this.getUserData.id;
},
commentButtonTitle() {
return this.noteType === constants.COMMENT ? __('Comment') : __('Start thread');
},
startDiscussionDescription() {
return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
? __('Discuss a specific suggestion or question that needs to be resolved.')
: __('Discuss a specific suggestion or question.');
},
isOpen() {
return this.openState === constants.OPENED || this.openState === constants.REOPENED;
},
canCreateNote() {
return this.getNoteableData.current_user.can_create_note;
},
canSetConfidential() {
return this.getNoteableData.current_user.can_update;
},
issueActionButtonTitle() {
const openOrClose = this.isOpen ? 'close' : 'reopen';
if (this.note.length) {
return sprintf(__('%{actionText} & %{openOrClose} %{noteable}'), {
actionText: this.commentButtonTitle,
openOrClose,
noteable: this.noteableDisplayName,
});
}
return sprintf(__('%{openOrClose} %{noteable}'), {
openOrClose: capitalizeFirstCharacter(openOrClose),
noteable: this.noteableDisplayName,
});
},
buttonVariant() {
return this.isOpen ? 'warning' : 'default';
},
actionButtonClassNames() {
return {
'btn-reopen': !this.isOpen,
'btn-close': this.isOpen,
'js-note-target-close': this.isOpen,
'js-note-target-reopen': !this.isOpen,
};
},
markdownDocsPath() {
return this.getNotesData.markdownDocsPath;
},
quickActionsDocsPath() {
return this.getNotesData.quickActionsDocsPath;
},
markdownPreviewPath() {
return this.getNoteableData.preview_note_path;
},
author() {
return this.getUserData;
},
canToggleIssueState() {
return (
this.getNoteableData.current_user.can_update &&
this.getNoteableData.state !== constants.MERGED &&
!this.closedAndLocked
);
},
closedAndLocked() {
return !this.isOpen && this.isLocked(this.getNoteableData);
},
endpoint() {
return this.getNoteableData.create_note_path;
},
issuableTypeTitle() {
return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
? __('merge request')
: __('issue');
},
isIssue() {
return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE;
},
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
},
hasCloseAndCommentButton() {
return !this.glFeatures.removeCommentCloseReopen;
},
confidentialNotesEnabled() {
return Boolean(this.glFeatures.confidentialNotes);
},
disableSubmitButton() {
return this.note.length === 0 || this.isSubmitting;
},
},
mounted() {
// jQuery is needed here because it is a custom event being dispatched with jQuery.
$(document).on('issuable:change', (e, isClosed) => {
this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED);
});
this.initAutoSave();
},
methods: {
...mapActions([
'saveNote',
'stopPolling',
'restartPolling',
'removePlaceholderNotes',
'closeIssuable',
'reopenIssuable',
'toggleIssueLocalState',
]),
handleSave(withIssueAction) {
if (this.note.length) {
const noteData = {
endpoint: this.endpoint,
flashContainer: this.$el,
data: {
note: {
noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id,
confidential: this.noteIsConfidential,
note: this.note,
},
merge_request_diff_head_sha: this.getNoteableData.diff_head_sha,
},
};
if (this.noteType === constants.DISCUSSION) {
noteData.data.note.type = constants.DISCUSSION_NOTE;
}
this.note = ''; // Empty textarea while being requested. Repopulate in catch
this.resizeTextarea();
this.stopPolling();
this.isSubmitting = true;
this.saveNote(noteData)
.then(() => {
this.restartPolling();
this.discard();
if (withIssueAction) {
this.toggleIssueState();
}
})
.catch(() => {
this.discard(false);
const msg = __(
'Your comment could not be submitted! Please check your network connection and try again.',
);
Flash(msg, 'alert', this.$el);
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
})
.finally(() => {
this.isSubmitting = false;
});
} else {
this.toggleIssueState();
}
},
toggleIssueState() {
if (this.isIssue) {
// We want to invoke the close/reopen logic in the issue header
// since that is where the blocked-by issues modal logic is also defined
eventHub.$emit('toggle.issuable.state');
return;
}
const toggleState = this.isOpen ? this.closeIssuable : this.reopenIssuable;
toggleState()
.then(refreshUserMergeRequestCounts)
.catch(() => Flash(constants.toggleStateErrorMessage[this.noteableType][this.openState]));
},
discard(shouldClear = true) {
// `blur` is needed to clear slash commands autocomplete cache if event fired.
// `focus` is needed to remain cursor in the textarea.
this.$refs.textarea.blur();
this.$refs.textarea.focus();
if (shouldClear) {
this.note = '';
this.noteIsConfidential = false;
this.resizeTextarea();
this.$refs.markdownField.previewMarkdown = false;
}
this.autosave.reset();
},
setNoteType(type) {
this.noteType = type;
},
editCurrentUserLastNote() {
if (this.note === '') {
const lastNote = this.getCurrentUserLastNote;
if (lastNote) {
eventHub.$emit('enterEditMode', {
noteId: lastNote.id,
});
}
}
},
initAutoSave() {
if (this.isLoggedIn) {
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
this.autosave = new Autosave($(this.$refs.textarea), [
__('Note'),
noteableType,
this.getNoteableData.id,
]);
}
},
resizeTextarea() {
this.$nextTick(() => {
Autosize.update(this.$refs.textarea);
});
},
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
},
};
</script>
<template>
<div>
<note-signed-out-widget v-if="!isLoggedIn" />
<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-content timeline-content-form">
<form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form">
<comment-field-layout
:with-alert-container="true"
:noteable-data="getNoteableData"
:noteable-type="noteableType"
>
<markdown-field
ref="markdownField"
:is-submitting="isSubmitting"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
:textarea-value="note"
>
<template #textarea>
<textarea
id="note-body"
ref="textarea"
v-model="note"
dir="auto"
:disabled="isSubmitting"
name="note[note]"
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
data-qa-selector="comment_field"
data-testid="comment-field"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()"
@keydown.ctrl.enter="handleSave()"
></textarea>
</template>
</markdown-field>
</comment-field-layout>
<div class="note-form-actions">
<gl-form-checkbox
v-if="confidentialNotesEnabled && canSetConfidential"
v-model="noteIsConfidential"
class="gl-mb-6"
data-testid="confidential-note-checkbox"
>
{{ s__('Notes|Make this comment confidential') }}
<gl-icon
v-gl-tooltip:tooltipcontainer.bottom
name="question"
:size="16"
:title="s__('Notes|Confidential comments are only visible to project members')"
class="gl-text-gray-500"
/>
</gl-form-checkbox>
<div
class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<gl-button
:disabled="disableSubmitButton"
class="js-comment-button js-comment-submit-button"
data-qa-selector="comment_button"
data-testid="comment-button"
type="submit"
category="primary"
variant="success"
:data-track-label="trackingLabel"
data-track-event="click_button"
@click.prevent="handleSave()"
>{{ commentButtonTitle }}</gl-button
>
<gl-button
:disabled="disableSubmitButton"
name="button"
category="primary"
variant="success"
class="note-type-toggle js-note-new-discussion dropdown-toggle"
data-qa-selector="note_dropdown"
data-display="static"
data-toggle="dropdown"
icon="chevron-down"
:aria-label="__('Open comment type dropdown')"
/>
<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')"
>
<gl-icon name="check" class="icon gl-flex-shrink-0" />
<div class="description">
<strong>{{ __('Comment') }}</strong>
<p>
{{
sprintf(__('Add a general comment to this %{noteableDisplayName}.'), {
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"
data-qa-selector="discussion_menu_item"
@click.prevent="setNoteType('discussion')"
>
<gl-icon name="check" class="icon gl-flex-shrink-0" />
<div class="description">
<strong>{{ __('Start thread') }}</strong>
<p>{{ startDiscussionDescription }}</p>
</div>
</button>
</li>
</ul>
</div>
<gl-button
v-if="hasCloseAndCommentButton && canToggleIssueState"
:loading="isToggleStateButtonLoading"
category="secondary"
:variant="buttonVariant"
:class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']"
:disabled="isSubmitting"
data-testid="close-reopen-button"
@click="handleSave(true)"
>{{ issueActionButtonTitle }}</gl-button
>
</div>
</form>
</div>
</timeline-entry-item>
</ul>
</div>
</template>