Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									ef1f98e770
								
							
						
					
					
						commit
						3284638f52
					
				|  | @ -1254,42 +1254,6 @@ rspec-ee system pg15 es8: | |||
|     - .rspec-ee-system-parallel | ||||
| 
 | ||||
| # PG16 | ||||
| rspec-ee unit pg16 opensearch1: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16-opensearch1 | ||||
|     - .rspec-ee-unit-parallel | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| rspec-ee unit pg16 opensearch2: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16-opensearch2 | ||||
|     - .rspec-ee-unit-parallel | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| rspec-ee integration pg16 opensearch1: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16-opensearch1 | ||||
|     - .rspec-ee-integration-parallel | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| rspec-ee integration pg16 opensearch2: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16-opensearch2 | ||||
|     - .rspec-ee-integration-parallel | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| rspec-ee system pg16 opensearch1: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16-opensearch1 | ||||
|     - .rspec-ee-system-parallel | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| rspec-ee system pg16 opensearch2: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16-opensearch2 | ||||
|     - .rspec-ee-system-parallel | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| rspec-ee migration pg16: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16 | ||||
|  | @ -1310,35 +1274,74 @@ rspec-ee unit pg16: | |||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
|     - .rspec-ee-unit-parallel | ||||
| 
 | ||||
| rspec-ee unit pg16 es8: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16-es8 | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
|     - .rspec-ee-unit-parallel | ||||
| 
 | ||||
| rspec-ee integration pg16: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16 | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
|     - .rspec-ee-integration-parallel | ||||
| 
 | ||||
| rspec-ee integration pg16 es8: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16-es8 | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
|     - .rspec-ee-integration-parallel | ||||
| 
 | ||||
| rspec-ee system pg16: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16 | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
|     - .rspec-ee-system-parallel | ||||
| 
 | ||||
| rspec-ee system pg16 es8: | ||||
|   extends: | ||||
|     - .rspec-ee-base-pg16-es8 | ||||
|     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
|     - .rspec-ee-system-parallel | ||||
| # We have too many jobs in nightly pipeline, more than 2k+, | ||||
| # which exceeds the limit of jobs a pipeline can have. Disable below for now. | ||||
| # | ||||
| # rspec-ee unit pg16 opensearch1: | ||||
| #   extends: | ||||
| #     - .rspec-ee-base-pg16-opensearch1 | ||||
| #     - .rspec-ee-unit-parallel | ||||
| #     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| # rspec-ee unit pg16 opensearch2: | ||||
| #   extends: | ||||
| #     - .rspec-ee-base-pg16-opensearch2 | ||||
| #     - .rspec-ee-unit-parallel | ||||
| #     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| # rspec-ee integration pg16 opensearch1: | ||||
| #   extends: | ||||
| #     - .rspec-ee-base-pg16-opensearch1 | ||||
| #     - .rspec-ee-integration-parallel | ||||
| #     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| # rspec-ee integration pg16 opensearch2: | ||||
| #   extends: | ||||
| #     - .rspec-ee-base-pg16-opensearch2 | ||||
| #     - .rspec-ee-integration-parallel | ||||
| #     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| # rspec-ee system pg16 opensearch1: | ||||
| #   extends: | ||||
| #     - .rspec-ee-base-pg16-opensearch1 | ||||
| #     - .rspec-ee-system-parallel | ||||
| #     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| # rspec-ee system pg16 opensearch2: | ||||
| #   extends: | ||||
| #     - .rspec-ee-base-pg16-opensearch2 | ||||
| #     - .rspec-ee-system-parallel | ||||
| #     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| 
 | ||||
| # rspec-ee unit pg16 es8: | ||||
| #   extends: | ||||
| #     - .rspec-ee-base-pg16-es8 | ||||
| #     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| #     - .rspec-ee-unit-parallel | ||||
| 
 | ||||
| # rspec-ee integration pg16 es8: | ||||
| #   extends: | ||||
| #     - .rspec-ee-base-pg16-es8 | ||||
| #     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| #     - .rspec-ee-integration-parallel | ||||
| 
 | ||||
| # rspec-ee system pg16 es8: | ||||
| #   extends: | ||||
| #     - .rspec-ee-base-pg16-es8 | ||||
| #     - .rails:rules:default-branch-schedule-nightly--code-backstage-ee-only | ||||
| #     - .rspec-ee-system-parallel | ||||
| # EE: default branch nightly scheduled jobs # | ||||
| ##################################### | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| export const BYTES_IN_KIB = 1024; | ||||
| export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250; | ||||
| export const THOUSAND = 1000; | ||||
| export const MILLION = THOUSAND ** 2; | ||||
| export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; | ||||
| export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; | ||||
| export const BV_SHOW_MODAL = 'bv::show::modal'; | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { formatNumber, sprintf, __ } from '~/locale'; | ||||
| import { BYTES_IN_KIB, THOUSAND } from './constants'; | ||||
| import { BYTES_IN_KIB, THOUSAND, MILLION } from './constants'; | ||||
| 
 | ||||
