[Mappings editor] Add code improvements for source field (#201188)

Closes https://github.com/elastic/kibana/issues/200769

## Summary

This PR is a follow-up to https://github.com/elastic/kibana/pull/199854
and it adds the following code improvements:

- Replaces Mappings-editor-context-level property `hasEnterpriceLicense`
with plugin-context-level `canUseSyntheticSource` property
- Adds jest tests to check if the synthetic option is correctly
displayed based on license
- Improves readability of serializer logic for the source field


**How to test:**
The same test instructions from
https://github.com/elastic/kibana/pull/199854 can be followed with a
focus on checking that the synthetic option is only available in
Enterprise license.
This commit is contained in:
Elena Stoeva 2024-11-25 12:46:05 +00:00 committed by GitHub
parent e4d32fd527
commit 762bb7f59d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 93 additions and 92 deletions

View file

@ -79,6 +79,7 @@ export interface AppDependencies {
docLinks: DocLinksStart;
kibanaVersion: SemVer;
overlays: OverlayStart;
canUseSyntheticSource: boolean;
}
export const AppContextProvider = ({

View file

@ -28,6 +28,14 @@ const setup = (props: any = { onUpdate() {} }, appDependencies?: any) => {
return testBed;
};
const getContext = (sourceFieldEnabled: boolean = true, canUseSyntheticSource: boolean = true) =>
({
config: {
enableMappingsSourceFieldSection: sourceFieldEnabled,
},
canUseSyntheticSource,
} as unknown as AppDependencies);
describe('Mappings editor: configuration form', () => {
let testBed: TestBed<TestSubjects>;
@ -49,14 +57,8 @@ describe('Mappings editor: configuration form', () => {
describe('_source field', () => {
it('renders the _source field when it is enabled', async () => {
const ctx = {
config: {
enableMappingsSourceFieldSection: true,
},
} as unknown as AppDependencies;
await act(async () => {
testBed = setup({ esNodesPlugins: [] }, ctx);
testBed = setup({ esNodesPlugins: [] }, getContext());
});
testBed.component.update();
const { exists } = testBed;
@ -65,19 +67,37 @@ describe('Mappings editor: configuration form', () => {
});
it("doesn't render the _source field when it is disabled", async () => {
const ctx = {
config: {
enableMappingsSourceFieldSection: false,
},
} as unknown as AppDependencies;
await act(async () => {
testBed = setup({ esNodesPlugins: [] }, ctx);
testBed = setup({ esNodesPlugins: [] }, getContext(false));
});
testBed.component.update();
const { exists } = testBed;
expect(exists('sourceField')).toBe(false);
});
it('has synthetic option if `canUseSyntheticSource` is set to true', async () => {
await act(async () => {
testBed = setup({ esNodesPlugins: [] }, getContext(true, true));
});
testBed.component.update();
const { exists, find } = testBed;
// Clicking on the field to open the options dropdown
find('sourceValueField').simulate('click');
expect(exists('syntheticSourceFieldOption')).toBe(true);
});
it("doesn't have synthetic option if `canUseSyntheticSource` is set to false", async () => {
await act(async () => {
testBed = setup({ esNodesPlugins: [] }, getContext(true, false));
});
testBed.component.update();
const { exists, find } = testBed;
// Clicking on the field to open the options dropdown
find('sourceValueField').simulate('click');
expect(exists('syntheticSourceFieldOption')).toBe(false);
});
});
});

View file

@ -28,22 +28,9 @@ describe('Mappings editor: core', () => {
let onChangeHandler: jest.Mock = jest.fn();
let getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler);
let testBed: MappingsEditorTestBed;
let hasEnterpriseLicense = true;
const mockLicenseCheck = jest.fn((type: any) => hasEnterpriseLicense);
const appDependencies = {
plugins: {
ml: { mlApi: {} },
licensing: {
license$: {
subscribe: jest.fn((callback: any) => {
callback({
isActive: true,
hasAtLeast: mockLicenseCheck,
});
return { unsubscribe: jest.fn() };
}),
},
},
},
};
@ -314,6 +301,7 @@ describe('Mappings editor: core', () => {
config: {
enableMappingsSourceFieldSection: true,
},
canUseSyntheticSource: true,
...appDependencies,
};
@ -512,8 +500,7 @@ describe('Mappings editor: core', () => {
});
['logsdb', 'time_series'].forEach((indexMode) => {
it(`defaults to 'synthetic' with ${indexMode} index mode prop on enterprise license`, async () => {
hasEnterpriseLicense = true;
it(`defaults to 'synthetic' with ${indexMode} index mode prop when 'canUseSyntheticSource' is set to true`, async () => {
await act(async () => {
testBed = setup(
{
@ -537,8 +524,7 @@ describe('Mappings editor: core', () => {
expect(find('sourceValueField').prop('value')).toBe('synthetic');
});
it(`defaults to 'standard' with ${indexMode} index mode prop on basic license`, async () => {
hasEnterpriseLicense = false;
it(`defaults to 'standard' with ${indexMode} index mode prop when 'canUseSyntheticSource' is set to true`, async () => {
await act(async () => {
testBed = setup(
{
@ -546,7 +532,7 @@ describe('Mappings editor: core', () => {
onChange: onChangeHandler,
indexMode,
},
ctx
{ ...ctx, canUseSyntheticSource: false }
);
});
testBed.component.update();

View file

@ -39,6 +39,31 @@ interface SerializedSourceField {
excludes?: string[];
}
const serializeSourceField = (sourceField: any): SerializedSourceField | undefined => {
if (sourceField?.option === SYNTHETIC_SOURCE_OPTION) {
return { mode: SYNTHETIC_SOURCE_OPTION };
}
if (sourceField?.option === DISABLED_SOURCE_OPTION) {
return { enabled: false };
}
if (sourceField?.option === STORED_SOURCE_OPTION) {
return {
mode: 'stored',
includes: sourceField.includes,
excludes: sourceField.excludes,
};
}
if (sourceField?.includes || sourceField?.excludes) {
// If sourceField?.option is undefined, the user hasn't explicitly selected
// this option, so don't include the `mode` property
return {
includes: sourceField.includes,
excludes: sourceField.excludes,
};
}
return undefined;
};
export const formSerializer = (formData: GenericObject) => {
const { dynamicMapping, sourceField, metaField, _routing, _size, subobjects } = formData;
@ -48,30 +73,12 @@ export const formSerializer = (formData: GenericObject) => {
? 'strict'
: dynamicMapping?.enabled;
const _source =
sourceField?.option === SYNTHETIC_SOURCE_OPTION
? { mode: SYNTHETIC_SOURCE_OPTION }
: sourceField?.option === DISABLED_SOURCE_OPTION
? { enabled: false }
: sourceField?.option === STORED_SOURCE_OPTION
? {
mode: 'stored',
includes: sourceField?.includes,
excludes: sourceField?.excludes,
}
: sourceField?.includes || sourceField?.excludes
? {
includes: sourceField?.includes,
excludes: sourceField?.excludes,
}
: undefined;
const serialized = {
dynamic,
numeric_detection: dynamicMapping?.numeric_detection,
date_detection: dynamicMapping?.date_detection,
dynamic_date_formats: dynamicMapping?.dynamic_date_formats,
_source: _source as SerializedSourceField,
_source: serializeSourceField(sourceField),
_meta: metaField,
_routing,
_size,

View file

@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink, EuiSpacer, EuiComboBox, EuiFormRow, EuiCallOut, EuiText } from '@elastic/eui';
import { useMappingsState } from '../../../mappings_state_context';
import { useAppContext } from '../../../../../app_context';
import { documentationService } from '../../../../../services/documentation';
import { UseField, FormDataProvider, FormRow, SuperSelectField } from '../../../shared_imports';
import { ComboBoxOption } from '../../../types';
@ -24,7 +24,7 @@ import {
} from './constants';
export const SourceFieldSection = () => {
const state = useMappingsState();
const { canUseSyntheticSource } = useAppContext();
const renderOptionDropdownDisplay = (option: SourceOptionKey) => (
<Fragment>
@ -44,7 +44,7 @@ export const SourceFieldSection = () => {
},
];
if (state.hasEnterpriseLicense) {
if (canUseSyntheticSource) {
sourceValueOptions.push({
value: SYNTHETIC_SOURCE_OPTION,
inputDisplay: sourceOptionLabels[SYNTHETIC_SOURCE_OPTION],

View file

@ -421,7 +421,6 @@ describe('utils', () => {
selectedDataTypes: ['Boolean'],
},
inferenceToModelIdMap: {},
hasEnterpriseLicense: true,
mappingViewFields: { byId: {}, rootLevelFields: [], aliases: {}, maxNestedDepth: 0 },
};
test('returns list of matching fields with search term', () => {

View file

@ -9,7 +9,6 @@ import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui';
import { ILicense } from '@kbn/licensing-plugin/common/types';
import { useAppContext } from '../../app_context';
import { IndexMode } from '../../../../common/types/data_streams';
import {
@ -61,9 +60,7 @@ export interface Props {
export const MappingsEditor = React.memo(
({ onChange, value, docLinks, indexSettings, esNodesPlugins, indexMode }: Props) => {
const {
plugins: { licensing },
} = useAppContext();
const { canUseSyntheticSource } = useAppContext();
const { parsedDefaultValue, multipleMappingsDeclared } = useMemo<MappingsEditorParsedMetadata>(
() => parseMappings(value),
[value]
@ -128,39 +125,22 @@ export const MappingsEditor = React.memo(
[dispatch]
);
const [isLicenseCheckComplete, setIsLicenseCheckComplete] = useState(false);
useEffect(() => {
const subscription = licensing?.license$.subscribe((license: ILicense) => {
dispatch({
type: 'hasEnterpriseLicense.update',
value: license.isActive && license.hasAtLeast('enterprise'),
});
setIsLicenseCheckComplete(true);
});
return () => subscription?.unsubscribe();
}, [dispatch, licensing]);
useEffect(() => {
if (
isLicenseCheckComplete &&
!state.configuration.defaultValue._source &&
(indexMode === LOGSDB_INDEX_MODE || indexMode === TIME_SERIES_MODE)
) {
if (state.hasEnterpriseLicense) {
if (canUseSyntheticSource) {
// If the source field is undefined (hasn't been set in the form)
// and if the user has selected a `logsdb` or `time_series` index mode in the Logistics step,
// update the form data with synthetic _source
dispatch({
type: 'configuration.save',
value: { ...state.configuration.defaultValue, _source: { mode: 'synthetic' } } as any,
});
}
}
}, [
indexMode,
dispatch,
state.configuration,
state.hasEnterpriseLicense,
isLicenseCheckComplete,
]);
}, [indexMode, dispatch, state.configuration, canUseSyntheticSource]);
const tabToContentMap = {
fields: (

View file

@ -60,7 +60,6 @@ export const StateProvider: React.FC<{ children?: React.ReactNode }> = ({ childr
selectedDataTypes: [],
},
inferenceToModelIdMap: {},
hasEnterpriseLicense: false,
mappingViewFields: { byId: {}, rootLevelFields: [], aliases: {}, maxNestedDepth: 0 },
};

View file

@ -629,11 +629,5 @@ export const reducer = (state: State, action: Action): State => {
inferenceToModelIdMap: action.value.inferenceToModelIdMap,
};
}
case 'hasEnterpriseLicense.update': {
return {
...state,
hasEnterpriseLicense: action.value,
};
}
}
};

View file

@ -108,7 +108,6 @@ export interface State {
};
templates: TemplatesFormState;
inferenceToModelIdMap?: InferenceToModelIdMap;
hasEnterpriseLicense: boolean;
mappingViewFields: NormalizedFields; // state of the incoming index mappings, separate from the editor state above
}
@ -141,7 +140,6 @@ export type Action =
| { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } }
| { type: 'search:update'; value: string }
| { type: 'validity:update'; value: boolean }
| { type: 'filter:update'; value: { selectedOptions: EuiSelectableOption[] } }
| { type: 'hasEnterpriseLicense.update'; value: boolean };
| { type: 'filter:update'; value: { selectedOptions: EuiSelectableOption[] } };
export type Dispatch = (action: Action) => void;

View file

@ -57,6 +57,7 @@ export function getIndexManagementDependencies({
cloud,
startDependencies,
uiMetricService,
canUseSyntheticSource,
}: {
core: CoreStart;
usageCollection: UsageCollectionSetup;
@ -68,6 +69,7 @@ export function getIndexManagementDependencies({
cloud?: CloudSetup;
startDependencies: StartDependencies;
uiMetricService: UiMetricService;
canUseSyntheticSource: boolean;
}): AppDependencies {
const { docLinks, application, uiSettings, settings } = core;
const { url } = startDependencies.share;
@ -100,6 +102,7 @@ export function getIndexManagementDependencies({
docLinks,
kibanaVersion,
overlays: core.overlays,
canUseSyntheticSource,
};
}
@ -112,6 +115,7 @@ export async function mountManagementSection({
kibanaVersion,
config,
cloud,
canUseSyntheticSource,
}: {
coreSetup: CoreSetup<StartDependencies>;
usageCollection: UsageCollectionSetup;
@ -121,6 +125,7 @@ export async function mountManagementSection({
kibanaVersion: SemVer;
config: AppDependencies['config'];
cloud?: CloudSetup;
canUseSyntheticSource: boolean;
}) {
const { element, setBreadcrumbs, history } = params;
const [core, startDependencies] = await coreSetup.getStartServices();
@ -148,6 +153,7 @@ export async function mountManagementSection({
startDependencies,
uiMetricService,
usageCollection,
canUseSyntheticSource,
});
const unmountAppCallback = renderApp(element, { core, dependencies: appDependencies });

View file

@ -20,6 +20,7 @@ import {
IndexManagementPluginStart,
} from '@kbn/index-management-shared-types';
import { IndexManagementLocator } from '@kbn/index-management-shared-types';
import { Subscription } from 'rxjs';
import { setExtensionsService } from './application/store/selectors/extension_service';
import { ExtensionsService } from './services/extensions_service';
@ -57,6 +58,8 @@ export class IndexMgmtUIPlugin
enableProjectLevelRetentionChecks: boolean;
enableSemanticText: boolean;
};
private canUseSyntheticSource: boolean = false;
private licensingSubscription?: Subscription;
constructor(ctx: PluginInitializerContext) {
// Temporary hack to provide the service instances in module files in order to avoid a big refactor
@ -113,6 +116,7 @@ export class IndexMgmtUIPlugin
kibanaVersion: this.kibanaVersion,
config: this.config,
cloud,
canUseSyntheticSource: this.canUseSyntheticSource,
});
},
});
@ -133,6 +137,11 @@ export class IndexMgmtUIPlugin
public start(coreStart: CoreStart, plugins: StartDependencies): IndexManagementPluginStart {
const { fleet, usageCollection, cloud, share, console, ml, licensing } = plugins;
this.licensingSubscription = licensing?.license$.subscribe((next) => {
this.canUseSyntheticSource = next.hasAtLeast('enterprise');
});
return {
extensionsService: this.extensionsService.setup(),
getIndexMappingComponent: (deps: { history: ScopedHistory<unknown> }) => {
@ -213,5 +222,7 @@ export class IndexMgmtUIPlugin
},
};
}
public stop() {}
public stop() {
this.licensingSubscription?.unsubscribe();
}
}