[Enterprise Search] Add editable filter rules to connectors (#145170)

This commit is contained in:
Sander Philipse 2022-11-15 14:15:35 +01:00 committed by GitHub
parent 5ad2a36305
commit 3e0448e69a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1787 additions and 164 deletions

View file

@ -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<FeatureName, boolean | null> | null;
filtering: FilteringConfig[];
id: string;
index_name: string;

View file

@ -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<T> {
primaryTerm: number | undefined;
seqNo: number | undefined;
value: T;
}

View file

@ -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: {

View file

@ -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: {

View file

@ -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
);

View file

@ -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
);

View file

@ -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 (
<EuiFormRow
label={i18n.translate(
'xpack.enterpriseSearch.content.indices.connector.filtering.advancedRules.title',
{
defaultMessage: 'Advanced filtering rules',
}
)}
isInvalid={hasError}
error={
hasError
? i18n.translate(
'xpack.enterpriseSearch.content.indices.connector.filtering.advancedRules.error',
{
defaultMessage: 'JSON format is invalid',
}
)
: undefined
}
fullWidth
>
<CodeEditor
isCopyable
languageId="json"
options={{
detectIndentation: true,
lineNumbers: 'on',
tabSize: 2,
}}
value={localAdvancedSnippet}
onChange={(value) => {
setLocalAdvancedSnippet(value);
}}
height="250px"
width="100%"
/>
</EuiFormRow>
);
};

View file

@ -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 && (
<EditFilteringFlyout
revertLocalFilteringRules={() => setLocalFilteringRules(filteringRules)}
revertLocalAdvancedFiltering={() => setLocalAdvancedSnippet(advancedSnippet)}
setIsEditing={setIsEditing}
/>
)}
<EuiSpacer />
<EuiFlexGroup direction="column">
{hasDraft && (
<EuiFlexItem>
<FilteringStateCallouts
applyDraft={applyDraft}
editDraft={() => setIsEditing(true)}
state={draftState}
/>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="s">
<h3>
{i18n.translate('xpack.enterpriseSearch.index.connector.filtering.title', {
defaultMessage: 'Sync filters ',
})}
</h3>
</EuiTitle>
<EuiSpacer />
<EuiText size="s">
<p>
{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,
},
})}
</p>
<p>
<EuiLink href="TODOTODOTODOTODO" external>
{i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.syncFiltersLabel',
{
defaultMessage: 'Learn more about sync rules',
}
)}
</EuiLink>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-telemetry-id="entSearchContent-connector-filtering-editRules-editDraftRules"
color="primary"
onClick={() => 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',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel color="plain" hasShadow={false} hasBorder>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="s">
<h3>
{i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.basicFiltersTitle',
{
defaultMessage: 'Basic filters',
}
)}
</h3>
</EuiTitle>
<EuiSpacer />
<EuiText size="s">
<p>
{i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.basicFiltersDescription',
{
defaultMessage: 'These filters apply to documents in post-processing.',
}
)}
</p>
</EuiText>
</EuiFlexItem>
<FilteringRulesTable filteringRules={filteringRules} showOrder />
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
{!!advancedSnippet && (
<EuiFlexItem>
<EuiPanel color="plain" hasShadow={false} hasBorder>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="s">
<h3>
{i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.advancedFiltersTitle',
{
defaultMessage: 'Advanced filters',
}
)}
</h3>
</EuiTitle>
<EuiSpacer />
<EuiText size="s">
<p>
{i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.advancedFiltersDescription',
{
defaultMessage: 'These filters apply to documents at the data source.',
}
)}
</p>
<p>
<EuiLink external href="TODOTODOTODODOTO">
{i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.advancedFiltersLinkTitle',
{
defaultMessage: 'Learn more about advanced sync rules.',
}
)}
</EuiLink>
</p>
</EuiText>
</EuiFlexItem>
<EuiCodeBlock isCopyable language="json">
{advancedSnippet}
</EuiCodeBlock>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
)}
</EuiFlexGroup>
</>
);
};

View file

