Commit 0890bda6 authored by Illya Klymov's avatar Illya Klymov

Merge branch '212543-frontend-filtered-search-requirements' into 'master'

Add frontend for Filtered Search & Sort in Requirements page

Closes #212543

See merge request gitlab-org/gitlab!33484
parents 14c9a9af 5254b3d0
......@@ -3,4 +3,5 @@ import recentSearchesStorageKeysCE from '~/filtered_search/recent_searches_stora
export default {
...recentSearchesStorageKeysCE,
epics: 'epics-recent-searches',
requirements: 'requirements-recent-searches',
};
......@@ -2,10 +2,15 @@
import * as Sentry from '@sentry/browser';
import { GlPagination } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import Api from '~/api';
import createFlash from '~/flash';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import { ANY_AUTHOR } from '~/vue_shared/components/filtered_search_bar/constants';
import RequirementsTabs from './requirements_tabs.vue';
import RequirementsLoading from './requirements_loading.vue';
import RequirementsEmptyState from './requirements_empty_state.vue';
......@@ -17,13 +22,15 @@ import projectRequirementsCount from '../queries/projectRequirementsCount.query.
import createRequirement from '../queries/createRequirement.mutation.graphql';
import updateRequirement from '../queries/updateRequirement.mutation.graphql';
import { FilterState, DEFAULT_PAGE_SIZE } from '../constants';
import { FilterState, AvailableSortOptions, DEFAULT_PAGE_SIZE } from '../constants';
export default {
DEFAULT_PAGE_SIZE,
AvailableSortOptions,
components: {
RequirementsTabs,
GlPagination,
FilteredSearchBar,
RequirementsTabs,
RequirementsLoading,
RequirementsEmptyState,
RequirementItem,
......@@ -38,6 +45,21 @@ export default {
type: String,
required: true,
},
initialTextSearch: {
type: String,
required: false,
default: '',
},
initialSortBy: {
type: String,
required: false,
default: 'created_desc',
},
initialAuthorUsernames: {
type: Array,
required: false,
default: () => [],
},
initialRequirementsCount: {
type: Object,
required: true,
......@@ -96,6 +118,18 @@ export default {
queryVariables.state = this.filterBy;
}
if (this.textSearch) {
queryVariables.search = this.textSearch;
}
if (this.authorUsernames.length) {
queryVariables.authorUsernames = this.authorUsernames;
}
if (this.sortBy) {
queryVariables.sortBy = this.sortBy;
}
return queryVariables;
},
update(data) {
......@@ -136,6 +170,9 @@ export default {
data() {
return {
filterBy: this.initialFilterBy,
textSearch: this.initialTextSearch,
authorUsernames: this.initialAuthorUsernames,
sortBy: this.initialSortBy,
showCreateForm: false,
showUpdateFormForRequirement: 0,
createRequirementRequestActive: false,
......@@ -177,6 +214,13 @@ export default {
return this.requirementsListEmpty && !this.showCreateForm;
},
showPaginationControls() {
const { hasPreviousPage, hasNextPage } = this.requirements.pageInfo;
// This explicit check is necessary as both the variables
// can also be `false` and we just want to ensure that they're present.
if (hasPreviousPage !== undefined || hasNextPage !== undefined) {
return Boolean(hasPreviousPage || hasNextPage);
}
return this.totalRequirementsForCurrentTab > DEFAULT_PAGE_SIZE && !this.requirementsListEmpty;
},
prevPage() {
......@@ -190,27 +234,87 @@ export default {
},
},
methods: {
getFilteredSearchTokens() {
return [
{
type: 'author_username',
icon: 'user',
title: __('Author'),
unique: false,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
];
},
getFilteredSearchValue() {
const value = this.authorUsernames.map(author => ({
type: 'author_username',
value: { data: author },
}));
if (this.textSearch) {
value.push(this.textSearch);
}
return value;
},
/**
* Update browser URL with updated query-param values
* based on current page details.
*/
updateUrl({ page, prev, next }) {
updateUrl() {
const { href, search } = window.location;
const queryParams = urlParamsToObject(search);
const {
filterBy,
currentPage,
prevPageCursor,
nextPageCursor,
textSearch,
authorUsernames,
sortBy,
} = this;
queryParams.page = page || 1;
queryParams.page = currentPage || 1;
// Only keep params that have any values.
if (prev) {
queryParams.prev = prev;
if (prevPageCursor) {
queryParams.prev = prevPageCursor;
} else {
delete queryParams.prev;
}
if (next) {
queryParams.next = next;
if (nextPageCursor) {
queryParams.next = nextPageCursor;
} else {
delete queryParams.next;
}
if (filterBy) {
queryParams.state = filterBy.toLowerCase();
} else {
delete queryParams.state;
}
if (textSearch) {
queryParams.search = textSearch;
} else {
delete queryParams.search;
}
if (sortBy) {
queryParams.sort = sortBy;
} else {
delete queryParams.sort;
}
delete queryParams.author_username;
if (authorUsernames.length) {
queryParams['author_username[]'] = authorUsernames;
}
// We want to replace the history state so that back button
// correctly reloads the page with previous URL.
updateHistory({
......@@ -356,6 +460,32 @@ export default {
handleUpdateRequirementCancel() {
this.showUpdateFormForRequirement = 0;
},
handleFilterRequirements(filters = []) {
const authors = [];
filters.forEach(filter => {
if (typeof filter === 'string') {
this.textSearch = filter;
} else if (filter.value.data !== ANY_AUTHOR) {
authors.push(filter.value.data);
}
});
this.authorUsernames = [...authors];
this.currentPage = 1;
this.prevPageCursor = '';
this.nextPageCursor = '';
this.updateUrl();
},
handleSortRequirements(sortBy) {
this.sortBy = sortBy;
this.currentPage = 1;
this.prevPageCursor = '';
this.nextPageCursor = '';
this.updateUrl();
},
handlePageChange(page) {
const { startCursor, endCursor } = this.requirements.pageInfo;
......@@ -369,11 +499,7 @@ export default {
this.currentPage = page;
this.updateUrl({
page,
prev: this.prevPageCursor,
next: this.nextPageCursor,
});
this.updateUrl();
},
},
};
......@@ -389,6 +515,17 @@ export default {
@clickTab="handleTabClick"
@clickNewRequirement="handleNewRequirementClick"
/>
<filtered-search-bar
:namespace="projectPath"
:search-input-placeholder="__('Search requirements')"
:tokens="getFilteredSearchTokens()"
:sort-options="$options.AvailableSortOptions"
:initial-filter-value="getFilteredSearchValue()"
:initial-sort-by="sortBy"
class="row-content-block"
@onFilter="handleFilterRequirements"
@onSort="handleSortRequirements"
/>
<requirement-form
v-if="showCreateForm"
:requirement-request-active="createRequirementRequestActive"
......
......@@ -11,6 +11,25 @@ export const FilterStateEmptyMessage = {
ARCHIVED: __('There are no archived requirements'),
};
export const AvailableSortOptions = [
{
id: 1,
title: __('Created date'),
sortDirection: {
descending: 'created_desc',
ascending: 'created_asc',
},
},
{
id: 2,
title: __('Last updated'),
sortDirection: {
descending: 'updated_desc',
ascending: 'updated_asc',
},
},
];
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_TITLE_LENGTH = 255;
......@@ -5,6 +5,9 @@ query projectRequirements(
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
$authorUsernames: [String!] = []
$search: String = ""
$sortBy: Sort = created_desc
) {
project(fullPath: $projectPath) {
requirements(
......@@ -12,8 +15,10 @@ query projectRequirements(
last: $lastPageSize
after: $nextPageCursor
before: $prevPageCursor
sort: created_desc
state: $state
authorUsername: $authorUsernames
search: $search
sort: $sortBy
) {
nodes {
iid
......@@ -33,6 +38,8 @@ query projectRequirements(
}
}
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
......
......@@ -44,6 +44,9 @@ export default () => {
page,
next,
prev,
textSearch,
authorUsernames,
sortBy,
projectPath,
emptyStatePath,
opened,
......@@ -60,6 +63,9 @@ export default () => {
return {
initialFilterBy: stateFilterBy,
initialTextSearch: textSearch,
initialAuthorUsernames: authorUsernames ? JSON.parse(authorUsernames) : [],
initialSortBy: sortBy,
initialRequirementsCount: {
OPENED,
ARCHIVED,
......@@ -79,6 +85,9 @@ export default () => {
props: {
projectPath: this.projectPath,
initialFilterBy: this.initialFilterBy,
initialTextSearch: this.initialTextSearch,
initialAuthorUsernames: this.initialAuthorUsernames,
initialSortBy: this.initialSortBy,
initialRequirementsCount: this.initialRequirementsCount,
page: parseInt(this.page, 10) || 1,
prev: this.prev,
......
......@@ -20,6 +20,9 @@
page: params[:page],
prev: params[:prev],
next: params[:next],
text_search: params[:search],
author_usernames: params[:author_username],
sort_by: params[:sort],
project_path: @project.full_path,
opened: requirements_count['opened'],
archived: requirements_count['archived'],
......
......@@ -236,6 +236,21 @@ RSpec.describe 'Requirements list', :js do
end
end
end
context 'filtered search' do
it 'shows filtered search input field' do
page.within('.vue-filtered-search-bar-container') do
expect(page).to have_selector('input.gl-filtered-search-term-input')
end
end
it 'shows sort dropdown' do
page.within('.vue-filtered-search-bar-container') do
expect(page).to have_selector('.gl-new-dropdown button.gl-dropdown-toggle')
expect(page).to have_selector('.gl-new-dropdown ul.dropdown-menu', visible: false)
end
end
end
end
context 'when accessing project as guest user' do
......
......@@ -4,6 +4,9 @@ import { GlPagination } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import createFlash from '~/flash';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import RequirementsRoot from 'ee/requirements/components/requirements_root.vue';
import RequirementsTabs from 'ee/requirements/components/requirements_tabs.vue';
import RequirementsLoading from 'ee/requirements/components/requirements_loading.vue';
......@@ -19,15 +22,13 @@ import {
mockRequirementsOpen,
mockRequirementsCount,
mockPageInfo,
mockFilters,
} from '../mock_data';
jest.mock('ee/requirements/constants', () => ({
DEFAULT_PAGE_SIZE: 2,
FilterState: {
opened: 'OPENED',
archived: 'ARCHIVED',
all: 'ALL',
},
FilterState: jest.requireActual('ee/requirements/constants').FilterState,
AvailableSortOptions: jest.requireActual('ee/requirements/constants').AvailableSortOptions,
}));
jest.mock('~/flash');
......@@ -181,6 +182,32 @@ describe('RequirementsRoot', () => {
expect(wrapper.vm.showPaginationControls).toBe(false);
});
});
it.each`
hasPreviousPage | hasNextPage | isVisible
${true} | ${undefined} | ${true}
${undefined} | ${true} | ${true}
${false} | ${undefined} | ${false}
${undefined} | ${false} | ${false}
${false} | ${false} | ${false}
${true} | ${true} | ${true}
`(
'returns $isVisible when hasPreviousPage is $hasPreviousPage and hasNextPage is $hasNextPage within `requirements.pageInfo`',
({ hasPreviousPage, hasNextPage, isVisible }) => {
wrapper.setData({
requirements: {
pageInfo: {
hasPreviousPage,
hasNextPage,
},
},
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.showPaginationControls).toBe(isVisible);
});
},
);
});
describe('prevPage', () => {
......@@ -225,23 +252,37 @@ describe('RequirementsRoot', () => {
},
};
describe('updateUrl', () => {
it('updates window URL with query params `page` and `prev`', () => {
wrapper.vm.updateUrl({
page: 2,
prev: mockPageInfo.startCursor,
describe('getFilteredSearchValue', () => {
it('returns array containing applied filter search values', () => {
wrapper.setData({
authorUsernames: ['root', 'john.doe'],
textSearch: 'foo',
});
expect(global.window.location.href).toContain(`?page=2&prev=${mockPageInfo.startCursor}`);
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.getFilteredSearchValue()).toEqual(mockFilters);
});
});
});
it('updates window URL with query params `page` and `next`', () => {
wrapper.vm.updateUrl({
page: 1,
next: mockPageInfo.endCursor,
describe('updateUrl', () => {
it('updates window URL based on presence of props for filtered search and sort criteria', () => {
wrapper.setData({
filterBy: FilterState.all,
currentPage: 2,
nextPageCursor: mockPageInfo.endCursor,
authorUsernames: ['root', 'john.doe'],
textSearch: 'foo',
sortBy: 'updated_asc',
});
expect(global.window.location.href).toContain(`?page=1&next=${mockPageInfo.endCursor}`);
return wrapper.vm.$nextTick(() => {
wrapper.vm.updateUrl();
expect(global.window.location.href).toBe(
`http://localhost/?page=2&next=${mockPageInfo.endCursor}&state=all&search=foo&sort=updated_asc&author_username%5B%5D=root&author_username%5B%5D=john.doe`,
);
});
});
});
......@@ -613,45 +654,72 @@ describe('RequirementsRoot', () => {
});
});
describe('handlePageChange', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateUrl').mockImplementation(jest.fn());
describe('handleFilterRequirements', () => {
it('updates props tied to requirements Graph query', () => {
wrapper.vm.handleFilterRequirements(mockFilters);
expect(wrapper.vm.authorUsernames).toEqual(['root', 'john.doe']);
expect(wrapper.vm.textSearch).toBe('foo');
expect(wrapper.vm.currentPage).toBe(1);
expect(wrapper.vm.prevPageCursor).toBe('');
expect(wrapper.vm.nextPageCursor).toBe('');
expect(global.window.location.href).toBe(
`http://localhost/?page=1&state=opened&search=foo&sort=created_desc&author_username%5B%5D=root&author_username%5B%5D=john.doe`,
);
});
});
describe('handleSortRequirements', () => {
it('updates props tied to requirements Graph query', () => {
wrapper.vm.handleSortRequirements('updated_desc');
expect(wrapper.vm.sortBy).toBe('updated_desc');
expect(wrapper.vm.currentPage).toBe(1);
expect(wrapper.vm.prevPageCursor).toBe('');
expect(wrapper.vm.nextPageCursor).toBe('');
expect(global.window.location.href).toBe(
`http://localhost/?page=1&state=opened&sort=updated_desc`,
);
});
});
describe('handlePageChange', () => {
it('sets data prop `prevPageCursor` to empty string and `nextPageCursor` to `requirements.pageInfo.endCursor` when provided page param is greater than currentPage', () => {
wrapper.setData({
requirements: {
list: mockRequirementsOpen,
pageInfo: mockPageInfo,
},
currentPage: 1,
requirementsCount: mockRequirementsCount,
});
return wrapper.vm.$nextTick();
});
it('calls `updateUrl` with `page` and `next` params when value of page is `2`', () => {
wrapper.vm.handlePageChange(2);
expect(wrapper.vm.updateUrl).toHaveBeenCalledWith({
page: 2,
prev: '',
next: mockPageInfo.endCursor,
});
expect(wrapper.vm.prevPageCursor).toBe('');
expect(wrapper.vm.nextPageCursor).toBe(mockPageInfo.endCursor);
expect(global.window.location.href).toBe(
`http://localhost/?page=2&state=opened&sort=created_desc&next=${mockPageInfo.endCursor}`,
);
});
it('calls `updateUrl` with `page` and `next` params when value of page is `1`', () => {
it('sets data prop `nextPageCursor` to empty string and `prevPageCursor` to `requirements.pageInfo.startCursor` when provided page param is less than currentPage', () => {
wrapper.setData({
requirements: {
list: mockRequirementsOpen,
pageInfo: mockPageInfo,
},
currentPage: 2,
requirementsCount: mockRequirementsCount,
});
return wrapper.vm.$nextTick(() => {
wrapper.vm.handlePageChange(1);
wrapper.vm.handlePageChange(1);
expect(wrapper.vm.updateUrl).toHaveBeenCalledWith({
page: 1,
prev: mockPageInfo.startCursor,
next: '',
});
});
expect(wrapper.vm.prevPageCursor).toBe(mockPageInfo.startCursor);
expect(wrapper.vm.nextPageCursor).toBe('');
expect(global.window.location.href).toBe(
`http://localhost/?page=1&state=opened&sort=created_desc&prev=${mockPageInfo.startCursor}`,
);
});
});
});
......@@ -662,7 +730,27 @@ describe('RequirementsRoot', () => {
});
it('renders requirements-tabs component', () => {
expect(wrapper.find(RequirementsTabs).exists()).toBe(true);
expect(wrapper.contains(RequirementsTabs)).toBe(true);
});
it('renders filtered-search-bar component', () => {
expect(wrapper.contains(FilteredSearchBarRoot)).toBe(true);
expect(wrapper.find(FilteredSearchBarRoot).props('searchInputPlaceholder')).toBe(
'Search requirements',
);
expect(wrapper.find(FilteredSearchBarRoot).props('tokens')).toEqual([
{
type: 'author_username',
icon: 'user',
title: 'Author',
unique: false,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchPath: 'gitlab-org/gitlab-shell',
fetchAuthors: expect.any(Function),
},
]);
});
it('renders empty state when query results are empty', () => {
......@@ -676,7 +764,7 @@ describe('RequirementsRoot', () => {
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(RequirementsEmptyState).exists()).toBe(true);
expect(wrapper.contains(RequirementsEmptyState)).toBe(true);
});
});
......@@ -694,7 +782,7 @@ describe('RequirementsRoot', () => {
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(RequirementForm).exists()).toBe(true);
expect(wrapper.contains(RequirementForm)).toBe(true);
});
});
......@@ -704,7 +792,7 @@ describe('RequirementsRoot', () => {
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(RequirementsEmptyState).exists()).toBe(false);
expect(wrapper.contains(RequirementsEmptyState)).toBe(false);
});
});
......
......@@ -72,3 +72,15 @@ export const mockPageInfo = {
startCursor: 'eyJpZCI6IjI1IiwiY3JlYXRlZF9hdCI6IjIwMjAtMDMtMzEgMTM6MzI6MTQgVVRDIn0',
endCursor: 'eyJpZCI6IjIxIiwiY3JlYXRlZF9hdCI6IjIwMjAtMDMtMzEgMTM6MzE6MTUgVVRDIn0',
};
export const mockFilters = [
{
type: 'author_username',
value: { data: 'root' },
},
{
type: 'author_username',
value: { data: 'john.doe' },
},
'foo',
];
......@@ -6575,6 +6575,9 @@ msgstr ""
msgid "Created by me"
msgstr ""
msgid "Created date"
msgstr ""
msgid "Created issue %{issueLink}"
msgstr ""
......@@ -19170,6 +19173,9 @@ msgstr ""
msgid "Search projects..."
msgstr ""
msgid "Search requirements"
msgstr ""
msgid "Search users"
msgstr ""
......
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