Commit f6082c02 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '217943-vsa-additional-filter-bar-tokens' into 'master'

VSA filter bar additional tokens

See merge request gitlab-org/gitlab!34536
parents 56aa67fc 1cc19f7a
<script>
import { mapState } from 'vuex';
import { mapState, mapActions } from 'vuex';
import { GlFilteredSearch } from '@gitlab/ui';
import { __ } from '~/locale';
import MilestoneToken from '../../shared/components/tokens/milestone_token.vue';
import LabelToken from '../../shared/components/tokens/label_token.vue';
import UserToken from '../../shared/components/tokens/user_token.vue';
export default {
name: 'FilterBar',
......@@ -16,20 +20,114 @@ export default {
},
data() {
return {
searchTerms: [],
value: [],
};
},
computed: {
...mapState('filters', ['milestonesPath', 'labelsPath']),
...mapState('filters', {
milestones: state => state.milestones.data,
milestonesLoading: state => state.milestones.isLoading,
labels: state => state.labels.data,
labelsLoading: state => state.labels.isLoading,
authors: state => state.authors.data,
authorsLoading: state => state.authors.isLoading,
assignees: state => state.assignees.data,
assigneesLoading: state => state.assignees.isLoading,
}),
availableTokens() {
return [
{
icon: 'clock',
title: __('Milestone'),
type: 'milestone',
token: MilestoneToken,
milestones: this.milestones,
unique: true,
symbol: '%',
isLoading: this.milestonesLoading,
operators: [{ value: '=', description: 'is', default: 'true' }],
},
{
icon: 'labels',
title: __('Label'),
type: 'labels',
token: LabelToken,
labels: this.labels,
unique: false,
symbol: '~',
isLoading: this.labelsLoading,
operators: [{ value: '=', description: 'is', default: 'true' }],
},
{
icon: 'pencil',
title: __('Author'),
type: 'author',
token: UserToken,
users: this.authors,
unique: true,
isLoading: this.authorsLoading,
operators: [{ value: '=', description: 'is', default: 'true' }],
},
{
icon: 'user',
title: __('Assignees'),
type: 'assignees',
token: UserToken,
users: this.assignees,
unique: false,
isLoading: this.assigneesLoading,
operators: [{ value: '=', description: 'is', default: 'true' }],
},
];
},
},
methods: {
...mapActions('filters', ['setFilters']),
processFilters(filters) {
return filters.reduce((acc, token) => {
const { type, value } = token;
const { operator } = value;
let tokenValue = value.data;
// remove wrapping double quotes which were added for token values that include spaces
if (
(tokenValue[0] === "'" && tokenValue[tokenValue.length - 1] === "'") ||
(tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')
) {
tokenValue = tokenValue.slice(1, -1);
}
if (!acc[type]) {
acc[type] = [];
}
acc[type].push({ value: tokenValue, operator });
return acc;
}, {});
},
filteredSearchSubmit(filters) {
const { labels, milestone, author, assignees } = this.processFilters(filters);
this.setFilters({
selectedAuthor: author ? author[0].value : null,
selectedMilestone: milestone ? milestone[0].value : null,
selectedAssignees: assignees ? assignees.map(a => a.value) : [],
selectedLabels: labels ? labels.map(l => l.value) : [],
});
},
},
};
</script>
<template>
<gl-filtered-search
v-model="value"
:disabled="disabled"
:v-model="searchTerms"
:placeholder="__('Filter results')"
:clear-button-title="__('Clear')"
:close-button-title="__('Close')"
:available-tokens="availableTokens"
@submit="filteredSearchSubmit"
/>
</template>
......@@ -234,6 +234,9 @@ export const removeStage = ({ dispatch, state }, stageId) => {
.catch(error => dispatch('receiveRemoveStageError', error));
};
export const setSelectedFilters = ({ commit }, filters) =>
commit(types.SET_SELECTED_FILTERS, filters);
export const initializeCycleAnalyticsSuccess = ({ commit }) =>
commit(types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS);
......
import * as types from './mutation_types';
// eslint-disable-next-line import/prefer-default-export
const appendExtension = path => (path.indexOf('.') > -1 ? path : `${path}.json`);
export const setPaths = ({ commit }, { milestonesPath = '', labelsPath = '' }) => {
commit(types.SET_MILESTONES_PATH, milestonesPath);
commit(types.SET_LABELS_PATH, labelsPath);
commit(types.SET_MILESTONES_PATH, appendExtension(milestonesPath));
commit(types.SET_LABELS_PATH, appendExtension(labelsPath));
};
export const setFilters = ({ dispatch, state }, params) => {
const { selectedLabels: labelNames = [], ...rest } = params;
const {
labels: { data: labelsList = [] },
} = state;
const selectedLabels = labelsList.filter(({ title }) => labelNames.includes(title));
const nextFilters = {
...rest,
selectedLabels,
};
return dispatch('setSelectedFilters', nextFilters, { root: true });
};
export default () => ({
milestonesPath: '',
labelsPath: '',
milestones: {
isLoading: false,
data: [],
},
labels: {
isLoading: false,
data: [],
},
authors: {
isLoading: false,
data: [],
},
assignees: {
isLoading: false,
data: [],
},
});
......@@ -4,6 +4,7 @@ export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
export const SET_SELECTED_PROJECTS = 'SET_SELECTED_PROJECTS';
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const SET_DATE_RANGE = 'SET_DATE_RANGE';
export const SET_SELECTED_FILTERS = 'SET_SELECTED_FILTERS';
export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA';
export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS';
......
......@@ -115,4 +115,5 @@ export default {
state.isSavingStageOrder = false;
state.errorSavingStageOrder = true;
},
[types.SET_SELECTED_FILTERS]() {},
};
......@@ -69,21 +69,21 @@ export default {
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...this.$attrs }" v-on="$listeners">
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<template #view="{ inputValue }">
<template v-if="config.symbol">{{ config.symbol }}</template>
{{ 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-dropdown-divider v-if="config.isLoading || filteredLabels.length" />
<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-dropdown-divider v-if="config.isLoading || filteredLabels.length" />
<gl-filtered-search-suggestion
v-for="label in filteredLabels"
ref="labelItem"
......
......@@ -30,9 +30,12 @@ export default {
return this.config.milestones;
},
filteredMilestones() {
return this.milestones.filter(
milestone => milestone.title.toLowerCase().indexOf(this.value.data?.toLowerCase()) !== -1,
);
return this.value?.data
? this.milestones.filter(
milestone =>
milestone.title.toLowerCase().indexOf(this.value.data?.toLowerCase()) !== -1,
)
: this.milestones;
},
},
methods: {
......@@ -70,7 +73,7 @@ export default {
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...this.$attrs }" v-on="$listeners">
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<template #view="{ inputValue }">
<template v-if="config.symbol">{{ config.symbol }}</template
>{{ inputValue }}
......
<script>
import {
GlAvatar,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
} from '@gitlab/ui';
export default {
components: {
GlAvatar,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
},
inheritAttrs: false,
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
computed: {
users() {
return this.config.users;
},
selectedUser() {
return this.value?.data
? this.config.users.find(({ username }) => username === this.value.data)
: {};
},
},
};
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<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 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>
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlFilteredSearch } from '@gitlab/ui';
import FilterBar from 'ee/analytics/cycle_analytics/components/filter_bar.vue';
import initialFiltersState from 'ee/analytics/cycle_analytics/store/modules/filters/state';
import { filterMilestones, filterLabels } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const milestoneTokenType = 'milestone';
const labelsTokenType = 'labels';
const authorTokenType = 'author';
const assigneesTokenType = 'assignees';
describe('Filter bar', () => {
let wrapper;
let store;
let setFiltersMock;
const createStore = (initialState = {}) => {
setFiltersMock = jest.fn();
return new Vuex.Store({
modules: {
filters: {
namespaced: true,
state: {
...initialFiltersState(),
...initialState,
},
actions: {
setFilters: setFiltersMock,
},
},
},
});
};
const createComponent = initialStore =>
shallowMount(FilterBar, {
localVue,
store: initialStore,
});
afterEach(() => {
wrapper.destroy();
});
const selectedMilestone = [filterMilestones[0]];
const selectedLabel = [filterLabels[0]];
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
const getSearchToken = type =>
findFilteredSearch()
.props('availableTokens')
.filter(token => token.type === type)[0];
it('renders GlFilteredSearch component', () => {
store = createStore();
wrapper = createComponent(store);
expect(findFilteredSearch().exists()).toBe(true);
});
describe('when the state has data', () => {
beforeEach(() => {
store = createStore({
milestones: { data: selectedMilestone },
labels: { data: selectedLabel },
authors: { data: [] },
assignees: { data: [] },
});
wrapper = createComponent(store);
});
it('displays the milestone and label token', () => {
const tokens = findFilteredSearch().props('availableTokens');
expect(tokens).toHaveLength(4);
expect(tokens[0].type).toBe(milestoneTokenType);
expect(tokens[1].type).toBe(labelsTokenType);
expect(tokens[2].type).toBe(authorTokenType);
expect(tokens[3].type).toBe(assigneesTokenType);
});
it('displays options in the milestone token', () => {
const { milestones: milestoneToken } = getSearchToken(milestoneTokenType);
expect(milestoneToken).toHaveLength(selectedMilestone.length);
});
it('displays options in the label token', () => {
const { labels: labelToken } = getSearchToken(labelsTokenType);
expect(labelToken).toHaveLength(selectedLabel.length);
});
});
describe('when the user interacts', () => {
beforeEach(() => {
store = createStore({
milestones: { data: filterMilestones },
labels: { data: filterLabels },
});
wrapper = createComponent(store);
});
it('clicks on the search button, setFilters is dispatched', () => {
findFilteredSearch().vm.$emit('submit', [
{ type: 'milestone', value: { data: selectedMilestone[0].title, operator: '=' } },
{ type: 'labels', value: { data: selectedLabel[0].title, operator: '=' } },
]);
expect(setFiltersMock).toHaveBeenCalledWith(
expect.anything(),
{
selectedLabels: [selectedLabel[0].title],
selectedMilestone: selectedMilestone[0].title,
selectedAssignees: [],
selectedAuthor: null,
},
undefined,
);
});
it('removes wrapping double quotes from the data and dispatches setFilters', () => {
findFilteredSearch().vm.$emit('submit', [
{ type: 'milestone', value: { data: '"milestone with spaces"', operator: '=' } },
]);
expect(setFiltersMock).toHaveBeenCalledWith(
expect.anything(),
{
selectedMilestone: 'milestone with spaces',
selectedLabels: [],
selectedAssignees: [],
selectedAuthor: null,
},
undefined,
);
});
it('removes wrapping single quotes from the data and dispatches setFilters', () => {
findFilteredSearch().vm.$emit('submit', [
{ type: 'milestone', value: { data: "'milestone with spaces'", operator: '=' } },
]);
expect(setFiltersMock).toHaveBeenCalledWith(
expect.anything(),
{
selectedMilestone: 'milestone with spaces',
selectedLabels: [],
selectedAssignees: [],
selectedAuthor: null,
},
undefined,
);
});
it('does not remove inner double quotes from the data and dispatches setFilters ', () => {
findFilteredSearch().vm.$emit('submit', [
{ type: 'milestone', value: { data: 'milestone "with" spaces', operator: '=' } },
]);
expect(setFiltersMock).toHaveBeenCalledWith(
expect.anything(),
{
selectedMilestone: 'milestone "with" spaces',
selectedAssignees: [],
selectedAuthor: null,
selectedLabels: [],
},
undefined,
);
});
});
});
......@@ -281,3 +281,54 @@ export const selectedProjects = [
// Value returned from JSON fixture is 345600 for issue stage which equals 4d
export const pathNavIssueMetric = '4d';
export const filterMilestones = [
{ id: 1, title: 'None', name: 'Any' },
{ id: 101, title: 'Any', name: 'None' },
{ id: 1001, title: 'v1.0', name: 'v1.0' },
{ id: 10101, title: 'v0.0', name: 'v0.0' },
];
export const filterUsers = [
{
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,
},
];
export const filterLabels = [
{ id: 194, title: 'Afterfunc-Phureforge-781', color: '#990000', text_color: '#FFFFFF' },
{ id: 10, title: 'Afternix', color: '#16ecf2', text_color: '#FFFFFF' },
{ id: 176, title: 'Panasync-Pens-266', color: '#990000', text_color: '#FFFFFF' },
{ id: 79, title: 'Passat', color: '#f1a3d4', text_color: '#333333' },
{ id: 197, title: 'Phast-Onesync-395', color: '#990000', text_color: '#FFFFFF' },
];
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from 'ee/analytics/cycle_analytics/store/modules/filters/actions';
import * as types from 'ee/analytics/cycle_analytics/store/modules/filters/mutation_types';
import initialState from 'ee/analytics/cycle_analytics/store/modules/filters/state';
import { filterLabels } from '../../../mock_data';
const milestonesPath = 'fake_milestones_path';
const labelsPath = 'fake_labels_path';
jest.mock('~/flash', () => jest.fn());
describe('Filters actions', () => {
let state;
let mock;
beforeEach(() => {
state = {};
state = initialState();
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('setFilters', () => {
const nextFilters = {
selectedAuthor: 'Mr cool',
selectedMilestone: 'NEXT',
};
it('dispatches the root/setSelectedFilters action', () => {
return testAction(
actions.setFilters,
nextFilters,
state,
[],
[
{
type: 'setSelectedFilters',
payload: {
...nextFilters,
selectedLabels: [],
},
},
],
);
});
it('sets the selectedLabels from the labels available', () => {
return testAction(
actions.setFilters,
{ ...nextFilters, selectedLabels: [filterLabels[1].title] },
{ ...state, labels: { data: filterLabels } },
[],
[
{
type: 'setSelectedFilters',
payload: {
...nextFilters,
selectedLabels: [filterLabels[1]],
},
},
],
);
});
});
describe('setPaths', () => {
it('dispatches error', () => {
it('sets the api paths and dispatches requests for initial data', () => {
return testAction(
actions.setPaths,
{
milestonesPath: 'milestones_path',
labelsPath: 'labels_path',
},
{ milestonesPath, labelsPath },
state,
[
{ payload: 'milestones_path', type: types.SET_MILESTONES_PATH },
{ payload: 'labels_path', type: types.SET_LABELS_PATH },
{ payload: 'fake_milestones_path.json', type: types.SET_MILESTONES_PATH },
{ payload: 'fake_labels_path.json', type: types.SET_LABELS_PATH },
],
[],
);
......
......@@ -5,7 +5,7 @@ let state = null;
describe('Filters mutations', () => {
beforeEach(() => {
state = {};
state = { initialTokens: {}, milestones: {}, authors: {}, labels: {}, assignees: {} };
});
afterEach(() => {
......
......@@ -3,7 +3,7 @@ import { GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import LabelToken from 'ee/analytics/shared/components/tokens/label_token.vue';
import { mockLabels } from './mock_data';
describe('MilestoneToken', () => {
describe('LabelToken', () => {
let wrapper;
const defaultValue = { data: '' };
const defaultConfig = {
......
......@@ -33,3 +33,39 @@ 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 } 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 createComponent = (props = {}, options) => {
wrapper = shallowMount(UserToken, {
propsData: props,
...options,
});
};
const findFilteredSearchSuggestion = index =>
wrapper.findAll(GlFilteredSearchSuggestion).at(index);
const findAllUserSuggestions = () => wrapper.findAll('[data-testid="user-item"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
value = { data: '' };
config = {
users: mockUsers,
isLoading: false,
};
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);
});
});
});
......@@ -3002,6 +3002,9 @@ msgstr ""
msgid "Assignee(s)"
msgstr ""
msgid "Assignees"
msgstr ""
msgid "Assigns %{assignee_users_sentence}."
msgstr ""
......
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