[Cases] Enable case search by ID (#149233)

Fixes #148084

[The uuid PR was merged](https://github.com/elastic/kibana/pull/149135)
so I am removing the `draft` status here.

## Summary

This PR introduces search by UUID in the Cases table. 

If a user puts a UUID in the search bar and presses enter the search
result will now return the case with that ID.

Additionally, we look for the matches of that search text in the title
and description fields.

See the example below:

<img width="1554" alt="Screenshot 2023-01-19 at 16 06 53"
src="https://user-images.githubusercontent.com/1533137/213477884-498d34c0-d4d1-405d-8d76-f077d46157aa.png">

We are searching for `733e1c40-9586-11ed-a29f-8b57be9cf211`. There are
two matches because that search text matches the ID of a case and the
title of another.

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))


### Release notes

Users can now search for Cases by ID.

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Antonio 2023-01-25 12:27:11 +01:00 committed by GitHub
parent 7f2bf935ec
commit a04a03b438
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 324 additions and 43 deletions

View file

@ -213,6 +213,10 @@ export const CasesFindRequestRt = rt.partial({
* The fields to perform the simple_query_string parsed query against
*/
searchFields: rt.union([rt.array(rt.string), rt.string]),
/**
* The root fields to perform the simple_query_string parsed query against
*/
rootSearchFields: rt.array(rt.string),
/**
* The field to use for sorting the found objects.
*

View file

@ -64,7 +64,7 @@ export const INCIDENT_MANAGEMENT_SYSTEM = i18n.translate('xpack.cases.caseTable.
});
export const SEARCH_PLACEHOLDER = i18n.translate('xpack.cases.caseTable.searchPlaceholder', {
defaultMessage: 'e.g. case name',
defaultMessage: 'Search cases',
});
export const CLOSED = i18n.translate('xpack.cases.caseTable.closed', {

View file

@ -14,7 +14,9 @@ export type AuthorizationMock = jest.Mocked<Schema>;
export const createAuthorizationMock = () => {
const mocked: AuthorizationMock = {
ensureAuthorized: jest.fn(),
getAuthorizationFilter: jest.fn(),
getAuthorizationFilter: jest.fn().mockImplementation(async () => {
return { filter: undefined, ensureSavedObjectsAreAuthorized: () => {} };
}),
getAndEnsureAuthorizedEntities: jest.fn(),
};
return mocked;

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { v1 as uuidv1 } from 'uuid';
import type { CaseResponse } from '../../../common/api';
import { flattenCaseSavedObject } from '../../common/utils';
import { mockCases } from '../../mocks';
import { createCasesClientMockArgs, createCasesClientMockFindRequest } from '../mocks';
import { find } from './find';
describe('find', () => {
describe('constructSearch', () => {
const clientArgs = createCasesClientMockArgs();
const casesMap = new Map<string, CaseResponse>(
mockCases.map((obj) => {
return [obj.id, flattenCaseSavedObject({ savedObject: obj, totalComment: 2 })];
})
);
clientArgs.services.caseService.findCasesGroupedByID.mockResolvedValue({
page: 1,
perPage: 10,
total: casesMap.size,
casesMap,
});
clientArgs.services.caseService.getCaseStatusStats.mockResolvedValue({
open: 1,
'in-progress': 2,
closed: 3,
});
afterEach(() => {
jest.clearAllMocks();
});
it('search by uuid updates search term and adds rootSearchFields', async () => {
const search = uuidv1();
const findRequest = createCasesClientMockFindRequest({ search });
await find(findRequest, clientArgs);
await expect(clientArgs.services.caseService.findCasesGroupedByID).toHaveBeenCalled();
const call = clientArgs.services.caseService.findCasesGroupedByID.mock.calls[0][0];
expect(call.caseOptions.search).toBe(`"${search}" "cases:${search}"`);
expect(call.caseOptions).toHaveProperty('rootSearchFields');
expect(call.caseOptions.rootSearchFields).toStrictEqual(['_id']);
});
it('regular search term does not cause rootSearchFields to be appended', async () => {
const search = 'foobar';
const findRequest = createCasesClientMockFindRequest({ search });
await find(findRequest, clientArgs);
await expect(clientArgs.services.caseService.findCasesGroupedByID).toHaveBeenCalled();
const call = clientArgs.services.caseService.findCasesGroupedByID.mock.calls[0][0];
expect(call.caseOptions.search).toBe(search);
expect(call.caseOptions).not.toHaveProperty('rootSearchFields');
});
});
});

View file

@ -16,7 +16,7 @@ import { CasesFindRequestRt, throwErrors, CasesFindResponseRt, excess } from '..
import { createCaseError } from '../../common/error';
import { asArray, transformCases } from '../../common/utils';
import { constructQueryOptions } from '../utils';
import { constructQueryOptions, constructSearch } from '../utils';
import { includeFieldsRequiredForAuthentication } from '../../authorization/utils';
import { Operations } from '../../authorization';
import type { CasesClientArgs } from '..';
@ -36,6 +36,8 @@ export const find = async (
services: { caseService, licensingService },
authorization,
logger,
savedObjectsSerializer,
spaceId,
} = clientArgs;
try {
@ -85,11 +87,14 @@ export const find = async (
const caseQueryOptions = constructQueryOptions({ ...queryArgs, authorizationFilter });
const caseSearch = constructSearch(queryParams.search, spaceId, savedObjectsSerializer);
const [cases, statusStats] = await Promise.all([
caseService.findCasesGroupedByID({
caseOptions: {
...queryParams,
...caseQueryOptions,
...caseSearch,
searchFields: asArray(queryParams.searchFields),
fields: includeFieldsRequiredForAuthentication(fields),
},

View file

@ -145,6 +145,7 @@ export class CasesClientFactory {
securityStartPlugin: this.options.securityPluginStart,
publicBaseUrl: this.options.publicBaseUrl,
spaceId: this.options.spacesPluginStart.spacesService.getSpaceId(request),
savedObjectsSerializer,
});
}

View file

@ -181,9 +181,6 @@ function createMockClientArgs() {
});
const authorization = createAuthorizationMock();
authorization.getAuthorizationFilter.mockImplementation(async () => {
return { filter: undefined, ensureSavedObjectsAreAuthorized: () => {} };
});
const soClient = savedObjectsClientMock.create();

View file

@ -19,9 +19,6 @@ export function createMockClient() {
export function createMockClientArgs() {
const authorization = createAuthorizationMock();
authorization.getAuthorizationFilter.mockImplementation(async () => {
return { filter: undefined, ensureSavedObjectsAreAuthorized: () => {} };
});
const soClient = savedObjectsClientMock.create();

View file

@ -7,11 +7,29 @@
import type { PublicContract, PublicMethodsOf } from '@kbn/utility-types';
import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import type { ISavedObjectsSerializer } from '@kbn/core-saved-objects-server';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client.mock';
import { makeLensEmbeddableFactory } from '@kbn/lens-plugin/server/embeddable/make_lens_embeddable_factory';
import { serializerMock } from '@kbn/core-saved-objects-base-server-mocks';
import type { CasesFindRequest } from '../../common/api';
import type { CasesClient } from '.';
import type { AttachmentsSubClient } from './attachments/client';
import type { CasesSubClient } from './cases/client';
import type { ConfigureSubClient } from './configure/client';
import type { CasesClientFactory } from './factory';
import type { MetricsSubClient } from './metrics/client';
import type { UserActionsSubClient } from './user_actions/client';
import { CaseStatuses } from '../../common';
import { CaseSeverity } from '../../common/api';
import { SortFieldCase } from '../../public/containers/types';
import {
createExternalReferenceAttachmentTypeRegistryMock,
createPersistableStateAttachmentTypeRegistryMock,
} from '../attachment_framework/mocks';
import { createAuthorizationMock } from '../authorization/mock';
import {
connectorMappingsServiceMock,
@ -23,16 +41,6 @@ import {
createUserActionServiceMock,
createNotificationServiceMock,
} from '../services/mocks';
import type { AttachmentsSubClient } from './attachments/client';
import type { CasesSubClient } from './cases/client';
import type { ConfigureSubClient } from './configure/client';
import type { CasesClientFactory } from './factory';
import type { MetricsSubClient } from './metrics/client';
import type { UserActionsSubClient } from './user_actions/client';
import {
createExternalReferenceAttachmentTypeRegistryMock,
createPersistableStateAttachmentTypeRegistryMock,
} from '../attachment_framework/mocks';
type CasesSubClientMock = jest.Mocked<CasesSubClient>;
@ -127,6 +135,20 @@ export const createCasesClientFactory = (): CasesClientFactoryMock => {
return factory as unknown as CasesClientFactoryMock;
};
type SavedObjectsSerializerMock = jest.Mocked<ISavedObjectsSerializer>;
export const createSavedObjectsSerializerMock = (): SavedObjectsSerializerMock => {
const serializer = serializerMock.create();
serializer.generateRawId.mockImplementation(
(namespace: string | undefined, type: string, id: string) => {
const namespacePrefix = namespace ? `${namespace}:` : '';
return `${namespacePrefix}${type}:${id}`;
}
);
return serializer;
};
export const createCasesClientMockArgs = () => {
return {
services: {
@ -160,5 +182,22 @@ export const createCasesClientMockArgs = () => {
{}
)
),
savedObjectsSerializer: createSavedObjectsSerializerMock(),
};
};
export const createCasesClientMockFindRequest = (
overwrites?: CasesFindRequest
): CasesFindRequest => ({
search: '',
searchFields: ['title', 'description'],
severity: CaseSeverity.LOW,
assignees: [],
reporters: [],
status: CaseStatuses.open,
tags: [],
owner: [],
sortField: SortFieldCase.createdAt,
sortOrder: 'desc',
...overwrites,
});

View file

@ -11,6 +11,7 @@ import type { ActionsClient } from '@kbn/actions-plugin/server';
import type { LensServerPluginSetup } from '@kbn/lens-plugin/server';
import type { SecurityPluginStart } from '@kbn/security-plugin/server';
import type { IBasePath } from '@kbn/core-http-browser';
import type { ISavedObjectsSerializer } from '@kbn/core-saved-objects-server';
import type { KueryNode } from '@kbn/es-query';
import type { CasesFindRequest, User } from '../../common/api';
import type { Authorization } from '../authorization/authorization';
@ -53,6 +54,7 @@ export interface CasesClientArgs {
readonly externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
readonly securityStartPlugin: SecurityPluginStart;
readonly spaceId: string;
readonly savedObjectsSerializer: ISavedObjectsSerializer;
readonly publicBaseUrl?: IBasePath['publicBaseUrl'];
}

View file

@ -5,17 +5,23 @@
* 2.0.
*/
import { v1 as uuidv1 } from 'uuid';
import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server';
import { toElasticsearchQuery } from '@kbn/es-query';
import { CaseStatuses } from '../../common';
import { CaseSeverity } from '../../common/api';
import { ESCaseSeverity, ESCaseStatus } from '../services/cases/types';
import { createSavedObjectsSerializerMock } from './mocks';
import {
arraysDifference,
buildNestedFilter,
buildRangeFilter,
constructQueryOptions,
constructSearch,
convertSortField,
} from './utils';
import { toElasticsearchQuery } from '@kbn/es-query';
import { CaseStatuses } from '../../common';
import { CaseSeverity } from '../../common/api';
import { ESCaseSeverity, ESCaseStatus } from '../services/cases/types';
describe('utils', () => {
describe('convertSortField', () => {
@ -916,4 +922,36 @@ describe('utils', () => {
});
});
});
describe('constructSearchById', () => {
const savedObjectsSerializer = createSavedObjectsSerializerMock();
it('returns the rootSearchFields and search with correct values when given a uuid', () => {
const uuid = uuidv1(); // the specific version is irrelevant
expect(constructSearch(uuid, DEFAULT_NAMESPACE_STRING, savedObjectsSerializer))
.toMatchInlineSnapshot(`
Object {
"rootSearchFields": Array [
"_id",
],
"search": "\\"${uuid}\\" \\"cases:${uuid}\\"",
}
`);
});
it('search value not changed and no rootSearchFields when search is non-uuid', () => {
const search = 'foobar';
const result = constructSearch(search, DEFAULT_NAMESPACE_STRING, savedObjectsSerializer);
expect(result).not.toHaveProperty('rootSearchFields');
expect(result).toEqual({ search });
});
it('returns undefined if search term undefined', () => {
expect(constructSearch(undefined, DEFAULT_NAMESPACE_STRING, savedObjectsSerializer)).toEqual(
undefined
);
});
});
});