@ -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 (
<EuiFlexGroup direction="column">
<UnsavedChangesPrompt
hasUnsavedChanges={isEditing}
messageText={i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.unsavedChanges',
{
defaultMessage: 'Your changes have not been saved. Are you sure you want to leave?',
}
)}
/>
<EuiFlexItem>
<EuiForm>{children}</EuiForm>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween">
{isEditing && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-telemetry-id="entSearchContent-connector-filtering-editRules-cancelEditing"
onClick={() => {
setIsEditing(!isEditing);
}}
>
{i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.cancelEditingFilteringDraft',
{
defaultMessage: 'Cancel',
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButton
data-telemetry-id="entSearchContent-connector-filtering-editRules-saveAndValidate"
disabled={hasJsonValidationError}
isLoading={isLoading}
onClick={saveDraftFilteringRules}
>
{i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.validateDraftTitle',
{
defaultMessage: 'Save and validate draft',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -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<PutConnectorFilteringArgs, PutConnectorFilteringResponse>,
'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<FetchIndexApiParams, FetchIndexApiResponse>['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<ConnectorFilteringValues, ConnectorFilteringActions>
>({
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],
}),
});

View file

@ -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<EditFilteringFlyoutProps> = ({
revertLocalFilteringRules,
revertLocalAdvancedFiltering,
setIsEditing,
}) => {
const tabs: EuiTabbedContentTab[] = [
{
content: (
<EditFilteringTab revertAction={revertLocalFilteringRules}>
<FilteringRulesTable />
</EditFilteringTab>
),
id: FilteringTabs.BASIC,
name: i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.basicTabTitle',
{
defaultMessage: 'Basic filters',
}
),
},
{
content: (
<EditFilteringTab revertAction={revertLocalAdvancedFiltering}>
<AdvancedFilteringRules />
</EditFilteringTab>
),
id: FilteringTabs.ADVANCED,
name: i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.advancedTabTitle',
{
defaultMessage: 'Advanced filters',
}
),
},
];
return (
<EuiFlyout
ownFocus
onClose={() => setIsEditing(false)}
aria-labelledby="filteringFlyout"
size="l"
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id="filteringFlyout">
{i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.flyout.title',
{
defaultMessage: 'Draft rules',
}
)}
</h2>
</EuiTitle>
<EuiText size="s">
{i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.flyout.description',
{
defaultMessage: 'Plan and edit filters here before applying them to the next sync.',
}
)}
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiTabbedContent tabs={tabs} />
</EuiFlyoutBody>
</EuiFlyout>
);
};

View file

@ -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 (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
data-telemetry-id="entSearchContent-connector-filtering-editRules-revert"
onClick={revertAction}
>
{i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.flyout.revertButtonTitle',
{
defaultMessage: 'Revert to active rules',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<ConnectorFilteringForm>{children}</ConnectorFilteringForm>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -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 = (
<EuiText size="s" color="default">
{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 },
})}
<EuiSpacer />
<EuiLink href={'TODOTODOTODO'} external>
{i18n.translate('xpack.enterpriseSearch.content.index.connector.filteringRules.link', {
defaultMessage: 'Learn more about customizing your index rules.',
})}
</EuiLink>
</EuiText>
);
const columns: Array<InlineEditableTableColumn<FilteringRule>> = [
{
editingRender: (filteringRule, onChange) => (
<EuiSelect
fullWidth
value={filteringRule.policy}
onChange={(e) => 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) => (
<EuiText size="s">{filteringPolicyToText(indexingRule.policy)}</EuiText>
),
},
{
editingRender: (filteringRule, onChange) => (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem>
<EuiFieldText
fullWidth
value={filteringRule.field}
onChange={(e) => onChange(e.target.value)}
/>
</EuiFlexItem>
</EuiFlexGroup>
),
field: 'field',
name: i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.basicTable.fieldTitle',
{ defaultMessage: 'Field' }
),
render: (indexingRule) => (
<EuiText size="s">
<EuiCode>{indexingRule.field}</EuiCode>
</EuiText>
),
},
{
editingRender: (filteringRule, onChange) => (
<EuiSelect
fullWidth
value={filteringRule.rule}
onChange={(e) => 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) => (
<EuiText size="s">{filteringRuleToText(filteringRule.rule)}</EuiText>
),
},
{
editingRender: (filteringRule, onChange) => (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem>
<EuiFieldText
fullWidth
value={filteringRule.value}
onChange={(e) => onChange(e.target.value)}
/>
</EuiFlexItem>
</EuiFlexGroup>
),
field: 'value',
name: i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.basicTable.valueTitle',
{
defaultMessage: 'Value',
}
),
render: (indexingRule) => (
<EuiText size="s">
<EuiCode>{indexingRule.value}</EuiCode>
</EuiText>
),
},
];
return (
<InlineEditableTable
addButtonText={i18n.translate(
'xpack.enterpriseSearch.content.index.connector.filtering.table.addRuleLabel',
{ defaultMessage: 'Add filter rule' }
)}
columns={columns}
defaultItem={{
policy: FilteringPolicy.INCLUDE,
rule: FilteringRuleRule.EQUALS,
value: '',
}}
description={description}
instanceId={instanceId}
items={editableFilteringRules}
onAdd={(rule) => {
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<ItemWithAnID>).actions.doneEditing();
}}
onDelete={deleteFilteringRule}
onUpdate={updateFilteringRule}
onReorder={reorderFilteringRules}
title=""
validateItem={validateItem}
bottomRows={[
<EuiText size="s">
{i18n.translate(
'xpack.enterpriseSearch.content.sources.filteringRulesTable.includeEverythingMessage',
{
defaultMessage: 'Include everything else from this source',
}
)}
</EuiText>,
]}
canRemoveLastItem
emptyPropertyAllowed
showRowIndex
/>
);
};

