Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									c6c7437861
								
							
						
					
					
						commit
						d3fc3be040
					
				|  | @ -45,7 +45,13 @@ export default { | ||||||
| 
 | 
 | ||||||
|     <template v-else> |     <template v-else> | ||||||
|       <blob-content-error v-if="viewerError" :viewer-error="viewerError" /> |       <blob-content-error v-if="viewerError" :viewer-error="viewerError" /> | ||||||
|       <component :is="viewer" v-else ref="contentViewer" :content="content" /> |       <component | ||||||
|  |         :is="viewer" | ||||||
|  |         v-else | ||||||
|  |         ref="contentViewer" | ||||||
|  |         :content="content" | ||||||
|  |         :type="activeViewer.fileType" | ||||||
|  |       /> | ||||||
|     </template> |     </template> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
|  | @ -5,10 +5,43 @@ import { handleLocationHash } from '../../lib/utils/common_utils'; | ||||||
| import axios from '../../lib/utils/axios_utils'; | import axios from '../../lib/utils/axios_utils'; | ||||||
| import { __ } from '~/locale'; | import { __ } from '~/locale'; | ||||||
| 
 | 
 | ||||||
|  | const loadRichBlobViewer = type => { | ||||||
|  |   switch (type) { | ||||||
|  |     case 'balsamiq': | ||||||
|  |       return import(/* webpackChunkName: 'balsamiq_viewer' */ '../balsamiq_viewer'); | ||||||
|  |     case 'notebook': | ||||||
|  |       return import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer'); | ||||||
|  |     case 'openapi': | ||||||
|  |       return import(/* webpackChunkName: 'openapi_viewer' */ '../openapi_viewer'); | ||||||
|  |     case 'pdf': | ||||||
|  |       return import(/* webpackChunkName: 'pdf_viewer' */ '../pdf_viewer'); | ||||||
|  |     case 'sketch': | ||||||
|  |       return import(/* webpackChunkName: 'sketch_viewer' */ '../sketch_viewer'); | ||||||
|  |     case 'stl': | ||||||
|  |       return import(/* webpackChunkName: 'stl_viewer' */ '../stl_viewer'); | ||||||
|  |     default: | ||||||
|  |       return Promise.resolve(); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const handleBlobRichViewer = (viewer, type) => { | ||||||
|  |   if (!viewer || !type) return; | ||||||
|  | 
 | ||||||
|  |   loadRichBlobViewer(type) | ||||||
|  |     .then(module => module?.default(viewer)) | ||||||
|  |     .catch(error => { | ||||||
|  |       Flash(__('Error loading file viewer.')); | ||||||
|  |       throw error; | ||||||
|  |     }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export default class BlobViewer { | export default class BlobViewer { | ||||||
|   constructor() { |   constructor() { | ||||||
|  |     const viewer = document.querySelector('.blob-viewer[data-type="rich"]'); | ||||||
|  |     const type = viewer?.dataset?.richType; | ||||||
|     BlobViewer.initAuxiliaryViewer(); |     BlobViewer.initAuxiliaryViewer(); | ||||||
|     BlobViewer.initRichViewer(); | 
 | ||||||
|  |     handleBlobRichViewer(viewer, type); | ||||||
| 
 | 
 | ||||||
|     this.initMainViewers(); |     this.initMainViewers(); | ||||||
|   } |   } | ||||||
|  | @ -20,42 +53,6 @@ export default class BlobViewer { | ||||||
|     BlobViewer.loadViewer(auxiliaryViewer); |     BlobViewer.loadViewer(auxiliaryViewer); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   static initRichViewer() { |  | ||||||
|     const viewer = document.querySelector('.blob-viewer[data-type="rich"]'); |  | ||||||
|     if (!viewer || !viewer.dataset.richType) return; |  | ||||||
| 
 |  | ||||||
|     const initViewer = promise => |  | ||||||
|       promise |  | ||||||
|         .then(module => module.default(viewer)) |  | ||||||
|         .catch(error => { |  | ||||||
|           Flash(__('Error loading file viewer.')); |  | ||||||
|           throw error; |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|     switch (viewer.dataset.richType) { |  | ||||||
|       case 'balsamiq': |  | ||||||
|         initViewer(import(/* webpackChunkName: 'balsamiq_viewer' */ '../balsamiq_viewer')); |  | ||||||
|         break; |  | ||||||
|       case 'notebook': |  | ||||||
|         initViewer(import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer')); |  | ||||||
|         break; |  | ||||||
|       case 'openapi': |  | ||||||
|         initViewer(import(/* webpackChunkName: 'openapi_viewer' */ '../openapi_viewer')); |  | ||||||
|         break; |  | ||||||
|       case 'pdf': |  | ||||||
|         initViewer(import(/* webpackChunkName: 'pdf_viewer' */ '../pdf_viewer')); |  | ||||||
|         break; |  | ||||||
|       case 'sketch': |  | ||||||
|         initViewer(import(/* webpackChunkName: 'sketch_viewer' */ '../sketch_viewer')); |  | ||||||
|         break; |  | ||||||
|       case 'stl': |  | ||||||
|         initViewer(import(/* webpackChunkName: 'stl_viewer' */ '../stl_viewer')); |  | ||||||
|         break; |  | ||||||
|       default: |  | ||||||
|         break; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   initMainViewers() { |   initMainViewers() { | ||||||
|     this.$fileHolder = $('.file-holder'); |     this.$fileHolder = $('.file-holder'); | ||||||
|     if (!this.$fileHolder.length) return; |     if (!this.$fileHolder.length) return; | ||||||
|  |  | ||||||
|  | @ -4,5 +4,9 @@ export default { | ||||||
|       type: String, |       type: String, | ||||||
|       required: true, |       required: true, | ||||||
|     }, |     }, | ||||||
|  |     type: { | ||||||
|  |       type: String, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -1,10 +1,14 @@ | ||||||
| <script> | <script> | ||||||
| import ViewerMixin from './mixins'; | import ViewerMixin from './mixins'; | ||||||
|  | import { handleBlobRichViewer } from '~/blob/viewer'; | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   mixins: [ViewerMixin], |   mixins: [ViewerMixin], | ||||||
|  |   mounted() { | ||||||
|  |     handleBlobRichViewer(this.$refs.content, this.type); | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
| <template> | <template> | ||||||
|   <div v-html="content"></div> |   <div ref="content" v-html="content"></div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
|  | @ -27,7 +27,12 @@ export default { | ||||||
|     <span :style="labelStyle" class="badge color-label"> |     <span :style="labelStyle" class="badge color-label"> | ||||||
|       {{ label.title }} |       {{ label.title }} | ||||||
|     </span> |     </span> | ||||||
|     <gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport"> |     <gl-tooltip | ||||||
|  |       v-if="label.description" | ||||||
|  |       :target="() => $refs.regularLabelRef" | ||||||
|  |       placement="top" | ||||||
|  |       boundary="viewport" | ||||||
|  |     > | ||||||
|       {{ label.description }} |       {{ label.description }} | ||||||
|     </gl-tooltip> |     </gl-tooltip> | ||||||
|   </a> |   </a> | ||||||
|  |  | ||||||
|  | @ -33,7 +33,12 @@ export default { | ||||||
|       <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label"> |       <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label"> | ||||||
|         {{ label.title }} |         {{ label.title }} | ||||||
|       </span> |       </span> | ||||||
|       <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport"> |       <gl-tooltip | ||||||
|  |         v-if="label.description" | ||||||
|  |         :target="() => $refs.labelTitleRef" | ||||||
|  |         placement="top" | ||||||
|  |         boundary="viewport" | ||||||
|  |       > | ||||||
|         <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span |         <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span | ||||||
|         ><br /> |         ><br /> | ||||||
|         {{ label.description }} |         {{ label.description }} | ||||||
|  |  | ||||||
|  | @ -0,0 +1,21 @@ | ||||||
|  | <script> | ||||||
|  | import { mapGetters } from 'vuex'; | ||||||
|  | import { GlButton, GlIcon } from '@gitlab/ui'; | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   components: { | ||||||
|  |     GlButton, | ||||||
|  |     GlIcon, | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     ...mapGetters(['dropdownButtonText']), | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <gl-button class="labels-select-dropdown-button w-100 text-left"> | ||||||
|  |     <span class="dropdown-toggle-text">{{ dropdownButtonText }}</span> | ||||||
|  |     <gl-icon name="chevron-down" class="pull-right" /> | ||||||
|  |   </gl-button> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,30 @@ | ||||||
|  | <script> | ||||||
|  | import { mapState } from 'vuex'; | ||||||
|  | 
 | ||||||
|  | import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; | ||||||
|  | import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   components: { | ||||||
|  |     DropdownContentsLabelsView, | ||||||
|  |     DropdownContentsCreateView, | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     ...mapState(['showDropdownContentsCreateView']), | ||||||
|  |     dropdownContentsView() { | ||||||
|  |       if (this.showDropdownContentsCreateView) { | ||||||
|  |         return 'dropdown-contents-create-view'; | ||||||
|  |       } | ||||||
|  |       return 'dropdown-contents-labels-view'; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute" | ||||||
|  |   > | ||||||
|  |     <component :is="dropdownContentsView" /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,124 @@ | ||||||
|  | <script> | ||||||
|  | import { mapState, mapActions } from 'vuex'; | ||||||
|  | import { | ||||||
|  |   GlTooltipDirective, | ||||||
|  |   GlButton, | ||||||
|  |   GlIcon, | ||||||
|  |   GlFormInput, | ||||||
|  |   GlLink, | ||||||
|  |   GlLoadingIcon, | ||||||
|  | } from '@gitlab/ui'; | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   components: { | ||||||
|  |     GlButton, | ||||||
|  |     GlIcon, | ||||||
|  |     GlFormInput, | ||||||
|  |     GlLink, | ||||||
|  |     GlLoadingIcon, | ||||||
|  |   }, | ||||||
|  |   directives: { | ||||||
|  |     GlTooltip: GlTooltipDirective, | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       labelTitle: '', | ||||||
|  |       selectedColor: '', | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     ...mapState(['labelsCreateTitle', 'labelCreateInProgress']), | ||||||
|  |     disableCreate() { | ||||||
|  |       return !this.labelTitle.length || !this.selectedColor.length || this.labelCreateInProgress; | ||||||
|  |     }, | ||||||
|  |     suggestedColors() { | ||||||
|  |       const colorsMap = gon.suggested_label_colors; | ||||||
|  |       return Object.keys(colorsMap).map(color => ({ [color]: colorsMap[color] })); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     ...mapActions(['toggleDropdownContents', 'toggleDropdownContentsCreateView', 'createLabel']), | ||||||
|  |     getColorCode(color) { | ||||||
|  |       return Object.keys(color).pop(); | ||||||
|  |     }, | ||||||
|  |     getColorName(color) { | ||||||
|  |       return Object.values(color).pop(); | ||||||
|  |     }, | ||||||
|  |     handleColorClick(color) { | ||||||
|  |       this.selectedColor = this.getColorCode(color); | ||||||
|  |     }, | ||||||
|  |     handleCreateClick() { | ||||||
|  |       this.createLabel({ | ||||||
|  |         title: this.labelTitle, | ||||||
|  |         color: this.selectedColor, | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <div class="labels-select-contents-create"> | ||||||
|  |     <div class="dropdown-title d-flex align-items-center pt-0 pb-2"> | ||||||
|  |       <gl-button | ||||||
|  |         :aria-label="__('Go back')" | ||||||
|  |         variant="link" | ||||||
|  |         size="sm" | ||||||
|  |         class="dropdown-header-button p-0" | ||||||
|  |         @click="toggleDropdownContentsCreateView" | ||||||
|  |       > | ||||||
|  |         <gl-icon name="arrow-left" /> | ||||||
|  |       </gl-button> | ||||||
|  |       <span class="flex-grow-1">{{ labelsCreateTitle }}</span> | ||||||
|  |       <gl-button | ||||||
|  |         :aria-label="__('Close')" | ||||||
|  |         variant="link" | ||||||
|  |         size="sm" | ||||||
|  |         class="dropdown-header-button p-0" | ||||||
|  |         @click="toggleDropdownContents" | ||||||
|  |       > | ||||||
|  |         <gl-icon name="close" /> | ||||||
|  |       </gl-button> | ||||||
|  |     </div> | ||||||
|  |     <div class="dropdown-input"> | ||||||
|  |       <gl-form-input | ||||||
|  |         v-model.trim="labelTitle" | ||||||
|  |         :placeholder="__('Name new label')" | ||||||
|  |         :autofocus="true" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |     <div class="dropdown-content px-2"> | ||||||
|  |       <div class="suggest-colors suggest-colors-dropdown mt-0 mb-2"> | ||||||
|  |         <gl-link | ||||||
|  |           v-for="(color, index) in suggestedColors" | ||||||
|  |           :key="index" | ||||||
|  |           v-gl-tooltip:tooltipcontainer | ||||||
|  |           :style="{ backgroundColor: getColorCode(color) }" | ||||||
|  |           :title="getColorName(color)" | ||||||
|  |           @click.prevent="handleColorClick(color)" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <div class="color-input-container d-flex"> | ||||||
|  |         <span | ||||||
|  |           class="dropdown-label-color-preview position-relative position-relative d-inline-block" | ||||||
|  |           :style="{ backgroundColor: selectedColor }" | ||||||
|  |         ></span> | ||||||
|  |         <gl-form-input v-model.trim="selectedColor" :placeholder="__('Use custom color #FF0000')" /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div class="dropdown-actions clearfix pt-2 px-2"> | ||||||
|  |       <gl-button | ||||||
|  |         :disabled="disableCreate" | ||||||
|  |         variant="primary" | ||||||
|  |         class="pull-left d-flex align-items-center" | ||||||
|  |         @click="handleCreateClick" | ||||||
|  |       > | ||||||
|  |         <gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" /> | ||||||
|  |         {{ __('Create') }} | ||||||
|  |       </gl-button> | ||||||
|  |       <gl-button class="pull-right" @click="toggleDropdownContentsCreateView"> | ||||||
|  |         {{ __('Cancel') }} | ||||||
|  |       </gl-button> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,178 @@ | ||||||
|  | <script> | ||||||
|  | import { mapState, mapGetters, mapActions } from 'vuex'; | ||||||
|  | import { GlLoadingIcon, GlButton, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; | ||||||
|  | 
 | ||||||
|  | import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   components: { | ||||||
|  |     GlLoadingIcon, | ||||||
|  |     GlButton, | ||||||
|  |     GlIcon, | ||||||
|  |     GlSearchBoxByType, | ||||||
|  |     GlLink, | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       searchKey: '', | ||||||
|  |       currentHighlightItem: -1, | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     ...mapState([ | ||||||
|  |       'labelsManagePath', | ||||||
|  |       'labels', | ||||||
|  |       'labelsFetchInProgress', | ||||||
|  |       'labelsListTitle', | ||||||
|  |       'footerCreateLabelTitle', | ||||||
|  |       'footerManageLabelTitle', | ||||||
|  |     ]), | ||||||
|  |     ...mapGetters(['selectedLabelsList']), | ||||||
|  |     visibleLabels() { | ||||||
|  |       if (this.searchKey) { | ||||||
|  |         return this.labels.filter(label => | ||||||
|  |           label.title.toLowerCase().includes(this.searchKey.toLowerCase()), | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |       return this.labels; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   watch: { | ||||||
|  |     searchKey(value) { | ||||||
|  |       // When there is search string present | ||||||
|  |       // and there are matching results, | ||||||
|  |       // highlight first item by default. | ||||||
|  |       if (value && this.visibleLabels.length) { | ||||||
|  |         this.currentHighlightItem = 0; | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   mounted() { | ||||||
|  |     this.fetchLabels(); | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     ...mapActions([ | ||||||
|  |       'toggleDropdownContents', | ||||||
|  |       'toggleDropdownContentsCreateView', | ||||||
|  |       'fetchLabels', | ||||||
|  |       'updateSelectedLabels', | ||||||
|  |     ]), | ||||||
|  |     getDropdownLabelBoxStyle(label) { | ||||||
|  |       return { | ||||||
|  |         backgroundColor: label.color, | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |     isLabelSelected(label) { | ||||||
|  |       return this.selectedLabelsList.includes(label.id); | ||||||
|  |     }, | ||||||
|  |     /** | ||||||
|  |      * This method scrolls item from dropdown into | ||||||
|  |      * the view if it is off the viewable area of the | ||||||
|  |      * container. | ||||||
|  |      */ | ||||||
|  |     scrollIntoViewIfNeeded() { | ||||||
|  |       const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused'); | ||||||
|  | 
 | ||||||
|  |       if (highlightedLabel) { | ||||||
|  |         const rect = highlightedLabel.getBoundingClientRect(); | ||||||
|  |         if (rect.bottom > this.$refs.labelsListContainer.clientHeight) { | ||||||
|  |           highlightedLabel.scrollIntoView(false); | ||||||
|  |         } | ||||||
|  |         if (rect.top < 0) { | ||||||
|  |           highlightedLabel.scrollIntoView(); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     /** | ||||||
|  |      * This method enables keyboard navigation support for | ||||||
|  |      * the dropdown. | ||||||
|  |      */ | ||||||
|  |     handleKeyDown(e) { | ||||||
|  |       if (e.keyCode === UP_KEY_CODE && this.currentHighlightItem > 0) { | ||||||
|  |         this.currentHighlightItem -= 1; | ||||||
|  |       } else if ( | ||||||
|  |         e.keyCode === DOWN_KEY_CODE && | ||||||
|  |         this.currentHighlightItem < this.visibleLabels.length - 1 | ||||||
|  |       ) { | ||||||
|  |         this.currentHighlightItem += 1; | ||||||
|  |       } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) { | ||||||
|  |         this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]); | ||||||
|  |       } else if (e.keyCode === ESC_KEY_CODE) { | ||||||
|  |         this.toggleDropdownContents(); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if (e.keyCode !== ESC_KEY_CODE) { | ||||||
|  |         // Scroll the list only after highlighting | ||||||
|  |         // styles are rendered completely. | ||||||
|  |         this.$nextTick(() => { | ||||||
|  |           this.scrollIntoViewIfNeeded(); | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     handleLabelClick(label) { | ||||||
|  |       this.updateSelectedLabels([label]); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <div class="labels-select-contents-list" @keydown="handleKeyDown"> | ||||||
|  |     <gl-loading-icon | ||||||
|  |       v-if="labelsFetchInProgress" | ||||||
|  |       class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100" | ||||||
|  |       size="md" | ||||||
|  |     /> | ||||||
|  |     <div class="dropdown-title d-flex align-items-center pt-0 pb-2"> | ||||||
|  |       <span class="flex-grow-1">{{ labelsListTitle }}</span> | ||||||
|  |       <gl-button | ||||||
|  |         :aria-label="__('Close')" | ||||||
|  |         variant="link" | ||||||
|  |         size="sm" | ||||||
|  |         class="dropdown-header-button p-0" | ||||||
|  |         @click="toggleDropdownContents" | ||||||
|  |       > | ||||||
|  |         <gl-icon name="close" /> | ||||||
|  |       </gl-button> | ||||||
|  |     </div> | ||||||
|  |     <div class="dropdown-input"> | ||||||
|  |       <gl-search-box-by-type v-model="searchKey" :autofocus="true" /> | ||||||
|  |     </div> | ||||||
|  |     <div v-if="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content"> | ||||||
|  |       <ul class="list-unstyled mb-0"> | ||||||
|  |         <li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left"> | ||||||
|  |           <gl-link | ||||||
|  |             class="d-flex align-items-baseline text-break-word label-item" | ||||||
|  |             :class="{ 'is-focused': index === currentHighlightItem }" | ||||||
|  |             @click="handleLabelClick(label)" | ||||||
|  |           > | ||||||
|  |             <gl-icon v-show="label.set" name="mobile-issue-close" class="mr-2 align-self-center" /> | ||||||
|  |             <span v-show="!label.set" class="mr-3 pr-2"></span> | ||||||
|  |             <span class="dropdown-label-box" :style="getDropdownLabelBoxStyle(label)"></span> | ||||||
|  |             <span>{{ label.title }}</span> | ||||||
|  |           </gl-link> | ||||||
|  |         </li> | ||||||
|  |         <li v-if="!visibleLabels.length" class="p-2 text-center"> | ||||||
|  |           {{ __('No matching results') }} | ||||||
|  |         </li> | ||||||
|  |       </ul> | ||||||
|  |     </div> | ||||||
|  |     <div class="dropdown-footer"> | ||||||
|  |       <ul class="list-unstyled"> | ||||||
|  |         <li> | ||||||
|  |           <gl-button | ||||||
|  |             variant="link" | ||||||
|  |             class="d-flex w-100 flex-row text-break-word label-item" | ||||||
|  |             @click="toggleDropdownContentsCreateView" | ||||||
|  |             >{{ footerCreateLabelTitle }}</gl-button | ||||||
|  |           > | ||||||
|  |         </li> | ||||||
|  |         <li> | ||||||
|  |           <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item"> | ||||||
|  |             {{ footerManageLabelTitle }} | ||||||
|  |           </gl-link> | ||||||
|  |         </li> | ||||||
|  |       </ul> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,39 @@ | ||||||
|  | <script> | ||||||
|  | import { mapState, mapActions } from 'vuex'; | ||||||
|  | import { GlButton, GlLoadingIcon } from '@gitlab/ui'; | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   components: { | ||||||
|  |     GlButton, | ||||||
|  |     GlLoadingIcon, | ||||||
|  |   }, | ||||||
|  |   props: { | ||||||
|  |     labelsSelectInProgress: { | ||||||
|  |       type: Boolean, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     ...mapState(['allowLabelEdit', 'labelsFetchInProgress']), | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     ...mapActions(['toggleDropdownContents']), | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <div class="title hide-collapsed append-bottom-10"> | ||||||
|  |     {{ __('Labels') }} | ||||||
|  |     <template v-if="allowLabelEdit"> | ||||||
|  |       <gl-loading-icon v-show="labelsSelectInProgress" inline /> | ||||||
|  |       <gl-button | ||||||
|  |         variant="link" | ||||||
|  |         class="pull-right js-sidebar-dropdown-toggle" | ||||||
|  |         data-qa-selector="labels_edit_button" | ||||||
|  |         @click="toggleDropdownContents" | ||||||
|  |         >{{ __('Edit') }}</gl-button | ||||||
|  |       > | ||||||
|  |     </template> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,53 @@ | ||||||
|  | <script> | ||||||
|  | import { mapState } from 'vuex'; | ||||||
|  | import { GlLabel } from '@gitlab/ui'; | ||||||
|  | 
 | ||||||
|  | import { isScopedLabel } from '~/lib/utils/common_utils'; | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   components: { | ||||||
|  |     GlLabel, | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     ...mapState([ | ||||||
|  |       'selectedLabels', | ||||||
|  |       'allowScopedLabels', | ||||||
|  |       'labelsFilterBasePath', | ||||||
|  |       'scopedLabelsDocumentationPath', | ||||||
|  |     ]), | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     labelFilterUrl(label) { | ||||||
|  |       return `${this.labelsFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`; | ||||||
|  |     }, | ||||||
|  |     scopedLabel(label) { | ||||||
|  |       return this.allowScopedLabels && isScopedLabel(label); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     :class="{ | ||||||
|  |       'has-labels': selectedLabels.length, | ||||||
|  |     }" | ||||||
|  |     class="hide-collapsed value issuable-show-labels js-value" | ||||||
|  |   > | ||||||
|  |     <span v-if="!selectedLabels.length" class="text-secondary"> | ||||||
|  |       <slot></slot> | ||||||
|  |     </span> | ||||||
|  |     <template v-for="label in selectedLabels" v-else> | ||||||
|  |       <gl-label | ||||||
|  |         :key="label.id" | ||||||
|  |         :title="label.title" | ||||||
|  |         :description="label.description" | ||||||
|  |         :background-color="label.color" | ||||||
|  |         :target="labelFilterUrl(label)" | ||||||
|  |         :scoped="scopedLabel(label)" | ||||||
|  |         :scoped-labels-documentation-link="scopedLabelsDocumentationPath" | ||||||
|  |         tooltip-placement="top" | ||||||
|  |       /> | ||||||
|  |     </template> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,173 @@ | ||||||
|  | <script> | ||||||
|  | import Vue from 'vue'; | ||||||
|  | import Vuex, { mapState, mapActions } from 'vuex'; | ||||||
|  | import { __ } from '~/locale'; | ||||||
|  | 
 | ||||||
|  | import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; | ||||||
|  | 
 | ||||||
|  | import labelsSelectModule from './store'; | ||||||
|  | 
 | ||||||
|  | import DropdownTitle from './dropdown_title.vue'; | ||||||
|  | import DropdownValue from './dropdown_value.vue'; | ||||||
|  | import DropdownButton from './dropdown_button.vue'; | ||||||
|  | import DropdownContents from './dropdown_contents.vue'; | ||||||
|  | 
 | ||||||
|  | Vue.use(Vuex); | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   store: new Vuex.Store(labelsSelectModule()), | ||||||
|  |   components: { | ||||||
|  |     DropdownTitle, | ||||||
|  |     DropdownValue, | ||||||
|  |     DropdownButton, | ||||||
|  |     DropdownContents, | ||||||
|  |     DropdownValueCollapsed, | ||||||
|  |   }, | ||||||
|  |   props: { | ||||||
|  |     allowLabelEdit: { | ||||||
|  |       type: Boolean, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     allowLabelCreate: { | ||||||
|  |       type: Boolean, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     allowScopedLabels: { | ||||||
|  |       type: Boolean, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     dropdownOnly: { | ||||||
|  |       type: Boolean, | ||||||
|  |       required: false, | ||||||
|  |       default: false, | ||||||
|  |     }, | ||||||
|  |     selectedLabels: { | ||||||
|  |       type: Array, | ||||||
|  |       required: false, | ||||||
|  |       default: () => [], | ||||||
|  |     }, | ||||||
|  |     labelsSelectInProgress: { | ||||||
|  |       type: Boolean, | ||||||
|  |       required: false, | ||||||
|  |       default: false, | ||||||
|  |     }, | ||||||
|  |     labelsFetchPath: { | ||||||
|  |       type: String, | ||||||
|  |       required: false, | ||||||
|  |       default: '', | ||||||
|  |     }, | ||||||
|  |     labelsManagePath: { | ||||||
|  |       type: String, | ||||||
|  |       required: false, | ||||||
|  |       default: '', | ||||||
|  |     }, | ||||||
|  |     labelsFilterBasePath: { | ||||||
|  |       type: String, | ||||||
|  |       required: false, | ||||||
|  |       default: '', | ||||||
|  |     }, | ||||||
|  |     scopedLabelsDocumentationPath: { | ||||||
|  |       type: String, | ||||||
|  |       required: false, | ||||||
|  |       default: '', | ||||||
|  |     }, | ||||||
|  |     labelsListTitle: { | ||||||
|  |       type: String, | ||||||
|  |       required: false, | ||||||
|  |       default: __('Assign labels'), | ||||||
|  |     }, | ||||||
|  |     labelsCreateTitle: { | ||||||
|  |       type: String, | ||||||
|  |       required: false, | ||||||
|  |       default: __('Create group label'), | ||||||
|  |     }, | ||||||
|  |     footerCreateLabelTitle: { | ||||||
|  |       type: String, | ||||||
|  |       required: false, | ||||||
|  |       default: __('Create group label'), | ||||||
|  |     }, | ||||||
|  |     footerManageLabelTitle: { | ||||||
|  |       type: String, | ||||||
|  |       required: false, | ||||||
|  |       default: __('Manage group labels'), | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     ...mapState(['showDropdownButton', 'showDropdownContents']), | ||||||
|  |   }, | ||||||
|  |   watch: { | ||||||
|  |     selectedLabels(selectedLabels) { | ||||||
|  |       this.setInitialState({ | ||||||
|  |         selectedLabels, | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   mounted() { | ||||||
|  |     this.setInitialState({ | ||||||
|  |       dropdownOnly: this.dropdownOnly, | ||||||
|  |       allowLabelEdit: this.allowLabelEdit, | ||||||
|  |       allowLabelCreate: this.allowLabelCreate, | ||||||
|  |       allowScopedLabels: this.allowScopedLabels, | ||||||
|  |       selectedLabels: this.selectedLabels, | ||||||
|  |       labelsFetchPath: this.labelsFetchPath, | ||||||
|  |       labelsManagePath: this.labelsManagePath, | ||||||
|  |       labelsFilterBasePath: this.labelsFilterBasePath, | ||||||
|  |       scopedLabelsDocumentationPath: this.scopedLabelsDocumentationPath, | ||||||
|  |       labelsListTitle: this.labelsListTitle, | ||||||
|  |       labelsCreateTitle: this.labelsCreateTitle, | ||||||
|  |       footerCreateLabelTitle: this.footerCreateLabelTitle, | ||||||
|  |       footerManageLabelTitle: this.footerManageLabelTitle, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     this.$store.subscribeAction({ | ||||||
|  |       after: this.handleVuexActionDispatch, | ||||||
|  |     }); | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     ...mapActions(['setInitialState']), | ||||||
|  |     /** | ||||||
|  |      * This method differentiates between | ||||||
|  |      * dispatched actions and calls necessary method. | ||||||
|  |      */ | ||||||
|  |     handleVuexActionDispatch(action, state) { | ||||||
|  |       if ( | ||||||
|  |         action.type === 'toggleDropdownContents' && | ||||||
|  |         !state.showDropdownButton && | ||||||
|  |         !state.showDropdownContents | ||||||
|  |       ) { | ||||||
|  |         this.handleDropdownClose(state.labels.filter(label => label.touched)); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     handleDropdownClose(labels) { | ||||||
|  |       // Only emit label updates if there are any labels to update | ||||||
|  |       // on UI. | ||||||
|  |       if (labels.length) this.$emit('updateSelectedLabels', labels); | ||||||
|  |       this.$emit('onDropdownClose'); | ||||||
|  |     }, | ||||||
|  |     handleCollapsedValueClick() { | ||||||
|  |       this.$emit('toggleCollapse'); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <div class="labels-select-wrapper position-relative"> | ||||||
|  |     <div v-if="!dropdownOnly"> | ||||||
|  |       <dropdown-value-collapsed | ||||||
|  |         v-if="allowLabelCreate" | ||||||
|  |         :labels="selectedLabels" | ||||||
|  |         @onValueClick="handleCollapsedValueClick" | ||||||
|  |       /> | ||||||
|  |       <dropdown-title | ||||||
|  |         :allow-label-edit="allowLabelEdit" | ||||||
|  |         :labels-select-in-progress="labelsSelectInProgress" | ||||||
|  |       /> | ||||||
|  |       <dropdown-value v-show="!showDropdownButton"> | ||||||
|  |         <slot></slot> | ||||||
|  |       </dropdown-value> | ||||||
|  |       <dropdown-button v-show="showDropdownButton" /> | ||||||
|  |       <dropdown-contents v-if="showDropdownButton && showDropdownContents" /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,61 @@ | ||||||
|  | import flash from '~/flash'; | ||||||
|  | import { __ } from '~/locale'; | ||||||
|  | import axios from '~/lib/utils/axios_utils'; | ||||||
|  | import * as types from './mutation_types'; | ||||||
|  | 
 | ||||||
|  | export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props); | ||||||
|  | 
 | ||||||
|  | export const toggleDropdownButton = ({ commit }) => commit(types.TOGGLE_DROPDOWN_BUTTON); | ||||||
|  | export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS); | ||||||
|  | 
 | ||||||
|  | export const toggleDropdownContentsCreateView = ({ commit }) => | ||||||
|  |   commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW); | ||||||
|  | 
 | ||||||
|  | export const requestLabels = ({ commit }) => commit(types.REQUEST_LABELS); | ||||||
|  | export const receiveLabelsSuccess = ({ commit }, labels) => | ||||||
|  |   commit(types.RECEIVE_SET_LABELS_SUCCESS, labels); | ||||||
|  | export const receiveLabelsFailure = ({ commit }) => { | ||||||
|  |   commit(types.RECEIVE_SET_LABELS_FAILURE); | ||||||
|  |   flash(__('Error fetching labels.')); | ||||||
|  | }; | ||||||
|  | export const fetchLabels = ({ state, dispatch }) => { | ||||||
|  |   dispatch('requestLabels'); | ||||||
|  |   axios | ||||||
|  |     .get(state.labelsFetchPath) | ||||||
|  |     .then(({ data }) => { | ||||||
|  |       dispatch('receiveLabelsSuccess', data); | ||||||
|  |     }) | ||||||
|  |     .catch(() => dispatch('receiveLabelsFailure')); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LABEL); | ||||||
|  | export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS); | ||||||
|  | export const receiveCreateLabelFailure = ({ commit }) => { | ||||||
|  |   commit(types.RECEIVE_CREATE_LABEL_FAILURE); | ||||||
|  |   flash(__('Error creating label.')); | ||||||
|  | }; | ||||||
|  | export const createLabel = ({ state, dispatch }, label) => { | ||||||
|  |   dispatch('requestCreateLabel'); | ||||||
|  |   axios | ||||||
|  |     .post(state.labelsManagePath, { | ||||||
|  |       label, | ||||||
|  |     }) | ||||||
|  |     .then(({ data }) => { | ||||||
|  |       if (data.id) { | ||||||
|  |         dispatch('receiveCreateLabelSuccess'); | ||||||
|  |         dispatch('toggleDropdownContentsCreateView'); | ||||||
|  |       } else { | ||||||
|  |         // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
 | ||||||
|  |         throw new Error('Error Creating Label'); | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |     .catch(() => { | ||||||
|  |       dispatch('receiveCreateLabelFailure'); | ||||||
|  |     }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const updateSelectedLabels = ({ commit }, labels) => | ||||||
|  |   commit(types.UPDATE_SELECTED_LABELS, { labels }); | ||||||
|  | 
 | ||||||
|  | // prevent babel-plugin-rewire from generating an invalid default during karma tests
 | ||||||
|  | export default () => {}; | ||||||
|  | @ -0,0 +1,30 @@ | ||||||
|  | import { __, s__, sprintf } from '~/locale'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Returns string representing current labels | ||||||
|  |  * selection on dropdown button. | ||||||
|  |  * | ||||||
|  |  * @param {object} state | ||||||
|  |  */ | ||||||
|  | export const dropdownButtonText = state => { | ||||||
|  |   const selectedLabels = state.labels.filter(label => label.set); | ||||||
|  |   if (!selectedLabels.length) { | ||||||
|  |     return __('Label'); | ||||||
|  |   } else if (selectedLabels.length > 1) { | ||||||
|  |     return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { | ||||||
|  |       firstLabelName: selectedLabels[0].title, | ||||||
|  |       remainingLabelCount: selectedLabels.length - 1, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |   return selectedLabels[0].title; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Returns array containing only label IDs from | ||||||
|  |  * selectedLabels array. | ||||||
|  |  * @param {object} state | ||||||
|  |  */ | ||||||
|  | export const selectedLabelsList = state => state.selectedLabels.map(label => label.id); | ||||||
|  | 
 | ||||||
|  | // prevent babel-plugin-rewire from generating an invalid default during karma tests
 | ||||||
|  | export default () => {}; | ||||||
|  | @ -0,0 +1,12 @@ | ||||||
|  | import * as actions from './actions'; | ||||||
|  | import * as getters from './getters'; | ||||||
|  | import mutations from './mutations'; | ||||||
|  | import state from './state'; | ||||||
|  | 
 | ||||||
|  | export default () => ({ | ||||||
|  |   namespaced: true, | ||||||
|  |   state: state(), | ||||||
|  |   actions, | ||||||
|  |   getters, | ||||||
|  |   mutations, | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,20 @@ | ||||||
|  | export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; | ||||||
|  | 
 | ||||||
|  | export const REQUEST_LABELS = 'REQUEST_LABELS'; | ||||||
|  | export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS'; | ||||||
|  | export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE'; | ||||||
|  | 
 | ||||||
|  | export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS'; | ||||||
|  | export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS'; | ||||||
|  | export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE'; | ||||||
|  | 
 | ||||||
|  | export const REQUEST_CREATE_LABEL = 'REQUEST_CREATE_LABEL'; | ||||||
|  | export const RECEIVE_CREATE_LABEL_SUCCESS = 'RECEIVE_CREATE_LABEL_SUCCESS'; | ||||||
|  | export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE'; | ||||||
|  | 
 | ||||||
|  | export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY'; | ||||||
|  | export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS'; | ||||||
|  | 
 | ||||||
|  | export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS'; | ||||||
|  | 
 | ||||||
|  | export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW'; | ||||||
|  | @ -0,0 +1,76 @@ | ||||||
|  | import * as types from './mutation_types'; | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   [types.SET_INITIAL_STATE](state, props) { | ||||||
|  |     Object.assign(state, { ...props }); | ||||||
|  |   }, | ||||||
|  | 
 | ||||||
|  |   [types.TOGGLE_DROPDOWN_BUTTON](state) { | ||||||
|  |     state.showDropdownButton = !state.showDropdownButton; | ||||||
|  |   }, | ||||||
|  | 
 | ||||||
|  |   [types.TOGGLE_DROPDOWN_CONTENTS](state) { | ||||||
|  |     if (!state.dropdownOnly) { | ||||||
|  |       state.showDropdownButton = !state.showDropdownButton; | ||||||
|  |     } | ||||||
|  |     state.showDropdownContents = !state.showDropdownContents; | ||||||
|  |     // Ensure that Create View is hidden by default
 | ||||||
|  |     // when dropdown contents are revealed.
 | ||||||
|  |     if (state.showDropdownContents) { | ||||||
|  |       state.showDropdownContentsCreateView = false; | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | 
 | ||||||
|  |   [types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) { | ||||||
|  |     state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView; | ||||||
|  |   }, | ||||||
|  | 
 | ||||||
|  |   [types.REQUEST_LABELS](state) { | ||||||
|  |     state.labelsFetchInProgress = true; | ||||||
|  |   }, | ||||||
|  |   [types.RECEIVE_SET_LABELS_SUCCESS](state, labels) { | ||||||
|  |     // Iterate over every label and add a `set` prop
 | ||||||
|  |     // to determine whether it is already a part of
 | ||||||
|  |     // selectedLabels array.
 | ||||||
|  |     const selectedLabelIds = state.selectedLabels.map(label => label.id); | ||||||
|  |     state.labelsFetchInProgress = false; | ||||||
|  |     state.labels = labels.reduce((allLabels, label) => { | ||||||
|  |       allLabels.push({ | ||||||
|  |         ...label, | ||||||
|  |         set: selectedLabelIds.includes(label.id), | ||||||
|  |       }); | ||||||
|  |       return allLabels; | ||||||
|  |     }, []); | ||||||
|  |   }, | ||||||
|  |   [types.RECEIVE_SET_LABELS_FAILURE](state) { | ||||||
|  |     state.labelsFetchInProgress = false; | ||||||
|  |   }, | ||||||
|  | 
 | ||||||
|  |   [types.REQUEST_CREATE_LABEL](state) { | ||||||
|  |     state.labelCreateInProgress = true; | ||||||
|  |   }, | ||||||
|  |   [types.RECEIVE_CREATE_LABEL_SUCCESS](state) { | ||||||
|  |     state.labelCreateInProgress = false; | ||||||
|  |   }, | ||||||
|  |   [types.RECEIVE_CREATE_LABEL_FAILURE](state) { | ||||||
|  |     state.labelCreateInProgress = false; | ||||||
|  |   }, | ||||||
|  | 
 | ||||||
|  |   [types.UPDATE_SELECTED_LABELS](state, { labels }) { | ||||||
|  |     // Iterate over all the labels and update
 | ||||||
|  |     // `set` prop value to represent their current state.
 | ||||||
|  |     const labelIds = labels.map(label => label.id); | ||||||
|  |     state.labels = state.labels.reduce((allLabels, label) => { | ||||||
|  |       if (labelIds.includes(label.id)) { | ||||||
|  |         allLabels.push({ | ||||||
|  |           ...label, | ||||||
|  |           touched: true, | ||||||
|  |           set: !label.set, | ||||||
|  |         }); | ||||||
|  |       } else { | ||||||
|  |         allLabels.push(label); | ||||||
|  |       } | ||||||
|  |       return allLabels; | ||||||
|  |     }, []); | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | @ -0,0 +1,27 @@ | ||||||
|  | export default () => ({ | ||||||
|  |   // Initial Data
 | ||||||
|  |   labels: [], | ||||||
|  |   selectedLabels: [], | ||||||
|  |   labelsListTitle: '', | ||||||
|  |   labelsCreateTitle: '', | ||||||
|  |   footerCreateLabelTitle: '', | ||||||
|  |   footerManageLabelTitle: '', | ||||||
|  | 
 | ||||||
|  |   // Paths
 | ||||||
|  |   namespace: '', | ||||||
|  |   labelsFetchPath: '', | ||||||
|  |   labelsFilterBasePath: '', | ||||||
|  |   scopedLabelsDocumentationPath: '#', | ||||||
|  | 
 | ||||||
|  |   // UI Flags
 | ||||||
|  |   allowLabelCreate: false, | ||||||
|  |   allowLabelEdit: false, | ||||||
|  |   allowScopedLabels: false, | ||||||
|  |   dropdownOnly: false, | ||||||
|  |   showDropdownButton: false, | ||||||
|  |   showDropdownContents: false, | ||||||
|  |   showDropdownContentsCreateView: false, | ||||||
|  |   labelsFetchInProgress: false, | ||||||
|  |   labelCreateInProgress: false, | ||||||
|  |   selectedLabelsUpdated: false, | ||||||
|  | }); | ||||||
|  | @ -1019,3 +1019,54 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { | ||||||
|     opacity: 0; |     opacity: 0; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .labels-select-wrapper { | ||||||
|  |   .labels-select-dropdown-contents { | ||||||
|  |     min-height: $dropdown-min-height; | ||||||
|  |     max-height: 330px; | ||||||
|  |     background-color: $white-light; | ||||||
|  |     border: 1px solid $border-color; | ||||||
|  |     box-shadow: 0 2px 4px $dropdown-shadow-color; | ||||||
|  |     z-index: 2; | ||||||
|  | 
 | ||||||
|  |     .dropdown-content { | ||||||
|  |       height: 135px; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .labels-fetch-loading { | ||||||
|  |     top: 0; | ||||||
|  |     left: 0; | ||||||
|  |     opacity: 0.5; | ||||||
|  |     background-color: $white-light; | ||||||
|  |     z-index: 1; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .dropdown-header-button { | ||||||
|  |     .gl-icon { | ||||||
|  |       color: $dropdown-title-btn-color; | ||||||
|  | 
 | ||||||
|  |       &:hover { | ||||||
|  |         color: $gl-gray-400; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .label-item { | ||||||
|  |     padding: 8px 20px; | ||||||
|  | 
 | ||||||
|  |     &:hover, | ||||||
|  |     &.is-focused { | ||||||
|  |       @include dropdown-item-hover; | ||||||
|  | 
 | ||||||
|  |       text-decoration: none; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .color-input-container { | ||||||
|  |     .dropdown-label-color-preview { | ||||||
|  |       border: 1px solid $gray-200; | ||||||
|  |       border-right: 0; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -281,11 +281,10 @@ class Snippet < ApplicationRecord | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def create_repository |   def create_repository | ||||||
|     return if repository_exists? |     return if repository_exists? && snippet_repository | ||||||
| 
 | 
 | ||||||
|     repository.create_if_not_exists |     repository.create_if_not_exists | ||||||
| 
 |     track_snippet_repository | ||||||
|     track_snippet_repository if repository_exists? |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def track_snippet_repository |   def track_snippet_repository | ||||||
|  |  | ||||||
|  | @ -4,6 +4,9 @@ module Snippets | ||||||
|   class UpdateService < Snippets::BaseService |   class UpdateService < Snippets::BaseService | ||||||
|     include SpamCheckMethods |     include SpamCheckMethods | ||||||
| 
 | 
 | ||||||
|  |     UpdateError = Class.new(StandardError) | ||||||
|  |     CreateRepositoryError = Class.new(StandardError) | ||||||
|  | 
 | ||||||
|     def execute(snippet) |     def execute(snippet) | ||||||
|       # check that user is allowed to set specified visibility_level |       # check that user is allowed to set specified visibility_level | ||||||
|       new_visibility = visibility_level |       new_visibility = visibility_level | ||||||
|  | @ -20,11 +23,7 @@ module Snippets | ||||||
|       snippet.assign_attributes(params) |       snippet.assign_attributes(params) | ||||||
|       spam_check(snippet, current_user) |       spam_check(snippet, current_user) | ||||||
| 
 | 
 | ||||||
|       snippet_saved = snippet.with_transaction_returning_status do |       if save_and_commit(snippet) | ||||||
|         snippet.save |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       if snippet_saved |  | ||||||
|         Gitlab::UsageDataCounters::SnippetCounter.count(:update) |         Gitlab::UsageDataCounters::SnippetCounter.count(:update) | ||||||
| 
 | 
 | ||||||
|         ServiceResponse.success(payload: { snippet: snippet } ) |         ServiceResponse.success(payload: { snippet: snippet } ) | ||||||
|  | @ -32,5 +31,54 @@ module Snippets | ||||||
|         snippet_error_response(snippet, 400) |         snippet_error_response(snippet, 400) | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     private | ||||||
|  | 
 | ||||||
|  |     def save_and_commit(snippet) | ||||||
|  |       snippet.with_transaction_returning_status do | ||||||
|  |         snippet.save.tap do |saved| | ||||||
|  |           break false unless saved | ||||||
|  | 
 | ||||||
|  |           # In order to avoid non migrated snippets scenarios, | ||||||
|  |           # if the snippet does not have a repository we created it | ||||||
|  |           # We don't need to check if the repository exists | ||||||
|  |           # because `create_repository` already handles it | ||||||
|  |           if Feature.enabled?(:version_snippets, current_user) | ||||||
|  |             create_repository_for(snippet) | ||||||
|  |           end | ||||||
|  | 
 | ||||||
|  |           # If the snippet repository exists we commit always | ||||||
|  |           # the changes | ||||||
|  |           create_commit(snippet) if snippet.repository_exists? | ||||||
|  |         end | ||||||
|  |       rescue | ||||||
|  |         snippet.errors.add(:base, 'Error updating the snippet') | ||||||
|  | 
 | ||||||
|  |         false | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def create_repository_for(snippet) | ||||||
|  |       snippet.create_repository | ||||||
|  | 
 | ||||||
|  |       raise CreateRepositoryError, 'Repository could not be created' unless snippet.repository_exists? | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def create_commit(snippet) | ||||||
|  |       raise UpdateError unless snippet.snippet_repository | ||||||
|  | 
 | ||||||
|  |       commit_attrs = { | ||||||
|  |         branch_name: 'master', | ||||||
|  |         message: 'Update snippet' | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       snippet.snippet_repository.multi_files_action(current_user, snippet_files(snippet), commit_attrs) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def snippet_files(snippet) | ||||||
|  |       [{ previous_path: snippet.blobs.first&.path, | ||||||
|  |          file_path: params[:file_name], | ||||||
|  |          content: params[:content] }] | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | --- | ||||||
|  | title: Special handling for the rich viewer on specific file types | ||||||
|  | merge_request: 26260 | ||||||
|  | author: | ||||||
|  | type: changed | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | --- | ||||||
|  | title: Update files when snippet is updated | ||||||
|  | merge_request: 23993 | ||||||
|  | author: | ||||||
|  | type: changed | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | --- | ||||||
|  | title: Make design_management_versions.created_at not null | ||||||
|  | merge_request: 20182 | ||||||
|  | author: Lee Tickett | ||||||
|  | type: other | ||||||
|  | @ -0,0 +1,15 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class MakeCreatedAtNotNullInDesignManagementVersions < ActiveRecord::Migration[5.2] | ||||||
|  |   include Gitlab::Database::MigrationHelpers | ||||||
|  | 
 | ||||||
|  |   DOWNTIME = false | ||||||
|  | 
 | ||||||
|  |   def up | ||||||
|  |     change_column_null :design_management_versions, :created_at, false, Time.now.to_s(:db) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     change_column_null :design_management_versions, :created_at, true | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -1446,7 +1446,7 @@ ActiveRecord::Schema.define(version: 2020_03_03_074328) do | ||||||
|   create_table "design_management_versions", force: :cascade do |t| |   create_table "design_management_versions", force: :cascade do |t| | ||||||
|     t.binary "sha", null: false |     t.binary "sha", null: false | ||||||
|     t.bigint "issue_id" |     t.bigint "issue_id" | ||||||
|     t.datetime_with_timezone "created_at" |     t.datetime_with_timezone "created_at", null: false | ||||||
|     t.integer "author_id" |     t.integer "author_id" | ||||||
|     t.index ["author_id"], name: "index_design_management_versions_on_author_id", where: "(author_id IS NOT NULL)" |     t.index ["author_id"], name: "index_design_management_versions_on_author_id", where: "(author_id IS NOT NULL)" | ||||||
|     t.index ["issue_id"], name: "index_design_management_versions_on_issue_id" |     t.index ["issue_id"], name: "index_design_management_versions_on_issue_id" | ||||||
|  |  | ||||||
|  | @ -71,12 +71,6 @@ the need as part of the product in a future version of GitLab! | ||||||
| Implement each task as an isolated piece of functionality and place it in its | Implement each task as an isolated piece of functionality and place it in its | ||||||
| own directory under `danger` as `danger/<task-name>/Dangerfile`. | own directory under `danger` as `danger/<task-name>/Dangerfile`. | ||||||
| 
 | 
 | ||||||
| Add a line to the top-level `Dangerfile` to ensure it is loaded like: |  | ||||||
| 
 |  | ||||||
| ```ruby |  | ||||||
| danger.import_dangerfile('danger/<task-name>') |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| Each task should be isolated from the others, and able to function in isolation. | Each task should be isolated from the others, and able to function in isolation. | ||||||
| If there is code that should be shared between multiple tasks, add a plugin to | If there is code that should be shared between multiple tasks, add a plugin to | ||||||
| `danger/plugins/...` and require it in each task that needs it. You can also | `danger/plugins/...` and require it in each task that needs it. You can also | ||||||
|  |  | ||||||
|  | @ -7778,6 +7778,9 @@ msgstr "" | ||||||
| msgid "Error creating epic" | msgid "Error creating epic" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | msgid "Error creating label." | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
| msgid "Error deleting  %{issuableType}" | msgid "Error deleting  %{issuableType}" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | @ -21315,6 +21318,9 @@ msgstr "" | ||||||
| msgid "Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA)." | msgid "Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA)." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | msgid "Use custom color #FF0000" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
| msgid "Use group milestones to manage issues from multiple projects in the same milestone." | msgid "Use group milestones to manage issues from multiple projects in the same milestone." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,14 +1,19 @@ | ||||||
| import { shallowMount } from '@vue/test-utils'; | import { shallowMount } from '@vue/test-utils'; | ||||||
| import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue'; | import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue'; | ||||||
|  | import { handleBlobRichViewer } from '~/blob/viewer'; | ||||||
|  | 
 | ||||||
|  | jest.mock('~/blob/viewer'); | ||||||
| 
 | 
 | ||||||
| describe('Blob Rich Viewer component', () => { | describe('Blob Rich Viewer component', () => { | ||||||
|   let wrapper; |   let wrapper; | ||||||
|   const content = '<h1 id="markdown">Foo Bar</h1>'; |   const content = '<h1 id="markdown">Foo Bar</h1>'; | ||||||
|  |   const defaultType = 'markdown'; | ||||||
| 
 | 
 | ||||||
|   function createComponent() { |   function createComponent(type = defaultType) { | ||||||
|     wrapper = shallowMount(RichViewer, { |     wrapper = shallowMount(RichViewer, { | ||||||
|       propsData: { |       propsData: { | ||||||
|         content, |         content, | ||||||
|  |         type, | ||||||
|       }, |       }, | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  | @ -24,4 +29,8 @@ describe('Blob Rich Viewer component', () => { | ||||||
|   it('renders the passed content without transformations', () => { |   it('renders the passed content without transformations', () => { | ||||||
|     expect(wrapper.html()).toContain(content); |     expect(wrapper.html()).toContain(content); | ||||||
|   }); |   }); | ||||||
|  | 
 | ||||||
|  |   it('queries for advanced viewer', () => { | ||||||
|  |     expect(handleBlobRichViewer).toHaveBeenCalledWith(expect.anything(), defaultType); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -10,6 +10,7 @@ describe('Blob Simple Viewer component', () => { | ||||||
|     wrapper = shallowMount(SimpleViewer, { |     wrapper = shallowMount(SimpleViewer, { | ||||||
|       propsData: { |       propsData: { | ||||||
|         content, |         content, | ||||||
|  |         type: 'text', | ||||||
|       }, |       }, | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -0,0 +1,55 @@ | ||||||
|  | import Vuex from 'vuex'; | ||||||
|  | import { shallowMount, createLocalVue } from '@vue/test-utils'; | ||||||
|  | 
 | ||||||
|  | import { GlIcon } from '@gitlab/ui'; | ||||||
|  | import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue'; | ||||||
|  | 
 | ||||||
|  | import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; | ||||||
|  | 
 | ||||||
|  | import { mockConfig } from './mock_data'; | ||||||
|  | 
 | ||||||
|  | const localVue = createLocalVue(); | ||||||
|  | localVue.use(Vuex); | ||||||
|  | 
 | ||||||
|  | const createComponent = (initialState = mockConfig) => { | ||||||
|  |   const store = new Vuex.Store(labelSelectModule()); | ||||||
|  | 
 | ||||||
|  |   store.dispatch('setInitialState', initialState); | ||||||
|  | 
 | ||||||
|  |   return shallowMount(DropdownButton, { | ||||||
|  |     localVue, | ||||||
|  |     store, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | describe('DropdownButton', () => { | ||||||
|  |   let wrapper; | ||||||
|  | 
 | ||||||
|  |   beforeEach(() => { | ||||||
|  |     wrapper = createComponent(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   afterEach(() => { | ||||||
|  |     wrapper.destroy(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('template', () => { | ||||||
|  |     it('renders component container element', () => { | ||||||
|  |       expect(wrapper.is('gl-button-stub')).toBe(true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders button text element', () => { | ||||||
|  |       const dropdownTextEl = wrapper.find('.dropdown-toggle-text'); | ||||||
|  | 
 | ||||||
|  |       expect(dropdownTextEl.exists()).toBe(true); | ||||||
|  |       expect(dropdownTextEl.text()).toBe('Label'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders chevron icon element', () => { | ||||||
|  |       const iconEl = wrapper.find(GlIcon); | ||||||
|  | 
 | ||||||
|  |       expect(iconEl.exists()).toBe(true); | ||||||
|  |       expect(iconEl.props('name')).toBe('chevron-down'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,223 @@ | ||||||
|  | import Vuex from 'vuex'; | ||||||
|  | import { shallowMount, createLocalVue } from '@vue/test-utils'; | ||||||
|  | 
 | ||||||
|  | import { GlButton, GlIcon, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; | ||||||
|  | import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue'; | ||||||
|  | 
 | ||||||
|  | import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; | ||||||
|  | 
 | ||||||
|  | import { mockConfig, mockSuggestedColors } from './mock_data'; | ||||||
|  | 
 | ||||||
|  | const localVue = createLocalVue(); | ||||||
|  | localVue.use(Vuex); | ||||||
|  | 
 | ||||||
|  | const createComponent = (initialState = mockConfig) => { | ||||||
|  |   const store = new Vuex.Store(labelSelectModule()); | ||||||
|  | 
 | ||||||
|  |   store.dispatch('setInitialState', initialState); | ||||||
|  | 
 | ||||||
|  |   return shallowMount(DropdownContentsCreateView, { | ||||||
|  |     localVue, | ||||||
|  |     store, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | describe('DropdownContentsCreateView', () => { | ||||||
|  |   let wrapper; | ||||||
|  |   const colors = Object.keys(mockSuggestedColors).map(color => ({ | ||||||
|  |     [color]: mockSuggestedColors[color], | ||||||
|  |   })); | ||||||
|  | 
 | ||||||
|  |   beforeEach(() => { | ||||||
|  |     gon.suggested_label_colors = mockSuggestedColors; | ||||||
|  |     wrapper = createComponent(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   afterEach(() => { | ||||||
|  |     wrapper.destroy(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('computed', () => { | ||||||
|  |     describe('disableCreate', () => { | ||||||
|  |       it('returns `true` when label title and color is not defined', () => { | ||||||
|  |         expect(wrapper.vm.disableCreate).toBe(true); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('returns `true` when `labelCreateInProgress` is true', () => { | ||||||
|  |         wrapper.setData({ | ||||||
|  |           labelTitle: 'Foo', | ||||||
|  |           selectedColor: '#ff0000', | ||||||
|  |         }); | ||||||
|  |         wrapper.vm.$store.dispatch('requestCreateLabel'); | ||||||
|  | 
 | ||||||
|  |         return wrapper.vm.$nextTick(() => { | ||||||
|  |           expect(wrapper.vm.disableCreate).toBe(true); | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('returns `false` when label title and color is defined and create request is not already in progress', () => { | ||||||
|  |         wrapper.setData({ | ||||||
|  |           labelTitle: 'Foo', | ||||||
|  |           selectedColor: '#ff0000', | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         return wrapper.vm.$nextTick(() => { | ||||||
|  |           expect(wrapper.vm.disableCreate).toBe(false); | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('suggestedColors', () => { | ||||||
|  |       it('returns array of color objects containing color code and name', () => { | ||||||
|  |         colors.forEach((color, index) => { | ||||||
|  |           expect(wrapper.vm.suggestedColors[index]).toEqual(expect.objectContaining(color)); | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('methods', () => { | ||||||
|  |     describe('getColorCode', () => { | ||||||
|  |       it('returns color code from color object', () => { | ||||||
|  |         expect(wrapper.vm.getColorCode(colors[0])).toBe(Object.keys(colors[0]).pop()); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('getColorName', () => { | ||||||
|  |       it('returns color name from color object', () => { | ||||||
|  |         expect(wrapper.vm.getColorName(colors[0])).toBe(Object.values(colors[0]).pop()); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('handleColorClick', () => { | ||||||
|  |       it('sets provided `color` param to `selectedColor` prop', () => { | ||||||
|  |         wrapper.vm.handleColorClick(colors[0]); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.vm.selectedColor).toBe(Object.keys(colors[0]).pop()); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('handleCreateClick', () => { | ||||||
|  |       it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', () => { | ||||||
|  |         jest.spyOn(wrapper.vm, 'createLabel').mockImplementation(); | ||||||
|  |         wrapper.setData({ | ||||||
|  |           labelTitle: 'Foo', | ||||||
|  |           selectedColor: '#ff0000', | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         wrapper.vm.handleCreateClick(); | ||||||
|  | 
 | ||||||
|  |         return wrapper.vm.$nextTick(() => { | ||||||
|  |           expect(wrapper.vm.createLabel).toHaveBeenCalledWith( | ||||||
|  |             expect.objectContaining({ | ||||||
|  |               title: 'Foo', | ||||||
|  |               color: '#ff0000', | ||||||
|  |             }), | ||||||
|  |           ); | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('template', () => { | ||||||
|  |     it('renders component container element with class "labels-select-contents-create"', () => { | ||||||
|  |       expect(wrapper.attributes('class')).toContain('labels-select-contents-create'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders dropdown back button element', () => { | ||||||
|  |       const backBtnEl = wrapper | ||||||
|  |         .find('.dropdown-title') | ||||||
|  |         .findAll(GlButton) | ||||||
|  |         .at(0); | ||||||
|  | 
 | ||||||
|  |       expect(backBtnEl.exists()).toBe(true); | ||||||
|  |       expect(backBtnEl.attributes('aria-label')).toBe('Go back'); | ||||||
|  |       expect(backBtnEl.find(GlIcon).props('name')).toBe('arrow-left'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders dropdown title element', () => { | ||||||
|  |       const headerEl = wrapper.find('.dropdown-title > span'); | ||||||
|  | 
 | ||||||
|  |       expect(headerEl.exists()).toBe(true); | ||||||
|  |       expect(headerEl.text()).toBe('Create label'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders dropdown close button element', () => { | ||||||
|  |       const closeBtnEl = wrapper | ||||||
|  |         .find('.dropdown-title') | ||||||
|  |         .findAll(GlButton) | ||||||
|  |         .at(1); | ||||||
|  | 
 | ||||||
|  |       expect(closeBtnEl.exists()).toBe(true); | ||||||
|  |       expect(closeBtnEl.attributes('aria-label')).toBe('Close'); | ||||||
|  |       expect(closeBtnEl.find(GlIcon).props('name')).toBe('close'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders label title input element', () => { | ||||||
|  |       const titleInputEl = wrapper.find('.dropdown-input').find(GlFormInput); | ||||||
|  | 
 | ||||||
|  |       expect(titleInputEl.exists()).toBe(true); | ||||||
|  |       expect(titleInputEl.attributes('placeholder')).toBe('Name new label'); | ||||||
|  |       expect(titleInputEl.attributes('autofocus')).toBe('true'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders color block element for all suggested colors', () => { | ||||||
|  |       const colorBlocksEl = wrapper.find('.dropdown-content').findAll(GlLink); | ||||||
|  | 
 | ||||||
|  |       colorBlocksEl.wrappers.forEach((colorBlock, index) => { | ||||||
|  |         expect(colorBlock.attributes('style')).toContain('background-color'); | ||||||
|  |         expect(colorBlock.attributes('title')).toBe(Object.values(colors[index]).pop()); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders color input element', () => { | ||||||
|  |       wrapper.setData({ | ||||||
|  |         selectedColor: '#ff0000', | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       return wrapper.vm.$nextTick(() => { | ||||||
|  |         const colorPreviewEl = wrapper.find( | ||||||
|  |           '.color-input-container > .dropdown-label-color-preview', | ||||||
|  |         ); | ||||||
|  |         const colorInputEl = wrapper.find('.color-input-container').find(GlFormInput); | ||||||
|  | 
 | ||||||
|  |         expect(colorPreviewEl.exists()).toBe(true); | ||||||
|  |         expect(colorPreviewEl.attributes('style')).toContain('background-color'); | ||||||
|  |         expect(colorInputEl.exists()).toBe(true); | ||||||
|  |         expect(colorInputEl.attributes('placeholder')).toBe('Use custom color #FF0000'); | ||||||
|  |         expect(colorInputEl.attributes('value')).toBe('#ff0000'); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders create button element', () => { | ||||||
|  |       const createBtnEl = wrapper | ||||||
|  |         .find('.dropdown-actions') | ||||||
|  |         .findAll(GlButton) | ||||||
|  |         .at(0); | ||||||
|  | 
 | ||||||
|  |       expect(createBtnEl.exists()).toBe(true); | ||||||
|  |       expect(createBtnEl.text()).toContain('Create'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('shows gl-loading-icon within create button element when `labelCreateInProgress` is `true`', () => { | ||||||
|  |       wrapper.vm.$store.dispatch('requestCreateLabel'); | ||||||
|  | 
 | ||||||
|  |       return wrapper.vm.$nextTick(() => { | ||||||
|  |         const loadingIconEl = wrapper.find('.dropdown-actions').find(GlLoadingIcon); | ||||||
|  | 
 | ||||||
|  |         expect(loadingIconEl.exists()).toBe(true); | ||||||
|  |         expect(loadingIconEl.isVisible()).toBe(true); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders cancel button element', () => { | ||||||
|  |       const cancelBtnEl = wrapper | ||||||
|  |         .find('.dropdown-actions') | ||||||
|  |         .findAll(GlButton) | ||||||
|  |         .at(1); | ||||||
|  | 
 | ||||||
|  |       expect(cancelBtnEl.exists()).toBe(true); | ||||||
|  |       expect(cancelBtnEl.text()).toContain('Cancel'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,265 @@ | ||||||
|  | import Vuex from 'vuex'; | ||||||
|  | import { shallowMount, createLocalVue } from '@vue/test-utils'; | ||||||
|  | 
 | ||||||
|  | import { GlButton, GlLoadingIcon, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; | ||||||
|  | import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; | ||||||
|  | import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue'; | ||||||
|  | 
 | ||||||
|  | import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state'; | ||||||
|  | import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations'; | ||||||
|  | import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions'; | ||||||
|  | import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/store/getters'; | ||||||
|  | 
 | ||||||
|  | import { mockConfig, mockLabels, mockRegularLabel } from './mock_data'; | ||||||
|  | 
 | ||||||
|  | const localVue = createLocalVue(); | ||||||
|  | localVue.use(Vuex); | ||||||
|  | 
 | ||||||
|  | const createComponent = (initialState = mockConfig) => { | ||||||
|  |   const store = new Vuex.Store({ | ||||||
|  |     getters, | ||||||
|  |     mutations, | ||||||
|  |     state: { | ||||||
|  |       ...defaultState(), | ||||||
|  |       footerCreateLabelTitle: 'Create label', | ||||||
|  |       footerManageLabelTitle: 'Manage labels', | ||||||
|  |     }, | ||||||
|  |     actions: { | ||||||
|  |       ...actions, | ||||||
|  |       fetchLabels: jest.fn(), | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   store.dispatch('setInitialState', initialState); | ||||||
|  |   store.dispatch('receiveLabelsSuccess', mockLabels); | ||||||
|  | 
 | ||||||
|  |   return shallowMount(DropdownContentsLabelsView, { | ||||||
|  |     localVue, | ||||||
|  |     store, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | describe('DropdownContentsLabelsView', () => { | ||||||
|  |   let wrapper; | ||||||
|  | 
 | ||||||
|  |   beforeEach(() => { | ||||||
|  |     wrapper = createComponent(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   afterEach(() => { | ||||||
|  |     wrapper.destroy(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('computed', () => { | ||||||
|  |     describe('visibleLabels', () => { | ||||||
|  |       it('returns matching labels filtered with `searchKey`', () => { | ||||||
|  |         wrapper.setData({ | ||||||
|  |           searchKey: 'bug', | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.vm.visibleLabels.length).toBe(1); | ||||||
|  |         expect(wrapper.vm.visibleLabels[0].title).toBe('Bug'); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('returns all labels when `searchKey` is empty', () => { | ||||||
|  |         wrapper.setData({ | ||||||
|  |           searchKey: '', | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('methods', () => { | ||||||
|  |     describe('getDropdownLabelBoxStyle', () => { | ||||||
|  |       it('returns an object containing `backgroundColor` based on provided `label` param', () => { | ||||||
|  |         expect(wrapper.vm.getDropdownLabelBoxStyle(mockRegularLabel)).toEqual( | ||||||
|  |           expect.objectContaining({ | ||||||
|  |             backgroundColor: mockRegularLabel.color, | ||||||
|  |           }), | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('isLabelSelected', () => { | ||||||
|  |       it('returns true when provided `label` param is one of the selected labels', () => { | ||||||
|  |         expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('returns false when provided `label` param is not one of the selected labels', () => { | ||||||
|  |         expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('handleKeyDown', () => { | ||||||
|  |       it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => { | ||||||
|  |         wrapper.setData({ | ||||||
|  |           currentHighlightItem: 1, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         wrapper.vm.handleKeyDown({ | ||||||
|  |           keyCode: UP_KEY_CODE, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.vm.currentHighlightItem).toBe(0); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => { | ||||||
|  |         wrapper.setData({ | ||||||
|  |           currentHighlightItem: 1, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         wrapper.vm.handleKeyDown({ | ||||||
|  |           keyCode: DOWN_KEY_CODE, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.vm.currentHighlightItem).toBe(2); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { | ||||||
|  |         jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); | ||||||
|  |         wrapper.setData({ | ||||||
|  |           currentHighlightItem: 1, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         wrapper.vm.handleKeyDown({ | ||||||
|  |           keyCode: ENTER_KEY_CODE, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([ | ||||||
|  |           { | ||||||
|  |             ...mockLabels[1], | ||||||
|  |             set: true, | ||||||
|  |           }, | ||||||
|  |         ]); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('calls action `toggleDropdownContents` when Esc key is pressed', () => { | ||||||
|  |         jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation(); | ||||||
|  |         wrapper.setData({ | ||||||
|  |           currentHighlightItem: 1, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         wrapper.vm.handleKeyDown({ | ||||||
|  |           keyCode: ESC_KEY_CODE, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled(); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => { | ||||||
|  |         jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation(); | ||||||
|  |         wrapper.setData({ | ||||||
|  |           currentHighlightItem: 1, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         wrapper.vm.handleKeyDown({ | ||||||
|  |           keyCode: DOWN_KEY_CODE, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         return wrapper.vm.$nextTick(() => { | ||||||
|  |           expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled(); | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('handleLabelClick', () => { | ||||||
|  |       it('calls action `updateSelectedLabels` with provided `label` param', () => { | ||||||
|  |         jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); | ||||||
|  | 
 | ||||||
|  |         wrapper.vm.handleLabelClick(mockRegularLabel); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('template', () => { | ||||||
|  |     it('renders component container element with class `labels-select-contents-list`', () => { | ||||||
|  |       expect(wrapper.attributes('class')).toContain('labels-select-contents-list'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => { | ||||||
|  |       wrapper.vm.$store.dispatch('requestLabels'); | ||||||
|  | 
 | ||||||
|  |       return wrapper.vm.$nextTick(() => { | ||||||
|  |         const loadingIconEl = wrapper.find(GlLoadingIcon); | ||||||
|  | 
 | ||||||
|  |         expect(loadingIconEl.exists()).toBe(true); | ||||||
|  |         expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading'); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders dropdown title element', () => { | ||||||
|  |       const titleEl = wrapper.find('.dropdown-title > span'); | ||||||
|  | 
 | ||||||
|  |       expect(titleEl.exists()).toBe(true); | ||||||
|  |       expect(titleEl.text()).toBe('Assign labels'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders dropdown close button element', () => { | ||||||
|  |       const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton); | ||||||
|  | 
 | ||||||
|  |       expect(closeButtonEl.exists()).toBe(true); | ||||||
|  |       expect(closeButtonEl.find(GlIcon).exists()).toBe(true); | ||||||
|  |       expect(closeButtonEl.find(GlIcon).props('name')).toBe('close'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders label search input element', () => { | ||||||
|  |       const searchInputEl = wrapper.find(GlSearchBoxByType); | ||||||
|  | 
 | ||||||
|  |       expect(searchInputEl.exists()).toBe(true); | ||||||
|  |       expect(searchInputEl.attributes('autofocus')).toBe('true'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders label elements for all labels', () => { | ||||||
|  |       const labelsEl = wrapper.findAll('.dropdown-content li'); | ||||||
|  |       const labelItemEl = labelsEl.at(0).find(GlLink); | ||||||
|  | 
 | ||||||
|  |       expect(labelsEl.length).toBe(mockLabels.length); | ||||||
|  |       expect(labelItemEl.exists()).toBe(true); | ||||||
|  |       expect(labelItemEl.find(GlIcon).props('name')).toBe('mobile-issue-close'); | ||||||
|  |       expect(labelItemEl.find('.dropdown-label-box').attributes('style')).toBe( | ||||||
|  |         'background-color: rgb(186, 218, 85);', | ||||||
|  |       ); | ||||||
|  |       expect(labelItemEl.find(GlLink).text()).toContain(mockLabels[0].title); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => { | ||||||
|  |       wrapper.setData({ | ||||||
|  |         currentHighlightItem: 0, | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       return wrapper.vm.$nextTick(() => { | ||||||
|  |         const labelsEl = wrapper.findAll('.dropdown-content li'); | ||||||
|  |         const labelItemEl = labelsEl.at(0).find(GlLink); | ||||||
|  | 
 | ||||||
|  |         expect(labelItemEl.attributes('class')).toContain('is-focused'); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders element containing "No matching results" when `searchKey` does not match with any label', () => { | ||||||
|  |       wrapper.setData({ | ||||||
|  |         searchKey: 'abc', | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       return wrapper.vm.$nextTick(() => { | ||||||
|  |         const noMatchEl = wrapper.find('.dropdown-content li'); | ||||||
|  | 
 | ||||||
|  |         expect(noMatchEl.exists()).toBe(true); | ||||||
|  |         expect(noMatchEl.text()).toContain('No matching results'); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders footer list items', () => { | ||||||
|  |       const createLabelBtn = wrapper.find('.dropdown-footer').find(GlButton); | ||||||
|  |       const manageLabelsLink = wrapper.find('.dropdown-footer').find(GlLink); | ||||||
|  | 
 | ||||||
|  |       expect(createLabelBtn.exists()).toBe(true); | ||||||
|  |       expect(createLabelBtn.text()).toBe('Create label'); | ||||||
|  |       expect(manageLabelsLink.exists()).toBe(true); | ||||||
|  |       expect(manageLabelsLink.text()).toBe('Manage labels'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,54 @@ | ||||||
|  | import Vuex from 'vuex'; | ||||||
|  | import { shallowMount, createLocalVue } from '@vue/test-utils'; | ||||||
|  | 
 | ||||||
|  | import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue'; | ||||||
|  | 
 | ||||||
|  | import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; | ||||||
|  | 
 | ||||||
|  | import { mockConfig } from './mock_data'; | ||||||
|  | 
 | ||||||
|  | const localVue = createLocalVue(); | ||||||
|  | localVue.use(Vuex); | ||||||
|  | 
 | ||||||
|  | const createComponent = (initialState = mockConfig) => { | ||||||
|  |   const store = new Vuex.Store(labelsSelectModule()); | ||||||
|  | 
 | ||||||
|  |   store.dispatch('setInitialState', initialState); | ||||||
|  | 
 | ||||||
|  |   return shallowMount(DropdownContents, { | ||||||
|  |     localVue, | ||||||
|  |     store, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | describe('DropdownContent', () => { | ||||||
|  |   let wrapper; | ||||||
|  | 
 | ||||||
|  |   beforeEach(() => { | ||||||
|  |     wrapper = createComponent(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   afterEach(() => { | ||||||
|  |     wrapper.destroy(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('computed', () => { | ||||||
|  |     describe('dropdownContentsView', () => { | ||||||
|  |       it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => { | ||||||
|  |         wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView'); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view'); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => { | ||||||
|  |         expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view'); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('template', () => { | ||||||
|  |     it('renders component container element with class `labels-select-dropdown-contents`', () => { | ||||||
|  |       expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,61 @@ | ||||||
|  | import Vuex from 'vuex'; | ||||||
|  | import { shallowMount, createLocalVue } from '@vue/test-utils'; | ||||||
|  | 
 | ||||||
|  | import { GlButton, GlLoadingIcon } from '@gitlab/ui'; | ||||||
|  | import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue'; | ||||||
|  | 
 | ||||||
|  | import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; | ||||||
|  | 
 | ||||||
|  | import { mockConfig } from './mock_data'; | ||||||
|  | 
 | ||||||
|  | const localVue = createLocalVue(); | ||||||
|  | localVue.use(Vuex); | ||||||
|  | 
 | ||||||
|  | const createComponent = (initialState = mockConfig) => { | ||||||
|  |   const store = new Vuex.Store(labelsSelectModule()); | ||||||
|  | 
 | ||||||
|  |   store.dispatch('setInitialState', initialState); | ||||||
|  | 
 | ||||||
|  |   return shallowMount(DropdownTitle, { | ||||||
|  |     localVue, | ||||||
|  |     store, | ||||||
|  |     propsData: { | ||||||
|  |       labelsSelectInProgress: false, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | describe('DropdownTitle', () => { | ||||||
|  |   let wrapper; | ||||||
|  | 
 | ||||||
|  |   beforeEach(() => { | ||||||
|  |     wrapper = createComponent(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   afterEach(() => { | ||||||
|  |     wrapper.destroy(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('template', () => { | ||||||
|  |     it('renders component container element with string "Labels"', () => { | ||||||
|  |       expect(wrapper.text()).toContain('Labels'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders edit link', () => { | ||||||
|  |       const editBtnEl = wrapper.find(GlButton); | ||||||
|  | 
 | ||||||
|  |       expect(editBtnEl.exists()).toBe(true); | ||||||
|  |       expect(editBtnEl.text()).toBe('Edit'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders loading icon element when `labelsSelectInProgress` prop is true', () => { | ||||||
|  |       wrapper.setProps({ | ||||||
|  |         labelsSelectInProgress: true, | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       return wrapper.vm.$nextTick(() => { | ||||||
|  |         expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,84 @@ | ||||||
|  | import Vuex from 'vuex'; | ||||||
|  | import { shallowMount, createLocalVue } from '@vue/test-utils'; | ||||||
|  | 
 | ||||||
|  | import { GlLabel } from '@gitlab/ui'; | ||||||
|  | import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue'; | ||||||
|  | 
 | ||||||
|  | import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; | ||||||
|  | 
 | ||||||
|  | import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data'; | ||||||
|  | 
 | ||||||
|  | const localVue = createLocalVue(); | ||||||
|  | localVue.use(Vuex); | ||||||
|  | 
 | ||||||
|  | const createComponent = (initialState = mockConfig, slots = {}) => { | ||||||
|  |   const store = new Vuex.Store(labelsSelectModule()); | ||||||
|  | 
 | ||||||
|  |   store.dispatch('setInitialState', initialState); | ||||||
|  | 
 | ||||||
|  |   return shallowMount(DropdownValue, { | ||||||
|  |     localVue, | ||||||
|  |     store, | ||||||
|  |     slots, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | describe('DropdownValue', () => { | ||||||
|  |   let wrapper; | ||||||
|  | 
 | ||||||
|  |   beforeEach(() => { | ||||||
|  |     wrapper = createComponent(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   afterEach(() => { | ||||||
|  |     wrapper.destroy(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('methods', () => { | ||||||
|  |     describe('labelFilterUrl', () => { | ||||||
|  |       it('returns a label filter URL based on provided label param', () => { | ||||||
|  |         expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe( | ||||||
|  |           '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('scopedLabel', () => { | ||||||
|  |       it('returns `true` when provided label param is a scoped label', () => { | ||||||
|  |         expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('returns `false` when provided label param is a regular label', () => { | ||||||
|  |         expect(wrapper.vm.scopedLabel(mockRegularLabel)).toBe(false); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('template', () => { | ||||||
|  |     it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => { | ||||||
|  |       expect(wrapper.attributes('class')).toContain('has-labels'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders element containing `None` when `selectedLabels` is empty', () => { | ||||||
|  |       const wrapperNoLabels = createComponent( | ||||||
|  |         { | ||||||
|  |           ...mockConfig, | ||||||
|  |           selectedLabels: [], | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           default: 'None', | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |       const noneEl = wrapperNoLabels.find('span.text-secondary'); | ||||||
|  | 
 | ||||||
|  |       expect(noneEl.exists()).toBe(true); | ||||||
|  |       expect(noneEl.text()).toBe('None'); | ||||||
|  | 
 | ||||||
|  |       wrapperNoLabels.destroy(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders labels when `selectedLabels` is not empty', () => { | ||||||
|  |       expect(wrapper.findAll(GlLabel).length).toBe(2); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,127 @@ | ||||||
|  | import Vuex from 'vuex'; | ||||||
|  | import { shallowMount, createLocalVue } from '@vue/test-utils'; | ||||||
|  | 
 | ||||||
|  | import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; | ||||||
|  | import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue'; | ||||||
|  | import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue'; | ||||||
|  | import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; | ||||||
|  | import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue'; | ||||||
|  | import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue'; | ||||||
|  | 
 | ||||||
|  | import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; | ||||||
|  | 
 | ||||||
|  | import { mockConfig } from './mock_data'; | ||||||
|  | 
 | ||||||
|  | const localVue = createLocalVue(); | ||||||
|  | localVue.use(Vuex); | ||||||
|  | 
 | ||||||
|  | const createComponent = (config = mockConfig, slots = {}) => | ||||||
|  |   shallowMount(LabelsSelectRoot, { | ||||||
|  |     localVue, | ||||||
|  |     slots, | ||||||
|  |     store: new Vuex.Store(labelsSelectModule()), | ||||||
|  |     propsData: config, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  | describe('LabelsSelectRoot', () => { | ||||||
|  |   let wrapper; | ||||||
|  | 
 | ||||||
|  |   beforeEach(() => { | ||||||
|  |     wrapper = createComponent(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   afterEach(() => { | ||||||
|  |     wrapper.destroy(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('methods', () => { | ||||||
|  |     describe('handleVuexActionDispatch', () => { | ||||||
|  |       it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => { | ||||||
|  |         jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation(); | ||||||
|  | 
 | ||||||
|  |         wrapper.vm.handleVuexActionDispatch( | ||||||
|  |           { type: 'toggleDropdownContents' }, | ||||||
|  |           { | ||||||
|  |             showDropdownButton: false, | ||||||
|  |             showDropdownContents: false, | ||||||
|  |             labels: [{ id: 1 }, { id: 2, touched: true }], | ||||||
|  |           }, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith( | ||||||
|  |           expect.arrayContaining([ | ||||||
|  |             { | ||||||
|  |               id: 2, | ||||||
|  |               touched: true, | ||||||
|  |             }, | ||||||
|  |           ]), | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('handleDropdownClose', () => { | ||||||
|  |       it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => { | ||||||
|  |         wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.emitted().updateSelectedLabels).toBeTruthy(); | ||||||
|  |         expect(wrapper.emitted().onDropdownClose).toBeTruthy(); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => { | ||||||
|  |         wrapper.vm.handleDropdownClose([]); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.emitted().updateSelectedLabels).toBeFalsy(); | ||||||
|  |         expect(wrapper.emitted().onDropdownClose).toBeTruthy(); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('handleCollapsedValueClick', () => { | ||||||
|  |       it('emits `toggleCollapse` event on component', () => { | ||||||
|  |         wrapper.vm.handleCollapsedValueClick(); | ||||||
|  | 
 | ||||||
|  |         expect(wrapper.emitted().toggleCollapse).toBeTruthy(); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('template', () => { | ||||||
|  |     it('renders component with classes `labels-select-wrapper position-relative`', () => { | ||||||
|  |       expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => { | ||||||
|  |       expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders `dropdown-title` component', () => { | ||||||
|  |       expect(wrapper.find(DropdownTitle).exists()).toBe(true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders `dropdown-value` component with slot when `showDropdownButton` prop is `false`', () => { | ||||||
|  |       const wrapperDropdownValue = createComponent(mockConfig, { | ||||||
|  |         default: 'None', | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       const valueComp = wrapperDropdownValue.find(DropdownValue); | ||||||
|  | 
 | ||||||
|  |       expect(valueComp.exists()).toBe(true); | ||||||
|  |       expect(valueComp.text()).toBe('None'); | ||||||
|  | 
 | ||||||
|  |       wrapperDropdownValue.destroy(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', () => { | ||||||
|  |       wrapper.vm.$store.dispatch('toggleDropdownButton'); | ||||||
|  | 
 | ||||||
|  |       expect(wrapper.find(DropdownButton).exists()).toBe(true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', () => { | ||||||
|  |       wrapper.vm.$store.dispatch('toggleDropdownContents'); | ||||||
|  | 
 | ||||||
|  |       return wrapper.vm.$nextTick(() => { | ||||||
|  |         expect(wrapper.find(DropdownContents).exists()).toBe(true); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,66 @@ | ||||||
|  | export const mockRegularLabel = { | ||||||
|  |   id: 26, | ||||||
|  |   title: 'Foo Label', | ||||||
|  |   description: 'Foobar', | ||||||
|  |   color: '#BADA55', | ||||||
|  |   textColor: '#FFFFFF', | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const mockScopedLabel = { | ||||||
|  |   id: 27, | ||||||
|  |   title: 'Foo::Bar', | ||||||
|  |   description: 'Foobar', | ||||||
|  |   color: '#0033CC', | ||||||
|  |   textColor: '#FFFFFF', | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const mockLabels = [ | ||||||
|  |   mockRegularLabel, | ||||||
|  |   mockScopedLabel, | ||||||
|  |   { | ||||||
|  |     id: 28, | ||||||
|  |     title: 'Bug', | ||||||
|  |     description: 'Label for bugs', | ||||||
|  |     color: '#FF0000', | ||||||
|  |     textColor: '#FFFFFF', | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
|  | export const mockConfig = { | ||||||
|  |   allowLabelEdit: true, | ||||||
|  |   allowLabelCreate: true, | ||||||
|  |   allowScopedLabels: true, | ||||||
|  |   labelsListTitle: 'Assign labels', | ||||||
|  |   labelsCreateTitle: 'Create label', | ||||||
|  |   dropdownOnly: false, | ||||||
|  |   selectedLabels: [mockRegularLabel, mockScopedLabel], | ||||||
|  |   labelsSelectInProgress: false, | ||||||
|  |   labelsFetchPath: '/gitlab-org/my-project/-/labels.json', | ||||||
|  |   labelsManagePath: '/gitlab-org/my-project/-/labels', | ||||||
|  |   labelsFilterBasePath: '/gitlab-org/my-project/issues', | ||||||
|  |   scopedLabelsDocumentationPath: '/help/user/project/labels.md#scoped-labels-premium', | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const mockSuggestedColors = { | ||||||
|  |   '#0033CC': 'UA blue', | ||||||
|  |   '#428BCA': 'Moderate blue', | ||||||
|  |   '#44AD8E': 'Lime green', | ||||||
|  |   '#A8D695': 'Feijoa', | ||||||
|  |   '#5CB85C': 'Slightly desaturated green', | ||||||
|  |   '#69D100': 'Bright green', | ||||||
|  |   '#004E00': 'Very dark lime green', | ||||||
|  |   '#34495E': 'Very dark desaturated blue', | ||||||
|  |   '#7F8C8D': 'Dark grayish cyan', | ||||||
|  |   '#A295D6': 'Slightly desaturated blue', | ||||||
|  |   '#5843AD': 'Dark moderate blue', | ||||||
|  |   '#8E44AD': 'Dark moderate violet', | ||||||
|  |   '#FFECDB': 'Very pale orange', | ||||||
|  |   '#AD4363': 'Dark moderate pink', | ||||||
|  |   '#D10069': 'Strong pink', | ||||||
|  |   '#CC0033': 'Strong red', | ||||||
|  |   '#FF0000': 'Pure red', | ||||||
|  |   '#D9534F': 'Soft red', | ||||||
|  |   '#D1D100': 'Strong yellow', | ||||||
|  |   '#F0AD4E': 'Soft orange', | ||||||
|  |   '#AD8D43': 'Dark moderate orange', | ||||||
|  | }; | ||||||
|  | @ -0,0 +1,276 @@ | ||||||
|  | import MockAdapter from 'axios-mock-adapter'; | ||||||
|  | 
 | ||||||
|  | import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state'; | ||||||
|  | import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types'; | ||||||
|  | import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions'; | ||||||
|  | 
 | ||||||
|  | import testAction from 'helpers/vuex_action_helper'; | ||||||
|  | import axios from '~/lib/utils/axios_utils'; | ||||||
|  | 
 | ||||||
|  | describe('LabelsSelect Actions', () => { | ||||||
|  |   let state; | ||||||
|  |   const mockInitialState = { | ||||||
|  |     labels: [], | ||||||
|  |     selectedLabels: [], | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   beforeEach(() => { | ||||||
|  |     state = Object.assign({}, defaultState()); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('setInitialState', () => { | ||||||
|  |     it('sets initial store state', done => { | ||||||
|  |       testAction( | ||||||
|  |         actions.setInitialState, | ||||||
|  |         mockInitialState, | ||||||
|  |         state, | ||||||
|  |         [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }], | ||||||
|  |         [], | ||||||
|  |         done, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('toggleDropdownButton', () => { | ||||||
|  |     it('toggles dropdown button', done => { | ||||||
|  |       testAction( | ||||||
|  |         actions.toggleDropdownButton, | ||||||
|  |         {}, | ||||||
|  |         state, | ||||||
|  |         [{ type: types.TOGGLE_DROPDOWN_BUTTON }], | ||||||
|  |         [], | ||||||
|  |         done, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('toggleDropdownContents', () => { | ||||||
|  |     it('toggles dropdown contents', done => { | ||||||
|  |       testAction( | ||||||
|  |         actions.toggleDropdownContents, | ||||||
|  |         {}, | ||||||
|  |         state, | ||||||
|  |         [{ type: types.TOGGLE_DROPDOWN_CONTENTS }], | ||||||
|  |         [], | ||||||
|  |         done, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('toggleDropdownContentsCreateView', () => { | ||||||
|  |     it('toggles dropdown create view', done => { | ||||||
|  |       testAction( | ||||||
|  |         actions.toggleDropdownContentsCreateView, | ||||||
|  |         {}, | ||||||
|  |         state, | ||||||
|  |         [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }], | ||||||
|  |         [], | ||||||
|  |         done, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('requestLabels', () => { | ||||||
|  |     it('sets value of `state.labelsFetchInProgress` to `true`', done => { | ||||||
|  |       testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('receiveLabelsSuccess', () => { | ||||||
|  |     it('sets provided labels to `state.labels`', done => { | ||||||
|  |       const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; | ||||||
|  | 
 | ||||||
|  |       testAction( | ||||||
|  |         actions.receiveLabelsSuccess, | ||||||
|  |         labels, | ||||||
|  |         state, | ||||||
|  |         [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }], | ||||||
|  |         [], | ||||||
|  |         done, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('receiveLabelsFailure', () => { | ||||||
|  |     beforeEach(() => { | ||||||
|  |       setFixtures('<div class="flash-container"></div>'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('sets value `state.labelsFetchInProgress` to `false`', done => { | ||||||
|  |       testAction( | ||||||
|  |         actions.receiveLabelsFailure, | ||||||
|  |         {}, | ||||||
|  |         state, | ||||||
|  |         [{ type: types.RECEIVE_SET_LABELS_FAILURE }], | ||||||
|  |         [], | ||||||
|  |         done, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('shows flash error', () => { | ||||||
|  |       actions.receiveLabelsFailure({ commit: () => {} }); | ||||||
|  | 
 | ||||||
|  |       expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( | ||||||
|  |         'Error fetching labels.', | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('fetchLabels', () => { | ||||||
|  |     let mock; | ||||||
|  | 
 | ||||||
|  |     beforeEach(() => { | ||||||
|  |       mock = new MockAdapter(axios); | ||||||
|  |       state.labelsFetchPath = 'labels.json'; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     afterEach(() => { | ||||||
|  |       mock.restore(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('on success', () => { | ||||||
|  |       it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', done => { | ||||||
|  |         const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; | ||||||
|  |         mock.onGet(/labels.json/).replyOnce(200, labels); | ||||||
|  | 
 | ||||||
|  |         testAction( | ||||||
|  |           actions.fetchLabels, | ||||||
|  |           {}, | ||||||
|  |           state, | ||||||
|  |           [], | ||||||
|  |           [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }], | ||||||
|  |           done, | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('on failure', () => { | ||||||
|  |       it('dispatches `requestLabels` & `receiveLabelsFailure` actions', done => { | ||||||
|  |         mock.onGet(/labels.json/).replyOnce(500, {}); | ||||||
|  | 
 | ||||||
|  |         testAction( | ||||||
|  |           actions.fetchLabels, | ||||||
|  |           {}, | ||||||
|  |           state, | ||||||
|  |           [], | ||||||
|  |           [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }], | ||||||
|  |           done, | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('requestCreateLabel', () => { | ||||||
|  |     it('sets value `state.labelCreateInProgress` to `true`', done => { | ||||||
|  |       testAction( | ||||||
|  |         actions.requestCreateLabel, | ||||||
|  |         {}, | ||||||
|  |         state, | ||||||
|  |         [{ type: types.REQUEST_CREATE_LABEL }], | ||||||
|  |         [], | ||||||
|  |         done, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('receiveCreateLabelSuccess', () => { | ||||||
|  |     it('sets value `state.labelCreateInProgress` to `false`', done => { | ||||||
|  |       testAction( | ||||||
|  |         actions.receiveCreateLabelSuccess, | ||||||
|  |         {}, | ||||||
|  |         state, | ||||||
|  |         [{ type: types.RECEIVE_CREATE_LABEL_SUCCESS }], | ||||||
|  |         [], | ||||||
|  |         done, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('receiveCreateLabelFailure', () => { | ||||||
|  |     beforeEach(() => { | ||||||
|  |       setFixtures('<div class="flash-container"></div>'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('sets value `state.labelCreateInProgress` to `false`', done => { | ||||||
|  |       testAction( | ||||||
|  |         actions.receiveCreateLabelFailure, | ||||||
|  |         {}, | ||||||
|  |         state, | ||||||
|  |         [{ type: types.RECEIVE_CREATE_LABEL_FAILURE }], | ||||||
|  |         [], | ||||||
|  |         done, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('shows flash error', () => { | ||||||
|  |       actions.receiveCreateLabelFailure({ commit: () => {} }); | ||||||
|  | 
 | ||||||
|  |       expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( | ||||||
|  |         'Error creating label.', | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('createLabel', () => { | ||||||
|  |     let mock; | ||||||
|  | 
 | ||||||
|  |     beforeEach(() => { | ||||||
|  |       mock = new MockAdapter(axios); | ||||||
|  |       state.labelsManagePath = 'labels.json'; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     afterEach(() => { | ||||||
|  |       mock.restore(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('on success', () => { | ||||||
|  |       it('dispatches `requestCreateLabel`, `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', done => { | ||||||
|  |         const label = { id: 1 }; | ||||||
|  |         mock.onPost(/labels.json/).replyOnce(200, label); | ||||||
|  | 
 | ||||||
|  |         testAction( | ||||||
|  |           actions.createLabel, | ||||||
|  |           {}, | ||||||
|  |           state, | ||||||
|  |           [], | ||||||
|  |           [ | ||||||
|  |             { type: 'requestCreateLabel' }, | ||||||
|  |             { type: 'receiveCreateLabelSuccess' }, | ||||||
|  |             { type: 'toggleDropdownContentsCreateView' }, | ||||||
|  |           ], | ||||||
|  |           done, | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('on failure', () => { | ||||||
|  |       it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', done => { | ||||||
|  |         mock.onPost(/labels.json/).replyOnce(500, {}); | ||||||
|  | 
 | ||||||
|  |         testAction( | ||||||
|  |           actions.createLabel, | ||||||
|  |           {}, | ||||||
|  |           state, | ||||||
|  |           [], | ||||||
|  |           [{ type: 'requestCreateLabel' }, { type: 'receiveCreateLabelFailure' }], | ||||||
|  |           done, | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('updateSelectedLabels', () => { | ||||||
|  |     it('updates `state.labels` based on provided `labels` param', done => { | ||||||
|  |       const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; | ||||||
|  | 
 | ||||||
|  |       testAction( | ||||||
|  |         actions.updateSelectedLabels, | ||||||
|  |         labels, | ||||||
|  |         state, | ||||||
|  |         [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }], | ||||||
|  |         [], | ||||||
|  |         done, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,31 @@ | ||||||
|  | import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/store/getters'; | ||||||
|  | 
 | ||||||
|  | describe('LabelsSelect Getters', () => { | ||||||
|  |   describe('dropdownButtonText', () => { | ||||||
|  |     it('returns string "Label" when state.labels has no selected labels', () => { | ||||||
|  |       const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; | ||||||
|  | 
 | ||||||
|  |       expect(getters.dropdownButtonText({ labels })).toBe('Label'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('returns label title when state.labels has only 1 label', () => { | ||||||
|  |       const labels = [{ id: 1, title: 'Foobar', set: true }]; | ||||||
|  | 
 | ||||||
|  |       expect(getters.dropdownButtonText({ labels })).toBe('Foobar'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('returns first label title and remaining labels count when state.labels has more than 1 label', () => { | ||||||
|  |       const labels = [{ id: 1, title: 'Foo', set: true }, { id: 2, title: 'Bar', set: true }]; | ||||||
|  | 
 | ||||||
|  |       expect(getters.dropdownButtonText({ labels })).toBe('Foo +1 more'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('selectedLabelsList', () => { | ||||||
|  |     it('returns array of IDs of all labels within `state.selectedLabels`', () => { | ||||||
|  |       const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; | ||||||
|  | 
 | ||||||
|  |       expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,172 @@ | ||||||
|  | import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations'; | ||||||
|  | import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types'; | ||||||
|  | 
 | ||||||
|  | describe('LabelsSelect Mutations', () => { | ||||||
|  |   describe(`${types.SET_INITIAL_STATE}`, () => { | ||||||
|  |     it('initializes provided props to store state', () => { | ||||||
|  |       const state = {}; | ||||||
|  |       mutations[types.SET_INITIAL_STATE](state, { | ||||||
|  |         labels: 'foo', | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       expect(state.labels).toEqual('foo'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => { | ||||||
|  |     it('toggles value of `state.showDropdownButton`', () => { | ||||||
|  |       const state = { | ||||||
|  |         showDropdownButton: false, | ||||||
|  |       }; | ||||||
|  |       mutations[types.TOGGLE_DROPDOWN_BUTTON](state); | ||||||
|  | 
 | ||||||
|  |       expect(state.showDropdownButton).toBe(true); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => { | ||||||
|  |     it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => { | ||||||
|  |       const state = { | ||||||
|  |         dropdownOnly: false, | ||||||
|  |         showDropdownButton: false, | ||||||
|  |       }; | ||||||
|  |       mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); | ||||||
|  | 
 | ||||||
|  |       expect(state.showDropdownButton).toBe(true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('toggles value of `state.showDropdownContents`', () => { | ||||||
|  |       const state = { | ||||||
|  |         showDropdownContents: false, | ||||||
|  |       }; | ||||||
|  |       mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); | ||||||
|  | 
 | ||||||
|  |       expect(state.showDropdownContents).toBe(true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => { | ||||||
|  |       const state = { | ||||||
|  |         showDropdownContents: false, | ||||||
|  |         showDropdownContentsCreateView: true, | ||||||
|  |       }; | ||||||
|  |       mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); | ||||||
|  | 
 | ||||||
|  |       expect(state.showDropdownContentsCreateView).toBe(false); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => { | ||||||
|  |     it('toggles value of `state.showDropdownContentsCreateView`', () => { | ||||||
|  |       const state = { | ||||||
|  |         showDropdownContentsCreateView: false, | ||||||
|  |       }; | ||||||
|  |       mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state); | ||||||
|  | 
 | ||||||
|  |       expect(state.showDropdownContentsCreateView).toBe(true); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe(`${types.REQUEST_LABELS}`, () => { | ||||||
|  |     it('sets value of `state.labelsFetchInProgress` to true', () => { | ||||||
|  |       const state = { | ||||||
|  |         labelsFetchInProgress: false, | ||||||
|  |       }; | ||||||
|  |       mutations[types.REQUEST_LABELS](state); | ||||||
|  | 
 | ||||||
|  |       expect(state.labelsFetchInProgress).toBe(true); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => { | ||||||
|  |     const selectedLabels = [{ id: 2 }, { id: 4 }]; | ||||||
|  |     const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; | ||||||
|  | 
 | ||||||
|  |     it('sets value of `state.labelsFetchInProgress` to false', () => { | ||||||
|  |       const state = { | ||||||
|  |         selectedLabels, | ||||||
|  |         labelsFetchInProgress: true, | ||||||
|  |       }; | ||||||
|  |       mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels); | ||||||
|  | 
 | ||||||
|  |       expect(state.labelsFetchInProgress).toBe(false); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('sets provided `labels` to `state.labels` along with `set` prop based on `state.selectedLabels`', () => { | ||||||
|  |       const selectedLabelIds = selectedLabels.map(label => label.id); | ||||||
|  |       const state = { | ||||||
|  |         selectedLabels, | ||||||
|  |         labelsFetchInProgress: true, | ||||||
|  |       }; | ||||||
|  |       mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels); | ||||||
|  | 
 | ||||||
|  |       state.labels.forEach(label => { | ||||||
|  |         if (selectedLabelIds.includes(label.id)) { | ||||||
|  |           expect(label.set).toBe(true); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe(`${types.RECEIVE_SET_LABELS_FAILURE}`, () => { | ||||||
|  |     it('sets value of `state.labelsFetchInProgress` to false', () => { | ||||||
|  |       const state = { | ||||||
|  |         labelsFetchInProgress: true, | ||||||
|  |       }; | ||||||
|  |       mutations[types.RECEIVE_SET_LABELS_FAILURE](state); | ||||||
|  | 
 | ||||||
|  |       expect(state.labelsFetchInProgress).toBe(false); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe(`${types.REQUEST_CREATE_LABEL}`, () => { | ||||||
|  |     it('sets value of `state.labelCreateInProgress` to true', () => { | ||||||
|  |       const state = { | ||||||
|  |         labelCreateInProgress: false, | ||||||
|  |       }; | ||||||
|  |       mutations[types.REQUEST_CREATE_LABEL](state); | ||||||
|  | 
 | ||||||
|  |       expect(state.labelCreateInProgress).toBe(true); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe(`${types.RECEIVE_CREATE_LABEL_SUCCESS}`, () => { | ||||||
|  |     it('sets value of `state.labelCreateInProgress` to false', () => { | ||||||
|  |       const state = { | ||||||
|  |         labelCreateInProgress: false, | ||||||
|  |       }; | ||||||
|  |       mutations[types.RECEIVE_CREATE_LABEL_SUCCESS](state); | ||||||
|  | 
 | ||||||
|  |       expect(state.labelCreateInProgress).toBe(false); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe(`${types.RECEIVE_CREATE_LABEL_FAILURE}`, () => { | ||||||
|  |     it('sets value of `state.labelCreateInProgress` to false', () => { | ||||||
|  |       const state = { | ||||||
|  |         labelCreateInProgress: false, | ||||||
|  |       }; | ||||||
|  |       mutations[types.RECEIVE_CREATE_LABEL_FAILURE](state); | ||||||
|  | 
 | ||||||
|  |       expect(state.labelCreateInProgress).toBe(false); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe(`${types.UPDATE_SELECTED_LABELS}`, () => { | ||||||
|  |     const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; | ||||||
|  | 
 | ||||||
|  |     it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => { | ||||||
|  |       const updatedLabelIds = [2, 4]; | ||||||
|  |       const state = { | ||||||
|  |         labels, | ||||||
|  |       }; | ||||||
|  |       mutations[types.UPDATE_SELECTED_LABELS](state, { labels }); | ||||||
|  | 
 | ||||||
|  |       state.labels.forEach(label => { | ||||||
|  |         if (updatedLabelIds.includes(label.id)) { | ||||||
|  |           expect(label.touched).toBe(true); | ||||||
|  |           expect(label.set).toBe(true); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -601,10 +601,23 @@ describe Snippet do | ||||||
|         expect(snippet.create_repository).to be_nil |         expect(snippet.create_repository).to be_nil | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'does not track snippet repository' do |       context 'when snippet_repository exists' do | ||||||
|         expect do |         it 'does not create a new snippet repository' do | ||||||
|           snippet.create_repository |           expect do | ||||||
|         end.not_to change(SnippetRepository, :count) |             snippet.create_repository | ||||||
|  |           end.not_to change(SnippetRepository, :count) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when snippet_repository does not exist' do | ||||||
|  |         it 'creates a snippet_repository' do | ||||||
|  |           snippet.snippet_repository.destroy | ||||||
|  |           snippet.reload | ||||||
|  | 
 | ||||||
|  |           expect do | ||||||
|  |             snippet.create_repository | ||||||
|  |           end.to change(SnippetRepository, :count).by(1) | ||||||
|  |         end | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -91,7 +91,7 @@ describe 'Updating a Snippet' do | ||||||
| 
 | 
 | ||||||
|   describe 'PersonalSnippet' do |   describe 'PersonalSnippet' do | ||||||
|     it_behaves_like 'graphql update actions' do |     it_behaves_like 'graphql update actions' do | ||||||
|       let_it_be(:snippet) do |       let(:snippet) do | ||||||
|         create(:personal_snippet, |         create(:personal_snippet, | ||||||
|                :private, |                :private, | ||||||
|                file_name: original_file_name, |                file_name: original_file_name, | ||||||
|  | @ -104,7 +104,7 @@ describe 'Updating a Snippet' do | ||||||
| 
 | 
 | ||||||
|   describe 'ProjectSnippet' do |   describe 'ProjectSnippet' do | ||||||
|     let_it_be(:project) { create(:project, :private) } |     let_it_be(:project) { create(:project, :private) } | ||||||
|     let_it_be(:snippet) do |     let(:snippet) do | ||||||
|       create(:project_snippet, |       create(:project_snippet, | ||||||
|              :private, |              :private, | ||||||
|              project: project, |              project: project, | ||||||
|  |  | ||||||
|  | @ -278,13 +278,13 @@ describe API::ProjectSnippets do | ||||||
| 
 | 
 | ||||||
|   describe 'PUT /projects/:project_id/snippets/:id/' do |   describe 'PUT /projects/:project_id/snippets/:id/' do | ||||||
|     let(:visibility_level) { Snippet::PUBLIC } |     let(:visibility_level) { Snippet::PUBLIC } | ||||||
|     let(:snippet) { create(:project_snippet, author: admin, visibility_level: visibility_level, project: project) } |     let(:snippet) { create(:project_snippet, :repository, author: admin, visibility_level: visibility_level, project: project) } | ||||||
| 
 | 
 | ||||||
|     it 'updates snippet' do |     it 'updates snippet' do | ||||||
|       new_content = 'New content' |       new_content = 'New content' | ||||||
|       new_description = 'New description' |       new_description = 'New description' | ||||||
| 
 | 
 | ||||||
|       put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), params: { code: new_content, description: new_description, visibility: 'private' } |       update_snippet(params: { code: new_content, description: new_description, visibility: 'private' }) | ||||||
| 
 | 
 | ||||||
|       expect(response).to have_gitlab_http_status(:ok) |       expect(response).to have_gitlab_http_status(:ok) | ||||||
|       snippet.reload |       snippet.reload | ||||||
|  | @ -297,7 +297,7 @@ describe API::ProjectSnippets do | ||||||
|       new_content = 'New content' |       new_content = 'New content' | ||||||
|       new_description = 'New description' |       new_description = 'New description' | ||||||
| 
 | 
 | ||||||
|       put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), params: { content: new_content, description: new_description } |       update_snippet(params: { content: new_content, description: new_description }) | ||||||
| 
 | 
 | ||||||
|       expect(response).to have_gitlab_http_status(:ok) |       expect(response).to have_gitlab_http_status(:ok) | ||||||
|       snippet.reload |       snippet.reload | ||||||
|  | @ -306,21 +306,21 @@ describe API::ProjectSnippets do | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     it 'returns 400 when both code and content parameters specified' do |     it 'returns 400 when both code and content parameters specified' do | ||||||
|       put api("/projects/#{snippet.project.id}/snippets/1234", admin), params: { code: 'some content', content: 'other content' } |       update_snippet(params: { code: 'some content', content: 'other content' }) | ||||||
| 
 | 
 | ||||||
|       expect(response).to have_gitlab_http_status(:bad_request) |       expect(response).to have_gitlab_http_status(:bad_request) | ||||||
|       expect(json_response['error']).to eq('code, content are mutually exclusive') |       expect(json_response['error']).to eq('code, content are mutually exclusive') | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     it 'returns 404 for invalid snippet id' do |     it 'returns 404 for invalid snippet id' do | ||||||
|       put api("/projects/#{snippet.project.id}/snippets/1234", admin), params: { title: 'foo' } |       update_snippet(snippet_id: '1234', params: { title: 'foo' }) | ||||||
| 
 | 
 | ||||||
|       expect(response).to have_gitlab_http_status(:not_found) |       expect(response).to have_gitlab_http_status(:not_found) | ||||||
|       expect(json_response['message']).to eq('404 Snippet Not Found') |       expect(json_response['message']).to eq('404 Snippet Not Found') | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     it 'returns 400 for missing parameters' do |     it 'returns 400 for missing parameters' do | ||||||
|       put api("/projects/#{project.id}/snippets/1234", admin) |       update_snippet | ||||||
| 
 | 
 | ||||||
|       expect(response).to have_gitlab_http_status(:bad_request) |       expect(response).to have_gitlab_http_status(:bad_request) | ||||||
|     end |     end | ||||||
|  | @ -328,16 +328,16 @@ describe API::ProjectSnippets do | ||||||
|     it 'returns 400 for empty code field' do |     it 'returns 400 for empty code field' do | ||||||
|       new_content = '' |       new_content = '' | ||||||
| 
 | 
 | ||||||
|       put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), params: { code: new_content } |       update_snippet(params: { code: new_content }) | ||||||
| 
 | 
 | ||||||
|       expect(response).to have_gitlab_http_status(:bad_request) |       expect(response).to have_gitlab_http_status(:bad_request) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     context 'when the snippet is spam' do |     it_behaves_like 'update with repository actions' do | ||||||
|       def update_snippet(snippet_params = {}) |       let(:snippet_without_repo) { create(:project_snippet, author: admin, project: project, visibility_level: visibility_level) } | ||||||
|         put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}", admin), params: snippet_params |     end | ||||||
|       end |  | ||||||
| 
 | 
 | ||||||
|  |     context 'when the snippet is spam' do | ||||||
|       before do |       before do | ||||||
|         allow_next_instance_of(Spam::AkismetService) do |instance| |         allow_next_instance_of(Spam::AkismetService) do |instance| | ||||||
|           allow(instance).to receive(:spam?).and_return(true) |           allow(instance).to receive(:spam?).and_return(true) | ||||||
|  | @ -348,7 +348,7 @@ describe API::ProjectSnippets do | ||||||
|         let(:visibility_level) { Snippet::PRIVATE } |         let(:visibility_level) { Snippet::PRIVATE } | ||||||
| 
 | 
 | ||||||
|         it 'creates the snippet' do |         it 'creates the snippet' do | ||||||
|           expect { update_snippet(title: 'Foo') } |           expect { update_snippet(params: { title: 'Foo' }) } | ||||||
|             .to change { snippet.reload.title }.to('Foo') |             .to change { snippet.reload.title }.to('Foo') | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|  | @ -357,12 +357,12 @@ describe API::ProjectSnippets do | ||||||
|         let(:visibility_level) { Snippet::PUBLIC } |         let(:visibility_level) { Snippet::PUBLIC } | ||||||
| 
 | 
 | ||||||
|         it 'rejects the snippet' do |         it 'rejects the snippet' do | ||||||
|           expect { update_snippet(title: 'Foo') } |           expect { update_snippet(params: { title: 'Foo' }) } | ||||||
|             .not_to change { snippet.reload.title } |             .not_to change { snippet.reload.title } | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         it 'creates a spam log' do |         it 'creates a spam log' do | ||||||
|           expect { update_snippet(title: 'Foo') } |           expect { update_snippet(params: { title: 'Foo' }) } | ||||||
|             .to log_spam(title: 'Foo', user_id: admin.id, noteable_type: 'ProjectSnippet') |             .to log_spam(title: 'Foo', user_id: admin.id, noteable_type: 'ProjectSnippet') | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|  | @ -371,7 +371,7 @@ describe API::ProjectSnippets do | ||||||
|         let(:visibility_level) { Snippet::PRIVATE } |         let(:visibility_level) { Snippet::PRIVATE } | ||||||
| 
 | 
 | ||||||
|         it 'rejects the snippet' do |         it 'rejects the snippet' do | ||||||
|           expect { update_snippet(title: 'Foo', visibility: 'public') } |           expect { update_snippet(params: { title: 'Foo', visibility: 'public' }) } | ||||||
|             .not_to change { snippet.reload.title } |             .not_to change { snippet.reload.title } | ||||||
| 
 | 
 | ||||||
|           expect(response).to have_gitlab_http_status(:bad_request) |           expect(response).to have_gitlab_http_status(:bad_request) | ||||||
|  | @ -379,7 +379,7 @@ describe API::ProjectSnippets do | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         it 'creates a spam log' do |         it 'creates a spam log' do | ||||||
|           expect { update_snippet(title: 'Foo', visibility: 'public') } |           expect { update_snippet(params: { title: 'Foo', visibility: 'public' }) } | ||||||
|             .to log_spam(title: 'Foo', user_id: admin.id, noteable_type: 'ProjectSnippet') |             .to log_spam(title: 'Foo', user_id: admin.id, noteable_type: 'ProjectSnippet') | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|  | @ -390,6 +390,10 @@ describe API::ProjectSnippets do | ||||||
|         let(:request) { put api("/projects/#{project_no_snippets.id}/snippets/123", admin), params: { description: 'foo' } } |         let(:request) { put api("/projects/#{project_no_snippets.id}/snippets/123", admin), params: { description: 'foo' } } | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     def update_snippet(snippet_id: snippet.id, params: {}) | ||||||
|  |       put api("/projects/#{snippet.project.id}/snippets/#{snippet_id}", admin), params: params | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe 'DELETE /projects/:project_id/snippets/:id/' do |   describe 'DELETE /projects/:project_id/snippets/:id/' do | ||||||
|  |  | ||||||
|  | @ -301,7 +301,7 @@ describe API::Snippets do | ||||||
|     let(:visibility_level) { Snippet::PUBLIC } |     let(:visibility_level) { Snippet::PUBLIC } | ||||||
|     let(:other_user) { create(:user) } |     let(:other_user) { create(:user) } | ||||||
|     let(:snippet) do |     let(:snippet) do | ||||||
|       create(:personal_snippet, author: user, visibility_level: visibility_level) |       create(:personal_snippet, :repository, author: user, visibility_level: visibility_level) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     shared_examples 'snippet updates' do |     shared_examples 'snippet updates' do | ||||||
|  | @ -309,7 +309,7 @@ describe API::Snippets do | ||||||
|         new_content = 'New content' |         new_content = 'New content' | ||||||
|         new_description = 'New description' |         new_description = 'New description' | ||||||
| 
 | 
 | ||||||
|         put api("/snippets/#{snippet.id}", user), params: { content: new_content, description: new_description, visibility: 'internal' } |         update_snippet(params: { content: new_content, description: new_description, visibility: 'internal' }) | ||||||
| 
 | 
 | ||||||
|         expect(response).to have_gitlab_http_status(:ok) |         expect(response).to have_gitlab_http_status(:ok) | ||||||
|         snippet.reload |         snippet.reload | ||||||
|  | @ -332,30 +332,30 @@ describe API::Snippets do | ||||||
|     it_behaves_like 'snippet updates' |     it_behaves_like 'snippet updates' | ||||||
| 
 | 
 | ||||||
|     it 'returns 404 for invalid snippet id' do |     it 'returns 404 for invalid snippet id' do | ||||||
|       put api("/snippets/1234", user), params: { title: 'foo' } |       update_snippet(snippet_id: '1234', params: { title: 'Foo' }) | ||||||
| 
 | 
 | ||||||
|       expect(response).to have_gitlab_http_status(:not_found) |       expect(response).to have_gitlab_http_status(:not_found) | ||||||
|       expect(json_response['message']).to eq('404 Snippet Not Found') |       expect(json_response['message']).to eq('404 Snippet Not Found') | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     it "returns 404 for another user's snippet" do |     it "returns 404 for another user's snippet" do | ||||||
|       put api("/snippets/#{snippet.id}", other_user), params: { title: 'fubar' } |       update_snippet(requester: other_user, params: { title: 'foobar' }) | ||||||
| 
 | 
 | ||||||
|       expect(response).to have_gitlab_http_status(:not_found) |       expect(response).to have_gitlab_http_status(:not_found) | ||||||
|       expect(json_response['message']).to eq('404 Snippet Not Found') |       expect(json_response['message']).to eq('404 Snippet Not Found') | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     it 'returns 400 for missing parameters' do |     it 'returns 400 for missing parameters' do | ||||||
|       put api("/snippets/1234", user) |       update_snippet | ||||||
| 
 | 
 | ||||||
|       expect(response).to have_gitlab_http_status(:bad_request) |       expect(response).to have_gitlab_http_status(:bad_request) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     context 'when the snippet is spam' do |     it_behaves_like 'update with repository actions' do | ||||||
|       def update_snippet(snippet_params = {}) |       let(:snippet_without_repo) { create(:personal_snippet, author: user, visibility_level: visibility_level) } | ||||||
|         put api("/snippets/#{snippet.id}", user), params: snippet_params |     end | ||||||
|       end |  | ||||||
| 
 | 
 | ||||||
|  |     context 'when the snippet is spam' do | ||||||
|       before do |       before do | ||||||
|         allow_next_instance_of(Spam::AkismetService) do |instance| |         allow_next_instance_of(Spam::AkismetService) do |instance| | ||||||
|           allow(instance).to receive(:spam?).and_return(true) |           allow(instance).to receive(:spam?).and_return(true) | ||||||
|  | @ -366,7 +366,7 @@ describe API::Snippets do | ||||||
|         let(:visibility_level) { Snippet::PRIVATE } |         let(:visibility_level) { Snippet::PRIVATE } | ||||||
| 
 | 
 | ||||||
|         it 'updates the snippet' do |         it 'updates the snippet' do | ||||||
|           expect { update_snippet(title: 'Foo') } |           expect { update_snippet(params: { title: 'Foo' }) } | ||||||
|             .to change { snippet.reload.title }.to('Foo') |             .to change { snippet.reload.title }.to('Foo') | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|  | @ -375,7 +375,7 @@ describe API::Snippets do | ||||||
|         let(:visibility_level) { Snippet::PUBLIC } |         let(:visibility_level) { Snippet::PUBLIC } | ||||||
| 
 | 
 | ||||||
|         it 'rejects the shippet' do |         it 'rejects the shippet' do | ||||||
|           expect { update_snippet(title: 'Foo') } |           expect { update_snippet(params: { title: 'Foo' }) } | ||||||
|             .not_to change { snippet.reload.title } |             .not_to change { snippet.reload.title } | ||||||
| 
 | 
 | ||||||
|           expect(response).to have_gitlab_http_status(:bad_request) |           expect(response).to have_gitlab_http_status(:bad_request) | ||||||
|  | @ -383,7 +383,7 @@ describe API::Snippets do | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         it 'creates a spam log' do |         it 'creates a spam log' do | ||||||
|           expect { update_snippet(title: 'Foo') }.to log_spam(title: 'Foo', user_id: user.id, noteable_type: 'PersonalSnippet') |           expect { update_snippet(params: { title: 'Foo' }) }.to log_spam(title: 'Foo', user_id: user.id, noteable_type: 'PersonalSnippet') | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|  | @ -391,16 +391,20 @@ describe API::Snippets do | ||||||
|         let(:visibility_level) { Snippet::PRIVATE } |         let(:visibility_level) { Snippet::PRIVATE } | ||||||
| 
 | 
 | ||||||
|         it 'rejects the snippet' do |         it 'rejects the snippet' do | ||||||
|           expect { update_snippet(title: 'Foo', visibility: 'public') } |           expect { update_snippet(params: { title: 'Foo', visibility: 'public' }) } | ||||||
|             .not_to change { snippet.reload.title } |             .not_to change { snippet.reload.title } | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         it 'creates a spam log' do |         it 'creates a spam log' do | ||||||
|           expect { update_snippet(title: 'Foo', visibility: 'public') } |           expect { update_snippet(params: { title: 'Foo', visibility: 'public' }) } | ||||||
|             .to log_spam(title: 'Foo', user_id: user.id, noteable_type: 'PersonalSnippet') |             .to log_spam(title: 'Foo', user_id: user.id, noteable_type: 'PersonalSnippet') | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     def update_snippet(snippet_id: snippet.id, params: {}, requester: user) | ||||||
|  |       put api("/snippets/#{snippet_id}", requester), params: params | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe 'DELETE /snippets/:id' do |   describe 'DELETE /snippets/:id' do | ||||||
|  |  | ||||||
|  | @ -16,14 +16,9 @@ describe Snippets::UpdateService do | ||||||
|       } |       } | ||||||
|     end |     end | ||||||
|     let(:updater) { user } |     let(:updater) { user } | ||||||
|  |     let(:service) { Snippets::UpdateService.new(project, updater, options) } | ||||||
| 
 | 
 | ||||||
|     subject do |     subject { service.execute(snippet) } | ||||||
|       described_class.new( |  | ||||||
|         project, |  | ||||||
|         updater, |  | ||||||
|         options |  | ||||||
|       ).execute(snippet) |  | ||||||
|     end |  | ||||||
| 
 | 
 | ||||||
|     shared_examples 'a service that updates a snippet' do |     shared_examples 'a service that updates a snippet' do | ||||||
|       it 'updates a snippet with the provided attributes' do |       it 'updates a snippet with the provided attributes' do | ||||||
|  | @ -98,9 +93,109 @@ describe Snippets::UpdateService do | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |     shared_examples 'creates repository and creates file' do | ||||||
|  |       it 'creates repository' do | ||||||
|  |         expect(snippet.repository).not_to exist | ||||||
|  | 
 | ||||||
|  |         subject | ||||||
|  | 
 | ||||||
|  |         expect(snippet.repository).to exist | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'commits the files to the repository' do | ||||||
|  |         subject | ||||||
|  | 
 | ||||||
|  |         expect(snippet.blobs.count).to eq 1 | ||||||
|  | 
 | ||||||
|  |         blob = snippet.repository.blob_at('master', options[:file_name]) | ||||||
|  | 
 | ||||||
|  |         expect(blob.data).to eq options[:content] | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when the repository does not exist' do | ||||||
|  |         it 'does not try to commit file' do | ||||||
|  |           allow(snippet).to receive(:repository_exists?).and_return(false) | ||||||
|  | 
 | ||||||
|  |           expect(service).not_to receive(:create_commit) | ||||||
|  | 
 | ||||||
|  |           subject | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when feature flag is disabled' do | ||||||
|  |         before do | ||||||
|  |           stub_feature_flags(version_snippets: false) | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         it 'does not create repository' do | ||||||
|  |           subject | ||||||
|  | 
 | ||||||
|  |           expect(snippet.repository).not_to exist | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         it 'does not try to commit file' do | ||||||
|  |           expect(service).not_to receive(:create_commit) | ||||||
|  | 
 | ||||||
|  |           subject | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'returns error when the commit action fails' do | ||||||
|  |         allow_next_instance_of(SnippetRepository) do |instance| | ||||||
|  |           allow(instance).to receive(:multi_files_action).and_raise(SnippetRepository::CommitError) | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         response = subject | ||||||
|  | 
 | ||||||
|  |         expect(response).to be_error | ||||||
|  |         expect(response.payload[:snippet].errors.full_messages).to eq ['Error updating the snippet'] | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     shared_examples 'updates repository content' do | ||||||
|  |       it 'commit the files to the repository' do | ||||||
|  |         blob = snippet.blobs.first | ||||||
|  |         options[:file_name] = blob.path + '_new' | ||||||
|  | 
 | ||||||
|  |         expect(blob.data).not_to eq(options[:content]) | ||||||
|  | 
 | ||||||
|  |         subject | ||||||
|  | 
 | ||||||
|  |         blob = snippet.blobs.first | ||||||
|  | 
 | ||||||
|  |         expect(blob.path).to eq(options[:file_name]) | ||||||
|  |         expect(blob.data).to eq(options[:content]) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'returns error when the commit action fails' do | ||||||
|  |         allow(snippet.snippet_repository).to receive(:multi_files_action).and_raise(SnippetRepository::CommitError) | ||||||
|  | 
 | ||||||
|  |         response = subject | ||||||
|  | 
 | ||||||
|  |         expect(response).to be_error | ||||||
|  |         expect(response.payload[:snippet].errors.full_messages).to eq ['Error updating the snippet'] | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'returns error if snippet does not have a snippet_repository' do | ||||||
|  |         allow(snippet).to receive(:snippet_repository).and_return(nil) | ||||||
|  | 
 | ||||||
|  |         expect(subject).to be_error | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when the repository does not exist' do | ||||||
|  |         it 'does not try to commit file' do | ||||||
|  |           allow(snippet).to receive(:repository_exists?).and_return(false) | ||||||
|  | 
 | ||||||
|  |           expect(service).not_to receive(:create_commit) | ||||||
|  | 
 | ||||||
|  |           subject | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|     context 'when Project Snippet' do |     context 'when Project Snippet' do | ||||||
|       let_it_be(:project) { create(:project) } |       let_it_be(:project) { create(:project) } | ||||||
|       let!(:snippet) { create(:project_snippet, author: user, project: project) } |       let!(:snippet) { create(:project_snippet, :repository, author: user, project: project) } | ||||||
| 
 | 
 | ||||||
|       before do |       before do | ||||||
|         project.add_developer(user) |         project.add_developer(user) | ||||||
|  | @ -109,15 +204,29 @@ describe Snippets::UpdateService do | ||||||
|       it_behaves_like 'a service that updates a snippet' |       it_behaves_like 'a service that updates a snippet' | ||||||
|       it_behaves_like 'public visibility level restrictions apply' |       it_behaves_like 'public visibility level restrictions apply' | ||||||
|       it_behaves_like 'snippet update data is tracked' |       it_behaves_like 'snippet update data is tracked' | ||||||
|  |       it_behaves_like 'updates repository content' | ||||||
|  | 
 | ||||||
|  |       context 'when snippet does not have a repository' do | ||||||
|  |         let!(:snippet) { create(:project_snippet, author: user, project: project) } | ||||||
|  | 
 | ||||||
|  |         it_behaves_like 'creates repository and creates file' | ||||||
|  |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     context 'when PersonalSnippet' do |     context 'when PersonalSnippet' do | ||||||
|       let(:project) { nil } |       let(:project) { nil } | ||||||
|       let!(:snippet) { create(:personal_snippet, author: user) } |       let!(:snippet) { create(:personal_snippet, :repository, author: user) } | ||||||
| 
 | 
 | ||||||
|       it_behaves_like 'a service that updates a snippet' |       it_behaves_like 'a service that updates a snippet' | ||||||
|       it_behaves_like 'public visibility level restrictions apply' |       it_behaves_like 'public visibility level restrictions apply' | ||||||
|       it_behaves_like 'snippet update data is tracked' |       it_behaves_like 'snippet update data is tracked' | ||||||
|  |       it_behaves_like 'updates repository content' | ||||||
|  | 
 | ||||||
|  |       context 'when snippet does not have a repository' do | ||||||
|  |         let!(:snippet) { create(:personal_snippet, author: user, project: project) } | ||||||
|  | 
 | ||||||
|  |         it_behaves_like 'creates repository and creates file' | ||||||
|  |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -0,0 +1,43 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | RSpec.shared_examples 'update with repository actions' do | ||||||
|  |   context 'when the repository exists' do | ||||||
|  |     it 'commits the changes to the repository' do | ||||||
|  |       existing_blob = snippet.blobs.first | ||||||
|  |       new_file_name = existing_blob.path + '_new' | ||||||
|  |       new_content = 'New content' | ||||||
|  | 
 | ||||||
|  |       update_snippet(params: { content: new_content, file_name: new_file_name }) | ||||||
|  | 
 | ||||||
|  |       aggregate_failures do | ||||||
|  |         expect(response).to have_gitlab_http_status(:ok) | ||||||
|  |         expect(snippet.repository.blob_at('master', existing_blob.path)).to be_nil | ||||||
|  | 
 | ||||||
|  |         blob = snippet.repository.blob_at('master', new_file_name) | ||||||
|  |         expect(blob).not_to be_nil | ||||||
|  |         expect(blob.data).to eq(new_content) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   context 'when the repository does not exist' do | ||||||
|  |     let(:snippet) { snippet_without_repo } | ||||||
|  | 
 | ||||||
|  |     it 'creates the repository' do | ||||||
|  |       update_snippet(snippet_id: snippet.id, params: { title: 'foo' }) | ||||||
|  | 
 | ||||||
|  |       expect(snippet.repository).to exist | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'commits the file to the repository' do | ||||||
|  |       content = 'New Content' | ||||||
|  |       file_name = 'file_name.rb' | ||||||
|  | 
 | ||||||
|  |       update_snippet(snippet_id: snippet.id, params: { content: content, file_name: file_name }) | ||||||
|  | 
 | ||||||
|  |       blob = snippet.repository.blob_at('master', file_name) | ||||||
|  |       expect(blob).not_to be_nil | ||||||
|  |       expect(blob.data).to eq content | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
		Loading…
	
		Reference in New Issue