mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Discover] Add getRenderAppWrapper
extension (#197556)
## Summary This PR adds a `getRenderAppWrapper` extension to Discover to support advanced state management use cases. It also includes new documentation for the extension point, and overriding default profile implementations: https://github.com/user-attachments/assets/70633cbb-1cfe-47fe-984e-ba8afb18fc90 To test, add the following config to `kibana.dev.yml`: ```yml discover.experimental.enabledProfiles: [ 'example-root-profile', 'example-solution-view-root-profile', 'example-data-source-profile', 'example-document-profile', ] ``` Then ingest demo logs and run this in dev tools: ``` POST _aliases { "actions": [ { "add": { "index": "kibana_sample_data_logs", "alias": "my-example-logs" } } ] } ``` Flaky test runs: - 🔴 x25: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/7238 - 🔴 x25: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/7289 - 🟢 x25: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/7291 - x25: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/7303 ### 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) --------- Co-authored-by: Julia Rechkunova <julia.rechkunova@elastic.co>
This commit is contained in:
parent
d601e23c40
commit
4a95eec82f
35 changed files with 824 additions and 115 deletions
|
@ -16,6 +16,7 @@ import { LoadingIndicator } from '../../components/common/loading_indicator';
|
|||
import { useDataView } from '../../hooks/use_data_view';
|
||||
import type { ContextHistoryLocationState } from './services/locator';
|
||||
import { useDiscoverServices } from '../../hooks/use_discover_services';
|
||||
import { useRootProfile } from '../../context_awareness';
|
||||
|
||||
export interface ContextUrlParams {
|
||||
dataViewId: string;
|
||||
|
@ -47,8 +48,8 @@ export function ContextAppRoute() {
|
|||
const { dataViewId: encodedDataViewId, id } = useParams<ContextUrlParams>();
|
||||
const dataViewId = decodeURIComponent(encodedDataViewId);
|
||||
const anchorId = decodeURIComponent(id);
|
||||
|
||||
const { dataView, error } = useDataView({ index: locationState?.dataViewSpec || dataViewId });
|
||||
const rootProfileState = useRootProfile();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
@ -72,9 +73,13 @@ export function ContextAppRoute() {
|
|||
);
|
||||
}
|
||||
|
||||
if (!dataView) {
|
||||
if (!dataView || rootProfileState.rootProfileLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <ContextApp anchorId={anchorId} dataView={dataView} referrer={locationState?.referrer} />;
|
||||
return (
|
||||
<rootProfileState.AppWrapper>
|
||||
<ContextApp anchorId={anchorId} dataView={dataView} referrer={locationState?.referrer} />
|
||||
</rootProfileState.AppWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { firstValueFrom, lastValueFrom } from 'rxjs';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ISearchSource, EsQuerySortValue } from '@kbn/data-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
|
@ -29,11 +29,7 @@ export async function fetchAnchor(
|
|||
anchorRow: DataTableRecord;
|
||||
interceptedWarnings: SearchResponseWarning[];
|
||||
}> {
|
||||
const { core, profilesManager } = services;
|
||||
|
||||
const solutionNavId = await firstValueFrom(core.chrome.getActiveSolutionNavId$());
|
||||
await profilesManager.resolveRootProfile({ solutionNavId });
|
||||
await profilesManager.resolveDataSourceProfile({
|
||||
await services.profilesManager.resolveDataSourceProfile({
|
||||
dataSource: createDataSource({ dataView, query: undefined }),
|
||||
dataView,
|
||||
query: { query: '', language: 'kuery' },
|
||||
|
@ -68,7 +64,7 @@ export async function fetchAnchor(
|
|||
});
|
||||
|
||||
return {
|
||||
anchorRow: profilesManager.resolveDocumentProfile({
|
||||
anchorRow: services.profilesManager.resolveDocumentProfile({
|
||||
record: buildDataTableRecord(doc, dataView, true),
|
||||
}),
|
||||
interceptedWarnings,
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPage, EuiPageBody } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ElasticRequestState } from '@kbn/unified-doc-viewer';
|
||||
|
@ -31,18 +30,16 @@ export interface DocProps extends EsDocSearchProps {
|
|||
export function Doc(props: DocProps) {
|
||||
const { dataView } = props;
|
||||
const services = useDiscoverServices();
|
||||
const { locator, chrome, docLinks, core, profilesManager } = services;
|
||||
const { locator, chrome, docLinks, profilesManager } = services;
|
||||
const indexExistsLink = docLinks.links.apis.indexExists;
|
||||
|
||||
const onBeforeFetch = useCallback(async () => {
|
||||
const solutionNavId = await firstValueFrom(core.chrome.getActiveSolutionNavId$());
|
||||
await profilesManager.resolveRootProfile({ solutionNavId });
|
||||
await profilesManager.resolveDataSourceProfile({
|
||||
dataSource: dataView?.id ? createDataViewDataSource({ dataViewId: dataView.id }) : undefined,
|
||||
dataView,
|
||||
query: { query: '', language: 'kuery' },
|
||||
});
|
||||
}, [profilesManager, core, dataView]);
|
||||
}, [profilesManager, dataView]);
|
||||
|
||||
const onProcessRecord = useCallback(
|
||||
(record: DataTableRecord) => {
|
||||
|
|
|
@ -19,6 +19,7 @@ import { useDiscoverServices } from '../../hooks/use_discover_services';
|
|||
import { DiscoverError } from '../../components/common/error_alert';
|
||||
import { useDataView } from '../../hooks/use_data_view';
|
||||
import { DocHistoryLocationState } from './locator';
|
||||
import { useRootProfile } from '../../context_awareness';
|
||||
|
||||
export interface DocUrlParams {
|
||||
dataViewId: string;
|
||||
|
@ -53,6 +54,8 @@ export const SingleDocRoute = () => {
|
|||
index: locationState?.dataViewSpec || decodeURIComponent(dataViewId),
|
||||
});
|
||||
|
||||
const rootProfileState = useRootProfile();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
|
@ -75,7 +78,7 @@ export const SingleDocRoute = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (!dataView) {
|
||||
if (!dataView || rootProfileState.rootProfileLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
|
@ -94,5 +97,9 @@ export const SingleDocRoute = () => {
|
|||
);
|
||||
}
|
||||
|
||||
return <Doc id={id} index={index} dataView={dataView} referrer={locationState?.referrer} />;
|
||||
return (
|
||||
<rootProfileState.AppWrapper>
|
||||
<Doc id={id} index={index} dataView={dataView} referrer={locationState?.referrer} />
|
||||
</rootProfileState.AppWrapper>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
|
@ -50,6 +50,7 @@ jest.mock('../../context_awareness', () => {
|
|||
...originalModule,
|
||||
useRootProfile: () => ({
|
||||
rootProfileLoading: mockRootProfileLoading,
|
||||
AppWrapper: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
|
|
@ -345,27 +345,26 @@ export function DiscoverMainRoute({
|
|||
stateContainer,
|
||||
]);
|
||||
|
||||
const { solutionNavId } = customizationContext;
|
||||
const { rootProfileLoading } = useRootProfile({ solutionNavId });
|
||||
const rootProfileState = useRootProfile();
|
||||
|
||||
if (error) {
|
||||
return <DiscoverError error={error} />;
|
||||
}
|
||||
|
||||
if (!customizationService || rootProfileLoading) {
|
||||
if (!customizationService || rootProfileState.rootProfileLoading) {
|
||||
return loadingIndicator;
|
||||
}
|
||||
|
||||
return (
|
||||
<DiscoverCustomizationProvider value={customizationService}>
|
||||
<DiscoverMainProvider value={stateContainer}>
|
||||
<>
|
||||
<rootProfileState.AppWrapper>
|
||||
<DiscoverTopNavInline
|
||||
stateContainer={stateContainer}
|
||||
hideNavMenuItems={loading || noDataState.showNoDataPage}
|
||||
/>
|
||||
{mainContent}
|
||||
</>
|
||||
</rootProfileState.AppWrapper>
|
||||
</DiscoverMainProvider>
|
||||
</DiscoverCustomizationProvider>
|
||||
);
|
||||
|
|
|
@ -45,7 +45,6 @@ const discoverContainerWrapperCss = css`
|
|||
`;
|
||||
|
||||
const customizationContext: DiscoverCustomizationContext = {
|
||||
solutionNavId: null,
|
||||
displayMode: 'embedded',
|
||||
inlineTopNav: {
|
||||
enabled: false,
|
||||
|
|
|
@ -102,7 +102,7 @@ Existing providers can be extended using the [`extendProfileProvider`](./profile
|
|||
|
||||
Example profile provider implementations are located in [`profile_providers/example`](./profile_providers/example).
|
||||
|
||||
## Example implementation
|
||||
### Example implementation
|
||||
|
||||
```ts
|
||||
/**
|
||||
|
@ -191,3 +191,191 @@ const createDataSourceProfileProviders = (providerServices: ProfileProviderServi
|
|||
* to resolve the profile: `FROM my-example-logs`
|
||||
*/
|
||||
```
|
||||
|
||||
## React context and state management
|
||||
|
||||
In the Discover context awareness framework, pieces of Discover’s state are passed down explicitly to extension points as needed. This avoids leaking Discover internals – which may change – to consumer extension point implementations and allows us to be intentional about which pieces of state extension points have access to. This approach generally works well when extension points need access to things like the current ES|QL query or data view, time range, columns, etc. However, this does not provide a solution for consumers to manage custom shared state between their extension point implementations.
|
||||
|
||||
In cases where the state for an extension point implementation is local to that implementation, consumers can simply manage the state within the corresponding profile method or returned React component:
|
||||
|
||||
```tsx
|
||||
// Extension point implementation definition
|
||||
const getCellRenderers = (prev) => (params) => {
|
||||
// Declare shared state within the profile method closure
|
||||
const blueOrRed$ = new BehaviorSubject<'blue' | 'red'>('blue');
|
||||
|
||||
return {
|
||||
...prev(params),
|
||||
foo: function FooComponent() {
|
||||
// It's still in scope and can be easily accessed...
|
||||
const blueOrRed = useObservable(blueOrRed$, blueOrRed$.getValue());
|
||||
|
||||
return (
|
||||
// ...and modified...
|
||||
<button onClick={() => blueOrRed$.next(blueOrRed === 'blue' ? 'red' : 'blue')}>
|
||||
Click to make bar {blueOrRed === 'blue' ? 'red' : 'blue'}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
bar: function BarComponent() {
|
||||
const blueOrRed = useObservable(blueOrRed$, blueOrRed$.getValue());
|
||||
|
||||
// ...and we can react to the changes
|
||||
return <span style={{ color: blueOrRed }}>Look ma, I'm {blueOrRed}!</span>;
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
For more advanced use cases, such as when state needs to be shared across extension point implementations, we provide an extension point called `getRenderAppWrapper`. The app wrapper extension point allows consumers to wrap the Discover root in a custom wrapper component, such as a React context provider. With this approach consumers can handle things like integrating with a state management library, accessing custom services from within their extension point implementations, managing shared components such as flyouts, etc. in a React-friendly way and without needing to work around the context awareness framework:
|
||||
|
||||
```tsx
|
||||
// The app wrapper extension point supports common patterns like React context
|
||||
const flyoutContext = createContext({ setFlyoutOpen: (open: boolean) => {} });
|
||||
|
||||
// App wrapper implementations can only exist at the root level, and their lifecycle will match the Discover lifecycle
|
||||
export const createSecurityRootProfileProvider = (): RootProfileProvider => ({
|
||||
profileId: 'security-root-profile',
|
||||
profile: {
|
||||
// The app wrapper extension point implementation
|
||||
getRenderAppWrapper: (PrevWrapper) =>
|
||||
function AppWrapper({ children }) {
|
||||
// Now we can declare state high up in the React tree
|
||||
const [flyoutOpen, setFlyoutOpen] = useState(false);
|
||||
|
||||
return (
|
||||
// Be sure to render the previous wrapper as well
|
||||
<PrevWrapper>
|
||||
// This is our wrapper -- it uses React context to give extension point implementations
|
||||
access to the shared state
|
||||
<flyoutContext.Provider value={{ setFlyoutOpen }}>
|
||||
// Make sure to render `children`, which is the Discover app
|
||||
{children}
|
||||
// Now extension point implementations can interact with shared state managed higher
|
||||
up in the tree
|
||||
{flyoutOpen && (
|
||||
<EuiFlyout onClose={() => setFlyoutOpen(false)}>
|
||||
Check it out, I'm a flyout!
|
||||
</EuiFlyout>
|
||||
)}
|
||||
</flyoutContext.Provider>
|
||||
</PrevWrapper>
|
||||
);
|
||||
},
|
||||
// Some other extension point implementation that depends on the shared state
|
||||
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
|
||||
const { setFlyoutOpen } = useContext(flyoutContext);
|
||||
|
||||
return <button onClick={() => setFlyoutOpen(true)}>Click me to open a flyout!</button>;
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolve: (params) => {
|
||||
if (params.solutionNavId === SolutionType.Security) {
|
||||
return {
|
||||
isMatch: true,
|
||||
context: { solutionType: SolutionType.Security },
|
||||
};
|
||||
}
|
||||
|
||||
return { isMatch: false };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 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 it’s done within the Observability Solution, the Search Solution, or the classic on-prem experience.
|
||||
|
||||
We’re aiming to make these profiles generic enough that they don’t obstruct Solution workflows or create confusion, but there will always be some complexity around juggling the various Discover use cases. For situations where Solution teams are confident some common profile feature will not be helpful to their users or will create confusion, there is an option to override these defaults while keeping the remainder of the functionality for the target profile intact. To do so a Solution team would follow these steps:
|
||||
|
||||
- Create and register a Solution specific root profile provider, e.g. `SecurityRootProfileProvider`.
|
||||
- Identify the contextual feature you want to override and the common profile provider it belongs to, e.g. the `getDocViewer` implementation in the common `LogsDataSourceProfileProvider`.
|
||||
- Implement a Solution specific version of the profile provider that extends the common provider as its base (using the `extendProfileProvider` utility), and excludes the extension point implementations you don’t want, e.g. `SecurityLogsDataSourceProfileProvider`. Other than the excluded extension point implementations, the only required change is to update its `resolve` method to first check the `rootContext.solutionType` for the target solution type before executing the base provider’s `resolve` method. This will ensure the override profile only resolves for the specific Solution, and will fall back to the common profile in other Solutions.
|
||||
- Register the Solution specific version of the profile provider in Discover, ensuring it precedes the common provider in the registration array. The ordering here is important since the Solution specific profile should attempt to resolve first, otherwise the common profile would be resolved instead.
|
||||
|
||||
This is how an example implementation would work in code:
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* profile_providers/security/security_root_profile/profile.tsx
|
||||
*/
|
||||
|
||||
// Create a solution specific root profile provider
|
||||
export const createSecurityRootProfileProvider = (): RootProfileProvider => ({
|
||||
profileId: 'security-root-profile',
|
||||
profile: {},
|
||||
resolve: (params) => {
|
||||
if (params.solutionNavId === SolutionType.Security) {
|
||||
return {
|
||||
isMatch: true,
|
||||
context: { solutionType: SolutionType.Security },
|
||||
};
|
||||
}
|
||||
|
||||
return { isMatch: false };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* profile_providers/security/security_logs_data_source_profile/profile.tsx
|
||||
*/
|
||||
|
||||
// Create a solution specific data source profile provider that extends a target base provider
|
||||
export const createSecurityLogsDataSourceProfileProivder = (
|
||||
logsDataSourceProfileProvider: DataSourceProfileProvider
|
||||
): DataSourceProfileProvider =>
|
||||
// Extend the base profile provider with `extendProfileProvider`
|
||||
extendProfileProvider(logsDataSourceProfileProvider, {
|
||||
profileId: 'security-logs-data-source-profile',
|
||||
profile: {
|
||||
// Completely remove a specific extension point implementation
|
||||
getDocViewer: undefined,
|
||||
// Modify the result of an existing extension point implementation
|
||||
getCellRenderers: (prev) => (params) => {
|
||||
// Retrieve and execute the base implementation
|
||||
const baseImpl = logsDataSourceProfileProvider.profile.getCellRenderers?.(prev);
|
||||
const baseRenderers = baseImpl?.(params);
|
||||
|
||||
// Return the modified result
|
||||
return omit(baseRenderers, 'log.level');
|
||||
},
|
||||
},
|
||||
// Customize the `resolve` implementation
|
||||
resolve: (params) => {
|
||||
// Only match this profile when in the target solution context
|
||||
if (params.rootContext.solutionType !== SolutionType.Security) {
|
||||
return { isMatch: false };
|
||||
}
|
||||
|
||||
// Delegate to the base implementation
|
||||
return logsDataSourceProfileProvider.resolve(params);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* profile_providers/register_profile_providers.ts
|
||||
*/
|
||||
|
||||
// Register root profile providers
|
||||
const createRootProfileProviders = (providerServices: ProfileProviderServices) => [
|
||||
// Register the solution specific root profile provider
|
||||
createSecurityRootProfileProvider(),
|
||||
];
|
||||
|
||||
// Register data source profile providers
|
||||
const createDataSourceProfileProviders = (providerServices: ProfileProviderServices) => {
|
||||
// Instantiate the data source profile provider base implementation
|
||||
const logsDataSourceProfileProvider = createLogsDataSourceProfileProvider(providerServices);
|
||||
|
||||
return [
|
||||
// Ensure the solution specific override is registered and resolved first
|
||||
createSecurityLogsDataSourceProfileProivder(logsDataSourceProfileProvider),
|
||||
// Then register the base implementation
|
||||
logsDataSourceProfileProvider,
|
||||
];
|
||||
};
|
||||
```
|
||||
|
|
|
@ -8,5 +8,5 @@
|
|||
*/
|
||||
|
||||
export { useProfileAccessor } from './use_profile_accessor';
|
||||
export { useRootProfile } from './use_root_profile';
|
||||
export { useRootProfile, BaseAppWrapper } from './use_root_profile';
|
||||
export { useAdditionalCellActions } from './use_additional_cell_actions';
|
||||
|
|
|
@ -8,13 +8,20 @@
|
|||
*/
|
||||
|
||||
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 { useRootProfile } from './use_root_profile';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
const mockSolutionNavId$ = new BehaviorSubject('solutionNavId');
|
||||
|
||||
jest
|
||||
.spyOn(discoverServiceMock.core.chrome, 'getActiveSolutionNavId$')
|
||||
.mockReturnValue(mockSolutionNavId$);
|
||||
|
||||
const render = () => {
|
||||
return renderHook((props) => useRootProfile(props), {
|
||||
return renderHook(() => useRootProfile(), {
|
||||
initialProps: { solutionNavId: 'solutionNavId' } as React.PropsWithChildren<{
|
||||
solutionNavId: string;
|
||||
}>,
|
||||
|
@ -25,24 +32,36 @@ const render = () => {
|
|||
};
|
||||
|
||||
describe('useRootProfile', () => {
|
||||
it('should return rootProfileLoading as true', () => {
|
||||
const { result } = render();
|
||||
beforeEach(() => {
|
||||
mockSolutionNavId$.next('solutionNavId');
|
||||
});
|
||||
|
||||
it('should return rootProfileLoading as true', async () => {
|
||||
const { result, waitForNextUpdate } = render();
|
||||
expect(result.current.rootProfileLoading).toBe(true);
|
||||
expect((result.current as Record<string, unknown>).AppWrapper).toBeUndefined();
|
||||
// avoid act warning
|
||||
await waitForNextUpdate();
|
||||
});
|
||||
|
||||
it('should return rootProfileLoading as false', async () => {
|
||||
const { result, waitForNextUpdate } = render();
|
||||
await waitForNextUpdate();
|
||||
expect(result.current.rootProfileLoading).toBe(false);
|
||||
expect((result.current as Record<string, unknown>).AppWrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return rootProfileLoading as true when solutionNavId changes', async () => {
|
||||
const { result, rerender, waitForNextUpdate } = render();
|
||||
await waitForNextUpdate();
|
||||
expect(result.current.rootProfileLoading).toBe(false);
|
||||
rerender({ solutionNavId: 'newSolutionNavId' });
|
||||
expect((result.current as Record<string, unknown>).AppWrapper).toBeDefined();
|
||||
act(() => mockSolutionNavId$.next('newSolutionNavId'));
|
||||
rerender();
|
||||
expect(result.current.rootProfileLoading).toBe(true);
|
||||
expect((result.current as Record<string, unknown>).AppWrapper).toBeUndefined();
|
||||
await waitForNextUpdate();
|
||||
expect(result.current.rootProfileLoading).toBe(false);
|
||||
expect((result.current as Record<string, unknown>).AppWrapper).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDiscoverServices } from '../../hooks/use_discover_services';
|
||||
|
||||
/**
|
||||
* Hook to trigger and wait for root profile resolution
|
||||
* @param options Options object
|
||||
* @returns If the root profile is loading
|
||||
*/
|
||||
export const useRootProfile = ({ solutionNavId }: { solutionNavId: string | null }) => {
|
||||
const { profilesManager } = useDiscoverServices();
|
||||
const [rootProfileLoading, setRootProfileLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false;
|
||||
|
||||
setRootProfileLoading(true);
|
||||
|
||||
profilesManager.resolveRootProfile({ solutionNavId }).then(() => {
|
||||
if (!aborted) {
|
||||
setRootProfileLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
};
|
||||
}, [profilesManager, solutionNavId]);
|
||||
|
||||
return { rootProfileLoading };
|
||||
};
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { distinctUntilChanged, filter, switchMap, tap } from 'rxjs';
|
||||
import React from 'react';
|
||||
import { useDiscoverServices } from '../../hooks/use_discover_services';
|
||||
import type { Profile } from '../types';
|
||||
|
||||
/**
|
||||
* Hook to trigger and wait for root profile resolution
|
||||
* @param options Options object
|
||||
* @returns If the root profile is loading
|
||||
*/
|
||||
export const useRootProfile = () => {
|
||||
const { profilesManager, core } = useDiscoverServices();
|
||||
const [rootProfileState, setRootProfileState] = useState<
|
||||
| { rootProfileLoading: true }
|
||||
| { rootProfileLoading: false; AppWrapper: Profile['getRenderAppWrapper'] }
|
||||
>({ rootProfileLoading: true });
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = core.chrome
|
||||
.getActiveSolutionNavId$()
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
filter((id) => id !== undefined),
|
||||
tap(() => setRootProfileState({ rootProfileLoading: true })),
|
||||
switchMap((id) => profilesManager.resolveRootProfile({ solutionNavId: id })),
|
||||
tap(({ getRenderAppWrapper }) =>
|
||||
setRootProfileState({
|
||||
rootProfileLoading: false,
|
||||
AppWrapper: getRenderAppWrapper?.(BaseAppWrapper) ?? BaseAppWrapper,
|
||||
})
|
||||
)
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [core.chrome, profilesManager]);
|
||||
|
||||
return rootProfileState;
|
||||
};
|
||||
|
||||
export const BaseAppWrapper: Profile['getRenderAppWrapper'] = ({ children }) => <>{children}</>;
|
|
@ -11,4 +11,9 @@ export * from './types';
|
|||
export * from './profiles';
|
||||
export { getMergedAccessor } from './composable_profile';
|
||||
export { ProfilesManager } from './profiles_manager';
|
||||
export { useProfileAccessor, useRootProfile, useAdditionalCellActions } from './hooks';
|
||||
export {
|
||||
useProfileAccessor,
|
||||
useRootProfile,
|
||||
useAdditionalCellActions,
|
||||
BaseAppWrapper,
|
||||
} from './hooks';
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
const exampleContext = createContext<{
|
||||
currentMessage: string | undefined;
|
||||
setCurrentMessage: (message: string | undefined) => void;
|
||||
}>({
|
||||
currentMessage: undefined,
|
||||
setCurrentMessage: () => {},
|
||||
});
|
||||
|
||||
export const ExampleContextProvider = exampleContext.Provider;
|
||||
|
||||
export const useExampleContext = () => useContext(exampleContext);
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { EuiBadge, EuiFlyout } from '@elastic/eui';
|
||||
import { EuiBadge, EuiLink, EuiFlyout } from '@elastic/eui';
|
||||
import {
|
||||
AppMenuActionId,
|
||||
AppMenuActionType,
|
||||
|
@ -21,6 +21,7 @@ import { capitalize } from 'lodash';
|
|||
import React from 'react';
|
||||
import { DataSourceType, isDataSourceType } from '../../../../../common/data_sources';
|
||||
import { DataSourceCategory, DataSourceProfileProvider } from '../../../profiles';
|
||||
import { useExampleContext } from '../example_context';
|
||||
|
||||
export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvider => ({
|
||||
profileId: 'example-data-source-profile',
|
||||
|
@ -58,6 +59,20 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi
|
|||
</EuiBadge>
|
||||
);
|
||||
},
|
||||
message: function Message(props) {
|
||||
const { currentMessage, setCurrentMessage } = useExampleContext();
|
||||
const message = getFieldValue(props.row, 'message') as string;
|
||||
|
||||
return (
|
||||
<EuiLink
|
||||
onClick={() => setCurrentMessage(message)}
|
||||
css={{ fontWeight: currentMessage === message ? 'bold' : undefined }}
|
||||
data-test-subj="exampleDataSourceProfileMessage"
|
||||
>
|
||||
{message}
|
||||
</EuiLink>
|
||||
);
|
||||
},
|
||||
}),
|
||||
getDocViewer: (prev) => (params) => {
|
||||
const recordId = params.record.id;
|
||||
|
|
|
@ -7,4 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { createExampleRootProfileProvider } from './profile';
|
||||
export {
|
||||
createExampleRootProfileProvider,
|
||||
createExampleSolutionViewRootProfileProvider,
|
||||
} from './profile';
|
|
@ -7,15 +7,24 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { EuiBadge, EuiFlyout } from '@elastic/eui';
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiCodeBlock,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { AppMenuActionType, getFieldValue } from '@kbn/discover-utils';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { RootProfileProvider, SolutionType } from '../../../profiles';
|
||||
import { ExampleContextProvider } from '../example_context';
|
||||
|
||||
export const createExampleRootProfileProvider = (): RootProfileProvider => ({
|
||||
profileId: 'example-root-profile',
|
||||
isExperimental: true,
|
||||
profile: {
|
||||
getRenderAppWrapper,
|
||||
getCellRenderers: (prev) => (params) => ({
|
||||
...prev(params),
|
||||
'@timestamp': (props) => {
|
||||
|
@ -99,3 +108,46 @@ export const createExampleRootProfileProvider = (): RootProfileProvider => ({
|
|||
return { isMatch: true, context: { solutionType: SolutionType.Default } };
|
||||
},
|
||||
});
|
||||
|
||||
export const createExampleSolutionViewRootProfileProvider = (): RootProfileProvider => ({
|
||||
profileId: 'example-solution-view-root-profile',
|
||||
isExperimental: true,
|
||||
profile: { getRenderAppWrapper },
|
||||
resolve: (params) => ({
|
||||
isMatch: true,
|
||||
context: { solutionType: params.solutionNavId as SolutionType },
|
||||
}),
|
||||
});
|
||||
|
||||
const getRenderAppWrapper: RootProfileProvider['profile']['getRenderAppWrapper'] =
|
||||
(PrevWrapper) =>
|
||||
({ children }) => {
|
||||
const [currentMessage, setCurrentMessage] = useState<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<PrevWrapper>
|
||||
<ExampleContextProvider value={{ currentMessage, setCurrentMessage }}>
|
||||
{children}
|
||||
{currentMessage && (
|
||||
<EuiFlyout
|
||||
type="push"
|
||||
maxWidth={500}
|
||||
onClose={() => setCurrentMessage(undefined)}
|
||||
data-test-subj="exampleRootProfileFlyout"
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2>Inspect message</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiCodeBlock isCopyable data-test-subj="exampleRootProfileCurrentMessage">
|
||||
{currentMessage}
|
||||
</EuiCodeBlock>
|
||||
</EuiFlyoutBody>
|
||||
</EuiFlyout>
|
||||
)}
|
||||
</ExampleContextProvider>
|
||||
</PrevWrapper>
|
||||
);
|
||||
};
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import { createEsqlDataSource } from '../../../common/data_sources';
|
||||
import { createContextAwarenessMocks } from '../__mocks__';
|
||||
import { createExampleRootProfileProvider } from './example/example_root_pofile';
|
||||
import { createExampleRootProfileProvider } from './example/example_root_profile';
|
||||
import { createExampleDataSourceProfileProvider } from './example/example_data_source_profile/profile';
|
||||
import { createExampleDocumentProfileProvider } from './example/example_document_profile';
|
||||
|
||||
|
|
|
@ -15,7 +15,10 @@ import type {
|
|||
import type { BaseProfileProvider, BaseProfileService } from '../profile_service';
|
||||
import { createExampleDataSourceProfileProvider } from './example/example_data_source_profile/profile';
|
||||
import { createExampleDocumentProfileProvider } from './example/example_document_profile';
|
||||
import { createExampleRootProfileProvider } from './example/example_root_pofile';
|
||||
import {
|
||||
createExampleSolutionViewRootProfileProvider,
|
||||
createExampleRootProfileProvider,
|
||||
} from './example/example_root_profile';
|
||||
import { createLogsDataSourceProfileProviders } from './common/logs_data_source_profile';
|
||||
import { createLogDocumentProfileProvider } from './common/log_document_profile';
|
||||
import { createSecurityRootProfileProvider } from './security/security_root_profile';
|
||||
|
@ -117,6 +120,7 @@ export const registerEnabledProfileProviders = <
|
|||
*/
|
||||
const createRootProfileProviders = (providerServices: ProfileProviderServices) => [
|
||||
createExampleRootProfileProvider(),
|
||||
createExampleSolutionViewRootProfileProvider(),
|
||||
createSecurityRootProfileProvider(providerServices),
|
||||
];
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@ export enum DataSourceCategory {
|
|||
/**
|
||||
* The data source profile interface
|
||||
*/
|
||||
export type DataSourceProfile = Profile;
|
||||
export type DataSourceProfile = Omit<Profile, 'getRenderAppWrapper'>;
|
||||
|
||||
/**
|
||||
* Parameters for the data source profile provider `resolve` method
|
||||
|
|
|
@ -25,7 +25,7 @@ import type {
|
|||
DocumentContext,
|
||||
} from './profiles';
|
||||
import type { ContextWithProfileId } from './profile_service';
|
||||
import { DiscoverEBTManager } from '../services/discover_ebt_manager';
|
||||
import type { DiscoverEBTManager } from '../services/discover_ebt_manager';
|
||||
|
||||
interface SerializedRootProfileParams {
|
||||
solutionNavId: RootProfileProviderParams['solutionNavId'];
|
||||
|
@ -79,7 +79,7 @@ export class ProfilesManager {
|
|||
const serializedParams = serializeRootProfileParams(params);
|
||||
|
||||
if (isEqual(this.prevRootProfileParams, serializedParams)) {
|
||||
return;
|
||||
return { getRenderAppWrapper: this.getRootRenderAppWrapper() };
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
@ -95,11 +95,13 @@ export class ProfilesManager {
|
|||
}
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
return { getRenderAppWrapper: this.getRootRenderAppWrapper() };
|
||||
}
|
||||
|
||||
this.rootContext$.next(context);
|
||||
this.prevRootProfileParams = serializedParams;
|
||||
|
||||
return { getRenderAppWrapper: this.getRootRenderAppWrapper() };
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -208,6 +210,11 @@ export class ProfilesManager {
|
|||
|
||||
this.ebtManager.updateProfilesContextWith(dscProfiles);
|
||||
}
|
||||
|
||||
private getRootRenderAppWrapper() {
|
||||
const rootProfile = this.rootProfileService.getProfile(this.rootContext$.getValue());
|
||||
return rootProfile.getRenderAppWrapper;
|
||||
}
|
||||
}
|
||||
|
||||
const serializeRootProfileParams = (
|
||||
|
|
|
@ -20,10 +20,11 @@ import type { EuiIconType } from '@elastic/eui/src/components/icon/icon';
|
|||
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import type { OmitIndexSignature } from 'type-fest';
|
||||
import type { Trigger } from '@kbn/ui-actions-plugin/public';
|
||||
import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
|
||||
import type { DiscoverDataSource } from '../../common/data_sources';
|
||||
import type { DiscoverAppState } from '../application/main/state_management/discover_app_state_container';
|
||||
import { DiscoverStateContainer } from '../application/main/state_management/discover_state';
|
||||
import type { DiscoverStateContainer } from '../application/main/state_management/discover_state';
|
||||
|
||||
/**
|
||||
* Supports extending the Discover app menu
|
||||
|
@ -257,6 +258,14 @@ export interface Profile {
|
|||
* Lifecycle
|
||||
*/
|
||||
|
||||
/**
|
||||
* Render a custom wrapper component around the Discover application,
|
||||
* e.g. to allow using profile specific context providers
|
||||
* @param props The app wrapper props
|
||||
* @returns The custom app wrapper component
|
||||
*/
|
||||
getRenderAppWrapper: (props: PropsWithChildren<{}>) => ReactElement;
|
||||
|
||||
/**
|
||||
* Gets default Discover app state that should be used when the profile is resolved
|
||||
* @param params The default app state extension parameters
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
import { DiscoverCustomizationContext } from './types';
|
||||
|
||||
export const defaultCustomizationContext: DiscoverCustomizationContext = {
|
||||
solutionNavId: null,
|
||||
displayMode: 'standalone',
|
||||
inlineTopNav: {
|
||||
enabled: false,
|
||||
|
|
|
@ -22,10 +22,6 @@ export type CustomizationCallback = (
|
|||
export type DiscoverDisplayMode = 'embedded' | 'standalone';
|
||||
|
||||
export interface DiscoverCustomizationContext {
|
||||
/**
|
||||
* The current solution nav ID
|
||||
*/
|
||||
solutionNavId: string | null;
|
||||
/*
|
||||
* Display mode in which discover is running
|
||||
*/
|
||||
|
|
|
@ -238,7 +238,7 @@ describe('saved search embeddable', () => {
|
|||
await waitOneTick(); // wait for build to complete
|
||||
|
||||
expect(resolveRootProfileSpy).toHaveBeenCalledWith({ solutionNavId: 'test' });
|
||||
resolveRootProfileSpy.mockReset();
|
||||
resolveRootProfileSpy.mockClear();
|
||||
expect(resolveRootProfileSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
@ -42,6 +42,7 @@ import {
|
|||
SearchEmbeddableSerializedState,
|
||||
} from './types';
|
||||
import { deserializeState, serializeState } from './utils/serialization_utils';
|
||||
import { BaseAppWrapper } from '../context_awareness';
|
||||
|
||||
export const getSearchEmbeddableFactory = ({
|
||||
startServices,
|
||||
|
@ -69,7 +70,10 @@ export const getSearchEmbeddableFactory = ({
|
|||
const solutionNavId = await firstValueFrom(
|
||||
discoverServices.core.chrome.getActiveSolutionNavId$()
|
||||
);
|
||||
await discoverServices.profilesManager.resolveRootProfile({ solutionNavId });
|
||||
const { getRenderAppWrapper } = await discoverServices.profilesManager.resolveRootProfile({
|
||||
solutionNavId,
|
||||
});
|
||||
const AppWrapper = getRenderAppWrapper?.(BaseAppWrapper) ?? BaseAppWrapper;
|
||||
|
||||
/** Specific by-reference state */
|
||||
const savedObjectId$ = new BehaviorSubject<string | undefined>(initialState?.savedObjectId);
|
||||
|
@ -280,30 +284,32 @@ export const getSearchEmbeddableFactory = ({
|
|||
return (
|
||||
<KibanaRenderContextProvider {...discoverServices.core}>
|
||||
<KibanaContextProvider services={discoverServices}>
|
||||
{renderAsFieldStatsTable ? (
|
||||
<SearchEmbeddablFieldStatsTableComponent
|
||||
api={{
|
||||
...api,
|
||||
fetchContext$,
|
||||
}}
|
||||
dataView={dataView!}
|
||||
onAddFilter={isEsqlMode(savedSearch) ? undefined : onAddFilter}
|
||||
stateManager={searchEmbeddable.stateManager}
|
||||
/>
|
||||
) : (
|
||||
<CellActionsProvider
|
||||
getTriggerCompatibleActions={
|
||||
discoverServices.uiActions.getTriggerCompatibleActions
|
||||
}
|
||||
>
|
||||
<SearchEmbeddableGridComponent
|
||||
api={{ ...api, fetchWarnings$, fetchContext$ }}
|
||||
<AppWrapper>
|
||||
{renderAsFieldStatsTable ? (
|
||||
<SearchEmbeddablFieldStatsTableComponent
|
||||
api={{
|
||||
...api,
|
||||
fetchContext$,
|
||||
}}
|
||||
dataView={dataView!}
|
||||
onAddFilter={isEsqlMode(savedSearch) ? undefined : onAddFilter}
|
||||
stateManager={searchEmbeddable.stateManager}
|
||||
/>
|
||||
</CellActionsProvider>
|
||||
)}
|
||||
) : (
|
||||
<CellActionsProvider
|
||||
getTriggerCompatibleActions={
|
||||
discoverServices.uiActions.getTriggerCompatibleActions
|
||||
}
|
||||
>
|
||||
<SearchEmbeddableGridComponent
|
||||
api={{ ...api, fetchWarnings$, fetchContext$ }}
|
||||
dataView={dataView!}
|
||||
onAddFilter={isEsqlMode(savedSearch) ? undefined : onAddFilter}
|
||||
stateManager={searchEmbeddable.stateManager}
|
||||
/>
|
||||
</CellActionsProvider>
|
||||
)}
|
||||
</AppWrapper>
|
||||
</KibanaContextProvider>
|
||||
</KibanaRenderContextProvider>
|
||||
);
|
||||
|
|
|
@ -213,7 +213,6 @@ export class DiscoverPlugin
|
|||
.pipe(
|
||||
map((solutionNavId) => ({
|
||||
...defaultCustomizationContext,
|
||||
solutionNavId,
|
||||
inlineTopNav:
|
||||
this.inlineTopNav.get(solutionNavId) ??
|
||||
this.inlineTopNav.get(null) ??
|
||||
|
|
|
@ -25,7 +25,12 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
...baseConfig.kbnTestServer,
|
||||
serverArgs: [
|
||||
...baseConfig.kbnTestServer.serverArgs,
|
||||
'--discover.experimental.enabledProfiles=["example-root-profile","example-data-source-profile","example-document-profile"]',
|
||||
`--discover.experimental.enabledProfiles=${JSON.stringify([
|
||||
'example-root-profile',
|
||||
'example-solution-view-root-profile',
|
||||
'example-data-source-profile',
|
||||
'example-document-profile',
|
||||
])}`,
|
||||
`--plugin-path=${path.resolve(
|
||||
__dirname,
|
||||
'../../../../analytics/plugins/analytics_ftr_helpers'
|
||||
|
|
|
@ -0,0 +1,171 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import kbnRison from '@kbn/rison';
|
||||
import expect from '@kbn/expect';
|
||||
import type { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const { common, discover, header, unifiedFieldList, dashboard } = getPageObjects([
|
||||
'common',
|
||||
'discover',
|
||||
'header',
|
||||
'unifiedFieldList',
|
||||
'dashboard',
|
||||
]);
|
||||
const testSubjects = getService('testSubjects');
|
||||
const dataViews = getService('dataViews');
|
||||
const dataGrid = getService('dataGrid');
|
||||
const browser = getService('browser');
|
||||
const retry = getService('retry');
|
||||
const dashboardAddPanel = getService('dashboardAddPanel');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
|
||||
describe('extension getRenderAppWrapper', () => {
|
||||
after(async () => {
|
||||
await kibanaServer.savedObjects.clean({ types: ['search'] });
|
||||
});
|
||||
|
||||
describe('ES|QL mode', () => {
|
||||
it('should allow clicking message cells to inspect the message', 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 unifiedFieldList.clickFieldListItemAdd('message');
|
||||
let messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
let message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is a debug log');
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is an error log');
|
||||
});
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
await testSubjects.missingOrFail('exampleRootProfileFlyout');
|
||||
|
||||
// check Dashboard page
|
||||
await discover.saveSearch('ES|QL app wrapper test');
|
||||
await dashboard.navigateToApp();
|
||||
await dashboard.gotoDashboardLandingPage();
|
||||
await dashboard.clickNewDashboard();
|
||||
await dashboardAddPanel.addSavedSearch('ES|QL app wrapper test');
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await dashboard.waitForRenderComplete();
|
||||
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is a debug log');
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is an error log');
|
||||
});
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
await testSubjects.missingOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data view mode', () => {
|
||||
it('should allow clicking message cells to inspect the message', async () => {
|
||||
await common.navigateToActualUrl('discover', undefined, {
|
||||
ensureCurrentUrl: false,
|
||||
});
|
||||
await dataViews.switchTo('my-example-logs');
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await unifiedFieldList.clickFieldListItemAdd('message');
|
||||
let messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
let message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is a debug log');
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is an error log');
|
||||
});
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
await testSubjects.missingOrFail('exampleRootProfileFlyout');
|
||||
|
||||
// check Surrounding docs page
|
||||
await dataGrid.clickRowToggle();
|
||||
const [, surroundingActionEl] = await dataGrid.getRowActions();
|
||||
await surroundingActionEl.click();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await browser.refresh();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is a debug log');
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is an error log');
|
||||
});
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
await testSubjects.missingOrFail('exampleRootProfileFlyout');
|
||||
await browser.goBack();
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
|
||||
// check Dashboard page
|
||||
await discover.saveSearch('Data view app wrapper test');
|
||||
await dashboard.navigateToApp();
|
||||
await dashboard.gotoDashboardLandingPage();
|
||||
await dashboard.clickNewDashboard();
|
||||
await dashboardAddPanel.addSavedSearch('Data view app wrapper test');
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await dashboard.waitForRenderComplete();
|
||||
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is a debug log');
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is an error log');
|
||||
});
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
await testSubjects.missingOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -46,5 +46,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid
|
|||
loadTestFile(require.resolve('./extensions/_get_default_app_state'));
|
||||
loadTestFile(require.resolve('./extensions/_get_additional_cell_actions'));
|
||||
loadTestFile(require.resolve('./extensions/_get_app_menu'));
|
||||
loadTestFile(require.resolve('./extensions/_get_render_app_wrapper'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
* 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 kbnRison from '@kbn/rison';
|
||||
import expect from '@kbn/expect';
|
||||
import type { FtrProviderContext } from '../../../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const { common, discover, header, unifiedFieldList, dashboard, svlCommonPage } = getPageObjects([
|
||||
'common',
|
||||
'discover',
|
||||
'header',
|
||||
'unifiedFieldList',
|
||||
'dashboard',
|
||||
'svlCommonPage',
|
||||
]);
|
||||
const testSubjects = getService('testSubjects');
|
||||
const dataViews = getService('dataViews');
|
||||
const dataGrid = getService('dataGrid');
|
||||
const browser = getService('browser');
|
||||
const retry = getService('retry');
|
||||
const dashboardAddPanel = getService('dashboardAddPanel');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
|
||||
describe('extension getRenderAppWrapper', () => {
|
||||
before(async () => {
|
||||
await svlCommonPage.loginAsAdmin();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await kibanaServer.savedObjects.clean({ types: ['search'] });
|
||||
});
|
||||
|
||||
describe('ES|QL mode', () => {
|
||||
it('should allow clicking message cells to inspect the message', 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 unifiedFieldList.clickFieldListItemAdd('message');
|
||||
let messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
let message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is a debug log');
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is an error log');
|
||||
});
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
await testSubjects.missingOrFail('exampleRootProfileFlyout');
|
||||
|
||||
// check Dashboard page
|
||||
await discover.saveSearch('ES|QL app wrapper test');
|
||||
await dashboard.navigateToApp();
|
||||
await dashboard.gotoDashboardLandingPage();
|
||||
await dashboard.clickNewDashboard();
|
||||
await dashboardAddPanel.addSavedSearch('ES|QL app wrapper test');
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await dashboard.waitForRenderComplete();
|
||||
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is a debug log');
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is an error log');
|
||||
});
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
await testSubjects.missingOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data view mode', () => {
|
||||
it('should allow clicking message cells to inspect the message', async () => {
|
||||
await common.navigateToActualUrl('discover', undefined, {
|
||||
ensureCurrentUrl: false,
|
||||
});
|
||||
await dataViews.switchTo('my-example-logs');
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await unifiedFieldList.clickFieldListItemAdd('message');
|
||||
let messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
let message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is a debug log');
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is an error log');
|
||||
});
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
await testSubjects.missingOrFail('exampleRootProfileFlyout');
|
||||
|
||||
// check Surrounding docs page
|
||||
await dataGrid.clickRowToggle();
|
||||
const [, surroundingActionEl] = await dataGrid.getRowActions();
|
||||
await surroundingActionEl.click();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await browser.refresh();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is a debug log');
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is an error log');
|
||||
});
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
await testSubjects.missingOrFail('exampleRootProfileFlyout');
|
||||
await browser.goBack();
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
|
||||
// check Dashboard page
|
||||
await discover.saveSearch('Data view app wrapper test');
|
||||
await dashboard.navigateToApp();
|
||||
await dashboard.gotoDashboardLandingPage();
|
||||
await dashboard.clickNewDashboard();
|
||||
await dashboardAddPanel.addSavedSearch('Data view app wrapper test');
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await dashboard.waitForRenderComplete();
|
||||
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(0, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is a debug log');
|
||||
messageCell = await dataGrid.getCellElementExcludingControlColumns(1, 2);
|
||||
await retry.try(async () => {
|
||||
await (await messageCell.findByTestSubject('exampleDataSourceProfileMessage')).click();
|
||||
await testSubjects.existOrFail('exampleRootProfileFlyout');
|
||||
message = await testSubjects.find('exampleRootProfileCurrentMessage');
|
||||
expect(await message.getVisibleText()).to.be('This is an error log');
|
||||
});
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
await testSubjects.missingOrFail('exampleRootProfileFlyout');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -44,5 +44,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid
|
|||
loadTestFile(require.resolve('./extensions/_get_default_app_state'));
|
||||
loadTestFile(require.resolve('./extensions/_get_additional_cell_actions'));
|
||||
loadTestFile(require.resolve('./extensions/_get_app_menu'));
|
||||
loadTestFile(require.resolve('./extensions/_get_render_app_wrapper'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -14,7 +14,12 @@ export default createTestConfig({
|
|||
reportName: 'Serverless Observability Discover Context Awareness Functional Tests',
|
||||
},
|
||||
kbnServerArgs: [
|
||||
'--discover.experimental.enabledProfiles=["example-root-profile","example-data-source-profile","example-document-profile"]',
|
||||
`--discover.experimental.enabledProfiles=${JSON.stringify([
|
||||
'example-root-profile',
|
||||
'example-solution-view-root-profile',
|
||||
'example-data-source-profile',
|
||||
'example-document-profile',
|
||||
])}`,
|
||||
],
|
||||
// include settings from project controller
|
||||
// https://github.com/elastic/project-controller/blob/main/internal/project/observability/config/elasticsearch.yml
|
||||
|
|
|
@ -14,7 +14,12 @@ export default createTestConfig({
|
|||
reportName: 'Serverless Search Discover Context Awareness Functional Tests',
|
||||
},
|
||||
kbnServerArgs: [
|
||||
'--discover.experimental.enabledProfiles=["example-root-profile","example-data-source-profile","example-document-profile"]',
|
||||
`--discover.experimental.enabledProfiles=${JSON.stringify([
|
||||
'example-root-profile',
|
||||
'example-solution-view-root-profile',
|
||||
'example-data-source-profile',
|
||||
'example-document-profile',
|
||||
])}`,
|
||||
],
|
||||
// include settings from project controller
|
||||
// https://github.com/elastic/project-controller/blob/main/internal/project/observability/config/elasticsearch.yml
|
||||
|
|
|
@ -14,7 +14,12 @@ export default createTestConfig({
|
|||
reportName: 'Serverless Security Discover Context Awareness Functional Tests',
|
||||
},
|
||||
kbnServerArgs: [
|
||||
'--discover.experimental.enabledProfiles=["example-root-profile","example-data-source-profile","example-document-profile"]',
|
||||
`--discover.experimental.enabledProfiles=${JSON.stringify([
|
||||
'example-root-profile',
|
||||
'example-solution-view-root-profile',
|
||||
'example-data-source-profile',
|
||||
'example-document-profile',
|
||||
])}`,
|
||||
],
|
||||
// include settings from project controller
|
||||
// https://github.com/elastic/project-controller/blob/main/internal/project/observability/config/elasticsearch.yml
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue