summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/notes
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-09-19 23:18:09 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-09-19 23:18:09 +0000
commit6ed4ec3e0b1340f96b7c043ef51d1b33bbe85fde (patch)
treedc4d20fe6064752c0bd323187252c77e0a89144b /app/assets/javascripts/notes
parent9868dae7fc0655bd7ce4a6887d4e6d487690eeed (diff)
downloadgitlab-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')
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue18
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue8
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue93
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue109
-rw-r--r--app/assets/javascripts/notes/components/discussion_navigator.vue11
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue15
-rw-r--r--app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue49
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue18
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue12
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue26
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue25
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue18
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue36
-rw-r--r--app/assets/javascripts/notes/components/sidebar_subscription.vue2
-rw-r--r--app/assets/javascripts/notes/components/sort_discussion.vue76
-rw-r--r--app/assets/javascripts/notes/components/timeline_toggle.vue1
-rw-r--r--app/assets/javascripts/notes/constants.js2
-rw-r--r--app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql8
-rw-r--r--app/assets/javascripts/notes/index.js8
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js107
-rw-r--r--app/assets/javascripts/notes/sort_discussions.js17
-rw-r--r--app/assets/javascripts/notes/stores/actions.js55
-rw-r--r--app/assets/javascripts/notes/stores/getters.js7
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js3
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;
+ },
};