Commit 9c2bacff authored by Justin Ho Tuan Duong's avatar Justin Ho Tuan Duong Committed by Paul Slaughter

Add labels as search token in Jira issues list

It is added both as filtered search value and token. This
allows users to see labels as the selected token and they
can even select more labels in the filter bar if needed.

We are currently not using `fetchLabels` since the backend
for this is not ready yet.

Changelog: changed
EE: true
parent 44276368
......@@ -37,6 +37,7 @@ export const SortDirection = {
ascending: 'ascending',
};
export const FILTERED_SEARCH_LABELS = 'labels';
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
export const TOKEN_TITLE_AUTHOR = __('Author');
......
......@@ -117,6 +117,29 @@ export default {
!this.preloadedTokenIds.includes(tokenValue[this.valueIdentifier]),
);
},
showDefaultSuggestions() {
return this.defaultSuggestions.length;
},
showRecentSuggestions() {
return this.isRecentSuggestionsEnabled && this.recentSuggestions.length && !this.searchKey;
},
showPreloadedSuggestions() {
return this.preloadedSuggestions.length && !this.searchKey;
},
showAvailableSuggestions() {
return this.availableSuggestions.length;
},
showSuggestions() {
// These conditions must match the template under `#suggestions` slot
// See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65817#note_632619411
return (
this.showDefaultSuggestions ||
this.showRecentSuggestions ||
this.showPreloadedSuggestions ||
this.suggestionsLoading ||
this.showAvailableSuggestions
);
},
},
watch: {
active: {
......@@ -168,8 +191,8 @@ export default {
<template #view="viewTokenProps">
<slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
</template>
<template #suggestions>
<template v-if="defaultSuggestions.length">
<template v-if="showSuggestions" #suggestions>
<template v-if="showDefaultSuggestions">
<gl-filtered-search-suggestion
v-for="token in defaultSuggestions"
:key="token.value"
......@@ -179,13 +202,13 @@ export default {
</gl-filtered-search-suggestion>
<gl-dropdown-divider />
</template>
<template v-if="isRecentSuggestionsEnabled && recentSuggestions.length && !searchKey">
<template v-if="showRecentSuggestions">
<gl-dropdown-section-header>{{ __('Recently used') }}</gl-dropdown-section-header>
<slot name="suggestions-list" :suggestions="recentSuggestions"></slot>
<gl-dropdown-divider />
</template>
<slot
v-if="preloadedSuggestions.length && !searchKey"
v-if="showPreloadedSuggestions"
name="suggestions-list"
:suggestions="preloadedSuggestions"
></slot>
......
......@@ -10,6 +10,14 @@ import {
AvailableSortOptions,
DEFAULT_PAGE_SIZE,
} from '~/issuable_list/constants';
import {
FILTERED_SEARCH_LABELS,
FILTERED_SEARCH_TERM,
OPERATOR_IS_ONLY,
TOKEN_TITLE_LABEL,
} from '~/vue_shared/components/filtered_search_bar/constants';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import { ISSUES_LIST_FETCH_ERROR } from '../constants';
import getJiraIssuesQuery from '../graphql/queries/get_jira_issues.query.graphql';
import JiraIssuesListEmptyState from './jira_issues_list_empty_state.vue';
......@@ -117,16 +125,48 @@ export default {
},
},
methods: {
getFilteredSearchValue() {
getFilteredSearchTokens() {
return [
{
type: 'filtered-search-term',
value: {
data: this.filterParams.search || '',
type: FILTERED_SEARCH_LABELS,
icon: 'labels',
symbol: '~',
title: TOKEN_TITLE_LABEL,
unique: false,
token: LabelToken,
operators: OPERATOR_IS_ONLY,
defaultLabels: [],
fetchLabels: () => {
return Promise.resolve([]);
},
},
];
},
getFilteredSearchValue() {
const { labels, search } = this.filterParams || {};
const filteredSearchValue = [];
if (labels) {
filteredSearchValue.push(
...labels.map((label) => ({
type: FILTERED_SEARCH_LABELS,
value: { data: label },
})),
);
}
if (search) {
filteredSearchValue.push({
type: FILTERED_SEARCH_TERM,
value: {
data: search,
},
});
}
return filteredSearchValue;
},
onJiraIssuesQueryError(error) {
createFlash({
message: error.message,
......@@ -147,11 +187,21 @@ export default {
},
onIssuableListFilter(filters = []) {
const filterParams = {};
const labels = [];
const plainText = [];
filters.forEach((filter) => {
if (filter.type === 'filtered-search-term' && filter.value.data) {
plainText.push(filter.value.data);
if (!filter.value.data) return;
switch (filter.type) {
case FILTERED_SEARCH_LABELS:
labels.push(filter.value.data);
break;
case FILTERED_SEARCH_TERM:
plainText.push(filter.value.data);
break;
default:
break;
}
});
......@@ -159,6 +209,10 @@ export default {
filterParams.search = plainText.join(' ');
}
if (labels.length) {
filterParams.labels = labels;
}
this.filterParams = filterParams;
},
},
......@@ -171,7 +225,7 @@ export default {
:tabs="$options.IssuableListTabs"
:current-tab="currentState"
:search-input-placeholder="s__('Integrations|Search Jira issues')"
:search-tokens="[]"
:search-tokens="getFilteredSearchTokens()"
:sort-options="$options.AvailableSortOptions"
:initial-filter-value="getFilteredSearchValue()"
:initial-sort-by="sortedBy"
......
......@@ -8,14 +8,7 @@ Object {
"enableLabelPermalinks": true,
"hasNextPage": false,
"hasPreviousPage": false,
"initialFilterValue": Array [
Object {
"type": "filtered-search-term",
"value": Object {
"data": "",
},
},
],
"initialFilterValue": Array [],
"initialSortBy": "created_desc",
"isManualOrdering": false,
"issuableSymbol": "#",
......@@ -107,7 +100,24 @@ Object {
"previousPage": 0,
"recentSearchesStorageKey": "jira_issues",
"searchInputPlaceholder": "Search Jira issues",
"searchTokens": Array [],
"searchTokens": Array [
Object {
"defaultLabels": Array [],
"fetchLabels": [Function],
"icon": "labels",
"operators": Array [
Object {
"description": "is",
"value": "=",
},
],
"symbol": "~",
"title": "Label",
"token": "LabelTokenMock",
"type": "labels",
"unique": false,
},
],
"showBulkEditSidebar": false,
"showPaginationControls": true,
"sortOptions": Array [
......
......@@ -23,6 +23,10 @@ jest.mock('~/issuable_list/constants', () => ({
IssuableListTabs: jest.requireActual('~/issuable_list/constants').IssuableListTabs,
AvailableSortOptions: jest.requireActual('~/issuable_list/constants').AvailableSortOptions,
}));
jest.mock(
'~/vue_shared/components/filtered_search_bar/tokens/label_token.vue',
() => 'LabelTokenMock',
);
const resolvedValue = {
headers: {
......@@ -49,7 +53,12 @@ describe('JiraIssuesListRoot', () => {
let wrapper;
let mock;
const mockSearchTerm = 'test issue';
const mockLabel = 'ecosystem';
const findIssuableList = () => wrapper.findComponent(IssuableList);
const createLabelFilterEvent = (data) => ({ type: 'labels', value: { data } });
const createSearchFilterEvent = (data) => ({ type: 'filtered-search-term', value: { data } });
const createComponent = ({
apolloProvider = createMockApolloProvider(),
......@@ -109,12 +118,15 @@ describe('JiraIssuesListRoot', () => {
});
describe('with `initialFilterParams` prop', () => {
const mockSearchTerm = 'foo';
beforeEach(async () => {
jest.spyOn(axios, 'get').mockResolvedValue(resolvedValue);
createComponent({ initialFilterParams: { search: mockSearchTerm } });
createComponent({
initialFilterParams: {
labels: [mockLabel],
search: mockSearchTerm,
},
});
await waitForPromises();
});
......@@ -122,6 +134,7 @@ describe('JiraIssuesListRoot', () => {
const issuableList = findIssuableList();
expect(issuableList.props('initialFilterValue')).toEqual([
{ type: 'labels', value: { data: mockLabel } },
{ type: 'filtered-search-term', value: { data: mockSearchTerm } },
]);
expect(issuableList.props('urlParams').search).toBe(mockSearchTerm);
......@@ -215,32 +228,31 @@ describe('JiraIssuesListRoot', () => {
expect(issuableList.props('initialSortBy')).toBe(mockSortBy);
});
it('filter event sets `filterParams` value and calls fetchIssues', async () => {
const mockFilterTerm = 'foo';
const issuableList = findIssuableList();
it.each`
desc | input | expected
${'with label and search'} | ${[createLabelFilterEvent(mockLabel), createSearchFilterEvent(mockSearchTerm)]} | ${{ labels: [mockLabel], search: mockSearchTerm }}
${'with multiple lables'} | ${[createLabelFilterEvent('label1'), createLabelFilterEvent('label2')]} | ${{ labels: ['label1', 'label2'], search: undefined }}
${'with multiple searches'} | ${[createSearchFilterEvent('foo bar'), createSearchFilterEvent('lorem')]} | ${{ labels: undefined, search: 'foo bar lorem' }}
`(
'$desc, filter event sets "filterParams" value and calls fetchIssues',
async ({ input, expected }) => {
const issuableList = findIssuableList();
issuableList.vm.$emit('filter', [
{
type: 'filtered-search-term',
value: {
data: mockFilterTerm,
},
},
]);
await waitForPromises();
issuableList.vm.$emit('filter', input);
await waitForPromises();
expect(axios.get).toHaveBeenCalledWith(mockProvide.issuesFetchPath, {
params: {
labels: undefined,
page: 1,
per_page: 2,
search: mockFilterTerm,
sort: 'created_desc',
state: 'opened',
with_labels_details: true,
},
});
});
expect(axios.get).toHaveBeenCalledWith(mockProvide.issuesFetchPath, {
params: {
page: 1,
per_page: 2,
sort: 'created_desc',
state: 'opened',
with_labels_details: true,
...expected,
},
});
},
);
});
});
......
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