Commit c801180b authored by Sean McGivern's avatar Sean McGivern

Merge branch '229404-filter-capabilities' into 'master'

Resolve "Add filter capabilities to Incident list"

Closes #229404

See merge request gitlab-org/gitlab!42377
parents bdf0f246 4b6bca3a
......@@ -8,7 +8,6 @@ import {
GlAvatar,
GlTooltipDirective,
GlButton,
GlSearchBoxByType,
GlIcon,
GlPagination,
GlTabs,
......@@ -16,16 +15,25 @@ import {
GlBadge,
GlEmptyState,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import Api from '~/api';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
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 { convertToSnakeCase } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
import { mergeUrlParams, joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import {
visitUrl,
mergeUrlParams,
joinPaths,
updateHistory,
setUrlParams,
} from '~/lib/utils/url_utility';
import getIncidents from '../graphql/queries/get_incidents.query.graphql';
import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
import { I18N, DEFAULT_PAGE_SIZE, INCIDENT_SEARCH_DELAY, INCIDENT_STATUS_TABS } from '../constants';
import { I18N, DEFAULT_PAGE_SIZE, INCIDENT_STATUS_TABS } from '../constants';
const TH_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
const tdClass =
......@@ -82,7 +90,6 @@ export default {
GlAvatar,
GlButton,
TimeAgoTooltip,
GlSearchBoxByType,
GlIcon,
GlPagination,
GlTabs,
......@@ -91,6 +98,7 @@ export default {
GlBadge,
GlEmptyState,
SeverityToken,
FilteredSearchBar,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -103,6 +111,9 @@ export default {
'issuePath',
'publishedAvailable',
'emptyListSvgPath',
'textQuery',
'authorUsernamesQuery',
'assigneeUsernamesQuery',
],
apollo: {
incidents: {
......@@ -118,6 +129,8 @@ export default {
lastPageSize: this.pagination.lastPageSize,
prevPageCursor: this.pagination.prevPageCursor,
nextPageCursor: this.pagination.nextPageCursor,
authorUsername: this.authorUsername,
assigneeUsernames: this.assigneeUsernames,
};
},
update({ project: { issues: { nodes = [], pageInfo = {} } = {} } = {} }) {
......@@ -135,6 +148,8 @@ export default {
variables() {
return {
searchTerm: this.searchTerm,
authorUsername: this.authorUsername,
assigneeUsernames: this.assigneeUsernames,
projectPath: this.projectPath,
issueTypes: ['INCIDENT'],
};
......@@ -149,7 +164,7 @@ export default {
errored: false,
isErrorAlertDismissed: false,
redirecting: false,
searchTerm: '',
searchTerm: this.textQuery,
pagination: initialPaginationState,
incidents: {},
sort: 'created_desc',
......@@ -157,6 +172,9 @@ export default {
sortDesc: true,
statusFilter: '',
filteredByStatus: '',
authorUsername: this.authorUsernamesQuery,
assigneeUsernames: this.assigneeUsernamesQuery,
filterParams: {},
};
},
computed: {
......@@ -242,14 +260,57 @@ export default {
btnText: createIncidentBtnLabel,
};
},
filteredSearchTokens() {
return [
{
type: 'author_username',
icon: 'user',
title: __('Author'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
methods: {
onInputChange: debounce(function debounceSearch(input) {
const trimmedInput = input.trim();
if (trimmedInput !== this.searchTerm) {
this.searchTerm = trimmedInput;
{
type: 'assignee_username',
icon: 'user',
title: __('Assignees'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
];
},
filteredSearchValue() {
const value = [];
if (this.authorUsername) {
value.push({
type: 'author_username',
value: { data: this.authorUsername },
});
}
if (this.assigneeUsernames) {
value.push({
type: 'assignee_username',
value: { data: this.assigneeUsernames },
});
}
}, INCIDENT_SEARCH_DELAY),
if (this.searchTerm) {
value.push(this.searchTerm);
}
return value;
},
},
methods: {
filterIncidentsByStatus(tabIndex) {
const { filters, status } = this.$options.statusTabs[tabIndex];
this.statusFilter = filters;
......@@ -292,6 +353,61 @@ export default {
getSeverity(severity) {
return INCIDENT_SEVERITY[severity];
},
handleFilterIncidents(filters) {
const filterParams = { authorUsername: '', assigneeUsername: [], search: '' };
filters.forEach(filter => {
if (typeof filter === 'object') {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
case 'assignee_username':
filterParams.assigneeUsername.push(filter.value.data);
break;
case 'filtered-search-term':
if (filter.value.data !== '') filterParams.search = filter.value.data;
break;
default:
break;
}
}
});
this.filterParams = filterParams;
this.updateUrl();
this.searchTerm = filterParams?.search;
this.authorUsername = filterParams?.authorUsername;
this.assigneeUsernames = filterParams?.assigneeUsername;
},
updateUrl() {
const queryParams = urlParamsToObject(window.location.search);
const { authorUsername, assigneeUsername, search } = this.filterParams || {};
if (authorUsername) {
queryParams.author_username = authorUsername;
} else {
delete queryParams.author_username;
}
if (assigneeUsername) {
queryParams.assignee_username = assigneeUsername;
} else {
delete queryParams.assignee_username;
}
if (search) {
queryParams.search = search;
} else {
delete queryParams.search;
}
updateHistory({
url: setUrlParams(queryParams, window.location.href, true),
title: document.title,
replace: true,
});
},
},
};
</script>
......@@ -331,12 +447,16 @@ export default {
</gl-button>
</div>
<div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100">
<gl-search-box-by-type
:value="searchTerm"
class="gl-bg-white"
:placeholder="$options.i18n.searchPlaceholder"
@input="onInputChange"
<div class="filtered-search-wrapper">
<filtered-search-bar
:namespace="projectPath"
:search-input-placeholder="$options.i18n.searchPlaceholder"
:tokens="filteredSearchTokens"
:initial-filter-value="filteredSearchValue"
initial-sortby="created_desc"
recent-searches-storage-key="incidents"
class="row-content-block"
@onFilter="handleFilterIncidents"
/>
</div>
......
......@@ -6,7 +6,7 @@ export const I18N = {
unassigned: s__('IncidentManagement|Unassigned'),
createIncidentBtnLabel: s__('IncidentManagement|Create incident'),
unPublished: s__('IncidentManagement|Unpublished'),
searchPlaceholder: __('Search results…'),
searchPlaceholder: __('Search or filter results…'),
emptyState: {
title: s__('IncidentManagement|Display your incidents in a dedicated view'),
emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'),
......@@ -34,5 +34,4 @@ export const INCIDENT_STATUS_TABS = [
},
];
export const INCIDENT_SEARCH_DELAY = 300;
export const DEFAULT_PAGE_SIZE = 20;
query getIncidentsCountByStatus($searchTerm: String, $projectPath: ID!, $issueTypes: [IssueType!]) {
query getIncidentsCountByStatus(
$searchTerm: String
$projectPath: ID!
$issueTypes: [IssueType!]
$authorUsername: String = ""
$assigneeUsernames: [String!] = []
) {
project(fullPath: $projectPath) {
issueStatusCounts(search: $searchTerm, types: $issueTypes) {
issueStatusCounts(
search: $searchTerm
types: $issueTypes
authorUsername: $authorUsername
assigneeUsername: $assigneeUsernames
) {
all
opened
closed
......
......@@ -9,7 +9,9 @@ query getIncidents(
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
$searchTerm: String
$searchTerm: String = ""
$authorUsername: String = ""
$assigneeUsernames: [String!] = []
) {
project(fullPath: $projectPath) {
issues(
......@@ -17,6 +19,8 @@ query getIncidents(
types: $issueTypes
sort: $sort
state: $status
authorUsername: $authorUsername
assigneeUsername: $assigneeUsernames
first: $firstPageSize
last: $lastPageSize
after: $nextPageCursor
......
......@@ -16,6 +16,9 @@ export default () => {
issuePath,
publishedAvailable,
emptyListSvgPath,
textQuery,
authorUsernamesQuery,
assigneeUsernamesQuery,
} = domEl.dataset;
const apolloProvider = new VueApollo({
......@@ -32,6 +35,9 @@ export default () => {
issuePath,
publishedAvailable,
emptyListSvgPath,
textQuery,
authorUsernamesQuery,
assigneeUsernamesQuery,
},
apolloProvider,
components: {
......
......@@ -18,7 +18,10 @@ module IssueResolverArguments
argument :milestone_title, GraphQL::STRING_TYPE.to_list_type,
required: false,
description: 'Milestone applied to this issue'
argument :assignee_username, GraphQL::STRING_TYPE,
argument :author_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of the author of the issue'
argument :assignee_username, [GraphQL::STRING_TYPE],
required: false,
description: 'Username of a user assigned to the issue'
argument :assignee_id, GraphQL::STRING_TYPE,
......
# frozen_string_literal: true
module Projects::IncidentsHelper
def incidents_data(project)
def incidents_data(project, params)
{
'project-path' => project.full_path,
'new-issue-path' => new_project_issue_path(project),
'incident-template-name' => 'incident',
'incident-type' => 'incident',
'issue-path' => project_issues_path(project),
'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg')
'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg'),
'text-query': params[:search],
'author-usernames-query': params[:author_username],
'assignee-usernames-query': params[:assignee_username]
}
end
end
......
- page_title _('Incidents')
#js-incidents{ data: incidents_data(@project) }
#js-incidents{ data: incidents_data(@project, params) }
---
title: Resolve Add filter capabilities to Incident list
merge_request: 42377
author:
type: changed
......@@ -6778,7 +6778,12 @@ type Group {
"""
Username of a user assigned to the issue
"""
assigneeUsername: String
assigneeUsername: [String!]
"""
Username of the author of the issue
"""
authorUsername: String
"""
Returns the elements in the list that come before the specified cursor.
......@@ -12248,7 +12253,12 @@ type Project {
"""
Username of a user assigned to the issue
"""
assigneeUsername: String
assigneeUsername: [String!]
"""
Username of the author of the issue
"""
authorUsername: String
"""
Issues closed after this date
......@@ -12338,7 +12348,12 @@ type Project {
"""
Username of a user assigned to the issue
"""
assigneeUsername: String
assigneeUsername: [String!]
"""
Username of the author of the issue
"""
authorUsername: String
"""
Issues closed after this date
......@@ -12418,7 +12433,12 @@ type Project {
"""
Username of a user assigned to the issue
"""
assigneeUsername: String
assigneeUsername: [String!]
"""
Username of the author of the issue
"""
authorUsername: String
"""
Returns the elements in the list that come before the specified cursor.
......
......@@ -18848,13 +18848,31 @@
},
"defaultValue": null
},
{
"name": "authorUsername",
"description": "Username of the author of the issue",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
......@@ -36419,13 +36437,31 @@
},
"defaultValue": null
},
{
"name": "authorUsername",
"description": "Username of the author of the issue",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
......@@ -36630,13 +36666,31 @@
},
"defaultValue": null
},
{
"name": "authorUsername",
"description": "Username of the author of the issue",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
......@@ -36807,13 +36861,31 @@
},
"defaultValue": null
},
{
"name": "authorUsername",
"description": "Username of the author of the issue",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
......@@ -6,7 +6,7 @@ module EE
extend ::Gitlab::Utils::Override
override :incidents_data
def incidents_data(project)
def incidents_data(project, params)
super.merge(
incidents_data_published_available(project)
)
......
......@@ -9,6 +9,13 @@ RSpec.describe Projects::IncidentsHelper do
let(:project_path) { project.full_path }
let(:new_issue_path) { new_project_issue_path(project) }
let(:issue_path) { project_issues_path(project) }
let(:params) do
{
search: 'search text',
author_username: 'root',
assignee_username: 'max.power'
}
end
describe '#incidents_data' do
let(:expected_incidents_data) do
......@@ -18,11 +25,14 @@ RSpec.describe Projects::IncidentsHelper do
'incident-template-name' => 'incident',
'incident-type' => 'incident',
'issue-path' => issue_path,
'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg')
'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'),
'text-query': 'search text',
'author-usernames-query': 'root',
'assignee-usernames-query': 'max.power'
}
end
subject { helper.incidents_data(project) }
subject { helper.incidents_data(project, params) }
before do
allow(project).to receive(:feature_available?).with(:status_page).and_return(status_page_feature_available)
......
......@@ -22242,9 +22242,6 @@ msgstr ""
msgid "Search requirements"
msgstr ""
msgid "Search results…"
msgstr ""
msgid "Search test cases"
msgstr ""
......
......@@ -5,7 +5,6 @@ import {
GlTable,
GlAvatar,
GlPagination,
GlSearchBoxByType,
GlTab,
GlTabs,
GlBadge,
......@@ -15,13 +14,18 @@ import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility';
import IncidentsList from '~/incidents/components/incidents_list.vue';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
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 { I18N, INCIDENT_STATUS_TABS } from '~/incidents/constants';
import mockIncidents from '../mocks/incidents.json';
import mockFilters from '../mocks/incidents_filter.json';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
joinPaths: jest.fn().mockName('joinPaths'),
mergeUrlParams: jest.fn().mockName('mergeUrlParams'),
setUrlParams: jest.fn().mockName('setUrlParams'),
updateHistory: jest.fn().mockName('updateHistory'),
}));
describe('Incidents List', () => {
......@@ -43,7 +47,7 @@ describe('Incidents List', () => {
const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip);
const findDateColumnHeader = () =>
wrapper.find('[data-testid="incident-management-created-at-sort"]');
const findSearch = () => wrapper.find(GlSearchBoxByType);
const findSearch = () => wrapper.find(FilteredSearchBar);
const findAssingees = () => wrapper.findAll('[data-testid="incident-assignees"]');
const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
......@@ -76,6 +80,9 @@ describe('Incidents List', () => {
issuePath: '/project/isssues',
publishedAvailable: true,
emptyListSvgPath,
textQuery: '',
authorUsernamesQuery: '',
assigneeUsernamesQuery: '',
},
stubs: {
GlButton: true,
......@@ -315,7 +322,7 @@ describe('Incidents List', () => {
});
});
describe('Search', () => {
describe('Filtered search component', () => {
beforeEach(() => {
mountComponent({
data: {
......@@ -331,15 +338,62 @@ describe('Incidents List', () => {
});
it('renders the search component for incidents', () => {
expect(findSearch().exists()).toBe(true);
expect(findSearch().props('searchInputPlaceholder')).toBe('Search or filter results…');
expect(findSearch().props('tokens')).toEqual([
{
type: 'author_username',
icon: 'user',
title: 'Author',
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchPath: '/project/path',
fetchAuthors: expect.any(Function),
},
{
type: 'assignee_username',
icon: 'user',
title: 'Assignees',
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchPath: '/project/path',
fetchAuthors: expect.any(Function),
},
]);
expect(findSearch().props('recentSearchesStorageKey')).toBe('incidents');
});
it('returns correctly applied filter search values', async () => {
const searchTerm = 'foo';
wrapper.setData({
searchTerm,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredSearchValue).toEqual([searchTerm]);
});
it('sets the `searchTerm` graphql variable', () => {
const SEARCH_TERM = 'Simple Incident';
it('updates props tied to getIncidents GraphQL query', () => {
wrapper.vm.handleFilterIncidents(mockFilters);
expect(wrapper.vm.authorUsername).toBe('root');
expect(wrapper.vm.assigneeUsernames).toEqual(['root2']);
expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data);
});
it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => {
wrapper.setData({
authorUsername: 'foo',
searchTerm: 'bar',
});
findSearch().vm.$emit('input', SEARCH_TERM);
wrapper.vm.handleFilterIncidents([]);
expect(wrapper.vm.$data.searchTerm).toBe(SEARCH_TERM);
expect(wrapper.vm.authorUsername).toBe('');
expect(wrapper.vm.searchTerm).toBe('');
});
});
......
[
{
"type": "assignee_username",
"value": { "data": "root2" }
},
{
"type": "author_username",
"value": { "data": "root" }
},
{
"type": "filtered-search-term",
"value": { "data": "bar" }
}
]
\ No newline at end of file
......@@ -54,10 +54,21 @@ RSpec.describe Resolvers::IssuesResolver do
expect(resolve_issues(assignee_id: IssuableFinder::Params::FILTER_ANY)).to contain_exactly(issue2)
end
it 'filters by two assignees' do
user_2 = create(:user)
issue2.update!(assignees: [assignee, user_2])
expect(resolve_issues(assignee_id: [assignee.id, user_2.id])).to contain_exactly(issue2)
end
it 'filters by no assignee' do
expect(resolve_issues(assignee_id: IssuableFinder::Params::FILTER_NONE)).to contain_exactly(issue1)
end
it 'filters by author' do
expect(resolve_issues(author_username: issue1.author.username)).to contain_exactly(issue1, issue2)
end
it 'filters by labels' do
expect(resolve_issues(label_name: [label1.title])).to contain_exactly(issue1, issue2)
expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2)
......
......@@ -9,9 +9,16 @@ RSpec.describe Projects::IncidentsHelper do
let(:project_path) { project.full_path }
let(:new_issue_path) { new_project_issue_path(project) }
let(:issue_path) { project_issues_path(project) }
let(:params) do
{
search: 'search text',
author_username: 'root',
assignee_username: 'max.power'
}
end
describe '#incidents_data' do
subject(:data) { helper.incidents_data(project) }
subject(:data) { helper.incidents_data(project, params) }
it 'returns frontend configuration' do
expect(data).to match(
......@@ -20,7 +27,10 @@ RSpec.describe Projects::IncidentsHelper do
'incident-template-name' => 'incident',
'incident-type' => 'incident',
'issue-path' => issue_path,
'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg')
'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'),
'text-query': 'search text',
'author-usernames-query': 'root',
'assignee-usernames-query': 'max.power'
)
end
end
......
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