Commit 263a9980 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch 'kp-add-issuable-move-dropdown-component' into 'master'

Add Issuable Move dropdown component

See merge request gitlab-org/gitlab!46526
parents 3327a26d e75cbceb
<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 @@ ...@@ -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 { .right-sidebar-collapsed {
.sidebar-grouped-item { .sidebar-grouped-item {
.sidebar-collapsed-icon { .sidebar-collapsed-icon {
......
...@@ -11139,6 +11139,9 @@ msgstr "" ...@@ -11139,6 +11139,9 @@ msgstr ""
msgid "Failed to load milestones. Please try again." msgid "Failed to load milestones. Please try again."
msgstr "" msgstr ""
msgid "Failed to load projects"
msgstr ""
msgid "Failed to load related branches" msgid "Failed to load related branches"
msgstr "" 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