Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									e5c31c104e
								
							
						
					
					
						commit
						917d93d86d
					
				|  | @ -67,7 +67,7 @@ review-build-cng: | |||
|     GITLAB_IMAGE_REPOSITORY: "registry.gitlab.com/gitlab-org/build/cng-mirror" | ||||
|     GITLAB_IMAGE_SUFFIX: "ee" | ||||
|     GITLAB_REVIEW_APP_BASE_CONFIG_FILE: "scripts/review_apps/base-config.yaml" | ||||
|     GITLAB_HELM_CHART_REF: "eace227d3465e17e37b1a2e3764dd244c8e2d716"  # 7.6.1: https://gitlab.com/gitlab-org/charts/gitlab/-/commit/eace227d3465e17e37b1a2e3764dd244c8e2d716 | ||||
|     GITLAB_HELM_CHART_REF: "c91feed6983b24a1b0dbacaf5050ca5c59af3d46"  # 7.8.0: https://gitlab.com/gitlab-org/charts/gitlab/-/commit/c91feed6983b24a1b0dbacaf5050ca5c59af3d46 | ||||
|   environment: | ||||
|     name: review/${CI_COMMIT_REF_SLUG}${SCHEDULE_TYPE}  # No separator for SCHEDULE_TYPE so it's compatible as before and looks nice without it | ||||
|     url: https://gitlab-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN} | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ const PERSISTENT_USER_CALLOUTS = [ | |||
|   '.js-new-nav-for-everyone-callout', | ||||
|   '.js-namespace-over-storage-users-combined-alert', | ||||
|   '.js-code-suggestions-ga-alert', | ||||
|   '.js-joining-a-project-alert', | ||||
| ]; | ||||
| 
 | ||||