View file

@ -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<FilteringStatusCalloutsProps> = ({
applyDraft,
editDraft,
state,
}) => {
switch (state) {
case FilteringValidationState.EDITED:
return (
<EuiCallOut
color="warning"
title={
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner />
</EuiFlexItem>
<EuiFlexItem>
{i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.validatingTitle',
{
defaultMessage: 'Draft sync rules are validating',
}
)}
</EuiFlexItem>
</EuiFlexGroup>
}
>
<EuiFlexGroup direction="column">
<EuiFlexItem>
{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.',
}
)}
</EuiFlexItem>
<EuiFlexItem>
<span>
<EuiButton
data-telemetry-id="entSearchContent-connector-filtering-validatingCallout-editRules"
onClick={editDraft}
color="warning"
fill
>
{i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.validatingCallout.editDraftRulesTitle',
{
defaultMessage: 'Edit draft rules',
}
)}
</EuiButton>
</span>
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
);
case FilteringValidationState.INVALID:
return (
<EuiCallOut
color="danger"
iconType="cross"
title={i18n.translate('xpack.enterpriseSearch.index.connector.filtering.invalidTitle', {
defaultMessage: 'Draft sync rules are invalid',
})}
>
<EuiFlexGroup direction="column">
<EuiFlexItem>
{i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.invalidDescription',
{
defaultMessage:
'Draft rules did not validate. Edit the draft rules before they can take effect.',
}
)}
</EuiFlexItem>
<EuiFlexItem>
<span>
<EuiButton
data-telemetry-id="entSearchContent-connector-filtering-errorCallout-editRules"
onClick={editDraft}
color="danger"
fill
>
{i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.errorCallout.editDraftRulesTitle',
{
defaultMessage: 'Edit draft rules',
}
)}
</EuiButton>
</span>
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
);
case FilteringValidationState.VALID:
return (
<EuiCallOut
color="success"
iconType="check"
title={i18n.translate('xpack.enterpriseSearch.index.connector.filtering.validatedTitle', {
defaultMessage: 'Draft sync rules validated',
})}
>
<EuiFlexGroup direction="column">
<EuiFlexItem>
{i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.validatedDescription',
{
defaultMessage: 'Apply draft rules to take effect on the next sync.',
}
)}
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="flexStart">
<EuiFlexItem grow={false}>
<span>
<EuiButton
data-telemetry-id="entSearchContent-connector-filtering-successCallout-applyRules"
onClick={applyDraft}
color="success"
fill
>
{i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.successCallout.applyDraftRulesTitle',
{
defaultMessage: 'Apply draft rules',
}
)}
</EuiButton>
</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>
<EuiButton
data-telemetry-id="entSearchContent-connector-filtering-successCallout-editRules"
onClick={editDraft}
color="success"
>
{i18n.translate(
'xpack.enterpriseSearch.index.connector.filtering.errorCallout.successEditDraftRulesTitle',
{
defaultMessage: 'Edit draft rules',
}
)}
</EuiButton>
</span>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
);
default:
return <></>;
}
};

View file

@ -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',

View file

@ -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<MakeLogicType<IndexViewValues, IndexViewAction
() => [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),

View file

