diff --git a/.buildkite/pipeline-resource-definitions/kibana-package-registry.yml b/.buildkite/pipeline-resource-definitions/kibana-package-registry.yml new file mode 100644 index 0000000000000..392a511e22281 --- /dev/null +++ b/.buildkite/pipeline-resource-definitions/kibana-package-registry.yml @@ -0,0 +1,36 @@ +# yaml-language-server: $schema=https://gist.githubusercontent.com/elasticmachine/988b80dae436cafea07d9a4a460a011d/raw/rre.schema.json +apiVersion: backstage.io/v1alpha1 +kind: Resource +metadata: + name: bk-kibana-package-registry-promote + description: Promote package-registry/distribution:lite + links: + - url: 'https://buildkite.com/elastic/kibana-package-registry-promote' + title: Pipeline link +spec: + type: buildkite-pipeline + owner: 'group:kibana-operations' + system: buildkite + implementation: + apiVersion: buildkite.elastic.dev/v1 + kind: Pipeline + metadata: + name: kibana / package registry promote + description: Promote package-registry/distribution:lite + spec: + env: + SLACK_NOTIFICATIONS_CHANNEL: "#kibana-operations-alerts" + ELASTIC_SLACK_NOTIFICATIONS_ENABLED: "false" + repository: elastic/kibana + branch_configuration: main + default_branch: main + pipeline_file: ".buildkite/pipelines/fleet/package_registry.yml" + provider_settings: + trigger_mode: none + teams: + everyone: + access_level: BUILD_AND_READ + kibana-operations: + access_level: MANAGE_BUILD_AND_READ + tags: + - kibana diff --git a/.buildkite/pipeline-resource-definitions/locations.yml b/.buildkite/pipeline-resource-definitions/locations.yml index 7f96bff2b51b4..c88e37490eb43 100644 --- a/.buildkite/pipeline-resource-definitions/locations.yml +++ b/.buildkite/pipeline-resource-definitions/locations.yml @@ -27,6 +27,7 @@ spec: - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-migration-staging.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-on-merge-unsupported-ftrs.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-on-merge.yml + - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-package-registry.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-performance-daily.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-performance-data-set-extraction-daily.yml - https://github.com/elastic/kibana/blob/main/.buildkite/pipeline-resource-definitions/kibana-pointer-compression.yml diff --git a/.buildkite/pipelines/fleet/package_registry.yml b/.buildkite/pipelines/fleet/package_registry.yml new file mode 100644 index 0000000000000..52fc4f910713a --- /dev/null +++ b/.buildkite/pipelines/fleet/package_registry.yml @@ -0,0 +1,2 @@ +steps: + - command: echo "Placeholder" diff --git a/.eslintrc.js b/.eslintrc.js index 0e486a64c9440..f751c9692c996 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1026,7 +1026,9 @@ module.exports = { */ { files: ['x-pack/plugins/fleet/**/*.{js,mjs,ts,tsx}'], + plugins: ['testing-library'], rules: { + 'testing-library/await-async-utils': 'error', '@typescript-eslint/consistent-type-imports': 'error', 'import/order': [ 'warn', @@ -1954,6 +1956,16 @@ module.exports = { }, }, + /** + * Cloud Security Team overrides + */ + { + files: ['x-pack/plugins/cloud_security_posture/**/*.{js,mjs,ts,tsx}'], + plugins: ['testing-library'], + rules: { + 'testing-library/await-async-utils': 'error', + }, + }, /** * Code inside .buildkite runs separately from everything else in CI, before bootstrap, with ts-node. It needs a few tweaks because of this. */ diff --git a/dev_docs/tutorials/saved_objects.mdx b/dev_docs/tutorials/saved_objects.mdx index f93d774e663f3..233795374f2e9 100644 --- a/dev_docs/tutorials/saved_objects.mdx +++ b/dev_docs/tutorials/saved_objects.mdx @@ -18,7 +18,7 @@ import { SavedObjectsType } from 'src/core/server'; export const dashboardVisualization: SavedObjectsType = { name: 'dashboard_visualization', [1] - hidden: true, + hidden: true, [3] switchToModelVersionAt: '8.10.0', // this is the default, feel free to omit it unless you intend to switch to using model versions before 8.10.0 namespaceType: 'multiple-isolated', [2] mappings: { @@ -46,6 +46,9 @@ these should follow our API URL path convention and always be written in snake c that objects of this type can only exist in a single space. See for more information. +[3] This field determines whether repositories have access to the type by default. Hidden types will not be automatically exposed via the Saved Objects Client APIs. +Hidden types must be listed in `SavedObjectsClientProviderOptions[includedHiddenTypes]` to be accessible by the client. + **src/plugins/my_plugin/server/saved_objects/index.ts** ```ts @@ -301,4 +304,4 @@ export const foo: SavedObjectsType = { [1] Needs to be `false` to use the `hiddenFromHttpApis` option -[2] Set this to `true` to build your own HTTP API and have complete control over the route handler. \ No newline at end of file +[2] Set this to `true` to build your own HTTP API and have complete control over the route handler. diff --git a/examples/guided_onboarding_example/public/application.tsx b/examples/guided_onboarding_example/public/application.tsx index 1227b8e7271df..b3d67e9de630a 100755 --- a/examples/guided_onboarding_example/public/application.tsx +++ b/examples/guided_onboarding_example/public/application.tsx @@ -10,20 +10,24 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { AppMountParameters, CoreStart } from '@kbn/core/public'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { AppPluginStartDependencies } from './types'; import { GuidedOnboardingExampleApp } from './components/app'; export const renderApp = ( - { notifications }: CoreStart, + coreStart: CoreStart, { guidedOnboarding }: AppPluginStartDependencies, { element, history }: AppMountParameters ) => { + const { notifications } = coreStart; ReactDOM.render( - , + + + , element ); diff --git a/examples/guided_onboarding_example/public/components/app.tsx b/examples/guided_onboarding_example/public/components/app.tsx index 20430534a54e3..650f683e82bbb 100755 --- a/examples/guided_onboarding_example/public/components/app.tsx +++ b/examples/guided_onboarding_example/public/components/app.tsx @@ -8,11 +8,10 @@ */ import React from 'react'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n-react'; +import { FormattedMessage } from '@kbn/i18n-react'; import { Routes, Router, Route } from '@kbn/shared-ux-router'; import { EuiPageTemplate } from '@elastic/eui'; import { CoreStart, ScopedHistory } from '@kbn/core/public'; - import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public/types'; import { StepTwo } from './step_two'; import { StepOne } from './step_one'; @@ -30,62 +29,60 @@ export const GuidedOnboardingExampleApp = (props: GuidedOnboardingExampleAppDeps const { notifications, guidedOnboarding, history } = props; return ( - - - + + + } + /> + {guidedOnboarding?.guidedOnboardingApi?.isEnabled ? ( + + + + +
+ + + + + + + + + + + + + + + + + ) : ( + + + + } + body={ +

+ +

} /> - {guidedOnboarding?.guidedOnboardingApi?.isEnabled ? ( - - - - -
- - - - - - - - - - - - - - - - - ) : ( - - - - } - body={ -

- -

- } - /> - )} - - + )} + ); }; diff --git a/examples/guided_onboarding_example/tsconfig.json b/examples/guided_onboarding_example/tsconfig.json index 0707df0a33308..6dca87ec7eb23 100644 --- a/examples/guided_onboarding_example/tsconfig.json +++ b/examples/guided_onboarding_example/tsconfig.json @@ -17,6 +17,7 @@ "@kbn/i18n", "@kbn/guided-onboarding", "@kbn/shared-ux-router", + "@kbn/react-kibana-context-render", ], "exclude": [ "target/**/*", diff --git a/package.json b/package.json index cda08f1a5b06f..03e7ff0eeb745 100644 --- a/package.json +++ b/package.json @@ -1027,10 +1027,10 @@ "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mapbox/mapbox-gl-supported": "2.0.1", "@mapbox/vector-tile": "1.3.1", - "@openfeature/core": "^1.4.0", + "@openfeature/core": "^1.5.0", "@openfeature/launchdarkly-client-provider": "^0.3.0", - "@openfeature/server-sdk": "^1.15.1", - "@openfeature/web-sdk": "^1.2.4", + "@openfeature/server-sdk": "^1.16.1", + "@openfeature/web-sdk": "^1.3.1", "@opentelemetry/api": "^1.1.0", "@opentelemetry/api-metrics": "^0.31.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.34.0", @@ -1497,7 +1497,7 @@ "@octokit/rest": "^17.11.2", "@parcel/watcher": "^2.1.0", "@playwright/test": "=1.46.0", - "@redocly/cli": "^1.25.8", + "@redocly/cli": "^1.25.9", "@statoscope/webpack-plugin": "^5.28.2", "@storybook/addon-a11y": "^6.5.16", "@storybook/addon-actions": "^6.5.16", @@ -1714,6 +1714,7 @@ "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-perf": "^3.3.1", + "eslint-plugin-testing-library": "^6.4.0", "eslint-traverse": "^1.0.0", "exit-hook": "^2.2.0", "expect": "^29.7.0", diff --git a/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts b/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts index 1912863b52703..a29875a733d68 100644 --- a/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts +++ b/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_type.ts @@ -37,7 +37,12 @@ export interface SavedObjectsType { * The hidden types will not be automatically exposed via the HTTP API. * Therefore, that should prevent unexpected behavior in the client code, as all the interactions will be done via the plugin API. * + * Hidden types must be listed to be accessible by the client. + * + * (await context.core).savedObjects.getClient({ includeHiddenTypes: [MY_PLUGIN_HIDDEN_SAVED_OBJECT_TYPE] }) + * * See {@link SavedObjectsServiceStart.createInternalRepository | createInternalRepository}. + * */ hidden: boolean; /** diff --git a/packages/kbn-visualization-utils/index.ts b/packages/kbn-visualization-utils/index.ts index 5d4dfecc0ae29..1773a04db76d9 100644 --- a/packages/kbn-visualization-utils/index.ts +++ b/packages/kbn-visualization-utils/index.ts @@ -11,3 +11,6 @@ export { getTimeZone } from './src/get_timezone'; export { getLensAttributesFromSuggestion } from './src/get_lens_attributes'; export { TooltipWrapper } from './src/tooltip_wrapper'; export { useDebouncedValue } from './src/debounced_value'; +export { ChartType } from './src/types'; +export { getDatasourceId } from './src/get_datasource_id'; +export { mapVisToChartType } from './src/map_vis_to_chart_type'; diff --git a/packages/kbn-visualization-utils/src/get_datasource_id.ts b/packages/kbn-visualization-utils/src/get_datasource_id.ts new file mode 100644 index 0000000000000..c87d08f8e3e27 --- /dev/null +++ b/packages/kbn-visualization-utils/src/get_datasource_id.ts @@ -0,0 +1,17 @@ +/* + * 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". + */ + +export const getDatasourceId = (datasourceStates: Record) => { + const datasourceId: 'formBased' | 'textBased' | undefined = [ + 'formBased' as const, + 'textBased' as const, + ].find((key) => Boolean(datasourceStates[key])); + + return datasourceId; +}; diff --git a/packages/kbn-visualization-utils/src/map_vis_to_chart_type.ts b/packages/kbn-visualization-utils/src/map_vis_to_chart_type.ts new file mode 100644 index 0000000000000..288202f4b999f --- /dev/null +++ b/packages/kbn-visualization-utils/src/map_vis_to_chart_type.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { ChartType, LensVisualizationType } from './types'; + +type ValueOf = T[keyof T]; +type LensToChartMap = { + [K in ValueOf]: ChartType; +}; +const lensTypesToChartTypes: LensToChartMap = { + [LensVisualizationType.XY]: ChartType.XY, + [LensVisualizationType.Metric]: ChartType.Metric, + [LensVisualizationType.LegacyMetric]: ChartType.Metric, + [LensVisualizationType.Pie]: ChartType.Pie, + [LensVisualizationType.Heatmap]: ChartType.Heatmap, + [LensVisualizationType.Gauge]: ChartType.Gauge, + [LensVisualizationType.Datatable]: ChartType.Table, +}; +function isLensVisualizationType(value: string): value is LensVisualizationType { + return Object.values(LensVisualizationType).includes(value as LensVisualizationType); +} +export const mapVisToChartType = (visualizationType: string) => { + if (isLensVisualizationType(visualizationType)) { + return lensTypesToChartTypes[visualizationType]; + } +}; diff --git a/packages/kbn-visualization-utils/src/types.ts b/packages/kbn-visualization-utils/src/types.ts index 0337c3349332b..cd73cbea20631 100644 --- a/packages/kbn-visualization-utils/src/types.ts +++ b/packages/kbn-visualization-utils/src/types.ts @@ -43,3 +43,30 @@ export interface Suggestion { changeType: TableChangeType; keptLayerIds: string[]; } + +export enum ChartType { + XY = 'XY', + Gauge = 'Gauge', + Bar = 'Bar', + Line = 'Line', + Area = 'Area', + Donut = 'Donut', + Heatmap = 'Heatmap', + Metric = 'Metric', + Treemap = 'Treemap', + Tagcloud = 'Tagcloud', + Waffle = 'Waffle', + Pie = 'Pie', + Mosaic = 'Mosaic', + Table = 'Table', +} + +export enum LensVisualizationType { + XY = 'lnsXY', + Metric = 'lnsMetric', + Pie = 'lnsPie', + Heatmap = 'lnsHeatmap', + Gauge = 'lnsGauge', + Datatable = 'lnsDatatable', + LegacyMetric = 'lnsLegacyMetric', +} diff --git a/src/plugins/discover/public/application/context/context_app_route.tsx b/src/plugins/discover/public/application/context/context_app_route.tsx index a272a032bbe35..dad0dd2eb7b93 100644 --- a/src/plugins/discover/public/application/context/context_app_route.tsx +++ b/src/plugins/discover/public/application/context/context_app_route.tsx @@ -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(); 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 ; } - return ; + return ( + + + + ); } diff --git a/src/plugins/discover/public/application/context/services/anchor.ts b/src/plugins/discover/public/application/context/services/anchor.ts index 350c292772d87..ee5198a8b4100 100644 --- a/src/plugins/discover/public/application/context/services/anchor.ts +++ b/src/plugins/discover/public/application/context/services/anchor.ts @@ -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, diff --git a/src/plugins/discover/public/application/doc/components/doc.tsx b/src/plugins/discover/public/application/doc/components/doc.tsx index 432687fdca5e6..8609968f838de 100644 --- a/src/plugins/discover/public/application/doc/components/doc.tsx +++ b/src/plugins/discover/public/application/doc/components/doc.tsx @@ -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) => { diff --git a/src/plugins/discover/public/application/doc/single_doc_route.tsx b/src/plugins/discover/public/application/doc/single_doc_route.tsx index 8091e637e8beb..3eedac7be1644 100644 --- a/src/plugins/discover/public/application/doc/single_doc_route.tsx +++ b/src/plugins/discover/public/application/doc/single_doc_route.tsx @@ -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 ( { ); } - if (!dataView) { + if (!dataView || rootProfileState.rootProfileLoading) { return ; } @@ -94,5 +97,9 @@ export const SingleDocRoute = () => { ); } - return ; + return ( + + + + ); }; diff --git a/src/plugins/discover/public/application/main/discover_main_route.test.tsx b/src/plugins/discover/public/application/main/discover_main_route.test.tsx index d2e074720bb0b..df787f5756ae7 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.test.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.test.tsx @@ -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}, }), }; }); diff --git a/src/plugins/discover/public/application/main/discover_main_route.tsx b/src/plugins/discover/public/application/main/discover_main_route.tsx index 6991b1c30d9b2..d86788172386f 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.tsx @@ -345,27 +345,26 @@ export function DiscoverMainRoute({ stateContainer, ]); - const { solutionNavId } = customizationContext; - const { rootProfileLoading } = useRootProfile({ solutionNavId }); + const rootProfileState = useRootProfile(); if (error) { return ; } - if (!customizationService || rootProfileLoading) { + if (!customizationService || rootProfileState.rootProfileLoading) { return loadingIndicator; } return ( - <> + {mainContent} - + ); diff --git a/src/plugins/discover/public/components/discover_container/discover_container.tsx b/src/plugins/discover/public/components/discover_container/discover_container.tsx index 4210fd86144b0..92ae1b7c0f4cb 100644 --- a/src/plugins/discover/public/components/discover_container/discover_container.tsx +++ b/src/plugins/discover/public/components/discover_container/discover_container.tsx @@ -45,7 +45,6 @@ const discoverContainerWrapperCss = css` `; const customizationContext: DiscoverCustomizationContext = { - solutionNavId: null, displayMode: 'embedded', inlineTopNav: { enabled: false, diff --git a/src/plugins/discover/public/context_awareness/README.md b/src/plugins/discover/public/context_awareness/README.md index 3bb70dbb93e73..a6e7e3e24a585 100644 --- a/src/plugins/discover/public/context_awareness/README.md +++ b/src/plugins/discover/public/context_awareness/README.md @@ -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... + + ); + }, + bar: function BarComponent() { + const blueOrRed = useObservable(blueOrRed$, blueOrRed$.getValue()); + + // ...and we can react to the changes + return Look ma, I'm {blueOrRed}!; + }, + }; +}; +``` + +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 + + // This is our wrapper -- it uses React context to give extension point implementations + access to the shared state + + // 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 && ( + setFlyoutOpen(false)}> + Check it out, I'm a flyout! + + )} + + + ); + }, + // 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 ; + }, + }), + }, + 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, + ]; +}; +``` diff --git a/src/plugins/discover/public/context_awareness/hooks/index.ts b/src/plugins/discover/public/context_awareness/hooks/index.ts index c509fd0119059..28a45be84de76 100644 --- a/src/plugins/discover/public/context_awareness/hooks/index.ts +++ b/src/plugins/discover/public/context_awareness/hooks/index.ts @@ -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'; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx index 8edbc35ab11a1..26c3aa2df3f15 100644 --- a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx +++ b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx @@ -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).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).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).AppWrapper).toBeDefined(); + act(() => mockSolutionNavId$.next('newSolutionNavId')); + rerender(); expect(result.current.rootProfileLoading).toBe(true); + expect((result.current as Record).AppWrapper).toBeUndefined(); await waitForNextUpdate(); expect(result.current.rootProfileLoading).toBe(false); + expect((result.current as Record).AppWrapper).toBeDefined(); }); }); diff --git a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts deleted file mode 100644 index 2ffccc6d786b2..0000000000000 --- a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts +++ /dev/null @@ -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 }; -}; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.tsx b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.tsx new file mode 100644 index 0000000000000..bf20d6ba58a97 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.tsx @@ -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}; diff --git a/src/plugins/discover/public/context_awareness/index.ts b/src/plugins/discover/public/context_awareness/index.ts index fcaec25c0f247..61d829d4e5c5c 100644 --- a/src/plugins/discover/public/context_awareness/index.ts +++ b/src/plugins/discover/public/context_awareness/index.ts @@ -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'; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/example/example_context.ts b/src/plugins/discover/public/context_awareness/profile_providers/example/example_context.ts new file mode 100644 index 0000000000000..e9475d61f1425 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profile_providers/example/example_context.ts @@ -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); diff --git a/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx b/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx index a27d3e034f7d3..b32267c0b3fe8 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx +++ b/src/plugins/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx @@ -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 ); }, + message: function Message(props) { + const { currentMessage, setCurrentMessage } = useExampleContext(); + const message = getFieldValue(props.row, 'message') as string; + + return ( + setCurrentMessage(message)} + css={{ fontWeight: currentMessage === message ? 'bold' : undefined }} + data-test-subj="exampleDataSourceProfileMessage" + > + {message} + + ); + }, }), getDocViewer: (prev) => (params) => { const recordId = params.record.id; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/index.ts b/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts similarity index 80% rename from src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/index.ts rename to src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts index 0c13a49d17d7a..b286a7d8cdce0 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/index.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts @@ -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'; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/profile.tsx b/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx similarity index 70% rename from src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/profile.tsx rename to src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx index 82988e5514b1c..1b957718c5d6b 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_pofile/profile.tsx +++ b/src/plugins/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx @@ -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(undefined); + + return ( + + + {children} + {currentMessage && ( + setCurrentMessage(undefined)} + data-test-subj="exampleRootProfileFlyout" + > + + +

Inspect message

+
+
+ + + {currentMessage} + + +
+ )} +
+
+ ); + }; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts b/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts index 940eb6b67e591..ddff243b5117a 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts @@ -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'; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.ts b/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.ts index 58ff63ca35c19..5bac0d9cea483 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.ts +++ b/src/plugins/discover/public/context_awareness/profile_providers/register_profile_providers.ts @@ -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), ]; diff --git a/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts b/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts index 807072d777a93..c4d06e0a502cb 100644 --- a/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts +++ b/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts @@ -25,7 +25,7 @@ export enum DataSourceCategory { /** * The data source profile interface */ -export type DataSourceProfile = Profile; +export type DataSourceProfile = Omit; /** * Parameters for the data source profile provider `resolve` method diff --git a/src/plugins/discover/public/context_awareness/profiles_manager.ts b/src/plugins/discover/public/context_awareness/profiles_manager.ts index 6b7bef5e02294..a209e5dfc9f7c 100644 --- a/src/plugins/discover/public/context_awareness/profiles_manager.ts +++ b/src/plugins/discover/public/context_awareness/profiles_manager.ts @@ -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 = ( diff --git a/src/plugins/discover/public/context_awareness/types.ts b/src/plugins/discover/public/context_awareness/types.ts index 5f37caeeb46b6..4b75e6473aafd 100644 --- a/src/plugins/discover/public/context_awareness/types.ts +++ b/src/plugins/discover/public/context_awareness/types.ts @@ -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 diff --git a/src/plugins/discover/public/customizations/defaults.ts b/src/plugins/discover/public/customizations/defaults.ts index 600f1501a1d41..d44b6527b3909 100644 --- a/src/plugins/discover/public/customizations/defaults.ts +++ b/src/plugins/discover/public/customizations/defaults.ts @@ -10,7 +10,6 @@ import { DiscoverCustomizationContext } from './types'; export const defaultCustomizationContext: DiscoverCustomizationContext = { - solutionNavId: null, displayMode: 'standalone', inlineTopNav: { enabled: false, diff --git a/src/plugins/discover/public/customizations/types.ts b/src/plugins/discover/public/customizations/types.ts index e72426b00d8a2..bf71fa80148ec 100644 --- a/src/plugins/discover/public/customizations/types.ts +++ b/src/plugins/discover/public/customizations/types.ts @@ -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 */ diff --git a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx index 1c8b77982fb24..b1c589f3e1d84 100644 --- a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx +++ b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx @@ -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(); }); diff --git a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx index 549b42c8a6cbe..37213b17c377d 100644 --- a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx +++ b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx @@ -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(initialState?.savedObjectId); @@ -280,30 +284,32 @@ export const getSearchEmbeddableFactory = ({ return ( - {renderAsFieldStatsTable ? ( - - ) : ( - - + {renderAsFieldStatsTable ? ( + - - )} + ) : ( + + + + )} + ); diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index dbbcc90a7d451..0ee80da03a7d1 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -213,7 +213,6 @@ export class DiscoverPlugin .pipe( map((solutionNavId) => ({ ...defaultCustomizationContext, - solutionNavId, inlineTopNav: this.inlineTopNav.get(solutionNavId) ?? this.inlineTopNav.get(null) ?? diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts index 9bd732d81340c..c29f8041d8044 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts @@ -72,7 +72,8 @@ function createRawEventLoopDelaysDailyDocs() { return { rawEventLoopDelaysDaily, outdatedRawEventLoopDelaysDaily }; } -describe(`daily rollups integration test`, () => { +// Failing: See https://github.com/elastic/kibana/issues/111821 +describe.skip(`daily rollups integration test`, () => { let esServer: TestElasticsearchUtils; let root: TestKibanaUtils['root']; let internalRepository: ISavedObjectsRepository; diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss index 19d556b0b142a..64e700c73fca5 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss @@ -90,7 +90,7 @@ } & [data-gridcell-column-id='pin_field'] .euiDataGridRowCell__content { - padding: $euiSizeXS / 2 0 0 $euiSizeXS; + padding: calc($euiSizeXS / 2) 0 0 $euiSizeXS; } .kbnDocViewer__fieldsGrid__pinAction { diff --git a/src/plugins/unified_histogram/public/services/lens_vis_service.ts b/src/plugins/unified_histogram/public/services/lens_vis_service.ts index e48ebc6459071..2367e729b5a70 100644 --- a/src/plugins/unified_histogram/public/services/lens_vis_service.ts +++ b/src/plugins/unified_histogram/public/services/lens_vis_service.ts @@ -27,7 +27,11 @@ import type { import type { AggregateQuery, TimeRange } from '@kbn/es-query'; import { getAggregateQueryMode, isOfAggregateQueryType } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; -import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils'; +import { + getLensAttributesFromSuggestion, + ChartType, + mapVisToChartType, +} from '@kbn/visualization-utils'; import { LegendSize } from '@kbn/visualizations-plugin/public'; import { XYConfiguration } from '@kbn/visualizations-plugin/common'; import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; @@ -42,6 +46,7 @@ import { isSuggestionShapeAndVisContextCompatible, deriveLensSuggestionFromLensAttributes, type QueryParams, + injectESQLQueryIntoLensLayers, } from '../utils/external_vis_context'; import { computeInterval } from '../utils/compute_interval'; import { fieldSupportsBreakdown } from '../utils/field_supports_breakdown'; @@ -147,7 +152,10 @@ export class LensVisService { externalVisContextStatus: UnifiedHistogramExternalVisContextStatus ) => void; }) => { - const allSuggestions = this.getAllSuggestions({ queryParams }); + const allSuggestions = this.getAllSuggestions({ + queryParams, + preferredVisAttributes: externalVisContext?.attributes, + }); const suggestionState = this.getCurrentSuggestionState({ externalVisContext, @@ -252,6 +260,7 @@ export class LensVisService { const histogramSuggestionForESQL = this.getHistogramSuggestionForESQL({ queryParams, breakdownField, + preferredVisAttributes: externalVisContext?.attributes, }); if (histogramSuggestionForESQL) { // In case if histogram suggestion, we want to empty the array and push the new suggestion @@ -463,9 +472,11 @@ export class LensVisService { private getHistogramSuggestionForESQL = ({ queryParams, breakdownField, + preferredVisAttributes, }: { queryParams: QueryParams; breakdownField?: DataViewField; + preferredVisAttributes?: UnifiedHistogramVisContext['attributes']; }): Suggestion | undefined => { const { dataView, query, timeRange, columns } = queryParams; const breakdownColumn = breakdownField?.name @@ -510,7 +521,22 @@ export class LensVisService { if (breakdownColumn) { context.textBasedColumns.push(breakdownColumn); } - const suggestions = this.lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? []; + + // here the attributes contain the main query and not the histogram one + const updatedAttributesWithQuery = preferredVisAttributes + ? injectESQLQueryIntoLensLayers(preferredVisAttributes, { + esql: esqlQuery, + }) + : undefined; + + const suggestions = + this.lensSuggestionsApi( + context, + dataView, + ['lnsDatatable'], + ChartType.XY, + updatedAttributesWithQuery + ) ?? []; if (suggestions.length) { const suggestion = suggestions[0]; const suggestionVisualizationState = Object.assign({}, suggestion?.visualizationState); @@ -574,9 +600,25 @@ export class LensVisService { ); }; - private getAllSuggestions = ({ queryParams }: { queryParams: QueryParams }): Suggestion[] => { + private getAllSuggestions = ({ + queryParams, + preferredVisAttributes, + }: { + queryParams: QueryParams; + preferredVisAttributes?: UnifiedHistogramVisContext['attributes']; + }): Suggestion[] => { const { dataView, columns, query, isPlainRecord } = queryParams; + const preferredChartType = preferredVisAttributes + ? mapVisToChartType(preferredVisAttributes.visualizationType) + : undefined; + + let visAttributes = preferredVisAttributes; + + if (query && isOfAggregateQueryType(query) && preferredVisAttributes) { + visAttributes = injectESQLQueryIntoLensLayers(preferredVisAttributes, query); + } + const context = { dataViewSpec: dataView?.toSpec(), fieldName: '', @@ -584,7 +626,13 @@ export class LensVisService { query: query && isOfAggregateQueryType(query) ? query : undefined, }; const allSuggestions = isPlainRecord - ? this.lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? [] + ? this.lensSuggestionsApi( + context, + dataView, + ['lnsDatatable'], + preferredChartType, + visAttributes + ) ?? [] : []; return allSuggestions; diff --git a/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts b/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts index 2931d3a8410ca..1cbad8b308078 100644 --- a/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts +++ b/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts @@ -13,6 +13,7 @@ import { canImportVisContext, exportVisContext, isSuggestionShapeAndVisContextCompatible, + injectESQLQueryIntoLensLayers, } from './external_vis_context'; import { getLensVisMock } from '../__mocks__/lens_vis'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; @@ -162,4 +163,63 @@ describe('external_vis_context', () => { ).toBe(true); }); }); + + describe('injectESQLQueryIntoLensLayers', () => { + it('should return the Lens attributes as they are for unknown datasourceId', async () => { + const attributes = { + visualizationType: 'lnsXY', + state: { + visualization: { preferredSeriesType: 'line' }, + datasourceStates: { unknownId: { layers: {} } }, + }, + } as unknown as UnifiedHistogramVisContext['attributes']; + expect(injectESQLQueryIntoLensLayers(attributes, { esql: 'from foo' })).toStrictEqual( + attributes + ); + }); + + it('should return the Lens attributes as they are for DSL config (formbased)', async () => { + const attributes = { + visualizationType: 'lnsXY', + state: { + visualization: { preferredSeriesType: 'line' }, + datasourceStates: { formBased: { layers: {} } }, + }, + } as UnifiedHistogramVisContext['attributes']; + expect(injectESQLQueryIntoLensLayers(attributes, { esql: 'from foo' })).toStrictEqual( + attributes + ); + }); + + it('should inject the query to the Lens attributes for ES|QL config (textbased)', async () => { + const attributes = { + visualizationType: 'lnsXY', + state: { + visualization: { preferredSeriesType: 'line' }, + datasourceStates: { textBased: { layers: { layer1: { query: { esql: 'from foo' } } } } }, + }, + } as unknown as UnifiedHistogramVisContext['attributes']; + + const expectedAttributes = { + ...attributes, + state: { + ...attributes.state, + datasourceStates: { + ...attributes.state.datasourceStates, + textBased: { + ...attributes.state.datasourceStates.textBased, + layers: { + layer1: { + query: { esql: 'from foo | stats count(*)' }, + }, + }, + }, + }, + }, + } as unknown as UnifiedHistogramVisContext['attributes']; + expect( + injectESQLQueryIntoLensLayers(attributes, { esql: 'from foo | stats count(*)' }) + ).toStrictEqual(expectedAttributes); + }); + }); }); diff --git a/src/plugins/unified_histogram/public/utils/external_vis_context.ts b/src/plugins/unified_histogram/public/utils/external_vis_context.ts index fd516dd2c32d8..ef5788b4b25ba 100644 --- a/src/plugins/unified_histogram/public/utils/external_vis_context.ts +++ b/src/plugins/unified_histogram/public/utils/external_vis_context.ts @@ -7,9 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { isEqual } from 'lodash'; +import { isEqual, cloneDeep } from 'lodash'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import { getDatasourceId } from '@kbn/visualization-utils'; import type { DatatableColumn } from '@kbn/expressions-plugin/common'; import type { PieVisualizationState, Suggestion, XYState } from '@kbn/lens-plugin/public'; import { UnifiedHistogramSuggestionType, UnifiedHistogramVisContext } from '../types'; @@ -103,6 +104,42 @@ export const isSuggestionShapeAndVisContextCompatible = ( ); }; +export const injectESQLQueryIntoLensLayers = ( + visAttributes: UnifiedHistogramVisContext['attributes'], + query: AggregateQuery +) => { + const datasourceId = getDatasourceId(visAttributes.state.datasourceStates); + + // if the datasource is formBased, we should not fix the query + if (!datasourceId || datasourceId === 'formBased') { + return visAttributes; + } + + if (!visAttributes.state.datasourceStates[datasourceId]) { + return visAttributes; + } + + const datasourceState = cloneDeep(visAttributes.state.datasourceStates[datasourceId]); + + if (datasourceState && datasourceState.layers) { + Object.values(datasourceState.layers).forEach((layer) => { + if (!isEqual(layer.query, query)) { + layer.query = query; + } + }); + } + return { + ...visAttributes, + state: { + ...visAttributes.state, + datasourceStates: { + ...visAttributes.state.datasourceStates, + [datasourceId]: datasourceState, + }, + }, + }; +}; + export function deriveLensSuggestionFromLensAttributes({ externalVisContext, queryParams, @@ -122,10 +159,7 @@ export function deriveLensSuggestionFromLensAttributes({ } // it should be one of 'formBased'/'textBased' and have value - const datasourceId: 'formBased' | 'textBased' | undefined = [ - 'formBased' as const, - 'textBased' as const, - ].find((key) => Boolean(externalVisContext.attributes.state.datasourceStates[key])); + const datasourceId = getDatasourceId(externalVisContext.attributes.state.datasourceStates); if (!datasourceId) { return undefined; diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss index 7a14dd3a64ef3..85c5b6bb9278f 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss @@ -7,7 +7,7 @@ .globalFilterItem { line-height: $euiSize; color: $euiTextColor; - padding-block: $euiSizeM / 2; + padding-block: calc($euiSizeM / 2); white-space: normal; /* 1 */ &:not(.globalFilterItem-isDisabled) { diff --git a/test/functional/apps/discover/context_awareness/config.ts b/test/functional/apps/discover/context_awareness/config.ts index 9261cef450adb..ded4755a61f92 100644 --- a/test/functional/apps/discover/context_awareness/config.ts +++ b/test/functional/apps/discover/context_awareness/config.ts @@ -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' diff --git a/test/functional/apps/discover/context_awareness/extensions/_get_render_app_wrapper.ts b/test/functional/apps/discover/context_awareness/extensions/_get_render_app_wrapper.ts new file mode 100644 index 0000000000000..b30d16c215044 --- /dev/null +++ b/test/functional/apps/discover/context_awareness/extensions/_get_render_app_wrapper.ts @@ -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'); + }); + }); + }); +} diff --git a/test/functional/apps/discover/context_awareness/index.ts b/test/functional/apps/discover/context_awareness/index.ts index 40f2df358a4ce..0edf18b7e9027 100644 --- a/test/functional/apps/discover/context_awareness/index.ts +++ b/test/functional/apps/discover/context_awareness/index.ts @@ -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')); }); } diff --git a/test/functional/apps/discover/esql/_esql_view.ts b/test/functional/apps/discover/esql/_esql_view.ts index 31e89ac42f3ea..dc550ac5be93d 100644 --- a/test/functional/apps/discover/esql/_esql_view.ts +++ b/test/functional/apps/discover/esql/_esql_view.ts @@ -676,6 +676,94 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB | where countB > 0\nAND \`geo.dest\`=="BT"` ); }); + + it('should append a where clause by clicking the table without changing the chart type', async () => { + await discover.selectTextBaseLang(); + const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB`; + await monacoEditor.setCodeEditorValue(testQuery); + + await testSubjects.click('querySubmitButton'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + // change the type to line + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('lnsChartSwitchPopover'); + await testSubjects.click('lnsChartSwitchPopover_line'); + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('applyFlyoutButton'); + + await dataGrid.clickCellFilterForButtonExcludingControlColumns(0, 1); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + const editorValue = await monacoEditor.getCodeEditorValue(); + expect(editorValue).to.eql( + `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB\n| WHERE \`geo.dest\`=="BT"` + ); + + // check that the type is still line + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + await header.waitUntilLoadingHasFinished(); + const chartSwitcher = await testSubjects.find('lnsChartSwitchPopover'); + const type = await chartSwitcher.getVisibleText(); + expect(type).to.be('Line'); + }); + + it('should append a where clause by clicking the table without changing the chart type nor the visualization state', async () => { + await discover.selectTextBaseLang(); + const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB`; + await monacoEditor.setCodeEditorValue(testQuery); + + await testSubjects.click('querySubmitButton'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + // change the type to line + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('lnsChartSwitchPopover'); + await testSubjects.click('lnsChartSwitchPopover_line'); + + // change the color to red + await testSubjects.click('lnsXY_yDimensionPanel'); + const colorPickerInput = await testSubjects.find('~indexPattern-dimension-colorPicker'); + await colorPickerInput.clearValueWithKeyboard(); + await colorPickerInput.type('#ff0000'); + await common.sleep(1000); // give time for debounced components to rerender + + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('lns-indexPattern-dimensionContainerClose'); + await testSubjects.click('applyFlyoutButton'); + + await dataGrid.clickCellFilterForButtonExcludingControlColumns(0, 1); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + const editorValue = await monacoEditor.getCodeEditorValue(); + expect(editorValue).to.eql( + `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB\n| WHERE \`geo.dest\`=="BT"` + ); + + // check that the type is still line + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + await header.waitUntilLoadingHasFinished(); + const chartSwitcher = await testSubjects.find('lnsChartSwitchPopover'); + const type = await chartSwitcher.getVisibleText(); + expect(type).to.be('Line'); + + // check that the color is still red + await testSubjects.click('lnsXY_yDimensionPanel'); + const colorPickerInputAfterFilter = await testSubjects.find( + '~indexPattern-dimension-colorPicker' + ); + expect(await colorPickerInputAfterFilter.getAttribute('value')).to.be('#FF0000'); + }); }); describe('histogram breakdown', () => { diff --git a/test/functional/apps/discover/group3/_lens_vis.ts b/test/functional/apps/discover/group3/_lens_vis.ts index 5e13c8bbb243c..0864382cad7a8 100644 --- a/test/functional/apps/discover/group3/_lens_vis.ts +++ b/test/functional/apps/discover/group3/_lens_vis.ts @@ -288,7 +288,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - expect(await getCurrentVisTitle()).to.be('Bar'); + // Line has been retained although the query changed! + expect(await getCurrentVisTitle()).to.be('Line'); await checkESQLHistogramVis(defaultTimespanESQL, '100'); @@ -567,15 +568,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('partitionVisChart'); expect(await discover.getVisContextSuggestionType()).to.be('lensSuggestion'); - await monacoEditor.setCodeEditorValue( - 'from logstash-* | stats averageB = avg(bytes) by extension.raw' - ); + // reset to histogram + await monacoEditor.setCodeEditorValue('from logstash-*'); await testSubjects.click('querySubmitButton'); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); expect(await getCurrentVisTitle()).to.be('Bar'); - expect(await discover.getVisContextSuggestionType()).to.be('lensSuggestion'); + expect(await discover.getVisContextSuggestionType()).to.be('histogramForESQL'); await testSubjects.existOrFail('unsavedChangesBadge'); diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index 31a162a8800d6..4bdf99a9b7b35 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -91,40 +91,46 @@ export class HomePageObject extends FtrService { async addSampleDataSet(id: string) { await this.openSampleDataAccordion(); - const isInstalled = await this.isSampleDataSetInstalled(id); - if (!isInstalled) { + await this.retry.waitFor('sample data to be installed', async () => { + // count for the edge case where some how installation completes just before the retry occurs + if (await this.isSampleDataSetInstalled(id)) { + return true; + } + this.log.debug(`Attempting to add sample data: ${id}`); - await this.retry.waitFor('sample data to be installed', async () => { - // Echoing the adjustments made to 'removeSampleDataSet', as we are seeing flaky test cases here as well - // https://github.com/elastic/kibana/issues/52714 - await this.testSubjects.waitForEnabled(`addSampleDataSet${id}`); - await this.common.sleep(1010); - await this.testSubjects.click(`addSampleDataSet${id}`); - await this.common.sleep(1010); - await this._waitForSampleDataLoadingAction(id); - return await this.isSampleDataSetInstalled(id); - }); - } + + // Echoing the adjustments made to 'removeSampleDataSet', as we are seeing flaky test cases here as well + // https://github.com/elastic/kibana/issues/52714 + await this.testSubjects.waitForEnabled(`addSampleDataSet${id}`); + await this.common.sleep(1010); + await this.testSubjects.click(`addSampleDataSet${id}`); + await this.common.sleep(1010); + await this._waitForSampleDataLoadingAction(id); + return await this.isSampleDataSetInstalled(id); + }); } async removeSampleDataSet(id: string) { await this.openSampleDataAccordion(); - const isInstalled = await this.isSampleDataSetInstalled(id); - if (isInstalled) { + await this.retry.waitFor('sample data to be removed', async () => { + // account for the edge case where some how data is uninstalled just before the retry occurs + if (!(await this.isSampleDataSetInstalled(id))) { + return true; + } + this.log.debug(`Attempting to remove sample data: ${id}`); - await this.retry.waitFor('sample data to be removed', async () => { - // looks like overkill but we're hitting flaky cases where we click but it doesn't remove - await this.testSubjects.waitForEnabled(`removeSampleDataSet${id}`); - // https://github.com/elastic/kibana/issues/65949 - // Even after waiting for the "Remove" button to be enabled we still have failures - // where it appears the click just didn't work. - await this.common.sleep(1010); - await this.testSubjects.click(`removeSampleDataSet${id}`); - await this.common.sleep(1010); - await this._waitForSampleDataLoadingAction(id); - return !(await this.isSampleDataSetInstalled(id)); - }); - } + + // looks like overkill but we're hitting flaky cases where we click but it doesn't remove + await this.testSubjects.waitForEnabled(`removeSampleDataSet${id}`); + // https://github.com/elastic/kibana/issues/65949 + // Even after waiting for the "Remove" button to be enabled we still have failures + // where it appears the click just didn't work. + await this.common.sleep(1010); + await this.testSubjects.click(`removeSampleDataSet${id}`); + await this.common.sleep(1010); + await this._waitForSampleDataLoadingAction(id); + return !(await this.isSampleDataSetInstalled(id)); + }); } // loading action is either uninstall and install diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx index cfc8d2d3d52f9..180b88fc3cdc8 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx @@ -425,6 +425,63 @@ describe('KnowledgeBaseSettingsManagement', () => { expect(mockCreateEntry).toHaveBeenCalledWith({ ...mockData[3], users: undefined }); }); + it('does not show duplicate entry modal on new document entry creation', async () => { + // Covers the BUG: https://github.com/elastic/kibana/issues/198892 + const closeFlyoutMock = jest.fn(); + (useFlyoutModalVisibility as jest.Mock).mockReturnValue({ + isFlyoutOpen: true, + openFlyout: jest.fn(), + closeFlyout: closeFlyoutMock, + }); + render(, { + wrapper, + }); + + await waitFor(() => { + fireEvent.click(screen.getAllByTestId('edit-button')[3]); + }); + expect(screen.getByTestId('flyout')).toBeVisible(); + + await waitFor(() => { + expect(screen.getByText('Edit document entry')).toBeInTheDocument(); + }); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('sharing-select')); + fireEvent.click(screen.getByTestId('sharing-private-option')); + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(screen.getByTestId('create-duplicate-entry-modal')).toBeInTheDocument(); + await waitFor(() => { + fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); + }); + expect(screen.queryByTestId('create-duplicate-entry-modal')).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockCreateEntry).toHaveBeenCalledTimes(1); + }); + + // Create a new document entry + await waitFor(() => { + fireEvent.click(screen.getByTestId('addEntry')); + }); + await waitFor(() => { + fireEvent.click(screen.getByTestId('addDocument')); + }); + + expect(screen.getByTestId('flyout')).toBeVisible(); + + await userEvent.type(screen.getByTestId('entryNameInput'), 'hi'); + await userEvent.type(screen.getByTestId('entryMarkdownInput'), 'hi'); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(screen.queryByTestId('create-duplicate-entry-modal')).not.toBeInTheDocument(); + expect(closeFlyoutMock).toHaveBeenCalled(); + }); + it('shows warning icon for index entries with missing indices', async () => { render(, { wrapper, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx index 904ceba7a1f6f..86b3594daa3cd 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx @@ -166,26 +166,31 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d isRefetching: kbStatus?.is_setup_in_progress, }); + const resetStateAndCloseFlyout = useCallback(() => { + setOriginalEntry(undefined); + setSelectedEntry(undefined); + setDuplicateKBItem(null); + closeFlyout(); + }, [closeFlyout]); + // Flyout Save/Cancel Actions const onSaveConfirmed = useCallback(async () => { if (isKnowledgeBaseEntryResponse(selectedEntry)) { await updateEntries([selectedEntry]); - closeFlyout(); + resetStateAndCloseFlyout(); } else if (isKnowledgeBaseEntryCreateProps(selectedEntry)) { if (originalEntry) { setDuplicateKBItem(selectedEntry); return; } await createEntry(selectedEntry); - closeFlyout(); + resetStateAndCloseFlyout(); } - }, [selectedEntry, originalEntry, updateEntries, closeFlyout, createEntry]); + }, [selectedEntry, updateEntries, resetStateAndCloseFlyout, originalEntry, createEntry]); const onSaveCancelled = useCallback(() => { - setOriginalEntry(undefined); - setSelectedEntry(undefined); - closeFlyout(); - }, [closeFlyout]); + resetStateAndCloseFlyout(); + }, [resetStateAndCloseFlyout]); const { value: existingIndices } = useAsync(() => { const indices: string[] = []; @@ -323,10 +328,9 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d const handleDuplicateEntry = useCallback(async () => { if (duplicateKBItem) { await createEntry(duplicateKBItem); - closeFlyout(); - setDuplicateKBItem(null); + resetStateAndCloseFlyout(); } - }, [closeFlyout, createEntry, duplicateKBItem]); + }, [createEntry, duplicateKBItem, resetStateAndCloseFlyout]); if (!enableKnowledgeBaseByDefault) { return ( diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index efc56a0da7995..80a6532c4a094 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -174,4 +174,4 @@ export const SINGLE_ACCOUNT = 'single-account'; export const CLOUD_SECURITY_PLUGIN_VERSION = '1.9.0'; // Cloud Credentials Template url was implemented in 1.10.0-preview01. See PR - https://github.com/elastic/integrations/pull/9828 -export const CLOUD_CREDENTIALS_PACKAGE_VERSION = '1.11.0-preview10'; +export const CLOUD_CREDENTIALS_PACKAGE_VERSION = '1.11.0-preview13'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx index 1ebfe5a897b07..62b39e2e8708a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx @@ -6,7 +6,9 @@ */ import React from 'react'; -import { act, fireEvent, waitFor } from '@testing-library/react'; +import { waitFor, act } from '@testing-library/react'; + +import { userEvent } from '@testing-library/user-event'; import { getInheritedNamespace } from '../../../../../../../../common/services'; @@ -60,18 +62,6 @@ describe('StepDefinePackagePolicy', () => { package_policies: [], is_protected: false, }, - { - id: 'agent-policy-2', - namespace: 'default', - name: 'Agent policy 2', - is_managed: false, - status: 'active', - updated_at: '', - updated_by: '', - revision: 1, - package_policies: [], - is_protected: false, - }, ]; let packagePolicy: NewPackagePolicy; const mockUpdatePackagePolicy = jest.fn().mockImplementation((val: any) => { @@ -86,20 +76,23 @@ describe('StepDefinePackagePolicy', () => { description: null, namespace: null, inputs: {}, - vars: {}, + vars: { + 'Required var': ['Required var is required'], + }, }; let testRenderer: TestRenderer; let renderResult: ReturnType; - const render = () => + + const render = (namespacePlaceholder = getInheritedNamespace(agentPolicies)) => (renderResult = testRenderer.render( )); @@ -107,57 +100,100 @@ describe('StepDefinePackagePolicy', () => { packagePolicy = { name: '', description: 'desc', - namespace: 'default', + namespace: 'package-policy-ns', + enabled: true, policy_id: '', policy_ids: [''], - enabled: true, + package: { + name: 'apache', + title: 'Apache', + version: '1.0.0', + }, inputs: [], + vars: { + 'Show user var': { + type: 'string', + value: 'showUserVarVal', + }, + 'Required var': { + type: 'bool', + value: undefined, + }, + 'Advanced var': { + type: 'bool', + value: true, + }, + }, }; testRenderer = createFleetTestRendererMock(); }); describe('default API response', () => { - beforeEach(() => { - render(); - }); - it('should display vars coming from package policy', async () => { - waitFor(() => { - expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument(); - expect(renderResult.getByRole('switch')).toHaveAttribute('aria-label', 'Required var'); - expect(renderResult.getByText('Required var is required')).toHaveAttribute( - 'class', - 'euiFormErrorText' + act(() => { + render(); + }); + expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument(); + expect(renderResult.getByRole('switch', { name: 'Required var' })).toBeInTheDocument(); + expect(renderResult.queryByRole('switch', { name: 'Advanced var' })).not.toBeInTheDocument(); + + expect(renderResult.getByText('Required var is required')).toHaveClass('euiFormErrorText'); + + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + + await waitFor(() => { + expect(renderResult.getByRole('switch', { name: 'Advanced var' })).toBeInTheDocument(); + expect(renderResult.getByTestId('packagePolicyNamespaceInput')).toHaveTextContent( + 'package-policy-ns' ); }); + }); - await act(async () => { - fireEvent.click(renderResult.getByText('Advanced options').closest('button')!); + it(`should display namespace from agent policy when there's no package policy namespace`, async () => { + packagePolicy.namespace = ''; + act(() => { + render(); }); - waitFor(() => { - expect(renderResult.getByRole('switch')).toHaveAttribute('aria-label', 'Advanced var'); - expect(renderResult.getByTestId('packagePolicyNamespaceInput')).toHaveAttribute( + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + + await waitFor(() => { + expect(renderResult.getByTestId('comboBoxSearchInput')).toHaveAttribute( 'placeholder', 'ns' ); }); }); + + it(`should fallback to the default namespace when namespace is not set in package policy and there's no agent policy`, async () => { + packagePolicy.namespace = ''; + act(() => { + render(getInheritedNamespace([])); + }); + + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + + await waitFor(() => { + expect(renderResult.getByTestId('comboBoxSearchInput')).toHaveAttribute( + 'placeholder', + 'default' + ); + }); + }); }); describe('update', () => { describe('when package vars are introduced in a new package version', () => { - it('should display new package vars', () => { - render(); - - waitFor(async () => { - expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument(); - expect(renderResult.getByText('Required var')).toBeInTheDocument(); + it('should display new package vars', async () => { + act(() => { + render(); + }); + expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument(); + expect(renderResult.getByText('Required var')).toBeInTheDocument(); - await act(async () => { - fireEvent.click(renderResult.getByText('Advanced options').closest('button')!); - }); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(async () => { expect(renderResult.getByText('Advanced var')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.test.tsx index 7d1962939d1fa..583957861bd79 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_hosts.test.tsx @@ -6,7 +6,9 @@ */ import React from 'react'; -import { act, fireEvent, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; + +import { userEvent } from '@testing-library/user-event'; import type { TestRenderer } from '../../../../../../../mock'; import { createFleetTestRendererMock } from '../../../../../../../mock'; @@ -108,22 +110,23 @@ describe('StepSelectHosts', () => { testRenderer = createFleetTestRendererMock(); }); - it('should display create form when no agent policies', () => { + it('should display create form when no agent policies', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [], }, }); + (useAllNonManagedAgentPolicies as jest.MockedFunction).mockReturnValue([]); render(); - waitFor(() => { - expect(renderResult.getByText('Agent policy 1')).toBeInTheDocument(); + await waitFor(() => { + expect(renderResult.getByText('New agent policy name')).toBeInTheDocument(); }); expect(renderResult.queryByRole('tablist')).not.toBeInTheDocument(); }); - it('should display tabs with New hosts selected when agent policies exist', () => { + it('should display tabs with New hosts selected when agent policies exist', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [{ id: '1', name: 'Agent policy 1', namespace: 'default' }], @@ -135,10 +138,7 @@ describe('StepSelectHosts', () => { render(); - waitFor(() => { - expect(renderResult.getByRole('tablist')).toBeInTheDocument(); - expect(renderResult.getByText('Agent policy 3')).toBeInTheDocument(); - }); + expect(renderResult.getByRole('tablist')).toBeInTheDocument(); expect(renderResult.getByText('New hosts').closest('button')).toHaveAttribute( 'aria-selected', 'true' @@ -157,16 +157,15 @@ describe('StepSelectHosts', () => { render(); - waitFor(() => { - expect(renderResult.getByRole('tablist')).toBeInTheDocument(); - }); - act(() => { - fireEvent.click(renderResult.getByText('Existing hosts').closest('button')!); - }); + expect(renderResult.getByRole('tablist')).toBeInTheDocument(); + + await userEvent.click(renderResult.getByText('Existing hosts').closest('button')!); - expect( - renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]')?.textContent - ).toContain('Agent policy 1'); + await waitFor(() => { + expect( + renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]')?.textContent + ).toContain('Agent policy 1'); + }); }); it('should display dropdown without preselected value when Existing hosts selected with mulitple agent policies', async () => { @@ -185,14 +184,11 @@ describe('StepSelectHosts', () => { render(); - waitFor(() => { - expect(renderResult.getByRole('tablist')).toBeInTheDocument(); - }); - act(() => { - fireEvent.click(renderResult.getByText('Existing hosts').closest('button')!); - }); + expect(renderResult.getByRole('tablist')).toBeInTheDocument(); + + await userEvent.click(renderResult.getByText('Existing hosts').closest('button')!); - await act(async () => { + await waitFor(() => { const select = renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]'); expect((select as any)?.value).toEqual(''); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts index 4f6da695f260a..82e4f80c9b271 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook, act } from '@testing-library/react-hooks/dom'; import { waitFor } from '@testing-library/react'; @@ -135,9 +135,7 @@ describe('useAgentless', () => { }); }); -// FLAKY: https://github.com/elastic/kibana/issues/189038 -// FLAKY: https://github.com/elastic/kibana/issues/192126 -describe.skip('useSetupTechnology', () => { +describe('useSetupTechnology', () => { const setNewAgentPolicy = jest.fn(); const updateAgentPoliciesMock = jest.fn(); const setSelectedPolicyTabMock = jest.fn(); @@ -298,7 +296,7 @@ describe.skip('useSetupTechnology', () => { }); it('should fetch agentless policy if agentless feature is enabled and isServerless is true', async () => { - const { waitForNextUpdate } = renderHook(() => + renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -308,9 +306,9 @@ describe.skip('useSetupTechnology', () => { }) ); - await waitForNextUpdate(); - - expect(sendGetOneAgentPolicy).toHaveBeenCalled(); + await waitFor(() => { + expect(sendGetOneAgentPolicy).toHaveBeenCalled(); + }); }); it('should set agentless setup technology if agent policy supports agentless in edit page', async () => { @@ -356,7 +354,7 @@ describe.skip('useSetupTechnology', () => { isCloudEnabled: true, }, }); - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -371,14 +369,13 @@ describe.skip('useSetupTechnology', () => { act(() => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS); }); - - waitForNextUpdate(); - - expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENTLESS); - expect(setNewAgentPolicy).toHaveBeenCalledWith({ - name: 'Agentless policy for endpoint-1', - supports_agentless: true, - inactivity_timeout: 3600, + await waitFor(() => { + expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENTLESS); + expect(setNewAgentPolicy).toHaveBeenCalledWith({ + name: 'Agentless policy for endpoint-1', + supports_agentless: true, + inactivity_timeout: 3600, + }); }); }); @@ -396,21 +393,20 @@ describe.skip('useSetupTechnology', () => { isCloudEnabled: true, }, }); - const { result, rerender } = renderHook(() => - useSetupTechnology({ - setNewAgentPolicy, - newAgentPolicy: newAgentPolicyMock, - updateAgentPolicies: updateAgentPoliciesMock, - setSelectedPolicyTab: setSelectedPolicyTabMock, - packagePolicy: packagePolicyMock, - }) - ); - await rerender(); + const initialProps = { + setNewAgentPolicy, + newAgentPolicy: newAgentPolicyMock, + updateAgentPolicies: updateAgentPoliciesMock, + setSelectedPolicyTab: setSelectedPolicyTabMock, + packagePolicy: packagePolicyMock, + }; - expect(generateNewAgentPolicyWithDefaults).toHaveBeenCalled(); + const { result, rerender } = renderHook((props = initialProps) => useSetupTechnology(props), { + initialProps, + }); - expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); + expect(generateNewAgentPolicyWithDefaults).toHaveBeenCalled(); act(() => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS); @@ -434,7 +430,7 @@ describe.skip('useSetupTechnology', () => { }, }); - waitFor(() => { + await waitFor(() => { expect(setNewAgentPolicy).toHaveBeenCalledWith({ name: 'Agentless policy for endpoint-2', inactivity_timeout: 3600, @@ -451,7 +447,7 @@ describe.skip('useSetupTechnology', () => { }, }); - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -467,8 +463,7 @@ describe.skip('useSetupTechnology', () => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENT_BASED); }); - waitForNextUpdate(); - expect(setNewAgentPolicy).toHaveBeenCalledTimes(0); + await waitFor(() => expect(setNewAgentPolicy).toHaveBeenCalledTimes(0)); }); it('should not fetch agentless policy if agentless is enabled but serverless is disabled', async () => { @@ -493,7 +488,7 @@ describe.skip('useSetupTechnology', () => { }); it('should update agent policy and selected policy tab when setup technology is agentless', async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -503,18 +498,24 @@ describe.skip('useSetupTechnology', () => { }) ); - await waitForNextUpdate(); - act(() => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS); }); - expect(updateAgentPoliciesMock).toHaveBeenCalledWith([{ id: 'agentless-policy-id' }]); - expect(setSelectedPolicyTabMock).toHaveBeenCalledWith(SelectedPolicyTab.EXISTING); + await waitFor(() => { + expect(updateAgentPoliciesMock).toHaveBeenCalledWith([ + { + inactivity_timeout: 3600, + name: 'Agentless policy for endpoint-1', + supports_agentless: true, + }, + ]); + expect(setSelectedPolicyTabMock).toHaveBeenCalledWith(SelectedPolicyTab.EXISTING); + }); }); it('should update new agent policy and selected policy tab when setup technology is agent-based', async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -524,8 +525,6 @@ describe.skip('useSetupTechnology', () => { }) ); - await waitForNextUpdate(); - expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); act(() => { @@ -540,8 +539,10 @@ describe.skip('useSetupTechnology', () => { expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); - expect(setNewAgentPolicy).toHaveBeenCalledWith(newAgentPolicyMock); - expect(setSelectedPolicyTabMock).toHaveBeenCalledWith(SelectedPolicyTab.NEW); + await waitFor(() => { + expect(setNewAgentPolicy).toHaveBeenCalledWith(newAgentPolicyMock); + expect(setSelectedPolicyTabMock).toHaveBeenCalledWith(SelectedPolicyTab.NEW); + }); }); it('should not update agent policy and selected policy tab when agentless is disabled', async () => { @@ -569,7 +570,7 @@ describe.skip('useSetupTechnology', () => { }); it('should not update agent policy and selected policy tab when setup technology matches the current one ', async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -579,7 +580,7 @@ describe.skip('useSetupTechnology', () => { }) ); - await waitForNextUpdate(); + await waitFor(() => new Promise((resolve) => resolve(null))); expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); @@ -594,7 +595,7 @@ describe.skip('useSetupTechnology', () => { }); it('should revert the agent policy name to the original value when switching from agentless back to agent-based', async () => { - const { result, rerender } = renderHook(() => + const { result } = renderHook(() => useSetupTechnology({ setNewAgentPolicy, newAgentPolicy: newAgentPolicyMock, @@ -603,7 +604,6 @@ describe.skip('useSetupTechnology', () => { packagePolicy: packagePolicyMock, }) ); - await rerender(); expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); @@ -612,20 +612,68 @@ describe.skip('useSetupTechnology', () => { }); expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENTLESS); - expect(setNewAgentPolicy).toHaveBeenCalledWith({ - id: 'agentless-policy-id', + + await waitFor(() => { + expect(setNewAgentPolicy).toHaveBeenCalledWith({ + name: 'Agentless policy for endpoint-1', + supports_agentless: true, + inactivity_timeout: 3600, + }); }); act(() => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENT_BASED); }); + + expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); + expect(setNewAgentPolicy).toHaveBeenCalledWith(newAgentPolicyMock); + }); + + it('should have global_data_tags with the integration team when creating agentless policy with global_data_tags', async () => { + (useConfig as MockFn).mockReturnValue({ + agentless: { + enabled: true, + api: { + url: 'https://agentless.api.url', + }, + }, + } as any); + (useStartServices as MockFn).mockReturnValue({ + cloud: { + isCloudEnabled: true, + }, + }); + + const { result } = renderHook(() => + useSetupTechnology({ + setNewAgentPolicy, + newAgentPolicy: newAgentPolicyMock, + updateAgentPolicies: updateAgentPoliciesMock, + setSelectedPolicyTab: setSelectedPolicyTabMock, + packagePolicy: packagePolicyMock, + packageInfo: packageInfoMock, + }) + ); + + act(() => { + result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS, 'cspm'); + }); + await waitFor(() => { - expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENT_BASED); - expect(setNewAgentPolicy).toHaveBeenCalledWith(newAgentPolicyMock); + expect(setNewAgentPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + supports_agentless: true, + global_data_tags: [ + { name: 'organization', value: 'org' }, + { name: 'division', value: 'div' }, + { name: 'team', value: 'team' }, + ], + }) + ); }); }); - it('should have global_data_tags with the integration team when updating the agentless policy', async () => { + it('should not fail and not have global_data_tags when creating the agentless policy when it cannot find the policy template', async () => { (useConfig as MockFn).mockReturnValue({ agentless: { enabled: true, @@ -648,19 +696,23 @@ describe.skip('useSetupTechnology', () => { setSelectedPolicyTab: setSelectedPolicyTabMock, packagePolicy: packagePolicyMock, packageInfo: packageInfoMock, - isEditPage: true, - agentPolicies: [{ id: 'agentless-policy-id', supports_agentless: true } as any], }) ); act(() => { - result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS, 'cspm'); + result.current.handleSetupTechnologyChange( + SetupTechnology.AGENTLESS, + 'never-gonna-give-you-up' + ); }); - waitFor(() => { + await waitFor(() => { expect(setNewAgentPolicy).toHaveBeenCalledWith({ - ...newAgentPolicyMock, + name: 'Agentless policy for endpoint-1', supports_agentless: true, + inactivity_timeout: 3600, + }); + expect(setNewAgentPolicy).not.toHaveBeenCalledWith({ global_data_tags: [ { name: 'organization', value: 'org' }, { name: 'division', value: 'div' }, @@ -670,7 +722,7 @@ describe.skip('useSetupTechnology', () => { }); }); - it('should not fail and not have global_data_tags when updating the agentless policy when it cannot find the policy template', async () => { + it('should not fail and not have global_data_tags when creating the agentless policy without the policy template name', async () => { (useConfig as MockFn).mockReturnValue({ agentless: { enabled: true, @@ -692,27 +744,31 @@ describe.skip('useSetupTechnology', () => { updateAgentPolicies: updateAgentPoliciesMock, setSelectedPolicyTab: setSelectedPolicyTabMock, packagePolicy: packagePolicyMock, - isEditPage: true, - agentPolicies: [{ id: 'agentless-policy-id', supports_agentless: true } as any], + packageInfo: packageInfoMock, }) ); act(() => { - result.current.handleSetupTechnologyChange( - SetupTechnology.AGENTLESS, - 'never-gonna-give-you-up' - ); + result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS); }); - waitFor(() => { + await waitFor(() => { expect(setNewAgentPolicy).toHaveBeenCalledWith({ - ...newAgentPolicyMock, + name: 'Agentless policy for endpoint-1', supports_agentless: true, + inactivity_timeout: 3600, + }); + expect(setNewAgentPolicy).not.toHaveBeenCalledWith({ + global_data_tags: [ + { name: 'organization', value: 'org' }, + { name: 'division', value: 'div' }, + { name: 'team', value: 'team' }, + ], }); }); }); - it('should not fail and not have global_data_tags when updating the agentless policy without the policy temaplte name', async () => { + it('should not fail and not have global_data_tags when creating the agentless policy without the packageInfo', async () => { (useConfig as MockFn).mockReturnValue({ agentless: { enabled: true, @@ -734,25 +790,30 @@ describe.skip('useSetupTechnology', () => { updateAgentPolicies: updateAgentPoliciesMock, setSelectedPolicyTab: setSelectedPolicyTabMock, packagePolicy: packagePolicyMock, - packageInfo: packageInfoMock, - isEditPage: true, - agentPolicies: [{ id: 'agentless-policy-id', supports_agentless: true } as any], }) ); act(() => { - result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS); + result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS, 'cspm'); }); - waitFor(() => { + await waitFor(() => { expect(setNewAgentPolicy).toHaveBeenCalledWith({ - ...newAgentPolicyMock, + name: 'Agentless policy for endpoint-1', supports_agentless: true, + inactivity_timeout: 3600, + }); + expect(setNewAgentPolicy).not.toHaveBeenCalledWith({ + global_data_tags: [ + { name: 'organization', value: 'org' }, + { name: 'division', value: 'div' }, + { name: 'team', value: 'team' }, + ], }); }); }); - it('should not fail and not have global_data_tags when updating the agentless policy without the packageInfo', async () => { + it('should not have global_data_tags when switching from agentless to agent-based policy', async () => { (useConfig as MockFn).mockReturnValue({ agentless: { enabled: true, @@ -774,8 +835,7 @@ describe.skip('useSetupTechnology', () => { updateAgentPolicies: updateAgentPoliciesMock, setSelectedPolicyTab: setSelectedPolicyTabMock, packagePolicy: packagePolicyMock, - isEditPage: true, - agentPolicies: [{ id: 'agentless-policy-id', supports_agentless: true } as any], + packageInfo: packageInfoMock, }) ); @@ -783,10 +843,31 @@ describe.skip('useSetupTechnology', () => { result.current.handleSetupTechnologyChange(SetupTechnology.AGENTLESS, 'cspm'); }); - waitFor(() => { - expect(setNewAgentPolicy).toHaveBeenCalledWith({ - ...newAgentPolicyMock, - supports_agentless: true, + await waitFor(() => { + expect(setNewAgentPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + supports_agentless: true, + global_data_tags: [ + { name: 'organization', value: 'org' }, + { name: 'division', value: 'div' }, + { name: 'team', value: 'team' }, + ], + }) + ); + }); + + act(() => { + result.current.handleSetupTechnologyChange(SetupTechnology.AGENT_BASED); + }); + + await waitFor(() => { + expect(setNewAgentPolicy).toHaveBeenCalledWith(newAgentPolicyMock); + expect(setNewAgentPolicy).not.toHaveBeenCalledWith({ + global_data_tags: [ + { name: 'organization', value: 'org' }, + { name: 'division', value: 'div' }, + { name: 'team', value: 'team' }, + ], }); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts index 465a7241b3ad3..6bd3288af2e0d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts @@ -180,6 +180,7 @@ export function useSetupTechnology({ } as NewAgentPolicy; setNewAgentPolicy(agentlessPolicy); + setNewAgentlessPolicy(agentlessPolicy); setSelectedPolicyTab(SelectedPolicyTab.NEW); updateAgentPolicies([agentlessPolicy] as AgentPolicy[]); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/components/step_edit_hosts.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/components/step_edit_hosts.test.tsx index 064624d364a92..e30fa6c22c5ce 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/components/step_edit_hosts.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/components/step_edit_hosts.test.tsx @@ -6,7 +6,9 @@ */ import React from 'react'; -import { act, fireEvent, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; + +import { userEvent } from '@testing-library/user-event'; import type { TestRenderer } from '../../../../../../mock'; import { createFleetTestRendererMock } from '../../../../../../mock'; @@ -111,18 +113,18 @@ describe('StepEditHosts', () => { testRenderer = createFleetTestRendererMock(); }); - it('should display create form when no agent policies', () => { + it('should display create form when no agent policies', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [], }, }); + (useAllNonManagedAgentPolicies as jest.MockedFunction).mockReturnValue([]); + render(); - waitFor(() => { - expect(renderResult.getByText('Agent policy 1')).toBeInTheDocument(); - }); + expect(renderResult.getByText('New agent policy name')).toBeInTheDocument(); expect(renderResult.queryByRole('tablist')).not.toBeInTheDocument(); }); @@ -144,7 +146,7 @@ describe('StepEditHosts', () => { ).toContain('Agent policy 1'); }); - it('should display dropdown without preselected value when mulitple agent policies', () => { + it('should display dropdown without preselected value when multiple agent policies', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [ @@ -156,12 +158,12 @@ describe('StepEditHosts', () => { render(); - waitFor(() => { - expect(renderResult.getByText('At least one agent policy is required.')).toBeInTheDocument(); - }); + expect( + renderResult.getByText('Select an agent policy to add this integration to') + ).toBeInTheDocument(); }); - it('should display delete button when add button clicked', () => { + it('should display delete button when add button clicked', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [{ id: '1', name: 'Agent policy 1', namespace: 'default' }], @@ -173,10 +175,12 @@ describe('StepEditHosts', () => { render(); - act(() => { - fireEvent.click(renderResult.getByTestId('createNewAgentPolicyButton').closest('button')!); - }); + await userEvent.click( + renderResult.getByTestId('createNewAgentPolicyButton').closest('button')! + ); - expect(renderResult.getByTestId('deleteNewAgentPolicyButton')).toBeInTheDocument(); + await waitFor(() => { + expect(renderResult.getByTestId('deleteNewAgentPolicyButton')).toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/lens/public/lens_suggestions_api/helpers.test.ts b/x-pack/plugins/lens/public/lens_suggestions_api/helpers.test.ts new file mode 100644 index 0000000000000..177a7e2e0d33c --- /dev/null +++ b/x-pack/plugins/lens/public/lens_suggestions_api/helpers.test.ts @@ -0,0 +1,206 @@ +/* + * 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 type { DatatableColumn } from '@kbn/expressions-plugin/common'; +import { mergeSuggestionWithVisContext } from './helpers'; +import { mockAllSuggestions } from '../mocks'; +import type { TypedLensByValueInput } from '../embeddable/embeddable_component'; + +const context = { + dataViewSpec: { + id: 'index1', + title: 'index1', + name: 'DataView', + }, + fieldName: '', + textBasedColumns: [ + { + id: 'field1', + name: 'field1', + meta: { + type: 'number', + }, + }, + { + id: 'field2', + name: 'field2', + meta: { + type: 'string', + }, + }, + ] as DatatableColumn[], + query: { + esql: 'FROM index1 | keep field1, field2', + }, +}; + +describe('lens suggestions api helpers', () => { + describe('mergeSuggestionWithVisContext', () => { + it('should return the suggestion as it is if the visualization types do not match', async () => { + const suggestion = mockAllSuggestions[0]; + const visAttributes = { + visualizationType: 'lnsXY', + state: { + visualization: { + preferredSeriesType: 'bar_stacked', + }, + datasourceStates: { textBased: { layers: {} } }, + }, + } as unknown as TypedLensByValueInput['attributes']; + expect(mergeSuggestionWithVisContext({ suggestion, visAttributes, context })).toStrictEqual( + suggestion + ); + }); + + it('should return the suggestion as it is if the context is not from ES|QL', async () => { + const nonESQLContext = { + dataViewSpec: { + id: 'index1', + title: 'index1', + name: 'DataView', + }, + fieldName: 'field1', + }; + const suggestion = mockAllSuggestions[0]; + const visAttributes = { + visualizationType: 'lnsHeatmap', + state: { + visualization: { + preferredSeriesType: 'bar_stacked', + }, + datasourceStates: { textBased: { layers: {} } }, + }, + } as unknown as TypedLensByValueInput['attributes']; + expect( + mergeSuggestionWithVisContext({ suggestion, visAttributes, context: nonESQLContext }) + ).toStrictEqual(suggestion); + }); + + it('should return the suggestion as it is for DSL config (formbased)', async () => { + const suggestion = mockAllSuggestions[0]; + const visAttributes = { + visualizationType: 'lnsHeatmap', + state: { + visualization: { + preferredSeriesType: 'bar_stacked', + }, + datasourceStates: { formBased: { layers: {} } }, + }, + } as unknown as TypedLensByValueInput['attributes']; + expect(mergeSuggestionWithVisContext({ suggestion, visAttributes, context })).toStrictEqual( + suggestion + ); + }); + + it('should return the suggestion as it is for columns that dont match the context', async () => { + const suggestion = mockAllSuggestions[0]; + const visAttributes = { + visualizationType: 'lnsHeatmap', + state: { + visualization: { + shape: 'heatmap', + }, + datasourceStates: { + textBased: { + layers: { + layer1: { + index: 'layer1', + query: { + esql: 'FROM kibana_sample_data_flights | keep Dest, AvgTicketPrice', + }, + columns: [ + { + columnId: 'colA', + fieldName: 'Dest', + meta: { + type: 'string', + }, + }, + { + columnId: 'colB', + fieldName: 'AvgTicketPrice', + meta: { + type: 'number', + }, + }, + ], + timeField: 'timestamp', + }, + }, + }, + }, + }, + } as unknown as TypedLensByValueInput['attributes']; + expect(mergeSuggestionWithVisContext({ suggestion, visAttributes, context })).toStrictEqual( + suggestion + ); + }); + + it('should return the suggestion updated with the attributes if the visualization types and the context columns match', async () => { + const suggestion = mockAllSuggestions[0]; + const visAttributes = { + visualizationType: 'lnsHeatmap', + state: { + visualization: { + shape: 'heatmap', + layerId: 'layer1', + layerType: 'data', + legend: { + isVisible: false, + position: 'left', + type: 'heatmap_legend', + }, + gridConfig: { + type: 'heatmap_grid', + isCellLabelVisible: true, + isYAxisLabelVisible: false, + isXAxisLabelVisible: false, + isYAxisTitleVisible: false, + isXAxisTitleVisible: false, + }, + valueAccessor: 'acc1', + xAccessor: 'acc2', + }, + datasourceStates: { + textBased: { + layers: { + layer1: { + index: 'layer1', + query: { + esql: 'FROM index1 | keep field1, field2', + }, + columns: [ + { + columnId: 'field2', + fieldName: 'field2', + meta: { + type: 'string', + }, + }, + { + columnId: 'field1', + fieldName: 'field1', + meta: { + type: 'number', + }, + }, + ], + timeField: 'timestamp', + }, + }, + }, + }, + }, + } as unknown as TypedLensByValueInput['attributes']; + const updatedSuggestion = mergeSuggestionWithVisContext({ + suggestion, + visAttributes, + context, + }); + expect(updatedSuggestion.visualizationState).toStrictEqual(visAttributes.state.visualization); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/lens_suggestions_api/helpers.ts b/x-pack/plugins/lens/public/lens_suggestions_api/helpers.ts new file mode 100644 index 0000000000000..394d32e8c5bb7 --- /dev/null +++ b/x-pack/plugins/lens/public/lens_suggestions_api/helpers.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; +import { getDatasourceId } from '@kbn/visualization-utils'; +import type { VisualizeEditorContext, Suggestion } from '../types'; +import type { TypedLensByValueInput } from '../embeddable/embeddable_component'; + +/** + * Returns the suggestion updated with external visualization state for ES|QL charts + * The visualization state is merged with the suggestion if the datasource is textBased, the columns match the context and the visualization type matches + * @param suggestion the suggestion to be updated + * @param visAttributes the preferred visualization attributes + * @param context the lens suggestions api context as being set by the consumers + * @returns updated suggestion + */ + +export function mergeSuggestionWithVisContext({ + suggestion, + visAttributes, + context, +}: { + suggestion: Suggestion; + visAttributes: TypedLensByValueInput['attributes']; + context: VisualizeFieldContext | VisualizeEditorContext; +}): Suggestion { + if ( + visAttributes.visualizationType !== suggestion.visualizationId || + !('textBasedColumns' in context) + ) { + return suggestion; + } + + // it should be one of 'formBased'/'textBased' and have value + const datasourceId = getDatasourceId(visAttributes.state.datasourceStates); + + // if the datasource is formBased, we should not merge + if (!datasourceId || datasourceId === 'formBased') { + return suggestion; + } + const datasourceState = Object.assign({}, visAttributes.state.datasourceStates[datasourceId]); + + // should be based on same columns + if ( + !datasourceState?.layers || + Object.values(datasourceState?.layers).some( + (layer) => + layer.columns?.some( + (c: { fieldName: string }) => + !context?.textBasedColumns?.find((col) => col.name === c.fieldName) + ) || layer.columns?.length !== context?.textBasedColumns?.length + ) + ) { + return suggestion; + } + const layerIds = Object.keys(datasourceState.layers); + try { + return { + title: visAttributes.title, + visualizationId: visAttributes.visualizationType, + visualizationState: visAttributes.state.visualization, + keptLayerIds: layerIds, + datasourceState, + datasourceId, + columns: suggestion.columns, + changeType: suggestion.changeType, + score: suggestion.score, + previewIcon: suggestion.previewIcon, + }; + } catch { + return suggestion; + } +} diff --git a/x-pack/plugins/lens/public/lens_suggestions_api.ts b/x-pack/plugins/lens/public/lens_suggestions_api/index.ts similarity index 76% rename from x-pack/plugins/lens/public/lens_suggestions_api.ts rename to x-pack/plugins/lens/public/lens_suggestions_api/index.ts index 3bdadbf337227..c73379d9a42cd 100644 --- a/x-pack/plugins/lens/public/lens_suggestions_api.ts +++ b/x-pack/plugins/lens/public/lens_suggestions_api/index.ts @@ -6,22 +6,12 @@ */ import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; -import { getSuggestions } from './editor_frame_service/editor_frame/suggestion_helpers'; -import type { DatasourceMap, VisualizationMap, VisualizeEditorContext } from './types'; -import type { DataViewsState } from './state_management'; - -export enum ChartType { - XY = 'XY', - Bar = 'Bar', - Line = 'Line', - Area = 'Area', - Donut = 'Donut', - Heatmap = 'Heat map', - Treemap = 'Treemap', - Tagcloud = 'Tag cloud', - Waffle = 'Waffle', - Table = 'Table', -} +import type { ChartType } from '@kbn/visualization-utils'; +import { getSuggestions } from '../editor_frame_service/editor_frame/suggestion_helpers'; +import type { DatasourceMap, VisualizationMap, VisualizeEditorContext } from '../types'; +import type { DataViewsState } from '../state_management'; +import type { TypedLensByValueInput } from '../embeddable/embeddable_component'; +import { mergeSuggestionWithVisContext } from './helpers'; interface SuggestionsApiProps { context: VisualizeFieldContext | VisualizeEditorContext; @@ -30,6 +20,7 @@ interface SuggestionsApiProps { datasourceMap?: DatasourceMap; excludedVisualizations?: string[]; preferredChartType?: ChartType; + preferredVisAttributes?: TypedLensByValueInput['attributes']; } export const suggestionsApi = ({ @@ -39,6 +30,7 @@ export const suggestionsApi = ({ visualizationMap, excludedVisualizations, preferredChartType, + preferredVisAttributes, }: SuggestionsApiProps) => { const initialContext = context; if (!datasourceMap || !visualizationMap || !dataView.id) return undefined; @@ -79,32 +71,7 @@ export const suggestionsApi = ({ dataViews, }); if (!suggestions.length) return []; - // check if there is an XY chart suggested - // if user has requested for a line or area, we want to sligthly change the state - // to return line / area instead of a bar chart - const chartType = preferredChartType?.toLowerCase(); - const XYSuggestion = suggestions.find((sug) => sug.visualizationId === 'lnsXY'); - if (XYSuggestion && chartType && ['area', 'line'].includes(chartType)) { - const visualizationState = visualizationMap[ - XYSuggestion.visualizationId - ]?.switchVisualizationType?.(chartType, XYSuggestion?.visualizationState); - return [ - { - ...XYSuggestion, - visualizationState, - }, - ]; - } - // in case the user asks for another type (except from area, line) check if it exists - // in suggestions and return this instead - if (suggestions.length > 1 && preferredChartType) { - const suggestionFromModel = suggestions.find( - (s) => s.title.includes(preferredChartType) || s.visualizationId.includes(preferredChartType) - ); - if (suggestionFromModel) { - return [suggestionFromModel]; - } - } + const activeVisualization = suggestions[0]; if ( activeVisualization.incomplete || @@ -126,7 +93,46 @@ export const suggestionsApi = ({ visualizationState: activeVisualization.visualizationState, dataViews, }).filter((sug) => !sug.hide && sug.visualizationId !== 'lnsLegacyMetric'); + + // check if there is an XY chart suggested + // if user has requested for a line or area, we want to sligthly change the state + // to return line / area instead of a bar chart + const chartType = preferredChartType?.toLowerCase(); + const XYSuggestion = newSuggestions.find((s) => s.visualizationId === 'lnsXY'); + // a type can be area, line, area_stacked, area_percentage etc + const isAreaOrLine = ['area', 'line'].some((type) => chartType?.includes(type)); + if (XYSuggestion && chartType && isAreaOrLine) { + const visualizationState = visualizationMap[ + XYSuggestion.visualizationId + ]?.switchVisualizationType?.(chartType, XYSuggestion?.visualizationState); + + return [ + { + ...XYSuggestion, + visualizationState, + }, + ]; + } + // in case the user asks for another type (except from area, line) check if it exists + // in suggestions and return this instead const suggestionsList = [activeVisualization, ...newSuggestions]; + if (suggestionsList.length > 1 && preferredChartType) { + const compatibleSuggestion = suggestionsList.find( + (s) => s.title.includes(preferredChartType) || s.visualizationId.includes(preferredChartType) + ); + + if (compatibleSuggestion) { + const suggestion = preferredVisAttributes + ? mergeSuggestionWithVisContext({ + suggestion: compatibleSuggestion, + visAttributes: preferredVisAttributes, + context, + }) + : compatibleSuggestion; + + return [suggestion]; + } + } // if there is no preference from the user, send everything // until we separate the text based suggestions logic from the dataview one, diff --git a/x-pack/plugins/lens/public/lens_suggestions_api.test.ts b/x-pack/plugins/lens/public/lens_suggestions_api/lens_suggestions_api.test.ts similarity index 74% rename from x-pack/plugins/lens/public/lens_suggestions_api.test.ts rename to x-pack/plugins/lens/public/lens_suggestions_api/lens_suggestions_api.test.ts index 80d2f7a71f6ee..e5e60284e4919 100644 --- a/x-pack/plugins/lens/public/lens_suggestions_api.test.ts +++ b/x-pack/plugins/lens/public/lens_suggestions_api/lens_suggestions_api.test.ts @@ -6,9 +6,11 @@ */ import type { DataView } from '@kbn/data-views-plugin/public'; import type { DatatableColumn } from '@kbn/expressions-plugin/common'; -import { createMockVisualization, DatasourceMock, createMockDatasource } from './mocks'; -import { DatasourceSuggestion } from './types'; -import { suggestionsApi, ChartType } from './lens_suggestions_api'; +import { ChartType } from '@kbn/visualization-utils'; +import { createMockVisualization, DatasourceMock, createMockDatasource } from '../mocks'; +import { DatasourceSuggestion } from '../types'; +import { suggestionsApi } from '.'; +import type { TypedLensByValueInput } from '../embeddable/embeddable_component'; const generateSuggestion = (state = {}, layerId: string = 'first'): DatasourceSuggestion => ({ state, @@ -264,6 +266,9 @@ describe('suggestionsApi', () => { datasourceMap.textBased.getDatasourceSuggestionsForVisualizeField.mockReturnValue([ generateSuggestion(), ]); + datasourceMap.textBased.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(), + ]); const context = { dataViewSpec: { id: 'index1', @@ -284,8 +289,7 @@ describe('suggestionsApi', () => { preferredChartType: ChartType.Line, }); expect(suggestions?.length).toEqual(1); - expect(suggestions?.[0]).toMatchInlineSnapshot( - ` + expect(suggestions?.[0]).toMatchInlineSnapshot(` Object { "changeType": "unchanged", "columns": 0, @@ -302,8 +306,111 @@ describe('suggestionsApi', () => { "preferredSeriesType": "line", }, } - ` - ); + `); + }); + + test('returns the suggestion with the preferred attributes ', async () => { + const dataView = { id: 'index1' } as unknown as DataView; + const visualizationMap = { + lnsXY: { + ...mockVis, + switchVisualizationType(seriesType: string, state: unknown) { + return { + ...(state as Record), + preferredSeriesType: seriesType, + }; + }, + getSuggestions: () => [ + { + score: 0.8, + title: 'bar', + state: { + preferredSeriesType: 'bar_stacked', + legend: { + isVisible: true, + position: 'right', + }, + }, + previewIcon: 'empty', + visualizationId: 'lnsXY', + }, + { + score: 0.8, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + { + score: 0.8, + title: 'Test2', + state: {}, + previewIcon: 'empty', + incomplete: true, + }, + ], + }, + }; + datasourceMap.textBased.getDatasourceSuggestionsForVisualizeField.mockReturnValue([ + generateSuggestion(), + ]); + datasourceMap.textBased.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(), + ]); + const context = { + dataViewSpec: { + id: 'index1', + title: 'index1', + name: 'DataView', + }, + fieldName: '', + textBasedColumns: textBasedQueryColumns, + query: { + esql: 'FROM "index1" | keep field1, field2', + }, + }; + const suggestions = suggestionsApi({ + context, + dataView, + datasourceMap, + visualizationMap, + preferredChartType: ChartType.XY, + preferredVisAttributes: { + visualizationType: 'lnsXY', + state: { + visualization: { + preferredSeriesType: 'bar_stacked', + legend: { + isVisible: false, + position: 'left', + }, + }, + datasourceStates: { textBased: { layers: {} } }, + }, + } as unknown as TypedLensByValueInput['attributes'], + }); + expect(suggestions?.length).toEqual(1); + expect(suggestions?.[0]).toMatchInlineSnapshot(` + Object { + "changeType": "unchanged", + "columns": 0, + "datasourceId": "textBased", + "datasourceState": Object { + "layers": Object {}, + }, + "keptLayerIds": Array [], + "previewIcon": "empty", + "score": 0.8, + "title": undefined, + "visualizationId": "lnsXY", + "visualizationState": Object { + "legend": Object { + "isVisible": false, + "position": "left", + }, + "preferredSeriesType": "bar_stacked", + }, + } + `); }); test('filters out the suggestion if exists on excludedVisualizations', async () => { diff --git a/x-pack/plugins/lens/public/lens_suggestions_api/readme.md b/x-pack/plugins/lens/public/lens_suggestions_api/readme.md new file mode 100644 index 0000000000000..5a9bbef55d32a --- /dev/null +++ b/x-pack/plugins/lens/public/lens_suggestions_api/readme.md @@ -0,0 +1,77 @@ +# Lens Suggestions API + +This document provides an overview of the Lens Suggestions API. It is used mostly for suggesting ES|QL charts based on an ES|QL query. It is used by the observability assistant, Discover and Dashboards ES|QL charts. + +## Overview + +The Lens Suggestions API is designed to provide suggestions for visualizations based on a given ES|QL query. It helps users to quickly find the most relevant visualizations for their data. + +## Getting Started + +To use the Lens Suggestions API, you need to import it from the Lens plugin: + +```typescript +import useAsync from 'react-use/lib/useAsync'; + +const lensHelpersAsync = useAsync(() => { + return lensService?.stateHelperApi() ?? Promise.resolve(null); + }, [lensService]); + + if (lensHelpersAsync.value) { + const suggestionsApi = lensHelpersAsync.value.suggestions; + } +``` + +## The api + +The api returns an array of suggestions. + +#### Parameters + + dataView: DataView; + visualizationMap?: VisualizationMap; + datasourceMap?: DatasourceMap; + excludedVisualizations?: string[]; + preferredChartType?: ChartType; + preferredVisAttributes?: TypedLensByValueInput['attributes']; + +- `context`: The context as descibed by the VisualizeFieldContext. +- `dataView`: The dataView, can be an adhoc one too. For ES|QL you can create a dataview like this + +```typescript +const indexName = (await getIndexForESQLQuery({ dataViews })) ?? '*'; +const dataView = await getESQLAdHocDataview(`from ${indexName}`, dataViews); +``` +Optional parameters: +- `preferredChartType`: Use this if you want the suggestions api to prioritize a specific suggestion type. +- `preferredVisAttributes`: Use this with the preferredChartType if you want to prioritize a specific suggestion type with a non-default visualization state. + +#### Returns + +An array of suggestion objects + +## Example Usage + +```typescript +const abc = new AbortController(); + +const columns = await getESQLQueryColumns({ + esqlQuery, + search: dataService.search.search, + signal: abc.signal, + timeRange: dataService.query.timefilter.timefilter.getAbsoluteTime(), +}); + +const context = { + dataViewSpec: dataView?.toSpec(false), + fieldName: '', + textBasedColumns: columns, + query: { esql: esqlQuery }, +}; + +const chartSuggestions = lensHelpersAsync.value.suggestions(context, dataView); + +suggestions.forEach(suggestion => { + console.log(`Suggestion: ${suggestion.title}, Score: ${suggestion.score}`); +}); +``` \ No newline at end of file diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index b2293ea43b109..3145606abaf6c 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -62,6 +62,7 @@ import { ContentManagementPublicStart, } from '@kbn/content-management-plugin/public'; import { i18n } from '@kbn/i18n'; +import type { ChartType } from '@kbn/visualization-utils'; import type { ServerlessPluginStart } from '@kbn/serverless/public'; import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { EditorFrameService as EditorFrameServiceType } from './editor_frame_service'; @@ -137,7 +138,7 @@ import { } from '../common/content_management'; import type { EditLensConfigurationProps } from './app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration'; import { savedObjectToEmbeddableAttributes } from './lens_attribute_service'; -import { ChartType } from './lens_suggestions_api'; +import type { TypedLensByValueInput } from './embeddable/embeddable_component'; export type { SaveProps } from './app_plugin'; @@ -281,7 +282,8 @@ export type LensSuggestionsApi = ( context: VisualizeFieldContext | VisualizeEditorContext, dataViews: DataView, excludedVisualizations?: string[], - preferredChartType?: ChartType + preferredChartType?: ChartType, + preferredVisAttributes?: TypedLensByValueInput['attributes'] ) => Suggestion[] | undefined; export class LensPlugin { @@ -713,7 +715,13 @@ export class LensPlugin { return { formula: createFormulaPublicApi(), chartInfo: createChartInfoApi(startDependencies.dataViews, this.editorFrameService), - suggestions: (context, dataView, excludedVisualizations, preferredChartType) => { + suggestions: ( + context, + dataView, + excludedVisualizations, + preferredChartType, + preferredVisAttributes + ) => { return suggestionsApi({ datasourceMap, visualizationMap, @@ -721,6 +729,7 @@ export class LensPlugin { dataView, excludedVisualizations, preferredChartType, + preferredVisAttributes, }); }, }; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.tsx index e1889c7bc199a..a570d4ba0276a 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.tsx @@ -37,7 +37,7 @@ import { VisualizeESQLUserIntention, } from '@kbn/observability-ai-assistant-plugin/public'; import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; -import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils'; +import { getLensAttributesFromSuggestion, ChartType } from '@kbn/visualization-utils'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import ReactDOM from 'react-dom'; import useAsync from 'react-use/lib/useAsync'; @@ -48,19 +48,6 @@ import type { } from '../../common/functions/visualize_esql'; import { ObservabilityAIAssistantAppPluginStartDependencies } from '../types'; -enum ChartType { - XY = 'XY', - Bar = 'Bar', - Line = 'Line', - Area = 'Area', - Donut = 'Donut', - Heatmap = 'Heat map', - Treemap = 'Treemap', - Tagcloud = 'Tag cloud', - Waffle = 'Waffle', - Table = 'Table', -} - interface VisualizeESQLProps { /** Lens start contract, get the ES|QL charts suggestions api */ lens: LensPublicStart; diff --git a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_alerts.spec.ts b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_alerts.spec.ts index 7468437d8bc72..6c009682e1421 100644 --- a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_alerts.spec.ts +++ b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_alerts.spec.ts @@ -137,7 +137,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { after(() => apmSynthtraceEsClient.clean()); - describe('Transaction groups with avg transaction duration alerts', () => { + // FLAKY: https://github.com/elastic/kibana/issues/198866 + describe.skip('Transaction groups with avg transaction duration alerts', () => { let ruleId: string; let alerts: ApmAlertFields[]; diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_render_app_wrapper.ts b/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_render_app_wrapper.ts new file mode 100644 index 0000000000000..a2a1d4d9156ae --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_render_app_wrapper.ts @@ -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'); + }); + }); + }); +} diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/index.ts b/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/index.ts index cf2d861bb7b7d..9fb95c5ccd962 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/index.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/index.ts @@ -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')); }); } diff --git a/x-pack/test_serverless/functional/test_suites/observability/config.context_awareness.ts b/x-pack/test_serverless/functional/test_suites/observability/config.context_awareness.ts index 76362cc111e6f..283e4e7e10a2f 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/config.context_awareness.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/config.context_awareness.ts @@ -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 diff --git a/x-pack/test_serverless/functional/test_suites/search/config.context_awareness.ts b/x-pack/test_serverless/functional/test_suites/search/config.context_awareness.ts index 7b608c29c9f3a..6be4a7e30e999 100644 --- a/x-pack/test_serverless/functional/test_suites/search/config.context_awareness.ts +++ b/x-pack/test_serverless/functional/test_suites/search/config.context_awareness.ts @@ -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 diff --git a/x-pack/test_serverless/functional/test_suites/security/config.context_awareness.ts b/x-pack/test_serverless/functional/test_suites/security/config.context_awareness.ts index 6276922df83f4..984ce1c904d80 100644 --- a/x-pack/test_serverless/functional/test_suites/security/config.context_awareness.ts +++ b/x-pack/test_serverless/functional/test_suites/security/config.context_awareness.ts @@ -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 diff --git a/yarn.lock b/yarn.lock index e2a1487cf91af..fd1a40ecb0786 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8103,10 +8103,10 @@ resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== -"@openfeature/core@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@openfeature/core/-/core-1.4.0.tgz#07a929ef6f731903b210cdbaa58d76d4d2623a79" - integrity sha512-Cd5eeAouAYaj1RMgVq4gfasoAc4TSkN4fuhloZ3yCQA2t74IdVMAT0iadq1Seqy+G7PZoN2jy706ei9HT55PIg== +"@openfeature/core@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@openfeature/core/-/core-1.5.0.tgz#5fda73aa125dfb5729f5dd7362df00b6dca23a24" + integrity sha512-dRBJjnYhEa6XoF9BNf9sW4sHuXmigfBbbatA5djbRXRBDExrXsMydMpEWQqKYhd7XwdwFatuh2q+UkVbXriUKA== "@openfeature/launchdarkly-client-provider@^0.3.0": version "0.3.0" @@ -8115,15 +8115,15 @@ dependencies: lodash.isempty "4.4.0" -"@openfeature/server-sdk@^1.15.1": - version "1.15.1" - resolved "https://registry.yarnpkg.com/@openfeature/server-sdk/-/server-sdk-1.15.1.tgz#7a20ca06297f947f6060852e072b0bc24e03f126" - integrity sha512-PaJETh/fr4N8BVQlgb5vBH8VdN25VhxaVvL0s4Wv3kAUC+MXi7B9hEVM1GUlI9CrjxRExlbAAYtLY7kzjE7SXg== +"@openfeature/server-sdk@^1.16.1": + version "1.16.1" + resolved "https://registry.yarnpkg.com/@openfeature/server-sdk/-/server-sdk-1.16.1.tgz#2f32aeca5ff8d5e97deb2ee8a72daae38cc40461" + integrity sha512-5xcsuQTyomKFSs+VbW1fGZATGFE1mLewHZ220IRzLtlSeNNRoRIpYMtkxn7N9dG9k+rqikv+SrVD0/LoPDJiqg== -"@openfeature/web-sdk@^1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@openfeature/web-sdk/-/web-sdk-1.2.4.tgz#5b3e1805f81fd0e50bbe10776292ba24a2239ac4" - integrity sha512-v3RYqMIq+/UXH7eVqfTfp7iWPJ4/Ck5a3RwxAEhypocq5IxUDyEUxXvVU82bkVkbNEKvXYLUWlxT+IuHvh8Eng== +"@openfeature/web-sdk@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@openfeature/web-sdk/-/web-sdk-1.3.1.tgz#001bfdcb5cb38b760670a319d66cf3d0febf2b6d" + integrity sha512-KpsekYseZ0zQcDa/WzylqBA5SOxS4xv2goEZl2SB4nd6lEJMTEW2qOkXPhJiV3qXAt8bcrv+Yr0sbCwJ+u+U/Q== "@opentelemetry/api-metrics@0.31.0", "@opentelemetry/api-metrics@^0.31.0": version "0.31.0" @@ -8481,12 +8481,12 @@ require-from-string "^2.0.2" uri-js-replace "^1.0.1" -"@redocly/cli@^1.25.8": - version "1.25.8" - resolved "https://registry.yarnpkg.com/@redocly/cli/-/cli-1.25.8.tgz#fecd62d9ee1d564e6f0e1522f2c5648f514ce02b" - integrity sha512-oVFN3rpGFqupx57ZS0mF2B8grnk3i0xjTQrrMm1oftF3GEf7yTg5JzwnWi8KKRWuxin4qI7j+Id5AKgNQNmTKA== +"@redocly/cli@^1.25.9": + version "1.25.10" + resolved "https://registry.yarnpkg.com/@redocly/cli/-/cli-1.25.10.tgz#647e33e4171d74a4f879304ba87366ac650ed83d" + integrity sha512-zoRMvSYOLzurcb3be5HLLlc5dLGICyHY8mueCbdE2DmLbFERhJJ5iiABKvNRJSr03AR6X569f4mraBJpAsGJnQ== dependencies: - "@redocly/openapi-core" "1.25.8" + "@redocly/openapi-core" "1.25.10" abort-controller "^3.0.0" chokidar "^3.5.1" colorette "^1.2.0" @@ -8506,18 +8506,18 @@ styled-components "^6.0.7" yargs "17.0.1" -"@redocly/config@^0.12.1": - version "0.12.1" - resolved "https://registry.yarnpkg.com/@redocly/config/-/config-0.12.1.tgz#7b905a17d710244550ef826542d0db164d5ace02" - integrity sha512-RW3rSirfsPdr0uvATijRDU3f55SuZV3m7/ppdTDvGw4IB0cmeZRkFmqTrchxMqWP50Gfg1tpHnjdxUCNo0E2qg== +"@redocly/config@^0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@redocly/config/-/config-0.16.0.tgz#4b7700a5cb6e04bc6d6fdb94b871c9e260a1fba6" + integrity sha512-t9jnODbUcuANRSl/K4L9nb12V+U5acIHnVSl26NWrtSdDZVtoqUXk2yGFPZzohYf62cCfEQUT8ouJ3bhPfpnJg== -"@redocly/openapi-core@1.25.8", "@redocly/openapi-core@^1.4.0": - version "1.25.8" - resolved "https://registry.yarnpkg.com/@redocly/openapi-core/-/openapi-core-1.25.8.tgz#a3aff052b1d9d2db8ba86263ec994bbc85f6b8f1" - integrity sha512-eKKRqo2RYo7UIoDvIgcUB9ynhOjIWJnILXFz+VDevYeOBKd/CxvC0KbNRnuOrFqG3ip6363R/ONal2MyvuVrjg== +"@redocly/openapi-core@1.25.10", "@redocly/openapi-core@^1.4.0": + version "1.25.10" + resolved "https://registry.yarnpkg.com/@redocly/openapi-core/-/openapi-core-1.25.10.tgz#6ca3f1ad1b826e3680f91752abf11aa40856f6b8" + integrity sha512-wcGnSonJZvjpPaJJs+qh0ADYy0aCbaNhCXhJVES9RlknMc7V9nbqLQ67lkwaXhpp/fskm9GJWL/U9Xyiuclbqw== dependencies: "@redocly/ajv" "^8.11.2" - "@redocly/config" "^0.12.1" + "@redocly/config" "^0.16.0" colorette "^1.2.0" https-proxy-agent "^7.0.4" js-levenshtein "^1.1.6" @@ -11724,7 +11724,7 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.62.0", "@typescript-eslint/utils@^5.10.0", "@typescript-eslint/utils@^6.18.1": +"@typescript-eslint/utils@5.62.0", "@typescript-eslint/utils@^5.10.0", "@typescript-eslint/utils@^5.62.0", "@typescript-eslint/utils@^6.18.1": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" integrity sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ== @@ -17472,6 +17472,13 @@ eslint-plugin-react@^7.32.2: semver "^6.3.0" string.prototype.matchall "^4.0.8" +eslint-plugin-testing-library@^6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-6.4.0.tgz#1ba8a7422e3e31cc315a73ff17c34908f56f9838" + integrity sha512-yeWF+YgCgvNyPNI9UKnG0FjeE2sk93N/3lsKqcmR8dSfeXJwFT5irnWo7NjLf152HkRzfoFjh3LsBUrhvFz4eA== + dependencies: + "@typescript-eslint/utils" "^5.62.0" + eslint-rule-composer@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9"