Skip to content

Commit

Permalink
[Cases] Custom Fields as Cases Filters (#171176)
Browse files Browse the repository at this point in the history
Meta issue: #167651

## Summary


https://github.com/elastic/kibana/assets/17549662/b0fc9464-8cac-4547-8a85-aac24cf941e9

## What this PR does not include (will be done in future PRs)
- If the users adds to many filters, the UI will overflow
- If the url contains status or severity set and the filter is not
active, we need to automatically activate that filter and set the filter
option

## useEffect situation

We tried to remove some useEffects, specifically in
[useFilterConfig](https://github.com/elastic/kibana/pull/171176/files#diff-3e3d844f888b4030bd3f3ead9e71866757a6d9ff7e5d3972afebed9956fcddceR62)
and
[useSystemFilterConfig](https://github.com/elastic/kibana/pull/171176/files#diff-2696d6c860ec0b34363c060d2638f2b63698f06128d1155f735f34de7cc5b5b3R200)
but as they have dependencies that are loaded from API's, there are some
use cases, like if a new custom field is added or removed where effects
(I think) are a must. We will come back to this issue once we have the
feature in main to try to solve this issue

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
jcger and kibanamachine authored Nov 30, 2023
1 parent 1e68656 commit 219cbbd
Show file tree
Hide file tree
Showing 29 changed files with 1,682 additions and 202 deletions.
1 change: 1 addition & 0 deletions x-pack/plugins/cases/common/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export const LOCAL_STORAGE_KEYS = {
casesQueryParams: 'cases.list.queryParams',
casesFilterOptions: 'cases.list.filterOptions',
casesTableColumns: 'cases.list.tableColumns',
casesTableFiltersConfig: 'cases.list.tableFiltersConfig',
};

/**
Expand Down
13 changes: 12 additions & 1 deletion x-pack/plugins/cases/common/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
ExternalReferenceAttachment,
PersistableStateAttachment,
Configuration,
CustomFieldTypes,
} from '../types/domain';
import type {
CasePatchRequest,
Expand Down Expand Up @@ -145,7 +146,7 @@ export interface ParsedUrlQueryParams extends Partial<UrlQueryParams> {

export type LocalStorageQueryParams = Partial<Omit<QueryParams, 'page'>>;

export interface FilterOptions {
export interface SystemFilterOptions {
search: string;
searchFields: string[];
severity: CaseSeverity[];
Expand All @@ -156,6 +157,16 @@ export interface FilterOptions {
owner: string[];
category: string[];
}

export interface FilterOptions extends SystemFilterOptions {
customFields: {
[key: string]: {
type: CustomFieldTypes;
options: string[];
};
};
}

export type PartialFilterOptions = Partial<FilterOptions>;

export type SingleCaseMetrics = SingleCaseMetricsResponse;
Expand Down
4 changes: 2 additions & 2 deletions x-pack/plugins/cases/public/common/mock/test_providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export interface AppMockRenderer {
render: UiRender;
coreStart: StartServices;
queryClient: QueryClient;
AppWrapper: React.FC<{ children: React.ReactElement }>;
AppWrapper: React.FC<{ children: React.ReactNode }>;
getFilesClient: () => ScopedFilesClient;
}

Expand Down Expand Up @@ -176,7 +176,7 @@ export const createAppMockRenderer = ({

const getFilesClient = mockGetFilesClient();

const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => (
const AppWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<I18nProvider>
<KibanaContextProvider services={services}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ describe('AllCasesListGeneric', () => {
assignees: [],
owner: ['securitySolution', 'observability'],
category: [],
customFields: {},
},
queryParams: DEFAULT_QUERY_PARAMS,
});
Expand Down Expand Up @@ -686,6 +687,7 @@ describe('AllCasesListGeneric', () => {
assignees: [],
owner: ['securitySolution'],
category: [],
customFields: {},
},
queryParams: DEFAULT_QUERY_PARAMS,
});
Expand All @@ -709,6 +711,7 @@ describe('AllCasesListGeneric', () => {
assignees: [],
owner: ['securitySolution', 'observability'],
category: [],
customFields: {},
},
queryParams: DEFAULT_QUERY_PARAMS,
});
Expand Down Expand Up @@ -742,6 +745,7 @@ describe('AllCasesListGeneric', () => {
assignees: [],
owner: ['securitySolution'],
category: [],
customFields: {},
},
queryParams: DEFAULT_QUERY_PARAMS,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useIsLoadingCases } from './use_is_loading_cases';
import { useAllCasesState } from './use_all_cases_state';
import { useAvailableCasesOwners } from '../app/use_available_owners';
import { useCasesColumnsSelection } from './use_cases_columns_selection';
import { DEFAULT_FILTER_OPTIONS } from '../../containers/constants';

const ProgressLoader = styled(EuiProgress)`
${({ $isShow }: { $isShow: boolean }) =>
Expand Down Expand Up @@ -65,6 +66,7 @@ export const AllCasesList = React.memo<AllCasesListProps>(
const hasOwner = !!owner.length;
const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses));
const initialFilterOptions = {
...DEFAULT_FILTER_OPTIONS,
...(!isEmpty(hiddenStatuses) && firstAvailableStatus && { status: [firstAvailableStatus] }),
owner: hasOwner ? owner : availableSolutions,
};
Expand Down Expand Up @@ -210,6 +212,7 @@ export const AllCasesList = React.memo<AllCasesListProps>(
availableSolutions={hasOwner ? [] : availableSolutions}
hiddenStatuses={hiddenStatuses}
onCreateCasePressed={onCreateCasePressed}
initialFilterOptions={initialFilterOptions}
isSelectorView={isSelectorView}
isLoading={isLoadingCurrentUserProfile}
currentUserProfile={currentUserProfile}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,23 @@ describe('multi select filter', () => {
await waitForEuiPopoverOpen();
expect(screen.getAllByTestId(TEST_ID).length).toBe(2);
});

it('should not show the amount of options if hideActiveOptionsNumber is active', () => {
const onChange = jest.fn();
const props = {
id: 'tags',
buttonLabel: 'Tags',
options: [
{ label: 'tag a', key: 'tag a' },
{ label: 'tag b', key: 'tag b' },
],
onChange,
selectedOptionKeys: ['tag b'],
};

const { rerender } = render(<MultiSelectFilter {...props} />);
expect(screen.queryByLabelText('1 active filters')).toBeInTheDocument();
rerender(<MultiSelectFilter {...props} hideActiveOptionsNumber />);
expect(screen.queryByLabelText('1 active filters')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import {
import { isEqual } from 'lodash/fp';
import * as i18n from './translations';

type FilterOption<T extends string> = EuiSelectableOption<{
key: string;
type FilterOption<T extends string, K extends string = string> = EuiSelectableOption<{
key: K;
label: T;
}>;

Expand All @@ -38,12 +38,12 @@ export const mapToMultiSelectOption = <T extends string>(options: T[]) => {
});
};

const fromRawOptionsToEuiSelectableOptions = <T extends string>(
options: Array<FilterOption<T>>,
const fromRawOptionsToEuiSelectableOptions = <T extends string, K extends string>(
options: Array<FilterOption<T, K>>,
selectedOptionKeys: string[]
): Array<FilterOption<T>> => {
): Array<FilterOption<T, K>> => {
return options.map(({ key, label }) => {
const selectableOption: FilterOption<T> = { label, key };
const selectableOption: FilterOption<T, K> = { label, key };
if (selectedOptionKeys.includes(key)) {
selectableOption.checked = 'on';
}
Expand All @@ -52,49 +52,46 @@ const fromRawOptionsToEuiSelectableOptions = <T extends string>(
});
};

const fromEuiSelectableOptionToRawOption = <T extends string>(
options: Array<FilterOption<T>>
const fromEuiSelectableOptionToRawOption = <T extends string, K extends string>(
options: Array<FilterOption<T, K>>
): string[] => {
return options.map((option) => option.key);
};

const getEuiSelectableCheckedOptions = <T extends string>(options: Array<FilterOption<T>>) =>
options.filter((option) => option.checked === 'on');
const getEuiSelectableCheckedOptions = <T extends string, K extends string>(
options: Array<FilterOption<T, K>>
) => options.filter((option) => option.checked === 'on') as Array<FilterOption<T, K>>;

interface UseFilterParams<T extends string> {
interface UseFilterParams<T extends string, K extends string = string> {
buttonLabel?: string;
buttonIconType?: string;
hideActiveOptionsNumber?: boolean;
id: string;
limit?: number;
limitReachedMessage?: string;
onChange: ({
filterId,
selectedOptionKeys,
}: {
filterId: string;
selectedOptionKeys: string[];
}) => void;
options: Array<FilterOption<T>>;
onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void;
options: Array<FilterOption<T, K>>;
selectedOptionKeys?: string[];
renderOption?: (option: FilterOption<T>) => React.ReactNode;
renderOption?: (option: FilterOption<T, K>) => React.ReactNode;
}
export const MultiSelectFilter = <T extends string>({
export const MultiSelectFilter = <T extends string, K extends string = string>({
buttonLabel,
buttonIconType,
hideActiveOptionsNumber,
id,
limit,
limitReachedMessage,
onChange,
options: rawOptions,
selectedOptionKeys = [],
renderOption,
}: UseFilterParams<T>) => {
}: UseFilterParams<T, K>) => {
const { euiTheme } = useEuiTheme();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const toggleIsPopoverOpen = () => setIsPopoverOpen((prevValue) => !prevValue);
const showActiveOptionsNumber = !hideActiveOptionsNumber;
const isInvalid = Boolean(limit && limitReachedMessage && selectedOptionKeys.length >= limit);
const options: Array<FilterOption<T>> = fromRawOptionsToEuiSelectableOptions(
rawOptions,
selectedOptionKeys
);
const options = fromRawOptionsToEuiSelectableOptions(rawOptions, selectedOptionKeys);

useEffect(() => {
const newSelectedOptions = selectedOptionKeys.filter((selectedOptionKey) =>
Expand All @@ -108,7 +105,7 @@ export const MultiSelectFilter = <T extends string>({
}
}, [selectedOptionKeys, rawOptions, id, onChange]);

const _onChange = (newOptions: Array<FilterOption<T>>) => {
const _onChange = (newOptions: Array<FilterOption<T, K>>) => {
const newSelectedOptions = getEuiSelectableCheckedOptions(newOptions);
if (isInvalid && limit && newSelectedOptions.length >= limit) {
return;
Expand All @@ -126,12 +123,12 @@ export const MultiSelectFilter = <T extends string>({
button={
<EuiFilterButton
data-test-subj={`options-filter-popover-button-${id}`}
iconType="arrowDown"
iconType={buttonIconType || 'arrowDown'}
onClick={toggleIsPopoverOpen}
isSelected={isPopoverOpen}
numFilters={options.length}
hasActiveFilters={selectedOptionKeys.length > 0}
numActiveFilters={selectedOptionKeys.length}
numFilters={showActiveOptionsNumber ? options.length : undefined}
hasActiveFilters={showActiveOptionsNumber ? selectedOptionKeys.length > 0 : undefined}
numActiveFilters={showActiveOptionsNumber ? selectedOptionKeys.length : undefined}
aria-label={buttonLabel}
>
{buttonLabel}
Expand All @@ -154,10 +151,14 @@ export const MultiSelectFilter = <T extends string>({
<EuiHorizontalRule margin="none" />
</>
)}
<EuiSelectable<FilterOption<T>>
<EuiSelectable<FilterOption<T, K>>
options={options}
searchable
searchProps={{ placeholder: buttonLabel, compressed: false }}
searchProps={{
placeholder: buttonLabel,
compressed: false,
'data-test-subj': `${id}-search-input`,
}}
emptyMessage={i18n.EMPTY_FILTER_MESSAGE}
onChange={_onChange}
singleSelection={false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import React from 'react';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
import { SeverityFilter } from './severity_filter';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,7 @@ import * as i18n from './translations';

interface Props {
selectedOptionKeys: CaseSeverity[];
onChange: ({
filterId,
selectedOptionKeys,
}: {
filterId: string;
selectedOptionKeys: string[];
}) => void;
onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void;
}

const options = mapToMultiSelectOption(Object.keys(severities) as CaseSeverity[]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,7 @@ import type { CasesOwners } from '../../client/helpers/can_use_cases';
import { useCasesContext } from '../cases_context/use_cases_context';

interface FilterPopoverProps {
onChange: ({
filterId,
selectedOptionKeys,
}: {
filterId: string;
selectedOptionKeys: string[];
}) => void;
onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void;
selectedOptionKeys: string[];
availableSolutions: string[];
}
Expand Down
30 changes: 14 additions & 16 deletions x-pack/plugins/cases/public/components/all_cases/status_filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,25 @@ import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Status } from '@kbn/cases-components/src/status/status';
import { CaseStatuses } from '../../../common/types/domain';
import { statuses } from '../status';

import type { MultiSelectFilterOption } from './multi_select_filter';
import { MultiSelectFilter, mapToMultiSelectOption } from './multi_select_filter';
import { MultiSelectFilter } from './multi_select_filter';
import * as i18n from './translations';

interface Props {
countClosedCases: number | null;
countInProgressCases: number | null;
countOpenCases: number | null;
hiddenStatuses?: CaseStatuses[];
onChange: ({
filterId,
selectedOptionKeys,
}: {
filterId: string;
selectedOptionKeys: string[];
}) => void;
onChange: (params: { filterId: string; selectedOptionKeys: string[] }) => void;
selectedOptionKeys: string[];
}

const caseStatuses = Object.keys(statuses) as CaseStatuses[];
const caseStatuses = [
{ key: CaseStatuses.open, label: i18n.STATUS_OPEN },
{ key: CaseStatuses['in-progress'], label: i18n.STATUS_IN_PROGRESS },
{ key: CaseStatuses.closed, label: i18n.STATUS_CLOSED },
];

export const StatusFilterComponent = ({
countClosedCases,
Expand All @@ -49,13 +47,13 @@ export const StatusFilterComponent = ({
);
const options = useMemo(
() =>
mapToMultiSelectOption(
[...caseStatuses].filter((status) => !hiddenStatuses.includes(status))
),
[...caseStatuses].filter((status) => !hiddenStatuses.includes(status.key)) as Array<
MultiSelectFilterOption<string, CaseStatuses>
>,
[hiddenStatuses]
);
const renderOption = (option: MultiSelectFilterOption<CaseStatuses>) => {
const selectedStatus = option.label;
const renderOption = (option: MultiSelectFilterOption<string, CaseStatuses>) => {
const selectedStatus = option.key;
return (
<EuiFlexGroup gutterSize="xs" alignItems={'center'} responsive={false}>
<EuiFlexItem grow={1}>
Expand All @@ -68,7 +66,7 @@ export const StatusFilterComponent = ({
);
};
return (
<MultiSelectFilter<CaseStatuses>
<MultiSelectFilter
buttonLabel={i18n.STATUS}
id={'status'}
onChange={onChange}
Expand Down
Loading

0 comments on commit 219cbbd

Please sign in to comment.