Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									6170bdc060
								
							
						
					
					
						commit
						3034c7e6aa
					
				
							
								
								
									
										3
									
								
								Gemfile
								
								
								
								
							
							
						
						
									
										3
									
								
								Gemfile
								
								
								
								
							|  | @ -385,7 +385,9 @@ group :development, :test do | |||
|   gem 'haml_lint', '~> 0.40.0', require: false | ||||
|   gem 'bundler-audit', '~> 0.7.0.1', require: false | ||||
| 
 | ||||
|   # Benchmarking & profiling | ||||
|   gem 'benchmark-ips', '~> 2.3.0', require: false | ||||
|   gem 'benchmark-memory', '~> 0.1', require: false | ||||
| 
 | ||||
|   gem 'knapsack', '~> 1.21.1' | ||||
|   gem 'crystalball', '~> 0.7.0', require: false | ||||
|  | @ -460,7 +462,6 @@ gem 'ruby-prof', '~> 1.3.0' | |||
| gem 'stackprof', '~> 0.2.21', require: false | ||||
| gem 'rbtrace', '~> 0.4', require: false | ||||
| gem 'memory_profiler', '~> 0.9', require: false | ||||
| gem 'benchmark-memory', '~> 0.1', require: false | ||||
| gem 'activerecord-explain-analyze', '~> 0.1', require: false | ||||
| 
 | ||||
