Commit f1f5371a authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Added user token for authors and assignees

Adds an additional user token that can be
shared between the authors and assignees
filter keys
parent b6ca6057
......@@ -37,6 +37,9 @@ export default {
value: this.getEscapedText(label.title),
}));
},
hideDefault() {
return this.config?.hideDefaultOptions;
},
},
methods: {
getEscapedText(text) {
......@@ -69,21 +72,23 @@ 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>
<div v-if="!hideDefault">
<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" />
</div>
<gl-filtered-search-suggestion
v-for="label in filteredLabels"
ref="labelItem"
......
......@@ -30,13 +30,16 @@ 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: {
getEscapedText(text) {
getEscapedText(text = null) {
let escapedText = text;
const hasSpace = text.indexOf(' ') !== -1;
const hasDoubleQuote = text.indexOf('"') !== -1;
......@@ -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 createFlash from '~/flash';
import { filterMilestones, filterUsers, 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 },
],
[],
);
......
import mutations from 'ee/analytics/cycle_analytics/store/modules/filters/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/modules/filters/mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { filterMilestones, filterUsers, filterLabels } from '../../../mock_data';
let state = null;
const milestones = filterMilestones.map(convertObjectPropsToCamelCase);
const users = filterUsers.map(convertObjectPropsToCamelCase);
const labels = filterLabels.map(convertObjectPropsToCamelCase);
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 = {
......@@ -56,6 +56,23 @@ describe('MilestoneToken', () => {
});
});
describe('hideDefaultOptions = true', () => {
it('does not render defualt suggestions', () => {
createComponent(
{
config: {
...defaultConfig,
hideDefaultOptions: true,
},
},
{ stubs },
);
const html = wrapper.html();
expect(html).not.toContain('None');
expect(html).not.toContain('Any');
});
});
describe('when no search term is given', () => {
it('renders two label suggestions', () => {
createComponent(null, { stubs });
......
......@@ -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);
});
});
});
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