diff options
33 files changed, 460 insertions, 554 deletions
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue index df07038151e..c39a72a45b9 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue @@ -27,25 +27,12 @@ export default { <gl-dropdown-item :key="user.username" data-testid="assigneeDropdownItem" - class="assignee-dropdown-item gl-vertical-align-middle" :active="active" active-class="is-active" + :avatar-url="user.avatar_url" + :secondary-text="`@${user.username}`" @click="$emit('update-alert-assignees', user.username)" > - <span class="gl-relative mr-2"> - <img - :alt="user.username" - :src="user.avatar_url" - :width="32" - class="avatar avatar-inline gl-m-0 s32" - data-qa-selector="avatar_image" - /> - </span> - <span class="d-flex gl-flex-direction-column gl-overflow-hidden"> - <strong class="dropdown-menu-user-full-name"> - {{ user.name }} - </strong> - <span class="dropdown-menu-user-username"> {{ user.username }}</span> - </span> + {{ user.name }} </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue index 5e4fd56738b..3af68d42ddf 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue @@ -13,7 +13,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.mutation.graphql'; import SidebarAssignee from './sidebar_assignee.vue'; @@ -96,7 +96,10 @@ export default { .sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1)); // eslint-disable-line no-nested-ternary }, dropdownClass() { - return this.isDropdownShowing ? 'show' : 'gl-display-none'; + return this.isDropdownShowing ? 'dropdown-menu-selectable show' : 'gl-display-none'; + }, + dropDownTitle() { + return this.userName ?? __('Select assignee'); }, userListValid() { return !this.isDropdownSearching && this.users.length > 0; @@ -217,81 +220,80 @@ export default { </a> </p> - <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-dropdown - ref="dropdown" - :text="userName" - class="w-100" - toggle-class="dropdown-menu-toggle" - @keydown.esc.native="hideDropdown" - @hide="hideDropdown" - > - <p class="gl-new-dropdown-header-top"> - {{ __('Assign To') }} - </p> - <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" /> - <div class="dropdown-content dropdown-body"> - <template v-if="userListValid"> - <gl-dropdown-item - :active="!userName" - active-class="is-active" - @click="updateAlertAssignees('')" - > - {{ __('Unassigned') }} - </gl-dropdown-item> - <gl-dropdown-divider /> - - <gl-dropdown-section-header> - {{ __('Assignee') }} - </gl-dropdown-section-header> - <sidebar-assignee - v-for="user in sortedUsers" - :key="user.username" - :user="user" - :active="user.active" - @update-alert-assignees="updateAlertAssignees" - /> - </template> - <p v-else-if="userListEmpty" class="mx-3 my-2"> - {{ __('No Matching Results') }} - </p> - <gl-loading-icon v-else /> - </div> - </gl-dropdown> - </div> + <gl-dropdown + ref="dropdown" + :text="dropDownTitle" + class="gl-w-full" + :class="dropdownClass" + toggle-class="dropdown-menu-toggle" + @keydown.esc.native="hideDropdown" + @hide="hideDropdown" + > + <p class="gl-new-dropdown-header-top"> + {{ __('Assign To') }} + </p> + <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" /> + <div class="dropdown-content dropdown-body"> + <template v-if="userListValid"> + <gl-dropdown-item + :active="!userName" + active-class="is-active" + @click="updateAlertAssignees('')" + > + {{ __('Unassigned') }} + </gl-dropdown-item> + <gl-dropdown-divider /> - <gl-loading-icon v-if="isUpdating" :inline="true" /> - <div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }"> - <div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users"> - <span class="gl-relative mr-2"> - <img - :alt="userName" - :src="userImg" - :width="32" - class="avatar avatar-inline gl-m-0 s32" - data-qa-selector="avatar_image" + <gl-dropdown-section-header> + {{ __('Assignee') }} + </gl-dropdown-section-header> + <sidebar-assignee + v-for="user in sortedUsers" + :key="user.username" + :user="user" + :active="user.active" + @update-alert-assignees="updateAlertAssignees" /> - </span> - <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden"> - <strong class="dropdown-menu-user-full-name"> - {{ userFullName }} - </strong> - <span class="dropdown-menu-user-username">{{ userName }}</span> - </span> + </template> + <p v-else-if="userListEmpty" class="gl-mx-5 gl-my-4"> + {{ __('No Matching Results') }} + </p> + <gl-loading-icon v-else /> </div> - <span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal"> - {{ __('None') }} - - <gl-button - class="gl-ml-2" - href="#" - variant="link" - data-testid="unassigned-users" - @click="updateAlertAssignees(currentUser)" - > - {{ __('assign yourself') }} - </gl-button> + </gl-dropdown> + </div> + + <gl-loading-icon v-if="isUpdating" :inline="true" /> + <div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }"> + <div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users"> + <span class="gl-relative gl-mr-4"> + <img + :alt="userName" + :src="userImg" + :width="32" + class="avatar avatar-inline gl-m-0 s32" + data-qa-selector="avatar_image" + /> + </span> + <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden"> + <strong class="dropdown-menu-user-full-name"> + {{ userFullName }} + </strong> + <span class="dropdown-menu-user-username">@{{ userName }}</span> </span> </div> + <span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal"> + {{ __('None') }} - + <gl-button + class="gl-ml-2" + href="#" + variant="link" + data-testid="unassigned-users" + @click="updateAlertAssignees(currentUser)" + > + {{ __('assign yourself') }} + </gl-button> + </span> </div> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue index 4c8c86390f4..a7b05c93c61 100644 --- a/app/assets/javascripts/issue_show/components/header_actions.vue +++ b/app/assets/javascripts/issue_show/components/header_actions.vue @@ -1,12 +1,13 @@ <script> import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash, { FLASH_TYPES } from '~/flash'; import { IssuableType } from '~/issuable_show/constants'; import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; +import eventHub from '~/notes/event_hub'; import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql'; import updateIssueMutation from '../queries/update_issue.mutation.graphql'; @@ -72,15 +73,11 @@ export default { default: '', }, }, - data() { - return { - isUpdatingState: false, - }; - }, computed: { - ...mapGetters(['getNoteableData']), + ...mapState(['isToggleStateButtonLoading']), + ...mapGetters(['openState', 'getBlockedByIssues']), isClosed() { - return this.getNoteableData.state === IssuableStatus.Closed; + return this.openState === IssuableStatus.Closed; }, buttonText() { return this.isClosed @@ -107,9 +104,16 @@ export default { return canClose || canReopen; }, }, + created() { + eventHub.$on('toggle.issuable.state', this.toggleIssueState); + }, + beforeDestroy() { + eventHub.$off('toggle.issuable.state', this.toggleIssueState); + }, methods: { + ...mapActions(['toggleStateButtonLoading']), toggleIssueState() { - if (!this.isClosed && this.getNoteableData?.blocked_by_issues?.length) { + if (!this.isClosed && this.getBlockedByIssues.length) { this.$refs.blockedByIssuesModal.show(); return; } @@ -117,7 +121,7 @@ export default { this.invokeUpdateIssueMutation(); }, invokeUpdateIssueMutation() { - this.isUpdatingState = true; + this.toggleStateButtonLoading(true); this.$apollo .mutate({ @@ -148,11 +152,11 @@ export default { }) .catch(() => createFlash({ message: __('Update failed. Please try again.') })) .finally(() => { - this.isUpdatingState = false; + this.toggleStateButtonLoading(false); }); }, promoteToEpic() { - this.isUpdatingState = true; + this.toggleStateButtonLoading(true); this.$apollo .mutate({ @@ -179,7 +183,7 @@ export default { }) .catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage })) .finally(() => { - this.isUpdatingState = false; + this.toggleStateButtonLoading(false); }); }, }, @@ -191,7 +195,7 @@ export default { <gl-dropdown class="gl-display-block gl-display-sm-none!" block :text="dropdownText"> <gl-dropdown-item v-if="showToggleIssueStateButton" - :disabled="isUpdatingState" + :disabled="isToggleStateButtonLoading" @click="toggleIssueState" > {{ buttonText }} @@ -199,7 +203,11 @@ export default { <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> {{ newIssueTypeText }} </gl-dropdown-item> - <gl-dropdown-item v-if="canPromoteToEpic" :disabled="isUpdatingState" @click="promoteToEpic"> + <gl-dropdown-item + v-if="canPromoteToEpic" + :disabled="isToggleStateButtonLoading" + @click="promoteToEpic" + > {{ __('Promote to epic') }} </gl-dropdown-item> <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> @@ -220,7 +228,7 @@ export default { class="gl-display-none gl-display-sm-inline-flex!" category="secondary" :data-qa-selector="qaSelector" - :loading="isUpdatingState" + :loading="isToggleStateButtonLoading" :variant="buttonVariant" @click="toggleIssueState" > @@ -243,7 +251,7 @@ export default { </gl-dropdown-item> <gl-dropdown-item v-if="canPromoteToEpic" - :disabled="isUpdatingState" + :disabled="isToggleStateButtonLoading" data-testid="promote-button" @click="promoteToEpic" > @@ -272,7 +280,7 @@ export default { > <p>{{ __('This issue is currently blocked by the following issues:') }}</p> <ul> - <li v-for="issue in getNoteableData.blocked_by_issues" :key="issue.iid"> + <li v-for="issue in getBlockedByIssues" :key="issue.iid"> <gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link> </li> </ul> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 9cc53a320b8..b70e999c765 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -3,23 +3,23 @@ import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import { isEmpty } from 'lodash'; import Autosize from 'autosize'; -import { GlAlert, GlIntersperse, GlLink, GlSprintf, GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import { deprecatedCreateFlash as Flash } from '../../flash'; -import Autosave from '../../autosave'; +import { deprecatedCreateFlash as Flash } from '~/flash'; +import Autosave from '~/autosave'; import { capitalizeFirstCharacter, convertToCamelCase, splitCamelCase, slugifyWithUnderscore, -} from '../../lib/utils/text_utility'; +} from '~/lib/utils/text_utility'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import * as constants from '../constants'; import eventHub from '../event_hub'; -import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue'; -import markdownField from '../../vue_shared/components/markdown/field.vue'; -import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; +import markdownField from '~/vue_shared/components/markdown/field.vue'; +import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; import discussionLockedWidget from './discussion_locked_widget.vue'; import issuableStateMixin from '../mixins/issuable_state'; @@ -34,10 +34,6 @@ export default { userAvatarLink, GlButton, TimelineEntryItem, - GlAlert, - GlIntersperse, - GlLink, - GlSprintf, GlIcon, }, mixins: [issuableStateMixin], @@ -63,9 +59,8 @@ export default { 'getNoteableDataByProp', 'getNotesData', 'openState', - 'getBlockedByIssues', ]), - ...mapState(['isToggleStateButtonLoading', 'isToggleBlockedIssueWarning']), + ...mapState(['isToggleStateButtonLoading']), noteableDisplayName() { return splitCamelCase(this.noteableType).toLowerCase(); }, @@ -143,8 +138,8 @@ export default { ? __('merge request') : __('issue'); }, - isIssueType() { - return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE; + isMergeRequest() { + return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE; }, trackingLabel() { return slugifyWithUnderscore(`${this.commentButtonTitle} button`); @@ -172,11 +167,9 @@ export default { 'stopPolling', 'restartPolling', 'removePlaceholderNotes', - 'closeIssue', - 'reopenIssue', + 'closeMergeRequest', + 'reopenMergeRequest', 'toggleIssueLocalState', - 'toggleStateButtonLoading', - 'toggleBlockedIssueWarning', ]), setIsSubmitButtonDisabled(note, isSubmitting) { if (!isEmpty(note) && !isSubmitting) { @@ -186,8 +179,6 @@ export default { } }, handleSave(withIssueAction) { - this.isSubmitting = true; - if (this.note.length) { const noteData = { endpoint: this.endpoint, @@ -210,9 +201,10 @@ export default { this.resizeTextarea(); this.stopPolling(); + this.isSubmitting = true; + this.saveNote(noteData) .then(() => { - this.enableButton(); this.restartPolling(); this.discard(); @@ -221,7 +213,6 @@ export default { } }) .catch(() => { - this.enableButton(); this.discard(false); const msg = __( 'Your comment could not be submitted! Please check your network connection and try again.', @@ -229,64 +220,31 @@ export default { Flash(msg, 'alert', this.$el); this.note = noteData.data.note.note; // Restore textarea content. this.removePlaceholderNotes(); + }) + .finally(() => { + this.isSubmitting = false; }); } else { this.toggleIssueState(); } }, - enableButton() { - this.isSubmitting = false; - }, toggleIssueState() { - if ( - this.noteableType.toLowerCase() === constants.ISSUE_NOTEABLE_TYPE && - this.isOpen && - this.getBlockedByIssues && - this.getBlockedByIssues.length > 0 - ) { - this.toggleBlockedIssueWarning(true); + if (!this.isMergeRequest) { + eventHub.$emit('toggle.issuable.state'); return; } - if (this.isOpen) { - this.forceCloseIssue(); - } else { - this.reopenIssue() - .then(() => { - this.enableButton(); - refreshUserMergeRequestCounts(); - }) - .catch(({ data }) => { - this.enableButton(); - this.toggleStateButtonLoading(false); - let errorMessage = sprintf( - __('Something went wrong while reopening the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, - ); - if (data) { - errorMessage = Object.values(data).join('\n'); - } + const toggleMergeRequestState = this.isOpen + ? this.closeMergeRequest + : this.reopenMergeRequest; - Flash(errorMessage); - }); - } - }, - forceCloseIssue() { - this.closeIssue() - .then(() => { - this.enableButton(); - refreshUserMergeRequestCounts(); - }) - .catch(() => { - this.enableButton(); - this.toggleStateButtonLoading(false); - Flash( - sprintf( - __('Something went wrong while closing the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, - ), - ); - }); + const errorMessage = this.isOpen + ? __('Something went wrong while closing the merge request. Please try again later') + : __('Something went wrong while reopening the merge request. Please try again later'); + + toggleMergeRequestState() + .then(refreshUserMergeRequestCounts) + .catch(() => Flash(errorMessage)); }, discard(shouldClear = true) { // `blur` is needed to clear slash commands autocomplete cache if event fired. @@ -384,6 +342,7 @@ export default { name="note[note]" class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area" data-qa-selector="comment_field" + data-testid="comment-field" data-supports-quick-actions="true" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @@ -392,36 +351,7 @@ export default { @keydown.ctrl.enter="handleSave()" ></textarea> </markdown-field> - <gl-alert - v-if="isToggleBlockedIssueWarning" - class="gl-mt-5" - :title="__('Are you sure you want to close this blocked issue?')" - :primary-button-text="__('Yes, close issue')" - :secondary-button-text="__('Cancel')" - variant="warning" - :dismissible="false" - @primaryAction="toggleBlockedIssueWarning(false) && forceCloseIssue()" - @secondaryAction="toggleBlockedIssueWarning(false) && enableButton()" - > - <p> - <gl-sprintf - :message=" - __('This issue is currently blocked by the following issues: %{issues}.') - " - > - <template #issues> - <gl-intersperse> - <gl-link - v-for="blockingIssue in getBlockedByIssues" - :key="blockingIssue.web_url" - :href="blockingIssue.web_url" - >#{{ blockingIssue.iid }}</gl-link - > - </gl-intersperse> - </template> - </gl-sprintf> - </p> - </gl-alert> + <div class="note-form-actions"> <div class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" @@ -430,6 +360,7 @@ export default { :disabled="isSubmitButtonDisabled" class="js-comment-button js-comment-submit-button" data-qa-selector="comment_button" + data-testid="comment-button" type="submit" category="primary" variant="success" @@ -488,15 +419,13 @@ export default { </div> <gl-button - v-if="canToggleIssueState && !isToggleBlockedIssueWarning" + v-if="canToggleIssueState" :loading="isToggleStateButtonLoading" category="secondary" :variant="buttonVariant" - :class="[ - actionButtonClassNames, - 'btn-comment btn-comment-and-close js-action-button', - ]" - :disabled="isToggleStateButtonLoading || isSubmitting" + :class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']" + :disabled="isSubmitting" + data-testid="close-reopen-button" @click="handleSave(true)" >{{ issueActionButtonTitle }}</gl-button > diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index ee668f4406f..f62b17de10c 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -244,21 +244,7 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, }); }; -export const toggleBlockedIssueWarning = ({ commit }, value) => { - commit(types.TOGGLE_BLOCKED_ISSUE_WARNING, value); - // Hides Close issue button at the top of issue page - const closeDropdown = document.querySelector('.js-issuable-close-dropdown'); - if (closeDropdown) { - closeDropdown.classList.toggle('d-none'); - } else { - const closeButton = document.querySelector( - '.detail-page-header-actions .btn-close.btn-grouped', - ); - closeButton.classList.toggle('d-md-block'); - } -}; - -export const closeIssue = ({ commit, dispatch, state }) => { +export const closeMergeRequest = ({ commit, dispatch, state }) => { dispatch('toggleStateButtonLoading', true); return axios.put(state.notesData.closePath).then(({ data }) => { commit(types.CLOSE_ISSUE); @@ -267,7 +253,7 @@ export const closeIssue = ({ commit, dispatch, state }) => { }); }; -export const reopenIssue = ({ commit, dispatch, state }) => { +export const reopenMergeRequest = ({ commit, dispatch, state }) => { dispatch('toggleStateButtonLoading', true); return axios.put(state.notesData.reopenPath).then(({ data }) => { commit(types.REOPEN_ISSUE); diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 3194a2099ea..4421a84a6b1 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -26,7 +26,6 @@ export default () => ({ // View layer isToggleStateButtonLoading: false, - isToggleBlockedIssueWarning: false, isNotesFetched: false, isLoading: true, isLoadingDescriptionVersion: false, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 8270f2a225b..5c4f62f4575 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -44,7 +44,6 @@ export const SET_RESOLVING_DISCUSSION = 'SET_RESOLVING_DISCUSSION'; export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const REOPEN_ISSUE = 'REOPEN_ISSUE'; export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; -export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING'; export const SET_ISSUE_CONFIDENTIAL = 'SET_ISSUE_CONFIDENTIAL'; export const SET_ISSUABLE_LOCK = 'SET_ISSUABLE_LOCK'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 85bdf60e8f9..53387b2eaff 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -305,10 +305,6 @@ export default { Object.assign(state, { isToggleStateButtonLoading: value }); }, - [types.TOGGLE_BLOCKED_ISSUE_WARNING](state, value) { - Object.assign(state, { isToggleBlockedIssueWarning: value }); - }, - [types.SET_NOTES_FETCHED_STATE](state, value) { Object.assign(state, { isNotesFetched: value }); }, diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue index c45666e69eb..fb61c13983f 100644 --- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue @@ -1,10 +1,13 @@ <script> import Tribute from 'tributejs'; +import { + GfmAutocompleteType, + tributeConfig, +} from 'ee_else_ce/vue_shared/components/gfm_autocomplete/utils'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import SidebarMediator from '~/sidebar/sidebar_mediator'; -import { GfmAutocompleteType, tributeConfig } from '~/vue_shared/components/gfm_autocomplete/utils'; export default { errorMessage: __( diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js index b2e995d0f17..2581888b504 100644 --- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js @@ -24,7 +24,7 @@ export const tributeConfig = { [GfmAutocompleteType.Issues]: { config: { trigger: '#', - lookup: value => value.iid + value.title, + lookup: value => `${value.iid}${value.title}`, menuItemTemplate: ({ original }) => `<small>${original.reference || original.iid}</small> ${escape(original.title)}`, selectTemplate: ({ original }) => original.reference || `#${original.iid}`, @@ -61,7 +61,7 @@ export const tributeConfig = { trigger: '@', fillAttr: 'username', lookup: value => - value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username, + value.type === groupType ? last(value.name.split(' / ')) : `${value.name}${value.username}`, menuItemTemplate: ({ original }) => { const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; const noAvatarClasses = `${commonClasses} gl-rounded-small @@ -115,7 +115,7 @@ export const tributeConfig = { [GfmAutocompleteType.MergeRequests]: { config: { trigger: '!', - lookup: value => value.iid + value.title, + lookup: value => `${value.iid}${value.title}`, menuItemTemplate: ({ original }) => `<small>${original.reference || original.iid}</small> ${escape(original.title)}`, selectTemplate: ({ original }) => original.reference || `!${original.iid}`, @@ -135,7 +135,7 @@ export const tributeConfig = { config: { trigger: '$', fillAttr: 'id', - lookup: value => value.id + value.title, + lookup: value => `${value.id}${value.title}`, menuItemTemplate: ({ original }) => `<small>${original.id}</small> ${escape(original.title)}`, }, }, diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 0d703545073..232a3054cd0 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -173,7 +173,7 @@ export default { members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - epics: this.enableAutocomplete, + epics: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue index c12012d8419..ad6f6e0e2e3 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -88,7 +88,7 @@ export default { }; </script> <template> - <div class="issuable-note-warning"> + <div class="issuable-note-warning" data-testid="confidential-warning"> <gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" /> <span v-if="isLockedAndConfidential" ref="lockedAndConfidential"> diff --git a/app/assets/stylesheets/page_bundles/alert_management_details.scss b/app/assets/stylesheets/page_bundles/alert_management_details.scss index beb80a14c5a..2eaf4517710 100644 --- a/app/assets/stylesheets/page_bundles/alert_management_details.scss +++ b/app/assets/stylesheets/page_bundles/alert_management_details.scss @@ -17,22 +17,19 @@ } } - .assignee-dropdown-item { - .dropdown-item { - @include gl-display-flex; - @include gl-align-items-center; - + .dropdown-item { + &:first-child { &::before { - top: 50% !important; + @include gl-pt-0; } + } - &.is-active { - &:last-child { - @include gl-border-b-gray-100; - @include gl-border-b-1; - @include gl-border-b-solid; - } - } + &::before { + @include gl-pt-8; + } + + .gl-new-dropdown-item-text-wrapper { + @include gl-py-0; } } diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index 0153ede2821..df7a574848f 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -9,11 +9,14 @@ module SnippetsActions include Gitlab::NoteableMetadata include Snippets::SendBlob include SnippetsSort + include RedisTracking included do skip_before_action :verify_authenticity_token, if: -> { action_name == 'show' && js_request? } + track_redis_hll_event :show, name: 'i_snippets_show', feature: :usage_data_i_snippets_show, feature_default_enabled: false + respond_to :html end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 4adbd37608f..0d7ce966537 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -25,8 +25,7 @@ class ContainerRepository < ApplicationRecord .with_container_registry .select(:id) - ContainerRepository - .joins("INNER JOIN (#{project_scope.to_sql}) projects on projects.id=container_repositories.project_id") + joins("INNER JOIN (#{project_scope.to_sql}) projects on projects.id=container_repositories.project_id") end scope :for_project_id, ->(project_id) { where(project_id: project_id) } scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } diff --git a/changelogs/unreleased/255171-dropdown-alerts-replacement.yml b/changelogs/unreleased/255171-dropdown-alerts-replacement.yml new file mode 100644 index 00000000000..fed1d5dcffd --- /dev/null +++ b/changelogs/unreleased/255171-dropdown-alerts-replacement.yml @@ -0,0 +1,5 @@ +--- +title: Update alert details sidebar assignee dropdown to use correct styling and formatting +merge_request: 48285 +author: +type: fixed diff --git a/config/feature_flags/development/usage_data_i_snippets_show.yml b/config/feature_flags/development/usage_data_i_snippets_show.yml new file mode 100644 index 00000000000..01dcb4da1e9 --- /dev/null +++ b/config/feature_flags/development/usage_data_i_snippets_show.yml @@ -0,0 +1,8 @@ +--- +name: usage_data_i_snippets_show +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48113 +rollout_issue_url: +milestone: '13.7' +type: development +group: group::editor +default_enabled: false diff --git a/doc/user/group/epics/img/new_epic_form_v13.2.png b/doc/user/group/epics/img/new_epic_form_v13.2.png Binary files differdeleted file mode 100644 index ac1450ae111..00000000000 --- a/doc/user/group/epics/img/new_epic_form_v13.2.png +++ /dev/null diff --git a/doc/user/group/epics/img/new_epic_from_groups_v13.2.png b/doc/user/group/epics/img/new_epic_from_groups_v13.2.png Binary files differdeleted file mode 100644 index bb75605af60..00000000000 --- a/doc/user/group/epics/img/new_epic_from_groups_v13.2.png +++ /dev/null diff --git a/doc/user/group/epics/img/new_epic_from_groups_v13.7.png b/doc/user/group/epics/img/new_epic_from_groups_v13.7.png Binary files differnew file mode 100644 index 00000000000..3607d5c7a3f --- /dev/null +++ b/doc/user/group/epics/img/new_epic_from_groups_v13.7.png diff --git a/doc/user/group/epics/manage_epics.md b/doc/user/group/epics/manage_epics.md index 5895b611bb3..7cc49783707 100644 --- a/doc/user/group/epics/manage_epics.md +++ b/doc/user/group/epics/manage_epics.md @@ -14,42 +14,28 @@ to them. ## Create an epic -A paginated list of epics is available in each group from where you can create -a new epic. The list of epics includes also epics from all subgroups of the -selected group. From your group page: +> - The New Epic form [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/211533) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.2. +> - In [GitLab 13.7](https://gitlab.com/gitlab-org/gitlab/-/issues/229621) and later, the New Epic button on the Epics list opens the New Epic form. -### Create an epic from the epic list +To create an epic in the group you're in: -To create an epic from the epic list, in a group: +1. Get to the New Epic form: + - From the **Epics** list in your group, select the **New Epic** button. + - From an epic in your group, select the **New Epic** button. + - From anywhere, in the top menu, select **New...** (**{plus-square}**) **> New epic**. -1. Go to **{epic}** **Epics**. -1. Select **New epic**. -1. Enter a descriptive title. -1. Select **Create epic**. + ![New epic from an open epic](img/new_epic_from_groups_v13.7.png) -### Access the New Epic form +1. Fill in these fields: -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/211533) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.2. + - Title + - Description + - [Confidentiality checkbox](#make-an-epic-confidential) + - Labels + - Start date + - Due date -There are two ways to get to the New Epic form and create an epic in the group you're in: - -- From an epic in your group, select **New Epic**. -- From anywhere, in the top menu, select **plus** (**{plus-square}**) **> New epic**. - - ![New epic from an open epic](img/new_epic_from_groups_v13.2.png) - -### Elements of the New Epic form - -When you're creating a new epic, these are the fields you can fill in: - -- Title -- Description -- Confidentiality checkbox -- Labels -- Start date -- Due date - -![New epic form](img/new_epic_form_v13.2.png) +1. Select **Create epic**. You are taken to view the newly created epic. ## Edit an epic diff --git a/lib/gitlab/danger/roulette.rb b/lib/gitlab/danger/roulette.rb index 23f877b4e0f..328083f7002 100644 --- a/lib/gitlab/danger/roulette.rb +++ b/lib/gitlab/danger/roulette.rb @@ -24,7 +24,7 @@ module Gitlab # # @return [Array<Spin>] def spin(project, categories, timezone_experiment: false) - spins = categories.map do |category| + spins = categories.sort.map do |category| including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(category, timezone_experiment) spin_for_category(project, category, timezone_experiment: including_timezone) diff --git a/lib/gitlab/usage_data_counters/known_events/common.yml b/lib/gitlab/usage_data_counters/known_events/common.yml index 58b023d374c..35e74c803d7 100644 --- a/lib/gitlab/usage_data_counters/known_events/common.yml +++ b/lib/gitlab/usage_data_counters/known_events/common.yml @@ -408,3 +408,8 @@ redis_slot: ci_secrets_management aggregation: weekly feature_flag: usage_data_i_ci_secrets_management_vault_build_created +- name: i_snippets_show + category: snippets + redis_slot: snippets + aggregation: weekly + feature_flag: usage_data_i_snippets_show diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7ec22748d11..81f45cb0a91 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -25422,7 +25422,7 @@ msgstr "" msgid "Something went wrong while archiving a requirement." msgstr "" -msgid "Something went wrong while closing the %{issuable}. Please try again later" +msgid "Something went wrong while closing the merge request. Please try again later" msgstr "" msgid "Something went wrong while creating a requirement." @@ -25509,7 +25509,7 @@ msgstr "" msgid "Something went wrong while reopening a requirement." msgstr "" -msgid "Something went wrong while reopening the %{issuable}. Please try again later" +msgid "Something went wrong while reopening the merge request. Please try again later" msgstr "" msgid "Something went wrong while resolving this discussion. Please try again." diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index 993ab5d1c72..51cecb348c8 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -172,6 +172,13 @@ RSpec.describe SnippetsController do expect(assigns(:snippet)).to eq(public_snippet) expect(response).to have_gitlab_http_status(:ok) end + + it_behaves_like 'tracking unique hll events', :usage_data_i_snippets_show do + subject(:request) { get :show, params: { id: public_snippet.to_param } } + + let(:target_id) { 'i_snippets_show' } + let(:expected_type) { instance_of(String) } + end end context 'when not signed in' do diff --git a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js index 1d87301aac9..6430273ec59 100644 --- a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js +++ b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js @@ -179,7 +179,7 @@ describe('Alert Details Sidebar Assignees', () => { findAssigned() .find('.dropdown-menu-user-username') .text(), - ).toBe('root'); + ).toBe('@root'); }); }); }); diff --git a/spec/frontend/issue_show/components/header_actions_spec.js b/spec/frontend/issue_show/components/header_actions_spec.js index 67b8665a889..b9836ae7240 100644 --- a/spec/frontend/issue_show/components/header_actions_spec.js +++ b/spec/frontend/issue_show/components/header_actions_spec.js @@ -7,6 +7,7 @@ import HeaderActions from '~/issue_show/components/header_actions.vue'; import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; import promoteToEpicMutation from '~/issue_show/queries/promote_to_epic.mutation.graphql'; import * as urlUtility from '~/lib/utils/url_utility'; +import eventHub from '~/notes/event_hub'; import createStore from '~/notes/stores'; jest.mock('~/flash'); @@ -82,8 +83,10 @@ describe('HeaderActions component', () => { } = {}) => { mutateMock = jest.fn().mockResolvedValue(mutateResponse); - store.getters.getNoteableData.state = issueState; - store.getters.getNoteableData.blocked_by_issues = blockedByIssues; + store.dispatch('setNoteableData', { + blocked_by_issues: blockedByIssues, + state: issueState, + }); return shallowMount(HeaderActions, { localVue, @@ -273,6 +276,26 @@ describe('HeaderActions component', () => { }); }); + describe('when `toggle.issuable.state` event is emitted', () => { + it('invokes a method to toggle the issue state', () => { + wrapper = mountComponent({ mutateResponse: updateIssueMutationResponse }); + + eventHub.$emit('toggle.issuable.state'); + + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + stateEvent: IssueStateEvent.Close, + }, + }, + }), + ); + }); + }); + describe('modal', () => { const blockedByIssues = [ { iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' }, diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 59fa7b372ed..2f5cd2895b9 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -1,14 +1,14 @@ -import $ from 'jquery'; -import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Autosize from 'autosize'; -import { trimText } from 'helpers/text_helper'; import axios from '~/lib/utils/axios_utils'; import createStore from '~/notes/stores'; import CommentForm from '~/notes/components/comment_form.vue'; import * as constants from '~/notes/constants'; +import eventHub from '~/notes/event_hub'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import { keyboardDownEvent } from '../../issue_show/helpers'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; jest.mock('autosize'); @@ -20,17 +20,33 @@ describe('issue_comment_form component', () => { let wrapper; let axiosMock; - const setupStore = (userData, noteableData) => { - store.dispatch('setUserData', userData); + const findCloseReopenButton = () => wrapper.find('[data-testid="close-reopen-button"]'); + + const findCommentButton = () => wrapper.find('[data-testid="comment-button"]'); + + const findTextArea = () => wrapper.find('[data-testid="comment-field"]'); + + const mountComponent = ({ + initialData = {}, + noteableType = 'issue', + noteableData = noteableDataMock, + notesData = notesDataMock, + userData = userDataMock, + mountFunction = shallowMount, + } = {}) => { store.dispatch('setNoteableData', noteableData); - store.dispatch('setNotesData', notesDataMock); - }; + store.dispatch('setNotesData', notesData); + store.dispatch('setUserData', userData); - const mountComponent = (noteableType = 'issue') => { - wrapper = mount(CommentForm, { + wrapper = mountFunction(CommentForm, { propsData: { noteableType, }, + data() { + return { + ...initialData, + }; + }, store, }); }; @@ -46,168 +62,157 @@ describe('issue_comment_form component', () => { }); describe('user is logged in', () => { - beforeEach(() => { - setupStore(userDataMock, noteableDataMock); - - mountComponent(); - }); + describe('avatar', () => { + it('should render user avatar with link', () => { + mountComponent({ mountFunction: mount }); - it('should render user avatar with link', () => { - expect(wrapper.find('.timeline-icon .user-avatar-link').attributes('href')).toEqual( - userDataMock.path, - ); + expect(wrapper.find(UserAvatarLink).attributes('href')).toBe(userDataMock.path); + }); }); describe('handleSave', () => { it('should request to save note when note is entered', () => { - wrapper.vm.note = 'hello world'; - jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {})); + mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); jest.spyOn(wrapper.vm, 'resizeTextarea'); jest.spyOn(wrapper.vm, 'stopPolling'); - wrapper.vm.handleSave(); + findCloseReopenButton().trigger('click'); - expect(wrapper.vm.isSubmitting).toEqual(true); - expect(wrapper.vm.note).toEqual(''); + expect(wrapper.vm.isSubmitting).toBe(true); + expect(wrapper.vm.note).toBe(''); expect(wrapper.vm.saveNote).toHaveBeenCalled(); expect(wrapper.vm.stopPolling).toHaveBeenCalled(); expect(wrapper.vm.resizeTextarea).toHaveBeenCalled(); }); it('should toggle issue state when no note', () => { + mountComponent({ mountFunction: mount }); + jest.spyOn(wrapper.vm, 'toggleIssueState'); - wrapper.vm.handleSave(); + findCloseReopenButton().trigger('click'); expect(wrapper.vm.toggleIssueState).toHaveBeenCalled(); }); - it('should disable action button while submitting', done => { + it('should disable action button while submitting', async () => { + mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); + const saveNotePromise = Promise.resolve(); - wrapper.vm.note = 'hello world'; + jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(saveNotePromise); jest.spyOn(wrapper.vm, 'stopPolling'); - const actionButton = wrapper.find('.js-action-button'); - - wrapper.vm.handleSave(); - - wrapper.vm - .$nextTick() - .then(() => { - expect(actionButton.vm.disabled).toBeTruthy(); - }) - .then(saveNotePromise) - .then(wrapper.vm.$nextTick) - .then(() => { - expect(actionButton.vm.disabled).toBeFalsy(); - }) - .then(done) - .catch(done.fail); + const actionButton = findCloseReopenButton(); + + await actionButton.trigger('click'); + + expect(actionButton.props('disabled')).toBe(true); + + await saveNotePromise; + + await nextTick(); + + expect(actionButton.props('disabled')).toBe(false); }); }); describe('textarea', () => { - it('should render textarea with placeholder', () => { - expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual( - 'Write a comment or drag your files here…', - ); - }); + describe('general', () => { + it('should render textarea with placeholder', () => { + mountComponent({ mountFunction: mount }); - it('should make textarea disabled while requesting', done => { - const $submitButton = $(wrapper.find('.js-comment-submit-button').element); - wrapper.vm.note = 'hello world'; - jest.spyOn(wrapper.vm, 'stopPolling'); - jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {})); - - wrapper.vm.$nextTick(() => { - // Wait for wrapper.vm.note change triggered. It should enable $submitButton. - $submitButton.trigger('click'); - - wrapper.vm.$nextTick(() => { - // Wait for wrapper.isSubmitting triggered. It should disable textarea. - expect(wrapper.find('.js-main-target-form textarea').attributes('disabled')).toBe( - 'disabled', - ); - done(); - }); + expect(findTextArea().attributes('placeholder')).toBe( + 'Write a comment or drag your files here…', + ); }); - }); - it('should support quick actions', () => { - expect( - wrapper.find('.js-main-target-form textarea').attributes('data-supports-quick-actions'), - ).toBe('true'); - }); + it('should make textarea disabled while requesting', async () => { + mountComponent({ mountFunction: mount }); - it('should link to markdown docs', () => { - const { markdownDocsPath } = notesDataMock; + jest.spyOn(wrapper.vm, 'stopPolling'); + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); - expect( - wrapper - .find(`a[href="${markdownDocsPath}"]`) - .text() - .trim(), - ).toEqual('Markdown'); - }); + await wrapper.setData({ note: 'hello world' }); - it('should link to quick actions docs', () => { - const { quickActionsDocsPath } = notesDataMock; + await findCommentButton().trigger('click'); - expect( - wrapper - .find(`a[href="${quickActionsDocsPath}"]`) - .text() - .trim(), - ).toEqual('quick actions'); - }); + expect(findTextArea().attributes('disabled')).toBe('disabled'); + }); + + it('should support quick actions', () => { + mountComponent({ mountFunction: mount }); + + expect(findTextArea().attributes('data-supports-quick-actions')).toBe('true'); + }); + + it('should link to markdown docs', () => { + mountComponent({ mountFunction: mount }); + + const { markdownDocsPath } = notesDataMock; - it('should resize textarea after note discarded', done => { - jest.spyOn(wrapper.vm, 'discard'); + expect(wrapper.find(`a[href="${markdownDocsPath}"]`).text()).toBe('Markdown'); + }); + + it('should link to quick actions docs', () => { + mountComponent({ mountFunction: mount }); - wrapper.vm.note = 'foo'; - wrapper.vm.discard(); + const { quickActionsDocsPath } = notesDataMock; + + expect(wrapper.find(`a[href="${quickActionsDocsPath}"]`).text()).toBe('quick actions'); + }); + + it('should resize textarea after note discarded', async () => { + mountComponent({ mountFunction: mount, initialData: { note: 'foo' } }); + + jest.spyOn(wrapper.vm, 'discard'); + + wrapper.vm.discard(); + + await nextTick(); - wrapper.vm.$nextTick(() => { expect(Autosize.update).toHaveBeenCalled(); - done(); }); }); describe('edit mode', () => { + beforeEach(() => { + mountComponent(); + }); + it('should enter edit mode when arrow up is pressed', () => { jest.spyOn(wrapper.vm, 'editCurrentUserLastNote'); - wrapper.find('.js-main-target-form textarea').value = 'Foo'; - wrapper - .find('.js-main-target-form textarea') - .element.dispatchEvent(keyboardDownEvent(38, true)); + + findTextArea().trigger('keydown.up'); expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled(); }); it('inits autosave', () => { expect(wrapper.vm.autosave).toBeDefined(); - expect(wrapper.vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`); + expect(wrapper.vm.autosave.key).toBe(`autosave/Note/Issue/${noteableDataMock.id}`); }); }); describe('event enter', () => { + beforeEach(() => { + mountComponent(); + }); + it('should save note when cmd+enter is pressed', () => { jest.spyOn(wrapper.vm, 'handleSave'); - wrapper.find('.js-main-target-form textarea').value = 'Foo'; - wrapper - .find('.js-main-target-form textarea') - .element.dispatchEvent(keyboardDownEvent(13, true)); + + findTextArea().trigger('keydown.enter', { metaKey: true }); expect(wrapper.vm.handleSave).toHaveBeenCalled(); }); it('should save note when ctrl+enter is pressed', () => { jest.spyOn(wrapper.vm, 'handleSave'); - wrapper.find('.js-main-target-form textarea').value = 'Foo'; - wrapper - .find('.js-main-target-form textarea') - .element.dispatchEvent(keyboardDownEvent(13, false, true)); + + findTextArea().trigger('keydown.enter', { ctrlKey: true }); expect(wrapper.vm.handleSave).toHaveBeenCalled(); }); @@ -216,137 +221,147 @@ describe('issue_comment_form component', () => { describe('actions', () => { it('should be possible to close the issue', () => { - expect( - wrapper - .find('.btn-comment-and-close') - .text() - .trim(), - ).toEqual('Close issue'); + mountComponent(); + + expect(findCloseReopenButton().text()).toBe('Close issue'); }); it('should render comment button as disabled', () => { - expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toEqual( - 'disabled', - ); + mountComponent(); + + expect(findCommentButton().props('disabled')).toBe(true); }); - it('should enable comment button if it has note', done => { - wrapper.vm.note = 'Foo'; - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toBeFalsy(); - done(); - }); + it('should enable comment button if it has note', async () => { + mountComponent(); + + await wrapper.setData({ note: 'Foo' }); + + expect(findCommentButton().props('disabled')).toBe(false); }); - it('should update buttons texts when it has note', done => { - wrapper.vm.note = 'Foo'; - wrapper.vm.$nextTick(() => { - expect( - wrapper - .find('.btn-comment-and-close') - .text() - .trim(), - ).toEqual('Comment & close issue'); - - done(); - }); + it('should update buttons texts when it has note', () => { + mountComponent({ initialData: { note: 'Foo' } }); + + expect(findCloseReopenButton().text()).toBe('Comment & close issue'); }); - it('updates button text with noteable type', done => { - wrapper.setProps({ noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE }); - - wrapper.vm.$nextTick(() => { - expect( - wrapper - .find('.btn-comment-and-close') - .text() - .trim(), - ).toEqual('Close merge request'); - done(); - }); + it('updates button text with noteable type', () => { + mountComponent({ noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE }); + + expect(findCloseReopenButton().text()).toBe('Close merge request'); }); describe('when clicking close/reopen button', () => { - it('should disable button and show a loading spinner', () => { - const toggleStateButton = wrapper.find('.js-action-button'); + it('should show a loading spinner', async () => { + mountComponent({ + noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE, + mountFunction: mount, + }); - toggleStateButton.trigger('click'); + await findCloseReopenButton().trigger('click'); - return wrapper.vm.$nextTick().then(() => { - expect(toggleStateButton.element.disabled).toEqual(true); - expect(toggleStateButton.props('loading')).toBe(true); - }); + expect(findCloseReopenButton().props('loading')).toBe(true); }); }); describe('when toggling state', () => { - it('should update MR count', done => { - jest.spyOn(wrapper.vm, 'closeIssue').mockResolvedValue(); + describe('when issue', () => { + it('emits event to toggle state', () => { + mountComponent({ mountFunction: mount }); - wrapper.vm.toggleIssueState(); + jest.spyOn(eventHub, '$emit'); - wrapper.vm.$nextTick(() => { - expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); + findCloseReopenButton().trigger('click'); - done(); + expect(eventHub.$emit).toHaveBeenCalledWith('toggle.issuable.state'); + }); + }); + + describe('when merge request', () => { + describe('when open', () => { + it('makes an API call to open the merge request', () => { + mountComponent({ + noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE, + noteableData: { ...noteableDataMock, state: constants.OPENED }, + mountFunction: mount, + }); + + jest.spyOn(wrapper.vm, 'closeMergeRequest').mockResolvedValue(); + + findCloseReopenButton().trigger('click'); + + expect(wrapper.vm.closeMergeRequest).toHaveBeenCalled(); + }); + }); + + describe('when closed', () => { + it('makes an API call to close the merge request', () => { + mountComponent({ + noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE, + noteableData: { ...noteableDataMock, state: constants.CLOSED }, + mountFunction: mount, + }); + + jest.spyOn(wrapper.vm, 'reopenMergeRequest').mockResolvedValue(); + + findCloseReopenButton().trigger('click'); + + expect(wrapper.vm.reopenMergeRequest).toHaveBeenCalled(); + }); + }); + + it('should update MR count', async () => { + mountComponent({ + noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE, + mountFunction: mount, + }); + + jest.spyOn(wrapper.vm, 'closeMergeRequest').mockResolvedValue(); + + await findCloseReopenButton().trigger('click'); + + expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); }); }); }); }); describe('issue is confidential', () => { - it('shows information warning', done => { - store.dispatch('setNoteableData', Object.assign(noteableDataMock, { confidential: true })); - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.confidential-issue-warning')).toBeDefined(); - done(); + it('shows information warning', () => { + mountComponent({ + noteableData: { ...noteableDataMock, confidential: true }, + mountFunction: mount, }); + + expect(wrapper.find('[data-testid="confidential-warning"]').exists()).toBe(true); }); }); }); describe('user is not logged in', () => { beforeEach(() => { - setupStore(null, loggedOutnoteableData); - - mountComponent(); + mountComponent({ userData: null, noteableData: loggedOutnoteableData, mountFunction: mount }); }); it('should render signed out widget', () => { - expect(trimText(wrapper.text())).toEqual('Please register or sign in to reply'); + expect(wrapper.text()).toBe('Please register or sign in to reply'); }); it('should not render submission form', () => { - expect(wrapper.find('textarea').exists()).toBe(false); + expect(findTextArea().exists()).toBe(false); }); }); - describe('when issuable is open', () => { - beforeEach(() => { - setupStore(userDataMock, noteableDataMock); - }); - - it.each([['opened', 'warning'], ['reopened', 'warning']])( - 'when %i, it changes the variant of the btn to %i', - (a, expected) => { - store.state.noteableData.state = a; - - mountComponent(); - - expect(wrapper.find('.js-action-button').props('variant')).toBe(expected); - }, - ); - }); - - describe('when issuable is not open', () => { - beforeEach(() => { - setupStore(userDataMock, noteableDataMock); - - mountComponent(); - }); + describe('close/reopen button variants', () => { + it.each([ + [constants.OPENED, 'warning'], + [constants.REOPENED, 'warning'], + [constants.CLOSED, 'default'], + ])('when %s, the variant of the btn is %s', (state, expected) => { + mountComponent({ noteableData: { ...noteableDataMock, state } }); - it('should render the "default" variant of the button', () => { - expect(wrapper.find('.js-action-button').props('variant')).toBe('warning'); + expect(findCloseReopenButton().props('variant')).toBe(expected); }); }); }); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 4a1d42647f8..69c6b7d4a14 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -174,10 +174,10 @@ describe('Actions Notes Store', () => { axiosMock.onAny().reply(200, {}); }); - describe('closeIssue', () => { + describe('closeMergeRequest', () => { it('sets state as closed', done => { store - .dispatch('closeIssue', { notesData: { closeIssuePath: '' } }) + .dispatch('closeMergeRequest', { notesData: { closeIssuePath: '' } }) .then(() => { expect(store.state.noteableData.state).toEqual('closed'); expect(store.state.isToggleStateButtonLoading).toEqual(false); @@ -187,10 +187,10 @@ describe('Actions Notes Store', () => { }); }); - describe('reopenIssue', () => { + describe('reopenMergeRequest', () => { it('sets state as reopened', done => { store - .dispatch('reopenIssue', { notesData: { reopenIssuePath: '' } }) + .dispatch('reopenMergeRequest', { notesData: { reopenIssuePath: '' } }) .then(() => { expect(store.state.noteableData.state).toEqual('reopened'); expect(store.state.isToggleStateButtonLoading).toEqual(false); @@ -253,30 +253,6 @@ describe('Actions Notes Store', () => { }); }); - describe('toggleBlockedIssueWarning', () => { - it('should set issue warning as true', done => { - testAction( - actions.toggleBlockedIssueWarning, - true, - {}, - [{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: true }], - [], - done, - ); - }); - - it('should set issue warning as false', done => { - testAction( - actions.toggleBlockedIssueWarning, - false, - {}, - [{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: false }], - [], - done, - ); - }); - }); - describe('fetchData', () => { describe('given there are no notes', () => { const lastFetchedAt = '13579'; diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index 922918ef50b..ec4de925721 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -697,42 +697,6 @@ describe('Notes Store mutations', () => { }); }); - describe('TOGGLE_BLOCKED_ISSUE_WARNING', () => { - it('should set isToggleBlockedIssueWarning as true', () => { - const state = { - discussions: [], - targetNoteHash: null, - lastFetchedAt: null, - isToggleStateButtonLoading: false, - isToggleBlockedIssueWarning: false, - notesData: {}, - userData: {}, - noteableData: {}, - }; - - mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, true); - - expect(state.isToggleBlockedIssueWarning).toEqual(true); - }); - - it('should set isToggleBlockedIssueWarning as false', () => { - const state = { - discussions: [], - targetNoteHash: null, - lastFetchedAt: null, - isToggleStateButtonLoading: false, - isToggleBlockedIssueWarning: true, - notesData: {}, - userData: {}, - noteableData: {}, - }; - - mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, false); - - expect(state.isToggleBlockedIssueWarning).toEqual(false); - }); - }); - describe('SET_APPLYING_BATCH_STATE', () => { const buildDiscussions = suggestionsInfo => { const suggestions = suggestionsInfo.map(({ suggestionId }) => ({ id: suggestionId })); diff --git a/spec/lib/gitlab/danger/roulette_spec.rb b/spec/lib/gitlab/danger/roulette_spec.rb index 1a900dfba22..561e108bf31 100644 --- a/spec/lib/gitlab/danger/roulette_spec.rb +++ b/spec/lib/gitlab/danger/roulette_spec.rb @@ -165,6 +165,14 @@ RSpec.describe Gitlab::Danger::Roulette do end end + context 'when change contains many categories' do + let(:categories) { [:frontend, :test, :qa, :engineering_productivity, :ci_template, :backend] } + + it 'has a deterministic sorting order' do + expect(spins.map(&:category)).to eq categories.sort + end + end + context 'when change contains QA category' do let(:categories) { [:qa] } diff --git a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb index 579fc048663..70ee9871486 100644 --- a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb @@ -44,7 +44,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s 'golang_packages', 'debian_packages', 'container_packages', - 'tag_packages' + 'tag_packages', + 'snippets' ) end end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 746c2aef7e5..7de7916c04d 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -1235,7 +1235,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do subject { described_class.redis_hll_counters } let(:categories) { ::Gitlab::UsageDataCounters::HLLRedisCounter.categories } - let(:ineligible_total_categories) { %w[source_code testing ci_secrets_management incident_management_alerts] } + let(:ineligible_total_categories) { %w[source_code testing ci_secrets_management incident_management_alerts snippets] } it 'has all known_events' do expect(subject).to have_key(:redis_hll_counters) |