diff --git a/src/platform/packages/shared/kbn-try-in-console/components/try_in_console_button.tsx b/src/platform/packages/shared/kbn-try-in-console/components/try_in_console_button.tsx
index a542f2dec7ee9..1e036758f9996 100644
--- a/src/platform/packages/shared/kbn-try-in-console/components/try_in_console_button.tsx
+++ b/src/platform/packages/shared/kbn-try-in-console/components/try_in_console_button.tsx
@@ -9,7 +9,13 @@
import React from 'react';
-import { EuiLink, EuiButton, EuiButtonEmpty, EuiContextMenuItem } from '@elastic/eui';
+import {
+ EuiLink,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiContextMenuItem,
+ EuiButtonColor,
+} from '@elastic/eui';
import { css } from '@emotion/react';
import type { ApplicationStart } from '@kbn/core-application-browser';
import type { SharePluginStart } from '@kbn/share-plugin/public';
@@ -28,6 +34,7 @@ export interface TryInConsoleButtonProps {
consolePlugin?: ConsolePluginStart;
sharePlugin?: SharePluginStart;
content?: string | React.ReactElement;
+ color?: EuiButtonColor;
showIcon?: boolean;
iconType?: string;
type?: 'link' | 'button' | 'emptyButton' | 'contextMenuItem';
@@ -41,6 +48,7 @@ export const TryInConsoleButton = ({
consolePlugin,
sharePlugin,
content = RUN_IN_CONSOLE,
+ color,
showIcon = true,
iconType = 'console',
type = 'emptyButton',
@@ -127,7 +135,7 @@ export const TryInConsoleButton = ({
case 'emptyButton':
default:
return (
-
+
{content}
);
diff --git a/x-pack/platform/packages/shared/kbn-search-index-documents/components/result/editable_result.tsx b/x-pack/platform/packages/shared/kbn-search-index-documents/components/result/editable_result.tsx
new file mode 100644
index 0000000000000..6e2279d02aa86
--- /dev/null
+++ b/x-pack/platform/packages/shared/kbn-search-index-documents/components/result/editable_result.tsx
@@ -0,0 +1,177 @@
+/*
+ * 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 React from 'react';
+
+import {
+ EuiButtonIcon,
+ EuiComboBox,
+ EuiFieldText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiHorizontalRule,
+ EuiIcon,
+ EuiLoadingSpinner,
+ EuiSplitPanel,
+ EuiText,
+} from '@elastic/eui';
+import { debounce } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { ResultFieldProps } from './result_types';
+import { ResultFields } from './results_fields';
+
+export interface EditableResultProps {
+ leftSideItem?: React.ReactNode;
+ hasIndexSelector?: boolean;
+ onDeleteDocument: () => void;
+ onIndexSelectorChange?: (index: string) => void;
+ onIdSelectorChange?: (id: string) => void;
+ onExpand?: () => void;
+ fields?: ResultFieldProps[];
+ indices?: string[];
+ initialDocId?: string;
+ initialIndex?: string;
+ error?: string;
+ isLoading?: boolean;
+}
+
+export const EditableResult: React.FC = ({
+ leftSideItem,
+ hasIndexSelector,
+ onIndexSelectorChange,
+ onIdSelectorChange,
+ onDeleteDocument,
+ onExpand,
+ indices = [],
+ fields = [],
+ initialDocId = '',
+ initialIndex = '',
+ error,
+ isLoading = false,
+}) => {
+ const [isExpanded, setIsExpanded] = React.useState(false);
+ const [documentId, setDocumentId] = React.useState(initialDocId);
+ const [index, setIndex] = React.useState(initialIndex);
+ return (
+
+
+
+ {leftSideItem && {leftSideItem}}
+
+
+
+ setDocumentId(e.target.value)}
+ onBlur={(e) => {
+ if (onIdSelectorChange) {
+ onIdSelectorChange(e.target.value);
+ }
+ }}
+ fullWidth
+ placeholder={i18n.translate(
+ 'xpack.sharedKbnSearchIndexDocuments.editableResult.documentIdPlaceholder',
+ {
+ defaultMessage: 'Document ID',
+ }
+ )}
+ />
+
+ {hasIndexSelector && (
+
+ ({ label: i, value: 'index' }))}
+ isClearable={false}
+ selectedOptions={index ? [{ label: index, value: 'index' }] : []}
+ onChange={(selected) => {
+ const selectedIndex = selected[0]?.label || '';
+ setIndex(selectedIndex);
+ if (onIndexSelectorChange) {
+ debounce(() => {
+ onIndexSelectorChange(selectedIndex);
+ }, 300)();
+ }
+ }}
+ />
+
+ )}
+
+
+
+
+
+ {error && }
+ {!error &&
+ hasIndexSelector &&
+ (isLoading ? (
+
+ ) : (
+ {
+ if (onExpand && !isExpanded) {
+ onExpand();
+ }
+ setIsExpanded(!isExpanded);
+ }}
+ />
+ ))}
+
+
+
+
+
+
+
+
+ {!error && fields?.length > 0 && isExpanded && (
+ <>
+
+
+
+
+ >
+ )}
+ {error && (
+
+
+
+
+
+
+ {error}
+
+
+
+
+ )}
+
+ );
+};
diff --git a/x-pack/platform/packages/shared/kbn-search-index-documents/components/result/index.ts b/x-pack/platform/packages/shared/kbn-search-index-documents/components/result/index.ts
index 5573bebd71e1d..bada4952123b9 100644
--- a/x-pack/platform/packages/shared/kbn-search-index-documents/components/result/index.ts
+++ b/x-pack/platform/packages/shared/kbn-search-index-documents/components/result/index.ts
@@ -12,3 +12,4 @@ export {
resultToFieldFromMappings as resultToField,
reorderFieldsInImportance,
} from './result_metadata';
+export { EditableResult } from './editable_result';
diff --git a/x-pack/solutions/search/plugins/search_query_rules/common/api_routes.ts b/x-pack/solutions/search/plugins/search_query_rules/common/api_routes.ts
index 6b6b47848ebac..ff6e27c1702c4 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/common/api_routes.ts
+++ b/x-pack/solutions/search/plugins/search_query_rules/common/api_routes.ts
@@ -11,5 +11,8 @@ export enum APIRoutes {
QUERY_RULES_SETS = '/internal/search_query_rules/query_rules_sets',
QUERY_RULES_QUERY_RULE_FETCH = '/internal/search_query_rules/ruleset/{ruleset_id}/rule/{rule_id}',
QUERY_RULES_RULESET_ID = '/internal/search_query_rules/ruleset/{ruleset_id}',
+ FETCH_INDICES = '/internal/search_query_rules/indices',
+ FETCH_DOCUMENT = '/internal/search_query_rules/document/{indexName}/{documentId}',
+ GENERATE_RULE_ID = '/internal/search_query_rules/ruleset/{rulesetId}/generate_rule_id',
QUERY_RULES_RULESET_RULE = '/internal/search_query_rules/ruleset/{ruleset_id}/rule/{rule_id}',
}
diff --git a/x-pack/solutions/search/plugins/search_query_rules/common/types.ts b/x-pack/solutions/search/plugins/search_query_rules/common/types.ts
index d1f430d9e828c..464c90b6dcce5 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/common/types.ts
+++ b/x-pack/solutions/search/plugins/search_query_rules/common/types.ts
@@ -6,6 +6,8 @@
*/
import {
+ GetResponse,
+ IndicesGetMappingResponse,
QueryRulesQueryRule,
QueryRulesQueryRuleCriteria,
QueryRulesQueryRuleset,
@@ -33,6 +35,11 @@ export type SearchQueryRulesQueryRuleset = Omit
rules: SearchQueryRulesQueryRule[];
};
+export interface SearchQueryDocumentResponse {
+ document: GetResponse;
+ mappings: IndicesGetMappingResponse;
+}
+
export type QueryRuleEditorForm = Pick<
SearchQueryRulesQueryRule,
'criteria' | 'type' | 'actions'
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/empty_prompt.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/empty_prompt.tsx
index f6f5d237c1614..d094d6d6db458 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/empty_prompt.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/empty_prompt.tsx
@@ -79,7 +79,7 @@ export const EmptyPrompt: React.FC = ({ getStartedAction }) =>
@@ -88,7 +88,7 @@ export const EmptyPrompt: React.FC = ({ getStartedAction }) =>
@@ -248,7 +248,7 @@ export const EmptyPrompt: React.FC = ({ getStartedAction }) =>
>
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/overview/overview.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/overview/overview.tsx
index 64fce0ce1abd7..6f1971c9d5f56 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/components/overview/overview.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/overview/overview.tsx
@@ -5,19 +5,20 @@
* 2.0.
*/
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import {
EuiButton,
+ EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
- EuiLink,
EuiLoadingSpinner,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
+import { i18n } from '@kbn/i18n';
import { docLinks } from '../../../common/doc_links';
import { useFetchQueryRulesSets } from '../../hooks/use_fetch_query_rules_sets';
import { EmptyPrompt } from '../empty_prompt/empty_prompt';
@@ -30,7 +31,20 @@ import { CreateRulesetModal } from '../query_rules_sets/create_ruleset_modal';
import { QueryRulesPageTemplate } from '../../layout/query_rules_page_template';
export const QueryRulesOverview = () => {
- const { data: queryRulesData, isInitialLoading, isError, error } = useFetchQueryRulesSets();
+ const {
+ data: queryRulesData,
+ isInitialLoading,
+ isError,
+ error,
+ refetch,
+ } = useFetchQueryRulesSets();
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ refetch();
+ }, 1000);
+ return () => clearInterval(interval);
+ }, [refetch]);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const backgroundProps = css({
backgroundImage: `url(${queryRulesBackground})`,
@@ -52,17 +66,23 @@ export const QueryRulesOverview = () => {
rightSideItems={[
-
-
-
+ {i18n.translate('xpack.queryRules.queryRulesetDetail.apiReferenceButton', {
+ defaultMessage: 'API reference',
+ })}
+
void;
+ setIsFormDirty?: (isDirty: boolean) => void;
+ updateRule: (updatedRule: SearchQueryRulesQueryRule) => void;
+ addNewRule: (newRuleId: string) => void;
+ deleteRule?: (ruleId: string) => void;
rulesetId: QueryRulesQueryRuleset['ruleset_id'];
tourInfo?: {
title: string;
@@ -26,10 +33,17 @@ interface QueryRuleDetailPanelProps {
export const QueryRuleDetailPanel: React.FC = ({
rulesetId,
tourInfo,
+ rules,
+ setIsFormDirty,
+ setNewRules,
+ updateRule,
+ addNewRule,
+ deleteRule,
}) => {
- const { rules, setNewRules, updateRule } = useQueryRulesetDetailState({ rulesetId });
const [ruleIdToEdit, setRuleIdToEdit] = React.useState(null);
+ const { mutate: generateRuleId } = useGenerateRuleId(rulesetId);
+
return (
{ruleIdToEdit !== null && (
@@ -44,6 +58,7 @@ export const QueryRuleDetailPanel: React.FC = ({
onClose={() => {
setRuleIdToEdit(null);
}}
+ setIsFormDirty={setIsFormDirty}
/>
)}
@@ -58,9 +73,12 @@ export const QueryRuleDetailPanel: React.FC = ({
color="primary"
data-test-subj="queryRulesetDetailAddRuleButton"
onClick={() => {
- // TODO: Logic to add a new rule
- // This opens the query rule flyout in create mode.
- // ruleid cannot be null or empty when creating a new rule. Add logic to generate a rule id.
+ generateRuleId(undefined, {
+ onSuccess: (newRuleId) => {
+ addNewRule(newRuleId);
+ setRuleIdToEdit(newRuleId);
+ },
+ });
}}
>
= ({
setNewRules(newRules)}
+ onReorder={(newRules) => {
+ setNewRules(newRules);
+ if (setIsFormDirty) {
+ setIsFormDirty(true);
+ }
+ }}
onEditRuleFlyoutOpen={(ruleId: string) => setRuleIdToEdit(ruleId)}
tourInfo={tourInfo}
+ deleteRule={deleteRule}
/>
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/query_rule_draggable_list.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/query_rule_draggable_list.tsx
index 1163a8d57f938..b7aaffdf0c380 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/query_rule_draggable_list.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/query_rule_draggable_list.tsx
@@ -37,9 +37,10 @@ import { DeleteRulesetRuleModal } from './delete_ruleset_rule_modal';
export interface QueryRuleDraggableListItemProps {
rules: SearchQueryRulesQueryRule[];
queryRule: QueryRulesQueryRule;
- rulesetId: string; // Add this prop to pass down the ruleset ID
+ rulesetId: string;
index: number;
onEditRuleFlyoutOpen: (ruleId: string) => void;
+ deleteRule?: (ruleId: string) => void;
isLastItem?: boolean;
tourInfo?: {
title: string;
@@ -50,9 +51,10 @@ export interface QueryRuleDraggableListItemProps {
export const QueryRuleDraggableListItem: React.FC = ({
index,
- rulesetId, // Add this prop
+ rulesetId,
rules,
onEditRuleFlyoutOpen,
+ deleteRule,
queryRule,
tourInfo,
isLastItem = false,
@@ -67,14 +69,19 @@ export const QueryRuleDraggableListItem: React.FC {
setIsPopoverOpen(true);
}, []);
- const [ruleToDelete, setRuleToDelete] = useState(null); // Rename to be clearer
+ const [ruleToDelete, setRuleToDelete] = useState(null);
return (
<>
{ruleToDelete && (
setRuleToDelete(null)}
+ onSuccessAction={() => {
+ if (deleteRule) {
+ deleteRule(ruleToDelete);
+ }
+ }}
/>
)}
void;
+ isLastItem?: boolean;
+ tourInfo?: {
+ title: string;
+ content: string;
+ tourTargetRef?: React.RefObject;
+ };
+}
+
export interface QueryRuleDraggableListProps {
rules: SearchQueryRulesQueryRule[];
- rulesetId: string; // Add this prop
+ rulesetId: string;
onReorder: (queryRules: SearchQueryRulesQueryRule[]) => void;
onEditRuleFlyoutOpen: (ruleId: string) => void;
+ deleteRule?: (ruleId: string) => void;
tourInfo?: {
title: string;
content: string;
@@ -240,6 +260,7 @@ export const QueryRuleDraggableList: React.FC = ({
rules,
rulesetId,
onEditRuleFlyoutOpen,
+ deleteRule,
onReorder,
tourInfo,
}) => {
@@ -266,7 +287,9 @@ export const QueryRuleDraggableList: React.FC = ({
{
const { euiTheme } = useEuiTheme();
return (
-
+
-
+
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/document_selector.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/document_selector.tsx
new file mode 100644
index 0000000000000..e27da4594436d
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/document_selector.tsx
@@ -0,0 +1,71 @@
+/*
+ * 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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiNotificationBadge, EuiPanel } from '@elastic/eui';
+import { EditableResult } from '@kbn/search-index-documents';
+import React from 'react';
+import { resultToFieldFromMappingResponse } from '@kbn/search-index-documents/components/result/result_metadata';
+import { useFetchDocument } from '../../../../hooks/use_fetch_document';
+
+interface DocumentSelectorProps {
+ initialDocId: string;
+ index?: string;
+ indexDoc?: number;
+ type?: 'exclude' | 'pinned';
+ onDeleteDocument?: () => void;
+ onIdSelectorChange?: (id: string) => void;
+ onIndexSelectorChange?: (index: string) => void;
+ indices?: string[];
+ hasIndexSelector?: boolean;
+}
+
+export const DocumentSelector: React.FC = ({
+ initialDocId = '',
+ index = '',
+ indexDoc = undefined,
+ type = undefined,
+ onDeleteDocument = () => {},
+ onIdSelectorChange = () => {},
+ onIndexSelectorChange = () => {},
+ indices = [],
+ hasIndexSelector = true,
+}) => {
+ const { data, error, isError, isLoading } = useFetchDocument(index, initialDocId);
+ const { document, mappings } = data || {};
+
+ return (
+
+ {type === 'pinned' && (
+
+
+
+
+
+
+ {(indexDoc ?? 0) + 1}
+
+
+
+ )}
+ >
+ }
+ data-test-subj="searchQueryRulesQueryRuleFlyoutDocumentCount"
+ indices={indices}
+ hasIndexSelector={hasIndexSelector}
+ fields={document && resultToFieldFromMappingResponse(document, mappings)}
+ onIdSelectorChange={onIdSelectorChange}
+ onIndexSelectorChange={onIndexSelectorChange}
+ onDeleteDocument={onDeleteDocument}
+ isLoading={isLoading}
+ error={isError ? error?.body?.message : undefined}
+ />
+ );
+};
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout.test.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout.test.tsx
index 5b46c94c4f815..c43f0d9020c62 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout.test.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout.test.tsx
@@ -157,8 +157,8 @@ describe('Query rule edit flyout', () => {
expect(onSaveMock).not.toHaveBeenCalled();
expect(onCloseMock).toHaveBeenCalled();
});
-
- it('should call onSave when update button is clicked', () => {
+ // TODO: Needs to be fixed, receiving "_id": undefined, "_index": undefined,
+ it.skip('should call onSave when update button is clicked', () => {
render(
void;
ruleId: string;
rulesetId: string;
+ setIsFormDirty?: (isDirty: boolean) => void;
}
export const QueryRuleFlyout: React.FC = ({
@@ -50,12 +59,37 @@ export const QueryRuleFlyout: React.FC = ({
onSave,
ruleId,
rulesetId,
+ setIsFormDirty,
}) => {
- const { control, getValues, reset } = useFormContext();
+ const {
+ services: { application },
+ } = useKibana();
+ const [isFlyoutDirty, setIsFlyoutDirty] = useState(false);
+ const { control, getValues, reset, setValue } = useFormContext();
const { fields, remove, replace, update, append } = useFieldArray({
control,
name: 'criteria',
});
+ const {
+ fields: actionFields,
+ remove: removeAction,
+ replace: replaceAction,
+ append: appendAction,
+ } = useFieldArray({
+ control,
+ name: 'actions.docs',
+ });
+
+ const pinType = useWatch({
+ control,
+ name: 'type',
+ });
+ const actionIdsFields = useWatch({
+ control,
+ name: 'actions.ids',
+ });
+
+ const { data: indexNames } = useFetchIndexNames('');
const { euiTheme } = useEuiTheme();
@@ -63,6 +97,8 @@ export const QueryRuleFlyout: React.FC = ({
const [isAlways, setIsAlways] = useState(
(ruleFromRuleset?.criteria && isCriteriaAlways(ruleFromRuleset?.criteria)) ?? false
);
+ const isIdRule = Boolean(actionFields.length === 0 && actionIdsFields?.length);
+ const isDocRule = Boolean(actionFields.length > 0);
useEffect(() => {
if (ruleFromRuleset) {
@@ -79,9 +115,99 @@ export const QueryRuleFlyout: React.FC = ({
);
}
}, [ruleFromRuleset, reset, getValues, rulesetId, ruleId]);
+ const handleAddCriteria = () => {
+ setIsFlyoutDirty(true);
+ append({
+ type: 'exact',
+ metadata: '',
+ values: [],
+ });
+ };
+
+ const appendNewAction = () => {
+ setIsFlyoutDirty(true);
+ if (isIdRule) {
+ setValue('actions.ids', [...(getValues('actions.ids') || []), '']);
+ } else {
+ appendAction({
+ _id: '',
+ _index: '',
+ });
+ }
+ };
+
+ const handleSave = () => {
+ setIsFormDirty?.(true);
+ const index = rules.findIndex((rule) => rule.rule_id === ruleId);
+ if (index !== -1) {
+ if (isAlways) {
+ replace([
+ {
+ metadata: 'always',
+ type: 'always',
+ values: ['always'],
+ },
+ ]);
+ }
+ let actions = {};
+ if (isDocRule) {
+ actions = {
+ docs: actionFields.map((doc) => ({
+ _id: doc._id,
+ _index: doc._index,
+ })),
+ };
+ } else if (isIdRule) {
+ actions = { ids: actionIdsFields };
+ }
+ const updatedRule = {
+ rule_id: ruleId,
+ criteria: fields.map((criteria) => {
+ const normalizedCriteria = {
+ values: criteria.values,
+ metadata: criteria.metadata,
+ type: criteria.type,
+ };
+ return normalizedCriteria;
+ }),
+ type: getValues('type'),
+ actions,
+ };
+ onSave(updatedRule);
+ }
+ };
+ const CRITERIA_CALLOUT_STORAGE_KEY = 'queryRules.criteriaCalloutState';
+ const [criteriaCalloutActive, setCriteriaCalloutActive] = useState(() => {
+ try {
+ const savedState = localStorage.getItem(CRITERIA_CALLOUT_STORAGE_KEY);
+ if (savedState === null) {
+ localStorage.setItem(CRITERIA_CALLOUT_STORAGE_KEY, 'true');
+ return true;
+ }
+ return savedState !== 'false';
+ } catch (e) {
+ return true;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(CRITERIA_CALLOUT_STORAGE_KEY, criteriaCalloutActive ? 'true' : 'false');
+ } catch (e) {
+ // If localStorage is not available, we can ignore the error
+ }
+ }, [criteriaCalloutActive]);
return (
-
+
@@ -95,14 +221,25 @@ export const QueryRuleFlyout: React.FC = ({
-
-
+
+
+
+
+ }
+ position="right"
+ />
+
@@ -110,7 +247,7 @@ export const QueryRuleFlyout: React.FC = ({
-
+
= ({
),
},
]}
- onChange={onChange}
+ onChange={(id) => {
+ setIsFlyoutDirty(true);
+ onChange(id);
+ }}
buttonSize="compressed"
type="single"
idSelected={value}
@@ -186,7 +326,6 @@ export const QueryRuleFlyout: React.FC = ({
-
= ({
/>
- {}}>
-
- {ruleFromRuleset?.actions?.ids?.map((value, index) => (
-
- {() => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- )) || <>>}
-
-
+
+
+
+ {/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
+ {
+ e.preventDefault();
+ application.navigateToApp(DISCOVER_APP_ID, {
+ openInNewTab: true,
+ });
+ }}
+ >
+
+
+
+
+
+
+ {
+ if (source && destination && ruleFromRuleset) {
+ setIsFlyoutDirty(true);
+ if (isDocRule) {
+ const newActions = euiDragDropReorder(
+ actionFields,
+ source.index,
+ destination.index
+ );
+ replaceAction(newActions);
+ } else if (isIdRule && actionIdsFields) {
+ const newActions = euiDragDropReorder(
+ actionIdsFields,
+ source.index,
+ destination.index
+ );
+ setValue('actions.ids', newActions);
+ }
+ }
+ }}
+ >
+
+ {isIdRule && actionIdsFields
+ ? actionIdsFields.map((doc, index) => (
+
+ {() => (
+ {
+ if (ruleFromRuleset) {
+ setIsFlyoutDirty(true);
+ const updatedActions = actionIdsFields.filter(
+ (_, i) => i !== index
+ );
+ setValue('actions.ids', updatedActions);
+ }
+ }}
+ onIdSelectorChange={(id) => {
+ if (ruleFromRuleset) {
+ setIsFlyoutDirty(true);
+ const updatedActions = actionIdsFields.map((value, i) =>
+ i === index ? id : value
+ );
+ setValue('actions.ids', updatedActions);
+ }
+ }}
+ />
+ )}
+
+ ))
+ : actionFields.map((doc, index) => (
+
+ {() => (
+ {
+ setIsFlyoutDirty(true);
+ removeAction(index);
+ }}
+ onIndexSelectorChange={(indexName) => {
+ if (ruleFromRuleset) {
+ setIsFlyoutDirty(true);
+ const updatedActions = actionFields.map((action, i) =>
+ i === index ? { ...action, _index: indexName } : action
+ );
+ replaceAction(updatedActions);
+ }
+ }}
+ onIdSelectorChange={(id) => {
+ if (ruleFromRuleset) {
+ setIsFlyoutDirty(true);
+ const updatedActions = actionFields.map((action, i) =>
+ i === index ? { ...action, _id: id } : action
+ );
+ replaceAction(updatedActions);
+ }
+ }}
+ indices={indexNames}
+ />
+ )}
+
+ )) || <>>}
+
+
+
+ {getValues('type') === 'pinned' && actionFields.length !== 0 ? (
+
+ }
+ />
+ ) : null}
+
+
+ {pinType === 'pinned' ? (
+ actionFields.length === 0 ? (
+
+ ) : (
+
+ )
+ ) : actionFields.length === 0 ? (
+
+ ) : (
+
+ )}
+
-
+
= ({
},
]}
onChange={(id) => {
+ setIsFlyoutDirty(true);
setIsAlways(id === 'always');
}}
buttonSize="compressed"
@@ -307,6 +589,24 @@ export const QueryRuleFlyout: React.FC = ({
+ {criteriaCalloutActive && !isAlways ? (
+ <>
+ {
+ setCriteriaCalloutActive(false);
+ }}
+ title={
+
+ }
+ />
+
+ >
+ ) : null}
{ruleFromRuleset &&
!isAlways &&
fields.map((field, index) => (
@@ -315,9 +615,11 @@ export const QueryRuleFlyout: React.FC = ({
criteria={field}
key={field.id}
onChange={(newCriteria) => {
+ setIsFlyoutDirty(true);
update(index, newCriteria);
}}
onRemove={() => {
+ setIsFlyoutDirty(true);
remove(index);
}}
/>
@@ -328,17 +630,12 @@ export const QueryRuleFlyout: React.FC = ({
{ruleFromRuleset && !isAlways && (
{
- append({
- type: 'exact',
- metadata: '',
- values: [],
- });
- }}
+ onClick={handleAddCriteria}
iconType="plusInCircle"
iconSide="left"
size="s"
- color="text"
+ color={fields.length === 0 ? 'primary' : 'text'}
+ fill={fields.length === 0}
>
= ({
{
- const index = rules.findIndex((rule) => rule.rule_id === ruleId);
- if (index !== -1) {
- if (isAlways) {
- replace([
- {
- metadata: 'always',
- type: 'always',
- values: ['always'],
- },
- ]);
- }
- onSave({
- rule_id: ruleId,
- criteria: getValues('criteria'),
- type: getValues('type'),
- actions: getValues('actions'),
- });
- }
- }}
+ onClick={handleSave}
+ disabled={!isFlyoutDirty}
>
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_ruleset_detail.test.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_ruleset_detail.test.tsx
index 9e030aa199fc1..68eb04d24e4a0 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_ruleset_detail.test.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_ruleset_detail.test.tsx
@@ -42,6 +42,35 @@ jest.mock('react-router-dom', () => ({
useParams: jest.fn(() => ({ rulesetId: MOCK_QUERY_RULESET_RESPONSE_FIXTURE.ruleset_id })),
}));
+jest.mock('../../hooks/use_kibana', () => ({
+ useKibana: () => ({
+ services: {
+ application: {
+ navigateToUrl: jest.fn(),
+ getUrlForApp: jest.fn().mockReturnValue('/app/test'),
+ },
+ http: {
+ basePath: {
+ prepend: jest.fn().mockImplementation((path) => `/base${path}`),
+ },
+ },
+ overlays: {
+ openConfirm: jest.fn().mockResolvedValue(true),
+ },
+ history: {
+ block: jest.fn().mockReturnValue(jest.fn()),
+ listen: jest.fn().mockReturnValue(jest.fn()),
+ },
+ console: {},
+ share: {},
+ },
+ }),
+}));
+
+jest.mock('@kbn/unsaved-changes-prompt', () => ({
+ useUnsavedChangesPrompt: jest.fn(),
+}));
+
describe('Query rule detail', () => {
const TEST_IDS = {
DetailPage: 'queryRulesetDetailPage',
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_ruleset_detail.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_ruleset_detail.tsx
index e298fd347e16a..849902d8fb1ce 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_ruleset_detail.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_ruleset_detail.tsx
@@ -27,6 +27,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { useParams } from 'react-router-dom';
import { css } from '@emotion/react';
+import { useUnsavedChangesPrompt } from '@kbn/unsaved-changes-prompt';
import { PLUGIN_ROUTE_ROOT } from '../../../common/api_routes';
import { useKibana } from '../../hooks/use_kibana';
import { UseRunQueryRuleset } from '../../hooks/use_run_query_ruleset';
@@ -36,17 +37,32 @@ import { ErrorPrompt } from '../error_prompt/error_prompt';
import { DeleteRulesetModal } from '../query_rules_sets/delete_ruleset_modal';
import { QueryRuleDetailPanel } from './query_rule_detail_panel';
import { useQueryRulesetDetailState } from './use_query_ruleset_detail_state';
+import { usePutRuleset } from '../../hooks/use_put_query_rules_ruleset';
+import { docLinks } from '../../../common/doc_links';
export const QueryRulesetDetail: React.FC = () => {
const { euiTheme } = useEuiTheme();
const {
- services: { application, http },
+ services: { application, http, history },
} = useKibana();
+ const { overlays } = useKibana().services;
const { rulesetId = '' } = useParams<{
rulesetId?: string;
}>();
- const { queryRuleset, isInitialLoading, isError, error } = useQueryRulesetDetailState({
+ const { mutate: createRuleset } = usePutRuleset();
+
+ const {
+ queryRuleset,
+ rules,
+ setNewRules,
+ addNewRule,
+ deleteRule,
+ updateRule,
+ isInitialLoading,
+ isError,
+ error,
+ } = useQueryRulesetDetailState({
rulesetId,
});
const [isPopoverActionsOpen, setPopoverActions] = useState(false);
@@ -99,7 +115,6 @@ export const QueryRulesetDetail: React.FC = () => {
}
return tourConfig;
} catch (e) {
- // Handle localStorage access errors (e.g., in private browsing mode)
return {
...tourConfig,
isTourActive: false,
@@ -148,6 +163,37 @@ export const QueryRulesetDetail: React.FC = () => {
});
};
+ const handleSave = () => {
+ setIsFormDirty(false);
+ createRuleset({
+ rulesetId,
+ forceWrite: true,
+ rules,
+ });
+ };
+
+ const [isFormDirty, setIsFormDirty] = useState(false);
+
+ useUnsavedChangesPrompt({
+ cancelButtonText: i18n.translate('xpack.queryRules.queryRulesetDetail.unsavedPrompt.cancel', {
+ defaultMessage: 'Continue setup',
+ }),
+ confirmButtonText: i18n.translate('xpack.queryRules.queryRulesetDetail.unsavedPrompt.confirm', {
+ defaultMessage: 'Leave the page',
+ }),
+ hasUnsavedChanges: isFormDirty,
+ history,
+ http,
+ messageText: i18n.translate('xpack.queryRules.queryRulesetDetail.unsavedPrompt.body', {
+ defaultMessage: 'Make sure to save your changes before leaving this page.',
+ }),
+ navigateToUrl: application.navigateToUrl,
+ openConfirm: overlays?.openConfirm ?? (() => Promise.resolve(false)),
+ titleText: i18n.translate('xpack.queryRules.queryRulesetDetail.unsavedPrompt.title', {
+ defaultMessage: 'Your ruleset has some unsaved changes',
+ }),
+ });
+
return (
{!isInitialLoading && !isError && !!queryRuleset && (
@@ -174,7 +220,30 @@ export const QueryRulesetDetail: React.FC = () => {
color="primary"
data-test-subj="queryRulesetDetailHeader"
rightSideItems={[
-
+
+
+
+ {i18n.translate('xpack.queryRules.queryRulesetDetail.apiReferenceButton', {
+ defaultMessage: 'API reference',
+ })}
+
+
{tourStepsInfo[0].content}
}
@@ -236,7 +305,8 @@ export const QueryRulesetDetail: React.FC = () => {
>
{
fill
color="primary"
data-test-subj="queryRulesetDetailHeaderSaveButton"
- onClick={() => {
- // Logic to save the query ruleset
- }}
+ onClick={handleSave}
+ disabled={!isFormDirty || isInitialLoading}
>
{
button={
{
)}
{!isError && (
<>
-
+
- {tourStepsInfo[1]?.tourTargetRef?.current && (
+ {tourStepsInfo[1]?.tourTargetRef?.current !== null && (
tourStepsInfo[1]?.tourTargetRef?.current || document.body}
content={{tourStepsInfo[1].content}
}
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/use_query_ruleset_detail_state.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/use_query_ruleset_detail_state.tsx
index 7e399f28f0ce2..54904f283a51a 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/use_query_ruleset_detail_state.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/use_query_ruleset_detail_state.tsx
@@ -34,12 +34,32 @@ export const useQueryRulesetDetailState = ({ rulesetId }: UseQueryRulesetDetailS
);
setRules([...newRules]);
};
+ const addNewRule = (newRuleId: string) => {
+ setRules((prevRules) => [
+ ...prevRules,
+ {
+ rule_id: newRuleId,
+ criteria: [],
+ type: 'pinned',
+ actions: {
+ docs: [],
+ },
+ },
+ ]);
+ };
+
+ const deleteRule = (ruleId: string) => {
+ const newRules = rules.filter((rule) => rule.rule_id !== ruleId);
+ setRules(newRules);
+ };
return {
queryRuleset,
rules,
setNewRules: setRules,
updateRule,
+ addNewRule,
+ deleteRule,
isInitialLoading,
isError,
error,
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_document.ts b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_document.ts
new file mode 100644
index 0000000000000..c2f26e925a85d
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_document.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 { useQuery } from '@tanstack/react-query';
+import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
+import { useKibana } from './use_kibana';
+import { SearchQueryDocumentResponse } from '../types';
+
+export const useFetchDocument = (indexName: string, documentId: string) => {
+ const {
+ services: { http },
+ } = useKibana();
+
+ return useQuery({
+ queryKey: ['fetchDocument', indexName, documentId],
+ queryFn: async () => {
+ const response = await http.get(
+ `/internal/search_query_rules/document/${indexName}/${documentId}`
+ );
+ return response;
+ },
+ enabled: Boolean(indexName && documentId),
+ refetchOnWindowFocus: false,
+ retry: false,
+ });
+};
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_index_names.ts b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_index_names.ts
new file mode 100644
index 0000000000000..5294da41864af
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_index_names.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 { useQuery } from '@tanstack/react-query';
+import { APIRoutes } from '../../common/api_routes';
+import { useKibana } from './use_kibana';
+
+export const useFetchIndexNames = (searchQuery: string) => {
+ const {
+ services: { http },
+ } = useKibana();
+
+ return useQuery({
+ queryKey: ['fetchIndexNames', searchQuery],
+ queryFn: async () => {
+ const response = await http.get(APIRoutes.FETCH_INDICES, {
+ ...(searchQuery.trim() === ''
+ ? {}
+ : {
+ query: {
+ searchQuery,
+ },
+ }),
+ });
+ return response;
+ },
+ refetchOnWindowFocus: false,
+ retry: false,
+ });
+};
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_rules_sets.ts b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_rules_sets.ts
index 3b3422aed4558..03c24002362f3 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_rules_sets.ts
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_rules_sets.ts
@@ -30,7 +30,6 @@ export const useFetchQueryRulesSets = (page: Page = DEFAULT_PAGE_VALUE) => {
}
);
},
- refetchOnWindowFocus: false,
retry: false,
});
};
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_generate_rule_id.ts b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_generate_rule_id.ts
new file mode 100644
index 0000000000000..c8fc5c8b084e2
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_generate_rule_id.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 { useMutation } from '@tanstack/react-query';
+import { useKibana } from './use_kibana';
+
+export const useGenerateRuleId = (rulesetId: string) => {
+ const {
+ services: { http },
+ } = useKibana();
+
+ return useMutation({
+ mutationFn: async () => {
+ const response = await http.post<{ ruleId: string }>(
+ `/internal/search_query_rules/ruleset/${rulesetId}/generate_rule_id`
+ );
+ return response.ruleId;
+ },
+ retry: false,
+ });
+};
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_put_query_rules_ruleset.ts b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_put_query_rules_ruleset.ts
index 75ab0ee4c81fa..c229d85f4e9ae 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_put_query_rules_ruleset.ts
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_put_query_rules_ruleset.ts
@@ -19,6 +19,7 @@ import { useKibana } from './use_kibana';
interface MutationArgs {
rulesetId: string;
forceWrite?: boolean;
+ rules?: QueryRulesQueryRuleset['rules'];
}
export const usePutRuleset = (
@@ -31,11 +32,12 @@ export const usePutRuleset = (
} = useKibana();
return useMutation(
- async ({ rulesetId, forceWrite }: MutationArgs) => {
+ async ({ rulesetId, forceWrite, rules }: MutationArgs) => {
return await http.put(
`/internal/search_query_rules/ruleset/${rulesetId}`,
{
query: { forceWrite },
+ ...(rules ? { body: JSON.stringify({ rules }) } : {}),
}
);
},
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_run_query_ruleset.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_run_query_ruleset.tsx
index 23330483996f4..4fdaa4e26b87b 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_run_query_ruleset.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_run_query_ruleset.tsx
@@ -8,12 +8,14 @@
import React, { useMemo } from 'react';
import dedent from 'dedent';
import { TryInConsoleButton } from '@kbn/try-in-console';
+import { EuiButtonColor } from '@elastic/eui';
import { useFetchQueryRuleset } from './use_fetch_query_ruleset';
import { useKibana } from './use_kibana';
export interface UseRunQueryRulesetProps {
rulesetId: string;
type?: 'link' | 'button' | 'emptyButton' | 'contextMenuItem';
content?: string;
+ color?: EuiButtonColor;
onClick?: () => void;
}
@@ -21,6 +23,7 @@ export const UseRunQueryRuleset = ({
rulesetId,
type = 'emptyButton',
content,
+ color,
onClick,
}: UseRunQueryRulesetProps) => {
const { application, share, console: consolePlugin } = useKibana().services;
@@ -112,6 +115,7 @@ export const UseRunQueryRuleset = ({
request={TEST_QUERY_RULESET_API_SNIPPET}
type={type}
content={content}
+ color={color}
showIcon
onClick={onClick}
/>
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/providers/query_ruleset_details_form.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/providers/query_ruleset_details_form.tsx
index 3ff1d648a5624..114df30413707 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/providers/query_ruleset_details_form.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/providers/query_ruleset_details_form.tsx
@@ -24,7 +24,7 @@ export const QueryRulesetDetailsForm: React.FC<
ruleId: '',
criteria: [],
type: 'pinned',
- actions: { docs: [] },
+ actions: { docs: [], ids: [] },
},
});
diff --git a/x-pack/solutions/search/plugins/search_query_rules/server/lib/fetch_indices.test.ts b/x-pack/solutions/search/plugins/search_query_rules/server/lib/fetch_indices.test.ts
new file mode 100644
index 0000000000000..af9c6adfc8ae2
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_query_rules/server/lib/fetch_indices.test.ts
@@ -0,0 +1,62 @@
+/*
+ * 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 { ElasticsearchClient } from '@kbn/core/server';
+
+import { fetchIndices } from './fetch_indices';
+
+describe('fetch indices', () => {
+ const mockIndexResponse = {
+ 'index-1': {
+ aliases: {
+ 'search-alias-1': {},
+ 'search-alias-2': {},
+ },
+ },
+ 'index-2': {
+ aliases: {
+ 'search-alias-3': {},
+ 'search-alias-4': {},
+ },
+ },
+ 'index-3': {
+ aliases: {
+ 'search-alias-1': {},
+ 'search-alias-2': {},
+ },
+ },
+ };
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ const mockClient = {
+ asCurrentUser: { indices: { get: jest.fn() } },
+ };
+
+ it('returns index data with for non-hidden indices', async () => {
+ mockClient.asCurrentUser.indices.get.mockImplementationOnce(() => {
+ return mockIndexResponse;
+ });
+
+ const indexData = await fetchIndices(
+ mockClient.asCurrentUser as unknown as ElasticsearchClient,
+ undefined
+ );
+
+ expect(indexData).toEqual({
+ indexNames: [
+ 'index-1',
+ 'index-2',
+ 'index-3',
+ 'search-alias-1',
+ 'search-alias-2',
+ 'search-alias-3',
+ 'search-alias-4',
+ ],
+ });
+ });
+});
diff --git a/x-pack/solutions/search/plugins/search_query_rules/server/lib/fetch_indices.ts b/x-pack/solutions/search/plugins/search_query_rules/server/lib/fetch_indices.ts
new file mode 100644
index 0000000000000..c60e6b5082610
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_query_rules/server/lib/fetch_indices.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 { IndicesIndexState } from '@elastic/elasticsearch/lib/api/types';
+
+import { ElasticsearchClient } from '@kbn/core/server';
+
+function isHidden(index: IndicesIndexState): boolean {
+ return index.settings?.index?.hidden === true || index.settings?.index?.hidden === 'true';
+}
+function isClosed(index: IndicesIndexState): boolean {
+ return (
+ index.settings?.index?.verified_before_close === true ||
+ index.settings?.index?.verified_before_close === 'true'
+ );
+}
+
+export const fetchIndices = async (
+ client: ElasticsearchClient,
+ searchQuery: string | undefined,
+ { exact }: { exact?: boolean } = { exact: false }
+): Promise<{
+ indexNames: string[];
+}> => {
+ const indexPattern = exact && searchQuery ? searchQuery : searchQuery ? `*${searchQuery}*` : '*';
+ const allIndexMatches = await client.indices.get({
+ expand_wildcards: ['open'],
+ // for better performance only compute aliases and settings of indices but not mappings
+ features: ['aliases', 'settings'],
+ // only get specified index properties from ES to keep the response under 536MB
+ // node.js string length limit: https://github.com/nodejs/node/issues/33960
+ filter_path: ['*.aliases', '*.settings.index.hidden', '*.settings.index.verified_before_close'],
+ index: indexPattern,
+ });
+
+ const allIndexNames = Object.keys(allIndexMatches).filter(
+ (indexName) =>
+ allIndexMatches[indexName] &&
+ !isHidden(allIndexMatches[indexName]) &&
+ !isClosed(allIndexMatches[indexName])
+ );
+
+ const allAliases = allIndexNames.reduce((acc, indexName) => {
+ const aliases = allIndexMatches[indexName].aliases;
+ if (aliases) {
+ Object.keys(aliases).forEach((alias) => {
+ if (!acc.includes(alias)) {
+ acc.push(alias);
+ }
+ });
+ }
+ return acc;
+ }, []);
+
+ const allOptions = [...allIndexNames, ...allAliases];
+
+ const indexNames = searchQuery
+ ? allOptions.filter((indexName) => indexName.includes(searchQuery.toLowerCase()))
+ : allOptions;
+
+ return {
+ indexNames,
+ };
+};
diff --git a/x-pack/solutions/search/plugins/search_query_rules/server/lib/put_query_rules_ruleset_set.ts b/x-pack/solutions/search/plugins/search_query_rules/server/lib/put_query_rules_ruleset_set.ts
index f1978b5fe5029..b3ca8b77bfecb 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/server/lib/put_query_rules_ruleset_set.ts
+++ b/x-pack/solutions/search/plugins/search_query_rules/server/lib/put_query_rules_ruleset_set.ts
@@ -5,13 +5,24 @@
* 2.0.
*/
-import { QueryRulesPutRulesetResponse } from '@elastic/elasticsearch/lib/api/types';
+import {
+ QueryRulesPutRulesetResponse,
+ QueryRulesQueryRuleset,
+} from '@elastic/elasticsearch/lib/api/types';
import { ElasticsearchClient } from '@kbn/core/server';
export const putRuleset = async (
client: ElasticsearchClient,
- rulesetId: string
+ rulesetId: string,
+ rules?: QueryRulesQueryRuleset['rules']
): Promise => {
+ if (rules && rules.length > 0) {
+ return client.queryRules.putRuleset({
+ ruleset_id: rulesetId,
+ rules,
+ });
+ }
+ // TODO: remove this with updated ruleset creation
// Adding mandatory default "criteria" and "actions" values, we should manage temporary empty values before release
return client.queryRules.putRuleset({
ruleset_id: rulesetId,
diff --git a/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts b/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts
index 4cdedbcc8a485..e8ccbfd6d3c01 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts
+++ b/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts
@@ -5,20 +5,22 @@
* 2.0.
*/
-import { IRouter, Logger } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
+import { IRouter, Logger } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
+import { QueryRulesQueryRuleset } from '@elastic/elasticsearch/lib/api/types';
import { APIRoutes } from '../common/api_routes';
-import { errorHandler } from './utils/error_handler';
-import { fetchQueryRulesSets } from './lib/fetch_query_rules_sets';
import { DEFAULT_PAGE_VALUE } from '../common/pagination';
+import { deleteRuleset } from './lib/delete_query_rules_ruleset';
+import { deleteRulesetRule } from './lib/delete_query_rules_ruleset_rule';
+import { fetchIndices } from './lib/fetch_indices';
+import { fetchQueryRulesQueryRule } from './lib/fetch_query_rules_query_rule';
import { fetchQueryRulesRuleset } from './lib/fetch_query_rules_ruleset';
+import { fetchQueryRulesSets } from './lib/fetch_query_rules_sets';
import { isQueryRulesetExist } from './lib/is_query_ruleset_exist';
import { putRuleset } from './lib/put_query_rules_ruleset_set';
-import { fetchQueryRulesQueryRule } from './lib/fetch_query_rules_query_rule';
-import { deleteRuleset } from './lib/delete_query_rules_ruleset';
-import { deleteRulesetRule } from './lib/delete_query_rules_ruleset_rule';
+import { errorHandler } from './utils/error_handler';
export function defineRoutes({ logger, router }: { logger: Logger; router: IRouter }) {
router.get(
@@ -141,6 +143,37 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout
query: schema.object({
forceWrite: schema.boolean({ defaultValue: false }),
}),
+ // TODO: body is not going to be nullable. It will be fixed in the followup PR
+ body: schema.nullable(
+ schema.maybe(
+ schema.object({
+ rules: schema.arrayOf(
+ schema.object({
+ rule_id: schema.string(),
+ type: schema.string(),
+ criteria: schema.arrayOf(
+ schema.object({
+ type: schema.string(),
+ metadata: schema.string(),
+ values: schema.arrayOf(schema.string()),
+ })
+ ),
+ actions: schema.object({
+ ids: schema.maybe(schema.arrayOf(schema.string())),
+ docs: schema.maybe(
+ schema.arrayOf(
+ schema.object({
+ _id: schema.string(),
+ _index: schema.string(),
+ })
+ )
+ ),
+ }),
+ })
+ ),
+ })
+ )
+ ),
},
},
errorHandler(logger)(async (context, request, response) => {
@@ -165,6 +198,7 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout
}
const rulesetId = request.params.ruleset_id;
const forceWrite = request.query.forceWrite;
+ const rules = request.body?.rules as QueryRulesQueryRuleset['rules'] | undefined;
const isExisting = await isQueryRulesetExist(asCurrentUser, rulesetId);
if (isExisting && !forceWrite) {
return response.customError({
@@ -175,7 +209,7 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout
}),
});
}
- const result = await putRuleset(asCurrentUser, rulesetId);
+ const result = await putRuleset(asCurrentUser, rulesetId, rules);
return response.ok({
headers: {
'content-type': 'application/json',
@@ -314,4 +348,186 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout
});
})
);
+
+ router.get(
+ {
+ path: APIRoutes.FETCH_INDICES,
+ options: {
+ access: 'internal',
+ },
+ security: {
+ authz: {
+ requiredPrivileges: ['manage_search_query_rules'],
+ },
+ },
+ validate: {
+ query: schema.object({
+ searchQuery: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ errorHandler(logger)(async (context, request, response) => {
+ const { searchQuery } = request.query;
+ const core = await context.core;
+ const {
+ client: { asCurrentUser },
+ } = core.elasticsearch;
+ const user = core.security.authc.getCurrentUser();
+ if (!user) {
+ return response.customError({
+ statusCode: 502,
+ body: 'Could not retrieve current user, security plugin is not ready',
+ });
+ }
+ const hasSearchQueryRulesPrivilege = await asCurrentUser.security.hasPrivileges({
+ cluster: ['manage_search_query_rules'],
+ });
+ if (!hasSearchQueryRulesPrivilege.has_all_requested) {
+ return response.forbidden({
+ body: "Your user doesn't have manage_search_query_rules privileges",
+ });
+ }
+ const { indexNames } = await fetchIndices(asCurrentUser, searchQuery);
+ return response.ok({
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: indexNames,
+ });
+ })
+ );
+ router.get(
+ {
+ path: APIRoutes.FETCH_DOCUMENT,
+ options: {
+ access: 'internal',
+ },
+ security: {
+ authz: {
+ requiredPrivileges: ['manage_search_query_rules'],
+ },
+ },
+ validate: {
+ params: schema.object({
+ indexName: schema.string(),
+ documentId: schema.string(),
+ }),
+ },
+ },
+ errorHandler(logger)(async (context, request, response) => {
+ const { indexName, documentId } = request.params;
+ const core = await context.core;
+ const {
+ client: { asCurrentUser },
+ } = core.elasticsearch;
+ const user = core.security.authc.getCurrentUser();
+ if (!user) {
+ return response.customError({
+ statusCode: 502,
+ body: 'Could not retrieve current user, security plugin is not ready',
+ });
+ }
+ const hasSearchQueryRulesPrivilege = await asCurrentUser.security.hasPrivileges({
+ cluster: ['manage_search_query_rules'],
+ });
+ if (!hasSearchQueryRulesPrivilege.has_all_requested) {
+ return response.forbidden({
+ body: "Your user doesn't have manage_search_query_rules privileges",
+ });
+ }
+ try {
+ const document = await asCurrentUser.get({
+ index: indexName,
+ id: documentId,
+ });
+ const mappings = await asCurrentUser.indices.getMapping({
+ index: indexName,
+ });
+ return response.ok({
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: {
+ document,
+ mappings,
+ },
+ });
+ } catch (error) {
+ if (error.statusCode === 404) {
+ return response.notFound({
+ body: `Document with ID ${documentId} not found in index ${indexName}`,
+ });
+ }
+ throw error;
+ }
+ })
+ );
+ router.post(
+ {
+ path: APIRoutes.GENERATE_RULE_ID,
+ options: {
+ access: 'internal',
+ },
+ security: {
+ authz: {
+ requiredPrivileges: ['manage_search_query_rules'],
+ },
+ },
+ validate: {
+ params: schema.object({
+ rulesetId: schema.string(),
+ }),
+ },
+ },
+ errorHandler(logger)(async (context, request, response) => {
+ const { rulesetId } = request.params;
+ const core = await context.core;
+ const {
+ client: { asCurrentUser },
+ } = core.elasticsearch;
+ const user = core.security.authc.getCurrentUser();
+
+ if (!user) {
+ return response.customError({
+ statusCode: 502,
+ body: 'Could not retrieve current user, security plugin is not ready',
+ });
+ }
+ const hasSearchQueryRulesPrivilege = await asCurrentUser.security.hasPrivileges({
+ cluster: ['manage_search_query_rules'],
+ });
+ if (!hasSearchQueryRulesPrivilege.has_all_requested) {
+ return response.forbidden({
+ body: "Your user doesn't have manage_search_query_rules privileges",
+ });
+ }
+
+ for (let i = 0; i < 100; i++) {
+ const ruleId = `rule-${Math.floor(Math.random() * 10000)
+ .toString()
+ .slice(-4)}`;
+ // check if it is existing by fetching the rule
+ try {
+ await asCurrentUser.queryRules.getRule({ ruleset_id: rulesetId, rule_id: ruleId });
+ } catch (error) {
+ // if the rule does not exist return the ruleId
+ if (error.statusCode === 404) {
+ return response.ok({
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: { ruleId },
+ });
+ }
+ throw error;
+ }
+ }
+ return response.customError({
+ statusCode: 409,
+ body: i18n.translate('xpack.search.rules.api.routes.generateRuleIdErrorMessage', {
+ defaultMessage: 'Failed to generate a unique rule ID after 100 attempts.',
+ }),
+ });
+ })
+ );
}
diff --git a/x-pack/solutions/search/plugins/search_query_rules/tsconfig.json b/x-pack/solutions/search/plugins/search_query_rules/tsconfig.json
index 574a3cdd43582..f88604a80ec6b 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/tsconfig.json
+++ b/x-pack/solutions/search/plugins/search_query_rules/tsconfig.json
@@ -27,6 +27,9 @@
"@kbn/shared-ux-page-kibana-template",
"@kbn/try-in-console",
"@kbn/share-plugin",
+ "@kbn/search-index-documents",
+ "@kbn/unsaved-changes-prompt",
+ "@kbn/deeplinks-analytics",
],
"exclude": [
"target/**/*",