View file

@ -11,22 +11,24 @@ import deepEqual from 'fast-deep-equal';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import { validate as uuidValidate } from 'uuid';
import type { ISavedObjectsSerializer } from '@kbn/core-saved-objects-server';
import type { KueryNode } from '@kbn/es-query';
import { nodeBuilder, fromKueryExpression, escapeKuery } from '@kbn/es-query';
import {
isCommentRequestTypeExternalReference,
isCommentRequestTypePersistableState,
} from '../../common/utils/attachments';
import { CASE_SAVED_OBJECT, NO_ASSIGNEES_FILTERING_KEYWORD } from '../../common/constants';
import { SEVERITY_EXTERNAL_TO_ESMODEL, STATUS_EXTERNAL_TO_ESMODEL } from '../common/constants';
import { nodeBuilder, fromKueryExpression, escapeKuery } from '@kbn/es-query';
import { spaceIdToNamespace } from '@kbn/spaces-plugin/server/lib/utils/namespace';
import type {
CaseStatuses,
CommentRequest,
CaseSeverity,
CommentRequestExternalReferenceType,
CasesFindRequest,
} from '../../common/api';
import type { SavedObjectFindOptionsKueryNode } from '../common/types';
import type { CasesFindQueryParams } from './types';
import {
OWNER_FIELD,
AlertCommentRequestRt,
@ -39,7 +41,13 @@ import {
ExternalReferenceNoSORt,
PersistableStateAttachmentRt,
} from '../../common/api';
import { CASE_SAVED_OBJECT, NO_ASSIGNEES_FILTERING_KEYWORD } from '../../common/constants';
import {
isCommentRequestTypeExternalReference,
isCommentRequestTypePersistableState,
} from '../../common/utils/attachments';
import { combineFilterWithAuthorizationFilter } from '../authorization/utils';
import { SEVERITY_EXTERNAL_TO_ESMODEL, STATUS_EXTERNAL_TO_ESMODEL } from '../common/constants';
import {
getIDsAndIndicesAsArrays,
isCommentRequestTypeAlert,
@ -47,8 +55,6 @@ import {
isCommentRequestTypeActions,
assertUnreachable,
} from '../common/utils';
import type { SavedObjectFindOptionsKueryNode } from '../common/types';
import type { CasesFindQueryParams } from './types';
export const decodeCommentRequest = (comment: CommentRequest) => {
if (isCommentRequestTypeUser(comment)) {
@ -537,3 +543,28 @@ export const convertSortField = (sortField: string | undefined): SortFieldCase =
return SortFieldCase.createdAt;
}
};
export const constructSearch = (
search: string | undefined,
spaceId: string,
savedObjectsSerializer: ISavedObjectsSerializer
): Pick<CasesFindRequest, 'search' | 'rootSearchFields'> | undefined => {
if (!search) {
return undefined;
}
if (uuidValidate(search)) {
const rawId = savedObjectsSerializer.generateRawId(
spaceIdToNamespace(spaceId),
CASE_SAVED_OBJECT,
search
);
return {
search: `"${search}" "${rawId}"`,
rootSearchFields: ['_id'],
};
}
return { search };
};

View file

@ -54,6 +54,7 @@
"@kbn/ecs",
"@kbn/core-saved-objects-api-server",
"@kbn/core-saved-objects-base-server-mocks",
"@kbn/core-saved-objects-utils-server",
],
"exclude": [
"target/**/*",

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { v1 as uuidv1 } from 'uuid';
import expect from '@kbn/expect';
import { CASES_URL } from '@kbn/cases-plugin/common/constants';
import {
@ -345,6 +347,10 @@ export default ({ getService }: FtrProviderContext): void => {
await createCase(supertest, postCaseReq);
});
afterEach(async () => {
await deleteAllCaseItems(es);
});
it('should successfully find a case when using valid searchFields', async () => {
const cases = await findCases({
supertest,
@ -363,6 +369,44 @@ export default ({ getService }: FtrProviderContext): void => {
expect(cases.total).to.be(1);
});
it('should successfully find a case when using a valid uuid', async () => {
const caseWithId = await createCase(supertest, postCaseReq);
const cases = await findCases({
supertest,
query: { searchFields: ['title', 'description'], search: caseWithId.id },
});
expect(cases.total).to.be(1);
expect(cases.cases[0].id).to.equal(caseWithId.id);
});
it('should successfully find a case with a valid uuid in title', async () => {
const uuid = uuidv1();
await createCase(supertest, { ...postCaseReq, title: uuid });
const cases = await findCases({
supertest,
query: { searchFields: ['title', 'description'], search: uuid },
});
expect(cases.total).to.be(1);
expect(cases.cases[0].title).to.equal(uuid);
});
it('should successfully find a case with a valid uuid in title', async () => {
const uuid = uuidv1();
await createCase(supertest, { ...postCaseReq, description: uuid });
const cases = await findCases({
supertest,
query: { searchFields: ['title', 'description'], search: uuid },
});
expect(cases.total).to.be(1);
expect(cases.cases[0].description).to.equal(uuid);
});
it('should not find any cases when it does not use a wildcard and the string does not match', async () => {
const cases = await findCases({
supertest,

View file

@ -115,7 +115,20 @@ export function CasesTableServiceProvider(
await testSubjects.missingOrFail('cases-table-loading', { timeout: 5000 });
},
async getCaseFromTable(index: number) {
async getCaseById(caseId: string) {
const targetCase = await find.allByCssSelector(
`[data-test-subj*="cases-table-row-${caseId}"`,
100
);
if (!targetCase.length) {
throw new Error(`Cannot find case with id ${caseId} on table.`);
}
return targetCase[0];
},
async getCaseByIndex(index: number) {
const rows = await find.allByCssSelector('[data-test-subj*="cases-table-row-"', 100);
assertCaseExists(index, rows.length);
@ -361,7 +374,7 @@ export function CasesTableServiceProvider(
async getCaseTitle(index: number) {
const titleElement = await (
await this.getCaseFromTable(index)
await this.getCaseByIndex(index)
).findByTestSubject('case-details-link');
return await titleElement.getVisibleText();

View file

@ -281,6 +281,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
describe('filtering', () => {
const caseTitle = 'matchme';
const caseIds: string[] = [];
before(async () => {
await createUsersAndRoles(getService, users, roles);
@ -288,14 +289,26 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
const profiles = await cases.api.suggestUserProfiles({ name: 'all', owners: ['cases'] });
await cases.api.createCase({
const case1 = await cases.api.createCase({
title: caseTitle,
tags: ['one'],
description: 'lots of information about an incident',
});
await cases.api.createCase({ title: 'test2', tags: ['two'] });
await cases.api.createCase({ title: 'test3', assignees: [{ uid: profiles[0].uid }] });
await cases.api.createCase({ title: 'test4', assignees: [{ uid: profiles[1].uid }] });
const case2 = await cases.api.createCase({ title: 'test2', tags: ['two'] });
const case3 = await cases.api.createCase({
title: case2.id,
assignees: [{ uid: profiles[0].uid }],
});
const case4 = await cases.api.createCase({
title: 'test4',
assignees: [{ uid: profiles[1].uid }],
description: case2.id,
});
caseIds.push(case1.id);
caseIds.push(case2.id);
caseIds.push(case3.id);
caseIds.push(case4.id);
await header.waitUntilLoadingHasFinished();
await cases.casesTable.waitForCasesToBeListed();
@ -329,6 +342,34 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await cases.casesTable.validateCasesTableHasNthRows(4);
});
it('filters cases from the list using an id search', async () => {
await testSubjects.missingOrFail('cases-table-loading', { timeout: 5000 });
const input = await testSubjects.find('search-cases');
await input.type(caseIds[0]);
await input.pressKeys(browser.keys.ENTER);
await cases.casesTable.validateCasesTableHasNthRows(1);
await cases.casesTable.getCaseById(caseIds[0]);
await testSubjects.click('clearSearchButton');
await cases.casesTable.validateCasesTableHasNthRows(4);
});
it('id search also matches title and description', async () => {
await testSubjects.missingOrFail('cases-table-loading', { timeout: 5000 });
const input = await testSubjects.find('search-cases');
await input.type(caseIds[1]);
await input.pressKeys(browser.keys.ENTER);
await cases.casesTable.validateCasesTableHasNthRows(3);
await cases.casesTable.getCaseById(caseIds[1]); // id match
await cases.casesTable.getCaseById(caseIds[2]); // title match
await cases.casesTable.getCaseById(caseIds[3]); // description match
await testSubjects.click('clearSearchButton');
await cases.casesTable.validateCasesTableHasNthRows(4);
});
it('only shows cases with a wildcard query "test*" matching the title', async () => {
await testSubjects.missingOrFail('cases-table-loading', { timeout: 5000 });
@ -336,7 +377,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await input.type('test*');
await input.pressKeys(browser.keys.ENTER);
await cases.casesTable.validateCasesTableHasNthRows(3);
await cases.casesTable.validateCasesTableHasNthRows(2);
await testSubjects.click('clearSearchButton');
await cases.casesTable.validateCasesTableHasNthRows(4);
});
@ -381,7 +422,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await cases.casesTable.filterByTag('one');
await cases.casesTable.refreshTable();
await cases.casesTable.validateCasesTableHasNthRows(1);
const row = await cases.casesTable.getCaseFromTable(0);
const row = await cases.casesTable.getCaseByIndex(0);
const tags = await row.findByTestSubject('case-table-column-tags-one');
expect(await tags.getVisibleText()).to.be('one');
});
@ -426,11 +467,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await cases.casesTable.validateCasesTableHasNthRows(2);
const firstCaseTitle = await (
await cases.casesTable.getCaseFromTable(0)
await cases.casesTable.getCaseByIndex(0)
).findByTestSubject('case-details-link');
const secondCaseTitle = await (
await cases.casesTable.getCaseFromTable(1)
await cases.casesTable.getCaseByIndex(1)
).findByTestSubject('case-details-link');
expect(await firstCaseTitle.getVisibleText()).be('test2');