mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Security Solution] Insight filter builder form as markdown plugin (#150363)
## Summary This pr expands upon the work done in https://github.com/elastic/kibana/pull/145240 to make use of the filters builder form from unified_search to serialize filters into a markdown compatible string, so that investigation guides, timeline notes or any other place where text is parsed as markdown can make use of standard kibana filters and view a count of the matching documents at a glance, and open the entire set in timeline as well. These are generally converted to timeline data providers to enable drag and drop query building, however this is not supported for filters of range type, so regular kibana filters are used in that case for now.        ### Checklist - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
This commit is contained in:
parent
7e9a9bcc99
commit
13d1f398ff
29 changed files with 868 additions and 261 deletions
|
@ -36,6 +36,7 @@ const createStartContract = (): Start => {
|
|||
IndexPatternSelect: jest.fn(),
|
||||
SearchBar: jest.fn().mockReturnValue(null),
|
||||
AggregateQuerySearchBar: jest.fn().mockReturnValue(null),
|
||||
FiltersBuilderLazy: jest.fn(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -24,6 +24,7 @@ import type {
|
|||
import { createFilterAction } from './actions/apply_filter_action';
|
||||
import { createUpdateFilterReferencesAction } from './actions/update_filter_references_action';
|
||||
import { ACTION_GLOBAL_APPLY_FILTER, UPDATE_FILTER_REFERENCES_ACTION } from './actions';
|
||||
import { FiltersBuilderLazy } from './filters_builder';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
|
@ -92,6 +93,7 @@ export class UnifiedSearchPublicPlugin
|
|||
IndexPatternSelect: createIndexPatternSelect(dataViews),
|
||||
SearchBar,
|
||||
AggregateQuerySearchBar: SearchBar,
|
||||
FiltersBuilderLazy,
|
||||
},
|
||||
autocomplete: autocompleteStart,
|
||||
};
|
||||
|
|
|
@ -18,6 +18,7 @@ import { CoreStart, DocLinksStart } from '@kbn/core/public';
|
|||
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
|
||||
import { AutocompleteSetup, AutocompleteStart } from './autocomplete';
|
||||
import type { IndexPatternSelectProps, StatefulSearchBarProps } from '.';
|
||||
import type { FiltersBuilderProps } from './filters_builder/filters_builder';
|
||||
|
||||
export interface UnifiedSearchSetupDependencies {
|
||||
uiActions: UiActionsSetup;
|
||||
|
@ -46,6 +47,7 @@ export interface UnifiedSearchPublicPluginStartUi {
|
|||
AggregateQuerySearchBar: <QT extends Query | AggregateQuery = Query>(
|
||||
props: StatefulSearchBarProps<QT>
|
||||
) => React.ReactElement;
|
||||
FiltersBuilderLazy: React.ComponentType<FiltersBuilderProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -93,7 +93,7 @@ describe('Timeline notes tab', () => {
|
|||
|
||||
it('should render insight query from markdown', () => {
|
||||
addNotesToTimeline(
|
||||
`!{insight{"description":"2 top level OR providers, 1 nested AND","label":"test insight", "providers": [[{ "field": "event.id", "value": "kibana.alert.original_event.id", "type": "parameter" }], [{ "field": "event.category", "value": "network", "type": "literal" }, {"field": "process.pid", "value": "process.pid", "type": "parameter"}]]}}`
|
||||
`!{insight{"description":"2 top level OR providers, 1 nested AND","label":"test insight", "providers": [[{ "field": "event.id", "value": "kibana.alert.original_event.id", "queryType": "phrase", "excluded": "false" }], [{ "field": "event.category", "value": "network", "queryType": "phrase", "excluded": "false" }, {"field": "process.pid", "value": "process.pid", "queryType": "phrase", "excluded": "false"}]]}}`
|
||||
);
|
||||
cy.get(MARKDOWN_INVESTIGATE_BUTTON).should('exist');
|
||||
});
|
||||
|
|
|
@ -83,7 +83,7 @@ export const useHoverActions = ({
|
|||
const values = useMemo(() => {
|
||||
const val = dataProvider.queryMatch.value;
|
||||
|
||||
if (typeof val === 'number') {
|
||||
if (typeof val === 'number' || typeof val === 'boolean') {
|
||||
return val.toString();
|
||||
}
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ export const { uiPlugins, parsingPlugins, processingPlugins } = {
|
|||
|
||||
uiPlugins.push(timelineMarkdownPlugin.plugin);
|
||||
uiPlugins.push(osqueryMarkdownPlugin.plugin);
|
||||
uiPlugins.push(insightMarkdownPlugin.plugin);
|
||||
|
||||
parsingPlugins.push(insightMarkdownPlugin.parser);
|
||||
parsingPlugins.push(timelineMarkdownPlugin.parser);
|
||||
|
|
|
@ -69,7 +69,7 @@ describe('insight component renderer', () => {
|
|||
label={'test label'}
|
||||
description={'test description'}
|
||||
providers={
|
||||
'[[{"field":"event.id","value":"kibana.alert.original_event.id","type":"parameter"}],[{"field":"event.category","value":"network","type":"literal"},{"field":"process.pid","value":"process.pid","type":"parameter"}]]'
|
||||
'[[{"field":"event.id","value":"{{kibana.alert.original_event.id}}","queryType":"phrase", "excluded": "false"}],[{"field":"event.category","value":"network","queryType":"phrase", "excluded": "false"}},{"field":"process.pid","value":"process.pid","queryType":"phrase", "excluded": "false", "valueType":"number"}}]]'
|
||||
}
|
||||
/>
|
||||
</TestProviders>
|
||||
|
|
|
@ -5,23 +5,71 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { pickBy, isEmpty } from 'lodash';
|
||||
import type { Plugin } from 'unified';
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import React, { useContext, useMemo, useCallback, useState } from 'react';
|
||||
import type { RemarkTokenizer } from '@elastic/eui';
|
||||
import { EuiLoadingSpinner, EuiIcon } from '@elastic/eui';
|
||||
import {
|
||||
EuiLoadingSpinner,
|
||||
EuiIcon,
|
||||
EuiSpacer,
|
||||
EuiBetaBadge,
|
||||
EuiCodeBlock,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiFieldText,
|
||||
EuiSelect,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import numeral from '@elastic/numeral';
|
||||
import { css } from '@emotion/react';
|
||||
import type { EuiMarkdownEditorUiPluginEditorProps } from '@elastic/eui/src/components/markdown_editor/markdown_types';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import {
|
||||
FILTERS,
|
||||
isCombinedFilter,
|
||||
isRangeFilter,
|
||||
isPhraseFilter,
|
||||
isPhrasesFilter,
|
||||
isExistsFilter,
|
||||
BooleanRelation,
|
||||
FilterStateStore,
|
||||
} from '@kbn/es-query';
|
||||
import type { PhraseFilterValue } from '@kbn/es-query/src/filters/build_filters';
|
||||
import { useForm, FormProvider, useController } from 'react-hook-form';
|
||||
import { useAppToasts } from '../../../../hooks/use_app_toasts';
|
||||
import { useKibana } from '../../../../lib/kibana';
|
||||
import { useInsightQuery } from './use_insight_query';
|
||||
import { useInsightDataProviders } from './use_insight_data_providers';
|
||||
import { useInsightDataProviders, type Provider } from './use_insight_data_providers';
|
||||
import { BasicAlertDataContext } from '../../../event_details/investigation_guide_view';
|
||||
import { InvestigateInTimelineButton } from '../../../event_details/table/investigate_in_timeline_button';
|
||||
import { getTimeRangeSettings } from '../../../../utils/default_date_settings';
|
||||
import {
|
||||
getTimeRangeSettings,
|
||||
parseDateWithDefault,
|
||||
DEFAULT_FROM_MOMENT,
|
||||
DEFAULT_TO_MOMENT,
|
||||
} from '../../../../utils/default_date_settings';
|
||||
import type { TimeRange } from '../../../../store/inputs/model';
|
||||
import { DEFAULT_TIMEPICKER_QUICK_RANGES } from '../../../../../../common/constants';
|
||||
import { useSourcererDataView } from '../../../../containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../../store/sourcerer/model';
|
||||
|
||||
interface InsightComponentProps {
|
||||
label?: string;
|
||||
description?: string;
|
||||
providers?: string;
|
||||
relativeFrom?: string;
|
||||
relativeTo?: string;
|
||||
}
|
||||
|
||||
export const parser: Plugin = function () {
|
||||
|
@ -95,8 +143,88 @@ export const parser: Plugin = function () {
|
|||
methods.splice(methods.indexOf('text'), 0, 'insight');
|
||||
};
|
||||
|
||||
const buildPrimitiveProvider = (filter: Filter): Provider => {
|
||||
const field = filter.meta?.key ?? '';
|
||||
const excluded = filter.meta?.negate ?? false;
|
||||
const queryType = filter.meta?.type ?? FILTERS.PHRASE;
|
||||
const baseFilter = {
|
||||
field,
|
||||
excluded,
|
||||
queryType,
|
||||
};
|
||||
if (isRangeFilter(filter)) {
|
||||
const gte = filter.query.range[field].gte;
|
||||
const lt = filter.query.range[field].lt;
|
||||
const value = JSON.stringify({ gte, lt });
|
||||
return {
|
||||
...baseFilter,
|
||||
value,
|
||||
queryType: filter.meta.type ?? FILTERS.RANGE,
|
||||
};
|
||||
} else if (isPhrasesFilter(filter)) {
|
||||
const typeOfParams: PhraseFilterValue = typeof filter.meta?.params[0];
|
||||
return {
|
||||
...baseFilter,
|
||||
value: JSON.stringify(filter.meta?.params ?? []),
|
||||
valueType: typeOfParams,
|
||||
queryType: filter.meta.type ?? FILTERS.PHRASES,
|
||||
};
|
||||
} else if (isExistsFilter(filter)) {
|
||||
return {
|
||||
...baseFilter,
|
||||
value: '',
|
||||
queryType: filter.meta.type ?? FILTERS.EXISTS,
|
||||
};
|
||||
} else if (isPhraseFilter(filter)) {
|
||||
const valueType: PhraseFilterValue = typeof filter.meta?.params?.query;
|
||||
return {
|
||||
...baseFilter,
|
||||
value: filter.meta?.params?.query ?? '',
|
||||
valueType,
|
||||
queryType: filter.meta.type ?? FILTERS.PHRASE,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...baseFilter,
|
||||
value: '',
|
||||
queryType: FILTERS.PHRASE,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const filtersToInsightProviders = (filters: Filter[]): Provider[][] => {
|
||||
const providers = [];
|
||||
for (let index = 0; index < filters.length; index++) {
|
||||
const filter = filters[index];
|
||||
if (isCombinedFilter(filter)) {
|
||||
if (filter.meta.relation === BooleanRelation.AND) {
|
||||
return filtersToInsightProviders(filter.meta?.params);
|
||||
} else {
|
||||
return filter.meta?.params.map((innerFilter) => {
|
||||
if (isCombinedFilter(innerFilter)) {
|
||||
return filtersToInsightProviders([innerFilter]).map(([provider]) => provider);
|
||||
} else {
|
||||
return [buildPrimitiveProvider(innerFilter)];
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
providers.push([buildPrimitiveProvider(filter)]);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
};
|
||||
|
||||
const resultFormat = '0,0.[000]a';
|
||||
|
||||
// receives the configuration from the parser and renders
|
||||
const InsightComponent = ({ label, description, providers }: InsightComponentProps) => {
|
||||
const InsightComponent = ({
|
||||
label,
|
||||
description,
|
||||
providers,
|
||||
relativeFrom,
|
||||
relativeTo,
|
||||
}: InsightComponentProps) => {
|
||||
const { addError } = useAppToasts();
|
||||
let parsedProviders = [];
|
||||
try {
|
||||
|
@ -111,15 +239,28 @@ const InsightComponent = ({ label, description, providers }: InsightComponentPro
|
|||
});
|
||||
}
|
||||
const { data: alertData } = useContext(BasicAlertDataContext);
|
||||
const dataProviders = useInsightDataProviders({
|
||||
const { dataProviders, filters } = useInsightDataProviders({
|
||||
providers: parsedProviders,
|
||||
alertData,
|
||||
});
|
||||
const { totalCount, isQueryLoading, oldestTimestamp, hasError } = useInsightQuery({
|
||||
dataProviders,
|
||||
filters,
|
||||
});
|
||||
const timerange: TimeRange = useMemo(() => {
|
||||
if (oldestTimestamp != null) {
|
||||
if (relativeFrom && relativeTo) {
|
||||
const fromStr = relativeFrom;
|
||||
const toStr = relativeTo;
|
||||
const from = parseDateWithDefault(fromStr, DEFAULT_FROM_MOMENT).toISOString();
|
||||
const to = parseDateWithDefault(toStr, DEFAULT_TO_MOMENT, true).toISOString();
|
||||
return {
|
||||
kind: 'relative',
|
||||
from,
|
||||
to,
|
||||
fromStr,
|
||||
toStr,
|
||||
};
|
||||
} else if (oldestTimestamp != null) {
|
||||
return {
|
||||
kind: 'absolute',
|
||||
from: oldestTimestamp,
|
||||
|
@ -135,24 +276,300 @@ const InsightComponent = ({ label, description, providers }: InsightComponentPro
|
|||
toStr,
|
||||
};
|
||||
}
|
||||
}, [oldestTimestamp]);
|
||||
}, [oldestTimestamp, relativeFrom, relativeTo]);
|
||||
if (isQueryLoading) {
|
||||
return <EuiLoadingSpinner size="l" />;
|
||||
} else {
|
||||
return (
|
||||
<InvestigateInTimelineButton
|
||||
asEmptyButton={false}
|
||||
isDisabled={hasError}
|
||||
dataProviders={dataProviders}
|
||||
timeRange={timerange}
|
||||
keepDataView={true}
|
||||
data-test-subj="insight-investigate-in-timeline-button"
|
||||
>
|
||||
<EuiIcon type="timeline" />
|
||||
{` ${label} (${totalCount}) - ${description}`}
|
||||
</InvestigateInTimelineButton>
|
||||
<>
|
||||
<InvestigateInTimelineButton
|
||||
asEmptyButton={false}
|
||||
isDisabled={hasError}
|
||||
dataProviders={dataProviders}
|
||||
filters={filters}
|
||||
timeRange={timerange}
|
||||
keepDataView={true}
|
||||
data-test-subj="insight-investigate-in-timeline-button"
|
||||
>
|
||||
<EuiIcon type="timeline" />
|
||||
{` ${label} (${numeral(totalCount).format(resultFormat)})`}
|
||||
</InvestigateInTimelineButton>
|
||||
<div>{description}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export { InsightComponent as renderer };
|
||||
|
||||
const InsightEditorComponent = ({
|
||||
node,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: EuiMarkdownEditorUiPluginEditorProps<InsightComponentProps & { relativeTimerange: string }>) => {
|
||||
const isEditMode = node != null;
|
||||
const { sourcererDataView, indexPattern } = useSourcererDataView(SourcererScopeName.default);
|
||||
const {
|
||||
unifiedSearch: {
|
||||
ui: { FiltersBuilderLazy },
|
||||
},
|
||||
uiSettings,
|
||||
} = useKibana().services;
|
||||
const [providers, setProviders] = useState<Provider[][]>([[]]);
|
||||
const dateRangeChoices = useMemo(() => {
|
||||
const settings: Array<{ from: string; to: string; display: string }> = uiSettings.get(
|
||||
DEFAULT_TIMEPICKER_QUICK_RANGES
|
||||
);
|
||||
const emptyValue = { value: '0', text: '' };
|
||||
return [
|
||||
emptyValue,
|
||||
...settings.map(({ display }, index) => {
|
||||
return {
|
||||
value: String(index),
|
||||
text: display,
|
||||
};
|
||||
}),
|
||||
];
|
||||
}, [uiSettings]);
|
||||
const kibanaDataProvider = useMemo(() => {
|
||||
return {
|
||||
...sourcererDataView,
|
||||
fields: sourcererDataView?.indexFields,
|
||||
} as DataView;
|
||||
}, [sourcererDataView]);
|
||||
const formMethods = useForm<{
|
||||
label: string;
|
||||
description: string;
|
||||
relativeTimerange?: string;
|
||||
}>({
|
||||
defaultValues: {
|
||||
label: node?.label,
|
||||
description: node?.description,
|
||||
relativeTimerange: node?.relativeTimerange || '0',
|
||||
},
|
||||
shouldUnregister: true,
|
||||
});
|
||||
|
||||
const labelController = useController({ name: 'label', control: formMethods.control });
|
||||
const descriptionController = useController({
|
||||
name: 'description',
|
||||
control: formMethods.control,
|
||||
});
|
||||
const relativeTimerangeController = useController({
|
||||
name: 'relativeTimerange',
|
||||
control: formMethods.control,
|
||||
});
|
||||
|
||||
const getTimeRangeSelection = useCallback(
|
||||
(selection?: string) => {
|
||||
const selectedOption = dateRangeChoices.find((option) => {
|
||||
return option.value === selection;
|
||||
});
|
||||
if (selectedOption && selectedOption.value !== '0') {
|
||||
const settingsIndex = Number(selectedOption.value);
|
||||
const settings: Array<{ from: string; to: string; display: string }> = uiSettings.get(
|
||||
DEFAULT_TIMEPICKER_QUICK_RANGES
|
||||
);
|
||||
return {
|
||||
relativeFrom: settings[settingsIndex].from,
|
||||
relativeTo: settings[settingsIndex].to,
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
[dateRangeChoices, uiSettings]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
onSave(
|
||||
`!{insight${JSON.stringify(
|
||||
pickBy(
|
||||
{
|
||||
label: labelController.field.value,
|
||||
description: descriptionController.field.value,
|
||||
providers,
|
||||
...getTimeRangeSelection(relativeTimerangeController.field.value),
|
||||
},
|
||||
(value) => !isEmpty(value)
|
||||
)
|
||||
)}}`,
|
||||
{
|
||||
block: true,
|
||||
}
|
||||
);
|
||||
}, [
|
||||
onSave,
|
||||
providers,
|
||||
labelController.field.value,
|
||||
descriptionController.field.value,
|
||||
relativeTimerangeController.field.value,
|
||||
getTimeRangeSelection,
|
||||
]);
|
||||
|
||||
const onChange = useCallback((filters: Filter[]) => {
|
||||
setProviders(filtersToInsightProviders(filters));
|
||||
}, []);
|
||||
const selectOnChange = useCallback(
|
||||
(event) => {
|
||||
relativeTimerangeController.field.onChange(event.target.value);
|
||||
},
|
||||
[relativeTimerangeController.field]
|
||||
);
|
||||
const filtersStub = useMemo(() => {
|
||||
const index = indexPattern && indexPattern.getName ? indexPattern.getName() : '*';
|
||||
return [
|
||||
{
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index,
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [indexPattern]);
|
||||
return (
|
||||
<>
|
||||
<EuiModalHeader
|
||||
css={css`
|
||||
min-width: 700px;
|
||||
`}
|
||||
>
|
||||
<EuiModalHeaderTitle>
|
||||
<EuiFlexGroup gutterSize={'s'}>
|
||||
<EuiFlexItem>
|
||||
{isEditMode ? (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.markdown.insight.editModalTitle"
|
||||
defaultMessage="Edit investigation query"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.markdown.insight.addModalTitle"
|
||||
defaultMessage="Add investigation query"
|
||||
/>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBetaBadge
|
||||
color={'hollow'}
|
||||
label={i18n.translate('xpack.securitySolution.markdown.insight.technicalPreview', {
|
||||
defaultMessage: 'Technical Preview',
|
||||
})}
|
||||
size="s"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
<EuiModalBody>
|
||||
<FormProvider {...formMethods}>
|
||||
<EuiForm fullWidth>
|
||||
<EuiFormRow
|
||||
label="Label"
|
||||
helpText="Label for the filter button rendered in the guide"
|
||||
fullWidth
|
||||
>
|
||||
<EuiFieldText
|
||||
{...{
|
||||
...formMethods.register('label'),
|
||||
ref: null,
|
||||
}}
|
||||
name="label"
|
||||
onChange={labelController.field.onChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label="Description"
|
||||
helpText="Description of the relevance of the query"
|
||||
fullWidth
|
||||
>
|
||||
<EuiFieldText
|
||||
{...{ ...formMethods.register('description'), ref: null }}
|
||||
name="description"
|
||||
onChange={descriptionController.field.onChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow fullWidth>
|
||||
<FiltersBuilderLazy
|
||||
filters={filtersStub}
|
||||
onChange={onChange}
|
||||
dataView={kibanaDataProvider}
|
||||
maxDepth={2}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label="Relative time range"
|
||||
helpText="Select a time range relative to the time of the alert (optional)"
|
||||
fullWidth
|
||||
>
|
||||
<EuiSelect
|
||||
{...{ ...formMethods.register('relativeTimerange'), ref: null }}
|
||||
onChange={selectOnChange}
|
||||
options={dateRangeChoices}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
</FormProvider>
|
||||
</EuiModalBody>
|
||||
|
||||
<EuiModalFooter>
|
||||
<EuiButtonEmpty onClick={onCancel}>
|
||||
{i18n.translate('xpack.securitySolution.markdown.insight.modalCancelButtonLabel', {
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
<EuiButton onClick={formMethods.handleSubmit(onSubmit)} fill>
|
||||
{isEditMode ? (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.markdown.insight.addModalConfirmButtonLabel"
|
||||
defaultMessage="Add query"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.markdown.insight.editModalConfirmButtonLabel"
|
||||
defaultMessage="Save changes"
|
||||
/>
|
||||
)}
|
||||
</EuiButton>
|
||||
</EuiModalFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InsightEditor = React.memo(InsightEditorComponent);
|
||||
const exampleInsight = `!{insight{
|
||||
"label": "Test action",
|
||||
"description": "Click to investigate",
|
||||
"providers": [
|
||||
[
|
||||
{"field": "event.id", "value": "{{kibana.alert.original_event.id}}", "queryType": "phrase", "excluded": "false"}
|
||||
],
|
||||
[
|
||||
{"field": "event.action", "value": "", "queryType": "exists", "excluded": "false"},
|
||||
{"field": "process.pid", "value": "{{process.pid}}", "queryType": "phrase", "excluded":"false"}
|
||||
]
|
||||
]
|
||||
}}`;
|
||||
|
||||
export const plugin = {
|
||||
name: 'insights',
|
||||
button: {
|
||||
label: 'Insights',
|
||||
iconType: 'aggregate',
|
||||
},
|
||||
helpText: (
|
||||
<div>
|
||||
<EuiCodeBlock language="md" fontSize="l" paddingSize="s" isCopyable>
|
||||
{exampleInsight}
|
||||
</EuiCodeBlock>
|
||||
<EuiSpacer size="s" />
|
||||
</div>
|
||||
),
|
||||
editor: InsightEditor,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { each } from 'lodash';
|
||||
import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy';
|
||||
|
||||
export const replaceParamsQuery = (
|
||||
query: string | number | boolean,
|
||||
data?: TimelineEventsDetailsItem[] | null
|
||||
) => {
|
||||
if (typeof query === 'number' || typeof query === 'boolean') {
|
||||
return {
|
||||
result: query,
|
||||
skipped: true,
|
||||
matchedBrackets: null,
|
||||
};
|
||||
}
|
||||
const regex = /\{{([^}]+)\}}/g;
|
||||
const matchedBrackets = query.match(regex);
|
||||
let resultQuery = query;
|
||||
|
||||
if (matchedBrackets && data) {
|
||||
each(matchedBrackets, (bracesText: string) => {
|
||||
const field = bracesText.replace(/{{|}}/g, '').trim();
|
||||
if (resultQuery.includes(bracesText)) {
|
||||
const foundField = data.find(({ field: alertField }) => alertField === field);
|
||||
if (foundField && foundField.values) {
|
||||
const {
|
||||
values: [foundFieldValue],
|
||||
} = foundField;
|
||||
resultQuery = resultQuery.replace(bracesText, foundFieldValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const skipped = regex.test(resultQuery);
|
||||
|
||||
return {
|
||||
result: resultQuery,
|
||||
skipped,
|
||||
matchedBrackets,
|
||||
};
|
||||
};
|
|
@ -5,10 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import type { DataProvider } from '@kbn/timelines-plugin/common';
|
||||
import type { UseInsightDataProvidersProps, Provider } from './use_insight_data_providers';
|
||||
import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy';
|
||||
import { useInsightDataProviders } from './use_insight_data_providers';
|
||||
import {
|
||||
useInsightDataProviders,
|
||||
type UseInsightDataProvidersResult,
|
||||
} from './use_insight_data_providers';
|
||||
import { mockAlertDetailsData } from '../../../event_details/__mocks__';
|
||||
|
||||
const mockAlertDetailsDataWithIsObject = mockAlertDetailsData.map((detail) => {
|
||||
|
@ -18,115 +20,152 @@ const mockAlertDetailsDataWithIsObject = mockAlertDetailsData.map((detail) => {
|
|||
};
|
||||
}) as TimelineEventsDetailsItem[];
|
||||
|
||||
const nestedAndProvider = [
|
||||
const nestedAndProvider: Provider[][] = [
|
||||
[
|
||||
{
|
||||
field: 'event.id',
|
||||
value: 'kibana.alert.rule.uuid',
|
||||
type: 'parameter',
|
||||
value: '{{kibana.alert.rule.uuid}}',
|
||||
queryType: 'phrase',
|
||||
excluded: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
field: 'event.category',
|
||||
value: 'network',
|
||||
type: 'literal',
|
||||
queryType: 'phrase',
|
||||
excluded: false,
|
||||
},
|
||||
{
|
||||
field: 'process.pid',
|
||||
value: 'process.pid',
|
||||
type: 'parameter',
|
||||
value: '{{process.pid}}',
|
||||
queryType: 'phrase',
|
||||
excluded: false,
|
||||
},
|
||||
],
|
||||
] as Provider[][];
|
||||
];
|
||||
|
||||
const topLevelOnly = [
|
||||
[
|
||||
{
|
||||
field: 'event.id',
|
||||
value: 'kibana.alert.rule.uuid',
|
||||
type: 'parameter',
|
||||
value: '{{kibana.alert.rule.uuid}}',
|
||||
queryType: 'phrase',
|
||||
excluded: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
field: 'event.category',
|
||||
value: 'network',
|
||||
type: 'literal',
|
||||
queryType: 'phrase',
|
||||
excluded: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
field: 'process.pid',
|
||||
value: 'process.pid',
|
||||
type: 'parameter',
|
||||
value: 1000,
|
||||
valueType: 'number',
|
||||
queryType: 'phrase',
|
||||
excluded: false,
|
||||
},
|
||||
],
|
||||
] as Provider[][];
|
||||
];
|
||||
|
||||
const nonExistantField = [
|
||||
const nonExistantField: Provider[][] = [
|
||||
[
|
||||
{
|
||||
field: 'event.id',
|
||||
value: 'kibana.alert.rule.parameters.threshold.field',
|
||||
type: 'parameter',
|
||||
value: '{{kibana.alert.rule.parameters.threshold.field}}',
|
||||
excluded: false,
|
||||
queryType: 'phrase',
|
||||
},
|
||||
],
|
||||
] as Provider[][];
|
||||
];
|
||||
|
||||
const providerWithRange: Provider[][] = [
|
||||
[
|
||||
{
|
||||
field: 'event.id',
|
||||
value: '',
|
||||
excluded: false,
|
||||
queryType: 'exists',
|
||||
},
|
||||
{
|
||||
field: 'event.id',
|
||||
value: '{"gte":0,"lt":100}',
|
||||
excluded: false,
|
||||
queryType: 'range',
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
describe('useInsightDataProviders', () => {
|
||||
it('should return 2 data providers, 1 with a nested provider ANDed to it', () => {
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, UseInsightDataProvidersResult>(() =>
|
||||
useInsightDataProviders({
|
||||
providers: nestedAndProvider,
|
||||
alertData: mockAlertDetailsDataWithIsObject,
|
||||
})
|
||||
);
|
||||
const providers = result.current;
|
||||
const { dataProviders: providers, filters } = result.current;
|
||||
const providersWithNonEmptyAnd = providers.filter((provider) => provider.and.length > 0);
|
||||
expect(providers.length).toBe(2);
|
||||
expect(providersWithNonEmptyAnd.length).toBe(1);
|
||||
expect(filters.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 3 data providers without any containing nested ANDs', () => {
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, UseInsightDataProvidersResult>(() =>
|
||||
useInsightDataProviders({
|
||||
providers: topLevelOnly,
|
||||
alertData: mockAlertDetailsDataWithIsObject,
|
||||
})
|
||||
);
|
||||
const providers = result.current;
|
||||
const { dataProviders: providers } = result.current;
|
||||
const providersWithNonEmptyAnd = providers.filter((provider) => provider.and.length > 0);
|
||||
expect(providers.length).toBe(3);
|
||||
expect(providersWithNonEmptyAnd.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should use a wildcard for a field not present in an alert', () => {
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
|
||||
it('should use the string literal if no field in the alert matches a bracketed value', () => {
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, UseInsightDataProvidersResult>(() =>
|
||||
useInsightDataProviders({
|
||||
providers: nonExistantField,
|
||||
alertData: mockAlertDetailsDataWithIsObject,
|
||||
})
|
||||
);
|
||||
const providers = result.current;
|
||||
const { dataProviders: providers } = result.current;
|
||||
const {
|
||||
queryMatch: { value },
|
||||
} = providers[0];
|
||||
expect(providers.length).toBe(1);
|
||||
expect(value).toBe('*');
|
||||
expect(value).toBe('{{kibana.alert.rule.parameters.threshold.field}}');
|
||||
});
|
||||
|
||||
it('should use template data providers when called without alertData', () => {
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, UseInsightDataProvidersResult>(() =>
|
||||
useInsightDataProviders({
|
||||
providers: nestedAndProvider,
|
||||
})
|
||||
);
|
||||
const providers = result.current;
|
||||
const { dataProviders: providers } = result.current;
|
||||
const [first, second] = providers;
|
||||
const [nestedSecond] = second.and;
|
||||
expect(second.type).toBe('default');
|
||||
expect(first.type).toBe('template');
|
||||
expect(nestedSecond.type).toBe('template');
|
||||
});
|
||||
|
||||
it('should return an empty array of dataProviders and populated filters if a provider contains a range type', () => {
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, UseInsightDataProvidersResult>(() =>
|
||||
useInsightDataProviders({
|
||||
providers: providerWithRange,
|
||||
})
|
||||
);
|
||||
const { dataProviders: providers, filters } = result.current;
|
||||
expect(providers.length).toBe(0);
|
||||
expect(filters.length > providers.length);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,109 +6,241 @@
|
|||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { FILTERS, BooleanRelation, FilterStateStore } from '@kbn/es-query';
|
||||
import type { QueryOperator, DataProvider } from '@kbn/timelines-plugin/common';
|
||||
import { DataProviderType } from '@kbn/timelines-plugin/common';
|
||||
import { IS_OPERATOR } from '../../../../../timelines/components/timeline/data_providers/data_provider';
|
||||
import { replaceParamsQuery } from './replace_params_query';
|
||||
import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy';
|
||||
import {
|
||||
EXISTS_OPERATOR,
|
||||
IS_OPERATOR,
|
||||
IS_ONE_OF_OPERATOR,
|
||||
} from '../../../../../timelines/components/timeline/data_providers/data_provider';
|
||||
|
||||
export interface Provider {
|
||||
field: string;
|
||||
value: string;
|
||||
type: 'parameter' | 'value';
|
||||
excluded: boolean;
|
||||
queryType: string;
|
||||
value: string | number | boolean;
|
||||
valueType?: string;
|
||||
}
|
||||
export interface UseInsightDataProvidersProps {
|
||||
providers: Provider[][];
|
||||
alertData?: TimelineEventsDetailsItem[] | null;
|
||||
}
|
||||
|
||||
export interface UseInsightDataProvidersResult {
|
||||
dataProviders: DataProvider[];
|
||||
filters: Filter[];
|
||||
}
|
||||
|
||||
const dataProviderQueryType = (type: string): QueryOperator => {
|
||||
if (type === FILTERS.EXISTS) {
|
||||
return EXISTS_OPERATOR;
|
||||
} else if (type === FILTERS.PHRASES) {
|
||||
return IS_ONE_OF_OPERATOR;
|
||||
} else {
|
||||
return IS_OPERATOR;
|
||||
}
|
||||
};
|
||||
|
||||
const buildDataProviders = (
|
||||
providers: Provider[][],
|
||||
alertData?: TimelineEventsDetailsItem[] | null
|
||||
): DataProvider[] => {
|
||||
return providers.map((innerProvider) => {
|
||||
return innerProvider.reduce((prev, next, index): DataProvider => {
|
||||
const { field, value, excluded, queryType } = next;
|
||||
const { result, matchedBrackets } = replaceParamsQuery(value, alertData);
|
||||
const isTemplate = !alertData && matchedBrackets;
|
||||
if (index === 0) {
|
||||
return {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: JSON.stringify(field + value),
|
||||
name: field,
|
||||
excluded,
|
||||
kqlQuery: '',
|
||||
type: isTemplate ? DataProviderType.template : DataProviderType.default,
|
||||
queryMatch: {
|
||||
field,
|
||||
value: result,
|
||||
operator: dataProviderQueryType(queryType),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const newProvider = {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: JSON.stringify(field + value),
|
||||
name: field,
|
||||
excluded,
|
||||
kqlQuery: '',
|
||||
type: isTemplate ? DataProviderType.template : DataProviderType.default,
|
||||
queryMatch: {
|
||||
field,
|
||||
value: result,
|
||||
operator: dataProviderQueryType(queryType),
|
||||
},
|
||||
};
|
||||
prev.and.push(newProvider);
|
||||
}
|
||||
return prev;
|
||||
}, {} as DataProvider);
|
||||
});
|
||||
};
|
||||
const filterStub = {
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
meta: {
|
||||
disabled: false,
|
||||
negate: false,
|
||||
alias: null,
|
||||
index: undefined,
|
||||
},
|
||||
};
|
||||
const buildPrimitiveFilter = (provider: Provider): Filter => {
|
||||
const baseFilter = {
|
||||
...filterStub,
|
||||
meta: {
|
||||
...filterStub.meta,
|
||||
negate: provider.excluded,
|
||||
type: provider.queryType,
|
||||
},
|
||||
};
|
||||
if (provider.queryType === FILTERS.EXISTS) {
|
||||
return {
|
||||
...baseFilter,
|
||||
meta: {
|
||||
...baseFilter.meta,
|
||||
params: undefined,
|
||||
value: 'exists',
|
||||
},
|
||||
query: { exists: { field: provider.field } },
|
||||
};
|
||||
} else if (provider.queryType === FILTERS.PHRASES) {
|
||||
let values = JSON.parse(String(provider.value));
|
||||
if (provider.valueType === 'number') {
|
||||
values = values.map(Number);
|
||||
} else if (provider.valueType === 'boolean') {
|
||||
values = values.map(Boolean);
|
||||
}
|
||||
return {
|
||||
...baseFilter,
|
||||
meta: {
|
||||
...baseFilter.meta,
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
minimum_should_match: 1,
|
||||
should: values?.map((param: string | number | boolean) => ({
|
||||
match_phrase: { [provider.field]: param },
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (provider.queryType === FILTERS.PHRASE) {
|
||||
return {
|
||||
...baseFilter,
|
||||
meta: {
|
||||
...baseFilter.meta,
|
||||
params: { query: provider.value },
|
||||
value: undefined,
|
||||
},
|
||||
query: { match_phrase: { [provider.field]: provider.value ?? '' } },
|
||||
};
|
||||
} else if (provider.queryType === FILTERS.RANGE) {
|
||||
let gte;
|
||||
let lt;
|
||||
try {
|
||||
const input = JSON.parse(String(provider.value));
|
||||
gte = input.gte;
|
||||
lt = input.lt;
|
||||
} catch {
|
||||
gte = '';
|
||||
lt = '';
|
||||
}
|
||||
const params = {
|
||||
gte,
|
||||
lt,
|
||||
};
|
||||
return {
|
||||
...baseFilter,
|
||||
meta: {
|
||||
...baseFilter.meta,
|
||||
params,
|
||||
},
|
||||
query: {
|
||||
range: {
|
||||
[provider.field]: params,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return baseFilter;
|
||||
}
|
||||
};
|
||||
|
||||
const buildFiltersFromInsightProviders = (
|
||||
providers: Provider[][],
|
||||
alertData?: TimelineEventsDetailsItem[] | null
|
||||
): Filter[] => {
|
||||
const filters: Filter[] = [];
|
||||
for (let index = 0; index < providers.length; index++) {
|
||||
const provider = providers[index];
|
||||
if (provider.length > 1) {
|
||||
// Only support 1 level of nesting currently
|
||||
const innerProviders = provider.map((innerProvider) => {
|
||||
return buildPrimitiveFilter(innerProvider);
|
||||
});
|
||||
const combinedFilter = {
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
meta: {
|
||||
type: FILTERS.COMBINED,
|
||||
relation: BooleanRelation.AND,
|
||||
params: innerProviders,
|
||||
index: undefined,
|
||||
disabled: false,
|
||||
negate: false,
|
||||
},
|
||||
};
|
||||
filters.push(combinedFilter);
|
||||
} else {
|
||||
const baseProvider = provider[0];
|
||||
|
||||
const baseFilter = buildPrimitiveFilter(baseProvider);
|
||||
filters.push(baseFilter);
|
||||
}
|
||||
}
|
||||
return filters;
|
||||
};
|
||||
|
||||
export const useInsightDataProviders = ({
|
||||
providers,
|
||||
alertData,
|
||||
}: UseInsightDataProvidersProps): DataProvider[] => {
|
||||
function getFieldValue(fields: TimelineEventsDetailsItem[], fieldToFind: string) {
|
||||
const alertField = fields.find((dataField) => dataField.field === fieldToFind);
|
||||
return alertField?.values ? alertField.values[0] : '*';
|
||||
}
|
||||
}: UseInsightDataProvidersProps): UseInsightDataProvidersResult => {
|
||||
const providersContainRangeQuery = useMemo(() => {
|
||||
return providers.some((innerProvider) => {
|
||||
return innerProvider.some((provider) => provider.queryType === 'range');
|
||||
});
|
||||
}, [providers]);
|
||||
const dataProviders: DataProvider[] = useMemo(() => {
|
||||
if (alertData) {
|
||||
return providers.map((innerProvider) => {
|
||||
return innerProvider.reduce((prev, next, index): DataProvider => {
|
||||
const { field, value, type } = next;
|
||||
if (index === 0) {
|
||||
return {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: JSON.stringify(field + value + type),
|
||||
name: field,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
type: DataProviderType.default,
|
||||
queryMatch: {
|
||||
field,
|
||||
value: type === 'parameter' ? getFieldValue(alertData, value) : value,
|
||||
operator: IS_OPERATOR as QueryOperator,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const newProvider = {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: JSON.stringify(field + value + type),
|
||||
name: field,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
type: DataProviderType.default,
|
||||
queryMatch: {
|
||||
field,
|
||||
value: type === 'parameter' ? getFieldValue(alertData, value) : value,
|
||||
operator: IS_OPERATOR as QueryOperator,
|
||||
},
|
||||
};
|
||||
prev.and.push(newProvider);
|
||||
}
|
||||
return prev;
|
||||
}, {} as DataProvider);
|
||||
});
|
||||
if (providersContainRangeQuery) {
|
||||
return [];
|
||||
} else {
|
||||
return providers.map((innerProvider) => {
|
||||
return innerProvider.reduce((prev, next, index) => {
|
||||
const { field, value, type } = next;
|
||||
if (index === 0) {
|
||||
return {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: JSON.stringify(field + value + type),
|
||||
name: field,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
type: type === 'parameter' ? DataProviderType.template : DataProviderType.default,
|
||||
queryMatch: {
|
||||
field,
|
||||
value: type === 'parameter' ? `{${value}}` : value,
|
||||
operator: IS_OPERATOR as QueryOperator,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const newProvider = {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: JSON.stringify(field + value + type),
|
||||
name: field,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
type: type === 'parameter' ? DataProviderType.template : DataProviderType.default,
|
||||
queryMatch: {
|
||||
field,
|
||||
value: type === 'parameter' ? `{${value}}` : value,
|
||||
operator: IS_OPERATOR as QueryOperator,
|
||||
},
|
||||
};
|
||||
prev.and.push(newProvider);
|
||||
}
|
||||
return prev;
|
||||
}, {} as DataProvider);
|
||||
});
|
||||
return buildDataProviders(providers, alertData);
|
||||
}
|
||||
}, [alertData, providers]);
|
||||
return dataProviders;
|
||||
}, [alertData, providers, providersContainRangeQuery]);
|
||||
const filters = useMemo(() => {
|
||||
if (!providersContainRangeQuery) {
|
||||
return [];
|
||||
} else {
|
||||
return buildFiltersFromInsightProviders(providers, alertData);
|
||||
}
|
||||
}, [providersContainRangeQuery, providers, alertData]);
|
||||
return { dataProviders, filters };
|
||||
};
|
||||
|
|
|
@ -33,6 +33,7 @@ describe('useInsightQuery', () => {
|
|||
() =>
|
||||
useInsightQuery({
|
||||
dataProviders: [mockProvider],
|
||||
filters: [],
|
||||
}),
|
||||
{
|
||||
wrapper: TestProviders,
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { getEsQueryConfig } from '@kbn/data-plugin/common';
|
||||
import type { DataProvider } from '@kbn/timelines-plugin/common';
|
||||
import { TimelineId } from '../../../../../../common/types/timeline';
|
||||
|
@ -17,6 +18,7 @@ import { SourcererScopeName } from '../../../../store/sourcerer/model';
|
|||
|
||||
export interface UseInsightQuery {
|
||||
dataProviders: DataProvider[];
|
||||
filters: Filter[];
|
||||
}
|
||||
|
||||
export interface UseInsightQueryResult {
|
||||
|
@ -26,7 +28,10 @@ export interface UseInsightQueryResult {
|
|||
hasError: boolean;
|
||||
}
|
||||
|
||||
export const useInsightQuery = ({ dataProviders }: UseInsightQuery): UseInsightQueryResult => {
|
||||
export const useInsightQuery = ({
|
||||
dataProviders,
|
||||
filters,
|
||||
}: UseInsightQuery): UseInsightQueryResult => {
|
||||
const { uiSettings } = useKibana().services;
|
||||
const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]);
|
||||
const { browserFields, selectedPatterns, indexPattern, dataViewId } = useSourcererDataView(
|
||||
|
@ -41,7 +46,7 @@ export const useInsightQuery = ({ dataProviders }: UseInsightQuery): UseInsightQ
|
|||
dataProviders,
|
||||
indexPattern,
|
||||
browserFields,
|
||||
filters: [],
|
||||
filters,
|
||||
kqlQuery: {
|
||||
query: '',
|
||||
language: 'kuery',
|
||||
|
@ -54,7 +59,7 @@ export const useInsightQuery = ({ dataProviders }: UseInsightQuery): UseInsightQ
|
|||
setHasError(true);
|
||||
return null;
|
||||
}
|
||||
}, [browserFields, dataProviders, esQueryConfig, hasError, indexPattern]);
|
||||
}, [browserFields, dataProviders, esQueryConfig, hasError, indexPattern, filters]);
|
||||
|
||||
const [isQueryLoading, { events, totalCount }] = useTimelineEvents({
|
||||
dataViewId,
|
||||
|
|
|
@ -446,6 +446,7 @@ export const useSourcererDataView = (
|
|||
selectedPatterns,
|
||||
// if we have to do an update to data view, tell us which patterns are active
|
||||
...(legacyPatterns.length > 0 ? { activePatterns: sourcererDataView.patternList } : {}),
|
||||
sourcererDataView,
|
||||
}),
|
||||
[sourcererDataView, selectedPatterns, indicesExist, loading, legacyPatterns.length]
|
||||
);
|
||||
|
|
|
@ -18,6 +18,12 @@ import type { BrowserFields } from '../../../../common/search_strategy';
|
|||
import type { DataProvider, DataProvidersAnd } from '../../../../common/types';
|
||||
import { DataProviderType, EXISTS_OPERATOR } from '../../../../common/types';
|
||||
|
||||
export type PrimitiveOrArrayOfPrimitives =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<string | number | boolean>;
|
||||
|
||||
export interface CombineQueries {
|
||||
config: EsQueryConfig;
|
||||
dataProviders: DataProvider[];
|
||||
|
@ -29,8 +35,8 @@ export interface CombineQueries {
|
|||
}
|
||||
|
||||
export const escapeQueryValue = (
|
||||
val: string | number | Array<string | number> = ''
|
||||
): string | number | Array<string | number> => {
|
||||
val: PrimitiveOrArrayOfPrimitives = ''
|
||||
): PrimitiveOrArrayOfPrimitives => {
|
||||
if (isString(val)) {
|
||||
if (isEmpty(val)) {
|
||||
return '""';
|
||||
|
@ -68,12 +74,13 @@ export const convertKueryToElasticSearchQuery = (
|
|||
}
|
||||
};
|
||||
|
||||
const isNumber = (value: string | number | Array<string | number>) => !isNaN(Number(value));
|
||||
export const isNumber = (value: PrimitiveOrArrayOfPrimitives): value is number =>
|
||||
!isNaN(Number(value));
|
||||
|
||||
const convertDateFieldToQuery = (field: string, value: string | number | Array<string | number>) =>
|
||||
export const convertDateFieldToQuery = (field: string, value: PrimitiveOrArrayOfPrimitives) =>
|
||||
`${field}: ${isNumber(value) ? value : new Date(value.toString()).valueOf()}`;
|
||||
|
||||
const getBaseFields = memoizeOne((browserFields: BrowserFields): string[] => {
|
||||
export const getBaseFields = memoizeOne((browserFields: BrowserFields): string[] => {
|
||||
const baseFields = get('base', browserFields);
|
||||
if (baseFields != null && baseFields.fields != null) {
|
||||
return Object.keys(baseFields.fields);
|
||||
|
@ -81,7 +88,7 @@ const getBaseFields = memoizeOne((browserFields: BrowserFields): string[] => {
|
|||
return [];
|
||||
});
|
||||
|
||||
const getBrowserFieldPath = (field: string, browserFields: BrowserFields) => {
|
||||
export const getBrowserFieldPath = (field: string, browserFields: BrowserFields) => {
|
||||
const splitFields = field.split('.');
|
||||
const baseFields = getBaseFields(browserFields);
|
||||
if (baseFields.includes(field)) {
|
||||
|
@ -90,7 +97,7 @@ const getBrowserFieldPath = (field: string, browserFields: BrowserFields) => {
|
|||
return [splitFields[0], 'fields', field];
|
||||
};
|
||||
|
||||
const checkIfFieldTypeIsDate = (field: string, browserFields: BrowserFields) => {
|
||||
export const checkIfFieldTypeIsDate = (field: string, browserFields: BrowserFields) => {
|
||||
const pathBrowserField = getBrowserFieldPath(field, browserFields);
|
||||
const browserField = get(pathBrowserField, browserFields);
|
||||
if (browserField != null && browserField.type === 'date') {
|
||||
|
@ -99,9 +106,9 @@ const checkIfFieldTypeIsDate = (field: string, browserFields: BrowserFields) =>
|
|||
return false;
|
||||
};
|
||||
|
||||
const convertNestedFieldToQuery = (
|
||||
export const convertNestedFieldToQuery = (
|
||||
field: string,
|
||||
value: string | number | Array<string | number>,
|
||||
value: PrimitiveOrArrayOfPrimitives,
|
||||
browserFields: BrowserFields
|
||||
) => {
|
||||
const pathBrowserField = getBrowserFieldPath(field, browserFields);
|
||||
|
@ -111,7 +118,7 @@ const convertNestedFieldToQuery = (
|
|||
return `${nestedPath}: { ${key}: ${browserField.type === 'date' ? `"${value}"` : value} }`;
|
||||
};
|
||||
|
||||
const convertNestedFieldToExistQuery = (field: string, browserFields: BrowserFields) => {
|
||||
export const convertNestedFieldToExistQuery = (field: string, browserFields: BrowserFields) => {
|
||||
const pathBrowserField = getBrowserFieldPath(field, browserFields);
|
||||
const browserField = get(pathBrowserField, browserFields);
|
||||
const nestedPath = browserField.subType.nested.path;
|
||||
|
@ -119,7 +126,7 @@ const convertNestedFieldToExistQuery = (field: string, browserFields: BrowserFie
|
|||
return `${nestedPath}: { ${key}: * }`;
|
||||
};
|
||||
|
||||
const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) => {
|
||||
export const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) => {
|
||||
const pathBrowserField = getBrowserFieldPath(field, browserFields);
|
||||
const browserField = get(pathBrowserField, browserFields);
|
||||
if (browserField != null && browserField.subType && browserField.subType.nested) {
|
||||
|
|
|
@ -93,6 +93,7 @@ export interface SelectedDataView {
|
|||
selectedPatterns: SourcererScope['selectedPatterns'];
|
||||
// active patterns when dataViewId == null
|
||||
activePatterns?: string[];
|
||||
sourcererDataView?: SourcererDataView | (Omit<SourcererDataView, 'id'> & { id: string | null });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -10,12 +10,13 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||
import type { EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { EuiComboBox } from '@elastic/eui';
|
||||
|
||||
import { isStringOrNumberArray } from '../../timeline/helpers';
|
||||
import { isPrimitiveArray } from '../../timeline/helpers';
|
||||
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
interface ControlledDataProviderInput {
|
||||
onChangeCallback: (value: string | number | string[]) => void;
|
||||
value: string | number | Array<string | number>;
|
||||
value: PrimitiveOrArrayOfPrimitives;
|
||||
}
|
||||
|
||||
export const ControlledComboboxInput = ({
|
||||
|
@ -71,9 +72,9 @@ export const ControlledComboboxInput = ({
|
|||
};
|
||||
|
||||
export const convertValuesToComboboxValueArray = (
|
||||
values: string | number | Array<string | number>
|
||||
values: PrimitiveOrArrayOfPrimitives
|
||||
): EuiComboBoxOptionOption[] =>
|
||||
isStringOrNumberArray(values) ? values.map((item) => ({ label: String(item) })) : [];
|
||||
isPrimitiveArray(values) ? values.map((item) => ({ label: String(item) })) : [];
|
||||
|
||||
export const convertComboboxValuesToStringArray = (values: EuiComboBoxOptionOption[]): string[] =>
|
||||
values.map((item) => item.label);
|
||||
|
|
|
@ -9,13 +9,14 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||
|
||||
import { EuiFieldText } from '@elastic/eui';
|
||||
|
||||
import { isStringOrNumberArray } from '../../timeline/helpers';
|
||||
import { isPrimitiveArray } from '../../timeline/helpers';
|
||||
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
|
||||
import { sanatizeValue } from '../helpers';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
interface ControlledDataProviderInput {
|
||||
onChangeCallback: (value: string | number | string[]) => void;
|
||||
value: string | number | Array<string | number>;
|
||||
value: PrimitiveOrArrayOfPrimitives;
|
||||
}
|
||||
|
||||
const VALUE_INPUT_CLASS_NAME = 'edit-data-provider-value';
|
||||
|
@ -24,7 +25,9 @@ export const ControlledDefaultInput = ({
|
|||
value,
|
||||
onChangeCallback,
|
||||
}: ControlledDataProviderInput) => {
|
||||
const [primitiveValue, setPrimitiveValue] = useState<string | number>(getDefaultValue(value));
|
||||
const [primitiveValue, setPrimitiveValue] = useState<string | number | boolean>(
|
||||
getDefaultValue(value)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeCallback(sanatizeValue(primitiveValue));
|
||||
|
@ -44,10 +47,8 @@ export const ControlledDefaultInput = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const getDefaultValue = (
|
||||
value: string | number | Array<string | number>
|
||||
): string | number => {
|
||||
if (isStringOrNumberArray(value)) {
|
||||
export const getDefaultValue = (value: PrimitiveOrArrayOfPrimitives): string | number | boolean => {
|
||||
if (isPrimitiveArray(value)) {
|
||||
return value[0] ?? '';
|
||||
} else return value;
|
||||
};
|
||||
|
|
|
@ -126,9 +126,8 @@ export const getExcludedFromSelection = (selectedOperator: EuiComboBoxOptionOpti
|
|||
};
|
||||
|
||||
/** Ensure that a value passed to ControlledDefaultInput is not an array */
|
||||
export const sanatizeValue = (value: string | number | unknown[]): string => {
|
||||
export const sanatizeValue = (value: string | number | boolean | unknown[]): string => {
|
||||
if (Array.isArray(value)) {
|
||||
// fun fact: value should never be an array
|
||||
return value.length ? `${value[0]}` : '';
|
||||
}
|
||||
return `${value}`;
|
||||
|
|
|
@ -21,6 +21,7 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
|||
import styled from 'styled-components';
|
||||
|
||||
import type { BrowserFields } from '../../../common/containers/source';
|
||||
import type { PrimitiveOrArrayOfPrimitives } from '../../../common/lib/kuery';
|
||||
import type { OnDataProviderEdited } from '../timeline/events';
|
||||
import type { QueryOperator } from '../timeline/data_providers/data_provider';
|
||||
import { DataProviderType } from '../timeline/data_providers/data_provider';
|
||||
|
@ -57,7 +58,7 @@ interface Props {
|
|||
operator: QueryOperator;
|
||||
providerId: string;
|
||||
timelineId: string;
|
||||
value: string | number | Array<string | number>;
|
||||
value: PrimitiveOrArrayOfPrimitives;
|
||||
type?: DataProviderType;
|
||||
}
|
||||
|
||||
|
@ -92,9 +93,7 @@ export const StatefulEditDataProvider = React.memo<Props>(
|
|||
getInitialOperatorLabel(isExcluded, operator)
|
||||
);
|
||||
|
||||
const [updatedValue, setUpdatedValue] = useState<string | number | Array<string | number>>(
|
||||
value
|
||||
);
|
||||
const [updatedValue, setUpdatedValue] = useState<PrimitiveOrArrayOfPrimitives>(value);
|
||||
|
||||
const showComboBoxInput = useMemo(
|
||||
() =>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
|
||||
/** Represents the Timeline data providers */
|
||||
|
||||
/** The `is` operator in a KQL query */
|
||||
|
@ -27,8 +27,8 @@ export enum DataProviderType {
|
|||
export interface QueryMatch {
|
||||
field: string;
|
||||
displayField?: string;
|
||||
value: string | number | Array<string | number>;
|
||||
displayValue?: string | number;
|
||||
value: PrimitiveOrArrayOfPrimitives;
|
||||
displayValue?: string | number | boolean;
|
||||
operator: QueryOperator;
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,8 @@ import type { DraggableLocation } from 'react-beautiful-dnd';
|
|||
import type { Dispatch } from 'redux';
|
||||
|
||||
import { updateProviders } from '../../../store/timeline/actions';
|
||||
import { isStringOrNumberArray } from '../helpers';
|
||||
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
|
||||
import { isPrimitiveArray } from '../helpers';
|
||||
|
||||
import type { DataProvider, DataProvidersAnd } from './data_provider';
|
||||
|
||||
|
@ -344,10 +345,8 @@ export const addContentToTimeline = ({
|
|||
}
|
||||
};
|
||||
|
||||
export const getDisplayValue = (
|
||||
value: string | number | Array<string | number>
|
||||
): string | number => {
|
||||
if (isStringOrNumberArray(value)) {
|
||||
export const getDisplayValue = (value: PrimitiveOrArrayOfPrimitives): string | number | boolean => {
|
||||
if (isPrimitiveArray(value)) {
|
||||
if (value.length) {
|
||||
return `( ${value.join(' OR ')} )`;
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { isString } from 'lodash/fp';
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
|
||||
import { TimelineType } from '../../../../../common/types/timeline';
|
||||
import { getEmptyString } from '../../../../common/components/empty_value';
|
||||
import { ProviderContainer } from '../../../../common/components/drag_and_drop/provider_container';
|
||||
|
@ -103,7 +104,7 @@ interface ProviderBadgeProps {
|
|||
togglePopover: () => void;
|
||||
toggleType: () => void;
|
||||
displayValue: string;
|
||||
val: string | number | Array<string | number>;
|
||||
val: PrimitiveOrArrayOfPrimitives;
|
||||
operator: QueryOperator;
|
||||
type: DataProviderType;
|
||||
timelineType: TimelineType;
|
||||
|
|
|
@ -12,6 +12,7 @@ import React from 'react';
|
|||
import styled from 'styled-components';
|
||||
|
||||
import { TimelineType } from '../../../../../common/types/timeline';
|
||||
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
|
||||
import type { BrowserFields } from '../../../../common/containers/source';
|
||||
|
||||
import type { OnDataProviderEdited } from '../events';
|
||||
|
@ -48,7 +49,7 @@ interface OwnProps {
|
|||
toggleEnabledProvider: () => void;
|
||||
toggleExcludedProvider: () => void;
|
||||
toggleTypeProvider: () => void;
|
||||
value: string | number | Array<string | number>;
|
||||
value: PrimitiveOrArrayOfPrimitives;
|
||||
type: DataProviderType;
|
||||
}
|
||||
|
||||
|
@ -80,7 +81,7 @@ interface GetProviderActionsProps {
|
|||
toggleEnabled: () => void;
|
||||
toggleExcluded: () => void;
|
||||
toggleType: () => void;
|
||||
value: string | number | Array<string | number>;
|
||||
value: PrimitiveOrArrayOfPrimitives;
|
||||
type: DataProviderType;
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
useShallowEqualSelector,
|
||||
} from '../../../../common/hooks/use_selector';
|
||||
import { timelineSelectors } from '../../../store/timeline';
|
||||
import type { PrimitiveOrArrayOfPrimitives } from '../../../../common/lib/kuery';
|
||||
|
||||
import type { OnDataProviderEdited } from '../events';
|
||||
import { ProviderBadge } from './provider_badge';
|
||||
|
@ -44,7 +45,7 @@ interface ProviderItemBadgeProps {
|
|||
toggleExcludedProvider: () => void;
|
||||
toggleTypeProvider: () => void;
|
||||
displayValue?: string;
|
||||
val: string | number | Array<string | number>;
|
||||
val: PrimitiveOrArrayOfPrimitives;
|
||||
type?: DataProviderType;
|
||||
wrapperRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { PrimitiveOrArrayOfPrimitives } from '../../../common/lib/kuery';
|
||||
import type { ColumnId } from './body/column_id';
|
||||
import type { DataProvider, QueryOperator } from './data_providers/data_provider';
|
||||
export type {
|
||||
|
@ -36,7 +37,7 @@ export type OnDataProviderEdited = ({
|
|||
id: string;
|
||||
operator: QueryOperator;
|
||||
providerId: string;
|
||||
value: string | number | Array<string | number>;
|
||||
value: PrimitiveOrArrayOfPrimitives;
|
||||
type: DataProvider['type'];
|
||||
}) => void;
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
buildIsOneOfQueryMatch,
|
||||
buildIsQueryMatch,
|
||||
handleIsOperator,
|
||||
isStringOrNumberArray,
|
||||
isPrimitiveArray,
|
||||
showGlobalFilters,
|
||||
} from './helpers';
|
||||
|
||||
|
@ -274,27 +274,27 @@ describe('Build KQL Query', () => {
|
|||
|
||||
describe('isStringOrNumberArray', () => {
|
||||
test('it returns false when value is not an array', () => {
|
||||
expect(isStringOrNumberArray('just a string')).toBe(false);
|
||||
expect(isPrimitiveArray('just a string')).toBe(false);
|
||||
});
|
||||
|
||||
test('it returns false when value is an array of mixed types', () => {
|
||||
expect(isStringOrNumberArray(['mixed', 123, 'types'])).toBe(false);
|
||||
expect(isPrimitiveArray(['mixed', 123, 'types'])).toBe(false);
|
||||
});
|
||||
test('it returns false when value is an array of bad types', () => {
|
||||
const badValues = [undefined, null, {}] as unknown as string[];
|
||||
expect(isStringOrNumberArray(badValues)).toBe(false);
|
||||
expect(isPrimitiveArray(badValues)).toBe(false);
|
||||
});
|
||||
|
||||
test('it returns true when value is an empty array', () => {
|
||||
expect(isStringOrNumberArray([])).toBe(true);
|
||||
expect(isPrimitiveArray([])).toBe(true);
|
||||
});
|
||||
|
||||
test('it returns true when value is an array of all strings', () => {
|
||||
expect(isStringOrNumberArray(['all', 'string', 'values'])).toBe(true);
|
||||
expect(isPrimitiveArray(['all', 'string', 'values'])).toBe(true);
|
||||
});
|
||||
|
||||
test('it returns true when value is an array of all numbers', () => {
|
||||
expect(isStringOrNumberArray([123, 456, 789])).toBe(true);
|
||||
expect(isPrimitiveArray([123, 456, 789])).toBe(true);
|
||||
});
|
||||
|
||||
describe('queryHandlerFunctions', () => {
|
||||
|
|
|
@ -5,8 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isEmpty, get } from 'lodash/fp';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
|
||||
import {
|
||||
elementOrChildrenHasFocus,
|
||||
|
@ -18,7 +17,16 @@ import {
|
|||
|
||||
import { assertUnreachable } from '../../../../common/utility_types';
|
||||
import type { BrowserFields } from '../../../common/containers/source';
|
||||
import { escapeQueryValue } from '../../../common/lib/kuery';
|
||||
import {
|
||||
escapeQueryValue,
|
||||
isNumber,
|
||||
convertDateFieldToQuery,
|
||||
checkIfFieldTypeIsDate,
|
||||
convertNestedFieldToQuery,
|
||||
convertNestedFieldToExistQuery,
|
||||
checkIfFieldTypeIsNested,
|
||||
type PrimitiveOrArrayOfPrimitives,
|
||||
} from '../../../common/lib/kuery';
|
||||
import type { DataProvider, DataProvidersAnd } from './data_providers/data_provider';
|
||||
import {
|
||||
DataProviderType,
|
||||
|
@ -28,66 +36,6 @@ import {
|
|||
} from './data_providers/data_provider';
|
||||
import { EVENTS_TABLE_CLASS_NAME } from './styles';
|
||||
|
||||
const isNumber = (value: string | number): value is number => !isNaN(Number(value));
|
||||
|
||||
const convertDateFieldToQuery = (field: string, value: string | number) =>
|
||||
`${field}: ${isNumber(value) ? value : new Date(value).valueOf()}`;
|
||||
|
||||
const getBaseFields = memoizeOne((browserFields: BrowserFields): string[] => {
|
||||
const baseFields = get('base', browserFields);
|
||||
if (baseFields != null && baseFields.fields != null) {
|
||||
return Object.keys(baseFields.fields);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const getBrowserFieldPath = (field: string, browserFields: BrowserFields) => {
|
||||
const splitFields = field.split('.');
|
||||
const baseFields = getBaseFields(browserFields);
|
||||
if (baseFields.includes(field)) {
|
||||
return ['base', 'fields', field];
|
||||
}
|
||||
return [splitFields[0], 'fields', field];
|
||||
};
|
||||
|
||||
const checkIfFieldTypeIsDate = (field: string, browserFields: BrowserFields) => {
|
||||
const pathBrowserField = getBrowserFieldPath(field, browserFields);
|
||||
const browserField = get(pathBrowserField, browserFields);
|
||||
if (browserField != null && browserField.type === 'date') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const convertNestedFieldToQuery = (
|
||||
field: string,
|
||||
value: string | number,
|
||||
browserFields: BrowserFields
|
||||
) => {
|
||||
const pathBrowserField = getBrowserFieldPath(field, browserFields);
|
||||
const browserField = get(pathBrowserField, browserFields);
|
||||
const nestedPath = browserField.subType.nested.path;
|
||||
const key = field.replace(`${nestedPath}.`, '');
|
||||
return `${nestedPath}: { ${key}: ${browserField.type === 'date' ? `"${value}"` : value} }`;
|
||||
};
|
||||
|
||||
const convertNestedFieldToExistQuery = (field: string, browserFields: BrowserFields) => {
|
||||
const pathBrowserField = getBrowserFieldPath(field, browserFields);
|
||||
const browserField = get(pathBrowserField, browserFields);
|
||||
const nestedPath = browserField.subType.nested.path;
|
||||
const key = field.replace(`${nestedPath}.`, '');
|
||||
return `${nestedPath}: { ${key}: * }`;
|
||||
};
|
||||
|
||||
const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) => {
|
||||
const pathBrowserField = getBrowserFieldPath(field, browserFields);
|
||||
const browserField = get(pathBrowserField, browserFields);
|
||||
if (browserField != null && browserField.subType && browserField.subType.nested) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const buildQueryMatch = (
|
||||
dataProvider: DataProvider | DataProvidersAnd,
|
||||
browserFields: BrowserFields
|
||||
|
@ -265,7 +213,7 @@ export const resetKeyboardFocus = () => {
|
|||
interface OperatorHandler {
|
||||
field: string;
|
||||
isExcluded: string;
|
||||
value: string | number | Array<string | number>;
|
||||
value: PrimitiveOrArrayOfPrimitives;
|
||||
}
|
||||
|
||||
export const handleIsOperator = ({
|
||||
|
@ -280,7 +228,7 @@ export const handleIsOperator = ({
|
|||
isFieldTypeNested: boolean;
|
||||
type?: DataProviderType;
|
||||
}) => {
|
||||
if (!isStringOrNumberArray(value)) {
|
||||
if (!isPrimitiveArray(value)) {
|
||||
return `${isExcluded}${
|
||||
type !== DataProviderType.template
|
||||
? buildIsQueryMatch({ browserFields, field, isFieldTypeNested, value })
|
||||
|
@ -292,7 +240,7 @@ export const handleIsOperator = ({
|
|||
};
|
||||
|
||||
const handleIsOneOfOperator = ({ field, isExcluded, value }: OperatorHandler) => {
|
||||
if (isStringOrNumberArray(value)) {
|
||||
if (isPrimitiveArray(value)) {
|
||||
return `${isExcluded}${buildIsOneOfQueryMatch({ field, value })}`;
|
||||
} else {
|
||||
return `${isExcluded}${field} : ${JSON.stringify(value)}`;
|
||||
|
@ -308,7 +256,7 @@ export const buildIsQueryMatch = ({
|
|||
browserFields: BrowserFields;
|
||||
field: string;
|
||||
isFieldTypeNested: boolean;
|
||||
value: string | number;
|
||||
value: string | number | boolean;
|
||||
}): string => {
|
||||
if (isFieldTypeNested) {
|
||||
return convertNestedFieldToQuery(field, value, browserFields);
|
||||
|
@ -338,17 +286,17 @@ export const buildIsOneOfQueryMatch = ({
|
|||
value,
|
||||
}: {
|
||||
field: string;
|
||||
value: Array<string | number>;
|
||||
value: Array<string | number | boolean>;
|
||||
}): string => {
|
||||
const trimmedField = field.trim();
|
||||
if (value.length) {
|
||||
return `${trimmedField} : (${value
|
||||
.map((item) => (isNumber(item) ? Number(item) : `${escapeQueryValue(item.trim())}`))
|
||||
.map((item) => (isNumber(item) ? Number(item) : `${escapeQueryValue(String(item).trim())}`))
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
return `${trimmedField} : ''`;
|
||||
};
|
||||
|
||||
export const isStringOrNumberArray = (value: unknown): value is Array<string | number> =>
|
||||
export const isPrimitiveArray = (value: unknown): value is Array<string | number | boolean> =>
|
||||
Array.isArray(value) &&
|
||||
(value.every((x) => typeof x === 'string') || value.every((x) => typeof x === 'number'));
|
||||
|
|
|
@ -27,8 +27,8 @@ export enum DataProviderType {
|
|||
export interface QueryMatch {
|
||||
field: string;
|
||||
displayField?: string;
|
||||
value: string | number | Array<string | number>;
|
||||
displayValue?: string | number;
|
||||
value: string | number | boolean | Array<string | number | boolean>;
|
||||
displayValue?: string | number | boolean;
|
||||
operator: QueryOperator;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue