Commit 52c4b9a7 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'prefill-recently-used' into 'master'

Pre-populate autocomplete projects in epic tree

See merge request gitlab-org/gitlab!56955
parents 8e8177b6 4a132303
...@@ -2,14 +2,21 @@ ...@@ -2,14 +2,21 @@
import { import {
GlButton, GlButton,
GlDropdown, GlDropdown,
GlDropdownDivider,
GlDropdownSectionHeader,
GlDropdownItem, GlDropdownItem,
GlFormInput, GlFormInput,
GlSearchBoxByType, GlSearchBoxByType,
GlLoadingIcon, GlLoadingIcon,
} from '@gitlab/ui'; } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import Api from '~/api';
import createFlash, { FLASH_TYPES } from '~/flash';
import { STORAGE_KEY, FREQUENT_ITEMS } from '~/frequent_items/constants';
import AccessorUtilities from '~/lib/utils/accessor';
import { __ } from '~/locale'; import { __ } from '~/locale';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue'; import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { SEARCH_DEBOUNCE } from '../constants'; import { SEARCH_DEBOUNCE } from '../constants';
...@@ -19,6 +26,8 @@ export default { ...@@ -19,6 +26,8 @@ export default {
GlButton, GlButton,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
GlFormInput, GlFormInput,
GlSearchBoxByType, GlSearchBoxByType,
GlLoadingIcon, GlLoadingIcon,
...@@ -26,16 +35,22 @@ export default { ...@@ -26,16 +35,22 @@ export default {
}, },
data() { data() {
return { return {
recentItems: [],
selectedProject: null, selectedProject: null,
searchKey: '', searchKey: '',
title: '', title: '',
recentItemFetchInProgress: false,
}; };
}, },
computed: { computed: {
...mapState(['projectsFetchInProgress', 'itemCreateInProgress', 'projects', 'parentItem']), ...mapState(['projectsFetchInProgress', 'itemCreateInProgress', 'projects', 'parentItem']),
dropdownToggleText() { dropdownToggleText() {
if (this.selectedProject) { if (this.selectedProject) {
return this.selectedProject.name_with_namespace; /** When selectedProject is fetched from localStorage
* name_with_namespace doesn't exist. Therefore we rely on
* namespace directly.
* */
return this.selectedProject.name_with_namespace || this.selectedProject.namespace;
} }
return __('Select a project'); return __('Select a project');
...@@ -49,6 +64,7 @@ export default { ...@@ -49,6 +64,7 @@ export default {
*/ */
searchKey: debounce(function debounceSearch() { searchKey: debounce(function debounceSearch() {
this.fetchProjects(this.searchKey); this.fetchProjects(this.searchKey);
this.setRecentItems(this.searchKey);
}, SEARCH_DEBOUNCE), }, SEARCH_DEBOUNCE),
/** /**
* As Issue Create Form already has `autofocus` set for * As Issue Create Form already has `autofocus` set for
...@@ -80,8 +96,67 @@ export default { ...@@ -80,8 +96,67 @@ export default {
}, },
handleDropdownShow() { handleDropdownShow() {
this.searchKey = ''; this.searchKey = '';
this.setRecentItems();
this.fetchProjects(); this.fetchProjects();
}, },
handleRecentItemSelection(selectedProject) {
this.recentItemFetchInProgress = true;
this.selectedProject = selectedProject;
Api.project(selectedProject.id)
.then((res) => res.data)
.then((data) => {
this.selectedProject = data;
})
.catch(() => {
createFlash({
message: __('Something went wrong while fetching details'),
type: FLASH_TYPES.ALERT,
});
this.selectedProject = null;
})
.finally(() => {
this.recentItemFetchInProgress = false;
});
},
setRecentItems(searchTerm) {
const { current_username: currentUsername } = gon;
if (!currentUsername) {
return [];
}
const storageKey = `${currentUsername}/${STORAGE_KEY.projects}`;
if (!AccessorUtilities.isLocalStorageAccessSafe()) {
return [];
}
const storedRawItems = localStorage.getItem(storageKey);
let storedFrequentItems = storedRawItems ? JSON.parse(storedRawItems) : [];
/* Filter for the current group */
storedFrequentItems = storedFrequentItems
.filter((item) => {
return Boolean(item.webUrl?.slice(1)?.startsWith(this.parentItem.fullPath));
})
.sort((a, b) => a.frequency > b.frequency);
if (searchTerm) {
storedFrequentItems = fuzzaldrinPlus.filter(storedFrequentItems, searchTerm, {
key: ['namespace'],
});
}
this.recentItems = storedFrequentItems
.map((item) => {
return { ...item, avatar_url: item.avatarUrl, web_url: item.webUrl };
})
.slice(0, FREQUENT_ITEMS.LIST_COUNT_DESKTOP); // Only keep top 5 results
return this.recentItems;
},
}, },
}; };
</script> </script>
...@@ -89,7 +164,7 @@ export default { ...@@ -89,7 +164,7 @@ export default {
<template> <template>
<div> <div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm"> <div class="col-sm-6">
<label class="label-bold">{{ s__('Issue|Title') }}</label> <label class="label-bold">{{ s__('Issue|Title') }}</label>
<gl-form-input <gl-form-input
ref="titleInput" ref="titleInput"
...@@ -100,7 +175,7 @@ export default { ...@@ -100,7 +175,7 @@ export default {
autofocus autofocus
/> />
</div> </div>
<div class="col-sm"> <div class="col-sm-6">
<label class="label-bold">{{ __('Project') }}</label> <label class="label-bold">{{ __('Project') }}</label>
<gl-dropdown <gl-dropdown
ref="dropdownButton" ref="dropdownButton"
...@@ -116,26 +191,50 @@ export default { ...@@ -116,26 +191,50 @@ export default {
class="gl-mx-3 gl-mb-2" class="gl-mx-3 gl-mb-2"
:disabled="projectsFetchInProgress" :disabled="projectsFetchInProgress"
/> />
<gl-loading-icon <div class="dropdown-contents gl-overflow-auto gl-pb-2">
v-show="projectsFetchInProgress" <gl-dropdown-section-header v-if="recentItems.length > 0">{{
class="projects-fetch-loading gl-align-items-center gl-p-3" __('Recently used')
size="md" }}</gl-dropdown-section-header>
/>
<div v-if="!projectsFetchInProgress" class="dropdown-contents gl-overflow-auto gl-p-2"> <div v-if="recentItems.length > 0" data-testid="recent-items-content">
<gl-dropdown-item
v-for="project in recentItems"
:key="`recent-${project.id}`"
class="gl-w-full select-project-dropdown"
@click="() => handleRecentItemSelection(project)"
>
<span><project-avatar :project="project" :size="32" /></span>
<span
><span class="block">{{ project.name }}</span>
<span class="block text-secondary">{{ project.namespace }}</span></span
>
</gl-dropdown-item>
</div>
<gl-dropdown-divider v-if="recentItems.length > 0" />
<template v-if="!projectsFetchInProgress">
<span v-if="!projects.length" class="gl-display-block text-center gl-p-3">{{ <span v-if="!projects.length" class="gl-display-block text-center gl-p-3">{{
__('No matches found') __('No matches found')
}}</span> }}</span>
<gl-dropdown-item <gl-dropdown-item
v-for="project in projects" v-for="project in projects"
:key="project.id" :key="project.id"
class="gl-w-full" class="gl-w-full select-project-dropdown"
:secondary-text="project.namespace.name"
@click="selectedProject = project" @click="selectedProject = project"
> >
<project-avatar :project="project" :size="32" /> <span><project-avatar :project="project" :size="32" /></span>
{{ project.name }} <span
><span class="block">{{ project.name }}</span>
<span class="block text-secondary">{{ project.namespace.name }}</span></span
>
</gl-dropdown-item> </gl-dropdown-item>
</template>
</div> </div>
<gl-loading-icon
v-show="projectsFetchInProgress"
class="projects-fetch-loading gl-align-items-center gl-p-3"
size="md"
/>
</gl-dropdown> </gl-dropdown>
</div> </div>
</div> </div>
...@@ -147,7 +246,7 @@ export default { ...@@ -147,7 +246,7 @@ export default {
variant="success" variant="success"
category="primary" category="primary"
:disabled="!selectedProject || itemCreateInProgress" :disabled="!selectedProject || itemCreateInProgress"
:loading="itemCreateInProgress" :loading="itemCreateInProgress || recentItemFetchInProgress"
@click="createIssue" @click="createIssue"
>{{ __('Create issue') }}</gl-button >{{ __('Create issue') }}</gl-button
> >
......
...@@ -106,3 +106,9 @@ ...@@ -106,3 +106,9 @@
min-height: 335px; min-height: 335px;
} }
} }
.add-item-form-container {
.select-project-dropdown .gl-new-dropdown-item-text-primary {
display: flex;
}
}
---
title: Pre-populate projects while adding issue to a project in epic tree
merge_request: 56955
author:
type: added
...@@ -5,6 +5,8 @@ import { ...@@ -5,6 +5,8 @@ import {
GlFormInput, GlFormInput,
GlSearchBoxByType, GlSearchBoxByType,
GlLoadingIcon, GlLoadingIcon,
GlDropdownDivider,
GlDropdownSectionHeader,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
...@@ -13,7 +15,12 @@ import CreateIssueForm from 'ee/related_items_tree/components/create_issue_form. ...@@ -13,7 +15,12 @@ import CreateIssueForm from 'ee/related_items_tree/components/create_issue_form.
import createDefaultStore from 'ee/related_items_tree/store'; import createDefaultStore from 'ee/related_items_tree/store';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue'; import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { mockInitialConfig, mockParentItem } from '../mock_data'; import {
mockInitialConfig,
mockParentItem,
mockFrequentlyUsedProjects,
mockMixedFrequentlyUsedProjects,
} from '../mock_data';
const mockProjects = getJSONFixture('static/projects.json'); const mockProjects = getJSONFixture('static/projects.json');
...@@ -32,15 +39,29 @@ const createComponent = () => { ...@@ -32,15 +39,29 @@ const createComponent = () => {
}); });
}; };
const getLocalstorageKey = () => {
return 'root/frequent-projects';
};
const setLocalstorageFrequentItems = (json = mockFrequentlyUsedProjects) => {
localStorage.setItem(getLocalstorageKey(), JSON.stringify(json));
};
const removeLocalstorageFrequentItems = () => {
localStorage.removeItem(getLocalstorageKey());
};
describe('CreateIssueForm', () => { describe('CreateIssueForm', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
gon.current_username = 'root';
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
delete gon.current_username;
}); });
describe('data', () => { describe('data', () => {
...@@ -53,7 +74,7 @@ describe('CreateIssueForm', () => { ...@@ -53,7 +74,7 @@ describe('CreateIssueForm', () => {
describe('computed', () => { describe('computed', () => {
describe('dropdownToggleText', () => { describe('dropdownToggleText', () => {
it('returns project name with namespace when `selectedProject` is not empty', () => { it('returns project name with name_with_namespace when `selectedProject` is not empty', () => {
wrapper.setData({ wrapper.setData({
selectedProject: mockProjects[0], selectedProject: mockProjects[0],
}); });
...@@ -62,6 +83,16 @@ describe('CreateIssueForm', () => { ...@@ -62,6 +83,16 @@ describe('CreateIssueForm', () => {
expect(wrapper.vm.dropdownToggleText).toBe(mockProjects[0].name_with_namespace); expect(wrapper.vm.dropdownToggleText).toBe(mockProjects[0].name_with_namespace);
}); });
}); });
it('returns project name with namespace when `selectedProject` is not empty and dont have name_with_namespace', async () => {
const project = { ...mockProjects[0], name_with_namespace: undefined, namespace: 'foo' };
wrapper.setData({
selectedProject: project,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.dropdownToggleText).toBe(project.namespace);
});
}); });
}); });
...@@ -140,12 +171,56 @@ describe('CreateIssueForm', () => { ...@@ -140,12 +171,56 @@ describe('CreateIssueForm', () => {
expect(projectsDropdownButton.findComponent(GlSearchBoxByType).exists()).toBe(true); expect(projectsDropdownButton.findComponent(GlSearchBoxByType).exists()).toBe(true);
expect(projectsDropdownButton.findComponent(GlLoadingIcon).exists()).toBe(true); expect(projectsDropdownButton.findComponent(GlLoadingIcon).exists()).toBe(true);
expect(dropdownItems).toHaveLength(mockProjects.length); expect(dropdownItems).toHaveLength(mockProjects.length);
expect(dropdownItem.text()).toBe(mockProjects[0].name); expect(dropdownItem.text()).toContain(mockProjects[0].name);
expect(dropdownItem.attributes('secondarytext')).toBe(mockProjects[0].namespace.name); expect(dropdownItem.text()).toContain(mockProjects[0].namespace.name);
expect(dropdownItem.findComponent(ProjectAvatar).exists()).toBe(true); expect(dropdownItem.findComponent(ProjectAvatar).exists()).toBe(true);
}); });
}); });
it('renders dropdown contents without recent items when `recentItems` are empty', () => {
const projectsDropdownButton = wrapper.findComponent(GlDropdown);
expect(projectsDropdownButton.findComponent(GlDropdownSectionHeader).exists()).toBe(false);
expect(projectsDropdownButton.findComponent(GlDropdownDivider).exists()).toBe(false);
expect(projectsDropdownButton.find('[data-testid="recent-items-content"]').exists()).toBe(
false,
);
});
it('renders recent items when localStorage has recent items', async () => {
setLocalstorageFrequentItems();
wrapper.vm.setRecentItems();
await wrapper.vm.$nextTick();
const projectsDropdownButton = wrapper.findComponent(GlDropdown);
expect(projectsDropdownButton.findComponent(GlDropdownSectionHeader).exists()).toBe(true);
expect(projectsDropdownButton.findComponent(GlDropdownDivider).exists()).toBe(true);
const content = projectsDropdownButton.find('[data-testid="recent-items-content"]');
expect(content.exists()).toBe(true);
expect(content.findAll(GlDropdownItem)).toHaveLength(mockFrequentlyUsedProjects.length);
removeLocalstorageFrequentItems();
});
it('renders recent items from the group when localStorage has recent items with mixed groups', async () => {
setLocalstorageFrequentItems(mockMixedFrequentlyUsedProjects);
wrapper.vm.setRecentItems();
await wrapper.vm.$nextTick();
const projectsDropdownButton = wrapper.findComponent(GlDropdown);
expect(
projectsDropdownButton.find('[data-testid="recent-items-content"]').findAll(GlDropdownItem),
).toHaveLength(mockMixedFrequentlyUsedProjects.length - 1);
removeLocalstorageFrequentItems();
});
it('renders Projects dropdown contents containing only matching project when searchKey is provided', () => { it('renders Projects dropdown contents containing only matching project when searchKey is provided', () => {
const searchKey = 'Underscore'; const searchKey = 'Underscore';
const filteredMockProjects = mockProjects.filter((project) => project.name === searchKey); const filteredMockProjects = mockProjects.filter((project) => project.name === searchKey);
...@@ -207,6 +282,20 @@ describe('CreateIssueForm', () => { ...@@ -207,6 +282,20 @@ describe('CreateIssueForm', () => {
}); });
}); });
it('renders loading icon within `Create issue` button when `recentItemFetchInProgress` is true', () => {
wrapper.vm.recentItemFetchInProgress = true;
return wrapper.vm.$nextTick(() => {
const createIssueButton = wrapper.findAllComponents(GlButton).at(0);
expect(createIssueButton.exists()).toBe(true);
expect(createIssueButton.props()).toMatchObject({
disabled: true,
loading: true,
});
});
});
it('renders `Cancel` button', () => { it('renders `Cancel` button', () => {
const cancelButton = wrapper.findAllComponents(GlButton).at(1); const cancelButton = wrapper.findAllComponents(GlButton).at(1);
......
...@@ -357,3 +357,45 @@ export const mockEpicTreeReorderInput = { ...@@ -357,3 +357,45 @@ export const mockEpicTreeReorderInput = {
moveAfterId: 'gid://gitlab/Epic/3', moveAfterId: 'gid://gitlab/Epic/3',
}, },
}; };
export const mockFrequentlyUsedProjects = [
{
id: 1,
name: 'Project 1',
namespace: 'Gitlab / Project 1',
webUrl: '/gitlab-org/project1',
avatarUrl: null,
lastAccessedOn: 123,
frequency: 1,
},
{
id: 2,
name: 'Project 2',
namespace: 'Gitlab / Project 2',
webUrl: '/gitlab-org/project2',
avatarUrl: null,
lastAccessedOn: 124,
frequency: 1,
},
];
export const mockMixedFrequentlyUsedProjects = [
{
id: 1,
name: 'Project 1',
namespace: 'Gitlab / Project 1',
webUrl: '/gitlab-org/project1',
avatarUrl: null,
lastAccessedOn: 123,
frequency: 1,
},
{
id: 2,
name: 'Project 2',
namespace: 'Gitlab.com / Project 2',
webUrl: '/gitlab-com/project2',
avatarUrl: null,
lastAccessedOn: 124,
frequency: 1,
},
];
...@@ -25439,6 +25439,9 @@ msgstr "" ...@@ -25439,6 +25439,9 @@ msgstr ""
msgid "Recent searches" msgid "Recent searches"
msgstr "" msgstr ""
msgid "Recently used"
msgstr ""
msgid "Reconfigure" msgid "Reconfigure"
msgstr "" msgstr ""
...@@ -28713,6 +28716,9 @@ msgstr "" ...@@ -28713,6 +28716,9 @@ msgstr ""
msgid "Something went wrong while fetching description changes. Please try again." msgid "Something went wrong while fetching description changes. Please try again."
msgstr "" msgstr ""
msgid "Something went wrong while fetching details"
msgstr ""
msgid "Something went wrong while fetching group member contributions" msgid "Something went wrong while fetching group member contributions"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment