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