[8.12] [Cases] Table Solution Filter Not Rendering Any Checked Option When All Selected (#172460) (#173244)

# Backport

This will backport the following commits from `main` to `8.12`:
- [[Cases] Table Solution Filter Not Rendering Any Checked Option When
All Selected (#172460)](https://github.com/elastic/kibana/pull/172460)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Julian
Gernun","email":"17549662+jcger@users.noreply.github.com"},"sourceCommit":{"committedDate":"2023-12-13T09:30:49Z","message":"[Cases]
Table Solution Filter Not Rendering Any Checked Option When All Selected
(#172460)\n\nCo-authored-by: Christos Nasikas
<christos.nasikas@elastic.co>","sha":"b6d291d1034752515b56439d4b2437f085cd2147","branchLabelMapping":{"^v8.13.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","release_note:skip","Team:ResponseOps","Feature:Cases","v8.12.0","v8.13.0"],"number":172460,"url":"https://github.com/elastic/kibana/pull/172460","mergeCommit":{"message":"[Cases]
Table Solution Filter Not Rendering Any Checked Option When All Selected
(#172460)\n\nCo-authored-by: Christos Nasikas
<christos.nasikas@elastic.co>","sha":"b6d291d1034752515b56439d4b2437f085cd2147"}},"sourceBranch":"main","suggestedTargetBranches":["8.12"],"targetPullRequestStates":[{"branch":"8.12","label":"v8.12.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.13.0","labelRegex":"^v8.13.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/172460","number":172460,"mergeCommit":{"message":"[Cases]
Table Solution Filter Not Rendering Any Checked Option When All Selected
(#172460)\n\nCo-authored-by: Christos Nasikas
<christos.nasikas@elastic.co>","sha":"b6d291d1034752515b56439d4b2437f085cd2147"}}]}]
BACKPORT-->

Co-authored-by: Julian Gernun <17549662+jcger@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2023-12-13 06:00:10 -05:00 committed by GitHub
parent 77bcd6e612
commit f54ca50f98
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 207 additions and 168 deletions

View file

@ -516,7 +516,6 @@ describe('AllCasesListGeneric', () => {
filterOptions: {
...DEFAULT_FILTER_OPTIONS,
searchFields: ['title', 'description'],
owner: ['securitySolution'],
category: ['twix'],
},
queryParams: DEFAULT_QUERY_PARAMS,
@ -645,82 +644,6 @@ describe('AllCasesListGeneric', () => {
});
describe('Solutions', () => {
it('should set the owner to all available solutions when deselecting all solutions', async () => {
const { getByTestId } = render(
<TestProviders owner={[]}>
<AllCasesList />
</TestProviders>
);
expect(useGetCasesMock).toHaveBeenCalledWith({
filterOptions: {
search: '',
searchFields: ['title', 'description'],
severity: [],
reporters: [],
status: [],
tags: [],
assignees: [],
owner: ['securitySolution', 'observability'],
category: [],
customFields: {},
},
queryParams: DEFAULT_QUERY_PARAMS,
});
userEvent.click(getByTestId('options-filter-popover-button-owner'));
await waitForEuiPopoverOpen();
userEvent.click(
getByTestId(`options-filter-popover-item-${SECURITY_SOLUTION_OWNER}`),
undefined,
{
skipPointerEventsCheck: true,
}
);
expect(useGetCasesMock).toBeCalledWith({
filterOptions: {
search: '',
searchFields: ['title', 'description'],
severity: [],
reporters: [],
status: [],
tags: [],
assignees: [],
owner: ['securitySolution'],
category: [],
customFields: {},
},
queryParams: DEFAULT_QUERY_PARAMS,
});
userEvent.click(
getByTestId(`options-filter-popover-item-${SECURITY_SOLUTION_OWNER}`),
undefined,
{
skipPointerEventsCheck: true,
}
);
expect(useGetCasesMock).toHaveBeenLastCalledWith({
filterOptions: {
search: '',
searchFields: ['title', 'description'],
severity: [],
reporters: [],
status: [],
tags: [],
assignees: [],
owner: ['securitySolution', 'observability'],
category: [],
customFields: {},
},
queryParams: DEFAULT_QUERY_PARAMS,
});
});
it('should hide the solutions filter if the owner is provided', async () => {
const { queryByTestId } = render(
<TestProviders owner={[SECURITY_SOLUTION_OWNER]}>
@ -730,30 +653,6 @@ describe('AllCasesListGeneric', () => {
expect(queryByTestId('options-filter-popover-button-owner')).toBeFalsy();
});
it('should call useGetCases with the correct owner on initial render', async () => {
render(
<TestProviders owner={[SECURITY_SOLUTION_OWNER]}>
<AllCasesList />
</TestProviders>
);
expect(useGetCasesMock).toHaveBeenCalledWith({
filterOptions: {
search: '',
searchFields: ['title', 'description'],
severity: [],
reporters: [],
status: [],
tags: [],
assignees: [],
owner: ['securitySolution'],
category: [],
customFields: {},
},
queryParams: DEFAULT_QUERY_PARAMS,
});
});
});
describe('Actions', () => {
@ -1102,7 +1001,6 @@ describe('AllCasesListGeneric', () => {
expect(useGetCasesMock).toHaveBeenLastCalledWith({
filterOptions: {
...DEFAULT_FILTER_OPTIONS,
owner: [SECURITY_SOLUTION_OWNER],
assignees: [],
},
queryParams: DEFAULT_QUERY_PARAMS,

View file

@ -63,10 +63,10 @@ export const AllCasesList = React.memo<AllCasesListProps>(
const isLoading = useIsLoadingCases();
const hasOwner = !!owner.length;
const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses));
const initialFilterOptions = {
...(!isEmpty(hiddenStatuses) && firstAvailableStatus && { status: [firstAvailableStatus] }),
owner: hasOwner ? owner : availableSolutions,
};
const { queryParams, setQueryParams, filterOptions, setFilterOptions } = useAllCasesState(
@ -210,7 +210,6 @@ export const AllCasesList = React.memo<AllCasesListProps>(
availableSolutions={hasOwner ? [] : availableSolutions}
hiddenStatuses={hiddenStatuses}
onCreateCasePressed={onCreateCasePressed}
initialFilterOptions={initialFilterOptions}
isSelectorView={isSelectorView}
isLoading={isLoadingCurrentUserProfile}
currentUserProfile={currentUserProfile}

View file

@ -100,7 +100,7 @@ describe('SolutionFilter ', () => {
expect(onChange).toHaveBeenCalledWith({
filterId: 'owner',
selectedOptionKeys: [solutions[0]],
selectedOptionKeys: [],
});
});
});
@ -168,7 +168,7 @@ describe('SolutionFilter ', () => {
expect(onChange).toHaveBeenCalledWith({
filterId: 'owner',
selectedOptionKeys: [solutions[0], solutions[1]],
selectedOptionKeys: [],
});
});
});

View file

@ -43,37 +43,6 @@ export const SolutionFilterComponent = ({
const options = mapToMultiSelectOption(hasOwner ? owner : availableSolutions);
const solutions = availableSolutions.map((solution) => mapToReadableSolutionName(solution));
/**
* If the user selects and deselects all solutions then the owner is set to an empty array.
* This results in fetching all cases the user has access to including
* the ones with read access. We want to show only the cases the user has full access to.
* For that reason we fallback to availableSolutions if the owner is empty.
*
* If the consumer of cases has passed an owner we fallback to the provided owner
*/
const _onChange = ({
filterId,
selectedOptionKeys: newOptions,
}: {
filterId: string;
selectedOptionKeys: string[];
}) => {
if (hasOwner) {
onChange({
filterId,
selectedOptionKeys: newOptions.length === 0 ? owner : newOptions,
});
} else {
onChange({
filterId,
selectedOptionKeys: newOptions.length === 0 ? availableSolutions : newOptions,
});
}
};
const selectedOptionsInFilter =
selectedOptionKeys.length === availableSolutions.length ? [] : selectedOptionKeys;
const renderOption = (option: EuiSelectableOption) => {
const solution = solutions.find((solutionData) => solutionData.id === option.label) as Solution;
return (
@ -90,10 +59,10 @@ export const SolutionFilterComponent = ({
<MultiSelectFilter
buttonLabel={i18n.SOLUTION}
id={'owner'}
onChange={_onChange}
onChange={onChange}
options={options}
renderOption={renderOption}
selectedOptionKeys={selectedOptionsInFilter}
selectedOptionKeys={selectedOptionKeys}
/>
);
};

View file

@ -7,7 +7,6 @@
import React from 'react';
import type { FilterOptions } from '../../../../common/ui';
import type { CaseStatuses } from '../../../../common/types/domain';
import { MAX_TAGS_FILTER_LENGTH, MAX_CATEGORY_FILTER_LENGTH } from '../../../../common/constants';
import { MultiSelectFilter, mapToMultiSelectOption } from '../multi_select_filter';
@ -28,7 +27,6 @@ interface UseFilterConfigProps {
countOpenCases: number | null;
currentUserProfile: CurrentUserProfile;
hiddenStatuses?: CaseStatuses[];
initialFilterOptions: Partial<FilterOptions>;
isLoading: boolean;
isSelectorView?: boolean;
onFilterOptionsChange: FilterChangeHandler;
@ -44,7 +42,6 @@ export const getSystemFilterConfig = ({
countOpenCases,
currentUserProfile,
hiddenStatuses,
initialFilterOptions,
isLoading,
isSelectorView,
onFilterOptionsChange,
@ -69,7 +66,7 @@ export const getSystemFilterConfig = ({
isAvailable: true,
getEmptyOptions: () => {
return {
severity: initialFilterOptions.severity || [],
severity: [],
};
},
render: ({ filterOptions }: FilterConfigRenderParams) => (
@ -86,7 +83,7 @@ export const getSystemFilterConfig = ({
isAvailable: true,
getEmptyOptions: () => {
return {
status: initialFilterOptions.status || [],
status: [],
};
},
render: ({ filterOptions }: FilterConfigRenderParams) => (
@ -107,7 +104,7 @@ export const getSystemFilterConfig = ({
isAvailable: caseAssignmentAuthorized && !isSelectorView,
getEmptyOptions: () => {
return {
assignees: initialFilterOptions.assignees || [],
assignees: [],
};
},
render: ({ filterOptions }: FilterConfigRenderParams) => {
@ -128,7 +125,7 @@ export const getSystemFilterConfig = ({
isAvailable: true,
getEmptyOptions: () => {
return {
tags: initialFilterOptions.tags || [],
tags: [],
};
},
render: ({ filterOptions }: FilterConfigRenderParams) => (
@ -150,7 +147,7 @@ export const getSystemFilterConfig = ({
isAvailable: true,
getEmptyOptions: () => {
return {
category: initialFilterOptions.category || [],
category: [],
};
},
render: ({ filterOptions }: FilterConfigRenderParams) => (
@ -172,7 +169,7 @@ export const getSystemFilterConfig = ({
isAvailable: availableSolutions.length > 1,
getEmptyOptions: () => {
return {
owner: initialFilterOptions.owner || [],
owner: [],
};
},
render: ({ filterOptions }: FilterConfigRenderParams) => (
@ -195,7 +192,6 @@ export const useSystemFilterConfig = ({
countOpenCases,
currentUserProfile,
hiddenStatuses,
initialFilterOptions,
isLoading,
isSelectorView,
onFilterOptionsChange,
@ -210,7 +206,6 @@ export const useSystemFilterConfig = ({
countOpenCases,
currentUserProfile,
hiddenStatuses,
initialFilterOptions,
isLoading,
isSelectorView,
onFilterOptionsChange,

View file

@ -262,6 +262,9 @@ describe('CasesTableFilters ', () => {
describe('Solution filter', () => {
it('shows Solution filter when provided more than 1 availableSolutions', () => {
appMockRender = createAppMockRenderer({
owner: [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER],
});
appMockRender.render(
<CasesTableFilters
{...props}
@ -272,13 +275,17 @@ describe('CasesTableFilters ', () => {
});
it('does not show Solution filter when provided less than 1 availableSolutions', () => {
appMockRender.render(
<CasesTableFilters {...props} availableSolutions={[OBSERVABILITY_OWNER]} />
);
appMockRender = createAppMockRenderer({
owner: [],
});
appMockRender.render(<CasesTableFilters {...props} availableSolutions={[]} />);
expect(screen.queryByTestId('options-filter-popover-button-owner')).not.toBeInTheDocument();
});
it('does not select a solution on initial render', () => {
appMockRender = createAppMockRenderer({
owner: [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER],
});
appMockRender.render(
<CasesTableFilters
{...props}
@ -291,11 +298,22 @@ describe('CasesTableFilters ', () => {
);
});
it('should reset the filter setting all available solutions when deactivated', async () => {
it('should reset the filter when deactivated', async () => {
appMockRender = createAppMockRenderer({
owner: [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER],
});
const overrideProps = {
...props,
filterOptions: {
...props.filterOptions,
owner: [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER],
},
};
appMockRender.render(
<CasesTableFilters
{...props}
initialFilterOptions={{ owner: [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER] }}
{...overrideProps}
availableSolutions={[SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER]}
/>
);
@ -306,8 +324,39 @@ describe('CasesTableFilters ', () => {
expect(onFilterChanged).toHaveBeenCalledWith({
...DEFAULT_FILTER_OPTIONS,
owner: [],
});
});
it('should check all options when all options are selected', async () => {
appMockRender = createAppMockRenderer({
owner: [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER],
});
const overrideProps = {
...props,
filterOptions: {
...props.filterOptions,
owner: [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER],
},
};
appMockRender.render(
<CasesTableFilters
{...overrideProps}
availableSolutions={[SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER]}
/>
);
userEvent.click(screen.getByRole('button', { name: 'Solution' }));
await waitForEuiPopoverOpen();
const allOptions = screen.getAllByRole('option');
expect(allOptions).toHaveLength(2);
expect(allOptions[0]).toHaveAttribute('aria-checked', 'true');
expect(allOptions[0]).toHaveTextContent('Security');
expect(allOptions[1]).toHaveAttribute('aria-checked', 'true');
expect(allOptions[1]).toHaveTextContent('Observability');
});
});

View file

@ -28,7 +28,6 @@ export interface CasesTableFiltersProps {
availableSolutions: string[];
isSelectorView?: boolean;
onCreateCasePressed?: () => void;
initialFilterOptions: Partial<FilterOptions>;
isLoading: boolean;
currentUserProfile: CurrentUserProfile;
filterOptions: FilterOptions;
@ -49,7 +48,6 @@ const CasesTableFiltersComponent = ({
availableSolutions,
isSelectorView = false,
onCreateCasePressed,
initialFilterOptions,
isLoading,
currentUserProfile,
filterOptions,
@ -78,7 +76,6 @@ const CasesTableFiltersComponent = ({
countOpenCases,
currentUserProfile,
hiddenStatuses,
initialFilterOptions,
isLoading,
isSelectorView,
onFilterOptionsChange,

View file

@ -11,10 +11,11 @@ import { useGetCases } from './use_get_cases';
import * as api from './api';
import type { AppMockRenderer } from '../common/mock';
import { createAppMockRenderer } from '../common/mock';
import { useToasts } from '../common/lib/kibana';
import { useToasts } from '../common/lib/kibana/hooks';
import { OWNERS } from '../../common/constants';
jest.mock('./api');
jest.mock('../common/lib/kibana');
jest.mock('../common/lib/kibana/hooks');
describe('useGetCases', () => {
const abortCtrl = new AbortController();
@ -24,8 +25,8 @@ describe('useGetCases', () => {
let appMockRender: AppMockRenderer;
beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});
it('calls getCases with correct arguments', async () => {
@ -33,9 +34,10 @@ describe('useGetCases', () => {
const { waitForNextUpdate } = renderHook(() => useGetCases(), {
wrapper: appMockRender.AppWrapper,
});
await waitForNextUpdate();
expect(spyOnGetCases).toBeCalledWith({
filterOptions: { ...DEFAULT_FILTER_OPTIONS },
filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: ['securitySolution'] },
queryParams: DEFAULT_QUERY_PARAMS,
signal: abortCtrl.signal,
});
@ -46,6 +48,7 @@ describe('useGetCases', () => {
spyOnGetCases.mockImplementation(() => {
throw new Error('Something went wrong');
});
const addError = jest.fn();
(useToasts as jest.Mock).mockReturnValue({ addSuccess, addError });
@ -56,4 +59,97 @@ describe('useGetCases', () => {
await waitForNextUpdate();
expect(addError).toHaveBeenCalled();
});
it('should set all owners when no owner is provided', async () => {
appMockRender = createAppMockRenderer({ owner: [] });
appMockRender.coreStart.application.capabilities = {
...appMockRender.coreStart.application.capabilities,
observabilityCases: {
create_cases: true,
read_cases: true,
update_cases: true,
push_cases: true,
cases_connectors: true,
delete_cases: true,
cases_settings: true,
},
securitySolutionCases: {
create_cases: true,
read_cases: true,
update_cases: true,
push_cases: true,
cases_connectors: true,
delete_cases: true,
cases_settings: true,
},
};
const spyOnGetCases = jest.spyOn(api, 'getCases');
const { waitForNextUpdate } = renderHook(() => useGetCases(), {
wrapper: appMockRender.AppWrapper,
});
await waitForNextUpdate();
expect(spyOnGetCases).toBeCalledWith({
filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [...OWNERS] },
queryParams: DEFAULT_QUERY_PARAMS,
signal: abortCtrl.signal,
});
});
it('should set only the available owners when no owner is provided', async () => {
appMockRender = createAppMockRenderer({ owner: [] });
const spyOnGetCases = jest.spyOn(api, 'getCases');
const { waitForNextUpdate } = renderHook(() => useGetCases(), {
wrapper: appMockRender.AppWrapper,
});
await waitForNextUpdate();
expect(spyOnGetCases).toBeCalledWith({
filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: ['cases'] },
queryParams: DEFAULT_QUERY_PARAMS,
signal: abortCtrl.signal,
});
});
it('should use the app owner when the filter options do not specify the owner', async () => {
appMockRender = createAppMockRenderer({ owner: ['observability'] });
const spyOnGetCases = jest.spyOn(api, 'getCases');
const { waitForNextUpdate } = renderHook(() => useGetCases(), {
wrapper: appMockRender.AppWrapper,
});
await waitForNextUpdate();
expect(spyOnGetCases).toBeCalledWith({
filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: ['observability'] },
queryParams: DEFAULT_QUERY_PARAMS,
signal: abortCtrl.signal,
});
});
it('respects the owner in the filter options if provided', async () => {
appMockRender = createAppMockRenderer({ owner: ['observability'] });
const spyOnGetCases = jest.spyOn(api, 'getCases');
const { waitForNextUpdate } = renderHook(
() => useGetCases({ filterOptions: { owner: ['my-owner'] } }),
{
wrapper: appMockRender.AppWrapper,
}
);
await waitForNextUpdate();
expect(spyOnGetCases).toBeCalledWith({
filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: ['my-owner'] },
queryParams: DEFAULT_QUERY_PARAMS,
signal: abortCtrl.signal,
});
});
});

View file

@ -13,6 +13,9 @@ import { useToasts } from '../common/lib/kibana';
import * as i18n from './translations';
import { getCases } from './api';
import type { ServerError } from '../types';
import { useCasesContext } from '../components/cases_context/use_cases_context';
import { useAvailableCasesOwners } from '../components/app/use_available_owners';
import { getAllPermissionsExceptFrom } from '../utils/permissions';
export const initialData: CasesFindResponseUI = {
cases: [],
@ -31,6 +34,17 @@ export const useGetCases = (
} = {}
): UseQueryResult<CasesFindResponseUI> => {
const toasts = useToasts();
const { owner } = useCasesContext();
const availableSolutions = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete'));
const hasOwner = !!owner.length;
const initialOwner = hasOwner ? owner : availableSolutions;
const ownerFilter =
params.filterOptions?.owner != null && params.filterOptions.owner.length > 0
? { owner: params.filterOptions.owner }
: { owner: initialOwner };
return useQuery(
casesQueriesKeys.cases(params),
({ signal }) => {
@ -38,6 +52,7 @@ export const useGetCases = (
filterOptions: {
...DEFAULT_FILTER_OPTIONS,
...(params.filterOptions ?? {}),
...ownerFilter,
},
queryParams: {
...DEFAULT_QUERY_PARAMS,

View file

@ -183,11 +183,16 @@ export function CasesTableServiceProvider(
await casesCommon.selectFirstRowInAssigneesPopover();
},
async filterByOwner(owner: string) {
await common.clickAndValidate(
'options-filter-popover-button-owner',
`options-filter-popover-item-${owner}`
);
async filterByOwner(
owner: string,
options: { popupAlreadyOpen: boolean } = { popupAlreadyOpen: false }
) {
if (!options.popupAlreadyOpen) {
await common.clickAndValidate(
'options-filter-popover-button-owner',
`options-filter-popover-item-${owner}`
);
}
await testSubjects.click(`options-filter-popover-item-${owner}`);
},

View file

@ -318,6 +318,22 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
}
});
it('filters with multiple selection', async () => {
await openModal();
let popupAlreadyOpen = false;
for (const [owner] of createdCases.entries()) {
await cases.casesTable.filterByOwner(owner, { popupAlreadyOpen });
popupAlreadyOpen = true;
}
await cases.casesTable.waitForTableToFinishLoading();
for (const caseId of createdCases.values()) {
await testSubjects.existOrFail(`cases-table-row-${caseId}`);
}
await closeModal();
});
it('attaches correctly', async () => {
for (const [owner, currentCaseId] of createdCases.entries()) {
await openModal();