Commit e75cbceb authored by Kushal Pandya's avatar Kushal Pandya

Add Issuable Move dropdown component

Adds Issuable Move dropdown component to
use with Issuable Sidebars
parent ac0709f4
<script>
import {
GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownForm,
GlDropdownItem,
GlSearchBoxByType,
GlButton,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
export default {
components: {
GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownForm,
GlDropdownItem,
GlSearchBoxByType,
GlButton,
},
directives: {
GlTooltip,
},
props: {
projectsFetchPath: {
type: String,
required: true,
},
dropdownButtonTitle: {
type: String,
required: true,
},
dropdownHeaderTitle: {
type: String,
required: true,
},
moveInProgress: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
projectsListLoading: false,
projectsListLoadFailed: false,
searchKey: '',
projects: [],
selectedProject: null,
projectItemClick: false,
};
},
computed: {
hasNoSearchResults() {
return Boolean(
!this.projectsListLoading &&
!this.projectsListLoadFailed &&
this.searchKey &&
!this.projects.length,
);
},
failedToLoadResults() {
return !this.projectsListLoading && this.projectsListLoadFailed;
},
},
watch: {
searchKey(value = '') {
this.fetchProjects(value);
},
},
methods: {
fetchProjects(search = '') {
this.projectsListLoading = true;
this.projectsListLoadFailed = false;
return axios
.get(this.projectsFetchPath, {
params: {
search,
},
})
.then(({ data }) => {
this.projects = data;
this.$refs.searchInput.focusInput();
})
.catch(() => {
this.projectsListLoadFailed = true;
})
.finally(() => {
this.projectsListLoading = false;
});
},
isSelectedProject(project) {
if (this.selectedProject) {
return this.selectedProject.id === project.id;
}
return false;
},
/**
* This handler is to prevent dropdown
* from closing when an item is selected
* and emit an event only when dropdown closes.
*/
handleDropdownHide(e) {
if (this.projectItemClick) {
e.preventDefault();
this.projectItemClick = false;
} else {
this.$emit('dropdown-close');
}
},
handleDropdownCloseClick() {
this.$refs.dropdown.hide();
},
handleProjectSelect(project) {
this.selectedProject = project.id === this.selectedProject?.id ? null : project;
this.projectItemClick = true;
},
handleMoveClick() {
this.$refs.dropdown.hide();
this.$emit('move-issuable', this.selectedProject);
},
},
};
</script>
<template>
<div class="block js-issuable-move-block issuable-move-dropdown sidebar-move-issue-dropdown">
<div
v-gl-tooltip.left.viewport
data-testid="move-collapsed"
:title="dropdownButtonTitle"
class="sidebar-collapsed-icon"
@click="$emit('toggle-collapse')"
>
<gl-icon name="arrow-right" />
</div>
<gl-dropdown
ref="dropdown"
:block="true"
:disabled="moveInProgress"
class="hide-collapsed"
toggle-class="js-sidebar-dropdown-toggle"
@shown="fetchProjects"
@hide="handleDropdownHide"
>
<template #button-content
><gl-loading-icon v-if="moveInProgress" class="gl-mr-3" />{{
dropdownButtonTitle
}}</template
>
<gl-dropdown-form class="gl-pt-0">
<div
data-testid="header"
class="gl-display-flex gl-pb-3 gl-border-1 gl-border-b-solid gl-border-gray-100"
>
<span class="gl-flex-grow-1 gl-text-center gl-font-weight-bold gl-py-1">{{
dropdownHeaderTitle
}}</span>
<gl-button
variant="link"
icon="close"
class="gl-mr-2 gl-w-auto! gl-p-2!"
@click.prevent="handleDropdownCloseClick"
/>
</div>
<gl-search-box-by-type
ref="searchInput"
v-model.trim="searchKey"
:placeholder="__('Search project')"
:debounce="300"
/>
<div data-testid="content" class="dropdown-content">
<gl-loading-icon v-if="projectsListLoading" size="md" class="gl-p-5" />
<ul v-else>
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
:is-check-item="true"
:is-checked="isSelectedProject(project)"
@click.stop.prevent="handleProjectSelect(project)"
>{{ project.name_with_namespace }}</gl-dropdown-item
>
</ul>
<div v-if="hasNoSearchResults" class="gl-text-center gl-p-3">
{{ __('No matching results') }}
</div>
<div v-if="failedToLoadResults" class="gl-text-center gl-p-3">
{{ __('Failed to load projects') }}
</div>
</div>
<div
data-testid="footer"
class="gl-pt-3 gl-px-3 gl-border-1 gl-border-t-solid gl-border-gray-100"
>
<gl-button
category="primary"
variant="success"
:disabled="!Boolean(selectedProject)"
class="gl-text-center! issuable-move-button"
@click="handleMoveClick"
>{{ __('Move') }}</gl-button
>
</div>
</gl-dropdown-form>
</gl-dropdown>
</div>
</template>
......@@ -921,6 +921,25 @@
}
}
/*
* Following overrides are done to prevent
* legacy dropdown styles from influencing
* GitLab UI components used within GlDropdown
*/
.issuable-move-dropdown {
.b-dropdown-form {
@include gl-p-0;
}
.gl-search-box-by-type button.gl-clear-icon-button:hover {
@include gl-bg-transparent;
}
.issuable-move-button:not(.disabled):hover {
@include gl-text-white;
}
}
.right-sidebar-collapsed {
.sidebar-grouped-item {
.sidebar-collapsed-icon {
......
......@@ -11139,6 +11139,9 @@ msgstr ""
msgid "Failed to load milestones. Please try again."
msgstr ""
msgid "Failed to load projects"
msgstr ""
msgid "Failed to load related branches"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import {
GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownForm,
GlDropdownItem,
GlSearchBoxByType,
GlButton,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import IssuableMoveDropdown from '~/vue_shared/components/sidebar/issuable_move_dropdown.vue';
const mockProjects = [
{
id: 2,
name_with_namespace: 'Gitlab Org / Gitlab Shell',
full_path: 'gitlab-org/gitlab-shell',
},
{
id: 3,
name_with_namespace: 'Gnuwget / Wget2',
full_path: 'gnuwget/wget2',
},
{
id: 4,
name_with_namespace: 'Commit451 / Lab Coat',
full_path: 'Commit451/lab-coat',
},
];
const mockProps = {
projectsFetchPath: '/-/autocomplete/projects?project_id=1',
dropdownButtonTitle: 'Move issuable',
dropdownHeaderTitle: 'Move issuable',
moveInProgress: false,
};
const mockEvent = {
stopPropagation: jest.fn(),
preventDefault: jest.fn(),
};
const createComponent = (propsData = mockProps) =>
shallowMount(IssuableMoveDropdown, {
propsData,
});
describe('IssuableMoveDropdown', () => {
let mock;
let wrapper;
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
wrapper.vm.$refs.dropdown.hide = jest.fn();
wrapper.vm.$refs.searchInput.focusInput = jest.fn();
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('watch', () => {
describe('searchKey', () => {
it('calls `fetchProjects` with value of the prop', async () => {
jest.spyOn(wrapper.vm, 'fetchProjects');
wrapper.setData({
searchKey: 'foo',
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.fetchProjects).toHaveBeenCalledWith('foo');
});
});
});
describe('methods', () => {
describe('fetchProjects', () => {
it('sets projectsListLoading to true and projectsListLoadFailed to false', () => {
wrapper.vm.fetchProjects();
expect(wrapper.vm.projectsListLoading).toBe(true);
expect(wrapper.vm.projectsListLoadFailed).toBe(false);
});
it('calls `axios.get` with `projectsFetchPath` and query param `search`', () => {
jest.spyOn(axios, 'get').mockResolvedValue({
data: mockProjects,
});
wrapper.vm.fetchProjects('foo');
expect(axios.get).toHaveBeenCalledWith(
mockProps.projectsFetchPath,
expect.objectContaining({
params: {
search: 'foo',
},
}),
);
});
it('sets response to `projects` and focuses on searchInput when request is successful', async () => {
jest.spyOn(axios, 'get').mockResolvedValue({
data: mockProjects,
});
await wrapper.vm.fetchProjects('foo');
expect(wrapper.vm.projects).toBe(mockProjects);
expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
});
it('sets projectsListLoadFailed to true when request fails', async () => {
jest.spyOn(axios, 'get').mockRejectedValue({});
await wrapper.vm.fetchProjects('foo');
expect(wrapper.vm.projectsListLoadFailed).toBe(true);
});
it('sets projectsListLoading to false when request completes', async () => {
jest.spyOn(axios, 'get').mockResolvedValue({
data: mockProjects,
});
await wrapper.vm.fetchProjects('foo');
expect(wrapper.vm.projectsListLoading).toBe(false);
});
});
describe('isSelectedProject', () => {
it.each`
project | selectedProject | title | returnValue
${mockProjects[0]} | ${mockProjects[0]} | ${'are same projects'} | ${true}
${mockProjects[0]} | ${mockProjects[1]} | ${'are different projects'} | ${false}
`(
'returns $returnValue when selectedProject and provided project param $title',
async ({ project, selectedProject, returnValue }) => {
wrapper.setData({
selectedProject,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.isSelectedProject(project)).toBe(returnValue);
},
);
it('returns false when selectedProject is null', async () => {
wrapper.setData({
selectedProject: null,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.isSelectedProject(mockProjects[0])).toBe(false);
});
});
});
describe('template', () => {
const findDropdownEl = () => wrapper.find(GlDropdown);
it('renders collapsed state element with icon', () => {
const collapsedEl = wrapper.find('[data-testid="move-collapsed"]');
expect(collapsedEl.exists()).toBe(true);
expect(collapsedEl.attributes('title')).toBe(mockProps.dropdownButtonTitle);
expect(collapsedEl.find(GlIcon).exists()).toBe(true);
expect(collapsedEl.find(GlIcon).props('name')).toBe('arrow-right');
});
describe('gl-dropdown component', () => {
it('renders component container element', () => {
expect(findDropdownEl().exists()).toBe(true);
expect(findDropdownEl().props('block')).toBe(true);
});
it('renders gl-dropdown-form component', () => {
expect(
findDropdownEl()
.find(GlDropdownForm)
.exists(),
).toBe(true);
});
it('renders header element', () => {
const headerEl = findDropdownEl().find('[data-testid="header"]');
expect(headerEl.exists()).toBe(true);
expect(headerEl.find('span').text()).toBe(mockProps.dropdownHeaderTitle);
expect(headerEl.find(GlButton).props('icon')).toBe('close');
});
it('renders gl-search-box-by-type component', () => {
const searchEl = findDropdownEl().find(GlSearchBoxByType);
expect(searchEl.exists()).toBe(true);
expect(searchEl.attributes()).toMatchObject({
placeholder: 'Search project',
debounce: '300',
});
});
it('renders gl-loading-icon component when projectsListLoading prop is true', async () => {
wrapper.setData({
projectsListLoading: true,
});
await wrapper.vm.$nextTick();
expect(
findDropdownEl()
.find(GlLoadingIcon)
.exists(),
).toBe(true);
});
it('renders gl-dropdown-item components for available projects', async () => {
wrapper.setData({
projects: mockProjects,
selectedProject: mockProjects[0],
});
await wrapper.vm.$nextTick();
const dropdownItems = wrapper.findAll(GlDropdownItem);
expect(dropdownItems).toHaveLength(mockProjects.length);
expect(dropdownItems.at(0).props()).toMatchObject({
isCheckItem: true,
isChecked: true,
});
expect(dropdownItems.at(0).text()).toBe(mockProjects[0].name_with_namespace);
});
it('renders string "No matching results" when search does not yield any matches', async () => {
wrapper.setData({
searchKey: 'foo',
});
// Wait for `searchKey` watcher to run.
await wrapper.vm.$nextTick();
wrapper.setData({
projects: [],
projectsListLoading: false,
});
await wrapper.vm.$nextTick();
const dropdownContentEl = wrapper.find('[data-testid="content"]');
expect(dropdownContentEl.text()).toContain('No matching results');
});
it('renders string "Failed to load projects" when loading projects list fails', async () => {
wrapper.setData({
projects: [],
projectsListLoading: false,
projectsListLoadFailed: true,
});
await wrapper.vm.$nextTick();
const dropdownContentEl = wrapper.find('[data-testid="content"]');
expect(dropdownContentEl.text()).toContain('Failed to load projects');
});
it('renders gl-button within footer', async () => {
const moveButtonEl = wrapper.find('[data-testid="footer"]').find(GlButton);
expect(moveButtonEl.text()).toBe('Move');
expect(moveButtonEl.attributes('disabled')).toBe('true');
wrapper.setData({
selectedProject: mockProjects[0],
});
await wrapper.vm.$nextTick();
expect(
wrapper
.find('[data-testid="footer"]')
.find(GlButton)
.attributes('disabled'),
).not.toBeDefined();
});
});
describe('events', () => {
it('collapsed state element emits `toggle-collapse` event on component when clicked', () => {
wrapper.find('[data-testid="move-collapsed"]').trigger('click');
expect(wrapper.emitted('toggle-collapse')).toBeTruthy();
});
it('gl-dropdown component calls `fetchProjects` on `shown` event', () => {
jest.spyOn(axios, 'get').mockResolvedValue({
data: mockProjects,
});
findDropdownEl().vm.$emit('shown');
expect(axios.get).toHaveBeenCalled();
});
it('gl-dropdown component prevents dropdown body from closing on `hide` event when `projectItemClick` prop is true', async () => {
wrapper.setData({
projectItemClick: true,
});
findDropdownEl().vm.$emit('hide', mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(wrapper.vm.projectItemClick).toBe(false);
});
it('gl-dropdown component emits `dropdown-close` event on component from `hide` event', async () => {
findDropdownEl().vm.$emit('hide');
expect(wrapper.emitted('dropdown-close')).toBeTruthy();
});
it('close icon in dropdown header closes the dropdown when clicked', () => {
wrapper
.find('[data-testid="header"]')
.find(GlButton)
.vm.$emit('click', mockEvent);
expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled();
});
it('sets project for clicked gl-dropdown-item to selectedProject', async () => {
wrapper.setData({
projects: mockProjects,
});
await wrapper.vm.$nextTick();
wrapper
.findAll(GlDropdownItem)
.at(0)
.vm.$emit('click', mockEvent);
expect(wrapper.vm.selectedProject).toBe(mockProjects[0]);
});
it('hides dropdown and emits `move-issuable` event when move button is clicked', async () => {
wrapper.setData({
selectedProject: mockProjects[0],
});
await wrapper.vm.$nextTick();
wrapper
.find('[data-testid="footer"]')
.find(GlButton)
.vm.$emit('click');
expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled();
expect(wrapper.emitted('move-issuable')).toBeTruthy();
expect(wrapper.emitted('move-issuable')[0]).toEqual([mockProjects[0]]);
});
});
});
});
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