[Workplace Search] Add indexing rules table (#124353)

[Workplace Search] Add indexing rules table
This commit is contained in:
Sander Philipse 2022-02-16 15:39:49 +01:00 committed by GitHub
parent 335d9f376a
commit 995c177629
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1312 additions and 106 deletions

View file

@ -27,6 +27,7 @@ import './inline_editable_tables.scss';
export interface InlineEditableTableProps<Item extends ItemWithAnID> {
columns: Array<InlineEditableTableColumn<Item>>;
items: Item[];
defaultItem?: Partial<Item>;
title: string;
addButtonText?: string;
canRemoveLastItem?: boolean;
@ -53,6 +54,7 @@ export const InlineEditableTable = <Item extends ItemWithAnID>(
const {
instanceId,
columns,
defaultItem,
onAdd,
onDelete,
onReorder,
@ -67,6 +69,7 @@ export const InlineEditableTable = <Item extends ItemWithAnID>(
props={{
instanceId,
columns,
defaultItem,
onAdd,
onDelete,
onReorder,
@ -90,6 +93,7 @@ export const InlineEditableTableContents = <Item extends ItemWithAnID>({
description,
isLoading,
lastItemWarning,
defaultItem,
noItemsMessage = () => null,
uneditableItems,
...rest

View file

@ -48,6 +48,7 @@ interface InlineEditableTableValues<Item extends ItemWithAnID> {
export interface InlineEditableTableProps<Item extends ItemWithAnID> {
columns: Array<InlineEditableTableColumn<Item>>;
instanceId: string;
defaultItem: Item;
// TODO Because these callbacks are params, they are only set on the logic once (i.e., they are cached)
// which makes using "useState" to back this really hard.
onAdd(item: Item, onSuccess: () => void): void;
@ -79,12 +80,15 @@ export const InlineEditableTableLogic = kea<InlineEditableTableLogicType<ItemWit
setFieldErrors: (fieldErrors) => ({ fieldErrors }),
setRowErrors: (rowErrors) => ({ rowErrors }),
}),
reducers: ({ props: { columns } }) => ({
reducers: ({ props: { columns, defaultItem } }) => ({
editingItemValue: [
null,
{
doneEditing: () => null,
editNewItem: () => generateEmptyItem(columns),
editNewItem: () =>
defaultItem
? { ...generateEmptyItem(columns), ...defaultItem }
: generateEmptyItem(columns),
editExistingItem: (_, { item }) => item,
setEditingItemValue: (_, { item }) => item,
},

View file

@ -7,6 +7,7 @@
import { groups } from './groups.mock';
import { IndexingRule } from '../types';
import { staticSourceData } from '../views/content_sources/source_data';
import { mergeServerAndStaticData } from '../views/content_sources/sources_logic';
@ -45,10 +46,25 @@ export const contentSources = [
},
];
const defaultIndexingRules: IndexingRule[] = [
{
filterType: 'object_type',
include: 'value',
},
{
filterType: 'path_template',
exclude: 'value',
},
{
filterType: 'file_extension',
include: 'value',
},
];
const defaultIndexing = {
enabled: true,
defaultAction: 'include',
rules: [],
rules: defaultIndexingRules,
schedule: {
full: 'P1D',
incremental: 'PT2H',

View file

@ -60,10 +60,10 @@ export const NAV = {
defaultMessage: 'Frequency',
}
),
SYNCHRONIZATION_OBJECTS_AND_ASSETS: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.nav.synchronizationObjectsAndAssets',
SYNCHRONIZATION_ASSETS_AND_OBJECTS: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.nav.synchronizationAssetsAndObjects',
{
defaultMessage: 'Objects and assets',
defaultMessage: 'Assets and objects',
}
),
DISPLAY_SETTINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.displaySettings', {

View file

@ -76,7 +76,8 @@ export const DISPLAY_SETTINGS_RESULT_DETAIL_PATH = `${SOURCE_DISPLAY_SETTINGS_PA
export const SYNC_FREQUENCY_PATH = `${SOURCE_SYNCHRONIZATION_PATH}/frequency`;
export const BLOCKED_TIME_WINDOWS_PATH = `${SOURCE_SYNCHRONIZATION_PATH}/frequency/blocked_windows`;
export const OBJECTS_AND_ASSETS_PATH = `${SOURCE_SYNCHRONIZATION_PATH}/objects_and_assets`;
export const OLD_OBJECTS_AND_ASSETS_PATH = `${SOURCE_SYNCHRONIZATION_PATH}/objects_and_assets`;
export const ASSETS_AND_OBJECTS_PATH = `${SOURCE_SYNCHRONIZATION_PATH}/assets_and_objects`;
export const ORG_SETTINGS_PATH = '/settings';
export const ORG_SETTINGS_CUSTOMIZE_PATH = `${ORG_SETTINGS_PATH}/customize`;

View file

@ -168,6 +168,18 @@ export interface BlockedWindow {
end: string;
}
export interface IndexingRuleExclude {
filterType: 'object_type' | 'path_template' | 'file_extension';
exclude: string;
}
export interface IndexingRuleInclude {
filterType: 'object_type' | 'path_template' | 'file_extension';
include: string;
}
export type IndexingRule = IndexingRuleInclude | IndexingRuleExclude;
export interface IndexingConfig {
enabled: boolean;
features: {
@ -178,6 +190,7 @@ export interface IndexingConfig {
enabled: boolean;
};
};
rules: IndexingRule[];
schedule: IndexingSchedule;
}

View file

@ -119,9 +119,9 @@ describe('useSourceSubNav', () => {
href: '/sources/2/synchronization/frequency',
},
{
id: 'sourceSynchronizationObjectsAndAssets',
name: 'Objects and assets',
href: '/sources/2/synchronization/objects_and_assets',
id: 'sourceSynchronizationAssetsAndObjects',
name: 'Assets and objects',
href: '/sources/2/synchronization/assets_and_objects',
},
],
},

View file

@ -16,19 +16,19 @@ import { shallow } from 'enzyme';
import { EuiSwitch } from '@elastic/eui';
import { ObjectsAndAssets } from './objects_and_assets';
import { AssetsAndObjects } from './assets_and_objects';
describe('ObjectsAndAssets', () => {
describe('AssetsAndObjects', () => {
const setThumbnailsChecked = jest.fn();
const setContentExtractionChecked = jest.fn();
const updateObjectsAndAssetsSettings = jest.fn();
const updateAssetsAndObjectsSettings = jest.fn();
const resetSyncSettings = jest.fn();
const contentSource = fullContentSources[0];
const mockActions = {
setThumbnailsChecked,
setContentExtractionChecked,
updateObjectsAndAssetsSettings,
updateAssetsAndObjectsSettings,
resetSyncSettings,
};
const mockValues = {
@ -37,7 +37,7 @@ describe('ObjectsAndAssets', () => {
contentSource,
thumbnailsChecked: true,
contentExtractionChecked: true,
hasUnsavedObjectsAndAssetsChanges: false,
hasUnsavedAssetsAndObjectsChanges: false,
};
beforeEach(() => {
@ -46,13 +46,13 @@ describe('ObjectsAndAssets', () => {
});
it('renders', () => {
const wrapper = shallow(<ObjectsAndAssets />);
const wrapper = shallow(<AssetsAndObjects />);
expect(wrapper.find(EuiSwitch)).toHaveLength(2);
});
it('handles thumbnails switch change', () => {
const wrapper = shallow(<ObjectsAndAssets />);
const wrapper = shallow(<AssetsAndObjects />);
wrapper
.find('[data-test-subj="ThumbnailsToggle"]')
.simulate('change', { target: { checked: false } });
@ -61,7 +61,7 @@ describe('ObjectsAndAssets', () => {
});
it('handles content extraction switch change', () => {
const wrapper = shallow(<ObjectsAndAssets />);
const wrapper = shallow(<AssetsAndObjects />);
wrapper
.find('[data-test-subj="ContentExtractionToggle"]')
.simulate('change', { target: { checked: false } });
@ -77,7 +77,7 @@ describe('ObjectsAndAssets', () => {
areThumbnailsConfigEnabled: false,
},
});
const wrapper = shallow(<ObjectsAndAssets />);
const wrapper = shallow(<AssetsAndObjects />);
expect(wrapper.find('[data-test-subj="ThumbnailsToggle"]').prop('label')).toEqual(
'Sync thumbnails - disabled at global configuration level'

View file

@ -18,7 +18,7 @@ import {
EuiLink,
EuiSpacer,
EuiSwitch,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { SAVE_BUTTON_LABEL } from '../../../../../shared/constants';
@ -27,27 +27,29 @@ import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prom
import { ViewContentHeader } from '../../../../components/shared/view_content_header';
import { NAV, RESET_BUTTON } from '../../../../constants';
import {
LEARN_MORE_LINK,
SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL,
SYNC_MANAGEMENT_THUMBNAILS_LABEL,
SYNC_MANAGEMENT_THUMBNAILS_GLOBAL_CONFIG_LABEL,
SOURCE_OBJECTS_AND_ASSETS_DESCRIPTION,
SOURCE_OBJECTS_AND_ASSETS_LABEL,
SOURCE_ASSETS_AND_OBJECTS_DESCRIPTION,
SOURCE_ASSETS_AND_OBJECTS_ASSETS_LABEL,
SYNC_UNSAVED_CHANGES_MESSAGE,
SOURCE_ASSETS_AND_OBJECTS_LEARN_MORE_LINK,
SOURCE_ASSETS_AND_OBJECTS_OBJECTS_LABEL,
} from '../../constants';
import { SourceLogic } from '../../source_logic';
import { SourceLayout } from '../source_layout';
import { IndexingRulesTable } from './indexing_rules_table';
import { SynchronizationLogic } from './synchronization_logic';
export const ObjectsAndAssets: React.FC = () => {
export const AssetsAndObjects: React.FC = () => {
const { contentSource, dataLoading } = useValues(SourceLogic);
const { thumbnailsChecked, contentExtractionChecked, hasUnsavedObjectsAndAssetsChanges } =
const { thumbnailsChecked, contentExtractionChecked, hasUnsavedAssetsAndObjectsChanges } =
useValues(SynchronizationLogic({ contentSource }));
const {
setThumbnailsChecked,
setContentExtractionChecked,
updateObjectsAndAssetsSettings,
updateAssetsAndObjectsSettings,
resetSyncSettings,
} = useActions(SynchronizationLogic({ contentSource }));
@ -55,47 +57,43 @@ export const ObjectsAndAssets: React.FC = () => {
const actions = (
<EuiFlexGroup>
<EuiFlexItem>
<EuiButtonEmpty onClick={resetSyncSettings} disabled={!hasUnsavedObjectsAndAssetsChanges}>
{RESET_BUTTON}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
fill
onClick={updateObjectsAndAssetsSettings}
disabled={!hasUnsavedObjectsAndAssetsChanges}
onClick={updateAssetsAndObjectsSettings}
disabled={!hasUnsavedAssetsAndObjectsChanges}
>
{SAVE_BUTTON_LABEL}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty onClick={resetSyncSettings} disabled={!hasUnsavedAssetsAndObjectsChanges}>
{RESET_BUTTON}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
);
return (
<SourceLayout
pageChrome={[NAV.SYNCHRONIZATION_OBJECTS_AND_ASSETS]}
pageChrome={[NAV.SYNCHRONIZATION_ASSETS_AND_OBJECTS]}
pageViewTelemetry="source_synchronization"
isLoading={dataLoading}
>
<UnsavedChangesPrompt
hasUnsavedChanges={hasUnsavedObjectsAndAssetsChanges}
hasUnsavedChanges={hasUnsavedAssetsAndObjectsChanges}
messageText={SYNC_UNSAVED_CHANGES_MESSAGE}
/>
<ViewContentHeader
title={NAV.SYNCHRONIZATION_OBJECTS_AND_ASSETS}
description={
<>
{SOURCE_OBJECTS_AND_ASSETS_DESCRIPTION}{' '}
<EuiLink href={docLinks.workplaceSearchSynch} external>
{LEARN_MORE_LINK}
</EuiLink>
</>
}
action={actions}
/>
<ViewContentHeader title={NAV.SYNCHRONIZATION_ASSETS_AND_OBJECTS} action={actions} />
{SOURCE_ASSETS_AND_OBJECTS_DESCRIPTION}
<EuiSpacer />
<EuiLink href={docLinks.workplaceSearchSynch} external>
{SOURCE_ASSETS_AND_OBJECTS_LEARN_MORE_LINK}
</EuiLink>
<EuiHorizontalRule />
<EuiText size="m">{SOURCE_OBJECTS_AND_ASSETS_LABEL}</EuiText>
<EuiTitle size="s">
<h3>{SOURCE_ASSETS_AND_OBJECTS_ASSETS_LABEL}</h3>
</EuiTitle>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
@ -122,6 +120,15 @@ export const ObjectsAndAssets: React.FC = () => {
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule />
<EuiTitle size="s">
<h3>{SOURCE_ASSETS_AND_OBJECTS_OBJECTS_LABEL}</h3>
</EuiTitle>
<EuiFlexGroup>
<EuiFlexItem>
<IndexingRulesTable />
</EuiFlexItem>
</EuiFlexGroup>
</SourceLayout>
);
};

View file

@ -0,0 +1,250 @@
/*
* 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 {
LogicMounter,
mockFlashMessageHelpers,
setMockActions,
setMockValues,
} from '../../../../../__mocks__/kea_logic';
import { fullContentSources } from '../../../../__mocks__/content_sources.mock';
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiFieldText, EuiSelect } from '@elastic/eui';
import { InlineEditableTable } from '../../../../../shared/tables/inline_editable_table';
import { SourceLogic } from '../../source_logic';
import { IndexingRulesTable } from './indexing_rules_table';
import { SynchronizationLogic } from './synchronization_logic';
describe('IndexingRulesTable', () => {
const { clearFlashMessages } = mockFlashMessageHelpers;
const { mount: sourceMount } = new LogicMounter(SourceLogic);
const { mount: syncMount } = new LogicMounter(SynchronizationLogic);
const indexingRules = [
{ id: 0, valueType: 'exclude', filterType: 'path_template', value: 'value' },
{ id: 1, valueType: 'include', filterType: 'file_extension', value: 'value' },
{ id: 2, valueType: 'include', filterType: 'object_type', value: 'value 2' },
{ id: 3, valueType: 'broken', filterType: 'not allowed', value: 'value 2' },
];
const contentSource = fullContentSources[0];
beforeEach(() => {
jest.clearAllMocks();
sourceMount({}, {});
setMockValues({ contentSource });
syncMount({}, { contentSource });
});
it('renders', () => {
const wrapper = shallow(<IndexingRulesTable />);
expect(wrapper.find(InlineEditableTable).exists()).toBe(true);
});
describe('columns', () => {
let wrapper: ShallowWrapper;
beforeEach(() => {
wrapper = shallow(<IndexingRulesTable />);
});
const renderColumn = (index: number, ruleIndex: number) => {
const columns = wrapper.find(InlineEditableTable).prop('columns');
return shallow(<div>{columns[index].render(indexingRules[ruleIndex])}</div>);
};
const onChange = jest.fn();
const renderColumnInEditingMode = (index: number, ruleIndex: number) => {
const columns = wrapper.find(InlineEditableTable).prop('columns');
return shallow(
<div>
{columns[index].editingRender(indexingRules[ruleIndex], onChange, {
isInvalid: false,
isLoading: false,
})}
</div>
);
};
describe(' column', () => {
it('shows the value type of an indexing rule', () => {
expect(renderColumn(0, 0).html()).toContain('Exclude');
expect(renderColumn(0, 1).html()).toContain('Include');
expect(renderColumn(0, 3).html()).toContain('');
});
it('can show the value type of an indexing rule as editable', () => {
const column = renderColumnInEditingMode(0, 0);
const selectField = column.find(EuiSelect);
expect(selectField.props()).toEqual(
expect.objectContaining({
value: 'exclude',
disabled: false,
isInvalid: false,
options: [
{ text: 'Include', value: 'include' },
{ text: 'Exclude', value: 'exclude' },
],
})
);
selectField.simulate('change', { target: { value: 'include' } });
expect(onChange).toHaveBeenCalledWith('include');
});
});
describe('filter type column', () => {
it('shows the filter type of an indexing rule', () => {
expect(renderColumn(1, 0).html()).toContain('Path');
expect(renderColumn(1, 1).html()).toContain('File');
expect(renderColumn(1, 2).html()).toContain('Item');
expect(renderColumn(1, 3).html()).toContain('');
});
it('can show the filter type of an indexing rule as editable', () => {
const column = renderColumnInEditingMode(1, 0);
const selectField = column.find(EuiSelect);
expect(selectField.props()).toEqual(
expect.objectContaining({
value: 'path_template',
disabled: false,
isInvalid: false,
options: [
{ text: 'Item', value: 'object_type' },
{ text: 'Path', value: 'path_template' },
{ text: 'File type', value: 'file_extension' },
],
})
);
selectField.simulate('change', { target: { value: 'object_type' } });
expect(onChange).toHaveBeenCalledWith('object_type');
});
});
describe('pattern column', () => {
it('shows the value of an indexing rule', () => {
expect(renderColumn(2, 0).html()).toContain('value');
});
it('can show the value of a indexing rule as editable', () => {
const column = renderColumnInEditingMode(2, 0);
const field = column.find(EuiFieldText);
expect(field.props()).toEqual(
expect.objectContaining({
value: 'value',
disabled: false,
isInvalid: false,
})
);
field.simulate('change', { target: { value: 'foo' } });
expect(onChange).toHaveBeenCalledWith('foo');
});
});
});
describe('when an indexing rule is added', () => {
it('should update the indexing rules for the current domain, and clear flash messages', () => {
const initAddIndexingRule = jest.fn();
const done = jest.fn();
setMockActions({
initAddIndexingRule,
});
const wrapper = shallow(<IndexingRulesTable />);
const table = wrapper.find(InlineEditableTable);
const newIndexingRule = {
id: 2,
value: 'new value',
filterType: 'path_template',
valueType: 'include',
};
table.prop('onAdd')(newIndexingRule, done);
expect(initAddIndexingRule).toHaveBeenCalledWith(newIndexingRule);
expect(clearFlashMessages).toHaveBeenCalled();
});
});
describe('when an indexing rule is updated', () => {
it('should update the indexing rules for the current domain, and clear flash messages', () => {
const initSetIndexingRule = jest.fn();
const done = jest.fn();
setMockActions({
initSetIndexingRule,
});
const wrapper = shallow(<IndexingRulesTable />);
const table = wrapper.find(InlineEditableTable);
const newIndexingRule = {
id: 2,
value: 'new value',
filterType: 'path_template',
valueType: 'include',
};
table.prop('onUpdate')(newIndexingRule, done);
expect(initSetIndexingRule).toHaveBeenCalledWith(newIndexingRule);
expect(clearFlashMessages).toHaveBeenCalled();
});
});
describe('when a indexing rule is deleted', () => {
it('should update the indexing rules for the current domain, and clear flash messages', () => {
const deleteIndexingRule = jest.fn();
const done = jest.fn();
setMockActions({
deleteIndexingRule,
});
const wrapper = shallow(<IndexingRulesTable />);
const table = wrapper.find(InlineEditableTable);
const newIndexingRule = {
id: 2,
value: 'new value',
filterType: 'path_template',
valueType: 'include',
};
table.prop('onDelete')(newIndexingRule, done);
expect(deleteIndexingRule).toHaveBeenCalledWith(newIndexingRule);
expect(clearFlashMessages).toHaveBeenCalled();
});
});
describe('when an indexing rule is reordered', () => {
it('should update the indexing rules for the current domain, and clear flash messages', () => {
const setIndexingRules = jest.fn();
const done = jest.fn();
setMockActions({
setIndexingRules,
});
const wrapper = shallow(<IndexingRulesTable />);
const table = wrapper.find(InlineEditableTable);
const newIndexingRules = [
{
id: 2,
value: 'new value',
filterType: 'path_template',
valueType: 'include',
},
];
table.prop('onReorder')!(newIndexingRules, indexingRules, done);
expect(setIndexingRules).toHaveBeenCalledWith(newIndexingRules);
expect(clearFlashMessages).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,235 @@
/*
* 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 {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiSelect,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { docLinks } from '../../../../../shared/doc_links';
import { clearFlashMessages } from '../../../../../shared/flash_messages';
import { InlineEditableTable } from '../../../../../shared/tables/inline_editable_table/inline_editable_table';
import { InlineEditableTableColumn } from '../../../../../shared/tables/inline_editable_table/types';
import { SourceLogic } from '../../source_logic';
import { EditableIndexingRule, SynchronizationLogic } from './synchronization_logic';
const SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_POLICY_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsObjectsTablePolicyLabel',
{
defaultMessage: 'Policy',
}
);
const SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_PATH_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsObjectsTablePathLabel',
{
defaultMessage: 'Path',
}
);
export const SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_ITEM_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsObjectsTableItemLabel',
{
defaultMessage: 'Item',
}
);
export const SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_FILE_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsObjectsTableFileLabel',
{
defaultMessage: 'File type',
}
);
export const SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_INCLUDE_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsObjectsTableIncludeLabel',
{
defaultMessage: 'Include',
}
);
export const SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_EXCLUDE_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsObjectsTableExcludeLabel',
{
defaultMessage: 'Exclude',
}
);
export const IndexingRulesTable: React.FC = () => {
const { contentSource } = useValues(SourceLogic);
const indexingRulesInstanceId = 'IndexingRulesTable';
const { indexingRules } = useValues(
SynchronizationLogic({ contentSource, indexingRulesInstanceId })
);
const { initAddIndexingRule, deleteIndexingRule, initSetIndexingRule, setIndexingRules } =
useActions(SynchronizationLogic({ contentSource }));
const description = (
<EuiText size="s" color="default">
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsObjectsDescription',
{
defaultMessage:
'Include or exclude high level items, file types and (file or folder) paths to synchronize from {contentSourceName}. Everything is included by default. Each document is tested against the rules below and the first rule that matches will be applied.',
values: { contentSourceName: contentSource.name },
}
)}
<EuiSpacer />
<EuiLink href={docLinks.workplaceSearchSynch} external>
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsSyncLearnMoreLink',
{
defaultMessage: 'Learn more about sync rules.',
}
)}
</EuiLink>
</EuiText>
);
const valueTypeToString = (input: string): string => {
switch (input) {
case 'include':
return SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_INCLUDE_LABEL;
case 'exclude':
return SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_EXCLUDE_LABEL;
default:
return '';
}
};
const filterTypeToString = (input: string): string => {
switch (input) {
case 'object_type':
return SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_ITEM_LABEL;
case 'path_template':
return SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_PATH_LABEL;
case 'file_extension':
return SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_FILE_LABEL;
default:
return '';
}
};
const columns: Array<InlineEditableTableColumn<EditableIndexingRule>> = [
{
editingRender: (indexingRule, onChange, { isInvalid, isLoading }) => (
<EuiSelect
fullWidth
value={indexingRule.valueType}
onChange={(e) => onChange(e.target.value)}
disabled={isLoading}
isInvalid={isInvalid}
options={[
{ text: SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_INCLUDE_LABEL, value: 'include' },
{ text: SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_EXCLUDE_LABEL, value: 'exclude' },
]}
/>
),
render: (indexingRule) => (
<EuiText size="s">{valueTypeToString(indexingRule.valueType)}</EuiText>
),
name: SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_POLICY_LABEL,
field: 'valueType',
},
{
editingRender: (indexingRule, onChange, { isInvalid, isLoading }) => (
<EuiSelect
fullWidth
value={indexingRule.filterType}
onChange={(e) => onChange(e.target.value)}
disabled={isLoading}
isInvalid={isInvalid}
options={[
{ text: SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_ITEM_LABEL, value: 'object_type' },
{ text: SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_PATH_LABEL, value: 'path_template' },
{ text: SOURCE_ASSETS_AND_OBJECTS_OBJECTS_TABLE_FILE_LABEL, value: 'file_extension' },
]}
/>
),
render: (indexingRule) => (
<EuiText size="s">{filterTypeToString(indexingRule.filterType)}</EuiText>
),
name: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsObjectsTableRuleLabel',
{
defaultMessage: 'Rule',
}
),
field: 'filterType',
},
{
editingRender: (indexingRule, onChange, { isInvalid, isLoading }) => (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem>
<EuiFieldText
fullWidth
value={indexingRule.value}
onChange={(e) => onChange(e.target.value)}
disabled={isLoading}
isInvalid={isInvalid}
/>
</EuiFlexItem>
</EuiFlexGroup>
),
render: (indexingRule) => <EuiText size="s">{indexingRule.value}</EuiText>,
name: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsObjectsTableValueLabel',
{
defaultMessage: 'Value',
}
),
field: 'value',
},
];
return (
<InlineEditableTable
addButtonText={i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsAddRuleLabel',
{ defaultMessage: 'Add indexing rule' }
)}
columns={columns}
defaultItem={{
valueType: 'include',
filterType: 'object_type',
}}
description={description}
instanceId={indexingRulesInstanceId}
items={indexingRules}
onAdd={(newRule) => {
initAddIndexingRule(newRule);
clearFlashMessages();
}}
onDelete={(rule) => {
deleteIndexingRule(rule);
clearFlashMessages();
}}
onUpdate={(rule) => {
initSetIndexingRule(rule);
clearFlashMessages();
}}
onReorder={(newIndexingRules) => {
setIndexingRules(newIndexingRules);
clearFlashMessages();
}}
title=""
canRemoveLastItem
/>
);
};

View file

@ -15,6 +15,11 @@ import { fullContentSources } from '../../../../__mocks__/content_sources.mock';
import { nextTick } from '@kbn/test-jest-helpers';
import {
InlineEditableTableLogic,
InlineEditableTableProps,
} from '../../../../../shared/tables/inline_editable_table/inline_editable_table_logic';
import { ItemWithAnID } from '../../../../../shared/tables/types';
import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers';
jest.mock('../../source_logic', () => ({
@ -30,11 +35,12 @@ import {
SynchronizationLogic,
emptyBlockedWindow,
stripScheduleSeconds,
EditableIndexingRule,
} from './synchronization_logic';
describe('SynchronizationLogic', () => {
const { http } = mockHttpValues;
const { flashSuccessToast } = mockFlashMessageHelpers;
const { flashSuccessToast, flashAPIErrors } = mockFlashMessageHelpers;
const { navigateToUrl } = mockKibanaValues;
const { mount } = new LogicMounter(SynchronizationLogic);
const contentSource = fullContentSources[0];
@ -49,12 +55,49 @@ describe('SynchronizationLogic', () => {
},
};
const defaultIndexingRules: EditableIndexingRule[] = [
{
filterType: 'object_type',
id: 0,
value: 'value',
valueType: 'include',
},
{
filterType: 'path_template',
id: 1,
value: 'value',
valueType: 'exclude',
},
{
filterType: 'file_extension',
id: 2,
value: 'value',
valueType: 'include',
},
];
const defaultValues = {
navigatingBetweenTabs: false,
hasUnsavedObjectsAndAssetsChanges: false,
hasUnsavedAssetsAndObjectsChanges: false,
hasUnsavedIndexingRulesChanges: false,
hasUnsavedFrequencyChanges: false,
contentExtractionChecked: true,
thumbnailsChecked: true,
indexingRules: defaultIndexingRules,
indexingRulesForAPI: [
{
filter_type: 'object_type',
include: 'value',
},
{
filter_type: 'path_template',
exclude: 'value',
},
{
filter_type: 'file_extension',
include: 'value',
},
],
schedule: contentSource.indexing.schedule,
cachedSchedule: contentSource.indexing.schedule,
};
@ -109,10 +152,17 @@ describe('SynchronizationLogic', () => {
it('resetSyncSettings', () => {
SynchronizationLogic.actions.setContentExtractionChecked(false);
SynchronizationLogic.actions.setThumbnailsChecked(false);
SynchronizationLogic.actions.addIndexingRule({
filterType: 'file_extension',
valueType: 'exclude',
value: 'value',
});
SynchronizationLogic.actions.resetSyncSettings();
expect(SynchronizationLogic.values.thumbnailsChecked).toEqual(true);
expect(SynchronizationLogic.values.contentExtractionChecked).toEqual(true);
expect(SynchronizationLogic.values.indexingRules).toEqual(defaultIndexingRules);
expect(SynchronizationLogic.values.hasUnsavedIndexingRulesChanges).toEqual(false);
});
describe('setSyncFrequency', () => {
@ -151,37 +201,133 @@ describe('SynchronizationLogic', () => {
expect(SynchronizationLogic.values.schedule.blockedWindows).toBeUndefined();
});
});
});
describe('setBlockedTimeWindow', () => {
it('sets "jobType"', () => {
SynchronizationLogic.actions.addBlockedWindow();
SynchronizationLogic.actions.setBlockedTimeWindow(0, 'jobType', 'incremental');
describe('setBlockedTimeWindow', () => {
it('sets "jobType"', () => {
SynchronizationLogic.actions.addBlockedWindow();
SynchronizationLogic.actions.setBlockedTimeWindow(0, 'jobType', 'incremental');
expect(SynchronizationLogic.values.schedule.blockedWindows![0].jobType).toEqual(
'incremental'
);
expect(SynchronizationLogic.values.schedule.blockedWindows![0].jobType).toEqual(
'incremental'
);
});
it('sets "day"', () => {
SynchronizationLogic.actions.addBlockedWindow();
SynchronizationLogic.actions.setBlockedTimeWindow(0, 'day', 'tuesday');
expect(SynchronizationLogic.values.schedule.blockedWindows![0].day).toEqual('tuesday');
});
it('sets "start"', () => {
SynchronizationLogic.actions.addBlockedWindow();
SynchronizationLogic.actions.setBlockedTimeWindow(0, 'start', '9:00:00Z');
expect(SynchronizationLogic.values.schedule.blockedWindows![0].start).toEqual('9:00:00Z');
});
it('sets "end"', () => {
SynchronizationLogic.actions.addBlockedWindow();
SynchronizationLogic.actions.setBlockedTimeWindow(0, 'end', '11:00:00Z');
expect(SynchronizationLogic.values.schedule.blockedWindows![0].end).toEqual('11:00:00Z');
});
});
it('sets "day"', () => {
SynchronizationLogic.actions.addBlockedWindow();
SynchronizationLogic.actions.setBlockedTimeWindow(0, 'day', 'tuesday');
describe('addIndexingRule', () => {
const indexingRule: EditableIndexingRule = {
filterType: 'file_extension',
valueType: 'exclude',
value: 'value',
id: 10,
};
expect(SynchronizationLogic.values.schedule.blockedWindows![0].day).toEqual('tuesday');
it('adds indexing rule with id 0', () => {
SynchronizationLogic.actions.setIndexingRules([]);
SynchronizationLogic.actions.addIndexingRule(indexingRule);
expect(SynchronizationLogic.values.indexingRules).toEqual([{ ...indexingRule, id: 0 }]);
expect(SynchronizationLogic.values.hasUnsavedIndexingRulesChanges).toEqual(true);
});
it('adds indexing rule with id existing length + 1', () => {
SynchronizationLogic.actions.addIndexingRule(indexingRule);
expect(SynchronizationLogic.values.indexingRules).toEqual([
...defaultValues.indexingRules,
{ ...indexingRule, id: 3 },
]);
expect(SynchronizationLogic.values.hasUnsavedIndexingRulesChanges).toEqual(true);
});
it('adds indexing rule with unique id in case of previous deletions', () => {
SynchronizationLogic.actions.deleteIndexingRule({ ...indexingRule, id: 1 });
SynchronizationLogic.actions.addIndexingRule(indexingRule);
expect(SynchronizationLogic.values.indexingRules).toEqual([
defaultValues.indexingRules[0],
defaultValues.indexingRules[2],
{ ...indexingRule, id: 3 },
]);
expect(SynchronizationLogic.values.hasUnsavedIndexingRulesChanges).toEqual(true);
});
});
it('sets "start"', () => {
SynchronizationLogic.actions.addBlockedWindow();
SynchronizationLogic.actions.setBlockedTimeWindow(0, 'start', '9:00:00Z');
describe('setIndexingRule', () => {
const indexingRule: EditableIndexingRule = {
filterType: 'file_extension',
valueType: 'exclude',
value: 'value',
id: 1,
};
expect(SynchronizationLogic.values.schedule.blockedWindows![0].start).toEqual('9:00:00Z');
it('updates indexing rule', () => {
SynchronizationLogic.actions.setIndexingRule(indexingRule);
expect(SynchronizationLogic.values.indexingRules).toEqual([
defaultValues.indexingRules[0],
indexingRule,
defaultValues.indexingRules[2],
]);
expect(SynchronizationLogic.values.hasUnsavedIndexingRulesChanges).toEqual(true);
});
});
it('sets "end"', () => {
SynchronizationLogic.actions.addBlockedWindow();
SynchronizationLogic.actions.setBlockedTimeWindow(0, 'end', '11:00:00Z');
describe('setIndexingRules', () => {
const indexingRule: EditableIndexingRule = {
filterType: 'file_extension',
valueType: 'exclude',
value: 'value',
id: 1,
};
expect(SynchronizationLogic.values.schedule.blockedWindows![0].end).toEqual('11:00:00Z');
it('updates indexing rules', () => {
SynchronizationLogic.actions.setIndexingRules([indexingRule, indexingRule]);
expect(SynchronizationLogic.values.indexingRules).toEqual([
{ ...indexingRule, id: 0 },
{ ...indexingRule, id: 1 },
]);
expect(SynchronizationLogic.values.hasUnsavedIndexingRulesChanges).toEqual(true);
});
});
describe('deleteIndexingRule', () => {
const indexingRule: EditableIndexingRule = {
filterType: 'file_extension',
valueType: 'exclude',
value: 'value',
id: 1,
};
it('updates indexing rules', () => {
const newIndexingRules = defaultValues.indexingRules.filter(
(val) => val.id !== indexingRule.id
);
SynchronizationLogic.actions.deleteIndexingRule(indexingRule);
expect(SynchronizationLogic.values.indexingRules).toEqual(newIndexingRules);
expect(SynchronizationLogic.values.hasUnsavedIndexingRulesChanges).toEqual(true);
});
});
});
@ -209,6 +355,204 @@ describe('SynchronizationLogic', () => {
});
});
describe('initAddIndexingRule', () => {
const indexingRule: EditableIndexingRule = {
filterType: 'file_extension',
valueType: 'exclude',
value: 'value',
id: 1,
};
it('calls validate endpoint and continues if no errors happen', async () => {
const addIndexingRuleSpy = jest.spyOn(SynchronizationLogic.actions, 'addIndexingRule');
const promise = Promise.resolve({ rules: [] });
const doneSpy = jest.spyOn(
InlineEditableTableLogic({
instanceId: 'IndexingRulesTable',
} as InlineEditableTableProps<ItemWithAnID>).actions,
'doneEditing'
);
http.post.mockReturnValue(promise);
SynchronizationLogic.actions.initAddIndexingRule(indexingRule);
expect(http.post).toHaveBeenCalledWith(
'/internal/workplace_search/org/sources/123/indexing_rules/validate',
{
body: JSON.stringify({
rules: [
{
filter_type: 'file_extension',
exclude: 'value',
},
],
}),
}
);
await promise;
expect(addIndexingRuleSpy).toHaveBeenCalledWith(indexingRule);
expect(doneSpy).toHaveBeenCalled();
});
it('calls validate endpoint and sets errors if there is an error', async () => {
const addIndexingRuleSpy = jest.spyOn(SynchronizationLogic.actions, 'addIndexingRule');
const promise = Promise.resolve({ rules: [{ valid: false, error: 'error' }] });
http.post.mockReturnValue(promise);
SynchronizationLogic.actions.initAddIndexingRule({ ...indexingRule, valueType: 'include' });
const doneSpy = jest.spyOn(
InlineEditableTableLogic({
instanceId: 'IndexingRulesTable',
} as InlineEditableTableProps<ItemWithAnID>).actions,
'doneEditing'
);
expect(http.post).toHaveBeenCalledWith(
'/internal/workplace_search/org/sources/123/indexing_rules/validate',
{
body: JSON.stringify({
rules: [
{
filter_type: 'file_extension',
include: 'value',
},
],
}),
}
);
await promise;
expect(addIndexingRuleSpy).not.toHaveBeenCalled();
expect(doneSpy).toHaveBeenCalled();
});
it('flashes an error if the API call fails', async () => {
const addIndexingRuleSpy = jest.spyOn(SynchronizationLogic.actions, 'addIndexingRule');
const promise = Promise.reject('error');
http.post.mockReturnValue(promise);
const doneSpy = jest.spyOn(
InlineEditableTableLogic({
instanceId: 'IndexingRulesTable',
} as InlineEditableTableProps<ItemWithAnID>).actions,
'doneEditing'
);
SynchronizationLogic.actions.initAddIndexingRule(indexingRule);
expect(http.post).toHaveBeenCalledWith(
'/internal/workplace_search/org/sources/123/indexing_rules/validate',
{
body: JSON.stringify({
rules: [
{
filter_type: 'file_extension',
exclude: 'value',
},
],
}),
}
);
await nextTick();
expect(addIndexingRuleSpy).not.toHaveBeenCalled();
expect(doneSpy).not.toHaveBeenCalled();
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
});
describe('initSetIndexingRule', () => {
const indexingRule: EditableIndexingRule = {
filterType: 'file_extension',
valueType: 'exclude',
value: 'value',
id: 1,
};
it('calls validate endpoint and continues if no errors happen', async () => {
const setIndexingRuleSpy = jest.spyOn(SynchronizationLogic.actions, 'setIndexingRule');
const promise = Promise.resolve({ rules: [] });
http.post.mockReturnValue(promise);
const doneSpy = jest.spyOn(
InlineEditableTableLogic({
instanceId: 'IndexingRulesTable',
} as InlineEditableTableProps<ItemWithAnID>).actions,
'doneEditing'
);
SynchronizationLogic.actions.initSetIndexingRule(indexingRule);
expect(http.post).toHaveBeenCalledWith(
'/internal/workplace_search/org/sources/123/indexing_rules/validate',
{
body: JSON.stringify({
rules: [
{
filter_type: 'file_extension',
exclude: 'value',
},
],
}),
}
);
await promise;
expect(setIndexingRuleSpy).toHaveBeenCalledWith(indexingRule);
expect(doneSpy).toHaveBeenCalled();
});
it('calls validate endpoint and sets errors if there is an error', async () => {
const setIndexingRuleSpy = jest.spyOn(SynchronizationLogic.actions, 'setIndexingRule');
const promise = Promise.resolve({ rules: [{ valid: false, error: 'error' }] });
http.post.mockReturnValue(promise);
const doneSpy = jest.spyOn(
InlineEditableTableLogic({
instanceId: 'IndexingRulesTable',
} as InlineEditableTableProps<ItemWithAnID>).actions,
'doneEditing'
);
SynchronizationLogic.actions.initSetIndexingRule({ ...indexingRule, valueType: 'include' });
expect(http.post).toHaveBeenCalledWith(
'/internal/workplace_search/org/sources/123/indexing_rules/validate',
{
body: JSON.stringify({
rules: [
{
filter_type: 'file_extension',
include: 'value',
},
],
}),
}
);
await promise;
expect(setIndexingRuleSpy).not.toHaveBeenCalled();
expect(doneSpy).toHaveBeenCalled();
});
it('flashes an error if the API call fails', async () => {
const setIndexingRuleSpy = jest.spyOn(SynchronizationLogic.actions, 'setIndexingRule');
const promise = Promise.reject('error');
http.post.mockReturnValue(promise);
const doneSpy = jest.spyOn(
InlineEditableTableLogic({
instanceId: 'IndexingRulesTable',
} as InlineEditableTableProps<ItemWithAnID>).actions,
'doneEditing'
);
SynchronizationLogic.actions.initSetIndexingRule(indexingRule);
expect(http.post).toHaveBeenCalledWith(
'/internal/workplace_search/org/sources/123/indexing_rules/validate',
{
body: JSON.stringify({
rules: [
{
filter_type: 'file_extension',
exclude: 'value',
},
],
}),
}
);
await nextTick();
expect(setIndexingRuleSpy).not.toHaveBeenCalled();
expect(doneSpy).not.toHaveBeenCalled();
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
});
describe('updateSyncEnabled', () => {
it('calls updateServerSettings method', async () => {
const updateServerSettingsSpy = jest.spyOn(
@ -225,13 +569,13 @@ describe('SynchronizationLogic', () => {
});
});
describe('updateObjectsAndAssetsSettings', () => {
describe('updateAssetsAndObjectsSettings', () => {
it('calls updateServerSettings method', async () => {
const updateServerSettingsSpy = jest.spyOn(
SynchronizationLogic.actions,
'updateServerSettings'
);
SynchronizationLogic.actions.updateObjectsAndAssetsSettings();
SynchronizationLogic.actions.updateAssetsAndObjectsSettings();
expect(updateServerSettingsSpy).toHaveBeenCalledWith({
content_source: {
@ -240,6 +584,20 @@ describe('SynchronizationLogic', () => {
content_extraction: { enabled: true },
thumbnails: { enabled: true },
},
rules: [
{
filter_type: 'object_type',
include: 'value',
},
{
filter_type: 'path_template',
exclude: 'value',
},
{
filter_type: 'file_extension',
include: 'value',
},
],
},
},
});

View file

@ -14,6 +14,11 @@ export type TabId = 'source_sync_frequency' | 'blocked_time_windows';
import { flashAPIErrors, flashSuccessToast } from '../../../../../shared/flash_messages';
import { HttpLogic } from '../../../../../shared/http';
import { KibanaLogic } from '../../../../../shared/kibana';
import {
InlineEditableTableLogic,
InlineEditableTableProps,
} from '../../../../../shared/tables/inline_editable_table/inline_editable_table_logic';
import { ItemWithAnID } from '../../../../../shared/tables/types';
import { AppLogic } from '../../../../app_logic';
import {
SYNC_FREQUENCY_PATH,
@ -25,9 +30,11 @@ import {
BlockedWindow,
DayOfWeek,
IndexingSchedule,
IndexingRule,
ContentSourceFullData,
SyncJobType,
TimeUnit,
IndexingRuleInclude,
} from '../../../../types';
import { SYNC_SETTINGS_UPDATED_MESSAGE } from '../../constants';
@ -57,6 +64,7 @@ interface ServerSyncSettingsBody {
permissions?: string;
blocked_windows?: ServerBlockedWindow[];
};
rules?: IndexingRule[];
};
};
}
@ -67,7 +75,7 @@ interface SynchronizationActions {
addBlockedWindow(): void;
removeBlockedWindow(index: number): number;
updateFrequencySettings(): void;
updateObjectsAndAssetsSettings(): void;
updateAssetsAndObjectsSettings(): void;
resetSyncSettings(): void;
updateSyncEnabled(enabled: boolean): boolean;
setThumbnailsChecked(checked: boolean): boolean;
@ -88,16 +96,26 @@ interface SynchronizationActions {
setContentExtractionChecked(checked: boolean): boolean;
setServerSchedule(schedule: IndexingSchedule): IndexingSchedule;
updateServerSettings(body: ServerSyncSettingsBody): ServerSyncSettingsBody;
addIndexingRule(indexingRule: EditableIndexingRuleBase): EditableIndexingRuleBase;
initAddIndexingRule(rule: EditableIndexingRule): { rule: EditableIndexingRule };
setIndexingRules(indexingRules: EditableIndexingRule[]): EditableIndexingRule[];
setIndexingRule(indexingRule: EditableIndexingRule): EditableIndexingRule;
initSetIndexingRule(indexingRule: EditableIndexingRule): { rule: EditableIndexingRule };
deleteIndexingRule(indexingRule: EditableIndexingRule): EditableIndexingRule;
}
interface SynchronizationValues {
navigatingBetweenTabs: boolean;
hasUnsavedIndexingRulesChanges: boolean;
hasUnsavedFrequencyChanges: boolean;
hasUnsavedObjectsAndAssetsChanges: boolean;
hasUnsavedAssetsAndObjectsChanges: boolean;
indexingRules: EditableIndexingRule[];
thumbnailsChecked: boolean;
contentExtractionChecked: boolean;
cachedSchedule: IndexingSchedule;
schedule: IndexingSchedule;
indexingRulesForAPI: IndexingRule[];
errors: string[];
}
export const emptyBlockedWindow: BlockedWindow = {
@ -111,6 +129,26 @@ type BlockedWindowMap = {
[prop in keyof BlockedWindow]: SyncJobType | DayOfWeek | 'all' | string;
};
interface EditableIndexingRuleBase {
filterType: 'object_type' | 'path_template' | 'file_extension';
valueType: 'include' | 'exclude';
value: string;
}
export interface EditableIndexingRule extends EditableIndexingRuleBase {
id: number;
}
interface IndexingRuleForAPI {
filter_type: 'object_type' | 'path_template' | 'file_extension';
include?: string;
exclude?: string;
}
const isIncludeRule = (rule: IndexingRule): rule is IndexingRuleInclude => {
return !!(rule as IndexingRuleInclude).include;
};
export const SynchronizationLogic = kea<
MakeLogicType<SynchronizationValues, SynchronizationActions>
>({
@ -135,11 +173,28 @@ export const SynchronizationLogic = kea<
setServerSchedule: (schedule: IndexingSchedule) => schedule,
removeBlockedWindow: (index: number) => index,
updateFrequencySettings: true,
updateObjectsAndAssetsSettings: true,
updateAssetsAndObjectsSettings: true,
resetSyncSettings: true,
addBlockedWindow: true,
addIndexingRule: (rule: EditableIndexingRuleBase) => rule,
deleteIndexingRule: (rule: EditableIndexingRule) => rule,
initAddIndexingRule: (rule: EditableIndexingRule) => ({ rule }),
initSetIndexingRule: (rule: EditableIndexingRule) => ({ rule }),
setIndexingRules: (indexingRules: EditableIndexingRule[]) => indexingRules,
setIndexingRule: (rule: EditableIndexingRule) => rule,
},
reducers: ({ props }) => ({
hasUnsavedIndexingRulesChanges: [
false,
{
setIndexingRule: () => true,
setIndexingRules: () => true,
addIndexingRule: () => true,
deleteIndexingRule: () => true,
resetSyncSettings: () => false,
updateServerSettings: () => false,
},
],
navigatingBetweenTabs: [
false,
{
@ -228,15 +283,55 @@ export const SynchronizationLogic = kea<
},
},
],
indexingRules: [
(props.contentSource.indexing.rules as IndexingRule[]).map((rule, index) => ({
filterType: rule.filterType,
id: index,
valueType: isIncludeRule(rule) ? 'include' : 'exclude',
value: isIncludeRule(rule) ? rule.include : rule.exclude,
})),
{
addIndexingRule: (indexingRules, rule) => [
...indexingRules,
{
...rule,
// make sure that we get a unique number, in case of multiple deletions and additions
id: indexingRules.reduce(
(prev, curr) => (curr.id >= prev ? curr.id + 1 : prev),
indexingRules.length
),
},
],
deleteIndexingRule: (indexingRules, rule) =>
indexingRules.filter((currentRule) => currentRule.id !== rule.id),
resetSyncSettings: () =>
(props.contentSource.indexing.rules as IndexingRule[]).map((rule, index) => ({
filterType: rule.filterType,
id: index,
valueType: isIncludeRule(rule) ? 'include' : 'exclude',
value: isIncludeRule(rule) ? rule.include : rule.exclude,
})),
setIndexingRules: (_, indexingRules) =>
indexingRules.map((val, index) => ({ ...val, id: index })),
setIndexingRule: (state, rule) =>
state.map((currentRule) => (currentRule.id === rule.id ? rule : currentRule)),
},
],
}),
selectors: ({ selectors }) => ({
hasUnsavedObjectsAndAssetsChanges: [
hasUnsavedAssetsAndObjectsChanges: [
() => [
selectors.thumbnailsChecked,
selectors.contentExtractionChecked,
selectors.hasUnsavedIndexingRulesChanges,
(_, props) => props.contentSource,
],
(thumbnailsChecked, contentExtractionChecked, contentSource) => {
(
thumbnailsChecked,
contentExtractionChecked,
hasUnsavedIndexingRulesChanges,
contentSource
) => {
const {
indexing: {
features: {
@ -248,7 +343,8 @@ export const SynchronizationLogic = kea<
return (
thumbnailsChecked !== thumbnailsEnabled ||
contentExtractionChecked !== contentExtractionEnabled
contentExtractionChecked !== contentExtractionEnabled ||
hasUnsavedIndexingRulesChanges
);
},
],
@ -256,6 +352,11 @@ export const SynchronizationLogic = kea<
() => [selectors.cachedSchedule, selectors.schedule],
(cachedSchedule, schedule) => !isEqual(cachedSchedule, schedule),
],
indexingRulesForAPI: [
() => [selectors.indexingRules],
(indexingRules: EditableIndexingRule[]) =>
indexingRules.map((indexingRule) => indexingRuleToApiFormat(indexingRule)),
],
}),
listeners: ({ actions, values, props }) => ({
handleSelectedTabChanged: async (tabId, breakpoint) => {
@ -277,6 +378,62 @@ export const SynchronizationLogic = kea<
KibanaLogic.values.navigateToUrl(path);
actions.setNavigatingBetweenTabs(false);
},
initAddIndexingRule: async ({ rule }) => {
const { id: sourceId } = props.contentSource;
const route = `/internal/workplace_search/org/sources/${sourceId}/indexing_rules/validate`;
try {
const response = await HttpLogic.values.http.post<{
rules: Array<{
valid: boolean;
error?: string;
}>;
}>(route, {
body: JSON.stringify({
rules: [indexingRuleToApiFormat(rule)],
}),
});
const error = response.rules[0]?.error;
const tableLogic = InlineEditableTableLogic({
instanceId: 'IndexingRulesTable',
} as InlineEditableTableProps<ItemWithAnID>);
if (error) {
tableLogic.actions.setRowErrors([error]);
} else {
actions.addIndexingRule(rule);
}
tableLogic.actions.doneEditing();
} catch (e) {
flashAPIErrors(e);
}
},
initSetIndexingRule: async ({ rule }) => {
const { id: sourceId } = props.contentSource;
const route = `/internal/workplace_search/org/sources/${sourceId}/indexing_rules/validate`;
try {
const response = await HttpLogic.values.http.post<{
rules: Array<{
valid: boolean;
error?: string;
}>;
}>(route, {
body: JSON.stringify({
rules: [indexingRuleToApiFormat(rule)],
}),
});
const error = response.rules[0]?.error;
const tableLogic = InlineEditableTableLogic({
instanceId: 'IndexingRulesTable',
} as InlineEditableTableProps<ItemWithAnID>);
if (error) {
tableLogic.actions.setRowErrors([error]);
} else {
actions.setIndexingRule(rule);
}
tableLogic.actions.doneEditing();
} catch (e) {
flashAPIErrors(e);
}
},
updateSyncEnabled: async (enabled) => {
actions.updateServerSettings({
content_source: {
@ -284,7 +441,7 @@ export const SynchronizationLogic = kea<
},
});
},
updateObjectsAndAssetsSettings: () => {
updateAssetsAndObjectsSettings: () => {
actions.updateServerSettings({
content_source: {
indexing: {
@ -292,6 +449,7 @@ export const SynchronizationLogic = kea<
content_extraction: { enabled: values.contentExtractionChecked },
thumbnails: { enabled: values.thumbnailsChecked },
},
rules: values.indexingRulesForAPI,
},
},
});
@ -360,3 +518,10 @@ const formatBlockedWindowsForServer = (
end,
}));
};
const indexingRuleToApiFormat = (indexingRule: EditableIndexingRule): IndexingRuleForAPI => {
const { valueType, filterType, value } = indexingRule;
return valueType === 'include'
? { filter_type: filterType, include: value }
: { filter_type: filterType, exclude: value };
};

View file

@ -10,12 +10,12 @@ import '../../../../../__mocks__/shallow_useeffect.mock';
import { setMockValues } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { Redirect, Route, Switch } from 'react-router-dom';
import { shallow } from 'enzyme';
import { AssetsAndObjects } from './assets_and_objects';
import { Frequency } from './frequency';
import { ObjectsAndAssets } from './objects_and_assets';
import { Synchronization } from './synchronization';
import { SynchronizationRouter } from './synchronization_router';
@ -25,9 +25,10 @@ describe('SynchronizationRouter', () => {
const wrapper = shallow(<SynchronizationRouter />);
expect(wrapper.find(Synchronization)).toHaveLength(1);
expect(wrapper.find(ObjectsAndAssets)).toHaveLength(1);
expect(wrapper.find(AssetsAndObjects)).toHaveLength(1);
expect(wrapper.find(Frequency)).toHaveLength(2);
expect(wrapper.find(Switch)).toHaveLength(1);
expect(wrapper.find(Route)).toHaveLength(4);
expect(wrapper.find(Redirect)).toHaveLength(1);
});
});

View file

@ -6,18 +6,19 @@
*/
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { Redirect, Route, Switch } from 'react-router-dom';
import {
SYNC_FREQUENCY_PATH,
BLOCKED_TIME_WINDOWS_PATH,
OBJECTS_AND_ASSETS_PATH,
ASSETS_AND_OBJECTS_PATH,
SOURCE_SYNCHRONIZATION_PATH,
getSourcesPath,
OLD_OBJECTS_AND_ASSETS_PATH,
} from '../../../../routes';
import { AssetsAndObjects } from './assets_and_objects';
import { Frequency } from './frequency';
import { ObjectsAndAssets } from './objects_and_assets';
import { Synchronization } from './synchronization';
export const SynchronizationRouter: React.FC = () => (
@ -31,8 +32,12 @@ export const SynchronizationRouter: React.FC = () => (
<Route exact path={getSourcesPath(BLOCKED_TIME_WINDOWS_PATH, true)}>
<Frequency tabId={1} />
</Route>
<Route exact path={getSourcesPath(OBJECTS_AND_ASSETS_PATH, true)}>
<ObjectsAndAssets />
<Route exact path={getSourcesPath(ASSETS_AND_OBJECTS_PATH, true)}>
<AssetsAndObjects />
</Route>
<Redirect
from={getSourcesPath(OLD_OBJECTS_AND_ASSETS_PATH, true)}
to={getSourcesPath(ASSETS_AND_OBJECTS_PATH, true)}
/>
</Switch>
);

View file

@ -24,9 +24,9 @@ describe('useSynchronizationSubNav', () => {
href: '/sources/1/synchronization/frequency',
},
{
id: 'sourceSynchronizationObjectsAndAssets',
name: 'Objects and assets',
href: '/sources/1/synchronization/objects_and_assets',
id: 'sourceSynchronizationAssetsAndObjects',
name: 'Assets and objects',
href: '/sources/1/synchronization/assets_and_objects',
},
]);
});

View file

@ -14,7 +14,7 @@ import { NAV } from '../../../../constants';
import {
getContentSourcePath,
SYNC_FREQUENCY_PATH,
OBJECTS_AND_ASSETS_PATH,
ASSETS_AND_OBJECTS_PATH,
} from '../../../../routes';
import { SourceLogic } from '../../source_logic';
@ -35,9 +35,9 @@ export const useSynchronizationSubNav = () => {
}),
},
{
id: 'sourceSynchronizationObjectsAndAssets',
name: NAV.SYNCHRONIZATION_OBJECTS_AND_ASSETS,
...generateNavLink({ to: getContentSourcePath(OBJECTS_AND_ASSETS_PATH, id, true) }),
id: 'sourceSynchronizationAssetsAndObjects',
name: NAV.SYNCHRONIZATION_ASSETS_AND_OBJECTS,
...generateNavLink({ to: getContentSourcePath(ASSETS_AND_OBJECTS_PATH, id, true) }),
},
];

View file

@ -543,21 +543,31 @@ export const SOURCE_FREQUENCY_DESCRIPTION = i18n.translate(
}
);
export const SOURCE_OBJECTS_AND_ASSETS_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceObjectsAndAssetsDescription',
export const SOURCE_ASSETS_AND_OBJECTS_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsDescription',
{
defaultMessage:
'Customize the indexing rules that determine which objects and assets are synchronized from this content source to Workplace Search.',
'Flexibly manage the documents to be synchronized and made available for search using granular controls below.',
}
);
export const SOURCE_OBJECTS_AND_ASSETS_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceObjectsAndAssetsLabel',
export const SOURCE_ASSETS_AND_OBJECTS_LEARN_MORE_LINK = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsLearnMoreLink',
{
defaultMessage: 'Object and details to include in search results',
defaultMessage: 'Learn more about sync objects types.',
}
);
export const SOURCE_ASSETS_AND_OBJECTS_ASSETS_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsAssetsLabel',
{ defaultMessage: 'Assets' }
);
export const SOURCE_ASSETS_AND_OBJECTS_OBJECTS_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAssetsAndObjectsObjectsLabel',
{ defaultMessage: 'Objects' }
);
export const SOURCE_SYNCHRONIZATION_TOGGLE_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceSynchronizationToggleLabel',
{

View file

@ -44,6 +44,8 @@ import {
registerOrgSourceOauthConfigurationRoute,
registerOrgSourceSynchronizeRoute,
registerOauthConnectorParamsRoute,
registerAccountSourceValidateIndexingRulesRoute,
registerOrgSourceValidateIndexingRulesRoute,
} from './sources';
const mockConfig = {
@ -310,6 +312,45 @@ describe('sources routes', () => {
});
});
describe('POST /internal/workplace_search/account/sources/{id}/indexing_rules/validate', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'post',
path: '/internal/workplace_search/account/sources/{id}/indexing_rules/validate',
});
registerAccountSourceValidateIndexingRulesRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/sources/:id/indexing_rules/validate',
});
});
describe('validates', () => {
it('correctly', () => {
const request = {
body: {
rules: [
{
filter_type: 'path_template',
exclude: '',
},
],
},
};
mockRouter.shouldValidate(request);
});
});
});
describe('GET /internal/workplace_search/account/pre_sources/{id}', () => {
let mockRouter: MockRouter;
@ -818,6 +859,45 @@ describe('sources routes', () => {
});
});
describe('POST /internal/workplace_search/org/sources/{id}/indexing_rules/validate', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'post',
path: '/internal/workplace_search/org/sources/{id}/indexing_rules/validate',
});
registerOrgSourceValidateIndexingRulesRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/org/sources/:id/indexing_rules/validate',
});
});
describe('validates', () => {
it('correctly', () => {
const request = {
body: {
rules: [
{
filter_type: 'path_template',
exclude: '',
},
],
},
};
mockRouter.shouldValidate(request);
});
});
});
describe('GET /internal/workplace_search/org/pre_sources/{id}', () => {
let mockRouter: MockRouter;

View file

@ -96,11 +96,32 @@ const sourceSettingsSchema = schema.object({
),
})
),
rules: schema.maybe(
schema.arrayOf(
schema.object({
filter_type: schema.string(),
exclude: schema.maybe(schema.string()),
include: schema.maybe(schema.string()),
})
)
),
})
),
}),
});
const validateRulesSchema = schema.object({
rules: schema.maybe(
schema.arrayOf(
schema.object({
filter_type: schema.string(),
exclude: schema.maybe(schema.string()),
include: schema.maybe(schema.string()),
})
)
),
});
// Account routes
export function registerAccountSourcesRoute({
router,
@ -273,6 +294,26 @@ export function registerAccountSourceSettingsRoute({
);
}
export function registerAccountSourceValidateIndexingRulesRoute({
router,
enterpriseSearchRequestHandler,
}: RouteDependencies) {
router.post(
{
path: '/internal/workplace_search/account/sources/{id}/indexing_rules/validate',
validate: {
body: validateRulesSchema,
params: schema.object({
id: schema.string(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/sources/:id/indexing_rules/validate',
})
);
}
export function registerAccountPreSourceRoute({
router,
enterpriseSearchRequestHandler,
@ -620,6 +661,26 @@ export function registerOrgSourceSettingsRoute({
);
}
export function registerOrgSourceValidateIndexingRulesRoute({
router,
enterpriseSearchRequestHandler,
}: RouteDependencies) {
router.post(
{
path: '/internal/workplace_search/org/sources/{id}/indexing_rules/validate',
validate: {
body: validateRulesSchema,
params: schema.object({
id: schema.string(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/org/sources/:id/indexing_rules/validate',
})
);
}
export function registerOrgPreSourceRoute({
router,
enterpriseSearchRequestHandler,
@ -955,6 +1016,7 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => {
registerAccountSourceFederatedSummaryRoute(dependencies);
registerAccountSourceReauthPrepareRoute(dependencies);
registerAccountSourceSettingsRoute(dependencies);
registerAccountSourceValidateIndexingRulesRoute(dependencies);
registerAccountPreSourceRoute(dependencies);
registerAccountPrepareSourcesRoute(dependencies);
registerAccountSourceSearchableRoute(dependencies);
@ -970,6 +1032,7 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => {
registerOrgSourceFederatedSummaryRoute(dependencies);
registerOrgSourceReauthPrepareRoute(dependencies);
registerOrgSourceSettingsRoute(dependencies);
registerOrgSourceValidateIndexingRulesRoute(dependencies);
registerOrgPreSourceRoute(dependencies);
registerOrgPrepareSourcesRoute(dependencies);
registerOrgSourceSearchableRoute(dependencies);

View file

@ -10666,7 +10666,6 @@
"xpack.enterpriseSearch.workplaceSearch.nav.sources": "ソース",
"xpack.enterpriseSearch.workplaceSearch.nav.synchronization": "同期",
"xpack.enterpriseSearch.workplaceSearch.nav.synchronizationFrequency": "頻度",
"xpack.enterpriseSearch.workplaceSearch.nav.synchronizationObjectsAndAssets": "オブジェクトとアセット",
"xpack.enterpriseSearch.workplaceSearch.nonPlatinumOauthDescription": "Workplace Search検索APIを安全に使用するために、OAuthアプリケーションを構成します。プラチナライセンスにアップグレードして、検索APIを有効にし、OAuthアプリケーションを作成します。",
"xpack.enterpriseSearch.workplaceSearch.nonPlatinumOauthTitle": "カスタム検索アプリケーションのOAuthを構成",
"xpack.enterpriseSearch.workplaceSearch.oauth.description": "組織のOAuthクライアントを作成します。",
@ -10920,8 +10919,6 @@
"xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.sharePoint": "SharePoint Online",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.slack": "Slack",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.zendesk": "Zendesk",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceObjectsAndAssetsDescription": "このコンテンツソースからWorkplace Searchに同期されるオブジェクトとアセットを決定するインデックスルールをカスタマイズします。",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceObjectsAndAssetsLabel": "検索結果に含めるオブジェクトと詳細",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceOverviewTitle": "ソース概要",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceSyncConfirmMessage": "この要求を続行し、他のすべての同期を停止しますか?",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceSyncConfirmTitle": "新しいコンテンツ同期を開始しますか?",

View file

@ -10524,7 +10524,6 @@
"xpack.enterpriseSearch.workplaceSearch.nav.sources": "源",
"xpack.enterpriseSearch.workplaceSearch.nav.synchronization": "同步",
"xpack.enterpriseSearch.workplaceSearch.nav.synchronizationFrequency": "频率",
"xpack.enterpriseSearch.workplaceSearch.nav.synchronizationObjectsAndAssets": "对象和资产",
"xpack.enterpriseSearch.workplaceSearch.nonPlatinumOauthDescription": "配置 OAuth 应用程序,以安全使用 Workplace Search 搜索 API。升级到白金级许可证以启用搜索 API 并创建您的 OAuth 应用程序。",
"xpack.enterpriseSearch.workplaceSearch.nonPlatinumOauthTitle": "正在为定制搜索应用程序配置 OAuth",
"xpack.enterpriseSearch.workplaceSearch.oauth.description": "为您的组织创建 OAuth 客户端。",
@ -10779,8 +10778,6 @@
"xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.sharePoint": "Sharepoint",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.slack": "Slack",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.zendesk": "Zendesk",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceObjectsAndAssetsDescription": "定制确定将哪些对象和资产从此内容源同步到 Workplace Search 的索引规则。",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceObjectsAndAssetsLabel": "要包括在搜索结果中的对象和详情",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceOverviewTitle": "源概览",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceSyncConfirmMessage": "是否确定要继续处理此请求并停止所有其他同步?",
"xpack.enterpriseSearch.workplaceSearch.sources.sourceSyncConfirmTitle": "开始新内容同步?",