diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 0d552d3af6eea..d56168f3f3479 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -128,6 +128,7 @@ export interface FilterOptions { status: CaseStatusWithAllStatus; tags: string[]; reporters: User[]; + owner: string[]; onlyCollectionType?: boolean; } diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index aeff350e416d2..7950f962a9cc1 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -47,6 +47,9 @@ jest.mock('../../containers/use_get_action_license'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../common/lib/kibana'); jest.mock('../../common/navigation/hooks'); +jest.mock('../app/use_available_owners', () => ({ + useAvailableCasesOwners: () => ['securitySolution', 'observability'], +})); const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; @@ -780,6 +783,32 @@ describe('AllCasesListGeneric', () => { expect(doRefresh).toHaveBeenCalled(); }); + it('shows Solution column if there are no set owners', async () => { + const doRefresh = jest.fn(); + + const wrapper = mount( + + + + ); + + const solutionHeader = wrapper.find({ children: 'Solution' }); + expect(solutionHeader.exists()).toBeTruthy(); + }); + + it('hides Solution column if there is a set owner', async () => { + const doRefresh = jest.fn(); + + const wrapper = mount( + + + + ); + + const solutionHeader = wrapper.find({ children: 'Solution' }); + expect(solutionHeader.exists()).toBeFalsy(); + }); + it('should deselect cases when refreshing', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index 37e4ef8c03d36..bf88c5a7906bf 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -23,6 +23,7 @@ import { SELECTABLE_MESSAGE_COLLECTIONS } from '../../common/translations'; import { useGetCases } from '../../containers/use_get_cases'; import { usePostComment } from '../../containers/use_post_comment'; +import { useAvailableCasesOwners } from '../app/use_available_owners'; import { useCasesColumns } from './columns'; import { getExpandedRowMap } from './expanded_row'; import { CasesTableFilters } from './table_filters'; @@ -66,10 +67,15 @@ export const AllCasesList = React.memo( updateCase, doRefresh, }) => { - const { userCanCrud } = useCasesContext(); + const { owner, userCanCrud } = useCasesContext(); + const hasOwner = !!owner.length; + const availableSolutions = useAvailableCasesOwners(); + const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses)); - const initialFilterOptions = - !isEmpty(hiddenStatuses) && firstAvailableStatus ? { status: firstAvailableStatus } : {}; + const initialFilterOptions = { + ...(!isEmpty(hiddenStatuses) && firstAvailableStatus && { status: firstAvailableStatus }), + owner: hasOwner ? owner : availableSolutions, + }; const { data, @@ -181,6 +187,7 @@ export const AllCasesList = React.memo( alertData, postComment, updateCase, + showSolutionColumn: !hasOwner && availableSolutions.length > 1, }); const itemIdToExpandedRowMap = useMemo( @@ -235,11 +242,13 @@ export const AllCasesList = React.memo( countOpenCases={data.countOpenCases} countInProgressCases={data.countInProgressCases} onFilterChanged={onFilterChangedCallback} + availableSolutions={hasOwner ? [] : availableSolutions} initial={{ search: filterOptions.search, reporters: filterOptions.reporters, tags: filterOptions.tags, status: filterOptions.status, + owner: filterOptions.owner, }} setFilterRefetch={setFilterRefetch} hiddenStatuses={hiddenStatuses} diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index 4ff90c20c9d60..663779029fcec 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -30,6 +30,7 @@ import { CommentRequestAlertType, ActionConnector, } from '../../../common/api'; +import { OWNER_INFO } from '../../../common/constants'; import { getEmptyTagValue } from '../empty_value'; import { FormattedRelativePreferenceDate } from '../formatted_date'; import { CaseDetailsLink } from '../links'; @@ -45,6 +46,7 @@ import { StatusContextMenu } from '../case_action_bar/status_context_menu'; import { TruncatedText } from '../truncated_text'; import { getConnectorIcon } from '../utils'; import { PostComment } from '../../containers/use_post_comment'; +import type { CasesOwners } from '../../methods/can_use_cases'; export type CasesColumns = | EuiTableActionsColumnType @@ -79,6 +81,7 @@ export interface GetCasesColumn { alertData?: Omit; postComment?: (args: PostComment) => Promise; updateCase?: (newCase: Case) => void; + showSolutionColumn?: boolean; } export const useCasesColumns = ({ dispatchUpdateCaseProperty, @@ -93,6 +96,7 @@ export const useCasesColumns = ({ alertData, postComment, updateCase, + showSolutionColumn, }: GetCasesColumn): CasesColumns[] => { // Delete case const { @@ -251,6 +255,23 @@ export const useCasesColumns = ({ ? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`) : getEmptyTagValue(), }, + ...(showSolutionColumn + ? [ + { + align: RIGHT_ALIGNMENT, + field: 'owner', + name: i18n.SOLUTION, + render: (caseOwner: CasesOwners) => { + const ownerInfo = OWNER_INFO[caseOwner]; + return ownerInfo ? ( + + ) : ( + getEmptyTagValue() + ); + }, + }, + ] + : []), { align: RIGHT_ALIGNMENT, field: 'totalComment', 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 2d14ffe5738ca..8089c85ee578b 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 @@ -9,6 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { CaseStatuses } from '../../../common/api'; +import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { TestProviders } from '../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; @@ -30,6 +31,7 @@ const props = { onFilterChanged, initial: DEFAULT_FILTER_OPTIONS, setFilterRefetch, + availableSolutions: [], }; describe('CasesTableFilters ', () => { @@ -168,4 +170,50 @@ describe('CasesTableFilters ', () => { } ); }); + + describe('dynamic Solution filter', () => { + it('shows Solution filter when provided more than 1 availableSolutions', () => { + const wrapper = mount( + + + + ); + expect( + wrapper.find(`[data-test-subj="options-filter-popover-button-Solution"]`).exists() + ).toBeTruthy(); + }); + + it('does not show Solution filter when provided less than 1 availableSolutions', () => { + const wrapper = mount( + + + + ); + expect( + wrapper.find(`[data-test-subj="options-filter-popover-button-Solution"]`).exists() + ).toBeFalsy(); + }); + }); + + it('should call onFilterChange when selected solution changes', () => { + const wrapper = mount( + + + + ); + wrapper + .find(`[data-test-subj="options-filter-popover-button-Solution"]`) + .last() + .simulate('click'); + + wrapper.find(`[data-test-subj="options-filter-popover-item-0"]`).last().simulate('click'); + + expect(onFilterChanged).toBeCalledWith({ owner: [SECURITY_SOLUTION_OWNER] }); + }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx index e1ed709e0d93f..f75cebf88933c 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx @@ -27,6 +27,7 @@ interface CasesTableFiltersProps { initial: FilterOptions; setFilterRefetch: (val: () => void) => void; hiddenStatuses?: CaseStatusWithAllStatus[]; + availableSolutions: string[]; } // Fix the width of the status dropdown to prevent hiding long text items @@ -48,6 +49,7 @@ const defaultInitial = { reporters: [], status: StatusAll, tags: [], + owner: [], }; const CasesTableFiltersComponent = ({ @@ -58,12 +60,14 @@ const CasesTableFiltersComponent = ({ initial = defaultInitial, setFilterRefetch, hiddenStatuses, + availableSolutions, }: CasesTableFiltersProps) => { const [selectedReporters, setSelectedReporters] = useState( initial.reporters.map((r) => r.full_name ?? r.username ?? '') ); const [search, setSearch] = useState(initial.search); const [selectedTags, setSelectedTags] = useState(initial.tags); + const [selectedOwner, setSelectedOwner] = useState(initial.owner); const { tags, fetchTags } = useGetTags(); const { reporters, respReporters, fetchReporters } = useGetReporters(); @@ -108,6 +112,16 @@ const CasesTableFiltersComponent = ({ [onFilterChanged, selectedTags] ); + const handleSelectedSolution = useCallback( + (newOwner) => { + if (!isEqual(newOwner, selectedOwner) && newOwner.length) { + setSelectedOwner(newOwner); + onFilterChanged({ owner: newOwner }); + } + }, + [onFilterChanged, selectedOwner] + ); + useEffect(() => { if (selectedTags.length) { const newTags = selectedTags.filter((t) => tags.includes(t)); @@ -183,6 +197,14 @@ const CasesTableFiltersComponent = ({ options={tags} optionsEmptyLabel={i18n.NO_TAGS_AVAILABLE} /> + {availableSolutions.length > 1 && ( + + )} diff --git a/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts b/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts index 32229d322162b..3475cae722e43 100644 --- a/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts +++ b/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts @@ -6,9 +6,8 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { SECURITY_SOLUTION_OWNER } from '../../../common'; -import { OBSERVABILITY_OWNER } from '../../../common/constants'; +import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { useKibana } from '../../common/lib/kibana'; import { useAvailableCasesOwners } from './use_available_owners'; diff --git a/x-pack/plugins/cases/public/components/filter_popover/index.tsx b/x-pack/plugins/cases/public/components/filter_popover/index.tsx index 91cd7bfd57fa0..c28d00b08912a 100644 --- a/x-pack/plugins/cases/public/components/filter_popover/index.tsx +++ b/x-pack/plugins/cases/public/components/filter_popover/index.tsx @@ -21,7 +21,7 @@ interface FilterPopoverProps { buttonLabel: string; onSelectedOptionsChanged: Dispatch>; options: string[]; - optionsEmptyLabel: string; + optionsEmptyLabel?: string; selectedOptions: string[]; } @@ -99,7 +99,7 @@ export const FilterPopoverComponent = ({ ))} - {options.length === 0 && ( + {options.length === 0 && optionsEmptyLabel != null && ( diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx index 99fbf48665138..fd7b2eddfe7c9 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -61,7 +61,7 @@ describe('useGetCases', () => { }); await waitForNextUpdate(); expect(spyOnGetCases).toBeCalledWith({ - filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] }, + filterOptions: { ...DEFAULT_FILTER_OPTIONS }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, }); @@ -174,6 +174,7 @@ describe('useGetCases', () => { search: 'new', tags: ['new'], status: CaseStatuses.closed, + owner: [SECURITY_SOLUTION_OWNER], }; const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { @@ -212,7 +213,7 @@ describe('useGetCases', () => { await waitForNextUpdate(); expect(spyOnGetCases.mock.calls[1][0]).toEqual({ - filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] }, + filterOptions: { ...DEFAULT_FILTER_OPTIONS }, queryParams: { ...DEFAULT_QUERY_PARAMS, ...newQueryParams, diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx index a54a4183f766a..eacad3c8ca020 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -19,7 +19,6 @@ import { import { useToasts } from '../common/lib/kibana'; import * as i18n from './translations'; import { getCases, patchCase } from './api'; -import { useCasesContext } from '../components/cases_context/use_cases_context'; export interface UseGetCasesState { data: AllCases; @@ -106,6 +105,7 @@ export const DEFAULT_FILTER_OPTIONS: FilterOptions = { status: StatusAll, tags: [], onlyCollectionType: false, + owner: [], }; export const DEFAULT_QUERY_PARAMS: QueryParams = { @@ -145,7 +145,6 @@ export const useGetCases = ( initialFilterOptions?: Partial; } = {} ): UseGetCases => { - const { owner } = useCasesContext(); const { initialQueryParams = empty, initialFilterOptions = empty } = params; const [state, dispatch] = useReducer(dataFetchReducer, { data: initialData, @@ -185,7 +184,7 @@ export const useGetCases = ( dispatch({ type: 'FETCH_INIT', payload: 'cases' }); const response = await getCases({ - filterOptions: { ...filterOptions, owner }, + filterOptions, queryParams, signal: abortCtrlFetchCases.current.signal, }); @@ -208,7 +207,7 @@ export const useGetCases = ( } } }, - [owner, toasts] + [toasts] ); const dispatchUpdateCaseProperty = useCallback( diff --git a/x-pack/plugins/cases/public/methods/can_use_cases.test.ts b/x-pack/plugins/cases/public/methods/can_use_cases.test.ts new file mode 100644 index 0000000000000..d4906c2702146 --- /dev/null +++ b/x-pack/plugins/cases/public/methods/can_use_cases.test.ts @@ -0,0 +1,145 @@ +/* + * 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 { ApplicationStart } from 'kibana/public'; +import { canUseCases } from './can_use_cases'; + +type CasesCapabilities = Pick< + ApplicationStart['capabilities'], + 'securitySolutionCases' | 'observabilityCases' +>; + +const hasAll: CasesCapabilities = { + securitySolutionCases: { + crud_cases: true, + read_cases: true, + }, + observabilityCases: { + crud_cases: true, + read_cases: true, + }, +}; + +const hasNone: CasesCapabilities = { + securitySolutionCases: { + crud_cases: false, + read_cases: false, + }, + observabilityCases: { + crud_cases: false, + read_cases: false, + }, +}; + +const hasSecurity = { + securitySolutionCases: { + crud_cases: true, + read_cases: true, + }, + observabilityCases: { + crud_cases: false, + read_cases: false, + }, +}; + +const hasObservability = { + securitySolutionCases: { + crud_cases: false, + read_cases: false, + }, + observabilityCases: { + crud_cases: true, + read_cases: true, + }, +}; + +const hasObservabilityCrudTrue = { + securitySolutionCases: { + crud_cases: false, + read_cases: false, + }, + observabilityCases: { + crud_cases: true, + read_cases: false, + }, +}; + +const hasSecurityCrudTrue = { + securitySolutionCases: { + crud_cases: false, + read_cases: false, + }, + observabilityCases: { + crud_cases: true, + read_cases: false, + }, +}; + +const hasObservabilityReadTrue = { + securitySolutionCases: { + crud_cases: false, + read_cases: false, + }, + observabilityCases: { + crud_cases: false, + read_cases: true, + }, +}; + +const hasSecurityReadTrue = { + securitySolutionCases: { + crud_cases: false, + read_cases: true, + }, + observabilityCases: { + crud_cases: false, + read_cases: false, + }, +}; + +const hasSecurityAsCrudAndObservabilityAsRead = { + securitySolutionCases: { + crud_cases: true, + }, + observabilityCases: { + read_cases: true, + }, +}; + +describe('canUseCases', () => { + it.each([hasAll, hasSecurity, hasObservability, hasSecurityAsCrudAndObservabilityAsRead])( + 'returns true for both crud and read, if a user has access to both on any solution', + (capability) => { + const permissions = canUseCases(capability)(); + expect(permissions).toStrictEqual({ crud: true, read: true }); + } + ); + + it.each([hasObservabilityCrudTrue, hasSecurityCrudTrue])( + 'returns true for only crud, if a user has access to only crud on any solution', + (capability) => { + const permissions = canUseCases(capability)(); + expect(permissions).toStrictEqual({ crud: true, read: false }); + } + ); + + it.each([hasObservabilityReadTrue, hasSecurityReadTrue])( + 'returns true for only read, if a user has access to only read on any solution', + (capability) => { + const permissions = canUseCases(capability)(); + expect(permissions).toStrictEqual({ crud: false, read: true }); + } + ); + + it.each([hasNone, {}])( + 'returns false for both, if a user has access to no solution', + (capability) => { + const permissions = canUseCases(capability)(); + expect(permissions).toStrictEqual({ crud: false, read: false }); + } + ); +}); diff --git a/x-pack/plugins/cases/public/methods/can_use_cases.ts b/x-pack/plugins/cases/public/methods/can_use_cases.ts new file mode 100644 index 0000000000000..d0b83241963d8 --- /dev/null +++ b/x-pack/plugins/cases/public/methods/can_use_cases.ts @@ -0,0 +1,29 @@ +/* + * 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 { ApplicationStart } from 'kibana/public'; +import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../common/constants'; + +export type CasesOwners = typeof SECURITY_SOLUTION_OWNER | typeof OBSERVABILITY_OWNER; + +/* + * Returns an object denoting the current user's ability to read and crud cases. + * If any owner(securitySolution, Observability) is found with crud or read capability respectively, + * then crud or read is set to true. + * Permissions for a specific owners can be found by passing an owner array + */ + +export const canUseCases = + (capabilities: Partial) => + ( + owners: CasesOwners[] = [OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER] + ): { crud: boolean; read: boolean } => ({ + crud: + (capabilities && owners.some((owner) => capabilities[`${owner}Cases`]?.crud_cases)) ?? false, + read: + (capabilities && owners.some((owner) => capabilities[`${owner}Cases`]?.read_cases)) ?? false, + }); diff --git a/x-pack/plugins/cases/public/methods/index.ts b/x-pack/plugins/cases/public/methods/index.ts index ee62ddaae7ce5..375bd42ee8581 100644 --- a/x-pack/plugins/cases/public/methods/index.ts +++ b/x-pack/plugins/cases/public/methods/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +export * from './can_use_cases'; export * from './get_cases'; export * from './get_recent_cases'; export * from './get_all_cases_selector_modal'; diff --git a/x-pack/plugins/cases/public/mocks.ts b/x-pack/plugins/cases/public/mocks.ts index f2c7984f1469e..6f508d9b6da3b 100644 --- a/x-pack/plugins/cases/public/mocks.ts +++ b/x-pack/plugins/cases/public/mocks.ts @@ -8,6 +8,7 @@ import { CasesUiStart } from './types'; const createStartContract = (): jest.Mocked => ({ + canUseCases: jest.fn(), getCases: jest.fn(), getAllCasesSelectorModal: jest.fn(), getCreateCaseFlyout: jest.fn(), diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts index 48371f65b49e1..f38b2d12e2ad4 100644 --- a/x-pack/plugins/cases/public/plugin.ts +++ b/x-pack/plugins/cases/public/plugin.ts @@ -14,6 +14,7 @@ import { getRecentCasesLazy, getAllCasesSelectorModalLazy, getCreateCaseFlyoutLazy, + canUseCases, } from './methods'; import { CasesUiConfigType } from '../common/ui/types'; import { ENABLE_CASE_CONNECTOR } from '../common/constants'; @@ -38,6 +39,7 @@ export class CasesUiPlugin implements Plugin(); KibanaServices.init({ ...core, ...plugins, kibanaVersion: this.kibanaVersion, config }); return { + canUseCases: canUseCases(core.application.capabilities), getCases: getCasesLazy, getRecentCases: getRecentCasesLazy, getCreateCaseFlyout: getCreateCaseFlyoutLazy, diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index 1e995db3caa31..cb2e570b58e13 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -8,8 +8,8 @@ import { CoreStart } from 'kibana/public'; import { ReactElement } from 'react'; -import { LensPublicStart } from '../../lens/public'; -import { SecurityPluginSetup } from '../../security/public'; +import type { LensPublicStart } from '../../lens/public'; +import type { SecurityPluginSetup } from '../../security/public'; import type { TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup, TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, @@ -19,11 +19,12 @@ import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public' import type { SpacesPluginStart } from '../../spaces/public'; import type { Storage } from '../../../../src/plugins/kibana_utils/public'; -import { +import type { GetCasesProps, GetAllCasesSelectorModalProps, GetCreateCaseFlyoutProps, GetRecentCasesProps, + CasesOwners, } from './methods'; export interface SetupPlugins { @@ -52,6 +53,15 @@ export type StartServices = CoreStart & }; export interface CasesUiStart { + /** + * Returns an object denoting the current user's ability to read and crud cases. + * If any owner(securitySolution, Observability) is found with crud or read capability respectively, + * then crud or read is set to true. + * Permissions for specific owners can be found by passing an owner array + * @param owners an array of CaseOwners that should be queried for permission + * @returns An object denoting the case permissions of the current user + */ + canUseCases: (owners?: CasesOwners[]) => { crud: boolean; read: boolean }; /** * Get cases * @param props GetCasesProps