From 98aa1d2d4f974f72a9a5397b1b91f11509f6fb7a Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Sat, 22 Feb 2020 07:29:44 -0700 Subject: [PATCH 1/4] [SIEM] [Case] Enable case by default. Snake to camel on UI (#57936) --- .../siem/public/components/links/index.tsx | 5 +- .../components/navigation/index.test.tsx | 4 +- .../siem/public/containers/case/api.ts | 21 ++-- .../siem/public/containers/case/types.ts | 40 ++++++-- .../public/containers/case/use_get_case.tsx | 8 +- .../public/containers/case/use_get_cases.tsx | 20 ++-- .../containers/case/use_update_case.tsx | 6 +- .../siem/public/containers/case/utils.ts | 33 +++++++ .../components/all_cases/__mock__/index.tsx | 77 +++++++++++++++ .../case/components/all_cases/columns.tsx | 42 +++++--- .../case/components/all_cases/index.test.tsx | 97 +++++++++++++++++++ .../pages/case/components/all_cases/index.tsx | 34 +++---- .../components/case_view/__mock__/index.tsx | 34 +++++++ .../case/components/case_view/index.test.tsx | 82 ++++++++++++++++ .../pages/case/components/case_view/index.tsx | 35 +++++-- .../pages/case/components/create/index.tsx | 4 +- .../pages/case/components/user_list/index.tsx | 4 +- .../public/pages/home/home_navigations.tsx | 2 +- x-pack/plugins/case/server/config.ts | 4 +- x-pack/plugins/case/server/plugin.ts | 1 + .../routes/api/__tests__/update_case.test.ts | 39 +++++++- .../case/server/routes/api/get_all_cases.ts | 7 +- .../plugins/case/server/routes/api/schema.ts | 5 + .../plugins/case/server/routes/api/types.ts | 13 +++ .../case/server/routes/api/update_case.ts | 71 ++++++++++++-- .../plugins/case/server/routes/api/utils.ts | 18 ++++ 26 files changed, 605 insertions(+), 101 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index 4f74f9ff2f5d6..b6548e3e950ba 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -44,7 +44,10 @@ const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailNam children, detailName, }) => ( - + {children ? children : detailName} ); diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx index 8eb08bd3d62f0..e1b3951a2317d 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx @@ -67,7 +67,7 @@ describe('SIEM Navigation', () => { detailName: undefined, navTabs: { case: { - disabled: true, + disabled: false, href: '#/link-to/case', id: 'case', name: 'Case', @@ -160,7 +160,7 @@ describe('SIEM Navigation', () => { filters: [], navTabs: { case: { - disabled: true, + disabled: false, href: '#/link-to/case', id: 'case', name: 'Case', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index 830e00c70975e..bff3bfd62a85c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -5,12 +5,12 @@ */ import { KibanaServices } from '../../lib/kibana'; -import { AllCases, FetchCasesProps, Case, NewCase, SortFieldCase } from './types'; -import { Direction } from '../../graphql/types'; +import { FetchCasesProps, Case, NewCase, SortFieldCase, AllCases, CaseSnake } from './types'; import { throwIfNotOk } from '../../hooks/api/api'; import { CASES_URL } from './constants'; +import { convertToCamelCase, convertAllCasesToCamel } from './utils'; -export const getCase = async (caseId: string, includeComments: boolean) => { +export const getCase = async (caseId: string, includeComments: boolean): Promise => { const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { method: 'GET', asResponse: true, @@ -19,7 +19,7 @@ export const getCase = async (caseId: string, includeComments: boolean) => { }, }); await throwIfNotOk(response.response); - return response.body!; + return convertToCamelCase(response.body!); }; export const getCases = async ({ @@ -31,7 +31,7 @@ export const getCases = async ({ page: 1, perPage: 20, sortField: SortFieldCase.createdAt, - sortOrder: Direction.desc, + sortOrder: 'desc', }, }: FetchCasesProps): Promise => { const tags = [...(filterOptions.tags?.map(t => `case-workflow.attributes.tags: ${t}`) ?? [])]; @@ -46,7 +46,7 @@ export const getCases = async ({ asResponse: true, }); await throwIfNotOk(response.response); - return response.body!; + return convertAllCasesToCamel(response.body!); }; export const createCase = async (newCase: NewCase): Promise => { @@ -56,18 +56,19 @@ export const createCase = async (newCase: NewCase): Promise => { body: JSON.stringify(newCase), }); await throwIfNotOk(response.response); - return response.body!; + return convertToCamelCase(response.body!); }; export const updateCaseProperty = async ( caseId: string, - updatedCase: Partial + updatedCase: Partial, + version: string ): Promise> => { const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { method: 'PATCH', asResponse: true, - body: JSON.stringify(updatedCase), + body: JSON.stringify({ case: updatedCase, version }), }); await throwIfNotOk(response.response); - return response.body!; + return convertToCamelCase, Partial>(response.body!); }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 0f80b2327a30c..1aea0b0f50a89 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Direction } from '../../graphql/types'; interface FormData { isNew?: boolean; } @@ -15,22 +14,35 @@ export interface NewCase extends FormData { title: string; } -export interface Case { +export interface CaseSnake { case_id: string; created_at: string; - created_by: ElasticUser; + created_by: ElasticUserSnake; description: string; state: string; tags: string[]; title: string; updated_at: string; + version?: string; +} + +export interface Case { + caseId: string; + createdAt: string; + createdBy: ElasticUser; + description: string; + state: string; + tags: string[]; + title: string; + updatedAt: string; + version?: string; } export interface QueryParams { page: number; perPage: number; sortField: SortFieldCase; - sortOrder: Direction; + sortOrder: 'asc' | 'desc'; } export interface FilterOptions { @@ -38,21 +50,33 @@ export interface FilterOptions { tags: string[]; } +export interface AllCasesSnake { + cases: CaseSnake[]; + page: number; + per_page: number; + total: number; +} + export interface AllCases { cases: Case[]; page: number; - per_page: number; + perPage: number; total: number; } export enum SortFieldCase { - createdAt = 'created_at', + createdAt = 'createdAt', state = 'state', - updatedAt = 'updated_at', + updatedAt = 'updatedAt', +} + +export interface ElasticUserSnake { + readonly username: string; + readonly full_name?: string | null; } export interface ElasticUser { readonly username: string; - readonly full_name?: string; + readonly fullName?: string | null; } export interface FetchCasesProps { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index 8cc961c68fdf0..bf76b69ef22d6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -50,16 +50,16 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { } }; const initialData: Case = { - case_id: '', - created_at: '', - created_by: { + caseId: '', + createdAt: '', + createdBy: { username: '', }, description: '', state: '', tags: [], title: '', - updated_at: '', + updatedAt: '', }; export const useGetCase = (caseId: string): [CaseState] => { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx index db9c07747ba04..4037823ccfc94 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -17,7 +17,6 @@ import { } from './constants'; import { AllCases, SortFieldCase, FilterOptions, QueryParams } from './types'; import { getTypedPayload } from './utils'; -import { Direction } from '../../graphql/types'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; import { useStateToaster } from '../../components/toasters'; import * as i18n from './translations'; @@ -31,16 +30,9 @@ export interface UseGetCasesState { filterOptions: FilterOptions; } -export interface QueryArgs { - page?: number; - perPage?: number; - sortField?: SortFieldCase; - sortOrder?: Direction; -} - export interface Action { type: string; - payload?: AllCases | QueryArgs | FilterOptions; + payload?: AllCases | Partial | FilterOptions; } const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesState => { switch (action.type) { @@ -83,13 +75,13 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS const initialData: AllCases = { page: 0, - per_page: 0, + perPage: 0, total: 0, cases: [], }; export const useGetCases = (): [ UseGetCasesState, - Dispatch>, + Dispatch>>, Dispatch> ] => { const [state, dispatch] = useReducer(dataFetchReducer, { @@ -104,11 +96,11 @@ export const useGetCases = (): [ page: DEFAULT_TABLE_ACTIVE_PAGE, perPage: DEFAULT_TABLE_LIMIT, sortField: SortFieldCase.createdAt, - sortOrder: Direction.desc, + sortOrder: 'desc', }, }); - const [queryParams, setQueryParams] = useState(state.queryParams as QueryArgs); - const [filterQuery, setFilters] = useState(state.filterOptions as FilterOptions); + const [queryParams, setQueryParams] = useState>(state.queryParams); + const [filterQuery, setFilters] = useState(state.filterOptions); const [, dispatchToaster] = useStateToaster(); useEffect(() => { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 68592c17e58dc..62e3d87b528c0 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -96,7 +96,11 @@ export const useUpdateCase = ( const updateData = async (updateKey: keyof Case) => { dispatch({ type: FETCH_INIT }); try { - const response = await updateCaseProperty(caseId, { [updateKey]: state.data[updateKey] }); + const response = await updateCaseProperty( + caseId, + { [updateKey]: state.data[updateKey] }, + state.data.version ?? '' // saved object versions are typed as string | undefined, hope that's not true + ); dispatch({ type: FETCH_SUCCESS, payload: response }); } catch (error) { errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 8e6eaca1a8f0c..14a3819bdfdad 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -4,4 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ +import { camelCase, isArray, isObject, set } from 'lodash'; +import { AllCases, AllCasesSnake, Case, CaseSnake } from './types'; + export const getTypedPayload = (a: unknown): T => a as T; + +export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] => + arrayOfSnakes.reduce((acc: unknown[], value) => { + if (isArray(value)) { + return [...acc, convertArrayToCamelCase(value)]; + } else if (isObject(value)) { + return [...acc, convertToCamelCase(value)]; + } else { + return [...acc, value]; + } + }, []); + +export const convertToCamelCase = (snakeCase: T): U => + Object.entries(snakeCase).reduce((acc, [key, value]) => { + if (isArray(value)) { + set(acc, camelCase(key), convertArrayToCamelCase(value)); + } else if (isObject(value)) { + set(acc, camelCase(key), convertToCamelCase(value)); + } else { + set(acc, camelCase(key), value); + } + return acc; + }, {} as U); + +export const convertAllCasesToCamel = (snakeCases: AllCasesSnake): AllCases => ({ + cases: snakeCases.cases.map(snakeCase => convertToCamelCase(snakeCase)), + page: snakeCases.page, + perPage: snakeCases.per_page, + total: snakeCases.total, +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx new file mode 100644 index 0000000000000..98a67304fcf1f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SortFieldCase } from '../../../../../containers/case/types'; +import { UseGetCasesState } from '../../../../../containers/case/use_get_cases'; + +export const useGetCasesMockState: UseGetCasesState = { + data: { + cases: [ + { + caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:23.627Z', + createdBy: { username: 'elastic' }, + description: 'Security banana Issue', + state: 'open', + tags: ['defacement'], + title: 'Another horrible breach', + updatedAt: '2020-02-13T19:44:23.627Z', + }, + { + caseId: '362a5c10-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:13.328Z', + createdBy: { username: 'elastic' }, + description: 'Security banana Issue', + state: 'open', + tags: ['phishing'], + title: 'Bad email', + updatedAt: '2020-02-13T19:44:13.328Z', + }, + { + caseId: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:11.328Z', + createdBy: { username: 'elastic' }, + description: 'Security banana Issue', + state: 'open', + tags: ['phishing'], + title: 'Bad email', + updatedAt: '2020-02-13T19:44:11.328Z', + }, + { + caseId: '31890e90-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:05.563Z', + createdBy: { username: 'elastic' }, + description: 'Security banana Issue', + state: 'closed', + tags: ['phishing'], + title: 'Uh oh', + updatedAt: '2020-02-18T21:32:24.056Z', + }, + { + caseId: '2f5b3210-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:01.901Z', + createdBy: { username: 'elastic' }, + description: 'Security banana Issue', + state: 'open', + tags: ['phishing'], + title: 'Uh oh', + updatedAt: '2020-02-13T19:44:01.901Z', + }, + ], + page: 1, + perPage: 5, + total: 10, + }, + isLoading: false, + isError: false, + queryParams: { + page: 1, + perPage: 5, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', + }, + filterOptions: { search: '', tags: [] }, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 92cd16fd2000e..4c47bf605051d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -14,14 +14,15 @@ import * as i18n from './translations'; export type CasesColumns = EuiTableFieldDataColumnType | EuiTableComputedColumnType; -const renderStringField = (field: string) => (field != null ? field : getEmptyTagValue()); +const renderStringField = (field: string, dataTestSubj: string) => + field != null ? {field} : getEmptyTagValue(); export const getCasesColumns = (): CasesColumns[] => [ { name: i18n.CASE_TITLE, render: (theCase: Case) => { - if (theCase.case_id != null && theCase.title != null) { - return {theCase.title}; + if (theCase.caseId != null && theCase.title != null) { + return {theCase.title}; } return getEmptyTagValue(); }, @@ -34,7 +35,11 @@ export const getCasesColumns = (): CasesColumns[] => [ return ( {tags.map((tag: string, i: number) => ( - + {tag} ))} @@ -46,28 +51,39 @@ export const getCasesColumns = (): CasesColumns[] => [ truncateText: true, }, { - field: 'created_at', + field: 'createdAt', name: i18n.CREATED_AT, sortable: true, - render: (createdAt: Case['created_at']) => { + render: (createdAt: Case['createdAt']) => { if (createdAt != null) { - return ; + return ( + + ); } return getEmptyTagValue(); }, }, { - field: 'created_by.username', + field: 'createdBy.username', name: i18n.REPORTER, - render: (createdBy: Case['created_by']['username']) => renderStringField(createdBy), + render: (createdBy: Case['createdBy']['username']) => + renderStringField(createdBy, `case-table-column-username`), }, { - field: 'updated_at', + field: 'updatedAt', name: i18n.LAST_UPDATED, sortable: true, - render: (updatedAt: Case['updated_at']) => { + render: (updatedAt: Case['updatedAt']) => { if (updatedAt != null) { - return ; + return ( + + ); } return getEmptyTagValue(); }, @@ -76,6 +92,6 @@ export const getCasesColumns = (): CasesColumns[] => [ field: 'state', name: i18n.STATE, sortable: true, - render: (state: Case['state']) => renderStringField(state), + render: (state: Case['state']) => renderStringField(state, `case-table-column-state`), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx new file mode 100644 index 0000000000000..5a87cf53142f7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import moment from 'moment-timezone'; +import { AllCases } from './'; +import { TestProviders } from '../../../../mock'; +import { useGetCasesMockState } from './__mock__'; +import * as apiHook from '../../../../containers/case/use_get_cases'; + +describe('AllCases', () => { + const setQueryParams = jest.fn(); + const setFilters = jest.fn(); + beforeEach(() => { + jest.resetAllMocks(); + jest + .spyOn(apiHook, 'useGetCases') + .mockReturnValue([useGetCasesMockState, setQueryParams, setFilters]); + moment.tz.setDefault('UTC'); + }); + it('should render AllCases', () => { + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`a[data-test-subj="case-details-link"]`) + .first() + .prop('href') + ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].caseId}`); + expect( + wrapper + .find(`a[data-test-subj="case-details-link"]`) + .first() + .text() + ).toEqual(useGetCasesMockState.data.cases[0].title); + expect( + wrapper + .find(`[data-test-subj="case-table-column-state"]`) + .first() + .text() + ).toEqual(useGetCasesMockState.data.cases[0].state); + expect( + wrapper + .find(`span[data-test-subj="case-table-column-tags-0"]`) + .first() + .prop('title') + ).toEqual(useGetCasesMockState.data.cases[0].tags[0]); + expect( + wrapper + .find(`[data-test-subj="case-table-column-username"]`) + .first() + .text() + ).toEqual(useGetCasesMockState.data.cases[0].createdBy.username); + expect( + wrapper + .find(`[data-test-subj="case-table-column-createdAt"]`) + .first() + .prop('value') + ).toEqual(useGetCasesMockState.data.cases[0].createdAt); + expect( + wrapper + .find(`[data-test-subj="case-table-column-updatedAt"]`) + .first() + .prop('value') + ).toEqual(useGetCasesMockState.data.cases[0].updatedAt); + + expect( + wrapper + .find(`[data-test-subj="case-table-case-count"]`) + .first() + .text() + ).toEqual('Showing 10 cases'); + }); + it('should tableHeaderSortButton AllCases', () => { + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="tableHeaderCell_state_5"] [data-test-subj="tableHeaderSortButton"]') + .simulate('click'); + expect(setQueryParams).toBeCalledWith({ + page: 1, + perPage: 5, + sortField: 'state', + sortOrder: 'asc', + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index b1dd39c95e191..3253a036c2990 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -18,7 +18,6 @@ import * as i18n from './translations'; import { getCasesColumns } from './columns'; import { SortFieldCase, Case, FilterOptions } from '../../../../containers/case/types'; -import { Direction } from '../../../../graphql/types'; import { useGetCases } from '../../../../containers/case/use_get_cases'; import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types'; import { Panel } from '../../../../components/panel'; @@ -32,7 +31,16 @@ import { UtilityBarText, } from '../../../../components/detection_engine/utility_bar'; import { getCreateCaseUrl } from '../../../../components/link_to'; - +const getSortField = (field: string): SortFieldCase => { + if (field === SortFieldCase.createdAt) { + return SortFieldCase.createdAt; + } else if (field === SortFieldCase.state) { + return SortFieldCase.state; + } else if (field === SortFieldCase.updatedAt) { + return SortFieldCase.updatedAt; + } + return SortFieldCase.createdAt; +}; export const AllCases = React.memo(() => { const [ { data, isLoading, queryParams, filterOptions }, @@ -44,24 +52,10 @@ export const AllCases = React.memo(() => { ({ page, sort }: EuiBasicTableOnChange) => { let newQueryParams = queryParams; if (sort) { - let newSort; - switch (sort.field) { - case 'state': - newSort = SortFieldCase.state; - break; - case 'created_at': - newSort = SortFieldCase.createdAt; - break; - case 'updated_at': - newSort = SortFieldCase.updatedAt; - break; - default: - newSort = SortFieldCase.createdAt; - } newQueryParams = { ...newQueryParams, - sortField: newSort, - sortOrder: sort.direction as Direction, + sortField: getSortField(sort.field), + sortOrder: sort.direction, }; } if (page) { @@ -114,7 +108,9 @@ export const AllCases = React.memo(() => { - {i18n.SHOWING_CASES(data.total ?? 0)} + + {i18n.SHOWING_CASES(data.total ?? 0)} + diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx new file mode 100644 index 0000000000000..7480c4fc4bb2a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CaseProps } from '../index'; +import { Case } from '../../../../../containers/case/types'; + +export const caseProps: CaseProps = { + caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + initialData: { + caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:23.627Z', + createdBy: { fullName: null, username: 'elastic' }, + description: 'Security banana Issue', + state: 'open', + tags: ['defacement'], + title: 'Another horrible breach!!', + updatedAt: '2020-02-19T15:02:57.995Z', + }, + isLoading: false, +}; + +export const data: Case = { + caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:23.627Z', + createdBy: { username: 'elastic', fullName: null }, + description: 'Security banana Issue', + state: 'open', + tags: ['defacement'], + title: 'Another horrible breach!!', + updatedAt: '2020-02-19T15:02:57.995Z', +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx new file mode 100644 index 0000000000000..a9e694bad705d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { CaseComponent } from './'; +import * as apiHook from '../../../../containers/case/use_update_case'; +import { caseProps, data } from './__mock__'; +import { TestProviders } from '../../../../mock'; + +describe('CaseView ', () => { + const dispatchUpdateCaseProperty = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(apiHook, 'useUpdateCase').mockReturnValue([{ data }, dispatchUpdateCaseProperty]); + }); + + it('should render CaseComponent', () => { + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-test-subj="case-view-title"]`) + .first() + .prop('title') + ).toEqual(data.title); + expect( + wrapper + .find(`[data-test-subj="case-view-state"]`) + .first() + .text() + ).toEqual(data.state); + expect( + wrapper + .find(`[data-test-subj="case-view-tag-list"] .euiBadge__text`) + .first() + .text() + ).toEqual(data.tags[0]); + expect( + wrapper + .find(`[data-test-subj="case-view-username"]`) + .first() + .text() + ).toEqual(data.createdBy.username); + expect( + wrapper + .find(`[data-test-subj="case-view-createdAt"]`) + .first() + .prop('value') + ).toEqual(data.createdAt); + expect( + wrapper + .find(`[data-test-subj="case-view-description"]`) + .first() + .prop('raw') + ).toEqual(data.description); + }); + + it('should dispatch update state when button is toggled', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('input[data-test-subj="toggle-case-state"]') + .simulate('change', { target: { value: false } }); + + expect(dispatchUpdateCaseProperty).toBeCalledWith({ + updateKey: 'state', + updateValue: 'closed', + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index a92cf99097fce..5cd71c5855d34 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -60,13 +60,13 @@ const BackgroundWrapper = styled.div` `} `; -interface CasesProps { +export interface CaseProps { caseId: string; initialData: Case; isLoading: boolean; } -export const Cases = React.memo(({ caseId, initialData, isLoading }) => { +export const CaseComponent = React.memo(({ caseId, initialData, isLoading }) => { const [{ data }, dispatchUpdateCaseProperty] = useUpdateCase(caseId, initialData); const [isEditDescription, setIsEditDescription] = useState(false); const [isEditTags, setIsEditTags] = useState(false); @@ -162,14 +162,14 @@ export const Cases = React.memo(({ caseId, initialData, isLoading }) ]; const userActions = [ { - avatarName: data.created_by.username, + avatarName: data.createdBy.username, title: (

- {`${data.created_by.username}`} + {`${data.createdBy.username}`} {` ${i18n.ADDED_DESCRIPTION} `}{' '} - + {/* STEPH FIX come back and add label `on` */}

@@ -206,7 +206,7 @@ export const Cases = React.memo(({ caseId, initialData, isLoading })
) : ( - + ), }, ]; @@ -229,6 +229,7 @@ export const Cases = React.memo(({ caseId, initialData, isLoading }) href: getCaseUrl(), text: i18n.BACK_TO_ALL, }} + data-test-subj="case-view-title" titleNode={titleNode} title={title} > @@ -239,13 +240,21 @@ export const Cases = React.memo(({ caseId, initialData, isLoading }) {i18n.STATUS} - {data.state} + + {data.state} + {i18n.CASE_OPENED} - + @@ -255,6 +264,7 @@ export const Cases = React.memo(({ caseId, initialData, isLoading }) (({ caseId, initialData, isLoading }) - + { ); } - return ; + return ; }); CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx index 9fd1525003b0b..7d79e287b22e7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -48,8 +48,8 @@ export const Create = React.memo(() => { } }, [form]); - if (newCase && newCase.case_id) { - return ; + if (newCase && newCase.caseId) { + return ; } return ( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx index b80ee58f8abbf..33e0a9541c5b4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx @@ -42,7 +42,7 @@ const renderUsers = (users: ElasticUser[]) => {

- {username} + {username}

@@ -50,7 +50,7 @@ const renderUsers = (users: ElasticUser[]) => {
window.alert('Email clicked')} + onClick={() => {}} // TO DO iconType="email" aria-label="email" /> diff --git a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx index 42d333f4f893e..a087dca38de00 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx @@ -55,7 +55,7 @@ export const navTabs: SiemNavTab = { id: SiemPageName.case, name: i18n.CASE, href: getCaseUrl(), - disabled: true, + disabled: false, urlKey: 'case', }, }; diff --git a/x-pack/plugins/case/server/config.ts b/x-pack/plugins/case/server/config.ts index a7cb117198f9b..8ff9a793b17dc 100644 --- a/x-pack/plugins/case/server/config.ts +++ b/x-pack/plugins/case/server/config.ts @@ -7,9 +7,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const ConfigSchema = schema.object({ - enabled: schema.boolean({ defaultValue: false }), - indexPattern: schema.string({ defaultValue: '.case-test-2' }), - secret: schema.string({ defaultValue: 'Cool secret huh?' }), + enabled: schema.boolean({ defaultValue: true }), }); export type ConfigType = TypeOf; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 37d087433a2ed..5ca640f0b25c3 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -34,6 +34,7 @@ export class CasePlugin { if (!config.enabled) { return; } + const service = new CaseService(this.log); this.log.debug( diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts index 23283d7f8a5be..25d5cafb4bb06 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts @@ -27,7 +27,8 @@ describe('UPDATE case', () => { id: 'mock-id-1', }, body: { - state: 'closed', + case: { state: 'closed' }, + version: 'WzAsMV0=', }, }); @@ -38,6 +39,42 @@ describe('UPDATE case', () => { expect(typeof response.payload.updated_at).toBe('string'); expect(response.payload.state).toEqual('closed'); }); + it(`Fails with 409 if version does not match`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'patch', + params: { + id: 'mock-id-1', + }, + body: { + case: { state: 'closed' }, + version: 'badv=', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(409); + }); + it(`Fails with 406 if updated field is unchanged`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'patch', + params: { + id: 'mock-id-1', + }, + body: { + case: { state: 'open' }, + version: 'WzAsMV0=', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(406); + }); it(`Returns an error if updateCase throws`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases/{id}', diff --git a/x-pack/plugins/case/server/routes/api/get_all_cases.ts b/x-pack/plugins/case/server/routes/api/get_all_cases.ts index 09075a32ac377..ba26a07dc2394 100644 --- a/x-pack/plugins/case/server/routes/api/get_all_cases.ts +++ b/x-pack/plugins/case/server/routes/api/get_all_cases.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '.'; -import { formatAllCases, wrapError } from './utils'; +import { formatAllCases, sortToSnake, wrapError } from './utils'; import { SavedObjectsFindOptionsSchema } from './schema'; import { AllCases } from './types'; @@ -23,7 +23,10 @@ export function initGetAllCasesApi({ caseService, router }: RouteDeps) { const args = request.query ? { client: context.core.savedObjects.client, - options: request.query, + options: { + ...request.query, + sortField: sortToSnake(request.query.sortField ?? ''), + }, } : { client: context.core.savedObjects.client, diff --git a/x-pack/plugins/case/server/routes/api/schema.ts b/x-pack/plugins/case/server/routes/api/schema.ts index 962dc474254f0..468abc8e7226f 100644 --- a/x-pack/plugins/case/server/routes/api/schema.ts +++ b/x-pack/plugins/case/server/routes/api/schema.ts @@ -41,6 +41,11 @@ export const UpdatedCaseSchema = schema.object({ title: schema.maybe(schema.string()), }); +export const UpdateCaseArguments = schema.object({ + case: UpdatedCaseSchema, + version: schema.string(), +}); + export const SavedObjectsFindOptionsSchema = schema.object({ defaultSearchOperator: schema.maybe(schema.oneOf([schema.literal('AND'), schema.literal('OR')])), fields: schema.maybe(schema.arrayOf(schema.string())), diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 2d1a88bcf1429..5f1c207bf9829 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -32,12 +32,14 @@ export interface CaseAttributes extends NewCaseType, SavedObjectAttributes { export type FlattenedCaseSavedObject = CaseAttributes & { case_id: string; + version: string; comments: FlattenedCommentSavedObject[]; }; export type FlattenedCasesSavedObject = Array< CaseAttributes & { case_id: string; + version: string; // TO DO it is partial because we need to add it the commentCount commentCount?: number; } @@ -52,6 +54,7 @@ export interface AllCases { export type FlattenedCommentSavedObject = CommentAttributes & { comment_id: string; + version: string; // TO DO We might want to add the case_id where this comment is related too }; @@ -69,3 +72,13 @@ export interface UpdatedCaseType { title?: UpdatedCaseTyped['title']; updated_at: string; } + +export enum SortFieldCase { + createdAt = 'created_at', + state = 'state', + updatedAt = 'updated_at', +} + +export type Writable = { + -readonly [K in keyof T]: T[K]; +}; diff --git a/x-pack/plugins/case/server/routes/api/update_case.ts b/x-pack/plugins/case/server/routes/api/update_case.ts index 2a814c7259e4a..1c1a56dfe9b3a 100644 --- a/x-pack/plugins/case/server/routes/api/update_case.ts +++ b/x-pack/plugins/case/server/routes/api/update_case.ts @@ -5,9 +5,17 @@ */ import { schema } from '@kbn/config-schema'; +import { SavedObject } from 'kibana/server'; +import Boom from 'boom'; +import { difference } from 'lodash'; import { wrapError } from './utils'; import { RouteDeps } from '.'; -import { UpdatedCaseSchema } from './schema'; +import { UpdateCaseArguments } from './schema'; +import { CaseAttributes, UpdatedCaseTyped, Writable } from './types'; + +interface UpdateCase extends Writable { + [key: string]: any; +} export function initUpdateCaseApi({ caseService, router }: RouteDeps) { router.patch( @@ -17,23 +25,70 @@ export function initUpdateCaseApi({ caseService, router }: RouteDeps) { params: schema.object({ id: schema.string(), }), - body: UpdatedCaseSchema, + body: UpdateCaseArguments, }, }, async (context, request, response) => { + let theCase: SavedObject; try { - const updatedCase = await caseService.updateCase({ + theCase = await caseService.getCase({ client: context.core.savedObjects.client, caseId: request.params.id, - updatedAttributes: { - ...request.body, - updated_at: new Date().toISOString(), - }, }); - return response.ok({ body: updatedCase.attributes }); } catch (error) { return response.customError(wrapError(error)); } + + if (request.body.version !== theCase.version) { + return response.customError( + wrapError( + Boom.conflict( + 'This case has been updated. Please refresh before saving additional updates.' + ) + ) + ); + } + const currentCase = theCase.attributes; + const updateCase: Partial = Object.entries(request.body.case).reduce( + (acc, [key, value]) => { + const currentValue = currentCase[key]; + if ( + Array.isArray(value) && + Array.isArray(currentValue) && + difference(value, currentValue).length !== 0 + ) { + return { + ...acc, + [key]: value, + }; + } else if (value !== currentCase[key]) { + return { + ...acc, + [key]: value, + }; + } + return acc; + }, + {} + ); + if (Object.keys(updateCase).length > 0) { + try { + const updatedCase = await caseService.updateCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + updatedAttributes: { + ...updateCase, + updated_at: new Date().toISOString(), + }, + }); + return response.ok({ body: { ...updatedCase.attributes, version: updatedCase.version } }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + return response.customError( + wrapError(Boom.notAcceptable('All update fields are identical to current version.')) + ); } ); } diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 51944b04836ab..32de41e1c01c5 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -20,6 +20,7 @@ import { AllCases, NewCaseType, NewCommentType, + SortFieldCase, UserType, } from './types'; @@ -80,6 +81,7 @@ export const flattenCaseSavedObject = ( comments: Array> ): FlattenedCaseSavedObject => ({ case_id: savedObject.id, + version: savedObject.version ? savedObject.version : '0', comments: flattenCommentSavedObjects(comments), ...savedObject.attributes, }); @@ -107,5 +109,21 @@ export const flattenCommentSavedObject = ( savedObject: SavedObject ): FlattenedCommentSavedObject => ({ comment_id: savedObject.id, + version: savedObject.version ? savedObject.version : '0', ...savedObject.attributes, }); + +export const sortToSnake = (sortField: string): SortFieldCase => { + switch (sortField) { + case 'state': + return SortFieldCase.state; + case 'createdAt': + case 'created_at': + return SortFieldCase.createdAt; + case 'updatedAt': + case 'updated_at': + return SortFieldCase.updatedAt; + default: + return SortFieldCase.createdAt; + } +}; From db0a9cc61ef78b7865e74862895a5b63fe50154e Mon Sep 17 00:00:00 2001 From: Maryia Lapata Date: Mon, 24 Feb 2020 14:03:16 +0300 Subject: [PATCH 2/4] [Bug fix] Update nav link when it belongs to the same plugin (#58008) * Update nav link when it belongs to the same plugin * Move the plugin name check to history listener * Add isUrlBelongsToApp function * Code review comments * Update unit tests Co-authored-by: Elastic Machine --- .../dashboard/np_ready/dashboard_constants.ts | 2 + .../kibana/public/dashboard/plugin.ts | 15 +++- .../url/kbn_url_tracker.test.ts | 79 ++++++++++++++----- .../state_management/url/kbn_url_tracker.ts | 19 ++++- 4 files changed, 89 insertions(+), 26 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts index fe42e07912799..0820ebd371004 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts @@ -23,6 +23,8 @@ export const DashboardConstants = { CREATE_NEW_DASHBOARD_URL: '/dashboard', ADD_EMBEDDABLE_ID: 'addEmbeddableId', ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', + DASHBOARDS_ID: 'dashboards', + DASHBOARD_ID: 'dashboard', }; export function createDashboardEditUrl(id: string) { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts index 7d330676e79ed..7d64ee969212f 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts @@ -83,7 +83,14 @@ export class DashboardPlugin implements Plugin { ); const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({ baseUrl: core.http.basePath.prepend('/app/kibana'), - defaultSubUrl: '#/dashboards', + defaultSubUrl: `#${DashboardConstants.LANDING_PAGE_PATH}`, + shouldTrackUrlUpdate: pathname => { + const targetAppName = pathname.split('/')[1]; + return ( + targetAppName === DashboardConstants.DASHBOARDS_ID || + targetAppName === DashboardConstants.DASHBOARD_ID + ); + }, storageKey: 'lastUrl:dashboard', navLinkUpdater$: this.appStateUpdater, toastNotifications: core.notifications.toasts, @@ -150,15 +157,15 @@ export class DashboardPlugin implements Plugin { }; kibanaLegacy.registerLegacyApp({ ...app, - id: 'dashboard', + id: DashboardConstants.DASHBOARD_ID, // only register the updater in once app, otherwise all updates would happen twice updater$: this.appStateUpdater.asObservable(), navLinkId: 'kibana:dashboard', }); - kibanaLegacy.registerLegacyApp({ ...app, id: 'dashboards' }); + kibanaLegacy.registerLegacyApp({ ...app, id: DashboardConstants.DASHBOARDS_ID }); home.featureCatalogue.register({ - id: 'dashboard', + id: DashboardConstants.DASHBOARD_ID, title: i18n.translate('kbn.dashboard.featureCatalogue.dashboardTitle', { defaultMessage: 'Dashboard', }), diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts index 4cf74d991ceb9..701154c06a2ff 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts @@ -38,7 +38,7 @@ describe('kbnUrlTracker', () => { let navLinkUpdaterSubject: BehaviorSubject<(app: AppBase) => { activeUrl?: string } | undefined>; let toastService: jest.Mocked; - function createTracker() { + function createTracker(shouldTrackUrlUpdate?: (pathname: string) => boolean) { urlTracker = createKbnUrlTracker({ baseUrl: '/app/test', defaultSubUrl: '#/start', @@ -57,6 +57,7 @@ describe('kbnUrlTracker', () => { ], navLinkUpdater$: navLinkUpdaterSubject, toastNotifications: toastService, + shouldTrackUrlUpdate, }); } @@ -82,44 +83,44 @@ describe('kbnUrlTracker', () => { }); test('set nav link to session storage value if defined', () => { - storage.setItem('storageKey', '#/deep/path'); + storage.setItem('storageKey', '#/start/deep/path'); createTracker(); - expect(getActiveNavLinkUrl()).toEqual('/app/test#/deep/path'); + expect(getActiveNavLinkUrl()).toEqual('/app/test#/start/deep/path'); }); test('set nav link to default if app gets mounted', () => { - storage.setItem('storageKey', '#/deep/path'); + storage.setItem('storageKey', '#/start/deep/path'); createTracker(); urlTracker.appMounted(); expect(getActiveNavLinkUrl()).toEqual('/app/test#/start'); }); test('keep nav link to default if path gets changed while app mounted', () => { - storage.setItem('storageKey', '#/deep/path'); + storage.setItem('storageKey', '#/start/deep/path'); createTracker(); urlTracker.appMounted(); - history.push('/deep/path/2'); + history.push('/start/deep/path/2'); expect(getActiveNavLinkUrl()).toEqual('/app/test#/start'); }); test('change nav link to last visited url within app after unmount', () => { createTracker(); urlTracker.appMounted(); - history.push('/deep/path/2'); - history.push('/deep/path/3'); + history.push('/start/deep/path/2'); + history.push('/start/deep/path/3'); urlTracker.appUnMounted(); - expect(getActiveNavLinkUrl()).toEqual('/app/test#/deep/path/3'); + expect(getActiveNavLinkUrl()).toEqual('/app/test#/start/deep/path/3'); }); test('unhash all urls that are recorded while app is mounted', () => { (unhashUrl as jest.Mock).mockImplementation(x => x + '?unhashed'); createTracker(); urlTracker.appMounted(); - history.push('/deep/path/2'); - history.push('/deep/path/3'); + history.push('/start/deep/path/2'); + history.push('/start/deep/path/3'); urlTracker.appUnMounted(); expect(unhashUrl).toHaveBeenCalledTimes(2); - expect(getActiveNavLinkUrl()).toEqual('/app/test#/deep/path/3?unhashed'); + expect(getActiveNavLinkUrl()).toEqual('/app/test#/start/deep/path/3?unhashed'); }); test('show warning and use hashed url if unhashing does not work', () => { @@ -128,17 +129,17 @@ describe('kbnUrlTracker', () => { }); createTracker(); urlTracker.appMounted(); - history.push('/deep/path/2'); + history.push('/start/deep/path/2'); urlTracker.appUnMounted(); - expect(getActiveNavLinkUrl()).toEqual('/app/test#/deep/path/2'); + expect(getActiveNavLinkUrl()).toEqual('/app/test#/start/deep/path/2'); expect(toastService.addDanger).toHaveBeenCalledWith('unhash broke'); }); test('change nav link back to default if app gets mounted again', () => { createTracker(); urlTracker.appMounted(); - history.push('/deep/path/2'); - history.push('/deep/path/3'); + history.push('/start/deep/path/2'); + history.push('/start/deep/path/3'); urlTracker.appUnMounted(); urlTracker.appMounted(); expect(getActiveNavLinkUrl()).toEqual('/app/test#/start'); @@ -151,11 +152,11 @@ describe('kbnUrlTracker', () => { }); test('update state param without overwriting rest of the url when app is not mounted', () => { - storage.setItem('storageKey', '#/deep/path?extrastate=1'); + storage.setItem('storageKey', '#/start/deep/path?extrastate=1'); createTracker(); state1Subject.next({ key1: 'abc' }); expect(getActiveNavLinkUrl()).toMatchInlineSnapshot( - `"/app/test#/deep/path?extrastate=1&state1=(key1:abc)"` + `"/app/test#/start/deep/path?extrastate=1&state1=(key1:abc)"` ); }); @@ -184,7 +185,45 @@ describe('kbnUrlTracker', () => { test('set url to storage when setActiveUrl was called', () => { createTracker(); - urlTracker.setActiveUrl('/deep/path/4'); - expect(storage.getItem('storageKey')).toEqual('#/deep/path/4'); + urlTracker.setActiveUrl('/start/deep/path/4'); + expect(storage.getItem('storageKey')).toEqual('#/start/deep/path/4'); + }); + + describe('shouldTrackUrlUpdate', () => { + test('change nav link when shouldTrackUrlUpdate is not overridden', () => { + storage.setItem('storageKey', '#/start/deep/path'); + createTracker(); + urlTracker.appMounted(); + history.push('/start/path'); + urlTracker.appUnMounted(); + expect(getActiveNavLinkUrl()).toEqual('/app/test#/start/path'); + }); + + test('not change nav link when shouldTrackUrlUpdate is not overridden', () => { + storage.setItem('storageKey', '#/start/deep/path'); + createTracker(); + urlTracker.appMounted(); + history.push('/setup/path/2'); + urlTracker.appUnMounted(); + expect(getActiveNavLinkUrl()).toEqual('/app/test#/start/deep/path'); + }); + + test('change nav link when shouldTrackUrlUpdate is overridden', () => { + storage.setItem('storageKey', '#/start/deep/path'); + createTracker(() => true); + urlTracker.appMounted(); + history.push('/setup/path/2'); + urlTracker.appUnMounted(); + expect(getActiveNavLinkUrl()).toEqual('/app/test#/setup/path/2'); + }); + + test('not change nav link when shouldTrackUrlUpdate is overridden', () => { + storage.setItem('storageKey', '#/start/deep/path'); + createTracker(() => false); + urlTracker.appMounted(); + history.push('/setup/path/2'); + urlTracker.appUnMounted(); + expect(getActiveNavLinkUrl()).toEqual('/app/test#/start/deep/path'); + }); }); }); diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts index 2edd135c184ec..b778535a2d428 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts @@ -58,6 +58,12 @@ export function createKbnUrlTracker({ toastNotifications, history, storage, + shouldTrackUrlUpdate = pathname => { + const currentAppName = defaultSubUrl.slice(2); // cut hash and slash symbols + const targetAppName = pathname.split('/')[1]; + + return currentAppName === targetAppName; + }, }: { /** * Base url of the current app. This will be used as a prefix for the @@ -82,7 +88,7 @@ export function createKbnUrlTracker({ stateUpdate$: Observable; }>; /** - * Key used to store the current sub url in session storage. This key should only be used for one active url tracker at any given ntime. + * Key used to store the current sub url in session storage. This key should only be used for one active url tracker at any given time. */ storageKey: string; /** @@ -101,6 +107,13 @@ export function createKbnUrlTracker({ * Storage object to use to persist currently active url. If this isn't provided, the browser wide session storage instance will be used. */ storage?: Storage; + /** + * Checks if pathname belongs to current app. It's used in history listener to define whether it's necessary to set pathname as active url or not. + * The default implementation compares the app name to the first part of pathname. Consumers can override this function for more complex cases. + * + * @param {string} pathname A location's pathname which comes to history listener + */ + shouldTrackUrlUpdate?: (pathname: string) => boolean; }): KbnUrlTracker { const historyInstance = history || createHashHistory(); const storageInstance = storage || sessionStorage; @@ -148,7 +161,9 @@ export function createKbnUrlTracker({ unsubscribe(); // track current hash when within app unsubscribeURLHistory = historyInstance.listen(location => { - setActiveUrl(location.pathname + location.search); + if (shouldTrackUrlUpdate(location.pathname)) { + setActiveUrl(location.pathname + location.search); + } }); } From e64eff0a3d5fde205256ab74b731a63766822f9a Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 24 Feb 2020 06:02:27 -0800 Subject: [PATCH 3/4] Temporarily removes kbn-optimizer cache key tests (#58318) While we investigate why they are interfering with other tests. Signed-off-by: Tyler Smalley Co-authored-by: Elastic Machine --- .../src/optimizer/cache_keys.test.ts | 206 ------------------ 1 file changed, 206 deletions(-) delete mode 100644 packages/kbn-optimizer/src/optimizer/cache_keys.test.ts diff --git a/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts b/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts deleted file mode 100644 index 2337017f54ed8..0000000000000 --- a/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Path from 'path'; - -import jestDiff from 'jest-diff'; -import { REPO_ROOT, createAbsolutePathSerializer } from '@kbn/dev-utils'; - -import { reformatJestDiff, getOptimizerCacheKey, diffCacheKey } from './cache_keys'; -import { OptimizerConfig } from './optimizer_config'; - -jest.mock('./get_changes.ts', () => ({ - getChanges: async () => - new Map([ - ['/foo/bar/a', 'modified'], - ['/foo/bar/b', 'modified'], - ['/foo/bar/c', 'deleted'], - ]), -})); - -jest.mock('./get_mtimes.ts', () => ({ - getMtimes: async (paths: string[]) => new Map(paths.map(path => [path, 12345])), -})); - -jest.mock('execa'); - -jest.mock('fs', () => { - const realFs = jest.requireActual('fs'); - jest.spyOn(realFs, 'readFile'); - return realFs; -}); - -expect.addSnapshotSerializer(createAbsolutePathSerializer()); - -jest.requireMock('execa').mockImplementation(async (cmd: string, args: string[], opts: object) => { - expect(cmd).toBe('git'); - expect(args).toEqual([ - 'log', - '-n', - '1', - '--pretty=format:%H', - '--', - expect.stringContaining('kbn-optimizer'), - ]); - expect(opts).toEqual({ - cwd: REPO_ROOT, - }); - - return { - stdout: '', - }; -}); - -describe('getOptimizerCacheKey()', () => { - it('uses latest commit, bootstrap cache, and changed files to create unique value', async () => { - jest - .requireMock('fs') - .readFile.mockImplementation( - (path: string, enc: string, cb: (err: null, file: string) => void) => { - expect(path).toBe( - Path.resolve(REPO_ROOT, 'packages/kbn-optimizer/target/.bootstrap-cache') - ); - expect(enc).toBe('utf8'); - cb(null, ''); - } - ); - - const config = OptimizerConfig.create({ - repoRoot: REPO_ROOT, - }); - - await expect(getOptimizerCacheKey(config)).resolves.toMatchInlineSnapshot(` - Object { - "bootstrap": "", - "deletedPaths": Array [ - "/foo/bar/c", - ], - "lastCommit": "", - "modifiedTimes": Object { - "/foo/bar/a": 12345, - "/foo/bar/b": 12345, - }, - "workerConfig": Object { - "browserslistEnv": "dev", - "cache": true, - "dist": false, - "optimizerCacheKey": "♻", - "profileWebpack": false, - "repoRoot": , - "watch": false, - }, - } - `); - }); -}); - -describe('diffCacheKey()', () => { - it('returns undefined if values are equal', () => { - expect(diffCacheKey('1', '1')).toBe(undefined); - expect(diffCacheKey(1, 1)).toBe(undefined); - expect(diffCacheKey(['1', '2', { a: 'b' }], ['1', '2', { a: 'b' }])).toBe(undefined); - expect( - diffCacheKey( - { - a: '1', - b: '2', - }, - { - b: '2', - a: '1', - } - ) - ).toBe(undefined); - }); - - it('returns a diff if the values are different', () => { - expect(diffCacheKey(['1', '2', { a: 'b' }], ['1', '2', { b: 'a' }])).toMatchInlineSnapshot(` - "- Expected - + Received - -  Array [ -  \\"1\\", -  \\"2\\", -  Object { - - \\"a\\": \\"b\\", - + \\"b\\": \\"a\\", -  }, -  ]" - `); - expect( - diffCacheKey( - { - a: '1', - b: '1', - }, - { - b: '2', - a: '2', - } - ) - ).toMatchInlineSnapshot(` - "- Expected - + Received - -  Object { - - \\"a\\": \\"1\\", - - \\"b\\": \\"1\\", - + \\"a\\": \\"2\\", - + \\"b\\": \\"2\\", -  }" - `); - }); -}); - -describe('reformatJestDiff()', () => { - it('reformats large jestDiff output to focus on the changed lines', () => { - const diff = jestDiff( - { - a: ['1', '1', '1', '1', '1', '1', '1', '2', '1', '1', '1', '1', '1', '1', '1', '1', '1'], - }, - { - b: ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '2', '1', '1', '1', '1'], - } - ); - - expect(reformatJestDiff(diff)).toMatchInlineSnapshot(` - "- Expected - + Received - -  Object { - - \\"a\\": Array [ - + \\"b\\": Array [ -  \\"1\\", -  \\"1\\", -  ... -  \\"1\\", -  \\"1\\", - - \\"2\\", -  \\"1\\", -  \\"1\\", -  ... -  \\"1\\", -  \\"1\\", - + \\"2\\", -  \\"1\\", -  \\"1\\", -  ..." - `); - }); -}); From 581f1bd6e9329b66db5bc3cb3348475595db5aa2 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 24 Feb 2020 16:26:34 +0100 Subject: [PATCH 4/4] Expressions debug mode (#57841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 add ability to execute expression in debug mode * feat: 🎸 store resolved arguments in debug information * feat: 🎸 track function execution time and set "success" flag * feat: 🎸 store debug information for functions that throw * feat: 🎸 use performance.now, safe "fn" reference, fix typo --- src/plugins/expressions/common/ast/types.ts | 53 ++++ .../common/execution/execution.test.ts | 253 +++++++++++++++++- .../expressions/common/execution/execution.ts | 67 ++++- .../expressions/common/executor/executor.ts | 26 +- 4 files changed, 379 insertions(+), 20 deletions(-) diff --git a/src/plugins/expressions/common/ast/types.ts b/src/plugins/expressions/common/ast/types.ts index 82a7578dd4b89..0b505f117a580 100644 --- a/src/plugins/expressions/common/ast/types.ts +++ b/src/plugins/expressions/common/ast/types.ts @@ -17,6 +17,9 @@ * under the License. */ +import { ExpressionValue, ExpressionValueError } from '../expression_types'; +import { ExpressionFunction } from '../../public'; + export type ExpressionAstNode = | ExpressionAstExpression | ExpressionAstFunction @@ -31,6 +34,56 @@ export interface ExpressionAstFunction { type: 'function'; function: string; arguments: Record; + + /** + * Debug information added to each function when expression is executed in *debug mode*. + */ + debug?: ExpressionAstFunctionDebug; +} + +export interface ExpressionAstFunctionDebug { + /** + * True if function successfully returned output, false if function threw. + */ + success: boolean; + + /** + * Reference to the expression function this AST node represents. + */ + fn: ExpressionFunction; + + /** + * Input that expression function received as its first argument. + */ + input: ExpressionValue; + + /** + * Map of resolved arguments expression function received as its second argument. + */ + args: Record; + + /** + * Result returned by the expression function. Including an error result + * if it was returned by the function (not thrown). + */ + output?: ExpressionValue; + + /** + * Error that function threw normalized to `ExpressionValueError`. + */ + error?: ExpressionValueError; + + /** + * Raw error that was thrown by the function, if any. + */ + rawError?: any | Error; + + /** + * Time in milliseconds it took to execute the function. Duration can be + * `undefined` if error happened during argument resolution, because function + * timing starts after the arguments have been resolved. + */ + duration: number | undefined; } export type ExpressionAstArgument = string | boolean | number | ExpressionAstExpression; diff --git a/src/plugins/expressions/common/execution/execution.test.ts b/src/plugins/expressions/common/execution/execution.test.ts index b6c1721e33eef..f6ff9efca848b 100644 --- a/src/plugins/expressions/common/execution/execution.test.ts +++ b/src/plugins/expressions/common/execution/execution.test.ts @@ -18,20 +18,28 @@ */ import { Execution } from './execution'; -import { parseExpression } from '../ast'; +import { parseExpression, ExpressionAstExpression } from '../ast'; import { createUnitTestExecutor } from '../test_helpers'; import { ExpressionFunctionDefinition } from '../../public'; import { ExecutionContract } from './execution_contract'; +beforeAll(() => { + if (typeof performance === 'undefined') { + (global as any).performance = { now: Date.now }; + } +}); + const createExecution = ( expression: string = 'foo bar=123', - context: Record = {} + context: Record = {}, + debug: boolean = false ) => { const executor = createUnitTestExecutor(); const execution = new Execution({ executor, ast: parseExpression(expression), context, + debug, }); return execution; }; @@ -406,4 +414,245 @@ describe('Execution', () => { }); }); }); + + describe('debug mode', () => { + test('can execute expression in debug mode', async () => { + const execution = createExecution('add val=1 | add val=2 | add val=3', {}, true); + execution.start(-1); + const result = await execution.result; + + expect(result).toEqual({ + type: 'num', + value: 5, + }); + }); + + test('can execute expression with sub-expression in debug mode', async () => { + const execution = createExecution( + 'add val={var_set name=foo value=5 | var name=foo} | add val=10', + {}, + true + ); + execution.start(0); + const result = await execution.result; + + expect(result).toEqual({ + type: 'num', + value: 15, + }); + }); + + describe('when functions succeed', () => { + test('sets "success" flag on all functions to true', async () => { + const execution = createExecution('add val=1 | add val=2 | add val=3', {}, true); + execution.start(-1); + await execution.result; + + for (const node of execution.state.get().ast.chain) { + expect(node.debug?.success).toBe(true); + } + }); + + test('stores "fn" reference to the function', async () => { + const execution = createExecution('add val=1 | add val=2 | add val=3', {}, true); + execution.start(-1); + await execution.result; + + for (const node of execution.state.get().ast.chain) { + expect(node.debug?.fn.name).toBe('add'); + } + }); + + test('saves duration it took to execute each function', async () => { + const execution = createExecution('add val=1 | add val=2 | add val=3', {}, true); + execution.start(-1); + await execution.result; + + for (const node of execution.state.get().ast.chain) { + expect(typeof node.debug?.duration).toBe('number'); + expect(node.debug?.duration).toBeLessThan(100); + expect(node.debug?.duration).toBeGreaterThanOrEqual(0); + } + }); + + test('sets duration to 10 milliseconds when function executes 10 milliseconds', async () => { + const execution = createExecution('sleep 10', {}, true); + execution.start(-1); + await execution.result; + + const node = execution.state.get().ast.chain[0]; + expect(typeof node.debug?.duration).toBe('number'); + expect(node.debug?.duration).toBeLessThan(50); + expect(node.debug?.duration).toBeGreaterThanOrEqual(5); + }); + + test('adds .debug field in expression AST on each executed function', async () => { + const execution = createExecution('add val=1 | add val=2 | add val=3', {}, true); + execution.start(-1); + await execution.result; + + for (const node of execution.state.get().ast.chain) { + expect(typeof node.debug).toBe('object'); + expect(!!node.debug).toBe(true); + } + }); + + test('stores input of each function', async () => { + const execution = createExecution('add val=1 | add val=2 | add val=3', {}, true); + execution.start(-1); + await execution.result; + + const { chain } = execution.state.get().ast; + + expect(chain[0].debug!.input).toBe(-1); + expect(chain[1].debug!.input).toEqual({ + type: 'num', + value: 0, + }); + expect(chain[2].debug!.input).toEqual({ + type: 'num', + value: 2, + }); + }); + + test('stores output of each function', async () => { + const execution = createExecution('add val=1 | add val=2 | add val=3', {}, true); + execution.start(-1); + await execution.result; + + const { chain } = execution.state.get().ast; + + expect(chain[0].debug!.output).toEqual({ + type: 'num', + value: 0, + }); + expect(chain[1].debug!.output).toEqual({ + type: 'num', + value: 2, + }); + expect(chain[2].debug!.output).toEqual({ + type: 'num', + value: 5, + }); + }); + + test('stores resolved arguments of a function', async () => { + const execution = createExecution( + 'add val={var_set name=foo value=5 | var name=foo} | add val=10', + {}, + true + ); + execution.start(-1); + await execution.result; + + const { chain } = execution.state.get().ast; + + expect(chain[0].debug!.args).toEqual({ + val: 5, + }); + + expect((chain[0].arguments.val[0] as ExpressionAstExpression).chain[0].debug!.args).toEqual( + { + name: 'foo', + value: 5, + } + ); + }); + + test('store debug information about sub-expressions', async () => { + const execution = createExecution( + 'add val={var_set name=foo value=5 | var name=foo} | add val=10', + {}, + true + ); + execution.start(0); + await execution.result; + + const { chain } = execution.state.get().ast.chain[0].arguments + .val[0] as ExpressionAstExpression; + + expect(typeof chain[0].debug).toBe('object'); + expect(typeof chain[1].debug).toBe('object'); + expect(!!chain[0].debug).toBe(true); + expect(!!chain[1].debug).toBe(true); + + expect(chain[0].debug!.input).toBe(0); + expect(chain[0].debug!.output).toBe(0); + expect(chain[1].debug!.input).toBe(0); + expect(chain[1].debug!.output).toBe(5); + }); + }); + + describe('when expression throws', () => { + const executor = createUnitTestExecutor(); + executor.registerFunction({ + name: 'throws', + args: {}, + help: '', + fn: () => { + throw new Error('foo'); + }, + }); + + test('stores debug information up until the function that throws', async () => { + const execution = new Execution({ + executor, + ast: parseExpression('add val=1 | throws | add val=3'), + debug: true, + }); + execution.start(0); + await execution.result; + + const node1 = execution.state.get().ast.chain[0]; + const node2 = execution.state.get().ast.chain[1]; + const node3 = execution.state.get().ast.chain[2]; + + expect(typeof node1.debug).toBe('object'); + expect(typeof node2.debug).toBe('object'); + expect(typeof node3.debug).toBe('undefined'); + }); + + test('stores error thrown in debug information', async () => { + const execution = new Execution({ + executor, + ast: parseExpression('add val=1 | throws | add val=3'), + debug: true, + }); + execution.start(0); + await execution.result; + + const node2 = execution.state.get().ast.chain[1]; + + expect(node2.debug?.error).toMatchObject({ + type: 'error', + error: { + message: '[throws] > foo', + }, + }); + expect(node2.debug?.rawError).toBeInstanceOf(Error); + }); + + test('sets .debug object to expected shape', async () => { + const execution = new Execution({ + executor, + ast: parseExpression('add val=1 | throws | add val=3'), + debug: true, + }); + execution.start(0); + await execution.result; + + const node2 = execution.state.get().ast.chain[1]; + + expect(node2.debug).toMatchObject({ + success: false, + fn: expect.any(Object), + input: expect.any(Object), + args: expect.any(Object), + error: expect.any(Object), + rawError: expect.any(Error), + duration: expect.any(Number), + }); + }); + }); + }); }); diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index 2a272e187cffc..7e7df822724ae 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -23,7 +23,7 @@ import { createExecutionContainer, ExecutionContainer } from './container'; import { createError } from '../util'; import { Defer } from '../../../kibana_utils/common'; import { RequestAdapter, DataAdapter } from '../../../inspector/common'; -import { isExpressionValueError } from '../expression_types/specs/error'; +import { isExpressionValueError, ExpressionValueError } from '../expression_types/specs/error'; import { ExpressionAstExpression, ExpressionAstFunction, @@ -32,7 +32,7 @@ import { parseExpression, } from '../ast'; import { ExecutionContext, DefaultInspectorAdapters } from './types'; -import { getType } from '../expression_types'; +import { getType, ExpressionValue } from '../expression_types'; import { ArgumentType, ExpressionFunction } from '../expression_functions'; import { getByAlias } from '../util/get_by_alias'; import { ExecutionContract } from './execution_contract'; @@ -44,6 +44,13 @@ export interface ExecutionParams< ast?: ExpressionAstExpression; expression?: string; context?: ExtraContext; + + /** + * Whether to execute expression in *debug mode*. In *debug mode* inputs and + * outputs as well as all resolved arguments and time it took to execute each + * function are saved and are available for introspection. + */ + debug?: boolean; } const createDefaultInspectorAdapters = (): DefaultInspectorAdapters => ({ @@ -190,23 +197,55 @@ export class Execution< } const { function: fnName, arguments: fnArgs } = link; - const fnDef = getByAlias(this.state.get().functions, fnName); + const fn = getByAlias(this.state.get().functions, fnName); - if (!fnDef) { + if (!fn) { return createError({ message: `Function ${fnName} could not be found.` }); } + let args: Record = {}; + let timeStart: number | undefined; + try { - // Resolve arguments before passing to function - // resolveArgs returns an object because the arguments themselves might - // actually have a 'then' function which would be treated as a promise - const { resolvedArgs } = await this.resolveArgs(fnDef, input, fnArgs); - const output = await this.invokeFunction(fnDef, input, resolvedArgs); + // `resolveArgs` returns an object because the arguments themselves might + // actually have a `then` function which would be treated as a `Promise`. + const { resolvedArgs } = await this.resolveArgs(fn, input, fnArgs); + args = resolvedArgs; + timeStart = this.params.debug ? performance.now() : 0; + const output = await this.invokeFunction(fn, input, resolvedArgs); + + if (this.params.debug) { + const timeEnd: number = performance.now(); + (link as ExpressionAstFunction).debug = { + success: true, + fn, + input, + args: resolvedArgs, + output, + duration: timeEnd - timeStart, + }; + } + if (getType(output) === 'error') return output; input = output; - } catch (e) { - e.message = `[${fnName}] > ${e.message}`; - return createError(e); + } catch (rawError) { + const timeEnd: number = this.params.debug ? performance.now() : 0; + rawError.message = `[${fnName}] > ${rawError.message}`; + const error = createError(rawError) as ExpressionValueError; + + if (this.params.debug) { + (link as ExpressionAstFunction).debug = { + success: false, + fn, + input, + args, + error, + rawError, + duration: timeStart ? timeEnd - timeStart : undefined, + }; + } + + return error; } } @@ -327,7 +366,9 @@ export class Execution< const resolveArgFns = mapValues(argAstsWithDefaults, (asts, argName) => { return asts.map((item: ExpressionAstExpression) => { return async (subInput = input) => { - const output = await this.params.executor.interpret(item, subInput); + const output = await this.params.executor.interpret(item, subInput, { + debug: this.params.debug, + }); if (isExpressionValueError(output)) throw output.error; const casted = this.cast(output, argDefs[argName as any].types); return casted; diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts index af3662d13de4e..2ecbc5f75a9e8 100644 --- a/src/plugins/expressions/common/executor/executor.ts +++ b/src/plugins/expressions/common/executor/executor.ts @@ -31,6 +31,15 @@ import { ExpressionAstExpression, ExpressionAstNode } from '../ast'; import { typeSpecs } from '../expression_types/specs'; import { functionSpecs } from '../expression_functions/specs'; +export interface ExpressionExecOptions { + /** + * Whether to execute expression in *debug mode*. In *debug mode* inputs and + * outputs as well as all resolved arguments and time it took to execute each + * function are saved and are available for introspection. + */ + debug?: boolean; +} + export class TypesRegistry implements IRegistry { constructor(private readonly executor: Executor) {} @@ -145,10 +154,14 @@ export class Executor = Record(ast: ExpressionAstNode, input: T): Promise { + public async interpret( + ast: ExpressionAstNode, + input: T, + options?: ExpressionExecOptions + ): Promise { switch (getType(ast)) { case 'expression': - return await this.interpretExpression(ast as ExpressionAstExpression, input); + return await this.interpretExpression(ast as ExpressionAstExpression, input, options); case 'string': case 'number': case 'null': @@ -161,9 +174,10 @@ export class Executor = Record( ast: string | ExpressionAstExpression, - input: T + input: T, + options?: ExpressionExecOptions ): Promise { - const execution = this.createExecution(ast); + const execution = this.createExecution(ast, undefined, options); execution.start(input); return await execution.result; } @@ -192,7 +206,8 @@ export class Executor = Record( ast: string | ExpressionAstExpression, - context: ExtraContext = {} as ExtraContext + context: ExtraContext = {} as ExtraContext, + { debug }: ExpressionExecOptions = {} as ExpressionExecOptions ): Execution { const params: ExecutionParams = { executor: this, @@ -200,6 +215,7 @@ export class Executor = Record