Commit 291f1e31 authored by Michael Lunøe's avatar Michael Lunøe Committed by Phil Hughes

Feat(Code Review Analytics): add url filter params

Add url-sync to handle url filter params, so the
user can deep link to a specific filter creating
a consistent experience for the user

In the process it converts to use the general
filter tokens
parent 81ff89a6
import Vue from 'vue';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import store from './store';
import CodeAnalyticsApp from './components/app.vue';
......@@ -14,6 +15,17 @@ export default () => {
} = container.dataset;
if (!container) return;
store.dispatch('filters/setEndpoints', {
milestonesEndpoint: milestonePath,
labelsEndpoint: labelsPath,
projectEndpoint: projectPath,
});
const { milestone_title = null, label_name = [] } = urlQueryToFilter(window.location.search);
store.dispatch('filters/initialize', {
selectedMilestone: milestone_title,
selectedLabelList: label_name,
});
// eslint-disable-next-line no-new
new Vue({
el: container,
......@@ -25,8 +37,6 @@ export default () => {
projectPath,
newMergeRequestUrl,
emptyStateSvgPath,
milestonePath,
labelsPath,
},
});
},
......
......@@ -33,14 +33,6 @@ export default {
type: String,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
},
},
computed: {
...mapState('mergeRequests', {
......@@ -66,18 +58,12 @@ export default {
if (!this.codeReviewAnalyticsHasNewSearch) {
this.filterManager = new FilteredSearchCodeReviewAnalytics();
this.filterManager.setup();
} else {
this.setEndpoints({
milestonesEndpoint: this.milestonePath,
labelsEndpoint: this.labelsPath,
});
}
this.setProjectId(this.projectId);
this.fetchMergeRequests();
},
methods: {
...mapActions('filters', ['setEndpoints']),
...mapActions('mergeRequests', ['setProjectId', 'fetchMergeRequests', 'setPage']),
},
};
......
<script>
import { mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import MilestoneToken from '../../shared/components/tokens/milestone_token.vue';
import LabelToken from '../../shared/components/tokens/label_token.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { processFilters } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import {
prepareTokens,
processFilters,
filterToQueryObject,
} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import {
DEFAULT_LABEL_NONE,
DEFAULT_LABEL_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants';
export default {
components: {
FilteredSearchBar,
UrlSync,
},
props: {
projectPath: {
......@@ -16,15 +26,8 @@ export default {
required: true,
},
},
data() {
return {
initialFilterValue: [],
};
},
computed: {
...mapState('filters', {
milestonesLoading: state => state.milestones.isLoading,
labelsLoading: state => state.labels.isLoading,
selectedMilestone: state => state.milestones.selected,
selectedLabelList: state => state.labels.selectedList,
milestonesData: state => state.milestones.data,
......@@ -37,29 +40,36 @@ export default {
title: __('Milestone'),
type: 'milestone',
token: MilestoneToken,
milestones: this.milestonesData,
initialMilestones: this.milestonesData,
unique: true,
symbol: '%',
isLoading: this.milestonesLoading,
fetchData: this.fetchMilestones,
fetchMilestones: this.fetchMilestones,
},
{
icon: 'labels',
title: __('Label'),
type: 'labels',
token: LabelToken,
labels: this.labelsData,
defaultLabels: [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY],
initialLabels: this.labelsData,
unique: false,
symbol: '~',
isLoading: this.labelsLoading,
fetchData: this.fetchLabels,
fetchLabels: this.fetchLabels,
},
];
},
},
created() {
this.fetchMilestones();
this.fetchLabels();
query() {
return filterToQueryObject({
milestone_title: this.selectedMilestone,
label_name: this.selectedLabelList,
});
},
initialFilterValue() {
return prepareTokens({
milestone: this.selectedMilestone,
labels: this.selectedLabelList,
});
},
},
methods: {
...mapActions('filters', ['setFilters', 'fetchMilestones', 'fetchLabels']),
......@@ -68,7 +78,7 @@ export default {
this.setFilters({
selectedMilestone: milestone ? milestone[0] : null,
selectedLabelList: labels,
selectedLabelList: labels || [],
});
},
},
......@@ -76,13 +86,16 @@ export default {
</script>
<template>
<filtered-search-bar
:namespace="projectPath"
recent-searches-storage-key="code-review-analytics"
:search-input-placeholder="__('Filter results')"
:tokens="tokens"
:initial-filter-value="initialFilterValue"
class="row-content-block"
@onFilter="handleFilter"
/>
<div>
<filtered-search-bar
class="gl-flex-grow-1 row-content-block"
:namespace="projectPath"
recent-searches-storage-key="code-review-analytics"
:search-input-placeholder="__('Filter results')"
:tokens="tokens"
:initial-filter-value="initialFilterValue"
@onFilter="handleFilter"
/>
<url-sync :query="query" />
</div>
</template>
<script>
import {
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDeprecatedDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
export default {
components: {
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDeprecatedDropdownDivider,
GlLoadingIcon,
},
inheritAttrs: false,
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
active: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
activeLabel: null,
};
},
computed: {
labels() {
return this.config.labels;
},
containerStyle() {
if (this.activeLabel) {
const { color, text_color } = this.activeLabel;
return { backgroundColor: color, color: text_color };
}
return {};
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && this.labels) {
this.activeLabel = this.labels.find(l => l.title === this.value?.data);
}
},
},
},
created() {
this.searchLabels(this.value);
},
methods: {
searchLabels: debounce(function debouncedSearch({ data = '' }) {
this.config.fetchData(data);
}, DEBOUNCE_DELAY),
},
defaultSuggestions: [
// eslint-disable-next-line @gitlab/require-i18n-strings
{ value: 'None', text: __('None') },
// eslint-disable-next-line @gitlab/require-i18n-strings
{ value: 'Any', text: __('Any') },
],
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchLabels"
>
<template #view-token="{ inputValue, cssClasses, listeners }">
<gl-token
variant="search-value"
:class="cssClasses"
:style="containerStyle"
data-testid="selected-label"
v-on="listeners"
>
<template v-if="config.symbol">{{ config.symbol }}</template>
{{ activeLabel ? activeLabel.title : inputValue }}
</gl-token>
</template>
<template #suggestions>
<gl-loading-icon v-if="config.isLoading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="suggestion in $options.defaultSuggestions"
:key="suggestion.value"
:value="suggestion.value"
>{{ suggestion.text }}</gl-filtered-search-suggestion
>
<gl-deprecated-dropdown-divider v-if="config.isLoading || labels.length" />
<gl-filtered-search-suggestion
v-for="label in labels"
ref="labelItem"
:key="label.id"
:value="label.title"
>
<div class="d-flex">
<span
class="d-inline-block mr-2 gl-w-16 gl-h-16 border-radius-small"
:style="{
backgroundColor: label.color,
}"
></span>
<span>{{ label.title }}</span>
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
<script>
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDeprecatedDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDeprecatedDropdownDivider,
GlLoadingIcon,
},
inheritAttrs: false,
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
computed: {
milestones() {
return this.config.milestones;
},
},
created() {
this.searchMilestones(this.value);
},
methods: {
getEscapedText(text) {
let escapedText = text;
const hasSpace = text.indexOf(' ') !== -1;
const hasDoubleQuote = text.indexOf('"') !== -1;
// Encapsulate value with quotes if it has spaces
// Known side effect: values's with both single and double quotes
// won't escape properly
if (hasSpace) {
if (hasDoubleQuote) {
escapedText = `'${text}'`;
} else {
// Encapsulate singleQuotes or if it hasSpace
escapedText = `"${text}"`;
}
}
return escapedText;
},
searchMilestones: debounce(function debouncedSearch({ data = '' }) {
this.config.fetchData(data);
}, DEBOUNCE_DELAY),
},
defaultSuggestions: [
// eslint-disable-next-line @gitlab/require-i18n-strings
{ value: 'None', text: __('None') },
// eslint-disable-next-line @gitlab/require-i18n-strings
{ value: 'Any', text: __('Any') },
// eslint-disable-next-line @gitlab/require-i18n-strings
{ value: 'Upcoming', text: __('Upcoming') },
// eslint-disable-next-line @gitlab/require-i18n-strings
{ value: 'Started', text: __('Started') },
],
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchMilestones"
>
<template #view="{ inputValue }">
<template v-if="config.symbol">{{ config.symbol }}</template
>{{ inputValue }}
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="suggestion in $options.defaultSuggestions"
:key="suggestion.value"
:value="suggestion.value"
>{{ suggestion.text }}</gl-filtered-search-suggestion
>
<gl-deprecated-dropdown-divider v-if="config.isLoading || milestones.length" />
<gl-loading-icon v-if="config.isLoading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="milestone in milestones"
ref="milestoneItem"
:key="milestone.id"
:value="getEscapedText(milestone.title)"
>
<div>{{ milestone.title }}</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
<script>
import {
GlAvatar,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
export default {
components: {
GlAvatar,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
},
inheritAttrs: false,
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
computed: {
selectedUser() {
return this.value?.data
? this.config.users.find(({ username }) => username === this.value.data)
: {};
},
},
created() {
this.searchUsers(this.value);
},
methods: {
searchUsers: debounce(function debouncedSearch({ data = '' }) {
this.config.fetchData(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchUsers"
>
<template #view="{ inputValue }">
<div v-if="selectedUser" data-testid="selected-user">
<gl-avatar :size="16" :src="selectedUser.avatar_url" />
<span>{{ inputValue }}</span>
</div>
</template>
<template #suggestions>
<gl-loading-icon v-if="config.isLoading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="user in config.users"
:key="user.username"
:value="user.username"
data-testid="user-item"
>
<div class="d-flex">
<gl-avatar :size="32" :src="user.avatar_url" />
<div>
<div>{{ user.name }}</div>
<div>@{{ user.username }}</div>
</div>
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -25,7 +25,6 @@ describe('CodeReviewAnalyticsApp component', () => {
let setPage;
let fetchMergeRequests;
let setEndpoints;
const pageInfo = {
page: 1,
......@@ -59,9 +58,6 @@ describe('CodeReviewAnalyticsApp component', () => {
...createFiltersState(),
...initialState.filters,
},
actions: {
setEndpoints,
},
},
},
});
......@@ -88,7 +84,6 @@ describe('CodeReviewAnalyticsApp component', () => {
beforeEach(() => {
setPage = jest.fn();
fetchMergeRequests = jest.fn();
setEndpoints = jest.fn();
});
afterEach(() => {
......@@ -116,10 +111,6 @@ describe('CodeReviewAnalyticsApp component', () => {
it("calls the filterManager's setup method", () => {
expect(mockFilterManagerSetup).toHaveBeenCalled();
});
it('does not call setEndpoints action', () => {
expect(setEndpoints).not.toHaveBeenCalled();
});
});
describe('when "codeReviewAnalyticsHasNewSearch" is enabled', () => {
......@@ -136,10 +127,6 @@ describe('CodeReviewAnalyticsApp component', () => {
it("does not call the filterManager's setup method", () => {
expect(mockFilterManagerSetup).not.toHaveBeenCalled();
});
it('calls setEndpoints action', () => {
expect(setEndpoints).toHaveBeenCalled();
});
});
});
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import storeConfig from 'ee/analytics/code_review_analytics/store';
import FilterBar from 'ee/analytics/code_review_analytics/components/filter_bar.vue';
import createFiltersState from 'ee/analytics/shared/store/modules/filters/state';
import initialFiltersState from 'ee/analytics/shared/store/modules/filters/state';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import * as utils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { mockMilestones, mockLabels } from '../mock_data';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { filterMilestones, filterLabels } from '../../shared/store/modules/filters/mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import * as urlUtils from '~/lib/utils/url_utility';
const localVue = createLocalVue();
localVue.use(Vuex);
const milestoneTokenType = 'milestone';
const labelTokenType = 'labels';
const labelsTokenType = 'labels';
const initialFilterBarState = {
selectedMilestone: null,
selectedLabelList: null,
};
const defaultParams = {
milestone_title: null,
'not[milestone_title]': null,
label_name: null,
'not[label_name]': null,
};
async function shouldMergeUrlParams(wrapper, result) {
await wrapper.vm.$nextTick();
expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(result, window.location.href, {
spreadArrays: true,
});
expect(commonUtils.historyPushState).toHaveBeenCalled();
}
function getFilterParams(tokens, operator, key = 'value') {
return tokens.map(token => {
return { [key]: token.title, operator };
});
}
function getFilterValues(tokens) {
return tokens.map(token => token.title);
}
describe('FilteredSearchBar', () => {
const selectedMilestoneParams = getFilterParams(filterMilestones, '=');
const unselectedMilestoneParams = getFilterParams(filterMilestones, '!=');
const selectedLabelParams = getFilterParams(filterLabels, '=');
const unselectedLabelParams = getFilterParams(filterLabels, '!=');
const milestoneValues = getFilterValues(filterMilestones);
const labelValues = getFilterValues(filterLabels);
describe('Filter bar', () => {
let wrapper;
let vuexStore;
let mock;
let setFiltersMock;
......@@ -26,12 +71,10 @@ describe('FilteredSearchBar', () => {
filters: {
namespaced: true,
state: {
...createFiltersState(),
...initialFiltersState(),
...initialState,
},
actions: {
fetchMilestones: jest.fn(),
fetchLabels: jest.fn(),
setFilters: setFiltersMock,
},
},
......@@ -39,17 +82,26 @@ describe('FilteredSearchBar', () => {
});
};
const createComponent = store =>
shallowMount(FilterBar, {
function createComponent(initialStore) {
return shallowMount(FilterBar, {
localVue,
store,
store: initialStore,
propsData: {
projectPath: 'foo',
},
stubs: {
UrlSync,
},
});
}
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
const findFilteredSearch = () => wrapper.find(FilteredSearchBar);
......@@ -58,18 +110,22 @@ describe('FilteredSearchBar', () => {
.props('tokens')
.find(token => token.type === type);
it('renders FilteredSearchBar component', () => {
vuexStore = createStore();
wrapper = createComponent(vuexStore);
describe('default', () => {
beforeEach(() => {
vuexStore = createStore();
wrapper = createComponent(vuexStore);
});
expect(findFilteredSearch().exists()).toBe(true);
it('renders FilteredSearchBar component', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
});
describe('when the state has data', () => {
beforeEach(() => {
vuexStore = createStore({
milestones: { data: mockMilestones },
labels: { data: mockLabels },
milestones: { data: filterMilestones },
labels: { data: filterLabels },
});
wrapper = createComponent(vuexStore);
});
......@@ -79,27 +135,27 @@ describe('FilteredSearchBar', () => {
expect(tokens).toHaveLength(2);
expect(tokens[0].type).toBe(milestoneTokenType);
expect(tokens[1].type).toBe(labelTokenType);
expect(tokens[1].type).toBe(labelsTokenType);
});
it('displays options in the milestone token', () => {
const { milestones: milestoneToken } = getSearchToken(milestoneTokenType);
it('provides the initial milestone token', () => {
const { initialMilestones: milestoneToken } = getSearchToken(milestoneTokenType);
expect(milestoneToken).toHaveLength(mockMilestones.length);
expect(milestoneToken).toHaveLength(filterMilestones.length);
});
it('displays options in the label token', () => {
const { labels: labelToken } = getSearchToken(labelTokenType);
it('provides the initial label token', () => {
const { initialLabels: labelToken } = getSearchToken(labelsTokenType);
expect(labelToken).toHaveLength(mockLabels.length);
expect(labelToken).toHaveLength(filterLabels.length);
});
});
describe('when the user interacts', () => {
beforeEach(() => {
vuexStore = createStore({
milestones: { data: mockMilestones },
labels: { data: mockLabels },
milestones: { data: filterMilestones },
labels: { data: filterLabels },
});
wrapper = createComponent(vuexStore);
jest.spyOn(utils, 'processFilters');
......@@ -107,8 +163,9 @@ describe('FilteredSearchBar', () => {
it('clicks on the search button, setFilters is dispatched', () => {
const filters = [
{ type: 'milestone', value: { data: 'my-milestone', operator: '=' } },
{ type: 'labels', value: { data: 'my-label', operator: '=' } },
{ type: 'milestone', value: getFilterParams(filterMilestones, '=', 'data')[2] },
{ type: 'labels', value: getFilterParams(filterLabels, '=', 'data')[2] },
{ type: 'labels', value: getFilterParams(filterLabels, '!=', 'data')[4] },
];
findFilteredSearch().vm.$emit('onFilter', filters);
......@@ -116,9 +173,39 @@ describe('FilteredSearchBar', () => {
expect(utils.processFilters).toHaveBeenCalledWith(filters);
expect(setFiltersMock).toHaveBeenCalledWith(expect.anything(), {
selectedLabelList: [{ value: 'my-label', operator: '=' }],
selectedMilestone: { value: 'my-milestone', operator: '=' },
selectedMilestone: selectedMilestoneParams[2],
selectedLabelList: [selectedLabelParams[2], unselectedLabelParams[4]],
});
});
});
describe.each`
stateKey | payload | paramKey | value
${'selectedMilestone'} | ${selectedMilestoneParams[3]} | ${'milestone_title'} | ${milestoneValues[3]}
${'selectedMilestone'} | ${unselectedMilestoneParams[0]} | ${'not[milestone_title]'} | ${milestoneValues[0]}
${'selectedLabelList'} | ${selectedLabelParams} | ${'label_name'} | ${labelValues}
${'selectedLabelList'} | ${unselectedLabelParams} | ${'not[label_name]'} | ${labelValues}
`(
'with a $stateKey updates the $paramKey url parameter',
({ stateKey, payload, paramKey, value }) => {
beforeEach(() => {
commonUtils.historyPushState = jest.fn();
urlUtils.mergeUrlParams = jest.fn();
mock = new MockAdapter(axios);
wrapper = createComponent(storeConfig);
wrapper.vm.$store.dispatch('filters/setFilters', {
...initialFilterBarState,
[stateKey]: payload,
});
});
it(`sets the ${paramKey} url parameter`, async () => {
await shouldMergeUrlParams(wrapper, {
...defaultParams,
[paramKey]: value,
});
});
},
);
});
......@@ -689,8 +689,8 @@ describe('Cycle Analytics component', () => {
wrapper.vm.$store.dispatch('initializeCycleAnalytics', initialCycleAnalyticsState);
});
it('sets the created_after and created_before url parameters', () => {
return shouldMergeUrlParams(wrapper, defaultParams);
it('sets the created_after and created_before url parameters', async () => {
await shouldMergeUrlParams(wrapper, defaultParams);
});
describe('with hideGroupDropDown=true', () => {
......@@ -712,8 +712,8 @@ describe('Cycle Analytics component', () => {
});
});
it('sets the group_id url parameter', () => {
return shouldMergeUrlParams(wrapper, {
it('sets the group_id url parameter', async () => {
await shouldMergeUrlParams(wrapper, {
...defaultParams,
created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate),
......@@ -729,8 +729,8 @@ describe('Cycle Analytics component', () => {
});
});
it('sets the group_id url parameter', () => {
return shouldMergeUrlParams(wrapper, {
it('sets the group_id url parameter', async () => {
await shouldMergeUrlParams(wrapper, {
...defaultParams,
group_id: fakeGroup.fullPath,
});
......@@ -747,8 +747,8 @@ describe('Cycle Analytics component', () => {
return wrapper.vm.$nextTick();
});
it('sets the project_ids url parameter', () => {
return shouldMergeUrlParams(wrapper, {
it('sets the project_ids url parameter', async () => {
await shouldMergeUrlParams(wrapper, {
...defaultParams,
created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate),
......
import { shallowMount } from '@vue/test-utils';
import { GlFilteredSearchSuggestion, GlLoadingIcon, GlFilteredSearchToken } from '@gitlab/ui';
import LabelToken from 'ee/analytics/shared/components/tokens/label_token.vue';
import { mockLabels } from './mock_data';
describe('LabelToken', () => {
let wrapper;
const defaultValue = { data: '' };
const defaultConfig = {
icon: 'labels',
title: 'Label',
type: 'label',
labels: mockLabels,
unique: false,
symbol: '~',
isLoading: false,
fetchData: jest.fn(),
};
const stubs = {
GlFilteredSearchToken: {
template: `<div><slot name="view-token"></slot><slot name="suggestions"></slot></div>`,
},
};
const createComponent = (props = {}, options = { stubs }) => {
wrapper = shallowMount(LabelToken, {
propsData: {
config: defaultConfig,
value: defaultValue,
...props,
},
...options,
});
};
const findFilteredSearchSuggestion = index =>
wrapper.findAll(GlFilteredSearchSuggestion).at(index);
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllLabelSuggestions = () => wrapper.findAll({ ref: 'labelItem' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findSelectedLabelToken = () => wrapper.find('[data-testid="selected-label"]');
it('renders a loading icon', () => {
createComponent({ config: { ...defaultConfig, isLoading: true }, value: {} }, { stubs });
expect(findLoadingIcon().exists()).toBe(true);
});
it('renders the selected label', () => {
const selectedLabel = mockLabels[0];
createComponent({ value: { data: selectedLabel.title } });
expect(findSelectedLabelToken().text()).toContain(selectedLabel.title);
});
it("sets the label's background and text color on the gl-token component", () => {
const selectedLabel = mockLabels[0];
createComponent({ value: { data: selectedLabel.title } });
expect(findSelectedLabelToken().attributes('style')).toEqual(
'background-color: rgb(98, 53, 242); color: rgb(255, 255, 255);',
);
});
describe('suggestions', () => {
it('renders a suggestion for each item', () => {
createComponent();
const res = findAllLabelSuggestions();
expect(res).toHaveLength(mockLabels.length);
mockLabels.forEach((m, index) => {
expect(res.at(index).html()).toContain(m.title);
});
});
describe('default suggestions', () => {
it.each`
text | dropdownIndex
${'None'} | ${0}
${'Any'} | ${1}
`('renders the "$text" suggestion', ({ text, dropdownIndex }) => {
createComponent(null);
expect(findFilteredSearchSuggestion(dropdownIndex).text()).toEqual(text);
});
});
});
describe('search', () => {
describe('when no search term is given', () => {
it('calls `fetchData` with an empty search term', () => {
createComponent({
value: defaultValue,
});
expect(defaultConfig.fetchData).toHaveBeenCalledWith('');
});
});
describe('when the search term "Peaches castle" is given', () => {
const data = "Peach's castle";
it('calls `fetchData` with the search term', () => {
createComponent({ value: { data } });
expect(defaultConfig.fetchData).toHaveBeenCalledWith(data);
});
});
describe('when the input changes', () => {
const data = 'Moo moo farm';
it('calls `fetchData` with the updated search term', () => {
createComponent({ value: defaultValue }, { stubs: { GlFilteredSearchToken } });
expect(defaultConfig.fetchData).not.toHaveBeenCalledWith(data);
findFilteredSearchToken().vm.$emit('input', { data });
expect(defaultConfig.fetchData).toHaveBeenCalledWith(data);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlFilteredSearchSuggestion, GlLoadingIcon, GlFilteredSearchToken } from '@gitlab/ui';
import MilestoneToken from 'ee/analytics/shared/components/tokens/milestone_token.vue';
import { mockMilestones } from './mock_data';
describe('MilestoneToken', () => {
let wrapper;
let value;
let config;
let stubs;
const createComponent = (props = {}, options) => {
wrapper = shallowMount(MilestoneToken, {
propsData: props,
...options,
});
};
const findFilteredSearchSuggestion = index =>
wrapper.findAll(GlFilteredSearchSuggestion).at(index);
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllMilestoneSuggestions = () => wrapper.findAll({ ref: 'milestoneItem' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
value = { data: '' };
config = {
icon: 'clock',
title: 'Milestone',
type: 'milestone',
milestones: mockMilestones,
unique: true,
symbol: '%',
isLoading: false,
fetchData: jest.fn(),
};
stubs = {
GlFilteredSearchToken: {
template: `<div><slot name="suggestions"></slot></div>`,
},
};
});
it('renders a loading icon', () => {
config.isLoading = true;
createComponent({ config, value: {} }, { stubs });
expect(findLoadingIcon().exists()).toBe(true);
});
describe('suggestions', () => {
describe('default suggestions', () => {
it.each`
text | dropdownIndex
${'None'} | ${0}
${'Any'} | ${1}
${'Upcoming'} | ${2}
${'Started'} | ${3}
`('renders the "$text" suggestion', ({ text, dropdownIndex }) => {
createComponent({ config, value }, { stubs });
expect(findFilteredSearchSuggestion(dropdownIndex).text()).toEqual(text);
});
});
it("adds wrapping quotes to the suggestion's value when the milestone title has spaces", () => {
createComponent({ config, value }, { stubs });
const milestoneWithSpaces = findAllMilestoneSuggestions().at(0);
expect(milestoneWithSpaces.props('value')).toBe(
'"Sprint - Eligendi et aut pariatur ab rerum vel."',
);
});
it('renders a suggestion for each item', () => {
createComponent({ config, value }, { stubs });
const res = findAllMilestoneSuggestions();
expect(res).toHaveLength(mockMilestones.length);
mockMilestones.forEach((m, index) => {
expect(res.at(index).html()).toContain(m.title);
});
});
});
describe('search', () => {
describe('when no search term is given', () => {
it('calls `fetchData` with an empty search term', () => {
createComponent({ config, value }, { stubs });
expect(config.fetchData).toHaveBeenCalledWith('');
});
});
describe('when the search term "v4" is given', () => {
const query = 'v4';
it('calls `fetchData` with the search term', () => {
value.data = query;
createComponent({ config, value }, { stubs });
expect(config.fetchData).toHaveBeenCalledWith(query);
});
});
describe('when the input changes', () => {
const data = 'v4';
it('calls `fetchData` with the updated search term', () => {
createComponent({ config, value }, { stubs: { GlFilteredSearchToken } });
expect(config.fetchData).not.toHaveBeenCalledWith(data);
findFilteredSearchToken().vm.$emit('input', { data });
expect(config.fetchData).toHaveBeenCalledWith(data);
});
});
});
});
export const mockMilestones = [
{
id: 41,
title: 'Sprint - Eligendi et aut pariatur ab rerum vel.',
project_id: 1,
description: 'Accusamus qui sapiente porro et in voluptates.',
due_date: '2020-01-14',
created_at: '2020-01-08T15:47:37.697Z',
updated_at: '2020-01-08T15:47:37.697Z',
state: 'active',
iid: 6,
start_date: '2020-01-08',
group_id: null,
name: 'Sprint - Eligendi et aut pariatur ab rerum vel.',
},
{
id: 5,
title: 'v4.0',
project_id: 1,
description: 'Atque laudantium reiciendis consequatur temporibus qui qui.',
due_date: null,
created_at: '2020-01-18T15:46:07.448Z',
updated_at: '2020-01-18T15:46:07.448Z',
state: 'active',
iid: 5,
start_date: null,
group_id: null,
name: 'v4.0',
},
];
export const mockLabels = [
{ id: 74, title: 'Alero', color: '#6235f2', text_color: '#FFFFFF' },
{ id: 9, title: 'Amsche', color: '#581cc8', text_color: '#FFFFFF' },
];
export const mockUsers = [
{
id: 31,
name: 'VSM User2',
username: 'vsm-user-2-1589776313',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/762398957a8c6e04eed16da88098899d?s=80\u0026d=identicon',
web_url: 'http://127.0.0.1:3001/vsm-user-2-1589776313',
access_level: 30,
expires_at: null,
},
{
id: 32,
name: 'VSM User3',
username: 'vsm-user-3-1589776313',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/f78932237e8a5c5376b65a709824802f?s=80\u0026d=identicon',
web_url: 'http://127.0.0.1:3001/vsm-user-3-1589776313',
access_level: 30,
expires_at: null,
},
{
id: 33,
name: 'VSM User4',
username: 'vsm-user-4-1589776313',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/ab506dc600d1a941e4d77d5ceeeba73f?s=80\u0026d=identicon',
web_url: 'http://127.0.0.1:3001/vsm-user-4-1589776313',
access_level: 30,
expires_at: null,
},
];
import { shallowMount } from '@vue/test-utils';
import { GlFilteredSearchSuggestion, GlLoadingIcon, GlFilteredSearchToken } from '@gitlab/ui';
import UserToken from 'ee/analytics/shared/components/tokens/user_token.vue';
import { mockUsers } from './mock_data';
describe('UserToken', () => {
let wrapper;
let value;
let config;
let stubs;
const defaultValue = { data: '' };
const createComponent = (props = {}, options) => {
wrapper = shallowMount(UserToken, {
propsData: props,
...options,
});
};
const findFilteredSearchSuggestion = index =>
wrapper.findAll(GlFilteredSearchSuggestion).at(index);
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllUserSuggestions = () => wrapper.findAll('[data-testid="user-item"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
value = defaultValue;
config = {
users: mockUsers,
isLoading: false,
fetchData: jest.fn(),
};
stubs = {
GlFilteredSearchToken: {
template: `<div><slot name="view"></slot><slot name="suggestions"></slot></div>`,
},
};
});
it('renders a loading icon', () => {
config.isLoading = true;
createComponent({ config, value: {} }, { stubs });
expect(findLoadingIcon().exists()).toBe(true);
});
it('renders the selected user', () => {
const selectedUser = mockUsers[1];
createComponent(
{
config,
value: {
data: selectedUser.username,
},
},
{ stubs },
);
const avatar = wrapper.find('[data-testid="selected-user"]').find('gl-avatar-stub');
expect(avatar.props('src')).toBe(selectedUser.avatar_url);
});
describe('suggestions', () => {
it('renders the username and user name for each user', () => {
createComponent({ config, value }, { stubs });
mockUsers.forEach((user, index) => {
const text = `${user.name} @${user.username}`;
expect(findFilteredSearchSuggestion(index).text()).toEqual(text);
});
});
it('renders all user suggestions', () => {
createComponent({ config, value }, { stubs });
expect(findAllUserSuggestions()).toHaveLength(3);
});
});
describe('search', () => {
describe('when no search term is given', () => {
it('calls `fetchData` with an empty search term', () => {
createComponent(
{
config,
value,
},
{ stubs },
);
expect(config.fetchData).toHaveBeenCalledWith('');
});
});
describe('when the search term "Diddy Kong" is given', () => {
const data = 'Diddy Kong';
it('calls `fetchData` with the search term', () => {
createComponent({ config, value: { data } }, { stubs });
expect(config.fetchData).toHaveBeenCalledWith(data);
});
});
describe('when the input changes', () => {
const data = 'Donkey Kong';
it('calls `fetchData` with the updated search term', () => {
createComponent({ config, value: defaultValue }, { stubs: { GlFilteredSearchToken } });
expect(config.fetchData).not.toHaveBeenCalledWith(data);
findFilteredSearchToken().vm.$emit('input', { data });
expect(config.fetchData).toHaveBeenCalledWith(data);
});
});
});
});
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