From 3e0448e69a32c5c4d5043d1c78bffc4759c43661 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Tue, 15 Nov 2022 14:15:35 +0100 Subject: [PATCH] [Enterprise Search] Add editable filter rules to connectors (#145170) --- .../common/types/connectors.ts | 6 + .../common/types/util_types.ts | 12 + .../__mocks__/search_indices.mock.ts | 2 + .../__mocks__/view_index.mock.ts | 2 + .../update_connector_filtering_api_logic.ts | 38 ++ ...ate_connector_filtering_draft_api_logic.ts | 38 ++ .../filtering/advanced_filtering_rules.tsx | 60 +++ .../filtering/connector_filtering.tsx | 195 ++++++++++ .../filtering/connector_filtering_form.tsx | 76 ++++ .../filtering/connector_filtering_logic.tsx | 343 ++++++++++++++++++ .../filtering/edit_filtering_flyout.tsx | 104 ++++++ .../filtering/edit_filtering_tab.tsx | 43 +++ .../editable_filtering_rules_table.tsx | 242 ++++++++++++ .../filtering/filtering_callouts.tsx | 178 +++++++++ .../search_index/index_view_logic.test.ts | 3 + .../search_index/index_view_logic.ts | 18 + .../components/search_index/search_index.tsx | 16 +- .../filtering_panel.test.tsx.snap | 132 ++----- .../sync_jobs/filtering_panel.tsx | 46 +-- .../filtering_rules_table.tsx | 77 ++++ .../inline_editable_table/action_column.tsx | 8 +- .../get_updated_columns.test.tsx | 1 + .../get_updated_columns.tsx | 3 + .../inline_editable_table.tsx | 3 + .../lib/connectors/add_connector.test.ts | 3 + .../server/lib/connectors/add_connector.ts | 1 + .../lib/connectors/fetch_connectors.test.ts | 12 +- .../server/lib/connectors/fetch_connectors.ts | 9 +- .../lib/connectors/put_update_filtering.ts | 68 ++++ .../connectors/put_update_filtering_draft.ts | 68 ++++ .../routes/enterprise_search/connectors.ts | 112 +++++- .../server/utils/validate_enum.ts | 32 ++ 32 files changed, 1787 insertions(+), 164 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/common/types/util_types.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/update_connector_filtering_api_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/update_connector_filtering_draft_api_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/advanced_filtering_rules.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering_form.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering_logic.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/edit_filtering_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/edit_filtering_tab.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/editable_filtering_rules_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/filtering_callouts.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/filtering_rules_table/filtering_rules_table.tsx create mode 100644 x-pack/plugins/enterprise_search/server/lib/connectors/put_update_filtering.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/connectors/put_update_filtering_draft.ts create mode 100644 x-pack/plugins/enterprise_search/server/utils/validate_enum.ts diff --git a/x-pack/plugins/enterprise_search/common/types/connectors.ts b/x-pack/plugins/enterprise_search/common/types/connectors.ts index d948afc85b2e..7aeae229a4f4 100644 --- a/x-pack/plugins/enterprise_search/common/types/connectors.ts +++ b/x-pack/plugins/enterprise_search/common/types/connectors.ts @@ -103,11 +103,17 @@ export enum TriggerMethod { SCHEDULED = 'scheduled', } +export enum FeatureName { + FILTERING_ADVANCED_CONFIG = 'filtering_advanced_config', + FILTERING_RULES = 'filtering_rules', +} + export interface Connector { api_key_id: string | null; configuration: ConnectorConfiguration; description: string | null; error: string | null; + features: Record | null; filtering: FilteringConfig[]; id: string; index_name: string; diff --git a/x-pack/plugins/enterprise_search/common/types/util_types.ts b/x-pack/plugins/enterprise_search/common/types/util_types.ts new file mode 100644 index 000000000000..8536a0103dff --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/types/util_types.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export interface OptimisticConcurrency { + primaryTerm: number | undefined; + seqNo: number | undefined; + value: T; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts index 0fccbf2c6f8a..2555ef905caf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts @@ -35,6 +35,7 @@ export const indices: ElasticsearchIndexWithIngestion[] = [ configuration: { foo: { label: 'bar', value: 'barbar' } }, description: null, error: null, + features: null, filtering: [ { active: { @@ -120,6 +121,7 @@ export const indices: ElasticsearchIndexWithIngestion[] = [ configuration: { foo: { label: 'bar', value: 'barbar' } }, description: null, error: null, + features: null, filtering: [ { active: { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts index 4f5d4f97cab8..73e4a65874c0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts @@ -45,6 +45,7 @@ export const connectorIndex: ConnectorViewIndex = { configuration: { foo: { label: 'bar', value: 'barbar' } }, description: null, error: null, + features: null, filtering: [ { active: { @@ -134,6 +135,7 @@ export const crawlerIndex: CrawlerViewIndex = { configuration: { foo: { label: 'bar', value: 'barbar' } }, description: null, error: null, + features: null, filtering: [ { active: { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/update_connector_filtering_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/update_connector_filtering_api_logic.ts new file mode 100644 index 000000000000..3f1a66820361 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/update_connector_filtering_api_logic.ts @@ -0,0 +1,38 @@ +/* + * 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 { FilteringRule, FilteringRules } from '../../../../../common/types/connectors'; +import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export interface PutConnectorFilteringArgs { + advancedSnippet: string; + connectorId: string; + filteringRules: FilteringRule[]; +} + +export type PutConnectorFilteringResponse = FilteringRules; + +export const putConnectorFiltering = async ({ + advancedSnippet, + connectorId, + filteringRules, +}: PutConnectorFilteringArgs) => { + const route = `/internal/enterprise_search/connectors/${connectorId}/filtering`; + + return await HttpLogic.values.http.put(route, { + body: JSON.stringify({ + advanced_snippet: advancedSnippet, + filtering_rules: filteringRules, + }), + }); +}; + +export const ConnectorFilteringApiLogic = createApiLogic( + ['content', 'connector_filtering_api_logic'], + putConnectorFiltering +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/update_connector_filtering_draft_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/update_connector_filtering_draft_api_logic.ts new file mode 100644 index 000000000000..aa7406aee253 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/update_connector_filtering_draft_api_logic.ts @@ -0,0 +1,38 @@ +/* + * 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 { FilteringRule, FilteringRules } from '../../../../../common/types/connectors'; +import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export interface PutConnectorFilteringDraftArgs { + advancedSnippet: string; + connectorId: string; + filteringRules: FilteringRule[]; +} + +export type PutConnectorFilteringDraftResponse = FilteringRules; + +export const putConnectorFilteringDraft = async ({ + advancedSnippet, + connectorId, + filteringRules, +}: PutConnectorFilteringDraftArgs) => { + const route = `/internal/enterprise_search/connectors/${connectorId}/filtering/draft`; + + return await HttpLogic.values.http.put(route, { + body: JSON.stringify({ + advanced_snippet: advancedSnippet, + filtering_rules: filteringRules, + }), + }); +}; + +export const ConnectorFilteringDraftApiLogic = createApiLogic( + ['content', 'connector_filtering_draft_api_logic'], + putConnectorFilteringDraft +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/advanced_filtering_rules.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/advanced_filtering_rules.tsx new file mode 100644 index 000000000000..67c6bdf009f4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/advanced_filtering_rules.tsx @@ -0,0 +1,60 @@ +/* + * 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 { useActions, useValues } from 'kea'; + +import { EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; + +import { ConnectorFilteringLogic } from './connector_filtering_logic'; + +export const AdvancedFilteringRules: React.FC = () => { + const { hasJsonValidationError: hasError, localAdvancedSnippet } = + useValues(ConnectorFilteringLogic); + const { setLocalAdvancedSnippet } = useActions(ConnectorFilteringLogic); + return ( + + { + setLocalAdvancedSnippet(value); + }} + height="250px" + width="100%" + /> + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering.tsx new file mode 100644 index 000000000000..875a4e9a9c6a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering.tsx @@ -0,0 +1,195 @@ +/* + * 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 { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { FilteringRulesTable } from '../../../shared/filtering_rules_table/filtering_rules_table'; +import { IndexViewLogic } from '../../index_view_logic'; + +import { ConnectorFilteringLogic } from './connector_filtering_logic'; +import { EditFilteringFlyout } from './edit_filtering_flyout'; +import { FilteringStateCallouts } from './filtering_callouts'; + +export const ConnectorFiltering: React.FC = () => { + const { indexName } = useValues(IndexViewLogic); + const { applyDraft, setLocalFilteringRules, setLocalAdvancedSnippet, setIsEditing } = + useActions(ConnectorFilteringLogic); + const { advancedSnippet, draftState, filteringRules, hasDraft, isEditing } = + useValues(ConnectorFilteringLogic); + + return ( + <> + {isEditing && ( + setLocalFilteringRules(filteringRules)} + revertLocalAdvancedFiltering={() => setLocalAdvancedSnippet(advancedSnippet)} + setIsEditing={setIsEditing} + /> + )} + + + {hasDraft && ( + + setIsEditing(true)} + state={draftState} + /> + + )} + + + + + +

+ {i18n.translate('xpack.enterpriseSearch.index.connector.filtering.title', { + defaultMessage: 'Sync filters ', + })} +

+
+ + +

+ {i18n.translate('xpack.enterpriseSearch.index.connector.filtering.description', { + defaultMessage: `Include or exclude high level items, file types and (file or folder) paths to + synchronize from {indexName}. Everything is included by default. Each document is + tested against the reules below and the first rule that matches will be applied.`, + values: { + indexName, + }, + })} +

+

+ + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.syncFiltersLabel', + { + defaultMessage: 'Learn more about sync rules', + } + )} + +

+
+
+ + setIsEditing(!isEditing)} + > + {hasDraft + ? i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.editFilterRulesTitle', + { + defaultMessage: 'Edit filter rules', + } + ) + : i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.draftNewFilterRulesTitle', + { + defaultMessage: 'Draft new filter rules', + } + )} + + +
+
+ + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.basicFiltersTitle', + { + defaultMessage: 'Basic filters', + } + )} +

