diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-19 08:27:35 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-19 08:27:35 +0000 |
commit | 7e9c479f7de77702622631cff2628a9c8dcbc627 (patch) | |
tree | c8f718a08e110ad7e1894510980d2155a6549197 /app/assets/javascripts/search | |
parent | e852b0ae16db4052c1c567d9efa4facc81146e88 (diff) | |
download | gitlab-ce-7e9c479f7de77702622631cff2628a9c8dcbc627.tar.gz |
Add latest changes from gitlab-org/gitlab@13-6-stable-eev13.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/search')
19 files changed, 426 insertions, 144 deletions
diff --git a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue b/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue deleted file mode 100644 index b6e2dd46358..00000000000 --- a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue +++ /dev/null @@ -1,100 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; -import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; -import { sprintf, s__ } from '~/locale'; - -export default { - name: 'DropdownFilter', - components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - }, - props: { - filterData: { - type: Object, - required: true, - }, - }, - computed: { - ...mapState(['query']), - scope() { - return this.query.scope; - }, - supportedScopes() { - return Object.values(this.filterData.scopes); - }, - initialFilter() { - return this.query[this.filterData.filterParam]; - }, - filter() { - return this.initialFilter || this.filterData.filters.ANY.value; - }, - filtersArray() { - return this.filterData.filterByScope[this.scope]; - }, - selectedFilter: { - get() { - if (this.filtersArray.some(({ value }) => value === this.filter)) { - return this.filter; - } - - return this.filterData.filters.ANY.value; - }, - set(filter) { - visitUrl(setUrlParams({ [this.filterData.filterParam]: filter })); - }, - }, - selectedFilterText() { - const f = this.filtersArray.find(({ value }) => value === this.selectedFilter); - if (!f || f === this.filterData.filters.ANY) { - return sprintf(s__('Any %{header}'), { header: this.filterData.header }); - } - - return f.label; - }, - showDropdown() { - return this.supportedScopes.includes(this.scope); - }, - }, - methods: { - dropDownItemClass(filter) { - return { - 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2': - filter === this.filterData.filters.ANY, - }; - }, - isFilterSelected(filter) { - return filter === this.selectedFilter; - }, - handleFilterChange(filter) { - this.selectedFilter = filter; - }, - }, -}; -</script> - -<template> - <gl-dropdown - v-if="showDropdown" - :text="selectedFilterText" - class="col-3 gl-pt-4 gl-pl-0 gl-pr-0 gl-mr-4" - menu-class="gl-w-full! gl-pl-0" - > - <header class="gl-text-center gl-font-weight-bold gl-font-lg"> - {{ filterData.header }} - </header> - <gl-dropdown-divider /> - <gl-dropdown-item - v-for="f in filtersArray" - :key="f.value" - :is-check-item="true" - :is-checked="isFilterSelected(f.value)" - :class="dropDownItemClass(f)" - @click="handleFilterChange(f.value)" - > - {{ f.label }} - </gl-dropdown-item> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/search/dropdown_filter/index.js b/app/assets/javascripts/search/dropdown_filter/index.js deleted file mode 100644 index e5e0745d990..00000000000 --- a/app/assets/javascripts/search/dropdown_filter/index.js +++ /dev/null @@ -1,38 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import DropdownFilter from './components/dropdown_filter.vue'; -import stateFilterData from './constants/state_filter_data'; -import confidentialFilterData from './constants/confidential_filter_data'; - -Vue.use(Translate); - -const mountDropdownFilter = (store, { id, filterData }) => { - const el = document.getElementById(id); - - if (!el) return false; - - return new Vue({ - el, - store, - render(createElement) { - return createElement(DropdownFilter, { - props: { - filterData, - }, - }); - }, - }); -}; - -const dropdownFilters = [ - { - id: 'js-search-filter-by-state', - filterData: stateFilterData, - }, - { - id: 'js-search-filter-by-confidential', - filterData: confidentialFilterData, - }, -]; - -export default store => [...dropdownFilters].map(filter => mountDropdownFilter(store, filter)); diff --git a/app/assets/javascripts/search/group_filter/components/group_filter.vue b/app/assets/javascripts/search/group_filter/components/group_filter.vue new file mode 100644 index 00000000000..4b7963c5187 --- /dev/null +++ b/app/assets/javascripts/search/group_filter/components/group_filter.vue @@ -0,0 +1,124 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, + GlIcon, + GlSkeletonLoader, + GlTooltipDirective, +} from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { isEmpty } from 'lodash'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import { ANY_GROUP, GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM } from '../constants'; + +export default { + name: 'GroupFilter', + components: { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, + GlIcon, + GlSkeletonLoader, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + initialGroup: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + groupSearch: '', + }; + }, + computed: { + ...mapState(['groups', 'fetchingGroups']), + selectedGroup: { + get() { + return isEmpty(this.initialGroup) ? ANY_GROUP : this.initialGroup; + }, + set(group) { + visitUrl(setUrlParams({ [GROUP_QUERY_PARAM]: group.id, [PROJECT_QUERY_PARAM]: null })); + }, + }, + }, + methods: { + ...mapActions(['fetchGroups']), + isGroupSelected(group) { + return group.id === this.selectedGroup.id; + }, + handleGroupChange(group) { + this.selectedGroup = group; + }, + }, + ANY_GROUP, +}; +</script> + +<template> + <gl-dropdown + ref="groupFilter" + class="gl-w-full" + menu-class="gl-w-full!" + toggle-class="gl-text-truncate gl-reset-line-height!" + :header-text="__('Filter results by group')" + @show="fetchGroups(groupSearch)" + > + <template #button-content> + <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate"> + {{ selectedGroup.name }} + </span> + <gl-loading-icon v-if="fetchingGroups" inline class="mr-2" /> + <gl-icon + v-if="!isGroupSelected($options.ANY_GROUP)" + v-gl-tooltip + name="clear" + :title="__('Clear')" + class="gl-text-gray-200! gl-hover-text-blue-800!" + @click.stop="handleGroupChange($options.ANY_GROUP)" + /> + <gl-icon name="chevron-down" /> + </template> + <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white"> + <gl-search-box-by-type + v-model="groupSearch" + class="m-2" + :debounce="500" + @input="fetchGroups" + /> + <gl-dropdown-item + class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2" + :is-check-item="true" + :is-checked="isGroupSelected($options.ANY_GROUP)" + @click="handleGroupChange($options.ANY_GROUP)" + > + {{ $options.ANY_GROUP.name }} + </gl-dropdown-item> + </div> + <div v-if="!fetchingGroups"> + <gl-dropdown-item + v-for="group in groups" + :key="group.id" + :is-check-item="true" + :is-checked="isGroupSelected(group)" + @click="handleGroupChange(group)" + > + {{ group.full_name }} + </gl-dropdown-item> + </div> + <div v-if="fetchingGroups" class="mx-3 mt-2"> + <gl-skeleton-loader :height="100"> + <rect y="0" width="90%" height="20" rx="4" /> + <rect y="40" width="70%" height="20" rx="4" /> + <rect y="80" width="80%" height="20" rx="4" /> + </gl-skeleton-loader> + </div> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/search/group_filter/constants.js b/app/assets/javascripts/search/group_filter/constants.js new file mode 100644 index 00000000000..9bd92eaa130 --- /dev/null +++ b/app/assets/javascripts/search/group_filter/constants.js @@ -0,0 +1,10 @@ +import { __ } from '~/locale'; + +export const ANY_GROUP = Object.freeze({ + id: null, + name: __('Any'), +}); + +export const GROUP_QUERY_PARAM = 'group_id'; + +export const PROJECT_QUERY_PARAM = 'project_id'; diff --git a/app/assets/javascripts/search/group_filter/index.js b/app/assets/javascripts/search/group_filter/index.js new file mode 100644 index 00000000000..9b009bc0305 --- /dev/null +++ b/app/assets/javascripts/search/group_filter/index.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import GroupFilter from './components/group_filter.vue'; + +Vue.use(Translate); + +export default store => { + let initialGroup; + const el = document.getElementById('js-search-group-dropdown'); + + const { initialGroupData } = el.dataset; + + initialGroup = JSON.parse(initialGroupData); + initialGroup = convertObjectPropsToCamelCase(initialGroup, { deep: true }); + + return new Vue({ + el, + store, + render(createElement) { + return createElement(GroupFilter, { + props: { + initialGroup, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/search/highlight_blob_search_result.js b/app/assets/javascripts/search/highlight_blob_search_result.js new file mode 100644 index 00000000000..e17c87735b4 --- /dev/null +++ b/app/assets/javascripts/search/highlight_blob_search_result.js @@ -0,0 +1,15 @@ +export default () => { + const highlightLineClass = 'hll'; + const contentBody = document.getElementById('content-body'); + const searchTerm = contentBody.querySelector('.js-search-input').value.toLowerCase(); + const blobs = contentBody.querySelectorAll('.blob-result'); + + blobs.forEach(blob => { + const lines = blob.querySelectorAll('.line'); + lines.forEach(line => { + if (line.textContent.toLowerCase().includes(searchTerm)) { + line.classList.add(highlightLineClass); + } + }); + }); +}; diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index 780d3ff0d25..781a564d077 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,9 +1,14 @@ import { queryToObject } from '~/lib/utils/url_utility'; import createStore from './store'; -import initDropdownFilters from './dropdown_filter'; +import { initSidebar } from './sidebar'; +import initGroupFilter from './group_filter'; -export default () => { - const store = createStore({ query: queryToObject(window.location.search) }); +export const initSearchApp = () => { + // Similar to url_utility.decodeUrlParameter + // Our query treats + as %20. This replaces the query + symbols with %20. + const sanitizedSearch = window.location.search.replace(/\+/g, '%20'); + const store = createStore({ query: queryToObject(sanitizedSearch) }); - initDropdownFilters(store); + initSidebar(store); + initGroupFilter(store); }; diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue new file mode 100644 index 00000000000..aa11b2025f2 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -0,0 +1,41 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlButton, GlLink } from '@gitlab/ui'; +import StatusFilter from './status_filter.vue'; +import ConfidentialityFilter from './confidentiality_filter.vue'; + +export default { + name: 'GlobalSearchSidebar', + components: { + GlButton, + GlLink, + StatusFilter, + ConfidentialityFilter, + }, + computed: { + ...mapState(['query']), + showReset() { + return this.query.state || this.query.confidential; + }, + }, + methods: { + ...mapActions(['applyQuery', 'resetQuery']), + }, +}; +</script> + +<template> + <form + class="gl-display-flex gl-flex-direction-column col-md-3 gl-mr-4 gl-mb-6 gl-mt-5" + @submit.prevent="applyQuery" + > + <status-filter /> + <confidentiality-filter /> + <div class="gl-display-flex gl-align-items-center gl-mt-3"> + <gl-button variant="success" type="submit">{{ __('Apply') }}</gl-button> + <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{ + __('Reset filters') + }}</gl-link> + </div> + </form> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue new file mode 100644 index 00000000000..38dccb9675d --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -0,0 +1,26 @@ +<script> +import { mapState } from 'vuex'; +import { confidentialFilterData } from '../constants/confidential_filter_data'; +import RadioFilter from './radio_filter.vue'; + +export default { + name: 'ConfidentialityFilter', + components: { + RadioFilter, + }, + computed: { + ...mapState(['query']), + showDropdown() { + return Object.values(confidentialFilterData.scopes).includes(this.query.scope); + }, + }, + confidentialFilterData, +}; +</script> + +<template> + <div v-if="showDropdown"> + <radio-filter :filter-data="$options.confidentialFilterData" /> + <hr class="gl-my-5 gl-border-gray-100" /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue new file mode 100644 index 00000000000..b27c4e26fb5 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue @@ -0,0 +1,68 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; + +export default { + name: 'RadioFilter', + components: { + GlFormRadioGroup, + GlFormRadio, + }, + props: { + filterData: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['query']), + ANY() { + return this.filterData.filters.ANY; + }, + scope() { + return this.query.scope; + }, + initialFilter() { + return this.query[this.filterData.filterParam]; + }, + filter() { + return this.initialFilter || this.ANY.value; + }, + filtersArray() { + return this.filterData.filterByScope[this.scope]; + }, + selectedFilter: { + get() { + if (this.filtersArray.some(({ value }) => value === this.filter)) { + return this.filter; + } + + return this.ANY.value; + }, + set(value) { + this.setQuery({ key: this.filterData.filterParam, value }); + }, + }, + }, + methods: { + ...mapActions(['setQuery']), + radioLabel(filter) { + return filter.value === this.ANY.value + ? sprintf(s__('Any %{header}'), { header: this.filterData.header.toLowerCase() }) + : filter.label; + }, + }, +}; +</script> + +<template> + <div> + <h5 class="gl-mt-0">{{ filterData.header }}</h5> + <gl-form-radio-group v-model="selectedFilter"> + <gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value"> + {{ radioLabel(f) }} + </gl-form-radio> + </gl-form-radio-group> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue new file mode 100644 index 00000000000..5cec2090906 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -0,0 +1,26 @@ +<script> +import { mapState } from 'vuex'; +import { stateFilterData } from '../constants/state_filter_data'; +import RadioFilter from './radio_filter.vue'; + +export default { + name: 'StatusFilter', + components: { + RadioFilter, + }, + computed: { + ...mapState(['query']), + showDropdown() { + return Object.values(stateFilterData.scopes).includes(this.query.scope); + }, + }, + stateFilterData, +}; +</script> + +<template> + <div v-if="showDropdown"> + <radio-filter :filter-data="$options.stateFilterData" /> + <hr class="gl-my-5 gl-border-gray-100" /> + </div> +</template> diff --git a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js b/app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js index b29daca89cb..ecb63ed9eea 100644 --- a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js +++ b/app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js @@ -27,7 +27,7 @@ const filterByScope = { const filterParam = 'confidential'; -export default { +export const confidentialFilterData = { header, filters, scopes, diff --git a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js b/app/assets/javascripts/search/sidebar/constants/state_filter_data.js index 0b93aa0be29..7c9a029ffe4 100644 --- a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js +++ b/app/assets/javascripts/search/sidebar/constants/state_filter_data.js @@ -33,7 +33,7 @@ const filterByScope = { const filterParam = 'state'; -export default { +export const stateFilterData = { header, filters, scopes, diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js new file mode 100644 index 00000000000..6419e8ac2c6 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import GlobalSearchSidebar from './components/app.vue'; + +Vue.use(Translate); + +export const initSidebar = store => { + const el = document.getElementById('js-search-sidebar'); + + if (!el) return false; + + return new Vue({ + el, + store, + render(createElement) { + return createElement(GlobalSearchSidebar); + }, + }); +}; diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js new file mode 100644 index 00000000000..447278aa223 --- /dev/null +++ b/app/assets/javascripts/search/store/actions.js @@ -0,0 +1,29 @@ +import Api from '~/api'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import * as types from './mutation_types'; + +export const fetchGroups = ({ commit }, search) => { + commit(types.REQUEST_GROUPS); + Api.groups(search) + .then(data => { + commit(types.RECEIVE_GROUPS_SUCCESS, data); + }) + .catch(() => { + createFlash({ message: __('There was a problem fetching groups.') }); + commit(types.RECEIVE_GROUPS_ERROR); + }); +}; + +export const setQuery = ({ commit }, { key, value }) => { + commit(types.SET_QUERY, { key, value }); +}; + +export const applyQuery = ({ state }) => { + visitUrl(setUrlParams({ ...state.query, page: null })); +}; + +export const resetQuery = ({ state }) => { + visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null })); +}; diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js index 10cfb647a92..e0a7e488f9f 100644 --- a/app/assets/javascripts/search/store/index.js +++ b/app/assets/javascripts/search/store/index.js @@ -1,10 +1,14 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; import createState from './state'; Vue.use(Vuex); export const getStoreConfig = ({ query }) => ({ + actions, + mutations, state: createState({ query }), }); diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js new file mode 100644 index 00000000000..2482621d4d7 --- /dev/null +++ b/app/assets/javascripts/search/store/mutation_types.js @@ -0,0 +1,5 @@ +export const REQUEST_GROUPS = 'REQUEST_GROUPS'; +export const RECEIVE_GROUPS_SUCCESS = 'RECEIVE_GROUPS_SUCCESS'; +export const RECEIVE_GROUPS_ERROR = 'RECEIVE_GROUPS_ERROR'; + +export const SET_QUERY = 'SET_QUERY'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js new file mode 100644 index 00000000000..e57850b870e --- /dev/null +++ b/app/assets/javascripts/search/store/mutations.js @@ -0,0 +1,18 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_GROUPS](state) { + state.fetchingGroups = true; + }, + [types.RECEIVE_GROUPS_SUCCESS](state, data) { + state.fetchingGroups = false; + state.groups = data; + }, + [types.RECEIVE_GROUPS_ERROR](state) { + state.fetchingGroups = false; + state.groups = []; + }, + [types.SET_QUERY](state, { key, value }) { + state.query[key] = value; + }, +}; diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index 9115a613767..70a8aab9998 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -1,4 +1,6 @@ const createState = ({ query }) => ({ query, + groups: [], + fetchingGroups: false, }); export default createState; |