Commit 0f56fcb5 authored by Phil Hughes's avatar Phil Hughes

Merge branch '213975-gl-filtered-search-on-roadmap' into 'master'

Use GlFilteredSearch in Roadmap for async filtering

Closes #213975

See merge request gitlab-org/gitlab!36421
parents 2f7101ac 068c1539
......@@ -20,8 +20,18 @@ export default {
},
},
computed: {
/**
* Both Epic and Roadmap pages share same recents store
* and with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36421
* Roadmap started using `GlFilteredSearch` which is not compatible
* with string tokens stored in recents, so this is a temporary
* fix by ignoring non-string recents while in Epic page.
*/
compatibleItems() {
return this.items.filter(item => typeof item === 'string');
},
processedItems() {
return this.items.map(item => {
return this.compatibleItems.map(item => {
const { tokens, searchToken } = FilteredSearchTokenizer.processTokens(
item,
this.allowedKeys,
......@@ -41,7 +51,7 @@ export default {
});
},
hasItems() {
return this.items.length > 0;
return this.compatibleItems.length > 0;
},
},
methods: {
......@@ -84,9 +94,7 @@ export default {
<span class="value">{{ token.suffix }}</span>
</span>
</span>
<span class="filtered-search-history-dropdown-search-token">
{{ item.searchToken }}
</span>
<span class="filtered-search-history-dropdown-search-token">{{ item.searchToken }}</span>
</button>
</li>
<li class="divider"></li>
......
......@@ -83,7 +83,7 @@ export default {
return {
initialRender: true,
recentSearchesPromise: null,
recentSearches: null,
recentSearches: [],
filterValue: this.initialFilterValue,
selectedSortOption,
selectedSortDirection,
......@@ -118,6 +118,9 @@ export default {
? __('Sort direction: Ascending')
: __('Sort direction: Descending');
},
filteredRecentSearches() {
return this.recentSearches.filter(item => typeof item !== 'string');
},
},
watch: {
/**
......@@ -246,7 +249,7 @@ export default {
v-model="filterValue"
:placeholder="searchInputPlaceholder"
:available-tokens="tokens"
:history-items="recentSearches"
:history-items="filteredRecentSearches"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
@clear-history="handleClearHistory"
......
......@@ -3,7 +3,7 @@ import {
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlNewDropdownDivider as GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
......@@ -102,14 +102,14 @@ export default {
@input="searchLabels"
>
<template #view-token="{ inputValue, cssClasses, listeners }">
<gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners">
~{{ activeLabel ? activeLabel.title : inputValue }}
</gl-token>
<gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners"
>~{{ activeLabel ? activeLabel.title : inputValue }}</gl-token
>
</template>
<template #suggestions>
<gl-filtered-search-suggestion :value="$options.noLabel">
{{ __('No label') }}
</gl-filtered-search-suggestion>
<gl-filtered-search-suggestion :value="$options.noLabel">{{
__('No label')
}}</gl-filtered-search-suggestion>
<gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import EpicsListEmpty from './epics_list_empty.vue';
import RoadmapFilters from './roadmap_filters.vue';
import RoadmapShell from './roadmap_shell.vue';
import eventHub from '../event_hub';
import { EXTEND_AS } from '../constants';
......@@ -10,17 +14,15 @@ export default {
components: {
EpicsListEmpty,
GlLoadingIcon,
RoadmapFilters,
RoadmapShell,
},
mixins: [glFeatureFlagsMixin()],
props: {
presetType: {
type: String,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
newEpicEndpoint: {
type: String,
required: true,
......@@ -42,8 +44,18 @@ export default {
'epicsFetchResultEmpty',
'epicsFetchFailure',
'isChildEpics',
'hasFiltersApplied',
'milestonesFetchFailure',
]),
showFilteredSearchbar() {
if (this.glFeatures.asyncFiltering) {
if (this.epicsFetchResultEmpty) {
return this.hasFiltersApplied;
}
return true;
}
return false;
},
timeframeStart() {
return this.timeframe[0];
},
......@@ -118,28 +130,31 @@ export default {
</script>
<template>
<div :class="{ 'overflow-reset': epicsFetchResultEmpty }" class="roadmap-container">
<gl-loading-icon v-if="epicsFetchInProgress" class="mt-4" size="md" />
<epics-list-empty
v-else-if="epicsFetchResultEmpty"
:preset-type="presetType"
:timeframe-start="timeframeStart"
:timeframe-end="timeframeEnd"
:has-filters-applied="hasFiltersApplied"
:new-epic-endpoint="newEpicEndpoint"
:empty-state-illustration-path="emptyStateIllustrationPath"
:is-child-epics="isChildEpics"
/>
<roadmap-shell
v-else-if="!epicsFetchFailure"
:preset-type="presetType"
:epics="epics"
:milestones="milestones"
:timeframe="timeframe"
:current-group-id="currentGroupId"
:has-filters-applied="hasFiltersApplied"
@onScrollToStart="handleScrollToExtend"
@onScrollToEnd="handleScrollToExtend"
/>
<div class="roadmap-app-container gl-h-full">
<roadmap-filters v-if="showFilteredSearchbar" />
<div :class="{ 'overflow-reset': epicsFetchResultEmpty }" class="roadmap-container">
<gl-loading-icon v-if="epicsFetchInProgress" class="gl-mt-5" size="md" />
<epics-list-empty
v-else-if="epicsFetchResultEmpty"
:preset-type="presetType"
:timeframe-start="timeframeStart"
:timeframe-end="timeframeEnd"
:has-filters-applied="hasFiltersApplied"
:new-epic-endpoint="newEpicEndpoint"
:empty-state-illustration-path="emptyStateIllustrationPath"
:is-child-epics="isChildEpics"
/>
<roadmap-shell
v-else-if="!epicsFetchFailure"
:preset-type="presetType"
:epics="epics"
:milestones="milestones"
:timeframe="timeframe"
:current-group-id="currentGroupId"
:has-filters-applied="hasFiltersApplied"
@onScrollToStart="handleScrollToExtend"
@onScrollToEnd="handleScrollToExtend"
/>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import {
GlFormGroup,
GlSegmentedControl,
GlNewDropdown as GlDropdown,
GlNewDropdownItem as GlDropdownItem,
GlNewDropdownDivider as GlDropdownDivider,
} from '@gitlab/ui';
import { __ } from '~/locale';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import { visitUrl, mergeUrlParams, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import { EPICS_STATES, PRESET_TYPES } from '../constants';
export default {
epicStates: EPICS_STATES,
availablePresets: [
{ text: __('Quarters'), value: PRESET_TYPES.QUARTERS },
{ text: __('Months'), value: PRESET_TYPES.MONTHS },
{ text: __('Weeks'), value: PRESET_TYPES.WEEKS },
],
availableSortOptions: [
{
id: 1,
title: __('Start date'),
sortDirection: {
descending: 'start_date_desc',
ascending: 'start_date_asc',
},
},
{
id: 2,
title: __('Due date'),
sortDirection: {
descending: 'end_date_desc',
ascending: 'end_date_asc',
},
},
],
components: {
GlFormGroup,
GlSegmentedControl,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
FilteredSearchBar,
},
computed: {
...mapState([
'presetType',
'epicsState',
'sortedBy',
'fullPath',
'groupLabelsEndpoint',
'filterParams',
]),
selectedEpicStateTitle() {
if (this.epicsState === EPICS_STATES.ALL) {
return __('All epics');
} else if (this.epicsState === EPICS_STATES.OPENED) {
return __('Open epics');
}
return __('Closed epics');
},
},
methods: {
...mapActions(['setEpicsState', 'setFilterParams', 'setSortedBy', 'fetchEpics']),
getFilteredSearchTokens() {
return [
{
type: 'author_username',
icon: 'user',
title: __('Author'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchAuthors: Api.users.bind(Api),
},
{
type: 'label_name',
icon: 'labels',
title: __('Label'),
unique: false,
symbol: '~',
token: LabelToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchLabels: (search = '') => {
const params = {
only_group_labels: true,
include_ancestor_groups: true,
include_descendant_groups: true,
};
if (search) {
params.search = search;
}
return axios.get(this.groupLabelsEndpoint, {
params,
});
},
},
];
},
getFilteredSearchValue() {
const { authorUsername, labelName, search } = this.filterParams || {};
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: authorUsername },
});
}
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map(label => ({
type: 'label_name',
value: { data: label },
})),
);
}
if (search) {
filteredSearchValue.push(search);
}
return filteredSearchValue;
},
updateUrl() {
const queryParams = urlParamsToObject(window.location.search);
const { authorUsername, labelName, search } = this.filterParams || {};
queryParams.state = this.epicsState;
queryParams.sort = this.sortedBy;
if (authorUsername) {
queryParams.author_username = authorUsername;
} else {
delete queryParams.author_username;
}
delete queryParams.label_name;
if (labelName?.length) {
queryParams['label_name[]'] = labelName;
}
if (search) {
queryParams.search = search;
} else {
delete queryParams.search;
}
// We want to replace the history state so that back button
// correctly reloads the page with previous URL.
updateHistory({
url: setUrlParams(queryParams, window.location.href, true),
title: document.title,
replace: true,
});
},
handleRoadmapLayoutChange(presetType) {
visitUrl(mergeUrlParams({ layout: presetType }, window.location.href));
},
handleEpicStateChange(epicsState) {
this.setEpicsState(epicsState);
this.fetchEpics();
this.updateUrl();
},
handleFilterEpics(filters) {
const filterParams = filters.length ? {} : null;
const labels = [];
filters.forEach(filter => {
if (typeof filter === 'object') {
if (filter.type === 'author_username') {
filterParams.authorUsername = filter.value.data;
} else if (filter.type === 'label_name') {
labels.push(filter.value.data);
}
} else {
filterParams.search = filter;
}
});
if (labels.length) {
filterParams.labelName = labels;
}
this.setFilterParams(filterParams);
this.fetchEpics();
this.updateUrl();
},
handleSortEpics(sortedBy) {
this.setSortedBy(sortedBy);
this.fetchEpics();
this.updateUrl();
},
},
};
</script>
<template>
<div class="epics-filters epics-roadmap-filters epics-roadmap-filters-gl-ui">
<div
class="epics-details-filters filtered-search-block gl-display-flex gl-flex-direction-column flex-xl-row row-content-block second-block"
>
<gl-form-group class="mb-0">
<gl-segmented-control
:checked="presetType"
:options="$options.availablePresets"
class="gl-display-flex d-xl-block"
buttons
@input="handleRoadmapLayoutChange"
/>
</gl-form-group>
<gl-dropdown
:text="selectedEpicStateTitle"
class="gl-my-2 my-xl-0 mx-xl-2"
toggle-class="gl-rounded-small"
>
<gl-dropdown-item
:is-check-item="true"
:is-checked="epicsState === $options.epicStates.ALL"
@click="handleEpicStateChange('all')"
>{{ __('All epics') }}</gl-dropdown-item
>
<gl-dropdown-divider />
<gl-dropdown-item
:is-check-item="true"
:is-checked="epicsState === $options.epicStates.OPENED"
@click="handleEpicStateChange('opened')"
>{{ __('Open epics') }}</gl-dropdown-item
>
<gl-dropdown-item
:is-check-item="true"
:is-checked="epicsState === $options.epicStates.CLOSED"
@click="handleEpicStateChange('closed')"
>{{ __('Closed epics') }}</gl-dropdown-item
>
</gl-dropdown>
<filtered-search-bar
:namespace="fullPath"
:search-input-placeholder="__('Search or filter results...')"
:tokens="getFilteredSearchTokens()"
:sort-options="$options.availableSortOptions"
:initial-filter-value="getFilteredSearchValue()"
:initial-sort-by="sortedBy"
recent-searches-storage-key="epics"
class="gl-flex-grow-1"
@onFilter="handleFilterEpics"
@onSort="handleSortEpics"
/>
</div>
</div>
</template>
......@@ -22,6 +22,12 @@ export const PRESET_TYPES = {
WEEKS: 'WEEKS',
};
export const EPICS_STATES = {
ALL: 'all',
OPENED: 'opened',
CLOSED: 'closed',
};
export const EXTEND_AS = {
PREPEND: 'prepend',
APPEND: 'append',
......
......@@ -86,6 +86,7 @@ export default () => {
fullPath: dataset.fullPath,
epicIid: dataset.iid,
newEpicEndpoint: dataset.newEpicEndpoint,
groupLabelsEndpoint: dataset.groupLabelsEndpoint,
epicsState: dataset.epicsState,
sortedBy: dataset.sortedBy,
filterQueryString,
......@@ -108,8 +109,10 @@ export default () => {
filterQueryString: this.filterQueryString,
filterParams: this.filterParams,
initialEpicsPath: this.initialEpicsPath,
groupLabelsEndpoint: this.groupLabelsEndpoint,
defaultInnerHeight: this.defaultInnerHeight,
isChildEpics: this.isChildEpics,
hasFiltersApplied: this.hasFiltersApplied,
allowSubEpics: this.allowSubEpics,
});
},
......@@ -119,10 +122,7 @@ export default () => {
render(createElement) {
return createElement('roadmap-app', {
props: {
store: this.store,
presetType: this.presetType,
hasFiltersApplied: this.hasFiltersApplied,
epicsState: this.epicsState,
newEpicEndpoint: this.newEpicEndpoint,
emptyStateIllustrationPath: this.emptyStateIllustrationPath,
},
......
......@@ -345,3 +345,13 @@ export const refreshMilestoneDates = ({ commit, state, getters }) => {
};
export const setBufferSize = ({ commit }, bufferSize) => commit(types.SET_BUFFER_SIZE, bufferSize);
export const setEpicsState = ({ commit }, epicsState) => commit(types.SET_EPICS_STATE, epicsState);
export const setFilterParams = ({ commit }, filterParams) =>
commit(types.SET_FILTER_PARAMS, filterParams);
export const setSortedBy = ({ commit }, sortedBy) => commit(types.SET_SORTED_BY, sortedBy);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -28,3 +28,7 @@ export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE';
export const SET_BUFFER_SIZE = 'SET_BUFFER_SIZE';
export const SET_EPICS_STATE = 'SET_EPICS_STATE';
export const SET_FILTER_PARAMS = 'SET_FILTER_PARAMS';
export const SET_SORTED_BY = 'SET_SORTED_BY';
......@@ -2,6 +2,12 @@ import Vue from 'vue';
import * as types from './mutation_types';
const resetEpics = state => {
state.epics = [];
state.childrenFlags = {};
state.epicIds = [];
};
export default {
[types.SET_INITIAL_DATA](state, data) {
Object.assign(state, { ...data });
......@@ -103,4 +109,20 @@ export default {
[types.SET_BUFFER_SIZE](state, bufferSize) {
state.bufferSize = bufferSize;
},
[types.SET_FILTER_PARAMS](state, filterParams) {
state.filterParams = filterParams;
state.hasFiltersApplied = Boolean(filterParams);
resetEpics(state);
},
[types.SET_EPICS_STATE](state, epicsState) {
state.epicsState = epicsState;
resetEpics(state);
},
[types.SET_SORTED_BY](state, sortedBy) {
state.sortedBy = sortedBy;
resetEpics(state);
},
};
......@@ -5,6 +5,7 @@ export default () => ({
filterQueryString: '',
initialEpicsPath: '',
filterParams: null,
groupLabelsEndpoint: '',
// Data
epicIid: '',
......@@ -26,6 +27,7 @@ export default () => ({
// UI Flags
defaultInnerHeight: 0,
isChildEpics: false,
hasFiltersApplied: false,
epicsFetchInProgress: false,
epicsFetchForTimeframeInProgress: false,
epicsFetchFailure: false,
......
......@@ -560,3 +560,16 @@ html.group-epics-roadmap-html {
color: $gray-700;
padding-top: $gl-spacing-scale-1;
}
// There are several styling issues happening while using
// `GlFilteredSearch` in roadmap due to some of our global
// styles which we need to override until those are fixed
// at framework level.
// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/908
.epics-roadmap-filters-gl-ui {
.gl-search-box-by-click {
.gl-filtered-search-scrollable {
border-radius: 0;
}
}
}
......@@ -12,6 +12,7 @@ module Groups
before_action do
push_frontend_feature_flag(:roadmap_buffered_rendering, @group)
push_frontend_feature_flag(:confidential_epics, @group, default_enabled: true)
push_frontend_feature_flag(:async_filtering, @group)
end
# show roadmap for a group
......
......@@ -13,7 +13,8 @@
- has_filters_applied = params[:label_name].present? || params[:author_username].present? || params[:search].present?
- if @epics_count != 0
= render 'shared/epic/search_bar', type: :epics, show_roadmap_presets: true, hide_extra_sort_options: true
- if !Feature.enabled?(:async_filtering, @group)
= render 'shared/epic/search_bar', type: :epics, show_roadmap_presets: true, hide_extra_sort_options: true
- if @epics_count > Groups::RoadmapController::EPICS_ROADMAP_LIMIT && show_callout?(epics_limit_feature)
.warning_message.mb-0.js-epics-limit-callout{ role: 'alert', data: { uid: epics_limit_feature } }
......@@ -24,7 +25,17 @@
%a.btn.btn-outline-warning#js-learn-more{ "href" => "https://docs.gitlab.com/ee/user/group/roadmap/" }
= _("Learn more")
#js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json), group_id: @group.id, full_path: @group.full_path, empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), has_filters_applied: "#{has_filters_applied}", new_epic_endpoint: group_epics_path(@group), preset_type: roadmap_layout, epics_state: @epics_state, sorted_by: @sort, allow_sub_epics: allow_sub_epics } }
#js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json),
group_id: @group.id,
full_path: @group.full_path,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: "#{has_filters_applied}",
new_epic_endpoint: group_epics_path(@group),
group_labels_endpoint: group_labels_path(@group, format: :json),
preset_type: roadmap_layout,
epics_state: @epics_state,
sorted_by: @sort,
allow_sub_epics: allow_sub_epics } }
- else
= render 'shared/empty_states/roadmap'
......@@ -9,6 +9,7 @@ RSpec.describe 'epics list', :js do
before do
stub_licensed_features(epics: true)
stub_feature_flags(unfiltered_epic_aggregates: false)
stub_feature_flags(async_filtering: false)
sign_in(user)
end
......
......@@ -25,6 +25,7 @@ RSpec.describe 'group epic roadmap', :js do
before do
stub_licensed_features(epics: true)
stub_feature_flags(unfiltered_epic_aggregates: false)
stub_feature_flags(async_filtering: false)
sign_in(user)
end
......
import { GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import EpicsListEmpty from 'ee/roadmap/components/epics_list_empty.vue';
import RoadmapApp from 'ee/roadmap/components/roadmap_app.vue';
import RoadmapFilters from 'ee/roadmap/components/roadmap_filters.vue';
import RoadmapShell from 'ee/roadmap/components/roadmap_shell.vue';
import { PRESET_TYPES, EXTEND_AS } from 'ee/roadmap/constants';
import eventHub from 'ee/roadmap/event_hub';
......@@ -42,10 +42,12 @@ describe('RoadmapApp', () => {
localVue,
propsData: {
emptyStateIllustrationPath,
hasFiltersApplied,
newEpicEndpoint,
presetType,
},
provide: {
glFeatures: { asyncFiltering: true },
},
store,
});
};
......@@ -57,6 +59,7 @@ describe('RoadmapApp', () => {
sortedBy: mockSortedBy,
presetType,
timeframe,
hasFiltersApplied,
filterQueryString: '',
initialEpicsPath: epicsPath,
basePath,
......@@ -142,6 +145,10 @@ describe('RoadmapApp', () => {
store.commit(types.RECEIVE_EPICS_SUCCESS, epics);
});
it('contains roadmap filters UI', () => {
expect(wrapper.contains(RoadmapFilters)).toBe(true);
});
it('contains the current group id', () => {
expect(wrapper.find(RoadmapShell).props('currentGroupId')).toBe(currentGroupId);
});
......@@ -223,7 +230,7 @@ describe('RoadmapApp', () => {
wrapper.vm.handleScrollToExtend(roadmapTimelineEl, extendType);
return Vue.nextTick(() => {
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.fetchEpicsForTimeframe).toHaveBeenCalledWith(
expect.objectContaining({
timeframe: wrapper.vm.extendedTimeframe,
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import {
GlSegmentedControl,
GlNewDropdown as GlDropdown,
GlNewDropdownItem as GlDropdownItem,
} from '@gitlab/ui';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import { visitUrl, mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import RoadmapFilters from 'ee/roadmap/components/roadmap_filters.vue';
import createStore from 'ee/roadmap/store';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES, EPICS_STATES } from 'ee/roadmap/constants';
import { mockSortedBy, mockTimeframeInitialDate } from 'ee_jest/roadmap/mock_data';
import { TEST_HOST } from 'helpers/test_constants';
jest.mock('~/lib/utils/url_utility', () => ({
mergeUrlParams: jest.fn(),
visitUrl: jest.fn(),
setUrlParams: jest.requireActual('~/lib/utils/url_utility').setUrlParams,
updateHistory: jest.requireActual('~/lib/utils/url_utility').updateHistory,
}));
const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
epicsState = EPICS_STATES.ALL,
sortedBy = mockSortedBy,
fullPath = 'gitlab-org',
groupLabelsEndpoint = '/groups/gitlab-org/-/labels.json',
timeframe = getTimeframeForMonthsView(mockTimeframeInitialDate),
filterParams = {},
} = {}) => {
const localVue = createLocalVue();
const store = createStore();
localVue.use(Vuex);
store.dispatch('setInitialData', {
presetType,
epicsState,
sortedBy,
fullPath,
groupLabelsEndpoint,
filterParams,
timeframe,
});
return shallowMount(RoadmapFilters, {
localVue,
store,
});
};
describe('RoadmapFilters', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('selectedEpicStateTitle', () => {
it.each`
returnValue | epicsState
${'All epics'} | ${EPICS_STATES.ALL}
${'Open epics'} | ${EPICS_STATES.OPENED}
${'Closed epics'} | ${EPICS_STATES.CLOSED}
`(
'returns string "$returnValue" when epicsState represents `$epicsState`',
({ returnValue, epicsState }) => {
wrapper.vm.$store.dispatch('setEpicsState', epicsState);
expect(wrapper.vm.selectedEpicStateTitle).toBe(returnValue);
},
);
});
});
describe('methods', () => {
describe('updateUrl', () => {
it('updates window URL based on presence of props for state, filtered search and sort criteria', async () => {
wrapper.vm.$store.dispatch('setEpicsState', EPICS_STATES.CLOSED);
wrapper.vm.$store.dispatch('setFilterParams', {
authorUsername: 'root',
labelName: ['Bug'],
});
wrapper.vm.$store.dispatch('setSortedBy', 'end_date_asc');
await wrapper.vm.$nextTick();
wrapper.vm.updateUrl();
expect(global.window.location.href).toBe(
`${TEST_HOST}/?state=${EPICS_STATES.CLOSED}&sort=end_date_asc&author_username=root&label_name%5B%5D=Bug`,
);
});
});
});
describe('template', () => {
beforeEach(() => {
updateHistory({ url: TEST_HOST, title: document.title, replace: true });
});
it('renders roadmap layout switching buttons', () => {
const layoutSwitches = wrapper.find(GlSegmentedControl);
expect(layoutSwitches.exists()).toBe(true);
expect(layoutSwitches.props('checked')).toBe(PRESET_TYPES.MONTHS);
expect(layoutSwitches.props('options')).toEqual([
{ text: 'Quarters', value: PRESET_TYPES.QUARTERS },
{ text: 'Months', value: PRESET_TYPES.MONTHS },
{ text: 'Weeks', value: PRESET_TYPES.WEEKS },
]);
});
it('switching layout using roadmap layout switching buttons causes page to reload with selected layout', () => {
wrapper.find(GlSegmentedControl).vm.$emit('input', PRESET_TYPES.OPENED);
expect(mergeUrlParams).toHaveBeenCalledWith(
expect.objectContaining({ layout: PRESET_TYPES.OPENED }),
`${TEST_HOST}/`,
);
expect(visitUrl).toHaveBeenCalled();
});
it('renders epics state toggling dropdown', () => {
const epicsStateDropdown = wrapper.find(GlDropdown);
expect(epicsStateDropdown.exists()).toBe(true);
expect(epicsStateDropdown.findAll(GlDropdownItem)).toHaveLength(3);
});
describe('FilteredSearchBar', () => {
const mockInitialFilterValue = [
{
type: 'author_username',
value: { data: 'root' },
},
{
type: 'label_name',
value: { data: 'Bug' },
},
];
let filteredSearchBar;
beforeEach(() => {
filteredSearchBar = wrapper.find(FilteredSearchBar);
});
it('component is rendered with correct namespace & recent search key', () => {
expect(filteredSearchBar.exists()).toBe(true);
expect(filteredSearchBar.props('namespace')).toBe('gitlab-org');
expect(filteredSearchBar.props('recentSearchesStorageKey')).toBe('epics');
});
it('includes `Author` and `Label` tokens', () => {
expect(filteredSearchBar.props('tokens')).toEqual([
{
type: 'author_username',
icon: 'user',
title: 'Author',
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchAuthors: expect.any(Function),
},
{
type: 'label_name',
icon: 'labels',
title: 'Label',
unique: false,
symbol: '~',
token: LabelToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchLabels: expect.any(Function),
},
]);
});
it('includes "Start date" and "Due date" sort options', () => {
expect(filteredSearchBar.props('sortOptions')).toEqual([
{
id: 1,
title: 'Start date',
sortDirection: {
descending: 'start_date_desc',
ascending: 'start_date_asc',
},
},
{
id: 2,
title: 'Due date',
sortDirection: {
descending: 'end_date_desc',
ascending: 'end_date_asc',
},
},
]);
});
it('has initialFilterValue prop set to array of formatted values based on `filterParams`', async () => {
wrapper.vm.$store.dispatch('setFilterParams', {
authorUsername: 'root',
labelName: ['Bug'],
});
await wrapper.vm.$nextTick();
expect(filteredSearchBar.props('initialFilterValue')).toEqual(mockInitialFilterValue);
});
it('fetches filtered epics when `onFilter` event is emitted', async () => {
jest.spyOn(wrapper.vm, 'setFilterParams');
jest.spyOn(wrapper.vm, 'fetchEpics');
jest.spyOn(wrapper.vm, 'updateUrl');
await wrapper.vm.$nextTick();
filteredSearchBar.vm.$emit('onFilter', mockInitialFilterValue);
await wrapper.vm.$nextTick();
expect(wrapper.vm.setFilterParams).toHaveBeenCalledWith({
authorUsername: 'root',
labelName: ['Bug'],
});
expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
});
it('fetches epics with updated sort order when `onSort` event is emitted', async () => {
jest.spyOn(wrapper.vm, 'setSortedBy');
jest.spyOn(wrapper.vm, 'fetchEpics');
jest.spyOn(wrapper.vm, 'updateUrl');
await wrapper.vm.$nextTick();
filteredSearchBar.vm.$emit('onSort', 'end_date_asc');
await wrapper.vm.$nextTick();
expect(wrapper.vm.setSortedBy).toHaveBeenCalledWith('end_date_asc');
expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
});
});
});
});
......@@ -3,7 +3,19 @@ import * as types from 'ee/roadmap/store/mutation_types';
import defaultState from 'ee/roadmap/store/state';
import { mockGroupId, basePath, epicsPath, mockSortedBy } from 'ee_jest/roadmap/mock_data';
import {
mockGroupId,
basePath,
epicsPath,
mockSortedBy,
mockEpic,
} from 'ee_jest/roadmap/mock_data';
const setEpicMockData = state => {
state.epics = [mockEpic];
state.childrenFlags = { 'gid://gitlab/Epic/1': {} };
state.epicIds = ['gid://gitlab/Epic/1'];
};
describe('Roadmap Store Mutations', () => {
let state;
......@@ -257,4 +269,53 @@ describe('Roadmap Store Mutations', () => {
expect(state.bufferSize).toBe(bufferSize);
});
});
describe('SET_FILTER_PARAMS', () => {
it('Should set `filterParams` and `hasFiltersApplied` to the state and reset existing epics', () => {
const filterParams = [{ foo: 'bar' }, { bar: 'baz' }];
setEpicMockData(state);
mutations[types.SET_FILTER_PARAMS](state, filterParams);
expect(state).toMatchObject({
filterParams,
hasFiltersApplied: true,
epics: [],
childrenFlags: {},
epicIds: [],
});
});
});
describe('SET_EPICS_STATE', () => {
it('Should set `epicsState` to the state and reset existing epics', () => {
const epicsState = 'all';
setEpicMockData(state);
mutations[types.SET_EPICS_STATE](state, epicsState);
expect(state).toMatchObject({
epicsState,
epics: [],
childrenFlags: {},
epicIds: [],
});
});
});
describe('SET_SORTED_BY', () => {
it('Should set `sortedBy` to the state and reset existing epics', () => {
const sortedBy = 'start_date_asc';
setEpicMockData(state);
mutations[types.SET_SORTED_BY](state, sortedBy);
expect(state).toMatchObject({
sortedBy,
epics: [],
childrenFlags: {},
epicIds: [],
});
});
});
});
......@@ -2330,6 +2330,9 @@ msgstr ""
msgid "All environments"
msgstr ""
msgid "All epics"
msgstr ""
msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
msgstr ""
......@@ -5027,6 +5030,9 @@ msgstr ""
msgid "Closed %{epicTimeagoDate}"
msgstr ""
msgid "Closed epics"
msgstr ""
msgid "Closed issues"
msgstr ""
......@@ -16861,6 +16867,9 @@ msgstr ""
msgid "Open comment type dropdown"
msgstr ""
msgid "Open epics"
msgstr ""
msgid "Open errors"
msgstr ""
......
......@@ -57,7 +57,11 @@ describe('Recent Searches Dropdown Content', () => {
beforeEach(() => {
createComponent({
items: ['foo', 'author:@root label:~foo bar'],
items: [
'foo',
'author:@root label:~foo bar',
[{ type: 'author_username', value: { data: 'toby', operator: '=' } }],
],
isLocalStorageAvailable: true,
});
});
......@@ -76,7 +80,7 @@ describe('Recent Searches Dropdown Content', () => {
});
it('renders a correct amount of dropdown items', () => {
expect(findDropdownItems()).toHaveLength(2);
expect(findDropdownItems()).toHaveLength(2); // Ignore non-string recent item
});
it('expect second dropdown to have 2 tokens', () => {
......
......@@ -103,6 +103,19 @@ describe('FilteredSearchBarRoot', () => {
expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Descending');
});
});
describe('filteredRecentSearches', () => {
it('returns array of recent searches filtering out any string type (unsupported) items', async () => {
wrapper.setData({
recentSearches: [{ foo: 'bar' }, 'foo'],
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredRecentSearches).toHaveLength(1);
expect(wrapper.vm.filteredRecentSearches[0]).toEqual({ foo: 'bar' });
});
});
});
describe('watchers', () => {
......
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