diff options
Diffstat (limited to 'app/assets/javascripts/header_search')
11 files changed, 373 insertions, 0 deletions
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue new file mode 100644 index 00000000000..580c27f6c61 --- /dev/null +++ b/app/assets/javascripts/header_search/components/app.vue @@ -0,0 +1,83 @@ +<script> +import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui'; +import { mapState, mapActions, mapGetters } from 'vuex'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import HeaderSearchDefaultItems from './header_search_default_items.vue'; +import HeaderSearchScopedItems from './header_search_scoped_items.vue'; + +export default { + name: 'HeaderSearchApp', + i18n: { + searchPlaceholder: __('Search or jump to...'), + }, + directives: { Outside }, + components: { + GlSearchBoxByType, + HeaderSearchDefaultItems, + HeaderSearchScopedItems, + }, + data() { + return { + showDropdown: false, + }; + }, + computed: { + ...mapState(['search']), + ...mapGetters(['searchQuery']), + searchText: { + get() { + return this.search; + }, + set(value) { + this.setSearch(value); + }, + }, + showSearchDropdown() { + return this.showDropdown && gon?.current_username; + }, + showDefaultItems() { + return !this.searchText; + }, + }, + methods: { + ...mapActions(['setSearch']), + openDropdown() { + this.showDropdown = true; + }, + closeDropdown() { + this.showDropdown = false; + }, + submitSearch() { + return visitUrl(this.searchQuery); + }, + }, +}; +</script> + +<template> + <section v-outside="closeDropdown" class="header-search gl-relative"> + <gl-search-box-by-type + v-model="searchText" + :debounce="500" + autocomplete="off" + :placeholder="$options.i18n.searchPlaceholder" + @focus="openDropdown" + @click="openDropdown" + @keydown.enter="submitSearch" + @keydown.esc="closeDropdown" + /> + <div + v-if="showSearchDropdown" + data-testid="header-search-dropdown-menu" + class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-left-0 gl-z-index-1 gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0" + > + <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2"> + <header-search-default-items v-if="showDefaultItems" /> + <template v-else> + <header-search-scoped-items /> + </template> + </div> + </div> + </section> +</template> diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue new file mode 100644 index 00000000000..2871937ed3a --- /dev/null +++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue @@ -0,0 +1,42 @@ +<script> +import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +import { mapState, mapGetters } from 'vuex'; +import { __ } from '~/locale'; + +export default { + name: 'HeaderSearchDefaultItems', + i18n: { + allGitLab: __('All GitLab'), + }, + components: { + GlDropdownSectionHeader, + GlDropdownItem, + }, + computed: { + ...mapState(['searchContext']), + ...mapGetters(['defaultSearchOptions']), + sectionHeader() { + return ( + this.searchContext.project?.name || + this.searchContext.group?.name || + this.$options.i18n.allGitLab + ); + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="(option, index) in defaultSearchOptions" + :id="`default-${index}`" + :key="index" + tabindex="-1" + :href="option.url" + > + {{ option.title }} + </gl-dropdown-item> + </div> +</template> diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue new file mode 100644 index 00000000000..645eba05148 --- /dev/null +++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue @@ -0,0 +1,31 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; +import { mapState, mapGetters } from 'vuex'; + +export default { + name: 'HeaderSearchScopedItems', + components: { + GlDropdownItem, + }, + computed: { + ...mapState(['search']), + ...mapGetters(['scopedSearchOptions']), + }, +}; +</script> + +<template> + <div> + <gl-dropdown-item + v-for="(option, index) in scopedSearchOptions" + :id="`scoped-${index}`" + :key="index" + tabindex="-1" + :href="option.url" + > + "<span class="gl-font-weight-bold">{{ search }}</span + >" {{ option.description }} + <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span> + </gl-dropdown-item> + </div> +</template> diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js new file mode 100644 index 00000000000..fffed7bcbdb --- /dev/null +++ b/app/assets/javascripts/header_search/constants.js @@ -0,0 +1,17 @@ +import { __ } from '~/locale'; + +export const MSG_ISSUES_ASSIGNED_TO_ME = __('Issues assigned to me'); + +export const MSG_ISSUES_IVE_CREATED = __("Issues I've created"); + +export const MSG_MR_ASSIGNED_TO_ME = __('Merge requests assigned to me'); + +export const MSG_MR_IM_REVIEWER = __("Merge requests that I'm a reviewer"); + +export const MSG_MR_IVE_CREATED = __("Merge requests I've created"); + +export const MSG_IN_ALL_GITLAB = __('in all GitLab'); + +export const MSG_IN_GROUP = __('in group'); + +export const MSG_IN_PROJECT = __('in project'); diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js new file mode 100644 index 00000000000..2d37ee137fc --- /dev/null +++ b/app/assets/javascripts/header_search/index.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import HeaderSearchApp from './components/app.vue'; +import createStore from './store'; + +Vue.use(Translate); + +export const initHeaderSearchApp = () => { + const el = document.getElementById('js-header-search'); + + if (!el) { + return false; + } + + const { searchPath, issuesPath, mrPath } = el.dataset; + let { searchContext } = el.dataset; + searchContext = JSON.parse(searchContext); + + return new Vue({ + el, + store: createStore({ searchPath, issuesPath, mrPath, searchContext }), + render(createElement) { + return createElement(HeaderSearchApp); + }, + }); +}; diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js new file mode 100644 index 00000000000..841aee04029 --- /dev/null +++ b/app/assets/javascripts/header_search/store/actions.js @@ -0,0 +1,5 @@ +import * as types from './mutation_types'; + +export const setSearch = ({ commit }, value) => { + commit(types.SET_SEARCH, value); +}; diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js new file mode 100644 index 00000000000..d1e1fc8ad73 --- /dev/null +++ b/app/assets/javascripts/header_search/store/getters.js @@ -0,0 +1,135 @@ +import { objectToQuery } from '~/lib/utils/url_utility'; + +import { + MSG_ISSUES_ASSIGNED_TO_ME, + MSG_ISSUES_IVE_CREATED, + MSG_MR_ASSIGNED_TO_ME, + MSG_MR_IM_REVIEWER, + MSG_MR_IVE_CREATED, + MSG_IN_PROJECT, + MSG_IN_GROUP, + MSG_IN_ALL_GITLAB, +} from '../constants'; + +export const searchQuery = (state) => { + const query = { + search: state.search, + nav_source: 'navbar', + project_id: state.searchContext.project?.id, + group_id: state.searchContext.group?.id, + scope: state.searchContext.scope, + }; + + return `${state.searchPath}?${objectToQuery(query)}`; +}; + +export const scopedIssuesPath = (state) => { + return ( + state.searchContext.project_metadata?.issues_path || + state.searchContext.group_metadata?.issues_path || + state.issuesPath + ); +}; + +export const scopedMRPath = (state) => { + return ( + state.searchContext.project_metadata?.mr_path || + state.searchContext.group_metadata?.mr_path || + state.mrPath + ); +}; + +export const defaultSearchOptions = (state, getters) => { + const userName = gon.current_username; + + return [ + { + title: MSG_ISSUES_ASSIGNED_TO_ME, + url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, + }, + { + title: MSG_ISSUES_IVE_CREATED, + url: `${getters.scopedIssuesPath}/?author_username=${userName}`, + }, + { + title: MSG_MR_ASSIGNED_TO_ME, + url: `${getters.scopedMRPath}/?assignee_username=${userName}`, + }, + { + title: MSG_MR_IM_REVIEWER, + url: `${getters.scopedMRPath}/?reviewer_username=${userName}`, + }, + { + title: MSG_MR_IVE_CREATED, + url: `${getters.scopedMRPath}/?author_username=${userName}`, + }, + ]; +}; + +export const projectUrl = (state) => { + if (!state.searchContext.project || !state.searchContext.group) { + return null; + } + + const query = { + search: state.search, + nav_source: 'navbar', + project_id: state.searchContext.project.id, + group_id: state.searchContext.group.id, + scope: state.searchContext.scope, + }; + + return `${state.searchPath}?${objectToQuery(query)}`; +}; + +export const groupUrl = (state) => { + if (!state.searchContext.group) { + return null; + } + + const query = { + search: state.search, + nav_source: 'navbar', + group_id: state.searchContext.group.id, + scope: state.searchContext.scope, + }; + + return `${state.searchPath}?${objectToQuery(query)}`; +}; + +export const allUrl = (state) => { + const query = { + search: state.search, + nav_source: 'navbar', + scope: state.searchContext.scope, + }; + + return `${state.searchPath}?${objectToQuery(query)}`; +}; + +export const scopedSearchOptions = (state, getters) => { + const options = []; + + if (state.searchContext.project) { + options.push({ + scope: state.searchContext.project.name, + description: MSG_IN_PROJECT, + url: getters.projectUrl, + }); + } + + if (state.searchContext.group) { + options.push({ + scope: state.searchContext.group.name, + description: MSG_IN_GROUP, + url: getters.groupUrl, + }); + } + + options.push({ + description: MSG_IN_ALL_GITLAB, + url: getters.allUrl, + }); + + return options; +}; diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js new file mode 100644 index 00000000000..8b74f8662a5 --- /dev/null +++ b/app/assets/javascripts/header_search/store/index.js @@ -0,0 +1,18 @@ +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 const getStoreConfig = ({ searchPath, issuesPath, mrPath, searchContext }) => ({ + actions, + getters, + mutations, + state: createState({ searchPath, issuesPath, mrPath, searchContext }), +}); + +const createStore = (config) => new Vuex.Store(getStoreConfig(config)); +export default createStore; diff --git a/app/assets/javascripts/header_search/store/mutation_types.js b/app/assets/javascripts/header_search/store/mutation_types.js new file mode 100644 index 00000000000..0bc94ae055f --- /dev/null +++ b/app/assets/javascripts/header_search/store/mutation_types.js @@ -0,0 +1 @@ +export const SET_SEARCH = 'SET_SEARCH'; diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js new file mode 100644 index 00000000000..5b1438929d4 --- /dev/null +++ b/app/assets/javascripts/header_search/store/mutations.js @@ -0,0 +1,7 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_SEARCH](state, value) { + state.search = value; + }, +}; diff --git a/app/assets/javascripts/header_search/store/state.js b/app/assets/javascripts/header_search/store/state.js new file mode 100644 index 00000000000..fb2c83dbbe3 --- /dev/null +++ b/app/assets/javascripts/header_search/store/state.js @@ -0,0 +1,8 @@ +const createState = ({ searchPath, issuesPath, mrPath, searchContext }) => ({ + searchPath, + issuesPath, + mrPath, + searchContext, + search: '', +}); +export default createState; |