281 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Vue
		
	
	
	
			
		
		
	
	
			281 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Vue
		
	
	
	
| <script>
 | |
| import { GlBadge, GlIcon, GlCollapsibleListbox } from '@gitlab/ui';
 | |
| import { debounce, isArray } from 'lodash';
 | |
| import { mapActions, mapGetters, mapState } from 'vuex';
 | |
| import { sprintf } from '~/locale';
 | |
| import {
 | |
|   ALL_REF_TYPES,
 | |
|   SEARCH_DEBOUNCE_MS,
 | |
|   DEFAULT_I18N,
 | |
|   REF_TYPE_BRANCHES,
 | |
|   REF_TYPE_TAGS,
 | |
|   REF_TYPE_COMMITS,
 | |
| } from '../constants';
 | |
| import createStore from '../stores';
 | |
| import { formatListBoxItems, formatErrors } from '../format_refs';
 | |
| 
 | |
| export default {
 | |
|   name: 'RefSelector',
 | |
|   components: {
 | |
|     GlBadge,
 | |
|     GlIcon,
 | |
|     GlCollapsibleListbox,
 | |
|   },
 | |
|   inheritAttrs: false,
 | |
|   props: {
 | |
|     enabledRefTypes: {
 | |
|       type: Array,
 | |
|       required: false,
 | |
|       default: () => ALL_REF_TYPES,
 | |
|       validator: (val) =>
 | |
|         // It has to be an arrray
 | |
|         isArray(val) &&
 | |
|         // with at least one item
 | |
|         val.length > 0 &&
 | |
|         // and only "REF_TYPE_BRANCHES", "REF_TYPE_TAGS", and "REF_TYPE_COMMITS" are allowed
 | |
|         val.every((item) => ALL_REF_TYPES.includes(item)) &&
 | |
|         // and no duplicates are allowed
 | |
|         val.length === new Set(val).size,
 | |
|     },
 | |
|     value: {
 | |
|       type: String,
 | |
|       required: false,
 | |
|       default: '',
 | |
|     },
 | |
|     refType: {
 | |
|       type: String,
 | |
|       required: false,
 | |
|       default: null,
 | |
|     },
 | |
|     projectId: {
 | |
|       type: String,
 | |
|       required: true,
 | |
|     },
 | |
|     translations: {
 | |
|       type: Object,
 | |
|       required: false,
 | |
|       default: () => ({}),
 | |
|     },
 | |
|     useSymbolicRefNames: {
 | |
|       type: Boolean,
 | |
|       required: false,
 | |
|       default: false,
 | |
|     },
 | |
| 
 | |
|     /** The validation state of this component. */
 | |
|     state: {
 | |
|       type: Boolean,
 | |
|       required: false,
 | |
|       default: true,
 | |
|     },
 | |
| 
 | |
|     /* Underlying form field name for scenarios where ref_selector
 | |
|      * is used as part of submitting an HTML form
 | |
|      */
 | |
|     name: {
 | |
|       type: String,
 | |
|       required: false,
 | |
|       default: '',
 | |
|     },
 | |
|     toggleButtonClass: {
 | |
|       type: [String, Object, Array],
 | |
|       required: false,
 | |
|       default: null,
 | |
|     },
 | |
|   },
 | |
|   data() {
 | |
|     return {
 | |
|       query: '',
 | |
|     };
 | |
|   },
 | |
|   computed: {
 | |
|     ...mapState({
 | |
|       matches: (state) => state.matches,
 | |
|       lastQuery: (state) => state.query,
 | |
|       selectedRef: (state) => state.selectedRef,
 | |
|     }),
 | |
|     ...mapGetters(['isLoading', 'isQueryPossiblyASha']),
 | |
|     i18n() {
 | |
|       return {
 | |
|         ...DEFAULT_I18N,
 | |
|         ...this.translations,
 | |
|       };
 | |
|     },
 | |
|     listBoxItems() {
 | |
|       return formatListBoxItems(this.branches, this.tags, this.commits);
 | |
|     },
 | |
|     branches() {
 | |
|       return this.enabledRefTypes.includes(REF_TYPE_BRANCHES) ? this.matches.branches.list : [];
 | |
|     },
 | |
|     tags() {
 | |
|       return this.enabledRefTypes.includes(REF_TYPE_TAGS) ? this.matches.tags.list : [];
 | |
|     },
 | |
|     commits() {
 | |
|       return this.enabledRefTypes.includes(REF_TYPE_COMMITS) ? this.matches.commits.list : [];
 | |
|     },
 | |
|     extendedToggleButtonClass() {
 | |
|       const classes = [
 | |
|         {
 | |
|           'gl-inset-border-1-red-500!': !this.state,
 | |
|           'gl-font-monospace': Boolean(this.selectedRef),
 | |
|         },
 | |
|       ];
 | |
| 
 | |
|       if (Array.isArray(this.toggleButtonClass)) {
 | |
|         classes.push(...this.toggleButtonClass);
 | |
|       } else {
 | |
|         classes.push(this.toggleButtonClass);
 | |
|       }
 | |
| 
 | |
|       return classes;
 | |
|     },
 | |
|     footerSlotProps() {
 | |
|       return {
 | |
|         isLoading: this.isLoading,
 | |
|         matches: this.matches,
 | |
|         query: this.lastQuery,
 | |
|       };
 | |
|     },
 | |
|     errors() {
 | |
|       return formatErrors(this.matches.branches, this.matches.tags, this.matches.commits);
 | |
|     },
 | |
|     selectedRefForDisplay() {
 | |
|       if (this.useSymbolicRefNames && this.selectedRef) {
 | |
|         return this.selectedRef.replace(/^refs\/(tags|heads)\//, '');
 | |
|       }
 | |
| 
 | |
|       return this.selectedRef;
 | |
|     },
 | |
|     buttonText() {
 | |
|       return this.selectedRefForDisplay || this.i18n.noRefSelected;
 | |
|     },
 | |
|     noResultsMessage() {
 | |
|       return this.lastQuery
 | |
|         ? sprintf(this.i18n.noResultsWithQuery, {
 | |
|             query: this.lastQuery,
 | |
|           })
 | |
|         : this.i18n.noResults;
 | |
|     },
 | |
|   },
 | |
|   watch: {
 | |
|     // Keep the Vuex store synchronized if the parent
 | |
|     // component updates the selected ref through v-model
 | |
|     value: {
 | |
|       immediate: true,
 | |
|       handler() {
 | |
|         if (this.value !== this.selectedRef) {
 | |
|           this.setSelectedRef(this.value);
 | |
|         }
 | |
|       },
 | |
|     },
 | |
|   },
 | |
|   beforeCreate() {
 | |
|     // Setting the store here instead of using
 | |
|     // the built in `store` component option because
 | |
|     // we need each new `RefSelector` instance to
 | |
|     // create a new Vuex store instance.
 | |
|     // See https://github.com/vuejs/vuex/issues/414#issue-184491718.
 | |
|     this.$store = createStore();
 | |
|   },
 | |
|   created() {
 | |
|     // This method is defined here instead of in `methods`
 | |
|     // because we need to access the .cancel() method
 | |
|     // lodash attaches to the function, which is
 | |
|     // made inaccessible by Vue.
 | |
|     this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS);
 | |
| 
 | |
|     this.setProjectId(this.projectId);
 | |
| 
 | |
|     this.$watch(
 | |
|       'enabledRefTypes',
 | |
|       () => {
 | |
|         this.setEnabledRefTypes(this.enabledRefTypes);
 | |
|         this.search();
 | |
|       },
 | |
|       { immediate: true },
 | |
|     );
 | |
| 
 | |
|     this.$watch(
 | |
|       'useSymbolicRefNames',
 | |
|       () => this.setUseSymbolicRefNames(this.useSymbolicRefNames),
 | |
|       { immediate: true },
 | |
|     );
 | |
|   },
 | |
|   methods: {
 | |
|     ...mapActions([
 | |
|       'setEnabledRefTypes',
 | |
|       'setUseSymbolicRefNames',
 | |
|       'setProjectId',
 | |
|       'setSelectedRef',
 | |
|     ]),
 | |
|     ...mapActions({ storeSearch: 'search' }),
 | |
|     onSearchBoxInput(searchQuery = '') {
 | |
|       this.query = searchQuery?.trim();
 | |
|       this.debouncedSearch();
 | |
|     },
 | |
|     selectRef(ref) {
 | |
|       this.setSelectedRef(ref);
 | |
|       this.$emit('input', this.selectedRef);
 | |
|     },
 | |
|     search() {
 | |
|       this.storeSearch(this.query);
 | |
|     },
 | |
|     totalCountText(count) {
 | |
|       return count > 999 ? this.i18n.totalCountLabel : `${count}`;
 | |
|     },
 | |
|   },
 | |
| };
 | |
