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 ff6e27c1702c4..2526d198ad1e9 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,6 +11,7 @@ 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}', + QUERY_RULES_RULESET_EXISTS = '/internal/search_query_rules/ruleset/{rulesetId}/exists', 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', diff --git a/x-pack/solutions/search/plugins/search_query_rules/common/constants.ts b/x-pack/solutions/search/plugins/search_query_rules/common/constants.ts index 8fd46622cdd42..c8ac9fa9fb967 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/common/constants.ts +++ b/x-pack/solutions/search/plugins/search_query_rules/common/constants.ts @@ -10,6 +10,8 @@ import dedent from 'dedent'; export const QUERY_RULES_SETS_QUERY_KEY = 'query-rules-sets-fetch'; export const QUERY_RULES_QUERY_RULESET_FETCH_KEY = 'query-rules-ruleset-fetch'; export const QUERY_RULES_QUERY_RULE_FETCH_KEY = 'query-rules-query-rule-fetch'; +export const QUERY_RULES_QUERY_RULESET_EXISTS_KEY = 'query-rules-query-ruleset-exists'; + export const CREATE_QUERY_RULE_SET_API_SNIPPET = dedent`# Create or update a query ruleset PUT /_query_rules/my-ruleset { diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/empty_prompt.test.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/empty_prompt.test.tsx index c3a2715717fc7..bc92a8223024c 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/empty_prompt.test.tsx +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/empty_prompt.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EmptyPrompt } from './empty_prompt'; -import { render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { I18nProvider } from '@kbn/i18n-react'; jest.mock('../../../common/doc_links', () => ({ @@ -21,16 +21,32 @@ const Wrapper = ({ children }: { children?: React.ReactNode }) => ( ); const mockGetStartedAction = jest.fn(); +const TEST_IDS = { + getStartedButton: 'searchQueryRulesEmptyPromptGetStartedButton', + footerLink: 'searchQueryRulesEmptyPromptFooterLink', +}; + +const ACTIONS = { + getStarted: () => { + act(() => { + fireEvent.click(screen.getByTestId(TEST_IDS.getStartedButton)); + }); + }, +}; + describe('Query Rules Overview Empty Prompt', () => { - it('renders', () => { - render( - - - - ); - expect(screen.getByTestId('searchQueryRulesEmptyPromptGetStartedButton')).toBeInTheDocument(); - expect(screen.getByTestId('searchQueryRulesEmptyPromptFooterLink').getAttribute('href')).toBe( - 'documentation-url' - ); + it('renders correctly', () => { + render(, { + wrapper: Wrapper, + }); + + expect(screen.getByTestId(TEST_IDS.getStartedButton)).toBeInTheDocument(); + expect(screen.getByTestId(TEST_IDS.footerLink).getAttribute('href')).toBe('documentation-url'); + }); + + it('calls getStartedAction when button is clicked', () => { + render(, { wrapper: Wrapper }); + ACTIONS.getStarted(); + expect(mockGetStartedAction).toHaveBeenCalled(); }); }); diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/ruleset_detail_empty_prompt.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/ruleset_detail_empty_prompt.tsx new file mode 100644 index 0000000000000..a797fa267ca10 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/empty_prompt/ruleset_detail_empty_prompt.tsx @@ -0,0 +1,63 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiText, + transparentize, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import queryRulesImg from '../../assets/query-rules-context-alt.svg'; + +export const RulesetDetailEmptyPrompt = () => { + const { euiTheme } = useEuiTheme(); + const positionRelative = css({ + position: 'relative', + }); + const imgProps = css({ + width: '100%', + height: '100%', + objectFit: 'cover', + }); + const gradientOverlay = css({ + background: `linear-gradient(180deg, ${transparentize( + euiTheme.colors.backgroundBasePlain, + 0 + )}, ${transparentize(euiTheme.colors.backgroundBasePlain, 1)} 100%)`, + position: 'absolute', + bottom: 0, + height: '30px', + width: '100%', + }); + + return ( + + + + + + + + + + Query Rules +
 
+
+
+
+ ); +}; diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/error_prompt/error_prompt.test.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/error_prompt/error_prompt.test.tsx new file mode 100644 index 0000000000000..fa4e5ab8480db --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/error_prompt/error_prompt.test.tsx @@ -0,0 +1,49 @@ +/* + * 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 { ErrorPrompt } from './error_prompt'; +import { render } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} +); +describe('ErrorPrompt', () => { + it("renders 'notFound' error type", () => { + const { getByText } = render(, { wrapper: Wrapper }); + + expect(getByText('Not found')).toBeInTheDocument(); + expect( + getByText('Requested resource was not found. Check if the URL is correct.') + ).toBeInTheDocument(); + }); + + it("renders 'generic' error type", () => { + const { getByText } = render(, { wrapper: Wrapper }); + + expect(getByText('An error occurred')).toBeInTheDocument(); + expect( + getByText( + 'An error occurred while fetching query rules. Check Kibana logs for more information.' + ) + ).toBeInTheDocument(); + }); + + it("renders 'missingPermissions' error type", () => { + const { getByText } = render(, { + wrapper: Wrapper, + }); + + expect(getByText('Missing permissions')).toBeInTheDocument(); + expect( + getByText( + 'You do not have the necessary permissions to manage query rules. Contact your system administrator.' + ) + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/overview/create_ruleset_modal.test.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/overview/create_ruleset_modal.test.tsx new file mode 100644 index 0000000000000..efc03ae0ddfee --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/overview/create_ruleset_modal.test.tsx @@ -0,0 +1,145 @@ +/* + * 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 { I18nProvider } from '@kbn/i18n-react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { CreateRulesetModal } from './create_ruleset_modal'; +import { useFetchQueryRulesetExist } from '../../hooks/use_fetch_ruleset_exists'; +import { useKibana } from '../../hooks/use_kibana'; + +const Wrapper = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; + +const TEST_IDS = { + ModalHeaderTitle: 'searchRulesetCreateRulesetModalHeader', + NameInput: 'searchRulesetCreateRulesetModalFieldText', + CreateButton: 'searchRulesetCreateRulesetModalCreateButton', + CloseButton: 'searchRulesetCreateRulesetModalCancelButton', + EditLink: 'searchRulesetCreateRulesetModalEditLink', +}; + +const ACTIONS = { + PressCloseButton: () => { + act(() => { + fireEvent.click(screen.getByTestId(TEST_IDS.CloseButton)); + }); + }, + TypeName: (name: string) => { + const nameInput = screen.getByTestId(TEST_IDS.NameInput) as HTMLInputElement; + act(() => { + fireEvent.change(nameInput, { target: { value: name } }); + }); + }, + PressEditLink: () => { + act(() => { + fireEvent.click(screen.getByTestId(TEST_IDS.EditLink)); + }); + }, +}; + +const mockOnClose = jest.fn(); + +const mockUseFetchQueryRulesetExist = useFetchQueryRulesetExist as jest.Mock; +jest.mock('../../hooks/use_fetch_ruleset_exists', () => ({ + useFetchQueryRulesetExist: jest.fn().mockImplementation(() => ({ + data: undefined, + isLoading: false, + isError: false, + })), +})); + +jest.mock('../../hooks/use_kibana', () => ({ + useKibana: () => ({ + services: { + http: { + basePath: { + prepend: (path: string) => path, + }, + }, + application: { + navigateToUrl: jest.fn(), + }, + }, + }), +})); + +describe('CreateRulesetModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders', () => { + render(, { wrapper: Wrapper }); + + expect(screen.getByTestId(TEST_IDS.ModalHeaderTitle).textContent).toBe('Create ruleset'); + + expect(screen.getByTestId(TEST_IDS.NameInput)).toBeInTheDocument(); + + expect(screen.getByTestId(TEST_IDS.CreateButton).textContent).toBe('Create ruleset'); + }); + + it('calls onClose when modal is closed', () => { + render(, { wrapper: Wrapper }); + + ACTIONS.PressCloseButton(); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should show conflict error when ruleset name already exists', () => { + mockUseFetchQueryRulesetExist.mockReturnValue({ + data: { exists: true }, + isLoading: false, + isError: false, + }); + render(, { wrapper: Wrapper }); + + ACTIONS.TypeName('existing-ruleset'); + act(() => { + mockUseFetchQueryRulesetExist.mock.calls[0][2]('existing-ruleset'); + }); + + waitFor(() => { + expect(screen.getByText('Ruleset name already exists')).toBeInTheDocument(); + }); + waitFor(() => { + expect(screen.getByTestId(TEST_IDS.CreateButton)).toBeDisabled(); + }); + waitFor(() => { + expect(screen.getByTestId(TEST_IDS.EditLink)).toHaveAttribute( + 'href', + '/app/search_query_rules/ruleset/existing-ruleset/edit' + ); + }); + + ACTIONS.PressEditLink(); + waitFor(() => { + expect(useKibana().services.application.navigateToUrl).toHaveBeenCalledWith( + '/app/search_query_rules/ruleset/existing-ruleset/edit' + ); + }); + }); + + it('should redirect user to create endpoint with given name', () => { + const mockNavigateToUrl = jest.fn(); + useKibana().services.application.navigateToUrl = mockNavigateToUrl; + + render(, { wrapper: Wrapper }); + + ACTIONS.TypeName('new-ruleset'); + act(() => { + mockUseFetchQueryRulesetExist.mock.calls[0][1]('new-ruleset'); + }); + + waitFor(() => { + expect(mockNavigateToUrl).toHaveBeenCalledWith( + '/app/search_query_rules/ruleset/new-ruleset/create' + ); + }); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_rules_sets/create_ruleset_modal.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/overview/create_ruleset_modal.tsx similarity index 69% rename from x-pack/solutions/search/plugins/search_query_rules/public/components/query_rules_sets/create_ruleset_modal.tsx rename to x-pack/solutions/search/plugins/search_query_rules/public/components/overview/create_ruleset_modal.tsx index f3079b4e41bd9..3b62093054398 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_rules_sets/create_ruleset_modal.tsx +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/overview/create_ruleset_modal.tsx @@ -10,46 +10,56 @@ import React, { useState } from 'react'; import { EuiButton, EuiButtonEmpty, - EuiCheckbox, EuiFieldText, EuiForm, EuiFormRow, + EuiLink, EuiModal, EuiModalBody, EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, EuiSpacer, + EuiText, useGeneratedHtmlId, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { formatRulesetName } from '../../utils/query_rules_utils'; -import { usePutRuleset } from '../../hooks/use_put_query_rules_ruleset'; +import { useKibana } from '../../hooks/use_kibana'; +import { useFetchQueryRulesetExist } from '../../hooks/use_fetch_ruleset_exists'; +import { PLUGIN_ROUTE_ROOT } from '../../../common/api_routes'; interface CreateRulesetModalProps { onClose: () => void; } export const CreateRulesetModal = ({ onClose }: CreateRulesetModalProps) => { const titleId = useGeneratedHtmlId({ prefix: 'createRulesetModalTitle' }); const formId = useGeneratedHtmlId({ prefix: 'createRulesetModalForm' }); - const overwriteId = useGeneratedHtmlId({ prefix: 'createRulesetModalOverwrite' }); const [name, setName] = useState(''); const [rawName, setRawName] = useState(''); const [conflictError, setConflictError] = useState(false); - const [forceWrite, setForceWrite] = useState(false); - const { mutate: createRuleset } = usePutRuleset( + const [checkName, setCheckName] = useState(''); + const { data: rulesetExist } = useFetchQueryRulesetExist( + checkName, () => { - onClose(); + application.navigateToUrl( + http.basePath.prepend(`${PLUGIN_ROUTE_ROOT}/ruleset/${name}/create`) + ); }, () => { setConflictError(true); } ); + + const { + services: { http, application }, + } = useKibana(); + return ( - + { component="form" onSubmit={(e) => { e.preventDefault(); - createRuleset({ rulesetId: name, forceWrite }); + setCheckName(name); }} > { setRawName(e.target.value); setName(formatRulesetName(e.target.value)); setConflictError(false); - setForceWrite(false); + setCheckName(''); }} /> {conflictError && ( <> - - setForceWrite(!forceWrite)} + + + + + + - + { + application.navigateToUrl( + http.basePath.prepend(`${PLUGIN_ROUTE_ROOT}/ruleset/${name}`) + ); + }} + > + {name} + + )} @@ -133,7 +155,7 @@ export const CreateRulesetModal = ({ onClose }: CreateRulesetModalProps) => { data-test-subj="searchRulesetCreateRulesetModalCreateButton" form={formId} fill - disabled={!name || (conflictError && !forceWrite)} + disabled={!name || (!!checkName && rulesetExist)} type="submit" > void; setIsFormDirty?: (isDirty: boolean) => void; updateRule: (updatedRule: SearchQueryRulesQueryRule) => void; - addNewRule: (newRuleId: string) => void; + addNewRule: (newRule: SearchQueryRulesQueryRule) => void; deleteRule?: (ruleId: string) => void; rulesetId: QueryRulesQueryRuleset['ruleset_id']; tourInfo?: { @@ -29,6 +30,7 @@ interface QueryRuleDetailPanelProps { content: string; tourTargetRef?: React.RefObject; }; + createMode?: boolean; } export const QueryRuleDetailPanel: React.FC = ({ rulesetId, @@ -39,10 +41,22 @@ export const QueryRuleDetailPanel: React.FC = ({ updateRule, addNewRule, deleteRule, + createMode = false, }) => { const [ruleIdToEdit, setRuleIdToEdit] = React.useState(null); + const [flyoutMode, setFlyoutMode] = React.useState<'create' | 'edit'>('edit'); const { mutate: generateRuleId } = useGenerateRuleId(rulesetId); + useEffect(() => { + if (createMode && rules.length === 0) { + generateRuleId(undefined, { + onSuccess: (newRuleId) => { + setFlyoutMode('create'); + setRuleIdToEdit(newRuleId); + }, + }); + } + }, [createMode, rules.length, addNewRule, generateRuleId, rulesetId]); return ( @@ -52,13 +66,20 @@ export const QueryRuleDetailPanel: React.FC = ({ rulesetId={rulesetId} ruleId={ruleIdToEdit} onSave={(rule) => { - updateRule(rule); + if (flyoutMode === 'create') { + addNewRule(rule); + } else { + updateRule(rule); + } setRuleIdToEdit(null); + setFlyoutMode('edit'); }} onClose={() => { setRuleIdToEdit(null); + setFlyoutMode('edit'); }} setIsFormDirty={setIsFormDirty} + createMode={flyoutMode === 'create'} /> )} @@ -75,7 +96,7 @@ export const QueryRuleDetailPanel: React.FC = ({ onClick={() => { generateRuleId(undefined, { onSuccess: (newRuleId) => { - addNewRule(newRuleId); + setFlyoutMode('create'); setRuleIdToEdit(newRuleId); }, }); @@ -102,19 +123,25 @@ export const QueryRuleDetailPanel: React.FC = ({ - { - setNewRules(newRules); - if (setIsFormDirty) { - setIsFormDirty(true); - } - }} - onEditRuleFlyoutOpen={(ruleId: string) => setRuleIdToEdit(ruleId)} - tourInfo={tourInfo} - deleteRule={deleteRule} - /> + {rules.length === 0 && } + {rules.length > 0 && ( + { + setNewRules(newRules); + if (setIsFormDirty) { + setIsFormDirty(true); + } + }} + onEditRuleFlyoutOpen={(ruleId: string) => { + setFlyoutMode('edit'); + 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/delete_ruleset_rule_modal.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/delete_ruleset_rule_modal.tsx index c5efb4f230f6a..e610cbccfb13b 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/delete_ruleset_rule_modal.tsx +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/delete_ruleset_rule_modal.tsx @@ -7,30 +7,18 @@ import React, { useState } from 'react'; -import { - EuiCheckbox, - EuiCodeBlock, - EuiConfirmModal, - EuiSpacer, - useGeneratedHtmlId, -} from '@elastic/eui'; +import { EuiCheckbox, EuiConfirmModal, EuiSpacer, useGeneratedHtmlId } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useDeleteRulesetRule } from '../../../hooks/use_delete_query_rules_rule'; export interface DeleteRulesetRuleModalProps { - rulesetId: string; - ruleId: string; closeDeleteModal: () => void; - onSuccessAction?: () => void; + onConfirm?: () => void; } export const DeleteRulesetRuleModal = ({ closeDeleteModal, - rulesetId, - ruleId, - onSuccessAction, + onConfirm: onSuccessAction, }: DeleteRulesetRuleModalProps) => { - const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const onSuccess = () => { setIsLoading(false); @@ -44,15 +32,11 @@ export const DeleteRulesetRuleModal = ({ }); const [checked, setChecked] = useState(false); - const onError = (errorMessage: string) => { - setIsLoading(false); - setError(errorMessage); - }; - const { mutate: deleteEndpoint } = useDeleteRulesetRule(onSuccess, onError); const deleteOperation = () => { setIsLoading(true); - deleteEndpoint({ rulesetId, ruleId }); + onSuccess(); }; + return ( - - {error && ( - - {error} - - )} ); }; diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/query_rule_draggable_item_criteria_display.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/query_rule_draggable_item_criteria_display.tsx index 8f4a0078e2c36..141ffebb543e2 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/query_rule_draggable_item_criteria_display.tsx +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_draggable_list/query_rule_draggable_item_criteria_display.tsx @@ -15,8 +15,18 @@ export const QueryRuleDraggableItemCriteriaDisplay: React.FC<{ const { euiTheme } = useEuiTheme(); return ( - {criteria.metadata}  - {criteria.type}  + {Boolean(criteria.metadata) && ( + <> + {criteria.metadata}  + + )} + {criteria.type === 'always' ? ( + {criteria.type} + ) : ( + <> + {criteria.type}  + + )} {criteria.values?.join(', ')} ); 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 b7aaffdf0c380..512b115efd23b 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 @@ -74,10 +74,8 @@ export const QueryRuleDraggableListItem: React.FC {ruleToDelete && ( setRuleToDelete(null)} - onSuccessAction={() => { + onConfirm={() => { if (deleteRule) { deleteRule(ruleToDelete); } 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 index e27da4594436d..22a29ad02c97c 100644 --- 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 @@ -36,6 +36,8 @@ export const DocumentSelector: React.FC = ({ }) => { const { data, error, isError, isLoading } = useFetchDocument(index, initialDocId); const { document, mappings } = data || {}; + // Otherwise it will show loading until first document is fetched + const showLoading = Boolean(isLoading && index && initialDocId); return ( = ({ onIdSelectorChange={onIdSelectorChange} onIndexSelectorChange={onIndexSelectorChange} onDeleteDocument={onDeleteDocument} - isLoading={isLoading} + isLoading={showLoading} 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/document_selector/draggable_list.test.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/draggable_list.test.tsx new file mode 100644 index 0000000000000..457d3c797028e --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/draggable_list.test.tsx @@ -0,0 +1,118 @@ +/* + * 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 { QueryRulesQueryRuleType } from '@elastic/elasticsearch/lib/api/types'; +import { OnDragEndResponder } from '@hello-pangea/dnd'; +import { I18nProvider } from '@kbn/i18n-react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { FieldArrayWithId } from 'react-hook-form'; +import { QueryRuleEditorForm } from '../../../../../common/types'; +import { DraggableList } from './draggable_list'; + +const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + +); + +jest.mock('../../../../hooks/use_fetch_document', () => ({ + useFetchDocument: jest.fn().mockReturnValue({ + isLoading: false, + isError: false, + data: null, + refetch: jest.fn(), + }), +})); + +const TEST_IDS = { + DraggableItemDocs: ( + index: number, + doc: FieldArrayWithId | string + ) => { + return `queryRuleDocumentDraggable-${typeof doc === 'string' ? 'id' : 'doc'}-${ + typeof doc === 'string' ? doc : doc._id + }-${index}`; + }, +}; + +describe('DraggableList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render items in docs mode', () => { + const actionFields = [ + { + _id: 'doc-1', + _index: 'index-1', + }, + { + _id: 'doc-2', + _index: 'index-2', + }, + ] as Array>; + const actionIdsFields = [] as string[]; + const pinType = 'pinned' as QueryRulesQueryRuleType; + const isIdRule = false; + const indexNames = [] as string[]; + const onDeleteDocument = jest.fn() as (index: number) => void; + const onIndexSelectorChange = jest.fn() as (index: number, indexName: string) => void; + const onIdSelectorChange = jest.fn() as (index: number, id: string) => void; + const dragEndHandle = jest.fn() as OnDragEndResponder; + + render( + , + { wrapper: Wrapper } + ); + + expect(screen.getByTestId(TEST_IDS.DraggableItemDocs(0, actionFields[0]))).toBeInTheDocument(); + expect(screen.getByTestId(TEST_IDS.DraggableItemDocs(1, actionFields[1]))).toBeInTheDocument(); + }); + + it('should render items in ids mode', () => { + const actionFields = [] as Array>; + const actionIdsFields = ['id-1', 'id-2']; + const pinType = 'pinned' as QueryRulesQueryRuleType; + const isIdRule = true; + const indexNames = [] as string[]; + const onDeleteDocument = jest.fn() as (index: number) => void; + const onIndexSelectorChange = jest.fn() as (index: number, indexName: string) => void; + const onIdSelectorChange = jest.fn() as (index: number, id: string) => void; + const dragEndHandle = jest.fn() as OnDragEndResponder; + + render( + , + { wrapper: Wrapper } + ); + + expect(screen.getByTestId(TEST_IDS.DraggableItemDocs(0, 'id-1'))).toBeInTheDocument(); + expect(screen.getByTestId(TEST_IDS.DraggableItemDocs(1, 'id-2'))).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/draggable_list.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/draggable_list.tsx new file mode 100644 index 0000000000000..e955496d00d3e --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/draggable_list.tsx @@ -0,0 +1,104 @@ +/* + * 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 { EuiDragDropContext, EuiDroppable, EuiDraggable } from '@elastic/eui'; +import type { FieldArrayWithId } from 'react-hook-form'; +import type { QueryRulesQueryRuleType } from '@elastic/elasticsearch/lib/api/types'; +import type { OnDragEndResponder } from '@hello-pangea/dnd'; +import { QueryRuleEditorForm } from '../../../../../common/types'; +import { DocumentSelector } from './document_selector'; + +interface DraggableListProps { + actionFields: Array>; + actionIdsFields?: string[]; + pinType: QueryRulesQueryRuleType; + isIdRule?: boolean; + indexNames?: string[]; + dragEndHandle: OnDragEndResponder; + onDeleteDocument: (index: number) => void; + onIndexSelectorChange: (index: number, indexName: string) => void; + onIdSelectorChange: (index: number, id: string) => void; +} +export const DraggableList: React.FC = ({ + actionFields, + actionIdsFields, + pinType, + isIdRule = false, + indexNames = [], + onDeleteDocument, + onIndexSelectorChange, + onIdSelectorChange, + dragEndHandle, +}) => { + return ( + + + {isIdRule && actionIdsFields + ? actionIdsFields.map((doc, index) => ( + + {() => ( + { + onDeleteDocument(index); + }} + onIdSelectorChange={(id) => { + onIdSelectorChange(index, id); + }} + /> + )} + + )) + : actionFields.map((doc, index) => ( + + {() => ( + { + onDeleteDocument(index); + }} + onIndexSelectorChange={(indexName) => { + onIndexSelectorChange(index, indexName); + }} + onIdSelectorChange={(id) => { + onIdSelectorChange(index, id); + }} + indices={indexNames} + /> + )} + + )) || <>} + + + ); +}; diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/rule_type_selector.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/rule_type_selector.tsx new file mode 100644 index 0000000000000..cb86bb8cd6e90 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/document_selector/rule_type_selector.tsx @@ -0,0 +1,64 @@ +/* + * 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 { EuiButtonGroup, EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +interface QueryRuleTypeSelectorProps { + setIsFlyoutDirty: (isDirty: boolean) => void; + onChange: (value: string) => void; + selectedId: string; +} +export const QueryRuleTypeSelector: React.FC = ({ + setIsFlyoutDirty, + onChange, + selectedId, +}) => ( + + +   + + + ), + }, + { + 'data-test-subj': 'searchQueryRulesQueryRuleActionTypeExclude', + id: 'exclude', + label: ( + <> + +   + + + ), + }, + ]} + onChange={(id) => { + setIsFlyoutDirty(true); + onChange(id); + }} + buttonSize="compressed" + type="single" + idSelected={selectedId} + /> +); diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/metadata_type_selector.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/metadata_type_selector.tsx new file mode 100644 index 0000000000000..3e995cb1f3e2e --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/metadata_type_selector.tsx @@ -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 React from 'react'; + +import { EuiButtonGroup } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +interface MetadataTypeSelectorProps { + setIsFlyoutDirty: (isDirty: boolean) => void; + isAlways: boolean; + setIsAlways: (isAlways: boolean) => void; +} +export const MetadataTypeSelector: React.FC = ({ + setIsFlyoutDirty, + isAlways, + setIsAlways, +}) => { + return ( + + + + ), + }, + { + 'data-test-subj': 'searchQueryRulesQueryRuleCriteriaAlways', + id: 'always', + label: ( + <> + + + ), + }, + ]} + onChange={(id) => { + setIsFlyoutDirty(true); + setIsAlways(id === 'always'); + }} + buttonSize="compressed" + type="single" + idSelected={isAlways ? 'always' : 'custom'} + /> + ); +}; diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/pin_exclude_document_button.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/pin_exclude_document_button.tsx new file mode 100644 index 0000000000000..b50f785276b2b --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/pin_exclude_document_button.tsx @@ -0,0 +1,57 @@ +/* + * 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 { EuiButton } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { QueryRulesQueryRuleType } from '@elastic/elasticsearch/lib/api/types'; + +interface ExcludePinDocumentButtonProps { + documentCount: number; + addNewAction: () => void; + pinType: QueryRulesQueryRuleType; +} +export const ExcludePinDocumentButton: React.FC = ({ + documentCount, + addNewAction, + pinType, +}) => { + return ( + + {pinType === 'pinned' ? ( + documentCount === 0 ? ( + + ) : ( + + ) + ) : documentCount === 0 ? ( + + ) : ( + + )} + + ); +}; diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout.tsx index 6c3364678f3fa..c2f1ead1b6e83 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout.tsx +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout.tsx @@ -5,44 +5,38 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { EuiButton, EuiButtonEmpty, - EuiButtonGroup, EuiCallOut, - EuiDragDropContext, - EuiDraggable, - EuiDroppable, EuiFlexGroup, EuiFlexItem, EuiFlyout, EuiFlyoutBody, EuiFlyoutFooter, - EuiFlyoutHeader, EuiHorizontalRule, - EuiIcon, - EuiIconTip, EuiLink, EuiPanel, EuiSpacer, EuiText, - EuiTitle, - euiDragDropReorder, useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form'; import { DISCOVER_APP_ID } from '@kbn/deeplinks-analytics'; -import { useFetchIndexNames } from '../../../hooks/use_fetch_index_names'; -import { QueryRuleEditorForm, SearchQueryRulesQueryRule } from '../../../types'; -import { DocumentSelector } from './document_selector/document_selector'; -import { isCriteriaAlways } from '../../../utils/query_rules_utils'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Controller } from 'react-hook-form'; +import { useKibana } from '../../../hooks/use_kibana'; +import { SearchQueryRulesQueryRule } from '../../../types'; import { QueryRuleFlyoutBody, QueryRuleFlyoutPanel } from '../styles'; import { QueryRuleMetadataEditor } from './query_rule_metadata_editor'; -import { useKibana } from '../../../hooks/use_kibana'; +import { useQueryRuleFlyoutState } from './use_query_rule_flyout_state'; +import { QueryRuleFlyoutHeader } from './query_rule_flyout_header'; +import { QueryRuleTypeSelector } from './document_selector/rule_type_selector'; +import { ExcludePinDocumentButton } from './pin_exclude_document_button'; +import { MetadataTypeSelector } from './metadata_type_selector'; +import { DraggableList } from './document_selector/draggable_list'; export interface QueryRuleFlyoutProps { rules: SearchQueryRulesQueryRule[]; @@ -51,6 +45,7 @@ export interface QueryRuleFlyoutProps { ruleId: string; rulesetId: string; setIsFormDirty?: (isDirty: boolean) => void; + createMode?: boolean; } export const QueryRuleFlyout: React.FC = ({ @@ -60,143 +55,65 @@ export const QueryRuleFlyout: React.FC = ({ ruleId, rulesetId, setIsFormDirty, + createMode = false, }) => { 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({ + actionFields, + actionIdsFields, + appendAction: appendNewAction, control, - name: 'type', - }); - const actionIdsFields = useWatch({ - control, - name: 'actions.ids', + documentCount, + dragEndHandle, + criteria, + criteriaCount, + handleAddCriteria, + handleSave, + indexNames, + isAlways, + isFlyoutDirty, + isIdRule, + onIndexSelectorChange, + pinType, + remove, + onIdSelectorChange, + onDeleteDocument, + setCriteriaCalloutActive, + setIsAlways, + setIsFlyoutDirty, + shouldShowCriteriaCallout, + shouldShowMetadataEditor, + update, + } = useQueryRuleFlyoutState({ + createMode, + rulesetId, + ruleId, + rules, + setIsFormDirty, + onSave, }); - const { data: indexNames } = useFetchIndexNames(''); + const { + services: { application }, + } = useKibana(); const { euiTheme } = useEuiTheme(); - const ruleFromRuleset = rules.find((rule) => rule.rule_id === ruleId); - const [isAlways, setIsAlways] = useState( - (ruleFromRuleset?.criteria && isCriteriaAlways(ruleFromRuleset?.criteria)) ?? false - ); - const isIdRule = Boolean(actionFields.length === 0 && actionIdsFields?.length); - const isDocRule = Boolean(actionFields.length > 0); + const dndBackgroundColor = css` + background-color: ${euiTheme.colors.backgroundBaseFormsPrepend}; + `; - useEffect(() => { - if (ruleFromRuleset) { - reset({ - ...getValues(), - criteria: ruleFromRuleset.criteria, - type: ruleFromRuleset.type, - actions: ruleFromRuleset.actions, - mode: 'edit', - ruleId, - }); - setIsAlways( - (ruleFromRuleset?.criteria && isCriteriaAlways(ruleFromRuleset?.criteria)) ?? false - ); - } - }, [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]); + const pinExcludeText = + pinType === 'pinned' ? ( + + ) : ( + + ); return ( = ({ overflowY: 'hidden', })} > - - - - -

- -

-
-
- - - - - - - - } - position="right" - /> - - - -
-
+ @@ -263,65 +145,17 @@ export const QueryRuleFlyout: React.FC = ({ control={control} name="type" render={({ field: { value, onChange } }) => ( - - -   - - - ), - }, - { - 'data-test-subj': 'searchQueryRulesQueryRuleActionTypeExclude', - id: 'exclude', - label: ( - <> - -   - - - ), - }, - ]} - onChange={(id) => { - setIsFlyoutDirty(true); - onChange(id); - }} - buttonSize="compressed" - type="single" - idSelected={value} + )} /> -

- {getValues('type') === 'pinned' ? ( - - ) : ( - - )} -

+

{pinExcludeText}

@@ -332,10 +166,7 @@ export const QueryRuleFlyout: React.FC = ({ id="xpack.search.queryRulesetDetail.queryRuleFlyout.documentCount" defaultMessage="{documentCount, plural, one {# document} other {# documents}}" values={{ - documentCount: - (ruleFromRuleset?.actions.ids?.length || - ruleFromRuleset?.actions.docs?.length) ?? - 0, + documentCount, }} /> @@ -366,120 +197,20 @@ export const QueryRuleFlyout: React.FC = ({ - - { - 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 ? ( + {pinType === 'pinned' && documentCount !== 0 && ( = ({ /> } /> - ) : null} + )} - - {pinType === 'pinned' ? ( - actionFields.length === 0 ? ( - - ) : ( - - ) - ) : actionFields.length === 0 ? ( - - ) : ( - - )} - + @@ -539,42 +243,10 @@ export const QueryRuleFlyout: React.FC = ({ - - - - ), - }, - { - 'data-test-subj': 'searchQueryRulesQueryRuleCriteriaAlways', - id: 'always', - label: ( - <> - - - ), - }, - ]} - onChange={(id) => { - setIsFlyoutDirty(true); - setIsAlways(id === 'always'); - }} - buttonSize="compressed" - type="single" - idSelected={isAlways ? 'always' : 'custom'} + @@ -589,7 +261,7 @@ export const QueryRuleFlyout: React.FC = ({ - {criteriaCalloutActive && !isAlways ? ( + {shouldShowCriteriaCallout && ( <> = ({ /> - ) : null} - {ruleFromRuleset && - !isAlways && - fields.map((field, index) => ( - - { - setIsFlyoutDirty(true); - update(index, newCriteria); - }} - onRemove={() => { - setIsFlyoutDirty(true); - remove(index); - }} - /> - - - ))} + )} + {shouldShowMetadataEditor && ( + <> + {criteria.map((field, index) => ( + + { + setIsFlyoutDirty(true); + update(index, newCriteria); + }} + onRemove={() => { + setIsFlyoutDirty(true); + remove(index); + }} + /> + + + ))} - {ruleFromRuleset && !isAlways && ( - - - + + + + )} @@ -667,10 +339,17 @@ export const QueryRuleFlyout: React.FC = ({ onClick={handleSave} disabled={!isFlyoutDirty} > - + {createMode ? ( + + ) : ( + + )} diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout_header.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout_header.tsx new file mode 100644 index 0000000000000..08c6eda70f1af --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/query_rule_flyout_header.tsx @@ -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 React from 'react'; + +import { EuiFlyoutHeader, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiIconTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +const getHeaderText = (createMode: boolean) => + createMode ? ( + + ) : ( + + ); + +interface QueryRuleFlyoutHeaderProps { + createMode: boolean; + ruleId: string; +} + +export const QueryRuleFlyoutHeader: React.FC = ({ + createMode, + ruleId, +}) => { + return ( + + + + +

{getHeaderText(createMode)}

+
+
+ + + + + + + + } + position="right" + /> + + + +
+
+ ); +}; diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/use_query_rule_flyout_state.test.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/use_query_rule_flyout_state.test.tsx new file mode 100644 index 0000000000000..3c6ff39df7bf1 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/use_query_rule_flyout_state.test.tsx @@ -0,0 +1,651 @@ +/* + * 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 { act, render, renderHook, waitFor } from '@testing-library/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useFetchIndexNames } from '../../../hooks/use_fetch_index_names'; +import { + UseQueryRuleFlyoutStateProps, + useQueryRuleFlyoutState, +} from './use_query_rule_flyout_state'; +import { QueryRuleEditorForm, SearchQueryRulesQueryRule } from '../../../../common/types'; +import { DropResult, ResponderProvided } from '@elastic/eui'; + +jest.mock('../../../hooks/use_fetch_index_names', () => ({ + useFetchIndexNames: jest.fn().mockReturnValue({ + data: [], + isLoading: false, + isError: false, + }), +})); + +const mockUseFetchIndexNames = useFetchIndexNames as jest.Mock; +let mockFlyoutState: ReturnType; + +const MockComponent = ({ initialState }: { initialState?: UseQueryRuleFlyoutStateProps }) => { + mockFlyoutState = useQueryRuleFlyoutState( + initialState || { + rulesetId: '', + createMode: false, + ruleId: '', + rules: [], + onSave: jest.fn(), + } + ); + return
test
; +}; +const MockFormProvider = ({ + children, + initialFormValues = { + defaultValues: { + mode: 'edit', + rulesetId: '', + ruleId: '', + criteria: [], + type: 'pinned', + actions: { docs: [], ids: [] }, + }, + }, +}: { + children: React.ReactNode; + initialFormValues?: Record; +}) => { + const methods = useForm(initialFormValues); + return {children}; +}; + +describe('useQueryRuleFlyoutState hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseFetchIndexNames.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + }); + }); + it('should return defaults in edit mode', () => { + const { result } = renderHook( + () => + useQueryRuleFlyoutState({ + rulesetId: '', + createMode: false, + ruleId: '', + rules: [], + onSave: jest.fn(), + }), + { + wrapper: MockFormProvider, + } + ); + + const { + actionFields, + actionIdsFields, + criteria, + criteriaCount, + documentCount, + indexNames, + isAlways, + isDocRule, + isFlyoutDirty, + isIdRule, + pinType, + shouldShowCriteriaCallout, + shouldShowMetadataEditor, + } = result.current; + + // expect all the properties defined above to have correct initial values + expect(actionFields).toEqual([]); + expect(actionIdsFields).toEqual([]); + expect(criteria).toEqual([]); + expect(criteriaCount).toBe(0); + expect(documentCount).toBe(0); + expect(indexNames).toEqual([]); + expect(isAlways).toBe(false); + expect(isDocRule).toBe(true); + expect(isFlyoutDirty).toBe(false); + expect(isIdRule).toBe(false); + expect(pinType).toBe('pinned'); + expect(shouldShowCriteriaCallout).toBe(true); + // This is not supposed to be happening at all. + // TODO: Better default + expect(shouldShowMetadataEditor).toBe(false); + }); + + describe('edit mode', () => { + it('should setup a rule with doc mode', async () => { + const rules: SearchQueryRulesQueryRule[] = [ + { + rule_id: 'rule-1', + type: 'pinned', + actions: { + docs: [ + { + _index: 'index-1', + _id: 'doc-1', + }, + ], + }, + criteria: [ + { + metadata: 'field1', + type: 'exact', + values: ['value1'], + }, + ], + }, + { + rule_id: 'rule-2', + type: 'exclude', + actions: { + docs: [ + { + _index: 'index-2', + _id: 'doc-2', + }, + ], + }, + criteria: [ + { + metadata: 'field2', + type: 'exact', + values: ['value2'], + }, + ], + }, + ]; + + mockUseFetchIndexNames.mockReturnValue({ + data: ['index-1', 'index-2'], + isLoading: false, + isError: false, + }); + render( + + + + ); + + const { + actionFields, + actionIdsFields, + criteria, + criteriaCount, + documentCount, + indexNames, + isAlways, + isDocRule, + isIdRule, + pinType, + } = mockFlyoutState; + await waitFor(() => + expect(actionFields).toEqual([ + expect.objectContaining({ + _index: 'index-2', + _id: 'doc-2', + }), + ]) + ); + expect(actionIdsFields).not.toBeDefined(); + expect(criteria).toEqual([ + expect.objectContaining({ + metadata: 'field2', + type: 'exact', + values: ['value2'], + }), + ]); + expect(criteriaCount).toBe(1); + expect(documentCount).toBe(1); + expect(indexNames).toEqual(['index-1', 'index-2']); + expect(isAlways).toBe(false); + expect(isDocRule).toBe(true); + expect(isIdRule).toBe(false); + expect(pinType).toBe('exclude'); + }); + it('should setup a rule with id mode', async () => { + const rules: SearchQueryRulesQueryRule[] = [ + { + rule_id: 'rule-1', + type: 'pinned', + actions: { + ids: ['id-1', 'id-2'], + }, + criteria: [ + { + metadata: 'field1', + type: 'exact', + values: ['value1'], + }, + ], + }, + ]; + + mockUseFetchIndexNames.mockReturnValue({ + data: ['index-3', 'index-4'], + isLoading: false, + isError: false, + }); + render( + + + + ); + + const { + actionFields, + actionIdsFields, + criteria, + criteriaCount, + documentCount, + indexNames, + isAlways, + isDocRule, + isIdRule, + pinType, + } = mockFlyoutState; + await waitFor(() => expect(actionIdsFields).toEqual(['id-1', 'id-2'])); + expect(actionFields).toEqual([]); + expect(criteria).toEqual([ + expect.objectContaining({ + metadata: 'field1', + type: 'exact', + values: ['value1'], + }), + ]); + expect(criteriaCount).toBe(1); + expect(documentCount).toBe(2); + expect(indexNames).toEqual(['index-3', 'index-4']); + expect(isAlways).toBe(false); + expect(isDocRule).toBe(false); + expect(isIdRule).toBe(true); + expect(pinType).toBe('pinned'); + }); + + it('should set isAlways to true when criteria is always', async () => { + const rules: SearchQueryRulesQueryRule[] = [ + { + rule_id: 'rule-1', + type: 'pinned', + actions: { + docs: [ + { + _index: 'index-1', + _id: 'doc-1', + }, + ], + }, + criteria: [ + { + type: 'always', + }, + ], + }, + ]; + + mockUseFetchIndexNames.mockReturnValue({ + data: ['index-1'], + isLoading: false, + isError: false, + }); + render( + + + + ); + + const { isAlways, criteria, criteriaCount } = mockFlyoutState; + expect(isAlways).toBe(true); + + expect(criteria).toEqual([ + expect.objectContaining({ + type: 'always', + }), + ]); + expect(criteriaCount).toBe(1); + }); + + it('should handle add criteria correctly', async () => { + const rules: SearchQueryRulesQueryRule[] = [ + { + rule_id: 'rule-1', + type: 'pinned', + actions: { + docs: [], + }, + criteria: [], + }, + ]; + + render( + + + + ); + + expect(mockFlyoutState.isFlyoutDirty).toBe(false); + act(() => mockFlyoutState.handleAddCriteria()); + + expect(mockFlyoutState.isFlyoutDirty).toBe(true); + + await waitFor(() => { + expect(mockFlyoutState.isFlyoutDirty).toBe(true); + }); + await waitFor(() => { + expect(mockFlyoutState.criteriaCount).toBe(1); + }); + await waitFor(() => + expect(mockFlyoutState.criteria[0]).toEqual( + expect.objectContaining({ type: 'exact', metadata: '', values: [] }) + ) + ); + }); + + it('should handle drag end event correctly for doc mode', async () => { + const rules: SearchQueryRulesQueryRule[] = [ + { + rule_id: 'rule-1', + type: 'pinned', + actions: { + docs: [ + { + _index: 'index-1', + _id: 'doc-1', + }, + { + _index: 'index-2', + _id: 'doc-2', + }, + ], + }, + criteria: [], + }, + ]; + + render( + + + + ); + + expect(mockFlyoutState.actionFields).toEqual([ + expect.objectContaining({ _index: 'index-1', _id: 'doc-1' }), + expect.objectContaining({ _index: 'index-2', _id: 'doc-2' }), + ]); + act(() => { + mockFlyoutState.dragEndHandle( + { + source: { index: 0, droppableId: 'actions' }, + destination: { index: 1, droppableId: 'actions' }, + } as DropResult, + {} as ResponderProvided + ); + }); + + await waitFor(() => + expect(mockFlyoutState.actionFields).toEqual([ + expect.objectContaining({ _index: 'index-2', _id: 'doc-2' }), + expect.objectContaining({ _index: 'index-1', _id: 'doc-1' }), + ]) + ); + + act(() => { + mockFlyoutState.dragEndHandle( + { + source: { index: 0, droppableId: 'actions' }, + destination: { index: 1, droppableId: 'actions' }, + } as DropResult, + {} as ResponderProvided + ); + }); + await waitFor(() => expect(mockFlyoutState.isFlyoutDirty).toBe(true)); + await waitFor(() => { + expect(mockFlyoutState.actionFields).toEqual([ + expect.objectContaining({ _index: 'index-1', _id: 'doc-1' }), + expect.objectContaining({ _index: 'index-2', _id: 'doc-2' }), + ]); + }); + }); + it('should handle drag end event correctly for id mode', async () => { + const rules: SearchQueryRulesQueryRule[] = [ + { + rule_id: 'rule-1', + type: 'pinned', + actions: { + ids: ['id-1', 'id-2'], + }, + criteria: [], + }, + ]; + + render( + + + + ); + + expect(mockFlyoutState.isIdRule).toBe(true); + expect(mockFlyoutState.actionIdsFields).toEqual(['id-1', 'id-2']); + act(() => { + mockFlyoutState.dragEndHandle( + { + source: { index: 0, droppableId: 'actions' }, + destination: { index: 1, droppableId: 'actions' }, + } as DropResult, + {} as ResponderProvided + ); + }); + + await waitFor(() => expect(mockFlyoutState.actionIdsFields).toEqual(['id-2', 'id-1'])); + + act(() => { + mockFlyoutState.dragEndHandle( + { + source: { index: 1, droppableId: 'actions' }, + destination: { index: 0, droppableId: 'actions' }, + } as DropResult, + + {} as ResponderProvided + ); + }); + + await waitFor(() => expect(mockFlyoutState.isFlyoutDirty).toBe(true)); + await waitFor(() => { + expect(mockFlyoutState.actionIdsFields).toEqual(['id-1', 'id-2']); + }); + }); + it('handles onIndexSelectorChange correctly', async () => { + const rules: SearchQueryRulesQueryRule[] = [ + { + rule_id: 'rule-1', + type: 'pinned', + actions: { + docs: [ + { + _index: 'index-1', + _id: 'doc-1', + }, + ], + }, + criteria: [], + }, + ]; + + render( + + + + ); + + expect(mockFlyoutState.isDocRule).toBe(true); + act(() => mockFlyoutState.onIndexSelectorChange(0, 'index-2')); + await waitFor(() => { + expect(mockFlyoutState.actionFields).toEqual([ + expect.objectContaining({ _index: 'index-2', _id: 'doc-1' }), + ]); + }); + }); + + it('should handle onIdSelectorChange correctly in id mode', async () => { + const rules: SearchQueryRulesQueryRule[] = [ + { + rule_id: 'rule-1', + type: 'pinned', + actions: { + ids: ['id-1'], + }, + criteria: [], + }, + ]; + + render( + + + + ); + + const { onIdSelectorChange, isIdRule } = mockFlyoutState; + expect(isIdRule).toBe(true); + + act(() => onIdSelectorChange(0, 'id-2')); + await waitFor(() => { + expect(mockFlyoutState.actionIdsFields).toEqual(['id-2']); + }); + }); + }); + describe('create mode', () => { + it('should setup a new rule with default values', () => { + render( + + + + ); + + const { + actionFields, + actionIdsFields, + criteria, + criteriaCount, + isAlways, + isDocRule, + isIdRule, + pinType, + } = mockFlyoutState; + + expect(actionFields).toEqual([expect.objectContaining({ _index: '', _id: '' })]); + expect(actionIdsFields).toEqual([]); + expect(criteria).toEqual([ + expect.objectContaining({ type: 'exact', metadata: '', values: [] }), + ]); + expect(criteriaCount).toBe(1); + expect(isAlways).toBe(false); + expect(isDocRule).toBe(true); + expect(pinType).toBe('pinned'); + expect(isIdRule).toBe(false); + }); + + it('should handle onIdSelectorChange correctly', async () => { + render( + + + + ); + + const { onIdSelectorChange, isIdRule, isDocRule } = mockFlyoutState; + expect(isIdRule).toBe(false); + expect(isDocRule).toBe(true); + + act(() => onIdSelectorChange(0, 'id-1')); + await waitFor(() => { + expect(mockFlyoutState.actionFields).toEqual([ + expect.objectContaining({ _index: '', _id: 'id-1' }), + ]); + }); + }); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/use_query_rule_flyout_state.ts b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/use_query_rule_flyout_state.ts new file mode 100644 index 0000000000000..981542eda41cf --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/components/query_ruleset_detail/query_rule_flyout/use_query_rule_flyout_state.ts @@ -0,0 +1,315 @@ +/* + * 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 { QueryRulesQueryRuleset } from '@elastic/elasticsearch/lib/api/types'; +import { useEffect, useState } from 'react'; +import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import { OnDragEndResponder } from '@hello-pangea/dnd'; +import { euiDragDropReorder } from '@elastic/eui'; +import { QueryRuleEditorForm, SearchQueryRulesQueryRule } from '../../../../common/types'; +import { useFetchIndexNames } from '../../../hooks/use_fetch_index_names'; +import { isCriteriaAlways } from '../../../utils/query_rules_utils'; + +export const createEmptyRuleset = ( + rulesetId: QueryRulesQueryRuleset['ruleset_id'] +): QueryRulesQueryRuleset => ({ + ruleset_id: rulesetId, + rules: [], +}); + +export interface UseQueryRuleFlyoutStateProps { + createMode: boolean; + rulesetId: string; + ruleId: string; + rules: SearchQueryRulesQueryRule[]; + setIsFormDirty?: (isDirty: boolean) => void; + onSave: (rule: SearchQueryRulesQueryRule) => void; +} + +export const useQueryRuleFlyoutState = ({ + createMode, + rulesetId, + ruleId, + rules, + setIsFormDirty, + onSave, +}: UseQueryRuleFlyoutStateProps) => { + const [isFlyoutDirty, setIsFlyoutDirty] = useState(false); + const { control, getValues, reset, setValue } = useFormContext(); + const { + fields: criteria, + 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 ruleFromRuleset = rules.find((rule) => rule.rule_id === ruleId); + const [isAlways, setIsAlways] = useState( + (ruleFromRuleset?.criteria && isCriteriaAlways(ruleFromRuleset?.criteria)) ?? false + ); + const isDocRule = Boolean( + !actionIdsFields || actionFields.length > 0 || !!(actionIdsFields?.length === 0) + ); + const isIdRule = Boolean(!isDocRule && actionFields.length === 0 && actionIdsFields?.length); + + useEffect(() => { + if (ruleFromRuleset) { + reset({ + ...getValues(), + criteria: ruleFromRuleset.criteria, + type: ruleFromRuleset.type, + actions: ruleFromRuleset.actions, + mode: 'edit', + ruleId, + }); + setIsAlways( + (ruleFromRuleset?.criteria && isCriteriaAlways(ruleFromRuleset?.criteria)) ?? false + ); + } + }, [ruleFromRuleset, reset, getValues, rulesetId, ruleId]); + + useEffect(() => { + if (createMode) { + reset({ + criteria: [ + { + type: 'exact', + metadata: '', + values: [], + }, + ], + type: 'pinned', + actions: { + docs: [ + { + _id: '', + _index: '', + }, + ], + ids: [], + }, + mode: 'create', + ruleId, + }); + setIsAlways(false); + } + }, [createMode, reset, 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([ + { + type: '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: isAlways + ? [{ type: 'always' } as QueryRuleEditorForm['criteria'][0]] + : criteria.map((c) => { + const normalizedCriteria = { + values: c.values, + metadata: c.metadata, + type: c.type, + }; + return normalizedCriteria; + }), + type: getValues('type'), + actions, + }; + onSave(updatedRule); + } else { + onSave({ + rule_id: ruleId, + criteria: isAlways + ? [{ type: 'always' }] + : criteria.map((c) => { + const normalizedCriteria = { + values: c.values, + metadata: c.metadata, + type: c.type, + }; + return normalizedCriteria; + }), + type: getValues('type'), + actions: isDocRule + ? { + docs: actionFields.map((doc) => ({ + _id: doc._id, + _index: doc._index, + })), + } + : { ids: actionIdsFields }, + }); + } + }; + 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]); + + const shouldShowCriteriaCallout = criteriaCalloutActive && !isAlways; + + const dragEndHandle: OnDragEndResponder = ({ source, destination }) => { + if (source && destination && (ruleFromRuleset || createMode)) { + 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); + } + } + }; + + const onDeleteDocument = (index: number) => { + setIsFlyoutDirty(true); + if (createMode || !isIdRule) { + removeAction(index); + } else { + if (actionIdsFields) { + const updatedActions = actionIdsFields.filter((_, i) => i !== index); + setValue('actions.ids', updatedActions); + } + } + }; + + const onIndexSelectorChange = (index: number, indexName: string) => { + setIsFlyoutDirty(true); + const updatedActions = actionFields.map((action, i) => + i === index ? { ...action, _index: indexName } : action + ); + replaceAction(updatedActions); + }; + + const onIdSelectorChange = (index: number, id: string) => { + setIsFlyoutDirty(true); + if (isIdRule && actionIdsFields) { + const updatedActions = actionIdsFields.map((value, i) => (i === index ? id : value)); + setValue('actions.ids', updatedActions); + } else { + if (isDocRule || createMode) { + const updatedActions = actionFields.map((action, i) => + i === index ? { ...action, _id: id } : action + ); + replaceAction(updatedActions); + } + } + }; + + const documentCount = actionFields.length || actionIdsFields?.length || 0; + const shouldShowMetadataEditor = (createMode || !!ruleFromRuleset) && !isAlways; + const criteriaCount = criteria.length; + + return { + actionFields, + actionIdsFields, + appendAction: appendNewAction, + control, + criteria, + criteriaCount, + documentCount, + dragEndHandle, + getValues, + handleAddCriteria, + handleSave, + indexNames, + isAlways, + isDocRule, + isFlyoutDirty, + isIdRule, + onDeleteDocument, + onIdSelectorChange, + onIndexSelectorChange, + pinType, + remove, + setCriteriaCalloutActive, + setIsAlways, + setIsFlyoutDirty, + shouldShowCriteriaCallout, + shouldShowMetadataEditor, + update, + }; +}; 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 68eb04d24e4a0..16e8faee3b2fb 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 @@ -12,6 +12,14 @@ import React from 'react'; import { QueryRulesetDetail } from './query_ruleset_detail'; import { MOCK_QUERY_RULESET_RESPONSE_FIXTURE } from '../../../common/__fixtures__/query_rules_ruleset'; +jest.mock('../../hooks/use_fetch_ruleset_exists', () => ({ + useFetchQueryRulesetExist: jest.fn(() => ({ + data: { exists: false }, + isLoading: false, + isError: false, + })), +})); + jest.mock('./use_query_ruleset_detail_state', () => ({ useQueryRulesetDetailState: jest.fn(() => ({ queryRuleset: MOCK_QUERY_RULESET_RESPONSE_FIXTURE, 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 849902d8fb1ce..f4656cd7b6735 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 @@ -19,17 +19,19 @@ import { EuiPopover, EuiTitle, EuiTourStep, - useGeneratedHtmlId, useEuiTheme, + useGeneratedHtmlId, } from '@elastic/eui'; +import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; 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 { useParams } from 'react-router-dom'; import { PLUGIN_ROUTE_ROOT } from '../../../common/api_routes'; +import { docLinks } from '../../../common/doc_links'; import { useKibana } from '../../hooks/use_kibana'; +import { usePutRuleset } from '../../hooks/use_put_query_rules_ruleset'; import { UseRunQueryRuleset } from '../../hooks/use_run_query_ruleset'; import { QueryRulesPageTemplate } from '../../layout/query_rules_page_template'; import { isNotFoundError, isPermissionError } from '../../utils/query_rules_utils'; @@ -37,20 +39,37 @@ 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'; +import { useFetchQueryRulesetExist } from '../../hooks/use_fetch_ruleset_exists'; + +export interface QueryRulesetDetailProps { + createMode?: boolean; +} -export const QueryRulesetDetail: React.FC = () => { +export const QueryRulesetDetail: React.FC = ({ createMode = false }) => { const { euiTheme } = useEuiTheme(); const { - services: { application, http, history }, + services: { application, http, history, overlays }, } = useKibana(); - const { overlays } = useKibana().services; const { rulesetId = '' } = useParams<{ rulesetId?: string; }>(); + const { data: rulesetExists, isLoading: isFailsafeLoading } = + useFetchQueryRulesetExist(rulesetId); - const { mutate: createRuleset } = usePutRuleset(); + useEffect(() => { + // This is a failsafe in case user navigates to an existing ruleset via URL directly + if (createMode && rulesetExists) { + application.navigateToUrl(http.basePath.prepend(`${PLUGIN_ROUTE_ROOT}/ruleset/${rulesetId}`)); + } + }, [createMode, rulesetExists, application, http.basePath, rulesetId]); + + const blockRender = (createMode && rulesetExists) || isFailsafeLoading; + + const { mutate: createRuleset } = usePutRuleset(() => { + if (createMode) { + application.navigateToUrl(http.basePath.prepend(`${PLUGIN_ROUTE_ROOT}/ruleset/${rulesetId}`)); + } + }); const { queryRuleset, @@ -64,7 +83,9 @@ export const QueryRulesetDetail: React.FC = () => { error, } = useQueryRulesetDetailState({ rulesetId, + createMode, }); + const [isPopoverActionsOpen, setPopoverActions] = useState(false); const splitButtonPopoverActionsId = useGeneratedHtmlId({ prefix: 'splitButtonPopoverActionsId', @@ -172,7 +193,7 @@ export const QueryRulesetDetail: React.FC = () => { }); }; - const [isFormDirty, setIsFormDirty] = useState(false); + const [isFormDirty, setIsFormDirty] = useState(createMode); useUnsavedChangesPrompt({ cancelButtonText: i18n.translate('xpack.queryRules.queryRulesetDetail.unsavedPrompt.cancel', { @@ -196,7 +217,7 @@ export const QueryRulesetDetail: React.FC = () => { return ( - {!isInitialLoading && !isError && !!queryRuleset && ( + {!isInitialLoading && !isError && !!queryRuleset && !blockRender && ( { ), color: 'primary', 'aria-current': false, - href: '#', onClick: () => application.navigateToUrl(http.basePath.prepend(`${PLUGIN_ROUTE_ROOT}`)), }, @@ -304,6 +324,7 @@ export const QueryRulesetDetail: React.FC = () => { } > { ]} /> )} - {!isError && ( - <> - - - {tourStepsInfo[1]?.tourTargetRef?.current !== null && ( - tourStepsInfo[1]?.tourTargetRef?.current || document.body} - content={

{tourStepsInfo[1].content}

} - isStepOpen={tourState.isTourActive && tourState.currentTourStep === 2} - maxWidth={tourState.tourPopoverWidth} - onFinish={finishTour} - step={1} - stepsTotal={(queryRuleset?.rules?.length ?? 0) > 1 ? 2 : 1} - title={ - -
{tourStepsInfo[1].title}
-
- } - anchorPosition="downLeft" - zIndex={1} - footerAction={ - - - - {i18n.translate('xpack.queryRules.queryRulesetDetail.backTourButton', { - defaultMessage: 'Back', - })} - - - - - {i18n.translate('xpack.queryRules.queryRulesetDetail.closeTourButton', { - defaultMessage: 'Close tour', - })} - - - - } + {(!blockRender && !isFailsafeLoading && isError && createMode) || + (!isError && ( + <> + - )} - - )} - {rulesetToDelete && ( + + {tourStepsInfo[1]?.tourTargetRef?.current !== null && ( + tourStepsInfo[1]?.tourTargetRef?.current || document.body} + content={

{tourStepsInfo[1].content}

} + isStepOpen={tourState.isTourActive && tourState.currentTourStep === 2} + maxWidth={tourState.tourPopoverWidth} + onFinish={finishTour} + step={1} + stepsTotal={(queryRuleset?.rules?.length ?? 0) > 1 ? 2 : 1} + title={ + +
{tourStepsInfo[1].title}
+
+ } + anchorPosition="downLeft" + zIndex={1} + footerAction={ + + + + {i18n.translate('xpack.queryRules.queryRulesetDetail.backTourButton', { + defaultMessage: 'Back', + })} + + + + + {i18n.translate('xpack.queryRules.queryRulesetDetail.closeTourButton', { + defaultMessage: 'Close tour', + })} + + + + } + /> + )} + + ))} + {!blockRender && rulesetToDelete && ( { @@ -427,7 +450,7 @@ export const QueryRulesetDetail: React.FC = () => { }} /> )} - {isError && error && ( + {!blockRender && isError && !createMode && error && ( ({ + ruleset_id: rulesetId, + rules: [], +}); + interface UseQueryRulesetDetailStateProps { rulesetId: string; + createMode: boolean; } -export const useQueryRulesetDetailState = ({ rulesetId }: UseQueryRulesetDetailStateProps) => { - const { data, isInitialLoading, isError, error } = useFetchQueryRuleset(rulesetId); - const [queryRuleset, setQueryRuleset] = useState(null); +export const useQueryRulesetDetailState = ({ + rulesetId, + createMode, +}: UseQueryRulesetDetailStateProps) => { + const { data, isInitialLoading, isError, error } = useFetchQueryRuleset(rulesetId, !createMode); + const [queryRuleset, setQueryRuleset] = useState( + createMode ? createEmptyRuleset(rulesetId) : null + ); const [rules, setRules] = useState([]); useEffect(() => { - if (data) { + if (!createMode && !isError && data) { const normalizedRuleset = normalizeQueryRuleset(data); setQueryRuleset(normalizedRuleset); setRules(normalizedRuleset.rules); } - }, [data, setRules, setQueryRuleset]); + }, [data, setRules, setQueryRuleset, createMode, isError]); const updateRule = (updatedRule: SearchQueryRulesQueryRule) => { const newRules = rules.map((rule) => @@ -34,16 +47,12 @@ export const useQueryRulesetDetailState = ({ rulesetId }: UseQueryRulesetDetailS ); setRules([...newRules]); }; - const addNewRule = (newRuleId: string) => { + + const addNewRule = (newRule: SearchQueryRulesQueryRule) => { setRules((prevRules) => [ ...prevRules, { - rule_id: newRuleId, - criteria: [], - type: 'pinned', - actions: { - docs: [], - }, + ...newRule, }, ]); }; diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_ruleset.ts b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_ruleset.ts index bb111f7c501e0..793f0a3605672 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_ruleset.ts +++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_ruleset.ts @@ -11,7 +11,7 @@ import { QueryRulesQueryRuleset } from '@elastic/elasticsearch/lib/api/types'; import { QUERY_RULES_QUERY_RULESET_FETCH_KEY } from '../../common/constants'; import { useKibana } from './use_kibana'; -export const useFetchQueryRuleset = (rulesetId: string) => { +export const useFetchQueryRuleset = (rulesetId: string, enabled = true) => { const { services: { http }, } = useKibana(); @@ -24,5 +24,6 @@ export const useFetchQueryRuleset = (rulesetId: string) => { ); }, retry: false, + enabled, }); }; diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.test.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.test.tsx new file mode 100644 index 0000000000000..5da58f9bb119a --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.test.tsx @@ -0,0 +1,79 @@ +/* + * 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; + +const mockHttpGet = jest.fn(); + +jest.mock('./use_kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + http: { + get: mockHttpGet, + }, + }, + }), +})); + +describe('useFetchQueryRulesetExist Hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient(); + return {children}; + }; + + it('should run onNoConflict when ruleset does not exist', async () => { + const { useFetchQueryRulesetExist } = jest.requireActual('./use_fetch_ruleset_exists'); + const onNoConflict = jest.fn(); + const onConflict = jest.fn(); + + mockHttpGet.mockResolvedValue({ exists: false }); + const { result } = renderHook( + () => useFetchQueryRulesetExist('non-existent-ruleset', onNoConflict, onConflict), + { wrapper } + ); + + await waitFor(() => + expect(mockHttpGet).toHaveBeenCalledWith( + '/internal/search_query_rules/ruleset/non-existent-ruleset/exists' + ) + ); + + await waitFor(() => expect(result.current.data).toBe(false)); + await waitFor(() => expect(onNoConflict).toHaveBeenCalled()); + await waitFor(() => expect(onConflict).not.toHaveBeenCalled()); + }); + + it('should run onConflict when ruleset exists', async () => { + const { useFetchQueryRulesetExist } = jest.requireActual('./use_fetch_ruleset_exists'); + const onNoConflict = jest.fn(); + const onConflict = jest.fn(); + + mockHttpGet.mockResolvedValue({ exists: true }); + + const { result } = renderHook( + () => useFetchQueryRulesetExist('existing-ruleset', onNoConflict, onConflict), + { wrapper } + ); + + await waitFor(() => + expect(mockHttpGet).toHaveBeenCalledWith( + '/internal/search_query_rules/ruleset/existing-ruleset/exists' + ) + ); + + await waitFor(() => expect(result.current.data).toBe(true)); + await waitFor(() => expect(onNoConflict).not.toHaveBeenCalled()); + await waitFor(() => expect(onConflict).toHaveBeenCalled()); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.ts b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.ts new file mode 100644 index 0000000000000..fc5d6de1cd5d9 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.ts @@ -0,0 +1,41 @@ +/* + * 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 { QUERY_RULES_QUERY_RULESET_EXISTS_KEY } from '../../common/constants'; +import { useKibana } from './use_kibana'; + +export const useFetchQueryRulesetExist = ( + rulesetId: string, + onNoConflict?: () => void, + onConflict?: () => void +) => { + const { + services: { http }, + } = useKibana(); + + return useQuery({ + queryKey: [QUERY_RULES_QUERY_RULESET_EXISTS_KEY, rulesetId], + queryFn: async () => { + const { exists } = await http.get<{ exists: boolean }>( + `/internal/search_query_rules/ruleset/${rulesetId}/exists` + ); + if (!exists && onNoConflict) { + onNoConflict(); + } + if (exists && onConflict) { + onConflict(); + } + + return exists; + }, + retry: false, + refetchOnWindowFocus: false, + enabled: !!rulesetId, + }); +}; 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 c229d85f4e9ae..e7bbf6fe9785a 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 @@ -10,6 +10,7 @@ import { QueryRulesQueryRuleset } from '@elastic/elasticsearch/lib/api/types'; import { i18n } from '@kbn/i18n'; import { KibanaServerError } from '@kbn/kibana-utils-plugin/common'; import { + QUERY_RULES_QUERY_RULESET_EXISTS_KEY, QUERY_RULES_QUERY_RULESET_FETCH_KEY, QUERY_RULES_SETS_QUERY_KEY, } from '../../common/constants'; @@ -43,8 +44,9 @@ export const usePutRuleset = ( }, { onSuccess: (_, { rulesetId }) => { - queryClient.invalidateQueries([QUERY_RULES_QUERY_RULESET_FETCH_KEY]); - queryClient.invalidateQueries([QUERY_RULES_SETS_QUERY_KEY]); + queryClient.invalidateQueries({ queryKey: [QUERY_RULES_QUERY_RULESET_FETCH_KEY] }); + queryClient.invalidateQueries({ queryKey: [QUERY_RULES_SETS_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [QUERY_RULES_QUERY_RULESET_EXISTS_KEY] }); notifications?.toasts?.addSuccess({ title: i18n.translate('xpack.queryRules.putRulesetSuccess', { defaultMessage: 'Ruleset added', 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 4fdaa4e26b87b..f8d9254402fa7 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 @@ -17,6 +17,7 @@ export interface UseRunQueryRulesetProps { content?: string; color?: EuiButtonColor; onClick?: () => void; + disabled?: boolean; } export const UseRunQueryRuleset = ({ @@ -25,9 +26,10 @@ export const UseRunQueryRuleset = ({ content, color, onClick, + disabled = false, }: UseRunQueryRulesetProps) => { const { application, share, console: consolePlugin } = useKibana().services; - const { data: queryRulesetData } = useFetchQueryRuleset(rulesetId); + const { data: queryRulesetData } = useFetchQueryRuleset(rulesetId, !disabled); // Loop through all actions children to gather unique _index values const { indices, matchCriteria } = useMemo((): { indices: string; matchCriteria: string } => { diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/routes.ts b/x-pack/solutions/search/plugins/search_query_rules/public/routes.ts index 0e0899b6ab448..6fae0ea1792ae 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/public/routes.ts +++ b/x-pack/solutions/search/plugins/search_query_rules/public/routes.ts @@ -7,3 +7,4 @@ export const ROOT_PATH = '/'; export const QUERY_RULESET_DETAIL_PATH = `${ROOT_PATH}ruleset/:rulesetId`; +export const CREATE_QUERY_RULESET_PATH = `${ROOT_PATH}ruleset/:rulesetId/create`; diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/search_query_router.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/search_query_router.tsx index d0c4693829fd4..47064f0077789 100644 --- a/x-pack/solutions/search/plugins/search_query_rules/public/search_query_router.tsx +++ b/x-pack/solutions/search/plugins/search_query_rules/public/search_query_router.tsx @@ -7,7 +7,7 @@ import { Route, Routes } from '@kbn/shared-ux-router'; import React from 'react'; -import { QUERY_RULESET_DETAIL_PATH, ROOT_PATH } from './routes'; +import { CREATE_QUERY_RULESET_PATH, QUERY_RULESET_DETAIL_PATH, ROOT_PATH } from './routes'; import { QueryRulesOverview } from './components/overview/overview'; import { QueryRulesetDetail } from './components/query_ruleset_detail/query_ruleset_detail'; @@ -17,6 +17,9 @@ export const QueryRulesRouter = () => { + + + 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 e8ccbfd6d3c01..b93e6553a44fd 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 @@ -21,6 +21,7 @@ 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 { errorHandler } from './utils/error_handler'; +import { checkPrivileges } from './utils/privilege_check'; export function defineRoutes({ logger, router }: { logger: Logger; router: IRouter }) { router.get( @@ -112,16 +113,18 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout } const rulesetData = await fetchQueryRulesRuleset(asCurrentUser, request.params.ruleset_id); + if (!rulesetData) { + return response.notFound({ + body: i18n.translate('xpack.search.rules.api.routes.rulesetNotFoundErrorMessage', { + defaultMessage: 'Ruleset not found', + }), + }); + } return response.ok({ headers: { 'content-type': 'application/json', }, - body: - rulesetData ?? - response.customError({ - statusCode: 404, - body: 'Ruleset not found', - }), + body: rulesetData, }); }) ); @@ -154,8 +157,8 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout criteria: schema.arrayOf( schema.object({ type: schema.string(), - metadata: schema.string(), - values: schema.arrayOf(schema.string()), + metadata: schema.maybe(schema.string()), + values: schema.maybe(schema.arrayOf(schema.string())), }) ), actions: schema.object({ @@ -218,6 +221,43 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout }); }) ); + router.get( + { + path: APIRoutes.QUERY_RULES_RULESET_EXISTS, + 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; + + await checkPrivileges(core, response); + + const isExisting = await isQueryRulesetExist(asCurrentUser, rulesetId); + + return response.ok({ + headers: { + 'content-type': 'application/json', + }, + body: { exists: isExisting }, + }); + }) + ); + router.delete( { path: APIRoutes.QUERY_RULES_RULESET_ID, diff --git a/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.test.ts b/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.test.ts new file mode 100644 index 0000000000000..3d8c128c7edb9 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { CoreRequestHandlerContext, KibanaResponseFactory } from '@kbn/core/server'; +import { checkPrivileges } from './privilege_check'; + +const MOCK_CORE = { + elasticsearch: { + client: { + asCurrentUser: { + security: { + hasPrivileges: jest.fn().mockResolvedValue({ has_all_requested: false }), + }, + }, + }, + }, + security: { + authc: { + getCurrentUser: jest.fn().mockReturnValue(null), + }, + }, +} as unknown as CoreRequestHandlerContext; + +const MOCK_RESPONSE = { + customError: jest.fn(), + forbidden: jest.fn(), +} as unknown as KibanaResponseFactory; + +describe('privilege check util', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return 502 error if user is not available', async () => { + const mockCore = { ...MOCK_CORE }; + const mockResponse = { ...MOCK_RESPONSE }; + await checkPrivileges(mockCore, mockResponse); + + expect(mockResponse.customError).toHaveBeenCalledWith({ + statusCode: 502, + body: expect.stringContaining( + 'Could not retrieve current user, security plugin is not ready' + ), + }); + }); + it('should return forbidden error if user does not have required privileges', async () => { + const mockCore = { ...MOCK_CORE }; + mockCore.security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'test_user' }); + const mockResponse = { ...MOCK_RESPONSE }; + await checkPrivileges(mockCore, mockResponse); + + expect(mockResponse.forbidden).toHaveBeenCalledWith({ + body: "You don't have manage_search_query_rules privileges", + }); + }); + it('should not return an error if all checks are passed', async () => { + const mockCore = { ...MOCK_CORE }; + mockCore.security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'test_user' }); + mockCore.elasticsearch.client.asCurrentUser.security.hasPrivileges = jest + .fn() + .mockResolvedValue({ has_all_requested: true }); + + const mockResponse = { ...MOCK_RESPONSE }; + await checkPrivileges(mockCore, mockResponse); + expect(mockResponse.forbidden).not.toHaveBeenCalled(); + expect(mockResponse.customError).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.ts b/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.ts new file mode 100644 index 0000000000000..3501493dd4346 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.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 { CoreRequestHandlerContext, KibanaResponseFactory } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; + +export const checkPrivileges = async ( + core: CoreRequestHandlerContext, + response: KibanaResponseFactory +) => { + const user = core.security?.authc.getCurrentUser(); + if (!user) { + return response.customError({ + statusCode: 502, + body: i18n.translate('xpack.search.queryRules.api.routes.noUserError', { + defaultMessage: 'Could not retrieve current user, security plugin is not ready', + }), + }); + } + const hasPrivilege = await core.elasticsearch.client.asCurrentUser.security.hasPrivileges({ + cluster: ['manage_search_query_rules'], + }); + if (!hasPrivilege.has_all_requested) { + response.forbidden({ + body: i18n.translate('xpack.search.queryRules.api.routes.permissionError', { + defaultMessage: "You don't have manage_search_query_rules privileges", + }), + }); + } +};