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:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-07-20 12:55:51 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-07-20 12:55:51 +0300
commite8d2c2579383897a1dd7f9debd359abe8ae8373d (patch)
treec42be41678c2586d49a75cabce89322082698334 /app/assets/javascripts/search
parentfc845b37ec3a90aaa719975f607740c22ba6a113 (diff)
Add latest changes from gitlab-org/gitlab@14-1-stable-eev14.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/search')
-rw-r--r--app/assets/javascripts/search/store/actions.js45
-rw-r--r--app/assets/javascripts/search/store/constants.js7
-rw-r--r--app/assets/javascripts/search/store/getters.js9
-rw-r--r--app/assets/javascripts/search/store/index.js2
-rw-r--r--app/assets/javascripts/search/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/search/store/mutations.js3
-rw-r--r--app/assets/javascripts/search/store/state.js6
-rw-r--r--app/assets/javascripts/search/store/utils.js80
-rw-r--r--app/assets/javascripts/search/topbar/components/group_filter.vue12
-rw-r--r--app/assets/javascripts/search/topbar/components/project_filter.vue12
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown.vue41
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue8
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>