Commit 4b6bca3a authored by David O'Regan's avatar David O'Regan Committed by Sean McGivern

Add incident token filter

Update incident filters to
allow token based search
with author and assignees
parent 59812e04
......@@ -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),
},
{
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 },
});
}
if (this.searchTerm) {
value.push(this.searchTerm);
}
return value;
},
},
methods: {
onInputChange: debounce(function debounceSearch(input) {
const trimmedInput = input.trim();
if (trimmedInput !== this.searchTerm) {
this.searchTerm = trimmedInput;
}
}, INCIDENT_SEARCH_DELAY),
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.
......
......@@ -18849,8 +18849,8 @@
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"name": "authorUsername",
"description": "Username of the author of the issue",
"type": {
"kind": "SCALAR",
"name": "String",
......@@ -18858,6 +18858,24 @@
},
"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
},
{
"name": "assigneeId",
"description": "ID of a user assigned to the issues, \"none\" and \"any\" values supported",
......@@ -36420,8 +36438,8 @@
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"name": "authorUsername",
"description": "Username of the author of the issue",
"type": {
"kind": "SCALAR",
"name": "String",
......@@ -36429,6 +36447,24 @@
},
"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
},
{
"name": "assigneeId",
"description": "ID of a user assigned to the issues, \"none\" and \"any\" values supported",
......@@ -36631,8 +36667,8 @@
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"name": "authorUsername",
"description": "Username of the author of the issue",
"type": {
"kind": "SCALAR",
"name": "String",
......@@ -36640,6 +36676,24 @@
},
"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
},
{
"name": "assigneeId",
"description": "ID of a user assigned to the issues, \"none\" and \"any\" values supported",
......@@ -36808,8 +36862,8 @@
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"name": "authorUsername",
"description": "Username of the author of the issue",
"type": {
"kind": "SCALAR",
"name": "String",
......@@ -36817,6 +36871,24 @@
},
"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
},
{
"name": "assigneeId",
"description": "ID of a user assigned to the issues, \"none\" and \"any\" values supported",
......@@ -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