diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-07-20 12:55:51 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-07-20 12:55:51 +0300 |
commit | e8d2c2579383897a1dd7f9debd359abe8ae8373d (patch) | |
tree | c42be41678c2586d49a75cabce89322082698334 /app/assets/javascripts/search | |
parent | fc845b37ec3a90aaa719975f607740c22ba6a113 (diff) |
Add latest changes from gitlab-org/gitlab@14-1-stable-eev14.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/search')
12 files changed, 217 insertions, 10 deletions
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 0c3f273fec7..b53557c0ec5 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -2,11 +2,13 @@ import Api from '~/api'; import createFlash from '~/flash'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; import * as types from './mutation_types'; +import { loadDataFromLS, setFrequentItemToLS, mergeById } from './utils'; export const fetchGroups = ({ commit }, search) => { commit(types.REQUEST_GROUPS); - Api.groups(search) + Api.groups(search, { order_by: 'similarity' }) .then((data) => { commit(types.RECEIVE_GROUPS_SUCCESS, data); }) @@ -30,7 +32,12 @@ export const fetchProjects = ({ commit, state }, search) => { if (groupId) { // TODO (https://gitlab.com/gitlab-org/gitlab/-/issues/323331): For errors `createFlash` is called twice; in `callback` and in `Api.groupProjects` - Api.groupProjects(groupId, search, {}, callback); + Api.groupProjects( + groupId, + search, + { order_by: 'similarity', with_shared: false, include_subgroups: true }, + callback, + ); } else { // The .catch() is due to the API method not handling a rejection properly Api.projects(search, { order_by: 'id' }, callback).catch(() => { @@ -39,6 +46,40 @@ export const fetchProjects = ({ commit, state }, search) => { } }; +export const loadFrequentGroups = async ({ commit }) => { + const data = loadDataFromLS(GROUPS_LOCAL_STORAGE_KEY); + commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data }); + + const promises = data.map((d) => Api.group(d.id)); + try { + const inflatedData = mergeById(await Promise.all(promises), data); + commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data: inflatedData }); + } catch { + createFlash({ message: __('There was a problem fetching recent groups.') }); + } +}; + +export const loadFrequentProjects = async ({ commit }) => { + const data = loadDataFromLS(PROJECTS_LOCAL_STORAGE_KEY); + commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data }); + + const promises = data.map((d) => Api.project(d.id).then((res) => res.data)); + try { + const inflatedData = mergeById(await Promise.all(promises), data); + commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: inflatedData }); + } catch { + createFlash({ message: __('There was a problem fetching recent projects.') }); + } +}; + +export const setFrequentGroup = ({ state }, item) => { + setFrequentItemToLS(GROUPS_LOCAL_STORAGE_KEY, state.frequentItems, item); +}; + +export const setFrequentProject = ({ state }, item) => { + setFrequentItemToLS(PROJECTS_LOCAL_STORAGE_KEY, state.frequentItems, item); +}; + export const setQuery = ({ commit }, { key, value }) => { commit(types.SET_QUERY, { key, value }); }; diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js new file mode 100644 index 00000000000..3abf7cac6ba --- /dev/null +++ b/app/assets/javascripts/search/store/constants.js @@ -0,0 +1,7 @@ +export const MAX_FREQUENT_ITEMS = 5; + +export const MAX_FREQUENCY = 5; + +export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups'; + +export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects'; diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js new file mode 100644 index 00000000000..650af5fa55a --- /dev/null +++ b/app/assets/javascripts/search/store/getters.js @@ -0,0 +1,9 @@ +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; + +export const frequentGroups = (state) => { + return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY]; +}; + +export const frequentProjects = (state) => { + return state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY]; +}; diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js index 1923c8b96ab..4fa88822722 100644 --- a/app/assets/javascripts/search/store/index.js +++ b/app/assets/javascripts/search/store/index.js @@ -1,6 +1,7 @@ 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'; @@ -8,6 +9,7 @@ Vue.use(Vuex); export const getStoreConfig = ({ query }) => ({ actions, + getters, mutations, state: createState({ query }), }); diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js index a6430b53c4f..5c1c29dc738 100644 --- a/app/assets/javascripts/search/store/mutation_types.js +++ b/app/assets/javascripts/search/store/mutation_types.js @@ -7,3 +7,5 @@ export const RECEIVE_PROJECTS_SUCCESS = 'RECEIVE_PROJECTS_SUCCESS'; export const RECEIVE_PROJECTS_ERROR = 'RECEIVE_PROJECTS_ERROR'; export const SET_QUERY = 'SET_QUERY'; + +export const LOAD_FREQUENT_ITEMS = 'LOAD_FREQUENT_ITEMS'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index 91d7cf66c8f..63156a89738 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -26,4 +26,7 @@ export default { [types.SET_QUERY](state, { key, value }) { state.query[key] = value; }, + [types.LOAD_FREQUENT_ITEMS](state, { key, data }) { + state.frequentItems[key] = data; + }, }; diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index 9a0d61d0b93..5b1429ccc97 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -1,8 +1,14 @@ +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; + const createState = ({ query }) => ({ query, groups: [], fetchingGroups: false, projects: [], fetchingProjects: false, + frequentItems: { + [GROUPS_LOCAL_STORAGE_KEY]: [], + [PROJECTS_LOCAL_STORAGE_KEY]: [], + }, }); export default createState; diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js new file mode 100644 index 00000000000..60c09221ca9 --- /dev/null +++ b/app/assets/javascripts/search/store/utils.js @@ -0,0 +1,80 @@ +import AccessorUtilities from '../../lib/utils/accessor'; +import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY } from './constants'; + +function extractKeys(object, keyList) { + return Object.fromEntries(keyList.map((key) => [key, object[key]])); +} + +export const loadDataFromLS = (key) => { + if (!AccessorUtilities.isLocalStorageAccessSafe()) { + return []; + } + + try { + return JSON.parse(localStorage.getItem(key)) || []; + } catch { + // The LS got in a bad state, let's wipe it + localStorage.removeItem(key); + return []; + } +}; + +export const setFrequentItemToLS = (key, data, itemData) => { + if (!AccessorUtilities.isLocalStorageAccessSafe()) { + return; + } + + const keyList = [ + 'id', + 'avatar_url', + 'name', + 'full_name', + 'name_with_namespace', + 'frequency', + 'lastUsed', + ]; + + try { + const frequentItems = data[key].map((obj) => extractKeys(obj, keyList)); + const item = extractKeys(itemData, keyList); + const existingItemIndex = frequentItems.findIndex((i) => i.id === item.id); + + if (existingItemIndex >= 0) { + // Up the frequency (Max 5) + const currentFrequency = frequentItems[existingItemIndex].frequency; + frequentItems[existingItemIndex].frequency = Math.min(currentFrequency + 1, MAX_FREQUENCY); + frequentItems[existingItemIndex].lastUsed = new Date().getTime(); + } else { + // Only store a max of 5 items + if (frequentItems.length >= MAX_FREQUENT_ITEMS) { + frequentItems.pop(); + } + + frequentItems.push({ ...item, frequency: 1, lastUsed: new Date().getTime() }); + } + + // Sort by frequency and lastUsed + frequentItems.sort((a, b) => { + if (a.frequency > b.frequency) { + return -1; + } else if (a.frequency < b.frequency) { + return 1; + } + return b.lastUsed - a.lastUsed; + }); + + // Note we do not need to commit a mutation here as immediately after this we refresh the page to + // update the search results. + localStorage.setItem(key, JSON.stringify(frequentItems)); + } catch { + // The LS got in a bad state, let's wipe it + localStorage.removeItem(key); + } +}; + +export const mergeById = (inflatedData, storedData) => { + return inflatedData.map((data) => { + const stored = storedData?.find((d) => d.id === data.id) || {}; + return { ...stored, ...data }; + }); +}; diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue index da9252eeacd..45a6ae73fac 100644 --- a/app/assets/javascripts/search/topbar/components/group_filter.vue +++ b/app/assets/javascripts/search/topbar/components/group_filter.vue @@ -1,6 +1,6 @@ <script> import { isEmpty } from 'lodash'; -import { mapState, mapActions } from 'vuex'; +import { mapState, mapActions, mapGetters } from 'vuex'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; import SearchableDropdown from './searchable_dropdown.vue'; @@ -19,13 +19,19 @@ export default { }, computed: { ...mapState(['groups', 'fetchingGroups']), + ...mapGetters(['frequentGroups']), selectedGroup() { return isEmpty(this.initialData) ? ANY_OPTION : this.initialData; }, }, methods: { - ...mapActions(['fetchGroups']), + ...mapActions(['fetchGroups', 'setFrequentGroup', 'loadFrequentGroups']), handleGroupChange(group) { + // If group.id is null we are clearing the filter and don't need to store that in LS. + if (group.id) { + this.setFrequentGroup(group); + } + visitUrl( setUrlParams({ [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null }), ); @@ -44,6 +50,8 @@ export default { :loading="fetchingGroups" :selected-item="selectedGroup" :items="groups" + :frequent-items="frequentGroups" + @first-open="loadFrequentGroups" @search="fetchGroups" @change="handleGroupChange" /> diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue index dbe8ba54216..1ca31db61e5 100644 --- a/app/assets/javascripts/search/topbar/components/project_filter.vue +++ b/app/assets/javascripts/search/topbar/components/project_filter.vue @@ -1,5 +1,5 @@ <script> -import { mapState, mapActions } from 'vuex'; +import { mapState, mapActions, mapGetters } from 'vuex'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; import SearchableDropdown from './searchable_dropdown.vue'; @@ -18,13 +18,19 @@ export default { }, computed: { ...mapState(['projects', 'fetchingProjects']), + ...mapGetters(['frequentProjects']), selectedProject() { return this.initialData ? this.initialData : ANY_OPTION; }, }, methods: { - ...mapActions(['fetchProjects']), + ...mapActions(['fetchProjects', 'setFrequentProject', 'loadFrequentProjects']), handleProjectChange(project) { + // If project.id is null we are clearing the filter and don't need to store that in LS. + if (project.id) { + this.setFrequentProject(project); + } + // This determines if we need to update the group filter or not const queryParams = { ...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }), @@ -47,6 +53,8 @@ export default { :loading="fetchingProjects" :selected-item="selectedProject" :items="projects" + :frequent-items="frequentProjects" + @first-open="loadFrequentProjects" @search="fetchProjects" @change="handleProjectChange" /> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue index 2e2aa052dd8..5653cddda60 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue @@ -2,6 +2,7 @@ import { GlDropdown, GlDropdownItem, + GlDropdownSectionHeader, GlSearchBoxByType, GlLoadingIcon, GlIcon, @@ -16,11 +17,13 @@ import SearchableDropdownItem from './searchable_dropdown_item.vue'; export default { i18n: { clearLabel: __('Clear'), + frequentlySearched: __('Frequently searched'), }, name: 'SearchableDropdown', components: { GlDropdown, GlDropdownItem, + GlDropdownSectionHeader, GlSearchBoxByType, GlLoadingIcon, GlIcon, @@ -61,17 +64,33 @@ export default { required: false, default: () => [], }, + frequentItems: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { searchText: '', + hasBeenOpened: false, }; }, + computed: { + showFrequentItems() { + return !this.searchText && this.frequentItems.length > 0; + }, + }, methods: { isSelected(selected) { return selected.id === this.selectedItem.id; }, openDropdown() { + if (!this.hasBeenOpened) { + this.hasBeenOpened = true; + this.$emit('first-open'); + } + this.$emit('search', this.searchText); }, resetDropdown() { @@ -99,7 +118,7 @@ export default { <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate"> {{ selectedItem[name] }} </span> - <gl-loading-icon v-if="loading" inline class="gl-mr-3" /> + <gl-loading-icon v-if="loading" size="sm" inline class="gl-mr-3" /> <gl-button v-if="!isSelected($options.ANY_OPTION)" v-gl-tooltip @@ -133,6 +152,25 @@ export default { <span data-testid="item-title">{{ $options.ANY_OPTION.name }}</span> </gl-dropdown-item> </div> + <div + v-if="showFrequentItems" + class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2 gl-mb-2" + > + <gl-dropdown-section-header>{{ + $options.i18n.frequentlySearched + }}</gl-dropdown-section-header> + <searchable-dropdown-item + v-for="item in frequentItems" + :key="item.id" + :item="item" + :selected-item="selectedItem" + :search-text="searchText" + :name="name" + :full-name="fullName" + data-testid="frequent-items" + @change="updateDropdown" + /> + </div> <div v-if="!loading"> <searchable-dropdown-item v-for="item in items" @@ -142,6 +180,7 @@ export default { :search-text="searchText" :name="name" :full-name="fullName" + data-testid="searchable-items" @change="updateDropdown" /> </div> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue index 498d4af59b4..42d6444e690 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem, GlAvatar } from '@gitlab/ui'; +import { GlDropdownItem, GlAvatar, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; @@ -9,6 +9,9 @@ export default { GlDropdownItem, GlAvatar, }, + directives: { + SafeHtml, + }, props: { item: { type: Object, @@ -62,8 +65,7 @@ export default { :size="32" /> <div class="gl-display-flex gl-flex-direction-column"> - <!-- eslint-disable-next-line vue/no-v-html --> - <span data-testid="item-title" v-html="highlightedItemName">{{ item[name] }}</span> + <span v-safe-html="highlightedItemName" data-testid="item-title"></span> <span class="gl-font-sm gl-text-gray-700" data-testid="item-namespace">{{ truncatedNamespace }}</span> |