Commit e9bbe0bb authored by Kushal Pandya's avatar Kushal Pandya

Merge branch...

Merge branch '198033-follow-up-from-fe-filters-tasks-by-type-in-customizable-cycle-analytics' into 'master'

Replace jquery dropdowns across analytics

Closes #198033

See merge request gitlab-org/gitlab!26742
parents 6c762e7a 45a40900
......@@ -212,10 +212,12 @@ export default {
@selected="onStageSelect"
/>
</div>
<div class="d-flex flex-column flex-md-row justify-content-between">
<div
class="gl-display-flex gl-flex-direction-column gl-justify-content-space-between flex-lg-row"
>
<groups-dropdown-filter
v-if="!hideGroupDropDown"
class="js-groups-dropdown-filter dropdown-select"
class="js-groups-dropdown-filter mr-0 mr-lg-2"
:query-params="$options.groupsQueryParams"
:default-group="selectedGroup"
@selected="onGroupSelect"
......@@ -223,7 +225,7 @@ export default {
<projects-dropdown-filter
v-if="shouldDisplayFilters"
:key="selectedGroup.id"
class="js-projects-dropdown-filter ml-0 mt-1 mt-md-0 dropdown-select"
class="js-projects-dropdown-filter my-2 my-lg-0"
:group-id="selectedGroup.id"
:query-params="$options.projectsQueryParams"
:multi-select="$options.multiProjectSelect"
......
......@@ -6,8 +6,7 @@ import { mapGetters } from 'vuex';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { removeFlash } from '../utils';
const DATA_REFETCH_DELAY = 250;
import { DATA_REFETCH_DELAY } from '../../shared/constants';
export default {
name: 'LabelsSelector',
......
<script>
import $ from 'jquery';
import { escape } from 'lodash';
import { GlDeprecatedButton } from '@gitlab/ui';
import {
GlNewDropdown as GlDropdown,
GlNewDropdownHeader as GlDropdownHeader,
GlNewDropdownItem as GlDropdownItem,
} from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'StageDropdownFilter',
components: {
Icon,
GlDeprecatedButton,
GlDropdown,
GlDropdownHeader,
GlDropdownItem,
},
props: {
stages: {
......@@ -31,77 +33,53 @@ export default {
selectedStagesLabel() {
const { stages, selectedStages } = this;
if (selectedStages.length === stages.length) {
if (selectedStages.length === 1) {
return selectedStages[0].title;
} else if (selectedStages.length === stages.length) {
return s__('CycleAnalytics|All stages');
}
if (selectedStages.length > 1) {
} else if (selectedStages.length > 1) {
return sprintf(s__('CycleAnalytics|%{stageCount} stages selected'), {
stageCount: selectedStages.length,
});
}
if (selectedStages.length === 1) {
return selectedStages[0].title;
}
return s__('CycleAnalytics|No stages selected');
},
},
mounted() {
$(this.$refs.stagesDropdown).glDropdown({
selectable: true,
multiSelect: true,
clicked: this.onClick.bind(this),
data: this.formatData.bind(this),
renderRow: group => this.rowTemplate(group),
text: stage => stage.title,
});
},
methods: {
setSelectedStages(selectedObj, isMarking) {
this.selectedStages = isMarking
? this.selectedStages.concat([selectedObj])
: this.selectedStages.filter(stage => stage.title !== selectedObj.title);
isStageSelected(stageId) {
return this.selectedStages.some(({ id }) => id === stageId);
},
onClick({ selectedObj, e, isMarking }) {
e.preventDefault();
this.setSelectedStages(selectedObj, isMarking);
onClick({ stage, isMarking }) {
this.selectedStages = isMarking
? this.selectedStages.filter(s => s.id !== stage.id)
: this.selectedStages.concat([stage]);
this.$emit('selected', this.selectedStages);
},
formatData(term, callback) {
callback(this.stages);
},
rowTemplate(stage) {
return `
<li>
<a href='#' class='dropdown-menu-link is-active'>
${escape(stage.title)}
</a>
</li>
`;
},
},
};
</script>
<template>
<div>
<div ref="stagesDropdown" class="dropdown dropdown-stages">
<gl-deprecated-button
class="dropdown-menu-toggle wide shadow-none bg-white"
type="button"
data-toggle="dropdown"
aria-expanded="false"
:aria-label="label"
<gl-dropdown
ref="stagesDropdown"
class="js-dropdown-stages"
toggle-class="gl-shadow-none"
:text="selectedStagesLabel"
right
>
{{ selectedStagesLabel }}
<icon name="chevron-down" />
</gl-deprecated-button>
<div
class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width dropdown-menu-right"
<gl-dropdown-header>{{ s__('CycleAnalytics|Stages') }}</gl-dropdown-header>
<gl-dropdown-item
v-for="stage in stages"
:key="stage.id"
:active="isStageSelected(stage.id)"
:is-check-item="true"
:is-checked="isStageSelected(stage.id)"
@click="onClick({ stage, isMarking: isStageSelected(stage.id) })"
>
<div class="dropdown-title text-left">{{ s__('CycleAnalytics|Stages') }}</div>
<div class="dropdown-content"></div>
</div>
</div>
</div>
{{ stage.title }}
</gl-dropdown-item>
</gl-dropdown>
</template>
<script>
import $ from 'jquery';
import { escape } from 'lodash';
import { GlLoadingIcon, GlDeprecatedButton, GlAvatar } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { escape, debounce } from 'lodash';
import {
GlIcon,
GlLoadingIcon,
GlAvatar,
GlNewDropdown as GlDropdown,
GlNewDropdownHeader as GlDropdownHeader,
GlNewDropdownItem as GlDropdownItem,
GlSearchBoxByType,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import Api from '~/api';
import { renderAvatar, renderIdenticon } from '~/helpers/avatar_helper';
import { DATA_REFETCH_DELAY } from '../constants';
import { filterBySearchTerm } from '../utils';
export default {
name: 'GroupsDropdownFilter',
components: {
Icon,
GlIcon,
GlLoadingIcon,
GlDeprecatedButton,
GlAvatar,
GlDropdown,
GlDropdownHeader,
GlDropdownItem,
GlSearchBoxByType,
},
props: {
label: {
......@@ -36,54 +46,50 @@ export default {
return {
loading: true,
selectedGroup: this.defaultGroup || {},
groups: [],
searchTerm: '',
};
},
computed: {
selectedGroupName() {
return this.selectedGroup.name || __('Choose a group');
},
selectedGroupId() {
return this.selectedGroup?.id;
},
availableGroups() {
return filterBySearchTerm(this.groups, this.searchTerm);
},
noResultsAvailable() {
const { loading, availableGroups } = this;
return !loading && !availableGroups.length;
},
},
watch: {
searchTerm() {
this.search();
},
},
mounted() {
$(this.$refs.groupsDropdown).glDropdown({
selectable: true,
filterable: true,
filterRemote: true,
fieldName: 'group_id',
search: {
fields: ['full_name'],
},
clicked: this.onClick.bind(this),
data: this.fetchData.bind(this),
renderRow: group => this.rowTemplate(group),
text: group => group.name,
opened: e => e.target.querySelector('.dropdown-input-field').focus(),
});
this.search();
},
methods: {
onClick({ selectedObj, e }) {
e.preventDefault();
this.selectedGroup = selectedObj;
search: debounce(function debouncedSearch() {
this.fetchData();
}, DATA_REFETCH_DELAY),
onClick({ group }) {
this.selectedGroup = group;
this.$emit('selected', this.selectedGroup);
},
fetchData(term, callback) {
fetchData() {
this.loading = true;
return Api.groups(term, this.queryParams, groups => {
return Api.groups(this.searchTerm, this.queryParams).then(groups => {
this.loading = false;
callback(groups);
this.groups = groups;
});
},
rowTemplate(group) {
return `
<li>
<a href='#' class='dropdown-menu-link d-flex'>
${this.avatarTemplate(group)}
<div class="js-group-path align-middle">${this.formatGroupPath(
group.full_name,
)}</div>
</a>
</li>
`;
isGroupSelected(id) {
return this.selectedGroupId === id;
},
/**
* Formats the group's full name.
......@@ -103,27 +109,14 @@ export default {
)
.join(' / ');
},
avatarTemplate(group) {
return group.avatar_url !== null
? renderAvatar(group, { sizeClass: 's16 rect-avatar flex-shrink-0' })
: renderIdenticon(group, {
sizeClass: 's16 rect-avatar d-flex justify-content-center flex-column flex-shrink-0',
});
},
},
};
</script>
<template>
<div>
<div ref="groupsDropdown" class="dropdown dropdown-groups">
<gl-deprecated-button
class="dropdown-menu-toggle wide shadow-none bg-white"
type="button"
data-toggle="dropdown"
aria-expanded="false"
:aria-label="label"
>
<gl-dropdown ref="groupsDropdown" class="dropdown dropdown-groups" toggle-class="gl-shadow-none">
<template #button-content>
<div class="gl-display-flex">
<gl-avatar
v-if="selectedGroup.name"
:src="selectedGroup.avatar_url"
......@@ -132,22 +125,39 @@ export default {
:size="16"
shape="rect"
:alt="selectedGroup.name"
class="d-inline-flex align-text-bottom"
class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2"
/>
{{ selectedGroupName }}
<icon name="chevron-down" />
</gl-deprecated-button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
<div class="dropdown-title">{{ __('Groups') }}</div>
<div class="dropdown-input">
<input class="dropdown-input-field" type="search" :placeholder="__('Search groups')" />
<icon name="search" class="dropdown-input-search" data-hidden="true" />
</div>
<div class="dropdown-loading pt-8">
<gl-loading-icon size="lg" class="pt-8" />
</div>
<div class="dropdown-content"></div>
</div>
<gl-icon class="gl-ml-2" name="chevron-down" />
</div>
</template>
<gl-dropdown-header>{{ __('Groups') }}</gl-dropdown-header>
<gl-search-box-by-type v-model.trim="searchTerm" class="gl-m-3" />
<gl-dropdown-item
v-for="group in availableGroups"
:key="group.id"
:is-check-item="true"
:is-checked="isGroupSelected(group.id)"
@click.prevent="onClick({ group, isSelected: isGroupSelected(group.id) })"
>
<div class="gl-display-flex">
<gl-avatar
class="gl-mr-2 gl-vertical-align-middle"
:alt="group.name"
:size="16"
:entity-id="group.id"
:entity-name="group.name"
:src="group.avatar_url"
shape="rect"
/>
<div class="js-group-path align-middle" v-html="formatGroupPath(group.full_name)"></div>
</div>
</gl-dropdown-item>
<gl-dropdown-item v-show="noResultsAvailable" class="gl-pointer-events-none text-secondary">{{
__('No matching results')
}}</gl-dropdown-item>
<gl-dropdown-item v-if="loading">
<gl-loading-icon size="lg" />
</gl-dropdown-item>
</gl-dropdown>
</template>
<script>
import $ from 'jquery';
import { escape } from 'lodash';
import { GlLoadingIcon, GlDeprecatedButton, GlAvatar } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { debounce } from 'lodash';
import {
GlIcon,
GlLoadingIcon,
GlAvatar,
GlNewDropdown as GlDropdown,
GlNewDropdownHeader as GlDropdownHeader,
GlNewDropdownItem as GlDropdownItem,
GlSearchBoxByType,
} from '@gitlab/ui';
import { n__, s__, __ } from '~/locale';
import Api from '~/api';
import { renderAvatar, renderIdenticon } from '~/helpers/avatar_helper';
import { DATA_REFETCH_DELAY } from '../constants';
import { filterBySearchTerm } from '../utils';
export default {
name: 'ProjectsDropdownFilter',
components: {
Icon,
GlIcon,
GlLoadingIcon,
GlDeprecatedButton,
GlAvatar,
GlDropdown,
GlDropdownHeader,
GlDropdownItem,
GlSearchBoxByType,
},
props: {
groupId: {
......@@ -44,7 +54,9 @@ export default {
data() {
return {
loading: true,
projects: [],
selectedProjects: this.defaultProjects || [],
searchTerm: '',
};
},
computed: {
......@@ -67,25 +79,29 @@ export default {
isOnlyOneProjectSelected() {
return this.selectedProjects.length === 1;
},
selectedProjectIds() {
return this.selectedProjects.map(p => p.id);
},
availableProjects() {
return filterBySearchTerm(this.projects, this.searchTerm);
},
noResultsAvailable() {
const { loading, availableProjects } = this;
return !loading && !availableProjects.length;
},
},
watch: {
searchTerm() {
this.search();
},
},
mounted() {
$(this.$refs.projectsDropdown).glDropdown({
selectable: true,
filterable: true,
filterRemote: true,
fieldName: 'project_id',
multiSelect: this.multiSelect,
search: {
fields: ['name'],
},
clicked: this.onClick.bind(this),
data: this.fetchData.bind(this),
renderRow: project => this.rowTemplate(project),
text: project => project.name,
opened: e => e.target.querySelector('.dropdown-input-field').focus(),
});
this.search();
},
methods: {
search: debounce(function debouncedSearch() {
this.fetchData();
}, DATA_REFETCH_DELAY),
getSelectedProjects(selectedProject, isMarking) {
return isMarking
? this.selectedProjects.concat([selectedProject])
......@@ -99,54 +115,32 @@ export default {
? this.getSelectedProjects(selectedObj, isMarking)
: this.singleSelectedProject(selectedObj, isMarking);
},
onClick({ selectedObj, e, isMarking }) {
e.preventDefault();
this.setSelectedProjects(selectedObj, isMarking);
onClick({ project, isSelected }) {
this.setSelectedProjects(project, !isSelected);
this.$emit('selected', this.selectedProjects);
},
fetchData(term, callback) {
fetchData() {
this.loading = true;
return Api.groupProjects(this.groupId, term, this.queryParams, projects => {
return Api.groupProjects(this.groupId, this.searchTerm, this.queryParams, projects => {
this.projects = projects;
this.loading = false;
callback(projects);
});
},
rowTemplate(project) {
const selected = this.defaultProjects
? this.defaultProjects.find(p => p.id === project.id)
: false;
const isActiveClass = selected ? 'is-active' : '';
return `
<li>
<a href='#' class='dropdown-menu-link ${isActiveClass}'>
${this.avatarTemplate(project)}
<div class="align-middle">${escape(project.name)}</div>
</a>
</li>
`;
},
avatarTemplate(project) {
const identiconSizeClass = 's16 rect-avatar d-flex justify-content-center flex-column';
return project.avatar_url
? renderAvatar(project, { sizeClass: 's16 rect-avatar' })
: renderIdenticon(project, {
sizeClass: identiconSizeClass,
});
isProjectSelected(id) {
return this.selectedProjects ? this.selectedProjectIds.includes(id) : false;
},
},
};
</script>
<template>
<div>
<div ref="projectsDropdown" class="dropdown dropdown-projects">
<gl-deprecated-button
class="dropdown-menu-toggle wide shadow-none bg-white"
type="button"
data-toggle="dropdown"
aria-expanded="false"
:aria-label="label"
<gl-dropdown
ref="projectsDropdown"
class="dropdown dropdown-projects"
toggle-class="gl-shadow-none"
>
<template #button-content>
<div class="gl-display-flex">
<gl-avatar
v-if="isOnlyOneProjectSelected"
:src="selectedProjects[0].avatar_url"
......@@ -155,22 +149,40 @@ export default {
:size="16"
shape="rect"
:alt="selectedProjects[0].name"
class="d-inline-flex align-text-bottom"
class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2"
/>
{{ selectedProjectsLabel }}
<icon name="chevron-down" />
</gl-deprecated-button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
<div class="dropdown-title">{{ __('Projects') }}</div>
<div class="dropdown-input">
<input class="dropdown-input-field" type="search" :placeholder="__('Search projects')" />
<icon name="search" class="dropdown-input-search" data-hidden="true" />
</div>
<div class="dropdown-loading pt-8">
<gl-loading-icon size="lg" class="pt-8" />
</div>
<div class="dropdown-content"></div>
</div>
<gl-icon class="gl-ml-2" name="chevron-down" />
</div>
</template>
<gl-dropdown-header>{{ __('Projects') }}</gl-dropdown-header>
<gl-search-box-by-type v-model.trim="searchTerm" class="gl-m-3" />
<gl-dropdown-item
v-for="project in availableProjects"
:key="project.id"
:is-check-item="true"
:is-checked="isProjectSelected(project.id)"
@click.prevent="onClick({ project, isSelected: isProjectSelected(project.id) })"
>
<div class="gl-display-flex">
<gl-avatar
class="gl-mr-2 vertical-align-middle"
:alt="project.name"
:size="16"
:entity-id="project.id"
:entity-name="project.name"
:src="project.avatar_url"
shape="rect"
/>
{{ project.name }}
</div>
</gl-dropdown-item>
<gl-dropdown-item v-show="noResultsAvailable" class="gl-pointer-events-none text-secondary">{{
__('No matching results')
}}</gl-dropdown-item>
<gl-dropdown-item v-if="loading">
<gl-loading-icon size="lg" />
</gl-dropdown-item>
</gl-dropdown>
</template>
......@@ -21,3 +21,5 @@ export const DATE_RANGE_LIMIT = 180;
export const OFFSET_DATE_BY_ONE = 1;
export const NO_DRAG_CLASS = 'no-drag';
export const DATA_REFETCH_DELAY = 250;
......@@ -97,3 +97,8 @@ export const buildCycleAnalyticsInitialData = ({
? buildProjectsFromJSON(projects).map(convertObjectPropsToCamelCase)
: [],
});
export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => {
if (!searchTerm?.length) return data;
return data.filter(item => item[filterByKey].toLowerCase().includes(searchTerm.toLowerCase()));
};
......@@ -37,11 +37,3 @@
0 0.25rem 0.75rem $gl-btn-active-background;
}
.dropdown-groups,
.dropdown-projects {
&.show.dropdown .dropdown-menu {
min-height: $grid-size * 21;
}
}
......@@ -942,7 +942,7 @@ RSpec.describe 'Group Value Stream Analytics', :js do
end
context 'Duration chart' do
let(:duration_chart_dropdown) { page.find('.dropdown-stages') }
let(:duration_chart_dropdown) { page.find('.js-dropdown-stages') }
default_stages = Analytics::CycleAnalytics::StagePresenter::DEFAULT_STAGE_ATTRIBUTES
.each_value
......@@ -950,7 +950,7 @@ RSpec.describe 'Group Value Stream Analytics', :js do
.freeze
def duration_chart_stages
duration_chart_dropdown.all('.dropdown-menu-link').collect(&:text)
duration_chart_dropdown.all('.dropdown-item').collect(&:text)
end
def toggle_duration_chart_dropdown
......
......@@ -11,8 +11,9 @@ import RecentActivityCard from 'ee/analytics/cycle_analytics/components/recent_a
import TimeMetricsCard from 'ee/analytics/cycle_analytics/components/time_metrics_card.vue';
import PathNavigation from 'ee/analytics/cycle_analytics/components/path_navigation.vue';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import 'bootstrap';
import '~/gl_dropdown';
import StageTableNav from 'ee/analytics/cycle_analytics/components/stage_table_nav.vue';
import StageNavItem from 'ee/analytics/cycle_analytics/components/stage_nav_item.vue';
import AddStageButton from 'ee/analytics/cycle_analytics/components/add_stage_button.vue';
import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue';
import Daterange from 'ee/analytics/shared/components/daterange.vue';
import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue';
......@@ -41,6 +42,7 @@ const defaultStubs = {
'tasks-by-type-chart': true,
'labels-selector': true,
DurationChart: true,
GroupsDropdownFilter: true,
};
function createComponent({
......@@ -99,10 +101,10 @@ describe('Cycle Analytics component', () => {
let wrapper;
let mock;
const selectStageNavItem = index =>
const findStageNavItemAtIndex = index =>
wrapper
.find(StageTable)
.findAll('.stage-nav-item')
.find(StageTableNav)
.findAll(StageNavItem)
.at(index);
const shouldSetUrlParams = result => {
......@@ -144,6 +146,10 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find(PathNavigation).exists()).toBe(flag);
};
const displaysAddStageButton = flag => {
expect(wrapper.find(AddStageButton).exists()).toBe(flag);
};
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
......@@ -201,7 +207,7 @@ describe('Cycle Analytics component', () => {
});
it('does not display the add stage button', () => {
expect(wrapper.find('.js-add-stage-button').exists()).toBe(false);
displaysAddStageButton(false);
});
it('does not display the path navigation', () => {
......@@ -227,6 +233,7 @@ describe('Cycle Analytics component', () => {
describe('after a filter has been selected', () => {
describe('the user has access to the group', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent({
withStageSelected: true,
});
......@@ -265,9 +272,18 @@ describe('Cycle Analytics component', () => {
});
it('displays the add stage button', () => {
wrapper = createComponent({ shallow: false, withStageSelected: true });
wrapper = createComponent({
opts: {
stubs: {
StageTable,
StageTableNav,
},
},
withStageSelected: true,
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find('.js-add-stage-button').exists()).toBe(true);
displaysAddStageButton(true);
});
});
......@@ -309,33 +325,32 @@ describe('Cycle Analytics component', () => {
wrapper = createComponent({
opts: {
stubs: {
'stage-event-list': true,
'add-stage-button': true,
'stage-table-header': true,
StageTable,
StageTableNav,
StageNavItem,
},
},
shallow: false,
withStageSelected: true,
});
});
it('has the first stage selected by default', () => {
const first = selectStageNavItem(0);
const second = selectStageNavItem(1);
const first = findStageNavItemAtIndex(0);
const second = findStageNavItemAtIndex(1);
expect(first.classes('active')).toBe(true);
expect(second.classes('active')).toBe(false);
expect(first.props('isActive')).toBe(true);
expect(second.props('isActive')).toBe(false);
});
it('can navigate to different stages', () => {
selectStageNavItem(2).trigger('click');
findStageNavItemAtIndex(2).trigger('click');
return wrapper.vm.$nextTick().then(() => {
const first = selectStageNavItem(0);
const third = selectStageNavItem(2);
const first = findStageNavItemAtIndex(0);
const third = findStageNavItemAtIndex(2);
expect(third.classes('active')).toBe(true);
expect(first.classes('active')).toBe(false);
expect(third.props('isActive')).toBe(true);
expect(first.props('isActive')).toBe(false);
});
});
});
......@@ -378,7 +393,7 @@ describe('Cycle Analytics component', () => {
});
it('does not display the add stage button', () => {
expect(wrapper.find('.js-add-stage-button').exists()).toBe(false);
displaysAddStageButton(false);
});
it('does not display the tasks by type chart', () => {
......
import Vuex from 'vuex';
import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import $ from 'jquery';
import 'bootstrap';
import '~/gl_dropdown';
import { GlLoadingIcon, GlNewDropdownItem } from '@gitlab/ui';
import durationChartStore from 'ee/analytics/cycle_analytics/store/modules/duration_chart';
import Scatterplot from 'ee/analytics/shared/components/scatterplot.vue';
import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue';
......@@ -72,17 +69,11 @@ describe('DurationChart', () => {
const findStageDropdown = _wrapper => _wrapper.find(StageDropdownFilter);
const findLoader = _wrapper => _wrapper.find(GlLoadingIcon);
const openStageDropdown = _wrapper => {
$(findStageDropdown(_wrapper).element).trigger('shown.bs.dropdown');
return _wrapper.vm.$nextTick();
};
const selectStage = (_wrapper, index = 0) => {
findStageDropdown(_wrapper)
.findAll('a')
.findAll(GlNewDropdownItem)
.at(index)
.trigger('click');
return _wrapper.vm.$nextTick();
.vm.$emit('click');
};
beforeEach(() => {
......@@ -111,8 +102,8 @@ describe('DurationChart', () => {
const selectedStages = stages.filter((_, index) => index !== selectedIndex);
beforeEach(() => {
wrapper = createComponent({ mountFn: mount, stubs: { StageDropdownFilter: false } });
return openStageDropdown(wrapper).then(() => selectStage(wrapper, selectedIndex));
wrapper = createComponent({ stubs: { StageDropdownFilter } });
selectStage(wrapper, selectedIndex);
});
it('calls the `updateSelectedDurationChartStages` action', () => {
......
import { shallowMount } from '@vue/test-utils';
import $ from 'jquery';
import 'bootstrap';
import '~/gl_dropdown';
import { GlNewDropdown as GlDropdown, GlNewDropdownItem as GlDropdownItem } from '@gitlab/ui';
import StageDropdownFilter from 'ee/analytics/cycle_analytics/components/stage_dropdown_filter.vue';
jest.mock('~/api', () => ({
groupProjects: jest.fn(),
}));
const stages = [
{
id: 1,
title: 'Issue',
},
{
id: 2,
title: 'Plan',
},
{
id: 3,
title: 'Code',
},
];
......@@ -40,28 +37,18 @@ describe('StageDropdownFilter component', () => {
createComponent();
});
const findDropdown = () => wrapper.find('.dropdown');
const openDropdown = () => {
$(findDropdown().element)
.parent()
.trigger('shown.bs.dropdown');
};
const findDropdownItems = () => findDropdown().findAll('a');
const findDropdown = () => wrapper.find(GlDropdown);
const selectDropdownItemAtIndex = index =>
findDropdown()
.findAll(GlDropdownItem)
.at(index)
.vm.$emit('click');
describe('on stage click', () => {
beforeEach(() => {
openDropdown();
return wrapper.vm.$nextTick();
});
describe('clicking a selected stage', () => {
it('should remove from selection', () => {
const item = findDropdownItems().at(0);
selectDropdownItemAtIndex(0);
item.trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emittedByOrder()).toEqual([
{
name: 'selected',
......@@ -70,23 +57,15 @@ describe('StageDropdownFilter component', () => {
]);
});
});
});
describe('clicking a deselected stage', () => {
beforeEach(() => {
selectDropdownItemAtIndex(0);
});
it('should add to selection', () => {
findDropdownItems()
.at(0)
.trigger('click');
selectDropdownItemAtIndex(0);
return wrapper.vm
.$nextTick()
.then(() => {
findDropdownItems()
.at(0)
.trigger('click');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.emittedByOrder()).toEqual([
{
name: 'selected',
......@@ -100,5 +79,4 @@ describe('StageDropdownFilter component', () => {
});
});
});
});
});
import { mount } from '@vue/test-utils';
import $ from 'jquery';
import 'bootstrap';
import '~/gl_dropdown';
import { GlNewDropdown as GlDropdown, GlNewDropdownItem as GlDropdownItem } from '@gitlab/ui';
import GroupsDropdownFilter from 'ee/analytics/shared/components/groups_dropdown_filter.vue';
import { TEST_HOST } from 'helpers/test_constants';
import Api from '~/api';
......@@ -41,26 +39,29 @@ describe('GroupsDropdownFilter component', () => {
});
beforeEach(() => {
jest.spyOn($.fn, 'glDropdown');
Api.groups.mockImplementation((term, options, callback) => {
callback(groups);
});
Api.groups.mockImplementation(() => Promise.resolve(groups));
});
const findDropdown = () => wrapper.find({ ref: 'groupsDropdown' });
const openDropdown = () => {
$(findDropdown().element)
.parent()
.trigger('shown.bs.dropdown');
};
const findDropdownItems = () => findDropdown().findAll('a');
const findDropdownButton = () => findDropdown().find('button');
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () =>
findDropdown()
.findAll(GlDropdownItem)
.filter(w => w.text() !== 'No matching results');
const findDropdownAtIndex = index => findDropdownItems().at(index);
const findDropdownButton = () => findDropdown().find('.dropdown-toggle');
const findDropdownButtonAvatar = () => findDropdown().find('.gl-avatar');
it('should call glDropdown', () => {
createComponent();
expect($.fn.glDropdown).toHaveBeenCalled();
});
const shouldContainAvatar = ({ dropdown, hasImage = true, hasIdenticon = true }) => {
expect(dropdown.find('img.gl-avatar').exists()).toBe(hasImage);
expect(dropdown.find('div.gl-avatar-identicon').exists()).toBe(hasIdenticon);
};
const selectDropdownAtIndex = index =>
findDropdownAtIndex(index)
.find('button')
.trigger('click');
describe('when passed a defaultGroup as prop', () => {
beforeEach(() => {
......@@ -81,10 +82,6 @@ describe('GroupsDropdownFilter component', () => {
describe('it renders the items correctly', () => {
beforeEach(() => {
createComponent();
openDropdown();
return wrapper.vm.$nextTick();
});
it('should contain 2 items', () => {
......@@ -92,56 +89,26 @@ describe('GroupsDropdownFilter component', () => {
});
it('renders an avatar when the group has an avatar_url', () => {
expect(
findDropdownItems()
.at(0)
.contains('img.avatar'),
).toBe(true);
expect(
findDropdownItems()
.at(0)
.contains('div.identicon'),
).toBe(false);
shouldContainAvatar({ dropdown: findDropdownAtIndex(0), hasIdenticon: false });
});
it("renders an identicon when the group doesn't have an avatar_url", () => {
expect(
findDropdownItems()
.at(1)
.contains('img.avatar'),
).toBe(false);
expect(
findDropdownItems()
.at(1)
.contains('div.identicon'),
).toBe(true);
shouldContainAvatar({ dropdown: findDropdownAtIndex(1), hasImage: false });
});
it('renders the full group name and highlights the last part', () => {
expect(
findDropdownItems()
.at(1)
.find('.js-group-path')
.html(),
).toContain('group / <strong>subgroup</strong>');
expect(findDropdownAtIndex(1).text()).toContain('group / subgroup');
});
});
describe('on group click', () => {
beforeEach(() => {
createComponent();
openDropdown();
return wrapper.vm.$nextTick();
});
it('should emit the "selected" event with the selected group', () => {
findDropdownItems()
.at(0)
.trigger('click');
selectDropdownAtIndex(0);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emittedByOrder()).toEqual([
{
name: 'selected',
......@@ -149,14 +116,10 @@ describe('GroupsDropdownFilter component', () => {
},
]);
});
});
it('should change selection when new group is clicked', () => {
findDropdownItems()
.at(1)
.trigger('click');
selectDropdownAtIndex(1);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emittedByOrder()).toEqual([
{
name: 'selected',
......@@ -164,29 +127,21 @@ describe('GroupsDropdownFilter component', () => {
},
]);
});
});
it('renders an avatar in the dropdown button when the group has an avatar_url', done => {
findDropdownItems()
.at(0)
.trigger('click');
it('renders an avatar in the dropdown button when the group has an avatar_url', () => {
selectDropdownAtIndex(0);
wrapper.vm.$nextTick(() => {
expect(findDropdownButton().contains('img.gl-avatar')).toBe(true);
expect(findDropdownButton().contains('.gl-avatar-identicon')).toBe(false);
done();
return wrapper.vm.$nextTick().then(() => {
shouldContainAvatar({ dropdown: findDropdownButton(), hasIdenticon: false });
});
});
it("renders an identicon in the dropdown button when the group doesn't have an avatar_url", done => {
findDropdownItems()
.at(1)
.trigger('click');
it("renders an identicon in the dropdown button when the group doesn't have an avatar_url", () => {
selectDropdownAtIndex(1);
wrapper.vm.$nextTick(() => {
return wrapper.vm.$nextTick().then(() => {
expect(findDropdownButton().contains('img.gl-avatar')).toBe(false);
expect(findDropdownButton().contains('.gl-avatar-identicon')).toBe(true);
done();
});
});
});
......
......@@ -2,6 +2,7 @@ import {
buildGroupFromDataset,
buildProjectFromDataset,
buildCycleAnalyticsInitialData,
filterBySearchTerm,
} from 'ee/analytics/shared/utils';
const groupDataset = {
......@@ -146,4 +147,27 @@ describe('buildCycleAnalyticsInitialData', () => {
expect(buildCycleAnalyticsInitialData()).toMatchObject({ [field]: null });
});
});
describe('filterBySearchTerm', () => {
const data = [
{ name: 'eins', title: 'one' },
{ name: 'zwei', title: 'two' },
{ name: 'drei', title: 'three' },
];
const searchTerm = 'rei';
it('filters data by `name` for the provided search term', () => {
expect(filterBySearchTerm(data, searchTerm)).toEqual([data[2]]);
});
it('with no search term returns the data', () => {
['', null].forEach(search => {
expect(filterBySearchTerm(data, search)).toEqual(data);
});
});
it('with a key, filters by the provided key', () => {
expect(filterBySearchTerm(data, 'ne', 'title')).toEqual([data[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