| </script>
 | |
| 
 | |
| <template>
 | |
|   <div>
 | |
|     <gl-collapsible-listbox
 | |
|       class="ref-selector gl-w-full"
 | |
|       block
 | |
|       searchable
 | |
|       :selected="selectedRef"
 | |
|       :header-text="i18n.dropdownHeader"
 | |
|       :items="listBoxItems"
 | |
|       :no-results-text="noResultsMessage"
 | |
|       :searching="isLoading"
 | |
|       :search-placeholder="i18n.searchPlaceholder"
 | |
|       :toggle-class="extendedToggleButtonClass"
 | |
|       :toggle-text="buttonText"
 | |
|       v-bind="$attrs"
 | |
|       v-on="$listeners"
 | |
|       @hidden="$emit('hide')"
 | |
|       @search="onSearchBoxInput"
 | |
|       @select="selectRef"
 | |
|     >
 | |
|       <template #group-label="{ group }">
 | |
|         {{ group.text }} <gl-badge size="sm">{{ totalCountText(group.options.length) }}</gl-badge>
 | |
|       </template>
 | |
|       <template #list-item="{ item }">
 | |
|         {{ item.text }}
 | |
|         <gl-badge v-if="item.default" size="sm" variant="info">{{
 | |
|           i18n.defaultLabelText
 | |
|         }}</gl-badge>
 | |
|       </template>
 | |
|       <template #footer>
 | |
|         <slot name="footer" v-bind="footerSlotProps"></slot>
 | |
|         <div
 | |
|           v-for="errorMessage in errors"
 | |
|           :key="errorMessage"
 | |
|           data-testid="red-selector-error-list"
 | |
|           class="gl-display-flex gl-align-items-flex-start gl-text-red-500 gl-mx-4 gl-my-3"
 | |
|         >
 | |
|           <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" />
 | |
|           <span>{{ errorMessage }}</span>
 | |
|         </div>
 | |
|       </template>
 | |
|     </gl-collapsible-listbox>
 | |
|     <input
 | |
|       v-if="name"
 | |
|       data-testid="selected-ref-form-field"
 | |
|       type="hidden"
 | |
|       :value="selectedRef"
 | |
|       :name="name"
 | |
|     />
 | |
|   </div>
 | |
| </template>
 |