diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_rules_sets/delete_ruleset_modal.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_rules_sets/delete_ruleset_modal.tsx index 955a050f26abb..2dd07db3cbabf 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_rules_sets/delete_ruleset_modal.tsx +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_rules_sets/delete_ruleset_modal.tsx @@ -20,15 +20,25 @@ import { useDeleteRuleset } from '../../hooks/use_delete_query_rules_ruleset'; export interface DeleteRulesetModalProps { rulesetId: string; closeDeleteModal: () => void; + onSuccess?: () => void; } -export const DeleteRulesetModal = ({ closeDeleteModal, rulesetId }: DeleteRulesetModalProps) => { +export const DeleteRulesetModal = ({ + closeDeleteModal, + rulesetId, + onSuccess: onSuccessProp, +}: DeleteRulesetModalProps) => { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const onSuccess = () => { setIsLoading(false); closeDeleteModal(); + if (onSuccessProp) { + onSuccessProp(); + } }; + const confirmCheckboxId = useGeneratedHtmlId({ prefix: 'confirmCheckboxId', }); 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 3ac351b91f1b0..0e537500c9309 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 @@ -30,7 +30,6 @@ describe('Query rule detail', () => { const TEST_IDS = { DetailPage: 'queryRulesetDetailPage', DetailPageHeader: 'queryRulesetDetailHeader', - HeaderDataButton: 'queryRulesetDetailHeaderDataButton', HeaderSaveButton: 'queryRulesetDetailHeaderSaveButton', AddRuleButton: 'queryRulesetDetailAddRuleButton', DraggableItem: 'searchQueryRulesDraggableItem', @@ -49,7 +48,6 @@ describe('Query rule detail', () => { const header = screen.getByTestId(TEST_IDS.DetailPageHeader); expect(within(header).getByText('my-ruleset')).toBeInTheDocument(); - expect(within(header).getByTestId(TEST_IDS.HeaderDataButton)).toBeInTheDocument(); expect(within(header).getByTestId(TEST_IDS.HeaderSaveButton)).toBeInTheDocument(); expect(screen.getByTestId(TEST_IDS.AddRuleButton)).toBeInTheDocument(); expect(screen.getAllByTestId(TEST_IDS.DraggableItem)).toHaveLength( 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 02a203f99cde7..748fff057d7d0 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 @@ -8,7 +8,17 @@ import React, { useEffect, useState } from 'react'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; -import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { + EuiButton, + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + useGeneratedHtmlId, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { useParams } from 'react-router-dom'; import { QueryRulesQueryRule } from '@elastic/elasticsearch/lib/api/types'; @@ -20,6 +30,8 @@ import { ErrorPrompt } from '../error_prompt/error_prompt'; import { isNotFoundError, isPermissionError } from '../../utils/query_rules_utils'; import { QueryRulesPageTemplate } from '../../layout/query_rules_page_template'; import { QueryRuleDetailPanel } from './query_rule_detail_panel'; +import { UseRunQueryRuleset } from '../../hooks/use_run_query_ruleset'; +import { DeleteRulesetModal } from '../query_rules_sets/delete_ruleset_modal'; export const QueryRulesetDetail: React.FC = () => { const { @@ -38,6 +50,31 @@ export const QueryRulesetDetail: React.FC = () => { const [rules, setRules] = useState(queryRulesetData?.rules ?? []); + const [isPopoverOpen, setPopover] = useState(false); + const splitButtonPopoverId = useGeneratedHtmlId({ + prefix: 'splitButtonPopover', + }); + + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + const items = [ + setRulesetToDelete(rulesetId)} + > + Delete ruleset + , + ]; + + const [rulesetToDelete, setRulesetToDelete] = useState(null); + useEffect(() => { if (queryRulesetData?.rules) { setRules(queryRulesetData.rules); @@ -72,40 +109,68 @@ export const QueryRulesetDetail: React.FC = () => { rightSideItems={[ - { - // Logic to handle data button click - }} - > - - - - - { - // Logic to save the query ruleset - }} - > - - + + + + { + // Logic to save the query ruleset + }} + > + + + + + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + + , ]} /> )} + {rulesetToDelete && ( + { + setRulesetToDelete(null); + }} + onSuccess={() => { + application.navigateToUrl(http.basePath.prepend(`${PLUGIN_ROUTE_ROOT}`)); + }} + /> + )} {!isError && } {isError && ( { console: mockConsole, }, }); + + // Default mock for ruleset data (useFetchQueryRuleset as jest.Mock).mockReturnValue({ data: { rules: [ @@ -53,6 +55,13 @@ describe('UseRunQueryRuleset', () => { }, ], }, + criteria: [ + { + metadata: 'user_query', + values: ['test-query'], + type: 'exact', + }, + ], }, ], }, @@ -76,9 +85,15 @@ describe('UseRunQueryRuleset', () => { expect.anything() ); - // Verify that the request contains the index from the fetched data const buttonProps = (TryInConsoleButton as jest.Mock).mock.calls[0][0]; + // Verify that the request contains the index from the fetched data expect(buttonProps.request).toContain('test-index'); + // Verify the request contains retriever structure + expect(buttonProps.request).toContain('retriever'); + // Verify the request contains the expected query + expect(buttonProps.request).toContain('"query": "pugs"'); + // Verify ruleset_ids are included + expect(buttonProps.request).toContain('test-ruleset'); }); it('renders with custom type and content', () => { @@ -107,11 +122,86 @@ describe('UseRunQueryRuleset', () => { expect(buttonProps.request).toContain('my_index'); }); - it('creates correct query with ruleset ID in the request', () => { - const rulesetId = 'special-test-ruleset'; - render(); + it('handles multiple indices from ruleset data', () => { + (useFetchQueryRuleset as jest.Mock).mockReturnValue({ + data: { + rules: [ + { + actions: { + docs: [ + { _index: 'index1', _id: 'id1' }, + { _index: 'index2', _id: 'id2' }, + ], + }, + }, + ], + }, + isInitialLoading: false, + isError: false, + }); + + render(); + + const buttonProps = (TryInConsoleButton as jest.Mock).mock.calls[0][0]; + expect(buttonProps.request).toContain('index1,index2'); + }); + + it('creates match criteria from ruleset data', () => { + (useFetchQueryRuleset as jest.Mock).mockReturnValue({ + data: { + rules: [ + { + criteria: [ + { + metadata: 'user_query', + values: 'search term', + type: 'exact', + }, + { + metadata: 'user_location', + values: ['US', 'UK'], + type: 'exact', + }, + ], + }, + ], + }, + isInitialLoading: false, + isError: false, + }); + + render(); + + const buttonProps = (TryInConsoleButton as jest.Mock).mock.calls[0][0]; + expect(buttonProps.request).toContain('"user_query": "search term"'); + expect(buttonProps.request).toMatch(/"user_location":\s*\[\s*"US",\s*"UK"\s*\]/); + }); + + it('handles complex nested criteria values', () => { + (useFetchQueryRuleset as jest.Mock).mockReturnValue({ + data: { + rules: [ + { + criteria: [ + { + values: { + nested_field: 'nested value', + another_field: ['array', 'of', 'values'], + }, + type: 'exact', + }, + ], + }, + ], + }, + isInitialLoading: false, + isError: false, + }); + + render(); const buttonProps = (TryInConsoleButton as jest.Mock).mock.calls[0][0]; - expect(buttonProps.request).toContain(`"ruleset_ids": [ "${rulesetId}" ]`); + expect(buttonProps.request).toContain('"nested_field": "nested value"'); + expect(buttonProps.request).toMatch(/"another_field":\s*\[\s*"array",\s*"of",\s*"values"\s*\]/); }); }); 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 30a9c7336e5b8..3f2a36f7d8ac0 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import dedent from 'dedent'; import { TryInConsoleButton } from '@kbn/try-in-console'; import { useFetchQueryRuleset } from './use_fetch_query_ruleset'; @@ -23,32 +23,84 @@ export const UseRunQueryRuleset = ({ }: UseRunQueryRulesetProps) => { const { application, share, console: consolePlugin } = useKibana().services; const { data: queryRulesetData } = useFetchQueryRuleset(rulesetId); - const indecesRuleset = queryRulesetData?.rules?.[0]?.actions?.docs?.[0]?._index || 'my_index'; + // Loop through all actions children to gather unique _index values + const { indices, matchCriteria } = useMemo((): { indices: string; matchCriteria: string } => { + const indicesSet = new Set(); + const criteriaData = []; + + for (const rule of queryRulesetData?.rules ?? []) { + // Collect indices + rule.actions?.docs?.forEach((doc) => { + if (doc._index) indicesSet.add(doc._index); + }); + + // Collect criteria + const criteriaArray = Array.isArray(rule.criteria) + ? rule.criteria + : rule.criteria + ? [rule.criteria] + : []; + + for (const criterion of criteriaArray) { + if ( + criterion.values && + typeof criterion.values === 'object' && + !Array.isArray(criterion.values) + ) { + Object.entries(criterion.values).forEach(([key, value]) => { + criteriaData.push({ metadata: key, values: value }); + }); + } else { + criteriaData.push({ + metadata: criterion.metadata || null, + values: criterion.values || null, + }); + } + } + } + + const reducedCriteria = criteriaData.reduce>( + (acc, { metadata, values }) => { + if (metadata && values !== undefined) acc[metadata] = values; + return acc; + }, + {} + ); + + return { + indices: indicesSet.size > 0 ? Array.from(indicesSet).join(',') : 'my_index', + matchCriteria: + Object.keys(reducedCriteria).length > 0 + ? JSON.stringify(reducedCriteria, null, 2).split('\n').join('\n ') + : `{\n "user_query": "pugs"\n }`, + }; + }, [queryRulesetData]); + // Example based on https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-rule-query#_example_request_2 const TEST_QUERY_RULESET_API_SNIPPET = dedent` -# Test your query ruleset -GET ${indecesRuleset}/_search -{ - "retriever": { - "rule": { + # Query Rules Retriever Example + # https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers#rule-retriever + GET ${indices}/_search + { "retriever": { - "standard": { - "query": { - "query_string": { - "query": "puggles" + "rule": { + "match_criteria": ${matchCriteria}, + "ruleset_ids": [ + "${rulesetId}" // An array of one or more unique query ruleset IDs + ], + "retriever": { + "standard": { + "query": { + "query_string": { + "query": "pugs" + } + } } } } - }, - "match_criteria": { - "query_string": "puggles", - "user_country": "us" - }, - "ruleset_ids": [ "${rulesetId}" ] + } } - } -} -`; + `; return (