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