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/**/*",