Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/ref')
-rw-r--r--app/assets/javascripts/ref/components/ref_results_section.vue124
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue186
-rw-r--r--app/assets/javascripts/ref/constants.js19
-rw-r--r--app/assets/javascripts/ref/stores/actions.js65
-rw-r--r--app/assets/javascripts/ref/stores/getters.js5
-rw-r--r--app/assets/javascripts/ref/stores/index.js16
-rw-r--r--app/assets/javascripts/ref/stores/mutation_types.js16
-rw-r--r--app/assets/javascripts/ref/stores/mutations.js91
-rw-r--r--app/assets/javascripts/ref/stores/state.js24
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,
+});