Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Cases] Custom Fields as Cases Filters #171176

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
cf63dac
first commit
jcger Nov 9, 2023
4b5371f
working filter status
jcger Nov 10, 2023
7b86adc
use maps
jcger Nov 13, 2023
ad0cea3
Merge branch 'cases/167651' of github.com:elastic/kibana into issue-1…
jcger Nov 14, 2023
c9c9555
refactor to use option keys
jcger Nov 14, 2023
252e0b1
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Nov 14, 2023
3a48a2f
minor changes
jcger Nov 14, 2023
8b7c774
Merge branch 'issue-167651-custom-fields-as-filters' of github.com:jc…
jcger Nov 14, 2023
ca2c4a4
add persistence
jcger Nov 15, 2023
1f5df78
renaming
jcger Nov 15, 2023
d84f402
first tests
jcger Nov 16, 2023
b5cc9b1
filter tests
jcger Nov 20, 2023
e8bc6b9
set right local storage key for filter config
jcger Nov 21, 2023
3fd75c4
add customFields in FilterOptions
jcger Nov 21, 2023
5f58c3d
add customField filters in query
jcger Nov 21, 2023
1e426f3
Merge branch 'cases/167651' of github.com:elastic/kibana into issue-1…
jcger Nov 21, 2023
3c5c108
Merge branch 'main' of github.com:jcger/kibana into issue-167651-cust…
jcger Nov 21, 2023
36615d0
Merge branch 'cases/167651' into issue-167651-custom-fields-as-filters
jcger Nov 21, 2023
b8df42d
Merge branch 'issue-167651-custom-fields-as-filters' of github.com:jc…
jcger Nov 21, 2023
c98f2bb
custom fields as part of the filter query
jcger Nov 22, 2023
a3ac59b
add prefix to custom field keys
jcger Nov 22, 2023
21d2137
update getCases test + custom fields test
jcger Nov 22, 2023
88a4e04
add custom field filter test
jcger Nov 22, 2023
c915c16
added onDelete prop on filters
jcger Nov 22, 2023
1d74669
deactivate + test
jcger Nov 23, 2023
4f7c987
fix assign deactivate +custom field filter factory
jcger Nov 23, 2023
7f6bc68
remove filter when custom field does not exist anymore
jcger Nov 24, 2023
6181709
remove keys in storage that does not exist + refactor merge fn
jcger Nov 24, 2023
24d6e79
filter out unavailable filters in hook
jcger Nov 27, 2023
177f26c
test dissappearing filter effect + smaller changes
jcger Nov 27, 2023
68d7731
Merge branch 'cases/167651' into issue-167651-custom-fields-as-filters
jcger Nov 27, 2023
c746466
remove not needed comment
jcger Nov 27, 2023
ff764d0
set back available filtering
jcger Nov 27, 2023
4ac9a16
fix more than one field removed but + comments
jcger Nov 28, 2023
4b26020
remove more button from selector view + pr review
jcger Nov 28, 2023
8b1f351
pr review
jcger Nov 29, 2023
6b5f78b
review
jcger Nov 29, 2023
11a8ee2
multi select key type to work with case statuses
jcger Nov 29, 2023
4d439b6
search by label test
jcger Nov 29, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions x-pack/plugins/cases/common/constants/index.ts
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
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;
jcger marked this conversation as resolved.
Show resolved Hide resolved
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
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 }>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems fine to me. Unrelated to the type change, what about wrapper: appMockRender.AppWrapper?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried but it returns another ts error

Type 'FC<{ children: ReactNode; }>' is not assignable to type 'WrapperComponent<{ systemFilterConfig: FilterConfig[]; onFilterOptionChange: FilterChangeHandler; }> | undefined'.

Any idea what it means?

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
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
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
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', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new feature as the "more" button doesn't seem to be showing this info

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();
});
});
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
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';
jcger marked this conversation as resolved.
Show resolved Hide resolved
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
import { SeverityFilter } from './severity_filter';

Expand Down
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
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
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