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 @@
import {
GlButton,
GlDropdown,
GlDropdownDivider,
GlDropdownSectionHeader,
GlDropdownItem,
GlFormInput,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { debounce } from 'lodash';
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 ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { SEARCH_DEBOUNCE } from '../constants';
......@@ -19,6 +26,8 @@ export default {
GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
GlFormInput,
GlSearchBoxByType,
GlLoadingIcon,
......@@ -26,16 +35,22 @@ export default {
},
data() {
return {
recentItems: [],
selectedProject: null,
searchKey: '',
title: '',
recentItemFetchInProgress: false,
};
},
computed: {
...mapState(['projectsFetchInProgress', 'itemCreateInProgress', 'projects', 'parentItem']),
dropdownToggleText() {
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');
......@@ -49,6 +64,7 @@ export default {
*/
searchKey: debounce(function debounceSearch() {
this.fetchProjects(this.searchKey);
this.setRecentItems(this.searchKey);
}, SEARCH_DEBOUNCE),
/**
* As Issue Create Form already has `autofocus` set for
......@@ -80,8 +96,67 @@ export default {
},
handleDropdownShow() {
this.searchKey = '';
this.setRecentItems();
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>
......@@ -89,7 +164,7 @@ export default {
<template>
<div>
<div class="row mb-3">
<div class="col-sm">
<div class="col-sm-6">
<label class="label-bold">{{ s__('Issue|Title') }}</label>
<gl-form-input
ref="titleInput"
......@@ -100,7 +175,7 @@ export default {
autofocus
/>
</div>
<div class="col-sm">
<div class="col-sm-6">
<label class="label-bold">{{ __('Project') }}</label>
<gl-dropdown
ref="dropdownButton"
......@@ -116,26 +191,50 @@ export default {
class="gl-mx-3 gl-mb-2"
:disabled="projectsFetchInProgress"
/>
<gl-loading-icon
v-show="projectsFetchInProgress"
class="projects-fetch-loading gl-align-items-center gl-p-3"
size="md"
/>
<div v-if="!projectsFetchInProgress" class="dropdown-contents gl-overflow-auto gl-p-2">
<div class="dropdown-contents gl-overflow-auto gl-pb-2">
<gl-dropdown-section-header v-if="recentItems.length > 0">{{
__('Recently used')
}}</gl-dropdown-section-header>
<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">{{
__('No matches found')
}}</span>
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
class="gl-w-full"
:secondary-text="project.namespace.name"
class="gl-w-full select-project-dropdown"
@click="selectedProject = project"
>
<project-avatar :project="project" :size="32" />
{{ project.name }}
<span><project-avatar :project="project" :size="32" /></span>
<span
><span class="block">{{ project.name }}</span>
<span class="block text-secondary">{{ project.namespace.name }}</span></span
>
</gl-dropdown-item>
</template>
</div>
<gl-loading-icon
v-show="projectsFetchInProgress"
class="projects-fetch-loading gl-align-items-center gl-p-3"
size="md"
/>
</gl-dropdown>
</div>
</div>
......@@ -147,7 +246,7 @@ export default {
variant="success"
category="primary"
:disabled="!selectedProject || itemCreateInProgress"
:loading="itemCreateInProgress"
:loading="itemCreateInProgress || recentItemFetchInProgress"
@click="createIssue"
>{{ __('Create issue') }}</gl-button
>
......
......@@ -106,3 +106,9 @@
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 {
GlFormInput,
GlSearchBoxByType,
GlLoadingIcon,
GlDropdownDivider,
GlDropdownSectionHeader,
} from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
......@@ -13,7 +15,12 @@ import CreateIssueForm from 'ee/related_items_tree/components/create_issue_form.
import createDefaultStore from 'ee/related_items_tree/store';
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');
......@@ -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', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
gon.current_username = 'root';
});
afterEach(() => {
wrapper.destroy();
delete gon.current_username;
});
describe('data', () => {
......@@ -53,7 +74,7 @@ describe('CreateIssueForm', () => {
describe('computed', () => {
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({
selectedProject: mockProjects[0],
});
......@@ -62,6 +83,16 @@ describe('CreateIssueForm', () => {
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', () => {
expect(projectsDropdownButton.findComponent(GlSearchBoxByType).exists()).toBe(true);
expect(projectsDropdownButton.findComponent(GlLoadingIcon).exists()).toBe(true);
expect(dropdownItems).toHaveLength(mockProjects.length);
expect(dropdownItem.text()).toBe(mockProjects[0].name);
expect(dropdownItem.attributes('secondarytext')).toBe(mockProjects[0].namespace.name);
expect(dropdownItem.text()).toContain(mockProjects[0].name);
expect(dropdownItem.text()).toContain(mockProjects[0].namespace.name);
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', () => {
const searchKey = 'Underscore';
const filteredMockProjects = mockProjects.filter((project) => project.name === searchKey);
......@@ -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', () => {
const cancelButton = wrapper.findAllComponents(GlButton).at(1);
......
......@@ -357,3 +357,45 @@ export const mockEpicTreeReorderInput = {
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 ""
msgid "Recent searches"
msgstr ""
msgid "Recently used"
msgstr ""
msgid "Reconfigure"
msgstr ""
......@@ -28713,6 +28716,9 @@ msgstr ""
msgid "Something went wrong while fetching description changes. Please try again."
msgstr ""
msgid "Something went wrong while fetching details"
msgstr ""
msgid "Something went wrong while fetching group member contributions"
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