281 lines
7.3 KiB
Vue
281 lines
7.3 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),
|
|
},
|
|
'gl-mb-0',
|
|
];
|
|
|
|
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>
|