[Discover] Add support for custom profile context object (#198637)

## Summary

This PR adds support for customizing the context object returned by a
profile provider's `resolve` method, and accessing it within extension
point implementations. The primary use case for this functionality is
passing custom dependencies, or async initializing services, e.g.
initializing a custom state store and passing it to a context provider
in `getRenderAppWrapper`.

Flaky test runs:
- x25:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/7343

### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#_add_your_labels)
- [ ] This will appear in the **Release Notes** and follow the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Davis McPhee 2024-11-06 13:44:27 -04:00 committed by GitHub
parent 4c649d9f14
commit bdbfd03213
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 587 additions and 176 deletions

View file

@ -44,6 +44,8 @@ The merging order for profiles is based on the context level hierarchy (`root` >
The following diagram illustrates the extension point merging process:
![image](./docs/merged_accessors.png)
Additionally, extension point implementations are passed an `accessorParams` argument as the second argument after `prev`. This object contains additional parameters that may be useful to extension point implementations, primarily the current `context` object. This is most useful in situations where consumers want to [customize the `context` object](#custom-context-objects) with properties specific to their profile, such as state stores and asynchronously initialized services.
Definitions for composable profiles and the merging routine are located in the [`composable_profile.ts`](./composable_profile.ts) file.
### Supporting services
@ -266,7 +268,8 @@ export const createSecurityRootProfileProvider = (): RootProfileProvider => ({
getCellRenderers: (prev) => (params) => ({
...prev(params),
foo: function FooComponent() {
// Since the app wrapper implementation wrapped Discover with a React context provider, we can now access its values from within our extension point implementations
// Since the app wrapper implementation wrapped Discover with a React context provider,
// we can now access its values from within our extension point implementations
const { setFlyoutOpen } = useContext(flyoutContext);
return <button onClick={() => setFlyoutOpen(true)}>Click me to open a flyout!</button>;
@ -286,6 +289,51 @@ export const createSecurityRootProfileProvider = (): RootProfileProvider => ({
});
```
## Custom `context` objects
By default the `context` object returned from each profile provider's `resolve` method conforms to a standard interface specific to their profile's context level. However, in some situations it may be useful for consumers to extend this object with properties specific to their profile implementation. To support this, profile providers can define a strongly typed `context` interface that extends the default interface, and allows passing properties through to their profile's extension point implementations. One potential use case for this is instantiating state stores or asynchronously initialized services, then accessing them within a `getRenderAppWrapper` implementation to pass to a React context provider:
```tsx
// The profile provider interfaces accept a custom context object type param
type SecurityRootProfileProvider = RootProfileProvider<{ stateStore: SecurityStateStore }>;
export const createSecurityRootProfileProvider = (
services: ProfileProviderServices
): SecurityRootProfileProvider => ({
profileId: 'security-root-profile',
profile: {
getRenderAppWrapper:
(PrevWrapper, { context }) =>
({ children }) =>
(
<PrevWrapper>
// Custom props can be accessed from the context object available in `accessorParams`
<SecurityStateProvider stateStore={context.stateStore}>
{children}
</SecurityStateProvider>
</PrevWrapper>
),
},
resolve: async (params) => {
if (params.solutionNavId !== SolutionType.Security) {
return { isMatch: false };
}
// Perform async service initialization within the `resolve` method
const stateStore = await initializeSecurityStateStore(services);
return {
isMatch: true,
context: {
solutionType: SolutionType.Security,
// Include the custom service in the returned context object
stateStore,
},
};
},
});
```
## Overriding defaults
Discover ships with a set of common contextual profiles, shared across Solutions in Kibana (e.g. the current logs data source profile). The goal of these profiles is to provide Solution agnostic contextual features to help improve the default data exploration experience for various data types. They should be generally useful across user types and not be tailored to specific Solution workflows for example, viewing logs should be a delightful experience regardless of whether its done within the Observability Solution, the Search Solution, or the classic on-prem experience.
@ -335,9 +383,12 @@ export const createSecurityLogsDataSourceProfileProivder = (
// Completely remove a specific extension point implementation
getDocViewer: undefined,
// Modify the result of an existing extension point implementation
getCellRenderers: (prev) => (params) => {
getCellRenderers: (prev, accessorParams) => (params) => {
// Retrieve and execute the base implementation
const baseImpl = logsDataSourceProfileProvider.profile.getCellRenderers?.(prev);
const baseImpl = logsDataSourceProfileProvider.profile.getCellRenderers?.(
prev,
accessorParams
);
const baseRenderers = baseImpl?.(params);
// Return the modified result

View file

@ -8,7 +8,7 @@
*/
import { DataGridDensity } from '@kbn/unified-data-table';
import { ComposableProfile, getMergedAccessor } from './composable_profile';
import { AppliedProfile, getMergedAccessor } from './composable_profile';
import { Profile } from './types';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
@ -30,13 +30,13 @@ describe('getMergedAccessor', () => {
it('should merge the accessors in the correct order', () => {
const baseImpl: Profile['getCellRenderers'] = jest.fn(() => ({ base: jest.fn() }));
const profile1: ComposableProfile = {
const profile1: AppliedProfile = {
getCellRenderers: jest.fn((prev) => (params) => ({
...prev(params),
profile1: jest.fn(),
})),
};
const profile2: ComposableProfile = {
const profile2: AppliedProfile = {
getCellRenderers: jest.fn((prev) => (params) => ({
...prev(params),
profile2: jest.fn(),
@ -57,10 +57,10 @@ describe('getMergedAccessor', () => {
it('should allow overwriting previous accessors', () => {
const baseImpl: Profile['getCellRenderers'] = jest.fn(() => ({ base: jest.fn() }));
const profile1: ComposableProfile = {
const profile1: AppliedProfile = {
getCellRenderers: jest.fn(() => () => ({ profile1: jest.fn() })),
};
const profile2: ComposableProfile = {
const profile2: AppliedProfile = {
getCellRenderers: jest.fn((prev) => (params) => ({
...prev(params),
profile2: jest.fn(),

View file

@ -14,16 +14,41 @@ import type { Profile } from './types';
*/
export type PartialProfile = Partial<Profile>;
/**
* The parameters passed to a composable accessor, such as the current context object
*/
export interface ComposableAccessorParams<TContext> {
/**
* The current context object
*/
context: TContext;
}
/**
* An accessor function that allows retrieving the extension point result from previous profiles
*/
export type ComposableAccessor<T> = (getPrevious: T) => T;
type ComposableAccessor<TPrev, TContext> = (
prev: TPrev,
params: ComposableAccessorParams<TContext>
) => TPrev;
/**
* A partial profile implementation that supports composition across multiple profiles
*/
export type ComposableProfile<TProfile extends PartialProfile = Profile> = {
[TKey in keyof TProfile]?: ComposableAccessor<TProfile[TKey]>;
export type ComposableProfile<TProfile extends PartialProfile, TContext> = {
[TKey in keyof TProfile]?: ComposableAccessor<TProfile[TKey], TContext>;
};
/**
* A partially applied accessor function with parameters bound to a specific context
*/
type AppliedAccessor<TPrev> = (prev: TPrev) => TPrev;
/**
* A partial profile implementation with applied accessors
*/
export type AppliedProfile = {
[TKey in keyof Profile]?: AppliedAccessor<Profile[TKey]>;
};
/**
@ -34,7 +59,7 @@ export type ComposableProfile<TProfile extends PartialProfile = Profile> = {
* @returns The merged extension point accessor function
*/
export const getMergedAccessor = <TKey extends keyof Profile>(
profiles: ComposableProfile[],
profiles: AppliedProfile[],
key: TKey,
baseImpl: Profile[TKey]
) => {

View file

@ -8,14 +8,14 @@
*/
import { renderHook } from '@testing-library/react-hooks';
import { ComposableProfile, getMergedAccessor } from '../composable_profile';
import { AppliedProfile, getMergedAccessor } from '../composable_profile';
import { useProfileAccessor } from './use_profile_accessor';
import { getDataTableRecords } from '../../__fixtures__/real_hits';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { useProfiles } from './use_profiles';
import { DataGridDensity } from '@kbn/unified-data-table';
let mockProfiles: ComposableProfile[] = [];
let mockProfiles: AppliedProfile[] = [];
jest.mock('./use_profiles', () => ({
useProfiles: jest.fn(() => mockProfiles),

View file

@ -8,24 +8,43 @@
*/
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { renderHook } from '@testing-library/react-hooks';
import { act, renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { discoverServiceMock } from '../../__mocks__/services';
import { GetProfilesOptions } from '../profiles_manager';
import type { GetProfilesOptions } from '../profiles_manager';
import { createContextAwarenessMocks } from '../__mocks__';
import { useProfiles } from './use_profiles';
import type { CellRenderersExtensionParams } from '../types';
import type { AppliedProfile } from '../composable_profile';
import { SolutionType } from '../profiles';
const {
rootProfileProviderMock,
dataSourceProfileProviderMock,
documentProfileProviderMock,
rootProfileServiceMock,
dataSourceProfileServiceMock,
documentProfileServiceMock,
contextRecordMock,
contextRecordMock2,
profilesManagerMock,
} = createContextAwarenessMocks();
} = createContextAwarenessMocks({ shouldRegisterProviders: false });
profilesManagerMock.resolveRootProfile({});
profilesManagerMock.resolveDataSourceProfile({});
rootProfileServiceMock.registerProvider({
profileId: 'other-root-profile',
profile: {},
resolve: (params) => {
if (params.solutionNavId === 'test') {
return { isMatch: true, context: { solutionType: SolutionType.Default } };
}
return { isMatch: false };
},
});
rootProfileServiceMock.registerProvider(rootProfileProviderMock);
dataSourceProfileServiceMock.registerProvider(dataSourceProfileProviderMock);
documentProfileServiceMock.registerProvider(documentProfileProviderMock);
const record = profilesManagerMock.resolveDocumentProfile({ record: contextRecordMock });
const record2 = profilesManagerMock.resolveDocumentProfile({ record: contextRecordMock2 });
@ -45,22 +64,30 @@ const render = () => {
};
describe('useProfiles', () => {
beforeEach(() => {
beforeEach(async () => {
jest.clearAllMocks();
await profilesManagerMock.resolveRootProfile({});
await profilesManagerMock.resolveDataSourceProfile({});
});
it('should return profiles', () => {
const { result } = render();
expect(getProfilesSpy).toHaveBeenCalledTimes(2);
expect(getProfiles$Spy).toHaveBeenCalledTimes(1);
expect(result.current).toEqual([
rootProfileProviderMock.profile,
dataSourceProfileProviderMock.profile,
documentProfileProviderMock.profile,
]);
expect(result.current).toHaveLength(3);
const [rootProfile, dataSourceProfile, documentProfile] = result.current;
const baseImpl = () => ({});
rootProfile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams);
expect(rootProfileProviderMock.profile.getCellRenderers).toHaveBeenCalledTimes(1);
dataSourceProfile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams);
expect(dataSourceProfileProviderMock.profile.getCellRenderers).toHaveBeenCalledTimes(1);
documentProfile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams);
expect(
(documentProfileProviderMock.profile as AppliedProfile).getCellRenderers
).toHaveBeenCalledTimes(1);
});
it('should return the same array reference if profiles do not change', () => {
it('should return the same array reference if profiles and record do not change', () => {
const { result, rerender } = render();
expect(getProfilesSpy).toHaveBeenCalledTimes(2);
expect(getProfiles$Spy).toHaveBeenCalledTimes(1);
@ -69,13 +96,23 @@ describe('useProfiles', () => {
expect(getProfilesSpy).toHaveBeenCalledTimes(2);
expect(getProfiles$Spy).toHaveBeenCalledTimes(1);
expect(result.current).toBe(prevResult);
});
it('should return a different array reference if record changes', () => {
const { result, rerender } = render();
expect(getProfilesSpy).toHaveBeenCalledTimes(2);
expect(getProfiles$Spy).toHaveBeenCalledTimes(1);
const prevResult = result.current;
rerender({ record: record2 });
expect(getProfilesSpy).toHaveBeenCalledTimes(3);
expect(getProfiles$Spy).toHaveBeenCalledTimes(2);
expect(result.current).toBe(prevResult);
expect(result.current).not.toBe(prevResult);
expect(result.current[0]).toBe(prevResult[0]);
expect(result.current[1]).toBe(prevResult[1]);
expect(result.current[2]).not.toBe(prevResult[2]);
});
it('should return a different array reference if profiles change', () => {
it('should return a different array reference if profiles change', async () => {
const { result, rerender } = render();
expect(getProfilesSpy).toHaveBeenCalledTimes(2);
expect(getProfiles$Spy).toHaveBeenCalledTimes(1);
@ -84,9 +121,15 @@ describe('useProfiles', () => {
expect(getProfilesSpy).toHaveBeenCalledTimes(2);
expect(getProfiles$Spy).toHaveBeenCalledTimes(1);
expect(result.current).toBe(prevResult);
rerender({ record: undefined });
await act(async () => {
await profilesManagerMock.resolveRootProfile({ solutionNavId: 'test' });
});
rerender({ record });
expect(getProfilesSpy).toHaveBeenCalledTimes(3);
expect(getProfiles$Spy).toHaveBeenCalledTimes(2);
expect(getProfiles$Spy).toHaveBeenCalledTimes(1);
expect(result.current).not.toBe(prevResult);
expect(result.current[0]).not.toBe(prevResult[0]);
expect(result.current[1]).toBe(prevResult[1]);
expect(result.current[2]).not.toBe(prevResult[2]);
});
});

View file

@ -101,10 +101,13 @@ describe('logDocumentProfileProvider', () => {
describe('getDocViewer', () => {
it('adds a log overview doc view to the registry', () => {
const getDocViewer = logDocumentProfileProvider.profile.getDocViewer!(() => ({
title: 'test title',
docViewsRegistry: (registry) => registry,
}));
const getDocViewer = logDocumentProfileProvider.profile.getDocViewer!(
() => ({
title: 'test title',
docViewsRegistry: (registry) => registry,
}),
{ context: { type: DocumentType.Log } }
);
const docViewer = getDocViewer({
record: buildDataTableRecord({}),
});

View file

@ -117,7 +117,9 @@ describe('logsDataSourceProfileProvider', () => {
const row = buildDataTableRecord({ fields: { 'log.level': 'info' } });
const euiTheme = { euiTheme: { colors: {} } } as unknown as EuiThemeComputed;
const getRowIndicatorProvider =
logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined);
logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined, {
context: { category: DataSourceCategory.Logs },
});
const getRowIndicator = getRowIndicatorProvider?.({
dataView: dataViewWithLogLevel,
});
@ -130,7 +132,9 @@ describe('logsDataSourceProfileProvider', () => {
const row = buildDataTableRecord({ fields: { other: 'info' } });
const euiTheme = { euiTheme: { colors: {} } } as unknown as EuiThemeComputed;
const getRowIndicatorProvider =
logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined);
logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined, {
context: { category: DataSourceCategory.Logs },
});
const getRowIndicator = getRowIndicatorProvider?.({
dataView: dataViewWithLogLevel,
});
@ -141,7 +145,9 @@ describe('logsDataSourceProfileProvider', () => {
it('should not set the color indicator handler if data view does not have log level field', () => {
const getRowIndicatorProvider =
logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined);
logsDataSourceProfileProvider.profile.getRowIndicatorProvider?.(() => undefined, {
context: { category: DataSourceCategory.Logs },
});
const getRowIndicator = getRowIndicatorProvider?.({
dataView: dataViewWithoutLogLevel,
});
@ -152,7 +158,12 @@ describe('logsDataSourceProfileProvider', () => {
describe('getCellRenderers', () => {
it('should return cell renderers for log level fields', () => {
const getCellRenderers = logsDataSourceProfileProvider.profile.getCellRenderers?.(() => ({}));
const getCellRenderers = logsDataSourceProfileProvider.profile.getCellRenderers?.(
() => ({}),
{
context: { category: DataSourceCategory.Logs },
}
);
const getCellRenderersParams = {
actions: { addFilter: jest.fn() },
dataView: dataViewWithTimefieldMock,
@ -172,7 +183,9 @@ describe('logsDataSourceProfileProvider', () => {
describe('getRowAdditionalLeadingControls', () => {
it('should return the passed additional controls', () => {
const getRowAdditionalLeadingControls =
logsDataSourceProfileProvider.profile.getRowAdditionalLeadingControls?.(() => undefined);
logsDataSourceProfileProvider.profile.getRowAdditionalLeadingControls?.(() => undefined, {
context: { category: DataSourceCategory.Logs },
});
const rowAdditionalLeadingControls = getRowAdditionalLeadingControls?.({
dataView: dataViewWithLogLevel,
});

View file

@ -41,7 +41,9 @@ describe('createApacheErrorLogsDataSourceProfileProvider', () => {
});
it('should return default app state', () => {
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}));
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), {
context: { category: DataSourceCategory.Logs },
});
expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({
columns: [
{ name: 'timestamp', width: 212 },

View file

@ -41,7 +41,9 @@ describe('createAwsS3accessLogsDataSourceProfileProvider', () => {
});
it('should return default app state', () => {
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}));
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), {
context: { category: DataSourceCategory.Logs },
});
expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({
columns: [
{ name: 'timestamp', width: 212 },

View file

@ -41,7 +41,9 @@ describe('createKubernetesContainerLogsDataSourceProfileProvider', () => {
});
it('should return default app state', () => {
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}));
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), {
context: { category: DataSourceCategory.Logs },
});
expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({
columns: [
{ name: 'timestamp', width: 212 },

View file

@ -41,7 +41,9 @@ describe('createNginxAccessLogsDataSourceProfileProvider', () => {
});
it('should return default app state', () => {
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}));
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), {
context: { category: DataSourceCategory.Logs },
});
expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({
columns: [
{ name: 'timestamp', width: 212 },

View file

@ -41,7 +41,9 @@ describe('createNginxErrorLogsDataSourceProfileProvider', () => {
});
it('should return default app state', () => {
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}));
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), {
context: { category: DataSourceCategory.Logs },
});
expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({
columns: [
{ name: 'timestamp', width: 212 },

View file

@ -41,7 +41,9 @@ describe('createSystemLogsDataSourceProfileProvider', () => {
});
it('should return default app state', () => {
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}));
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), {
context: { category: DataSourceCategory.Logs },
});
expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({
columns: [
{ name: 'timestamp', width: 212 },

View file

@ -41,7 +41,9 @@ describe('createWindowsLogsDataSourceProfileProvider', () => {
});
it('should return default app state', () => {
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}));
const getDefaultAppState = dataSourceProfileProvider.profile.getDefaultAppState?.(() => ({}), {
context: { category: DataSourceCategory.Logs },
});
expect(getDefaultAppState?.({ dataView: dataViewWithTimefieldMock })).toEqual({
columns: [
{ name: 'timestamp', width: 212 },

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiBadge, EuiLink, EuiFlyout } from '@elastic/eui';
import { EuiBadge, EuiLink, EuiFlyout, EuiFlyoutBody } from '@elastic/eui';
import {
AppMenuActionId,
AppMenuActionType,
@ -23,7 +23,9 @@ import { DataSourceType, isDataSourceType } from '../../../../../common/data_sou
import { DataSourceCategory, DataSourceProfileProvider } from '../../../profiles';
import { useExampleContext } from '../example_context';
export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvider => ({
export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvider<{
formatRecord: (flattenedRecord: Record<string, unknown>) => string;
}> => ({
profileId: 'example-data-source-profile',
isExperimental: true,
profile: {
@ -74,25 +76,32 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi
);
},
}),
getDocViewer: (prev) => (params) => {
const recordId = params.record.id;
const prevValue = prev(params);
return {
title: `Record #${recordId}`,
docViewsRegistry: (registry) => {
registry.add({
id: 'doc_view_example',
title: 'Example',
order: 0,
component: () => (
<div data-test-subj="exampleDataSourceProfileDocView">Example Doc View</div>
),
});
getDocViewer:
(prev, { context }) =>
(params) => {
const recordId = params.record.id;
const prevValue = prev(params);
return {
title: `Record #${recordId}`,
docViewsRegistry: (registry) => {
registry.add({
id: 'doc_view_example',
title: 'Example',
order: 0,
component: () => (
<EuiFlyoutBody>
<div data-test-subj="exampleDataSourceProfileDocView">Example Doc View</div>
<pre data-test-subj="exampleDataSourceProfileDocViewRecord">
{context.formatRecord(params.record.flattened)}
</pre>
</EuiFlyoutBody>
),
});
return prevValue.docViewsRegistry(registry);
},
};
},
return prevValue.docViewsRegistry(registry);
},
};
},
/**
* The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu.
* The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation.
@ -267,7 +276,10 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi
return {
isMatch: true,
context: { category: DataSourceCategory.Logs },
context: {
category: DataSourceCategory.Logs,
formatRecord: (record) => JSON.stringify(record, null, 2),
},
};
},
});

View file

@ -15,7 +15,7 @@ import type { BaseProfileProvider } from '../profile_service';
* @param extension The extension to apply to the base profile provider
* @returns The extended profile provider
*/
export const extendProfileProvider = <TProvider extends BaseProfileProvider<{}>>(
export const extendProfileProvider = <TProvider extends BaseProfileProvider<{}, {}>>(
baseProvider: TProvider,
extension: Partial<TProvider> & Pick<TProvider, 'profileId'>
): TProvider => ({

View file

@ -12,17 +12,21 @@ import { createContextAwarenessMocks } from '../__mocks__';
import { createExampleRootProfileProvider } from './example/example_root_profile';
import { createExampleDataSourceProfileProvider } from './example/example_data_source_profile/profile';
import { createExampleDocumentProfileProvider } from './example/example_document_profile';
import {
registerProfileProviders,
registerEnabledProfileProviders,
} from './register_profile_providers';
import type { CellRenderersExtensionParams } from '../types';
const exampleRootProfileProvider = createExampleRootProfileProvider();
const exampleDataSourceProfileProvider = createExampleDataSourceProfileProvider();
const exampleDocumentProfileProvider = createExampleDocumentProfileProvider();
describe('registerEnabledProfileProviders', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should register all profile providers', async () => {
const { rootProfileServiceMock, rootProfileProviderMock } = createContextAwarenessMocks({
shouldRegisterProviders: false,
@ -33,37 +37,52 @@ describe('registerEnabledProfileProviders', () => {
enabledExperimentalProfileIds: [],
});
const context = await rootProfileServiceMock.resolve({ solutionNavId: null });
expect(rootProfileServiceMock.getProfile(context)).toBe(rootProfileProviderMock.profile);
const profile = rootProfileServiceMock.getProfile({ context });
const baseImpl = () => ({});
profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams);
expect(rootProfileProviderMock.profile.getCellRenderers).toHaveBeenCalledTimes(1);
expect(rootProfileProviderMock.profile.getCellRenderers).toHaveBeenCalledWith(baseImpl, {
context,
});
});
it('should not register experimental profile providers by default', async () => {
jest.spyOn(exampleRootProfileProvider.profile, 'getCellRenderers');
const { rootProfileServiceMock } = createContextAwarenessMocks({
shouldRegisterProviders: false,
});
registerEnabledProfileProviders({
profileService: rootProfileServiceMock,
providers: [exampleRootProfileProvider],
enabledExperimentalProfileIds: [],
});
const context = await rootProfileServiceMock.resolve({ solutionNavId: null });
expect(rootProfileServiceMock.getProfile(context)).not.toBe(exampleRootProfileProvider.profile);
expect(rootProfileServiceMock.getProfile(context)).toMatchObject({});
const profile = rootProfileServiceMock.getProfile({ context });
const baseImpl = () => ({});
profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams);
expect(exampleRootProfileProvider.profile.getCellRenderers).not.toHaveBeenCalled();
expect(profile).toMatchObject({});
});
it('should register experimental profile providers when enabled by config', async () => {
jest.spyOn(exampleRootProfileProvider.profile, 'getCellRenderers');
const { rootProfileServiceMock, rootProfileProviderMock } = createContextAwarenessMocks({
shouldRegisterProviders: false,
});
registerEnabledProfileProviders({
profileService: rootProfileServiceMock,
providers: [exampleRootProfileProvider],
enabledExperimentalProfileIds: [exampleRootProfileProvider.profileId],
});
const context = await rootProfileServiceMock.resolve({ solutionNavId: null });
expect(rootProfileServiceMock.getProfile(context)).toBe(exampleRootProfileProvider.profile);
expect(rootProfileServiceMock.getProfile(context)).not.toBe(rootProfileProviderMock.profile);
const profile = rootProfileServiceMock.getProfile({ context });
const baseImpl = () => ({});
profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams);
expect(exampleRootProfileProvider.profile.getCellRenderers).toHaveBeenCalledTimes(1);
expect(exampleRootProfileProvider.profile.getCellRenderers).toHaveBeenCalledWith(baseImpl, {
context,
});
expect(rootProfileProviderMock.profile.getCellRenderers).not.toHaveBeenCalled();
});
});

View file

@ -86,8 +86,8 @@ export const registerProfileProviders = async ({
* @param options Register enabled profile providers options
*/
export const registerEnabledProfileProviders = <
TProvider extends BaseProfileProvider<{}>,
TService extends BaseProfileService<TProvider, {}>
TProvider extends BaseProfileProvider<{}, {}>,
TService extends BaseProfileService<TProvider>
>({
profileService,
providers: availableProviders,

View file

@ -9,8 +9,14 @@
/* eslint-disable max-classes-per-file */
import { AsyncProfileService, ContextWithProfileId, ProfileService } from './profile_service';
import { Profile } from './types';
import {
AsyncProfileProvider,
AsyncProfileService,
ContextWithProfileId,
ProfileProvider,
ProfileService,
} from './profile_service';
import type { CellRenderersExtensionParams, Profile } from './types';
interface TestParams {
myParam: string;
@ -25,7 +31,7 @@ const defaultContext: ContextWithProfileId<TestContext> = {
myContext: 'test',
};
class TestProfileService extends ProfileService<Profile, TestParams, TestContext> {
class TestProfileService extends ProfileService<ProfileProvider<Profile, TestParams, TestContext>> {
constructor() {
super(defaultContext);
}
@ -33,7 +39,9 @@ class TestProfileService extends ProfileService<Profile, TestParams, TestContext
type TestProfileProvider = Parameters<TestProfileService['registerProvider']>[0];
class TestAsyncProfileService extends AsyncProfileService<Profile, TestParams, TestContext> {
class TestAsyncProfileService extends AsyncProfileService<
AsyncProfileProvider<Profile, TestParams, TestContext>
> {
constructor() {
super(defaultContext);
}
@ -43,25 +51,27 @@ type TestAsyncProfileProvider = Parameters<TestAsyncProfileService['registerProv
const provider: TestProfileProvider = {
profileId: 'test-profile-1',
profile: { getCellRenderers: jest.fn() },
profile: {
getCellRenderers: jest.fn((prev) => (params) => prev(params)),
},
resolve: jest.fn(() => ({ isMatch: false })),
};
const provider2: TestProfileProvider = {
profileId: 'test-profile-2',
profile: { getCellRenderers: jest.fn() },
profile: { getCellRenderers: jest.fn((prev) => (params) => prev(params)) },
resolve: jest.fn(({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })),
};
const provider3: TestProfileProvider = {
profileId: 'test-profile-3',
profile: { getCellRenderers: jest.fn() },
profile: { getCellRenderers: jest.fn((prev) => (params) => prev(params)) },
resolve: jest.fn(({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })),
};
const asyncProvider2: TestAsyncProfileProvider = {
profileId: 'test-profile-2',
profile: { getCellRenderers: jest.fn() },
profile: { getCellRenderers: jest.fn((prev) => (params) => prev(params)) },
resolve: jest.fn(async ({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })),
};
@ -80,17 +90,30 @@ describe('ProfileService', () => {
it('should allow registering providers and getting profiles', () => {
service.registerProvider(provider);
service.registerProvider(provider2);
expect(service.getProfile({ profileId: 'test-profile-1', myContext: 'test' })).toBe(
provider.profile
);
expect(service.getProfile({ profileId: 'test-profile-2', myContext: 'test' })).toBe(
provider2.profile
);
const params = {
context: { profileId: 'test-profile-1', myContext: 'test' },
};
const params2 = {
context: { profileId: 'test-profile-2', myContext: 'test' },
};
const profile = service.getProfile(params);
const profile2 = service.getProfile(params2);
const baseImpl = jest.fn(() => ({}));
profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams);
expect(provider.profile.getCellRenderers).toHaveBeenCalledTimes(1);
expect(provider.profile.getCellRenderers).toHaveBeenCalledWith(baseImpl, params);
expect(baseImpl).toHaveBeenCalledTimes(1);
profile2.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams);
expect(provider2.profile.getCellRenderers).toHaveBeenCalledTimes(1);
expect(provider2.profile.getCellRenderers).toHaveBeenCalledWith(baseImpl, params2);
expect(baseImpl).toHaveBeenCalledTimes(2);
});
it('should return empty profile if no provider is found', () => {
service.registerProvider(provider);
expect(service.getProfile({ profileId: 'test-profile-2', myContext: 'test' })).toEqual({});
expect(
service.getProfile({ context: { profileId: 'test-profile-2', myContext: 'test' } })
).toEqual({});
});
it('should resolve to first matching context', () => {

View file

@ -9,13 +9,18 @@
/* eslint-disable max-classes-per-file */
import type { ComposableProfile, PartialProfile } from './composable_profile';
import type { Profile } from './types';
import { isFunction } from 'lodash';
import type {
AppliedProfile,
ComposableAccessorParams,
ComposableProfile,
PartialProfile,
} from './composable_profile';
/**
* The profile provider resolution result
*/
export type ResolveProfileResult<TContext> =
type ResolveProfileResult<TContext> =
| {
/**
* `true` if the associated profile is a match
@ -33,15 +38,10 @@ export type ResolveProfileResult<TContext> =
isMatch: false;
};
/**
* Context object with an injected profile ID
*/
export type ContextWithProfileId<TContext> = TContext & { profileId: string };
/**
* The base profile provider interface
*/
export interface BaseProfileProvider<TProfile extends PartialProfile> {
export interface BaseProfileProvider<TProfile extends PartialProfile, TContext> {
/**
* The unique profile ID
*/
@ -49,7 +49,7 @@ export interface BaseProfileProvider<TProfile extends PartialProfile> {
/**
* The composable profile implementation
*/
profile: ComposableProfile<TProfile>;
profile: ComposableProfile<TProfile, TContext>;
/**
* Set the `isExperimental` flag to `true` for any profile which is under development and should not be enabled by default.
*
@ -68,7 +68,7 @@ export interface BaseProfileProvider<TProfile extends PartialProfile> {
* A synchronous profile provider interface
*/
export interface ProfileProvider<TProfile extends PartialProfile, TParams, TContext>
extends BaseProfileProvider<TProfile> {
extends BaseProfileProvider<TProfile, TContext> {
/**
* The method responsible for context resolution and determining if the associated profile is a match
* @param params Parameters specific to the provider context level
@ -81,7 +81,7 @@ export interface ProfileProvider<TProfile extends PartialProfile, TParams, TCont
* An asynchronous profile provider interface
*/
export interface AsyncProfileProvider<TProfile extends PartialProfile, TParams, TContext>
extends BaseProfileProvider<TProfile> {
extends BaseProfileProvider<TProfile, TContext> {
/**
* The method responsible for context resolution and determining if the associated profile is a match
* @param params Parameters specific to the provider context level
@ -92,12 +92,36 @@ export interface AsyncProfileProvider<TProfile extends PartialProfile, TParams,
) => ResolveProfileResult<TContext> | Promise<ResolveProfileResult<TContext>>;
}
/**
* Context object with an injected profile ID
*/
export type ContextWithProfileId<TContext> = TContext &
Pick<BaseProfileProvider<{}, {}>, 'profileId'>;
/**
* Used to extract the profile type from a profile provider
*/
type ExtractProfile<TProvider> = TProvider extends BaseProfileProvider<infer TProfile, {}>
? TProfile
: never;
/**
* Used to extract the context type from a profile provider
*/
type ExtractContext<TProvider> = TProvider extends BaseProfileProvider<{}, infer TContext>
? TContext
: never;
const EMPTY_PROFILE = {};
/**
* The base profile service implementation
*/
export abstract class BaseProfileService<TProvider extends BaseProfileProvider<{}>, TContext> {
export abstract class BaseProfileService<
TProvider extends BaseProfileProvider<TProfile, TContext>,
TProfile extends PartialProfile = ExtractProfile<TProvider>,
TContext = ExtractContext<TProvider>
> {
protected readonly providers: TProvider[] = [];
/**
@ -114,31 +138,59 @@ export abstract class BaseProfileService<TProvider extends BaseProfileProvider<{
}
/**
* Returns the composable profile associated with the provided context object
* Returns the profile associated with the provided context object
* @param context A context object returned by a provider's `resolve` method
* @returns The composable profile associated with the context
* @returns The profile associated with the context
*/
public getProfile(context: ContextWithProfileId<TContext>): ComposableProfile<Profile> {
const provider = this.providers.find((current) => current.profileId === context.profileId);
return provider?.profile ?? EMPTY_PROFILE;
public getProfile(
params: ComposableAccessorParams<ContextWithProfileId<TContext>>
): AppliedProfile {
const provider = this.providers.find(
(current) => current.profileId === params.context.profileId
);
if (!provider?.profile) {
return EMPTY_PROFILE;
}
return new Proxy(provider.profile, {
get: (target, prop, receiver) => {
const accessor = Reflect.get(target, prop, receiver);
if (!isFunction(accessor)) {
return accessor;
}
return (prev: Parameters<typeof accessor>[0]) => accessor(prev, params);
},
}) as AppliedProfile;
}
}
/**
* Used to extract the parameters type from a profile provider
*/
type ExtractParams<TProvider> = TProvider extends ProfileProvider<{}, infer P, {}>
? P
: TProvider extends AsyncProfileProvider<{}, infer P, {}>
? P
: never;
/**
* A synchronous profile service implementation
*/
export class ProfileService<
TProfile extends PartialProfile,
TParams,
TContext
> extends BaseProfileService<ProfileProvider<TProfile, TParams, TContext>, TContext> {
TProvider extends ProfileProvider<{}, TParams, TContext>,
TParams = ExtractParams<TProvider>,
TContext = ExtractContext<TProvider>
> extends BaseProfileService<TProvider> {
/**
* Performs context resolution based on the provided context level parameters,
* returning the resolved context from the first matching profile provider
* @param params Parameters specific to the service context level
* @returns The resolved context object with an injected profile ID
*/
public resolve(params: TParams) {
public resolve(params: TParams): ContextWithProfileId<TContext> {
for (const provider of this.providers) {
const result = provider.resolve(params);
@ -158,17 +210,17 @@ export class ProfileService<
* An asynchronous profile service implementation
*/
export class AsyncProfileService<
TProfile extends PartialProfile,
TParams,
TContext
> extends BaseProfileService<AsyncProfileProvider<TProfile, TParams, TContext>, TContext> {
TProvider extends AsyncProfileProvider<{}, TParams, TContext>,
TParams = ExtractParams<TProvider>,
TContext = ExtractContext<TProvider>
> extends BaseProfileService<TProvider> {
/**
* Performs context resolution based on the provided context level parameters,
* returning the resolved context from the first matching profile provider
* @param params Parameters specific to the service context level
* @returns The resolved context object with an injected profile ID
*/
public async resolve(params: TParams) {
public async resolve(params: TParams): Promise<ContextWithProfileId<TContext>> {
for (const provider of this.providers) {
const result = await provider.resolve(params);

View file

@ -59,17 +59,13 @@ export interface DataSourceContext {
category: DataSourceCategory;
}
export type DataSourceProfileProvider = AsyncProfileProvider<
export type DataSourceProfileProvider<TProviderContext = {}> = AsyncProfileProvider<
DataSourceProfile,
DataSourceProfileProviderParams,
DataSourceContext
DataSourceContext & TProviderContext
>;
export class DataSourceProfileService extends AsyncProfileService<
DataSourceProfile,
DataSourceProfileProviderParams,
DataSourceContext
> {
export class DataSourceProfileService extends AsyncProfileService<DataSourceProfileProvider> {
constructor() {
super({
profileId: 'default-data-source-profile',

View file

@ -54,17 +54,13 @@ export interface DocumentContext {
type: DocumentType;
}
export type DocumentProfileProvider = ProfileProvider<
export type DocumentProfileProvider<TProviderContext = {}> = ProfileProvider<
DocumentProfile,
DocumentProfileProviderParams,
DocumentContext
DocumentContext & TProviderContext
>;
export class DocumentProfileService extends ProfileService<
DocumentProfile,
DocumentProfileProviderParams,
DocumentContext
> {
export class DocumentProfileService extends ProfileService<DocumentProfileProvider> {
constructor() {
super({
profileId: 'default-document-profile',

View file

@ -45,17 +45,13 @@ export interface RootContext {
solutionType: SolutionType;
}
export type RootProfileProvider = AsyncProfileProvider<
export type RootProfileProvider<TProviderContext = {}> = AsyncProfileProvider<
RootProfile,
RootProfileProviderParams,
RootContext
RootContext & TProviderContext
>;
export class RootProfileService extends AsyncProfileService<
RootProfile,
RootProfileProviderParams,
RootContext
> {
export class RootProfileService extends AsyncProfileService<RootProfileProvider> {
constructor() {
super({
profileId: 'default-root-profile',

View file

@ -12,11 +12,15 @@ import { createEsqlDataSource } from '../../common/data_sources';
import { addLog } from '../utils/add_log';
import { SolutionType } from './profiles/root_profile';
import { createContextAwarenessMocks } from './__mocks__';
import type { ComposableProfile } from './composable_profile';
jest.mock('../utils/add_log');
let mocks = createContextAwarenessMocks();
const toAppliedProfile = (profile: ComposableProfile<{}, {}>) =>
Object.keys(profile).reduce((acc, key) => ({ ...acc, [key]: expect.any(Function) }), {});
describe('ProfilesManager', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -32,13 +36,17 @@ describe('ProfilesManager', () => {
it('should resolve root profile', async () => {
await mocks.profilesManagerMock.resolveRootProfile({});
const profiles = mocks.profilesManagerMock.getProfiles();
expect(profiles).toEqual([mocks.rootProfileProviderMock.profile, {}, {}]);
expect(profiles).toEqual([toAppliedProfile(mocks.rootProfileProviderMock.profile), {}, {}]);
});
it('should resolve data source profile', async () => {
await mocks.profilesManagerMock.resolveDataSourceProfile({});
const profiles = mocks.profilesManagerMock.getProfiles();
expect(profiles).toEqual([{}, mocks.dataSourceProfileProviderMock.profile, {}]);
expect(profiles).toEqual([
{},
toAppliedProfile(mocks.dataSourceProfileProviderMock.profile),
{},
]);
});
it('should resolve document profile', async () => {
@ -46,7 +54,7 @@ describe('ProfilesManager', () => {
record: mocks.contextRecordMock,
});
const profiles = mocks.profilesManagerMock.getProfiles({ record });
expect(profiles).toEqual([{}, {}, mocks.documentProfileProviderMock.profile]);
expect(profiles).toEqual([{}, {}, toAppliedProfile(mocks.documentProfileProviderMock.profile)]);
});
it('should resolve multiple profiles', async () => {
@ -57,9 +65,9 @@ describe('ProfilesManager', () => {
});
const profiles = mocks.profilesManagerMock.getProfiles({ record });
expect(profiles).toEqual([
mocks.rootProfileProviderMock.profile,
mocks.dataSourceProfileProviderMock.profile,
mocks.documentProfileProviderMock.profile,
toAppliedProfile(mocks.rootProfileProviderMock.profile),
toAppliedProfile(mocks.dataSourceProfileProviderMock.profile),
toAppliedProfile(mocks.documentProfileProviderMock.profile),
]);
expect(mocks.ebtManagerMock.updateProfilesContextWith).toHaveBeenCalledWith([
@ -77,20 +85,24 @@ describe('ProfilesManager', () => {
const next = jest.fn();
profiles$.subscribe(next);
expect(getProfilesSpy).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith([{}, {}, mocks.documentProfileProviderMock.profile]);
expect(next).toHaveBeenCalledWith([
{},
{},
toAppliedProfile(mocks.documentProfileProviderMock.profile),
]);
await mocks.profilesManagerMock.resolveRootProfile({});
expect(getProfilesSpy).toHaveBeenCalledTimes(2);
expect(next).toHaveBeenCalledWith([
mocks.rootProfileProviderMock.profile,
toAppliedProfile(mocks.rootProfileProviderMock.profile),
{},
mocks.documentProfileProviderMock.profile,
toAppliedProfile(mocks.documentProfileProviderMock.profile),
]);
await mocks.profilesManagerMock.resolveDataSourceProfile({});
expect(getProfilesSpy).toHaveBeenCalledTimes(3);
expect(next).toHaveBeenCalledWith([
mocks.rootProfileProviderMock.profile,
mocks.dataSourceProfileProviderMock.profile,
mocks.documentProfileProviderMock.profile,
toAppliedProfile(mocks.rootProfileProviderMock.profile),
toAppliedProfile(mocks.dataSourceProfileProviderMock.profile),
toAppliedProfile(mocks.documentProfileProviderMock.profile),
]);
});
@ -135,7 +147,7 @@ describe('ProfilesManager', () => {
it('should log an error and fall back to the default profile if root profile resolution fails', async () => {
await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'solutionNavId' });
let profiles = mocks.profilesManagerMock.getProfiles();
expect(profiles).toEqual([mocks.rootProfileProviderMock.profile, {}, {}]);
expect(profiles).toEqual([toAppliedProfile(mocks.rootProfileProviderMock.profile), {}, {}]);
const resolveSpy = jest.spyOn(mocks.rootProfileProviderMock, 'resolve');
resolveSpy.mockRejectedValue(new Error('Failed to resolve'));
await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'newSolutionNavId' });
@ -153,7 +165,11 @@ describe('ProfilesManager', () => {
query: { esql: 'from *' },
});
let profiles = mocks.profilesManagerMock.getProfiles();
expect(profiles).toEqual([{}, mocks.dataSourceProfileProviderMock.profile, {}]);
expect(profiles).toEqual([
{},
toAppliedProfile(mocks.dataSourceProfileProviderMock.profile),
{},
]);
const resolveSpy = jest.spyOn(mocks.dataSourceProfileProviderMock, 'resolve');
resolveSpy.mockRejectedValue(new Error('Failed to resolve'));
await mocks.profilesManagerMock.resolveDataSourceProfile({
@ -173,7 +189,7 @@ describe('ProfilesManager', () => {
record: mocks.contextRecordMock,
});
let profiles = mocks.profilesManagerMock.getProfiles({ record });
expect(profiles).toEqual([{}, {}, mocks.documentProfileProviderMock.profile]);
expect(profiles).toEqual([{}, {}, toAppliedProfile(mocks.documentProfileProviderMock.profile)]);
const resolveSpy = jest.spyOn(mocks.documentProfileProviderMock, 'resolve');
resolveSpy.mockImplementation(() => {
throw new Error('Failed to resolve');
@ -220,7 +236,7 @@ describe('ProfilesManager', () => {
resolvedDeferredResult2$.next(undefined);
await promise2;
expect(mocks.profilesManagerMock.getProfiles()).toEqual([
mocks.rootProfileProviderMock.profile,
toAppliedProfile(mocks.rootProfileProviderMock.profile),
{},
{},
]);
@ -266,7 +282,7 @@ describe('ProfilesManager', () => {
await promise2;
expect(mocks.profilesManagerMock.getProfiles()).toEqual([
{},
mocks.dataSourceProfileProviderMock.profile,
toAppliedProfile(mocks.dataSourceProfileProviderMock.profile),
{},
]);
});

View file

@ -10,7 +10,7 @@
import type { DataTableRecord } from '@kbn/discover-utils';
import { isOfAggregateQueryType } from '@kbn/es-query';
import { isEqual } from 'lodash';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { BehaviorSubject, combineLatest, map, skip } from 'rxjs';
import { DataSourceType, isDataSourceType } from '../../common/data_sources';
import { addLog } from '../utils/add_log';
import type {
@ -26,6 +26,7 @@ import type {
} from './profiles';
import type { ContextWithProfileId } from './profile_service';
import type { DiscoverEBTManager } from '../services/discover_ebt_manager';
import type { AppliedProfile } from './composable_profile';
interface SerializedRootProfileParams {
solutionNavId: RootProfileProviderParams['solutionNavId'];
@ -53,8 +54,9 @@ export interface GetProfilesOptions {
export class ProfilesManager {
private readonly rootContext$: BehaviorSubject<ContextWithProfileId<RootContext>>;
private readonly dataSourceContext$: BehaviorSubject<ContextWithProfileId<DataSourceContext>>;
private readonly ebtManager: DiscoverEBTManager;
private rootProfile: AppliedProfile;
private dataSourceProfile: AppliedProfile;
private prevRootProfileParams?: SerializedRootProfileParams;
private prevDataSourceProfileParams?: SerializedDataSourceProfileParams;
private rootProfileAbortController?: AbortController;
@ -64,11 +66,22 @@ export class ProfilesManager {
private readonly rootProfileService: RootProfileService,
private readonly dataSourceProfileService: DataSourceProfileService,
private readonly documentProfileService: DocumentProfileService,
ebtManager: DiscoverEBTManager
private readonly ebtManager: DiscoverEBTManager
) {
this.rootContext$ = new BehaviorSubject(rootProfileService.defaultContext);
this.dataSourceContext$ = new BehaviorSubject(dataSourceProfileService.defaultContext);
this.ebtManager = ebtManager;
this.rootProfile = rootProfileService.getProfile({ context: this.rootContext$.getValue() });
this.dataSourceProfile = dataSourceProfileService.getProfile({
context: this.dataSourceContext$.getValue(),
});
this.rootContext$.pipe(skip(1)).subscribe((context) => {
this.rootProfile = rootProfileService.getProfile({ context });
});
this.dataSourceContext$.pipe(skip(1)).subscribe((context) => {
this.dataSourceProfile = dataSourceProfileService.getProfile({ context });
});
}
/**
@ -79,7 +92,7 @@ export class ProfilesManager {
const serializedParams = serializeRootProfileParams(params);
if (isEqual(this.prevRootProfileParams, serializedParams)) {
return { getRenderAppWrapper: this.getRootRenderAppWrapper() };
return { getRenderAppWrapper: this.rootProfile.getRenderAppWrapper };
}
const abortController = new AbortController();
@ -95,13 +108,13 @@ export class ProfilesManager {
}
if (abortController.signal.aborted) {
return { getRenderAppWrapper: this.getRootRenderAppWrapper() };
return { getRenderAppWrapper: this.rootProfile.getRenderAppWrapper };
}
this.rootContext$.next(context);
this.prevRootProfileParams = serializedParams;
return { getRenderAppWrapper: this.getRootRenderAppWrapper() };
return { getRenderAppWrapper: this.rootProfile.getRenderAppWrapper };
}
/**
@ -183,11 +196,13 @@ export class ProfilesManager {
*/
public getProfiles({ record }: GetProfilesOptions = {}) {
return [
this.rootProfileService.getProfile(this.rootContext$.getValue()),
this.dataSourceProfileService.getProfile(this.dataSourceContext$.getValue()),
this.documentProfileService.getProfile(
recordHasContext(record) ? record.context : this.documentProfileService.defaultContext
),
this.rootProfile,
this.dataSourceProfile,
this.documentProfileService.getProfile({
context: recordHasContext(record)
? record.context
: this.documentProfileService.defaultContext,
}),
];
}
@ -210,11 +225,6 @@ export class ProfilesManager {
this.ebtManager.updateProfilesContextWith(dscProfiles);
}
private getRootRenderAppWrapper() {
const rootProfile = this.rootProfileService.getProfile(this.rootContext$.getValue());
return rootProfile.getRenderAppWrapper;
}
}
const serializeRootProfileParams = (

View file

@ -20,6 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const dataViews = getService('dataViews');
const dataGrid = getService('dataGrid');
const retry = getService('retry');
describe('data source profile', () => {
describe('ES|QL mode', () => {
@ -98,6 +99,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.getVisibleText('docViewerRowDetailsTitle')).to.be('Record #0');
});
});
describe('custom context', () => {
it('should render formatted record in doc viewer using formatter from custom context', async () => {
const state = kbnRison.encode({
dataSource: { type: 'esql' },
query: { esql: 'from my-example-logs | sort @timestamp desc' },
});
await common.navigateToActualUrl('discover', `?_a=${state}`, {
ensureCurrentUrl: false,
});
await discover.waitUntilSearchingHasFinished();
await dataGrid.clickRowToggle({ rowIndex: 0, defaultTabId: 'doc_view_example' });
await retry.try(async () => {
const formattedRecord = await testSubjects.find(
'exampleDataSourceProfileDocViewRecord'
);
expect(await formattedRecord.getVisibleText()).to.be(
JSON.stringify(
{
'@timestamp': '2024-06-10T16:00:00.000Z',
'agent.name': 'java',
'agent.name.text': 'java',
'data_stream.type': 'logs',
'log.level': 'debug',
message: 'This is a debug log',
'service.name': 'product',
'service.name.text': 'product',
},
null,
2
)
);
});
});
});
});
describe('data view mode', () => {
@ -166,6 +202,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
});
});
describe('custom context', () => {
it('should render formatted record in doc viewer using formatter from custom context', async () => {
await common.navigateToActualUrl('discover', undefined, {
ensureCurrentUrl: false,
});
await dataViews.switchTo('my-example-logs');
await discover.waitUntilSearchingHasFinished();
await dataGrid.clickRowToggle({ rowIndex: 0, defaultTabId: 'doc_view_example' });
await retry.try(async () => {
const formattedRecord = await testSubjects.find(
'exampleDataSourceProfileDocViewRecord'
);
expect(await formattedRecord.getVisibleText()).to.be(
JSON.stringify(
{
'@timestamp': ['2024-06-10T16:00:00.000Z'],
'agent.name': ['java'],
'agent.name.text': ['java'],
'data_stream.type': ['logs'],
'log.level': ['debug'],
message: ['This is a debug log'],
'service.name': ['product'],
'service.name.text': ['product'],
_id: 'XdQFDpABfGznVC1bCHLo',
_index: 'my-example-logs',
_score: null,
},
null,
2
)
);
});
});
});
});
});
}

View file

@ -20,6 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const dataViews = getService('dataViews');
const dataGrid = getService('dataGrid');
const retry = getService('retry');
describe('data source profile', () => {
before(async () => {
@ -98,6 +99,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.getVisibleText('docViewerRowDetailsTitle')).to.be('Record #0');
});
});
describe('custom context', () => {
it('should render formatted record in doc viewer using formatter from custom context', async () => {
const state = kbnRison.encode({
dataSource: { type: 'esql' },
query: { esql: 'from my-example-logs | sort @timestamp desc' },
});
await PageObjects.common.navigateToActualUrl('discover', `?_a=${state}`, {
ensureCurrentUrl: false,
});
await PageObjects.discover.waitUntilSearchingHasFinished();
await dataGrid.clickRowToggle({ rowIndex: 0, defaultTabId: 'doc_view_example' });
await retry.try(async () => {
const formattedRecord = await testSubjects.find(
'exampleDataSourceProfileDocViewRecord'
);
expect(await formattedRecord.getVisibleText()).to.be(
JSON.stringify(
{
'@timestamp': '2024-06-10T16:00:00.000Z',
'agent.name': 'java',
'agent.name.text': 'java',
'data_stream.type': 'logs',
'log.level': 'debug',
message: 'This is a debug log',
'service.name': 'product',
'service.name.text': 'product',
},
null,
2
)
);
});
});
});
});
describe('data view mode', () => {
@ -162,6 +198,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
});
});
describe('custom context', () => {
it('should render formatted record in doc viewer using formatter from custom context', async () => {
await PageObjects.common.navigateToActualUrl('discover', undefined, {
ensureCurrentUrl: false,
});
await dataViews.switchTo('my-example-logs');
await PageObjects.discover.waitUntilSearchingHasFinished();
await dataGrid.clickRowToggle({ rowIndex: 0, defaultTabId: 'doc_view_example' });
await retry.try(async () => {
const formattedRecord = await testSubjects.find(
'exampleDataSourceProfileDocViewRecord'
);
expect(await formattedRecord.getVisibleText()).to.be(
JSON.stringify(
{
'@timestamp': ['2024-06-10T16:00:00.000Z'],
'agent.name': ['java'],
'agent.name.text': ['java'],
'data_stream.type': ['logs'],
'log.level': ['debug'],
message: ['This is a debug log'],
'service.name': ['product'],
'service.name.text': ['product'],
_id: 'XdQFDpABfGznVC1bCHLo',
_index: 'my-example-logs',
_score: null,
},
null,
2
)
);
});
});
});
});
});
}