diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-09-19 23:18:09 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-09-19 23:18:09 +0000 |
commit | 6ed4ec3e0b1340f96b7c043ef51d1b33bbe85fde (patch) | |
tree | dc4d20fe6064752c0bd323187252c77e0a89144b /app/assets/javascripts/notes | |
parent | 9868dae7fc0655bd7ce4a6887d4e6d487690eeed (diff) | |
download | gitlab-ce-6ed4ec3e0b1340f96b7c043ef51d1b33bbe85fde.tar.gz |
Add latest changes from gitlab-org/gitlab@15-4-stable-eev15.4.0-rc42
Diffstat (limited to 'app/assets/javascripts/notes')
28 files changed, 486 insertions, 247 deletions
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index bd5945a951b..bf35d5c3b25 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -14,7 +14,7 @@ import { slugifyWithUnderscore, } from '~/lib/utils/text_utility'; import { sprintf } from '~/locale'; -import markdownField from '~/vue_shared/components/markdown/field.vue'; +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'; @@ -25,8 +25,8 @@ import { COMMENT_FORM } from '../i18n'; import issuableStateMixin from '../mixins/issuable_state'; import CommentFieldLayout from './comment_field_layout.vue'; import CommentTypeDropdown from './comment_type_dropdown.vue'; -import discussionLockedWidget from './discussion_locked_widget.vue'; -import noteSignedOutWidget from './note_signed_out_widget.vue'; +import DiscussionLockedWidget from './discussion_locked_widget.vue'; +import NoteSignedOutWidget from './note_signed_out_widget.vue'; const { UNPROCESSABLE_ENTITY } = httpStatusCodes; @@ -34,9 +34,9 @@ export default { name: 'CommentForm', i18n: COMMENT_FORM, components: { - noteSignedOutWidget, - discussionLockedWidget, - markdownField, + NoteSignedOutWidget, + DiscussionLockedWidget, + MarkdownField, GlAlert, GlButton, TimelineEntryItem, @@ -214,11 +214,7 @@ export default { note: { noteable_type: this.noteableType, noteable_id: this.getNoteableData.id, - // Internal notes were identified as `confidential` - // before we decided to treat them as _internal_ - // so now until API is updated we need to use `confidential` - // in request payload. - confidential: this.noteIsInternal, + internal: this.noteIsInternal, note: this.note, }, merge_request_diff_head_sha: this.getNoteableData.diff_head_sha, diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index 3cf47f42e0c..1b1923a90f7 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -4,16 +4,16 @@ import { escape } from 'lodash'; import { mapActions } from 'vuex'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; -import noteEditedText from './note_edited_text.vue'; -import noteHeader from './note_header.vue'; +import NoteEditedText from './note_edited_text.vue'; +import NoteHeader from './note_header.vue'; export default { name: 'DiffDiscussionHeader', components: { GlAvatar, GlAvatarLink, - noteEditedText, - noteHeader, + NoteEditedText, + NoteHeader, }, directives: { SafeHtml, diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 6f0745d4fb0..dcbf4a0e5d3 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -59,6 +59,7 @@ export default { <resolve-discussion-button v-if="discussion.resolvable" data-qa-selector="resolve_discussion_button" + data-testid="resolve-discussion-button" :is-resolving="isResolving" :button-title="resolveButtonTitle" @onClick="$emit('resolve')" diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index eedcb0c09d4..6521b86edbb 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,7 +1,16 @@ <script> -import { GlTooltipDirective, GlButton, GlButtonGroup } from '@gitlab/ui'; +import { + GlTooltipDirective, + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlIcon, +} from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; +import { throttle } from 'lodash'; import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import discussionNavigation from '../mixins/discussion_navigation'; export default { @@ -11,14 +20,23 @@ export default { components: { GlButton, GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlIcon, }, - mixins: [discussionNavigation], + mixins: [glFeatureFlagsMixin(), discussionNavigation], props: { blocksMerge: { type: Boolean, required: true, }, }, + data() { + return { + jumpNext: throttle(this.jumpToNextDiscussion, 500), + jumpPrevious: throttle(this.jumpToPreviousDiscussion, 500), + }; + }, computed: { ...mapGetters([ 'getNoteableData', @@ -54,27 +72,44 @@ export default { <template> <div v-if="resolvableDiscussionsCount > 0" + id="discussionCounter" ref="discussionCounter" class="gl-display-flex discussions-counter" > <div - class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-mr-3" + class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-mr-3 gl-min-h-7" :class="{ 'gl-bg-orange-50': blocksMerge && !allResolved, 'gl-bg-gray-50': !blocksMerge || allResolved, - 'gl-pr-4': allResolved, 'gl-pr-2': !allResolved, }" data-testid="discussions-counter-text" > <template v-if="allResolved"> {{ __('All threads resolved!') }} + <gl-dropdown + size="small" + category="tertiary" + right + toggle-class="btn-icon" + class="gl-pt-0! gl-px-2 gl-h-full gl-ml-2" + > + <template #button-content> + <gl-icon name="ellipsis_v" class="mr-0" /> + </template> + <gl-dropdown-item + data-testid="toggle-all-discussions-btn" + @click="handleExpandDiscussions" + > + {{ toggleThreadsLabel }} + </gl-dropdown-item> + </gl-dropdown> </template> <template v-else> {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} <gl-button-group class="gl-ml-3"> <gl-button - v-gl-tooltip.hover + v-gl-tooltip:discussionCounter.hover.bottom :title="__('Go to previous unresolved thread')" :aria-label="__('Go to previous unresolved thread')" class="discussion-previous-btn gl-rounded-base! gl-px-2!" @@ -83,10 +118,10 @@ export default { data-track-property="click_previous_unresolved_thread_top" icon="chevron-lg-up" category="tertiary" - @click="jumpToPreviousDiscussion" + @click="jumpPrevious" /> <gl-button - v-gl-tooltip.hover + v-gl-tooltip:discussionCounter.hover.bottom :title="__('Go to next unresolved thread')" :aria-label="__('Go to next unresolved thread')" class="discussion-next-btn gl-rounded-base! gl-px-2!" @@ -95,29 +130,33 @@ export default { data-track-property="click_next_unresolved_thread_top" icon="chevron-lg-down" category="tertiary" - @click="jumpToNextDiscussion" + @click="jumpNext" /> + <gl-dropdown + size="small" + category="tertiary" + right + toggle-class="btn-icon" + class="gl-pt-0! gl-px-2" + > + <template #button-content> + <gl-icon name="ellipsis_v" class="mr-0" /> + </template> + <gl-dropdown-item + data-testid="toggle-all-discussions-btn" + @click="handleExpandDiscussions" + > + {{ toggleThreadsLabel }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="resolveAllDiscussionsIssuePath && !allResolved" + :href="resolveAllDiscussionsIssuePath" + > + {{ __('Create issue to resolve all threads') }} + </gl-dropdown-item> + </gl-dropdown> </gl-button-group> </template> </div> - <gl-button-group> - <gl-button - v-gl-tooltip - :title="toggleThreadsLabel" - :aria-label="toggleThreadsLabel" - class="toggle-all-discussions-btn" - :icon="allExpanded ? 'collapse' : 'expand'" - @click="handleExpandDiscussions" - /> - <gl-button - v-if="resolveAllDiscussionsIssuePath && !allResolved" - v-gl-tooltip - :href="resolveAllDiscussionsIssuePath" - :title="__('Create issue to resolve all threads')" - :aria-label="__('Create issue to resolve all threads')" - class="new-issue-for-discussion discussion-create-issue-btn" - icon="issue-new" - /> - </gl-button-group> </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 15887c2738d..8a42fb6bd85 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -2,6 +2,9 @@ import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE, @@ -9,15 +12,25 @@ import { DISCUSSION_TAB_LABEL, DISCUSSION_FILTER_TYPES, NOTE_UNDERSCORE, + ASC, + DESC, } from '../constants'; import notesEventHub from '../event_hub'; +const SORT_OPTIONS = [ + { key: DESC, text: __('Newest first'), cls: 'js-newest-first' }, + { key: ASC, text: __('Oldest first'), cls: 'js-oldest-first' }, +]; + export default { + SORT_OPTIONS, components: { GlDropdown, GlDropdownItem, GlDropdownDivider, + LocalStorageSync, }, + mixins: [Tracking.mixin()], props: { filters: { type: Array, @@ -39,11 +52,24 @@ export default { }; }, computed: { - ...mapGetters(['getNotesDataByProp', 'timelineEnabled', 'isLoading']), + ...mapGetters([ + 'getNotesDataByProp', + 'timelineEnabled', + 'isLoading', + 'sortDirection', + 'persistSortOrder', + 'noteableType', + ]), currentFilter() { if (!this.currentValue) return this.filters[0]; return this.filters.find((filter) => filter.value === this.currentValue); }, + selectedSortOption() { + return SORT_OPTIONS.find(({ key }) => this.sortDirection === key); + }, + sortStorageKey() { + return `sort_direction_${this.noteableType.toLowerCase()}`; + }, }, created() { if (window.mrTabs) { @@ -69,6 +95,7 @@ export default { 'setCommentsDisabled', 'setTargetNoteHash', 'setTimelineView', + 'setDiscussionSortDirection', ]), selectFilter(value, persistFilter = true) { const filter = parseInt(value, 10); @@ -108,31 +135,73 @@ export default { } return DISCUSSION_FILTER_TYPES.HISTORY; }, + fetchSortedDiscussions(direction) { + if (this.isSortDropdownItemActive(direction)) { + return; + } + + this.setDiscussionSortDirection({ direction }); + this.track('change_discussion_sort_direction', { property: direction }); + }, + isSortDropdownItemActive(sortDir) { + return sortDir === this.sortDirection; + }, }, }; </script> <template> - <gl-dropdown + <div v-if="displayFilters" - id="discussion-filter-dropdown" - class="full-width-mobile discussion-filter-container js-discussion-filter-container" - data-qa-selector="discussion_filter_dropdown" - :text="currentFilter.title" - :disabled="isLoading" + id="discussion-preferences" + data-testid="discussion-preferences" + class="gl-display-inline-block gl-vertical-align-bottom full-width-mobile" > - <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper"> - <gl-dropdown-item - :is-check-item="true" - :is-checked="filter.value === currentValue" - :class="{ 'is-active': filter.value === currentValue }" - :data-filter-type="filterType(filter.value)" - data-qa-selector="filter_menu_item" - @click.prevent="selectFilter(filter.value)" + <local-storage-sync + :value="sortDirection" + :storage-key="sortStorageKey" + :persist="persistSortOrder" + as-string + @input="setDiscussionSortDirection({ direction: $event })" + /> + <gl-dropdown + id="discussion-preferences-dropdown" + class="full-width-mobile" + data-qa-selector="discussion_preferences_dropdown" + text="Sort or filter" + :disabled="isLoading" + right + > + <div id="discussion-sort"> + <gl-dropdown-item + v-for="{ text, key, cls } in $options.SORT_OPTIONS" + :key="text" + :class="cls" + is-check-item + :is-checked="isSortDropdownItemActive(key)" + @click="fetchSortedDiscussions(key)" + > + {{ text }} + </gl-dropdown-item> + </div> + <gl-dropdown-divider /> + <div + id="discussion-filter" + class="discussion-filter-container js-discussion-filter-container" > - {{ filter.title }} - </gl-dropdown-item> - <gl-dropdown-divider v-if="filter.value === defaultValue" /> - </div> - </gl-dropdown> + <gl-dropdown-item + v-for="filter in filters" + :key="filter.value" + is-check-item + :is-checked="filter.value === currentValue" + :class="{ 'is-active': filter.value === currentValue }" + :data-filter-type="filterType(filter.value)" + data-qa-selector="filter_menu_item" + @click.prevent="selectFilter(filter.value)" + > + {{ filter.title }} + </gl-dropdown-item> + </div> + </gl-dropdown> + </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue index c1e39f31bbb..03bdc7a2cc6 100644 --- a/app/assets/javascripts/notes/components/discussion_navigator.vue +++ b/app/assets/javascripts/notes/components/discussion_navigator.vue @@ -1,6 +1,7 @@ <script> /* global Mousetrap */ import 'mousetrap'; +import { throttle } from 'lodash'; import { keysFor, MR_NEXT_UNRESOLVED_DISCUSSION, @@ -11,12 +12,18 @@ import discussionNavigation from '~/notes/mixins/discussion_navigation'; export default { mixins: [discussionNavigation], + data() { + return { + jumpToNext: throttle(() => this.jumpToNextDiscussion({ behavior: 'auto' }), 200), + jumpToPrevious: throttle(() => this.jumpToPreviousDiscussion({ behavior: 'auto' }), 200), + }; + }, created() { eventHub.$on('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion); }, mounted() { - Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNextDiscussion); - Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPreviousDiscussion); + Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNext); + Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPrevious); }, beforeDestroy() { Mousetrap.unbind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION)); diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index c7f293a219a..9806f8e5dc2 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui'; -import { mapActions, mapGetters } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import Api from '~/api'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; import createFlash from '~/flash'; @@ -11,6 +11,7 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge. import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { splitCamelCase } from '~/lib/utils/text_utility'; import ReplyButton from './note_actions/reply_button.vue'; +import TimelineEventButton from './note_actions/timeline_event_button.vue'; export default { i18n: { @@ -23,6 +24,7 @@ export default { components: { GlIcon, ReplyButton, + TimelineEventButton, GlButton, GlDropdownItem, UserAccessRoleBadge, @@ -133,7 +135,8 @@ export default { }, }, computed: { - ...mapGetters(['getUserDataByProp', 'getNoteableData']), + ...mapState(['isPromoteCommentToTimelineEventInProgress']), + ...mapGetters(['getUserDataByProp', 'getNoteableData', 'canUserAddIncidentTimelineEvents']), shouldShowActionsDropdown() { return this.currentUserId && (this.canEdit || this.canReportAsAbuse); }, @@ -199,7 +202,7 @@ export default { }, }, methods: { - ...mapActions(['toggleAwardRequest']), + ...mapActions(['toggleAwardRequest', 'promoteCommentToTimelineEvent']), onEdit() { this.$emit('handleEdit'); }, @@ -292,6 +295,12 @@ export default { class="line-resolve-btn note-action-button" @click="onResolve" /> + <timeline-event-button + v-if="canUserAddIncidentTimelineEvents" + :note-id="noteId" + :is-promotion-in-progress="isPromoteCommentToTimelineEventInProgress" + @click-promote-comment-to-event="promoteCommentToTimelineEvent" + /> <emoji-picker v-if="canAwardEmoji" toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary" diff --git a/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue b/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue new file mode 100644 index 00000000000..4dd0c968282 --- /dev/null +++ b/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue @@ -0,0 +1,49 @@ +<script> +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + i18n: { + buttonText: __('Add comment to incident timeline'), + addError: __('Error promoting the note to timeline event: %{error}'), + addGenericError: __('Something went wrong while promoting the note to timeline event.'), + }, + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + noteId: { + type: [String, Number], + required: true, + }, + isPromotionInProgress: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + handleButtonClick() { + this.$emit('click-promote-comment-to-event', { + noteId: this.noteId, + addError: this.$options.i18n.addError, + addGenericError: this.$options.i18n.addGenericError, + }); + }, + }, +}; +</script> +<template> + <span v-gl-tooltip :title="$options.i18n.buttonText"> + <gl-button + category="tertiary" + icon="clock" + :aria-label="$options.i18n.buttonText" + :disabled="isPromotionInProgress" + @click="handleButtonClick" + /> + </span> +</template> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index f1c41eea428..82c125b79ce 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -8,17 +8,17 @@ import { __ } from '~/locale'; import '~/behaviors/markdown/render_gfm'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import autosave from '../mixins/autosave'; -import noteAttachment from './note_attachment.vue'; -import noteAwardsList from './note_awards_list.vue'; -import noteEditedText from './note_edited_text.vue'; -import noteForm from './note_form.vue'; +import NoteAttachment from './note_attachment.vue'; +import NoteAwardsList from './note_awards_list.vue'; +import NoteEditedText from './note_edited_text.vue'; +import NoteForm from './note_form.vue'; export default { components: { - noteEditedText, - noteAwardsList, - noteAttachment, - noteForm, + NoteEditedText, + NoteAwardsList, + NoteAttachment, + NoteForm, Suggestions, }, directives: { @@ -71,7 +71,7 @@ export default { return this.note.note; }, saveButtonTitle() { - return this.note.confidential ? __('Save internal note') : __('Save comment'); + return this.note.internal ? __('Save internal note') : __('Save comment'); }, hasSuggestion() { return this.note.suggestions && this.note.suggestions.length; diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index 03cbdf45ddd..e0c3ed0c67a 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -1,11 +1,11 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ -import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { name: 'EditedNoteText', components: { - timeAgoTooltip, + TimeAgoTooltip, }, props: { actionText: { diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 30579a8eb0d..b6ede10d02b 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -4,7 +4,7 @@ import { mapGetters, mapActions, mapState } from 'vuex'; import { getDraft, updateDraft } from '~/lib/utils/autosave'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import markdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import eventHub from '../event_hub'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; @@ -15,7 +15,7 @@ export default { i18n: COMMENT_FORM, name: 'NoteForm', components: { - markdownField, + MarkdownField, CommentFieldLayout, GlButton, GlSprintf, @@ -136,7 +136,7 @@ export default { ); }, textareaPlaceholder() { - return this.discussionNote?.confidential + return this.discussionNote?.internal ? this.$options.i18n.bodyPlaceholderInternal : this.$options.i18n.bodyPlaceholder; }, @@ -331,7 +331,7 @@ export default { <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> <comment-field-layout :noteable-data="getNoteableData" - :is-internal-note="discussion.confidential" + :is-internal-note="discussion.internal" > <markdown-field :markdown-preview-path="markdownPreviewPath" @@ -423,7 +423,7 @@ export default { category="primary" variant="confirm" data-qa-selector="reply_comment_button" - class="gl-mr-3 js-vue-issue-save js-comment-button" + class="gl-sm-mr-3 gl-xs-mb-3 js-vue-issue-save js-comment-button" @click="handleUpdate()" > {{ saveButtonTitle }} @@ -432,7 +432,7 @@ export default { v-if="discussion.resolvable" category="secondary" variant="default" - class="gl-mr-3 js-comment-resolve-button" + class="gl-sm-mr-3 gl-xs-mb-3 js-comment-resolve-button" @click.prevent="handleUpdate(true)" > {{ resolveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 9917249f0db..f700802d6bc 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -8,13 +8,14 @@ import { } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __, s__ } from '~/locale'; -import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, components: { - timeAgoTooltip, + TimeAgoTooltip, GitlabTeamMemberBadge: () => import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'), GlIcon, @@ -26,6 +27,7 @@ export default { SafeHtml, GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { author: { type: Object, @@ -183,22 +185,35 @@ export default { :data-user-id="author.id" :data-username="author.username" > - <slot name="note-header-info"></slot> + <span + v-if="glFeatures.removeUserAttributesProjects || glFeatures.removeUserAttributesGroups" + class="note-header-author-name gl-font-weight-bold" + > + {{ authorName }} + </span> <user-name-with-status + v-else :name="authorName" :availability="userAvailability(author)" container-classes="note-header-author-name gl-font-weight-bold" /> </a> <span - v-if="authorStatus" + v-if=" + authorStatus && + !glFeatures.removeUserAttributesProjects && + !glFeatures.removeUserAttributesGroups + " ref="authorStatus" v-safe-html:[$options.safeHtmlConfig]="authorStatus" v-on=" authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {} " ></span> - <span class="text-nowrap author-username"> + <span + v-if="!glFeatures.removeUserAttributesProjects && !glFeatures.removeUserAttributesGroups" + class="text-nowrap author-username" + > <a ref="authorUsernameLink" class="author-username-link" @@ -207,6 +222,7 @@ export default { @mouseleave="handleUsernameMouseLeave" ><span class="note-headline-light">@{{ author.username }}</span> </a> + <slot name="note-header-info"></slot> <gitlab-team-member-badge v-if="author && author.is_gitlab_employee" /> </span> </template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index c5d174ed890..afa5e39d8b0 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -10,25 +10,25 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import { s__, __, sprintf } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +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'; -import diffDiscussionHeader from './diff_discussion_header.vue'; -import diffWithNote from './diff_with_note.vue'; +import DiffDiscussionHeader from './diff_discussion_header.vue'; +import DiffWithNote from './diff_with_note.vue'; import DiscussionActions from './discussion_actions.vue'; import DiscussionNotes from './discussion_notes.vue'; -import noteForm from './note_form.vue'; -import noteSignedOutWidget from './note_signed_out_widget.vue'; +import NoteForm from './note_form.vue'; +import NoteSignedOutWidget from './note_signed_out_widget.vue'; export default { name: 'NoteableDiscussion', components: { GlIcon, - userAvatarLink, - diffDiscussionHeader, - noteSignedOutWidget, - noteForm, + UserAvatarLink, + DiffDiscussionHeader, + NoteSignedOutWidget, + NoteForm, DraftNote, TimelineEntryItem, DiscussionNotes, @@ -96,7 +96,7 @@ export default { return isLoggedIn(); }, commentType() { - return this.discussion.confidential ? __('internal note') : __('comment'); + return this.discussion.internal ? __('internal note') : __('comment'); }, autosaveKey() { return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); @@ -108,7 +108,7 @@ export default { return this.discussion.notes.slice(0, 1)[0]; }, saveButtonTitle() { - return this.discussion.confidential ? __('Reply internally') : __('Reply'); + return this.discussion.internal ? __('Reply internally') : __('Reply'); }, shouldShowJumpToNextDiscussion() { return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion'); @@ -120,7 +120,7 @@ export default { return !this.shouldRenderDiffs; }, wrapperComponent() { - return this.shouldRenderDiffs ? diffWithNote : 'div'; + return this.shouldRenderDiffs ? DiffWithNote : 'div'; }, wrapperComponentProps() { if (this.shouldRenderDiffs) { @@ -269,6 +269,7 @@ export default { <div class="timeline-content"> <div :data-discussion-id="discussion.id" + :data-discussion-resolved="discussion.resolved" class="discussion js-discussion-container" data-qa-selector="discussion_content" > diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 875cfff74fe..e51969f95c7 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -22,16 +22,16 @@ import { commentLineOptions, formatLineRange, } from './multiline_comment_utils'; -import noteActions from './note_actions.vue'; +import NoteActions from './note_actions.vue'; import NoteBody from './note_body.vue'; -import noteHeader from './note_header.vue'; +import NoteHeader from './note_header.vue'; export default { name: 'NoteableNote', components: { GlSprintf, - noteHeader, - noteActions, + NoteHeader, + NoteActions, NoteBody, TimelineEntryItem, GlAvatarLink, @@ -109,7 +109,7 @@ export default { return this.note.author; }, commentType() { - return this.note.confidential ? __('internal note') : __('comment'); + return this.note.internal ? __('internal note') : __('comment'); }, classNameBindings() { return { @@ -259,7 +259,7 @@ export default { }); const confirmed = await confirmAction(msg, { primaryBtnVariant: 'danger', - primaryBtnText: this.note.confidential ? __('Delete internal note') : __('Delete comment'), + primaryBtnText: this.note.internal ? __('Delete internal note') : __('Delete comment'), }); if (confirmed) { @@ -406,7 +406,7 @@ export default { <template> <timeline-entry-item :id="noteAnchorId" - :class="{ ...classNameBindings, 'internal-note': note.confidential }" + :class="{ ...classNameBindings, 'internal-note': note.internal }" :data-award-url="note.toggle_award_path" :data-note-id="note.id" class="note note-wrapper" @@ -440,7 +440,7 @@ export default { </gl-avatar-link> </div> - <div v-else class="gl-float-left gl-pl-3 gl-mr-3 gl-md-pl-2 gl-md-pr-2"> + <div v-else class="gl-float-left gl-pl-3 gl-md-pl-2"> <gl-avatar-link :href="author.path"> <gl-avatar :src="author.avatar_url" @@ -459,7 +459,7 @@ export default { :author="author" :created-at="note.created_at" :note-id="note.id" - :is-internal-note="note.confidential" + :is-internal-note="note.internal" :noteable-type="noteableType" > <template #note-header-info> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 754c2917182..37bc8bad305 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -6,34 +6,34 @@ import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import draftNote from '~/batch_comments/components/draft_note.vue'; +import DraftNote from '~/batch_comments/components/draft_note.vue'; import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility'; -import placeholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; -import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; -import skeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue'; -import systemNote from '~/vue_shared/components/notes/system_note.vue'; +import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; +import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; +import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue'; +import SystemNote from '~/vue_shared/components/notes/system_note.vue'; import * as constants from '../constants'; import eventHub from '../event_hub'; -import commentForm from './comment_form.vue'; -import discussionFilterNote from './discussion_filter_note.vue'; -import noteableDiscussion from './noteable_discussion.vue'; -import noteableNote from './noteable_note.vue'; +import CommentForm from './comment_form.vue'; +import DiscussionFilterNote from './discussion_filter_note.vue'; +import NoteableDiscussion from './noteable_discussion.vue'; +import NoteableNote from './noteable_note.vue'; import SidebarSubscription from './sidebar_subscription.vue'; export default { name: 'NotesApp', components: { - noteableNote, - noteableDiscussion, - systemNote, - commentForm, - placeholderNote, - placeholderSystemNote, - skeletonLoadingContainer, - discussionFilterNote, + NoteableNote, + NoteableDiscussion, + SystemNote, + CommentForm, + PlaceholderNote, + PlaceholderSystemNote, + SkeletonLoadingContainer, + DiscussionFilterNote, OrderedLayout, SidebarSubscription, - draftNote, + DraftNote, TimelineEntryItem, }, mixins: [glFeatureFlagsMixin()], diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue index 52dadc7b4c3..9fc11ff65d5 100644 --- a/app/assets/javascripts/notes/components/sidebar_subscription.vue +++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue @@ -3,7 +3,7 @@ import { mapActions } from 'vuex'; import { IssuableType } from '~/issues/constants'; import { fetchPolicies } from '~/lib/graphql'; import { confidentialityQueries } from '~/sidebar/constants'; -import { defaultClient as gqlClient } from '~/sidebar/graphql'; +import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client'; export default { props: { diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue deleted file mode 100644 index bcc5d12b7c8..00000000000 --- a/app/assets/javascripts/notes/components/sort_discussion.vue +++ /dev/null @@ -1,76 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { mapActions, mapGetters } from 'vuex'; -import { __ } from '~/locale'; -import Tracking from '~/tracking'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { ASC, DESC } from '../constants'; - -const SORT_OPTIONS = [ - { key: DESC, text: __('Newest first'), cls: 'js-newest-first' }, - { key: ASC, text: __('Oldest first'), cls: 'js-oldest-first' }, -]; - -export default { - SORT_OPTIONS, - components: { - GlDropdown, - GlDropdownItem, - LocalStorageSync, - }, - mixins: [Tracking.mixin()], - computed: { - ...mapGetters(['sortDirection', 'persistSortOrder', 'noteableType']), - selectedOption() { - return SORT_OPTIONS.find(({ key }) => this.sortDirection === key); - }, - dropdownText() { - return this.selectedOption.text; - }, - storageKey() { - return `sort_direction_${this.noteableType.toLowerCase()}`; - }, - }, - methods: { - ...mapActions(['setDiscussionSortDirection']), - fetchSortedDiscussions(direction) { - if (this.isDropdownItemActive(direction)) { - return; - } - - this.setDiscussionSortDirection({ direction }); - this.track('change_discussion_sort_direction', { property: direction }); - }, - isDropdownItemActive(sortDir) { - return sortDir === this.sortDirection; - }, - }, -}; -</script> - -<template> - <div - data-testid="sort-discussion-filter" - class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile" - > - <local-storage-sync - :value="sortDirection" - :storage-key="storageKey" - :persist="persistSortOrder" - as-string - @input="setDiscussionSortDirection({ direction: $event })" - /> - <gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile"> - <gl-dropdown-item - v-for="{ text, key, cls } in $options.SORT_OPTIONS" - :key="key" - :class="cls" - :is-check-item="true" - :is-checked="isDropdownItemActive(key)" - @click="fetchSortedDiscussions(key)" - > - {{ text }} - </gl-dropdown-item> - </gl-dropdown> - </div> -</template> diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue index e4d89f54652..8632eea5d8e 100644 --- a/app/assets/javascripts/notes/components/timeline_toggle.vue +++ b/app/assets/javascripts/notes/components/timeline_toggle.vue @@ -53,7 +53,6 @@ export default { :selected="timelineEnabled" :title="tooltip" :aria-label="tooltip" - class="gl-mr-3" @click="toggleTimeline" /> </template> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index a5f459c8910..88f438975f6 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -13,6 +13,7 @@ export const MERGED = 'merged'; export const ISSUE_NOTEABLE_TYPE = 'Issue'; export const EPIC_NOTEABLE_TYPE = 'Epic'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest'; +export const INCIDENT_NOTEABLE_TYPE = 'INCIDENT'; // TODO: check if value can be converted to `Incident` export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; export const DESCRIPTION_TYPE = 'changed the description'; @@ -31,6 +32,7 @@ export const NOTEABLE_TYPE_MAPPING = { Issue: ISSUE_NOTEABLE_TYPE, MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE, Epic: EPIC_NOTEABLE_TYPE, + Incident: INCIDENT_NOTEABLE_TYPE, }; export const DISCUSSION_FILTER_TYPES = { diff --git a/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql b/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql new file mode 100644 index 00000000000..c9df9cfd6d3 --- /dev/null +++ b/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql @@ -0,0 +1,8 @@ +mutation PromoteTimelineEvent($input: TimelineEventPromoteFromNoteInput!) { + timelineEventPromoteFromNote(input: $input) { + timelineEvent { + id + } + errors + } +} diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 19fa484d659..054a5bd36e2 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import notesApp from './components/notes_app.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import NotesApp from './components/notes_app.vue'; import initDiscussionFilters from './discussion_filters'; -import initSortDiscussions from './sort_discussions'; import { store } from './stores'; import initTimelineToggle from './timeline'; @@ -16,7 +16,7 @@ export default () => { el, name: 'NotesRoot', components: { - notesApp, + NotesApp, }, store, data() { @@ -40,6 +40,7 @@ export default () => { username: parsedUserData.username, avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, path: parsedUserData.path, + can_add_timeline_events: parseBoolean(notesDataset.canAddTimelineEvents), }; } @@ -61,6 +62,5 @@ export default () => { }); initDiscussionFilters(store); - initSortDiscussions(store); initTimelineToggle(store); }; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 45df91796fc..db5f9ebf3f0 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,5 +1,5 @@ import { mapGetters, mapActions, mapState } from 'vuex'; -import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils'; +import { scrollToElementWithContext, scrollToElement, contentTop } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; @@ -7,13 +7,14 @@ import eventHub from '../event_hub'; * @param {string} selector * @returns {boolean} */ -function scrollTo(selector, { withoutContext = false } = {}) { +function scrollTo(selector, { withoutContext = false, offset = 0 } = {}) { const el = document.querySelector(selector); const scrollFunction = withoutContext ? scrollToElement : scrollToElementWithContext; if (el) { scrollFunction(el, { behavior: 'auto', + offset, }); return true; } @@ -67,7 +68,10 @@ function diffsJump({ expandDiscussion }, id, firstNoteId) { function discussionJump({ expandDiscussion }, id) { const selector = `div.discussion[data-discussion-id="${id}"]`; expandDiscussion({ discussionId: id }); - return scrollTo(selector, { withoutContext: true }); + return scrollTo(selector, { + withoutContext: true, + offset: window.gon?.features?.movedMrSidebar ? -28 : 0, + }); } /** @@ -94,8 +98,6 @@ function jumpToDiscussion(self, discussion) { if (activeTab === 'diffs' && isDiffDiscussion) { diffsJump(self, id, firstNoteId); - } else if (activeTab === 'show') { - discussionJump(self, id); } else { switchToDiscussionsTabAndJumpTo(self, id); } @@ -105,11 +107,10 @@ function jumpToDiscussion(self, discussion) { /** * @param {object} self Component instance with mixin applied * @param {function} fn Which function used to get the target discussion's id - * @param {string} [discussionId=this.currentDiscussionId] Current discussion id, will be null if discussions have not been traversed yet */ -function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) { +function handleDiscussionJump(self, fn) { const isDiffView = window.mrTabs.currentAction === 'diffs'; - const targetId = fn(discussionId, isDiffView); + const targetId = fn(self.currentDiscussionId, isDiffView); const discussion = self.getDiscussion(targetId); const discussionFilePath = discussion?.diff_file?.file_path; @@ -127,6 +128,70 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) }); } +function getAllDiscussionElements() { + return Array.from( + document.querySelectorAll('[data-discussion-id]:not([data-discussion-resolved])'), + ); +} + +function hasReachedPageEnd() { + return document.body.scrollHeight <= Math.ceil(window.scrollY + window.innerHeight); +} + +function findNextClosestVisibleDiscussion(discussionElements) { + const offsetHeight = contentTop(); + let isActive; + const index = discussionElements.findIndex((element) => { + const { y } = element.getBoundingClientRect(); + const visibleHorizontalOffset = Math.ceil(y) - offsetHeight; + // handle rect rounding errors + isActive = visibleHorizontalOffset < 2; + return visibleHorizontalOffset >= 0; + }); + return [discussionElements[index], index, isActive]; +} + +function getNextDiscussion() { + const discussionElements = getAllDiscussionElements(); + const firstDiscussion = discussionElements[0]; + if (hasReachedPageEnd()) { + return firstDiscussion; + } + const [nextClosestDiscussion, index, isActive] = findNextClosestVisibleDiscussion( + discussionElements, + ); + if (nextClosestDiscussion && !isActive) { + return nextClosestDiscussion; + } + const nextDiscussion = discussionElements[index + 1]; + if (!nextClosestDiscussion || !nextDiscussion) { + return firstDiscussion; + } + return nextDiscussion; +} + +function getPreviousDiscussion() { + const discussionElements = getAllDiscussionElements(); + const lastDiscussion = discussionElements[discussionElements.length - 1]; + const [, index] = findNextClosestVisibleDiscussion(discussionElements); + const previousDiscussion = discussionElements[index - 1]; + if (previousDiscussion) { + return previousDiscussion; + } + return lastDiscussion; +} + +function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) { + if (window.mrTabs.currentAction !== 'show') { + handleDiscussionJump(ctx, fn); + } else { + const discussion = getDiscussion(); + const id = discussion.dataset.discussionId; + ctx.expandDiscussion({ discussionId: id }); + scrollToElement(discussion, scrollOptions); + } +} + export default { computed: { ...mapGetters([ @@ -142,12 +207,22 @@ export default { ...mapActions(['expandDiscussion', 'setCurrentDiscussionId']), ...mapActions('diffs', ['scrollToFile']), - jumpToNextDiscussion() { - handleDiscussionJump(this, this.nextUnresolvedDiscussionId); + jumpToNextDiscussion(scrollOptions) { + handleJumpForBothPages( + getNextDiscussion, + this, + this.nextUnresolvedDiscussionId, + scrollOptions, + ); }, - jumpToPreviousDiscussion() { - handleDiscussionJump(this, this.previousUnresolvedDiscussionId); + jumpToPreviousDiscussion(scrollOptions) { + handleJumpForBothPages( + getPreviousDiscussion, + this, + this.previousUnresolvedDiscussionId, + scrollOptions, + ); }, jumpToFirstUnresolvedDiscussion() { @@ -157,13 +232,5 @@ export default { }) .catch(() => {}); }, - - /** - * Go to the next discussion from the given discussionId - * @param {String} discussionId The id we are jumping from - */ - jumpToNextRelativeDiscussion(discussionId) { - handleDiscussionJump(this, this.nextUnresolvedDiscussionId, discussionId); - }, }, }; diff --git a/app/assets/javascripts/notes/sort_discussions.js b/app/assets/javascripts/notes/sort_discussions.js deleted file mode 100644 index ca8df880fe4..00000000000 --- a/app/assets/javascripts/notes/sort_discussions.js +++ /dev/null @@ -1,17 +0,0 @@ -import Vue from 'vue'; -import SortDiscussion from './components/sort_discussion.vue'; - -export default (store) => { - const el = document.getElementById('js-vue-sort-issue-discussions'); - - if (!el) return null; - - return new Vue({ - el, - name: 'SortDiscussionRoot', - store, - render(createElement) { - return createElement(SortDiscussion); - }, - }); -}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 82417c9134b..fcef26d720c 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -6,6 +6,7 @@ import createFlash from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; +import toast from '~/vue_shared/plugins/global_toast'; import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; @@ -18,6 +19,12 @@ import sidebarTimeTrackingEventHub from '~/sidebar/event_hub'; import TaskList from '~/task_list'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import SidebarStore from '~/sidebar/stores/sidebar_store'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_NOTE } from '~/graphql_shared/constants'; +import notesEventHub from '../event_hub'; + +import promoteTimelineEvent from '../graphql/promote_timeline_event.mutation.graphql'; + import * as constants from '../constants'; import * as types from './mutation_types'; import * as utils from './utils'; @@ -226,6 +233,54 @@ export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes) }); }; +export const promoteCommentToTimelineEvent = ( + { commit }, + { noteId, addError, addGenericError }, +) => { + commit(types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, true); // Set loading state + return utils.gqClient + .mutate({ + mutation: promoteTimelineEvent, + variables: { + input: { + noteId: convertToGraphQLId(TYPE_NOTE, noteId), + }, + }, + }) + .then(({ data = {} }) => { + const errors = data.timelineEventPromoteFromNote?.errors; + if (errors.length) { + const errorMessage = sprintf(addError, { + error: errors.join('. '), + }); + throw new Error(errorMessage); + } else { + notesEventHub.$emit('comment-promoted-to-timeline-event'); + toast(__('Comment added to the timeline.')); + } + }) + .catch((error) => { + const message = error.message || addGenericError; + + let captureError = false; + let errorObj = null; + + if (message === addGenericError) { + captureError = true; + errorObj = error; + } + + createFlash({ + message, + captureError, + error: errorObj, + }); + }) + .finally(() => { + commit(types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, false); // Revert loading state + }); +}; + export const replyToDiscussion = ( { commit, state, getters, dispatch }, { endpoint, data: reply }, diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 1fe82d96435..6876220f75c 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -93,6 +93,13 @@ export const getUserDataByProp = (state) => (prop) => state.userData && state.us export const descriptionVersions = (state) => state.descriptionVersions; +export const canUserAddIncidentTimelineEvents = (state) => { + return ( + state.userData.can_add_timeline_events && + state.noteableData.type === constants.NOTEABLE_TYPE_MAPPING.Incident + ); +}; + export const notesById = (state) => state.discussions.reduce((acc, note) => { note.notes.every((n) => Object.assign(acc, { [n.id]: n })); diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index f779aad5679..7ba1f470b05 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -30,6 +30,7 @@ export default () => ({ isNotesFetched: false, isLoading: true, isLoadingDescriptionVersion: false, + isPromoteCommentToTimelineEventInProgress: false, // holds endpoints and permissions provided through haml notesData: { diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index e28a7bc5cdd..42df6bc0980 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -57,3 +57,6 @@ export const RECEIVE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DESCRIPTION_VERSION_ER export const REQUEST_DELETE_DESCRIPTION_VERSION = 'REQUEST_DELETE_DESCRIPTION_VERSION'; export const RECEIVE_DELETE_DESCRIPTION_VERSION = 'RECEIVE_DELETE_DESCRIPTION_VERSION'; export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR'; + +// Incidents +export const SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS = 'SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 0823eacf1b7..83c15c12eac 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -425,4 +425,7 @@ export default { [types.SET_DONE_FETCHING_BATCH_DISCUSSIONS](state, value) { state.doneFetchingBatchDiscussions = value; }, + [types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS](state, value) { + state.isPromoteCommentToTimelineEventInProgress = value; + }, }; |