diff --git a/x-pack/plugins/cases/public/client/ui/get_cases.tsx b/x-pack/plugins/cases/public/client/ui/get_cases.tsx index 36556523fc3a3..3274bc67d2a47 100644 --- a/x-pack/plugins/cases/public/client/ui/get_cases.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_cases.tsx @@ -28,7 +28,6 @@ export const getCasesLazy = ({ owner, permissions, basePath, - onComponentInitialized, actionsNavigation, ruleDetailsNavigation, showAlertDetails, @@ -52,7 +51,6 @@ export const getCasesLazy = ({ > }> { ); expect(mockAddLabels).toHaveBeenCalledWith({ alert_count: 3 }); }); + + it('should not start any transactions if the app ID is not defined', () => { + const { result } = renderUseCreateCaseWithAttachmentsTransaction(); + + result.current.startTransaction(); + + expect(mockStartTransaction).not.toHaveBeenCalled(); + expect(mockAddLabels).not.toHaveBeenCalled(); + }); }); describe('useAddAttachmentToExistingCaseTransaction', () => { @@ -104,5 +113,14 @@ describe('cases transactions', () => { ); expect(mockAddLabels).toHaveBeenCalledWith({ alert_count: 3 }); }); + + it('should not start any transactions if the app ID is not defined', () => { + const { result } = renderUseAddAttachmentToExistingCaseTransaction(); + + result.current.startTransaction({ attachments: bulkAttachments }); + + expect(mockStartTransaction).not.toHaveBeenCalled(); + expect(mockAddLabels).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/cases/public/common/apm/use_cases_transactions.ts b/x-pack/plugins/cases/public/common/apm/use_cases_transactions.ts index 891726322cb8e..400f1aa2d956c 100644 --- a/x-pack/plugins/cases/public/common/apm/use_cases_transactions.ts +++ b/x-pack/plugins/cases/public/common/apm/use_cases_transactions.ts @@ -17,8 +17,8 @@ const BULK_ADD_ATTACHMENT_TO_NEW_CASE = 'bulkAddAttachmentsToNewCase' as const; const ADD_ATTACHMENT_TO_EXISTING_CASE = 'addAttachmentToExistingCase' as const; const BULK_ADD_ATTACHMENT_TO_EXISTING_CASE = 'bulkAddAttachmentsToExistingCase' as const; -export type StartCreateCaseWithAttachmentsTransaction = (param: { - appId: string; +export type StartCreateCaseWithAttachmentsTransaction = (param?: { + appId?: string; attachments?: CaseAttachmentsWithoutOwner; }) => Transaction | undefined; @@ -28,11 +28,17 @@ export const useCreateCaseWithAttachmentsTransaction = () => { const startCreateCaseWithAttachmentsTransaction = useCallback( - ({ appId, attachments }) => { + ({ appId, attachments } = {}) => { + if (!appId) { + return; + } + if (!attachments) { return startTransaction(`Cases [${appId}] ${CREATE_CASE}`); } + const alertCount = getAlertCount(attachments); + if (alertCount <= 1) { return startTransaction(`Cases [${appId}] ${ADD_ATTACHMENT_TO_NEW_CASE}`); } @@ -48,7 +54,7 @@ export const useCreateCaseWithAttachmentsTransaction = () => { }; export type StartAddAttachmentToExistingCaseTransaction = (param: { - appId: string; + appId?: string; attachments: CaseAttachmentsWithoutOwner; }) => Transaction | undefined; @@ -59,13 +65,20 @@ export const useAddAttachmentToExistingCaseTransaction = () => { const startAddAttachmentToExistingCaseTransaction = useCallback( ({ appId, attachments }) => { + if (!appId) { + return; + } + const alertCount = getAlertCount(attachments); + if (alertCount <= 1) { return startTransaction(`Cases [${appId}] ${ADD_ATTACHMENT_TO_EXISTING_CASE}`); } + const transaction = startTransaction( `Cases [${appId}] ${BULK_ADD_ATTACHMENT_TO_EXISTING_CASE}` ); + transaction?.addLabels({ alert_count: alertCount }); return transaction; }, diff --git a/x-pack/plugins/cases/public/common/hooks.test.tsx b/x-pack/plugins/cases/public/common/hooks.test.tsx index 80602f23012f5..42ca1c20d578e 100644 --- a/x-pack/plugins/cases/public/common/hooks.test.tsx +++ b/x-pack/plugins/cases/public/common/hooks.test.tsx @@ -10,9 +10,9 @@ import { renderHook } from '@testing-library/react-hooks'; import { TestProviders } from './mock'; import { useIsMainApplication } from './hooks'; -import { useApplication } from '../components/cases_context/use_application'; +import { useApplication } from './lib/kibana/use_application'; -jest.mock('../components/cases_context/use_application'); +jest.mock('./lib/kibana/use_application'); const useApplicationMock = useApplication as jest.Mock; diff --git a/x-pack/plugins/cases/public/common/hooks.ts b/x-pack/plugins/cases/public/common/hooks.ts index f65b56fecfd84..a3837d4ebb16c 100644 --- a/x-pack/plugins/cases/public/common/hooks.ts +++ b/x-pack/plugins/cases/public/common/hooks.ts @@ -6,10 +6,10 @@ */ import { STACK_APP_ID } from '../../common/constants'; -import { useCasesContext } from '../components/cases_context/use_cases_context'; +import { useApplication } from './lib/kibana/use_application'; export const useIsMainApplication = () => { - const { appId } = useCasesContext(); + const { appId } = useApplication(); return appId === STACK_APP_ID; }; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts index 3aa4c02457ef7..7bf4e71e0717a 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts @@ -25,7 +25,6 @@ export const useKibana = jest.fn().mockReturnValue({ export const useHttp = jest.fn().mockReturnValue(createStartServicesMock().http); export const useTimeZone = jest.fn(); export const useDateFormat = jest.fn(); -export const useBasePath = jest.fn(() => '/test/base/path'); export const useToasts = jest .fn() .mockReturnValue(notificationServiceMock.createStartContract().toasts); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/use_application.tsx b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/use_application.tsx new file mode 100644 index 0000000000000..2a4945436e184 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/use_application.tsx @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export const useApplication = jest + .fn() + .mockReturnValue({ appId: 'testAppId', appTitle: 'test-title' }); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts index 39b4d3d1edc76..3d72e5ca552b9 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -30,8 +30,6 @@ export const useTimeZone = (): string => { return timeZone === 'Browser' ? moment.tz.guess() : timeZone; }; -export const useBasePath = (): string => useKibana().services.http.basePath.get(); - export const useToasts = (): StartServices['notifications']['toasts'] => useKibana().services.notifications.toasts; @@ -116,12 +114,12 @@ export const useCurrentUser = (): AuthenticatedElasticUser | null => { * Returns a full URL to the provided page path by using * kibana's `getUrlForApp()` */ -export const useAppUrl = (appId: string) => { +export const useAppUrl = (appId?: string) => { const { getUrlForApp } = useKibana().services.application; const getAppUrl = useCallback( (options?: { deepLinkId?: string; path?: string; absolute?: boolean }) => - getUrlForApp(appId, options), + getUrlForApp(appId ?? '', options), [appId, getUrlForApp] ); return { getAppUrl }; @@ -131,7 +129,7 @@ export const useAppUrl = (appId: string) => { * Navigate to any app using kibana's `navigateToApp()` * or by url using `navigateToUrl()` */ -export const useNavigateTo = (appId: string) => { +export const useNavigateTo = (appId?: string) => { const { navigateToApp, navigateToUrl } = useKibana().services.application; const navigateTo = useCallback( @@ -144,7 +142,7 @@ export const useNavigateTo = (appId: string) => { if (url) { navigateToUrl(url); } else { - navigateToApp(appId, options); + navigateToApp(appId ?? '', options); } }, [appId, navigateToApp, navigateToUrl] @@ -156,7 +154,7 @@ export const useNavigateTo = (appId: string) => { * Returns navigateTo and getAppUrl navigation hooks * */ -export const useNavigation = (appId: string) => { +export const useNavigation = (appId?: string) => { const { navigateTo } = useNavigateTo(appId); const { getAppUrl } = useAppUrl(appId); return { navigateTo, getAppUrl }; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx index 195c1f433a8e7..0223e4648ac93 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { BehaviorSubject } from 'rxjs'; import type { PublicAppInfo } from '@kbn/core/public'; +import { AppStatus } from '@kbn/core/public'; import type { RecursivePartial } from '@elastic/eui/src/components/common'; import { coreMock } from '@kbn/core/public/mocks'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; @@ -52,7 +53,21 @@ export const createStartServicesMock = ({ license }: StartServiceArgs = {}): Sta services.application.currentAppId$ = new BehaviorSubject('testAppId'); services.application.applications$ = new BehaviorSubject>( - new Map([['testAppId', { category: { label: 'Test' } } as unknown as PublicAppInfo]]) + new Map([ + [ + 'testAppId', + { + id: 'testAppId', + title: 'test-title', + category: { id: 'test-label-id', label: 'Test' }, + status: AppStatus.accessible, + visibleIn: ['globalSearch'], + appRoute: `/app/some-id`, + keywords: [], + deepLinks: [], + }, + ], + ]) ); services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ diff --git a/x-pack/plugins/cases/public/common/lib/kibana/use_application.test.tsx b/x-pack/plugins/cases/public/common/lib/kibana/use_application.test.tsx new file mode 100644 index 0000000000000..81152d3d3c5a1 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/use_application.test.tsx @@ -0,0 +1,108 @@ +/* + * 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 type { PublicAppInfo } from '@kbn/core-application-browser'; +import { AppStatus } from '@kbn/core-application-browser'; +import { renderHook } from '@testing-library/react-hooks'; +import { BehaviorSubject, Subject } from 'rxjs'; +import type { AppMockRenderer } from '../../mock'; +import { createAppMockRenderer } from '../../mock'; +import { useApplication } from './use_application'; + +describe('useApplication', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + const getApp = (props: Partial = {}): PublicAppInfo => ({ + id: 'testAppId', + title: 'Test title', + status: AppStatus.accessible, + visibleIn: ['globalSearch'], + appRoute: `/app/some-id`, + keywords: [], + deepLinks: [], + ...props, + }); + + it('returns the appId and the appTitle correctly', () => { + const { result } = renderHook(() => useApplication(), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toEqual({ + appId: 'testAppId', + appTitle: 'Test', + }); + }); + + it('returns undefined appId and appTitle if the currentAppId observable is not defined', () => { + appMockRender.coreStart.application.currentAppId$ = new Subject(); + + const { result } = renderHook(() => useApplication(), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toEqual({}); + }); + + it('returns undefined appTitle if the applications observable is not defined', () => { + appMockRender.coreStart.application.applications$ = new Subject(); + + const { result } = renderHook(() => useApplication(), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toEqual({ + appId: 'testAppId', + appTitle: undefined, + }); + }); + + it('returns the label as appTitle', () => { + appMockRender.coreStart.application.applications$ = new BehaviorSubject( + new Map([['testAppId', getApp({ category: { id: 'test-label-id', label: 'Test label' } })]]) + ); + + const { result } = renderHook(() => useApplication(), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toEqual({ + appId: 'testAppId', + appTitle: 'Test label', + }); + }); + + it('returns the title as appTitle if the categories label is missing', () => { + appMockRender.coreStart.application.applications$ = new BehaviorSubject( + new Map([['testAppId', getApp({ title: 'Test title' })]]) + ); + + const { result } = renderHook(() => useApplication(), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toEqual({ + appId: 'testAppId', + appTitle: 'Test title', + }); + }); + + it('gets the value from the default value of the currentAppId observable if it exists', () => { + appMockRender.coreStart.application.currentAppId$ = new BehaviorSubject('new-test-id'); + + const { result } = renderHook(() => useApplication(), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toEqual({ appId: 'new-test-id' }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/cases_context/use_application.tsx b/x-pack/plugins/cases/public/common/lib/kibana/use_application.tsx similarity index 96% rename from x-pack/plugins/cases/public/components/cases_context/use_application.tsx rename to x-pack/plugins/cases/public/common/lib/kibana/use_application.tsx index 4ea44c087705d..5e8ad524ffe09 100644 --- a/x-pack/plugins/cases/public/components/cases_context/use_application.tsx +++ b/x-pack/plugins/cases/public/common/lib/kibana/use_application.tsx @@ -6,7 +6,7 @@ */ import useObservable from 'react-use/lib/useObservable'; import type { BehaviorSubject, Observable } from 'rxjs'; -import { useKibana } from '../../common/lib/kibana'; +import { useKibana } from './kibana_react'; interface UseApplicationReturn { appId: string | undefined; diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 0361014cbfb68..45239024313db 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -19,7 +19,7 @@ import type { ScopedFilesClient } from '@kbn/files-plugin/public'; import { euiDarkVars } from '@kbn/ui-theme'; import { I18nProvider } from '@kbn/i18n-react'; import { createMockFilesClient } from '@kbn/shared-ux-file-mocks'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient } from '@tanstack/react-query'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { FilesContext } from '@kbn/shared-ux-file-context'; @@ -108,10 +108,9 @@ const TestProvidersComponent: React.FC = ({ permissions, getFilesClient, }} + queryClient={queryClient} > - - {children} - + {children} @@ -191,8 +190,9 @@ export const createAppMockRenderer = ({ releasePhase, getFilesClient, }} + queryClient={queryClient} > - {children} + {children} diff --git a/x-pack/plugins/cases/public/common/navigation/hooks.ts b/x-pack/plugins/cases/public/common/navigation/hooks.ts index 5d8b835defdc5..2072b29400c8a 100644 --- a/x-pack/plugins/cases/public/common/navigation/hooks.ts +++ b/x-pack/plugins/cases/public/common/navigation/hooks.ts @@ -10,11 +10,11 @@ import { useLocation, useParams } from 'react-router-dom'; import { APP_ID, CASES_CONFIGURE_PATH, CASES_CREATE_PATH } from '../../../common/constants'; import { useNavigation } from '../lib/kibana'; -import { useCasesContext } from '../../components/cases_context/use_cases_context'; import type { ICasesDeepLinkId } from './deep_links'; import type { CaseViewPathParams, CaseViewPathSearchParams } from './paths'; import { generateCaseViewPath } from './paths'; import { stringifyToURL, parseURL } from '../../components/utils'; +import { useApplication } from '../lib/kibana/use_application'; export const useCaseViewParams = () => useParams(); @@ -46,7 +46,7 @@ export const useCasesNavigation = ({ path?: string; deepLinkId?: ICasesDeepLinkId; }): UseCasesNavigation => { - const { appId } = useCasesContext(); + const { appId } = useApplication(); const { navigateTo, getAppUrl } = useNavigation(appId); const getCasesUrl = useCallback( (absolute) => getAppUrl({ path, deepLinkId, absolute }), @@ -100,7 +100,7 @@ type GetCaseViewUrl = (pathParams: CaseViewPathParams, absolute?: boolean) => st type NavigateToCaseView = (pathParams: CaseViewPathParams) => void; export const useCaseViewNavigation = () => { - const { appId } = useCasesContext(); + const { appId } = useApplication(); const { navigateTo, getAppUrl } = useNavigation(appId); const deepLinkId = APP_ID; diff --git a/x-pack/plugins/cases/public/common/use_cases_local_storage.test.tsx b/x-pack/plugins/cases/public/common/use_cases_local_storage.test.tsx new file mode 100644 index 0000000000000..740fa78dc3c0d --- /dev/null +++ b/x-pack/plugins/cases/public/common/use_cases_local_storage.test.tsx @@ -0,0 +1,149 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { Subject } from 'rxjs'; +import type { AppMockRenderer } from './mock/test_providers'; +import { createAppMockRenderer } from './mock/test_providers'; +import { useCasesLocalStorage } from './use_cases_local_storage'; + +describe('useCasesLocalStorage', () => { + const initialValue = { foo: 'bar' }; + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + localStorage.clear(); + }); + + describe('owner', () => { + const lsKey = 'myKey'; + const ownerLSKey = `securitySolution.${lsKey}`; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + + it('initialize the local storage correctly', async () => { + const { result } = renderHook(() => useCasesLocalStorage(lsKey, initialValue), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current[0]).toEqual(initialValue); + expect(localStorage.getItem(ownerLSKey)).toEqual('{"foo":"bar"}'); + }); + + it('initialize with an existing value in the local storage correctly', async () => { + localStorage.setItem(ownerLSKey, '{"foo":"new value"}'); + + const { result } = renderHook(() => useCasesLocalStorage(lsKey, initialValue), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current[0]).toEqual({ foo: 'new value' }); + expect(localStorage.getItem(ownerLSKey)).toEqual('{"foo":"new value"}'); + }); + + it('persists to the local storage correctly', async () => { + const { result } = renderHook(() => useCasesLocalStorage(lsKey, initialValue), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current[1]({ foo: 'test' }); + }); + + expect(result.current[0]).toEqual({ foo: 'test' }); + expect(localStorage.getItem(ownerLSKey)).toEqual('{"foo":"test"}'); + }); + + it('returns the initial value in case of parsing errors', async () => { + localStorage.setItem(ownerLSKey, 'test'); + + const { result } = renderHook(() => useCasesLocalStorage(lsKey, initialValue), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current[0]).toEqual(initialValue); + expect(localStorage.getItem(ownerLSKey)).toEqual('{"foo":"bar"}'); + }); + + it('supports multiple owners correctly', async () => { + appMockRender = createAppMockRenderer({ owner: ['securitySolution', 'observability'] }); + const { result } = renderHook(() => useCasesLocalStorage(lsKey, initialValue), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current[0]).toEqual(initialValue); + expect(localStorage.getItem('securitySolution.observability.myKey')).toEqual('{"foo":"bar"}'); + }); + }); + + describe('appId', () => { + const lsKey = 'myKey'; + const ownerLSKey = `testAppId.${lsKey}`; + + beforeEach(() => { + appMockRender = createAppMockRenderer({ owner: [] }); + }); + + it('initialize the local storage correctly', async () => { + const { result } = renderHook(() => useCasesLocalStorage(lsKey, initialValue), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current[0]).toEqual(initialValue); + expect(localStorage.getItem(ownerLSKey)).toEqual('{"foo":"bar"}'); + }); + + it('initialize with an existing value in the local storage correctly', async () => { + localStorage.setItem(ownerLSKey, '{"foo":"new value"}'); + + const { result } = renderHook(() => useCasesLocalStorage(lsKey, initialValue), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current[0]).toEqual({ foo: 'new value' }); + expect(localStorage.getItem(ownerLSKey)).toEqual('{"foo":"new value"}'); + }); + + it('persists to the local storage correctly', async () => { + const { result } = renderHook(() => useCasesLocalStorage(lsKey, initialValue), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current[1]({ foo: 'test' }); + }); + + expect(result.current[0]).toEqual({ foo: 'test' }); + expect(localStorage.getItem(ownerLSKey)).toEqual('{"foo":"test"}'); + }); + + it('returns the initial value in case of parsing errors', async () => { + localStorage.setItem(ownerLSKey, 'test'); + + const { result } = renderHook(() => useCasesLocalStorage(lsKey, initialValue), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current[0]).toEqual(initialValue); + expect(localStorage.getItem(ownerLSKey)).toEqual('{"foo":"bar"}'); + }); + + it('returns the initial value and not persist to local storage if the appId is not defined', async () => { + appMockRender.coreStart.application.currentAppId$ = new Subject(); + + const { result } = renderHook(() => useCasesLocalStorage(lsKey, initialValue), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current[0]).toEqual(initialValue); + expect(localStorage.getItem(ownerLSKey)).toEqual(null); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/common/use_cases_local_storage.tsx b/x-pack/plugins/cases/public/common/use_cases_local_storage.tsx new file mode 100644 index 0000000000000..1d709a45f1b31 --- /dev/null +++ b/x-pack/plugins/cases/public/common/use_cases_local_storage.tsx @@ -0,0 +1,70 @@ +/* + * 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 { useCallback, useState, useRef } from 'react'; +import { useCasesContext } from '../components/cases_context/use_cases_context'; +import { useApplication } from './lib/kibana/use_application'; + +export const useCasesLocalStorage = ( + key: string, + initialValue: T +): [T, (newItem: T) => void] => { + const isStorageInitialized = useRef(false); + const { appId } = useApplication(); + const { owner } = useCasesContext(); + + const lsKeyPrefix = owner.length > 0 ? owner.join('.') : appId; + const lsKey = getLocalStorageKey(key, lsKeyPrefix); + + const [value, setValue] = useState(() => getStorageItem(lsKey, initialValue)); + + const setItem = useCallback( + (newValue: T) => { + setValue(newValue); + saveItemToStorage(lsKey, newValue); + }, + [lsKey] + ); + + if (!lsKeyPrefix) { + return [initialValue, setItem]; + } + + if (lsKeyPrefix != null && !isStorageInitialized.current) { + isStorageInitialized.current = true; + setItem(getStorageItem(lsKey, initialValue)); + } + + return [value, setItem]; +}; + +const getStorageItem = (key: string, initialValue: T): T => { + try { + const value = localStorage.getItem(key); + if (!value) { + return initialValue; + } + + return JSON.parse(value); + } catch (error) { + // silent errors + return initialValue; + } +}; + +const saveItemToStorage = (key: string, item: T) => { + try { + const value = JSON.stringify(item); + localStorage.setItem(key, value); + } catch (error) { + // silent errors + } +}; + +const getLocalStorageKey = (localStorageKey: string, prefix?: string) => { + return [prefix, localStorageKey].filter(Boolean).join('.'); +}; diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx index 53fe9118a6aa0..e45b55eb3a765 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx @@ -14,13 +14,16 @@ import { alertComment, basicComment, mockCase } from '../containers/mock'; import React from 'react'; import userEvent from '@testing-library/user-event'; import type { SupportedCaseAttachment } from '../types'; -import { getByTestId } from '@testing-library/react'; +import { getByTestId, queryByTestId, screen } from '@testing-library/react'; import { OWNER_INFO } from '../../common/constants'; +import { useApplication } from './lib/kibana/use_application'; jest.mock('./lib/kibana'); +jest.mock('./lib/kibana/use_application'); const useToastsMock = useToasts as jest.Mock; const useKibanaMock = useKibana as jest.Mocked; +const useApplicationMock = useApplication as jest.Mock; describe('Use cases toast hook', () => { const successMock = jest.fn(); @@ -66,6 +69,8 @@ describe('Use cases toast hook', () => { getUrlForApp, navigateToUrl, }; + + useApplicationMock.mockReturnValue({ appId: 'testAppId' }); }); describe('showSuccessAttach', () => { @@ -147,6 +152,7 @@ describe('Use cases toast hook', () => { describe('Toast content', () => { let appMockRender: AppMockRenderer; const onViewCaseClick = jest.fn(); + beforeEach(() => { appMockRender = createAppMockRenderer(); onViewCaseClick.mockReset(); @@ -213,9 +219,16 @@ describe('Use cases toast hook', () => { const result = appMockRender.render( ); + userEvent.click(result.getByTestId('toaster-content-case-view-link')); expect(onViewCaseClick).toHaveBeenCalled(); }); + + it('hides the view case link when onViewCaseClick is not defined', () => { + appMockRender.render(); + + expect(screen.queryByTestId('toaster-content-case-view-link')).not.toBeInTheDocument(); + }); }); describe('Toast navigation', () => { @@ -267,6 +280,31 @@ describe('Use cases toast hook', () => { path: '/mock-id', }); }); + + it('does not navigates to a case if the appId is not defined', () => { + useApplicationMock.mockReturnValue({ appId: undefined }); + + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + + result.current.showSuccessAttach({ + theCase: { ...mockCase, owner: 'in-valid' }, + title: 'Custom title', + }); + + const mockParams = successMock.mock.calls[0][0]; + const el = document.createElement('div'); + mockParams.text(el); + const button = queryByTestId(el, 'toaster-content-case-view-link'); + + expect(button).toBeNull(); + expect(getUrlForApp).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.tsx index baa15f1b47248..ef872058a88af 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.tsx @@ -23,7 +23,7 @@ import { VIEW_CASE, } from './translations'; import { OWNER_INFO } from '../../common/constants'; -import { useCasesContext } from '../components/cases_context/use_cases_context'; +import { useApplication } from './lib/kibana/use_application'; const LINE_CLAMP = 3; const Title = styled.span` @@ -119,7 +119,7 @@ const getErrorMessage = (error: Error | ServerError): string => { }; export const useCasesToast = () => { - const { appId } = useCasesContext(); + const { appId } = useApplication(); const { getUrlForApp, navigateToUrl } = useKibana().services.application; const toasts = useToasts(); @@ -141,13 +141,18 @@ export const useCasesToast = () => { ? OWNER_INFO[theCase.owner].appId : appId; - const url = getUrlForApp(appIdToNavigateTo, { - deepLinkId: 'cases', - path: generateCaseViewPath({ detailName: theCase.id }), - }); + const url = + appIdToNavigateTo != null + ? getUrlForApp(appIdToNavigateTo, { + deepLinkId: 'cases', + path: generateCaseViewPath({ detailName: theCase.id }), + }) + : null; const onViewCaseClick = () => { - navigateToUrl(url); + if (url) { + navigateToUrl(url); + } }; const renderTitle = getToastTitle({ theCase, title, attachments }); @@ -158,7 +163,10 @@ export const useCasesToast = () => { iconType: 'check', title: toMountPoint({renderTitle}), text: toMountPoint( - + ), }); }, @@ -189,7 +197,7 @@ export const CaseToastSuccessContent = ({ onViewCaseClick, content, }: { - onViewCaseClick: () => void; + onViewCaseClick?: () => void; content?: string; }) => { return ( @@ -199,14 +207,16 @@ export const CaseToastSuccessContent = ({ {content} ) : null} - - {VIEW_CASE} - + {onViewCaseClick !== undefined ? ( + + {VIEW_CASE} + + ) : null} ); }; diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index 4242dbbae81e1..71c435c2efadd 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -49,7 +49,7 @@ const sampleData: CaseAttachmentWithoutOwner = { comment: 'what a cool comment', type: AttachmentType.user as const, }; -const appId = 'testAppId'; +const appId = 'securitySolution'; const draftKey = `cases.${appId}.${addCommentProps.caseId}.${addCommentProps.id}.markdownEditor`; describe('AddComment ', () => { diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx index f29e71673198c..4dab97634d74f 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -73,9 +73,13 @@ export const AddComment = React.memo( ) => { const editorRef = useRef(null); const [focusOnContext, setFocusOnContext] = useState(false); - const { permissions, owner, appId } = useCasesContext(); + const { permissions, owner } = useCasesContext(); const { isLoading, mutate: createAttachments } = useCreateAttachments(); - const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, id); + const draftStorageKey = getMarkdownEditorStorageKey({ + appId: owner[0], + caseId, + commentId: id, + }); const { form } = useForm({ defaultValue: initialCommentValue, diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index e7aa9e37961be..7cfc840356355 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -25,6 +25,7 @@ import { useCasesAddToExistingCaseModal } from './use_cases_add_to_existing_case import { PersistableStateAttachmentTypeRegistry } from '../../../client/attachment_framework/persistable_state_registry'; jest.mock('../../../common/use_cases_toast'); +jest.mock('../../../common/lib/kibana/use_application'); jest.mock('../../../containers/use_create_attachments'); // dummy mock, will call onRowclick when rendering jest.mock('./all_cases_selector_modal', () => { @@ -71,8 +72,6 @@ describe('use cases add to existing case modal hook', () => { persistableStateAttachmentTypeRegistry, owner: ['test'], permissions: allCasesPermissions(), - appId: 'test', - appTitle: 'jest', basePath: '/jest', dispatch, features: { alerts: { sync: true, enabled: true, isExperimental: false }, metrics: [] }, diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx index ea60c43937346..2c739cfe1b984 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx @@ -6,6 +6,7 @@ */ import { useCallback } from 'react'; +import { useApplication } from '../../../common/lib/kibana/use_application'; import { CaseStatuses } from '../../../../common/types/domain'; import type { AllCasesSelectorModalProps } from '.'; import { useCasesToast } from '../../../common/use_cases_toast'; @@ -52,7 +53,8 @@ export const useCasesAddToExistingCaseModal = ({ toastContent: successToaster?.content, }); - const { dispatch, appId } = useCasesContext(); + const { dispatch } = useCasesContext(); + const { appId } = useApplication(); const casesToasts = useCasesToast(); const { mutateAsync: createAttachments } = useCreateAttachments(); const { startTransaction } = useAddAttachmentToExistingCaseTransaction(); diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.test.tsx index 25dd550a3ecf3..daea26475e364 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.test.tsx @@ -99,7 +99,7 @@ describe('useFilterConfig', () => { const uiCustomFieldKey = `${CUSTOM_FIELD_KEY_PREFIX}${customFieldKey}`; localStorage.setItem( - 'testAppId.cases.list.tableFiltersConfig', + 'securitySolution.cases.list.tableFiltersConfig', JSON.stringify([{ key: uiCustomFieldKey, isActive: false }]) ); diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx index 6c342770a12f3..2a30f36abcd01 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_filter_config.tsx @@ -7,13 +7,12 @@ import type { SetStateAction } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; -import useLocalStorage from 'react-use/lib/useLocalStorage'; import { merge, isEqual, isEmpty } from 'lodash'; +import { useCasesLocalStorage } from '../../../common/use_cases_local_storage'; import type { CasesConfigurationUI, FilterOptions } from '../../../../common/ui'; import { LOCAL_STORAGE_KEYS } from '../../../../common/constants'; import type { FilterConfig, FilterConfigState } from './types'; import { useCustomFieldsFilterConfig } from './use_custom_fields_filter_config'; -import { useCasesContext } from '../../cases_context/use_cases_context'; import { deflattenCustomFieldKey, isFlattenCustomField } from '../utils'; const mergeSystemAndCustomFieldConfigs = ({ @@ -50,9 +49,8 @@ const shouldBeActive = ({ }; const useActiveByFilterKeyState = ({ filterOptions }: { filterOptions: FilterOptions }) => { - const { appId } = useCasesContext(); - const [activeByFilterKey, setActiveByFilterKey] = useLocalStorage( - `${appId}.${LOCAL_STORAGE_KEYS.casesTableFiltersConfig}`, + const [activeByFilterKey, setActiveByFilterKey] = useCasesLocalStorage( + LOCAL_STORAGE_KEYS.casesTableFiltersConfig, [] ); diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index d407d03517b25..3d6bfa856b7a8 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -469,7 +469,7 @@ describe('CasesTableFilters ', () => { beforeEach(() => { const previousState = [{ key: uiCustomFieldKey, isActive: true }]; localStorage.setItem( - 'testAppId.cases.list.tableFiltersConfig', + 'securitySolution.cases.list.tableFiltersConfig', JSON.stringify(previousState) ); @@ -571,7 +571,7 @@ describe('CasesTableFilters ', () => { it('should reset the selected options when a custom field filter is deactivated', async () => { const previousState = [{ key: uiCustomFieldKey, isActive: true }]; localStorage.setItem( - 'testAppId.cases.list.tableFiltersConfig', + 'securitySolution.cases.list.tableFiltersConfig', JSON.stringify(previousState) ); const customProps = { @@ -673,7 +673,9 @@ describe('CasesTableFilters ', () => { await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(5)); userEvent.click(screen.getByRole('option', { name: 'Toggle' })); - const storedFilterState = localStorage.getItem('testAppId.cases.list.tableFiltersConfig'); + const storedFilterState = localStorage.getItem( + 'securitySolution.cases.list.tableFiltersConfig' + ); expect(storedFilterState).toBeTruthy(); expect(JSON.parse(storedFilterState!)).toMatchInlineSnapshot(` Array [ @@ -752,7 +754,9 @@ describe('CasesTableFilters ', () => { userEvent.click(screen.getByRole('option', { name: 'Status' })); - const storedFilterState = localStorage.getItem('testAppId.cases.list.tableFiltersConfig'); + const storedFilterState = localStorage.getItem( + 'securitySolution.cases.list.tableFiltersConfig' + ); expect(storedFilterState).toBeTruthy(); expect(JSON.parse(storedFilterState || '')).toMatchInlineSnapshot(` Array [ @@ -792,7 +796,7 @@ describe('CasesTableFilters ', () => { ]; localStorage.setItem( - 'testAppId.cases.list.tableFiltersConfig', + 'securitySolution.cases.list.tableFiltersConfig', JSON.stringify(previousState) ); @@ -822,7 +826,7 @@ describe('CasesTableFilters ', () => { { key: `${CUSTOM_FIELD_KEY_PREFIX}toggle`, isActive: true }, ]; localStorage.setItem( - 'testAppId.cases.list.tableFiltersConfig', + 'securitySolution.cases.list.tableFiltersConfig', JSON.stringify(previousState) ); @@ -950,7 +954,7 @@ describe('CasesTableFilters ', () => { { key: `${CUSTOM_FIELD_KEY_PREFIX}toggle`, isActive: true }, ]; localStorage.setItem( - 'testAppId.cases.list.tableFiltersConfig', + 'securitySolution.cases.list.tableFiltersConfig', JSON.stringify(previousState) ); @@ -960,7 +964,9 @@ describe('CasesTableFilters ', () => { // we need any user action to trigger the filter config update userEvent.click(await screen.findByRole('option', { name: 'Toggle' })); - const storedFilterState = localStorage.getItem('testAppId.cases.list.tableFiltersConfig'); + const storedFilterState = localStorage.getItem( + 'securitySolution.cases.list.tableFiltersConfig' + ); // the fakeField and owner filter should be removed and toggle should update isActive expect(JSON.parse(storedFilterState || '')).toMatchInlineSnapshot(` Array [ @@ -1003,7 +1009,7 @@ describe('CasesTableFilters ', () => { ]; localStorage.setItem( - 'testAppId.cases.list.tableFiltersConfig', + 'securitySolution.cases.list.tableFiltersConfig', JSON.stringify(previousState) ); @@ -1052,7 +1058,7 @@ describe('CasesTableFilters ', () => { license: { type: 'platinum' }, }); - localStorage.setItem('testAppId.cases.list.tableFiltersConfig', JSON.stringify([])); + localStorage.setItem('securitySolution.cases.list.tableFiltersConfig', JSON.stringify([])); const overrideProps = { ...props, diff --git a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx index 43aec66176c6f..ce394a5047fed 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.test.tsx @@ -43,7 +43,7 @@ jest.mock('react-router-dom', () => ({ const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; -const LS_KEY = 'testAppId.cases.list.state'; +const LS_KEY = 'securitySolution.cases.list.state'; describe('useAllCasesQueryParams', () => { beforeEach(() => { @@ -563,7 +563,7 @@ describe('useAllCasesQueryParams', () => { }); // first call is the initial call made by useLocalStorage - expect(lsSpy).toBeCalledTimes(1); + expect(lsSpy).toBeCalledTimes(2); }); it('does not update the local storage when the custom field configuration is loading', async () => { diff --git a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx index 39bac2c05d569..094c5605edbe0 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_state.tsx @@ -5,11 +5,9 @@ * 2.0. */ -import type { Dispatch, SetStateAction } from 'react'; import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { useLocation, useHistory } from 'react-router-dom'; import deepEqual from 'react-fast-compare'; -import useLocalStorage from 'react-use/lib/useLocalStorage'; import { isEmpty } from 'lodash'; import type { FilterOptions, QueryParams } from '../../../common/ui/types'; @@ -24,9 +22,9 @@ import { stringifyUrlParams } from './utils/stringify_url_params'; import { allCasesUrlStateDeserializer } from './utils/all_cases_url_state_deserializer'; import { allCasesUrlStateSerializer } from './utils/all_cases_url_state_serializer'; import { parseUrlParams } from './utils/parse_url_params'; -import { useCasesContext } from '../cases_context/use_cases_context'; import { sanitizeState } from './utils/sanitize_state'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { useCasesLocalStorage } from '../../common/use_cases_local_storage'; interface UseAllCasesStateReturn { filterOptions: FilterOptions; @@ -177,13 +175,14 @@ const isURLStateEmpty = (urlState: AllCasesURLState) => { const useAllCasesLocalStorage = (): [ AllCasesTableState | undefined, - Dispatch> + (item: AllCasesTableState | undefined) => void ] => { - const { appId } = useCasesContext(); - - const [state, setState] = useLocalStorage( - getAllCasesTableStateLocalStorageKey(appId), - { queryParams: DEFAULT_QUERY_PARAMS, filterOptions: DEFAULT_FILTER_OPTIONS } + const [state, setState] = useCasesLocalStorage( + LOCAL_STORAGE_KEYS.casesTableState, + { + queryParams: DEFAULT_QUERY_PARAMS, + filterOptions: DEFAULT_FILTER_OPTIONS, + } ); const sanitizedState = sanitizeState(state); @@ -199,8 +198,3 @@ const useAllCasesLocalStorage = (): [ setState, ]; }; - -const getAllCasesTableStateLocalStorageKey = (appId: string) => { - const key = LOCAL_STORAGE_KEYS.casesTableState; - return `${appId}.${key}`; -}; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.test.tsx index 98f4e33ee9132..26f0f8c2fb85e 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.test.tsx @@ -18,7 +18,7 @@ jest.mock('./use_cases_columns_configuration'); const useCasesColumnsConfigurationMock = useCasesColumnsConfiguration as jest.Mock; -const localStorageKey = 'testAppId.cases.list.tableColumns'; +const localStorageKey = 'securitySolution.cases.list.tableColumns'; const casesColumnsConfig = { title: { field: 'title', diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.tsx index 7b81e88c0d383..4ec6c221e36ef 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns_selection.tsx @@ -5,26 +5,19 @@ * 2.0. */ -import useLocalStorage from 'react-use/lib/useLocalStorage'; - import type { CasesColumnSelection } from './types'; import { LOCAL_STORAGE_KEYS } from '../../../common/constants'; -import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesColumnsConfiguration } from './use_cases_columns_configuration'; import { mergeSelectedColumnsWithConfiguration } from './utils/merge_selected_columns_with_configuration'; - -const getTableColumnsLocalStorageKey = (appId: string) => { - const filteringKey = LOCAL_STORAGE_KEYS.casesTableColumns; - return `${appId}.${filteringKey}`; -}; +import { useCasesLocalStorage } from '../../common/use_cases_local_storage'; export function useCasesColumnsSelection() { - const { appId } = useCasesContext(); const casesColumnsConfig = useCasesColumnsConfiguration(); - const [selectedColumns, setSelectedColumns] = useLocalStorage( - getTableColumnsLocalStorageKey(appId) + const [selectedColumns, setSelectedColumns] = useCasesLocalStorage( + LOCAL_STORAGE_KEYS.casesTableColumns, + [] ); const columns = selectedColumns || []; diff --git a/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx index f58e7aa2698cc..378425325cca8 100644 --- a/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx @@ -23,7 +23,7 @@ import { CasesTableUtilityBar } from './utility_bar'; describe('Severity form field', () => { let appMockRender: AppMockRenderer; const deselectCases = jest.fn(); - const localStorageKey = 'cases.testAppId.utilityBar.hideMaxLimitWarning'; + const localStorageKey = 'securitySolution.cases.utilityBar.hideMaxLimitWarning'; const props = { totalCases: 5, @@ -337,7 +337,7 @@ describe('Severity form field', () => { expect(await screen.findByTestId('all-cases-maximum-limit-warning')).toBeInTheDocument(); expect(await screen.findByTestId('do-not-show-warning')).toBeInTheDocument(); - expect(localStorage.getItem(localStorageKey)).toBe(null); + expect(localStorage.getItem(localStorageKey)).toBe('false'); }); it('should hide warning correctly when do not show button clicked', async () => { diff --git a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx index afd9398d91cce..2e7a200cf79a3 100644 --- a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx @@ -7,7 +7,6 @@ import type { FunctionComponent } from 'react'; import { css } from '@emotion/react'; -import useLocalStorage from 'react-use/lib/useLocalStorage'; import React, { useCallback, useState } from 'react'; import type { Pagination } from '@elastic/eui'; import { @@ -29,6 +28,7 @@ import { useRefreshCases } from './use_on_refresh_cases'; import { useBulkActions } from './use_bulk_actions'; import { useCasesContext } from '../cases_context/use_cases_context'; import { ColumnsPopover } from './columns_popover'; +import { useCasesLocalStorage } from '../../common/use_cases_local_storage'; interface Props { isSelectorView?: boolean; @@ -56,12 +56,15 @@ export const CasesTableUtilityBar: FunctionComponent = React.memo( }) => { const { euiTheme } = useEuiTheme(); const refreshCases = useRefreshCases(); - const { permissions, appId } = useCasesContext(); + const { permissions } = useCasesContext(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isMessageDismissed, setIsMessageDismissed] = useState(false); - const localStorageKey = `cases.${appId}.utilityBar.hideMaxLimitWarning`; - const [localStorageWarning, setLocalStorageWarning] = useLocalStorage(localStorageKey); + const localStorageKey = `cases.utilityBar.hideMaxLimitWarning`; + const [doNotShowAgain, setDoNotShowAgain] = useCasesLocalStorage( + localStorageKey, + false + ); const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); const closePopover = useCallback(() => setIsPopoverOpen(false), []); @@ -83,7 +86,7 @@ export const CasesTableUtilityBar: FunctionComponent = React.memo( }); const handleNotShowAgain = () => { - setLocalStorageWarning(true); + setDoNotShowAgain(true); }; /** @@ -101,8 +104,6 @@ export const CasesTableUtilityBar: FunctionComponent = React.memo( totalCases >= MAX_DOCS_PER_PAGE && pagination.pageSize * (pagination.pageIndex + 1) >= MAX_DOCS_PER_PAGE; - const isDoNotShowAgainSelected = localStorageWarning && localStorageWarning === true; - const renderMaxLimitWarning = (): React.ReactNode => ( @@ -245,7 +246,7 @@ export const CasesTableUtilityBar: FunctionComponent = React.memo( {modals} {flyouts} - {hasReachedMaxCases && !isMessageDismissed && !isDoNotShowAgainSelected && ( + {hasReachedMaxCases && !isMessageDismissed && !doNotShowAgain && ( <> diff --git a/x-pack/plugins/cases/public/components/app/routes.tsx b/x-pack/plugins/cases/public/components/app/routes.tsx index 27bf536b11ab8..a6eab04267788 100644 --- a/x-pack/plugins/cases/public/components/app/routes.tsx +++ b/x-pack/plugins/cases/public/components/app/routes.tsx @@ -32,7 +32,6 @@ import type { CaseViewProps } from '../case_view/types'; const CaseViewLazy: React.FC = lazy(() => import('../case_view')); const CasesRoutesComponent: React.FC = ({ - onComponentInitialized, actionsNavigation, ruleDetailsNavigation, showAlertDetails, @@ -81,7 +80,6 @@ const CasesRoutesComponent: React.FC = ({ }> void; actionsNavigation?: CasesNavigation; ruleDetailsNavigation?: CasesNavigation; showAlertDetails?: (alertId: string, index: string) => void; diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index 59ca9a98de12e..1d76033295376 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -14,7 +14,6 @@ import { useUrlParams } from '../../common/navigation/hooks'; import { CaseViewPage } from './case_view_page'; import { caseData, caseViewProps } from './mocks'; import type { CaseViewPageProps } from './types'; -import { waitForComponentToUpdate } from '../../common/test_utils'; import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; jest.mock('../../common/navigation/hooks'); @@ -100,34 +99,4 @@ describe('CaseViewPage', () => { expect(useCasesTitleBreadcrumbsMock).toHaveBeenCalledWith(caseProps.caseData.title); }); }); - - it('should call onComponentInitialized on mount', async () => { - const onComponentInitialized = jest.fn(); - - appMockRenderer.render( - - ); - - await waitFor(() => { - expect(onComponentInitialized).toHaveBeenCalled(); - }); - }); - - it('should call onComponentInitialized only once', async () => { - const onComponentInitialized = jest.fn(); - - const { rerender } = appMockRenderer.render( - - ); - - await waitFor(() => { - expect(onComponentInitialized).toHaveBeenCalled(); - }); - - rerender(); - - await waitForComponentToUpdate(); - - expect(onComponentInitialized).toBeCalledTimes(1); - }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index 4d9e3ba640449..0f3c873dc841a 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -6,7 +6,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useUrlParams } from '../../common/navigation'; import { useCasesContext } from '../cases_context/use_cases_context'; @@ -34,7 +34,6 @@ const getActiveTabId = (tabId?: string) => { export const CaseViewPage = React.memo( ({ caseData, - onComponentInitialized, refreshRef, ruleDetailsNavigation, actionsNavigation, @@ -49,7 +48,6 @@ export const CaseViewPage = React.memo( const activeTabId = getActiveTabId(urlParams?.tabId); - const init = useRef(true); const timelineUi = useTimelineContext()?.ui; const { onUpdateField, isLoading, loadingKey } = useOnUpdateField({ @@ -85,16 +83,6 @@ export const CaseViewPage = React.memo( [onUpdateField] ); - // useEffect used for component's initialization - useEffect(() => { - if (init.current) { - init.current = false; - if (onComponentInitialized) { - onComponentInitialized(); - } - } - }, [onComponentInitialized]); - return ( <> { +describe('EditTags ', () => { let appMockRender: AppMockRenderer; const sampleTags = ['coke', 'pepsi']; @@ -62,7 +61,8 @@ describe.skip('EditTags ', () => { userEvent.click(await screen.findByTestId('tag-list-edit-button')); - userEvent.type(await screen.findByRole('combobox'), `${sampleTags[0]}{enter}`); + userEvent.paste(await screen.findByRole('combobox'), `${sampleTags[0]}`); + userEvent.keyboard('{enter}'); userEvent.click(await screen.findByTestId('edit-tags-submit')); @@ -76,7 +76,8 @@ describe.skip('EditTags ', () => { expect(await screen.findByTestId('edit-tags')).toBeInTheDocument(); - userEvent.type(await screen.findByRole('combobox'), 'dude{enter}'); + userEvent.paste(await screen.findByRole('combobox'), 'dude'); + userEvent.keyboard('{enter}'); userEvent.click(await screen.findByTestId('edit-tags-submit')); @@ -90,7 +91,8 @@ describe.skip('EditTags ', () => { expect(await screen.findByTestId('edit-tags')).toBeInTheDocument(); - userEvent.type(await screen.findByRole('combobox'), 'dude {enter}'); + userEvent.paste(await screen.findByRole('combobox'), 'dude '); + userEvent.keyboard('{enter}'); userEvent.click(await screen.findByTestId('edit-tags-submit')); @@ -102,7 +104,8 @@ describe.skip('EditTags ', () => { userEvent.click(await screen.findByTestId('tag-list-edit-button')); - userEvent.type(await screen.findByRole('combobox'), 'new{enter}'); + userEvent.paste(await screen.findByRole('combobox'), 'new'); + userEvent.keyboard('{enter}'); expect(await screen.findByTestId('comboBoxInput')).toHaveTextContent('new'); @@ -122,7 +125,8 @@ describe.skip('EditTags ', () => { expect(await screen.findByTestId('edit-tags')).toBeInTheDocument(); - userEvent.type(await screen.findByRole('combobox'), ' {enter}'); + userEvent.paste(await screen.findByRole('combobox'), ' '); + userEvent.keyboard('{enter}'); expect(await screen.findByText('A tag must contain at least one non-space character.')); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx index 07bf8ac11c39f..7537042c17246 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiText, EuiHorizontalRule, @@ -17,15 +17,9 @@ import { EuiLoadingSpinner, } from '@elastic/eui'; import styled, { css } from 'styled-components'; -import { isEqual } from 'lodash/fp'; import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { - Form, - FormDataProvider, - useForm, - getUseField, -} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { Form, useForm, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import * as i18n from '../../tags/translations'; import { useGetTags } from '../../../containers/use_get_tags'; import { Tags } from '../../tags/tags'; @@ -36,8 +30,6 @@ export const schema: FormSchema = { tags: schemaTags, }; -const CommonUseField = getUseField({ component: Field }); - export interface EditTagsProps { isLoading: boolean; onSubmit: (a: string[]) => void; @@ -68,11 +60,13 @@ const ColumnFlexGroup = styled(EuiFlexGroup)` export const EditTags = React.memo(({ isLoading, onSubmit, tags }: EditTagsProps) => { const { permissions } = useCasesContext(); const initialState = { tags }; + const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { submit } = form; const [isEditTags, setIsEditTags] = useState(false); @@ -89,21 +83,11 @@ export const EditTags = React.memo(({ isLoading, onSubmit, tags }: EditTagsProps }, [onSubmit, submit]); const { data: tagOptions = [] } = useGetTags(); - const [options, setOptions] = useState( - tagOptions.map((label) => ({ - label, - })) - ); - useEffect( - () => - setOptions( - tagOptions.map((label) => ({ - label, - })) - ), - [tagOptions] - ); + const options = tagOptions.map((label) => ({ + label, + })); + return ( @@ -123,7 +107,7 @@ export const EditTags = React.memo(({ isLoading, onSubmit, tags }: EditTagsProps data-test-subj="tag-list-edit-button" aria-label={i18n.EDIT_TAGS_ARIA} iconType={'pencil'} - onClick={setIsEditTags.bind(null, true)} + onClick={() => setIsEditTags(true)} /> )} @@ -140,8 +124,9 @@ export const EditTags = React.memo(({ isLoading, onSubmit, tags }: EditTagsProps
- - - {({ tags: anotherTags }) => { - const current: string[] = options.map((opt) => opt.label); - const newOptions = anotherTags.reduce((acc: string[], item: string) => { - if (!acc.includes(item)) { - return [...acc, item]; - } - return acc; - }, current); - if (!isEqual(current, newOptions)) { - setOptions( - newOptions.map((label: string) => ({ - label, - })) - ); - } - return null; - }} -
@@ -193,7 +159,7 @@ export const EditTags = React.memo(({ isLoading, onSubmit, tags }: EditTagsProps setIsEditTags(false)} size="s" > {i18n.CANCEL} diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index ecbde67ba15df..2468ffa057313 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -33,7 +33,6 @@ export const CaseViewLoading = () => ( export const CaseView = React.memo( ({ - onComponentInitialized, actionsNavigation, ruleDetailsNavigation, showAlertDetails, @@ -87,7 +86,6 @@ export const CaseView = React.memo( ; +type CasesContextValueDispatch = Dispatch; export interface CasesContextValue { externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; owner: string[]; - appId: string; - appTitle: string; permissions: CasesPermissions; basePath: string; features: CasesFeaturesAllRequired; @@ -66,12 +62,7 @@ export interface CasesContextProps export const CasesContext = React.createContext(undefined); -export interface CasesContextStateValue extends Omit { - appId?: string; - appTitle?: string; -} - -export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ +export const CasesProvider: React.FC<{ value: CasesContextProps; queryClient?: QueryClient }> = ({ children, value: { externalReferenceAttachmentTypeRegistry, @@ -83,49 +74,55 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ releasePhase = 'ga', getFilesClient, }, + queryClient = casesQueryClient, }) => { - const { appId, appTitle } = useApplication(); const [state, dispatch] = useReducer(casesContextReducer, getInitialCasesContextState()); - const [value, setValue] = useState(() => ({ - externalReferenceAttachmentTypeRegistry, - persistableStateAttachmentTypeRegistry, - owner, - permissions, - basePath, + + const value: CasesContextValue = useMemo( + () => ({ + externalReferenceAttachmentTypeRegistry, + persistableStateAttachmentTypeRegistry, + owner, + permissions: { + all: permissions.all, + connectors: permissions.connectors, + create: permissions.create, + delete: permissions.delete, + push: permissions.push, + read: permissions.read, + settings: permissions.settings, + update: permissions.update, + }, + basePath, + /** + * The empty object at the beginning avoids the mutation + * of the DEFAULT_FEATURES object + */ + features: merge( + {}, + DEFAULT_FEATURES, + features + ), + releasePhase, + dispatch, + }), /** - * The empty object at the beginning avoids the mutation - * of the DEFAULT_FEATURES object + * We want to trigger a rerender only when the permissions will change. + * The registries, the owner, and the rest of the values should + * not change during the lifecycle of the cases application. */ - features: merge( - {}, - DEFAULT_FEATURES, - features - ), - releasePhase, - dispatch, - })); - - /** - * Only update the context if the nested permissions fields changed, this avoids a rerender when the object's reference - * changes. - */ - useDeepCompareEffect(() => { - setValue((prev) => ({ ...prev, permissions })); - }, [permissions]); - - /** - * `appId` and `appTitle` are dynamically retrieved from kibana context. - * We need to update the state if any of these values change, the rest of props are never updated. - */ - useEffect(() => { - if (appId && appTitle) { - setValue((prev) => ({ - ...prev, - appId, - appTitle, - })); - } - }, [appTitle, appId]); + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + permissions.all, + permissions.connectors, + permissions.create, + permissions.delete, + permissions.push, + permissions.read, + permissions.settings, + permissions.update, + ] + ); const applyFilesContext = useCallback( (contextChildren: ReactNode) => { @@ -148,8 +145,8 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ [getFilesClient, owner] ); - return isCasesContextValue(value) ? ( - + return ( + {applyFilesContext( <> @@ -159,13 +156,10 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ )} - ) : null; + ); }; -CasesProvider.displayName = 'CasesProvider'; -function isCasesContextValue(value: CasesContextStateValue): value is CasesContextValue { - return value.appId != null && value.appTitle != null && value.permissions != null; -} +CasesProvider.displayName = 'CasesProvider'; // eslint-disable-next-line import/no-default-export export default CasesProvider; diff --git a/x-pack/plugins/cases/public/components/create/description.test.tsx b/x-pack/plugins/cases/public/components/create/description.test.tsx index f3fd7342db17e..d0426731f97d9 100644 --- a/x-pack/plugins/cases/public/components/create/description.test.tsx +++ b/x-pack/plugins/cases/public/components/create/description.test.tsx @@ -17,8 +17,7 @@ import { MAX_DESCRIPTION_LENGTH } from '../../../common/constants'; import { FormTestComponent } from '../../common/test_utils'; import type { FormSchema } from '@kbn/index-management-plugin/public/shared_imports'; -// FLAKY: https://github.com/elastic/kibana/issues/175204 -describe.skip('Description', () => { +describe('Description', () => { let appMockRender: AppMockRenderer; const onSubmit = jest.fn(); const draftStorageKey = `cases.caseView.createCase.description.markdownEditor`; diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx index 7f4a84b4a3e29..8e21319c31842 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx @@ -33,8 +33,6 @@ describe('use cases add to new case flyout hook', () => { persistableStateAttachmentTypeRegistry, owner: ['test'], permissions: allCasesPermissions(), - appId: 'test', - appTitle: 'jest', basePath: '/jest', dispatch, features: { alerts: { sync: true, enabled: true, isExperimental: false }, metrics: [] }, diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 280adaa66aeec..fe98b7f0cf67e 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -219,8 +219,12 @@ export const CreateCaseForm: React.FC = React.memo( attachments, initialValue, }) => { - const { owner, appId } = useCasesContext(); - const draftStorageKey = getMarkdownEditorStorageKey(appId, 'createCase', 'description'); + const { owner } = useCasesContext(); + const draftStorageKey = getMarkdownEditorStorageKey({ + appId: owner[0], + caseId: 'createCase', + commentId: 'description', + }); const handleOnConfirmationCallback = (): void => { onCancel(); diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index 2457b964ac3fc..b48bcce96becf 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -32,6 +32,7 @@ import type { CaseAttachmentsWithoutOwner } from '../../types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useCreateCaseWithAttachmentsTransaction } from '../../common/apm/use_cases_transactions'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { useApplication } from '../../common/lib/kibana/use_application'; const initialCaseValue: FormProps = { description: '', @@ -41,7 +42,6 @@ const initialCaseValue: FormProps = { connectorId: NONE_CONNECTOR_ID, fields: null, syncAlerts: true, - selectedOwner: null, assignees: [], customFields: {}, }; @@ -70,7 +70,8 @@ export const FormContext: React.FC = ({ data: { customFields: customFieldsConfiguration }, isLoading: isLoadingCaseConfiguration, } = useGetCaseConfiguration(); - const { owner, appId } = useCasesContext(); + const { owner } = useCasesContext(); + const { appId } = useApplication(); const { isSyncAlertsEnabled } = useCasesFeatures(); const { mutateAsync: postCase } = usePostCase(); const { mutateAsync: createAttachments } = useCreateAttachments(); diff --git a/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx b/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx index 94ce019498078..451207b080dfb 100644 --- a/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx @@ -6,139 +6,111 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { act, waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import { SECURITY_SOLUTION_OWNER } from '../../../common'; -import { OBSERVABILITY_OWNER } from '../../../common/constants'; -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { OBSERVABILITY_OWNER, OWNER_INFO } from '../../../common/constants'; import { CreateCaseOwnerSelector } from './owner_selector'; -import type { FormProps } from './schema'; -import { schema } from './schema'; -import { waitForComponentToPaint } from '../../common/test_utils'; - -// FLAKY: https://github.com/elastic/kibana/issues/175570 -describe.skip('Case Owner Selection', () => { - let globalForm: FormHook; - - const MockHookWrapperComponent: React.FC = ({ children }) => { - const { form } = useForm({ - defaultValue: { selectedOwner: '' }, - schema: { - selectedOwner: schema.selectedOwner, - }, - }); - - globalForm = form; +import { FormTestComponent } from '../../common/test_utils'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import userEvent from '@testing-library/user-event'; - return
{children}
; - }; +describe('Case Owner Selection', () => { + const onSubmit = jest.fn(); + let appMockRender: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); }); it('renders', async () => { - const wrapper = mount( - + appMockRender.render( + - + ); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeTruthy(); + expect(await screen.findByTestId('caseOwnerSelector')).toBeInTheDocument(); }); it.each([ [OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER], [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER], ])('disables %s button if user only has %j', async (disabledButton, permission) => { - const wrapper = mount( - + appMockRender.render( + - + ); - await waitForComponentToPaint(wrapper); - - expect( - wrapper.find(`[data-test-subj="${disabledButton}RadioButton"] input`).first().props().disabled - ).toBeTruthy(); - expect( - wrapper.find(`[data-test-subj="${permission}RadioButton"] input`).first().props().disabled - ).toBeFalsy(); - expect( - wrapper.find(`[data-test-subj="${permission}RadioButton"] input`).first().props().checked - ).toBeTruthy(); + expect(await screen.findByLabelText(OWNER_INFO[disabledButton].label)).toBeDisabled(); + expect(await screen.findByLabelText(OWNER_INFO[permission].label)).not.toBeDisabled(); }); it('defaults to security Solution', async () => { - const wrapper = mount( - + appMockRender.render( + - + + ); + + expect(await screen.findByLabelText('Observability')).not.toBeChecked(); + expect(await screen.findByLabelText('Security')).toBeChecked(); + + userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith({ selectedOwner: 'securitySolution' }, true); + }); + }); + + it('defaults to security Solution with empty owners', async () => { + appMockRender.render( + + + ); - await waitForComponentToPaint(wrapper); + expect(await screen.findByLabelText('Observability')).not.toBeChecked(); + expect(await screen.findByLabelText('Security')).toBeChecked(); + + userEvent.click(await screen.findByTestId('form-test-component-submit-button')); - expect( - wrapper.find(`[data-test-subj="observabilityRadioButton"] input`).first().props().checked - ).toBeFalsy(); - expect( - wrapper.find(`[data-test-subj="securitySolutionRadioButton"] input`).first().props().checked - ).toBeTruthy(); + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith({ selectedOwner: 'securitySolution' }, true); + }); }); - it('it changes the selection', async () => { - const wrapper = mount( - + it('changes the selection', async () => { + appMockRender.render( + - + ); - await act(async () => { - wrapper - .find(`[data-test-subj="observabilityRadioButton"] input`) - .first() - .simulate('change', OBSERVABILITY_OWNER); - }); + expect(await screen.findByLabelText('Security')).toBeChecked(); + expect(await screen.findByLabelText('Observability')).not.toBeChecked(); - await waitFor(() => { - wrapper.update(); - expect( - wrapper.find(`[data-test-subj="observabilityRadioButton"] input`).first().props().checked - ).toBeTruthy(); - expect( - wrapper.find(`[data-test-subj="securitySolutionRadioButton"] input`).first().props().checked - ).toBeFalsy(); - }); + userEvent.click(await screen.findByLabelText('Observability')); - expect(globalForm.getFormData()).toEqual({ selectedOwner: OBSERVABILITY_OWNER }); + expect(await screen.findByLabelText('Observability')).toBeChecked(); + expect(await screen.findByLabelText('Security')).not.toBeChecked(); - await act(async () => { - wrapper - .find(`[data-test-subj="securitySolutionRadioButton"] input`) - .first() - .simulate('change', SECURITY_SOLUTION_OWNER); - }); + userEvent.click(await screen.findByTestId('form-test-component-submit-button')); await waitFor(() => { - wrapper.update(); - expect( - wrapper.find(`[data-test-subj="securitySolutionRadioButton"] input`).first().props().checked - ).toBeTruthy(); - expect( - wrapper.find(`[data-test-subj="observabilityRadioButton"] input`).first().props().checked - ).toBeFalsy(); + // data, isValid + expect(onSubmit).toBeCalledWith({ selectedOwner: 'observability' }, true); }); - - expect(globalForm.getFormData()).toEqual({ selectedOwner: SECURITY_SOLUTION_OWNER }); }); }); diff --git a/x-pack/plugins/cases/public/components/create/owner_selector.tsx b/x-pack/plugins/cases/public/components/create/owner_selector.tsx index 440fcffcbb5d8..00dd4a03f2664 100644 --- a/x-pack/plugins/cases/public/components/create/owner_selector.tsx +++ b/x-pack/plugins/cases/public/components/create/owner_selector.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo, useCallback, useEffect } from 'react'; +import React, { memo, useCallback } from 'react'; import { EuiFlexGroup, @@ -61,16 +61,6 @@ const OwnerSelector = ({ const onChange = useCallback((val: string) => field.setValue(val), [field]); - useEffect(() => { - if (!field.value) { - onChange( - availableOwners.includes(SECURITY_SOLUTION_OWNER) - ? SECURITY_SOLUTION_OWNER - : availableOwners[0] - ); - } - }, [availableOwners, field.value, onChange]); - return ( ); }; + OwnerSelector.displayName = 'OwnerSelector'; const CaseOwnerSelector: React.FC = ({ availableOwners, isLoading }) => { + const defaultValue = availableOwners.includes(SECURITY_SOLUTION_OWNER) + ? SECURITY_SOLUTION_OWNER + : availableOwners[0] ?? SECURITY_SOLUTION_OWNER; + return ( diff --git a/x-pack/plugins/cases/public/components/create/severity.test.tsx b/x-pack/plugins/cases/public/components/create/severity.test.tsx index 5431d17d5f54c..927848a9a6a24 100644 --- a/x-pack/plugins/cases/public/components/create/severity.test.tsx +++ b/x-pack/plugins/cases/public/components/create/severity.test.tsx @@ -5,83 +5,76 @@ * 2.0. */ -import { CaseSeverity } from '../../../common/types/domain'; import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Severity } from './severity'; -import type { FormProps } from './schema'; -import { schema } from './schema'; import userEvent from '@testing-library/user-event'; -import { waitFor } from '@testing-library/react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; +import { FormTestComponent } from '../../common/test_utils'; -let globalForm: FormHook; -const MockHookWrapperComponent: React.FC = ({ children }) => { - const { form } = useForm({ - defaultValue: { severity: CaseSeverity.LOW }, - schema: { - severity: schema.severity, - }, - }); - - globalForm = form; +const onSubmit = jest.fn(); - return
{children}
; -}; -// FLAKY: https://github.com/elastic/kibana/issues/175934 -// FLAKY: https://github.com/elastic/kibana/issues/175935 -describe.skip('Severity form field', () => { +describe('Severity form field', () => { let appMockRender: AppMockRenderer; + beforeEach(() => { appMockRender = createAppMockRenderer(); }); - it('renders', () => { - const result = appMockRender.render( - + + it('renders', async () => { + appMockRender.render( + - + ); - expect(result.getByTestId('caseSeverity')).toBeTruthy(); - expect(result.getByTestId('case-severity-selection')).not.toHaveAttribute('disabled'); + + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('case-severity-selection')).not.toHaveAttribute('disabled'); }); // default to LOW in this test configuration - it('defaults to the correct value', () => { - const result = appMockRender.render( - + it('defaults to the correct value', async () => { + appMockRender.render( + - + ); - expect(result.getByTestId('caseSeverity')).toBeTruthy(); - // ID removed for options dropdown here: - // https://github.com/elastic/eui/pull/6630#discussion_r1123657852 - expect(result.getAllByTestId('case-severity-selection-low').length).toBe(1); + + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('case-severity-selection-low')).toBeInTheDocument(); }); it('selects the correct value when changed', async () => { - const result = appMockRender.render( - + appMockRender.render( + - + ); - expect(result.getByTestId('caseSeverity')).toBeTruthy(); - userEvent.click(result.getByTestId('case-severity-selection')); + + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('case-severity-selection')); await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('case-severity-selection-high')); + + userEvent.click(await screen.findByTestId('case-severity-selection-high')); + + userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + await waitFor(() => { - expect(globalForm.getFormData()).toEqual({ severity: 'high' }); + // data, isValid + expect(onSubmit).toBeCalledWith({ severity: 'high' }, true); }); }); - it('disables when loading data', () => { - const result = appMockRender.render( - + it('disables when loading data', async () => { + appMockRender.render( + - + ); - expect(result.getByTestId('case-severity-selection')).toHaveAttribute('disabled'); + + expect(await screen.findByTestId('case-severity-selection')).toHaveAttribute('disabled'); }); }); diff --git a/x-pack/plugins/cases/public/components/create/severity.tsx b/x-pack/plugins/cases/public/components/create/severity.tsx index 00c0c3c32642c..b65ec7f6a6350 100644 --- a/x-pack/plugins/cases/public/components/create/severity.tsx +++ b/x-pack/plugins/cases/public/components/create/severity.tsx @@ -8,10 +8,10 @@ import { EuiFormRow } from '@elastic/eui'; import React, { memo } from 'react'; import { + getFieldValidityAndErrorMessage, UseField, - useFormContext, - useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { isEmpty } from 'lodash'; import { CaseSeverity } from '../../../common/types/domain'; import { SeveritySelector } from '../severity/selector'; import { SEVERITY_TITLE } from '../severity/translations'; @@ -20,33 +20,38 @@ interface Props { isLoading: boolean; } -const SeverityFieldFormComponent = ({ isLoading }: { isLoading: boolean }) => { - const { setFieldValue } = useFormContext(); - const [{ severity }] = useFormData({ watch: ['severity'] }); - const onSeverityChange = (newSeverity: CaseSeverity) => { - setFieldValue('severity', newSeverity); - }; - return ( - - - - ); -}; -SeverityFieldFormComponent.displayName = 'SeverityFieldForm'; - const SeverityComponent: React.FC = ({ isLoading }) => ( - path={'severity'} - component={SeverityFieldFormComponent} componentProps={{ isLoading, }} - /> + > + {(field) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + const onChange = (newSeverity: CaseSeverity) => { + field.setValue(newSeverity); + }; + + return ( + + + + ); + }} + ); SeverityComponent.displayName = 'SeverityComponent'; diff --git a/x-pack/plugins/cases/public/components/description/index.test.tsx b/x-pack/plugins/cases/public/components/description/index.test.tsx index ed03dc453737e..108a33876312e 100644 --- a/x-pack/plugins/cases/public/components/description/index.test.tsx +++ b/x-pack/plugins/cases/public/components/description/index.test.tsx @@ -20,7 +20,7 @@ jest.mock('../../common/lib/kibana'); jest.mock('../../common/navigation/hooks'); const defaultProps = { - appId: 'testAppId', + appId: 'securitySolution', caseData: { ...basicCase, }, @@ -140,7 +140,7 @@ describe('Description', () => { }); describe('draft message', () => { - const draftStorageKey = `cases.testAppId.basic-case-id.description.markdownEditor`; + const draftStorageKey = `cases.securitySolution.basic-case-id.description.markdownEditor`; beforeEach(() => { sessionStorage.setItem(draftStorageKey, 'value set in storage'); diff --git a/x-pack/plugins/cases/public/components/description/index.tsx b/x-pack/plugins/cases/public/components/description/index.tsx index a3e074f6f5867..6409fae84fdcf 100644 --- a/x-pack/plugins/cases/public/components/description/index.tsx +++ b/x-pack/plugins/cases/public/components/description/index.tsx @@ -73,7 +73,7 @@ const getDraftDescription = ( caseId: string, commentId: string ): string | null => { - const draftStorageKey = getMarkdownEditorStorageKey(applicationId, caseId, commentId); + const draftStorageKey = getMarkdownEditorStorageKey({ appId: applicationId, caseId, commentId }); return sessionStorage.getItem(draftStorageKey); }; @@ -97,7 +97,7 @@ export const Description = ({ const descriptionMarkdownRef = useRef(null); const { euiTheme } = useEuiTheme(); - const { appId, permissions } = useCasesContext(); + const { permissions, owner } = useCasesContext(); const { clearDraftComment: clearLensDraftComment, @@ -121,7 +121,7 @@ export const Description = ({ const toggleCollapse = () => setIsCollapsed((oldValue: boolean) => !oldValue); - const draftDescription = getDraftDescription(appId, caseData.id, DESCRIPTION_ID); + const draftDescription = getDraftDescription(owner[0], caseData.id, DESCRIPTION_ID); if ( hasIncomingLensState && diff --git a/x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx b/x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx index 8c4540aaadbec..5498c81ad4474 100644 --- a/x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx +++ b/x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx @@ -21,8 +21,7 @@ jest.mock('../../containers/use_delete_file_attachment'); const useDeleteFileAttachmentMock = useDeleteFileAttachment as jest.Mock; -// FLAKY: https://github.com/elastic/kibana/issues/175956 -describe.skip('FileDeleteButton', () => { +describe('FileDeleteButton', () => { let appMockRender: AppMockRenderer; const mutate = jest.fn(); @@ -40,8 +39,6 @@ describe.skip('FileDeleteButton', () => { ); expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument(); - - expect(useDeleteFileAttachmentMock).toBeCalledTimes(1); }); it('clicking delete button opens the confirmation modal', async () => { diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx index d0404c089077e..836923b8c3f9e 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx @@ -26,7 +26,7 @@ const onSaveContent = jest.fn(); const newValue = 'Hello from Tehas'; const hyperlink = `[hyperlink](http://elastic.co)`; -const draftStorageKey = `cases.testAppId.caseId.markdown-id.markdownEditor`; +const draftStorageKey = `cases.securitySolution.caseId.markdown-id.markdownEditor`; const content = `A link to a timeline ${hyperlink}`; const maxLength = 5000; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx index 6cbaca3378242..bd91d219ee877 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx @@ -11,9 +11,9 @@ import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form import { Form, UseField, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { MarkdownEditorForm } from '.'; import { removeItemFromSessionStorage } from '../utils'; -import { useCasesContext } from '../cases_context/use_cases_context'; import { getMarkdownEditorStorageKey } from './utils'; import { EditableMarkdownFooter } from './editable_markdown_footer'; +import { useCasesContext } from '../cases_context/use_cases_context'; export interface EditableMarkdownRefObject { setComment: (newComment: string) => void; @@ -37,8 +37,9 @@ const EditableMarkDownRenderer = forwardRef< { id, content, caseId, fieldName, onChangeEditable, onSaveContent, editorRef, formSchema }, ref ) => { - const { appId } = useCasesContext(); - const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, id); + const { owner } = useCasesContext(); + const draftStorageKey = getMarkdownEditorStorageKey({ appId: owner[0], caseId, commentId: id }); + const initialState = { content }; const { form } = useForm({ diff --git a/x-pack/plugins/cases/public/components/markdown_editor/utils.test.ts b/x-pack/plugins/cases/public/components/markdown_editor/utils.test.ts index ef1de4a1bc327..8c616b25c69db 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/utils.test.ts +++ b/x-pack/plugins/cases/public/components/markdown_editor/utils.test.ts @@ -12,7 +12,7 @@ describe('getMarkdownEditorStorageKey', () => { const appId = 'security-solution'; const caseId = 'case-id'; const commentId = 'comment-id'; - const sessionKey = getMarkdownEditorStorageKey(appId, caseId, commentId); + const sessionKey = getMarkdownEditorStorageKey({ appId, caseId, commentId }); expect(sessionKey).toEqual(`cases.${appId}.${caseId}.${commentId}.markdownEditor`); }); @@ -20,7 +20,7 @@ describe('getMarkdownEditorStorageKey', () => { const appId = 'security-solution'; const caseId = 'case-id'; const commentId = ''; - const sessionKey = getMarkdownEditorStorageKey(appId, caseId, commentId); + const sessionKey = getMarkdownEditorStorageKey({ appId, caseId, commentId }); expect(sessionKey).toEqual(`cases.${appId}.${caseId}.comment.markdownEditor`); }); @@ -28,7 +28,7 @@ describe('getMarkdownEditorStorageKey', () => { const appId = 'security-solution'; const caseId = ''; const commentId = 'comment-id'; - const sessionKey = getMarkdownEditorStorageKey(appId, caseId, commentId); + const sessionKey = getMarkdownEditorStorageKey({ appId, caseId, commentId }); expect(sessionKey).toEqual(`cases.${appId}.case.${commentId}.markdownEditor`); }); @@ -36,7 +36,14 @@ describe('getMarkdownEditorStorageKey', () => { const appId = ''; const caseId = 'case-id'; const commentId = 'comment-id'; - const sessionKey = getMarkdownEditorStorageKey(appId, caseId, commentId); + const sessionKey = getMarkdownEditorStorageKey({ appId, caseId, commentId }); + expect(sessionKey).toEqual(`cases.cases.${caseId}.${commentId}.markdownEditor`); + }); + + it('should return default key when app id is undefined', () => { + const caseId = 'case-id'; + const commentId = 'comment-id'; + const sessionKey = getMarkdownEditorStorageKey({ caseId, commentId }); expect(sessionKey).toEqual(`cases.cases.${caseId}.${commentId}.markdownEditor`); }); }); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/utils.ts b/x-pack/plugins/cases/public/components/markdown_editor/utils.ts index 1fb81875b926b..134b2355c9979 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/utils.ts +++ b/x-pack/plugins/cases/public/components/markdown_editor/utils.ts @@ -5,12 +5,16 @@ * 2.0. */ -export const getMarkdownEditorStorageKey = ( - appId: string, - caseId: string, - commentId: string -): string => { - const appIdKey = appId !== '' ? appId : 'cases'; +export const getMarkdownEditorStorageKey = ({ + caseId, + commentId, + appId, +}: { + caseId: string; + commentId: string; + appId?: string; +}): string => { + const appIdKey = appId && appId !== '' ? appId : 'cases'; const caseIdKey = caseId !== '' ? caseId : 'case'; const commentIdKey = commentId !== '' ? commentId : 'comment'; diff --git a/x-pack/plugins/cases/public/components/use_breadcrumbs/index.ts b/x-pack/plugins/cases/public/components/use_breadcrumbs/index.ts index 854722436d998..6312918842ba3 100644 --- a/x-pack/plugins/cases/public/components/use_breadcrumbs/index.ts +++ b/x-pack/plugins/cases/public/components/use_breadcrumbs/index.ts @@ -11,7 +11,7 @@ import { useCallback, useEffect } from 'react'; import { KibanaServices, useKibana, useNavigation } from '../../common/lib/kibana'; import type { ICasesDeepLinkId } from '../../common/navigation'; import { CasesDeepLinkId } from '../../common/navigation'; -import { useCasesContext } from '../cases_context/use_cases_context'; +import { useApplication } from '../../common/lib/kibana/use_application'; const casesBreadcrumbTitle: Record = { [CasesDeepLinkId.cases]: i18n.translate('xpack.cases.breadcrumbs.all_cases', { @@ -61,7 +61,7 @@ const useApplyBreadcrumbs = () => { }; export const useCasesBreadcrumbs = (pageDeepLink: ICasesDeepLinkId) => { - const { appId, appTitle } = useCasesContext(); + const { appId, appTitle } = useApplication(); const { getAppUrl } = useNavigation(appId); const applyBreadcrumbs = useApplyBreadcrumbs(); @@ -89,7 +89,7 @@ export const useCasesBreadcrumbs = (pageDeepLink: ICasesDeepLinkId) => { }; export const useCasesTitleBreadcrumbs = (caseTitle: string) => { - const { appId, appTitle } = useCasesContext(); + const { appId, appTitle } = useApplication(); const { getAppUrl } = useNavigation(appId); const applyBreadcrumbs = useApplyBreadcrumbs(); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx index e189506a450cb..53ddc22cad30b 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx @@ -52,7 +52,7 @@ const hasDraftComment = ( commentId: string, comment: string ): boolean => { - const draftStorageKey = getMarkdownEditorStorageKey(applicationId, caseId, commentId); + const draftStorageKey = getMarkdownEditorStorageKey({ appId: applicationId, caseId, commentId }); const sessionValue = sessionStorage.getItem(draftStorageKey); diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx index 15a8094561332..87bae9a4624a0 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx @@ -21,7 +21,7 @@ const onChangeEditable = jest.fn(); const onSaveContent = jest.fn(); const hyperlink = `[hyperlink](http://elastic.co)`; -const draftStorageKey = `cases.testAppId.caseId.markdown-id.markdownEditor`; +const draftStorageKey = `cases.securitySolution.caseId.markdown-id.markdownEditor`; const defaultProps = { content: `A link to a timeline ${hyperlink}`, id: 'markdown-id', diff --git a/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx b/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx index 3b21d68ac43af..2ccf235a446f5 100644 --- a/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx @@ -93,11 +93,9 @@ export const UserActionsList = React.memo( bottomActions = [], isExpandable = false, }: UserActionListProps) => { - const { - externalReferenceAttachmentTypeRegistry, - persistableStateAttachmentTypeRegistry, - appId, - } = useCasesContext(); + const { externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry } = + useCasesContext(); + const { owner } = useCasesContext(); const { commentId } = useCaseViewParams(); const [initLoading, setInitLoading] = useState(true); @@ -128,7 +126,7 @@ export const UserActionsList = React.memo( } const userActionBuilder = builder({ - appId, + appId: owner[0], caseData, casesConfiguration, caseConnectors, @@ -159,7 +157,7 @@ export const UserActionsList = React.memo( }, []); }, [ caseUserActions, - appId, + owner, caseData, casesConfiguration, caseConnectors, diff --git a/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.test.tsx index 5fdf94f96c266..a6dc16434a8f2 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.test.tsx +++ b/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.test.tsx @@ -6,12 +6,12 @@ */ import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { UserToolTip } from './user_tooltip'; -// FLAKY: https://github.com/elastic/kibana/issues/176643 -describe.skip('UserToolTip', () => { +describe('UserToolTip', () => { it('renders the tooltip when hovering', async () => { const profile: UserProfileWithAvatar = { uid: '1', @@ -34,12 +34,12 @@ describe.skip('UserToolTip', () => { ); - fireEvent.mouseOver(screen.getByText('case user')); + userEvent.hover(await screen.findByText('case user')); - await waitFor(() => screen.getByTestId('user-profile-tooltip')); - expect(screen.getByText('Some Super User')).toBeInTheDocument(); - expect(screen.getByText('some.user@google.com')).toBeInTheDocument(); - expect(screen.getByText('SU')).toBeInTheDocument(); + expect(await screen.findByTestId('user-profile-tooltip')).toBeInTheDocument(); + expect(await screen.findByText('Some Super User')).toBeInTheDocument(); + expect(await screen.findByText('some.user@google.com')).toBeInTheDocument(); + expect(await screen.findByText('SU')).toBeInTheDocument(); }); it('only shows the display name if full name is missing', async () => { @@ -63,12 +63,13 @@ describe.skip('UserToolTip', () => { ); - fireEvent.mouseOver(screen.getByText('case user')); + userEvent.hover(await screen.findByText('case user')); - await waitFor(() => screen.getByTestId('user-profile-tooltip')); + expect(await screen.findByTestId('user-profile-tooltip')).toBeInTheDocument(); + + expect(await screen.findByText('some.user@google.com')).toBeInTheDocument(); + expect(await screen.findByText('SU')).toBeInTheDocument(); expect(screen.queryByText('Some Super User')).not.toBeInTheDocument(); - expect(screen.getByText('some.user@google.com')).toBeInTheDocument(); - expect(screen.getByText('SU')).toBeInTheDocument(); }); it('only shows the full name if display name is missing', async () => { @@ -93,12 +94,12 @@ describe.skip('UserToolTip', () => { ); - fireEvent.mouseOver(screen.getByText('case user')); + userEvent.hover(await screen.findByText('case user')); - await waitFor(() => screen.getByTestId('user-profile-tooltip')); - expect(screen.getByText('Some Super User')).toBeInTheDocument(); - expect(screen.getByText('some.user@google.com')).toBeInTheDocument(); - expect(screen.getByText('SU')).toBeInTheDocument(); + expect(await screen.findByTestId('user-profile-tooltip')).toBeInTheDocument(); + expect(await screen.findByText('Some Super User')).toBeInTheDocument(); + expect(await screen.findByText('some.user@google.com')).toBeInTheDocument(); + expect(await screen.findByText('SU')).toBeInTheDocument(); }); it('only shows the email once when display name and full name are not defined', async () => { @@ -122,12 +123,12 @@ describe.skip('UserToolTip', () => { ); - fireEvent.mouseOver(screen.getByText('case user')); + userEvent.hover(await screen.findByText('case user')); - await waitFor(() => screen.getByTestId('user-profile-tooltip')); + expect(await screen.findByTestId('user-profile-tooltip')).toBeInTheDocument(); + expect(await screen.findByText('some.user@google.com')).toBeInTheDocument(); + expect(await screen.findByText('SU')).toBeInTheDocument(); expect(screen.queryByText('Some Super User')).not.toBeInTheDocument(); - expect(screen.getByText('some.user@google.com')).toBeInTheDocument(); - expect(screen.getByText('SU')).toBeInTheDocument(); }); it('only shows the username once when all other fields are undefined', async () => { @@ -150,13 +151,13 @@ describe.skip('UserToolTip', () => { ); - fireEvent.mouseOver(screen.getByText('case user')); + userEvent.hover(await screen.findByText('case user')); - await waitFor(() => screen.getByTestId('user-profile-tooltip')); + expect(await screen.findByTestId('user-profile-tooltip')).toBeInTheDocument(); expect(screen.queryByText('Some Super User')).not.toBeInTheDocument(); + expect(await screen.findByText('user')).toBeInTheDocument(); + expect(await screen.findByText('SU')).toBeInTheDocument(); expect(screen.queryByText('some.user@google.com')).not.toBeInTheDocument(); - expect(screen.getByText('user')).toBeInTheDocument(); - expect(screen.getByText('SU')).toBeInTheDocument(); }); it('shows an unknown users display name and avatar', async () => { @@ -166,10 +167,10 @@ describe.skip('UserToolTip', () => { ); - fireEvent.mouseOver(screen.getByText('case user')); + userEvent.hover(await screen.findByText('case user')); - await waitFor(() => screen.getByTestId('user-profile-tooltip')); - expect(screen.getByText('Unable to find user profile')).toBeInTheDocument(); - expect(screen.getByText('?')).toBeInTheDocument(); + expect(await screen.findByTestId('user-profile-tooltip')).toBeInTheDocument(); + expect(await screen.findByText('Unable to find user profile')).toBeInTheDocument(); + expect(await screen.findByText('?')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx index 712dcb5487c5c..2be8dd6052408 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; import { basicCase } from './mock'; @@ -58,13 +58,12 @@ describe('useGetCaseFiles', () => { }); it('calls filesClient.list with correct arguments', async () => { - await act(async () => { - const { waitForNextUpdate } = renderHook(() => useGetCaseFiles(hookParams), { - wrapper: appMockRender.AppWrapper, - }); - await waitForNextUpdate(); - - expect(appMockRender.getFilesClient().list).toBeCalledWith(expectedCallParams); + const { waitForNextUpdate } = renderHook(() => useGetCaseFiles(hookParams), { + wrapper: appMockRender.AppWrapper, }); + + await waitForNextUpdate(); + + expect(appMockRender.getFilesClient().list).toBeCalledWith(expectedCallParams); }); }); diff --git a/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx index 8a08b7bc0ab5e..44cec799c9f51 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; import type { SingleCaseMetricsFeature } from '../../common/ui'; import { useGetCaseMetrics } from './use_get_case_metrics'; import { basicCase } from './mock'; @@ -30,13 +30,13 @@ describe('useGetCaseMetrics', () => { it('calls getSingleCaseMetrics with correct arguments', async () => { const spyOnGetCaseMetrics = jest.spyOn(api, 'getSingleCaseMetrics'); - await act(async () => { - const { waitForNextUpdate } = renderHook(() => useGetCaseMetrics(basicCase.id, features), { - wrapper, - }); - await waitForNextUpdate(); - expect(spyOnGetCaseMetrics).toBeCalledWith(basicCase.id, features, abortCtrl.signal); + + const { waitForNextUpdate } = renderHook(() => useGetCaseMetrics(basicCase.id, features), { + wrapper, }); + + await waitForNextUpdate(); + expect(spyOnGetCaseMetrics).toBeCalledWith(basicCase.id, features, abortCtrl.signal); }); it('shows an error toast when getSingleCaseMetrics throws', async () => { @@ -51,7 +51,9 @@ describe('useGetCaseMetrics', () => { const { waitForNextUpdate } = renderHook(() => useGetCaseMetrics(basicCase.id, features), { wrapper, }); + await waitForNextUpdate(); + expect(spyOnGetCaseMetrics).toBeCalledWith(basicCase.id, features, abortCtrl.signal); expect(addError).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/cases/public/containers/use_messages_storage.test.tsx b/x-pack/plugins/cases/public/containers/use_messages_storage.test.tsx index 8f12ab8e56daf..ef4a164e759ba 100644 --- a/x-pack/plugins/cases/public/containers/use_messages_storage.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_messages_storage.test.tsx @@ -9,7 +9,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import type { UseMessagesStorage } from './use_messages_storage'; import { useMessagesStorage } from './use_messages_storage'; -describe('useLocalStorage', () => { +describe('useMessagesStorage', () => { beforeEach(() => { localStorage.clear(); }); diff --git a/x-pack/plugins/cases/tsconfig.json b/x-pack/plugins/cases/tsconfig.json index 750ac4f644b88..b217bcb3587de 100644 --- a/x-pack/plugins/cases/tsconfig.json +++ b/x-pack/plugins/cases/tsconfig.json @@ -71,6 +71,7 @@ "@kbn/content-management-plugin", "@kbn/index-management-plugin", "@kbn/rison", + "@kbn/core-application-browser", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx index c48a824722368..94fc9063f042e 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx @@ -17,6 +17,7 @@ import { } from '../../common/components/guided_onboarding_tour/tour_config'; jest.mock('../../common/components/guided_onboarding_tour'); +jest.mock('../../common/lib/kibana'); type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; @@ -51,6 +52,7 @@ describe('cases page in security', () => { }); jest.clearAllMocks(); }); + it('calls endTour on cases details page when SecurityStepId.alertsCases tour is active and step is AlertsCasesTourSteps.viewCase', () => { render( @@ -58,8 +60,10 @@ describe('cases page in security', () => { , { wrapper: TestProviders } ); + expect(endTourStep).toHaveBeenCalledWith(SecurityStepId.alertsCases); }); + it('does not call endTour on cases details page when SecurityStepId.alertsCases tour is not active', () => { (useTourContext as jest.Mock).mockReturnValue({ activeStep: AlertsCasesTourSteps.viewCase, @@ -73,8 +77,10 @@ describe('cases page in security', () => { , { wrapper: TestProviders } ); + expect(endTourStep).not.toHaveBeenCalled(); }); + it('does not call endTour on cases details page when SecurityStepId.alertsCases tour is active and step is not AlertsCasesTourSteps.viewCase', () => { (useTourContext as jest.Mock).mockReturnValue({ activeStep: AlertsCasesTourSteps.expandEvent, @@ -82,12 +88,14 @@ describe('cases page in security', () => { endTourStep, isTourShown: () => true, }); + render( , { wrapper: TestProviders } ); + expect(endTourStep).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.tsx index 50111337e919c..6a27656a73150 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -108,19 +108,6 @@ const CaseContainerComponent: React.FC = () => { selected_endpoint: endpointId, }), }); - // TO-DO: onComponentInitialized not needed after removing the expandedEvent state from timeline - const onComponentInitialized = useCallback(() => { - dispatch( - timelineActions.createTimeline({ - id: TimelineId.casePage, - columns: [], - dataViewId: null, - indexNames: [], - expandedDetail: {}, - show: false, - }) - ); - }, [dispatch]); const refreshRef = useRef(null); const { activeStep, endTourStep, isTourShown } = useTourContext(); @@ -134,6 +121,20 @@ const CaseContainerComponent: React.FC = () => { if (isTourActive) endTourStep(SecurityStepId.alertsCases); }, [endTourStep, isTourActive]); + useEffect(() => { + dispatch( + timelineActions.createTimeline({ + id: TimelineId.casePage, + columns: [], + dataViewId: null, + indexNames: [], + expandedDetail: {}, + show: false, + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( @@ -151,7 +152,6 @@ const CaseContainerComponent: React.FC = () => { alerts: { isExperimental: false }, }, refreshRef, - onComponentInitialized, actionsNavigation: { href: endpointDetailsHref, onClick: (endpointId: string, e) => { diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts index 27d48a9ff6417..4c686eeb8d7a5 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts @@ -121,3 +121,7 @@ export const useCapabilities = jest.fn((featureId?: string) => ? mockStartServicesMock.application.capabilities[featureId] : mockStartServicesMock.application.capabilities ); + +export const useNavigation = jest + .fn() + .mockReturnValue({ getAppUrl: jest.fn(), navigateTo: jest.fn() }); diff --git a/x-pack/plugins/security_solution/public/common/store/store.ts b/x-pack/plugins/security_solution/public/common/store/store.ts index 1ff89fc2a8f75..a3b8a44aad0ff 100644 --- a/x-pack/plugins/security_solution/public/common/store/store.ts +++ b/x-pack/plugins/security_solution/public/common/store/store.ts @@ -20,6 +20,8 @@ import type { EnhancerOptions } from 'redux-devtools-extension'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { CoreStart } from '@kbn/core/public'; import reduceReducers from 'reduce-reducers'; +import { TimelineType } from '../../../common/api/timeline'; +import { TimelineId } from '../../../common/types'; import { initialGroupingState } from './grouping/reducer'; import type { GroupState } from './grouping/types'; import { @@ -47,6 +49,7 @@ import { resolverMiddlewareFactory } from '../../resolver/store/middleware'; import { dataAccessLayerFactory } from '../../resolver/data_access_layer/factory'; import { sourcererActions } from './sourcerer'; import { createMiddlewares } from './middlewares'; +import { addNewTimeline } from '../../timelines/store/helpers'; let store: Store | null = null; @@ -104,6 +107,15 @@ export const createStoreFactory = async ( ...subPlugins.timelines.store.initialState.timeline!, timelineById: { ...subPlugins.timelines.store.initialState.timeline.timelineById, + ...addNewTimeline({ + id: TimelineId.active, + timelineById: {}, + show: false, + timelineType: TimelineType.default, + columns: [], + dataViewId: null, + indexNames: [], + }), }, }, }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/__snapshots__/netflow_row_renderer.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/__snapshots__/netflow_row_renderer.test.tsx.snap index 5c69540ebb0c4..fa510b06b588a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/__snapshots__/netflow_row_renderer.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/__snapshots__/netflow_row_renderer.test.tsx.snap @@ -2,12 +2,7 @@ exports[`netflowRowRenderer renders correctly against snapshot 1`] = ` - .c14 svg { - position: relative; - top: -1px; -} - -.c0 { + .c0 { display: inline-block; font-size: 12px; line-height: 1.5; @@ -21,6 +16,11 @@ exports[`netflowRowRenderer renders correctly against snapshot 1`] = ` border-radius: 4px; } +.c14 svg { + position: relative; + top: -1px; +} + .c12, .c12 * { display: inline-block; diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts index f6ec13a492318..bce45cb13280b 100644 --- a/x-pack/test/functional/services/cases/list.ts +++ b/x-pack/test/functional/services/cases/list.ts @@ -465,10 +465,10 @@ export function CasesTableServiceProvider( }, async setAllCasesStateInLocalStorage(state: Record) { - await browser.setLocalStorageItem('management.cases.list.state', JSON.stringify(state)); + await browser.setLocalStorageItem('cases.cases.list.state', JSON.stringify(state)); const currentState = JSON.parse( - (await browser.getLocalStorageItem('management.cases.list.state')) ?? '{}' + (await browser.getLocalStorageItem('cases.cases.list.state')) ?? '{}' ); expect(deepEqual(currentState, state)).to.be(true); @@ -476,7 +476,7 @@ export function CasesTableServiceProvider( async getAllCasesStateInLocalStorage() { const currentState = JSON.parse( - (await browser.getLocalStorageItem('management.cases.list.state')) ?? '{}' + (await browser.getLocalStorageItem('cases.cases.list.state')) ?? '{}' ); return currentState; @@ -484,12 +484,12 @@ export function CasesTableServiceProvider( async setFiltersConfigurationInLocalStorage(state: Array<{ key: string; isActive: boolean }>) { await browser.setLocalStorageItem( - 'management.cases.list.tableFiltersConfig', + 'cases.cases.list.tableFiltersConfig', JSON.stringify(state) ); const currentState = JSON.parse( - (await browser.getLocalStorageItem('management.cases.list.tableFiltersConfig')) ?? '{}' + (await browser.getLocalStorageItem('cases.cases.list.tableFiltersConfig')) ?? '{}' ); expect(deepEqual(currentState, state)).to.be(true);