+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.basicFiltersDescription', + { + defaultMessage: 'These filters apply to documents in post-processing.', + } + )} +

+
+
+ +
+
+
+ {!!advancedSnippet && ( + + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.advancedFiltersTitle', + { + defaultMessage: 'Advanced filters', + } + )} +

+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.advancedFiltersDescription', + { + defaultMessage: 'These filters apply to documents at the data source.', + } + )} +

+

+ + {i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.advancedFiltersLinkTitle', + { + defaultMessage: 'Learn more about advanced sync rules.', + } + )} + +

+
+
+ + {advancedSnippet} + +
+
+
+ )} +
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering_form.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering_form.tsx new file mode 100644 index 000000000000..c0821a3bccfe --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering_form.tsx @@ -0,0 +1,76 @@ +/* + * 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 { useActions, useValues } from 'kea'; + +import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiForm } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; + +import { ConnectorFilteringLogic } from './connector_filtering_logic'; + +export const ConnectorFilteringForm: React.FC = ({ children }) => { + const { saveDraftFilteringRules, setIsEditing } = useActions(ConnectorFilteringLogic); + const { hasJsonValidationError, isEditing, isLoading } = useValues(ConnectorFilteringLogic); + + return ( + + + + {children} + + + + {isEditing && ( + + { + setIsEditing(!isEditing); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.cancelEditingFilteringDraft', + { + defaultMessage: 'Cancel', + } + )} + + + )} + + + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.validateDraftTitle', + { + defaultMessage: 'Save and validate draft', + } + )} + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering_logic.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering_logic.tsx new file mode 100644 index 000000000000..98b0c7f286c5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/connector_filtering_logic.tsx @@ -0,0 +1,343 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { isEqual } from 'lodash'; + +import { v4 as uuidv4 } from 'uuid'; + +import { i18n } from '@kbn/i18n'; + +import { Status } from '../../../../../../../common/types/api'; + +import { + FilteringConfig, + FilteringPolicy, + FilteringRule, + FilteringRuleRule, + FilteringValidationState, +} from '../../../../../../../common/types/connectors'; +import { Actions } from '../../../../../shared/api_logic/create_api_logic'; +import { + flashAPIErrors, + flashSuccessToast, + clearFlashMessages, +} from '../../../../../shared/flash_messages'; +import { + ConnectorFilteringApiLogic, + PutConnectorFilteringArgs, + PutConnectorFilteringResponse, +} from '../../../../api/connector/update_connector_filtering_api_logic'; +import { + ConnectorFilteringDraftApiLogic, + PutConnectorFilteringDraftArgs, + PutConnectorFilteringDraftResponse, +} from '../../../../api/connector/update_connector_filtering_draft_api_logic'; +import { + FetchIndexApiLogic, + FetchIndexApiParams, + FetchIndexApiResponse, +} from '../../../../api/index/fetch_index_api_logic'; +import { isConnectorIndex } from '../../../../utils/indices'; + +type ConnectorFilteringActions = Pick< + Actions, + 'apiError' | 'apiSuccess' | 'makeRequest' +> & { + addFilteringRule(filteringRule: FilteringRule): FilteringRule; + applyDraft: () => void; + deleteFilteringRule(filteringRule: FilteringRule): FilteringRule; + draftApiError: Actions< + PutConnectorFilteringDraftArgs, + PutConnectorFilteringDraftResponse + >['apiError']; + draftApiSuccess: Actions< + PutConnectorFilteringDraftArgs, + PutConnectorFilteringDraftResponse + >['apiSuccess']; + draftMakeRequest: Actions< + PutConnectorFilteringDraftArgs, + PutConnectorFilteringDraftResponse + >['makeRequest']; + fetchIndexApiSuccess: Actions['apiSuccess']; + reorderFilteringRules(filteringRules: FilteringRule[]): FilteringRule[]; + revertLocalAdvancedFiltering: () => void; + revertLocalFilteringRules: () => void; + saveDraftFilteringRules: () => void; + setFilteringConfig(filtering: FilteringConfig | null): FilteringConfig | null; + setHasJsonValidationError(hasJsonValidationError: boolean): { hasJsonValidationError: boolean }; + setIsEditing(isEditing: boolean): { isEditing: boolean }; + setLocalAdvancedSnippet(advancedSnippet: string): { advancedSnippet: string }; + setLocalFilteringRules(filteringRules: FilteringRule[]): FilteringRule[]; + updateFilteringRule(filteringRule: FilteringRule): FilteringRule; +}; + +interface ConnectorFilteringValues { + advancedSnippet: string; + draftState: FilteringValidationState; + editableFilteringRules: FilteringRule[]; + filteringConfig: FilteringConfig | null; + filteringRules: FilteringRule[]; + hasDraft: boolean; + hasJsonValidationError: boolean; + index: FetchIndexApiResponse; + isEditing: boolean; + isLoading: boolean; + jsonValidationError: boolean; + lastFilteringRule: FilteringRule; + localAdvancedSnippet: string; + localFilteringRules: FilteringRule[]; + status: Status; +} + +export const ConnectorFilteringLogic = kea< + MakeLogicType +>({ + actions: { + addFilteringRule: (filteringRule) => filteringRule, + applyDraft: true, + deleteFilteringRule: (filteringRule) => filteringRule, + reorderFilteringRules: (filteringRules) => filteringRules, + revertLocalAdvancedFiltering: true, + revertLocalFilteringRules: true, + saveDraftFilteringRules: true, + setFilteringConfig: (filteringConfig) => filteringConfig, + setIsEditing: (isEditing: boolean) => ({ + isEditing, + }), + + setLocalAdvancedSnippet: (advancedSnippet) => ({ + advancedSnippet, + }), + setLocalFilteringRules: (filteringRules) => filteringRules, + updateFilteringRule: (filteringRule) => filteringRule, + }, + connect: { + actions: [ + ConnectorFilteringApiLogic, + ['apiError', 'apiSuccess', 'makeRequest'], + ConnectorFilteringDraftApiLogic, + [ + 'apiError as draftApiError', + 'apiSuccess as draftApiSuccess', + 'makeRequest as draftMakeRequest', + ], + FetchIndexApiLogic, + ['apiSuccess as fetchIndexApiSuccess'], + ], + values: [ConnectorFilteringApiLogic, ['status'], FetchIndexApiLogic, ['data as index']], + }, + events: ({ actions, values }) => ({ + afterMount: () => + actions.setFilteringConfig( + isConnectorIndex(values.index) ? values.index.connector.filtering[0] : null + ), + }), + listeners: ({ actions, values }) => ({ + apiError: (error) => flashAPIErrors(error), + apiSuccess: () => { + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.successToastRules.title', + { defaultMessage: 'Filtering rules updated' } + ) + ); + }, + applyDraft: () => { + if (isConnectorIndex(values.index)) { + actions.makeRequest({ + advancedSnippet: values.localAdvancedSnippet ?? '', + connectorId: values.index.connector.id, + filteringRules: values.localFilteringRules ?? [], + }); + } + }, + draftApiError: (error) => flashAPIErrors(error), + draftApiSuccess: () => { + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.successToastDraft.title', + { defaultMessage: 'Draft saved' } + ) + ); + }, + draftMakeRequest: () => clearFlashMessages(), + fetchIndexApiSuccess: (index) => { + if ( + !values.isEditing && + isConnectorIndex(index) && + !isEqual(values.filteringConfig, index.connector.filtering[0]) + ) { + actions.setFilteringConfig(index.connector.filtering[0]); + } + }, + makeRequest: () => clearFlashMessages(), + saveDraftFilteringRules: () => { + if (isConnectorIndex(values.index)) { + actions.draftMakeRequest({ + advancedSnippet: values.localAdvancedSnippet ?? '', + connectorId: values.index.connector.id, + filteringRules: values.localFilteringRules ?? [], + }); + } + }, + setIsEditing: (isEditing) => { + if (isEditing && values.filteringConfig) { + actions.setLocalFilteringRules( + values.hasDraft ? values.filteringConfig.draft.rules : values.filteringConfig.active.rules + ); + actions.setLocalAdvancedSnippet( + values.hasDraft + ? JSON.stringify( + values.filteringConfig.draft.advanced_snippet.value ?? {}, + undefined, + 2 + ) + : JSON.stringify( + values.filteringConfig.active.advanced_snippet.value ?? {}, + undefined, + 2 + ) + ); + } + }, + }), + path: ['enterprise_search', 'content', 'connector_filtering'], + reducers: () => ({ + filteringConfig: [ + null, + { + apiSuccess: (filteringConfig, filteringRules) => + filteringConfig + ? { + ...filteringConfig, + active: filteringRules, + } + : null, + draftApiSuccess: (filteringConfig, filteringRules) => + filteringConfig + ? { + ...filteringConfig, + draft: filteringRules, + } + : null, + setFilteringConfig: (_, filteringConfig) => filteringConfig, + }, + ], + isEditing: [ + false, + { + draftApiError: () => false, + draftApiSuccess: () => false, + setIsEditing: (_, { isEditing }) => isEditing, + }, + ], + localAdvancedSnippet: [ + '', + { + setLocalAdvancedSnippet: (_, { advancedSnippet }) => advancedSnippet, + }, + ], + localFilteringRules: [ + [], + { + addFilteringRule: (filteringRules, filteringRule) => { + const newFilteringRules = filteringRules.length + ? [ + ...filteringRules.slice(0, filteringRules.length - 1), + filteringRule, + filteringRules[filteringRules.length - 1], + ] + : [ + filteringRule, + { + created_at: new Date().toISOString(), + field: '_', + id: uuidv4(), + order: 0, + policy: FilteringPolicy.INCLUDE, + rule: FilteringRuleRule.EQUALS, + updated_at: new Date().toISOString(), + value: '.*', + }, + ]; + return newFilteringRules.map((rule, index) => ({ ...rule, order: index })); + }, + deleteFilteringRule: (filteringRules, filteringRule) => + filteringRules.filter((rule) => rule.id !== filteringRule.id), + reorderFilteringRules: (filteringRules, newFilteringRules) => { + const lastItem = filteringRules.length + ? filteringRules[filteringRules.length - 1] + : { + created_at: new Date().toISOString(), + field: '_', + id: uuidv4(), + order: 0, + policy: FilteringPolicy.INCLUDE, + rule: FilteringRuleRule.EQUALS, + updated_at: new Date().toISOString(), + value: '.*', + }; + return [...newFilteringRules, lastItem].map((rule, index) => ({ ...rule, order: index })); + }, + setLocalFilteringRules: (_, filteringRules) => filteringRules, + updateFilteringRule: (filteringRules, filteringRule) => + filteringRules.map((rule) => (rule.id === filteringRule.id ? filteringRule : rule)), + }, + ], + }), + selectors: ({ selectors }) => ({ + advancedSnippet: [ + () => [selectors.filteringConfig], + (filteringConfig: FilteringConfig | null) => + filteringConfig?.active.advanced_snippet.value + ? JSON.stringify(filteringConfig?.active.advanced_snippet.value, undefined, 2) + : '', + ], + draftErrors: [ + () => [selectors.filteringConfig], + (filteringConfig: FilteringConfig | null) => filteringConfig?.draft.validation.errors ?? [], + ], + draftState: [ + () => [selectors.filteringConfig], + (filteringConfig: FilteringConfig | null) => + filteringConfig?.draft.validation.state ?? FilteringValidationState.VALID, + ], + editableFilteringRules: [ + () => [selectors.localFilteringRules], + (filteringRules: FilteringRule[] | null) => { + return filteringRules?.length ? filteringRules.slice(0, filteringRules.length - 1) : []; + }, + ], + filteringRules: [ + () => [selectors.filteringConfig], + (filteringConfig: FilteringConfig | null) => filteringConfig?.active.rules ?? [], + ], + hasDraft: [ + () => [selectors.filteringConfig], + (filteringConfig: FilteringConfig | null) => + !isEqual( + filteringConfig?.active.advanced_snippet.value, + filteringConfig?.draft.advanced_snippet.value + ) || !isEqual(filteringConfig?.draft.rules, filteringConfig?.active.rules), + ], + hasJsonValidationError: [ + () => [selectors.localAdvancedSnippet], + (advancedSnippet: string) => { + if (!advancedSnippet) return false; + try { + JSON.parse(advancedSnippet); + return false; + } catch { + return true; + } + }, + ], + isLoading: [() => [selectors.status], (status: Status) => status === Status.LOADING], + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/edit_filtering_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/edit_filtering_flyout.tsx new file mode 100644 index 000000000000..7d4fe096fd99 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/edit_filtering_flyout.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 { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiText, + EuiTabbedContent, + EuiTabbedContentTab, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AdvancedFilteringRules } from './advanced_filtering_rules'; +import { EditFilteringTab } from './edit_filtering_tab'; +import { FilteringRulesTable } from './editable_filtering_rules_table'; + +interface EditFilteringFlyoutProps { + revertLocalAdvancedFiltering: () => void; + revertLocalFilteringRules: () => void; + setIsEditing: (value: boolean) => void; +} + +enum FilteringTabs { + BASIC = 'basic', + ADVANCED = 'advanced', +} + +export const EditFilteringFlyout: React.FC = ({ + revertLocalFilteringRules, + revertLocalAdvancedFiltering, + setIsEditing, +}) => { + const tabs: EuiTabbedContentTab[] = [ + { + content: ( + + + + ), + id: FilteringTabs.BASIC, + name: i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.basicTabTitle', + { + defaultMessage: 'Basic filters', + } + ), + }, + { + content: ( + + + + ), + id: FilteringTabs.ADVANCED, + name: i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.advancedTabTitle', + { + defaultMessage: 'Advanced filters', + } + ), + }, + ]; + + return ( + setIsEditing(false)} + aria-labelledby="filteringFlyout" + size="l" + > + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.flyout.title', + { + defaultMessage: 'Draft rules', + } + )} +

+
+ + {i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.flyout.description', + { + defaultMessage: 'Plan and edit filters here before applying them to the next sync.', + } + )} + +
+ + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/edit_filtering_tab.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/edit_filtering_tab.tsx new file mode 100644 index 000000000000..48a815d3c55c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/edit_filtering_tab.tsx @@ -0,0 +1,43 @@ +/* + * 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, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { ConnectorFilteringForm } from './connector_filtering_form'; + +export const EditFilteringTab: React.FC<{ revertAction: () => void }> = ({ + children, + revertAction, +}) => { + return ( + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filtering.flyout.revertButtonTitle', + { + defaultMessage: 'Revert to active rules', + } + )} + + + + + + {children} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/editable_filtering_rules_table.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/editable_filtering_rules_table.tsx new file mode 100644 index 000000000000..e0d39e5a0b38 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/editable_filtering_rules_table.tsx @@ -0,0 +1,242 @@ +/* + * 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 { useActions, useValues } from 'kea'; +import { v4 as uuidv4 } from 'uuid'; + +import { + EuiCode, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSelect, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { + FilteringPolicy, + FilteringRule, + FilteringRuleRule, +} from '../../../../../../../common/types/connectors'; + +import { InlineEditableTable } from '../../../../../shared/tables/inline_editable_table/inline_editable_table'; +import { + InlineEditableTableLogic, + InlineEditableTableProps, +} from '../../../../../shared/tables/inline_editable_table/inline_editable_table_logic'; +import { + FormErrors, + InlineEditableTableColumn, +} from '../../../../../shared/tables/inline_editable_table/types'; +import { ItemWithAnID } from '../../../../../shared/tables/types'; + +import { + filteringPolicyToText, + filteringRuleToText, +} from '../../../../utils/filtering_rule_helpers'; + +import { IndexViewLogic } from '../../index_view_logic'; + +import { ConnectorFilteringLogic } from './connector_filtering_logic'; + +const instanceId = 'FilteringRulesTable'; + +function validateItem(filteringRule: FilteringRule): FormErrors { + if (filteringRule.rule === FilteringRuleRule.REGEX) { + try { + new RegExp(filteringRule.value); + return {}; + } catch { + return { + value: i18n.translate( + 'xpack.enterpriseSearch.content.index.connector.filteringRules.regExError', + { defaultMessage: 'Value should be a regular expression' } + ), + }; + } + } + return {}; +} + +export const FilteringRulesTable: React.FC = () => { + const { editableFilteringRules } = useValues(ConnectorFilteringLogic); + const { indexName } = useValues(IndexViewLogic); + const { addFilteringRule, deleteFilteringRule, reorderFilteringRules, updateFilteringRule } = + useActions(ConnectorFilteringLogic); + + const description = ( + + {i18n.translate('xpack.enterpriseSearch.content.index.connector.filteringRules.description', { + defaultMessage: + 'Add an indexing rule to customize what data is synchronized from {indexName}. Everything is included by default, and documents are validated against the configured set of indexing rules starting from the top listed down.', + values: { indexName }, + })} + + + {i18n.translate('xpack.enterpriseSearch.content.index.connector.filteringRules.link', { + defaultMessage: 'Learn more about customizing your index rules.', + })} + + + ); + + const columns: Array> = [ + { + editingRender: (filteringRule, onChange) => ( + onChange(e.target.value)} + options={[ + { + text: filteringPolicyToText(FilteringPolicy.INCLUDE), + value: FilteringPolicy.INCLUDE, + }, + { + text: filteringPolicyToText(FilteringPolicy.EXCLUDE), + value: FilteringPolicy.EXCLUDE, + }, + ]} + /> + ), + field: 'policy', + name: i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.basicTable.policyTitle', + { defaultMessage: 'Policy' } + ), + render: (indexingRule) => ( + {filteringPolicyToText(indexingRule.policy)} + ), + }, + { + editingRender: (filteringRule, onChange) => ( + + + onChange(e.target.value)} + /> + + + ), + field: 'field', + name: i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.basicTable.fieldTitle', + { defaultMessage: 'Field' } + ), + render: (indexingRule) => ( + + {indexingRule.field} + + ), + }, + { + editingRender: (filteringRule, onChange) => ( + onChange(e.target.value)} + options={Object.values(FilteringRuleRule).map((rule) => ({ + text: filteringRuleToText(rule), + value: rule, + }))} + /> + ), + field: 'rule', + name: i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.basicTable.ruleTitle', + { defaultMessage: 'Rule' } + ), + render: (filteringRule) => ( + {filteringRuleToText(filteringRule.rule)} + ), + }, + { + editingRender: (filteringRule, onChange) => ( + + + onChange(e.target.value)} + /> + + + ), + field: 'value', + name: i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.basicTable.valueTitle', + { + defaultMessage: 'Value', + } + ), + render: (indexingRule) => ( + + {indexingRule.value} + + ), + }, + ]; + + return ( + { + const now = new Date().toISOString(); + + const newRule = { + ...rule, + created_at: now, + // crypto.randomUUID isn't widely enough available in browsers yet + id: uuidv4(), + updated_at: now, + }; + addFilteringRule(newRule); + InlineEditableTableLogic({ + instanceId, + } as InlineEditableTableProps).actions.doneEditing(); + }} + onDelete={deleteFilteringRule} + onUpdate={updateFilteringRule} + onReorder={reorderFilteringRules} + title="" + validateItem={validateItem} + bottomRows={[ + + {i18n.translate( + 'xpack.enterpriseSearch.content.sources.filteringRulesTable.includeEverythingMessage', + { + defaultMessage: 'Include everything else from this source', + } + )} + , + ]} + canRemoveLastItem + emptyPropertyAllowed + showRowIndex + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/filtering_callouts.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/filtering_callouts.tsx new file mode 100644 index 000000000000..18c49513d1e6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/filtering/filtering_callouts.tsx @@ -0,0 +1,178 @@ +/* + * 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 { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { FilteringValidationState } from '../../../../../../../common/types/connectors'; + +interface FilteringStatusCalloutsProps { + applyDraft: () => void; + editDraft: () => void; + state: FilteringValidationState; +} + +export const FilteringStateCallouts: React.FC = ({ + applyDraft, + editDraft, + state, +}) => { + switch (state) { + case FilteringValidationState.EDITED: + return ( + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.validatingTitle', + { + defaultMessage: 'Draft sync rules are validating', + } + )} + + + } + > + + + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.validatingDescription', + { + defaultMessage: + 'Draft rules need to be validated before they can take effect. This may take a few minutes.', + } + )} + + + + + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.validatingCallout.editDraftRulesTitle', + { + defaultMessage: 'Edit draft rules', + } + )} + + + + + + ); + case FilteringValidationState.INVALID: + return ( + + + + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.invalidDescription', + { + defaultMessage: + 'Draft rules did not validate. Edit the draft rules before they can take effect.', + } + )} + + + + + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.errorCallout.editDraftRulesTitle', + { + defaultMessage: 'Edit draft rules', + } + )} + + + + + + ); + case FilteringValidationState.VALID: + return ( + + + + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.validatedDescription', + { + defaultMessage: 'Apply draft rules to take effect on the next sync.', + } + )} + + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.successCallout.applyDraftRulesTitle', + { + defaultMessage: 'Apply draft rules', + } + )} + + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.index.connector.filtering.errorCallout.successEditDraftRulesTitle', + { + defaultMessage: 'Edit draft rules', + } + )} + + + + + + + + ); + default: + return <>; + } +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.test.ts index e7a02b1191e0..e1827118935f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.test.ts @@ -34,6 +34,9 @@ const DEFAULT_VALUES = { connectorId: null, fetchIndexApiData: undefined, fetchIndexApiStatus: Status.LOADING, + hasAdvancedFilteringFeature: false, + hasBasicFilteringFeature: false, + hasFilteringFeature: false, index: undefined, indexData: null, indexName: 'index-name', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts index 43f1fcb29ded..f915a0935744 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import { Status } from '../../../../../common/types/api'; import { Connector, + FeatureName, IngestPipelineParams, SyncStatus, } from '../../../../../common/types/connectors'; @@ -70,6 +71,9 @@ export interface IndexViewValues { connectorId: string | null; fetchIndexApiData: typeof CachedFetchIndexApiLogic.values.fetchIndexApiData; fetchIndexApiStatus: Status; + hasAdvancedFilteringFeature: boolean; + hasBasicFilteringFeature: boolean; + hasFilteringFeature: boolean; index: ElasticsearchViewIndex | undefined; indexData: typeof CachedFetchIndexApiLogic.values.indexData; indexName: string; @@ -203,6 +207,20 @@ export const IndexViewLogic = kea [selectors.indexData], (index) => (isConnectorViewIndex(index) ? index.connector.id : null), ], + hasAdvancedFilteringFeature: [ + () => [selectors.connector], + (connector?: Connector) => + connector?.features ? connector.features[FeatureName.FILTERING_ADVANCED_CONFIG] : false, + ], + hasBasicFilteringFeature: [ + () => [selectors.connector], + (connector?: Connector) => + connector?.features ? connector.features[FeatureName.FILTERING_RULES] : false, + ], + hasFilteringFeature: [ + () => [selectors.hasAdvancedFilteringFeature, selectors.hasBasicFilteringFeature], + (advancedFeature: boolean, basicFeature: boolean) => advancedFeature || basicFeature, + ], index: [ () => [selectors.indexData], (data: IndexViewValues['indexData']) => (data ? indexToViewIndex(data) : undefined), diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx index 18f1972675f7..fe5089e92b93 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx @@ -24,6 +24,7 @@ import { SEARCH_INDEX_SELECT_CONNECTOR_PATH, SEARCH_INDEX_TAB_PATH, } from '../../routes'; + import { isConnectorIndex, isCrawlerIndex } from '../../utils/indices'; import { EnterpriseSearchContentPageTemplate } from '../layout/page_template'; @@ -32,6 +33,7 @@ import { baseBreadcrumbs } from '../search_indices'; import { getHeaderActions } from './components/header_actions/header_actions'; import { ConnectorConfiguration } from './connector/connector_configuration'; import { ConnectorSchedulingComponent } from './connector/connector_scheduling'; +import { ConnectorFiltering } from './connector/filtering/connector_filtering'; import { AutomaticCrawlScheduler } from './crawler/automatic_crawl_scheduler/automatic_crawl_scheduler'; import { CrawlCustomSettingsFlyout } from './crawler/crawl_custom_settings_flyout/crawl_custom_settings_flyout'; import { SearchIndexDomainManagement } from './crawler/domain_management/domain_management'; @@ -50,13 +52,14 @@ export enum SearchIndexTabId { PIPELINES = 'pipelines', // connector indices CONFIGURATION = 'configuration', + FILTERS = 'filters', SCHEDULING = 'scheduling', // crawler indices DOMAIN_MANAGEMENT = 'domain_management', } export const SearchIndex: React.FC = () => { - const { index, isInitialLoading } = useValues(IndexViewLogic); + const { hasFilteringFeature, index, isInitialLoading } = useValues(IndexViewLogic); const { tabId = SearchIndexTabId.OVERVIEW } = useParams<{ tabId?: string; @@ -122,6 +125,17 @@ export const SearchIndex: React.FC = () => { defaultMessage: 'Configuration', }), }, + ...(hasFilteringFeature + ? [ + { + content: , + id: SearchIndexTabId.FILTERS, + name: i18n.translate('xpack.enterpriseSearch.content.searchIndex.filtersTabLabel', { + defaultMessage: 'Filters', + }), + }, + ] + : []), { content: , id: SearchIndexTabId.SCHEDULING, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/sync_jobs/__snapshots__/filtering_panel.test.tsx.snap b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/sync_jobs/__snapshots__/filtering_panel.test.tsx.snap index b79b4c3927b5..474f60bd853f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/sync_jobs/__snapshots__/filtering_panel.test.tsx.snap +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/sync_jobs/__snapshots__/filtering_panel.test.tsx.snap @@ -5,35 +5,9 @@ exports[`FilteringPanel renders 1`] = ` - - } - responsive={true} - tableLayout="fixed" + @@ -44,34 +18,9 @@ exports[`FilteringPanel renders advanced snippet 1`] = ` - ", "value": "THIS VALUE", }, + Object { + "order": 4, + "policy": "exclude", + "rule": "<", + "value": "THIS VALUE", + }, ] } - noItemsMessage={ - - } - responsive={true} - tableLayout="fixed" + showOrder={false} /> @@ -134,34 +82,9 @@ exports[`FilteringPanel renders filtering rules list 1`] = ` - ", "value": "THIS VALUE", }, + Object { + "order": 4, + "policy": "exclude", + "rule": "<", + "value": "THIS VALUE", + }, ] } - noItemsMessage={ - - } - responsive={true} - tableLayout="fixed" + showOrder={false} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/sync_jobs/filtering_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/sync_jobs/filtering_panel.tsx index 1fba4a4d19a0..08e07259b14a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/sync_jobs/filtering_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/sync_jobs/filtering_panel.tsx @@ -7,25 +7,13 @@ import React from 'react'; -import { - EuiBasicTable, - EuiBasicTableColumn, - EuiCode, - EuiCodeBlock, - EuiPanel, - EuiSpacer, -} from '@elastic/eui'; +import { EuiCodeBlock, EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { - FilteringPolicy, - FilteringRule, - FilteringRuleRule, - FilteringRules, -} from '../../../../../../common/types/connectors'; +import { FilteringRule, FilteringRules } from '../../../../../../common/types/connectors'; -import { filteringRuleToText, filteringPolicyToText } from '../../../utils/filtering_rule_helpers'; +import { FilteringRulesTable } from '../../shared/filtering_rules_table/filtering_rules_table'; import { FlyoutPanel } from './flyout_panel'; @@ -38,29 +26,6 @@ export const FilteringPanel: React.FC = ({ advancedSnippet, filteringRules, }) => { - const columns: Array> = [ - { - field: 'policy', - name: i18n.translate('xpack.enterpriseSearch.content.index.syncJobs.filtering.policy', { - defaultMessage: 'Pipeline setting', - }), - render: (policy: FilteringPolicy) => filteringPolicyToText(policy), - }, - { - field: 'rule', - name: i18n.translate('xpack.enterpriseSearch.content.index.syncJobs.filtering.rule', { - defaultMessage: 'Rule', - }), - render: (rule: FilteringRuleRule) => filteringRuleToText(rule), - }, - { - field: 'value', - name: i18n.translate('xpack.enterpriseSearch.content.index.syncJobs.filtering.value', { - defaultMessage: 'Value', - }), - render: (value: string) => {value}, - }, - ]; return ( <> = ({ defaultMessage: 'Filtering', })} > - order - secondOrder)} - /> + {!!advancedSnippet?.value ? ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/filtering_rules_table/filtering_rules_table.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/filtering_rules_table/filtering_rules_table.tsx new file mode 100644 index 000000000000..fc31f7892261 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/filtering_rules_table/filtering_rules_table.tsx @@ -0,0 +1,77 @@ +/* + * 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 { EuiBasicTable, EuiBasicTableColumn, EuiCode } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { + FilteringRule, + FilteringPolicy, + FilteringRuleRule, +} from '../../../../../../common/types/connectors'; + +import { filteringPolicyToText, filteringRuleToText } from '../../../utils/filtering_rule_helpers'; + +interface FilteringRulesTableProps { + filteringRules: FilteringRule[]; + showOrder: boolean; +} + +export const FilteringRulesTable: React.FC = ({ + showOrder, + filteringRules, +}) => { + const columns: Array> = [ + ...(showOrder + ? [ + { + field: 'order', + name: i18n.translate('xpack.enterpriseSearch.content.index.filtering.priority', { + defaultMessage: 'Rule priority', + }), + }, + ] + : []), + { + field: 'policy', + name: i18n.translate('xpack.enterpriseSearch.content.index.filtering.policy', { + defaultMessage: 'Policy', + }), + render: (policy: FilteringPolicy) => filteringPolicyToText(policy), + }, + { + field: 'field', + name: i18n.translate('xpack.enterpriseSearch.content.index.filtering.field', { + defaultMessage: 'field', + }), + render: (value: string) => {value}, + }, + { + field: 'rule', + name: i18n.translate('xpack.enterpriseSearch.content.index.filtering.rule', { + defaultMessage: 'Rule', + }), + render: (rule: FilteringRuleRule) => filteringRuleToText(rule), + }, + { + field: 'value', + name: i18n.translate('xpack.enterpriseSearch.content.index.filtering.value', { + defaultMessage: 'Value', + }), + render: (value: string) => {value}, + }, + ]; + return ( + order - secondOrder)} + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.tsx index 3293e8c5021d..6cffd3bd9c3a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/action_column.tsx @@ -24,6 +24,7 @@ import { InlineEditableTableLogic } from './inline_editable_table_logic'; interface ActionColumnProps { displayedItems: Item[]; + emptyPropertyAllowed?: boolean; isActivelyEditing: (i: Item) => boolean; isLoading?: boolean; item: Item; @@ -33,6 +34,7 @@ interface ActionColumnProps { } export const ActionColumn = ({ + emptyPropertyAllowed = false, displayedItems, isActivelyEditing, isLoading = false, @@ -61,7 +63,11 @@ export const ActionColumn = ({ color="primary" iconType="checkInCircleFilled" onClick={isEditingUnsavedItem ? saveNewItem : saveExistingItem} - disabled={isLoading || isInvalid || doesEditingItemValueContainEmptyProperty} + disabled={ + isLoading || + isInvalid || + (doesEditingItemValueContainEmptyProperty && !emptyPropertyAllowed) + } > {SAVE_BUTTON_LABEL} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.test.tsx index 6fccdfd327df..d431c9f0c148 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.test.tsx @@ -81,6 +81,7 @@ describe('getUpdatedColumns', () => { expect(actionColumn.props()).toEqual({ isActivelyEditing: expect.any(Function), displayedItems, + emptyPropertyAllowed: false, isLoading: false, canRemoveLastItem, lastItemWarning, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.tsx index e3a3b2b496a9..f6c6398a2510 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/tables/inline_editable_table/get_updated_columns.tsx @@ -17,6 +17,7 @@ import { InlineEditableTableColumn } from './types'; interface GetUpdatedColumnProps { columns: Array>; displayedItems: Item[]; + emptyPropertyAllowed?: boolean; isActivelyEditing: (item: Item) => boolean; canRemoveLastItem?: boolean; isLoading?: boolean; @@ -27,6 +28,7 @@ interface GetUpdatedColumnProps { export const getUpdatedColumns = ({ columns, displayedItems, + emptyPropertyAllowed = false, isActivelyEditing, canRemoveLastItem, isLoading = false, @@ -52,6 +54,7 @@ export const getUpdatedColumns = ({ render: (item: Item) => ( { columns: Array>; items: Item[]; defaultItem?: Partial; + emptyPropertyAllowed?: boolean; title: string; addButtonText?: string; canRemoveLastItem?: boolean; @@ -87,6 +88,7 @@ export const InlineEditableTable = ( export const InlineEditableTableContents = ({ columns, + emptyPropertyAllowed, items, title, addButtonText, @@ -117,6 +119,7 @@ export const InlineEditableTableContents = ({ const updatedColumns = getUpdatedColumns({ columns, displayedItems, + emptyPropertyAllowed, isActivelyEditing, canRemoveLastItem, isLoading, diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts index d97a6f0b7999..e9f22cae5b94 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts @@ -88,6 +88,7 @@ describe('addConnector lib function', () => { configuration: {}, description: null, error: null, + features: null, filtering: [ { active: { @@ -270,6 +271,7 @@ describe('addConnector lib function', () => { configuration: {}, description: null, error: null, + features: null, filtering: [ { active: { @@ -374,6 +376,7 @@ describe('addConnector lib function', () => { configuration: {}, description: null, error: null, + features: null, filtering: [ { active: { diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts index 05a3b9ab4b7e..f0ff60a07543 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts @@ -95,6 +95,7 @@ export const addConnector = async ( configuration: {}, description: null, error: null, + features: null, filtering: [ { active: { diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.test.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.test.ts index cd3d1a5a20d0..d44bc07fd6ad 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.test.ts @@ -29,11 +29,17 @@ describe('fetchConnectors lib', () => { describe('fetch connector by id', () => { it('should fetch connector by id', async () => { mockClient.asCurrentUser.get.mockImplementationOnce(() => - Promise.resolve({ _id: 'connectorId', _source: { source: 'source' } }) + Promise.resolve({ + _id: 'connectorId', + _primary_term: 'primaryTerm', + _seq_no: 5, + _source: { source: 'source' }, + }) ); await expect(fetchConnectorById(mockClient as any, 'id')).resolves.toEqual({ - id: 'connectorId', - source: 'source', + primaryTerm: 'primaryTerm', + seqNo: 5, + value: { id: 'connectorId', source: 'source' }, }); expect(mockClient.asCurrentUser.get).toHaveBeenCalledWith({ id: 'id', diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.ts index 4ef696dc3cd4..d579d3e085d1 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/fetch_connectors.ts @@ -10,6 +10,7 @@ import { IScopedClusterClient } from '@kbn/core/server'; import { CONNECTORS_INDEX } from '../..'; import { Connector, ConnectorDocument } from '../../../common/types/connectors'; +import { OptimisticConcurrency } from '../../../common/types/util_types'; import { setupConnectorsIndices } from '../../index_management/setup_indices'; import { isIndexNotFoundException } from '../../utils/identify_exceptions'; @@ -18,14 +19,18 @@ import { fetchAll } from '../fetch_all'; export const fetchConnectorById = async ( client: IScopedClusterClient, connectorId: string -): Promise => { +): Promise | undefined> => { try { const connectorResult = await client.asCurrentUser.get({ id: connectorId, index: CONNECTORS_INDEX, }); return connectorResult._source - ? { ...connectorResult._source, id: connectorResult._id } + ? { + primaryTerm: connectorResult._primary_term, + seqNo: connectorResult._seq_no, + value: { ...connectorResult._source, id: connectorResult._id }, + } : undefined; } catch (error) { if (isIndexNotFoundException(error)) { diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/put_update_filtering.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/put_update_filtering.ts new file mode 100644 index 000000000000..5b113af164b1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/put_update_filtering.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; + +import { CONNECTORS_INDEX } from '../..'; +import { + Connector, + FilteringRule, + FilteringRules, + FilteringValidationState, +} from '../../../common/types/connectors'; + +import { fetchConnectorById } from './fetch_connectors'; + +export const updateFiltering = async ( + client: IScopedClusterClient, + connectorId: string, + { + advancedSnippet, + filteringRules, + }: { + advancedSnippet: string; + filteringRules: FilteringRule[]; + } +): Promise => { + const now = new Date().toISOString(); + const parsedAdvancedSnippet: Record = advancedSnippet + ? JSON.parse(advancedSnippet) + : {}; + const parsedFilteringRules = filteringRules.map((filteringRule) => ({ + ...filteringRule, + created_at: filteringRule.created_at ? filteringRule.created_at : now, + updated_at: now, + })); + const connectorResult = await fetchConnectorById(client, connectorId); + if (!connectorResult) { + throw new Error(`Could not find connector with id ${connectorId}`); + } + const { value: connector, seqNo, primaryTerm } = connectorResult; + const active: FilteringRules = { + advanced_snippet: { + created_at: connector.filtering[0].active.advanced_snippet.created_at || now, + updated_at: now, + value: parsedAdvancedSnippet, + }, + rules: parsedFilteringRules, + validation: { + errors: [], + state: FilteringValidationState.VALID, + }, + }; + + const result = await client.asCurrentUser.update({ + doc: { ...connector, filtering: [{ ...connector.filtering[0], active, draft: active }] }, + id: connectorId, + if_primary_term: primaryTerm, + if_seq_no: seqNo, + index: CONNECTORS_INDEX, + refresh: true, + }); + + return result.result === 'updated' ? active : undefined; +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/put_update_filtering_draft.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/put_update_filtering_draft.ts new file mode 100644 index 000000000000..ffa93cf92da3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/put_update_filtering_draft.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; + +import { CONNECTORS_INDEX } from '../..'; +import { + Connector, + FilteringRule, + FilteringRules, + FilteringValidationState, +} from '../../../common/types/connectors'; + +import { fetchConnectorById } from './fetch_connectors'; + +export const updateFilteringDraft = async ( + client: IScopedClusterClient, + connectorId: string, + { + advancedSnippet, + filteringRules, + }: { + advancedSnippet: string; + filteringRules: FilteringRule[]; + } +): Promise => { + const now = new Date().toISOString(); + const parsedAdvancedSnippet: Record = advancedSnippet + ? JSON.parse(advancedSnippet) + : {}; + const parsedFilteringRules = filteringRules.map((filteringRule) => ({ + ...filteringRule, + created_at: filteringRule.created_at ? filteringRule.created_at : now, + updated_at: now, + })); + const draft: FilteringRules = { + advanced_snippet: { + created_at: now, + updated_at: now, + value: parsedAdvancedSnippet, + }, + rules: parsedFilteringRules, + validation: { + errors: [], + state: FilteringValidationState.EDITED, + }, + }; + const connectorResult = await fetchConnectorById(client, connectorId); + if (!connectorResult) { + throw new Error(`Could not find connector with id ${connectorId}`); + } + const { value: connector, seqNo, primaryTerm } = connectorResult; + + const result = await client.asCurrentUser.update({ + doc: { ...connector, filtering: [{ ...connector.filtering[0], draft }] }, + id: connectorId, + if_primary_term: primaryTerm, + if_seq_no: seqNo, + index: CONNECTORS_INDEX, + refresh: true, + }); + + return result.result === 'updated' ? draft : undefined; +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts index 81207f19d126..42de9eb46f65 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts @@ -9,12 +9,19 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; -import { ConnectorStatus } from '../../../common/types/connectors'; +import { + ConnectorStatus, + FilteringPolicy, + FilteringRule, + FilteringRuleRule, +} from '../../../common/types/connectors'; import { ErrorCode } from '../../../common/types/error_codes'; import { addConnector } from '../../lib/connectors/add_connector'; import { fetchSyncJobsByConnectorId } from '../../lib/connectors/fetch_sync_jobs'; import { cancelSyncs } from '../../lib/connectors/post_cancel_syncs'; +import { updateFiltering } from '../../lib/connectors/put_update_filtering'; +import { updateFilteringDraft } from '../../lib/connectors/put_update_filtering_draft'; import { startConnectorSync } from '../../lib/connectors/start_sync'; import { updateConnectorConfiguration } from '../../lib/connectors/update_connector_configuration'; import { updateConnectorNameAndDescription } from '../../lib/connectors/update_connector_name_and_description'; @@ -28,6 +35,7 @@ import { updateConnectorPipeline } from '../../lib/pipelines/update_pipeline'; import { RouteDependencies } from '../../plugin'; import { createError } from '../../utils/create_error'; import { elasticsearchErrorHandler } from '../../utils/elasticsearch_error_handler'; +import { validateEnum } from '../../utils/validate_enum'; export function registerConnectorRoutes({ router, log }: RouteDependencies) { router.post( @@ -127,12 +135,12 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) { { path: '/internal/enterprise_search/connectors/{connectorId}/start_sync', validate: { - params: schema.object({ - connectorId: schema.string(), - }), body: schema.object({ nextSyncConfig: schema.maybe(schema.string()), }), + params: schema.object({ + connectorId: schema.string(), + }), }, }, elasticsearchErrorHandler(log, async (context, request, response) => { @@ -225,10 +233,10 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) { { path: '/internal/enterprise_search/connectors/{connectorId}/service_type', validate: { + body: schema.object({ serviceType: schema.string() }), params: schema.object({ connectorId: schema.string(), }), - body: schema.object({ serviceType: schema.string() }), }, }, elasticsearchErrorHandler(log, async (context, request, response) => { @@ -246,10 +254,10 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) { { path: '/internal/enterprise_search/connectors/{connectorId}/status', validate: { + body: schema.object({ status: schema.string() }), params: schema.object({ connectorId: schema.string(), }), - body: schema.object({ status: schema.string() }), }, }, elasticsearchErrorHandler(log, async (context, request, response) => { @@ -267,13 +275,13 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) { { path: '/internal/enterprise_search/connectors/{connectorId}/name_and_description', validate: { + body: schema.object({ + description: schema.nullable(schema.string()), + name: schema.string(), + }), params: schema.object({ connectorId: schema.string(), }), - body: schema.object({ - name: schema.string(), - description: schema.nullable(schema.string()), - }), }, }, elasticsearchErrorHandler(log, async (context, request, response) => { @@ -286,4 +294,88 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) { return response.ok({ body: result }); }) ); + + router.put( + { + path: '/internal/enterprise_search/connectors/{connectorId}/filtering/draft', + validate: { + body: schema.object({ + advanced_snippet: schema.string(), + filtering_rules: schema.arrayOf( + schema.object({ + created_at: schema.string(), + field: schema.string(), + id: schema.string(), + order: schema.number(), + policy: schema.string({ + validate: validateEnum(FilteringPolicy, 'policy'), + }), + rule: schema.string({ + validate: validateEnum(FilteringRuleRule, 'rule'), + }), + updated_at: schema.string(), + value: schema.string(), + }) + ), + }), + params: schema.object({ + connectorId: schema.string(), + }), + }, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const { client } = (await context.core).elasticsearch; + const { connectorId } = request.params; + const { advanced_snippet, filtering_rules } = request.body; + const result = await updateFilteringDraft(client, connectorId, { + advancedSnippet: advanced_snippet, + // Have to cast here because our API schema validator doesn't know how to deal with enums + // We're relying on the schema in the validator above to flag if something goes wrong + filteringRules: filtering_rules as FilteringRule[], + }); + return result ? response.ok({ body: result }) : response.conflict(); + }) + ); + + router.put( + { + path: '/internal/enterprise_search/connectors/{connectorId}/filtering', + validate: { + body: schema.object({ + advanced_snippet: schema.string(), + filtering_rules: schema.arrayOf( + schema.object({ + created_at: schema.string(), + field: schema.string(), + id: schema.string(), + order: schema.number(), + policy: schema.string({ + validate: validateEnum(FilteringPolicy, 'policy'), + }), + rule: schema.string({ + validate: validateEnum(FilteringRuleRule, 'rule'), + }), + updated_at: schema.string(), + value: schema.string(), + }) + ), + }), + params: schema.object({ + connectorId: schema.string(), + }), + }, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const { client } = (await context.core).elasticsearch; + const { connectorId } = request.params; + const { advanced_snippet, filtering_rules } = request.body; + const result = await updateFiltering(client, connectorId, { + advancedSnippet: advanced_snippet, + // Have to cast here because our API schema validator doesn't know how to deal with enums + // We're relying on the schema in the validator above to flag if something goes wrong + filteringRules: filtering_rules as FilteringRule[], + }); + return result ? response.ok({ body: result }) : response.conflict(); + }) + ); } diff --git a/x-pack/plugins/enterprise_search/server/utils/validate_enum.ts b/x-pack/plugins/enterprise_search/server/utils/validate_enum.ts new file mode 100644 index 000000000000..d3323a18264a --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/utils/validate_enum.ts @@ -0,0 +1,32 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +/** + * + * @param enumToValidateAgainst enum to validate against + * @param fieldName name of field, used for diagnostic messaging + * @returns A validator usable in our API schema validator to validate that values adhere to the enum they're supposed to have + */ + +export function validateEnum( + enumToValidateAgainst: Record, + fieldName: string +) { + return (value: string) => { + if (!Object.values(enumToValidateAgainst).includes(value)) { + return i18n.translate('xpack.enterpriseSearch.server.utils.invalidEnumValue', { + defaultMessage: 'Illegal value {value} for field {fieldName}', + values: { + fieldName, + value, + }, + }); + } + }; +}