Commit 2ba1fa8c authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '224614-filter-sort-requirements-by-status' into 'master'

Add status filtering token support

See merge request gitlab-org/gitlab!54056
parents 5b562979 9b685b9e
......@@ -96,18 +96,20 @@ As soon as a requirement is reopened, it no longer appears in the **Archived** t
## Search for a requirement
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/212543) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.1.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/212543) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.1.
> - Searching by status [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/224614) in GitLab 13.10.
You can search for a requirement from the requirements list page based on the following criteria:
- Requirement title
- Title
- Author's username
- Status (satisfied, failed, or missing)
To search for a requirement:
1. In a project, go to **Requirements > List**.
1. Select the **Search or filter results** field. A dropdown menu appears.
1. Select the requirement author from the dropdown or enter plain text to search by requirement title.
1. Select the requirement author or status from the dropdown or enter plain text to search by requirement title.
1. Press <kbd>Enter</kbd> on your keyboard to filter the list.
You can also sort the requirements list by:
......
......@@ -17,10 +17,6 @@ export default {
type: Object,
required: true,
},
lastTestReportManuallyCreated: {
type: Boolean,
required: true,
},
elementType: {
type: String,
required: false,
......@@ -51,15 +47,6 @@ export default {
tooltipTitle: '',
};
},
hideTestReportBadge() {
// User can manually indicate that a requirement has not been satisfied
// Internally, we create a test report with FAILED state with null build_id
// (this type of test report with null build id is said to be manually created).
// In this case, we do not show 'failed' badge.
return (
this.testReport.state === TestReportStatus.Failed && this.lastTestReportManuallyCreated
);
},
},
methods: {
getTestReportBadgeTarget() {
......@@ -70,7 +57,7 @@ export default {
</script>
<template>
<component :is="elementType" v-if="!hideTestReportBadge" class="requirement-status-badge">
<component :is="elementType" class="requirement-status-badge">
<gl-badge ref="testReportBadge" :variant="testReportBadge.variant">
<gl-icon :name="testReportBadge.icon" class="mr-1" />
{{ testReportBadge.text }}
......
......@@ -16,6 +16,7 @@ import {
FilterState,
AvailableSortOptions,
TestReportStatus,
TestReportStatusToValue,
DEFAULT_PAGE_SIZE,
} from '../constants';
import createRequirement from '../queries/createRequirement.mutation.graphql';
......@@ -31,6 +32,8 @@ import RequirementsEmptyState from './requirements_empty_state.vue';
import RequirementsLoading from './requirements_loading.vue';
import RequirementsTabs from './requirements_tabs.vue';
import StatusToken from './tokens/status_token.vue';
export default {
DEFAULT_PAGE_SIZE,
AvailableSortOptions,
......@@ -71,6 +74,11 @@ export default {
required: false,
default: () => [],
},
initialStatus: {
type: String,
required: false,
default: '',
},
initialRequirementsCount: {
type: Object,
required: true,
......@@ -145,6 +153,10 @@ export default {
queryVariables.authorUsernames = this.authorUsernames;
}
if (this.status) {
queryVariables.status = TestReportStatusToValue[this.status];
}
if (this.sortBy) {
queryVariables.sortBy = this.sortBy;
}
......@@ -202,6 +214,7 @@ export default {
filterBy: this.initialFilterBy,
textSearch: this.initialTextSearch,
authorUsernames: this.initialAuthorUsernames,
status: this.initialStatus,
sortBy: this.initialSortBy,
showRequirementCreateDrawer: false,
showRequirementViewDrawer: false,
......@@ -233,11 +246,7 @@ export default {
return this.$apollo.queries.requirements.loading;
},
requirementsListEmpty() {
return (
!this.$apollo.queries.requirements.loading &&
!this.requirements.list.length &&
this.requirementsCount[this.filterBy] === 0
);
return !this.$apollo.queries.requirements.loading && !this.requirementsList.length;
},
totalRequirementsForCurrentTab() {
return this.requirementsCount[this.filterBy];
......@@ -279,6 +288,14 @@ export default {
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
{
type: 'status',
icon: 'status',
title: __('Status'),
unique: true,
token: StatusToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
},
];
},
getFilteredSearchValue() {
......@@ -287,8 +304,18 @@ export default {
value: { data: author },
}));
if (this.status) {
value.push({
type: 'status',
value: { data: this.status },
});
}
if (this.textSearch) {
value.push(this.textSearch);
value.push({
type: 'filtered-search-term',
value: { data: this.textSearch },
});
}
return value;
......@@ -307,6 +334,7 @@ export default {
nextPageCursor,
textSearch,
authorUsernames,
status,
sortBy,
} = this;
......@@ -347,6 +375,12 @@ export default {
queryParams['author_username[]'] = authorUsernames;
}
if (status) {
queryParams.status = status;
} else {
delete queryParams.status;
}
// We want to replace the history state so that back button
// correctly reloads the page with previous URL.
updateHistory({
......@@ -570,23 +604,35 @@ export default {
},
handleFilterRequirements(filters = []) {
const authors = [];
let textSearch = '';
let status = '';
const textSearch = [];
filters.forEach((filter) => {
if (typeof filter === 'string') {
textSearch = filter;
} else if (filter.value.data !== DEFAULT_LABEL_ANY.value) {
authors.push(filter.value.data);
switch (filter.type) {
case 'author_username':
if (filter.value.data !== DEFAULT_LABEL_ANY.value) {
authors.push(filter.value.data);
}
break;
case 'status':
status = filter.value.data;
break;
case 'filtered-search-term':
if (filter.value.data) textSearch.push(filter.value.data);
break;
default:
break;
}
});
this.authorUsernames = [...authors];
this.textSearch = textSearch;
this.status = status;
this.textSearch = textSearch.join(' ');
this.currentPage = 1;
this.prevPageCursor = '';
this.nextPageCursor = '';
if (textSearch || authors.length) {
if (textSearch.length || authors.length || status) {
this.track('filter', {
property: JSON.stringify(filters),
});
......
<script>
import { GlIcon, GlToken, GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
statuses: [
{
id: 1,
value: 'satisfied',
text: __('Satisfied'),
icon: 'status_success',
iconClass: 'gl-text-green-700',
containerClass: 'gl-bg-green-100 gl-text-gray-900',
},
{
id: 2,
value: 'failed',
text: __('Failed'),
icon: 'status_failed',
iconClass: 'gl-text-red-700',
containerClass: 'gl-bg-red-100 gl-text-gray-900',
},
{
id: 3,
value: 'missing',
text: __('Missing'),
icon: 'status-waiting',
iconClass: 'gl-text-gray-900',
containerClass: 'gl-bg-gray-100 gl-text-gray-900',
},
],
components: {
GlIcon,
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
computed: {
activeStatus() {
return this.$options.statuses.find((status) => status.value === this.value.data);
},
},
};
</script>
<template>
<gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<template #view-token>
<gl-token
v-if="activeStatus"
variant="search-value"
:class="['gl-display-flex', activeStatus.containerClass]"
>
<gl-icon :name="activeStatus.icon" :class="activeStatus.iconClass" />
<div class="gl-ml-2">{{ activeStatus.text }}</div>
</gl-token>
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="status in $options.statuses"
:key="status.id"
:value="status.value"
>
<div class="gl-display-flex">
<gl-icon :name="status.icon" :class="status.iconClass" />
<div class="gl-ml-2">{{ status.text }}</div>
</div>
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
</template>
......@@ -35,6 +35,12 @@ export const TestReportStatus = {
Failed: 'FAILED',
};
export const TestReportStatusToValue = {
satisfied: 'PASSED',
failed: 'FAILED',
missing: 'MISSING',
};
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_TITLE_LENGTH = 255;
......@@ -9,6 +9,7 @@ query projectRequirementsEE(
$prevPageCursor: String = ""
$nextPageCursor: String = ""
$authorUsernames: [String!] = []
$status: RequirementStatusFilter
$search: String = ""
$sortBy: Sort = CREATED_DESC
) {
......@@ -20,6 +21,7 @@ query projectRequirementsEE(
before: $prevPageCursor
state: $state
authorUsername: $authorUsernames
lastTestReportState: $status
search: $search
sort: $sortBy
) {
......
......@@ -50,6 +50,7 @@ export default () => {
prev,
textSearch,
authorUsernames,
status,
sortBy,
projectPath,
emptyStatePath,
......@@ -71,6 +72,7 @@ export default () => {
initialFilterBy: stateFilterBy,
initialTextSearch: textSearch,
initialAuthorUsernames: authorUsernames ? JSON.parse(authorUsernames) : [],
initialStatus: status,
initialSortBy: sortBy,
initialRequirementsCount: {
OPENED,
......@@ -95,6 +97,7 @@ export default () => {
initialFilterBy: this.initialFilterBy,
initialTextSearch: this.initialTextSearch,
initialAuthorUsernames: this.initialAuthorUsernames,
initialStatus: this.initialStatus,
initialSortBy: this.initialSortBy,
initialRequirementsCount: this.initialRequirementsCount,
page: parseInt(this.page, 10) || 1,
......
......@@ -24,6 +24,7 @@
next: params[:next],
text_search: params[:search],
author_usernames: params[:author_username],
status: params[:status],
sort_by: params[:sort],
project_path: @project.full_path,
opened: requirements_count['opened'],
......
---
title: Add status filtering token support for Requirements
merge_request: 54056
author:
type: added
......@@ -57,6 +57,45 @@ RSpec.describe 'Requirements list', :js do
end
end
it 'shows filtered search input' do
page.within('.requirements-list-container .vue-filtered-search-bar-container') do
expect(page).to have_selector('.gl-search-box-by-click')
expect(page.find('.gl-filtered-search-term-input')[:placeholder]).to eq('Search requirements')
expect(page).to have_selector('.sort-dropdown-container')
page.find('.sort-dropdown-container button.gl-dropdown-toggle').click
expect(page.find('.sort-dropdown-container')).to have_selector('li', count: 2)
end
end
context 'filtered search input' do
it 'shows filter tokens author and status' do
page.within('.vue-filtered-search-bar-container .gl-search-box-by-click') do
page.find('input').click
expect(page.find('.gl-filtered-search-suggestion-list')).to have_selector('li', count: 2)
page.within('.gl-filtered-search-suggestion-list') do
expect(page.find('li:nth-child(1)')).to have_content('Author')
expect(page.find('li:nth-child(2)')).to have_content('Status')
end
end
end
it 'shows options `satisfied`, `failed` and `missing` for status token' do
page.within('.vue-filtered-search-bar-container .gl-search-box-by-click') do
page.find('input').click
page.find('.gl-filtered-search-suggestion-list li:nth-child(2)').click
expect(page.find('.gl-filtered-search-suggestion-list')).to have_selector('li', count: 3)
page.within('.gl-filtered-search-suggestion-list') do
expect(page.find('li:nth-child(1)')).to have_content('Satisfied')
expect(page.find('li:nth-child(2)')).to have_content('Failed')
expect(page.find('li:nth-child(3)')).to have_content('Missing')
end
end
end
end
context 'new requirement' do
it 'shows requirement create form when "New requirement" button is clicked' do
page.within('.nav-controls') do
......
......@@ -112,20 +112,9 @@ describe('RequirementStatusBadge', () => {
describe(`when the last test report's been manually created`, () => {
it('renders GlBadge component when status is "PASSED"', () => {
wrapper = createComponent({ lastTestReportManuallyCreated: true });
expect(findGlBadge(wrapper).exists()).toBe(true);
expect(findGlBadge(wrapper).text()).toBe('satisfied');
});
it('does not render GlBadge component when status is "FAILED"', () => {
wrapper = createComponent({
testReport: mockTestReportFailed,
lastTestReportManuallyCreated: true,
});
expect(findGlBadge(wrapper).exists()).toBe(false);
});
});
});
});
......@@ -15,7 +15,6 @@ import { TEST_HOST } from 'helpers/test_constants';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
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 {
FilterState,
......@@ -23,6 +22,8 @@ import {
mockRequirementsCount,
mockPageInfo,
mockFilters,
mockAuthorToken,
mockStatusToken,
} from '../mock_data';
jest.mock('ee/requirements/constants', () => ({
......@@ -106,7 +107,7 @@ describe('RequirementsRoot', () => {
wrapperLoading.destroy();
});
it('returns `false` when `requirements.list` is empty', () => {
it('returns `true` when `requirements.list` is empty', () => {
wrapper.setData({
requirements: {
list: [],
......@@ -114,7 +115,7 @@ describe('RequirementsRoot', () => {
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.requirementsListEmpty).toBe(false);
expect(wrapper.vm.requirementsListEmpty).toBe(true);
});
});
......@@ -272,6 +273,7 @@ describe('RequirementsRoot', () => {
it('returns array containing applied filter search values', () => {
wrapper.setData({
authorUsernames: ['root', 'john.doe'],
status: 'satisfied',
textSearch: 'foo',
});
......@@ -828,31 +830,35 @@ describe('RequirementsRoot', () => {
wrapper.vm.handleFilterRequirements(mockFilters);
expect(wrapper.vm.authorUsernames).toEqual(['root', 'john.doe']);
expect(wrapper.vm.status).toBe('satisfied');
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(
`${TEST_HOST}/?page=1&state=opened&search=foo&sort=created_desc&author_username%5B%5D=root&author_username%5B%5D=john.doe`,
`${TEST_HOST}/?page=1&state=opened&search=foo&sort=created_desc&author_username%5B%5D=root&author_username%5B%5D=john.doe&status=satisfied`,
);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'filter', {
property: JSON.stringify([
{ type: 'author_username', value: { data: 'root' } },
{ type: 'author_username', value: { data: 'john.doe' } },
'foo',
{ type: 'status', value: { data: 'satisfied' } },
{ type: 'filtered-search-term', value: { data: 'foo' } },
]),
});
});
it('updates props `textSearch` and `authorUsernames` with empty values when passed filters param is empty', () => {
wrapper.setData({
authorUsernames: ['foo'],
textSearch: 'bar',
authorUsernames: ['root'],
status: 'satisfied',
textSearch: 'foo',
});
wrapper.vm.handleFilterRequirements([]);
expect(wrapper.vm.authorUsernames).toEqual([]);
expect(wrapper.vm.status).toBe('');
expect(wrapper.vm.textSearch).toBe('');
expect(trackingSpy).not.toHaveBeenCalled();
});
......@@ -934,17 +940,8 @@ describe('RequirementsRoot', () => {
'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),
},
mockAuthorToken,
mockStatusToken,
]);
expect(wrapper.find(FilteredSearchBarRoot).props('recentSearchesStorageKey')).toBe(
'requirements',
......
import { GlIcon, GlFilteredSearchToken, GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import StatusToken from 'ee/requirements/components/tokens/status_token.vue';
import { stubComponent } from 'helpers/stub_component';
import { mockStatusToken } from '../../mock_data';
const mockStatuses = [
{
text: 'Satisfied',
icon: 'status_success',
},
{
text: 'Failed',
icon: 'status_failed',
},
{
text: 'Missing',
icon: 'status-waiting',
},
];
function createComponent(options = {}) {
const { config = mockStatusToken, value = { data: '' } } = options;
return shallowMount(StatusToken, {
propsData: {
config,
value,
},
stubs: {
GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, {
template: `
<div>
<slot name="view-token"></slot>
<slot name="suggestions"></slot>
</div>
`,
}),
},
});
}
describe('StatusToken', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders gl-filtered-search-token component', () => {
const token = wrapper.find(GlFilteredSearchToken);
expect(token.exists()).toBe(true);
expect(token.props('config')).toMatchObject(mockStatusToken);
});
it.each`
value | text | icon
${'satisfied'} | ${'Satisfied'} | ${'status_success'}
${'failed'} | ${'Failed'} | ${'status_failed'}
${'missing'} | ${'Missing'} | ${'status-waiting'}
`(
'renders token icon and text representing status "$text" when `value.data` is set to "$value"',
({ value, text, icon }) => {
wrapper = createComponent({ value: { data: value } });
expect(wrapper.find(GlToken).text()).toContain(text);
expect(wrapper.find(GlIcon).props('name')).toBe(icon);
},
);
it('renders provided statuses as suggestions', async () => {
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
expect(suggestions).toHaveLength(mockStatuses.length);
mockStatuses.forEach((status, index) => {
const iconEl = suggestions.at(index).find(GlIcon);
expect(iconEl.exists()).toBe(true);
expect(iconEl.props('name')).toBe(status.icon);
expect(suggestions.at(index).text()).toBe(status.text);
});
});
});
});
import StatusToken from 'ee/requirements/components/tokens/status_token.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
export const mockUserPermissions = {
updateRequirement: true,
adminRequirement: true,
......@@ -26,7 +29,7 @@ export const mockTestReportFailed = {
export const mockTestReportMissing = {
id: 'gid://gitlab/RequirementsManagement::TestReport/1',
state: '',
state: 'MISSING',
createdAt: '2020-06-04T10:55:48Z',
__typename: 'TestReport',
};
......@@ -141,5 +144,33 @@ export const mockFilters = [
type: 'author_username',
value: { data: 'john.doe' },
},
'foo',
{
type: 'status',
value: { data: 'satisfied' },
},
{
type: 'filtered-search-term',
value: { data: 'foo' },
},
];
export const mockAuthorToken = {
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),
};
export const mockStatusToken = {
type: 'status',
icon: 'status',
title: 'Status',
unique: true,
token: StatusToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
};
......@@ -19638,6 +19638,9 @@ msgstr ""
msgid "Mirroring will only be available if the feature is included in the plan of the selected group or user."
msgstr ""
msgid "Missing"
msgstr ""
msgid "Missing OAuth configuration for GitHub."
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