Commit cd1ea9b5 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch...

Merge branch '336385-vsa-fe-easily-distinguish-selected-projects-in-the-project-selector' into 'master'

[VSA][FE] Easily distinguish selected projects in the project selector

See merge request gitlab-org/gitlab!70143
parents 0b00bb96 10861232
...@@ -15,6 +15,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; ...@@ -15,6 +15,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { n__, s__, __ } from '~/locale'; import { n__, s__, __ } from '~/locale';
import getProjects from '../graphql/projects.query.graphql'; import getProjects from '../graphql/projects.query.graphql';
const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name));
export default { export default {
name: 'ProjectsDropdownFilter', name: 'ProjectsDropdownFilter',
components: { components: {
...@@ -88,6 +90,9 @@ export default { ...@@ -88,6 +90,9 @@ export default {
selectedProjectIds() { selectedProjectIds() {
return this.selectedProjects.map((p) => p.id); return this.selectedProjects.map((p) => p.id);
}, },
hasSelectedProjects() {
return Boolean(this.selectedProjects.length);
},
availableProjects() { availableProjects() {
return filterBySearchTerm(this.projects, this.searchTerm); return filterBySearchTerm(this.projects, this.searchTerm);
}, },
...@@ -95,6 +100,14 @@ export default { ...@@ -95,6 +100,14 @@ export default {
const { loading, availableProjects } = this; const { loading, availableProjects } = this;
return !loading && !availableProjects.length; return !loading && !availableProjects.length;
}, },
selectedItems() {
return sortByProjectName(
this.availableProjects.filter(({ id }) => this.selectedProjectIds.includes(id)),
);
},
unselectedItems() {
return this.availableProjects.filter(({ id }) => !this.selectedProjectIds.includes(id));
},
}, },
watch: { watch: {
searchTerm() { searchTerm() {
...@@ -105,44 +118,53 @@ export default { ...@@ -105,44 +118,53 @@ export default {
this.search(); this.search();
}, },
methods: { methods: {
handleUpdatedSelectedProjects() {
this.$emit('selected', this.selectedProjects);
},
search: debounce(function debouncedSearch() { search: debounce(function debouncedSearch() {
this.fetchData(); this.fetchData();
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
getSelectedProjects(selectedProject, isMarking) { getSelectedProjects(selectedProject, isSelected) {
return isMarking return isSelected
? this.selectedProjects.concat([selectedProject]) ? this.selectedProjects.concat([selectedProject])
: this.selectedProjects.filter((project) => project.id !== selectedProject.id); : this.selectedProjects.filter((project) => project.id !== selectedProject.id);
}, },
singleSelectedProject(selectedObj, isMarking) { singleSelectedProject(selectedObj, isMarking) {
return isMarking ? [selectedObj] : []; return isMarking ? [selectedObj] : [];
}, },
setSelectedProjects(selectedObj, isMarking) { setSelectedProjects(project) {
this.selectedProjects = this.multiSelect this.selectedProjects = this.multiSelect
? this.getSelectedProjects(selectedObj, isMarking) ? this.getSelectedProjects(project, !this.isProjectSelected(project))
: this.singleSelectedProject(selectedObj, isMarking); : this.singleSelectedProject(project, !this.isProjectSelected(project));
}, },
onClick({ project, isSelected }) { onClick(project) {
this.setSelectedProjects(project, !isSelected); this.setSelectedProjects(project);
this.$emit('selected', this.selectedProjects); this.handleUpdatedSelectedProjects();
}, },
onMultiSelectClick({ project, isSelected }) { onMultiSelectClick(project) {
this.setSelectedProjects(project, !isSelected); this.setSelectedProjects(project);
this.isDirty = true; this.isDirty = true;
}, },
onSelected(ev) { onSelected(project) {
if (this.multiSelect) { if (this.multiSelect) {
this.onMultiSelectClick(ev); this.onMultiSelectClick(project);
} else { } else {
this.onClick(ev); this.onClick(project);
} }
}, },
onHide() { onHide() {
if (this.multiSelect && this.isDirty) { if (this.multiSelect && this.isDirty) {
this.$emit('selected', this.selectedProjects); this.handleUpdatedSelectedProjects();
} }
this.searchTerm = ''; this.searchTerm = '';
this.isDirty = false; this.isDirty = false;
}, },
onClearAll() {
if (this.hasSelectedProjects) {
this.isDirty = true;
}
this.selectedProjects = [];
},
fetchData() { fetchData() {
this.loading = true; this.loading = true;
...@@ -168,8 +190,8 @@ export default { ...@@ -168,8 +190,8 @@ export default {
this.projects = nodes; this.projects = nodes;
}); });
}, },
isProjectSelected(id) { isProjectSelected(project) {
return this.selectedProjects ? this.selectedProjectIds.includes(id) : false; return this.selectedProjectIds.includes(project.id);
}, },
getEntityId(project) { getEntityId(project) {
return getIdFromGraphQLId(project.id); return getIdFromGraphQLId(project.id);
...@@ -182,6 +204,10 @@ export default { ...@@ -182,6 +204,10 @@ export default {
ref="projectsDropdown" ref="projectsDropdown"
class="dropdown dropdown-projects" class="dropdown dropdown-projects"
toggle-class="gl-shadow-none" toggle-class="gl-shadow-none"
:show-clear-all="hasSelectedProjects"
show-highlighted-items-title
highlighted-items-title-class="gl-p-3"
@clear-all.stop="onClearAll"
@hide="onHide" @hide="onHide"
> >
<template #button-content> <template #button-content>
...@@ -204,14 +230,37 @@ export default { ...@@ -204,14 +230,37 @@ export default {
<gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header> <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header>
<gl-search-box-by-type v-model.trim="searchTerm" /> <gl-search-box-by-type v-model.trim="searchTerm" />
</template> </template>
<template #highlighted-items>
<gl-dropdown-item
v-for="project in selectedItems"
:key="project.id"
is-check-item
:is-checked="isProjectSelected(project)"
@click.native.capture.stop="onSelected(project)"
>
<div class="gl-display-flex">
<gl-avatar
class="gl-mr-2 gl-vertical-align-middle"
:alt="project.name"
:size="16"
:entity-id="getEntityId(project)"
:entity-name="project.name"
:src="project.avatarUrl"
shape="rect"
/>
<div>
<div data-testid="project-name">{{ project.name }}</div>
<div class="gl-text-gray-500" data-testid="project-full-path">
{{ project.fullPath }}
</div>
</div>
</div>
</gl-dropdown-item>
</template>
<gl-dropdown-item <gl-dropdown-item
v-for="project in availableProjects" v-for="project in unselectedItems"
:key="project.id" :key="project.id"
:is-check-item="true" @click.native.capture.stop="onSelected(project)"
:is-checked="isProjectSelected(project.id)"
@click.native.capture.stop="
onSelected({ project, isSelected: isProjectSelected(project.id) })
"
> >
<div class="gl-display-flex"> <div class="gl-display-flex">
<gl-avatar <gl-avatar
......
import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import getProjects from '~/analytics/shared/graphql/projects.query.graphql'; import getProjects from '~/analytics/shared/graphql/projects.query.graphql';
...@@ -25,6 +26,17 @@ const projects = [ ...@@ -25,6 +26,17 @@ const projects = [
}, },
]; ];
const MockGlDropdown = stubComponent(GlDropdown, {
template: `
<div>
<div data-testid="vsa-highlighted-items">
<slot name="highlighted-items"></slot>
</div>
<div data-testid="vsa-default-items"><slot></slot></div>
</div>
`,
});
const defaultMocks = { const defaultMocks = {
$apollo: { $apollo: {
query: jest.fn().mockResolvedValue({ query: jest.fn().mockResolvedValue({
...@@ -38,22 +50,32 @@ let spyQuery; ...@@ -38,22 +50,32 @@ let spyQuery;
describe('ProjectsDropdownFilter component', () => { describe('ProjectsDropdownFilter component', () => {
let wrapper; let wrapper;
const createComponent = (props = {}) => { const createComponent = (props = {}, stubs = {}) => {
spyQuery = defaultMocks.$apollo.query; spyQuery = defaultMocks.$apollo.query;
wrapper = mount(ProjectsDropdownFilter, { wrapper = mountExtended(ProjectsDropdownFilter, {
mocks: { ...defaultMocks }, mocks: { ...defaultMocks },
propsData: { propsData: {
groupId: 1, groupId: 1,
groupNamespace: 'gitlab-org', groupNamespace: 'gitlab-org',
...props, ...props,
}, },
stubs,
}); });
}; };
const createWithMockDropdown = (props) => {
createComponent(props, { GlDropdown: MockGlDropdown });
return wrapper.vm.$nextTick();
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items');
const findHighlightedItemsTitle = () => wrapper.findByText('Selected');
const findClearAllButton = () => wrapper.findByText('Clear all');
const findDropdown = () => wrapper.find(GlDropdown); const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => const findDropdownItems = () =>
...@@ -75,8 +97,19 @@ describe('ProjectsDropdownFilter component', () => { ...@@ -75,8 +97,19 @@ describe('ProjectsDropdownFilter component', () => {
const findDropdownFullPathAtIndex = (index) => const findDropdownFullPathAtIndex = (index) =>
findDropdownAtIndex(index).find('[data-testid="project-full-path"]'); findDropdownAtIndex(index).find('[data-testid="project-full-path"]');
const selectDropdownItemAtIndex = (index) => const selectDropdownItemAtIndex = (index) => {
findDropdownAtIndex(index).find('button').trigger('click'); findDropdownAtIndex(index).find('button').trigger('click');
return wrapper.vm.$nextTick();
};
// NOTE: Selected items are now visually separated from unselected items
const findSelectedDropdownItems = () => findHighlightedItems().findAll(GlDropdownItem);
const findSelectedDropdownAtIndex = (index) => findSelectedDropdownItems().at(index);
const findSelectedButtonIdentIconAtIndex = (index) =>
findSelectedDropdownAtIndex(index).find('div.gl-avatar-identicon');
const findSelectedButtonAvatarItemAtIndex = (index) =>
findSelectedDropdownAtIndex(index).find('img.gl-avatar');
const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id); const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id);
...@@ -109,7 +142,62 @@ describe('ProjectsDropdownFilter component', () => { ...@@ -109,7 +142,62 @@ describe('ProjectsDropdownFilter component', () => {
}); });
}); });
describe('when passed a an array of defaultProject as prop', () => { describe('highlighted items', () => {
const blockDefaultProps = { multiSelect: true };
beforeEach(() => {
createComponent(blockDefaultProps);
});
describe('with no project selected', () => {
it('does not render the highlighted items', async () => {
await createWithMockDropdown(blockDefaultProps);
expect(findSelectedDropdownItems().length).toBe(0);
});
it('does not render the highlighted items title', () => {
expect(findHighlightedItemsTitle().exists()).toBe(false);
});
it('does not render the clear all button', () => {
expect(findClearAllButton().exists()).toBe(false);
});
});
describe('with a selected project', () => {
beforeEach(async () => {
await selectDropdownItemAtIndex(0);
});
it('renders the highlighted items', async () => {
await createWithMockDropdown(blockDefaultProps);
await selectDropdownItemAtIndex(0);
expect(findSelectedDropdownItems().length).toBe(1);
});
it('renders the highlighted items title', () => {
expect(findHighlightedItemsTitle().exists()).toBe(true);
});
it('renders the clear all button', () => {
expect(findClearAllButton().exists()).toBe(true);
});
it('clears all selected items when the clear all button is clicked', async () => {
await selectDropdownItemAtIndex(1);
expect(wrapper.text()).toContain('2 projects selected');
findClearAllButton().trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain('2 projects selected');
expect(wrapper.text()).toContain('Select projects');
});
});
});
describe('when passed an array of defaultProject as prop', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
defaultProjects: [projects[0]], defaultProjects: [projects[0]],
...@@ -130,8 +218,9 @@ describe('ProjectsDropdownFilter component', () => { ...@@ -130,8 +218,9 @@ describe('ProjectsDropdownFilter component', () => {
}); });
describe('when multiSelect is false', () => { describe('when multiSelect is false', () => {
const blockDefaultProps = { multiSelect: false };
beforeEach(() => { beforeEach(() => {
createComponent({ multiSelect: false }); createComponent(blockDefaultProps);
}); });
describe('displays the correct information', () => { describe('displays the correct information', () => {
...@@ -183,21 +272,19 @@ describe('ProjectsDropdownFilter component', () => { ...@@ -183,21 +272,19 @@ describe('ProjectsDropdownFilter component', () => {
}); });
it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => { it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => {
selectDropdownItemAtIndex(0); await createWithMockDropdown(blockDefaultProps);
await selectDropdownItemAtIndex(0);
await wrapper.vm.$nextTick().then(() => { expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true); expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
}); });
it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => { it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => {
selectDropdownItemAtIndex(1); await createWithMockDropdown(blockDefaultProps);
await selectDropdownItemAtIndex(1);
await wrapper.vm.$nextTick().then(() => { expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(false);
expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false); expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
}); });
}); });
}); });
......
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