Commit 4d9b9c2d authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '235367-remove-jquery-from-project-select-component' into 'master'

Remove jquery-based dropdown from project_select.vue

See merge request gitlab-org/gitlab!49938
parents b8ea66a5 fcac89d1
...@@ -389,10 +389,12 @@ const Api = { ...@@ -389,10 +389,12 @@ const Api = {
.get(url, { .get(url, {
params: { ...defaults, ...options }, params: { ...defaults, ...options },
}) })
.then(({ data }) => callback(data)) .then(({ data }) => (callback ? callback(data) : data))
.catch(() => { .catch(() => {
flash(__('Something went wrong while fetching projects')); flash(__('Something went wrong while fetching projects'));
callback(); if (callback) {
callback();
}
}); });
}, },
......
<script> <script>
import $ from 'jquery'; import {
import { escape } from 'lodash'; GlDropdown,
import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; GlDropdownItem,
import { __ } from '~/locale'; GlDropdownText,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import { s__ } from '~/locale';
import Api from '../../api'; import Api from '../../api';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { ListType } from '../constants'; import { ListType } from '../constants';
export default { export default {
name: 'BoardProjectSelect', name: 'ProjectSelect',
i18n: {
headerTitle: s__(`BoardNewIssue|Projects`),
dropdownText: s__(`BoardNewIssue|Select a project`),
searchPlaceholder: s__(`BoardNewIssue|Search projects`),
emptySearchResult: s__(`BoardNewIssue|No matching results`),
},
defaultFetchOptions: {
with_issues_enabled: true,
with_shared: false,
include_subgroups: true,
order_by: 'similarity',
},
components: { components: {
GlIcon,
GlLoadingIcon, GlLoadingIcon,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
}, },
props: { props: {
list: { list: {
...@@ -24,97 +42,108 @@ export default { ...@@ -24,97 +42,108 @@ export default {
inject: ['groupId'], inject: ['groupId'],
data() { data() {
return { return {
loading: true, initialLoading: true,
isFetching: false,
projects: [],
selectedProject: {}, selectedProject: {},
searchTerm: '',
}; };
}, },
computed: { computed: {
selectedProjectName() { selectedProjectName() {
return this.selectedProject.name || __('Select a project'); return this.selectedProject.name || this.$options.i18n.dropdownText;
},
fetchOptions() {
const additionalAttrs = {};
if (this.list.type && this.list.type !== ListType.backlog) {
additionalAttrs.min_access_level = featureAccessLevel.EVERYONE;
}
return {
...this.$options.defaultFetchOptions,
...additionalAttrs,
};
},
isFetchResultEmpty() {
return this.projects.length === 0;
}, },
}, },
mounted() { watch: {
initDeprecatedJQueryDropdown($(this.$refs.projectsDropdown), { searchTerm() {
filterable: true, this.fetchProjects();
filterRemote: true, },
search: { },
fields: ['name_with_namespace'], async mounted() {
}, await this.fetchProjects();
clicked: ({ $el, e }) => {
e.preventDefault();
this.selectedProject = {
id: $el.data('project-id'),
name: $el.data('project-name'),
path: $el.data('project-path'),
};
eventHub.$emit('setSelectedProject', this.selectedProject);
},
selectable: true,
data: (term, callback) => {
this.loading = true;
const additionalAttrs = {};
if ((this.list.type || this.list.listType) !== ListType.backlog) { this.initialLoading = false;
additionalAttrs.min_access_level = featureAccessLevel.EVERYONE; },
} methods: {
async fetchProjects() {
this.isFetching = true;
try {
const projects = await Api.groupProjects(this.groupId, this.searchTerm, this.fetchOptions);
return Api.groupProjects( this.projects = projects.map(project => {
this.groupId, return {
term, id: project.id,
{ name: project.name,
with_issues_enabled: true, namespacedName: project.name_with_namespace,
with_shared: false, path: project.path_with_namespace,
include_subgroups: true, };
order_by: 'similarity', });
...additionalAttrs, } catch (err) {
}, /* Handled in Api.groupProjects */
projects => { } finally {
this.loading = false; this.isFetching = false;
callback(projects); }
}, },
); selectProject(projectId) {
}, this.selectedProject = this.projects.find(project => project.id === projectId);
renderRow(project) {
return ` /*
<li> TODO Remove eventhub, use Vuex for BoardNewIssue and GraphQL for BoardNewIssueNew
<a href='#' class='dropdown-menu-link' https://gitlab.com/gitlab-org/gitlab/-/issues/276173
data-project-id="${project.id}" */
data-project-name="${project.name}" eventHub.$emit('setSelectedProject', this.selectedProject);
data-project-name-with-namespace="${project.name_with_namespace}" },
data-project-path="${project.path_with_namespace}"
>
${escape(project.name_with_namespace)}
</a>
</li>
`;
},
text: project => project.name_with_namespace,
});
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<label class="label-bold gl-mt-3">{{ __('Project') }}</label> <label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{
<div ref="projectsDropdown" class="dropdown dropdown-projects"> $options.i18n.headerTitle
<button }}</label>
class="dropdown-menu-toggle wide" <gl-dropdown
type="button" data-testid="project-select-dropdown"
data-toggle="dropdown" :text="selectedProjectName"
aria-expanded="false" :header-text="$options.i18n.headerTitle"
block
menu-class="gl-w-full!"
:loading="initialLoading"
>
<gl-search-box-by-type
v-model.trim="searchTerm"
debounce="250"
:placeholder="$options.i18n.searchPlaceholder"
/>
<gl-dropdown-item
v-for="project in projects"
v-show="!isFetching"
:key="project.id"
:name="project.name"
@click="selectProject(project.id)"
> >
{{ selectedProjectName }} <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon" /> {{ project.namespacedName }}
</button> </gl-dropdown-item>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"> <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon">
<div class="dropdown-title">{{ __('Projects') }}</div> <gl-loading-icon class="gl-mx-auto" />
<div class="dropdown-input"> </gl-dropdown-text>
<input class="dropdown-input-field" type="search" :placeholder="__('Search projects')" /> <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message">
<gl-icon name="search" class="dropdown-input-search" data-hidden="true" /> <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
</div> </gl-dropdown-text>
<div class="dropdown-content"></div> </gl-dropdown>
<div class="dropdown-loading"><gl-loading-icon /></div>
</div>
</div>
</div> </div>
</template> </template>
...@@ -4534,6 +4534,18 @@ msgstr "" ...@@ -4534,6 +4534,18 @@ msgstr ""
msgid "Board scope affects which issues are displayed for anyone who visits this board" msgid "Board scope affects which issues are displayed for anyone who visits this board"
msgstr "" msgstr ""
msgid "BoardNewIssue|No matching results"
msgstr ""
msgid "BoardNewIssue|Projects"
msgstr ""
msgid "BoardNewIssue|Search projects"
msgstr ""
msgid "BoardNewIssue|Select a project"
msgstr ""
msgid "Boards" msgid "Boards"
msgstr "" msgstr ""
......
...@@ -20,14 +20,19 @@ RSpec.describe 'Group Boards' do ...@@ -20,14 +20,19 @@ RSpec.describe 'Group Boards' do
page.within(find('.board', match: :first)) do page.within(find('.board', match: :first)) do
issue_title = 'New Issue' issue_title = 'New Issue'
find(:css, '.issue-count-badge-add-button').click find(:css, '.issue-count-badge-add-button').click
wait_for_requests
expect(find('.board-new-issue-form')).to be_visible expect(find('.board-new-issue-form')).to be_visible
fill_in 'issue_title', with: issue_title fill_in 'issue_title', with: issue_title
find('.dropdown-menu-toggle').click
wait_for_requests page.within("[data-testid='project-select-dropdown']") do
find('button.gl-dropdown-toggle').click
find('.gl-new-dropdown-item button').click
end
click_link(project.name)
click_button 'Submit issue' click_button 'Submit issue'
expect(page).to have_content(issue_title) expect(page).to have_content(issue_title)
......
...@@ -350,3 +350,18 @@ export const issues = { ...@@ -350,3 +350,18 @@ export const issues = {
[mockIssue3.id]: mockIssue3, [mockIssue3.id]: mockIssue3,
[mockIssue4.id]: mockIssue4, [mockIssue4.id]: mockIssue4,
}; };
export const mockRawGroupProjects = [
{
id: 0,
name: 'Example Project',
name_with_namespace: 'Awesome Group / Example Project',
path_with_namespace: 'awesome-group/example-project',
},
{
id: 1,
name: 'Foobar Project',
name_with_namespace: 'Awesome Group / Foobar Project',
path_with_namespace: 'awesome-group/foobar-project',
},
];
import { mount } from '@vue/test-utils';
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
import httpStatus from '~/lib/utils/http_status';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import { ListType } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import { deprecatedCreateFlash as flash } from '~/flash';
import ProjectSelect from '~/boards/components/project_select.vue';
import { listObj, mockRawGroupProjects } from './mock_data';
jest.mock('~/boards/eventhub');
jest.mock('~/flash');
const dummyGon = {
api_version: 'v4',
relative_url_root: '/gitlab',
};
const mockGroupId = 1;
const mockProjectsList1 = mockRawGroupProjects.slice(0, 1);
const mockProjectsList2 = mockRawGroupProjects.slice(1);
const mockDefaultFetchOptions = {
with_issues_enabled: true,
with_shared: false,
include_subgroups: true,
order_by: 'similarity',
};
const itemsPerPage = 20;
describe('ProjectSelect component', () => {
let wrapper;
let axiosMock;
const findLabel = () => wrapper.find("[data-testid='header-label']");
const findGlDropdown = () => wrapper.find(GlDropdown);
const findGlDropdownLoadingIcon = () =>
findGlDropdown()
.find('button:first-child')
.find(GlLoadingIcon);
const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType);
const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findFirstGlDropdownItem = () => findGlDropdownItems().at(0);
const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']");
const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']");
const mockGetRequest = (data = [], statusCode = httpStatus.OK) => {
axiosMock
.onGet(`/gitlab/api/v4/groups/${mockGroupId}/projects.json`)
.replyOnce(statusCode, data);
};
const searchForProject = async (keyword, waitForAll = true) => {
findGlSearchBoxByType().vm.$emit('input', keyword);
if (waitForAll) {
await axios.waitForAll();
}
};
const createWrapper = async ({ list = listObj } = {}, waitForAll = true) => {
wrapper = mount(ProjectSelect, {
propsData: {
list,
},
provide: {
groupId: 1,
},
});
if (waitForAll) {
await axios.waitForAll();
}
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
window.gon = dummyGon;
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
axiosMock.restore();
jest.clearAllMocks();
});
it('displays a header title', async () => {
createWrapper({});
expect(findLabel().text()).toBe('Projects');
});
it('renders a default dropdown text', async () => {
createWrapper({});
expect(findGlDropdown().exists()).toBe(true);
expect(findGlDropdown().text()).toContain('Select a project');
});
describe('when mounted', () => {
it('displays a loading icon while projects are being fetched', async () => {
mockGetRequest([]);
createWrapper({}, false);
expect(findGlDropdownLoadingIcon().exists()).toBe(true);
await axios.waitForAll();
expect(axiosMock.history.get[0].params).toMatchObject({ search: '' });
expect(axiosMock.history.get[0].url).toBe(
`/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
);
expect(findGlDropdownLoadingIcon().exists()).toBe(false);
});
});
describe('when dropdown menu is open', () => {
describe('by default', () => {
beforeEach(async () => {
mockGetRequest(mockProjectsList1);
await createWrapper();
});
it('shows GlSearchBoxByType with default attributes', () => {
expect(findGlSearchBoxByType().exists()).toBe(true);
expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({
placeholder: 'Search projects',
debounce: '250',
});
});
it("displays the fetched project's name", () => {
expect(findFirstGlDropdownItem().exists()).toBe(true);
expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name);
});
it("doesn't render loading icon in the menu", () => {
expect(findInMenuLoadingIcon().isVisible()).toBe(false);
});
it('renders empty search result message', async () => {
await createWrapper();
expect(findEmptySearchMessage().exists()).toBe(true);
});
});
describe('when a project is selected', () => {
beforeEach(async () => {
mockGetRequest(mockProjectsList1);
await createWrapper();
await findFirstGlDropdownItem()
.find('button')
.trigger('click');
});
it('emits setSelectedProject with correct project metadata', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('setSelectedProject', {
id: mockProjectsList1[0].id,
path: mockProjectsList1[0].path_with_namespace,
name: mockProjectsList1[0].name,
namespacedName: mockProjectsList1[0].name_with_namespace,
});
});
it('renders the name of the selected project', () => {
expect(
findGlDropdown()
.find('.gl-new-dropdown-button-text')
.text(),
).toBe(mockProjectsList1[0].name);
});
});
describe('when user searches for a project', () => {
beforeEach(async () => {
mockGetRequest(mockProjectsList1);
await createWrapper();
});
it('calls API with correct parameters with default fetch options', async () => {
await searchForProject('foobar');
const expectedApiParams = {
search: 'foobar',
per_page: itemsPerPage,
...mockDefaultFetchOptions,
};
expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams);
expect(axiosMock.history.get[1].url).toBe(
`/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
);
});
describe("when list type is defined and isn't backlog", () => {
it('calls API with an additional fetch option (min_access_level)', async () => {
axiosMock.reset();
await createWrapper({ list: { ...listObj, type: ListType.label } });
await searchForProject('foobar');
const expectedApiParams = {
search: 'foobar',
per_page: itemsPerPage,
...mockDefaultFetchOptions,
min_access_level: featureAccessLevel.EVERYONE,
};
expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams);
expect(axiosMock.history.get[1].url).toBe(
`/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
);
});
});
it('displays and hides gl-loading-icon while and after fetching data', async () => {
await searchForProject('some keyword', false);
await wrapper.vm.$nextTick();
expect(findInMenuLoadingIcon().isVisible()).toBe(true);
await axios.waitForAll();
expect(findInMenuLoadingIcon().isVisible()).toBe(false);
});
it('flashes an error message when fetching fails', async () => {
mockGetRequest([], httpStatus.INTERNAL_SERVER_ERROR);
await searchForProject('foobar');
expect(flash).toHaveBeenCalledTimes(1);
expect(flash).toHaveBeenCalledWith('Something went wrong while fetching projects');
});
describe('with non-empty search result', () => {
beforeEach(async () => {
mockGetRequest(mockProjectsList2);
await searchForProject('foobar');
});
it('displays the retrieved list of projects', async () => {
expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList2[0].name);
});
it('does not render empty search result message', async () => {
expect(findEmptySearchMessage().exists()).toBe(false);
});
});
});
});
});
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