| const initCallouts = () => { | ||||
|  |  | |||
|  | @ -39,7 +39,7 @@ export default { | |||
|       default: () => [], | ||||
|     }, | ||||
|     itemValue: { | ||||
|       type: Object, | ||||
|       type: [Array, String], | ||||
|       required: false, | ||||
|       default: null, | ||||
|     }, | ||||
|  | @ -68,30 +68,40 @@ export default { | |||
|       required: false, | ||||
|       default: '', | ||||
|     }, | ||||
|     multiSelect: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false, | ||||
|     }, | ||||
|     showFooter: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false, | ||||
|     }, | ||||
|     infiniteScroll: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false, | ||||
|     }, | ||||
|     infiniteScrollLoading: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false, | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       isEditing: false, | ||||
|       localSelectedItem: this.itemValue?.id, | ||||
|       localSelectedItem: this.itemValue, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     hasValue() { | ||||
|       return this.itemValue != null || !isEmpty(this.item); | ||||
|     }, | ||||
|     listboxText() { | ||||
|       return ( | ||||
|         this.listItems.find(({ value }) => this.localSelectedItem === value)?.text || | ||||
|         this.itemValue?.title || | ||||
|         this.$options.i18n.none | ||||
|       ); | ||||
|       return this.multiSelect ? !isEmpty(this.itemValue) : this.itemValue !== null; | ||||
|     }, | ||||
|     inputId() { | ||||
|       return `work-item-dropdown-listbox-value-${this.dropdownName}`; | ||||
|     }, | ||||
|     toggleText() { | ||||
|       return this.toggleDropdownText || this.listboxText; | ||||
|     }, | ||||
|     resetButton() { | ||||
|       return this.resetButtonLabel || this.$options.i18n.resetButtonText; | ||||
|     }, | ||||
|  | @ -100,7 +110,7 @@ export default { | |||
|     itemValue: { | ||||
|       handler(newVal) { | ||||
|         if (!this.isEditing) { | ||||
|           this.localSelectedItem = newVal?.id; | ||||
|           this.localSelectedItem = newVal; | ||||
|         } | ||||
|       }, | ||||
|     }, | ||||
|  | @ -114,18 +124,25 @@ export default { | |||
|     }, | ||||
|     handleItemClick(item) { | ||||
|       this.localSelectedItem = item; | ||||
|       this.$emit('updateValue', item); | ||||
|       if (!this.multiSelect) { | ||||
|         this.$emit('updateValue', item); | ||||
|       } else { | ||||
|         this.$emit('updateSelected', this.localSelectedItem); | ||||
|       } | ||||
|     }, | ||||
|     onListboxShown() { | ||||
|       this.$emit('dropdownShown'); | ||||
|     }, | ||||
|     onListboxHide() { | ||||
|       this.isEditing = false; | ||||
|       if (this.multiSelect) { | ||||
|         this.$emit('updateValue', this.localSelectedItem); | ||||
|       } | ||||
|     }, | ||||
|     unassignValue() { | ||||
|       this.localSelectedItem = null; | ||||
|       this.localSelectedItem = this.multiSelect ? [] : null; | ||||
|       this.isEditing = false; | ||||
|       this.$emit('updateValue', null); | ||||
|       this.$emit('updateValue', this.localSelectedItem); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
|  | @ -165,34 +182,42 @@ export default { | |||
|       </div> | ||||
|       <gl-collapsible-listbox | ||||
|         :id="inputId" | ||||
|         :multiple="multiSelect" | ||||
|         block | ||||
|         searchable | ||||
|         start-opened | ||||
|         is-check-centered | ||||
|         fluid-width | ||||
|         :infinite-scroll="infiniteScroll" | ||||
|         :searching="loading" | ||||
|         :header-text="headerText" | ||||
|         :toggle-text="toggleText" | ||||
|         :toggle-text="toggleDropdownText" | ||||
|         :no-results-text="$options.i18n.noMatchingResults" | ||||
|         :items="listItems" | ||||
|         :selected="localSelectedItem" | ||||
|         :reset-button-label="resetButton" | ||||
|         :infinite-scroll-loading="infiniteScrollLoading" | ||||
|         @reset="unassignValue" | ||||
|         @search="debouncedSearchKeyUpdate" | ||||
|         @select="handleItemClick" | ||||
|         @shown="onListboxShown" | ||||
|         @hidden="onListboxHide" | ||||
|         @bottom-reached="$emit('bottomReached')" | ||||
|       > | ||||
|         <template #list-item="{ item }"> | ||||
|           <slot name="list-item" :item="item">{{ item.text }}</slot> | ||||
|         </template> | ||||
|         <template v-if="showFooter" #footer> | ||||
|           <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-200 gl-p-2!"> | ||||
|             <slot name="footer"></slot> | ||||
|           </div> | ||||
|         </template> | ||||
|       </gl-collapsible-listbox> | ||||
|       {{ hasValue }} | ||||
|     </gl-form> | ||||
|     <slot v-else-if="hasValue" name="readonly"> | ||||
|       {{ listboxText }} | ||||
|     </slot> | ||||
|     <div v-else class="gl-text-secondary"> | ||||
|     <slot v-else-if="hasValue" name="readonly"></slot> | ||||
|     <slot v-else class="gl-text-secondary" name="none"> | ||||
|       {{ $options.i18n.none }} | ||||
|     </div> | ||||
|     </slot> | ||||
|   </div> | ||||
| </template> | ||||
|  |  | |||
|  | @ -0,0 +1,292 @@ | |||
| <script> | ||||
| import { GlButton } from '@gitlab/ui'; | ||||
| import { isEmpty } from 'lodash'; | ||||
| import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; | ||||
| import groupUsersSearchQuery from '~/graphql_shared/queries/group_users_search.query.graphql'; | ||||
| import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; | ||||
| import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; | ||||
| import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; | ||||
| import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue'; | ||||
| import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue'; | ||||
| import { s__, sprintf, __ } from '~/locale'; | ||||
| import Tracking from '~/tracking'; | ||||
| import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; | ||||
| import { i18n, TRACKING_CATEGORY_SHOW, DEFAULT_PAGE_SIZE_ASSIGNEES } from '../constants'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     WorkItemSidebarDropdownWidgetWithEdit, | ||||
|     InviteMembersTrigger, | ||||
|     SidebarParticipant, | ||||
|     GlButton, | ||||
|     UncollapsedAssigneeList, | ||||
|   }, | ||||
|   mixins: [Tracking.mixin()], | ||||
|   inject: ['isGroup'], | ||||
|   props: { | ||||
|     fullPath: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     workItemId: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     assignees: { | ||||
|       type: Array, | ||||
|       required: true, | ||||
|     }, | ||||
|     allowsMultipleAssignees: { | ||||
|       type: Boolean, | ||||
|       required: true, | ||||
|     }, | ||||
|     workItemType: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     canUpdate: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false, | ||||
|     }, | ||||
|     canInviteMembers: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false, | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       localAssigneeIds: this.assignees.map(({ id }) => id), | ||||
|       searchStarted: false, | ||||
|       searchKey: '', | ||||
|       users: { | ||||
|         nodes: [], | ||||
|       }, | ||||
|       currentUser: null, | ||||
|       isLoadingMore: false, | ||||
|       updateInProgress: false, | ||||
|     }; | ||||
|   }, | ||||
|   apollo: { | ||||
|     users: { | ||||
|       query() { | ||||
|         return this.isGroup ? groupUsersSearchQuery : usersSearchQuery; | ||||
|       }, | ||||
|       variables() { | ||||
|         return { | ||||
|           fullPath: this.fullPath, | ||||
|           search: this.searchKey, | ||||
|           first: DEFAULT_PAGE_SIZE_ASSIGNEES, | ||||
|         }; | ||||
|       }, | ||||
|       skip() { | ||||
|         return !this.searchStarted; | ||||
|       }, | ||||
|       update(data) { | ||||
|         return data.workspace?.users; | ||||
|       }, | ||||
|       error() { | ||||
|         this.$emit('error', i18n.fetchError); | ||||
|       }, | ||||
|     }, | ||||
|     currentUser: { | ||||
|       query: currentUserQuery, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     searchUsers() { | ||||
|       return this.users.nodes.map(({ user }) => ({ | ||||
|         ...user, | ||||
|         value: user.id, | ||||
|         text: user.name, | ||||
|       })); | ||||
|     }, | ||||
|     pageInfo() { | ||||
|       return this.users.pageInfo; | ||||
|     }, | ||||
|     tracking() { | ||||
|       return { | ||||
|         category: TRACKING_CATEGORY_SHOW, | ||||
|         label: 'item_assignees', | ||||
|         property: `type_${this.workItemType}`, | ||||
|       }; | ||||
|     }, | ||||
|     isLoadingUsers() { | ||||
|       return this.$apollo.queries.users.loading && !this.isLoadingMore; | ||||
|     }, | ||||
|     hasNextPage() { | ||||
|       return this.pageInfo?.hasNextPage; | ||||
|     }, | ||||
|     selectedAssigneeIds() { | ||||
|       return this.allowsMultipleAssignees ? this.localAssigneeIds : this.localAssigneeIds[0]; | ||||
|     }, | ||||
|     dropdownText() { | ||||
|       if (this.localAssigneeIds.length === 0) { | ||||
|         return s__('WorkItem|No assignees'); | ||||
|       } | ||||
| 
 | ||||
|       return this.localAssigneeIds.length === 1 | ||||
|         ? this.localAssignees.map(({ name }) => name).join(', ') | ||||
|         : sprintf(s__('WorkItem|%{usersLength} assignees'), { | ||||
|             usersLength: this.localAssigneeIds.length, | ||||
|           }); | ||||
|     }, | ||||
|     dropdownLabel() { | ||||
|       return this.allowsMultipleAssignees ? __('Assignees') : __('Assignee'); | ||||
|     }, | ||||
|     headerText() { | ||||
|       return this.allowsMultipleAssignees ? __('Select assignees') : __('Select assignee'); | ||||
|     }, | ||||
|     filteredAssignees() { | ||||
|       return isEmpty(this.searchUsers) | ||||
|         ? this.assignees | ||||
|         : this.searchUsers.filter(({ id }) => this.localAssigneeIds.includes(id)); | ||||
|     }, | ||||
|     localAssignees() { | ||||
|       return this.filteredAssignees || []; | ||||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|     assignees: { | ||||
|       handler(newVal) { | ||||
|         this.localAssigneeIds = newVal.map(({ id }) => id); | ||||
|       }, | ||||
|       deep: true, | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     handleAssigneesInput(assignees) { | ||||
|       this.setLocalAssigneeIdsOnEvent(assignees); | ||||
|       this.setAssignees(); | ||||
|     }, | ||||
|     handleAssigneeClick(assignees) { | ||||
|       this.setLocalAssigneeIdsOnEvent(assignees); | ||||
|     }, | ||||
|     async setAssignees() { | ||||
|       this.updateInProgress = true; | ||||
|       const { localAssigneeIds } = this; | ||||
| 
 | ||||
|       try { | ||||
|         const { | ||||
|           data: { | ||||
|             workItemUpdate: { errors }, | ||||
|           }, | ||||
|         } = await this.$apollo.mutate({ | ||||
|           mutation: updateWorkItemMutation, | ||||
|           variables: { | ||||
|             input: { | ||||
|               id: this.workItemId, | ||||
|               assigneesWidget: { | ||||
|                 assigneeIds: localAssigneeIds, | ||||
|               }, | ||||
|             }, | ||||
|           }, | ||||
|         }); | ||||
|         if (errors.length > 0) { | ||||
|           this.throwUpdateError(); | ||||
|           return; | ||||
|         } | ||||
|         this.track('updated_assignees'); | ||||
|       } catch { | ||||
|         this.throwUpdateError(); | ||||
|       } finally { | ||||
|         this.updateInProgress = false; | ||||
|       } | ||||
|     }, | ||||
|     setLocalAssigneeIdsOnEvent(assignees) { | ||||
|       const singleSelectAssignee = assignees === null ? [] : [assignees]; | ||||
|       this.localAssigneeIds = this.allowsMultipleAssignees ? assignees : singleSelectAssignee; | ||||
|     }, | ||||
|     async fetchMoreAssignees() { | ||||
|       if (this.isLoadingMore && !this.hasNextPage) return; | ||||
| 
 | ||||
|       this.isLoadingMore = true; | ||||
|       await this.$apollo.queries.users.fetchMore({ | ||||
|         variables: { | ||||
|           after: this.pageInfo.endCursor, | ||||
|           first: DEFAULT_PAGE_SIZE_ASSIGNEES, | ||||
|         }, | ||||
|       }); | ||||
|       this.isLoadingMore = false; | ||||
|     }, | ||||
|     setSearchKey(value) { | ||||
|       this.searchKey = value; | ||||
|       this.searchStarted = true; | ||||
|     }, | ||||
|     assignToCurrentUser() { | ||||
|       const assignees = this.allowsMultipleAssignees ? [this.currentUser.id] : this.currentUser.id; | ||||
|       this.setLocalAssigneeIdsOnEvent(assignees); | ||||
|       this.setAssignees(); | ||||
|     }, | ||||
|     throwUpdateError() { | ||||
|       this.$emit('error', i18n.updateError); | ||||
|       // If mutation is rejected, we're rolling back to initial state | ||||
|       this.localAssigneeIds = this.assignees.map(({ id }) => id); | ||||
|     }, | ||||
|     onDropdownShown() { | ||||
|       this.searchStarted = true; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <work-item-sidebar-dropdown-widget-with-edit | ||||
|     :multi-select="allowsMultipleAssignees" | ||||
|     class="issuable-assignees gl-mt-2" | ||||
|     :dropdown-label="dropdownLabel" | ||||
|     :can-update="canUpdate" | ||||
|     dropdown-name="assignees" | ||||
|     show-footer | ||||
|     :infinite-scroll="hasNextPage" | ||||
|     :infinite-scroll-loading="isLoadingMore" | ||||
|     :loading="isLoadingUsers" | ||||
|     :list-items="searchUsers" | ||||
|     :item-value="selectedAssigneeIds" | ||||
|     :toggle-dropdown-text="dropdownText" | ||||
|     :header-text="headerText" | ||||
|     :update-in-progress="updateInProgress" | ||||
|     :reset-button-label="__('Clear')" | ||||
|     data-testid="work-item-assignees-with-edit" | ||||
|     @dropdownShown="onDropdownShown" | ||||
|     @searchStarted="setSearchKey" | ||||
|     @updateValue="handleAssigneesInput" | ||||
|     @updateSelected="handleAssigneeClick" | ||||
|     @bottomReached="fetchMoreAssignees" | ||||
|   > | ||||
|     <template #list-item="{ item }"> | ||||
|       <sidebar-participant :user="item" /> | ||||
|     </template> | ||||
|     <template v-if="canInviteMembers" #footer> | ||||
|       <gl-button category="tertiary" block class="gl-justify-content-start!"> | ||||
|         <invite-members-trigger | ||||
|           :display-text="__('Invite members')" | ||||
|           trigger-element="side-nav" | ||||
|           icon="plus" | ||||
|           trigger-source="work-item-assignees-with-edit" | ||||
|           classes="gl-hover-text-decoration-none! gl-pb-2" | ||||
|         /> | ||||
|       </gl-button> | ||||
|     </template> | ||||
|     <template #none> | ||||
|       <div class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-gap-2"> | ||||
|         <span>{{ __('None') }}</span> | ||||
|         <template v-if="currentUser && canUpdate"> | ||||
|           <span>-</span> | ||||
|           <gl-button variant="link" data-testid="assign-self" @click.stop="assignToCurrentUser" | ||||
|             ><span class="gl-text-gray-500 gl-hover-text-blue-800">{{ | ||||
|               __('assign yourself') | ||||
|             }}</span></gl-button | ||||
|           > | ||||
|         </template> | ||||
|       </div> | ||||
|     </template> | ||||
|     <template #readonly> | ||||
|       <uncollapsed-assignee-list | ||||
|         :users="localAssignees" | ||||
|         show-less-assignees-class="gl-hover-bg-transparent!" | ||||
|       /> | ||||
|     </template> | ||||
|   </work-item-sidebar-dropdown-widget-with-edit> | ||||
| </template> | ||||
|  | @ -15,7 +15,8 @@ import { | |||
|   WORK_ITEM_TYPE_VALUE_TASK, | ||||
| } from '../constants'; | ||||
| import WorkItemDueDate from './work_item_due_date.vue'; | ||||
| import WorkItemAssignees from './work_item_assignees.vue'; | ||||
| import WorkItemAssigneesInline from './work_item_assignees_inline.vue'; | ||||
| import WorkItemAssigneesWithEdit from './work_item_assignees_with_edit.vue'; | ||||
| import WorkItemLabels from './work_item_labels.vue'; | ||||
| import WorkItemMilestoneInline from './work_item_milestone_inline.vue'; | ||||
| import WorkItemMilestoneWithEdit from './work_item_milestone_with_edit.vue'; | ||||
|  | @ -27,7 +28,8 @@ export default { | |||
|     WorkItemLabels, | ||||
|     WorkItemMilestoneInline, | ||||
|     WorkItemMilestoneWithEdit, | ||||
|     WorkItemAssignees, | ||||
|     WorkItemAssigneesInline, | ||||
|     WorkItemAssigneesWithEdit, | ||||
|     WorkItemDueDate, | ||||
|     WorkItemParent, | ||||
|     WorkItemParentInline, | ||||
|  | @ -114,17 +116,31 @@ export default { | |||
| 
 | ||||
| <template> | ||||
|   <div class="work-item-attributes-wrapper"> | ||||
|     <work-item-assignees | ||||
|       v-if="workItemAssignees" | ||||
|       :can-update="canUpdate" | ||||
|       :full-path="fullPath" | ||||
|       :work-item-id="workItem.id" | ||||
|       :assignees="workItemAssignees.assignees.nodes" | ||||
|       :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" | ||||
|       :work-item-type="workItemType" | ||||
|       :can-invite-members="workItemAssignees.canInviteMembers" | ||||
|       @error="$emit('error', $event)" | ||||
|     /> | ||||
|     <template v-if="workItemAssignees"> | ||||
|       <work-item-assignees-with-edit | ||||
|         v-if="glFeatures.workItemsMvc2" | ||||
|         class="gl-mb-5" | ||||
|         :can-update="canUpdate" | ||||
|         :full-path="fullPath" | ||||
|         :work-item-id="workItem.id" | ||||
|         :assignees="workItemAssignees.assignees.nodes" | ||||
|         :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" | ||||
|         :work-item-type="workItemType" | ||||
|         :can-invite-members="workItemAssignees.canInviteMembers" | ||||
|         @error="$emit('error', $event)" | ||||
|       /> | ||||
|       <work-item-assignees-inline | ||||
|         v-else | ||||
|         :can-update="canUpdate" | ||||
|         :full-path="fullPath" | ||||
|         :work-item-id="workItem.id" | ||||
|         :assignees="workItemAssignees.assignees.nodes" | ||||
|         :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" | ||||
|         :work-item-type="workItemType" | ||||
|         :can-invite-members="workItemAssignees.canInviteMembers" | ||||
|         @error="$emit('error', $event)" | ||||
|       /> | ||||
|     </template> | ||||
|     <work-item-labels | ||||
|       v-if="workItemLabels" | ||||
|       :can-update="canUpdate" | ||||
|  |  | |||
|  | @ -91,6 +91,9 @@ export default { | |||
|         expired, | ||||
|       })); | ||||
|     }, | ||||
|     localMilestoneId() { | ||||
|       return this.localMilestone?.id; | ||||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|     workItemMilestone(newVal) { | ||||
|  | @ -184,7 +187,7 @@ export default { | |||
|     dropdown-name="milestone" | ||||
|     :loading="isLoadingMilestones" | ||||
|     :list-items="milestonesList" | ||||
|     :item-value="localMilestone" | ||||
|     :item-value="localMilestoneId" | ||||
|     :update-in-progress="updateInProgress" | ||||
|     :toggle-dropdown-text="dropdownText" | ||||
|     :header-text="__('Select milestone')" | ||||
|  |  | |||
|  | @ -311,7 +311,7 @@ module ApplicationHelper | |||
|     class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards) | ||||
|     class_names << 'epic-boards-page gl-overflow-auto' if current_controller?(:epic_boards) | ||||
|     class_names << 'with-performance-bar' if performance_bar_enabled? | ||||
|     class_names << 'with-header' unless current_user | ||||
|     class_names << 'with-header' if @with_header || !current_user | ||||
|     class_names << 'with-top-bar' unless @hide_top_bar_padding | ||||
|     class_names << system_message_class | ||||
| 
 | ||||
|  |  | |||
|  | @ -81,7 +81,8 @@ module Users | |||
|       code_suggestions_ga_non_owner_alert: 79, # EE-only | ||||
|       duo_chat_callout: 80, # EE-only | ||||
|       code_suggestions_ga_owner_alert: 81, # EE-only | ||||
|       product_analytics_dashboard_feedback: 82 # EE-only | ||||
|       product_analytics_dashboard_feedback: 82, # EE-only | ||||
|       joining_a_project_alert: 83 # EE-only | ||||
|     } | ||||
| 
 | ||||
|     validates :feature_name, | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| .container | ||||
|   = render_if_exists 'dashboard/projects/joining_a_project_alert' | ||||
|   .gl-text-center.gl-pt-6.gl-pb-7 | ||||
|     %h2.gl-font-size-h1{ data: { testid: 'welcome-title-content' } } | ||||
|       = _('Welcome to GitLab') | ||||
|  |  | |||
|  | @ -1,6 +1,9 @@ | |||
| - add_page_specific_style 'page_bundles/login' | ||||
| - @with_header = true | ||||
| - page_classes = [user_application_theme, page_class.flatten.compact] | ||||
| 
 | ||||
| !!! 5 | ||||
| %html.html-devise-layout{ class: user_application_theme, lang: I18n.locale } | ||||
| %html.html-devise-layout{ class: page_classes, lang: I18n.locale } | ||||
|   = render "layouts/head" | ||||
|   %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}" } | ||||
|     = header_message | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| - @with_header = true | ||||
| - page_classes = page_class.push(@html_class).flatten.compact | ||||
| 
 | ||||
| !!! 5 | ||||
|  |  | |||
|  | @ -3,8 +3,17 @@ table_name: analytics_dashboards_pointers | |||
| classes: | ||||
| - Analytics::DashboardsPointer | ||||
| feature_categories: | ||||
|   - devops_reports | ||||
| description: Stores project link with configuration files for Analytics Dashboards group feature. | ||||
| - devops_reports | ||||
| description: Stores project link with configuration files for Analytics Dashboards | ||||
|   group feature. | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107673 | ||||
| milestone: '15.8' | ||||
| gitlab_schema: gitlab_main | ||||
| gitlab_schema: gitlab_main_cell | ||||
| allow_cross_joins: | ||||
| - gitlab_main_clusterwide | ||||
| allow_cross_transactions: | ||||
| - gitlab_main_clusterwide | ||||
| allow_cross_foreign_keys: | ||||
| - gitlab_main_clusterwide | ||||
| sharding_key: | ||||
|   target_project_id: projects | ||||
|  |  | |||
|  | @ -0,0 +1,34 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class RemoveIgnoredColumnsFromGeoNodeStatuses < Gitlab::Database::Migration[2.2] | ||||
|   disable_ddl_transaction! | ||||
| 
 | ||||
|   milestone '16.9' | ||||
| 
 | ||||
|   IGNORED_COLLUMNS = [ | ||||
|     :container_repositories_count, | ||||
|     :container_repositories_failed_count, | ||||
|     :container_repositories_registry_count, | ||||
|     :container_repositories_synced_count, | ||||
|     :job_artifacts_count, | ||||
|     :job_artifacts_failed_count, | ||||
|     :job_artifacts_synced_count, | ||||
|     :job_artifacts_synced_missing_on_primary_count, | ||||
|     :lfs_objects_count, | ||||
|     :lfs_objects_failed_count, | ||||
|     :lfs_objects_synced_count, | ||||
|     :lfs_objects_synced_missing_on_primary_count | ||||
|   ] | ||||
| 
 | ||||
|   def up | ||||
|     IGNORED_COLLUMNS.each do |column_name| | ||||
|       remove_column :geo_node_statuses, column_name, if_exists: true | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     IGNORED_COLLUMNS.each do |column_name| | ||||
|       add_column :geo_node_statuses, column_name, :integer, if_not_exists: true | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1 @@ | |||
| 78738644f53046494ba1f4a8e49ed9effc8147c8563e311bf3744a31a33449c6 | ||||
|  | @ -17301,9 +17301,6 @@ CREATE TABLE geo_node_statuses ( | |||
|     id integer NOT NULL, | ||||
|     geo_node_id integer NOT NULL, | ||||
|     db_replication_lag_seconds integer, | ||||
|     lfs_objects_count integer, | ||||
|     lfs_objects_synced_count integer, | ||||
|     lfs_objects_failed_count integer, | ||||
|     last_event_id bigint, | ||||
|     last_event_date timestamp without time zone, | ||||
|     cursor_last_event_id bigint, | ||||
|  | @ -17315,19 +17312,10 @@ CREATE TABLE geo_node_statuses ( | |||
|     replication_slots_count integer, | ||||
|     replication_slots_used_count integer, | ||||
|     replication_slots_max_retained_wal_bytes bigint, | ||||
|     job_artifacts_count integer, | ||||
|     job_artifacts_synced_count integer, | ||||
|     job_artifacts_failed_count integer, | ||||
|     version character varying, | ||||
|     revision character varying, | ||||
|     lfs_objects_synced_missing_on_primary_count integer, | ||||
|     job_artifacts_synced_missing_on_primary_count integer, | ||||
|     storage_configuration_digest bytea, | ||||
|     projects_count integer, | ||||
|     container_repositories_count integer, | ||||
|     container_repositories_synced_count integer, | ||||
|     container_repositories_failed_count integer, | ||||
|     container_repositories_registry_count integer, | ||||
|     status jsonb DEFAULT '{}'::jsonb NOT NULL | ||||
| ); | ||||
| 
 | ||||
|  |  | |||
|  | @ -31865,6 +31865,7 @@ Name of the feature that the callout is for. | |||
| | <a id="usercalloutfeaturenameenumgeo_migrate_hashed_storage"></a>`GEO_MIGRATE_HASHED_STORAGE` | Callout feature name for geo_migrate_hashed_storage. | | ||||
| | <a id="usercalloutfeaturenameenumgke_cluster_integration"></a>`GKE_CLUSTER_INTEGRATION` | Callout feature name for gke_cluster_integration. | | ||||
| | <a id="usercalloutfeaturenameenumgold_trial_billings"></a>`GOLD_TRIAL_BILLINGS` | Callout feature name for gold_trial_billings. | | ||||
| | <a id="usercalloutfeaturenameenumjoining_a_project_alert"></a>`JOINING_A_PROJECT_ALERT` | Callout feature name for joining_a_project_alert. | | ||||
| | <a id="usercalloutfeaturenameenummerge_request_settings_moved_callout"></a>`MERGE_REQUEST_SETTINGS_MOVED_CALLOUT` | Callout feature name for merge_request_settings_moved_callout. | | ||||
| | <a id="usercalloutfeaturenameenummr_experience_survey"></a>`MR_EXPERIENCE_SURVEY` | Callout feature name for mr_experience_survey. | | ||||
| | <a id="usercalloutfeaturenameenumnamespace_over_storage_users_combined_alert"></a>`NAMESPACE_OVER_STORAGE_USERS_COMBINED_ALERT` | Callout feature name for namespace_over_storage_users_combined_alert. | | ||||
|  |  | |||
|  | @ -337,6 +337,15 @@ create-release: | |||
| After committing and pushing changes, the pipeline tests the component, then creates | ||||
| a release if the earlier jobs pass. | ||||
| 
 | ||||
| #### Test a component against sample files | ||||
| 
 | ||||
| In some cases, components require source files to interact with. For example, a component | ||||
| that builds Go source code likely needs some samples of Go to test against. Alternatively, | ||||
| a component that builds Docker images likely needs some sample Dockerfiles to test against. | ||||
| 
 | ||||
| You can include sample files like these directly in the component project, to be used | ||||
| during component testing. For example, you can see the [code-quality CI/CD component's testing samples](https://gitlab.com/components/code-quality/-/tree/main/src). | ||||
| 
 | ||||
| ### Avoid using global keywords | ||||
| 
 | ||||
| Avoid using [global keywords](../yaml/index.md#global-keywords) in a component. | ||||
|  |  | |||
|  | @ -275,7 +275,6 @@ NOTES: | |||
| - A custom format is used for [dates](https://gitlab.com/gitlab-org/gitlab/blob/3be39f19ac3412c089be28553e6f91b681e5d739/config/initializers/date_time_formats.rb#L7) and [times](https://gitlab.com/gitlab-org/gitlab/blob/3be39f19ac3412c089be28553e6f91b681e5d739/config/initializers/date_time_formats.rb#L13) in CSV files. | ||||
| 
 | ||||
| WARNING: | ||||
| 
 | ||||
| Do not open the license usage file. If you open the file, failures might occur when [you submit your license usage data](../../administration/license_file.md#submit-license-usage-data). | ||||
| 
 | ||||
| ## Renew your subscription | ||||
|  |  | |||
|  | @ -33944,6 +33944,12 @@ msgstr "" | |||
| msgid "OnDemandScans|at" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Onboarding|If you can't find your organization, request an invite from your company's GitLab administrator." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Onboarding|Looking for your team?" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Once imported, repositories can be mirrored over SSH. Read more %{link_start}here%{link_end}." | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -55627,6 +55633,9 @@ msgstr "" | |||
| msgid "WorkItem|%{invalidWorkItemsList} cannot be added: Cannot assign a non-confidential %{childWorkItemType} to a confidential parent %{parentWorkItemType}. Make the selected %{childWorkItemType} confidential and try again." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|%{usersLength} assignees" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|%{workItemType} deleted" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -55797,6 +55806,9 @@ msgstr "" | |||
| msgid "WorkItem|New task" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|No assignees" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|No child items are currently assigned. Use child items to break down this issue into smaller parts." | ||||
| msgstr "" | ||||
| 
 | ||||
|  |  | |||
|  | @ -41,23 +41,62 @@ RSpec.describe 'Work item', :js, feature_category: :team_planning do | |||
|       expect(page).to have_button _('More actions') | ||||
|     end | ||||
| 
 | ||||
|     it 'reassigns to another user', | ||||
|       quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do | ||||
|       find('[data-testid="work-item-assignees-input"]').fill_in(with: user.username) | ||||
|       wait_for_requests | ||||
|     context 'when work_items_mvc_2 is disabled' do | ||||
|       before do | ||||
|         stub_feature_flags(work_items_mvc_2: false) | ||||
| 
 | ||||
|       send_keys(:enter) | ||||
|       find("body").click | ||||
|       wait_for_requests | ||||
|         page.refresh | ||||
|         wait_for_all_requests | ||||
|       end | ||||
| 
 | ||||
|       find('[data-testid="work-item-assignees-input"]').fill_in(with: user2.username) | ||||
|       wait_for_requests | ||||
|       it 'reassigns to another user', | ||||
|         quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do | ||||
|         find('[data-testid="work-item-assignees-input"]').fill_in(with: user.username) | ||||
|         wait_for_requests | ||||
| 
 | ||||
|       send_keys(:enter) | ||||
|       find("body").click | ||||
|       wait_for_requests | ||||
|         send_keys(:enter) | ||||
|         find("body").click | ||||
|         wait_for_requests | ||||
| 
 | ||||
|       expect(work_item.reload.assignees).to include(user2) | ||||
|         find('[data-testid="work-item-assignees-input"]').fill_in(with: user2.username) | ||||
|         wait_for_requests | ||||
| 
 | ||||
|         send_keys(:enter) | ||||
|         find("body").click | ||||
|         wait_for_requests | ||||
| 
 | ||||
|         expect(work_item.reload.assignees).to include(user2) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when work_items_mvc_2 is enabled' do | ||||
|       before do | ||||
|         stub_feature_flags(work_items_mvc_2: true) | ||||
| 
 | ||||
|         page.refresh | ||||
|         wait_for_all_requests | ||||
|       end | ||||
| 
 | ||||
|       it 'reassigns to another user', | ||||
|         quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do | ||||
|         within('[data-testid="work-item-assignees-with-edit"]') do | ||||
|           click_button 'Edit' | ||||
|         end | ||||
| 
 | ||||
|         select_listbox_item(user.username) | ||||
| 
 | ||||
|         wait_for_requests | ||||
| 
 | ||||
|         within('[data-testid="work-item-assignees-with-edit"]') do | ||||
|           click_button 'Edit' | ||||
|         end | ||||
| 
 | ||||
|         select_listbox_item(user2.username) | ||||
| 
 | ||||
|         wait_for_requests | ||||
| 
 | ||||
|         expect(work_item.reload.assignees).to include(user2) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'work items title' | ||||
|  | @ -118,9 +157,33 @@ RSpec.describe 'Work item', :js, feature_category: :team_planning do | |||
|       expect(page).to have_selector('[data-testid="award-button"].disabled') | ||||
|     end | ||||
| 
 | ||||
|     it 'assignees input field is disabled' do | ||||
|       within('[data-testid="work-item-assignees-input"]') do | ||||
|         expect(page).to have_field(type: 'text', disabled: true) | ||||
|     context 'when work_items_mvc_2 is disabled' do | ||||
|       before do | ||||
|         stub_feature_flags(work_items_mvc_2: false) | ||||
| 
 | ||||
|         page.refresh | ||||
|         wait_for_all_requests | ||||
|       end | ||||
| 
 | ||||
|       it 'assignees input field is disabled' do | ||||
|         within('[data-testid="work-item-assignees-input"]') do | ||||
|           expect(page).to have_field(type: 'text', disabled: true) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when work_items_mvc_2 is enabled' do | ||||
|       before do | ||||
|         stub_feature_flags(work_items_mvc_2: true) | ||||
| 
 | ||||
|         page.refresh | ||||
|         wait_for_all_requests | ||||
|       end | ||||
| 
 | ||||
|       it 'assignees edit button is not visible' do | ||||
|         within('[data-testid="work-item-assignees-with-edit"]') do | ||||
|           expect(page).not_to have_button('Edit') | ||||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|  |  | |||
|  | @ -21,6 +21,11 @@ describe('WorkItemSidebarDropdownWidgetWithEdit component', () => { | |||
|     canUpdate = true, | ||||
|     isEditing = false, | ||||
|     updateInProgress = false, | ||||
|     showFooter = false, | ||||
|     slots = {}, | ||||
|     multiSelect = false, | ||||
|     infiniteScroll = false, | ||||
|     infiniteScrollLoading = false, | ||||
|   } = {}) => { | ||||
|     wrapper = mountExtended(WorkItemSidebarDropdownWidgetWithEdit, { | ||||
|       propsData: { | ||||
|  | @ -31,7 +36,12 @@ describe('WorkItemSidebarDropdownWidgetWithEdit component', () => { | |||
|         canUpdate, | ||||
|         updateInProgress, | ||||
|         headerText: __('Select iteration'), | ||||
|         showFooter, | ||||
|         multiSelect, | ||||
|         infiniteScroll, | ||||
|         infiniteScrollLoading, | ||||
|       }, | ||||
|       slots, | ||||
|     }); | ||||
| 
 | ||||
|     if (isEditing) { | ||||
|  | @ -152,10 +162,41 @@ describe('WorkItemSidebarDropdownWidgetWithEdit component', () => { | |||
|         searching: false, | ||||
|         infiniteScroll: false, | ||||
|         noResultsText: 'No matching results', | ||||
|         toggleText: 'None', | ||||
|         searchPlaceholder: 'Search', | ||||
|         resetButtonLabel: 'Clear', | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('renders the footer when enabled', async () => { | ||||
|       const FOOTER_SLOT_HTML = 'Test message'; | ||||
|       createComponent({ isEditing: true, showFooter: true, slots: { footer: FOOTER_SLOT_HTML } }); | ||||
| 
 | ||||
|       await nextTick(); | ||||
|       expect(wrapper.text()).toContain(FOOTER_SLOT_HTML); | ||||
|     }); | ||||
| 
 | ||||
|     it('supports multiselect', async () => { | ||||
|       createComponent({ isEditing: true, multiSelect: true }); | ||||
| 
 | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findCollapsibleListbox().props('multiple')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('supports infinite scrolling', async () => { | ||||
|       createComponent({ isEditing: true, infiniteScroll: true }); | ||||
| 
 | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findCollapsibleListbox().props('infiniteScroll')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows loader when bottom reached', async () => { | ||||
|       createComponent({ isEditing: true, infiniteScroll: true, infiniteScrollLoading: true }); | ||||
| 
 | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findCollapsibleListbox().props('infiniteScrollLoading')).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphq | |||
| import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; | ||||
| import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; | ||||
| import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; | ||||
| import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; | ||||
| import WorkItemAssigneesInline from '~/work_items/components/work_item_assignees_inline.vue'; | ||||
| import { | ||||
|   i18n, | ||||
|   DEFAULT_PAGE_SIZE_ASSIGNEES, | ||||
|  | @ -35,7 +35,7 @@ Vue.use(VueApollo); | |||
| const workItemId = 'gid://gitlab/WorkItem/1'; | ||||
| const dropdownItems = projectMembersResponseWithCurrentUser.data.workspace.users.nodes; | ||||
| 
 | ||||
| describe('WorkItemAssignees component', () => { | ||||
| describe('WorkItemAssigneesInline component', () => { | ||||
|   let wrapper; | ||||
| 
 | ||||
|   const findAssigneeLinks = () => wrapper.findAllComponents(GlLink); | ||||
|  | @ -88,7 +88,7 @@ describe('WorkItemAssignees component', () => { | |||
|       [updateWorkItemMutation, updateWorkItemMutationHandler], | ||||
|     ]); | ||||
| 
 | ||||
|     wrapper = mountExtended(WorkItemAssignees, { | ||||
|     wrapper = mountExtended(WorkItemAssigneesInline, { | ||||
|       provide: { | ||||
|         isGroup, | ||||
|       }, | ||||
|  | @ -0,0 +1,300 @@ | |||
| import Vue, { nextTick } from 'vue'; | ||||
| import VueApollo from 'vue-apollo'; | ||||
| import WorkItemAssignees from '~/work_items/components/work_item_assignees_with_edit.vue'; | ||||
| import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue'; | ||||
| import groupUsersSearchQuery from '~/graphql_shared/queries/group_users_search.query.graphql'; | ||||
| import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; | ||||
| import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; | ||||
| import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; | ||||
| import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; | ||||
| import createMockApollo from 'helpers/mock_apollo_helper'; | ||||
| import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; | ||||
| import { mockTracking } from 'helpers/tracking_helper'; | ||||
| import waitForPromises from 'helpers/wait_for_promises'; | ||||
| import { | ||||
|   projectMembersResponseWithCurrentUser, | ||||
|   mockAssignees, | ||||
|   currentUserResponse, | ||||
|   currentUserNullResponse, | ||||
|   updateWorkItemMutationResponse, | ||||
|   projectMembersResponseWithCurrentUserWithNextPage, | ||||
|   projectMembersResponseWithNoMatchingUsers, | ||||
| } from 'jest/work_items/mock_data'; | ||||
| import { DEFAULT_PAGE_SIZE_ASSIGNEES, i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; | ||||
| 
 | ||||
| const workItemId = 'gid://gitlab/WorkItem/1'; | ||||
| 
 | ||||
| describe('WorkItemAssigneesWithEdit component', () => { | ||||
|   Vue.use(VueApollo); | ||||
| 
 | ||||
|   let wrapper; | ||||
| 
 | ||||
|   const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger); | ||||
|   const findAssignSelfButton = () => wrapper.findByTestId('assign-self'); | ||||
|   const findSidebarDropdownWidget = () => | ||||
|     wrapper.findComponent(WorkItemSidebarDropdownWidgetWithEdit); | ||||
| 
 | ||||
|   const successSearchQueryHandler = jest | ||||
|     .fn() | ||||
|     .mockResolvedValue(projectMembersResponseWithCurrentUser); | ||||
|   const successGroupSearchQueryHandler = jest | ||||
|     .fn() | ||||
|     .mockResolvedValue(projectMembersResponseWithCurrentUser); | ||||
|   const successSearchQueryHandlerWithMoreAssignees = jest | ||||
|     .fn() | ||||
|     .mockResolvedValue(projectMembersResponseWithCurrentUserWithNextPage); | ||||
|   const successCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse); | ||||
|   const noCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserNullResponse); | ||||
|   const successUpdateWorkItemMutationHandler = jest | ||||
|     .fn() | ||||
|     .mockResolvedValue(updateWorkItemMutationResponse); | ||||
|   const successSearchWithNoMatchingUsers = jest | ||||
|     .fn() | ||||
|     .mockResolvedValue(projectMembersResponseWithNoMatchingUsers); | ||||
| 
 | ||||
|   const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); | ||||
| 
 | ||||
|   const showDropdown = () => { | ||||
|     findSidebarDropdownWidget().vm.$emit('dropdownShown'); | ||||
|   }; | ||||
| 
 | ||||
|   const createComponent = ({ | ||||
|     assignees = mockAssignees, | ||||
|     searchQueryHandler = successSearchQueryHandler, | ||||
|     currentUserQueryHandler = successCurrentUserQueryHandler, | ||||
|     allowsMultipleAssignees = false, | ||||
|     canInviteMembers = false, | ||||
|     canUpdate = true, | ||||
|   } = {}) => { | ||||
|     const apolloProvider = createMockApollo([ | ||||
|       [usersSearchQuery, searchQueryHandler], | ||||
|       [groupUsersSearchQuery, successGroupSearchQueryHandler], | ||||
|       [currentUserQuery, currentUserQueryHandler], | ||||
|       [updateWorkItemMutation, successUpdateWorkItemMutationHandler], | ||||
|     ]); | ||||
| 
 | ||||
|     wrapper = shallowMountExtended(WorkItemAssignees, { | ||||
|       provide: { | ||||
|         isGroup: false, | ||||
|       }, | ||||
|       propsData: { | ||||
|         assignees, | ||||
|         fullPath: 'test-project-path', | ||||
|         workItemId, | ||||
|         allowsMultipleAssignees, | ||||
|         workItemType: 'Task', | ||||
|         canUpdate, | ||||
|         canInviteMembers, | ||||
|       }, | ||||
|       apolloProvider, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   it('has "Assignee" label for single select', () => { | ||||
|     createComponent(); | ||||
| 
 | ||||
|     expect(findSidebarDropdownWidget().props('dropdownLabel')).toBe('Assignee'); | ||||
|   }); | ||||
| 
 | ||||
|   describe('Dropdown search', () => { | ||||
|     it('shows no items in the dropdown when no results matching', async () => { | ||||
|       createComponent({ searchQueryHandler: successSearchWithNoMatchingUsers }); | ||||
|       showDropdown(); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(findSidebarDropdownWidget().props('listItems')).toHaveLength(0); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits error event if search users query fails', async () => { | ||||
|       createComponent({ searchQueryHandler: errorHandler }); | ||||
|       showDropdown(); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when assigning to current user', () => { | ||||
|     it('does not show `Assign yourself` button if current user is loading', () => { | ||||
|       createComponent(); | ||||
| 
 | ||||
|       expect(findAssignSelfButton().exists()).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('does now show `Assign yourself` button if user is not logged in', async () => { | ||||
|       createComponent({ currentUserQueryHandler: noCurrentUserQueryHandler, assignees: [] }); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(findAssignSelfButton().exists()).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('Dropdown options', () => { | ||||
|     beforeEach(() => { | ||||
|       createComponent({ canUpdate: true }); | ||||
|     }); | ||||
| 
 | ||||
|     it('calls successSearchQueryHandler with variables when dropdown is opened', async () => { | ||||
|       showDropdown(); | ||||
| 
 | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(successSearchQueryHandler).toHaveBeenCalledWith({ | ||||
|         first: DEFAULT_PAGE_SIZE_ASSIGNEES, | ||||
|         fullPath: 'test-project-path', | ||||
|         search: '', | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows the skeleton loader when the items are being fetched on click', async () => { | ||||
|       showDropdown(); | ||||
| 
 | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findSidebarDropdownWidget().props('loading')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows the iterations in dropdown when the items have finished fetching', async () => { | ||||
|       showDropdown(); | ||||
| 
 | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(findSidebarDropdownWidget().props('loading')).toBe(false); | ||||
|       expect(findSidebarDropdownWidget().props('listItems')).toHaveLength( | ||||
|         projectMembersResponseWithCurrentUser.data.workspace.users.nodes.length, | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when user is logged in and there are no assignees', () => { | ||||
|     beforeEach(() => { | ||||
|       createComponent({ assignees: [] }); | ||||
|       return waitForPromises(); | ||||
|     }); | ||||
| 
 | ||||
|     it('renders `Assign yourself` button', () => { | ||||
|       expect(findAssignSelfButton().exists()).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('calls update work item assignees mutation with current user as a variable on button click', async () => { | ||||
|       const { currentUser } = currentUserResponse.data; | ||||
|       findAssignSelfButton().vm.$emit('click', new MouseEvent('click')); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ | ||||
|         input: { | ||||
|           id: workItemId, | ||||
|           assigneesWidget: { | ||||
|             assigneeIds: [currentUser.id], | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when multiple assignees are allowed', () => { | ||||
|     beforeEach(() => { | ||||
|       createComponent({ allowsMultipleAssignees: true, assignees: [] }); | ||||
|       return waitForPromises(); | ||||
|     }); | ||||
| 
 | ||||
|     it('renders `Assignees` as label and `Select assignees` as dropdown button header', () => { | ||||
|       expect(findSidebarDropdownWidget().props()).toMatchObject({ | ||||
|         dropdownLabel: 'Assignees', | ||||
|         headerText: 'Select assignees', | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('adds multiple assignees when collapsible listbox provides multiple values', async () => { | ||||
|       showDropdown(); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       findSidebarDropdownWidget().vm.$emit('updateValue', [ | ||||
|         'gid://gitlab/User/5', | ||||
|         'gid://gitlab/User/6', | ||||
|       ]); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findSidebarDropdownWidget().props('itemValue')).toHaveLength(2); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('tracking', () => { | ||||
|     let trackingSpy; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       createComponent(); | ||||
|       trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); | ||||
|     }); | ||||
| 
 | ||||
|     afterEach(() => { | ||||
|       trackingSpy = null; | ||||
|     }); | ||||
| 
 | ||||
|     it('tracks editing the assignees on dropdown widget updateValue', async () => { | ||||
|       showDropdown(); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       findSidebarDropdownWidget().vm.$emit('updateValue', mockAssignees[0].id); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_assignees', { | ||||
|         category: TRACKING_CATEGORY_SHOW, | ||||
|         label: 'item_assignees', | ||||
|         property: 'type_Task', | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('invite members', () => { | ||||
|     it('does not render `Invite members` link if user has no permission to invite members', () => { | ||||
|       createComponent(); | ||||
| 
 | ||||
|       expect(findInviteMembersTrigger().exists()).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('renders `Invite members` link if user has a permission to invite members', () => { | ||||
|       createComponent({ canInviteMembers: true }); | ||||
| 
 | ||||
|       expect(findInviteMembersTrigger().exists()).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('load more assignees', () => { | ||||
|     it('does not have infinite scroll when no matching users', async () => { | ||||
|       createComponent({ searchQueryHandler: successSearchWithNoMatchingUsers }); | ||||
| 
 | ||||
|       showDropdown(); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(findSidebarDropdownWidget().props('infiniteScroll')).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not trigger load more when does not have next page', async () => { | ||||
|       createComponent(); | ||||
| 
 | ||||
|       showDropdown(); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(findSidebarDropdownWidget().props('infiniteScroll')).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('triggers load more when there are more users', async () => { | ||||
|       createComponent({ searchQueryHandler: successSearchQueryHandlerWithMoreAssignees }); | ||||
| 
 | ||||
|       showDropdown(); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       findSidebarDropdownWidget().vm.$emit('bottomReached'); | ||||
|       await waitForPromises(); | ||||
| 
 | ||||
|       expect(successSearchQueryHandlerWithMoreAssignees).toHaveBeenCalledWith({ | ||||
|         first: DEFAULT_PAGE_SIZE_ASSIGNEES, | ||||
|         after: | ||||
|           projectMembersResponseWithCurrentUserWithNextPage.data.workspace.users.pageInfo.endCursor, | ||||
|         search: '', | ||||
|         fullPath: 'test-project-path', | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -1,6 +1,6 @@ | |||
| import { nextTick } from 'vue'; | ||||
| import { shallowMount } from '@vue/test-utils'; | ||||
| import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; | ||||
| import WorkItemAssigneesWithEdit from '~/work_items/components/work_item_assignees_with_edit.vue'; | ||||
| import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue'; | ||||
| import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; | ||||
| import WorkItemMilestoneInline from '~/work_items/components/work_item_milestone_inline.vue'; | ||||
|  | @ -23,7 +23,7 @@ describe('WorkItemAttributesWrapper component', () => { | |||
|   const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); | ||||
| 
 | ||||
|   const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate); | ||||
|   const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); | ||||
|   const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssigneesWithEdit); | ||||
|   const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); | ||||
|   const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestoneWithEdit); | ||||
|   const findWorkItemMilestoneInline = () => wrapper.findComponent(WorkItemMilestoneInline); | ||||
|  |  | |||
|  | @ -150,7 +150,7 @@ describe('WorkItemMilestoneWithEdit component', () => { | |||
| 
 | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findSidebarDropdownWidget().props('itemValue').title).toBe(milestoneAtIndex.title); | ||||
|       expect(findSidebarDropdownWidget().props('itemValue')).toBe(milestoneAtIndex.id); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -701,17 +701,31 @@ RSpec.describe ApplicationHelper do | |||
|     end | ||||
| 
 | ||||
|     describe 'with-header' do | ||||
|       context 'when current_user' do | ||||
|       context 'when @with_header is falsey' do | ||||
|         before do | ||||
|           allow(helper).to receive(:current_user).and_return(user) | ||||
|           helper.instance_variable_set(:@with_header, nil) | ||||
|         end | ||||
| 
 | ||||
|         it { is_expected.not_to include('with-header') } | ||||
|         context 'when current_user' do | ||||
|           before do | ||||
|             allow(helper).to receive(:current_user).and_return(user) | ||||
|           end | ||||
| 
 | ||||
|           it { is_expected.not_to include('with-header') } | ||||
|         end | ||||
| 
 | ||||
|         context 'when no current_user' do | ||||
|           before do | ||||
|             allow(helper).to receive(:current_user).and_return(nil) | ||||
|           end | ||||
| 
 | ||||
|           it { is_expected.to include('with-header') } | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when no current_user' do | ||||
|       context 'when @with_header is true' do | ||||
|         before do | ||||
|           allow(helper).to receive(:current_user).and_return(nil) | ||||
|           helper.instance_variable_set(:@with_header, true) | ||||
|         end | ||||
| 
 | ||||
|         it { is_expected.to include('with-header') } | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ | |||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Gitlab::Auth::CurrentUserMode, :request_store do | ||||
| RSpec.describe Gitlab::Auth::CurrentUserMode, :request_store, feature_category: :system_access do | ||||
|   let(:user) { build_stubbed(:user) } | ||||
| 
 | ||||
|   subject { described_class.new(user) } | ||||
|  |  | |||
|  | @ -167,69 +167,125 @@ RSpec.shared_examples 'work items comments' do |type| | |||
| end | ||||
| 
 | ||||
| RSpec.shared_examples 'work items assignees' do | ||||
|   it 'successfully assigns the current user by searching', | ||||
|     quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do | ||||
|     # The button is only when the mouse is over the input | ||||
|     find('[data-testid="work-item-assignees-input"]').fill_in(with: user.username) | ||||
|     wait_for_requests | ||||
|     # submit and simulate blur to save | ||||
|     send_keys(:enter) | ||||
|     find("body").click | ||||
|     wait_for_requests | ||||
|   context 'when the work_items_mvc_2 FF is disabled' do | ||||
|     include_context 'with work_items_mvc_2', false | ||||
| 
 | ||||
|     expect(work_item.reload.assignees).to include(user) | ||||
|   end | ||||
|     it 'successfully assigns the current user by searching', | ||||
|       quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do | ||||
|       # The button is only when the mouse is over the input | ||||
|       find('[data-testid="work-item-assignees-input"]').fill_in(with: user.username) | ||||
|       wait_for_requests | ||||
|       # submit and simulate blur to save | ||||
|       send_keys(:enter) | ||||
|       find("body").click | ||||
|       wait_for_requests | ||||
| 
 | ||||
|   it 'successfully assigns the current user by clicking `Assign myself` button' do | ||||
|     find('[data-testid="work-item-assignees-input"]').hover | ||||
|     click_button _('Assign yourself') | ||||
| 
 | ||||
|     expect(work_item.reload.assignees).to include(user) | ||||
|   end | ||||
| 
 | ||||
|   it 'successfully removes all users on clear all button click' do | ||||
|     find('[data-testid="work-item-assignees-input"]').hover | ||||
|     click_button _('Assign yourself') | ||||
| 
 | ||||
|     expect(work_item.reload.assignees).to include(user) | ||||
| 
 | ||||
|     find('[data-testid="work-item-assignees-input"]').click | ||||
|     click_button 'Clear all' | ||||
|     find("body").click | ||||
|     wait_for_requests | ||||
| 
 | ||||
|     expect(work_item.reload.assignees).not_to include(user) | ||||
|   end | ||||
| 
 | ||||
|   it 'successfully removes user on clicking badge cross button' do | ||||
|     find('[data-testid="work-item-assignees-input"]').hover | ||||
|     click_button _('Assign yourself') | ||||
| 
 | ||||
|     expect(work_item.reload.assignees).to include(user) | ||||
| 
 | ||||
|     within('[data-testid="work-item-assignees-input"]') do | ||||
|       click_button 'Close' | ||||
|       expect(work_item.reload.assignees).to include(user) | ||||
|     end | ||||
|     find("body").click | ||||
|     wait_for_requests | ||||
| 
 | ||||
|     expect(work_item.reload.assignees).not_to include(user) | ||||
|   end | ||||
|     it 'successfully assigns the current user by clicking `Assign myself` button' do | ||||
|       find('[data-testid="work-item-assignees-input"]').hover | ||||
|       click_button _('Assign yourself') | ||||
| 
 | ||||
|   it 'updates the assignee in real-time' do | ||||
|     Capybara::Session.new(:other_session) | ||||
|       expect(work_item.reload.assignees).to include(user) | ||||
|     end | ||||
| 
 | ||||
|     it 'successfully removes all users on clear all button click' do | ||||
|       find('[data-testid="work-item-assignees-input"]').hover | ||||
|       click_button _('Assign yourself') | ||||
| 
 | ||||
|       expect(work_item.reload.assignees).to include(user) | ||||
| 
 | ||||
|       find('[data-testid="work-item-assignees-input"]').click | ||||
|       click_button 'Clear all' | ||||
|       find("body").click | ||||
|       wait_for_requests | ||||
| 
 | ||||
|     using_session :other_session do | ||||
|       visit work_items_path | ||||
|       expect(work_item.reload.assignees).not_to include(user) | ||||
|     end | ||||
| 
 | ||||
|     find('[data-testid="work-item-assignees-input"]').hover | ||||
|     click_button _('Assign yourself') | ||||
|     it 'successfully removes user on clicking badge cross button' do | ||||
|       find('[data-testid="work-item-assignees-input"]').hover | ||||
|       click_button _('Assign yourself') | ||||
| 
 | ||||
|     expect(work_item.reload.assignees).to include(user) | ||||
|     using_session :other_session do | ||||
|       expect(work_item.reload.assignees).to include(user) | ||||
| 
 | ||||
|       within('[data-testid="work-item-assignees-input"]') do | ||||
|         click_button 'Close' | ||||
|       end | ||||
|       find("body").click | ||||
|       wait_for_requests | ||||
| 
 | ||||
|       expect(work_item.reload.assignees).not_to include(user) | ||||
|     end | ||||
| 
 | ||||
|     it 'updates the assignee in real-time' do | ||||
|       Capybara::Session.new(:other_session) | ||||
| 
 | ||||
|       using_session :other_session do | ||||
|         visit work_items_path | ||||
|         expect(work_item.reload.assignees).not_to include(user) | ||||
|       end | ||||
| 
 | ||||
|       find('[data-testid="work-item-assignees-input"]').hover | ||||
|       click_button _('Assign yourself') | ||||
| 
 | ||||
|       expect(work_item.reload.assignees).to include(user) | ||||
|       using_session :other_session do | ||||
|         expect(work_item.reload.assignees).to include(user) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'when the work_items_mvc_2 FF is enabled' do | ||||
|     let(:work_item_assignees_selector) { '[data-testid="work-item-assignees-with-edit"]' } | ||||
| 
 | ||||
|     include_context 'with work_items_mvc_2', true | ||||
| 
 | ||||
|     it 'successfully assigns the current user by searching', | ||||
|       quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do | ||||
|       # The button is only when the mouse is over the input | ||||
|       find_and_click_edit(work_item_assignees_selector) | ||||
| 
 | ||||
|       select_listbox_item(user.username) | ||||
| 
 | ||||
|       find("body").click | ||||
|       wait_for_all_requests | ||||
| 
 | ||||
|       expect(work_item.assignees).to include(user) | ||||
|     end | ||||
| 
 | ||||
|     it 'successfully removes all users on clear all button click' do | ||||
|       find_and_click_edit(work_item_assignees_selector) | ||||
| 
 | ||||
|       select_listbox_item(user.username) | ||||
| 
 | ||||
|       find("body").click | ||||
|       wait_for_requests | ||||
| 
 | ||||
|       find_and_click_edit(work_item_assignees_selector) | ||||
| 
 | ||||
|       find_and_click_clear(work_item_assignees_selector) | ||||
|       wait_for_all_requests | ||||
| 
 | ||||
|       expect(work_item.assignees).not_to include(user) | ||||
|     end | ||||
| 
 | ||||
|     it 'updates the assignee in real-time' do | ||||
|       Capybara::Session.new(:other_session) | ||||
| 
 | ||||
|       using_session :other_session do | ||||
|         visit work_items_path | ||||
|         expect(work_item.reload.assignees).not_to include(user) | ||||
|       end | ||||
| 
 | ||||
|       click_button 'assign yourself' | ||||
|       wait_for_all_requests | ||||
| 
 | ||||
|       expect(work_item.reload.assignees).to include(user) | ||||
|       using_session :other_session do | ||||
|         expect(work_item.reload.assignees).to include(user) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -391,15 +447,37 @@ end | |||
| RSpec.shared_examples 'work items invite members' do | ||||
|   include Features::InviteMembersModalHelpers | ||||
| 
 | ||||
|   it 'successfully assigns the current user by searching' do | ||||
|     # The button is only when the mouse is over the input | ||||
|     find('[data-testid="work-item-assignees-input"]').fill_in(with: 'Invite members') | ||||
|     wait_for_requests | ||||
|   context 'when the work_items_mvc_2 FF is disabled' do | ||||
|     include_context 'with work_items_mvc_2', false | ||||
| 
 | ||||
|     click_button('Invite members') | ||||
|     it 'successfully assigns the current user by searching' do | ||||
|       # The button is only when the mouse is over the input | ||||
|       find('[data-testid="work-item-assignees-input"]').fill_in(with: 'Invite members') | ||||
|       wait_for_requests | ||||
| 
 | ||||
|     page.within invite_modal_selector do | ||||
|       expect(page).to have_text("You're inviting members to the #{work_item.project.name} project") | ||||
|       click_button('Invite members') | ||||
| 
 | ||||
|       page.within invite_modal_selector do | ||||
|         expect(page).to have_text("You're inviting members to the #{work_item.project.name} project") | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'when the work_items_mvc_2 FF is enabled' do | ||||
|     let(:work_item_assignees_selector) { '[data-testid="work-item-assignees-with-edit"]' } | ||||
| 
 | ||||
|     include_context 'with work_items_mvc_2', true | ||||
| 
 | ||||
|     it 'successfully assigns the current user by searching' do | ||||
|       # The button is only when the mouse is over the input | ||||
|       find_and_click_edit(work_item_assignees_selector) | ||||
|       wait_for_requests | ||||
| 
 | ||||
|       click_link('Invite members') | ||||
| 
 | ||||
|       page.within invite_modal_selector do | ||||
|         expect(page).to have_text("You're inviting members to the #{work_item.project.name} project") | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue