diff options
Diffstat (limited to 'app/assets/javascripts/ref')
-rw-r--r-- | app/assets/javascripts/ref/components/ref_results_section.vue | 124 | ||||
-rw-r--r-- | app/assets/javascripts/ref/components/ref_selector.vue | 186 | ||||
-rw-r--r-- | app/assets/javascripts/ref/constants.js | 19 | ||||
-rw-r--r-- | app/assets/javascripts/ref/stores/actions.js | 65 | ||||
-rw-r--r-- | app/assets/javascripts/ref/stores/getters.js | 5 | ||||
-rw-r--r-- | app/assets/javascripts/ref/stores/index.js | 16 | ||||
-rw-r--r-- | app/assets/javascripts/ref/stores/mutation_types.js | 16 | ||||
-rw-r--r-- | app/assets/javascripts/ref/stores/mutations.js | 91 | ||||
-rw-r--r-- | app/assets/javascripts/ref/stores/state.js | 24 |
9 files changed, 546 insertions, 0 deletions
diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue new file mode 100644 index 00000000000..32e916052c4 --- /dev/null +++ b/app/assets/javascripts/ref/components/ref_results_section.vue @@ -0,0 +1,124 @@ +<script> +import { GlNewDropdownHeader, GlNewDropdownItem, GlBadge, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'RefResultsSection', + components: { + GlNewDropdownHeader, + GlNewDropdownItem, + GlBadge, + GlIcon, + }, + props: { + sectionTitle: { + type: String, + required: true, + }, + + totalCount: { + type: Number, + required: true, + }, + + /** + * An array of object that have the following properties: + * + * - name (String, required): The name of the ref that will be displayed + * - value (String, optional): The value that will be selected when the ref + * is selected. If not provided, `name` will be used as the value. + * For example, commits use the short SHA for `name` + * and long SHA for `value`. + * - subtitle (String, optional): Text to render underneath the name. + * For example, used to render the commit's title underneath its SHA. + * - default (Boolean, optional): Whether or not to render a "default" + * indicator next to the item. Used to indicate + * the project's default branch. + * + */ + items: { + type: Array, + required: true, + validator: items => Array.isArray(items) && items.every(item => item.name), + }, + + /** + * The currently selected ref. + * Used to render a check mark by the selected item. + * */ + selectedRef: { + type: String, + required: false, + default: '', + }, + + /** + * An error object that indicates that an error + * occurred while fetching items for this section + */ + error: { + type: Error, + required: false, + default: null, + }, + + /** The message to display if an error occurs */ + errorMessage: { + type: String, + required: false, + default: '', + }, + }, + computed: { + totalCountText() { + return this.totalCount > 999 ? s__('TotalRefCountIndicator|1000+') : `${this.totalCount}`; + }, + }, + methods: { + showCheck(item) { + return item.name === this.selectedRef || item.value === this.selectedRef; + }, + }, +}; +</script> + +<template> + <div> + <gl-new-dropdown-header> + <div class="gl-display-flex align-items-center" data-testid="section-header"> + <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span> + <gl-badge variant="neutral">{{ totalCountText }}</gl-badge> + </div> + </gl-new-dropdown-header> + <template v-if="error"> + <div class="gl-display-flex align-items-start text-danger gl-ml-4 gl-mr-4 gl-mb-3"> + <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> + <span>{{ errorMessage }}</span> + </div> + </template> + <template v-else> + <gl-new-dropdown-item + v-for="item in items" + :key="item.name" + @click="$emit('selected', item.value || item.name)" + > + <div class="gl-display-flex align-items-start"> + <gl-icon + name="mobile-issue-close" + class="gl-mr-2 gl-flex-shrink-0" + :class="{ 'gl-visibility-hidden': !showCheck(item) }" + /> + + <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column"> + <span class="gl-font-monospace">{{ item.name }}</span> + <span class="gl-text-gray-600">{{ item.subtitle }}</span> + </div> + + <gl-badge v-if="item.default" size="sm" variant="info">{{ + s__('DefaultBranchLabel|default') + }}</gl-badge> + </div> + </gl-new-dropdown-item> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue new file mode 100644 index 00000000000..012a391a3da --- /dev/null +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -0,0 +1,186 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import { + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownHeader, + GlSearchBoxByType, + GlSprintf, + GlIcon, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import createStore from '../stores'; +import { SEARCH_DEBOUNCE_MS, DEFAULT_I18N } from '../constants'; +import RefResultsSection from './ref_results_section.vue'; + +export default { + name: 'RefSelector', + store: createStore(), + components: { + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownHeader, + GlSearchBoxByType, + GlSprintf, + GlIcon, + GlLoadingIcon, + RefResultsSection, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + projectId: { + type: String, + required: true, + }, + translations: { + type: Object, + required: false, + default: () => ({}), + }, + }, + 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, + }; + }, + showBranchesSection() { + return Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error); + }, + showTagsSection() { + return Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error); + }, + showCommitsSection() { + return Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error); + }, + showNoResults() { + return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection; + }, + }, + created() { + this.setProjectId(this.projectId); + this.search(this.query); + }, + methods: { + ...mapActions(['setProjectId', 'setSelectedRef', 'search']), + focusSearchBox() { + this.$refs.searchBox.$el.querySelector('input').focus(); + }, + onSearchBoxInput: debounce(function search() { + this.search(this.query); + }, SEARCH_DEBOUNCE_MS), + selectRef(ref) { + this.setSelectedRef(ref); + this.$emit('input', this.selectedRef); + }, + }, +}; +</script> + +<template> + <gl-new-dropdown class="ref-selector" @shown="focusSearchBox"> + <template slot="button-content"> + <span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-600" data-testid="button-content"> + <span v-if="selectedRef" class="gl-font-monospace">{{ selectedRef }}</span> + <span v-else>{{ i18n.noRefSelected }}</span> + </span> + <gl-icon name="chevron-down" /> + </template> + + <div class="gl-display-flex gl-flex-direction-column ref-selector-dropdown-content"> + <gl-new-dropdown-header> + <span class="gl-text-center gl-display-block">{{ i18n.dropdownHeader }}</span> + </gl-new-dropdown-header> + + <gl-new-dropdown-divider /> + + <gl-search-box-by-type + ref="searchBox" + v-model.trim="query" + class="gl-m-3" + :placeholder="i18n.searchPlaceholder" + @input="onSearchBoxInput" + /> + + <div class="gl-flex-grow-1 gl-overflow-y-auto"> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" /> + + <div + v-else-if="showNoResults" + class="gl-text-center gl-mx-3 gl-py-3" + data-testid="no-results" + > + <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery"> + <template #query> + <b class="gl-word-break-all">{{ lastQuery }}</b> + </template> + </gl-sprintf> + + <span v-else>{{ i18n.noResults }}</span> + </div> + + <template v-else> + <template v-if="showBranchesSection"> + <ref-results-section + :section-title="i18n.branches" + :total-count="matches.branches.totalCount" + :items="matches.branches.list" + :selected-ref="selectedRef" + :error="matches.branches.error" + :error-message="i18n.branchesErrorMessage" + data-testid="branches-section" + @selected="selectRef($event)" + /> + + <gl-new-dropdown-divider v-if="showTagsSection || showCommitsSection" /> + </template> + + <template v-if="showTagsSection"> + <ref-results-section + :section-title="i18n.tags" + :total-count="matches.tags.totalCount" + :items="matches.tags.list" + :selected-ref="selectedRef" + :error="matches.tags.error" + :error-message="i18n.tagsErrorMessage" + data-testid="tags-section" + @selected="selectRef($event)" + /> + + <gl-new-dropdown-divider v-if="showCommitsSection" /> + </template> + + <template v-if="showCommitsSection"> + <ref-results-section + :section-title="i18n.commits" + :total-count="matches.commits.totalCount" + :items="matches.commits.list" + :selected-ref="selectedRef" + :error="matches.commits.error" + :error-message="i18n.commitsErrorMessage" + data-testid="commits-section" + @selected="selectRef($event)" + /> + </template> + </template> + </div> + </div> + </gl-new-dropdown> +</template> diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js new file mode 100644 index 00000000000..ca82b951377 --- /dev/null +++ b/app/assets/javascripts/ref/constants.js @@ -0,0 +1,19 @@ +import { __ } from '~/locale'; + +export const X_TOTAL_HEADER = 'x-total'; + +export const SEARCH_DEBOUNCE_MS = 250; + +export const DEFAULT_I18N = Object.freeze({ + dropdownHeader: __('Select Git revision'), + searchPlaceholder: __('Search by Git revision'), + noResultsWithQuery: __('No matching results for "%{query}"'), + noResults: __('No matching results'), + branchesErrorMessage: __('An error occurred while fetching branches. Retry the search.'), + tagsErrorMessage: __('An error occurred while fetching tags. Retry the search.'), + commitsErrorMessage: __('An error occurred while fetching commits. Retry the search.'), + branches: __('Branches'), + tags: __('Tags'), + commits: __('Commits'), + noRefSelected: __('No ref selected'), +}); diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js new file mode 100644 index 00000000000..8fcc99cef38 --- /dev/null +++ b/app/assets/javascripts/ref/stores/actions.js @@ -0,0 +1,65 @@ +import Api from '~/api'; +import * as types from './mutation_types'; + +export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId); + +export const setSelectedRef = ({ commit }, selectedRef) => + commit(types.SET_SELECTED_REF, selectedRef); + +export const search = ({ dispatch, commit }, query) => { + commit(types.SET_QUERY, query); + + dispatch('searchBranches'); + dispatch('searchTags'); + dispatch('searchCommits'); +}; + +export const searchBranches = ({ commit, state }) => { + commit(types.REQUEST_START); + + Api.branches(state.projectId, state.query) + .then(response => { + commit(types.RECEIVE_BRANCHES_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_BRANCHES_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); +}; + +export const searchTags = ({ commit, state }) => { + commit(types.REQUEST_START); + + Api.tags(state.projectId, state.query) + .then(response => { + commit(types.RECEIVE_TAGS_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_TAGS_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); +}; + +export const searchCommits = ({ commit, state, getters }) => { + // Only query the Commit API if the search query looks like a commit SHA + if (getters.isQueryPossiblyASha) { + commit(types.REQUEST_START); + + Api.commit(state.projectId, state.query) + .then(response => { + commit(types.RECEIVE_COMMITS_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_COMMITS_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); + } else { + commit(types.RESET_COMMIT_MATCHES); + } +}; diff --git a/app/assets/javascripts/ref/stores/getters.js b/app/assets/javascripts/ref/stores/getters.js new file mode 100644 index 00000000000..02d4ae8ff91 --- /dev/null +++ b/app/assets/javascripts/ref/stores/getters.js @@ -0,0 +1,5 @@ +/** Returns `true` if the query string looks like it could be a commit SHA */ +export const isQueryPossiblyASha = ({ query }) => /^[0-9a-f]{4,40}$/i.test(query); + +/** Returns `true` if there is at least one in-progress request */ +export const isLoading = ({ requestCount }) => requestCount > 0; diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js new file mode 100644 index 00000000000..2bebffc19ab --- /dev/null +++ b/app/assets/javascripts/ref/stores/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + getters, + mutations, + state: createState(), + }); diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js new file mode 100644 index 00000000000..9f6195f5f3f --- /dev/null +++ b/app/assets/javascripts/ref/stores/mutation_types.js @@ -0,0 +1,16 @@ +export const SET_PROJECT_ID = 'SET_PROJECT_ID'; +export const SET_SELECTED_REF = 'SET_SELECTED_REF'; +export const SET_QUERY = 'SET_QUERY'; + +export const REQUEST_START = 'REQUEST_START'; +export const REQUEST_FINISH = 'REQUEST_FINISH'; + +export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS'; +export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR'; + +export const RECEIVE_TAGS_SUCCESS = 'RECEIVE_TAGS_SUCCESS'; +export const RECEIVE_TAGS_ERROR = 'RECEIVE_TAGS_ERROR'; + +export const RECEIVE_COMMITS_SUCCESS = 'RECEIVE_COMMITS_SUCCESS'; +export const RECEIVE_COMMITS_ERROR = 'RECEIVE_COMMITS_ERROR'; +export const RESET_COMMIT_MATCHES = 'RESET_COMMIT_MATCHES'; diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js new file mode 100644 index 00000000000..73f9d7ee487 --- /dev/null +++ b/app/assets/javascripts/ref/stores/mutations.js @@ -0,0 +1,91 @@ +import * as types from './mutation_types'; +import { X_TOTAL_HEADER } from '../constants'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +export default { + [types.SET_PROJECT_ID](state, projectId) { + state.projectId = projectId; + }, + [types.SET_SELECTED_REF](state, selectedRef) { + state.selectedRef = selectedRef; + }, + [types.SET_QUERY](state, query) { + state.query = query; + }, + + [types.REQUEST_START](state) { + state.requestCount += 1; + }, + [types.REQUEST_FINISH](state) { + state.requestCount -= 1; + }, + + [types.RECEIVE_BRANCHES_SUCCESS](state, response) { + state.matches.branches = { + list: convertObjectPropsToCamelCase(response.data).map(b => ({ + name: b.name, + default: b.default, + })), + totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10), + error: null, + }; + }, + [types.RECEIVE_BRANCHES_ERROR](state, error) { + state.matches.branches = { + list: [], + totalCount: 0, + error, + }; + }, + + [types.RECEIVE_TAGS_SUCCESS](state, response) { + state.matches.tags = { + list: convertObjectPropsToCamelCase(response.data).map(b => ({ + name: b.name, + })), + totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10), + error: null, + }; + }, + [types.RECEIVE_TAGS_ERROR](state, error) { + state.matches.tags = { + list: [], + totalCount: 0, + error, + }; + }, + + [types.RECEIVE_COMMITS_SUCCESS](state, response) { + const commit = convertObjectPropsToCamelCase(response.data); + + state.matches.commits = { + list: [ + { + name: commit.shortId, + value: commit.id, + subtitle: commit.title, + }, + ], + totalCount: 1, + error: null, + }; + }, + [types.RECEIVE_COMMITS_ERROR](state, error) { + state.matches.commits = { + list: [], + totalCount: 0, + + // 404's are expected when the search query doesn't match any commits + // and shouldn't be treated as an actual error + error: error.response?.status !== httpStatusCodes.NOT_FOUND ? error : null, + }; + }, + [types.RESET_COMMIT_MATCHES](state) { + state.matches.commits = { + list: [], + totalCount: 0, + error: null, + }; + }, +}; diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js new file mode 100644 index 00000000000..65b9d6449d7 --- /dev/null +++ b/app/assets/javascripts/ref/stores/state.js @@ -0,0 +1,24 @@ +export default () => ({ + projectId: null, + + query: '', + matches: { + branches: { + list: [], + totalCount: 0, + error: null, + }, + tags: { + list: [], + totalCount: 0, + error: null, + }, + commits: { + list: [], + totalCount: 0, + error: null, + }, + }, + selectedRef: null, + requestCount: 0, +}); |