| # OAuth | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| <script> | ||||
| import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; | ||||
| import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; | ||||
| import { TYPE_ISSUE } from '~/graphql_shared/constants'; | ||||
| import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants'; | ||||
| import { convertToGraphQLId } from '~/graphql_shared/utils'; | ||||
| import { truncate } from '~/lib/utils/text_utility'; | ||||
| import { __, n__, s__, sprintf } from '~/locale'; | ||||
|  | @ -10,10 +10,12 @@ export default { | |||
|   i18n: { | ||||
|     issuableType: { | ||||
|       [issuableTypes.issue]: __('issue'), | ||||
|       [issuableTypes.epic]: __('epic'), | ||||
|     }, | ||||
|   }, | ||||
|   graphQLIdType: { | ||||
|     [issuableTypes.issue]: TYPE_ISSUE, | ||||
|     [issuableTypes.epic]: TYPE_EPIC, | ||||
|   }, | ||||
|   referenceFormatter: { | ||||
|     [issuableTypes.issue]: (r) => r.split('/')[1], | ||||
|  | @ -40,7 +42,7 @@ export default { | |||
|       type: String, | ||||
|       required: true, | ||||
|       validator(value) { | ||||
|         return [issuableTypes.issue].includes(value); | ||||
|         return [issuableTypes.issue, issuableTypes.epic].includes(value); | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|  | @ -53,14 +55,21 @@ export default { | |||
|         return blockingIssuablesQueries[this.issuableType].query; | ||||
|       }, | ||||
|       variables() { | ||||
|         if (this.isEpic) { | ||||
|           return { | ||||
|             fullPath: this.item.group.fullPath, | ||||
|             iid: Number(this.item.iid), | ||||
|           }; | ||||
|         } | ||||
|         return { | ||||
|           id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id), | ||||
|         }; | ||||
|       }, | ||||
|       update(data) { | ||||
|         this.skip = true; | ||||
|         const issuable = this.isEpic ? data?.group?.issuable : data?.issuable; | ||||
| 
 | ||||
|         return data?.issuable?.blockingIssuables?.nodes || []; | ||||
|         return issuable?.blockingIssuables?.nodes || []; | ||||
|       }, | ||||
|       error(error) { | ||||
|         const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), { | ||||
|  | @ -77,13 +86,16 @@ export default { | |||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     isEpic() { | ||||
|       return this.issuableType === issuableTypes.epic; | ||||
|     }, | ||||
|     displayedIssuables() { | ||||
|       const { defaultDisplayLimit, referenceFormatter } = this.$options; | ||||
|       return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => { | ||||
|         return { | ||||
|           ...i, | ||||
|           title: truncate(i.title, this.$options.textTruncateWidth), | ||||
|           reference: referenceFormatter[this.issuableType](i.reference), | ||||
|           reference: this.isEpic ? i.reference : referenceFormatter[this.issuableType](i.reference), | ||||
|         }; | ||||
|       }); | ||||
|     }, | ||||
|  | @ -106,6 +118,9 @@ export default { | |||
|         }, | ||||
|       ); | ||||
|     }, | ||||
|     blockIcon() { | ||||
|       return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked'; | ||||
|     }, | ||||
|     glIconId() { | ||||
|       return `blocked-icon-${this.uniqueId}`; | ||||
|     }, | ||||
|  | @ -153,7 +168,7 @@ export default { | |||
|     <gl-icon | ||||
|       :id="glIconId" | ||||
|       ref="icon" | ||||
|       name="issue-block" | ||||
|       :name="blockIcon" | ||||
|       class="issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500" | ||||
|       data-testid="issue-blocked-icon" | ||||
|       @mouseenter="handleMouseEnter" | ||||
|  |  | |||
|  | @ -274,16 +274,16 @@ export default { | |||
|         class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden" | ||||
|       > | ||||
|         <gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" /> | ||||
|         <work-item-type-icon | ||||
|           v-if="showWorkItemTypeIcon" | ||||
|           :work-item-type="item.type" | ||||
|           show-tooltip-on-hover | ||||
|         /> | ||||
|         <span | ||||
|           v-if="item.referencePath" | ||||
|           class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-text-secondary" | ||||
|           :class="{ 'gl-font-base': isEpicBoard }" | ||||
|         > | ||||
|           <work-item-type-icon | ||||
|             v-if="showWorkItemTypeIcon" | ||||
|             :work-item-type="item.type" | ||||
|             show-tooltip-on-hover | ||||
|           /> | ||||
|           <tooltip-on-truncate | ||||
|             v-if="showReferencePath" | ||||
|             :title="itemReferencePath" | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ import { __ } from '~/locale'; | |||
| import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; | ||||
| import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; | ||||
| import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql'; | ||||
| import boardBlockingEpicsQuery from './graphql/board_blocking_epics.query.graphql'; | ||||
| import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql'; | ||||
| import updateBoardListMutation from './graphql/board_list_update.mutation.graphql'; | ||||
| 
 | ||||
|  | @ -70,6 +71,9 @@ export const blockingIssuablesQueries = { | |||
|   [issuableTypes.issue]: { | ||||
|     query: boardBlockingIssuesQuery, | ||||
|   }, | ||||
|   [issuableTypes.epic]: { | ||||
|     query: boardBlockingEpicsQuery, | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| export const updateListQueries = { | ||||
|  |  | |||
|  | @ -0,0 +1,17 @@ | |||
| query BoardBlockingEpics($fullPath: ID!, $iid: ID) { | ||||
|   group(fullPath: $fullPath) { | ||||
|     id | ||||
|     issuable: epic(iid: $iid) { | ||||
|       id | ||||
|       blockingIssuables: blockedByEpics { | ||||
|         nodes { | ||||
|           id | ||||
|           iid | ||||
|           title | ||||
|           reference(full: true) | ||||
|           webUrl | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -2,7 +2,7 @@ import { ContentEditor } from './index'; | |||
| 
 | ||||
| export default { | ||||
|   component: ContentEditor, | ||||
|   title: 'content_editor/components/content_editor', | ||||
|   title: 'content_editor/content_editor', | ||||
| }; | ||||
| 
 | ||||
| const Template = (_, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -3,15 +3,12 @@ import { | |||
|   GlIcon, | ||||
|   GlLink, | ||||
|   GlForm, | ||||
|   GlFormInputGroup, | ||||
|   GlInputGroupText, | ||||
|   GlFormInput, | ||||
|   GlFormGroup, | ||||
|   GlFormTextarea, | ||||
|   GlButton, | ||||
|   GlFormRadio, | ||||
|   GlFormRadioGroup, | ||||
|   GlFormSelect, | ||||
| } from '@gitlab/ui'; | ||||
| import { kebabCase } from 'lodash'; | ||||
| import { buildApiUrl } from '~/api/api_utils'; | ||||
|  | @ -21,6 +18,7 @@ import csrf from '~/lib/utils/csrf'; | |||
| import { redirectTo } from '~/lib/utils/url_utility'; | ||||
| import { s__ } from '~/locale'; | ||||
| import validation from '~/vue_shared/directives/validation'; | ||||
| import ProjectNamespace from './project_namespace.vue'; | ||||
| 
 | ||||
| const PRIVATE_VISIBILITY = 'private'; | ||||
| const INTERNAL_VISIBILITY = 'internal'; | ||||
|  | @ -39,28 +37,18 @@ const initFormField = ({ value, required = true, skipValidation = false }) => ({ | |||
|   feedback: null, | ||||
| }); | ||||
| 
 | ||||
| function sortNamespaces(namespaces) { | ||||
|   if (!namespaces || !namespaces?.length) { | ||||
|     return namespaces; | ||||
|   } | ||||
| 
 | ||||
|   return namespaces.sort((a, b) => a.full_name.localeCompare(b.full_name)); | ||||
| } | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GlForm, | ||||
|     GlIcon, | ||||
|     GlLink, | ||||
|     GlButton, | ||||
|     GlFormInputGroup, | ||||
|     GlInputGroupText, | ||||
|     GlFormInput, | ||||
|     GlFormTextarea, | ||||
|     GlFormGroup, | ||||
|     GlFormRadio, | ||||
|     GlFormRadioGroup, | ||||
|     GlFormSelect, | ||||
|     ProjectNamespace, | ||||
|   }, | ||||
|   directives: { | ||||
|     validation: validation(), | ||||
|  | @ -72,9 +60,6 @@ export default { | |||
|     visibilityHelpPath: { | ||||
|       default: '', | ||||
|     }, | ||||
|     endpoint: { | ||||
|       default: '', | ||||
|     }, | ||||
|     projectFullPath: { | ||||
|       default: '', | ||||
|     }, | ||||
|  | @ -96,6 +81,9 @@ export default { | |||
|     restrictedVisibilityLevels: { | ||||
|       default: [], | ||||
|     }, | ||||
|     namespaceId: { | ||||
|       default: '', | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     const form = { | ||||
|  | @ -117,14 +105,10 @@ export default { | |||
|     }; | ||||
|     return { | ||||
|       isSaving: false, | ||||
|       namespaces: [], | ||||
|       form, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     projectUrl() { | ||||
|       return `${gon.gitlab_url}/`; | ||||
|     }, | ||||
|     projectVisibilityLevel() { | ||||
|       return VISIBILITY_LEVEL[this.projectVisibility]; | ||||
|     }, | ||||
|  | @ -187,33 +171,31 @@ export default { | |||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|     // eslint-disable-next-line func-names | ||||
|     'form.fields.namespace.value': function () { | ||||
|       this.form.fields.visibility.value = | ||||
|         this.restrictedVisibilityLevels.length !== 0 ? null : PRIVATE_VISIBILITY; | ||||
|     }, | ||||
|     // eslint-disable-next-line func-names | ||||
|     'form.fields.name.value': function (newVal) { | ||||
|       this.form.fields.slug.value = kebabCase(newVal); | ||||
|     }, | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.fetchNamespaces(); | ||||
|   }, | ||||
|   methods: { | ||||
|     async fetchNamespaces() { | ||||
|       const { data } = await axios.get(this.endpoint); | ||||
|       this.namespaces = sortNamespaces(data.namespaces); | ||||
|     }, | ||||
|     isVisibilityLevelDisabled(visibility) { | ||||
|       return !this.allowedVisibilityLevels.includes(visibility); | ||||
|     }, | ||||
|     getInitialVisibilityValue() { | ||||
|       return this.restrictedVisibilityLevels.length !== 0 ? null : this.projectVisibility; | ||||
|     }, | ||||
|     setNamespace(namespace) { | ||||
|       this.form.fields.visibility.value = | ||||
|         this.restrictedVisibilityLevels.length !== 0 ? null : PRIVATE_VISIBILITY; | ||||
|       this.form.fields.namespace.value = namespace; | ||||
|       this.form.fields.namespace.state = true; | ||||
|     }, | ||||
|     async onSubmit() { | ||||
|       this.form.showValidation = true; | ||||
| 
 | ||||
|       if (!this.form.fields.namespace.value) { | ||||
|         this.form.fields.namespace.state = false; | ||||
|       } | ||||
| 
 | ||||
|       if (!this.form.state) { | ||||
|         return; | ||||
|       } | ||||
|  | @ -282,30 +264,7 @@ export default { | |||
|           :state="form.fields.namespace.state" | ||||
|           :invalid-feedback="s__('ForkProject|Please select a namespace')" | ||||
|         > | ||||
|           <gl-form-input-group> | ||||
|             <template #prepend> | ||||
|               <gl-input-group-text> | ||||
|                 {{ projectUrl }} | ||||
|               </gl-input-group-text> | ||||
|             </template> | ||||
|             <gl-form-select | ||||
|               id="fork-url" | ||||
|               v-model="form.fields.namespace.value" | ||||
|               v-validation:[form.showValidation] | ||||
|               name="namespace" | ||||
|               data-testid="fork-url-input" | ||||
|               data-qa-selector="fork_namespace_dropdown" | ||||
|               :state="form.fields.namespace.state" | ||||
|               required | ||||
|             > | ||||
|               <template #first> | ||||
|                 <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option> | ||||
|               </template> | ||||
|               <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace"> | ||||
|                 {{ namespace.full_name }} | ||||
|               </option> | ||||
|             </gl-form-select> | ||||
|           </gl-form-input-group> | ||||
|           <project-namespace @select="setNamespace" /> | ||||
|         </gl-form-group> | ||||
|       </div> | ||||
|       <div class="gl-flex-basis-half"> | ||||
|  |  | |||
|  | @ -0,0 +1,136 @@ | |||
| <script> | ||||
| import { | ||||
|   GlButton, | ||||
|   GlButtonGroup, | ||||
|   GlDropdown, | ||||
|   GlDropdownItem, | ||||
|   GlDropdownText, | ||||
|   GlDropdownSectionHeader, | ||||
|   GlSearchBoxByType, | ||||
|   GlTruncate, | ||||
| } from '@gitlab/ui'; | ||||
| import createFlash from '~/flash'; | ||||
| import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; | ||||
| import { s__ } from '~/locale'; | ||||
| import { getIdFromGraphQLId } from '~/graphql_shared/utils'; | ||||
| import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; | ||||
| import searchForkableNamespaces from '../queries/search_forkable_namespaces.query.graphql'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GlButton, | ||||
|     GlButtonGroup, | ||||
|     GlDropdown, | ||||
|     GlDropdownItem, | ||||
|     GlDropdownText, | ||||
|     GlDropdownSectionHeader, | ||||
|     GlSearchBoxByType, | ||||
|     GlTruncate, | ||||
|   }, | ||||
|   apollo: { | ||||
|     project: { | ||||
|       query: searchForkableNamespaces, | ||||
|       variables() { | ||||
|         return { | ||||
|           projectPath: this.projectFullPath, | ||||
|           search: this.search, | ||||
|         }; | ||||
|       }, | ||||
|       skip() { | ||||
|         const { length } = this.search; | ||||
|         return length > 0 && length < MINIMUM_SEARCH_LENGTH; | ||||
|       }, | ||||
|       error(error) { | ||||
|         createFlash({ | ||||
|           message: s__( | ||||
|             'ForkProject|Something went wrong while loading data. Please refresh the page to try again.', | ||||
|           ), | ||||
|           captureError: true, | ||||
|           error, | ||||
|         }); | ||||
|       }, | ||||
|       debounce: DEBOUNCE_DELAY, | ||||
|     }, | ||||
|   }, | ||||
|   inject: ['projectFullPath'], | ||||
|   data() { | ||||
|     return { | ||||
|       project: {}, | ||||
|       search: '', | ||||
|       selectedNamespace: null, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     rootUrl() { | ||||
|       return `${gon.gitlab_url}/`; | ||||
|     }, | ||||
|     namespaces() { | ||||
|       return this.project.forkTargets?.nodes || []; | ||||
|     }, | ||||
|     hasMatches() { | ||||
|       return this.namespaces.length; | ||||
|     }, | ||||
|     dropdownText() { | ||||
|       return this.selectedNamespace?.fullPath || s__('ForkProject|Select a namespace'); | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     handleDropdownShown() { | ||||
|       this.$refs.search.focusInput(); | ||||
|     }, | ||||
|     setNamespace(namespace) { | ||||
|       const id = getIdFromGraphQLId(namespace.id); | ||||
| 
 | ||||
|       this.$emit('select', { | ||||
|         id, | ||||
|         name: namespace.name, | ||||
|         visibility: namespace.visibility, | ||||
|       }); | ||||
| 
 | ||||
|       this.selectedNamespace = { id, fullPath: namespace.fullPath }; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <gl-button-group class="gl-w-full"> | ||||
|     <gl-button class="gl-text-truncate gl-flex-grow-0! gl-max-w-34" label :title="rootUrl">{{ | ||||
|       rootUrl | ||||
|     }}</gl-button> | ||||
| 
 | ||||
|     <gl-dropdown | ||||
|       class="gl-flex-grow-1" | ||||
|       toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20" | ||||
|       data-qa-selector="select_namespace_dropdown" | ||||
|       data-testid="select_namespace_dropdown" | ||||
|       no-flip | ||||
|       @shown="handleDropdownShown" | ||||
|     > | ||||
|       <template #button-text> | ||||
|         <gl-truncate :text="dropdownText" position="start" with-tooltip /> | ||||
|       </template> | ||||
|       <gl-search-box-by-type | ||||
|         ref="search" | ||||
|         v-model.trim="search" | ||||
|         :is-loading="$apollo.queries.project.loading" | ||||
|         data-qa-selector="select_namespace_dropdown_search_field" | ||||
|         data-testid="select_namespace_dropdown_search_field" | ||||
|       /> | ||||
|       <template v-if="!$apollo.queries.project.loading"> | ||||
|         <template v-if="hasMatches"> | ||||
|           <gl-dropdown-section-header>{{ __('Namespaces') }}</gl-dropdown-section-header> | ||||
|           <gl-dropdown-item | ||||
|             v-for="namespace of namespaces" | ||||
|             :key="namespace.id" | ||||
|             data-qa-selector="select_namespace_dropdown_item" | ||||
|             @click="setNamespace(namespace)" | ||||
|           > | ||||
|             {{ namespace.fullPath }} | ||||
|           </gl-dropdown-item> | ||||
|         </template> | ||||
|         <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text> | ||||
|       </template> | ||||
|     </gl-dropdown> | ||||
|   </gl-button-group> | ||||
| </template> | ||||
|  | @ -1,4 +1,6 @@ | |||
| import Vue from 'vue'; | ||||
| import VueApollo from 'vue-apollo'; | ||||
| import createDefaultClient from '~/lib/graphql'; | ||||
| import App from './components/app.vue'; | ||||
| 
 | ||||
| const mountElement = document.getElementById('fork-groups-mount-element'); | ||||
|  | @ -17,9 +19,14 @@ const { | |||
|   restrictedVisibilityLevels, | ||||
| } = mountElement.dataset; | ||||
| 
 | ||||
| Vue.use(VueApollo); | ||||
| 
 | ||||
| // eslint-disable-next-line no-new
 | ||||
| new Vue({ | ||||
|   el: mountElement, | ||||
|   apolloProvider: new VueApollo({ | ||||
|     defaultClient: createDefaultClient(), | ||||
|   }), | ||||
|   provide: { | ||||
|     newGroupPath, | ||||
|     visibilityHelpPath, | ||||
|  |  | |||
|  | @ -0,0 +1,13 @@ | |||
| query searchForkableNamespaces($projectPath: ID!, $search: String) { | ||||
|   project(fullPath: $projectPath) { | ||||
|     id | ||||
|     forkTargets(search: $search) { | ||||
|       nodes { | ||||
|         id | ||||
|         fullPath | ||||
|         name | ||||
|         visibility | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -141,7 +141,7 @@ export default class Project { | |||
|               if (doesPathContainRef) { | ||||
|                 // We are ignoring the url containing the ref portion
 | ||||
|                 // and plucking the thereafter portion to reconstructure the url that is correct
 | ||||
|                 const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0]; | ||||
|                 const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0]; | ||||
|                 selectedUrl.searchParams.set('path', targetPath); | ||||
|                 selectedUrl.hash = window.location.hash; | ||||
|               } | ||||
|  |  | |||
|  | @ -0,0 +1,112 @@ | |||
| <script> | ||||
| import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; | ||||
| 
 | ||||
| import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; | ||||
| import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; | ||||
| import RunnerName from '../runner_name.vue'; | ||||
| import RunnerTags from '../runner_tags.vue'; | ||||
| import RunnerTypeBadge from '../runner_type_badge.vue'; | ||||
| 
 | ||||
| import { formatJobCount } from '../../utils'; | ||||
| import { | ||||
|   I18N_LOCKED_RUNNER_DESCRIPTION, | ||||
|   I18N_VERSION_LABEL, | ||||
|   I18N_LAST_CONTACT_LABEL, | ||||
|   I18N_CREATED_AT_LABEL, | ||||
| } from '../../constants'; | ||||
| import RunnerSummaryField from './runner_summary_field.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GlIcon, | ||||
|     GlSprintf, | ||||
|     TimeAgo, | ||||
|     RunnerSummaryField, | ||||
|     RunnerName, | ||||
|     RunnerTags, | ||||
|     RunnerTypeBadge, | ||||
|     RunnerUpgradeStatusIcon: () => | ||||
|       import('ee_component/runner/components/runner_upgrade_status_icon.vue'), | ||||
|     TooltipOnTruncate, | ||||
|   }, | ||||
|   directives: { | ||||
|     GlTooltip: GlTooltipDirective, | ||||
|   }, | ||||
|   props: { | ||||
|     runner: { | ||||
|       type: Object, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     jobCount() { | ||||
|       return formatJobCount(this.runner.jobCount); | ||||
|     }, | ||||
|   }, | ||||
|   i18n: { | ||||
|     I18N_LOCKED_RUNNER_DESCRIPTION, | ||||
|     I18N_VERSION_LABEL, | ||||
|     I18N_LAST_CONTACT_LABEL, | ||||
|     I18N_CREATED_AT_LABEL, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div> | ||||
|     <div> | ||||
|       <slot :runner="runner" name="runner-name"> | ||||
|         <runner-name :runner="runner" /> | ||||
|       </slot> | ||||
|       <gl-icon | ||||
|         v-if="runner.locked" | ||||
|         v-gl-tooltip | ||||
|         :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION" | ||||
|         name="lock" | ||||
|       /> | ||||
|       <runner-type-badge :type="runner.runnerType" size="sm" /> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="gl-ml-auto gl-display-inline-flex gl-max-w-full gl-py-2"> | ||||
|       <div class="gl-flex-shrink-0"> | ||||
|         <runner-upgrade-status-icon :runner="runner" /> | ||||
|         <gl-sprintf v-if="runner.version" :message="$options.i18n.I18N_VERSION_LABEL"> | ||||
|           <template #version>{{ runner.version }}</template> | ||||
|         </gl-sprintf> | ||||
|       </div> | ||||
|       <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div> | ||||
|       <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description"> | ||||
|         {{ runner.description }} | ||||
|       </tooltip-on-truncate> | ||||
|     </div> | ||||
| 
 | ||||
|     <div> | ||||
|       <runner-summary-field icon="clock"> | ||||
|         <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> | ||||
|           <template #timeAgo> | ||||
|             <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> | ||||
|             <template v-else>{{ __('Never') }}</template> | ||||
|           </template> | ||||
|         </gl-sprintf> | ||||
|       </runner-summary-field> | ||||
| 
 | ||||
|       <runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')"> | ||||
|         {{ runner.ipAddress }} | ||||
|       </runner-summary-field> | ||||
| 
 | ||||
|       <runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')"> | ||||
|         {{ jobCount }} | ||||
|       </runner-summary-field> | ||||
| 
 | ||||
|       <runner-summary-field icon="calendar"> | ||||
|         <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL"> | ||||
|           <template #timeAgo> | ||||
|             <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> | ||||
|           </template> | ||||
|         </gl-sprintf> | ||||
|       </runner-summary-field> | ||||
|     </div> | ||||
| 
 | ||||
|     <runner-tags class="gl-display-block gl-pt-2" :tag-list="runner.tagList" size="sm" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,33 @@ | |||
| <script> | ||||
| import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GlIcon, | ||||
|   }, | ||||
|   directives: { | ||||
|     GlTooltip: GlTooltipDirective, | ||||
|   }, | ||||
|   props: { | ||||
|     icon: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '', | ||||
|     }, | ||||
|     tooltip: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '', | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2"> | ||||
|     <gl-icon v-if="icon" :name="icon" /> | ||||
|     <!-- display tooltip as a label for screen readers --> | ||||
|     <span class="gl-sr-only">{{ tooltip }}</span> | ||||
|     <slot></slot> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -2,11 +2,13 @@ | |||
| import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; | ||||
| import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; | ||||
| import { getIdFromGraphQLId } from '~/graphql_shared/utils'; | ||||
| import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; | ||||
| import { __, s__ } from '~/locale'; | ||||
| import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; | ||||
| import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; | ||||
| import { formatJobCount, tableField } from '../utils'; | ||||
| import RunnerSummaryCell from './cells/runner_summary_cell.vue'; | ||||
| import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue'; | ||||
| import RunnerStatusPopover from './runner_status_popover.vue'; | ||||
| import RunnerStatusCell from './cells/runner_status_cell.vue'; | ||||
| 
 | ||||
|  | @ -19,6 +21,12 @@ const defaultFields = [ | |||
|   tableField({ key: 'actions', label: '' }), | ||||
| ]; | ||||
| 
 | ||||
| const stackedLayoutFields = [ | ||||
|   tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }), | ||||
|   tableField({ key: 'summary', label: s__('Runners|Runner') }), | ||||
|   tableField({ key: 'actions', label: '', thClasses: ['gl-w-15p'] }), | ||||
| ]; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GlFormCheckbox, | ||||
|  | @ -28,11 +36,13 @@ export default { | |||
|     TimeAgo, | ||||
|     RunnerStatusPopover, | ||||
|     RunnerSummaryCell, | ||||
|     RunnerStackedSummaryCell, | ||||
|     RunnerStatusCell, | ||||
|   }, | ||||
|   directives: { | ||||
|     GlTooltip: GlTooltipDirective, | ||||
|   }, | ||||
|   mixins: [glFeatureFlagMixin()], | ||||
|   apollo: { | ||||
|     checkedRunnerIds: { | ||||
|       query: checkedRunnerIdsQuery, | ||||
|  | @ -62,6 +72,11 @@ export default { | |||
|     return { checkedRunnerIds: [] }; | ||||
|   }, | ||||
|   computed: { | ||||
|     stackedLayout() { | ||||
|       // runner_list_stacked_layout_admin or runner_list_stacked_layout | ||||
|       const { runnerListStackedLayoutAdmin, runnerListStackedLayout } = this.glFeatures || {}; | ||||
|       return runnerListStackedLayoutAdmin || runnerListStackedLayout; | ||||
|     }, | ||||
|     tableClass() { | ||||
|       // <gl-table-lite> does not provide a busy state, add | ||||
|       // simple support for it. | ||||
|  | @ -71,6 +86,8 @@ export default { | |||
|       }; | ||||
|     }, | ||||
|     fields() { | ||||
|       const fields = this.stackedLayout ? stackedLayoutFields : defaultFields; | ||||
| 
 | ||||
|       if (this.checkable) { | ||||
|         const checkboxField = tableField({ | ||||
|           key: 'checkbox', | ||||
|  | @ -78,9 +95,9 @@ export default { | |||
|           thClasses: ['gl-w-9'], | ||||
|           tdClass: ['gl-text-center'], | ||||
|         }); | ||||
|         return [checkboxField, ...defaultFields]; | ||||
|         return [checkboxField, ...fields]; | ||||
|       } | ||||
|       return defaultFields; | ||||
|       return fields; | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|  | @ -138,24 +155,30 @@ export default { | |||
|       </template> | ||||
| 
 | ||||
|       <template #cell(summary)="{ item, index }"> | ||||
|         <runner-summary-cell :runner="item"> | ||||
|         <runner-stacked-summary-cell v-if="stackedLayout" :runner="item"> | ||||
|           <template #runner-name="{ runner }"> | ||||
|             <slot name="runner-name" :runner="runner" :index="index"></slot> | ||||
|           </template> | ||||
|         </runner-stacked-summary-cell> | ||||
| 
 | ||||
|         <runner-summary-cell v-else :runner="item"> | ||||
|           <template #runner-name="{ runner }"> | ||||
|             <slot name="runner-name" :runner="runner" :index="index"></slot> | ||||
|           </template> | ||||
|         </runner-summary-cell> | ||||
|       </template> | ||||
| 
 | ||||
|       <template #cell(version)="{ item: { version } }"> | ||||
|       <template v-if="!stackedLayout" #cell(version)="{ item: { version } }"> | ||||
|         <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="version"> | ||||
|           {{ version }} | ||||
|         </tooltip-on-truncate> | ||||
|       </template> | ||||
| 
 | ||||
|       <template #cell(jobCount)="{ item: { jobCount } }"> | ||||
|         {{ formatJobCount(jobCount) }} | ||||
|       <template v-if="!stackedLayout" #cell(jobCount)="{ item: { jobCount } }"> | ||||
|         <span data-testid="job-count">{{ formatJobCount(jobCount) }}</span> | ||||
|       </template> | ||||
| 
 | ||||
|       <template #cell(contactedAt)="{ item: { contactedAt } }"> | ||||
|       <template v-if="!stackedLayout" #cell(contactedAt)="{ item: { contactedAt } }"> | ||||
|         <time-ago v-if="contactedAt" :time="contactedAt" /> | ||||
|         <template v-else>{{ __('Never') }}</template> | ||||
|       </template> | ||||
|  |  | |||
|  | @ -77,9 +77,13 @@ export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__( | |||
| ); | ||||
| export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); | ||||
| 
 | ||||
| // List
 | ||||
| export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( | ||||
|   'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.', | ||||
| ); | ||||
| export const I18N_VERSION_LABEL = s__('Runners|Version %{version}'); | ||||
| export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}'); | ||||
| export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}'); | ||||
| 
 | ||||
| // Runner details
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ fragment ListItemShared on CiRunner { | |||
|   locked | ||||
|   jobCount | ||||
|   tagList | ||||
|   createdAt | ||||
|   contactedAt | ||||
|   status(legacyMode: null) | ||||
|   userPermissions { | ||||
|  |  | |||
|  | @ -0,0 +1,243 @@ | |||
| <script> | ||||
| import { | ||||
|   GlButton, | ||||
|   GlTooltipDirective, | ||||
|   GlIcon, | ||||
|   GlFormCheckbox, | ||||
|   GlFormInput, | ||||
|   GlFormInputGroup, | ||||
|   GlDropdown, | ||||
|   GlDropdownItem, | ||||
|   GlSprintf, | ||||
|   GlSafeHtmlDirective, | ||||
| } from '@gitlab/ui'; | ||||
| import $ from 'jquery'; | ||||
| import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; | ||||
| import * as Emoji from '~/emoji'; | ||||
| import { __, s__ } from '~/locale'; | ||||
| import { timeRanges } from '~/vue_shared/constants'; | ||||
| 
 | ||||
| export const AVAILABILITY_STATUS = { | ||||
|   BUSY: 'busy', | ||||
|   NOT_SET: 'not_set', | ||||
| }; | ||||
| 
 | ||||
| const statusTimeRanges = [ | ||||
|   { | ||||
|     label: __('Never'), | ||||
|     name: 'never', | ||||
|   }, | ||||
|   ...timeRanges, | ||||
| ]; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GlButton, | ||||
|     GlIcon, | ||||
|     GlFormCheckbox, | ||||
|     GlFormInput, | ||||
|     GlFormInputGroup, | ||||
|     GlDropdown, | ||||
|     GlDropdownItem, | ||||
|     GlSprintf, | ||||
|     EmojiPicker: () => import('~/emoji/components/picker.vue'), | ||||
|   }, | ||||
|   directives: { | ||||
|     GlTooltip: GlTooltipDirective, | ||||
|     SafeHtml: GlSafeHtmlDirective, | ||||
|   }, | ||||
|   props: { | ||||
|     defaultEmoji: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '', | ||||
|     }, | ||||
|     emoji: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     message: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     availability: { | ||||
|       type: Boolean, | ||||
|       required: true, | ||||
|     }, | ||||
|     clearStatusAfter: { | ||||
|       type: Object, | ||||
|       required: false, | ||||
|       default: () => ({}), | ||||
|     }, | ||||
|     currentClearStatusAfter: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '', | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       defaultEmojiTag: '', | ||||
|       emojiTag: '', | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     isCustomEmoji() { | ||||
|       return this.emoji !== this.defaultEmoji; | ||||
|     }, | ||||
|     isDirty() { | ||||
|       return Boolean(this.message.length || this.isCustomEmoji); | ||||
|     }, | ||||
|     noEmoji() { | ||||
|       return this.emojiTag === ''; | ||||
|     }, | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.setupEmojiListAndAutocomplete(); | ||||
|   }, | ||||
|   methods: { | ||||
|     async setupEmojiListAndAutocomplete() { | ||||
|       const emojiAutocomplete = new GfmAutoComplete(); | ||||
|       emojiAutocomplete.setup($(this.$refs.statusMessageField.$el), { emojis: true }); | ||||
| 
 | ||||
|       if (this.emoji) { | ||||
|         this.emojiTag = Emoji.glEmojiTag(this.emoji); | ||||
|       } | ||||
|       this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji); | ||||
| 
 | ||||
|       this.setDefaultEmoji(); | ||||
|     }, | ||||
|     setDefaultEmoji() { | ||||
|       const { emojiTag } = this; | ||||
|       const hasStatusMessage = Boolean(this.message.length); | ||||
|       if (hasStatusMessage && emojiTag) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       if (hasStatusMessage) { | ||||
|         this.emojiTag = this.defaultEmojiTag; | ||||
|       } else if (emojiTag === this.defaultEmojiTag) { | ||||
|         this.clearEmoji(); | ||||
|       } | ||||
|     }, | ||||
|     handleEmojiClick(emoji) { | ||||
|       this.$emit('emoji-click', emoji); | ||||
| 
 | ||||
|       this.emojiTag = Emoji.glEmojiTag(emoji); | ||||
|     }, | ||||
|     clearEmoji() { | ||||
|       if (this.emojiTag) { | ||||
|         this.emojiTag = ''; | ||||
|       } | ||||
|     }, | ||||
|     clearStatusInputs() { | ||||
|       this.$emit('emoji-click', ''); | ||||
|       this.$emit('message-input', ''); | ||||
|       this.clearEmoji(); | ||||
|     }, | ||||
|   }, | ||||
|   statusTimeRanges, | ||||
|   safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, | ||||
|   i18n: { | ||||
|     statusMessagePlaceholder: s__(`SetStatusModal|What's your status?`), | ||||
|     clearStatusButtonLabel: s__('SetStatusModal|Clear status'), | ||||
|     availabilityCheckboxLabel: s__('SetStatusModal|Busy'), | ||||
|     availabilityCheckboxHelpText: s__( | ||||
|       'SetStatusModal|An indicator appears next to your name and avatar', | ||||
|     ), | ||||
|     clearStatusAfterDropdownLabel: s__('SetStatusModal|Clear status after'), | ||||
|     clearStatusAfterMessage: s__('SetStatusModal|Your status resets on %{date}.'), | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div> | ||||
|     <input :value="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" /> | ||||
|     <gl-form-input-group class="gl-mb-5"> | ||||
|       <gl-form-input | ||||
|         ref="statusMessageField" | ||||
|         :value="message" | ||||
|         :placeholder="$options.i18n.statusMessagePlaceholder" | ||||
|         class="js-status-message-field" | ||||
|         name="user[status][message]" | ||||
|         @keyup="setDefaultEmoji" | ||||
|         @input="$emit('message-input', $event)" | ||||
|         @keyup.enter.prevent | ||||
|       /> | ||||
|       <template #prepend> | ||||
|         <emoji-picker | ||||
|           dropdown-class="gl-h-full" | ||||
|           toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" | ||||
|           boundary="viewport" | ||||
|           :right="false" | ||||
|           @click="handleEmojiClick" | ||||
|         > | ||||
|           <template #button-content> | ||||
|             <span | ||||
|               v-if="noEmoji" | ||||
|               class="no-emoji-placeholder position-relative" | ||||
|               data-testid="no-emoji-placeholder" | ||||
|             > | ||||
|               <gl-icon name="slight-smile" class="award-control-icon-neutral" /> | ||||
|               <gl-icon name="smiley" class="award-control-icon-positive" /> | ||||
|               <gl-icon name="smile" class="award-control-icon-super-positive" /> | ||||
|             </span> | ||||
|             <span v-else> | ||||
|               <span | ||||
|                 v-safe-html:[$options.safeHtmlConfig]="emojiTag" | ||||
|                 data-testid="selected-emoji" | ||||
|               ></span> | ||||
|             </span> | ||||
|           </template> | ||||
|         </emoji-picker> | ||||
|       </template> | ||||
|       <template v-if="isDirty" #append> | ||||
|         <gl-button | ||||
|           v-gl-tooltip.bottom | ||||
|           :title="$options.i18n.clearStatusButtonLabel" | ||||
|           :aria-label="$options.i18n.clearStatusButtonLabel" | ||||
|           icon="close" | ||||
|           class="js-clear-user-status-button" | ||||
|           @click="clearStatusInputs" | ||||
|         /> | ||||
|       </template> | ||||
|     </gl-form-input-group> | ||||
| 
 | ||||
|     <gl-form-checkbox | ||||
|       :checked="availability" | ||||
|       class="gl-mb-5" | ||||
|       data-testid="user-availability-checkbox" | ||||
|       @input="$emit('availability-input', $event)" | ||||
|     > | ||||
|       {{ $options.i18n.availabilityCheckboxLabel }} | ||||
|       <template #help> | ||||
|         {{ $options.i18n.availabilityCheckboxHelpText }} | ||||
|       </template> | ||||
|     </gl-form-checkbox> | ||||
| 
 | ||||
|     <div class="form-group"> | ||||
|       <div class="gl-display-flex gl-align-items-baseline"> | ||||
|         <span class="gl-mr-3">{{ $options.i18n.clearStatusAfterDropdownLabel }}</span> | ||||
|         <gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown"> | ||||
|           <gl-dropdown-item | ||||
|             v-for="after in $options.statusTimeRanges" | ||||
|             :key="after.name" | ||||
|             :data-testid="after.name" | ||||
|             @click="$emit('clear-status-after-click', after)" | ||||
|             >{{ after.label }}</gl-dropdown-item | ||||
|           > | ||||
|         </gl-dropdown> | ||||
|       </div> | ||||
|       <p | ||||
|         v-if="currentClearStatusAfter.length" | ||||
|         class="gl-mt-3 gl-text-gray-400 gl-font-sm" | ||||
|         data-testid="clear-status-at-message" | ||||
|       > | ||||
|         <gl-sprintf :message="$options.i18n.clearStatusAfterMessage"> | ||||
|           <template #date>{{ currentClearStatusAfter }}</template> | ||||
|         </gl-sprintf> | ||||
|       </p> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -1,28 +1,14 @@ | |||
| <script> | ||||
| import { | ||||
|   GlButton, | ||||
|   GlToast, | ||||
|   GlModal, | ||||
|   GlTooltipDirective, | ||||
|   GlIcon, | ||||
|   GlFormCheckbox, | ||||
|   GlFormInput, | ||||
|   GlFormInputGroup, | ||||
|   GlDropdown, | ||||
|   GlDropdownItem, | ||||
|   GlSafeHtmlDirective, | ||||
| } from '@gitlab/ui'; | ||||
| import $ from 'jquery'; | ||||
| import { GlToast, GlTooltipDirective, GlSafeHtmlDirective, GlModal } from '@gitlab/ui'; | ||||
| import Vue from 'vue'; | ||||
| import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; | ||||
| import * as Emoji from '~/emoji'; | ||||
| import createFlash from '~/flash'; | ||||
| import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; | ||||
| import { __, s__, sprintf } from '~/locale'; | ||||
| import { __, s__ } from '~/locale'; | ||||
| import { updateUserStatus } from '~/rest_api'; | ||||
| import { timeRanges } from '~/vue_shared/constants'; | ||||
| import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; | ||||
| import { isUserBusy } from './utils'; | ||||
| import SetStatusForm from './set_status_form.vue'; | ||||
| 
 | ||||
| export const AVAILABILITY_STATUS = { | ||||
|   BUSY: 'busy', | ||||
|  | @ -41,15 +27,8 @@ const statusTimeRanges = [ | |||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GlButton, | ||||
|     GlIcon, | ||||
|     GlModal, | ||||
|     GlFormCheckbox, | ||||
|     GlFormInput, | ||||
|     GlFormInputGroup, | ||||
|     GlDropdown, | ||||
|     GlDropdownItem, | ||||
|     EmojiPicker: () => import('~/emoji/components/picker.vue'), | ||||
|     SetStatusForm, | ||||
|   }, | ||||
|   directives: { | ||||
|     GlTooltip: GlTooltipDirective, | ||||
|  | @ -85,26 +64,12 @@ export default { | |||
|     return { | ||||
|       defaultEmojiTag: '', | ||||
|       emoji: this.currentEmoji, | ||||
|       emojiMenu: null, | ||||
|       emojiTag: '', | ||||
|       message: this.currentMessage, | ||||
|       modalId: 'set-user-status-modal', | ||||
|       noEmoji: true, | ||||
|       availability: isUserBusy(this.currentAvailability), | ||||
|       clearStatusAfter: statusTimeRanges[0], | ||||
|       clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), { | ||||
|         date: this.currentClearStatusAfter, | ||||
|       }), | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     isCustomEmoji() { | ||||
|       return this.emoji !== this.defaultEmoji; | ||||
|     }, | ||||
|     isDirty() { | ||||
|       return Boolean(this.message.length || this.isCustomEmoji); | ||||
|     }, | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.$root.$emit(BV_SHOW_MODAL, this.modalId); | ||||
|   }, | ||||
|  | @ -112,62 +77,10 @@ export default { | |||
|     closeModal() { | ||||
|       this.$root.$emit(BV_HIDE_MODAL, this.modalId); | ||||
|     }, | ||||
|     setupEmojiListAndAutocomplete() { | ||||
|       const emojiAutocomplete = new GfmAutoComplete(); | ||||
|       emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true }); | ||||
| 
 | ||||
|       Emoji.initEmojiMap() | ||||
|         .then(() => { | ||||
|           if (this.emoji) { | ||||
|             this.emojiTag = Emoji.glEmojiTag(this.emoji); | ||||
|           } | ||||
|           this.noEmoji = this.emoji === ''; | ||||
|           this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji); | ||||
| 
 | ||||
|           this.setDefaultEmoji(); | ||||
|         }) | ||||
|         .catch(() => | ||||
|           createFlash({ | ||||
|             message: __('Failed to load emoji list.'), | ||||
|           }), | ||||
|         ); | ||||
|     }, | ||||
|     setDefaultEmoji() { | ||||
|       const { emojiTag } = this; | ||||
|       const hasStatusMessage = Boolean(this.message.length); | ||||
|       if (hasStatusMessage && emojiTag) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       if (hasStatusMessage) { | ||||
|         this.noEmoji = false; | ||||
|         this.emojiTag = this.defaultEmojiTag; | ||||
|       } else if (emojiTag === this.defaultEmojiTag) { | ||||
|         this.noEmoji = true; | ||||
|         this.clearEmoji(); | ||||
|       } | ||||
|     }, | ||||
|     setEmoji(emoji) { | ||||
|       this.emoji = emoji; | ||||
|       this.noEmoji = false; | ||||
|       this.clearEmoji(); | ||||
| 
 | ||||
|       this.emojiTag = Emoji.glEmojiTag(this.emoji); | ||||
|     }, | ||||
|     clearEmoji() { | ||||
|       if (this.emojiTag) { | ||||
|         this.emojiTag = ''; | ||||
|       } | ||||
|     }, | ||||
|     clearStatusInputs() { | ||||
|       this.emoji = ''; | ||||
|       this.message = ''; | ||||
|       this.noEmoji = true; | ||||
|       this.clearEmoji(); | ||||
|     }, | ||||
|     removeStatus() { | ||||
|       this.availability = false; | ||||
|       this.clearStatusInputs(); | ||||
|       this.emoji = ''; | ||||
|       this.message = ''; | ||||
|       this.setStatus(); | ||||
|     }, | ||||
|     setStatus() { | ||||
|  | @ -197,9 +110,18 @@ export default { | |||
| 
 | ||||
|       this.closeModal(); | ||||
|     }, | ||||
|     setClearStatusAfter(after) { | ||||
|     handleMessageInput(value) { | ||||
|       this.message = value; | ||||
|     }, | ||||
|     handleEmojiClick(emoji) { | ||||
|       this.emoji = emoji; | ||||
|     }, | ||||
|     handleClearStatusAfterClick(after) { | ||||
|       this.clearStatusAfter = after; | ||||
|     }, | ||||
|     handleAvailabilityInput(value) { | ||||
|       this.availability = value; | ||||
|     }, | ||||
|   }, | ||||
|   statusTimeRanges, | ||||
|   safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, | ||||
|  | @ -215,85 +137,20 @@ export default { | |||
|     :action-primary="$options.actionPrimary" | ||||
|     :action-secondary="$options.actionSecondary" | ||||
|     modal-class="set-user-status-modal" | ||||
|     @shown="setupEmojiListAndAutocomplete" | ||||
|     @primary="setStatus" | ||||
|     @secondary="removeStatus" | ||||
|   > | ||||
|     <input v-model="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" /> | ||||
|     <gl-form-input-group class="gl-mb-5"> | ||||
|       <gl-form-input | ||||
|         ref="statusMessageField" | ||||
|         v-model="message" | ||||
|         :placeholder="s__(`SetStatusModal|What's your status?`)" | ||||
|         class="js-status-message-field" | ||||
|         name="user[status][message]" | ||||
|         @keyup="setDefaultEmoji" | ||||
|         @keyup.enter.prevent | ||||
|       /> | ||||
|       <template #prepend> | ||||
|         <emoji-picker | ||||
|           dropdown-class="gl-h-full" | ||||
|           toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" | ||||
|           boundary="viewport" | ||||
|           :right="false" | ||||
|           @click="setEmoji" | ||||
|         > | ||||
|           <template #button-content> | ||||
|             <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span> | ||||
|             <span | ||||
|               v-show="noEmoji" | ||||
|               class="js-no-emoji-placeholder no-emoji-placeholder position-relative" | ||||
|             > | ||||
|               <gl-icon name="slight-smile" class="award-control-icon-neutral" /> | ||||
|               <gl-icon name="smiley" class="award-control-icon-positive" /> | ||||
|               <gl-icon name="smile" class="award-control-icon-super-positive" /> | ||||
|             </span> | ||||
|           </template> | ||||
|         </emoji-picker> | ||||
|       </template> | ||||
|       <template v-if="isDirty" #append> | ||||
|         <gl-button | ||||
|           v-gl-tooltip.bottom | ||||
|           :title="s__('SetStatusModal|Clear status')" | ||||
|           :aria-label="s__('SetStatusModal|Clear status')" | ||||
|           icon="close" | ||||
|           class="js-clear-user-status-button" | ||||
|           @click="clearStatusInputs" | ||||
|         /> | ||||
|       </template> | ||||
|     </gl-form-input-group> | ||||
| 
 | ||||
|     <gl-form-checkbox | ||||
|       v-model="availability" | ||||
|       class="gl-mb-5" | ||||
|       data-testid="user-availability-checkbox" | ||||
|     > | ||||
|       {{ s__('SetStatusModal|Busy') }} | ||||
|       <template #help> | ||||
|         {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }} | ||||
|       </template> | ||||
|     </gl-form-checkbox> | ||||
| 
 | ||||
|     <div class="form-group"> | ||||
|       <div class="gl-display-flex gl-align-items-baseline"> | ||||
|         <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span> | ||||
|         <gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown"> | ||||
|           <gl-dropdown-item | ||||
|             v-for="after in $options.statusTimeRanges" | ||||
|             :key="after.name" | ||||
|             :data-testid="after.name" | ||||
|             @click="setClearStatusAfter(after)" | ||||
|             >{{ after.label }}</gl-dropdown-item | ||||
|           > | ||||
|         </gl-dropdown> | ||||
|       </div> | ||||
|       <div | ||||
|         v-if="currentClearStatusAfter.length" | ||||
|         class="gl-mt-3 gl-text-gray-400 gl-font-sm" | ||||
|         data-testid="clear-status-at-message" | ||||
|       > | ||||
|         {{ clearStatusAfterMessage }} | ||||
|       </div> | ||||
|     </div> | ||||
|     <set-status-form | ||||
|       :default-emoji="defaultEmoji" | ||||
|       :emoji="emoji" | ||||
|       :message="message" | ||||
|       :availability="availability" | ||||
|       :clear-status-after="clearStatusAfter" | ||||
|       :current-clear-status-after="currentClearStatusAfter" | ||||
|       @message-input="handleMessageInput" | ||||
|       @emoji-click="handleEmojiClick" | ||||
|       @clear-status-after-click="handleClearStatusAfterClick" | ||||
|       @availability-input="handleAvailabilityInput" | ||||
|     /> | ||||
|   </gl-modal> | ||||
| </template> | ||||
|  |  | |||
|  | @ -19,6 +19,8 @@ const steps = [ | |||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| const MR_RENDER_LS_KEY = 'mr_survey_rendered'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'MergeRequestExperienceSurveyApp', | ||||
|   components: { | ||||
|  | @ -68,9 +70,20 @@ export default { | |||
|     onQueryLoaded({ shouldShowCallout }) { | ||||
|       this.visible = shouldShowCallout; | ||||
|       if (!this.visible) this.$emit('close'); | ||||
|       else if (!localStorage?.getItem(MR_RENDER_LS_KEY)) { | ||||
|         this.track('survey:mr_experience', { | ||||
|           label: 'render', | ||||
|           extra: { | ||||
|             accountAge: this.accountAge, | ||||
|           }, | ||||
|         }); | ||||
|         localStorage?.setItem(MR_RENDER_LS_KEY, '1'); | ||||
|       } | ||||
|     }, | ||||
|     onRate(event) { | ||||
|       this.$refs.dismisser?.dismiss(); | ||||
|       this.$emit('rate'); | ||||
|       localStorage?.removeItem(MR_RENDER_LS_KEY); | ||||
|       this.track('survey:mr_experience', { | ||||
|         label: this.step.label, | ||||
|         value: event, | ||||
|  | @ -87,21 +100,18 @@ export default { | |||
|     }, | ||||
|     handleKeyup(e) { | ||||
|       if (e.key !== 'Escape') return; | ||||
|       this.$emit('close'); | ||||
|       this.dismiss(); | ||||
|     }, | ||||
|     dismiss() { | ||||
|       this.$refs.dismisser?.dismiss(); | ||||
|       this.trackDismissal(); | ||||
|     }, | ||||
|     close() { | ||||
|       this.trackDismissal(); | ||||
|       this.$emit('close'); | ||||
|     }, | ||||
|     trackDismissal() { | ||||
|       this.track('survey:mr_experience', { | ||||
|         label: 'dismiss', | ||||
|         extra: { | ||||
|           accountAge: this.accountAge, | ||||
|         }, | ||||
|       }); | ||||
|       localStorage?.removeItem(MR_RENDER_LS_KEY); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
|  | @ -113,79 +123,71 @@ export default { | |||
|     feature-name="mr_experience_survey" | ||||
|     @queryResult.once="onQueryLoaded" | ||||
|   > | ||||
|     <template #default="{ dismiss }"> | ||||
|       <aside | ||||
|         class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5" | ||||
|         :aria-label="$options.i18n.survey" | ||||
|       > | ||||
|         <transition name="survey-slide-up"> | ||||
|     <aside | ||||
|       class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5" | ||||
|       :aria-label="$options.i18n.survey" | ||||
|     > | ||||
|       <transition name="survey-slide-up"> | ||||
|         <div | ||||
|           v-if="visible" | ||||
|           class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base" | ||||
|         > | ||||
|           <gl-button | ||||
|             v-tooltip="$options.i18n.close" | ||||
|             :aria-label="$options.i18n.close" | ||||
|             variant="default" | ||||
|             category="tertiary" | ||||
|             class="gl-top-4 gl-right-3 gl-absolute" | ||||
|             icon="close" | ||||
|             @click="dismiss" | ||||
|           /> | ||||
|           <div | ||||
|             v-if="visible" | ||||
|             class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base" | ||||
|             v-if="stepIndex === 0" | ||||
|             class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm" | ||||
|             role="note" | ||||
|           > | ||||
|             <gl-button | ||||
|               v-tooltip="$options.i18n.close" | ||||
|               :aria-label="$options.i18n.close" | ||||
|               variant="default" | ||||
|               category="tertiary" | ||||
|               class="gl-top-4 gl-right-3 gl-absolute" | ||||
|               icon="close" | ||||
|               @click=" | ||||
|                 dismiss(); | ||||
|                 close(); | ||||
|               " | ||||
|             /> | ||||
|             <div | ||||
|               v-if="stepIndex === 0" | ||||
|               class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm" | ||||
|               role="note" | ||||
|             > | ||||
|               <p class="gl-m-0"> | ||||
|                 <gl-sprintf :message="$options.i18n.legal"> | ||||
|                   <template #link="{ content }"> | ||||
|                     <a | ||||
|                       class="gl-text-decoration-underline gl-text-gray-500" | ||||
|                       href="https://about.gitlab.com/privacy/" | ||||
|                       target="_blank" | ||||
|                       rel="noreferrer nofollow" | ||||
|                       v-text="content" | ||||
|                     ></a> | ||||
|                   </template> | ||||
|                 </gl-sprintf> | ||||
|               </p> | ||||
|             </div> | ||||
|             <div class="gl-relative"> | ||||
|               <div class="gl-absolute"> | ||||
|                 <div | ||||
|                   v-safe-html="$options.gitlabLogo" | ||||
|                   aria-hidden="true" | ||||
|                   class="mr-experience-survey-logo" | ||||
|                 ></div> | ||||
|               </div> | ||||
|             </div> | ||||
|             <section v-if="step"> | ||||
|               <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7"> | ||||
|                 <gl-sprintf :message="step.question"> | ||||
|                   <template #strong="{ content }"> | ||||
|                     <strong>{{ content }}</strong> | ||||
|                   </template> | ||||
|                 </gl-sprintf> | ||||
|               </p> | ||||
|               <satisfaction-rate | ||||
|                 aria-labelledby="mr_survey_question" | ||||
|                 class="gl-mt-5" | ||||
|                 @rate=" | ||||
|                   dismiss(); | ||||
|                   onRate($event); | ||||
|                 " | ||||
|               /> | ||||
|             </section> | ||||
|             <section v-else class="gl-px-7"> | ||||
|               {{ $options.i18n.thanks }} | ||||
|             </section> | ||||
|             <p class="gl-m-0"> | ||||
|               <gl-sprintf :message="$options.i18n.legal"> | ||||
|                 <template #link="{ content }"> | ||||
|                   <a | ||||
|                     class="gl-text-decoration-underline gl-text-gray-500" | ||||
|                     href="https://about.gitlab.com/privacy/" | ||||
|                     target="_blank" | ||||
|                     rel="noreferrer nofollow" | ||||
|                     v-text="content" | ||||
|                   ></a> | ||||
|                 </template> | ||||
|               </gl-sprintf> | ||||
|             </p> | ||||
|           </div> | ||||
|         </transition> | ||||
|       </aside> | ||||
|     </template> | ||||
|           <div class="gl-relative"> | ||||
|             <div class="gl-absolute"> | ||||
|               <div | ||||
|                 v-safe-html="$options.gitlabLogo" | ||||
|                 aria-hidden="true" | ||||
|                 class="mr-experience-survey-logo" | ||||
|               ></div> | ||||
|             </div> | ||||
|           </div> | ||||
|           <section v-if="step"> | ||||
|             <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7"> | ||||
|               <gl-sprintf :message="step.question"> | ||||
|                 <template #strong="{ content }"> | ||||
|                   <strong>{{ content }}</strong> | ||||
|                 </template> | ||||
|               </gl-sprintf> | ||||
|             </p> | ||||
|             <satisfaction-rate | ||||
|               aria-labelledby="mr_survey_question" | ||||
|               class="gl-mt-5" | ||||
|               @rate="onRate" | ||||
|             /> | ||||
|           </section> | ||||
|           <section v-else class="gl-px-7"> | ||||
|             {{ $options.i18n.thanks }} | ||||
|           </section> | ||||
|         </div> | ||||
|       </transition> | ||||
|     </aside> | ||||
|   </user-callout-dismisser> | ||||
| </template> | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| <script> | ||||
| import { GlSprintf } from '@gitlab/ui'; | ||||
| import { GlSprintf, GlLink } from '@gitlab/ui'; | ||||
| import { escape } from 'lodash'; | ||||
| import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; | ||||
| import { n__, s__, sprintf } from '~/locale'; | ||||
|  | @ -9,6 +9,7 @@ const mergeCommitCount = s__('mrWidgetCommitsAdded|%{strongStart}1%{strongEnd} m | |||
| export default { | ||||
|   components: { | ||||
|     GlSprintf, | ||||
|     GlLink, | ||||
|   }, | ||||
|   mixins: [glFeatureFlagMixin()], | ||||
|   props: { | ||||
|  | @ -40,6 +41,11 @@ export default { | |||
|       required: false, | ||||
|       default: '', | ||||
|     }, | ||||
|     mergeCommitPath: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '', | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     isMerged() { | ||||
|  | @ -124,7 +130,9 @@ export default { | |||
|         </template> | ||||
|       </template> | ||||
|       <template #mergeCommitSha> | ||||
|         <span class="label-branch">{{ mergeCommitSha }}</span> | ||||
|         <gl-link :href="mergeCommitPath" class="label-branch" data-testid="merge-commit-sha">{{ | ||||
|           mergeCommitSha | ||||
|         }}</gl-link> | ||||
|       </template> | ||||
|     </gl-sprintf> | ||||
|   </span> | ||||
|  |  | |||
|  | @ -680,6 +680,7 @@ export default { | |||
|                     :is-fast-forward-enabled="!shouldShowMergeEdit" | ||||
|                     :commits-count="commitsCount" | ||||
|                     :target-branch="stateData.targetBranch" | ||||
|                     :merge-commit-path="mr.mergeCommitPath" | ||||
|                   /> | ||||
|                 </li> | ||||
|                 <li v-if="mr.state !== 'closed'" class="gl-line-height-normal"> | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import CodeBlock from './code_block.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: CodeBlock, | ||||
|   title: 'vue_shared/components/code_block', | ||||
|   title: 'vue_shared/code_block', | ||||
| }; | ||||
| 
 | ||||
| const Template = (args, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import CodeBlockHighlighted from './code_block_highlighted.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: CodeBlockHighlighted, | ||||
|   title: 'vue_shared/components/code_block_highlighted', | ||||
|   title: 'vue_shared/code_block_highlighted', | ||||
| }; | ||||
| 
 | ||||
| const Template = (args, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ import ConfirmDanger from './confirm_danger.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: ConfirmDanger, | ||||
|   title: 'vue_shared/components/modals/confirm_danger_modal', | ||||
|   title: 'vue_shared/modals/confirm_danger_modal', | ||||
| }; | ||||
| 
 | ||||
| const Template = (args, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ import DropdownWidget from './dropdown_widget.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: DropdownWidget, | ||||
|   title: 'vue_shared/components/dropdown/dropdown_widget/dropdown_widget', | ||||
|   title: 'vue_shared/dropdown/dropdown_widget/dropdown_widget', | ||||
| }; | ||||
| 
 | ||||
| const Template = (args, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: InputCopyToggleVisibility, | ||||
|   title: 'vue_shared/components/form/input_copy_toggle_visibility', | ||||
|   title: 'vue_shared/form/input_copy_toggle_visibility', | ||||
| }; | ||||
| 
 | ||||
| const defaultProps = { | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ import PaginationBar from './pagination_bar.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: PaginationBar, | ||||
|   title: 'vue_shared/components/pagination_bar/pagination_bar', | ||||
|   title: 'vue_shared/pagination_bar/pagination_bar', | ||||
| }; | ||||
| 
 | ||||
| const Template = (args, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import ProjectAvatar from './project_avatar.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: ProjectAvatar, | ||||
|   title: 'vue_shared/components/project_avatar', | ||||
|   title: 'vue_shared/project_avatar', | ||||
| }; | ||||
| 
 | ||||
| const Template = (args, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import ProjectListItem from './project_list_item.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: ProjectListItem, | ||||
|   title: 'vue_shared/components/project_selector/project_list_item', | ||||
|   title: 'vue_shared/project_selector/project_list_item', | ||||
| }; | ||||
| 
 | ||||
| const Template = (args, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import SettingsBlock from './settings_block.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: SettingsBlock, | ||||
|   title: 'vue_shared/components/settings/settings_block', | ||||
|   title: 'vue_shared/settings/settings_block', | ||||
| }; | ||||
| 
 | ||||
| const Template = (args, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -4,7 +4,7 @@ import TodoButton from './todo_button.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: TodoButton, | ||||
|   title: 'vue_shared/components/sidebar/todo_toggle/todo_button', | ||||
|   title: 'vue_shared/sidebar/todo_toggle/todo_button', | ||||
| }; | ||||
| 
 | ||||
| const Template = (args, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ const defaultWidth = '250px'; | |||
| 
 | ||||
| export default { | ||||
|   component: TooltipOnTruncate, | ||||
|   title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue', | ||||
|   title: 'vue_shared/tooltip_on_truncate/tooltip_on_truncate.vue', | ||||
| }; | ||||
| 
 | ||||
| const createStory = ({ ...options }) => { | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue'; | |||
| 
 | ||||
| export default { | ||||
|   component: UserDeletionObstaclesList, | ||||
|   title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list', | ||||
|   title: 'vue_shared/user_deletion_obstacles/user_deletion_obstacles_list', | ||||
| }; | ||||
| 
 | ||||
| const Template = (args, { argTypes }) => ({ | ||||
|  |  | |||
|  | @ -1,34 +1,17 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class Admin::HookLogsController < Admin::ApplicationController | ||||
|   include ::WebHooks::HookExecutionNotice | ||||
| module Admin | ||||
|   class HookLogsController < Admin::ApplicationController | ||||
|     include WebHooks::HookLogActions | ||||
| 
 | ||||
|   before_action :hook, only: [:show, :retry] | ||||
|   before_action :hook_log, only: [:show, :retry] | ||||
|     private | ||||
| 
 | ||||
|   respond_to :html | ||||
|     def hook | ||||
|       @hook ||= SystemHook.find(params[:hook_id]) | ||||
|     end | ||||
| 
 | ||||
|   feature_category :integrations | ||||
|   urgency :low, [:retry] | ||||
| 
 | ||||
|   def show | ||||
|   end | ||||
| 
 | ||||
|   def retry | ||||
|     result = hook.execute(hook_log.request_data, hook_log.trigger) | ||||
| 
 | ||||
|     set_hook_execution_notice(result) | ||||
| 
 | ||||
|     redirect_to edit_admin_hook_path(@hook) | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def hook | ||||
|     @hook ||= SystemHook.find(params[:hook_id]) | ||||
|   end | ||||
| 
 | ||||
|   def hook_log | ||||
|     @hook_log ||= hook.web_hook_logs.find(params[:id]) | ||||
|     def after_retry_redirect_path | ||||
|       edit_admin_hook_path(hook) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ class Admin::RunnersController < Admin::ApplicationController | |||
|   before_action :runner, except: [:index, :tag_list, :runner_setup_scripts] | ||||
|   before_action only: [:index] do | ||||
|     push_frontend_feature_flag(:admin_runners_bulk_delete) | ||||
|     push_frontend_feature_flag(:runner_list_stacked_layout_admin) | ||||
|   end | ||||
| 
 | ||||
|   feature_category :runner | ||||
|  |  | |||
|  | @ -0,0 +1,39 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module WebHooks | ||||
|   module HookLogActions | ||||
|     extend ActiveSupport::Concern | ||||
|     include HookExecutionNotice | ||||
| 
 | ||||
|     included do | ||||
|       before_action :hook, only: [:show, :retry] | ||||
|       before_action :hook_log, only: [:show, :retry] | ||||
| 
 | ||||
|       respond_to :html | ||||
| 
 | ||||
|       feature_category :integrations | ||||
|       urgency :low, [:retry] | ||||
|     end | ||||
| 
 | ||||
|     def show | ||||
|     end | ||||
| 
 | ||||
|     def retry | ||||
|       execute_hook | ||||
|       redirect_to after_retry_redirect_path | ||||
|     end | ||||
| 
 | ||||
|     private | ||||
| 
 | ||||
|     # rubocop:disable Gitlab/ModuleWithInstanceVariables | ||||
|     def hook_log | ||||
|       @hook_log ||= hook.web_hook_logs.find(params[:id]) | ||||
|     end | ||||
|     # rubocop:enable Gitlab/ModuleWithInstanceVariables | ||||
| 
 | ||||
|     def execute_hook | ||||
|       result = hook.execute(hook_log.request_data, hook_log.trigger) | ||||
|       set_hook_execution_notice(result) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -4,6 +4,9 @@ class Groups::RunnersController < Groups::ApplicationController | |||
|   before_action :authorize_read_group_runners!, only: [:index, :show] | ||||
|   before_action :authorize_admin_group_runners!, only: [:edit, :update, :destroy, :pause, :resume] | ||||
|   before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show] | ||||
|   before_action only: [:index] do | ||||
|     push_frontend_feature_flag(:runner_list_stacked_layout, @group) | ||||
|   end | ||||
| 
 | ||||
|   feature_category :runner | ||||
|   urgency :low | ||||
|  |  | |||
|  | @ -1,40 +1,19 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class Projects::HookLogsController < Projects::ApplicationController | ||||
|   include ::WebHooks::HookExecutionNotice | ||||
| 
 | ||||
|   before_action :authorize_admin_project! | ||||
| 
 | ||||
|   before_action :hook, only: [:show, :retry] | ||||
|   before_action :hook_log, only: [:show, :retry] | ||||
| 
 | ||||
|   respond_to :html | ||||
|   include WebHooks::HookLogActions | ||||
| 
 | ||||
|   layout 'project_settings' | ||||
| 
 | ||||
|   feature_category :integrations | ||||
|   urgency :low, [:retry] | ||||
| 
 | ||||
|   def show | ||||
|   end | ||||
| 
 | ||||
|   def retry | ||||
|     execute_hook | ||||
|     redirect_to edit_project_hook_path(@project, @hook) | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def execute_hook | ||||
|     result = hook.execute(hook_log.request_data, hook_log.trigger) | ||||
|     set_hook_execution_notice(result) | ||||
|   end | ||||
| 
 | ||||
|   def hook | ||||
|     @hook ||= @project.hooks.find(params[:hook_id]) | ||||
|   end | ||||
| 
 | ||||
|   def hook_log | ||||
|     @hook_log ||= hook.web_hook_logs.find(params[:id]) | ||||
|   def after_retry_redirect_path | ||||
|     edit_project_hook_path(@project, hook) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -7,13 +7,13 @@ module Projects | |||
| 
 | ||||
|       before_action :integration, only: [:show, :retry] | ||||
| 
 | ||||
|       def retry | ||||
|         execute_hook | ||||
|         redirect_to edit_project_settings_integration_path(@project, @integration) | ||||
|       end | ||||
| 
 | ||||
|       private | ||||
| 
 | ||||
|       override :after_retry_redirect_path | ||||
|       def after_retry_redirect_path | ||||
|         edit_project_settings_integration_path(@project, @integration) | ||||
|       end | ||||
| 
 | ||||
|       def integration | ||||
|         @integration ||= @project.find_or_initialize_integration(params[:integration_id]) | ||||
|       end | ||||
|  |  | |||
|  | @ -57,6 +57,13 @@ class SearchController < ApplicationController | |||
|       @search_highlight = @search_service.search_highlight | ||||
|     end | ||||
| 
 | ||||
|     Gitlab::Metrics::GlobalSearchSlis.record_apdex( | ||||
|       elapsed: @global_search_duration_s, | ||||
|       search_type: @search_type, | ||||
|       search_level: @search_level, | ||||
|       search_scope: @scope | ||||
|     ) | ||||
| 
 | ||||
|     increment_search_counters | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| #fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'), | ||||
|   endpoint: new_project_fork_path(@project, format: :json), | ||||
|   new_group_path: new_group_path, | ||||
|   project_full_path: project_path(@project), | ||||
|   project_full_path: @project.full_path, | ||||
|   visibility_help_path: help_page_path("user/public_access"), | ||||
|   project_id: @project.id, | ||||
|   project_name: @project.name, | ||||
|  |  | |||
|  | @ -0,0 +1,8 @@ | |||
| --- | ||||
| name: ci_variables_refactoring_to_variable | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95390 | ||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371559 | ||||
| milestone: '15.4' | ||||
| type: development | ||||
| group: group::pipeline authoring | ||||
| default_enabled: false | ||||
|  | @ -0,0 +1,8 @@ | |||
| --- | ||||
| name: global_search_custom_slis | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95182 | ||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/372107 | ||||
| milestone: '15.4' | ||||
| type: development | ||||
| group: group::application performance | ||||
| default_enabled: false | ||||
|  | @ -0,0 +1,8 @@ | |||
| --- | ||||
| name: runner_list_stacked_layout | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95617 | ||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371031 | ||||
| milestone: '15.4' | ||||
| type: development | ||||
| group: group::runner | ||||
| default_enabled: false | ||||
|  | @ -0,0 +1,8 @@ | |||
| --- | ||||
| name: runner_list_stacked_layout_admin | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95617 | ||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371031 | ||||
| milestone: '15.4' | ||||
| type: development | ||||
| group: group::runner | ||||
| default_enabled: false | ||||
|  | @ -40,6 +40,7 @@ if Gitlab::Metrics.enabled? && !Rails.env.test? && !(Rails.env.development? && d | |||
| 
 | ||||
|   if Gitlab::Runtime.puma? | ||||
|     Gitlab::Metrics::RequestsRackMiddleware.initialize_metrics | ||||
|     Gitlab::Metrics::GlobalSearchSlis.initialize_slis! | ||||
|   end | ||||
| 
 | ||||
|   GC::Profiler.enable | ||||
|  |  | |||
|  | @ -232,7 +232,7 @@ In progress. | |||
| 
 | ||||
| ## Timeline | ||||
| 
 | ||||
| - 2021-01-21: Parent [CI Scaling](../ci_scale/) blueprint [merge request](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52203) created. | ||||
| - 2021-01-21: Parent [CI Scaling](../ci_scale/index.md) blueprint [merge request](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52203) created. | ||||
| - 2021-04-26: CI Scaling blueprint approved and merged. | ||||
| - 2021-09-10: CI/CD data time decay blueprint discussions started. | ||||
| - 2022-01-07: CI/CD data time decay blueprint [merged](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70052). | ||||
|  |  | |||
|  | @ -171,7 +171,7 @@ Work required to achieve our next CI/CD scaling target is tracked in the | |||
| 1. ✓ Migrate primary keys to big integers on GitLab.com. | ||||
| 1. ✓ Implement the new architecture of builds queuing on GitLab.com. | ||||
| 1. [Make the new builds queuing architecture generally available](https://gitlab.com/groups/gitlab-org/-/epics/6954). | ||||
| 1. [Partition CI/CD data using time-decay pattern](../ci_data_decay/). | ||||
| 1. [Partition CI/CD data using time-decay pattern](../ci_data_decay/index.md). | ||||
| 
 | ||||
| ## Status | ||||
| 
 | ||||
|  |  | |||
|  | @ -146,43 +146,30 @@ See also the [Why CI/CD?](https://docs.google.com/presentation/d/1OGgk2Tcxbpl7DJ | |||
| As GitLab CI/CD has evolved, certain breaking changes have | ||||
| been necessary. | ||||
| 
 | ||||
| #### 15.0 and later | ||||
| 
 | ||||
| Going forward, all breaking changes are documented on the following pages: | ||||
| For GitLab 15.0 and later, all breaking changes are documented on the following pages: | ||||
| 
 | ||||
| - [Deprecations](../update/deprecations.md) | ||||
| - [Removals](../update/removals.md) | ||||
| 
 | ||||
| #### 14.0 | ||||
| The breaking changes for [GitLab Runner](https://docs.gitlab.com/runner/) in earlier | ||||
| major version releases are: | ||||
| 
 | ||||
| - No breaking changes. | ||||
| 
 | ||||
| #### 13.0 | ||||
| 
 | ||||
| - [Remove Backported `os.Expand`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4915). | ||||
| - [Remove Fedora 29 package support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/16158). | ||||
| - [Remove macOS 32-bit support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/25466). | ||||
| - [Removed `debug/jobs/list?v=1` endpoint](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6361). | ||||
| - [Remove support for array of strings when defining services for Docker executor](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4922). | ||||
| - [Remove `--docker-services` flag on register command](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6404). | ||||
| - [Remove legacy build directory caching](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4180). | ||||
| - [Remove `FF_USE_LEGACY_VOLUMES_MOUNTING_ORDER` feature flag](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6581). | ||||
| - [Remove support for Windows Server 1803](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6553). | ||||
| 
 | ||||
| #### 12.0 | ||||
| 
 | ||||
| - [Use `refspec` to clone/fetch Git repository](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4069). | ||||
| - [Old cache configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4070). | ||||
| - [Old metrics server configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4072). | ||||
| - [Remove `FF_K8S_USE_ENTRYPOINT_OVER_COMMAND`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4073). | ||||
| - [Remove Linux distributions that reach EOL](https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/1130). | ||||
| - [Update command line API for helper images](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4013). | ||||
| - [Remove old `git clean` flow](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4175). | ||||
| 
 | ||||
| #### 11.0 | ||||
| 
 | ||||
| - No breaking changes. | ||||
| 
 | ||||
| #### 10.0 | ||||
| 
 | ||||
| - No breaking changes. | ||||
| - 14.0: No breaking changes. | ||||
| - 13.0: | ||||
|   - [Remove Backported `os.Expand`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4915). | ||||
|   - [Remove Fedora 29 package support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/16158). | ||||
|   - [Remove macOS 32-bit support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/25466). | ||||
|   - [Removed `debug/jobs/list?v=1` endpoint](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6361). | ||||
|   - [Remove support for array of strings when defining services for Docker executor](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4922). | ||||
|   - [Remove `--docker-services` flag on register command](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6404). | ||||
|   - [Remove legacy build directory caching](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4180). | ||||
|   - [Remove `FF_USE_LEGACY_VOLUMES_MOUNTING_ORDER` feature flag](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6581). | ||||
|   - [Remove support for Windows Server 1803](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6553). | ||||
| - 12.0: | ||||
|   - [Use `refspec` to clone/fetch Git repository](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4069). | ||||
|   - [Old cache configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4070). | ||||
|   - [Old metrics server configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4072). | ||||
|   - [Remove `FF_K8S_USE_ENTRYPOINT_OVER_COMMAND`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4073). | ||||
|   - [Remove Linux distributions that reach EOL](https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/1130). | ||||
|   - [Update command line API for helper images](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4013). | ||||
|   - [Remove old `git clean` flow](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4175). | ||||
|  |  | |||
|  | @ -67,7 +67,7 @@ Some changes affect more than one group. For example: | |||
| 
 | ||||
| - Changes to [code review guidelines](code_review.md). | ||||
| - Changes to [commit message guidelines](contributing/merge_request_workflow.md#commit-messages-guidelines). | ||||
| - Changes to guidelines in [feature flags in development of GitLab](feature_flags/). | ||||
| - Changes to guidelines in [feature flags in development of GitLab](feature_flags/index.md). | ||||
| - Changes to [feature flags documentation guidelines](documentation/feature_flags.md). | ||||
| 
 | ||||
| In these cases, use the following workflow: | ||||
|  |  | |||
|  | @ -129,7 +129,7 @@ To deprecate an attribute: | |||
|    ``` | ||||
| 
 | ||||
| To widely announce a deprecation, or if it's a breaking change, | ||||
| [update the deprecations and removals documentation](../deprecation_guidelines/#update-the-deprecations-and-removals-documentation). | ||||
| [update the deprecations and removals documentation](../deprecation_guidelines/index.md#update-the-deprecations-and-removals-documentation). | ||||
| 
 | ||||
| ## Method description | ||||
| 
 | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ This document lists the different implementations of CSV export in GitLab codeba | |||
| | Export type | How it works | Advantages | Disadvantages | Existing examples | | ||||
| |---|---|---|---|---| | ||||
| | Streaming | - Query and yield data in batches to a response stream.<br>- Download starts immediately. | - Report available immediately. | - No progress indicator.<br>- Requires a reliable connection. | [Export Audit Event Log](../administration/audit_events.md#export-to-csv) | | ||||
| | Downloading | - Query and write data in batches to a temporary file.<br>- Loads the file into memory.<br>- Sends the file to the client. | - Report available immediately. | - Large amount of data might cause request timeout.<br>- Memory intensive.<br>- Request expires when user navigates to a different page. | - [Export Chain of Custody Report](../user/compliance/compliance_report/#chain-of-custody-report)<br>- [Export License Usage File](../subscriptions/self_managed/index.md#export-your-license-usage) | | ||||
| | Downloading | - Query and write data in batches to a temporary file.<br>- Loads the file into memory.<br>- Sends the file to the client. | - Report available immediately. | - Large amount of data might cause request timeout.<br>- Memory intensive.<br>- Request expires when user navigates to a different page. | - [Export Chain of Custody Report](../user/compliance/compliance_report/index.md#chain-of-custody-report)<br>- [Export License Usage File](../subscriptions/self_managed/index.md#export-your-license-usage) | | ||||
| | As email attachment | - Asynchronously process the query with background job.<br>- Email uses the export as an attachment. | - Asynchronous processing. | - Requires users use a different app (email) to download the CSV.<br>- Email providers may limit attachment size. | - [Export issues](../user/project/issues/csv_export.md)<br>- [Export merge requests](../user/project/merge_requests/csv_export.md) | | ||||
| | As downloadable link in email (*) | - Asynchronously process the query with background job.<br>- Email uses an export link. | - Asynchronous processing.<br>- Bypasses email provider attachment size limit. | - Requires users use a different app (email).<br>- Requires additional storage and cleanup. | [Export User Permissions](https://gitlab.com/gitlab-org/gitlab/-/issues/1772) | | ||||
| | Polling (non-persistent state) | - Asynchronously processes the query with the background job.<br>- Frontend(FE) polls every few seconds to check if CSV file is ready. | - Asynchronous processing.<br>- Automatically downloads to local machine on completion.<br>- In-app solution. | - Non-persistable request - request expires when user navigates to a different page.<br>- API is processed for each polling request. | [Export Vulnerabilities](../user/application_security/vulnerability_report/index.md#export-vulnerability-details) | | ||||
|  |  | |||
|  | @ -47,9 +47,9 @@ To add a story: | |||
| 1. Write the story as per the [official Storybook instructions](https://storybook.js.org/docs/vue/writing-stories/introduction/) | ||||
| 
 | ||||
|    NOTE: | ||||
|    Specify the `title` field of the story as the component's file path from the `javascripts/` directory. | ||||
|    Specify the `title` field of the story as the component's file path from the `javascripts/` directory, without the `/components` part. | ||||
|    For example, if the component is located at `app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue`, | ||||
|    specify the story `title` as `vue_shared/components/sidebar/todo_toggle/todo_button`. | ||||
|    specify the story `title` as `vue_shared/sidebar/todo_toggle/todo_button`. | ||||
|    If the component is located in the `ee/` directory, make sure to prefix the story's title with `ee/` as well. | ||||
|    This will ensure the Storybook navigation maps closely to our internal directory structure. | ||||
| 
 | ||||
|  |  | |||
|  | @ -65,7 +65,7 @@ listed here that also do not work properly in FIPS mode: | |||
| - [Solutions for vulnerabilities](../user/application_security/vulnerabilities/index.md#resolve-a-vulnerability) | ||||
|   for yarn projects. | ||||
| - [Static Application Security Testing (SAST)](../user/application_security/sast/index.md) | ||||
|   supports a reduced set of [analyzers](../user/application_security/sast/#fips-enabled-images) | ||||
|   supports a reduced set of [analyzers](../user/application_security/sast/index.md#fips-enabled-images) | ||||
|   when operating in FIPS-compliant mode. | ||||
| - Advanced Search is currently not included in FIPS mode. It must not be enabled in order to be FIPS-compliant.   | ||||
| - [Gravatar or Libravatar-based profile images](../administration/libravatar.md) are not FIPS-compliant. | ||||
|  |  | |||
|  | @ -254,7 +254,7 @@ Following the POSIX exit code standard, the scanner exits with 0 for success and | |||
| Success also includes the case when vulnerabilities are found. | ||||
| 
 | ||||
| When a CI job fails, security report results are not ingested by GitLab, even if the job | ||||
| [allows failure](../../ci/yaml/#allow_failure). The report artifacts are still uploaded to GitLab and available | ||||
| [allows failure](../../ci/yaml/index.md#allow_failure). The report artifacts are still uploaded to GitLab and available | ||||
| for [download in the pipeline security tab](../../user/application_security/vulnerability_report/pipeline.md#download-security-scan-outputs). | ||||
| 
 | ||||
| When executing a scanning job using the [Docker-in-Docker privileged mode](../../user/application_security/sast/index.md#requirements), | ||||
|  |  | |||
|  | @ -44,21 +44,21 @@ flowchart LR | |||
| ### Scanning | ||||
| 
 | ||||
| The scanning part is responsible for finding vulnerabilities in given resources, and exporting results. | ||||
| The scans are executed in CI/CD jobs via several small projects called [Analyzers](../../user/application_security/terminology/#analyzer), which can be found in our [Analyzers sub-group](https://gitlab.com/gitlab-org/security-products/analyzers). | ||||
| The Analyzers are wrappers around security tools called [Scanners](../../user/application_security/terminology/#scanner), developed internally or externally, to integrate them into GitLab. | ||||
| The scans are executed in CI/CD jobs via several small projects called [Analyzers](../../user/application_security/terminology/index.md#analyzer), which can be found in our [Analyzers sub-group](https://gitlab.com/gitlab-org/security-products/analyzers). | ||||
| The Analyzers are wrappers around security tools called [Scanners](../../user/application_security/terminology/index.md#scanner), developed internally or externally, to integrate them into GitLab. | ||||
| The Analyzers are mainly written in Go. | ||||
| 
 | ||||
| Some 3rd party integrators also make additional Scanners available by following our [integration documentation](../integrations/secure.md), which leverages the same architecture. | ||||
| 
 | ||||
| The results of the scans are exported as JSON reports that must comply with the [Secure report format](../../user/application_security/terminology/#secure-report-format) and are uploaded as [CI/CD Job Report artifacts](../../ci/pipelines/job_artifacts.md) to make them available for processing after the pipelines completes. | ||||
| The results of the scans are exported as JSON reports that must comply with the [Secure report format](../../user/application_security/terminology/index.md#secure-report-format) and are uploaded as [CI/CD Job Report artifacts](../../ci/pipelines/job_artifacts.md) to make them available for processing after the pipelines completes. | ||||
| 
 | ||||
| ### Processing, visualization, and management | ||||
| 
 | ||||
| After the data is available as a Report Artifact it can be processed by the GitLab Rails application to enable our security features, including: | ||||
| 
 | ||||
| - [Security Dashboards](../../user/application_security/security_dashboard/), Merge Request widget, Pipeline view, and so on. | ||||
| - [Interactions with vulnerabilities](../../user/application_security/#interact-with-findings-and-vulnerabilities). | ||||
| - [Approval rules](../../user/application_security/#security-approvals-in-merge-requests). | ||||
| - [Security Dashboards](../../user/application_security/security_dashboard/index.md), Merge Request widget, Pipeline view, and so on. | ||||
| - [Interactions with vulnerabilities](../../user/application_security/index.md#interact-with-findings-and-vulnerabilities). | ||||
| - [Approval rules](../../user/application_security/index.md#security-approvals-in-merge-requests). | ||||
| 
 | ||||
| Depending on the context, the security reports may be stored either in the database or stay as Report Artifacts for on-demand access. | ||||
| 
 | ||||
|  |  | |||
|  | @ -238,7 +238,7 @@ to the default branch (`main`). | |||
| 
 | ||||
| NOTE: | ||||
| For this tutorial, you merge your branch directly to the default branch for your | ||||
| repository. In GitLab, you typically use a [merge request](../user/project/merge_requests/) | ||||
| repository. In GitLab, you typically use a [merge request](../user/project/merge_requests/index.md) | ||||
| to merge your branch. | ||||
| 
 | ||||
| ### View your changes in GitLab | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w | |||
| 
 | ||||
| You can set a limit so that users and processes can't request more than a certain number of pipelines each minute. This limit can help save resources and improve stability. | ||||
| 
 | ||||
| For example, if you set a limit of `10`, and `11` requests are sent to the [trigger API](../../../ci/triggers/) within one minute, | ||||
| For example, if you set a limit of `10`, and `11` requests are sent to the [trigger API](../../../ci/triggers/index.md) within one minute, | ||||
| the eleventh request is blocked. Access to the endpoint is allowed again after one minute. | ||||
| 
 | ||||
| This limit is: | ||||
|  |  | |||
|  | @ -68,7 +68,7 @@ information directly in the merge request. | |||
| | [Solutions for vulnerabilities (auto-remediation)](#solutions-for-vulnerabilities-auto-remediation) | No | Yes | | ||||
| | Support for the [vulnerability allow list](#vulnerability-allowlisting) | No | Yes | | ||||
| | [Access to Security Dashboard page](#security-dashboard) | No | Yes | | ||||
| | [Access to Dependency List page](../dependency_list/) | No | Yes | | ||||
| | [Access to Dependency List page](../dependency_list/index.md) | No | Yes | | ||||
| 
 | ||||
| ## Requirements | ||||
| 
 | ||||
|  | @ -706,12 +706,12 @@ The results are stored in `gl-container-scanning-report.json`. | |||
| ## Reports JSON format | ||||
| 
 | ||||
| The container scanning tool emits JSON reports which the [GitLab Runner](https://docs.gitlab.com/runner/) | ||||
| recognizes through the [`artifacts:reports`](../../../ci/yaml/#artifactsreports) | ||||
| recognizes through the [`artifacts:reports`](../../../ci/yaml/index.md#artifactsreports) | ||||
| keyword in the CI configuration file. | ||||
| 
 | ||||
| Once the CI job finishes, the Runner uploads these reports to GitLab, which are then available in | ||||
| the CI Job artifacts. In GitLab Ultimate, these reports can be viewed in the corresponding [pipeline](../vulnerability_report/pipeline.md) | ||||
| and become part of the [Vulnerability Report](../vulnerability_report/). | ||||
| and become part of the [Vulnerability Report](../vulnerability_report/index.md). | ||||
| 
 | ||||
| These reports must follow a format defined in the | ||||
| [security report schemas](https://gitlab.com/gitlab-org/security-products/security-report-schemas/). See: | ||||
|  |  | |||
|  | @ -70,9 +70,9 @@ role. | |||
| Users granted: | ||||
| 
 | ||||
| - A higher role with Group Sync are displayed as having | ||||
|   [direct membership](../../project/members/#display-direct-members) of the group. | ||||
|   [direct membership](../../project/members/index.md#display-direct-members) of the group. | ||||
| - A lower or the same role with Group Sync are displayed as having | ||||
|   [inherited membership](../../project/members/#display-inherited-members) of the group. | ||||
|   [inherited membership](../../project/members/index.md#display-inherited-members) of the group. | ||||
| 
 | ||||
| ### Automatic member removal | ||||
| 
 | ||||
|  |  | |||
|  | @ -220,7 +220,7 @@ It is important that this SCIM `id` and SCIM `externalId` are configured to the | |||
| 
 | ||||
| ### How do I verify user's SAML NameId matches the SCIM externalId | ||||
| 
 | ||||
| Admins can use the Admin Area to [list SCIM identities for a user](../../admin_area/#user-identities). | ||||
| Admins can use the Admin Area to [list SCIM identities for a user](../../admin_area/index.md#user-identities). | ||||
| 
 | ||||
| Group owners can see the list of users and the `externalId` stored for each user in the group SAML SSO Settings page. | ||||
| 
 | ||||
|  |  | |||
|  | @ -110,8 +110,8 @@ If you don't set `TF_STATE_NAME` or `TF_ADDRESS` in your job, the job fails with | |||
| To resolve this, ensure that either `TF_ADDRESS` or `TF_STATE_NAME` is accessible in the | ||||
| job that returned the error: | ||||
| 
 | ||||
| 1. Configure the [CI/CD environment scope](../../../ci/variables/#add-a-cicd-variable-to-a-project) for the job. | ||||
| 1. Set the job's [environment](../../../ci/yaml/#environment), matching the environment scope from the previous step. | ||||
| 1. Configure the [CI/CD environment scope](../../../ci/variables/index.md#add-a-cicd-variable-to-a-project) for the job. | ||||
| 1. Set the job's [environment](../../../ci/yaml/index.md#environment), matching the environment scope from the previous step. | ||||
| 
 | ||||
| ### Error refreshing state: HTTP remote state endpoint requires auth | ||||
| 
 | ||||
|  |  | |||
|  | @ -125,7 +125,7 @@ upgrading to [GitLab Premium or Ultimate](https://about.gitlab.com/upgrade/). | |||
| 
 | ||||
| ## Purchase additional data transfer | ||||
| 
 | ||||
| Read more about managing your [data transfer limits](../../../subscriptions/gitlab_com/#purchase-more-storage-and-transfer). | ||||
| Read more about managing your [data transfer limits](../../../subscriptions/gitlab_com/index.md#purchase-more-storage-and-transfer). | ||||
| 
 | ||||
| ## Related issues | ||||
| 
 | ||||
|  |  | |||
|  | @ -119,7 +119,7 @@ The following table lists project permissions available for each role: | |||
| | [Merge requests](project/merge_requests/index.md):<br>Add labels                                                                                                                     |          |          | ✓         | ✓          | ✓        | | ||||
| | [Merge requests](project/merge_requests/index.md):<br>Lock threads                                                                                                                   |          |          | ✓         | ✓          | ✓        | | ||||
| | [Merge requests](project/merge_requests/index.md):<br>Manage or accept                                                                                                               |          |          | ✓         | ✓          | ✓        | | ||||
| | [Merge requests](project/merge_requests/index.md):<br>[Resolve a thread](discussions/#resolve-a-thread)                                                                              |          |          | ✓         | ✓          | ✓        | | ||||
| | [Merge requests](project/merge_requests/index.md):<br>[Resolve a thread](discussions/index.md#resolve-a-thread)                                                                      |          |          | ✓         | ✓          | ✓        | | ||||
| | [Merge requests](project/merge_requests/index.md):<br>Manage [merge approval rules](project/merge_requests/approvals/settings.md) (project settings)                                 |          |          |           | ✓          | ✓        | | ||||
| | [Merge requests](project/merge_requests/index.md):<br>Delete                                                                                                                         |          |          |           |            | ✓        | | ||||
| | [Metrics dashboards](../operations/metrics/dashboards/index.md):<br>Manage user-starred metrics dashboards (*6*)                                                                     | ✓        | ✓        | ✓         | ✓          | ✓        | | ||||
|  |  | |||
|  | @ -85,7 +85,7 @@ Migrate the assets in this order: | |||
| 
 | ||||
| Keep in mind the limitations of the [import/export feature](../settings/import_export.md#items-that-are-exported). | ||||
| 
 | ||||
| You must still migrate your [Container Registry](../../packages/container_registry/) | ||||
| You must still migrate your [Container Registry](../../packages/container_registry/index.md) | ||||
| over a series of Docker pulls and pushes. Re-run any CI pipelines to retrieve any build artifacts. | ||||
| 
 | ||||
| ## Migrate from GitLab.com to self-managed GitLab | ||||
|  |  | |||
|  | @ -39,7 +39,7 @@ Complete these steps in GitLab: | |||
| 1. Optional. Select **Test settings**. | ||||
| 1. Select **Save changes**. | ||||
| 
 | ||||
| After configuring the integration, see [Pipelines for external pull requests](../../../ci/ci_cd_for_external_repos/#pipelines-for-external-pull-requests) | ||||
| After configuring the integration, see [Pipelines for external pull requests](../../../ci/ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests) | ||||
| to configure pipelines to run for open pull requests. | ||||
| 
 | ||||
| ### Static or dynamic status check names | ||||
|  |  | |||
|  | @ -170,7 +170,7 @@ include:  # Execute individual project's configuration (if project contains .git | |||
| When used to enforce scan execution, this feature has some overlap with [scan execution policies](../../application_security/policies/scan-execution-policies.md), | ||||
| as we have not [unified the user experience for these two features](https://gitlab.com/groups/gitlab-org/-/epics/7312). | ||||
| For details on the similarities and differences between these features, see | ||||
| [Enforce scan execution](../../application_security/#enforce-scan-execution). | ||||
| [Enforce scan execution](../../application_security/index.md#enforce-scan-execution). | ||||
| 
 | ||||
| ### Ensure compliance jobs are always run | ||||
| 
 | ||||
|  |  | |||
|  | @ -57,7 +57,6 @@ module API | |||
|         optional :only_allow_merge_if_all_discussions_are_resolved, type: Boolean, desc: 'Only allow to merge if all threads are resolved' | ||||
|         optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, desc: 'Deprecated: Use :topics instead' | ||||
|         optional :topics, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, desc: 'The list of topics for a project' | ||||
|         # TODO: remove rubocop disable - https://gitlab.com/gitlab-org/gitlab/issues/14960 | ||||
|         optional :avatar, type: ::API::Validations::Types::WorkhorseFile, desc: 'Avatar image for project' | ||||
|         optional :printing_merge_request_link_enabled, type: Boolean, desc: 'Show link to create/view merge request when pushing from the command line' | ||||
|         optional :merge_method, type: String, values: %w(ff rebase_merge merge), desc: 'The merge method used when merging merge requests' | ||||
|  |  | |||
|  | @ -65,6 +65,13 @@ module API | |||
| 
 | ||||
|         set_global_search_log_information | ||||
| 
 | ||||
|         Gitlab::Metrics::GlobalSearchSlis.record_apdex( | ||||
|           elapsed: @search_duration_s, | ||||
|           search_type: search_type, | ||||
|           search_level: search_service.level, | ||||
|           search_scope: search_scope | ||||
|         ) | ||||
| 
 | ||||
|         Gitlab::UsageDataCounters::SearchCounter.count(:all_searches) | ||||
| 
 | ||||
|         paginate(@results) | ||||
|  |  | |||
|  | @ -0,0 +1,49 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Gitlab | ||||
|   module Ci | ||||
|     class Config | ||||
|       module Entry | ||||
|         ## | ||||
|         # Entry that represents CI/CD variables. | ||||
|         # The class will be renamed to `Variables` when removing the FF `ci_variables_refactoring_to_variable`. | ||||
|         # | ||||
|         class CurrentVariables < ::Gitlab::Config::Entry::ComposableHash | ||||
|           include ::Gitlab::Config::Entry::Validatable | ||||
| 
 | ||||
|           validations do | ||||
|             validates :config, type: Hash | ||||
|           end | ||||
| 
 | ||||
|           # Enable these lines when removing the FF `ci_variables_refactoring_to_variable` | ||||
|           # and renaming this class to `Variables`. | ||||
|           # def self.default(**) | ||||
|           #   {} | ||||
|           # end | ||||
| 
 | ||||
|           def value | ||||
|             @entries.to_h do |key, entry| | ||||
|               [key.to_s, entry.value] | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           def value_with_data | ||||
|             @entries.to_h do |key, entry| | ||||
|               [key.to_s, entry.value_with_data] | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           private | ||||
| 
 | ||||
|           def composable_class(_name, _config) | ||||
|             Entry::Variable | ||||
|           end | ||||
| 
 | ||||
|           def composable_metadata | ||||
|             { allowed_value_data: opt(:allowed_value_data) } | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,46 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Gitlab | ||||
|   module Ci | ||||
|     class Config | ||||
|       module Entry | ||||
|         ## | ||||
|         # Entry that represents environment variables. | ||||
|         # This is legacy implementation and will be removed with the FF `ci_variables_refactoring_to_variable`. | ||||
|         # | ||||
|         class LegacyVariables < ::Gitlab::Config::Entry::Node | ||||
|           include ::Gitlab::Config::Entry::Validatable | ||||
| 
 | ||||
|           ALLOWED_VALUE_DATA = %i[value description].freeze | ||||
| 
 | ||||
|           validations do | ||||
|             validates :config, variables: { allowed_value_data: ALLOWED_VALUE_DATA }, if: :use_value_data? | ||||
|             validates :config, variables: true, unless: :use_value_data? | ||||
|           end | ||||
| 
 | ||||
|           def value | ||||
|             @config.to_h { |key, value| [key.to_s, expand_value(value)[:value]] } | ||||
|           end | ||||
| 
 | ||||
|           def value_with_data | ||||
|             @config.to_h { |key, value| [key.to_s, expand_value(value)] } | ||||
|           end | ||||
| 
 | ||||
|           def use_value_data? | ||||
|             opt(:use_value_data) | ||||
|           end | ||||
| 
 | ||||
|           private | ||||
| 
 | ||||
|           def expand_value(value) | ||||
|             if value.is_a?(Hash) | ||||
|               { value: value[:value].to_s, description: value[:description] } | ||||
|             else | ||||
|               { value: value.to_s, description: nil } | ||||
|             end | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -48,9 +48,10 @@ module Gitlab | |||
|             description: 'Script that will be executed after each job.', | ||||
|             reserved: true | ||||
| 
 | ||||
|           # use_value_data will be removed with the FF ci_variables_refactoring_to_variable | ||||
|           entry :variables, Entry::Variables, | ||||
|             description: 'Environment variables that will be used.', | ||||
|             metadata: { use_value_data: true }, | ||||
|             metadata: { use_value_data: true, allowed_value_data: %i[value description] }, | ||||
|             reserved: true | ||||
| 
 | ||||
|           entry :stages, Entry::Stages, | ||||
|  |  | |||
|  | @ -0,0 +1,98 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Gitlab | ||||
|   module Ci | ||||
|     class Config | ||||
|       module Entry | ||||
|         ## | ||||
|         # Entry that represents a CI/CD variable. | ||||
|         # | ||||
|         class Variable < ::Gitlab::Config::Entry::Simplifiable | ||||
|           strategy :SimpleVariable, if: -> (config) { SimpleVariable.applies_to?(config) } | ||||
|           strategy :ComplexVariable, if: -> (config) { ComplexVariable.applies_to?(config) } | ||||
| 
 | ||||
|           class SimpleVariable < ::Gitlab::Config::Entry::Node | ||||
|             include ::Gitlab::Config::Entry::Validatable | ||||
| 
 | ||||
|             class << self | ||||
|               def applies_to?(config) | ||||
|                 Gitlab::Config::Entry::Validators::AlphanumericValidator.validate(config) | ||||
|               end | ||||
|             end | ||||
| 
 | ||||
|             validations do | ||||
|               validates :key, alphanumeric: true | ||||
|               validates :config, alphanumeric: true | ||||
|             end | ||||
| 
 | ||||
|             def value | ||||
|               @config.to_s | ||||
|             end | ||||
| 
 | ||||
|             def value_with_data | ||||
|               { value: @config.to_s, description: nil } | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           class ComplexVariable < ::Gitlab::Config::Entry::Node | ||||
|             include ::Gitlab::Config::Entry::Validatable | ||||
| 
 | ||||
|             class << self | ||||
|               def applies_to?(config) | ||||
|                 config.is_a?(Hash) | ||||
|               end | ||||
|             end | ||||
| 
 | ||||
|             validations do | ||||
|               validates :key, alphanumeric: true | ||||
|               validates :config_value, alphanumeric: true, allow_nil: false, if: :config_value_defined? | ||||
|               validates :config_description, alphanumeric: true, allow_nil: false, if: :config_description_defined? | ||||
| 
 | ||||
|               validate do | ||||
|                 allowed_value_data = Array(opt(:allowed_value_data)) | ||||
| 
 | ||||
|                 if allowed_value_data.any? | ||||
|                   extra_keys = config.keys - allowed_value_data | ||||
| 
 | ||||
|                   errors.add(:config, "uses invalid data keys: #{extra_keys.join(', ')}") if extra_keys.present? | ||||
|                 else | ||||
|                   errors.add(:config, "must be a string") | ||||
|                 end | ||||
|               end | ||||
|             end | ||||
| 
 | ||||
|             def value | ||||
|               config_value.to_s | ||||
|             end | ||||
| 
 | ||||
|             def value_with_data | ||||
|               { value: value, description: config_description } | ||||
|             end | ||||
| 
 | ||||
|             def config_value | ||||
|               @config[:value] | ||||
|             end | ||||
| 
 | ||||
|             def config_description | ||||
|               @config[:description] | ||||
|             end | ||||
| 
 | ||||
|             def config_value_defined? | ||||
|               config.key?(:value) | ||||
|             end | ||||
| 
 | ||||
|             def config_description_defined? | ||||
|               config.key?(:description) | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           class UnknownStrategy < ::Gitlab::Config::Entry::Node | ||||
|             def errors | ||||
|               ["variable definition must be either a string or a hash"] | ||||
|             end | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -5,43 +5,21 @@ module Gitlab | |||
|     class Config | ||||
|       module Entry | ||||
|         ## | ||||
|         # Entry that represents environment variables. | ||||
|         # Entry that represents CI/CD variables. | ||||
|         # CurrentVariables will be renamed to this class when removing the FF `ci_variables_refactoring_to_variable`. | ||||
|         # | ||||
|         class Variables < ::Gitlab::Config::Entry::Node | ||||
|           include ::Gitlab::Config::Entry::Validatable | ||||
| 
 | ||||
|           ALLOWED_VALUE_DATA = %i[value description].freeze | ||||
| 
 | ||||
|           validations do | ||||
|             validates :config, variables: { allowed_value_data: ALLOWED_VALUE_DATA }, if: :use_value_data? | ||||
|             validates :config, variables: true, unless: :use_value_data? | ||||
|           end | ||||
| 
 | ||||
|           def value | ||||
|             @config.to_h { |key, value| [key.to_s, expand_value(value)[:value]] } | ||||
|         class Variables | ||||
|           def self.new(...) | ||||
|             if YamlProcessor::FeatureFlags.enabled?(:ci_variables_refactoring_to_variable) | ||||
|               CurrentVariables.new(...) | ||||
|             else | ||||
|               LegacyVariables.new(...) | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           def self.default(**) | ||||
|             {} | ||||
|           end | ||||
| 
 | ||||
|           def value_with_data | ||||
|             @config.to_h { |key, value| [key.to_s, expand_value(value)] } | ||||
|           end | ||||
| 
 | ||||
|           def use_value_data? | ||||
|             opt(:use_value_data) | ||||
|           end | ||||
| 
 | ||||
|           private | ||||
| 
 | ||||
|           def expand_value(value) | ||||
|             if value.is_a?(Hash) | ||||
|               { value: value[:value].to_s, description: value[:description] } | ||||
|             else | ||||
|               { value: value.to_s, description: nil } | ||||
|             end | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  |  | |||
|  | @ -25,9 +25,9 @@ module Gitlab | |||
|               entry_class_name = entry_class.name.demodulize.underscore | ||||
| 
 | ||||
|               factory = ::Gitlab::Config::Entry::Factory.new(entry_class) | ||||
|                 .value(config || {}) | ||||
|                 .value(config.nil? ? {} : config) | ||||
|                 .with(key: name, parent: self, description: "#{name} #{entry_class_name} definition") # rubocop:disable CodeReuse/ActiveRecord | ||||
|                 .metadata(name: name) | ||||
|                 .metadata(composable_metadata.merge(name: name)) | ||||
| 
 | ||||
|               @entries[name] = factory.create! | ||||
|             end | ||||
|  | @ -38,9 +38,15 @@ module Gitlab | |||
|           end | ||||
|         end | ||||
| 
 | ||||
|         private | ||||
| 
 | ||||
|         def composable_class(name, config) | ||||
|           opt(:composable_class) | ||||
|         end | ||||
| 
 | ||||
|         def composable_metadata | ||||
|           {} | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  |  | |||
|  | @ -304,6 +304,7 @@ module Gitlab | |||
|           end | ||||
|         end | ||||
| 
 | ||||
|         # This will be removed with the FF `ci_variables_refactoring_to_variable`. | ||||
|         class VariablesValidator < ActiveModel::EachValidator | ||||
|           include LegacyValidationHelpers | ||||
| 
 | ||||
|  | @ -336,6 +337,18 @@ module Gitlab | |||
|           end | ||||
|         end | ||||
| 
 | ||||
|         class AlphanumericValidator < ActiveModel::EachValidator | ||||
|           def self.validate(value) | ||||
|             value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer) | ||||
|           end | ||||
| 
 | ||||
|           def validate_each(record, attribute, value) | ||||
|             unless self.class.validate(value) | ||||
|               record.errors.add(attribute, 'must be an alphanumeric string') | ||||
|             end | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         class ExpressionValidator < ActiveModel::EachValidator | ||||
|           def validate_each(record, attribute, value) | ||||
|             unless value.is_a?(String) && ::Gitlab::Ci::Pipeline::Expression::Statement.new(value).valid? | ||||
|  |  | |||
|  | @ -0,0 +1,101 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Gitlab | ||||
|   module Metrics | ||||
|     module GlobalSearchSlis | ||||
|       class << self | ||||
|         # The following targets are the 99.95th percentile of code searches | ||||
|         # gathered on 24-08-2022 | ||||
|         # from https://log.gprd.gitlab.net/goto/0c89cd80-23af-11ed-8656-f5f2137823ba (internal only) | ||||
|         BASIC_CONTENT_TARGET_S = 7.031 | ||||
|         BASIC_CODE_TARGET_S = 21.903 | ||||
|         ADVANCED_CONTENT_TARGET_S = 4.865 | ||||
|         ADVANCED_CODE_TARGET_S = 13.546 | ||||
| 
 | ||||
|         def initialize_slis! | ||||
|           return unless Feature.enabled?(:global_search_custom_slis) | ||||
| 
 | ||||
|           Gitlab::Metrics::Sli::Apdex.initialize_sli(:global_search, possible_labels) | ||||
|         end | ||||
| 
 | ||||
|         def record_apdex(elapsed:, search_type:, search_level:, search_scope:) | ||||
|           return unless Feature.enabled?(:global_search_custom_slis) | ||||
| 
 | ||||
|           Gitlab::Metrics::Sli::Apdex[:global_search].increment( | ||||
|             labels: labels(search_type: search_type, search_level: search_level, search_scope: search_scope), | ||||
|             success: elapsed < duration_target(search_type, search_scope) | ||||
|           ) | ||||
|         end | ||||
| 
 | ||||
|         private | ||||
| 
 | ||||
|         def duration_target(search_type, search_scope) | ||||
|           if search_type == 'basic' && content_search?(search_scope) | ||||
|             BASIC_CONTENT_TARGET_S | ||||
|           elsif search_type == 'basic' && code_search?(search_scope) | ||||
|             BASIC_CODE_TARGET_S | ||||
|           elsif search_type == 'advanced' && content_search?(search_scope) | ||||
|             ADVANCED_CONTENT_TARGET_S | ||||
|           elsif search_type == 'advanced' && code_search?(search_scope) | ||||
|             ADVANCED_CODE_TARGET_S | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         def search_types | ||||
|           %w[basic advanced] | ||||
|         end | ||||
| 
 | ||||
|         def search_levels | ||||
|           %w[project group global] | ||||
|         end | ||||
| 
 | ||||
|         def search_scopes | ||||
|           Gitlab::Search::AbuseDetection::ALLOWED_SCOPES | ||||
|         end | ||||
| 
 | ||||
|         def endpoint_ids | ||||
|           ['SearchController#show', 'GET /api/:version/search', 'GET /api/:version/projects/:id/(-/)search', | ||||
|            'GET /api/:version/groups/:id/(-/)search'] | ||||
|         end | ||||
| 
 | ||||
|         def possible_labels | ||||
|           search_types.flat_map do |search_type| | ||||
|             search_levels.flat_map do |search_level| | ||||
|               search_scopes.flat_map do |search_scope| | ||||
|                 endpoint_ids.flat_map do |endpoint_id| | ||||
|                   { | ||||
|                     search_type: search_type, | ||||
|                     search_level: search_level, | ||||
|                     search_scope: search_scope, | ||||
|                     endpoint_id: endpoint_id | ||||
|                   } | ||||
|                 end | ||||
|               end | ||||
|             end | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         def labels(search_type:, search_level:, search_scope:) | ||||
|           { | ||||
|             search_type: search_type, | ||||
|             search_level: search_level, | ||||
|             search_scope: search_scope, | ||||
|             endpoint_id: endpoint_id | ||||
|           } | ||||
|         end | ||||
| 
 | ||||
|         def endpoint_id | ||||
|           ::Gitlab::ApplicationContext.current_context_attribute(:caller_id) | ||||
|         end | ||||
| 
 | ||||
|         def code_search?(search_scope) | ||||
|           search_scope == 'blobs' | ||||
|         end | ||||
| 
 | ||||
|         def content_search?(search_scope) | ||||
|           !code_search?(search_scope) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -6392,6 +6392,9 @@ msgstr "" | |||
| msgid "Blocking" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Blocking epics" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Blocking issues" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -16806,6 +16809,9 @@ msgstr "" | |||
| msgid "ForkProject|Select a namespace" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "ForkProject|Something went wrong while loading data. Please refresh the page to try again." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "ForkProject|The project can be accessed by any logged in user." | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -33811,6 +33817,12 @@ msgstr "" | |||
| msgid "Runners|An error has occurred fetching instructions" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|An upgrade is available for this runner" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|An upgrade is recommended for this runner" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|Architecture" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -33859,6 +33871,9 @@ msgstr "" | |||
| msgid "Runners|Copy registration token" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|Created %{timeAgo}" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|Delete %d runner" | ||||
| msgid_plural "Runners|Delete %d runners" | ||||
| msgstr[0] "" | ||||
|  | @ -33930,6 +33945,9 @@ msgstr "" | |||
| msgid "Runners|Last contact" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|Last contact: %{timeAgo}" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|Locked to this project" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -34213,6 +34231,9 @@ msgstr "" | |||
| msgid "Runners|Version" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|Version %{version}" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Runners|View installation instructions" | ||||
| msgstr "" | ||||
| 
 | ||||
|  |  | |||
|  | @ -6,19 +6,40 @@ module QA | |||
|       module Fork | ||||
|         class New < Page::Base | ||||
|           view 'app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue' do | ||||
|             element :fork_namespace_dropdown | ||||
|             element :fork_project_button | ||||
|             element :fork_privacy_button | ||||
|           end | ||||
| 
 | ||||
|           view 'app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue' do | ||||
|             element :select_namespace_dropdown | ||||
|             element :select_namespace_dropdown_item | ||||
|             element :select_namespace_dropdown_search_field | ||||
|             element :select_namespace_dropdown_item | ||||
|           end | ||||
| 
 | ||||
|           def fork_project(namespace = Runtime::Namespace.path) | ||||
|             select_element(:fork_namespace_dropdown, namespace) | ||||
|             choose_namespace(namespace) | ||||
|             click_element(:fork_privacy_button, privacy_level: 'public') | ||||
|             click_element(:fork_project_button) | ||||
|           end | ||||
| 
 | ||||
|           def fork_namespace_dropdown_values | ||||
|             find_element(:fork_namespace_dropdown).all(:option).map { |option| option.text.tr("\n", '').strip } | ||||
|           def get_list_of_namespaces | ||||
|             click_element(:select_namespace_dropdown) | ||||
|             wait_until(reload: false) do | ||||
|               has_element?(:select_namespace_dropdown_item) | ||||
|             end | ||||
|             all_elements(:select_namespace_dropdown_item, minimum: 1).map(&:text) | ||||
|           end | ||||
| 
 | ||||
|           def choose_namespace(namespace) | ||||
|             retry_on_exception do | ||||
|               click_element(:select_namespace_dropdown) | ||||
|               fill_element(:select_namespace_dropdown_search_field, namespace) | ||||
|               wait_until(reload: false) do | ||||
|                 has_element?(:select_namespace_dropdown_item, text: namespace) | ||||
|               end | ||||
|               click_button(namespace) | ||||
|             end | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|  |  | |||
|  | @ -36,7 +36,7 @@ module QA | |||
|       def fabricate! | ||||
|         populate(:upstream, :user) | ||||
| 
 | ||||
|         namespace_path ||= user.name | ||||
|         namespace_path ||= user.username | ||||
| 
 | ||||
|         # Sign out as admin and sign is as the fork user | ||||
|         Flow::Login.sign_in(as: user) | ||||
|  |  | |||
|  | @ -270,6 +270,17 @@ RSpec.describe SearchController do | |||
|           get(:show, params: { search: 'foo@bar.com', scope: 'users' }) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       it 'increments the custom search sli apdex' do | ||||
|         expect(Gitlab::Metrics::GlobalSearchSlis).to receive(:record_apdex).with( | ||||
|           elapsed: a_kind_of(Numeric), | ||||
|           search_scope: 'issues', | ||||
|           search_type: 'basic', | ||||
|           search_level: 'global' | ||||
|         ) | ||||
| 
 | ||||
|         get :show, params: { scope: 'issues', search: 'hello world' } | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     describe 'GET #count', :aggregate_failures do | ||||
|  |  | |||
|  | @ -81,7 +81,7 @@ RSpec.describe "Admin Runners" do | |||
|         visit admin_runners_path | ||||
| 
 | ||||
|         within_runner_row(runner.id) do | ||||
|           expect(find("[data-label='Jobs']")).to have_content '2' | ||||
|           expect(find("[data-testid='job-count']")).to have_content '2' | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|  |  | |||
|  | @ -126,7 +126,10 @@ RSpec.describe 'Project fork' do | |||
|     let(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user } | ||||
| 
 | ||||
|     def submit_form | ||||
|       select(group.name) | ||||
|       find('[data-testid="select_namespace_dropdown"]').click | ||||
|       find('[data-testid="select_namespace_dropdown_search_field"]').fill_in(with: group.name) | ||||
|       click_button group.name | ||||
| 
 | ||||
|       click_button 'Fork project' | ||||
|     end | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,13 +10,17 @@ import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; | |||
| import { truncate } from '~/lib/utils/text_utility'; | ||||
| import { | ||||
|   mockIssue, | ||||
|   mockEpic, | ||||
|   mockBlockingIssue1, | ||||
|   mockBlockingIssue2, | ||||
|   mockBlockingEpic1, | ||||
|   mockBlockingIssuablesResponse1, | ||||
|   mockBlockingIssuablesResponse2, | ||||
|   mockBlockingIssuablesResponse3, | ||||
|   mockBlockedIssue1, | ||||
|   mockBlockedIssue2, | ||||
|   mockBlockedEpic1, | ||||
|   mockBlockingEpicIssuablesResponse1, | ||||
| } from '../mock_data'; | ||||
| 
 | ||||
| describe('BoardBlockedIcon', () => { | ||||
|  | @ -51,9 +55,11 @@ describe('BoardBlockedIcon', () => { | |||
|   const createWrapperWithApollo = ({ | ||||
|     item = mockBlockedIssue1, | ||||
|     blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1), | ||||
|     issuableItem = mockIssue, | ||||
|     issuableType = issuableTypes.issue, | ||||
|   } = {}) => { | ||||
|     mockApollo = createMockApollo([ | ||||
|       [blockingIssuablesQueries[issuableTypes.issue].query, blockingIssuablesSpy], | ||||
|       [blockingIssuablesQueries[issuableType].query, blockingIssuablesSpy], | ||||
|     ]); | ||||
| 
 | ||||
|     Vue.use(VueApollo); | ||||
|  | @ -62,27 +68,34 @@ describe('BoardBlockedIcon', () => { | |||
|         apolloProvider: mockApollo, | ||||
|         propsData: { | ||||
|           item: { | ||||
|             ...mockIssue, | ||||
|             ...issuableItem, | ||||
|             ...item, | ||||
|           }, | ||||
|           uniqueId: 'uniqueId', | ||||
|           issuableType: issuableTypes.issue, | ||||
|           issuableType, | ||||
|         }, | ||||
|         attachTo: document.body, | ||||
|       }), | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   const createWrapper = ({ item = {}, queries = {}, data = {}, loading = false } = {}) => { | ||||
|   const createWrapper = ({ | ||||
|     item = {}, | ||||
|     queries = {}, | ||||
|     data = {}, | ||||
|     loading = false, | ||||
|     mockIssuable = mockIssue, | ||||
|     issuableType = issuableTypes.issue, | ||||
|   } = {}) => { | ||||
|     wrapper = extendedWrapper( | ||||
|       shallowMount(BoardBlockedIcon, { | ||||
|         propsData: { | ||||
|           item: { | ||||
|             ...mockIssue, | ||||
|             ...mockIssuable, | ||||
|             ...item, | ||||
|           }, | ||||
|           uniqueId: 'uniqueid', | ||||
|           issuableType: issuableTypes.issue, | ||||
|           issuableType, | ||||
|         }, | ||||
|         data() { | ||||
|           return { | ||||
|  | @ -105,11 +118,24 @@ describe('BoardBlockedIcon', () => { | |||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   it('should render blocked icon', () => { | ||||
|     createWrapper(); | ||||
|   it.each` | ||||
|     mockIssuable | issuableType           | expectedIcon | ||||
|     ${mockIssue} | ${issuableTypes.issue} | ${'issue-block'} | ||||
|     ${mockEpic}  | ${issuableTypes.epic}  | ${'entity-blocked'} | ||||
|   `(
 | ||||
|     'should render blocked icon for $issuableType', | ||||
|     ({ mockIssuable, issuableType, expectedIcon }) => { | ||||
|       createWrapper({ | ||||
|         mockIssuable, | ||||
|         issuableType, | ||||
|       }); | ||||
| 
 | ||||
|     expect(findGlIcon().exists()).toBe(true); | ||||
|   }); | ||||
|       expect(findGlIcon().exists()).toBe(true); | ||||
|       const icon = findGlIcon(); | ||||
|       expect(icon.exists()).toBe(true); | ||||
|       expect(icon.props('name')).toBe(expectedIcon); | ||||
|     }, | ||||
|   ); | ||||
| 
 | ||||
|   it('should display a loading spinner while loading', () => { | ||||
|     createWrapper({ loading: true }); | ||||
|  | @ -124,17 +150,29 @@ describe('BoardBlockedIcon', () => { | |||
|   }); | ||||
| 
 | ||||
|   describe('on mouseenter on blocked icon', () => { | ||||
|     it('should query for blocking issuables and render the result', async () => { | ||||
|       createWrapperWithApollo(); | ||||
|     it.each` | ||||
|       item                 | issuableType           | mockBlockingIssuable  | issuableItem | blockingIssuablesSpy | ||||
|       ${mockBlockedIssue1} | ${issuableTypes.issue} | ${mockBlockingIssue1} | ${mockIssue} | ${jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1)} | ||||
|       ${mockBlockedEpic1}  | ${issuableTypes.epic}  | ${mockBlockingEpic1}  | ${mockEpic}  | ${jest.fn().mockResolvedValue(mockBlockingEpicIssuablesResponse1)} | ||||
|     `(
 | ||||
|       'should query for blocking issuables and render the result for $issuableType', | ||||
|       async ({ item, issuableType, issuableItem, mockBlockingIssuable, blockingIssuablesSpy }) => { | ||||
|         createWrapperWithApollo({ | ||||
|           item, | ||||
|           issuableType, | ||||
|           issuableItem, | ||||
|           blockingIssuablesSpy, | ||||
|         }); | ||||
| 
 | ||||
|       expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title); | ||||
|         expect(findGlPopover().text()).not.toContain(mockBlockingIssuable.title); | ||||
| 
 | ||||
|       await mouseenter(); | ||||
|         await mouseenter(); | ||||
| 
 | ||||
|       expect(findGlPopover().exists()).toBe(true); | ||||
|       expect(findIssuableTitle().text()).toContain(mockBlockingIssue1.title); | ||||
|       expect(wrapper.vm.skip).toBe(true); | ||||
|     }); | ||||
|         expect(findGlPopover().exists()).toBe(true); | ||||
|         expect(findIssuableTitle().text()).toContain(mockBlockingIssuable.title); | ||||
|         expect(wrapper.vm.skip).toBe(true); | ||||
|       }, | ||||
|     ); | ||||
| 
 | ||||
|     it('should emit "blocking-issuables-error" event on query error', async () => { | ||||
|       const mockError = new Error('mayday'); | ||||
|  |  | |||
|  | @ -266,6 +266,7 @@ export const rawIssue = { | |||
| }; | ||||
| 
 | ||||
| export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test'; | ||||
| export const mockEpicFullPath = 'gitlab-org/test-subgroup'; | ||||
| 
 | ||||
| export const mockIssue = { | ||||
|   id: 'gid://gitlab/Issue/436', | ||||
|  | @ -291,6 +292,47 @@ export const mockIssue = { | |||
|   type: 'ISSUE', | ||||
| }; | ||||
| 
 | ||||
| export const mockEpic = { | ||||
|   id: 'gid://gitlab/Epic/26', | ||||
|   iid: '1', | ||||
|   group: { | ||||
|     id: 'gid://gitlab/Group/33', | ||||
|     fullPath: 'twitter', | ||||
|     __typename: 'Group', | ||||
|   }, | ||||
|   title: 'Eum animi debitis occaecati ad non odio repellat voluptatem similique.', | ||||
|   state: 'opened', | ||||
|   reference: '&1', | ||||
|   referencePath: `${mockEpicFullPath}&1`, | ||||
|   webPath: `/groups/${mockEpicFullPath}/-/epics/1`, | ||||
|   webUrl: `${mockEpicFullPath}/-/epics/1`, | ||||
|   createdAt: '2022-01-18T05:15:15Z', | ||||
|   closedAt: null, | ||||
|   __typename: 'Epic', | ||||
|   relativePosition: null, | ||||
|   confidential: false, | ||||
|   subscribed: true, | ||||
|   blocked: true, | ||||
|   blockedByCount: 1, | ||||
|   labels: { | ||||
|     nodes: [], | ||||
|     __typename: 'LabelConnection', | ||||
|   }, | ||||
|   hasIssues: true, | ||||
|   descendantCounts: { | ||||
|     closedEpics: 0, | ||||
|     closedIssues: 0, | ||||
|     openedEpics: 0, | ||||
|     openedIssues: 2, | ||||
|     __typename: 'EpicDescendantCount', | ||||
|   }, | ||||
|   descendantWeightSum: { | ||||
|     closedIssues: 0, | ||||
|     openedIssues: 0, | ||||
|     __typename: 'EpicDescendantWeights', | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| export const mockActiveIssue = { | ||||
|   ...mockIssue, | ||||
|   id: 'gid://gitlab/Issue/436', | ||||
|  | @ -523,6 +565,15 @@ export const mockBlockingIssue1 = { | |||
|   __typename: 'Issue', | ||||
| }; | ||||
| 
 | ||||
| export const mockBlockingEpic1 = { | ||||
|   id: 'gid://gitlab/Epic/29', | ||||
|   iid: '4', | ||||
|   title: 'Sint nihil exercitationem aspernatur unde molestiae rem accusantium.', | ||||
|   reference: 'twitter&4', | ||||
|   webUrl: 'http://gdk.test:3000/groups/gitlab-org/test-subgroup/-/epics/4', | ||||
|   __typename: 'Epic', | ||||
| }; | ||||
| 
 | ||||
| export const mockBlockingIssue2 = { | ||||
|   id: 'gid://gitlab/Issue/524', | ||||
|   iid: '5', | ||||
|  | @ -564,6 +615,23 @@ export const mockBlockingIssuablesResponse1 = { | |||
|   }, | ||||
| }; | ||||
| 
 | ||||
| export const mockBlockingEpicIssuablesResponse1 = { | ||||
|   data: { | ||||
|     group: { | ||||
|       __typename: 'Group', | ||||
|       id: 'gid://gitlab/Group/33', | ||||
|       issuable: { | ||||
|         __typename: 'Epic', | ||||
|         id: 'gid://gitlab/Epic/26', | ||||
|         blockingIssuables: { | ||||
|           __typename: 'EpicConnection', | ||||
|           nodes: [mockBlockingEpic1], | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| export const mockBlockingIssuablesResponse2 = { | ||||
|   data: { | ||||
|     issuable: { | ||||
|  | @ -601,6 +669,12 @@ export const mockBlockedIssue2 = { | |||
|   webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0', | ||||
| }; | ||||
| 
 | ||||
| export const mockBlockedEpic1 = { | ||||
|   id: '26', | ||||
|   blockedByCount: 1, | ||||
|   webUrl: 'http://gdk.test:3000/gitlab-org/test-subgroup/-/epics/1', | ||||
| }; | ||||
| 
 | ||||
| export const mockMoveIssueParams = { | ||||
|   itemId: 1, | ||||
|   fromListId: 'gid://gitlab/List/1', | ||||
|  |  | |||
|  | @ -4,11 +4,14 @@ import { mount, shallowMount } from '@vue/test-utils'; | |||
| import axios from 'axios'; | ||||
| import AxiosMockAdapter from 'axios-mock-adapter'; | ||||
| import { kebabCase } from 'lodash'; | ||||
| import { nextTick } from 'vue'; | ||||
| import Vue, { nextTick } from 'vue'; | ||||
| import VueApollo from 'vue-apollo'; | ||||
| import createFlash from '~/flash'; | ||||
| import httpStatus from '~/lib/utils/http_status'; | ||||
| import * as urlUtility from '~/lib/utils/url_utility'; | ||||
| import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue'; | ||||
| import createMockApollo from 'helpers/mock_apollo_helper'; | ||||
| import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql'; | ||||
| import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue'; | ||||
| 
 | ||||
| jest.mock('~/flash'); | ||||
| jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); | ||||
|  | @ -16,6 +19,7 @@ jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); | |||
| describe('ForkForm component', () => { | ||||
|   let wrapper; | ||||
|   let axiosMock; | ||||
|   let mockQueryResponse; | ||||
| 
 | ||||
|   const PROJECT_VISIBILITY_TYPE = { | ||||
|     private: | ||||
|  | @ -24,26 +28,11 @@ describe('ForkForm component', () => { | |||
|     public: 'Public The project can be accessed without any authentication.', | ||||
|   }; | ||||
| 
 | ||||
|   const GON_GITLAB_URL = 'https://gitlab.com'; | ||||
|   const GON_API_VERSION = 'v7'; | ||||
| 
 | ||||
|   const MOCK_NAMESPACES_RESPONSE = [ | ||||
|     { | ||||
|       name: 'one', | ||||
|       full_name: 'one-group/one', | ||||
|       id: 1, | ||||
|     }, | ||||
|     { | ||||
|       name: 'two', | ||||
|       full_name: 'two-group/two', | ||||
|       id: 2, | ||||
|     }, | ||||
|   ]; | ||||
| 
 | ||||
|   const DEFAULT_PROVIDE = { | ||||
|     newGroupPath: 'some/groups/path', | ||||
|     visibilityHelpPath: 'some/visibility/help/path', | ||||
|     endpoint: '/some/project-full-path/-/forks/new.json', | ||||
|     projectFullPath: '/some/project-full-path', | ||||
|     projectId: '10', | ||||
|     projectName: 'Project Name', | ||||
|  | @ -53,12 +42,44 @@ describe('ForkForm component', () => { | |||
|     restrictedVisibilityLevels: [], | ||||
|   }; | ||||
| 
 | ||||
|   const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => { | ||||
|     axiosMock.onGet(DEFAULT_PROVIDE.endpoint).replyOnce(statusCode, data); | ||||
|   }; | ||||
|   Vue.use(VueApollo); | ||||
| 
 | ||||
|   const createComponentFactory = (mountFn) => (provide = {}, data = {}) => { | ||||
|     const queryResponse = { | ||||
|       project: { | ||||
|         id: 'gid://gitlab/Project/1', | ||||
|         forkTargets: { | ||||
|           nodes: [ | ||||
|             { | ||||
|               id: 'gid://gitlab/Group/21', | ||||
|               fullPath: 'flightjs', | ||||
|               name: 'Flight JS', | ||||
|               visibility: 'public', | ||||
|             }, | ||||
|             { | ||||
|               id: 'gid://gitlab/Namespace/4', | ||||
|               fullPath: 'root', | ||||
|               name: 'Administrator', | ||||
|               visibility: 'public', | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|       }, | ||||
|     }; | ||||
| 
 | ||||
|     mockQueryResponse = jest.fn().mockResolvedValue({ data: queryResponse }); | ||||
|     const requestHandlers = [[searchQuery, mockQueryResponse]]; | ||||
|     const apolloProvider = createMockApollo(requestHandlers); | ||||
| 
 | ||||
|     apolloProvider.clients.defaultClient.cache.writeQuery({ | ||||
|       query: searchQuery, | ||||
|       data: { | ||||
|         ...queryResponse, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     wrapper = mountFn(ForkForm, { | ||||
|       apolloProvider, | ||||
|       provide: { | ||||
|         ...DEFAULT_PROVIDE, | ||||
|         ...provide, | ||||
|  | @ -83,7 +104,6 @@ describe('ForkForm component', () => { | |||
|   beforeEach(() => { | ||||
|     axiosMock = new AxiosMockAdapter(axios); | ||||
|     window.gon = { | ||||
|       gitlab_url: GON_GITLAB_URL, | ||||
|       api_version: GON_API_VERSION, | ||||
|     }; | ||||
|   }); | ||||
|  | @ -93,12 +113,11 @@ describe('ForkForm component', () => { | |||
|     axiosMock.restore(); | ||||
|   }); | ||||
| 
 | ||||
|   const findFormSelectOptions = () => wrapper.find('select[name="namespace"]').findAll('option'); | ||||
|   const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]'); | ||||
|   const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]'); | ||||
|   const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]'); | ||||
|   const findForkNameInput = () => wrapper.find('[data-testid="fork-name-input"]'); | ||||
|   const findForkUrlInput = () => wrapper.find('[data-testid="fork-url-input"]'); | ||||
|   const findForkUrlInput = () => wrapper.findComponent(ProjectNamespace); | ||||
|   const findForkSlugInput = () => wrapper.find('[data-testid="fork-slug-input"]'); | ||||
|   const findForkDescriptionTextarea = () => | ||||
|     wrapper.find('[data-testid="fork-description-textarea"]'); | ||||
|  | @ -106,7 +125,6 @@ describe('ForkForm component', () => { | |||
|     wrapper.find('[data-testid="fork-visibility-radio-group"]'); | ||||
| 
 | ||||
|   it('will go to projectFullPath when click cancel button', () => { | ||||
|     mockGetRequest(); | ||||
|     createComponent(); | ||||
| 
 | ||||
|     const { projectFullPath } = DEFAULT_PROVIDE; | ||||
|  | @ -115,8 +133,13 @@ describe('ForkForm component', () => { | |||
|     expect(cancelButton.attributes('href')).toBe(projectFullPath); | ||||
|   }); | ||||
| 
 | ||||
|   const selectedMockNamespace = { name: 'two', full_name: 'two-group/two', id: 2 }; | ||||
| 
 | ||||
|   const fillForm = () => { | ||||
|     findForkUrlInput().vm.$emit('select', selectedMockNamespace); | ||||
|   }; | ||||
| 
 | ||||
|   it('has input with csrf token', () => { | ||||
|     mockGetRequest(); | ||||
|     createComponent(); | ||||
| 
 | ||||
|     expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe( | ||||
|  | @ -125,7 +148,6 @@ describe('ForkForm component', () => { | |||
|   }); | ||||
| 
 | ||||
|   it('pre-populate form from project props', () => { | ||||
|     mockGetRequest(); | ||||
|     createComponent(); | ||||
| 
 | ||||
|     expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROVIDE.projectName); | ||||
|  | @ -135,75 +157,19 @@ describe('ForkForm component', () => { | |||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it('sets project URL prepend text with gon.gitlab_url', () => { | ||||
|     mockGetRequest(); | ||||
|     createComponent(); | ||||
| 
 | ||||
|     expect(wrapper.find(GlFormInputGroup).text()).toContain(`${GON_GITLAB_URL}/`); | ||||
|   }); | ||||
| 
 | ||||
|   it('will have required attribute for required fields', () => { | ||||
|     mockGetRequest(); | ||||
|     createComponent(); | ||||
| 
 | ||||
|     expect(findForkNameInput().attributes('required')).not.toBeUndefined(); | ||||
|     expect(findForkUrlInput().attributes('required')).not.toBeUndefined(); | ||||
|     expect(findForkSlugInput().attributes('required')).not.toBeUndefined(); | ||||
|     expect(findVisibilityRadioGroup().attributes('required')).not.toBeUndefined(); | ||||
|     expect(findForkDescriptionTextarea().attributes('required')).toBeUndefined(); | ||||
|   }); | ||||
| 
 | ||||
|   describe('forks namespaces', () => { | ||||
|     beforeEach(() => { | ||||
|       mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE }); | ||||
|       createFullComponent(); | ||||
|     }); | ||||
| 
 | ||||
|     it('make GET request from endpoint', async () => { | ||||
|       await axios.waitForAll(); | ||||
| 
 | ||||
|       expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROVIDE.endpoint); | ||||
|     }); | ||||
| 
 | ||||
|     it('generate default option', async () => { | ||||
|       await axios.waitForAll(); | ||||
| 
 | ||||
|       const optionsArray = findForkUrlInput().findAll('option'); | ||||
| 
 | ||||
|       expect(optionsArray.at(0).text()).toBe('Select a namespace'); | ||||
|     }); | ||||
| 
 | ||||
|     it('populate project url namespace options', async () => { | ||||
|       await axios.waitForAll(); | ||||
| 
 | ||||
|       const optionsArray = findForkUrlInput().findAll('option'); | ||||
| 
 | ||||
|       expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1); | ||||
|       expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].full_name); | ||||
|       expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].full_name); | ||||
|     }); | ||||
| 
 | ||||
|     it('set namespaces in alphabetical order', async () => { | ||||
|       const namespace = { | ||||
|         name: 'three', | ||||
|         full_name: 'aaa/three', | ||||
|         id: 3, | ||||
|       }; | ||||
|       mockGetRequest({ | ||||
|         namespaces: [...MOCK_NAMESPACES_RESPONSE, namespace], | ||||
|       }); | ||||
|       createComponent(); | ||||
|       await axios.waitForAll(); | ||||
| 
 | ||||
|       expect(wrapper.vm.namespaces).toEqual([namespace, ...MOCK_NAMESPACES_RESPONSE]); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('project slug', () => { | ||||
|     const projectPath = 'some other project slug'; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       mockGetRequest(); | ||||
|       createComponent({ | ||||
|         projectPath, | ||||
|       }); | ||||
|  | @ -232,7 +198,6 @@ describe('ForkForm component', () => { | |||
| 
 | ||||
|   describe('visibility level', () => { | ||||
|     it('displays the correct description', () => { | ||||
|       mockGetRequest(); | ||||
|       createComponent(); | ||||
| 
 | ||||
|       const formRadios = wrapper.findAll(GlFormRadio); | ||||
|  | @ -243,7 +208,6 @@ describe('ForkForm component', () => { | |||
|     }); | ||||
| 
 | ||||
|     it('displays all 3 visibility levels', () => { | ||||
|       mockGetRequest(); | ||||
|       createComponent(); | ||||
| 
 | ||||
|       expect(wrapper.findAll(GlFormRadio)).toHaveLength(3); | ||||
|  | @ -262,16 +226,12 @@ describe('ForkForm component', () => { | |||
|         }, | ||||
|       ]; | ||||
| 
 | ||||
|       beforeEach(() => { | ||||
|         mockGetRequest(); | ||||
|       }); | ||||
| 
 | ||||
|       it('resets the visibility to default "private"', async () => { | ||||
|         createFullComponent({ projectVisibility: 'public' }, { namespaces }); | ||||
| 
 | ||||
|         expect(wrapper.vm.form.fields.visibility.value).toBe('public'); | ||||
|         await findFormSelectOptions().at(1).setSelected(); | ||||
| 
 | ||||
|         fillForm(); | ||||
|         await nextTick(); | ||||
| 
 | ||||
|         expect(getByRole(wrapper.element, 'radio', { name: /private/i }).checked).toBe(true); | ||||
|  | @ -280,8 +240,7 @@ describe('ForkForm component', () => { | |||
|       it('sets the visibility to be null when restrictedVisibilityLevels is set', async () => { | ||||
|         createFullComponent({ restrictedVisibilityLevels: [10] }, { namespaces }); | ||||
| 
 | ||||
|         await findFormSelectOptions().at(1).setSelected(); | ||||
| 
 | ||||
|         fillForm(); | ||||
|         await nextTick(); | ||||
| 
 | ||||
|         const container = getByRole(wrapper.element, 'radiogroup', { name: /visibility/i }); | ||||
|  | @ -315,8 +274,7 @@ describe('ForkForm component', () => { | |||
|       ${'public'}   | ${[0, 20]} | ||||
|       ${'public'}   | ${[10, 20]} | ||||
|       ${'public'}   | ${[0, 10, 20]} | ||||
|     `('checks the correct radio button', async ({ project, restrictedVisibilityLevels }) => {
 | ||||
|       mockGetRequest(); | ||||
|     `('checks the correct radio button', ({ project, restrictedVisibilityLevels }) => {
 | ||||
|       createFullComponent({ | ||||
|         projectVisibility: project, | ||||
|         restrictedVisibilityLevels, | ||||
|  | @ -357,7 +315,7 @@ describe('ForkForm component', () => { | |||
|       ${'public'}   | ${'public'}   | ${undefined}      | ${'true'}          | ${'true'}        | ${[0, 10, 20]} | ||||
|     `(
 | ||||
|       'sets appropriate radio button disabled state', | ||||
|       async ({ | ||||
|       ({ | ||||
|         project, | ||||
|         namespace, | ||||
|         privateIsDisabled, | ||||
|  | @ -365,7 +323,6 @@ describe('ForkForm component', () => { | |||
|         publicIsDisabled, | ||||
|         restrictedVisibilityLevels, | ||||
|       }) => { | ||||
|         mockGetRequest(); | ||||
|         createComponent( | ||||
|           { | ||||
|             projectVisibility: project, | ||||
|  | @ -387,11 +344,9 @@ describe('ForkForm component', () => { | |||
|     const setupComponent = (fields = {}) => { | ||||
|       jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); | ||||
| 
 | ||||
|       mockGetRequest(); | ||||
|       createFullComponent( | ||||
|         {}, | ||||
|         { | ||||
|           namespaces: MOCK_NAMESPACES_RESPONSE, | ||||
|           form: { | ||||
|             state: true, | ||||
|             ...fields, | ||||
|  | @ -400,17 +355,13 @@ describe('ForkForm component', () => { | |||
|       ); | ||||
|     }; | ||||
| 
 | ||||
|     const selectedMockNamespaceIndex = 1; | ||||
|     const namespaceId = MOCK_NAMESPACES_RESPONSE[selectedMockNamespaceIndex].id; | ||||
| 
 | ||||
|     const fillForm = async () => { | ||||
|       const namespaceOptions = findForkUrlInput().findAll('option'); | ||||
| 
 | ||||
|       await namespaceOptions.at(selectedMockNamespaceIndex + 1).setSelected(); | ||||
|     }; | ||||
|     beforeEach(() => { | ||||
|       setupComponent(); | ||||
|     }); | ||||
| 
 | ||||
|     const submitForm = async () => { | ||||
|       await fillForm(); | ||||
|       fillForm(); | ||||
|       await nextTick(); | ||||
|       const form = wrapper.find(GlForm); | ||||
| 
 | ||||
|       await form.trigger('submit'); | ||||
|  | @ -418,7 +369,7 @@ describe('ForkForm component', () => { | |||
|     }; | ||||
| 
 | ||||
|     describe('with invalid form', () => { | ||||
|       it('does not make POST request', async () => { | ||||
|       it('does not make POST request', () => { | ||||
|         jest.spyOn(axios, 'post'); | ||||
| 
 | ||||
|         setupComponent(); | ||||
|  | @ -471,7 +422,7 @@ describe('ForkForm component', () => { | |||
|           description: projectDescription, | ||||
|           id: projectId, | ||||
|           name: projectName, | ||||
|           namespace_id: namespaceId, | ||||
|           namespace_id: selectedMockNamespace.id, | ||||
|           path: projectPath, | ||||
|           visibility: projectVisibility, | ||||
|         }; | ||||
|  |  | |||
|  | @ -0,0 +1,177 @@ | |||
| import { | ||||
|   GlButton, | ||||
|   GlDropdown, | ||||
|   GlDropdownItem, | ||||
|   GlDropdownSectionHeader, | ||||
|   GlSearchBoxByType, | ||||
|   GlTruncate, | ||||
| } from '@gitlab/ui'; | ||||
| import { mount, shallowMount } from '@vue/test-utils'; | ||||
| import Vue, { nextTick } from 'vue'; | ||||
| import VueApollo from 'vue-apollo'; | ||||
| import createMockApollo from 'helpers/mock_apollo_helper'; | ||||
| import createFlash from '~/flash'; | ||||
| import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql'; | ||||
| import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue'; | ||||
| 
 | ||||
| jest.mock('~/flash'); | ||||
| 
 | ||||
| describe('ProjectNamespace component', () => { | ||||
|   let wrapper; | ||||
|   let originalGon; | ||||
| 
 | ||||
|   const data = { | ||||
|     project: { | ||||
|       __typename: 'Project', | ||||
|       id: 'gid://gitlab/Project/1', | ||||
|       forkTargets: { | ||||
|         nodes: [ | ||||
|           { | ||||
|             id: 'gid://gitlab/Group/21', | ||||
|             fullPath: 'flightjs', | ||||
|             name: 'Flight JS', | ||||
|             visibility: 'public', | ||||
|           }, | ||||
|           { | ||||
|             id: 'gid://gitlab/Namespace/4', | ||||
|             fullPath: 'root', | ||||
|             name: 'Administrator', | ||||
|             visibility: 'public', | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
| 
 | ||||
|   const mockQueryResponse = jest.fn().mockResolvedValue({ data }); | ||||
| 
 | ||||
|   const emptyQueryResponse = { | ||||
|     project: { | ||||
|       __typename: 'Project', | ||||
|       id: 'gid://gitlab/Project/1', | ||||
|       forkTargets: { | ||||
|         nodes: [], | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
| 
 | ||||
|   const mockQueryError = jest.fn().mockRejectedValue(new Error('Network error')); | ||||
| 
 | ||||
|   Vue.use(VueApollo); | ||||
| 
 | ||||
|   const gitlabUrl = 'https://gitlab.com'; | ||||
| 
 | ||||
|   const defaultProvide = { | ||||
|     projectFullPath: 'gitlab-org/project', | ||||
|   }; | ||||
| 
 | ||||
|   const mountComponent = ({ | ||||
|     provide = defaultProvide, | ||||
|     queryHandler = mockQueryResponse, | ||||
|     mountFn = shallowMount, | ||||
|   } = {}) => { | ||||
|     const requestHandlers = [[searchQuery, queryHandler]]; | ||||
|     const apolloProvider = createMockApollo(requestHandlers); | ||||
| 
 | ||||
|     wrapper = mountFn(ProjectNamespace, { | ||||
|       apolloProvider, | ||||
|       provide, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const findButtonLabel = () => wrapper.findComponent(GlButton); | ||||
|   const findDropdown = () => wrapper.findComponent(GlDropdown); | ||||
|   const findDropdownText = () => wrapper.findComponent(GlTruncate); | ||||
|   const findInput = () => wrapper.findComponent(GlSearchBoxByType); | ||||
| 
 | ||||
|   const clickDropdownItem = async () => { | ||||
|     wrapper.findComponent(GlDropdownItem).vm.$emit('click'); | ||||
|     await nextTick(); | ||||
|   }; | ||||
| 
 | ||||
|   const showDropdown = () => { | ||||
|     findDropdown().vm.$emit('shown'); | ||||
|   }; | ||||
| 
 | ||||
|   beforeAll(() => { | ||||
|     originalGon = window.gon; | ||||
|     window.gon = { gitlab_url: gitlabUrl }; | ||||
|   }); | ||||
| 
 | ||||
|   afterAll(() => { | ||||
|     window.gon = originalGon; | ||||
|     wrapper.destroy(); | ||||
|   }); | ||||
| 
 | ||||
|   describe('Initial state', () => { | ||||
|     beforeEach(() => { | ||||
|       mountComponent({ mountFn: mount }); | ||||
|       jest.runOnlyPendingTimers(); | ||||
|     }); | ||||
| 
 | ||||
|     it('renders the root url as a label', () => { | ||||
|       expect(findButtonLabel().text()).toBe(`${gitlabUrl}/`); | ||||
|       expect(findButtonLabel().props('label')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('renders placeholder text', () => { | ||||
|       expect(findDropdownText().props('text')).toBe('Select a namespace'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('After user interactions', () => { | ||||
|     beforeEach(async () => { | ||||
|       mountComponent({ mountFn: mount }); | ||||
|       jest.runOnlyPendingTimers(); | ||||
|       await nextTick(); | ||||
|       showDropdown(); | ||||
|     }); | ||||
| 
 | ||||
|     it('focuses on the input when the dropdown is opened', () => { | ||||
|       const spy = jest.spyOn(findInput().vm, 'focusInput'); | ||||
|       showDropdown(); | ||||
|       expect(spy).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
| 
 | ||||
|     it('displays fetched namespaces', () => { | ||||
|       const listItems = wrapper.findAll('li'); | ||||
|       expect(listItems).toHaveLength(3); | ||||
|       expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Namespaces'); | ||||
|       expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[0].fullPath); | ||||
|       expect(listItems.at(2).text()).toBe(data.project.forkTargets.nodes[1].fullPath); | ||||
|     }); | ||||
| 
 | ||||
|     it('sets the selected namespace', async () => { | ||||
|       const { fullPath } = data.project.forkTargets.nodes[0]; | ||||
|       await clickDropdownItem(); | ||||
|       expect(findDropdownText().props('text')).toBe(fullPath); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('With empty query response', () => { | ||||
|     beforeEach(() => { | ||||
|       mountComponent({ queryHandler: emptyQueryResponse, mountFn: mount }); | ||||
|       jest.runOnlyPendingTimers(); | ||||
|     }); | ||||
| 
 | ||||
|     it('renders `No matches found`', () => { | ||||
|       expect(wrapper.find('li').text()).toBe('No matches found'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('With error while fetching data', () => { | ||||
|     beforeEach(async () => { | ||||
|       mountComponent({ queryHandler: mockQueryError }); | ||||
|       jest.runOnlyPendingTimers(); | ||||
|       await nextTick(); | ||||
|     }); | ||||
| 
 | ||||
|     it('creates a flash message and captures the error', () => { | ||||
|       expect(createFlash).toHaveBeenCalledWith({ | ||||
|         message: 'Something went wrong while loading data. Please refresh the page to try again.', | ||||
|         captureError: true, | ||||
|         error: expect.any(Error), | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -0,0 +1,164 @@ | |||
| import { __ } from '~/locale'; | ||||
| import { mountExtended } from 'helpers/vue_test_utils_helper'; | ||||
| import RunnerStackedSummaryCell from '~/runner/components/cells/runner_stacked_summary_cell.vue'; | ||||
| import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; | ||||
| import RunnerTags from '~/runner/components/runner_tags.vue'; | ||||
| import RunnerSummaryField from '~/runner/components/cells/runner_summary_field.vue'; | ||||
| import { getIdFromGraphQLId } from '~/graphql_shared/utils'; | ||||
| 
 | ||||
| import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants'; | ||||
| 
 | ||||
| import { allRunnersData } from '../../mock_data'; | ||||
| 
 | ||||
| const mockRunner = allRunnersData.data.runners.nodes[0]; | ||||
| 
 | ||||
| describe('RunnerTypeCell', () => { | ||||
|   let wrapper; | ||||
| 
 | ||||
|   const findLockIcon = () => wrapper.findByTestId('lock-icon'); | ||||
|   const findRunnerTags = () => wrapper.findComponent(RunnerTags); | ||||
|   const findRunnerSummaryField = (icon) => | ||||
|     wrapper.findAllComponents(RunnerSummaryField).filter((w) => w.props('icon') === icon) | ||||
|       .wrappers[0]; | ||||
| 
 | ||||
|   const createComponent = (runner, options) => { | ||||
|     wrapper = mountExtended(RunnerStackedSummaryCell, { | ||||
|       propsData: { | ||||
|         runner: { | ||||
|           ...mockRunner, | ||||
|           ...runner, | ||||
|         }, | ||||
|       }, | ||||
|       stubs: { | ||||
|         RunnerSummaryField, | ||||
|       }, | ||||
|       ...options, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     createComponent(); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     wrapper.destroy(); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays the runner name as id and short token', () => { | ||||
|     expect(wrapper.text()).toContain( | ||||
|       `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`, | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it('Does not display the locked icon', () => { | ||||
|     expect(findLockIcon().exists()).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays the locked icon for locked runners', () => { | ||||
|     createComponent({ | ||||
|       runnerType: PROJECT_TYPE, | ||||
|       locked: true, | ||||
|     }); | ||||
| 
 | ||||
|     expect(findLockIcon().exists()).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays the runner type', () => { | ||||
|     createComponent({ | ||||
|       runnerType: INSTANCE_TYPE, | ||||
|       locked: true, | ||||
|     }); | ||||
| 
 | ||||
|     expect(wrapper.text()).toContain('shared'); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays the runner version', () => { | ||||
|     expect(wrapper.text()).toContain(mockRunner.version); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays the runner description', () => { | ||||
|     expect(wrapper.text()).toContain(mockRunner.description); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays last contact', () => { | ||||
|     createComponent({ | ||||
|       contactedAt: '2022-01-02', | ||||
|     }); | ||||
| 
 | ||||
|     expect(findRunnerSummaryField('clock').find(TimeAgo).props('time')).toBe('2022-01-02'); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays empty last contact', () => { | ||||
|     createComponent({ | ||||
|       contactedAt: null, | ||||
|     }); | ||||
| 
 | ||||
|     expect(findRunnerSummaryField('clock').find(TimeAgo).exists()).toBe(false); | ||||
|     expect(findRunnerSummaryField('clock').text()).toContain(__('Never')); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays ip address', () => { | ||||
|     createComponent({ | ||||
|       ipAddress: '127.0.0.1', | ||||
|     }); | ||||
| 
 | ||||
|     expect(findRunnerSummaryField('disk').text()).toContain('127.0.0.1'); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays no ip address', () => { | ||||
|     createComponent({ | ||||
|       ipAddress: null, | ||||
|     }); | ||||
| 
 | ||||
|     expect(findRunnerSummaryField('disk')).toBeUndefined(); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays job count', () => { | ||||
|     expect(findRunnerSummaryField('pipeline').text()).toContain(`${mockRunner.jobCount}`); | ||||
|   }); | ||||
| 
 | ||||
|   it('Formats large job counts ', () => { | ||||
|     createComponent({ | ||||
|       jobCount: 1000, | ||||
|     }); | ||||
| 
 | ||||
|     expect(findRunnerSummaryField('pipeline').text()).toContain('1,000'); | ||||
|   }); | ||||
| 
 | ||||
|   it('Formats large job counts with a plus symbol', () => { | ||||
|     createComponent({ | ||||
|       jobCount: 1001, | ||||
|     }); | ||||
| 
 | ||||
|     expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+'); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays created at', () => { | ||||
|     expect(findRunnerSummaryField('calendar').find(TimeAgo).props('time')).toBe( | ||||
|       mockRunner.createdAt, | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays tag list', () => { | ||||
|     createComponent({ | ||||
|       tagList: ['shell', 'linux'], | ||||
|     }); | ||||
| 
 | ||||
|     expect(findRunnerTags().props('tagList')).toEqual(['shell', 'linux']); | ||||
|   }); | ||||
| 
 | ||||
|   it('Displays a custom slot', () => { | ||||
|     const slotContent = 'My custom runner name'; | ||||
| 
 | ||||
|     createComponent( | ||||
|       {}, | ||||
|       { | ||||
|         slots: { | ||||
|           'runner-name': slotContent, | ||||
|         }, | ||||
|       }, | ||||
|     ); | ||||
| 
 | ||||
|     expect(wrapper.text()).toContain(slotContent); | ||||
|   }); | ||||
| }); | ||||
|  | @ -0,0 +1,49 @@ | |||
| import { GlIcon } from '@gitlab/ui'; | ||||
| import { shallowMount } from '@vue/test-utils'; | ||||
| import RunnerSummaryField from '~/runner/components/cells/runner_summary_field.vue'; | ||||
| import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; | ||||
| 
 | ||||
| describe('RunnerSummaryField', () => { | ||||
|   let wrapper; | ||||
| 
 | ||||
|   const findIcon = () => wrapper.findComponent(GlIcon); | ||||
|   const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip').value; | ||||
| 
 | ||||
|   const createComponent = ({ props, ...options } = {}) => { | ||||
|     wrapper = shallowMount(RunnerSummaryField, { | ||||
|       propsData: { | ||||
|         icon: '', | ||||
|         tooltip: '', | ||||
|         ...props, | ||||
|       }, | ||||
|       directives: { | ||||
|         GlTooltip: createMockDirective(), | ||||
|       }, | ||||
|       ...options, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     wrapper.destroy(); | ||||
|   }); | ||||
| 
 | ||||
|   it('shows content in slot', () => { | ||||
|     createComponent({ | ||||
|       slots: { default: 'content' }, | ||||
|     }); | ||||
| 
 | ||||
|     expect(wrapper.text()).toBe('content'); | ||||
|   }); | ||||
| 
 | ||||
|   it('shows icon', () => { | ||||
|     createComponent({ props: { icon: 'git' } }); | ||||
| 
 | ||||
|     expect(findIcon().props('name')).toBe('git'); | ||||
|   }); | ||||
| 
 | ||||
|   it('shows tooltip', () => { | ||||
|     createComponent({ props: { tooltip: 'tooltip' } }); | ||||
| 
 | ||||
|     expect(getTooltipValue()).toBe('tooltip'); | ||||
|   }); | ||||
| }); | ||||
|  | @ -22,7 +22,10 @@ describe('RunnerList', () => { | |||
|   const findCell = ({ row = 0, fieldKey }) => | ||||
|     extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); | ||||
| 
 | ||||
|   const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => { | ||||
|   const createComponent = ( | ||||
|     { props = {}, provide = {}, ...options } = {}, | ||||
|     mountFn = shallowMountExtended, | ||||
|   ) => { | ||||
|     wrapper = mountFn(RunnerList, { | ||||
|       propsData: { | ||||
|         runners: mockRunners, | ||||
|  | @ -32,6 +35,7 @@ describe('RunnerList', () => { | |||
|       provide: { | ||||
|         onlineContactTimeoutSecs, | ||||
|         staleTimeoutSecs, | ||||
|         ...provide, | ||||
|       }, | ||||
|       ...options, | ||||
|     }); | ||||
|  | @ -221,4 +225,60 @@ describe('RunnerList', () => { | |||
|       expect(findSkeletonLoader().exists()).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe.each` | ||||
|     glFeatures | ||||
|     ${{ runnerListStackedLayoutAdmin: true }} | ||||
|     ${{ runnerListStackedLayout: true }} | ||||
|   `('When glFeatures = $glFeatures', ({ glFeatures }) => {
 | ||||
|     beforeEach(() => { | ||||
|       createComponent( | ||||
|         { | ||||
|           stubs: { | ||||
|             RunnerStatusPopover: { | ||||
|               template: '<div/>', | ||||
|             }, | ||||
|           }, | ||||
|           provide: { | ||||
|             glFeatures, | ||||
|           }, | ||||
|         }, | ||||
|         mountExtended, | ||||
|       ); | ||||
|     }); | ||||
| 
 | ||||
|     it('Displays stacked list headers', () => { | ||||
|       const headerLabels = findHeaders().wrappers.map((w) => w.text()); | ||||
| 
 | ||||
|       expect(headerLabels).toEqual([ | ||||
|         'Status', | ||||
|         'Runner', | ||||
|         '', // actions has no label
 | ||||
|       ]); | ||||
|     }); | ||||
| 
 | ||||
|     it('Displays stacked details of a runner', () => { | ||||
|       const { id, description, version, shortSha } = mockRunners[0]; | ||||
|       const numericId = getIdFromGraphQLId(id); | ||||
| 
 | ||||
|       // Badges
 | ||||
|       expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText('never contacted'); | ||||
| 
 | ||||
|       // Runner summary
 | ||||
|       const summary = findCell({ fieldKey: 'summary' }).text(); | ||||
| 
 | ||||
|       expect(summary).toContain(`#${numericId} (${shortSha})`); | ||||
|       expect(summary).toContain('specific'); | ||||
| 
 | ||||
|       expect(summary).toContain(version); | ||||
|       expect(summary).toContain(description); | ||||
| 
 | ||||
|       expect(summary).toContain('Last contact'); | ||||
|       expect(summary).toContain('0'); // job count
 | ||||
|       expect(summary).toContain('Created'); | ||||
| 
 | ||||
|       // Actions
 | ||||
|       expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -0,0 +1,167 @@ | |||
| import $ from 'jquery'; | ||||
| import { mountExtended } from 'helpers/vue_test_utils_helper'; | ||||
| import waitForPromises from 'helpers/wait_for_promises'; | ||||
| import SetStatusForm from '~/set_status_modal/set_status_form.vue'; | ||||
| import EmojiPicker from '~/emoji/components/picker.vue'; | ||||
| import { timeRanges } from '~/vue_shared/constants'; | ||||
| import { sprintf } from '~/locale'; | ||||
| import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; | ||||
| 
 | ||||
| describe('SetStatusForm', () => { | ||||
|   let wrapper; | ||||
| 
 | ||||
|   const defaultPropsData = { | ||||
|     defaultEmoji: 'speech_balloon', | ||||
|     emoji: 'thumbsup', | ||||
|     message: 'Foo bar', | ||||
|     availability: false, | ||||
|   }; | ||||
| 
 | ||||
|   const createComponent = async ({ propsData = {} } = {}) => { | ||||
|     wrapper = mountExtended(SetStatusForm, { | ||||
|       propsData: { | ||||
|         ...defaultPropsData, | ||||
|         ...propsData, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     await waitForPromises(); | ||||
|   }; | ||||
| 
 | ||||
|   const findMessageInput = () => | ||||
|     wrapper.findByPlaceholderText(SetStatusForm.i18n.statusMessagePlaceholder); | ||||
|   const findSelectedEmoji = (emoji) => | ||||
|     wrapper.findByTestId('selected-emoji').find(`gl-emoji[data-name="${emoji}"]`); | ||||
| 
 | ||||
|   it('sets up emoji autocomplete for the message input', async () => { | ||||
|     const gfmAutoCompleteSetupSpy = jest.spyOn(GfmAutoComplete.prototype, 'setup'); | ||||
| 
 | ||||
|     await createComponent(); | ||||
| 
 | ||||
|     expect(gfmAutoCompleteSetupSpy).toHaveBeenCalledWith($(findMessageInput().element), { | ||||
|       emojis: true, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when emoji is set', () => { | ||||
|     it('displays emoji', async () => { | ||||
|       await createComponent(); | ||||
| 
 | ||||
|       expect(findSelectedEmoji(defaultPropsData.emoji).exists()).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when emoji is not set and message is changed', () => { | ||||
|     it('displays default emoji', async () => { | ||||
|       await createComponent({ | ||||
|         propsData: { | ||||
|           emoji: '', | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       await findMessageInput().trigger('keyup'); | ||||
| 
 | ||||
|       expect(findSelectedEmoji(defaultPropsData.defaultEmoji).exists()).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when message is set', () => { | ||||
|     it('displays filled in message input', async () => { | ||||
|       await createComponent(); | ||||
| 
 | ||||
|       expect(findMessageInput().element.value).toBe(defaultPropsData.message); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when clear status after is set', () => { | ||||
|     it('displays value in dropdown toggle button', async () => { | ||||
|       const clearStatusAfter = timeRanges[0]; | ||||
| 
 | ||||
|       await createComponent({ | ||||
|         propsData: { | ||||
|           clearStatusAfter, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       expect(wrapper.findByRole('button', { name: clearStatusAfter.label }).exists()).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when emoji is changed', () => { | ||||
|     beforeEach(async () => { | ||||
|       await createComponent(); | ||||
| 
 | ||||
|       wrapper.findComponent(EmojiPicker).vm.$emit('click', defaultPropsData.emoji); | ||||
|     }); | ||||
| 
 | ||||
|     it('emits `emoji-click` event', () => { | ||||
|       expect(wrapper.emitted('emoji-click')).toEqual([[defaultPropsData.emoji]]); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when message is changed', () => { | ||||
|     it('emits `message-input` event', async () => { | ||||
|       await createComponent(); | ||||
| 
 | ||||
|       const newMessage = 'Foo bar baz'; | ||||
| 
 | ||||
|       await findMessageInput().setValue(newMessage); | ||||
| 
 | ||||
|       expect(wrapper.emitted('message-input')).toEqual([[newMessage]]); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when availability checkbox is changed', () => { | ||||
|     it('emits `availability-input` event', async () => { | ||||
|       await createComponent(); | ||||
| 
 | ||||
|       await wrapper | ||||
|         .findByLabelText( | ||||
|           `${SetStatusForm.i18n.availabilityCheckboxLabel} ${SetStatusForm.i18n.availabilityCheckboxHelpText}`, | ||||
|         ) | ||||
|         .setChecked(); | ||||
| 
 | ||||
|       expect(wrapper.emitted('availability-input')).toEqual([[true]]); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when `Clear status after` dropdown is changed', () => { | ||||
|     it('emits `clear-status-after-click`', async () => { | ||||
|       await wrapper.findByTestId('thirtyMinutes').trigger('click'); | ||||
| 
 | ||||
|       expect(wrapper.emitted('clear-status-after-click')).toEqual([[timeRanges[0]]]); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when clear status button is clicked', () => { | ||||
|     beforeEach(async () => { | ||||
|       await createComponent(); | ||||
| 
 | ||||
|       await wrapper | ||||
|         .findByRole('button', { name: SetStatusForm.i18n.clearStatusButtonLabel }) | ||||
|         .trigger('click'); | ||||
|     }); | ||||
| 
 | ||||
|     it('clears emoji and message', () => { | ||||
|       expect(wrapper.emitted('emoji-click')).toEqual([['']]); | ||||
|       expect(wrapper.emitted('message-input')).toEqual([['']]); | ||||
|       expect(wrapper.findByTestId('no-emoji-placeholder').exists()).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when `currentClearStatusAfter` prop is set', () => { | ||||
|     it('displays clear status message', async () => { | ||||
|       const date = '2022-08-25 21:14:48 UTC'; | ||||
| 
 | ||||
|       await createComponent({ | ||||
|         propsData: { | ||||
|           currentClearStatusAfter: date, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       expect( | ||||
|         wrapper.findByText(sprintf(SetStatusForm.i18n.clearStatusAfterMessage, { date })).exists(), | ||||
|       ).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -9,6 +9,7 @@ import stubChildren from 'helpers/stub_children'; | |||
| import SetStatusModalWrapper, { | ||||
|   AVAILABILITY_STATUS, | ||||
| } from '~/set_status_modal/set_status_modal_wrapper.vue'; | ||||
| import SetStatusForm from '~/set_status_modal/set_status_form.vue'; | ||||
| 
 | ||||
| jest.mock('~/flash'); | ||||
| 
 | ||||
|  | @ -42,6 +43,7 @@ describe('SetStatusModalWrapper', () => { | |||
|         ...stubChildren(SetStatusModalWrapper), | ||||
|         GlFormInput: false, | ||||
|         GlFormInputGroup: false, | ||||
|         SetStatusForm: false, | ||||
|         EmojiPicker: EmojiPickerStub, | ||||
|       }, | ||||
|       mocks: { | ||||
|  | @ -118,10 +120,10 @@ describe('SetStatusModalWrapper', () => { | |||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('sets emojiTag when clicking in emoji picker', async () => { | ||||
|     it('passes emoji to `SetStatusForm`', async () => { | ||||
|       await getEmojiPicker().vm.$emit('click', 'thumbsup'); | ||||
| 
 | ||||
|       expect(wrapper.vm.emojiTag).toContain('data-name="thumbsup"'); | ||||
|       expect(wrapper.findComponent(SetStatusForm).props('emoji')).toBe('thumbsup'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  | @ -194,7 +196,7 @@ describe('SetStatusModalWrapper', () => { | |||
|         findAvailabilityCheckbox().vm.$emit('input', true); | ||||
| 
 | ||||
|         // set the currentClearStatusAfter to 30 minutes
 | ||||
|         wrapper.find('[data-testid="thirtyMinutes"]').vm.$emit('click'); | ||||
|         wrapper.find('[data-testid="thirtyMinutes"]').trigger('click'); | ||||
| 
 | ||||
|         findModal().vm.$emit('primary'); | ||||
|         await nextTick(); | ||||
|  |  | |||
|  | @ -6,6 +6,17 @@ import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisse | |||
| import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue'; | ||||
| import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue'; | ||||
| 
 | ||||
| const createRenderTrackedArguments = () => [ | ||||
|   undefined, | ||||
|   'survey:mr_experience', | ||||
|   { | ||||
|     label: 'render', | ||||
|     extra: { | ||||
|       accountAge: 0, | ||||
|     }, | ||||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| describe('MergeRequestExperienceSurveyApp', () => { | ||||
|   let trackingSpy; | ||||
|   let wrapper; | ||||
|  | @ -24,6 +35,7 @@ describe('MergeRequestExperienceSurveyApp', () => { | |||
|       dismiss, | ||||
|       shouldShowCallout, | ||||
|     }); | ||||
|     trackingSpy = mockTracking(undefined, undefined, jest.spyOn); | ||||
|     wrapper = shallowMountExtended(MergeRequestExperienceSurveyApp, { | ||||
|       propsData: { | ||||
|         accountAge: 0, | ||||
|  | @ -33,9 +45,12 @@ describe('MergeRequestExperienceSurveyApp', () => { | |||
|         GlSprintf, | ||||
|       }, | ||||
|     }); | ||||
|     trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); | ||||
|   }; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     localStorage.clear(); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when user callout is visible', () => { | ||||
|     beforeEach(() => { | ||||
|       createWrapper(); | ||||
|  | @ -47,6 +62,16 @@ describe('MergeRequestExperienceSurveyApp', () => { | |||
|       expect(wrapper.emitted().close).toBe(undefined); | ||||
|     }); | ||||
| 
 | ||||
|     it('tracks render once', async () => { | ||||
|       expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments()); | ||||
|     }); | ||||
| 
 | ||||
|     it("doesn't track subsequent renders", async () => { | ||||
|       createWrapper(); | ||||
|       expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments()); | ||||
|       expect(trackingSpy).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
| 
 | ||||
|     describe('when close button clicked', () => { | ||||
|       beforeEach(() => { | ||||
|         findCloseButton().vm.$emit('click'); | ||||
|  | @ -68,6 +93,15 @@ describe('MergeRequestExperienceSurveyApp', () => { | |||
|           }, | ||||
|         }); | ||||
|       }); | ||||
| 
 | ||||
|       it('tracks subsequent renders', async () => { | ||||
|         createWrapper(); | ||||
|         expect(trackingSpy.mock.calls).toEqual([ | ||||
|           createRenderTrackedArguments(), | ||||
|           expect.anything(), | ||||
|           createRenderTrackedArguments(), | ||||
|         ]); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('applies correct feature name for user callout', () => { | ||||
|  | @ -148,6 +182,10 @@ describe('MergeRequestExperienceSurveyApp', () => { | |||
|     it('emits close event', async () => { | ||||
|       expect(wrapper.emitted()).toMatchObject({ close: [[]] }); | ||||
|     }); | ||||
| 
 | ||||
|     it("doesn't track anything", async () => { | ||||
|       expect(trackingSpy).toHaveBeenCalledTimes(0); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when Escape key is pressed', () => { | ||||
|  |  | |||
|  | @ -1,10 +1,10 @@ | |||
| import { shallowMount } from '@vue/test-utils'; | ||||
| import { mount } from '@vue/test-utils'; | ||||
| import AddedCommentMessage from '~/vue_merge_request_widget/components/added_commit_message.vue'; | ||||
| 
 | ||||
| let wrapper; | ||||
| 
 | ||||
| function factory(propsData) { | ||||
|   wrapper = shallowMount(AddedCommentMessage, { | ||||
|   wrapper = mount(AddedCommentMessage, { | ||||
|     propsData: { | ||||
|       isFastForwardEnabled: false, | ||||
|       targetBranch: 'main', | ||||
|  | @ -23,4 +23,13 @@ describe('Widget added commit message', () => { | |||
| 
 | ||||
|     expect(wrapper.element.outerHTML).toContain('The changes were not merged'); | ||||
|   }); | ||||
| 
 | ||||
|   it('renders merge commit as a link', () => { | ||||
|     factory({ state: 'merged', mergeCommitPath: 'https://test.host/merge-commit-link' }); | ||||
| 
 | ||||
|     expect(wrapper.find('[data-testid="merge-commit-sha"]').exists()).toBe(true); | ||||
|     expect(wrapper.find('[data-testid="merge-commit-sha"]').attributes('href')).toBe( | ||||
|       'https://test.host/merge-commit-link', | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -605,8 +605,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do | |||
|     let(:deps) do | ||||
|       double('deps', | ||||
|         'default_entry' => default, | ||||
|         'workflow_entry' => workflow, | ||||
|         'variables_value' => nil) | ||||
|         'workflow_entry' => workflow) | ||||
|     end | ||||
| 
 | ||||
|     context 'when job config overrides default config' do | ||||
|  |  | |||
|  | @ -0,0 +1,173 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Gitlab::Ci::Config::Entry::LegacyVariables do | ||||
|   let(:config) { {} } | ||||
|   let(:metadata) { {} } | ||||
| 
 | ||||
|   subject(:entry) { described_class.new(config, **metadata) } | ||||
| 
 | ||||
|   before do | ||||
|     entry.compose! | ||||
|   end | ||||
| 
 | ||||
|   shared_examples 'valid config' do | ||||
|     describe '#value' do | ||||
|       it 'returns hash with key value strings' do | ||||
|         expect(entry.value).to eq result | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     describe '#errors' do | ||||
|       it 'does not append errors' do | ||||
|         expect(entry.errors).to be_empty | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     describe '#valid?' do | ||||
|       it 'is valid' do | ||||
|         expect(entry).to be_valid | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   shared_examples 'invalid config' do |error_message| | ||||
|     describe '#valid?' do | ||||
|       it 'is not valid' do | ||||
|         expect(entry).not_to be_valid | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     describe '#errors' do | ||||
|       it 'saves errors' do | ||||
|         expect(entry.errors) | ||||
|           .to include(error_message) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'when entry config value has key-value pairs' do | ||||
|     let(:config) do | ||||
|       { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } | ||||
|     end | ||||
| 
 | ||||
|     let(:result) do | ||||
|       { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'valid config' | ||||
| 
 | ||||
|     describe '#value_with_data' do | ||||
|       it 'returns variable with data' do | ||||
|         expect(entry.value_with_data).to eq( | ||||
|           'VARIABLE_1' => { value: 'value 1', description: nil }, | ||||
|           'VARIABLE_2' => { value: 'value 2', description: nil } | ||||
|         ) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'with numeric keys and values in the config' do | ||||
|     let(:config) { { 10 => 20 } } | ||||
|     let(:result) do | ||||
|       { '10' => '20' } | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'valid config' | ||||
|   end | ||||
| 
 | ||||
|   context 'when key is an array' do | ||||
|     let(:config) { { ['VAR1'] => 'val1' } } | ||||
|     let(:result) do | ||||
|       { 'VAR1' => 'val1' } | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'invalid config', /should be a hash of key value pairs/ | ||||
|   end | ||||
| 
 | ||||
|   context 'when value is a symbol' do | ||||
|     let(:config) { { 'VAR1' => :val1 } } | ||||
|     let(:result) do | ||||
|       { 'VAR1' => 'val1' } | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'valid config' | ||||
|   end | ||||
| 
 | ||||
|   context 'when value is a boolean' do | ||||
|     let(:config) { { 'VAR1' => true } } | ||||
|     let(:result) do | ||||
|       { 'VAR1' => 'val1' } | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'invalid config', /should be a hash of key value pairs/ | ||||
|   end | ||||
| 
 | ||||
|   context 'when entry config value has key-value pair and hash' do | ||||
|     let(:config) do | ||||
|       { 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' }, | ||||
|         'VARIABLE_2' => 'value 2' } | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'invalid config', /should be a hash of key value pairs/ | ||||
| 
 | ||||
|     context 'when metadata has use_value_data: true' do | ||||
|       let(:metadata) { { use_value_data: true } } | ||||
| 
 | ||||
|       let(:result) do | ||||
|         { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } | ||||
|       end | ||||
| 
 | ||||
|       it_behaves_like 'valid config' | ||||
| 
 | ||||
|       describe '#value_with_data' do | ||||
|         it 'returns variable with data' do | ||||
|           expect(entry.value_with_data).to eq( | ||||
|             'VARIABLE_1' => { value: 'value 1', description: 'variable 1' }, | ||||
|             'VARIABLE_2' => { value: 'value 2', description: nil } | ||||
|           ) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'when entry value is an array' do | ||||
|     let(:config) { [:VAR, 'test'] } | ||||
| 
 | ||||
|     it_behaves_like 'invalid config', /should be a hash of key value pairs/ | ||||
|   end | ||||
| 
 | ||||
|   context 'when metadata has use_value_data: true' do | ||||
|     let(:metadata) { { use_value_data: true } } | ||||
| 
 | ||||
|     context 'when entry value has hash with other key-pairs' do | ||||
|       let(:config) do | ||||
|         { 'VARIABLE_1' => { value: 'value 1', hello: 'variable 1' }, | ||||
|           'VARIABLE_2' => 'value 2' } | ||||
|       end | ||||
| 
 | ||||
|       it_behaves_like 'invalid config', /should be a hash of key value pairs, value can be a hash/ | ||||
|     end | ||||
| 
 | ||||
|     context 'when entry config value has hash with nil description' do | ||||
|       let(:config) do | ||||
|         { 'VARIABLE_1' => { value: 'value 1', description: nil } } | ||||
|       end | ||||
| 
 | ||||
|       it_behaves_like 'invalid config', /should be a hash of key value pairs, value can be a hash/ | ||||
|     end | ||||
| 
 | ||||
|     context 'when entry config value has hash without description' do | ||||
|       let(:config) do | ||||
|         { 'VARIABLE_1' => { value: 'value 1' } } | ||||
|       end | ||||
| 
 | ||||
|       let(:result) do | ||||
|         { 'VARIABLE_1' => 'value 1' } | ||||
|       end | ||||
| 
 | ||||
|       it_behaves_like 'valid config' | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -197,6 +197,34 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do | |||
|           end | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when a variable has an invalid data attribute' do | ||||
|         let(:config) do | ||||
|           { | ||||
|             script: 'echo', | ||||
|             variables: { 'VAR1' => 'val 1', 'VAR2' => { value: 'val 2', description: 'hello var 2' } } | ||||
|           } | ||||
|         end | ||||
| 
 | ||||
|         it 'reports error about variable' do | ||||
|           expect(entry.errors) | ||||
|             .to include 'variables:var2 config must be a string' | ||||
|         end | ||||
| 
 | ||||
|         context 'when the FF ci_variables_refactoring_to_variable is disabled' do | ||||
|           let(:entry_without_ff) { node_class.new(config, name: :rspec) } | ||||
| 
 | ||||
|           before do | ||||
|             stub_feature_flags(ci_variables_refactoring_to_variable: false) | ||||
|             entry_without_ff.compose! | ||||
|           end | ||||
| 
 | ||||
|           it 'reports error about variable' do | ||||
|             expect(entry_without_ff.errors) | ||||
|               .to include /config should be a hash of key value pairs/ | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  | @ -212,13 +240,11 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do | |||
|     let(:unspecified) { double('unspecified', 'specified?' => false) } | ||||
|     let(:default) { double('default', '[]' => unspecified) } | ||||
|     let(:workflow) { double('workflow', 'has_rules?' => false) } | ||||
|     let(:variables) {} | ||||
| 
 | ||||
|     let(:deps) do | ||||
|       double('deps', | ||||
|         default_entry: default, | ||||
|         workflow_entry: workflow, | ||||
|         variables_value: variables) | ||||
|         workflow_entry: workflow) | ||||
|     end | ||||
| 
 | ||||
|     context 'with workflow rules' do | ||||
|  |  | |||
|  | @ -350,6 +350,33 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do | |||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when a variable has an invalid data key' do | ||||
|       let(:hash) do | ||||
|         { variables: { VAR1: { invalid: 'hello' } }, rspec: { script: 'hello' } } | ||||
|       end | ||||
| 
 | ||||
|       describe '#errors' do | ||||
|         it 'reports errors about the invalid variable' do | ||||
|           expect(root.errors) | ||||
|             .to include /var1 config uses invalid data keys: invalid/ | ||||
|         end | ||||
| 
 | ||||
|         context 'when the FF ci_variables_refactoring_to_variable is disabled' do | ||||
|           let(:root_without_ff) { described_class.new(hash, user: user, project: project) } | ||||
| 
 | ||||
|           before do | ||||
|             stub_feature_flags(ci_variables_refactoring_to_variable: false) | ||||
|             root_without_ff.compose! | ||||
|           end | ||||
| 
 | ||||
|           it 'reports errors about the invalid variable' do | ||||
|             expect(root_without_ff.errors) | ||||
|               .to include /variables config should be a hash of key value pairs, value can be a hash/ | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'when value is not a hash' do | ||||
|  |  | |||
|  | @ -1,8 +1,7 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'fast_spec_helper' | ||||
| require 'spec_helper' | ||||
| require 'gitlab_chronic_duration' | ||||
| require_dependency 'active_model' | ||||
| 
 | ||||
| RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do | ||||
|   let(:factory) do | ||||
|  | @ -363,7 +362,20 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do | |||
|         it { is_expected.not_to be_valid } | ||||
| 
 | ||||
|         it 'returns an error about invalid variables:' do | ||||
|           expect(subject.errors).to include(/variables config should be a hash of key value pairs/) | ||||
|           expect(subject.errors).to include(/variables config should be a hash/) | ||||
|         end | ||||
| 
 | ||||
|         context 'when the FF ci_variables_refactoring_to_variable is disabled' do | ||||
|           let(:entry_without_ff) { factory.create! } | ||||
| 
 | ||||
|           before do | ||||
|             stub_feature_flags(ci_variables_refactoring_to_variable: false) | ||||
|             entry_without_ff.compose! | ||||
|           end | ||||
| 
 | ||||
|           it 'returns an error about invalid variables:' do | ||||
|             expect(subject.errors).to include(/variables config should be a hash/) | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  |  | |||
|  | @ -0,0 +1,212 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Gitlab::Ci::Config::Entry::Variable do | ||||
|   let(:config) { {} } | ||||
|   let(:metadata) { {} } | ||||
| 
 | ||||
|   subject(:entry) do | ||||
|     described_class.new(config, **metadata).tap do |entry| | ||||
|       entry.key = 'VAR1' # composable_hash requires key to be set | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   before do | ||||
|     entry.compose! | ||||
|   end | ||||
| 
 | ||||
|   describe 'SimpleVariable' do | ||||
|     context 'when config is a string' do | ||||
|       let(:config) { 'value' } | ||||
| 
 | ||||
|       describe '#valid?' do | ||||
|         it { is_expected.to be_valid } | ||||
|       end | ||||
| 
 | ||||
|       describe '#value' do | ||||
|         subject(:value) { entry.value } | ||||
| 
 | ||||
|         it { is_expected.to eq('value') } | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when config is an integer' do | ||||
|       let(:config) { 1 } | ||||
| 
 | ||||
|       describe '#valid?' do | ||||
|         it { is_expected.to be_valid } | ||||
|       end | ||||
| 
 | ||||
|       describe '#value' do | ||||
|         subject(:value) { entry.value } | ||||
| 
 | ||||
|         it { is_expected.to eq('1') } | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when config is an array' do | ||||
|       let(:config) { [] } | ||||
| 
 | ||||
|       describe '#valid?' do | ||||
|         it { is_expected.not_to be_valid } | ||||
|       end | ||||
| 
 | ||||
|       describe '#errors' do | ||||
|         subject(:errors) { entry.errors } | ||||
| 
 | ||||
|         it { is_expected.to include 'variable definition must be either a string or a hash' } | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe 'ComplexVariable' do | ||||
|     context 'when config is a hash with description' do | ||||
|       let(:config) { { value: 'value', description: 'description' } } | ||||
| 
 | ||||
|       context 'when metadata allowed_value_data is not provided' do | ||||
|         describe '#valid?' do | ||||
|           it { is_expected.not_to be_valid } | ||||
|         end | ||||
| 
 | ||||
|         describe '#errors' do | ||||
|           subject(:errors) { entry.errors } | ||||
| 
 | ||||
|           it { is_expected.to include 'var1 config must be a string' } | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when metadata allowed_value_data is (value, description)' do | ||||
|         let(:metadata) { { allowed_value_data: %i[value description] } } | ||||
| 
 | ||||
|         describe '#valid?' do | ||||
|           it { is_expected.to be_valid } | ||||
|         end | ||||
| 
 | ||||
|         describe '#value' do | ||||
|           subject(:value) { entry.value } | ||||
| 
 | ||||
|           it { is_expected.to eq('value') } | ||||
|         end | ||||
| 
 | ||||
|         describe '#value_with_data' do | ||||
|           subject(:value_with_data) { entry.value_with_data } | ||||
| 
 | ||||
|           it { is_expected.to eq(value: 'value', description: 'description') } | ||||
|         end | ||||
| 
 | ||||
|         context 'when config value is a symbol' do | ||||
|           let(:config) { { value: :value, description: 'description' } } | ||||
| 
 | ||||
|           describe '#value' do | ||||
|             subject(:value) { entry.value } | ||||
| 
 | ||||
|             it { is_expected.to eq('value') } | ||||
|           end | ||||
| 
 | ||||
|           describe '#value_with_data' do | ||||
|             subject(:value_with_data) { entry.value_with_data } | ||||
| 
 | ||||
|             it { is_expected.to eq(value: 'value', description: 'description') } | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when config value is an integer' do | ||||
|           let(:config) { { value: 123, description: 'description' } } | ||||
| 
 | ||||
|           describe '#value' do | ||||
|             subject(:value) { entry.value } | ||||
| 
 | ||||
|             it { is_expected.to eq('123') } | ||||
|           end | ||||
| 
 | ||||
|           describe '#value_with_data' do | ||||
|             subject(:value_with_data) { entry.value_with_data } | ||||
| 
 | ||||
|             it { is_expected.to eq(value: '123', description: 'description') } | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when config value is an array' do | ||||
|           let(:config) { { value: ['value'], description: 'description' } } | ||||
| 
 | ||||
|           describe '#valid?' do | ||||
|             it { is_expected.not_to be_valid } | ||||
|           end | ||||
| 
 | ||||
|           describe '#errors' do | ||||
|             subject(:errors) { entry.errors } | ||||
| 
 | ||||
|             it { is_expected.to include 'var1 config value must be an alphanumeric string' } | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when config description is a symbol' do | ||||
|           let(:config) { { value: 'value', description: :description } } | ||||
| 
 | ||||
|           describe '#value' do | ||||
|             subject(:value) { entry.value } | ||||
| 
 | ||||
|             it { is_expected.to eq('value') } | ||||
|           end | ||||
| 
 | ||||
|           describe '#value_with_data' do | ||||
|             subject(:value_with_data) { entry.value_with_data } | ||||
| 
 | ||||
|             it { is_expected.to eq(value: 'value', description: :description) } | ||||
|           end | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when metadata allowed_value_data is (value, xyz)' do | ||||
|         let(:metadata) { { allowed_value_data: %i[value xyz] } } | ||||
| 
 | ||||
|         describe '#valid?' do | ||||
|           it { is_expected.not_to be_valid } | ||||
|         end | ||||
| 
 | ||||
|         describe '#errors' do | ||||
|           subject(:errors) { entry.errors } | ||||
| 
 | ||||
|           it { is_expected.to include 'var1 config uses invalid data keys: description' } | ||||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when config is a hash without description' do | ||||
|       let(:config) { { value: 'value' } } | ||||
| 
 | ||||
|       context 'when metadata allowed_value_data is not provided' do | ||||
|         describe '#valid?' do | ||||
|           it { is_expected.not_to be_valid } | ||||
|         end | ||||
| 
 | ||||
|         describe '#errors' do | ||||
|           subject(:errors) { entry.errors } | ||||
| 
 | ||||
|           it { is_expected.to include 'var1 config must be a string' } | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when metadata allowed_value_data is (value, description)' do | ||||
|         let(:metadata) { { allowed_value_data: %i[value description] } } | ||||
| 
 | ||||
|         describe '#valid?' do | ||||
|           it { is_expected.to be_valid } | ||||
|         end | ||||
| 
 | ||||
|         describe '#value' do | ||||
|           subject(:value) { entry.value } | ||||
| 
 | ||||
|           it { is_expected.to eq('value') } | ||||
|         end | ||||
| 
 | ||||
|         describe '#value_with_data' do | ||||
|           subject(:value_with_data) { entry.value_with_data } | ||||
| 
 | ||||
|           it { is_expected.to eq(value: 'value', description: nil) } | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue