Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-02-16 09:10:11 +00:00
parent 0e0890828e
commit b1a0a71628
45 changed files with 16659 additions and 15985 deletions

15681
CHANGELOG.md

File diff suppressed because it is too large Load Diff

View File

@ -191,7 +191,7 @@ gem 'typhoeus', '~> 1.4.0' # Used with Elasticsearch to support http keep-alive
# Markdown and HTML processing
gem 'html-pipeline', '~> 2.14.3'
gem 'deckar01-task_list', '2.3.2'
gem 'gitlab-markup', '~> 1.8.0', require: 'github/markup'
gem 'gitlab-markup', '~> 1.9.0', require: 'github/markup'
gem 'commonmarker', '~> 0.23.6'
gem 'kramdown', '~> 2.3.1'
gem 'RedCloth', '~> 4.3.2'

View File

@ -206,7 +206,7 @@
{"name":"gitlab-labkit","version":"0.30.1","platform":"ruby","checksum":"bdedbd86014c83dfd6a50d20dbc1709697bba2bb9e3666383e5f28cbd312b113"},
{"name":"gitlab-license","version":"2.2.1","platform":"ruby","checksum":"39fcf6be8b2887df8afe01b5dcbae8d08b7c5d937ff56b0fb40484a8c4f02d30"},
{"name":"gitlab-mail_room","version":"0.0.9","platform":"ruby","checksum":"6700374b5c0aa9d9ad4e711aeb677f0b7d415a6d01d3baa699efab25349d851c"},
{"name":"gitlab-markup","version":"1.8.1","platform":"ruby","checksum":"ab1f9fd016977497c2af25b76341dea670533014f406861834a0bd99f646707b"},
{"name":"gitlab-markup","version":"1.9.0","platform":"ruby","checksum":"7eda045a08ec2d110084252fa13a8c9eac8bdac0e302035ca7db4b82bcbd7ed4"},
{"name":"gitlab-net-dns","version":"0.9.1","platform":"ruby","checksum":"bcd1a08dcb31b731e8ff602d828de619d2d9f53f5812f6abacf11c720873d4cb"},
{"name":"gitlab-sidekiq-fetcher","version":"0.9.0","platform":"ruby","checksum":"54041aec059f20c8e6dfce394e1b60e0c0a9c7cef32da912a58abbd333e13897"},
{"name":"gitlab-styles","version":"10.0.0","platform":"ruby","checksum":"8a1b20f7b5f351605ff4ed4ec648ef37226f2774d1e1377ed99389448d6913f0"},

View File

@ -594,7 +594,7 @@ GEM
redis (> 3.0.0, < 6.0.0)
gitlab-license (2.2.1)
gitlab-mail_room (0.0.9)
gitlab-markup (1.8.1)
gitlab-markup (1.9.0)
gitlab-net-dns (0.9.1)
gitlab-sidekiq-fetcher (0.9.0)
json (>= 2.5)
@ -1682,7 +1682,7 @@ DEPENDENCIES
gitlab-labkit (~> 0.30.1)
gitlab-license (~> 2.2.1)
gitlab-mail_room (~> 0.0.9)
gitlab-markup (~> 1.8.0)
gitlab-markup (~> 1.9.0)
gitlab-net-dns (~> 0.9.1)
gitlab-sidekiq-fetcher (= 0.9.0)
gitlab-styles (~> 10.0.0)

View File

@ -6,7 +6,7 @@ verify_ssl = true
[dev-packages]
[packages]
docutils = "==0.13.1"
docutils = "==0.19"
[requires]
python_version = "3.4"
python_version = "3.9.6"

11
Pipfile.lock generated
View File

@ -1,11 +1,11 @@
{
"_meta": {
"hash": {
"sha256": "ec82d5e7c10fd591aeebbc9b7b62d730f7fd70dc52e4e4818834891aa4194c73"
"sha256": "dccfc5b143b954e7fe0acb14d452f4f8e4eef9d611040d77d104b28f3d1b7b55"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.4"
"python_version": "3.9.6"
},
"sources": [
{
@ -18,12 +18,11 @@
"default": {
"docutils": {
"hashes": [
"sha256:718c0f5fb677be0f34b781e04241c4067cbd9327b66bdd8e763201130f5175be",
"sha256:cb3ebcb09242804f84bdbf0b26504077a054da6772c6f4d625f335cc53ebf94d",
"sha256:de454f1015958450b72641165c08afe7023cd7e3944396448f2fb1b0ccba9d77"
"sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6",
"sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"
],
"index": "pypi",
"version": "==0.13.1"
"version": "==0.19"
}
},
"develop": {}

View File

@ -117,6 +117,7 @@ export default {
:toggle-text="s__('Boards|Move card')"
:text-sr-only="true"
no-caret
data-testid="board-move-to-position"
@action="selectMoveAction"
/>
</template>

View File

@ -13,6 +13,7 @@ import {
toggleFormEventPrefix,
DraggableItemTypes,
listIssuablesQueries,
ListType,
} from 'ee_else_ce/boards/constants';
import eventHub from '../eventhub';
import BoardCard from './board_card.vue';
@ -196,6 +197,9 @@ export default {
disableScrollingWhenMutationInProgress() {
return this.hasNextPage && this.isUpdateIssueOrderInProgress;
},
showMoveToPosition() {
return !this.disabled && this.list.listType !== ListType.closed;
},
},
watch: {
boardListItems() {
@ -382,9 +386,8 @@ export default {
:data-draggable-item-type="$options.draggableItemTypes.card"
:show-work-item-type-icon="!isEpicBoard"
>
<!-- TODO: remove the condition when https://gitlab.com/gitlab-org/gitlab/-/issues/377862 is resolved -->
<board-card-move-to-position
v-if="!isEpicBoard && !disabled"
v-if="showMoveToPosition"
:item="item"
:index="index"
:list="list"

View File

@ -15,9 +15,11 @@ const updateListItemsCount = ({ state, listId, value }) => {
}
};
export const removeItemFromList = ({ state, listId, itemId }) => {
export const removeItemFromList = ({ state, listId, itemId, reordering = false }) => {
Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId));
if (!reordering) {
updateListItemsCount({ state, listId, value: -1 });
}
};
export const addItemToList = ({
@ -28,6 +30,7 @@ export const addItemToList = ({
moveAfterId,
atIndex,
positionInList,
reordering = false,
}) => {
const listIssues = state.boardItemsByListId[listId];
let newIndex = atIndex || 0;
@ -41,7 +44,9 @@ export const addItemToList = ({
}
listIssues.splice(newIndex, 0, itemId);
Vue.set(state.boardItemsByListId, listId, listIssues);
if (!reordering) {
updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 });
}
};
export default {

View File

@ -136,6 +136,7 @@ export default {
'gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown',
toggleClass,
]"
:data-qa-selector="qaSelector"
@shown="fetchData"
@search="searchData"
@select="selectItem"

View File

@ -1,32 +1,28 @@
<script>
import { GlAvatar, GlButton } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { __, s__ } from '~/locale';
import { clearDraft } from '~/lib/utils/autosave';
import Tracking from '~/tracking';
import { ASC } from '~/notes/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { updateCommentState } from '~/work_items/graphql/cache_utils';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { getWorkItemQuery } from '../utils';
import createNoteMutation from '../graphql/notes/create_work_item_note.mutation.graphql';
import { TRACKING_CATEGORY_SHOW, i18n } from '../constants';
import { getWorkItemQuery } from '../../utils';
import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql';
import { TRACKING_CATEGORY_SHOW, i18n } from '../../constants';
import WorkItemNoteSignedOut from './work_item_note_signed_out.vue';
import WorkItemCommentLocked from './work_item_comment_locked.vue';
import WorkItemCommentForm from './work_item_comment_form.vue';
export default {
constantOptions: {
markdownDocsPath: helpPagePath('user/markdown'),
avatarUrl: window.gon.current_user_avatar_url,
},
components: {
GlAvatar,
GlButton,
MarkdownEditor,
WorkItemNoteSignedOut,
WorkItemCommentLocked,
WorkItemCommentForm,
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
props: {
@ -78,13 +74,6 @@ export default {
isEditing: false,
isSubmitting: false,
isSubmittingWithKeydown: false,
commentText: '',
formFieldProps: {
'aria-label': __('Add a comment'),
placeholder: __('Write a comment or drag your files here…'),
id: 'work-item-add-comment',
name: 'work-item-add-comment',
},
};
},
apollo: {
@ -112,7 +101,7 @@ export default {
},
autosaveKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.workItemId}-comment`;
return this.discussionId ? `${this.discussionId}-comment` : `${this.workItemId}-comment`;
},
tracking() {
return {
@ -150,37 +139,9 @@ export default {
},
},
methods: {
startEditing() {
this.isEditing = true;
this.commentText = getDraft(this.autosaveKey) || '';
},
async cancelEditing() {
if (this.commentText) {
const msg = s__('WorkItem|Are you sure you want to cancel editing?');
const confirmed = await confirmAction(msg, {
primaryBtnText: __('Discard changes'),
cancelBtnText: __('Continue editing'),
});
if (!confirmed) {
return;
}
}
this.$emit('cancelEditing');
this.isEditing = false;
clearDraft(this.autosaveKey);
},
async updateWorkItem(event = {}) {
const { key } = event;
if (key) {
this.isSubmittingWithKeydown = true;
}
async updateWorkItem(commentText) {
this.isSubmitting = true;
this.$emit('replying', this.commentText);
this.$emit('replying', commentText);
const { queryVariables, fetchByIid } = this;
try {
@ -191,7 +152,7 @@ export default {
variables: {
input: {
noteableId: this.workItemId,
body: this.commentText,
body: commentText,
discussionId: this.discussionId || null,
},
},
@ -204,7 +165,7 @@ export default {
});
clearDraft(this.autosaveKey);
this.$emit('replied');
this.isEditing = false;
this.cancelEditing();
} catch (error) {
this.$emit('error', error.message);
Sentry.captureException(error);
@ -212,9 +173,9 @@ export default {
this.isSubmitting = false;
},
setCommentText(newText) {
this.commentText = newText;
updateDraft(this.autosaveKey, this.commentText);
cancelEditing() {
this.isEditing = false;
this.$emit('cancelEditing');
},
},
};
@ -230,37 +191,19 @@ export default {
/>
<div v-else class="gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap">
<gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
<form v-if="isEditing" class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
<markdown-editor
class="gl-mb-3"
:value="commentText"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="$options.constantOptions.markdownDocsPath"
:form-field-props="formFieldProps"
data-testid="work-item-add-comment"
enable-autocomplete
autofocus
use-bottom-toolbar
@input="setCommentText"
@keydown.meta.enter="updateWorkItem"
@keydown.ctrl.enter="updateWorkItem"
@keydown.esc="cancelEditing"
<work-item-comment-form
v-if="isEditing"
:work-item-type="workItemType"
:aria-label="__('Add a comment')"
:is-submitting="isSubmitting"
:autosave-key="autosaveKey"
@submitForm="updateWorkItem"
@cancelEditing="cancelEditing"
/>
<gl-button
category="primary"
variant="confirm"
:loading="isSubmitting"
@click="updateWorkItem"
>{{ __('Comment') }}
</gl-button>
<gl-button category="primary" class="gl-ml-3" @click="cancelEditing"
>{{ __('Cancel') }}
</gl-button>
</form>
<gl-button
v-else
class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
@click="startEditing"
@click="isEditing = true"
>{{ __('Add a comment') }}</gl-button
>
</div>

View File

@ -0,0 +1,126 @@
<script>
import { GlButton } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__, __ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
export default {
constantOptions: {
markdownDocsPath: helpPagePath('user/markdown'),
},
components: {
GlButton,
MarkdownEditor,
},
inject: ['fullPath'],
props: {
workItemType: {
type: String,
required: true,
},
ariaLabel: {
type: String,
required: true,
},
autosaveKey: {
type: String,
required: true,
},
isSubmitting: {
type: Boolean,
required: false,
default: false,
},
initialValue: {
type: String,
required: false,
default: '',
},
commentButtonText: {
type: String,
required: false,
default: __('Comment'),
},
},
data() {
return {
commentText: getDraft(this.autosaveKey) || this.initialValue || '',
};
},
computed: {
markdownPreviewPath() {
return joinPaths(
'/',
gon.relative_url_root || '',
this.fullPath,
`/preview_markdown?target_type=${this.workItemType}`,
);
},
formFieldProps() {
return {
'aria-label': this.ariaLabel,
placeholder: __('Write a comment or drag your files here…'),
id: 'work-item-add-or-edit-comment',
name: 'work-item-add-or-edit-comment',
};
},
},
methods: {
setCommentText(newText) {
this.commentText = newText;
updateDraft(this.autosaveKey, this.commentText);
},
async cancelEditing() {
if (this.commentText && this.commentText !== this.initialValue) {
const msg = s__('WorkItem|Are you sure you want to cancel editing?');
const confirmed = await confirmAction(msg, {
primaryBtnText: __('Discard changes'),
cancelBtnText: __('Continue editing'),
primaryBtnVariant: 'danger',
});
if (!confirmed) {
return;
}
}
this.$emit('cancelEditing');
clearDraft(this.autosaveKey);
},
},
};
</script>
<template>
<form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
<markdown-editor
:value="commentText"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="$options.constantOptions.markdownDocsPath"
:form-field-props="formFieldProps"
data-testid="work-item-add-comment"
class="gl-mb-3"
autofocus
use-bottom-toolbar
@input="setCommentText"
@keydown.meta.enter="$emit('submitForm', commentText)"
@keydown.ctrl.enter="$emit('submitForm', commentText)"
@keydown.esc.stop="cancelEditing"
/>
<gl-button
category="primary"
variant="confirm"
data-testid="confirm-button"
:loading="isSubmitting"
@click="$emit('submitForm', commentText)"
>{{ commentButtonText }}
</gl-button>
<gl-button data-testid="cancel-button" category="primary" class="gl-ml-3" @click="cancelEditing"
>{{ __('Cancel') }}
</gl-button>
</form>
</template>

View File

@ -6,7 +6,7 @@ import DiscussionNotesRepliesWrapper from '~/notes/components/discussion_notes_r
import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
import WorkItemNoteReplying from '~/work_items/components/notes/work_item_note_replying.vue';
import WorkItemCommentForm from '../work_item_comment_form.vue';
import WorkItemAddNote from './work_item_add_note.vue';
export default {
components: {
@ -14,7 +14,7 @@ export default {
GlAvatarLink,
GlAvatar,
WorkItemNote,
WorkItemCommentForm,
WorkItemAddNote,
ToggleRepliesWidget,
DiscussionNotesRepliesWrapper,
WorkItemNoteReplying,
@ -139,8 +139,11 @@ export default {
:is-first-note="true"
:note="note"
:discussion-id="discussionId"
:work-item-type="workItemType"
:class="{ 'gl-mb-5': hasReplies }"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', note)"
@error="$emit('error', $event)"
/>
<discussion-notes-replies-wrapper>
<toggle-replies-widget
@ -153,14 +156,16 @@ export default {
<template v-for="reply in replies">
<work-item-note
:key="threadKey(reply)"
discussion-id="discussionId"
:discussion-id="discussionId"
:note="reply"
:work-item-type="workItemType"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', reply)"
@error="$emit('error', $event)"
/>
</template>
<work-item-note-replying v-if="isReplying" :body="replyingText" />
<work-item-comment-form
<work-item-add-note
:autofocus="autofocus"
:query-variables="queryVariables"
:full-path="fullPath"

View File

@ -1,11 +1,16 @@
<script>
import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { __ } from '~/locale';
import { updateDraft, clearDraft } from '~/lib/utils/autosave';
import { renderMarkdown } from '~/notes/utils';
import EditedAt from '~/issues/show/components/edited.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import NoteBody from '~/work_items/components/notes/work_item_note_body.vue';
import NoteHeader from '~/notes/components/note_header.vue';
import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql';
import WorkItemCommentForm from './work_item_comment_form.vue';
export default {
name: 'WorkItemNoteThread',
@ -22,6 +27,8 @@ export default {
GlAvatarLink,
GlDropdown,
GlDropdownItem,
WorkItemCommentForm,
EditedAt,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -36,6 +43,15 @@ export default {
required: false,
default: false,
},
workItemType: {
type: String,
required: true,
},
},
data() {
return {
isEditing: false,
};
},
computed: {
author() {
@ -50,14 +66,54 @@ export default {
showReply() {
return this.note.userPermissions.createNote && this.isFirstNote;
},
autosaveKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.note.id}-comment`;
},
lastEditedBy() {
return this.note.lastEditedBy;
},
hasAdminPermission() {
return this.note.userPermissions.adminNote;
},
},
methods: {
renderGFM() {
renderGFM(this.$refs['note-body']);
},
showReplyForm() {
this.$emit('startReplying');
},
startEditing() {
this.isEditing = true;
updateDraft(this.autosaveKey, this.note.body);
},
async updateNote(newText) {
this.isEditing = false;
try {
await this.$apollo.mutate({
mutation: updateWorkItemNoteMutation,
variables: {
input: {
id: this.note.id,
body: newText,
},
},
optimisticResponse: {
updateNote: {
errors: [],
note: {
...this.note,
bodyHtml: renderMarkdown(newText),
},
},
},
});
clearDraft(this.autosaveKey);
} catch (error) {
updateDraft(this.autosaveKey, newText);
this.isEditing = true;
this.$emit('error', __('Something went wrong when updating a comment. Please try again'));
Sentry.captureException(error);
}
},
},
};
</script>
@ -74,13 +130,29 @@ export default {
/>
</gl-avatar-link>
</div>
<div class="timeline-content-inner">
<work-item-comment-form
v-if="isEditing"
:work-item-type="workItemType"
:aria-label="__('Edit comment')"
:autosave-key="autosaveKey"
:initial-value="note.body"
:comment-button-text="__('Save comment')"
:class="{ 'gl-pl-8': !isFirstNote }"
@cancelEditing="isEditing = false"
@submitForm="updateNote"
/>
<div v-else class="timeline-content-inner" data-testid="note-wrapper">
<div class="note-header">
<note-header :author="author" :created-at="note.createdAt" :note-id="note.id" />
<note-actions :show-reply="showReply" @startReplying="showReplyForm" />
<note-actions
:show-reply="showReply"
:show-edit="hasAdminPermission"
@startReplying="showReplyForm"
@startEditing="startEditing"
/>
<!-- v-if condition should be moved to "delete" dropdown item as soon as we implement copying the link -->
<gl-dropdown
v-if="note.userPermissions.adminNote"
v-if="hasAdminPermission"
v-gl-tooltip
icon="ellipsis_v"
text-sr-only
@ -102,6 +174,13 @@ export default {
<div class="timeline-discussion-body">
<note-body ref="noteBody" :note="note" />
</div>
<edited-at
v-if="note.lastEditedBy"
:updated-at="note.lastEditedAt"
:updated-by-name="lastEditedBy.name"
:updated-by-path="lastEditedBy.webPath"
:class="isFirstNote ? 'gl-pl-3' : 'gl-pl-8'"
/>
</div>
</timeline-entry-item>
</template>

View File

@ -1,16 +1,29 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
export default {
name: 'WorkItemNoteActions',
i18n: {
editButtonText: __('Edit comment'),
},
components: {
GlButton,
ReplyButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
showReply: {
type: Boolean,
required: true,
},
showEdit: {
type: Boolean,
required: true,
},
},
};
</script>
@ -18,5 +31,17 @@ export default {
<template>
<div class="note-actions">
<reply-button v-if="showReply" ref="replyButton" @startReplying="$emit('startReplying')" />
<gl-button
v-if="showEdit"
v-gl-tooltip
data-testid="edit-work-item-note"
data-track-action="click_button"
data-track-label="edit_button"
category="tertiary"
icon="pencil"
:title="$options.i18n.editButtonText"
:aria-label="$options.i18n.editButtonText"
@click="$emit('startEditing')"
/>
</div>
</template>

View File

@ -13,9 +13,18 @@ export default {
required: true,
},
},
mounted() {
watch: {
'note.bodyHtml': {
immediate: true,
async handler(newVal, oldVal) {
if (newVal === oldVal) {
return;
}
await this.$nextTick();
this.renderGFM();
},
},
},
methods: {
renderGFM() {
renderGFM(this.$refs['note-body']);

View File

@ -10,7 +10,7 @@ import { ASC, DESC } from '~/notes/constants';
import { getWorkItemNotesQuery } from '~/work_items/utils';
import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
import deleteNoteMutation from '../graphql/notes/delete_work_item_notes.mutation.graphql';
import WorkItemCommentForm from './work_item_comment_form.vue';
import WorkItemAddNote from './notes/work_item_add_note.vue';
export default {
i18n: {
@ -26,7 +26,7 @@ export default {
GlModal,
ActivityFilter,
SystemNote,
WorkItemCommentForm,
WorkItemAddNote,
WorkItemDiscussion,
},
props: {
@ -249,7 +249,7 @@ export default {
<div v-else class="issuable-discussion gl-mb-5 gl-clearfix!">
<template v-if="!initialLoading">
<ul class="notes main-notes-list timeline gl-clearfix!">
<work-item-comment-form
<work-item-add-note
v-if="formAtTop"
v-bind="workItemCommentFormProps"
@error="$emit('error', $event)"
@ -271,11 +271,12 @@ export default {
:fetch-by-iid="fetchByIid"
:work-item-type="workItemType"
@deleteNote="showDeleteNoteModal($event, discussion)"
@error="$emit('error', $event)"
/>
</template>
</template>
<work-item-comment-form
<work-item-add-note
v-if="!formAtTop"
v-bind="workItemCommentFormProps"
@error="$emit('error', $event)"

View File

@ -11,11 +11,8 @@ mutation createWorkItemNote($input: CreateNoteInput!) {
...WorkItemNote
}
}
__typename
}
__typename
}
errors
__typename
}
}

View File

@ -0,0 +1,10 @@
#import "./work_item_note.fragment.graphql"
mutation updateWorkItemNote($input: UpdateNoteInput!) {
updateNote(input: $input) {
note {
...WorkItemNote
}
errors
}
}

View File

@ -2,11 +2,17 @@
fragment WorkItemNote on Note {
id
body
bodyHtml
system
internal
systemNoteIconName
createdAt
lastEditedAt
lastEditedBy {
...User
webPath
}
discussion {
id
}

View File

@ -28,20 +28,18 @@ class Environment < ApplicationRecord
has_many :self_managed_prometheus_alert_events, inverse_of: :environment
has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment
# NOTE:
# 1) no-op arguments is to prevent accidental legacy preloading. See: https://gitlab.com/gitlab-org/gitlab/-/issues/369240
# 2) If you preload multiple last deployments of environments, use Preloaders::Environments::DeploymentPreloader.
has_one :last_deployment, -> (_env) { success.ordered }, class_name: 'Deployment', inverse_of: :environment
has_one :last_visible_deployment, -> (_env) { visible.order(id: :desc) }, inverse_of: :environment, class_name: 'Deployment'
has_one :upcoming_deployment, -> (_env) { upcoming.order(id: :desc) }, class_name: 'Deployment', inverse_of: :environment
# NOTE: If you preload multiple last deployments of environments, use Preloaders::Environments::DeploymentPreloader.
has_one :last_deployment, -> { success.ordered }, class_name: 'Deployment', inverse_of: :environment
has_one :last_visible_deployment, -> { visible.order(id: :desc) }, inverse_of: :environment, class_name: 'Deployment'
has_one :upcoming_deployment, -> { upcoming.order(id: :desc) }, class_name: 'Deployment', inverse_of: :environment
Deployment::FINISHED_STATUSES.each do |status|
has_one :"last_#{status}_deployment", -> (_env) { where(status: status).ordered },
has_one :"last_#{status}_deployment", -> { where(status: status).ordered },
class_name: 'Deployment', inverse_of: :environment
end
Deployment::UPCOMING_STATUSES.each do |status|
has_one :"last_#{status}_deployment", -> (_env) { where(status: status).ordered_as_upcoming },
has_one :"last_#{status}_deployment", -> { where(status: status).ordered_as_upcoming },
class_name: 'Deployment', inverse_of: :environment
end

8840
changelogs/archive-13.md Normal file

File diff suppressed because it is too large Load Diff

6839
changelogs/archive-14.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/389538
milestone: '15.9'
type: development
group: group::authentication and authorization
default_enabled: false
default_enabled: true

View File

@ -1,16 +1,16 @@
- title: "Slack Notifications integration" # (required) Clearly explain the change, or planned change. For example, "The `confidential` field for a `Note` is deprecated" or "CI/CD job names will be limited to 250 characters."
announcement_milestone: "15.8" # (required) The milestone when this feature was first announced as deprecated.
removal_milestone: "16.0" # (required) The milestone when this feature is planned to be removed
- title: "Slack notifications integration" # (required) Clearly explain the change, or planned change. For example, "The `confidential` field for a `Note` is deprecated" or "CI/CD job names will be limited to 250 characters."
announcement_milestone: "15.9" # (required) The milestone when this feature was first announced as deprecated.
removal_milestone: "17.0" # (required) The milestone when this feature is planned to be removed
breaking_change: true # (required) Change to false if this is not a breaking change.
reporter: g.hickman # (required) GitLab username of the person reporting the change
stage: manage # (required) String value of the stage that the feature was created in. e.g., Growth
issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/372411 # (required) Link to the deprecation issue in GitLab
body: | # (required) Do not modify this line, instead modify the lines below.
As we're consolidating all Slack capabilities into the
GitLab for Slack app, we're [deprecating the Slack Notifications
GitLab for Slack app, we're [deprecating the Slack notifications
integration](https://gitlab.com/gitlab-org/gitlab/-/issues/372411).
GitLab.com users can now use the GitLab for Slack app to manage notifications
to their Slack workspace. For self-managed users of the Slack Notifications integration,
to their Slack workspace. For self-managed users of the Slack notifications integration,
we'll be introducing support in [this epic](https://gitlab.com/groups/gitlab-org/-/epics/1211).
#
@ -19,7 +19,7 @@
# If an End of Support period applies, the announcement should be shared with GitLab Support
# in the `#spt_managers` channel in Slack, and mention `@gitlab-com/support` in this MR.
#
end_of_support_milestone: "16.0" # (optional) Use "XX.YY" format. The milestone when support for this feature will end.
end_of_support_milestone: "17.0" # (optional) Use "XX.YY" format. The milestone when support for this feature will end.
#
# OTHER OPTIONAL FIELDS
#

View File

@ -13,7 +13,7 @@ some of the important concepts related to them.
You can structure your pipelines with different methods, each with their
own advantages. These methods can be mixed and matched if needed:
- [Basic](#basic-pipelines): Good for straightforward projects where all the configuration is in one easy to find place.
- [Basic](#basic-pipelines): Good for straightforward projects where all the configuration is in one easy-to-find place.
- [Directed Acyclic Graph](#directed-acyclic-graph-pipelines): Good for large, complex projects that need efficient execution.
- [Parent-child pipelines](#parent-child-pipelines): Good for monorepos and projects with lots of independently defined components.

View File

@ -154,8 +154,11 @@ When a test frequently fails in `master`,
create [a ~"failure::flaky-test" issue](https://about.gitlab.com/handbook/engineering/workflow/#broken-master).
If the test cannot be fixed in a timely fashion, there is an impact on the
productivity of all the developers, so it should be quarantined by
assigning the `:quarantine` metadata with the issue URL, and add the `~"quarantined test"` label to the issue.
productivity of all the developers, so it should be quarantined. There are two ways to quarantine tests, depending on the test framework being used: RSpec and Jest.
### RSpec
For RSpec tests, you can use the `:quarantine` metadata with the issue URL.
```ruby
it 'succeeds', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/12345' do
@ -169,6 +172,26 @@ This means it is skipped unless run with `--tag quarantine`:
bin/rspec --tag quarantine
```
### Jest
For Jest specs, you can use the `.skip` method along with the `eslint-disable-next-line` comment to disable the `jest/no-disabled-tests` ESLint rule and include the issue URL. Here's an example:
```javascript
// https://gitlab.com/gitlab-org/gitlab/-/issues/56789
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should throw an error', () => {
expect(response).toThrowError(expected_error)
});
```
This means it is skipped unless the test suit is run with `--runInBand` Jest command line option:
```shell
jest --runInBand
```
For both test frameworks, make sure to add the `~"quarantined test"` label to the issue.
Once a test is in quarantine, there are 3 choices:
- Fix the test (that is, get rid of its flakiness).

View File

@ -406,6 +406,26 @@ automatically from GitLab 16.0 onwards.
</div>
<div class="deprecation removal-170 breaking-change">
### Slack notifications integration
End of Support: GitLab <span class="removal-milestone">17.0</span> <span class="support-end-date"></span><br />
Planned removal: GitLab <span class="removal-milestone">17.0</span> <span class="removal-date"></span>
WARNING:
This is a [breaking change](https://docs.gitlab.com/ee/development/deprecation_guidelines/).
Review the details carefully before upgrading.
As we're consolidating all Slack capabilities into the
GitLab for Slack app, we're [deprecating the Slack notifications
integration](https://gitlab.com/gitlab-org/gitlab/-/issues/372411).
GitLab.com users can now use the GitLab for Slack app to manage notifications
to their Slack workspace. For self-managed users of the Slack notifications integration,
we'll be introducing support in [this epic](https://gitlab.com/groups/gitlab-org/-/epics/1211).
</div>
<div class="deprecation removal-160 breaking-change">
### Support for Praefect custom metrics endpoint configuration
@ -832,26 +852,6 @@ Alternatives to using the `gitlab:import:repos` Rake task include:
<div class="deprecation removal-160 breaking-change">
### Slack Notifications integration
End of Support: GitLab <span class="removal-milestone">16.0</span> <span class="support-end-date"></span><br />
Planned removal: GitLab <span class="removal-milestone">16.0</span> <span class="removal-date"></span>
WARNING:
This is a [breaking change](https://docs.gitlab.com/ee/development/deprecation_guidelines/).
Review the details carefully before upgrading.
As we're consolidating all Slack capabilities into the
GitLab for Slack app, we're [deprecating the Slack Notifications
integration](https://gitlab.com/gitlab-org/gitlab/-/issues/372411).
GitLab.com users can now use the GitLab for Slack app to manage notifications
to their Slack workspace. For self-managed users of the Slack Notifications integration,
we'll be introducing support in [this epic](https://gitlab.com/groups/gitlab-org/-/epics/1211).
</div>
<div class="deprecation removal-160 breaking-change">
### Support for third party registries
Planned removal: GitLab <span class="removal-milestone">16.0</span> <span class="removal-date"></span>

View File

@ -470,8 +470,11 @@ subscriptions.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106256) in GitLab 15.7 [with a flag](../administration/feature_flags.md) named `customizable_roles`.
> - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/110810) in GitLab 15.9.
Custom roles allow group members who are assigned the Owner role to create roles specific to the needs
of their organization.
Custom roles allow group members who are assigned the Owner role to create roles
specific to the needs of their organization.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For a demo of the custom roles feature, see [[Demo] Ultimate Guest can view code on private repositories via custom role](https://www.youtube.com/watch?v=46cp_-Rtxps).
### Create a custom role
@ -495,7 +498,7 @@ the Owner role:
1. Associates the group member with the Guest+1 role using the [Group and Project Members API endpoint](../api/members.md#edit-a-member-of-a-group-or-project)
```shell
curl --request PUT --header "Content-Type: application/json" --header "Authorization: Bearer $YOUR_ACCESS_TOKEN" --data '{"member_role_id":$MEMBER_ROLE_ID},"access_level":10' "https://example.gitlab.com/api/v4/groups/$GROUP_PATH/members/$GUEST_USER_ID"
curl --request PUT --header "Content-Type: application/json" --header "Authorization: Bearer $YOUR_ACCESS_TOKEN" --data '{"member_role_id": '$MEMBER_ROLE_ID', "access_level": 10}' "https://example.gitlab.com/api/v4/groups/$GROUP_PATH/members/$GUEST_USER_ID"
```
Where:
@ -510,7 +513,7 @@ To remove a custom role from a group member, use the [Group and Project Members
and pass an empty `member_role_id` value.
```shell
curl --request PUT --header "Content-Type: application/json" --header "Authorization: Bearer $YOUR_ACCESS_TOKEN" --data '{"member_role_id":""},"access_level":10' "https://example.gitlab.com/api/v4/groups/$GROUP_PATH/members/$GUEST_USER_ID"
curl --request PUT --header "Content-Type: application/json" --header "Authorization: Bearer $YOUR_ACCESS_TOKEN" --data '{"member_role_id": "", "access_level": 10}' "https://example.gitlab.com/api/v4/groups/$GROUP_PATH/members/$GUEST_USER_ID"
```
Now the user is a regular Guest.

View File

@ -269,8 +269,6 @@ A `_sidebar` example, formatted with Markdown:
- [Sidebar](_sidebar)
```
Support for displaying a generated table of contents with a custom side navigation is being considered.
## Enable or disable a project wiki
Wikis are enabled by default in GitLab. Project [administrators](../../permissions.md)

View File

@ -40313,6 +40313,9 @@ msgstr ""
msgid "Something went wrong when sending the incident link to Slack."
msgstr ""
msgid "Something went wrong when updating a comment. Please try again"
msgstr ""
msgid "Something went wrong while adding timeline event."
msgstr ""

View File

@ -12,8 +12,8 @@ module QA
element :compare_branches_button
end
view 'app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js' do
element :source_branch_dropdown
view 'app/assets/javascripts/merge_requests/components/compare_dropdown.vue' do
element :source_branch_dropdown, ':data-qa-selector="qaSelector"' # rubocop:disable QA/ElementWithPattern
end
view 'app/views/projects/merge_requests/_page.html.haml' do

View File

@ -130,6 +130,41 @@ RSpec.describe 'Issue Boards', :js, feature_category: :team_planning do
end
end
context 'ordering in list using move to position' do
let(:move_to_position) { find('[data-testid="board-move-to-position"]') }
before do
visit project_board_path(project, board)
wait_for_requests
end
it 'moves to end of list' do
expect(all('.board-card').first).to have_content(issue3.title)
page.within(find('.board:nth-child(2)')) do
first('.board-card').hover
move_to_position.click
click_button 'Move to end of list'
end
expect(all('.board-card').last).to have_content(issue3.title)
end
it 'moves to start of list' do
expect(all('.board-card').last).to have_content(issue1.title)
page.within(find('.board:nth-child(2)')) do
all('.board-card').last.hover
move_to_position.click
click_button 'Move to start of list'
end
expect(all('.board-card').first).to have_content(issue1.title)
end
end
context 'ordering when changing list' do
let(:label2) { create(:label, project: project) }
let!(:list2) { create(:list, board: board, label: label2, position: 1) }

View File

@ -1,6 +1,6 @@
import Draggable from 'vuedraggable';
import { nextTick } from 'vue';
import { DraggableItemTypes } from 'ee_else_ce/boards/constants';
import { DraggableItemTypes, ListType } from 'ee_else_ce/boards/constants';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import waitForPromises from 'helpers/wait_for_promises';
import createComponent from 'jest/boards/board_list_helper';
@ -107,6 +107,20 @@ describe('Board list component', () => {
});
});
describe('when ListType is Closed', () => {
beforeEach(() => {
wrapper = createComponent({
listProps: {
listType: ListType.closed,
},
});
});
it('Board card move to position is not visible', () => {
expect(findMoveToPositionComponent().exists()).toBe(false);
});
});
describe('load more issues', () => {
const actions = {
fetchItemsForList: jest.fn(),

View File

@ -5,21 +5,23 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { updateDraft } from '~/lib/utils/autosave';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue';
import WorkItemCommentLocked from '~/work_items/components/work_item_comment_locked.vue';
import { clearDraft } from '~/lib/utils/autosave';
import { config } from '~/graphql_shared/issuable_client';
import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue';
import WorkItemCommentLocked from '~/work_items/components/notes/work_item_comment_locked.vue';
import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue';
import createNoteMutation from '~/work_items/graphql/notes/create_work_item_note.mutation.graphql';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemNotesQuery from '~/work_items/graphql/notes/work_item_notes.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
workItemResponseFactory,
workItemQueryResponse,
projectWorkItemResponse,
createWorkItemNoteResponse,
} from '../mock_data';
mockWorkItemNotesResponse,
} from '../../mock_data';
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
jest.mock('~/lib/utils/autosave');
@ -35,18 +37,7 @@ describe('WorkItemCommentForm', () => {
const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
let workItemResponseHandler;
const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
const setText = (newText) => {
return findMarkdownEditor().vm.$emit('input', newText);
};
const clickSave = () =>
wrapper
.findAllComponents(GlButton)
.filter((button) => button.text().startsWith('Comment'))
.at(0)
.vm.$emit('click', {});
const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm);
const createComponent = async ({
mutationHandler = mutationSuccessHandler,
@ -65,13 +56,28 @@ describe('WorkItemCommentForm', () => {
window.gon.current_user_avatar_url = 'avatar.png';
}
const { id } = workItemQueryResponse.data.workItem;
wrapper = shallowMount(WorkItemCommentForm, {
apolloProvider: createMockApollo([
const apolloProvider = createMockApollo(
[
[workItemQuery, workItemResponseHandler],
[createNoteMutation, mutationHandler],
[workItemByIidQuery, workItemByIidResponseHandler],
]),
],
{},
{ ...config.cacheConfig },
);
apolloProvider.clients.defaultClient.writeQuery({
query: workItemNotesQuery,
variables: {
id: workItemId,
pageSize: 100,
},
data: mockWorkItemNotesResponse.data,
});
const { id } = workItemQueryResponse.data.workItem;
wrapper = shallowMount(WorkItemAddNote, {
apolloProvider,
propsData: {
workItemId: id,
fullPath: 'test-project-path',
@ -80,7 +86,6 @@ describe('WorkItemCommentForm', () => {
workItemType,
},
stubs: {
MarkdownField,
WorkItemCommentLocked,
},
});
@ -101,9 +106,7 @@ describe('WorkItemCommentForm', () => {
signedIn: true,
});
setText(noteText);
clickSave();
findCommentForm().vm.$emit('submitForm', noteText);
await waitForPromises();
@ -120,9 +123,7 @@ describe('WorkItemCommentForm', () => {
await createComponent();
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
setText('test');
clickSave();
findCommentForm().vm.$emit('submitForm', 'test');
await waitForPromises();
@ -133,6 +134,33 @@ describe('WorkItemCommentForm', () => {
});
});
it('emits `replied` event and hides form after successful mutation', async () => {
await createComponent({
isEditing: true,
signedIn: true,
queryVariables: {
id: mockWorkItemNotesResponse.data.workItem.id,
},
});
findCommentForm().vm.$emit('submitForm', 'some text');
await waitForPromises();
expect(wrapper.emitted('replied')).toEqual([[]]);
});
it('clears a draft after successful mutation', async () => {
await createComponent({
isEditing: true,
signedIn: true,
});
findCommentForm().vm.$emit('submitForm', 'some text');
await waitForPromises();
expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment');
});
it('emits error when mutation returns error', async () => {
const error = 'eror';
@ -160,9 +188,7 @@ describe('WorkItemCommentForm', () => {
}),
});
setText('updated desc');
clickSave();
findCommentForm().vm.$emit('submitForm', 'updated desc');
await waitForPromises();
@ -177,24 +203,12 @@ describe('WorkItemCommentForm', () => {
mutationHandler: jest.fn().mockRejectedValue(new Error(error)),
});
setText('updated desc');
clickSave();
findCommentForm().vm.$emit('submitForm', 'updated desc');
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[error]]);
});
it('autosaves', async () => {
await createComponent({
isEditing: true,
});
setText('updated');
expect(updateDraft).toHaveBeenCalled();
});
});
it('calls the global ID work item query when `fetchByIid` prop is false', async () => {

View File

@ -0,0 +1,164 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import * as autosave from '~/lib/utils/autosave';
import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys';
import * as confirmViaGlModal from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
const draftComment = 'draft comment';
jest.mock('~/lib/utils/autosave', () => ({
updateDraft: jest.fn(),
clearDraft: jest.fn(),
getDraft: jest.fn().mockReturnValue(draftComment),
}));
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => ({
confirmAction: jest.fn().mockResolvedValue(true),
}));
describe('Work item comment form component', () => {
let wrapper;
const mockAutosaveKey = 'test-auto-save-key';
const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findConfirmButton = () => wrapper.find('[data-testid="confirm-button"]');
const createComponent = ({ isSubmitting = false, initialValue = '' } = {}) => {
wrapper = shallowMount(WorkItemCommentForm, {
propsData: {
workItemType: 'Issue',
ariaLabel: 'test-aria-label',
autosaveKey: mockAutosaveKey,
isSubmitting,
initialValue,
},
provide: {
fullPath: 'test-project-path',
},
});
};
it('passes correct markdown preview path to markdown editor', () => {
createComponent();
expect(findMarkdownEditor().props('renderMarkdownPath')).toBe(
'/test-project-path/preview_markdown?target_type=Issue',
);
});
it('passes correct form field props to markdown editor', () => {
createComponent();
expect(findMarkdownEditor().props('formFieldProps')).toEqual({
'aria-label': 'test-aria-label',
id: 'work-item-add-or-edit-comment',
name: 'work-item-add-or-edit-comment',
placeholder: 'Write a comment or drag your files here…',
});
});
it('passes correct `loading` prop to confirm button', () => {
createComponent({ isSubmitting: true });
expect(findConfirmButton().props('loading')).toBe(true);
});
it('passes a draft from local storage as a value to markdown editor if the draft exists', () => {
createComponent({ initialValue: 'parent comment' });
expect(findMarkdownEditor().props('value')).toBe(draftComment);
});
it('passes an initialValue prop as a value to markdown editor if storage draft does not exist', () => {
jest.spyOn(autosave, 'getDraft').mockImplementation(() => '');
createComponent({ initialValue: 'parent comment' });
expect(findMarkdownEditor().props('value')).toBe('parent comment');
});
it('passes an empty string as a value to markdown editor if storage draft and initialValue are empty', () => {
createComponent();
expect(findMarkdownEditor().props('value')).toBe('');
});
describe('on markdown editor input', () => {
beforeEach(() => {
createComponent();
});
it('sets correct comment text value', async () => {
expect(findMarkdownEditor().props('value')).toBe('');
findMarkdownEditor().vm.$emit('input', 'new comment');
await nextTick();
expect(findMarkdownEditor().props('value')).toBe('new comment');
});
it('calls `updateDraft` with correct parameters', async () => {
findMarkdownEditor().vm.$emit('input', 'new comment');
expect(autosave.updateDraft).toHaveBeenCalledWith(mockAutosaveKey, 'new comment');
});
});
describe('on cancel editing', () => {
beforeEach(() => {
jest.spyOn(autosave, 'getDraft').mockImplementation(() => draftComment);
createComponent();
findMarkdownEditor().vm.$emit('keydown', new KeyboardEvent('keydown', { key: ESC_KEY }));
return waitForPromises();
});
it('confirms a user action if comment text is not empty', () => {
expect(confirmViaGlModal.confirmAction).toHaveBeenCalled();
});
it('emits `cancelEditing` and clears draft from the local storage', () => {
expect(wrapper.emitted('cancelEditing')).toHaveLength(1);
expect(autosave.clearDraft).toHaveBeenCalledWith(mockAutosaveKey);
});
});
it('cancels editing on clicking cancel button', async () => {
createComponent();
findCancelButton().vm.$emit('click');
await waitForPromises();
expect(wrapper.emitted('cancelEditing')).toHaveLength(1);
expect(autosave.clearDraft).toHaveBeenCalledWith(mockAutosaveKey);
});
it('emits `submitForm` event on confirm button click', () => {
createComponent();
findConfirmButton().vm.$emit('click');
expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]);
});
it('emits `submitForm` event on pressing enter with meta key on markdown editor', () => {
createComponent();
findMarkdownEditor().vm.$emit(
'keydown',
new KeyboardEvent('keydown', { key: ENTER_KEY, metaKey: true }),
);
expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]);
});
it('emits `submitForm` event on pressing ctrl+enter on markdown editor', () => {
createComponent();
findMarkdownEditor().vm.$emit(
'keydown',
new KeyboardEvent('keydown', { key: ENTER_KEY, ctrlKey: true }),
);
expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]);
});
});

View File

@ -1,6 +1,6 @@
import { GlLink, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import WorkItemCommentLocked from '~/work_items/components/work_item_comment_locked.vue';
import WorkItemCommentLocked from '~/work_items/components/notes/work_item_comment_locked.vue';
const createComponent = ({ workItemType = 'Task', isProjectArchived = false } = {}) =>
shallowMount(WorkItemCommentLocked, {

View File

@ -6,7 +6,7 @@ import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
import WorkItemNoteReplying from '~/work_items/components/notes/work_item_note_replying.vue';
import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue';
import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue';
import {
mockWorkItemCommentNote,
mockWorkItemNotesResponseWithComments,
@ -27,7 +27,7 @@ describe('Work Item Discussion', () => {
const findToggleRepliesWidget = () => wrapper.findComponent(ToggleRepliesWidget);
const findAllThreads = () => wrapper.findAllComponents(WorkItemNote);
const findThreadAtIndex = (index) => findAllThreads().at(index);
const findWorkItemCommentForm = () => wrapper.findComponent(WorkItemCommentForm);
const findWorkItemAddNote = () => wrapper.findComponent(WorkItemAddNote);
const findWorkItemNoteReplying = () => wrapper.findComponent(WorkItemNoteReplying);
const createComponent = ({
@ -73,7 +73,7 @@ describe('Work Item Discussion', () => {
});
it('should not show the comment form by default', () => {
expect(findWorkItemCommentForm().exists()).toBe(false);
expect(findWorkItemAddNote().exists()).toBe(false);
});
});
@ -101,8 +101,8 @@ describe('Work Item Discussion', () => {
mainComment.vm.$emit('startReplying');
await nextTick();
expect(findWorkItemCommentForm().exists()).toBe(true);
expect(findWorkItemCommentForm().props('autofocus')).toBe(true);
expect(findWorkItemAddNote().exists()).toBe(true);
expect(findWorkItemAddNote().props('autofocus')).toBe(true);
});
});
@ -115,7 +115,7 @@ describe('Work Item Discussion', () => {
mainComment.vm.$emit('startReplying');
await nextTick();
await findWorkItemCommentForm().vm.$emit('replying', 'reply text');
await findWorkItemAddNote().vm.$emit('replying', 'reply text');
});
it('should show optimistic behavior when replying', async () => {
@ -124,7 +124,7 @@ describe('Work Item Discussion', () => {
});
it('should be expanded when the reply is successful', async () => {
findWorkItemCommentForm().vm.$emit('replied');
findWorkItemAddNote().vm.$emit('replied');
await nextTick();
expect(findToggleRepliesWidget().exists()).toBe(true);
expect(findToggleRepliesWidget().props('collapsed')).toBe(false);
@ -137,4 +137,13 @@ describe('Work Item Discussion', () => {
expect(wrapper.emitted('deleteNote')).toEqual([[mockWorkItemCommentNote]]);
});
it('emits `error` event when child note emits an `error`', () => {
const mockErrorText = 'Houston, we have a problem';
createComponent();
findThreadAtIndex(0).vm.$emit('error', mockErrorText);
expect(wrapper.emitted('error')).toEqual([[mockErrorText]]);
});
});

View File

@ -6,11 +6,13 @@ describe('Work Item Note Actions', () => {
let wrapper;
const findReplyButton = () => wrapper.findComponent(ReplyButton);
const findEditButton = () => wrapper.find('[data-testid="edit-work-item-note"]');
const createComponent = ({ showReply = true } = {}) => {
const createComponent = ({ showReply = true, showEdit = true } = {}) => {
wrapper = shallowMount(WorkItemNoteActions, {
propsData: {
showReply,
showEdit,
},
});
};
@ -28,4 +30,23 @@ describe('Work Item Note Actions', () => {
expect(findReplyButton().exists()).toBe(false);
});
});
it('shows edit button when `showEdit` prop is true', () => {
createComponent();
expect(findEditButton().exists()).toBe(true);
});
it('does not show edit button when `showEdit` prop is false', () => {
createComponent({ showEdit: false });
expect(findEditButton().exists()).toBe(false);
});
it('emits `startEditing` event when edit button is clicked', () => {
createComponent();
findEditButton().vm.$emit('click');
expect(wrapper.emitted('startEditing')).toEqual([[]]);
});
});

View File

@ -1,14 +1,41 @@
import { GlAvatarLink, GlDropdown } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import mockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { updateDraft } from '~/lib/utils/autosave';
import EditedAt from '~/issues/show/components/edited.vue';
import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import NoteBody from '~/work_items/components/notes/work_item_note_body.vue';
import NoteHeader from '~/notes/components/note_header.vue';
import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue';
import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue';
import updateWorkItemNoteMutation from '~/work_items/graphql/notes/update_work_item_note.mutation.graphql';
import { mockWorkItemCommentNote } from 'jest/work_items/mock_data';
Vue.use(VueApollo);
jest.mock('~/lib/utils/autosave');
describe('Work Item Note', () => {
let wrapper;
const updatedNoteText = '# Some title';
const updatedNoteBody = '<h1 data-sourcepos="1:1-1:12" dir="auto">Some title</h1>';
const successHandler = jest.fn().mockResolvedValue({
data: {
updateNote: {
errors: [],
note: {
...mockWorkItemCommentNote,
body: updatedNoteText,
bodyHtml: updatedNoteBody,
},
},
},
});
const errorHandler = jest.fn().mockRejectedValue('Oops');
const findAuthorAvatarLink = () => wrapper.findComponent(GlAvatarLink);
const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem);
@ -16,60 +43,192 @@ describe('Work Item Note', () => {
const findNoteBody = () => wrapper.findComponent(NoteBody);
const findNoteActions = () => wrapper.findComponent(NoteActions);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]');
const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm);
const findEditedAt = () => wrapper.findComponent(EditedAt);
const createComponent = ({ note = mockWorkItemCommentNote, isFirstNote = false } = {}) => {
const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]');
const findNoteWrapper = () => wrapper.find('[data-testid="note-wrapper"]');
const createComponent = ({
note = mockWorkItemCommentNote,
isFirstNote = false,
updateNoteMutationHandler = successHandler,
} = {}) => {
wrapper = shallowMount(WorkItemNote, {
propsData: {
note,
isFirstNote,
workItemType: 'Task',
},
apolloProvider: mockApollo([[updateWorkItemNoteMutation, updateNoteMutationHandler]]),
});
};
describe('Main comment', () => {
describe('when editing', () => {
beforeEach(() => {
createComponent();
findNoteActions().vm.$emit('startEditing');
return nextTick();
});
it('should render a comment form', () => {
expect(findCommentForm().exists()).toBe(true);
});
it('should not render note wrapper', () => {
expect(findNoteWrapper().exists()).toBe(false);
});
it('updates saved draft with current note text', () => {
expect(updateDraft).toHaveBeenCalledWith(
`${mockWorkItemCommentNote.id}-comment`,
mockWorkItemCommentNote.body,
);
});
it('passes correct autosave key prop to comment form component', () => {
expect(findCommentForm().props('autosaveKey')).toBe(`${mockWorkItemCommentNote.id}-comment`);
});
it('should hide a form and show wrapper when user cancels editing', async () => {
findCommentForm().vm.$emit('cancelEditing');
await nextTick();
expect(findCommentForm().exists()).toBe(false);
expect(findNoteWrapper().exists()).toBe(true);
});
});
describe('when submitting a form to edit a note', () => {
it('calls update mutation with correct variables', async () => {
createComponent();
findNoteActions().vm.$emit('startEditing');
await nextTick();
findCommentForm().vm.$emit('submitForm', updatedNoteText);
expect(successHandler).toHaveBeenCalledWith({
input: {
id: mockWorkItemCommentNote.id,
body: updatedNoteText,
},
});
});
it('hides the form after succesful mutation', async () => {
createComponent();
findNoteActions().vm.$emit('startEditing');
await nextTick();
findCommentForm().vm.$emit('submitForm', updatedNoteText);
await waitForPromises();
expect(findCommentForm().exists()).toBe(false);
});
describe('when mutation fails', () => {
beforeEach(async () => {
createComponent({ updateNoteMutationHandler: errorHandler });
findNoteActions().vm.$emit('startEditing');
await nextTick();
findCommentForm().vm.$emit('submitForm', updatedNoteText);
await waitForPromises();
});
it('opens the form again', () => {
expect(findCommentForm().exists()).toBe(true);
});
it('updates the saved draft with the latest comment text', () => {
expect(updateDraft).toHaveBeenCalledWith(
`${mockWorkItemCommentNote.id}-comment`,
updatedNoteText,
);
});
it('emits an error', () => {
expect(wrapper.emitted('error')).toHaveLength(1);
});
});
});
describe('when not editing', () => {
it('should not render a comment form', () => {
createComponent();
expect(findCommentForm().exists()).toBe(false);
});
it('should render note wrapper', () => {
createComponent();
expect(findNoteWrapper().exists()).toBe(true);
});
it('renders no "edited at" information by default', () => {
createComponent();
expect(findEditedAt().exists()).toBe(false);
});
it('renders "edited at" information if the note was edited', () => {
createComponent({
note: {
...mockWorkItemCommentNote,
lastEditedAt: '2023-02-12T07:47:40Z',
lastEditedBy: { ...mockWorkItemCommentNote.author, webPath: 'test-path' },
},
});
expect(findEditedAt().exists()).toBe(true);
expect(findEditedAt().props()).toEqual({
updatedAt: '2023-02-12T07:47:40Z',
updatedByName: 'Administrator',
updatedByPath: 'test-path',
});
});
describe('main comment', () => {
beforeEach(() => {
createComponent({ isFirstNote: true });
});
it('Should have the note header, actions and body', () => {
it('should have the note header, actions and body', () => {
expect(findTimelineEntryItem().exists()).toBe(true);
expect(findNoteHeader().exists()).toBe(true);
expect(findNoteBody().exists()).toBe(true);
expect(findNoteActions().exists()).toBe(true);
});
it('Should not have the Avatar link for main thread inside the timeline-entry', () => {
it('should not have the Avatar link for main thread inside the timeline-entry', () => {
expect(findAuthorAvatarLink().exists()).toBe(false);
});
it('Should have the reply button props', () => {
it('should have the reply button props', () => {
expect(findNoteActions().props('showReply')).toBe(true);
});
});
describe('Comment threads', () => {
describe('comment threads', () => {
beforeEach(() => {
createComponent();
});
it('Should have the note header, actions and body', () => {
it('should have the note header, actions and body', () => {
expect(findTimelineEntryItem().exists()).toBe(true);
expect(findNoteHeader().exists()).toBe(true);
expect(findNoteBody().exists()).toBe(true);
expect(findNoteActions().exists()).toBe(true);
});
it('Should have the Avatar link for comment threads', () => {
it('should have the Avatar link for comment threads', () => {
expect(findAuthorAvatarLink().exists()).toBe(true);
});
it('Should not have the reply button props', () => {
it('should not have the reply button props', () => {
expect(findNoteActions().props('showReply')).toBe(false);
});
});
it('should display a dropdown if user has a permission to delete note', () => {
it('should display a dropdown if user has a permission to delete a note', () => {
createComponent({
note: {
...mockWorkItemCommentNote,
@ -80,7 +239,7 @@ describe('Work Item Note', () => {
expect(findDropdown().exists()).toBe(true);
});
it('should not display a dropdown if user has no permission to delete note', () => {
it('should not display a dropdown if user has no permission to delete a note', () => {
createComponent();
expect(findDropdown().exists()).toBe(false);
@ -98,4 +257,5 @@ describe('Work Item Note', () => {
expect(wrapper.emitted('deleteNote')).toEqual([[]]);
});
});
});

View File

@ -8,7 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import SystemNote from '~/work_items/components/notes/system_note.vue';
import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue';
import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue';
import ActivityFilter from '~/work_items/components/notes/activity_filter.vue';
import workItemNotesQuery from '~/work_items/graphql/notes/work_item_notes.query.graphql';
import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql';
@ -54,7 +54,7 @@ describe('WorkItemNotes component', () => {
const findAllSystemNotes = () => wrapper.findAllComponents(SystemNote);
const findAllListItems = () => wrapper.findAll('ul.timeline > *');
const findActivityLabel = () => wrapper.find('label');
const findWorkItemCommentForm = () => wrapper.findComponent(WorkItemCommentForm);
const findWorkItemAddNote = () => wrapper.findComponent(WorkItemAddNote);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findSortingFilter = () => wrapper.findComponent(ActivityFilter);
const findSystemNoteAtIndex = (index) => findAllSystemNotes().at(index);
@ -123,7 +123,7 @@ describe('WorkItemNotes component', () => {
});
await waitForPromises();
expect(findWorkItemCommentForm().props('fetchByIid')).toEqual(false);
expect(findWorkItemAddNote().props('fetchByIid')).toEqual(false);
});
describe('when notes are loading', () => {
@ -161,7 +161,7 @@ describe('WorkItemNotes component', () => {
});
it('passes correct props to comment form component', () => {
expect(findWorkItemCommentForm().props('fetchByIid')).toEqual(true);
expect(findWorkItemAddNote().props('fetchByIid')).toEqual(true);
});
});
@ -218,13 +218,13 @@ describe('WorkItemNotes component', () => {
it('puts form at start of list in when sorting by newest first', async () => {
await findSortingFilter().vm.$emit('changeSortOrder', DESC);
expect(findAllListItems().at(0).is(WorkItemCommentForm)).toEqual(true);
expect(findAllListItems().at(0).is(WorkItemAddNote)).toEqual(true);
});
it('puts form at end of list in when sorting by oldest first', async () => {
await findSortingFilter().vm.$emit('changeSortOrder', ASC);
expect(findAllListItems().at(-1).is(WorkItemCommentForm)).toEqual(true);
expect(findAllListItems().at(-1).is(WorkItemAddNote)).toEqual(true);
});
});

View File

@ -1631,7 +1631,7 @@ export const projectWorkItemResponse = {
export const mockWorkItemNotesResponse = {
data: {
workItem: {
id: 'gid://gitlab/WorkItem/600',
id: 'gid://gitlab/WorkItem/1',
iid: '60',
widgets: [
{
@ -1675,10 +1675,13 @@ export const mockWorkItemNotesResponse = {
nodes: [
{
id: 'gid://gitlab/Note/2428',
body: 'added #31 as parent issue',
bodyHtml:
'<p data-sourcepos="1:1-1:25" dir="auto">added <a href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container=body data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue">#31</a> as parent issue</p>',
systemNoteIconName: 'link',
createdAt: '2022-11-14T04:18:59Z',
lastEditedAt: null,
lastEditedBy: null,
system: true,
internal: false,
discussion: {
@ -1715,10 +1718,13 @@ export const mockWorkItemNotesResponse = {
nodes: [
{
id: 'gid://gitlab/MilestoneNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864',
body: 'changed milestone to %v4.0',
bodyHtml:
'<p data-sourcepos="1:1-1:23" dir="auto">changed milestone to <a href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container=body data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a></p>',
systemNoteIconName: 'clock',
createdAt: '2022-11-14T04:18:59Z',
lastEditedAt: null,
lastEditedBy: null,
system: true,
internal: false,
discussion: {
@ -1755,9 +1761,12 @@ export const mockWorkItemNotesResponse = {
nodes: [
{
id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a9883864',
body: 'changed weight to **89**',
bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>',
systemNoteIconName: 'weight',
createdAt: '2022-11-25T07:16:20Z',
lastEditedAt: null,
lastEditedBy: null,
system: true,
internal: false,
discussion: {
@ -1853,10 +1862,13 @@ export const mockWorkItemNotesByIidResponse = {
nodes: [
{
id: 'gid://gitlab/Note/2428',
body: 'added as parent issue',
bodyHtml:
'\u003cp data-sourcepos="1:1-1:25" dir="auto"\u003eadded \u003ca href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container="body" data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue"\u003e#31\u003c/a\u003e as parent issue\u003c/p\u003e',
systemNoteIconName: 'link',
createdAt: '2022-11-14T04:18:59Z',
lastEditedAt: null,
lastEditedBy: null,
system: true,
internal: false,
discussion: {
@ -1895,10 +1907,13 @@ export const mockWorkItemNotesByIidResponse = {
{
id:
'gid://gitlab/MilestoneNote/7b08b89a728a5ceb7de8334246837ba1d07270dc',
body: 'changed milestone to %v4.0',
bodyHtml:
'\u003cp data-sourcepos="1:1-1:23" dir="auto"\u003echanged milestone to \u003ca href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container="body" data-placement="top" title="" class="gfm gfm-milestone has-tooltip"\u003e%v4.0\u003c/a\u003e\u003c/p\u003e',
systemNoteIconName: 'clock',
createdAt: '2022-11-14T04:18:59Z',
lastEditedAt: null,
lastEditedBy: null,
system: true,
internal: false,
discussion: {
@ -1937,10 +1952,14 @@ export const mockWorkItemNotesByIidResponse = {
{
id:
'gid://gitlab/IterationNote/addbc177f7664699a135130ab05ffb78c57e4db3',
body:
'changed iteration to Et autem debitis nam suscipit eos ut. Jul 13, 2022 - Jul 19, 2022',
bodyHtml:
'\u003cp data-sourcepos="1:1-1:36" dir="auto"\u003echanged iteration to \u003ca href="/groups/flightjs/-/iterations/5352" data-reference-type="iteration" data-original="*iteration:5352" data-link="false" data-link-reference="false" data-project="6" data-iteration="5352" data-container="body" data-placement="top" title="Iteration" class="gfm gfm-iteration has-tooltip"\u003eEt autem debitis nam suscipit eos ut. Jul 13, 2022 - Jul 19, 2022\u003c/a\u003e\u003c/p\u003e',
systemNoteIconName: 'iteration',
createdAt: '2022-11-14T04:19:00Z',
lastEditedAt: null,
lastEditedBy: null,
system: true,
internal: false,
discussion: {
@ -2034,10 +2053,13 @@ export const mockMoreWorkItemNotesResponse = {
nodes: [
{
id: 'gid://gitlab/Note/2428',
body: 'added #31 as parent issue',
bodyHtml:
'<p data-sourcepos="1:1-1:25" dir="auto">added <a href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container=body data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue">#31</a> as parent issue</p>',
systemNoteIconName: 'link',
createdAt: '2022-11-14T04:18:59Z',
lastEditedAt: null,
lastEditedBy: null,
system: true,
internal: false,
discussion: {
@ -2074,10 +2096,13 @@ export const mockMoreWorkItemNotesResponse = {
nodes: [
{
id: 'gid://gitlab/MilestoneNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83823',
body: 'changed milestone to %v4.0',
bodyHtml:
'<p data-sourcepos="1:1-1:23" dir="auto">changed milestone to <a href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container=body data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a></p>',
systemNoteIconName: 'clock',
createdAt: '2022-11-14T04:18:59Z',
lastEditedAt: null,
lastEditedBy: null,
system: true,
internal: false,
discussion: {
@ -2114,9 +2139,12 @@ export const mockMoreWorkItemNotesResponse = {
nodes: [
{
id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864',
body: 'changed weight to **89**',
bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>',
systemNoteIconName: 'weight',
createdAt: '2022-11-25T07:16:20Z',
lastEditedAt: null,
lastEditedBy: null,
system: true,
internal: false,
discussion: {
@ -2163,17 +2191,21 @@ export const createWorkItemNoteResponse = {
createNote: {
errors: [],
note: {
id: 'gid://gitlab/Note/569',
discussion: {
id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
notes: {
nodes: [
{
id: 'gid://gitlab/Note/569',
body: 'Main comment',
bodyHtml: '<p data-sourcepos="1:1-1:9" dir="auto">Main comment</p>',
system: false,
internal: false,
systemNoteIconName: null,
createdAt: '2023-01-25T04:49:46Z',
lastEditedAt: null,
lastEditedBy: null,
discussion: {
id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
__typename: 'Discussion',
@ -2214,10 +2246,13 @@ export const createWorkItemNoteResponse = {
export const mockWorkItemCommentNote = {
id: 'gid://gitlab/Note/158',
body: 'How are you ? what do you think about this ?',
bodyHtml:
'<p data-sourcepos="1:1-1:76" dir="auto"><gl-emoji title="waving hand sign" data-name="wave" data-unicode-version="6.0">👋</gl-emoji> Hi <a href="/fredda.brekke" data-reference-type="user" data-user="3" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="Sherie Nitzsche">@fredda.brekke</a> How are you ? what do you think about this ? <gl-emoji title="person with folded hands" data-name="pray" data-unicode-version="6.0">🙏</gl-emoji></p>',
systemNoteIconName: false,
createdAt: '2022-11-25T07:16:20Z',
lastEditedAt: null,
lastEditedBy: null,
system: false,
internal: false,
discussion: {
@ -2289,11 +2324,14 @@ export const mockWorkItemNotesResponseWithComments = {
nodes: [
{
id: 'gid://gitlab/DiscussionNote/174',
body: 'Separate thread',
bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Separate thread</p>',
system: false,
internal: false,
systemNoteIconName: null,
createdAt: '2023-01-12T07:47:40Z',
lastEditedAt: null,
lastEditedBy: null,
discussion: {
id: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
__typename: 'Discussion',
@ -2320,11 +2358,14 @@ export const mockWorkItemNotesResponseWithComments = {
},
{
id: 'gid://gitlab/DiscussionNote/235',
body: 'Thread comment',
bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Thread comment</p>',
system: false,
internal: false,
systemNoteIconName: null,
createdAt: '2023-01-18T09:09:54Z',
lastEditedAt: null,
lastEditedBy: null,
discussion: {
id: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
__typename: 'Discussion',
@ -2360,9 +2401,12 @@ export const mockWorkItemNotesResponseWithComments = {
nodes: [
{
id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a9883864',
body: 'Main thread 2',
bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Main thread 2</p>',
systemNoteIconName: 'weight',
createdAt: '2022-11-25T07:16:20Z',
lastEditedAt: null,
lastEditedBy: null,
system: false,
internal: false,
discussion: {

View File

@ -91,34 +91,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ
end
end
describe 'preloading deployment associations' do
let!(:environment) { create(:environment, project: project) }
associations = [:last_deployment, :last_visible_deployment, :upcoming_deployment]
associations.concat Deployment::FINISHED_STATUSES.map { |status| "last_#{status}_deployment".to_sym }
associations.concat Deployment::UPCOMING_STATUSES.map { |status| "last_#{status}_deployment".to_sym }
context 'raises error for legacy approach' do
let!(:error_pattern) { /Preloading instance dependent scopes is not supported/ }
subject { described_class.preload(association_name).find_by(id: environment) }
shared_examples 'raises error' do
it do
expect { subject }.to raise_error(error_pattern)
end
end
associations.each do |association|
context association.to_s do
let!(:association_name) { association }
include_examples "raises error"
end
end
end
end
describe 'validate and sanitize external url' do
let_it_be_with_refind(:environment) { create(:environment) }