| /** | ||||
|  * Function that allows a number with an X amount of decimals | ||||
|  | @ -127,16 +127,18 @@ export function numberToHumanSize(size, digits = 2, locale) { | |||
|  * | ||||
|  * @param number Number to format | ||||
|  * @param digits The number of digits to appear after the decimal point | ||||
|  * @param uppercase Whether to use uppercase suffix (K, M) | ||||
|  * @return {string} Formatted number | ||||
|  */ | ||||
| export function numberToMetricPrefix(number, digits = 1) { | ||||
| export function numberToMetricPrefix(number, uppercase = false) { | ||||
|   if (number < THOUSAND) { | ||||
|     return number.toString(); | ||||
|   } | ||||
|   if (number < THOUSAND ** 2) { | ||||
|     return `${Number((number / THOUSAND).toFixed(digits))}k`; | ||||
|   const digits = 1; | ||||
|   if (number < MILLION) { | ||||
|     return `${Number((number / THOUSAND).toFixed(digits))}${uppercase ? 'K' : 'k'}`; | ||||
|   } | ||||
|   return `${Number((number / THOUSAND ** 2).toFixed(digits))}m`; | ||||
|   return `${Number((number / MILLION).toFixed(digits))}${uppercase ? 'M' : 'm'}`; | ||||
| } | ||||
| /** | ||||
|  * A simple method that returns the value of a + b | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ import { | |||
|   MEMBER_MODEL_TYPE_GROUP_MEMBER, | ||||
|   MEMBER_MODEL_TYPE_PROJECT_MEMBER, | ||||
| } from '~/members/constants'; | ||||
| import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; | ||||
| import { I18N } from './constants'; | ||||
| import LeaveDropdownItem from './leave_dropdown_item.vue'; | ||||
| import RemoveMemberDropdownItem from './remove_member_dropdown_item.vue'; | ||||
|  | @ -29,6 +30,7 @@ export default { | |||
|   directives: { | ||||
|     GlTooltip: GlTooltipDirective, | ||||
|   }, | ||||
|   mixins: [glFeatureFlagMixin()], | ||||
|   props: { | ||||
|     member: { | ||||
|       type: Object, | ||||
|  | @ -94,7 +96,11 @@ export default { | |||
|         : this.$options.i18n.leaveGroup; | ||||
|     }, | ||||
|     showLdapOverride() { | ||||
|       return this.permissions.canOverride && !this.member.isOverridden; | ||||
|       return ( | ||||
|         !this.glFeatures.showRoleDetailsInDrawer && | ||||
|         this.permissions.canOverride && | ||||
|         !this.member.isOverridden | ||||
|       ); | ||||
|     }, | ||||
|     showBan() { | ||||
|       return !this.isCurrentUser && this.permissions.canBan; | ||||
|  |  | |||
|  | @ -5,28 +5,14 @@ import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; | |||
| import { helpPagePath } from '~/helpers/help_page_helper'; | ||||
| import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; | ||||
| import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; | ||||
| import axios from '~/lib/utils/axios_utils'; | ||||
| import { s__ } from '~/locale'; | ||||
| import { | ||||
|   GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, | ||||
|   MEMBER_ACCESS_LEVEL_PROPERTY_NAME, | ||||
|   MEMBERS_TAB_TYPES, | ||||
| } from '~/members/constants'; | ||||
| import { | ||||
|   getRoleDropdownItems, | ||||
|   getMemberRole, | ||||
| } from 'ee_else_ce/members/components/table/drawer/utils'; | ||||
| import * as Sentry from '~/ci/runner/sentry_utils'; | ||||
| import RoleUpdater from 'ee_else_ce/members/components/table/drawer/role_updater.vue'; | ||||
| import RoleSelector from '~/members/components/role_selector.vue'; | ||||
| import MemberAvatar from '../member_avatar.vue'; | ||||
| 
 | ||||
| // The API to update members uses different property names for the access level, depending on if it's a user or a group. | ||||
| // Users use 'access_level', groups use 'group_access'. | ||||
| const ACCESS_LEVEL_PROPERTY_NAME = { | ||||
|   [MEMBERS_TAB_TYPES.user]: MEMBER_ACCESS_LEVEL_PROPERTY_NAME, | ||||
|   [MEMBERS_TAB_TYPES.group]: GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, | ||||
| }; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     MemberAvatar, | ||||
|  | @ -37,11 +23,9 @@ export default { | |||
|     GlIcon, | ||||
|     GlAlert, | ||||
|     RoleSelector, | ||||
|     RoleUpdater, | ||||
|     RoleBadges: () => import('ee_component/members/components/table/role_badges.vue'), | ||||
|     GuestOverageConfirmation: () => | ||||
|       import('ee_component/members/components/table/drawer/guest_overage_confirmation.vue'), | ||||
|   }, | ||||
|   inject: ['group'], | ||||
|   props: { | ||||
|     member: { | ||||
|       type: Object, | ||||
|  | @ -53,7 +37,7 @@ export default { | |||
|     return { | ||||
|       selectedRole: null, | ||||
|       isSavingRole: false, | ||||
|       saveError: null, | ||||
|       alert: null, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|  | @ -63,12 +47,16 @@ export default { | |||
|     initialRole() { | ||||
|       return getMemberRole(this.roles.flatten, this.member); | ||||
|     }, | ||||
|     isRoleChanged() { | ||||
|       return this.selectedRole !== this.initialRole; | ||||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|     'member.accessLevel': { | ||||
|     member: { | ||||
|       immediate: true, | ||||
|       handler() { | ||||
|         if (this.member) { | ||||
|           this.alert = null; | ||||
|           this.selectedRole = this.initialRole; | ||||
|         } | ||||
|       }, | ||||
|  | @ -76,72 +64,18 @@ export default { | |||
|     isSavingRole() { | ||||
|       this.$emit('busy', this.isSavingRole); | ||||
|     }, | ||||
|     selectedRole() { | ||||
|       this.saveError = null; | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     closeDrawer() { | ||||
|       // Don't close the drawer if the role API call is still underway. | ||||
|       // Don't let the drawer close if the role is still saving. | ||||
|       if (!this.isSavingRole) { | ||||
|         this.$emit('close'); | ||||
|         this.alert = null; | ||||
|       } | ||||
|     }, | ||||
|     checkGuestOverage() { | ||||
|       this.saveError = null; | ||||
|       this.isSavingRole = true; | ||||
|       const checkOverageFn = this.$refs.guestOverageConfirmation?.checkOverage; | ||||
|       // If guestOverageConfirmation is real instead of the CE dummy, check the guest overage. Otherwise, just update | ||||
|       // the role. | ||||
|       if (checkOverageFn) { | ||||
|         checkOverageFn(); | ||||
|       } else { | ||||
|         this.updateRole(); | ||||
|       } | ||||
|     }, | ||||
|     async updateRole() { | ||||
|       try { | ||||
|         const accessLevelProp = ACCESS_LEVEL_PROPERTY_NAME[this.member.namespace]; | ||||
| 
 | ||||
|         const { data } = await axios.put(this.member.memberPath, { | ||||
|           [accessLevelProp]: this.selectedRole.accessLevel, | ||||
|           member_role_id: this.selectedRole.memberRoleId, | ||||
|         }); | ||||
| 
 | ||||
|         // EE has a flow where the role is not changed immediately, but goes through an approval process. In that case | ||||
|         // we need to restore the role back to what the member had initially. | ||||
|         if (data?.enqueued) { | ||||
|           this.$toast.show(s__('Members|Role change request was sent to the administrator.')); | ||||
|           this.resetRole(); | ||||
|         } else { | ||||
|           this.$toast.show(s__('Members|Role was successfully updated.')); | ||||
|           const { member } = this; | ||||
|           // Update the access level on the member object so that the members table shows the new role. | ||||
|           member.accessLevel = { | ||||
|             stringValue: this.selectedRole.text, | ||||
|             integerValue: this.selectedRole.accessLevel, | ||||
|             description: this.selectedRole.description, | ||||
|             memberRoleId: this.selectedRole.memberRoleId, | ||||
|           }; | ||||
|           // Update the license usage info to show/hide the "Is using seat" badge. | ||||
|           if (data?.using_license !== undefined) { | ||||
|             member.usingLicense = data?.using_license; | ||||
|           } | ||||
|         } | ||||
|       } catch (error) { | ||||
|         this.saveError = s__('MemberRole|Could not update role.'); | ||||
|         Sentry.captureException(error); | ||||
|       } finally { | ||||
|         this.isSavingRole = false; | ||||
|       } | ||||
|     }, | ||||
|     resetRole() { | ||||
|       this.selectedRole = this.initialRole; | ||||
|       this.isSavingRole = false; | ||||
|     }, | ||||
|     showCheckOverageError() { | ||||
|       this.saveError = s__('MemberRole|Could not check guest overage.'); | ||||
|       this.isSavingRole = false; | ||||
|     setRole(role) { | ||||
|       this.selectedRole = role; | ||||
|       this.alert = null; | ||||
|     }, | ||||
|   }, | ||||
|   getContentWrapperHeight, | ||||
|  | @ -183,13 +117,14 @@ export default { | |||
| 
 | ||||
|         <dl> | ||||
|           <dt class="gl-mb-3" data-testid="role-header">{{ s__('MemberRole|Role') }}</dt> | ||||
|           <dd class="gl-flex gl-flex-wrap gl-gap-x-2 gl-gap-y-3"> | ||||
|           <dd class="gl-flex gl-flex-wrap gl-items-baseline gl-gap-3"> | ||||
|             <role-selector | ||||
|               v-if="permissions.canUpdate" | ||||
|               v-model="selectedRole" | ||||
|               :value="selectedRole" | ||||
|               :roles="roles" | ||||
|               :loading="isSavingRole" | ||||
|               class="gl-w-full" | ||||
|               @input="setRole" | ||||
|             /> | ||||
|             <span v-else data-testid="role-text">{{ selectedRole.text }}</span> | ||||
|             <role-badges :member="member" :role="selectedRole" /> | ||||
|  | @ -207,7 +142,7 @@ export default { | |||
|             {{ s__('MemberRole|Permissions') }} | ||||
|           </dt> | ||||
|           <dd class="gl-display-flex gl-mb-5"> | ||||
|             <span v-if="selectedRole.permissions" class="gl-mr-3" data-testid="base-role"> | ||||
|             <span v-if="selectedRole.memberRoleId" class="gl-mr-3" data-testid="base-role"> | ||||
|               <gl-sprintf :message="s__('MemberRole|Base role: %{role}')"> | ||||
|                 <template #role> | ||||
|                   {{ $options.ACCESS_LEVEL_LABELS[selectedRole.accessLevel] }} | ||||
|  | @ -245,36 +180,44 @@ export default { | |||
|       </div> | ||||
| 
 | ||||
|       <template #footer> | ||||
|         <div v-if="selectedRole !== initialRole"> | ||||
|           <gl-alert v-if="saveError" class="gl-mb-5" variant="danger" :dismissible="false"> | ||||
|             {{ saveError }} | ||||
|         <role-updater | ||||
|           v-if="alert || isRoleChanged" | ||||
|           #default="{ saveRole }" | ||||
|           class="gl-flex gl-flex-col gl-gap-5" | ||||
|           :member="member" | ||||
|           :role="selectedRole" | ||||
|           @busy="isSavingRole = $event" | ||||
|           @alert="alert = $event" | ||||
|           @reset="selectedRole = initialRole" | ||||
|         > | ||||
|           <gl-alert | ||||
|             v-if="alert" | ||||
|             :variant="alert.variant" | ||||
|             :dismissible="alert.dismissible" | ||||
|             @dismiss="alert = null" | ||||
|           > | ||||
|             {{ alert.message }} | ||||
|           </gl-alert> | ||||
|           <gl-button | ||||
|             variant="confirm" | ||||
|             :loading="isSavingRole" | ||||
|             data-testid="save-button" | ||||
|             @click="checkGuestOverage" | ||||
|           > | ||||
|             {{ s__('MemberRole|Update role') }} | ||||
|           </gl-button> | ||||
|           <gl-button | ||||
|             class="gl-ml-2" | ||||
|             :disabled="isSavingRole" | ||||
|             data-testid="cancel-button" | ||||
|             @click="resetRole" | ||||
|           > | ||||
|             {{ __('Cancel') }} | ||||
|           </gl-button> | ||||
|           <guest-overage-confirmation | ||||
|             ref="guestOverageConfirmation" | ||||
|             :group-path="group.path" | ||||
|             :member="member" | ||||
|             :role="selectedRole" | ||||
|             @confirm="updateRole" | ||||
|             @cancel="resetRole" | ||||
|             @error="showCheckOverageError" | ||||
|           /> | ||||
|         </div> | ||||
| 
 | ||||
|           <div v-if="isRoleChanged"> | ||||
|             <gl-button | ||||
|               variant="confirm" | ||||
|               :loading="isSavingRole" | ||||
|               data-testid="save-button" | ||||
|               @click="saveRole" | ||||
|             > | ||||
|               {{ s__('MemberRole|Update role') }} | ||||
|             </gl-button> | ||||
|             <gl-button | ||||
|               class="gl-ml-2" | ||||
|               :disabled="isSavingRole" | ||||
|               data-testid="cancel-button" | ||||
|               @click="setRole(initialRole)" | ||||
|             > | ||||
|               {{ __('Cancel') }} | ||||
|             </gl-button> | ||||
|           </div> | ||||
|         </role-updater> | ||||
|       </template> | ||||
|     </gl-drawer> | ||||
|   </members-table-cell> | ||||
|  |  | |||
|  | @ -0,0 +1,47 @@ | |||
| <script> | ||||
| import { captureException } from '~/sentry/sentry_browser_wrapper'; | ||||
| import { I18N_ROLE_SAVE_SUCCESS, I18N_ROLE_SAVE_ERROR } from '~/members/constants'; | ||||
| import { callRoleUpdateApi, setMemberRole } from './utils'; | ||||
| 
 | ||||
| export default { | ||||
|   props: { | ||||
|     member: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|     role: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     async saveRole() { | ||||
|       try { | ||||
|         this.emitBusy(true); | ||||
|         this.emitAlert(null); | ||||
| 
 | ||||
|         await callRoleUpdateApi(this.member, this.role); | ||||
| 
 | ||||
|         setMemberRole(this.member, this.role); | ||||
|         this.emitAlert({ message: I18N_ROLE_SAVE_SUCCESS, variant: 'success' }); | ||||
|       } catch (error) { | ||||
|         captureException(error); | ||||
|         this.emitAlert({ message: I18N_ROLE_SAVE_ERROR, variant: 'danger', dismissible: false }); | ||||
|       } finally { | ||||
|         this.emitBusy(false); | ||||
|       } | ||||
|     }, | ||||
|     emitBusy(isBusy) { | ||||
|       this.$emit('busy', isBusy); | ||||
|     }, | ||||
|     emitAlert(alert) { | ||||
|       this.$emit('alert', alert); | ||||
|     }, | ||||
|   }, | ||||
|   render() { | ||||
|     return this.$scopedSlots.default({ | ||||
|       saveRole: this.saveRole, | ||||
|     }); | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | @ -1,20 +1,14 @@ | |||
| import axios from 'axios'; | ||||
| import { roleDropdownItems } from '~/members/utils'; | ||||
| import { roleDropdownItems, initialSelectedRole } from '~/members/utils'; | ||||
| import { | ||||
|   GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, | ||||
|   MEMBER_ACCESS_LEVEL_PROPERTY_NAME, | ||||
|   MEMBERS_TAB_TYPES, | ||||
| } from '~/members/constants'; | ||||
| 
 | ||||
| export const getMemberRole = (roles, member) => { | ||||
|   const { stringValue, integerValue, memberRoleId = null } = member.accessLevel; | ||||
|   const role = roles.find(({ accessLevel }) => accessLevel === member.accessLevel.integerValue); | ||||
| 
 | ||||
|   return role || { text: stringValue, value: integerValue, memberRoleId }; | ||||
| }; | ||||
| 
 | ||||
| // EE version has a special implementation, CE version just returns the basic version.
 | ||||
| // EE overrides these.
 | ||||
| export const getRoleDropdownItems = roleDropdownItems; | ||||
| export const getMemberRole = initialSelectedRole; | ||||
| 
 | ||||
| // The API to update members uses different property names for the access level, depending on if it's a user or a group.
 | ||||
| // Users use 'access_level', groups use 'group_access'.
 | ||||
|  |  | |||
|  | @ -82,6 +82,7 @@ export default { | |||
|         return state[this.namespace].members.map((member) => ({ | ||||
|           ...member, | ||||
|           memberPath: state[this.namespace].memberPath.replace(':id', member.id), | ||||
|           ldapOverridePath: state[this.namespace].ldapOverridePath?.replace(':id', member.id), | ||||
|           namespace: this.namespace, | ||||
|         })); | ||||
|       }, | ||||
|  |  | |||
|  | @ -4,7 +4,7 @@ import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_ | |||
| import { getIdFromGraphQLId } from '~/graphql_shared/utils'; | ||||
| import { createAlert } from '~/alert'; | ||||
| import { __, sprintf } from '~/locale'; | ||||
| import { ACCESS_LEVEL_DEVELOPER_INTEGER } from '~/access_level/constants'; | ||||
| import { ACCESS_LEVEL_REPORTER_INTEGER } from '~/access_level/constants'; | ||||
| import groupsAutocompleteQuery from '~/graphql_shared/queries/groups_autocomplete.query.graphql'; | ||||
| import Api from '~/api'; | ||||
| import { getProjects } from '~/rest_api'; | ||||
|  | @ -159,7 +159,7 @@ export default { | |||
|       return Api.projectGroups(this.projectPath, { | ||||
|         search, | ||||
|         with_shared: true, | ||||
|         shared_min_access_level: ACCESS_LEVEL_DEVELOPER_INTEGER, | ||||
|         shared_min_access_level: ACCESS_LEVEL_REPORTER_INTEGER, | ||||
|       }).then((data) => | ||||
|         data?.map((group) => ({ | ||||
|           text: group.full_name, | ||||
|  |  | |||
|  | @ -72,7 +72,7 @@ module DiffHelper | |||
|       spinner = render(Pajamas::SpinnerComponent.new(size: :sm, class: 'gl-display-none gl-text-align-right', data: { visible_when_loading: true })) | ||||
|       expand_html = content_tag(:div, [expand_button, spinner].join.html_safe, data: { expand_wrapper: true }) | ||||
|     else | ||||
|       expand_html = content_tag(:div, '...', data: { visible_when_loading: false, **expand_data }) | ||||
|       expand_html = '...' | ||||
|     end | ||||
| 
 | ||||
|     if old_pos | ||||
|  |  | |||
|  | @ -1,2 +1,5 @@ | |||
| = render Pajamas::ButtonComponent.new(href: project, variant: :danger, method: :delete, button_options: { data: { confirm: remove_project_message(project) } }) do | ||||
|   = _('Delete') | ||||
| = render Pajamas::ButtonComponent.new(href: project, | ||||
|   method: :delete, | ||||
|   category: :tertiary, | ||||
|   icon: 'remove', | ||||
|   button_options: { class: 'has-tooltip', title: _('Delete'), data: { confirm: remove_project_message(project), confirm_btn_variant: 'danger' } }) | ||||
|  |  | |||
|  | @ -3,25 +3,21 @@ | |||
| - add_page_specific_style 'page_bundles/projects' | ||||
| - @force_desktop_expanded_sidebar = true | ||||
| 
 | ||||
| = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-search-settings-section' }, header_options: { class: 'gl-new-card-header gl-display-flex' }, body_options: { class: 'gl-new-card-body' }) do |c| | ||||
|   - c.with_header do | ||||
|     .gl-new-card-title-wrapper | ||||
|       %h3.gl-new-card-title | ||||
|         = _('Projects') | ||||
|       .gl-new-card-count | ||||
|         = sprite_icon('project', css_class: 'gl-mr-2') | ||||
|         = @projects.size | ||||
|     .gl-new-card-actions | ||||
|       - if can? current_user, :admin_group, @group | ||||
|         = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), size: :small) do | ||||
|           = _("New project") | ||||
| = render ::Layouts::CrudComponent.new(_('Projects'), | ||||
|   icon: 'project', | ||||
|   count: @projects.size, | ||||
|   options: { class: 'js-search-settings-section' }) do |c| | ||||
|   - c.with_actions do | ||||
|     - if can? current_user, :admin_group, @group | ||||
|       = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), size: :small) do | ||||
|         = _("New project") | ||||
|   - c.with_body do | ||||
|     %ul.content-list{ class: 'gl-px-3!' } | ||||
|     %ul.content-list | ||||
|       - @projects.each do |project| | ||||
|         %li.project-row.gl-align-items-center{ class: 'gl-display-flex!' } | ||||
|           = render Pajamas::AvatarComponent.new(project, alt: project.name, size: 48, class: 'gl-flex-shrink-0 gl-mr-5') | ||||
|           .gl-min-w-0.gl-flex-grow-1 | ||||
|             .title | ||||
|             .title.gl-mr-5 | ||||
|               = link_to project_path(project), class: 'js-prefetch-document' do | ||||
|                 %span.project-full-name | ||||
|                   %span.namespace-name | ||||
|  | @ -38,7 +34,7 @@ | |||
| 
 | ||||
|           = render 'shared/projects/badges', project: project, css_class: 'gl-mr-3' | ||||
| 
 | ||||
|           .stats.gl-text-gray-500.gl-flex-shrink-0.gl-hidden.sm:gl-flex.gl-gap-3 | ||||
|           .stats.gl-text-secondary.gl-flex-shrink-0.gl-hidden.sm:gl-flex.gl-gap-3 | ||||
|             = gl_badge_tag storage_counter(project.statistics&.storage_size) | ||||
|           .controls.gl-flex-shrink-0.gl-ml-5 | ||||
|             = render Pajamas::ButtonComponent.new(href: project_project_members_path(project), | ||||
|  | @ -46,10 +42,11 @@ | |||
|               button_options: { class: 'gl-mr-2' }) do | ||||
|               = _('View members') | ||||
|             = render Pajamas::ButtonComponent.new(href: edit_project_path(project), | ||||
|               size: :small) do | ||||
|               = _('Edit') | ||||
|               category: :tertiary, | ||||
|               icon: 'pencil', | ||||
|               button_options: { class: 'has-tooltip', title: _('Edit') }) | ||||
|             = render 'delete_project_button', project: project | ||||
|       - if @projects.blank? | ||||
|         .nothing-here-block= _("This group has no projects yet") | ||||
| 
 | ||||
| = paginate @projects, theme: "gitlab" | ||||
|   - c.with_pagination do | ||||
|     = paginate @projects, theme: "gitlab" | ||||
|  |  | |||
|  | @ -0,0 +1,15 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class CreateIndexPurlTypeAndPackageNameOnAffectedPackages < Gitlab::Database::Migration[2.2] | ||||
|   INDEX_NAME = 'index_pm_affected_packages_on_purl_type_and_package_name' | ||||
|   disable_ddl_transaction! | ||||
|   milestone '17.3' | ||||
| 
 | ||||
|   def up | ||||
|     add_concurrent_index(:pm_affected_packages, [:purl_type, :package_name], name: INDEX_NAME) | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     remove_concurrent_index_by_name(:pm_affected_packages, INDEX_NAME) | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1 @@ | |||
| 92e9bbbcc3b79f03d6acadc9d4186b675bc7332a915bd96bd93b7da9eca40244 | ||||
|  | @ -28617,6 +28617,8 @@ CREATE UNIQUE INDEX index_pm_advisories_on_advisory_xid_and_source_xid ON pm_adv | |||
| 
 | ||||
| CREATE INDEX index_pm_affected_packages_on_pm_advisory_id ON pm_affected_packages USING btree (pm_advisory_id); | ||||
| 
 | ||||
| CREATE INDEX index_pm_affected_packages_on_purl_type_and_package_name ON pm_affected_packages USING btree (purl_type, package_name); | ||||
| 
 | ||||
| CREATE INDEX index_pm_package_version_licenses_on_pm_license_id ON pm_package_version_licenses USING btree (pm_license_id); | ||||
| 
 | ||||
| CREATE INDEX index_pm_package_version_licenses_on_pm_package_version_id ON pm_package_version_licenses USING btree (pm_package_version_id); | ||||
|  |  | |||
|  | @ -353,7 +353,7 @@ You can administer all runners in the GitLab instance from the Admin area's **Ru | |||
| To access the **Runners** page: | ||||
| 
 | ||||
| 1. On the left sidebar, at the bottom, select **Admin area**. | ||||
| 1. Select **Overview > Runners**. | ||||
| 1. Select **CI/CD > Runners**. | ||||
| 
 | ||||
| #### Search and filter runners | ||||
| 
 | ||||
|  |  | |||
|  | @ -356,6 +356,8 @@ curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://git | |||
| 
 | ||||
| - `204: No Content` if successfully revoked. | ||||
| - `400: Bad Request` if not revoked successfully. | ||||
| - `401: Unauthorized` if the access token is invalid. | ||||
| - `403: Forbidden` if the access token does not have the required permissions. | ||||
| 
 | ||||
| ### Using a request header | ||||
| 
 | ||||
|  | @ -379,6 +381,7 @@ curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://git | |||
| 
 | ||||
| - `204: No Content` if successfully revoked. | ||||
| - `400: Bad Request` if not revoked successfully. | ||||
| - `401: Unauthorized` if the access token is invalid. | ||||
| 
 | ||||
| ## Create a personal access token (administrator only) | ||||
| 
 | ||||
|  |  | |||
|  | @ -434,17 +434,22 @@ Follow these best practices for best results: | |||
| - Consider adding a retry limit if there is potential for the migration to fail. | ||||
|   This ensures that migrations can be halted if an issue occurs. | ||||
| 
 | ||||
| ## Deleting advanced search migrations in a major version upgrade | ||||
| ## Cleaning up advanced search migrations | ||||
| 
 | ||||
| Because our advanced search migrations usually require us to support multiple | ||||
| Because advanced search migrations usually require us to support multiple | ||||
| code paths for a long period of time, it's important to clean those up when we | ||||
| safely can. | ||||
| 
 | ||||
| We choose to use GitLab major version upgrades as a safe time to remove | ||||
| We choose to use GitLab [required stops](../database/required_stops.md) as a safe time to remove | ||||
| backwards compatibility for indices that have not been fully migrated. We | ||||
| [document this in our upgrade documentation](../../update/index.md#upgrading-to-a-new-major-version). | ||||
| We also choose to replace the migration code with the halted migration | ||||
| and remove tests so that: | ||||
| 
 | ||||
| [GitLab Housekeeper](../../../gems/gitlab-housekeeper/README.md) | ||||
| is used to automate the cleanup process. This process includes | ||||
| marking existing migrations as obsolete and deleting obsolete migrations. | ||||
| When a migration is marked as obsolete, the migration code is replaced with | ||||
| obsolete migration code and tests are replaced with obsolete migration shared | ||||
| examples so that: | ||||
| 
 | ||||
| - We don't need to maintain any code that is called from our advanced search | ||||
|   migrations. | ||||
|  | @ -453,14 +458,13 @@ and remove tests so that: | |||
| - Operators who have not run this migration and who upgrade directly to the | ||||
|   target version see a message prompting them to reindex from scratch. | ||||
| 
 | ||||
| To be extra safe, we do not delete migrations that were created in the last | ||||
| minor version before the major upgrade. So, if we are upgrading to `%14.0`, | ||||
| we should not delete migrations that were only added in `%13.12`. This | ||||
| extra safety net allows for migrations that might | ||||
| take multiple weeks to finish on GitLab.com. It would be bad if we upgraded | ||||
| GitLab.com to `%14.0` before the migrations in `%13.12` were finished. Because | ||||
| our deployments to GitLab.com are automated and we don't have | ||||
| automated checks to prevent this, the extra precaution is warranted. | ||||
| To be extra safe, we do not clean up migrations that were created in the last | ||||
| minor version before the last required stop. For example, if the last required stop | ||||
| was `%14.0`, we should not clean up migrations that were only added in `%13.12`. | ||||
| This extra safety net allows for migrations that might take multiple weeks to | ||||
| finish on GitLab.com. Because our deployments to GitLab.com | ||||
| are automated and we do not have automated checks to prevent this cleanup, | ||||
| the extra precaution is warranted. | ||||
| Additionally, even if we did have automated checks to prevent it, we wouldn't | ||||
| actually want to hold up GitLab.com deployments on advanced search migrations, | ||||
| as they may still have another week to go, and that's too long to block | ||||
|  | @ -468,29 +472,42 @@ deployments. | |||
| 
 | ||||
| ### Process for marking migrations as obsolete | ||||
| 
 | ||||
| For every migration that was created 2 minor versions before the major version | ||||
| being upgraded to, we do the following: | ||||
| Run the [`Keeps::MarkOldAdvancedSearchMigrationsAsObsolete` Keep](../../../gems/gitlab-housekeeper/README.md#running-for-real) | ||||
| manually to mark migrations as obsolete. | ||||
| 
 | ||||
| 1. Confirm the migration has actually completed successfully for GitLab.com. | ||||
| 1. Replace the content of the migration with: | ||||
| For every migration that was created two versions before the last required stop, | ||||
| the Keep: | ||||
| 
 | ||||
| 1. Retains the content of the migration and adds a prepend to the bottom: | ||||
| 
 | ||||
|    ```ruby | ||||
|    include Elastic::MigrationObsolete | ||||
|     ClassName.prepend ::Elastic::MigrationObsolete | ||||
|    ``` | ||||
| 
 | ||||
| 1. When marking a skippable migration as obsolete, keep the `skip_if` condition. | ||||
| 1. Delete any spec files to support this migration. | ||||
| 1. Verify that there are no references of the migration in the `.rubocop_todo/` directory. | ||||
| 1. Remove any logic handling backwards compatibility for this migration. You | ||||
|    can find this by looking for | ||||
|    `Elastic::DataMigrationService.migration_has_finished?(:migration_name_in_lowercase)`. | ||||
| 1. Create a merge request with these changes. Noting that we should not | ||||
|    accidentally merge this before the major release is started. | ||||
| 1. Replaces the spec file content with the `'a deprecated Advanced Search migration'` shared example. | ||||
| 1. Randomly selects a Global Search backend engineer as an assignee. | ||||
| 1. Updates the dictionary file to mark the migration as obsolete. | ||||
| 
 | ||||
| ### Process for removing migrations | ||||
| The MR assignee must: | ||||
| 
 | ||||
| 1. Select migrations that were marked as obsolete before the current major release | ||||
| 1. If the step above includes all obsolete migrations, keep one last migration as a safeguard for customers with unapplied migrations | ||||
| 1. Delete migration files and spec files for those migrations | ||||
| 1. Verify that there are no references of the migrations in the `.rubocop_todo/` directory. | ||||
| 1. Create a merge request and assign it to a team member from the global search team. | ||||
| 1. Ensure the dictionary file has the correct `marked_obsolete_by_url` and `marked_obsolete_in_milestone`. | ||||
| 1. Verify that no references to the migration or spec files exist in the `.rubocop_todo/` directory. | ||||
| 1. Remove any logic-handling backwards compatibility for this migration by | ||||
|    looking for `Elastic::DataMigrationService.migration_has_finished?(:migration_name_in_lowercase)`. | ||||
| 1. Push any required changes to the merge request. | ||||
| 
 | ||||
| ### Process for removing obsolete migrations | ||||
| 
 | ||||
| Run the [`Keeps::DeleteObsoleteAdvancedSearchMigrations` Keep](../../../gems/gitlab-housekeeper/README.md#running-for-real) | ||||
| manually to remove obsolete migrations and specs. The Keep removes all but the most | ||||
| recent obsolete migration. | ||||
| 
 | ||||
| 1. Select obsolete migrations that were marked as obsolete before the last required stop. | ||||
| 1. If the first step includes all obsolete migrations, keep one obsolete migration as a safeguard for customers with unapplied migrations. | ||||
| 1. Delete migration files and spec files for those migrations. | ||||
| 1. Create a merge request and assign it to a Global Search team member. | ||||
| 
 | ||||
| The MR assignee must: | ||||
| 
 | ||||
| 1. Verify that no references to the migration or spec files exist in the `.rubocop_todo/` directory. | ||||
| 1. Push any required changes to the merge request. | ||||
|  |  | |||
|  | @ -143,6 +143,25 @@ You can include additional instructions to be considered. For example: | |||
| - Focus on performance, for example `/refactor improving performance`. | ||||
| - Focus on potential vulnerabilities, for example `/refactor avoiding memory leaks and exploits`. | ||||
| 
 | ||||
| ## Fix code in the IDE | ||||
| 
 | ||||
| DETAILS: | ||||
| **Tier:** GitLab.com and Self-managed: For a limited time, Premium and Ultimate. In the future, [GitLab Duo Pro or Enterprise](../../subscriptions/subscription-add-ons.md). <br>GitLab Dedicated: GitLab Duo Pro or Enterprise. | ||||
| **Offering:** GitLab.com, Self-managed, GitLab Dedicated | ||||
| **Editors:** Web IDE, VS Code, JetBrains IDEs | ||||
| **LLMs:** Anthropic: [`claude-3-5-sonnet-20240620`](https://console.cloud.google.com/vertex-ai/publishers/anthropic/model-garden/claude-3-5-sonnet) | ||||
| 
 | ||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/429915) for GitLab.com, self-managed and GitLab Dedicated in GitLab 17.3. | ||||
| 
 | ||||
| `/fix` is a special command to generate a fix suggestion for the selected code in your editor. | ||||
| You can include additional instructions to be considered. For example: | ||||
| 
 | ||||
| - Focus on grammar and typos, for example, `/fix grammar mistakes and typos`. | ||||
| - Focus on a concrete algorithm or problem description, for example, `/fix duplicate database inserts` or `/fix race conditions`. | ||||
| - Focus on potential bugs that are not directly visible, for example, `/fix potential bugs`. | ||||
| - Focus on code performance problems, for example, `/fix performance problems`. | ||||
| - Focus on fixing the build when the code does not compile, for example, `/fix the build`.  | ||||
| 
 | ||||
| ## Write tests in the IDE | ||||
| 
 | ||||
| DETAILS: | ||||
|  | @ -257,3 +276,4 @@ Use the following commands to quickly accomplish specific tasks. | |||
| | /explain               | [Explain code](../gitlab_duo_chat/examples.md#explain-code-in-the-ide)              | | ||||
| | /vulnerability_explain | [Explain current vulnerability](../gitlab_duo/index.md#vulnerability-explanation)   | | ||||
| | /refactor              | [Refactor the code](../gitlab_duo_chat/examples.md#refactor-code-in-the-ide)        | | ||||
| | /fix                   | [Fix the code](../gitlab_duo_chat/examples.md#fix-code-in-the-ide)        | | ||||
|  |  | |||
|  | @ -66,7 +66,7 @@ In the IDEs, GitLab Duo Chat knows about these areas: | |||
| | Issues  | Ask about the URL. | | ||||
| 
 | ||||
| In addition, in the IDEs, when you use any of the slash commands, | ||||
| like `/explain`, `/refactor`, or `/tests,` Duo Chat has access to the | ||||
| like `/explain`, `/refactor`, `/fix`, or `/tests,` Duo Chat has access to the | ||||
| code you selected. | ||||
| 
 | ||||
| Duo Chat always has access to: | ||||
|  |  | |||
|  | @ -81,6 +81,7 @@ For more information about slash commands, refer to the documentation: | |||
| 
 | ||||
| - [/tests](../gitlab_duo_chat/examples.md#write-tests-in-the-ide) | ||||
| - [/refactor](../gitlab_duo_chat/examples.md#refactor-code-in-the-ide) | ||||
| - [/fix](../gitlab_duo_chat/examples.md#fix-code-in-the-ide) | ||||
| - [/explain](../gitlab_duo_chat/examples.md#explain-code-in-the-ide) | ||||
| 
 | ||||
| ## `Error M4001` | ||||
|  |  | |||
|  | @ -97,7 +97,7 @@ The following features are extended from standard Markdown: | |||
| 
 | ||||
| When you use GitLab Flavored Markdown, you are creating digital content. | ||||
| This content should be as accessible as possible to your audience. | ||||
| The following list is not exhaustive, but it provides guidance for some of the GLFM styles to pay | ||||
| The following list is not exhaustive, but it provides guidance for some of the GitLab Flavored Markdown styles to pay | ||||
| particular attention to: | ||||
| 
 | ||||
| ### Accessible headings | ||||
|  | @ -118,10 +118,10 @@ Don't use `image of` or `video of` in the description. For more information, see | |||
| 
 | ||||
| ## Line breaks | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#line-breaks). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#line-breaks). | ||||
| 
 | ||||
| A line break is inserted (a new paragraph starts) if the previous text is | ||||
| ended with two newlines, like when you press <kbd>Enter</kbd> twice in a row. If you only | ||||
| ended with two newlines. For example, when you press <kbd>Enter</kbd> twice in a row. If you only | ||||
| use one newline (press <kbd>Enter</kbd> once), the next sentence remains part of the | ||||
| same paragraph. Use this approach if you want to keep long lines from wrapping, and keep | ||||
| them editable: | ||||
|  | @ -171,7 +171,7 @@ A new line due to the previous backslash. | |||
| 
 | ||||
| ## Emphasis | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#emphasis). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#emphasis). | ||||
| 
 | ||||
| You can emphasize text in multiple ways. Use italics, bold, strikethrough, | ||||
| or combine these emphasis styles together. | ||||
|  | @ -202,7 +202,7 @@ Strikethrough with double tildes. ~~Scratch this.~~ | |||
| 
 | ||||
| ### Multiple underscores in words and mid-word emphasis | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#multiple-underscores-in-words). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#multiple-underscores-in-words). | ||||
| 
 | ||||
| Avoid italicizing a portion of a word, especially when you're | ||||
| dealing with code and names that often appear with multiple underscores. | ||||
|  | @ -244,7 +244,7 @@ do*this*and*do*that*and*another thing | |||
| 
 | ||||
| ### Inline diff | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#inline-diff). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#inline-diff). | ||||
| 
 | ||||
| With inline diff tags, you can display `{+ additions +}` or `[- deletions -]`. | ||||
| 
 | ||||
|  | @ -270,7 +270,7 @@ However, you cannot mix the wrapping tags: | |||
| - [- deletion -} | ||||
| ``` | ||||
| 
 | ||||
| Diff highlighting doesn't work with `` `inline code` ``. If your text includes backticks (`` ` ``), escape | ||||
| Diff highlighting doesn't work with `` `inline code` ``. If your text includes backticks (`` ` ``), [escape](#escape-characters) | ||||
| each backtick with a backslash <code>\</code>: | ||||
| 
 | ||||
| ```markdown | ||||
|  | @ -308,7 +308,7 @@ Alt-H2 | |||
| 
 | ||||
| > - Heading link generation [changed](https://gitlab.com/gitlab-org/gitlab/-/issues/440733) in GitLab 17.0. | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#heading-ids-and-links). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#heading-ids-and-links). | ||||
| 
 | ||||
| All Markdown-rendered headings automatically | ||||
| get IDs that can be linked to, except in comments. | ||||
|  | @ -349,7 +349,7 @@ Would generate the following link IDs: | |||
| 
 | ||||
| ## Links | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#links). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#links). | ||||
| 
 | ||||
| You can create links two ways: inline-style and reference-style. For example: | ||||
| 
 | ||||
|  | @ -412,7 +412,7 @@ points the link to `wikis/style` only when the link is inside of a wiki Markdown | |||
| 
 | ||||
| ### URL auto-linking | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#url-auto-linking). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#url-auto-linking). | ||||
| 
 | ||||
| Almost any URL you put into your text is auto-linked: | ||||
| 
 | ||||
|  | @ -484,7 +484,7 @@ Reference-style: | |||
| 
 | ||||
| ### Videos | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#videos). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#videos). | ||||
| 
 | ||||
| Image tags that link to files with a video extension are automatically converted to | ||||
| a video player. The valid video extensions are `.mp4`, `.m4v`, `.mov`, `.webm`, and `.ogv`: | ||||
|  | @ -502,7 +502,7 @@ Here's an example video: | |||
| > - Support for images [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/28118) in GitLab 15.7. | ||||
| > - Support for videos [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/17139) in GitLab 15.9. | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#change-the-image-or-video-dimensions). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#change-the-image-or-video-dimensions). | ||||
| 
 | ||||
| You can control the width and height of an image or video by following the image with | ||||
| an attribute list. | ||||
|  | @ -529,7 +529,7 @@ resized to 75% of its dimensions. | |||
| 
 | ||||
| ### Audio | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#audio). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#audio). | ||||
| 
 | ||||
| Similar to videos, link tags for files with an audio extension are automatically converted to | ||||
| an audio player. The valid audio extensions are `.mp3`, `.oga`, `.ogg`, `.spx`, and `.wav`: | ||||
|  | @ -544,12 +544,12 @@ Here's an example audio clip: | |||
| 
 | ||||
| ## Lists | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#lists). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#lists). | ||||
| 
 | ||||
| You can create ordered and unordered lists. | ||||
| 
 | ||||
| For an ordered list, add the number you want the list | ||||
| to start with, like `1.`, followed by a space, at the start of each line for ordered lists. | ||||
| to start with, like `1.`, followed by a space, at the start of each line. | ||||
| After the first number, it does not matter what number you use. Ordered lists are | ||||
| numbered automatically by vertical order, so repeating `1.` for all items in the | ||||
| same list is common. If you start with a number other than `1.`, it uses that as the first | ||||
|  | @ -582,7 +582,7 @@ See https://docs.gitlab.com/ee/development/documentation/styleguide/index.html#l | |||
| 1. And another item. | ||||
| 
 | ||||
| For an unordered list, add a `-`, `*` or `+`, followed by a space, at the start of | ||||
| each line for unordered lists, but you should not use a mix of them. | ||||
| each line. Don't mix the characters in the same list. | ||||
| 
 | ||||
| ```markdown | ||||
| Unordered lists can: | ||||
|  | @ -646,7 +646,8 @@ Example: | |||
| --- | ||||
| 
 | ||||
| If the first item's paragraph isn't indented with the proper number of spaces, | ||||
| the paragraph appears outside the list, instead of properly indented under the list item. | ||||
| the paragraph appears outside the list. | ||||
| Use the correct number of spaces to properly indent under the list item. | ||||
| For example: | ||||
| 
 | ||||
| ```markdown | ||||
|  | @ -684,8 +685,8 @@ Ordered lists that are the first sub-item of an unordered list item must have a | |||
| 
 | ||||
| --- | ||||
| 
 | ||||
| CommonMark ignores blank lines between ordered and unordered list items, and considers them part of a single list. These are rendered as a | ||||
| _[loose](https://spec.commonmark.org/0.30/#loose)_ list. Each list item is enclosed in a paragraph tag and, therefore, has paragraph spacing and margins. | ||||
| CommonMark ignores blank lines between ordered and unordered list items, and considers them part of a single list. The items are rendered as a | ||||
| _[loose](https://spec.commonmark.org/0.30/#loose)_ list. Each list item is enclosed in a paragraph tag and therefore has paragraph spacing and margins. | ||||
| This makes the list look like there is extra spacing between each item. | ||||
| 
 | ||||
| For example: | ||||
|  | @ -703,7 +704,7 @@ CommonMark ignores the blank line and renders this as one list with paragraph sp | |||
| 
 | ||||
| > - Inapplicable checkboxes [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85982) in GitLab 15.3. | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#task-lists). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#task-lists). | ||||
| 
 | ||||
| You can add task lists anywhere Markdown is supported. | ||||
| 
 | ||||
|  | @ -738,7 +739,7 @@ To include task lists in tables, [use HTML list tags or HTML tables](#task-lists | |||
| 
 | ||||
| ## Blockquotes | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#blockquotes). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#blockquotes). | ||||
| 
 | ||||
| Use a blockquote to highlight information, such as a side note. It's generated | ||||
| by starting the lines of the blockquote with `>`: | ||||
|  | @ -761,7 +762,7 @@ Quote break. | |||
| 
 | ||||
| ### Multiline blockquote | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#multiline-blockquote). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#multiline-blockquote). | ||||
| 
 | ||||
| Create multi-line blockquotes fenced by `>>>`: | ||||
| 
 | ||||
|  | @ -785,7 +786,7 @@ you can quote that without having to manually prepend `>` to every line! | |||
| 
 | ||||
| ## Code spans and blocks | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#code-spans-and-blocks). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#code-spans-and-blocks). | ||||
| 
 | ||||
| Highlight anything that should be viewed as code and not standard text. | ||||
| 
 | ||||
|  | @ -848,7 +849,7 @@ Tildes are OK too. | |||
| 
 | ||||
| ### Syntax highlighting | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#syntax-highlighting). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#syntax-highlighting). | ||||
| 
 | ||||
| GitLab uses the [Rouge Ruby library](https://github.com/rouge-ruby/rouge) for more colorful syntax | ||||
| highlighting in code blocks. For a list of supported languages visit the | ||||
|  | @ -924,7 +925,7 @@ In wikis, you can also add and edit diagrams created with the [diagrams.net edit | |||
| 
 | ||||
| > - Support for Entity Relationship diagrams and mind maps [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/384386) in GitLab 16.0. | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#mermaid). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#mermaid). | ||||
| 
 | ||||
| Visit the [official page](https://mermaidjs.github.io/) for more details. The | ||||
| [Mermaid Live Editor](https://mermaid-js.github.io/mermaid-live-editor/) helps you | ||||
|  | @ -1004,7 +1005,7 @@ For more information, see the [Kroki integration](../administration/integration/ | |||
| > - LaTeX-compatible fencing [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/21757) in GitLab 15.4 [with a flag](../administration/feature_flags.md) named `markdown_dollar_math`. Disabled by default. Enabled on GitLab.com. | ||||
| > - LaTeX-compatible fencing [generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/371180) in GitLab 15.8. Feature flag `markdown_dollar_math` removed. | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#math). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#math). | ||||
| 
 | ||||
| Math written in LaTeX syntax is rendered with [KaTeX](https://github.com/KaTeX/KaTeX). | ||||
| _KaTeX only supports a [subset](https://katex.org/docs/supported.html) of LaTeX._ | ||||
|  | @ -1058,7 +1059,7 @@ $$ | |||
| 
 | ||||
| ## Tables | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#tables-1). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#tables-1). | ||||
| 
 | ||||
| When creating tables: | ||||
| 
 | ||||
|  | @ -1076,7 +1077,7 @@ When creating tables: | |||
|     by pipes (`|`). | ||||
|   - You **can** have blank cells. | ||||
| - Column widths are calculated dynamically based on the content of the cells. | ||||
| - To use the pipe character (`|`) in the text and not as table delimiter, you must escape it with a backslash (`\|`). | ||||
| - To use the pipe character (`|`) in the text and not as table delimiter, you must [escape](#escape-characters) it with a backslash (`\|`). | ||||
| 
 | ||||
| Example: | ||||
| 
 | ||||
|  | @ -1096,7 +1097,7 @@ Example: | |||
| 
 | ||||
| ### Alignment | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#alignment). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#alignment). | ||||
| 
 | ||||
| Additionally, you can choose the alignment of text in columns by adding colons (`:`) | ||||
| to the sides of the "dash" lines in the second row. This affects every cell in the column: | ||||
|  | @ -1118,7 +1119,7 @@ the headers are always left-aligned in Chrome and Firefox, and centered in Safar | |||
| 
 | ||||
| ### Cells with multiple lines | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#cells-with-multiple-lines). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#cells-with-multiple-lines). | ||||
| 
 | ||||
| You can use HTML formatting to adjust the rendering of tables. For example, you can | ||||
| use `<br>` tags to force a cell to have multiple lines: | ||||
|  | @ -1396,7 +1397,7 @@ Second section content. | |||
| 
 | ||||
| ## Colors | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#colors). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#colors). | ||||
| 
 | ||||
| Markdown does not support changing text color. | ||||
| 
 | ||||
|  | @ -1435,7 +1436,7 @@ display a color chip next to the color code. For example: | |||
| 
 | ||||
| ## Emoji | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#emoji). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#emoji). | ||||
| 
 | ||||
| Sometimes you want to <img src="https://gitlab.com/gitlab-org/gitlab-foss/raw/master/public/-/emojis/2/monkey.png" width="20px" height="20px" style="display:inline;margin:0;border:0;padding:0;" title=":monkey:" alt=":monkey:"> | ||||
| around a bit and add some <img src="https://gitlab.com/gitlab-org/gitlab-foss/raw/master/public/-/emojis/2/star2.png" width="20px" height="20px" style="display:inline;margin:0;border:0;padding:0;" title=":star2:" alt=":star2:"> | ||||
|  | @ -1559,9 +1560,61 @@ $example = array( | |||
| --- | ||||
| ``` | ||||
| 
 | ||||
| ## Escape characters | ||||
| 
 | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#escape-characters). | ||||
| 
 | ||||
| Markdown reserves the following ASCII characters to format the page: | ||||
| 
 | ||||
| ```plaintext | ||||
| ! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \ ] ^ _ ` { | } ~ | ||||
| ``` | ||||
| 
 | ||||
| To use one of these reserved characters in your text, add the backslash character (` \ `) immediately before the | ||||
| reserved character. When you place the backslash before a reserved character, the Markdown parser omits the | ||||
| backslash and treats the reserved character as regular text. | ||||
| 
 | ||||
| Examples: | ||||
| 
 | ||||
| ```plaintext | ||||
| \# Not a heading | ||||
| 
 | ||||
| | Food            | Do you like this food? (circle) | | ||||
| |-----------------|---------------------------------| | ||||
| |  Pizza          |  Yes \| No                      | | ||||
| 
 | ||||
| 
 | ||||
| \**Not bold, just italic text placed between some asterisks*\* | ||||
| ``` | ||||
| 
 | ||||
| When rendered, the escaped characters look like this: | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| \# Not a heading | ||||
| 
 | ||||
| | Food            | Do you like this food? (circle)| | ||||
| |-----------------|--------------------------------| | ||||
| |  Pizza          |  Yes \| No                     | | ||||
| 
 | ||||
| \**Not bold, just italic text placed between some asterisks*\* | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| Exceptions: | ||||
| 
 | ||||
| A backslash doesn't always escape the following character. The backslash appears as regular text in the following cases: | ||||
| 
 | ||||
| - When the backslash appears before a non-reserved character, such as `A`, `3`, or a space. | ||||
| - When the backslash appears inside of these Markdown elements: | ||||
|   - Code blocks | ||||
|   - Code spans | ||||
|   - Auto-links | ||||
|   - Inline HTML | ||||
| 
 | ||||
| ## Footnotes | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#footnotes). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#footnotes). | ||||
| 
 | ||||
| Footnotes add a link to a note rendered at the end of a Markdown file. | ||||
| 
 | ||||
|  | @ -1602,7 +1655,7 @@ These are used to force the Vale ReferenceLinks check to skip these examples. | |||
| 
 | ||||
| ## Horizontal rule | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#horizontal-rule). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#horizontal-rule). | ||||
| 
 | ||||
| Create a horizontal rule by using three or more hyphens, asterisks, or underscores: | ||||
| 
 | ||||
|  | @ -1622,7 +1675,7 @@ ___ | |||
| 
 | ||||
| ## Inline HTML | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#inline-html). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#inline-html). | ||||
| 
 | ||||
| You can also use raw HTML in your Markdown, and it usually works pretty well. | ||||
| 
 | ||||
|  | @ -1687,7 +1740,7 @@ Markdown is fine in GitLab. | |||
| 
 | ||||
| ### Collapsible section | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#details-and-summary). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#details-and-summary). | ||||
| 
 | ||||
| Content can be collapsed using HTML's [`<details>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details) | ||||
| and [`<summary>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/summary) | ||||
|  | @ -1752,7 +1805,7 @@ These details <em>remain</em> <b>hidden</b> until expanded. | |||
| 
 | ||||
| ### Keyboard HTML tag | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#keyboard-html-tag). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#keyboard-html-tag). | ||||
| 
 | ||||
| The `<kbd>` element is used to identify text that represents user keyboard input. Text surrounded by `<kbd>` tags is typically displayed in the browser's default monospace font. | ||||
| 
 | ||||
|  | @ -1764,7 +1817,7 @@ Press <kbd>Enter</kbd> to go to the next page. | |||
| 
 | ||||
| ### Superscripts / Subscripts | ||||
| 
 | ||||
| [View this topic in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#superscripts-subscripts). | ||||
| [View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#superscripts-subscripts). | ||||
| 
 | ||||
| For superscripts and subscripts, use the standard HTML syntax: | ||||
| 
 | ||||
|  | @ -1812,8 +1865,8 @@ it links to `<your_wiki>/documentation/file.md`: | |||
| 
 | ||||
| ### Wiki - hierarchical link | ||||
| 
 | ||||
| A hierarchical link can be constructed relative to the current wiki page by using `./<page>`, | ||||
| `../<page>`, and so on. | ||||
| A hierarchical link can be constructed relative to the current wiki page by using relative paths like `./<page>` or | ||||
| `../<page>`. | ||||
| 
 | ||||
| If this example is on a page at `<your_wiki>/documentation/main`, | ||||
| it links to `<your_wiki>/documentation/related`: | ||||
|  |  | |||
|  | @ -30,15 +30,14 @@ With GitLab Duo Code Suggestions, you get: | |||
| - Code generation, which generates code based on a natural language code | ||||
|   comment block. Write a comment like `# check if code suggestions are | ||||
|   enabled for current user`, then press <kbd>Enter</kbd> to generate code based | ||||
|   on the context of your comment and the rest of your code. | ||||
| 
 | ||||
|   Code generation requests are slower than code completion requests, but provide | ||||
|   more accurate responses because: | ||||
|   on the context of your comment and the rest of your code. Code generation requests | ||||
|   are slower than code completion requests, but provide more accurate responses because: | ||||
|   - A larger LLM is used. | ||||
|   - Additional context is sent in the request, for example, | ||||
|     the libraries used by the project. | ||||
| 
 | ||||
|   Code generation is used when the: | ||||
| 
 | ||||
|   - User writes a comment and hits <kbd>Enter</kbd>. | ||||
|   - File being edited is less than five lines of code. | ||||
|   - User enters an empty function or method. | ||||
|  |  | |||
|  | @ -57,14 +57,18 @@ gitlab-advanced-sast: | |||
|       when: never | ||||
|     - if: $SAST_EXCLUDED_ANALYZERS =~ /gitlab-advanced-sast/ | ||||
|       when: never | ||||
|     - if: $CI_PIPELINE_SOURCE == "merge_request_event"  # Add the job to merge request pipelines if there's an open merge request. | ||||
|       # Add the job to merge request pipelines if there's an open merge request. | ||||
|     - if: $CI_PIPELINE_SOURCE == "merge_request_event" && | ||||
|           $GITLAB_FEATURES =~ /\bsast_advanced\b/ | ||||
|       exists: | ||||
|         - '**/*.py' | ||||
|         - '**/*.go' | ||||
|         - '**/*.java' | ||||
|     - if: $CI_OPEN_MERGE_REQUESTS  # Don't add it to a *branch* pipeline if it's already in a merge request pipeline. | ||||
|       when: never | ||||
|     - if: $CI_COMMIT_BRANCH        # If there's no open merge request, add it to a *branch* pipeline instead. | ||||
|     # If there's no open merge request, add it to a *branch* pipeline instead. | ||||
|     - if: $CI_COMMIT_BRANCH && | ||||
|           $GITLAB_FEATURES =~ /\bsast_advanced\b/ | ||||
|       exists: | ||||
|         - '**/*.py' | ||||
|         - '**/*.go' | ||||
|  |  | |||
|  | @ -32218,9 +32218,6 @@ msgstr "" | |||
| msgid "MemberRole|Change role" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "MemberRole|Could not check guest overage." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "MemberRole|Could not fetch available permissions." | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -32314,6 +32311,9 @@ msgstr "" | |||
| msgid "MemberRole|Permissions" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "MemberRole|Reverted to LDAP group sync settings. The role will be updated after the next LDAP sync." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "MemberRole|Role" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -32356,6 +32356,9 @@ msgstr "" | |||
| msgid "MemberRole|The Reporter role is suitable for team members who need to stay informed about a project or group but do not actively contribute code." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "MemberRole|This member is an LDAP user. Changing their role will override the settings from the LDAP group sync." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "MemberRole|This role has been manually selected and will not sync to the LDAP sync role." | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -32365,6 +32368,9 @@ msgstr "" | |||
| msgid "MemberRole|Update role" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "MemberRole|Use LDAP sync role" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "MemberRole|View permissions" | ||||
| msgstr "" | ||||
| 
 | ||||
|  |  | |||
|  | @ -113,5 +113,6 @@ FactoryBot.define do | |||
| 
 | ||||
|     factory :project_audit_event, traits: [:project_event] | ||||
|     factory :group_audit_event, traits: [:group_event] | ||||
|     factory :instance_audit_event, traits: [:instance_event] | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -145,6 +145,7 @@ describe('Number Utils', () => { | |||
|       ${123456789} | ${'123.5m'} | ||||
|     `('returns $expected given $number', ({ number, expected }) => {
 | ||||
|       expect(numberToMetricPrefix(number)).toBe(expected); | ||||
|       expect(numberToMetricPrefix(number, true)).toBe(expected.toUpperCase()); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,35 +1,45 @@ | |||
| import { GlDrawer, GlSprintf, GlAlert } from '@gitlab/ui'; | ||||
| import MockAdapter from 'axios-mock-adapter'; | ||||
| import axios from 'axios'; | ||||
| import { GlDrawer, GlAlert } from '@gitlab/ui'; | ||||
| import { nextTick } from 'vue'; | ||||
| import { cloneDeep } from 'lodash'; | ||||
| import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; | ||||
| import RoleDetailsDrawer from '~/members/components/table/drawer/role_details_drawer.vue'; | ||||
| import MembersTableCell from '~/members/components/table/members_table_cell.vue'; | ||||
| import MemberAvatar from '~/members/components/table/member_avatar.vue'; | ||||
| import RoleSelector from '~/members/components/role_selector.vue'; | ||||
| import { roleDropdownItems } from '~/members/utils'; | ||||
| import waitForPromises from 'helpers/wait_for_promises'; | ||||
| import RoleUpdater from 'ee_else_ce/members/components/table/drawer/role_updater.vue'; | ||||
| import { RENDER_ALL_SLOTS_TEMPLATE, stubComponent } from 'helpers/stub_component'; | ||||
| import { member as memberData, updateableMember } from '../../../mock_data'; | ||||
| 
 | ||||
| jest.mock('~/lib/utils/dom_utils', () => ({ | ||||
|   getContentWrapperHeight: () => '123', | ||||
| })); | ||||
| 
 | ||||
| describe('Role details drawer', () => { | ||||
|   const dropdownItems = roleDropdownItems(updateableMember); | ||||
|   const toastShowMock = jest.fn(); | ||||
|   const currentRole = dropdownItems.flatten[5]; | ||||
|   const newRole = dropdownItems.flatten[2]; | ||||
|   let axiosMock; | ||||
|   const saveRoleStub = jest.fn(); | ||||
|   let wrapper; | ||||
| 
 | ||||
|   const createWrapper = ({ member = updateableMember } = {}) => { | ||||
|     wrapper = shallowMountExtended(RoleDetailsDrawer, { | ||||
|       propsData: { member }, | ||||
|       provide: { | ||||
|         currentUserId: 1, | ||||
|         canManageMembers: true, | ||||
|         group: 'group/path', | ||||
|       stubs: { | ||||
|         GlDrawer: stubComponent(GlDrawer, { template: RENDER_ALL_SLOTS_TEMPLATE }), | ||||
|         RoleUpdater: stubComponent(RoleUpdater, { | ||||
|           template: '<div><slot :save-role="saveRole"></slot></div>', | ||||
|           methods: { saveRole: saveRoleStub }, | ||||
|         }), | ||||
|         MembersTableCell: stubComponent(MembersTableCell, { | ||||
|           render() { | ||||
|             return this.$scopedSlots.default({ | ||||
|               memberType: 'user', | ||||
|               isCurrentUser: false, | ||||
|               permissions: { canUpdate: member.canUpdate }, | ||||
|             }); | ||||
|           }, | ||||
|         }), | ||||
|       }, | ||||
|       stubs: { GlDrawer, MembersTableCell, GlSprintf }, | ||||
|       mocks: { $toast: { show: toastShowMock } }, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|  | @ -37,26 +47,18 @@ describe('Role details drawer', () => { | |||
|   const findRoleText = () => wrapper.findByTestId('role-text'); | ||||
|   const findRoleSelector = () => wrapper.findComponent(RoleSelector); | ||||
|   const findRoleDescription = () => wrapper.findByTestId('description-value'); | ||||
|   const findRoleUpdater = () => wrapper.findComponent(RoleUpdater); | ||||
|   const findSaveButton = () => wrapper.findByTestId('save-button'); | ||||
|   const findCancelButton = () => wrapper.findByTestId('cancel-button'); | ||||
|   const findAlert = () => wrapper.findComponent(GlAlert); | ||||
| 
 | ||||
|   const createWrapperChangeRoleAndClickSave = async () => { | ||||
|     createWrapper({ member: cloneDeep(updateableMember) }); | ||||
|   const createWrapperAndChangeRole = () => { | ||||
|     createWrapper(); | ||||
|     findRoleSelector().vm.$emit('input', newRole); | ||||
|     await nextTick(); | ||||
|     findSaveButton().vm.$emit('click'); | ||||
| 
 | ||||
|     return waitForPromises(); | ||||
|     return nextTick; | ||||
|   }; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     axiosMock = new MockAdapter(axios); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     axiosMock.restore(); | ||||
|   }); | ||||
| 
 | ||||
|   it('does not show the drawer when there is no member', () => { | ||||
|     createWrapper({ member: null }); | ||||
| 
 | ||||
|  | @ -64,42 +66,30 @@ describe('Role details drawer', () => { | |||
|   }); | ||||
| 
 | ||||
|   describe('when there is a member', () => { | ||||
|     beforeEach(() => { | ||||
|       createWrapper({ member: memberData }); | ||||
|     }); | ||||
|     beforeEach(createWrapper); | ||||
| 
 | ||||
|     it('shows the drawer with expected props', () => { | ||||
|       expect(findDrawer().props()).toMatchObject({ headerSticky: true, open: true, zIndex: 252 }); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows the user avatar', () => { | ||||
|       expect(wrapper.findComponent(MembersTableCell).props('member')).toBe(memberData); | ||||
|       expect(wrapper.findComponent(MemberAvatar).props()).toMatchObject({ | ||||
|         memberType: 'user', | ||||
|         isCurrentUser: false, | ||||
|         member: memberData, | ||||
|     it('shows the drawer', () => { | ||||
|       expect(findDrawer().props()).toMatchObject({ | ||||
|         headerHeight: '123', | ||||
|         headerSticky: true, | ||||
|         open: true, | ||||
|         zIndex: 252, | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not show footer buttons', () => { | ||||
|       expect(findSaveButton().exists()).toBe(false); | ||||
|       expect(findCancelButton().exists()).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits close event when drawer is closed', () => { | ||||
|       findDrawer().vm.$emit('close'); | ||||
| 
 | ||||
|       expect(wrapper.emitted('close')).toHaveLength(1); | ||||
|     it('shows the user avatar', () => { | ||||
|       expect(wrapper.findComponent(MembersTableCell).props('member')).toBe(updateableMember); | ||||
|       expect(wrapper.findComponent(MemberAvatar).props()).toEqual({ | ||||
|         memberType: 'user', | ||||
|         isCurrentUser: false, | ||||
|         member: updateableMember, | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('role name', () => { | ||||
|       it('shows the header', () => { | ||||
|         expect(wrapper.findByTestId('role-header').text()).toBe('Role'); | ||||
|       }); | ||||
| 
 | ||||
|       it('shows the role name', () => { | ||||
|         expect(findRoleText().text()).toContain('Owner'); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('role description', () => { | ||||
|  | @ -110,17 +100,6 @@ describe('Role details drawer', () => { | |||
|       it('shows the role description', () => { | ||||
|         expect(findRoleDescription().text()).toBe(currentRole.description); | ||||
|       }); | ||||
| 
 | ||||
|       it('shows "No description" when there is no role description', async () => { | ||||
|         // Create a member that's assigned to a non-existent custom role.
 | ||||
|         const member = { ...updateableMember, accessLevel: { memberRoleId: 999 } }; | ||||
|         wrapper.setProps({ member }); | ||||
|         await nextTick(); | ||||
|         const noDescriptionSpan = findRoleDescription().find('span'); | ||||
| 
 | ||||
|         expect(noDescriptionSpan.text()).toBe('No description'); | ||||
|         expect(noDescriptionSpan.classes('gl-text-gray-400')).toBe(true); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('role permissions', () => { | ||||
|  | @ -142,7 +121,7 @@ describe('Role details drawer', () => { | |||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('role selector', () => { | ||||
|   describe('role name/selector', () => { | ||||
|     it('shows role name when the member cannot be edited', () => { | ||||
|       createWrapper({ member: memberData }); | ||||
| 
 | ||||
|  | @ -162,53 +141,32 @@ describe('Role details drawer', () => { | |||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when the user only has read access', () => { | ||||
|     it('shows the custom role name', () => { | ||||
|       const member = { | ||||
|         ...memberData, | ||||
|         accessLevel: { stringValue: 'Custom role', memberRoleId: 102 }, | ||||
|       }; | ||||
|       createWrapper({ member }); | ||||
| 
 | ||||
|       expect(findRoleText().text()).toBe('Custom role'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when role is changed', () => { | ||||
|     beforeEach(() => { | ||||
|       createWrapper(); | ||||
|       findRoleSelector().vm.$emit('input', newRole); | ||||
|     beforeEach(createWrapperAndChangeRole); | ||||
| 
 | ||||
|     it('shows role updater', () => { | ||||
|       expect(findRoleUpdater().props()).toEqual({ member: updateableMember, role: newRole }); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows save button', () => { | ||||
|       expect(findSaveButton().text()).toBe('Update role'); | ||||
|       expect(findSaveButton().props()).toMatchObject({ | ||||
|         variant: 'confirm', | ||||
|         loading: false, | ||||
|       }); | ||||
|       expect(findSaveButton().props()).toMatchObject({ variant: 'confirm', loading: false }); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows cancel button', () => { | ||||
|       expect(findCancelButton().props('variant')).toBe('default'); | ||||
|       expect(findCancelButton().props()).toMatchObject({ | ||||
|         variant: 'default', | ||||
|         loading: false, | ||||
|       }); | ||||
|       expect(findCancelButton().text()).toBe('Cancel'); | ||||
|       expect(findCancelButton().props()).toMatchObject({ variant: 'default', loading: false }); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows the new role in the role selector', () => { | ||||
|       expect(findRoleSelector().props('value')).toBe(newRole); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|     it('does not call update role API', () => { | ||||
|       expect(axiosMock.history.put).toHaveLength(0); | ||||
|     }); | ||||
|   describe('when cancel button is clicked', () => { | ||||
|     beforeEach(createWrapperAndChangeRole); | ||||
| 
 | ||||
|     it('does not emit any events', () => { | ||||
|       expect(Object.keys(wrapper.emitted())).toHaveLength(0); | ||||
|     }); | ||||
| 
 | ||||
|     it('resets back to initial role when cancel button is clicked', async () => { | ||||
|     it('resets back to initial role', async () => { | ||||
|       findCancelButton().vm.$emit('click'); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|  | @ -217,118 +175,113 @@ describe('Role details drawer', () => { | |||
|   }); | ||||
| 
 | ||||
|   describe('when update role button is clicked', () => { | ||||
|     beforeEach(() => { | ||||
|       axiosMock.onPut('user/path/238').replyOnce(200); | ||||
|       createWrapperChangeRoleAndClickSave(); | ||||
|     it('calls saveRole method on the role updater', async () => { | ||||
|       await createWrapperAndChangeRole(); | ||||
|       findSaveButton().vm.$emit('click'); | ||||
| 
 | ||||
|       return nextTick(); | ||||
|       expect(saveRoleStub).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|     it('calls update role API with expected data', () => { | ||||
|       const expectedData = JSON.stringify({ | ||||
|         access_level: newRole.accessLevel, | ||||
|         member_role_id: newRole.memberRoleId, | ||||
|   describe('role updater', () => { | ||||
|     beforeEach(createWrapperAndChangeRole); | ||||
| 
 | ||||
|     describe.each([true, false])('when busy event is %s', (busy) => { | ||||
|       beforeEach(() => { | ||||
|         findRoleUpdater().vm.$emit('busy', busy); | ||||
|       }); | ||||
| 
 | ||||
|       expect(axiosMock.history.put[0].data).toBe(expectedData); | ||||
|       it('sets loading on role selector', () => { | ||||
|         expect(findRoleSelector().props('loading')).toBe(busy); | ||||
|       }); | ||||
| 
 | ||||
|       it('sets loading on save button', () => { | ||||
|         expect(findSaveButton().props('loading')).toBe(busy); | ||||
|       }); | ||||
| 
 | ||||
|       it('sets disabled on cancel button', () => { | ||||
|         expect(findCancelButton().props('disabled')).toBe(busy); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('disables footer buttons', () => { | ||||
|       expect(findSaveButton().props('loading')).toBe(true); | ||||
|       expect(findCancelButton().props('disabled')).toBe(true); | ||||
|     // This needs to be a separate test from the describe.each() block above because watchers aren't invoked if the
 | ||||
|     // value didn't change, so setting the busy state to false when it's already false will cause the test to fail.
 | ||||
|     // Here, we'll set it to true first, then false, which changes the value both times, thus invoking the watcher.
 | ||||
|     it('emits busy event when loading state is changed', async () => { | ||||
|       findRoleUpdater().vm.$emit('busy', true); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(wrapper.emitted('busy')[0][0]).toBe(true); | ||||
| 
 | ||||
|       findRoleUpdater().vm.$emit('busy', false); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(wrapper.emitted('busy')[1][0]).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('disables role dropdown', () => { | ||||
|       expect(findRoleSelector().props('loading')).toBe(true); | ||||
|     it('resets the selected role on a reset event', async () => { | ||||
|       await createWrapperAndChangeRole(); | ||||
|       findRoleUpdater().vm.$emit('reset'); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findRoleSelector().props('value')).toEqual(currentRole); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('alert', () => { | ||||
|     beforeEach(async () => { | ||||
|       await createWrapperAndChangeRole(); | ||||
|       findRoleUpdater().vm.$emit('alert', { | ||||
|         message: 'alert message', | ||||
|         variant: 'info', | ||||
|         dismissible: false, | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits busy event as true', () => { | ||||
|       const busyEvents = wrapper.emitted('busy'); | ||||
| 
 | ||||
|       expect(busyEvents).toHaveLength(1); | ||||
|       expect(busyEvents[0][0]).toBe(true); | ||||
|     it('shows an alert when role updater changes the alert', () => { | ||||
|       expect(findAlert().text()).toBe('alert message'); | ||||
|       expect(findAlert().props()).toMatchObject({ variant: 'info', dismissible: false }); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not close the drawer when it is trying to close', () => { | ||||
|     it('keeps alert when role updater resets selected role', async () => { | ||||
|       // Some workflows treat a role reset as a success. We shouldn't clear the alert in this case because it would
 | ||||
|       // clear out the success message.
 | ||||
|       findRoleUpdater().vm.$emit('reset'); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findAlert().exists()).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it.each` | ||||
|       phrase                                          | setupFn | ||||
|       ${'when the role updater emits an empty alert'} | ${() => findRoleUpdater().vm.$emit('alert', null)} | ||||
|       ${'when selected role is changed'}              | ${() => findRoleSelector().vm.$emit('input', currentRole)} | ||||
|       ${'when drawer is closed'}                      | ${() => findDrawer().vm.$emit('close')} | ||||
|       ${'when member is changed'}                     | ${() => wrapper.setProps({ member: memberData })} | ||||
|       ${'when alert is dismissed'}                    | ${() => findAlert().vm.$emit('dismiss')} | ||||
|     `('clears alert when $phrase', async ({ setupFn }) => {
 | ||||
|       setupFn(); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findAlert().exists()).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when drawer is closing', () => { | ||||
|     it('emits close event', () => { | ||||
|       createWrapper(); | ||||
|       findDrawer().vm.$emit('close'); | ||||
| 
 | ||||
|       expect(wrapper.emitted('close')).toHaveLength(1); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not allow the drawer to close when the role is saving', async () => { | ||||
|       await createWrapperAndChangeRole(); | ||||
|       findRoleUpdater().vm.$emit('busy', true); | ||||
|       findDrawer().vm.$emit('close'); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(wrapper.emitted('close')).toBeUndefined(); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when update role API call is finished', () => { | ||||
|     beforeEach(() => { | ||||
|       axiosMock.onPut('user/path/238').replyOnce(200); | ||||
|       return createWrapperChangeRoleAndClickSave(); | ||||
|     }); | ||||
| 
 | ||||
|     it('hides footer buttons', () => { | ||||
|       expect(findSaveButton().exists()).toBe(false); | ||||
|       expect(findCancelButton().exists()).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('enables role selector', () => { | ||||
|       expect(findRoleSelector().props('loading')).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits busy event with false', () => { | ||||
|       const busyEvents = wrapper.emitted('busy'); | ||||
| 
 | ||||
|       expect(busyEvents).toHaveLength(2); | ||||
|       expect(busyEvents[1][0]).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows toast', () => { | ||||
|       expect(toastShowMock).toHaveBeenCalledTimes(1); | ||||
|       expect(toastShowMock).toHaveBeenCalledWith('Role was successfully updated.'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when role admin approval is enabled and role is updated', () => { | ||||
|     beforeEach(() => { | ||||
|       axiosMock.onPut('user/path/238').replyOnce(200, { enqueued: true }); | ||||
|       return createWrapperChangeRoleAndClickSave(); | ||||
|     }); | ||||
| 
 | ||||
|     it('resets role back to initial role', () => { | ||||
|       expect(findRoleSelector().props('value')).toEqual(currentRole); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows toast', () => { | ||||
|       expect(toastShowMock).toHaveBeenCalledTimes(1); | ||||
|       expect(toastShowMock).toHaveBeenCalledWith( | ||||
|         'Role change request was sent to the administrator.', | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when update role API fails', () => { | ||||
|     beforeEach(() => { | ||||
|       axiosMock.onPut('user/path/238').replyOnce(500); | ||||
|       return createWrapperChangeRoleAndClickSave(); | ||||
|     }); | ||||
| 
 | ||||
|     it('enables save and cancel buttons', () => { | ||||
|       expect(findSaveButton().props('loading')).toBe(false); | ||||
|       expect(findCancelButton().props('disabled')).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('enables role dropdown', () => { | ||||
|       expect(findRoleSelector().props('loading')).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits busy event with false', () => { | ||||
|       const busyEvents = wrapper.emitted('busy'); | ||||
| 
 | ||||
|       expect(busyEvents).toHaveLength(2); | ||||
|       expect(busyEvents[1][0]).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows error message', () => { | ||||
|       const alert = wrapper.findComponent(GlAlert); | ||||
| 
 | ||||
|       expect(alert.text()).toBe('Could not update role.'); | ||||
|       expect(alert.props('variant')).toBe('danger'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -0,0 +1,126 @@ | |||
| import { nextTick } from 'vue'; | ||||
| import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; | ||||
| import RoleUpdater from '~/members/components/table/drawer/role_updater.vue'; | ||||
| import { callRoleUpdateApi, setMemberRole } from '~/members/components/table/drawer/utils'; | ||||
| import { captureException } from '~/sentry/sentry_browser_wrapper'; | ||||
| import { member } from '../../../mock_data'; | ||||
| 
 | ||||
| jest.mock('~/members/components/table/drawer/utils'); | ||||
| jest.mock('~/sentry/sentry_browser_wrapper'); | ||||
| 
 | ||||
| describe('Role updater CE', () => { | ||||
|   let wrapper; | ||||
|   const role = {}; | ||||
| 
 | ||||
|   const createWrapper = ({ slotContent = '' } = {}) => { | ||||
|     wrapper = shallowMountExtended(RoleUpdater, { | ||||
|       propsData: { member, role }, | ||||
|       scopedSlots: { default: slotContent }, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   it('renders slot content', () => { | ||||
|     const slotContent = '<span>slot content</span>'; | ||||
|     createWrapper({ slotContent }); | ||||
| 
 | ||||
|     expect(wrapper.html()).toContain(slotContent); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when save is started', () => { | ||||
|     beforeEach(() => { | ||||
|       createWrapper(); | ||||
|       // NOTE: We can't call wrapper.vm.saveRole() here because the tests will run on the next tick, so the microtask
 | ||||
|       // queue will be flushed beforehand and the entire saveRole function finishes executing. We need to instead call
 | ||||
|       // saveRole on the same frame as the expect() checks so that the microtask queue doesn't get a chance to flush.
 | ||||
|     }); | ||||
| 
 | ||||
|     it('emits busy = true event', () => { | ||||
|       wrapper.vm.saveRole(); | ||||
| 
 | ||||
|       expect(wrapper.emitted('busy')).toHaveLength(1); | ||||
|       expect(wrapper.emitted('busy')[0][0]).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('calls role update API', () => { | ||||
|       wrapper.vm.saveRole(); | ||||
| 
 | ||||
|       expect(callRoleUpdateApi).toHaveBeenCalledTimes(1); | ||||
|       expect(callRoleUpdateApi).toHaveBeenCalledWith(member, role); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not update member', () => { | ||||
|       wrapper.vm.saveRole(); | ||||
| 
 | ||||
|       expect(setMemberRole).not.toHaveBeenCalled(); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits alert event to clear alert', () => { | ||||
|       wrapper.vm.saveRole(); | ||||
| 
 | ||||
|       expect(wrapper.emitted('alert')).toHaveLength(1); | ||||
|       expect(wrapper.emitted('alert')[0][0]).toBe(null); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not emit busy = false event', () => { | ||||
|       wrapper.vm.saveRole(); | ||||
| 
 | ||||
|       expect(wrapper.emitted('busy')).not.toHaveLength(2); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when save is successful', () => { | ||||
|     beforeEach(() => { | ||||
|       createWrapper(); | ||||
|       wrapper.vm.saveRole(); | ||||
| 
 | ||||
|       return nextTick(); | ||||
|     }); | ||||
| 
 | ||||
|     it('updates member role', () => { | ||||
|       expect(setMemberRole).toHaveBeenCalledTimes(1); | ||||
|       expect(setMemberRole).toHaveBeenCalledWith(member, role); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits success alert', () => { | ||||
|       expect(wrapper.emitted('alert')).toHaveLength(2); | ||||
|       expect(wrapper.emitted('alert')[1][0]).toEqual({ | ||||
|         message: 'Role was successfully updated.', | ||||
|         variant: 'success', | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits busy = false event', () => { | ||||
|       expect(wrapper.emitted('busy')).toHaveLength(2); | ||||
|       expect(wrapper.emitted('busy')[1][0]).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when save has an error', () => { | ||||
|     const error = new Error(); | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       callRoleUpdateApi.mockRejectedValue(error); | ||||
|       createWrapper(); | ||||
|       wrapper.vm.saveRole(); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits error alert', () => { | ||||
|       expect(wrapper.emitted('alert')).toHaveLength(2); | ||||
|       expect(wrapper.emitted('alert')[1][0]).toEqual({ | ||||
|         message: 'Could not update role.', | ||||
|         variant: 'danger', | ||||
|         dismissible: false, | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('captures sentry exception', () => { | ||||
|       expect(captureException).toHaveBeenCalledTimes(1); | ||||
|       expect(captureException).toHaveBeenCalledWith(error); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits busy = false event', () => { | ||||
|       expect(wrapper.emitted('busy')).toHaveLength(2); | ||||
|       expect(wrapper.emitted('busy')[1][0]).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -309,12 +309,15 @@ describe('MembersTable', () => { | |||
|         expect(findRoleDetailsDrawer().props('member')).toBe(null); | ||||
|       }); | ||||
| 
 | ||||
|       it('disables role button when drawer is busy', async () => { | ||||
|         findRoleDetailsDrawer().vm.$emit('busy', true); | ||||
|         await nextTick(); | ||||
|       it.each([true, false])( | ||||
|         'enables/disables role button when drawer busy state is %s', | ||||
|         async (busy) => { | ||||
|           findRoleDetailsDrawer().vm.$emit('busy', busy); | ||||
|           await nextTick(); | ||||
| 
 | ||||
|         expect(findRoleButton().props('disabled')).toBe(true); | ||||
|       }); | ||||
|           expect(findRoleButton().props('disabled')).toBe(busy); | ||||
|         }, | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ import DeployKeyItem from '~/vue_shared/components/list_selector/deploy_key_item | |||
| import groupsAutocompleteQuery from '~/graphql_shared/queries/groups_autocomplete.query.graphql'; | ||||
| import createMockApollo from 'helpers/mock_apollo_helper'; | ||||
| import waitForPromises from 'helpers/wait_for_promises'; | ||||
| import { ACCESS_LEVEL_DEVELOPER_INTEGER } from '~/access_level/constants'; | ||||
| import { ACCESS_LEVEL_REPORTER_INTEGER } from '~/access_level/constants'; | ||||
| import { USERS_RESPONSE_MOCK, GROUPS_RESPONSE_MOCK, SUBGROUPS_RESPONSE_MOCK } from './mock_data'; | ||||
| 
 | ||||
| jest.mock('~/alert'); | ||||
|  | @ -275,7 +275,7 @@ describe('List Selector spec', () => { | |||
|       it('calls query with correct variables when Search box receives an input', () => { | ||||
|         expect(Api.projectGroups).toHaveBeenCalledWith(USERS_MOCK_PROPS.projectPath, { | ||||
|           search, | ||||
|           shared_min_access_level: ACCESS_LEVEL_DEVELOPER_INTEGER, | ||||
|           shared_min_access_level: ACCESS_LEVEL_REPORTER_INTEGER, | ||||
|           with_shared: true, | ||||
|         }); | ||||
|       }); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue