Commit c0d905ee authored by Michael Kozono's avatar Michael Kozono

Merge branch...

Merge branch '326751-refactor-issues-list-page-from-haml-to-vue-add-existing-filtered-search-components' into 'master'

Add author, assignee, and label tokens to issues list page refactor

See merge request gitlab-org/gitlab!59490
parents 558345e6 216470b8
<script> <script>
import { GlButton, GlEmptyState, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlEmptyState, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { toNumber } from 'lodash'; import { toNumber } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
...@@ -7,50 +8,35 @@ import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; ...@@ -7,50 +8,35 @@ import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import { import {
CREATED_DESC, CREATED_DESC,
i18n,
MAX_LIST_SIZE,
PAGE_SIZE, PAGE_SIZE,
RELATIVE_POSITION_ASC, RELATIVE_POSITION_ASC,
sortOptions, sortOptions,
sortParams, sortParams,
} from '~/issues_list/constants'; } from '~/issues_list/constants';
import {
convertToApiParams,
convertToSearchQuery,
convertToUrlParams,
getFilterTokens,
getSortKey,
} from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
import { __, s__ } from '~/locale'; import { __ } from '~/locale';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import IssueCardTimeInfo from './issue_card_time_info.vue'; import IssueCardTimeInfo from './issue_card_time_info.vue';
export default { export default {
CREATED_DESC, CREATED_DESC,
i18n,
IssuableListTabs, IssuableListTabs,
PAGE_SIZE, PAGE_SIZE,
sortOptions, sortOptions,
sortParams, sortParams,
i18n: {
calendarLabel: __('Subscribe to calendar'),
jiraIntegrationMessage: s__(
'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.',
),
jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'),
jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'),
newIssueLabel: __('New issue'),
noClosedIssuesTitle: __('There are no closed issues'),
noOpenIssuesDescription: __('To keep this project going, create a new issue'),
noOpenIssuesTitle: __('There are no open issues'),
noIssuesSignedInDescription: __(
'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.',
),
noIssuesSignedInTitle: __(
'The Issue Tracker is the place to add things that need to be improved or solved in a project',
),
noIssuesSignedOutButtonText: __('Register / Sign In'),
noIssuesSignedOutDescription: __(
'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
),
noIssuesSignedOutTitle: __('There are no issues to show'),
noSearchResultsDescription: __('To widen your search, change or remove filters above'),
noSearchResultsTitle: __('Sorry, your filter produced no results'),
reorderError: __('An error occurred while reordering issues.'),
rssLabel: __('Subscribe to RSS feed'),
},
components: { components: {
CsvImportExportButtons, CsvImportExportButtons,
GlButton, GlButton,
...@@ -66,6 +52,9 @@ export default { ...@@ -66,6 +52,9 @@ export default {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
inject: { inject: {
autocompleteUsersPath: {
default: '',
},
calendarPath: { calendarPath: {
default: '', default: '',
}, },
...@@ -81,9 +70,6 @@ export default { ...@@ -81,9 +70,6 @@ export default {
exportCsvPath: { exportCsvPath: {
default: '', default: '',
}, },
fullPath: {
default: '',
},
hasIssues: { hasIssues: {
default: false, default: false,
}, },
...@@ -99,6 +85,12 @@ export default { ...@@ -99,6 +85,12 @@ export default {
newIssuePath: { newIssuePath: {
default: '', default: '',
}, },
projectLabelsPath: {
default: '',
},
projectPath: {
default: '',
},
rssPath: { rssPath: {
default: '', default: '',
}, },
...@@ -112,27 +104,15 @@ export default { ...@@ -112,27 +104,15 @@ export default {
data() { data() {
const orderBy = getParameterByName('order_by'); const orderBy = getParameterByName('order_by');
const sort = getParameterByName('sort'); const sort = getParameterByName('sort');
const sortKey = Object.keys(sortParams).find(
(key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort,
);
const search = getParameterByName('search') || '';
const tokens = search.split(' ').map((searchWord) => ({
type: 'filtered-search-term',
value: {
data: searchWord,
},
}));
return { return {
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filters: sortParams[sortKey] || {}, filterTokens: getFilterTokens(window.location.search),
filterTokens: tokens,
isLoading: false, isLoading: false,
issues: [], issues: [],
page: toNumber(getParameterByName('page')) || 1, page: toNumber(getParameterByName('page')) || 1,
showBulkEditSidebar: false, showBulkEditSidebar: false,
sortKey: sortKey || CREATED_DESC, sortKey: getSortKey(orderBy, sort) || CREATED_DESC,
state: getParameterByName('state') || IssuableStates.Opened, state: getParameterByName('state') || IssuableStates.Opened,
totalIssues: 0, totalIssues: 0,
}; };
...@@ -144,13 +124,46 @@ export default { ...@@ -144,13 +124,46 @@ export default {
isOpenTab() { isOpenTab() {
return this.state === IssuableStates.Opened; return this.state === IssuableStates.Opened;
}, },
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
urlFilterParams() {
return convertToUrlParams(this.filterTokens);
},
searchQuery() { searchQuery() {
return ( return convertToSearchQuery(this.filterTokens) || undefined;
this.filterTokens },
.map((searchTerm) => searchTerm.value.data) searchTokens() {
.filter((searchWord) => Boolean(searchWord)) return [
.join(' ') || undefined {
); type: 'author_username',
title: __('Author'),
icon: 'pencil',
token: AuthorToken,
dataType: 'user',
unique: true,
defaultAuthors: [],
fetchAuthors: this.fetchUsers,
},
{
type: 'assignee_username',
title: __('Assignee'),
icon: 'user',
token: AuthorToken,
dataType: 'user',
unique: true,
defaultAuthors: [],
fetchAuthors: this.fetchUsers,
},
{
type: 'labels',
title: __('Label'),
icon: 'labels',
token: LabelToken,
defaultLabels: [],
fetchLabels: this.fetchLabels,
},
];
}, },
showPaginationControls() { showPaginationControls() {
return this.issues.length > 0; return this.issues.length > 0;
...@@ -169,7 +182,8 @@ export default { ...@@ -169,7 +182,8 @@ export default {
page: this.page, page: this.page,
search: this.searchQuery, search: this.searchQuery,
state: this.state, state: this.state,
...this.filters, ...sortParams[this.sortKey],
...this.urlFilterParams,
}; };
}, },
}, },
...@@ -184,6 +198,21 @@ export default { ...@@ -184,6 +198,21 @@ export default {
eventHub.$off('issuables:toggleBulkEdit'); eventHub.$off('issuables:toggleBulkEdit');
}, },
methods: { methods: {
fetchLabels(search) {
if (this.labelsCache) {
return search
? Promise.resolve(fuzzaldrinPlus.filter(this.labelsCache, search, { key: 'title' }))
: Promise.resolve(this.labelsCache.slice(0, MAX_LIST_SIZE));
}
return axios.get(this.projectLabelsPath).then(({ data }) => {
this.labelsCache = data;
return data.slice(0, MAX_LIST_SIZE);
});
},
fetchUsers(search) {
return axios.get(this.autocompleteUsersPath, { params: { search } });
},
fetchIssues() { fetchIssues() {
if (!this.hasIssues) { if (!this.hasIssues) {
return undefined; return undefined;
...@@ -199,7 +228,8 @@ export default { ...@@ -199,7 +228,8 @@ export default {
search: this.searchQuery, search: this.searchQuery,
state: this.state, state: this.state,
with_labels_details: true, with_labels_details: true,
...this.filters, ...sortParams[this.sortKey],
...this.apiFilterParams,
}, },
}) })
.then(({ data, headers }) => { .then(({ data, headers }) => {
...@@ -278,7 +308,6 @@ export default { ...@@ -278,7 +308,6 @@ export default {
}, },
handleSort(value) { handleSort(value) {
this.sortKey = value; this.sortKey = value;
this.filters = sortParams[value];
this.fetchIssues(); this.fetchIssues();
}, },
}, },
...@@ -288,10 +317,10 @@ export default { ...@@ -288,10 +317,10 @@ export default {
<template> <template>
<issuable-list <issuable-list
v-if="hasIssues" v-if="hasIssues"
:namespace="fullPath" :namespace="projectPath"
recent-searches-storage-key="issues" recent-searches-storage-key="issues"
:search-input-placeholder="__('Search or filter results…')" :search-input-placeholder="__('Search or filter results…')"
:search-tokens="[]" :search-tokens="searchTokens"
:initial-filter-value="filterTokens" :initial-filter-value="filterTokens"
:sort-options="$options.sortOptions" :sort-options="$options.sortOptions"
:initial-sort-by="sortKey" :initial-sort-by="sortKey"
......
import { __ } from '~/locale'; import { __, s__ } from '~/locale';
// Maps sort order as it appears in the URL query to API `order_by` and `sort` params. // Maps sort order as it appears in the URL query to API `order_by` and `sort` params.
const PRIORITY = 'priority'; const PRIORITY = 'priority';
...@@ -53,6 +53,34 @@ export const availableSortOptionsJira = [ ...@@ -53,6 +53,34 @@ export const availableSortOptionsJira = [
}, },
]; ];
export const i18n = {
calendarLabel: __('Subscribe to calendar'),
jiraIntegrationMessage: s__(
'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.',
),
jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'),
jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'),
newIssueLabel: __('New issue'),
noClosedIssuesTitle: __('There are no closed issues'),
noOpenIssuesDescription: __('To keep this project going, create a new issue'),
noOpenIssuesTitle: __('There are no open issues'),
noIssuesSignedInDescription: __(
'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.',
),
noIssuesSignedInTitle: __(
'The Issue Tracker is the place to add things that need to be improved or solved in a project',
),
noIssuesSignedOutButtonText: __('Register / Sign In'),
noIssuesSignedOutDescription: __(
'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
),
noIssuesSignedOutTitle: __('There are no issues to show'),
noSearchResultsDescription: __('To widen your search, change or remove filters above'),
noSearchResultsTitle: __('Sorry, your filter produced no results'),
reorderError: __('An error occurred while reordering issues.'),
rssLabel: __('Subscribe to RSS feed'),
};
export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map'; export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC';
...@@ -242,3 +270,42 @@ export const sortOptions = [ ...@@ -242,3 +270,42 @@ export const sortOptions = [
}, },
}, },
]; ];
export const MAX_LIST_SIZE = 10;
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
export const OPERATOR_IS = '=';
export const OPERATOR_IS_NOT = '!=';
export const filters = {
author_username: {
apiParam: {
[OPERATOR_IS]: 'author_username',
[OPERATOR_IS_NOT]: 'not[author_username]',
},
urlParam: {
[OPERATOR_IS]: 'author_username',
[OPERATOR_IS_NOT]: 'not[author_username]',
},
},
assignee_username: {
apiParam: {
[OPERATOR_IS]: 'assignee_username',
[OPERATOR_IS_NOT]: 'not[assignee_username]',
},
urlParam: {
[OPERATOR_IS]: 'assignee_username[]',
[OPERATOR_IS_NOT]: 'not[assignee_username][]',
},
},
labels: {
apiParam: {
[OPERATOR_IS]: 'labels',
[OPERATOR_IS_NOT]: 'not[labels]',
},
urlParam: {
[OPERATOR_IS]: 'label_name[]',
[OPERATOR_IS_NOT]: 'not[label_name][]',
},
},
};
...@@ -73,6 +73,7 @@ export function initIssuesListApp() { ...@@ -73,6 +73,7 @@ export function initIssuesListApp() {
} }
const { const {
autocompleteUsersPath,
calendarPath, calendarPath,
canBulkUpdate, canBulkUpdate,
canEdit, canEdit,
...@@ -81,7 +82,6 @@ export function initIssuesListApp() { ...@@ -81,7 +82,6 @@ export function initIssuesListApp() {
emptyStateSvgPath, emptyStateSvgPath,
endpoint, endpoint,
exportCsvPath, exportCsvPath,
fullPath,
hasBlockedIssuesFeature, hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature, hasIssuableHealthStatusFeature,
hasIssues, hasIssues,
...@@ -93,6 +93,8 @@ export function initIssuesListApp() { ...@@ -93,6 +93,8 @@ export function initIssuesListApp() {
maxAttachmentSize, maxAttachmentSize,
newIssuePath, newIssuePath,
projectImportJiraPath, projectImportJiraPath,
projectLabelsPath,
projectPath,
rssPath, rssPath,
showNewIssueLink, showNewIssueLink,
signInPath, signInPath,
...@@ -104,11 +106,11 @@ export function initIssuesListApp() { ...@@ -104,11 +106,11 @@ export function initIssuesListApp() {
// issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153 // issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
apolloProvider: {}, apolloProvider: {},
provide: { provide: {
autocompleteUsersPath,
calendarPath, calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate), canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath, emptyStateSvgPath,
endpoint, endpoint,
fullPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssues: parseBoolean(hasIssues), hasIssues: parseBoolean(hasIssues),
...@@ -117,6 +119,8 @@ export function initIssuesListApp() { ...@@ -117,6 +119,8 @@ export function initIssuesListApp() {
issuesPath, issuesPath,
jiraIntegrationPath, jiraIntegrationPath,
newIssuePath, newIssuePath,
projectLabelsPath,
projectPath,
rssPath, rssPath,
showNewIssueLink: parseBoolean(showNewIssueLink), showNewIssueLink: parseBoolean(showNewIssueLink),
signInPath, signInPath,
......
import { FILTERED_SEARCH_TERM, filters, sortParams } from '~/issues_list/constants';
export const getSortKey = (orderBy, sort) =>
Object.keys(sortParams).find(
(key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort,
);
const tokenTypes = Object.keys(filters);
const urlParamKeys = tokenTypes.flatMap((key) => Object.values(filters[key].urlParam));
const getTokenTypeFromUrlParamKey = (urlParamKey) =>
tokenTypes.find((key) => Object.values(filters[key].urlParam).includes(urlParamKey));
const getOperatorFromUrlParamKey = (tokenType, urlParamKey) =>
Object.entries(filters[tokenType].urlParam).find(([, urlParam]) => urlParam === urlParamKey)[0];
const convertToFilteredTokens = (locationSearch) =>
Array.from(new URLSearchParams(locationSearch).entries())
.filter(([key]) => urlParamKeys.includes(key))
.map(([key, data]) => {
const type = getTokenTypeFromUrlParamKey(key);
const operator = getOperatorFromUrlParamKey(type, key);
return {
type,
value: { data, operator },
};
});
const convertToFilteredSearchTerms = (locationSearch) =>
new URLSearchParams(locationSearch)
.get('search')
?.split(' ')
.map((word) => ({
type: FILTERED_SEARCH_TERM,
value: {
data: word,
},
})) || [];
export const getFilterTokens = (locationSearch) => {
if (!locationSearch) {
return [];
}
const filterTokens = convertToFilteredTokens(locationSearch);
const searchTokens = convertToFilteredSearchTerms(locationSearch);
return filterTokens.concat(searchTokens);
};
export const convertToApiParams = (filterTokens) =>
filterTokens
.filter((token) => token.type !== FILTERED_SEARCH_TERM)
.reduce((acc, token) => {
const apiParam = filters[token.type].apiParam[token.value.operator];
return Object.assign(acc, {
[apiParam]: acc[apiParam] ? `${acc[apiParam]},${token.value.data}` : token.value.data,
});
}, {});
export const convertToUrlParams = (filterTokens) =>
filterTokens
.filter((token) => token.type !== FILTERED_SEARCH_TERM)
.reduce((acc, token) => {
const urlParam = filters[token.type].urlParam[token.value.operator];
return Object.assign(acc, {
[urlParam]: acc[urlParam] ? acc[urlParam].concat(token.value.data) : [token.value.data],
});
}, {});
export const convertToSearchQuery = (filterTokens) =>
filterTokens
.filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data)
.map((token) => token.value.data)
.join(' ');
...@@ -165,6 +165,7 @@ module IssuesHelper ...@@ -165,6 +165,7 @@ module IssuesHelper
def issues_list_data(project, current_user, finder) def issues_list_data(project, current_user, finder)
{ {
autocomplete_users_path: autocomplete_users_path(active: true, current_user: true, project_id: project.id, format: :json),
calendar_path: url_for(safe_params.merge(calendar_url_options)), calendar_path: url_for(safe_params.merge(calendar_url_options)),
can_bulk_update: can?(current_user, :admin_issue, project).to_s, can_bulk_update: can?(current_user, :admin_issue, project).to_s,
can_edit: can?(current_user, :admin_project, project).to_s, can_edit: can?(current_user, :admin_project, project).to_s,
...@@ -173,7 +174,6 @@ module IssuesHelper ...@@ -173,7 +174,6 @@ module IssuesHelper
empty_state_svg_path: image_path('illustrations/issues.svg'), empty_state_svg_path: image_path('illustrations/issues.svg'),
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)), endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project), export_csv_path: export_csv_project_issues_path(project),
full_path: project.full_path,
has_issues: project_issues(project).exists?.to_s, has_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: import_csv_namespace_project_issues_path, import_csv_issues_path: import_csv_namespace_project_issues_path,
is_signed_in: current_user.present?.to_s, is_signed_in: current_user.present?.to_s,
...@@ -182,6 +182,8 @@ module IssuesHelper ...@@ -182,6 +182,8 @@ module IssuesHelper
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.try(:id), milestone_id: finder.milestones.first.try(:id) }), new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.try(:id), milestone_id: finder.milestones.first.try(:id) }),
project_import_jira_path: project_import_jira_path(project), project_import_jira_path: project_import_jira_path(project),
project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json),
project_path: project.full_path,
rss_path: url_for(safe_params.merge(rss_url_options)), rss_path: url_for(safe_params.merge(rss_url_options)),
show_new_issue_link: show_new_issue_link?(project).to_s, show_new_issue_link: show_new_issue_link?(project).to_s,
sign_in_path: new_user_session_path sign_in_path: new_user_session_path
......
...@@ -3,12 +3,12 @@ import { mount, shallowMount } from '@vue/test-utils'; ...@@ -3,12 +3,12 @@ import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter'; import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { filteredTokens, locationSearch } from 'jest/issues_list/mock_data';
import createFlash from '~/flash'; import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import { import {
CREATED_DESC, CREATED_DESC,
PAGE_SIZE, PAGE_SIZE,
...@@ -29,17 +29,19 @@ describe('IssuesListApp component', () => { ...@@ -29,17 +29,19 @@ describe('IssuesListApp component', () => {
let wrapper; let wrapper;
const defaultProvide = { const defaultProvide = {
autocompleteUsersPath: 'autocomplete/users/path',
calendarPath: 'calendar/path', calendarPath: 'calendar/path',
canBulkUpdate: false, canBulkUpdate: false,
emptyStateSvgPath: 'empty-state.svg', emptyStateSvgPath: 'empty-state.svg',
endpoint: 'api/endpoint', endpoint: 'api/endpoint',
exportCsvPath: 'export/csv/path', exportCsvPath: 'export/csv/path',
fullPath: 'path/to/project',
hasIssues: true, hasIssues: true,
isSignedIn: false, isSignedIn: false,
issuesPath: 'path/to/issues', issuesPath: 'path/to/issues',
jiraIntegrationPath: 'jira/integration/path', jiraIntegrationPath: 'jira/integration/path',
newIssuePath: 'new/issue/path', newIssuePath: 'new/issue/path',
projectLabelsPath: 'project/labels/path',
projectPath: 'path/to/project',
rssPath: 'rss/path', rssPath: 'rss/path',
showImportButton: true, showImportButton: true,
showNewIssueLink: true, showNewIssueLink: true,
...@@ -99,7 +101,7 @@ describe('IssuesListApp component', () => { ...@@ -99,7 +101,7 @@ describe('IssuesListApp component', () => {
it('renders', () => { it('renders', () => {
expect(findIssuableList().props()).toMatchObject({ expect(findIssuableList().props()).toMatchObject({
namespace: defaultProvide.fullPath, namespace: defaultProvide.projectPath,
recentSearchesStorageKey: 'issues', recentSearchesStorageKey: 'issues',
searchInputPlaceholder: 'Search or filter results…', searchInputPlaceholder: 'Search or filter results…',
sortOptions, sortOptions,
...@@ -213,6 +215,19 @@ describe('IssuesListApp component', () => { ...@@ -213,6 +215,19 @@ describe('IssuesListApp component', () => {
}); });
}); });
describe('search', () => {
it('is set from the url params', () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { search: locationSearch },
});
wrapper = mountComponent();
expect(findIssuableList().props('urlParams')).toMatchObject({ search: 'find issues' });
});
});
describe('sort', () => { describe('sort', () => {
it.each(Object.keys(sortParams))('is set as %s from the url params', (sortKey) => { it.each(Object.keys(sortParams))('is set as %s from the url params', (sortKey) => {
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
...@@ -243,6 +258,19 @@ describe('IssuesListApp component', () => { ...@@ -243,6 +258,19 @@ describe('IssuesListApp component', () => {
expect(findIssuableList().props('currentTab')).toBe(initialState); expect(findIssuableList().props('currentTab')).toBe(initialState);
}); });
}); });
describe('filter tokens', () => {
it('is set from the url params', () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { search: locationSearch },
});
wrapper = mountComponent();
expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens);
});
});
}); });
describe('bulk edit', () => { describe('bulk edit', () => {
...@@ -265,15 +293,13 @@ describe('IssuesListApp component', () => { ...@@ -265,15 +293,13 @@ describe('IssuesListApp component', () => {
describe('empty states', () => { describe('empty states', () => {
describe('when there are issues', () => { describe('when there are issues', () => {
describe('when search returns no results', () => { describe('when search returns no results', () => {
beforeEach(async () => { beforeEach(() => {
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
writable: true, writable: true,
value: { href: setUrlParams({ search: 'no results' }, TEST_HOST) }, value: { search: '?search=no+results' },
}); });
wrapper = mountComponent({ provide: { hasIssues: true } }); wrapper = mountComponent({ provide: { hasIssues: true } });
await waitForPromises();
}); });
it('shows empty state', () => { it('shows empty state', () => {
...@@ -418,7 +444,7 @@ describe('IssuesListApp component', () => { ...@@ -418,7 +444,7 @@ describe('IssuesListApp component', () => {
}); });
it('fetches issues with expected params', () => { it('fetches issues with expected params', () => {
expect(axiosMock.history.get[1].params).toEqual({ expect(axiosMock.history.get[1].params).toMatchObject({
page, page,
per_page: PAGE_SIZE, per_page: PAGE_SIZE,
state, state,
...@@ -525,21 +551,32 @@ describe('IssuesListApp component', () => { ...@@ -525,21 +551,32 @@ describe('IssuesListApp component', () => {
}); });
describe('when "filter" event is emitted by IssuableList', () => { describe('when "filter" event is emitted by IssuableList', () => {
beforeEach(async () => { beforeEach(() => {
wrapper = mountComponent(); wrapper = mountComponent();
const payload = [ findIssuableList().vm.$emit('filter', filteredTokens);
{ type: 'filtered-search-term', value: { data: 'no' } },
{ type: 'filtered-search-term', value: { data: 'issues' } },
];
findIssuableList().vm.$emit('filter', payload);
await waitForPromises();
}); });
it('makes an API call to search for issues with the search term', () => { it('makes an API call to search for issues with the search term', () => {
expect(axiosMock.history.get[1].params).toMatchObject({ search: 'no issues' }); expect(axiosMock.history.get[1].params).toMatchObject({
author_username: 'homer',
'not[author_username]': 'marge',
assignee_username: 'bart',
'not[assignee_username]': 'lisa',
labels: 'cartoon,tv',
'not[labels]': 'live action,drama',
});
});
it('updates IssuableList with url params', () => {
expect(findIssuableList().props('urlParams')).toMatchObject({
author_username: ['homer'],
'not[author_username]': ['marge'],
'assignee_username[]': ['bart'],
'not[assignee_username][]': ['lisa'],
'label_name[]': ['cartoon', 'tv'],
'not[label_name][]': ['live action', 'drama'],
});
}); });
}); });
}); });
......
import { OPERATOR_IS, OPERATOR_IS_NOT } from '~/issues_list/constants';
export const locationSearch = [
'?search=find+issues',
'author_username=homer',
'not[author_username]=marge',
'assignee_username[]=bart',
'not[assignee_username][]=lisa',
'label_name[]=cartoon',
'label_name[]=tv',
'not[label_name][]=live action',
'not[label_name][]=drama',
].join('&');
export const filteredTokens = [
{ type: 'author_username', value: { data: 'homer', operator: OPERATOR_IS } },
{ type: 'author_username', value: { data: 'marge', operator: OPERATOR_IS_NOT } },
{ type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } },
{ type: 'assignee_username', value: { data: 'lisa', operator: OPERATOR_IS_NOT } },
{ type: 'labels', value: { data: 'cartoon', operator: OPERATOR_IS } },
{ type: 'labels', value: { data: 'tv', operator: OPERATOR_IS } },
{ type: 'labels', value: { data: 'live action', operator: OPERATOR_IS_NOT } },
{ type: 'labels', value: { data: 'drama', operator: OPERATOR_IS_NOT } },
{ type: 'filtered-search-term', value: { data: 'find' } },
{ type: 'filtered-search-term', value: { data: 'issues' } },
];
import { filteredTokens, locationSearch } from 'jest/issues_list/mock_data';
import { sortParams } from '~/issues_list/constants';
import {
convertToApiParams,
convertToSearchQuery,
convertToUrlParams,
getFilterTokens,
getSortKey,
} from '~/issues_list/utils';
describe('getSortKey', () => {
it.each(Object.keys(sortParams))('returns %s given the correct inputs', (sortKey) => {
const { order_by, sort } = sortParams[sortKey];
expect(getSortKey(order_by, sort)).toBe(sortKey);
});
});
describe('getFilterTokens', () => {
it('returns filtered tokens given "window.location.search"', () => {
expect(getFilterTokens(locationSearch)).toEqual(filteredTokens);
});
});
describe('convertToApiParams', () => {
it('returns api params given filtered tokens', () => {
expect(convertToApiParams(filteredTokens)).toEqual({
author_username: 'homer',
'not[author_username]': 'marge',
assignee_username: 'bart',
'not[assignee_username]': 'lisa',
labels: 'cartoon,tv',
'not[labels]': 'live action,drama',
});
});
});
describe('convertToUrlParams', () => {
it('returns url params given filtered tokens', () => {
expect(convertToUrlParams(filteredTokens)).toEqual({
author_username: ['homer'],
'not[author_username]': ['marge'],
'assignee_username[]': ['bart'],
'not[assignee_username][]': ['lisa'],
'label_name[]': ['cartoon', 'tv'],
'not[label_name][]': ['live action', 'drama'],
});
});
});
describe('convertToSearchQuery', () => {
it('returns search string given filtered tokens', () => {
expect(convertToSearchQuery(filteredTokens)).toBe('find issues');
});
});
...@@ -293,6 +293,7 @@ RSpec.describe IssuesHelper do ...@@ -293,6 +293,7 @@ RSpec.describe IssuesHelper do
allow(helper).to receive(:url_for).and_return('#') allow(helper).to receive(:url_for).and_return('#')
expected = { expected = {
autocomplete_users_path: autocomplete_users_path(active: true, current_user: true, project_id: project.id, format: :json),
calendar_path: '#', calendar_path: '#',
can_bulk_update: 'true', can_bulk_update: 'true',
can_edit: 'true', can_edit: 'true',
...@@ -301,7 +302,6 @@ RSpec.describe IssuesHelper do ...@@ -301,7 +302,6 @@ RSpec.describe IssuesHelper do
empty_state_svg_path: '#', empty_state_svg_path: '#',
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)), endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project), export_csv_path: export_csv_project_issues_path(project),
full_path: project.full_path,
has_issues: project_issues(project).exists?.to_s, has_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#', import_csv_issues_path: '#',
is_signed_in: current_user.present?.to_s, is_signed_in: current_user.present?.to_s,
...@@ -310,6 +310,8 @@ RSpec.describe IssuesHelper do ...@@ -310,6 +310,8 @@ RSpec.describe IssuesHelper do
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.id, milestone_id: finder.milestones.first.id }), new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.id, milestone_id: finder.milestones.first.id }),
project_import_jira_path: project_import_jira_path(project), project_import_jira_path: project_import_jira_path(project),
project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json),
project_path: project.full_path,
rss_path: '#', rss_path: '#',
show_new_issue_link: 'true', show_new_issue_link: 'true',
sign_in_path: new_user_session_path sign_in_path: new_user_session_path
......
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