@ -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: <ConnectorFiltering />,
id: SearchIndexTabId.FILTERS,
name: i18n.translate('xpack.enterpriseSearch.content.searchIndex.filtersTabLabel', {
defaultMessage: 'Filters',
}),
},
]
: []),
{
content: <ConnectorSchedulingComponent />,
id: SearchIndexTabId.SCHEDULING,

View file

@ -5,35 +5,9 @@ exports[`FilteringPanel renders 1`] = `
<FlyoutPanel
title="Filtering"
>
<EuiBasicTable
columns={
Array [
Object {
"field": "policy",
"name": "Pipeline setting",
"render": [Function],
},
Object {
"field": "rule",
"name": "Rule",
"render": [Function],
},
Object {
"field": "value",
"name": "Value",
"render": [Function],
},
]
}
items={Array []}
noItemsMessage={
<EuiI18n
default="No items found"
token="euiBasicTable.noItemsMessage"
/>
}
responsive={true}
tableLayout="fixed"
<FilteringRulesTable
filteringRules={Array []}
showOrder={false}
/>
</FlyoutPanel>
</Fragment>
@ -44,34 +18,9 @@ exports[`FilteringPanel renders advanced snippet 1`] = `
<FlyoutPanel
title="Filtering"
>
<EuiBasicTable
columns={
<FilteringRulesTable
filteringRules={
Array [
Object {
"field": "policy",
"name": "Pipeline setting",
"render": [Function],
},
Object {
"field": "rule",
"name": "Rule",
"render": [Function],
},
Object {
"field": "value",
"name": "Value",
"render": [Function],
},
]
}
items={
Array [
Object {
"order": 0,
"policy": "include",
"rule": "equals",
"value": "THIS VALUE",
},
Object {
"order": 1,
"policy": "exclude",
@ -85,9 +34,9 @@ exports[`FilteringPanel renders advanced snippet 1`] = `
"value": "THIS VALUE",
},
Object {
"order": 4,
"policy": "exclude",
"rule": "<",
"order": 0,
"policy": "include",
"rule": "equals",
"value": "THIS VALUE",
},
Object {
@ -96,16 +45,15 @@ exports[`FilteringPanel renders advanced snippet 1`] = `
"rule": ">",
"value": "THIS VALUE",
},
Object {
"order": 4,
"policy": "exclude",
"rule": "<",
"value": "THIS VALUE",
},
]
}
noItemsMessage={
<EuiI18n
default="No items found"
token="euiBasicTable.noItemsMessage"
/>
}
responsive={true}
tableLayout="fixed"
showOrder={false}
/>
</FlyoutPanel>
<EuiSpacer />
@ -134,34 +82,9 @@ exports[`FilteringPanel renders filtering rules list 1`] = `
<FlyoutPanel
title="Filtering"
>
<EuiBasicTable
columns={
<FilteringRulesTable
filteringRules={
Array [
Object {
"field": "policy",
"name": "Pipeline setting",
"render": [Function],
},
Object {
"field": "rule",
"name": "Rule",
"render": [Function],
},
Object {
"field": "value",
"name": "Value",
"render": [Function],
},
]
}
items={
Array [
Object {
"order": 0,
"policy": "include",
"rule": "equals",
"value": "THIS VALUE",
},
Object {
"order": 1,
"policy": "exclude",
@ -175,9 +98,9 @@ exports[`FilteringPanel renders filtering rules list 1`] = `
"value": "THIS VALUE",
},
Object {
"order": 4,
"policy": "exclude",
"rule": "<",
"order": 0,
"policy": "include",
"rule": "equals",
"value": "THIS VALUE",
},
Object {
@ -186,16 +109,15 @@ exports[`FilteringPanel renders filtering rules list 1`] = `
"rule": ">",
"value": "THIS VALUE",
},
Object {
"order": 4,
"policy": "exclude",
"rule": "<",
"value": "THIS VALUE",
},
]
}
noItemsMessage={
<EuiI18n
default="No items found"
token="euiBasicTable.noItemsMessage"
/>
}
responsive={true}
tableLayout="fixed"
showOrder={false}
/>
</FlyoutPanel>
</Fragment>

View file

@ -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<FilteringPanelProps> = ({
advancedSnippet,
filteringRules,
}) => {
const columns: Array<EuiBasicTableColumn<FilteringRule>> = [
{
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) => <EuiCode>{value}</EuiCode>,
},
];
return (
<>
<FlyoutPanel
@ -68,10 +33,7 @@ export const FilteringPanel: React.FC<FilteringPanelProps> = ({
defaultMessage: 'Filtering',
})}
>
<EuiBasicTable
columns={columns}
items={filteringRules.sort(({ order }, { order: secondOrder }) => order - secondOrder)}
/>
<FilteringRulesTable filteringRules={filteringRules} showOrder={false} />
</FlyoutPanel>
{!!advancedSnippet?.value ? (
<>

View file

@ -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<FilteringRulesTableProps> = ({
showOrder,
filteringRules,
}) => {
const columns: Array<EuiBasicTableColumn<FilteringRule>> = [
...(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) => <EuiCode>{value}</EuiCode>,
},
{
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) => <EuiCode>{value}</EuiCode>,
},
];
return (
<EuiBasicTable
columns={columns}
items={filteringRules.sort(({ order }, { order: secondOrder }) => order - secondOrder)}
/>
);
};

View file

@ -24,6 +24,7 @@ import { InlineEditableTableLogic } from './inline_editable_table_logic';
interface ActionColumnProps<Item extends ItemWithAnID> {
displayedItems: Item[];
emptyPropertyAllowed?: boolean;
isActivelyEditing: (i: Item) => boolean;
isLoading?: boolean;
item: Item;
@ -33,6 +34,7 @@ interface ActionColumnProps<Item extends ItemWithAnID> {
}
export const ActionColumn = <Item extends ItemWithAnID>({
emptyPropertyAllowed = false,
displayedItems,
isActivelyEditing,
isLoading = false,
@ -61,7 +63,11 @@ export const ActionColumn = <Item extends ItemWithAnID>({
color="primary"
iconType="checkInCircleFilled"
onClick={isEditingUnsavedItem ? saveNewItem : saveExistingItem}
disabled={isLoading || isInvalid || doesEditingItemValueContainEmptyProperty}
disabled={
isLoading ||
isInvalid ||
(doesEditingItemValueContainEmptyProperty && !emptyPropertyAllowed)
}
>
{SAVE_BUTTON_LABEL}
</EuiButtonEmpty>

View file

@ -81,6 +81,7 @@ describe('getUpdatedColumns', () => {
expect(actionColumn.props()).toEqual({
isActivelyEditing: expect.any(Function),
displayedItems,
emptyPropertyAllowed: false,
isLoading: false,
canRemoveLastItem,
lastItemWarning,

View file

@ -17,6 +17,7 @@ import { InlineEditableTableColumn } from './types';
interface GetUpdatedColumnProps<Item extends ItemWithAnID> {
columns: Array<InlineEditableTableColumn<Item>>;
displayedItems: Item[];
emptyPropertyAllowed?: boolean;
isActivelyEditing: (item: Item) => boolean;
canRemoveLastItem?: boolean;
isLoading?: boolean;
@ -27,6 +28,7 @@ interface GetUpdatedColumnProps<Item extends ItemWithAnID> {
export const getUpdatedColumns = <Item extends ItemWithAnID>({
columns,
displayedItems,
emptyPropertyAllowed = false,
isActivelyEditing,
canRemoveLastItem,
isLoading = false,
@ -52,6 +54,7 @@ export const getUpdatedColumns = <Item extends ItemWithAnID>({
render: (item: Item) => (
<ActionColumn
displayedItems={displayedItems}
emptyPropertyAllowed={emptyPropertyAllowed ?? false}
isActivelyEditing={isActivelyEditing}
isLoading={isLoading}
canRemoveLastItem={canRemoveLastItem}

View file

@ -28,6 +28,7 @@ export interface InlineEditableTableProps<Item extends ItemWithAnID> {
columns: Array<InlineEditableTableColumn<Item>>;
items: Item[];
defaultItem?: Partial<Item>;
emptyPropertyAllowed?: boolean;
title: string;
addButtonText?: string;
canRemoveLastItem?: boolean;
@ -87,6 +88,7 @@ export const InlineEditableTable = <Item extends ItemWithAnID>(
export const InlineEditableTableContents = <Item extends ItemWithAnID>({
columns,
emptyPropertyAllowed,
items,
title,
addButtonText,
@ -117,6 +119,7 @@ export const InlineEditableTableContents = <Item extends ItemWithAnID>({
const updatedColumns = getUpdatedColumns({
columns,
displayedItems,
emptyPropertyAllowed,
isActivelyEditing,
canRemoveLastItem,
isLoading,

View file

@ -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: {

View file

@ -95,6 +95,7 @@ export const addConnector = async (
configuration: {},
description: null,
error: null,
features: null,
filtering: [
{
active: {

View file

@ -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',

View file

@ -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<Connector | undefined> => {
): Promise<OptimisticConcurrency<Connector> | undefined> => {
try {
const connectorResult = await client.asCurrentUser.get<ConnectorDocument>({
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)) {

View file

@ -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<FilteringRules | undefined> => {
const now = new Date().toISOString();
const parsedAdvancedSnippet: Record<string, unknown> = 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<Connector>({
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;
};

View file

@ -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<FilteringRules | undefined> => {
const now = new Date().toISOString();
const parsedAdvancedSnippet: Record<string, unknown> = 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<Connector>({
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;
};

View file

@ -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();
})
);
}

View file

@ -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<string | number, string | number>,
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,
},
});
}
};
}