Commit a277a89b authored by Illya Klymov's avatar Illya Klymov

Merge branch 'mw-cr-label-token' into 'master'

Code Review Analytics: Add label token for GlFilteredSearch

See merge request gitlab-org/gitlab!30706
parents 30da6da0 27a0aada
...@@ -3,6 +3,7 @@ import { mapState, mapActions } from 'vuex'; ...@@ -3,6 +3,7 @@ import { mapState, mapActions } from 'vuex';
import { GlFilteredSearch } from '@gitlab/ui'; import { GlFilteredSearch } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import MilestoneToken from '../../shared/components/tokens/milestone_token.vue'; import MilestoneToken from '../../shared/components/tokens/milestone_token.vue';
import LabelToken from '../../shared/components/tokens/label_token.vue';
export default { export default {
components: { components: {
...@@ -19,6 +20,8 @@ export default { ...@@ -19,6 +20,8 @@ export default {
labelsPath: 'labelsPath', labelsPath: 'labelsPath',
milestones: state => state.milestones.data, milestones: state => state.milestones.data,
milestonesLoading: state => state.milestones.isLoading, milestonesLoading: state => state.milestones.isLoading,
labels: state => state.labels.data,
labelsLoading: state => state.labels.isLoading,
}), }),
tokens() { tokens() {
return [ return [
...@@ -32,14 +35,25 @@ export default { ...@@ -32,14 +35,25 @@ export default {
symbol: '%', symbol: '%',
isLoading: this.milestonesLoading, isLoading: this.milestonesLoading,
}, },
{
icon: 'labels',
title: __('Label'),
type: 'label',
token: LabelToken,
labels: this.labels,
unique: false,
symbol: '~',
isLoading: this.labelsLoading,
},
]; ];
}, },
}, },
created() { created() {
this.fetchMilestones(); this.fetchMilestones();
this.fetchLabels();
}, },
methods: { methods: {
...mapActions('filters', ['fetchMilestones', 'setFilters']), ...mapActions('filters', ['fetchMilestones', 'fetchLabels', 'setFilters']),
processFilters(filters) { processFilters(filters) {
return filters.reduce((acc, token) => { return filters.reduce((acc, token) => {
const { type, value } = token; const { type, value } = token;
......
<script>
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
inheritAttrs: false,
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
computed: {
labels() {
return this.config.labels;
},
filteredLabels() {
return this.labels
.filter(label => label.title.toLowerCase().indexOf(this.value.data?.toLowerCase()) !== -1)
.map(label => ({
...label,
value: this.getEscapedText(label.title),
}));
},
},
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;
},
},
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="{ ...this.$attrs }" v-on="$listeners">
<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-dropdown-divider v-if="config.isLoading || filteredLabels.length" />
<gl-loading-icon v-if="config.isLoading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="label in filteredLabels"
ref="labelItem"
:key="label.id"
:value="label.value"
>
<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>
...@@ -3,12 +3,13 @@ import Vuex from 'vuex'; ...@@ -3,12 +3,13 @@ import Vuex from 'vuex';
import { GlFilteredSearch } from '@gitlab/ui'; import { GlFilteredSearch } from '@gitlab/ui';
import FilterBar from 'ee/analytics/code_review_analytics/components/filter_bar.vue'; import FilterBar from 'ee/analytics/code_review_analytics/components/filter_bar.vue';
import createFiltersState from 'ee/analytics/code_review_analytics/store/modules/filters/state'; import createFiltersState from 'ee/analytics/code_review_analytics/store/modules/filters/state';
import { mockMilestones } from '../mock_data'; import { mockMilestones, mockLabels } from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
const milestoneTokenType = 'milestone'; const milestoneTokenType = 'milestone';
const labelTokenType = 'label';
describe('FilteredSearchBar', () => { describe('FilteredSearchBar', () => {
let wrapper; let wrapper;
...@@ -29,6 +30,7 @@ describe('FilteredSearchBar', () => { ...@@ -29,6 +30,7 @@ describe('FilteredSearchBar', () => {
}, },
actions: { actions: {
fetchMilestones: jest.fn(), fetchMilestones: jest.fn(),
fetchLabels: jest.fn(),
setFilters: setFiltersMock, setFilters: setFiltersMock,
}, },
}, },
...@@ -61,15 +63,19 @@ describe('FilteredSearchBar', () => { ...@@ -61,15 +63,19 @@ describe('FilteredSearchBar', () => {
describe('when the state has data', () => { describe('when the state has data', () => {
beforeEach(() => { beforeEach(() => {
vuexStore = createStore({ milestones: { data: mockMilestones } }); vuexStore = createStore({
milestones: { data: mockMilestones },
labels: { data: mockLabels },
});
wrapper = createComponent(vuexStore); wrapper = createComponent(vuexStore);
}); });
it('displays the milestone token', () => { it('displays the milestone and label token', () => {
const tokens = findFilteredSearch().props('availableTokens'); const tokens = findFilteredSearch().props('availableTokens');
expect(tokens).toHaveLength(1); expect(tokens).toHaveLength(2);
expect(tokens[0].type).toBe(milestoneTokenType); expect(tokens[0].type).toBe(milestoneTokenType);
expect(tokens[1].type).toBe(labelTokenType);
}); });
it('displays options in the milestone token', () => { it('displays options in the milestone token', () => {
...@@ -77,23 +83,33 @@ describe('FilteredSearchBar', () => { ...@@ -77,23 +83,33 @@ describe('FilteredSearchBar', () => {
expect(milestoneToken).toHaveLength(mockMilestones.length); expect(milestoneToken).toHaveLength(mockMilestones.length);
}); });
it('displays options in the label token', () => {
const { labels: labelToken } = getSearchToken(labelTokenType);
expect(labelToken).toHaveLength(mockLabels.length);
});
}); });
describe('when the user interacts', () => { describe('when the user interacts', () => {
beforeEach(() => { beforeEach(() => {
vuexStore = createStore({ milestones: { data: mockMilestones } }); vuexStore = createStore({
milestones: { data: mockMilestones },
labels: { data: mockLabels },
});
wrapper = createComponent(vuexStore); wrapper = createComponent(vuexStore);
}); });
it('clicks on the search button, setFilters is dispatched', () => { it('clicks on the search button, setFilters is dispatched', () => {
findFilteredSearch().vm.$emit('submit', [ findFilteredSearch().vm.$emit('submit', [
{ type: 'milestone', value: { data: 'my-milestone', operator: '=' } }, { type: 'milestone', value: { data: 'my-milestone', operator: '=' } },
{ type: 'label', value: { data: 'my-label', operator: '=' } },
]); ]);
expect(setFiltersMock).toHaveBeenCalledWith( expect(setFiltersMock).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
{ {
label_name: undefined, label_name: ['my-label'],
milestone_title: ['my-milestone'], milestone_title: ['my-milestone'],
}, },
undefined, undefined,
......
...@@ -66,8 +66,6 @@ export const mockMilestones = [ ...@@ -66,8 +66,6 @@ export const mockMilestones = [
]; ];
export const mockLabels = [ export const mockLabels = [
[
{ id: 74, title: 'Alero', color: '#6235f2', text_color: '#FFFFFF' }, { id: 74, title: 'Alero', color: '#6235f2', text_color: '#FFFFFF' },
{ id: 9, title: 'Amsche', color: '#581cc8', text_color: '#FFFFFF' }, { id: 9, title: 'Amsche', color: '#581cc8', text_color: '#FFFFFF' },
],
]; ];
import { shallowMount } from '@vue/test-utils';
import { GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import LabelToken from 'ee/analytics/shared/components/tokens/label_token.vue';
import { mockLabels } from './mock_data';
describe('MilestoneToken', () => {
let wrapper;
const defaultValue = { data: '' };
const defaultConfig = {
icon: 'labels',
title: 'Label',
type: 'label',
labels: mockLabels,
unique: false,
symbol: '~',
isLoading: false,
};
const stubs = {
GlFilteredSearchToken: {
template: `<div><slot name="suggestions"></slot></div>`,
},
};
const createComponent = (props = {}, options) => {
wrapper = shallowMount(LabelToken, {
propsData: {
config: defaultConfig,
value: defaultValue,
...props,
},
...options,
});
};
const findFilteredSearchSuggestion = index =>
wrapper.findAll(GlFilteredSearchSuggestion).at(index);
const findAllLabelSuggestions = () => wrapper.findAll({ ref: 'labelItem' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
it('renders a loading icon', () => {
createComponent({ config: { isLoading: true }, value: {} }, { stubs });
expect(findLoadingIcon().exists()).toBe(true);
});
describe('suggestions', () => {
describe('default suggestions', () => {
it.each`
text | dropdownIndex
${'None'} | ${0}
${'Any'} | ${1}
`('renders the "$text" suggestion', ({ text, dropdownIndex }) => {
createComponent(null, { stubs });
expect(findFilteredSearchSuggestion(dropdownIndex).text()).toEqual(text);
});
});
describe('when no search term is given', () => {
it('renders two label suggestions', () => {
createComponent(null, { stubs });
expect(findAllLabelSuggestions()).toHaveLength(2);
});
});
describe('when the search term "Alero" is given', () => {
it('renders one label suggestion that matches the search term', () => {
createComponent({ value: { data: 'Alero' } }, { stubs });
expect(findAllLabelSuggestions()).toHaveLength(1);
});
});
});
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import MilestoneToken from 'ee/analytics/shared/components/tokens/milestone_token.vue'; import MilestoneToken from 'ee/analytics/shared/components/tokens/milestone_token.vue';
import mockMilestones from './mock_data'; import { mockMilestones } from './mock_data';
describe('MilestoneToken', () => { describe('MilestoneToken', () => {
let wrapper; let wrapper;
......
export default [ export const mockMilestones = [
{ {
id: 41, id: 41,
title: 'Sprint - Eligendi et aut pariatur ab rerum vel.', title: 'Sprint - Eligendi et aut pariatur ab rerum vel.',
...@@ -28,3 +28,8 @@ export default [ ...@@ -28,3 +28,8 @@ export default [
name: 'v4.0', name: 'v4.0',
}, },
]; ];
export const mockLabels = [
{ id: 74, title: 'Alero', color: '#6235f2', text_color: '#FFFFFF' },
{ id: 9, title: 'Amsche', color: '#581cc8', text_color: '#FFFFFF' },
];
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