[Stateful sidenav] Cleanup iteration 2 (#184367)

This commit is contained in:
Sébastien Loix 2024-06-03 12:59:27 +01:00 committed by GitHub
parent b4238544ae
commit eee34b2486
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 152 additions and 1245 deletions

View file

@ -311,7 +311,6 @@ enabled:
- x-pack/test/functional/apps/ml/short_tests/config.ts
- x-pack/test/functional/apps/ml/stack_management_jobs/config.ts
- x-pack/test/functional/apps/monitoring/config.ts
- x-pack/test/functional/apps/navigation/config.ts
- x-pack/test/functional/apps/observability_logs_explorer/config.ts
- x-pack/test/functional/apps/dataset_quality/config.ts
- x-pack/test/functional/apps/painless_lab/config.ts
@ -325,7 +324,6 @@ enabled:
- x-pack/test/functional/apps/search_playground/config.ts
- x-pack/test/functional/apps/snapshot_restore/config.ts
- x-pack/test/functional/apps/spaces/config.ts
- x-pack/test/functional/apps/spaces/in_solution_navigation/config.ts
- x-pack/test/functional/apps/status_page/config.ts
- x-pack/test/functional/apps/transform/creation/index_pattern/config.ts
- x-pack/test/functional/apps/transform/creation/runtime_mappings_saved_search/config.ts

View file

@ -83,13 +83,16 @@ export class ChromeService {
private readonly navLinks = new NavLinksService();
private readonly recentlyAccessed = new RecentlyAccessedService();
private readonly docTitle = new DocTitleService();
private readonly projectNavigation = new ProjectNavigationService();
private readonly projectNavigation: ProjectNavigationService;
private mutationObserver: MutationObserver | undefined;
private readonly isSideNavCollapsed$ = new BehaviorSubject<boolean>(true);
private logger: Logger;
private isServerless = false;
constructor(private readonly params: ConstructorParams) {
this.logger = params.coreContext.logger.get('chrome-browser');
this.isServerless = params.coreContext.env.packageInfo.buildFlavor === 'serverless';
this.projectNavigation = new ProjectNavigationService(this.isServerless);
}
/**

View file

@ -7,28 +7,25 @@
*/
import React from 'react';
import { EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { EuiContextMenuPanel, EuiContextMenuItem, EuiButtonEmpty } from '@elastic/eui';
import type {
AppDeepLinkId,
ChromeProjectBreadcrumb,
ChromeProjectNavigationNode,
ChromeSetProjectBreadcrumbsParams,
ChromeBreadcrumb,
SolutionNavigationDefinitions,
CloudLinks,
} from '@kbn/core-chrome-browser';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { getSolutionNavSwitcherBreadCrumb } from '../ui/solution_nav_switcher_breadcrumbs';
export function buildBreadcrumbs({
projectName,
cloudLinks,
projectBreadcrumbs,
activeNodes,
chromeBreadcrumbs,
solutionNavigations,
isServerless,
}: {
projectName?: string;
projectBreadcrumbs: {
@ -38,16 +35,12 @@ export function buildBreadcrumbs({
chromeBreadcrumbs: ChromeBreadcrumb[];
cloudLinks: CloudLinks;
activeNodes: ChromeProjectNavigationNode[][];
solutionNavigations?: {
definitions: SolutionNavigationDefinitions;
activeId: string | null;
onChange: (id: string) => void;
};
isServerless: boolean;
}): ChromeProjectBreadcrumb[] {
const rootCrumb = buildRootCrumb({
projectName,
solutionNavigations,
cloudLinks,
isServerless,
});
if (projectBreadcrumbs.params.absolute) {
@ -99,56 +92,84 @@ export function buildBreadcrumbs({
function buildRootCrumb({
projectName,
solutionNavigations,
cloudLinks,
isServerless,
}: {
projectName?: string;
cloudLinks: CloudLinks;
solutionNavigations?: {
definitions: SolutionNavigationDefinitions;
activeId: string | null;
onChange: (id: string) => void;
};
isServerless: boolean;
}): ChromeProjectBreadcrumb {
if (solutionNavigations) {
// if there are solution navigations, it means that we are in Kibana stateful and not
// in serverless with projects.
const { definitions, activeId, onChange } = solutionNavigations;
return getSolutionNavSwitcherBreadCrumb({
definitions,
onChange,
activeId,
cloudLinks,
});
if (isServerless) {
return {
text:
projectName ??
i18n.translate('core.ui.primaryNav.cloud.projectLabel', {
defaultMessage: 'Project',
}),
// increase the max-width of the root breadcrumb to not truncate too soon
style: { maxWidth: '320px' },
popoverContent: (
<EuiContextMenuPanel
size="s"
items={[
<EuiContextMenuItem key="project" href={cloudLinks.deployment?.href} icon={'gear'}>
<FormattedMessage
id="core.ui.primaryNav.cloud.linkToProject"
defaultMessage="Manage project"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem key="projects" href={cloudLinks.projects?.href} icon={'grid'}>
<FormattedMessage
id="core.ui.primaryNav.cloud.linkToAllProjects"
defaultMessage="View all projects"
/>
</EuiContextMenuItem>,
]}
/>
),
popoverProps: { panelPaddingSize: 'none' },
};
}
return {
text:
projectName ??
i18n.translate('core.ui.primaryNav.cloud.projectLabel', {
defaultMessage: 'Project',
}),
// increase the max-width of the root breadcrumb to not truncate too soon
style: { maxWidth: '320px' },
popoverContent: (
<EuiContextMenuPanel
size="s"
items={[
<EuiContextMenuItem key="project" href={cloudLinks.deployment?.href} icon={'gear'}>
<FormattedMessage
id="core.ui.primaryNav.cloud.linkToProject"
defaultMessage="Manage project"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem key="projects" href={cloudLinks.projects?.href} icon={'grid'}>
<FormattedMessage
id="core.ui.primaryNav.cloud.linkToAllProjects"
defaultMessage="View all projects"
/>
</EuiContextMenuItem>,
]}
/>
text: i18n.translate('core.ui.primaryNav.cloud.deploymentLabel', {
defaultMessage: 'Deployment',
}),
'data-test-subj': 'deploymentCrumb',
popoverContent: () => (
<>
{cloudLinks.deployment && (
<EuiButtonEmpty
href={cloudLinks.deployment.href}
color="text"
iconType="gear"
data-test-subj="manageDeploymentBtn"
>
{i18n.translate('core.ui.primaryNav.cloud.breadCrumbDropdown.manageDeploymentLabel', {
defaultMessage: 'Manage this deployment',
})}
</EuiButtonEmpty>
)}
{cloudLinks.deployments && (
<EuiButtonEmpty
href={cloudLinks.deployments.href}
color="text"
iconType="spaces"
data-test-subj="viewDeploymentsBtn"
>
{cloudLinks.deployments.title}
</EuiButtonEmpty>
)}
</>
),
popoverProps: { panelPaddingSize: 'none' },
popoverProps: {
panelPaddingSize: 'm',
zIndex: 6000,
panelStyle: { width: 260 },
panelProps: {
'data-test-subj': 'deploymentLinksPanel',
},
},
};
}

View file

@ -60,16 +60,18 @@ const logger = loggerMock.create();
const setup = ({
locationPathName = '/',
navLinkIds,
isServerless = true,
}: {
locationPathName?: string;
navLinkIds?: Readonly<string[]>;
isServerless?: boolean;
} = {}) => {
const history = createMemoryHistory({
initialEntries: [locationPathName],
});
history.replace(locationPathName);
const projectNavigationService = new ProjectNavigationService();
const projectNavigationService = new ProjectNavigationService(isServerless);
const chromeBreadcrumbs$ = new BehaviorSubject<ChromeBreadcrumb[]>([]);
const navLinksService = getNavLinksService(navLinkIds);
const application = {

View file

@ -93,6 +93,8 @@ export class ProjectNavigationService {
private navigationChangeSubscription?: Subscription;
private unlistenHistory?: () => void;
constructor(private isServerless: boolean) {}
public start({ application, navLinksService, http, chromeBreadcrumbs$, logger }: StartDeps) {
this.application = application;
this.navLinksService = navLinksService;
@ -159,45 +161,18 @@ export class ProjectNavigationService {
this.activeNodes$,
chromeBreadcrumbs$,
this.projectName$,
this.solutionNavDefinitions$,
this.nextSolutionNavDefinitionId$,
this.activeSolutionNavDefinitionId$,
this.cloudLinks$,
]).pipe(
map(
([
map(([projectBreadcrumbs, activeNodes, chromeBreadcrumbs, projectName, cloudLinks]) => {
return buildBreadcrumbs({
projectName,
projectBreadcrumbs,
activeNodes,
chromeBreadcrumbs,
projectName,
solutionNavDefinitions,
nextSolutionNavDefinitionId,
activeSolutionNavDefinitionId,
cloudLinks,
]) => {
const solutionNavigations =
Object.keys(solutionNavDefinitions).length > 0 &&
(nextSolutionNavDefinitionId !== null || activeSolutionNavDefinitionId !== null)
? {
definitions: solutionNavDefinitions,
activeId: activeSolutionNavDefinitionId,
onChange: (id: string) => {
this.goToSolutionHome(id);
this.changeActiveSolutionNavigation(id);
},
}
: undefined;
return buildBreadcrumbs({
projectName,
projectBreadcrumbs,
activeNodes,
chromeBreadcrumbs,
solutionNavigations,
cloudLinks,
});
}
)
isServerless: this.isServerless,
});
})
);
},
/** In stateful Kibana, get the registered solution navigations */

View file

@ -1,94 +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 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 or the Server
* Side Public License, v 1.
*/
import { EuiListGroup, EuiListGroupItem, EuiTitle, EuiSpacer, EuiButtonEmpty } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import type {
ChromeProjectBreadcrumb,
SolutionNavigationDefinitions,
CloudLinks,
} from '@kbn/core-chrome-browser';
export const getSolutionNavSwitcherBreadCrumb = ({
definitions,
activeId,
onChange,
cloudLinks,
}: {
definitions: SolutionNavigationDefinitions;
activeId: string | null;
onChange: (id: string) => void;
cloudLinks: CloudLinks;
}): ChromeProjectBreadcrumb => {
const text = Object.values(definitions).find(({ id }) => id === activeId)?.title;
return {
text,
'data-test-subj': 'solutionNavSwitcher',
popoverContent: (closePopover) => (
<>
<EuiTitle size="xxxs">
<h3>
{i18n.translate('core.ui.primaryNav.cloud.breadCrumbDropdown.title', {
defaultMessage: 'Solution view',
})}
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiListGroup bordered size="s">
{Object.values(definitions).map(({ id, title, icon = 'gear' }) => [
<EuiListGroupItem
key={id}
label={title}
isActive={id === activeId}
iconType={icon as string}
data-test-subj={`solutionNavSwitcher-${id}`}
onClick={() => {
onChange(id);
closePopover();
}}
/>,
])}
</EuiListGroup>
<EuiSpacer size="s" />
{cloudLinks.deployment && (
<EuiButtonEmpty
href={cloudLinks.deployment.href}
color="text"
iconType="gear"
data-test-subj="manageDeploymentBtn"
>
{i18n.translate('core.ui.primaryNav.cloud.breadCrumbDropdown.manageDeploymentLabel', {
defaultMessage: 'Manage this deployment',
})}
</EuiButtonEmpty>
)}
{cloudLinks.deployments && (
<EuiButtonEmpty
href={cloudLinks.deployments.href}
color="text"
iconType="spaces"
data-test-subj="viewDeploymentsBtn"
>
{cloudLinks.deployments.title}
</EuiButtonEmpty>
)}
</>
),
popoverProps: {
panelPaddingSize: 'm',
zIndex: 6000,
panelStyle: { width: 260 },
panelProps: {
'data-test-subj': 'solutionNavSwitcherPanel',
},
},
};
};

View file

@ -7,5 +7,3 @@
*/
export const SOLUTION_NAV_FEATURE_FLAG_NAME = 'solutionNavEnabled';
export const ENABLE_SOLUTION_NAV_UI_SETTING_ID = 'solutionNav:enable';

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export { SOLUTION_NAV_FEATURE_FLAG_NAME, ENABLE_SOLUTION_NAV_UI_SETTING_ID } from './constants';
export { SOLUTION_NAV_FEATURE_FLAG_NAME } from './constants';

View file

@ -6,7 +6,7 @@
"id": "navigation",
"server": true,
"browser": true,
"optionalPlugins": ["cloud","security", "cloudExperiments"],
"optionalPlugins": ["cloud", "cloudExperiments"],
"requiredPlugins": ["unifiedSearch"],
"requiredBundles": []
}

View file

@ -6,18 +6,15 @@
* Side Public License, v 1.
*/
import { firstValueFrom, of } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import { coreMock } from '@kbn/core/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks';
import type { BuildFlavor } from '@kbn/config';
import type { UserSettingsData } from '@kbn/user-profile-components';
import { ENABLE_SOLUTION_NAV_UI_SETTING_ID, SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common';
import { SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common';
import { NavigationPublicPlugin } from './plugin';
import { ConfigSchema } from './types';
import { SolutionNavUserProfileToggle } from './solution_nav_userprofile_toggle';
jest.mock('rxjs', () => {
const original = jest.requireActual('rxjs');
@ -27,57 +24,34 @@ jest.mock('rxjs', () => {
};
});
const defaultConfig: ConfigSchema['solutionNavigation'] = {
enabled: true,
defaultSolution: 'es',
};
const setup = (
partialConfig: Partial<ConfigSchema['solutionNavigation']> & {
config: {
featureOn: boolean;
},
{
buildFlavor = 'traditional',
userSettings = {},
uiSettingsValues,
}: {
buildFlavor?: BuildFlavor;
userSettings?: UserSettingsData;
uiSettingsValues?: Record<string, any>;
} = {}
) => {
const config = {
solutionNavigation: {
...defaultConfig,
...partialConfig,
},
};
const initializerContext = coreMock.createPluginInitializerContext(config, { buildFlavor });
const initializerContext = coreMock.createPluginInitializerContext({}, { buildFlavor });
const plugin = new NavigationPublicPlugin(initializerContext);
const setChromeStyle = jest.fn();
const coreStart = coreMock.createStart();
const unifiedSearch = unifiedSearchPluginMock.createStartContract();
const cloud = cloudMock.createStart();
const security = securityMock.createStart();
const cloudExperiments = cloudExperimentsMock.createStartMock();
cloudExperiments.getVariation.mockImplementation((key) => {
if (key === SOLUTION_NAV_FEATURE_FLAG_NAME) {
return Promise.resolve(partialConfig.featureOn);
return Promise.resolve(config.featureOn);
}
return Promise.resolve(false);
});
security.userProfiles.userProfileLoaded$ = of(true);
security.userProfiles.userProfile$ = of({ userSettings });
const getGlobalSetting$ = jest.fn();
if (uiSettingsValues) {
getGlobalSetting$.mockImplementation((settingId: string) =>
of(uiSettingsValues[settingId] ?? 'unknown')
);
}
const settingsGlobalClient = {
...coreStart.settings.globalClient,
get$: getGlobalSetting$,
@ -90,7 +64,6 @@ const setup = (
coreStart,
unifiedSearch,
cloud,
security,
cloudExperiments,
config,
setChromeStyle,
@ -120,35 +93,24 @@ describe('Navigation Plugin', () => {
describe('feature flag enabled', () => {
const featureOn = true;
it('should add the opt in/out toggle in the user menu', async () => {
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
};
// TODO: this test will have to be updated when we will read the space state
it.skip('should add the default solution navs **and** set the active nav', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup({ featureOn });
const { plugin, coreStart, unifiedSearch, cloud, security, cloudExperiments } = setup(
{
featureOn,
},
{ uiSettingsValues }
);
plugin.start(coreStart, { unifiedSearch, cloud, security, cloudExperiments });
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments });
await new Promise((resolve) => setTimeout(resolve));
expect(security.navControlService.addUserMenuLinks).toHaveBeenCalled();
const [menuLink] = security.navControlService.addUserMenuLinks.mock.calls[0][0];
expect((menuLink.content as any)?.type).toBe(SolutionNavUserProfileToggle);
expect(coreStart.chrome.project.updateSolutionNavigations).toHaveBeenCalled();
expect(coreStart.chrome.project.changeActiveSolutionNavigation).toHaveBeenCalledWith(
'security'
);
});
describe('set Chrome style', () => {
it('should set the Chrome style to "classic" when the feature is not enabled', async () => {
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
};
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup(
{ featureOn: false }, // feature not enabled
{ uiSettingsValues }
{ featureOn: false } // feature not enabled
);
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments });
@ -156,29 +118,10 @@ describe('Navigation Plugin', () => {
expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic');
});
it('should set the Chrome style to "classic" when the feature is enabled BUT globalSettings is disabled', async () => {
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // Global setting disabled
};
it('should NOT set the Chrome style when the feature is enabled BUT on serverless', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup(
{ featureOn: true }, // feature enabled
{ uiSettingsValues }
);
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments });
await new Promise((resolve) => setTimeout(resolve));
expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic');
});
it('should NOT set the Chrome style when the feature is enabled, globalSettings is enabled BUT on serverless', async () => {
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, // Global setting enabled
};
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup(
{ featureOn: true }, // feature enabled
{ uiSettingsValues, buildFlavor: 'serverless' }
{ buildFlavor: 'serverless' }
);
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments });
@ -186,15 +129,11 @@ describe('Navigation Plugin', () => {
expect(coreStart.chrome.setChromeStyle).not.toHaveBeenCalled();
});
it('should set the Chrome style to "project" when the feature is enabled', async () => {
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
};
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup(
{ featureOn: true },
{ uiSettingsValues }
);
// TODO: this test will have to be updated when we will read the space state
it.skip('should set the Chrome style to "project" when the feature is enabled', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup({
featureOn: true,
});
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments });
await new Promise((resolve) => setTimeout(resolve));
@ -203,145 +142,10 @@ describe('Navigation Plugin', () => {
});
describe('isSolutionNavEnabled$', () => {
describe('user has not opted in or out (undefined)', () => {
const testCases: Array<[Record<string, any>, string, boolean]> = [
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
},
'should be enabled',
true,
],
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // feature not enabled
},
'should not be enabled',
false,
],
];
testCases.forEach(([uiSettingsValues, description, expected]) => {
it(description, async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup(
{
featureOn,
},
{
userSettings: {
// user has not opted in or out
solutionNavOptOut: undefined,
},
uiSettingsValues,
}
);
const { isSolutionNavEnabled$ } = plugin.start(coreStart, {
unifiedSearch,
cloud,
cloudExperiments,
});
await new Promise((resolve) => setTimeout(resolve));
expect(await firstValueFrom(isSolutionNavEnabled$)).toBe(expected);
});
});
});
describe('user has opted in', () => {
const testCases: Array<[Record<string, any>, string, boolean]> = [
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
},
'should be enabled',
true,
],
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // feature not enabled
},
'should not be enabled',
false,
],
];
testCases.forEach(([uiSettingsValues, description, expected]) => {
it(description, async () => {
const { plugin, coreStart, unifiedSearch, cloud, security, cloudExperiments } = setup(
{
featureOn,
},
{
userSettings: {
// user has opted in
solutionNavOptOut: false,
},
uiSettingsValues,
}
);
const { isSolutionNavEnabled$ } = plugin.start(coreStart, {
security,
unifiedSearch,
cloud,
cloudExperiments,
});
await new Promise((resolve) => setTimeout(resolve));
expect(await firstValueFrom(isSolutionNavEnabled$)).toBe(expected);
});
});
});
describe('user has opted out', () => {
const testCases: Array<[Record<string, any>, string, boolean]> = [
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
},
'should not be enabled',
false,
],
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // feature not enabled
},
'should not be enabled',
false,
],
];
testCases.forEach(([uiSettingsValues, description, expected]) => {
it(description, async () => {
const { plugin, coreStart, unifiedSearch, cloud, security, cloudExperiments } = setup(
{
featureOn,
},
{ userSettings: { solutionNavOptOut: true }, uiSettingsValues } // user has opted out
);
const { isSolutionNavEnabled$ } = plugin.start(coreStart, {
security,
unifiedSearch,
cloud,
cloudExperiments,
});
await new Promise((resolve) => setTimeout(resolve));
expect(await firstValueFrom(isSolutionNavEnabled$)).toBe(expected);
});
});
});
it('on serverless should flag must be disabled', async () => {
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, // enabled, but we are on serverless
};
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup(
{ featureOn },
{ buildFlavor: 'serverless', uiSettingsValues }
{ buildFlavor: 'serverless' }
);
const { isSolutionNavEnabled$ } = plugin.start(coreStart, {

View file

@ -6,45 +6,23 @@
* Side Public License, v 1.
*/
import React from 'react';
import {
combineLatest,
debounceTime,
distinctUntilChanged,
firstValueFrom,
from,
map,
Observable,
of,
ReplaySubject,
shareReplay,
skipWhile,
switchMap,
take,
takeUntil,
} from 'rxjs';
import { firstValueFrom, from, of, ReplaySubject, shareReplay, take } from 'rxjs';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { SecurityPluginStart, UserMenuLink } from '@kbn/security-plugin/public';
import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser';
import { InternalChromeStart } from '@kbn/core-chrome-browser-internal';
import type { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation';
import { UserProfileData } from '@kbn/user-profile-components';
import { ENABLE_SOLUTION_NAV_UI_SETTING_ID, SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common';
import { SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common';
import type {
NavigationPublicSetup,
NavigationPublicStart,
NavigationPublicSetupDependencies,
NavigationPublicStartDependencies,
ConfigSchema,
AddSolutionNavigationArg,
SolutionType,
} from './types';
import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu';
import { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data';
import { SideNavComponent } from './side_navigation';
import { SolutionNavUserProfileToggle } from './solution_nav_userprofile_toggle';
const DEFAULT_OPT_OUT_NEW_NAV = false;
export class NavigationPublicPlugin
implements
@ -60,11 +38,9 @@ export class NavigationPublicPlugin
private readonly stop$ = new ReplaySubject<void>(1);
private coreStart?: CoreStart;
private depsStart?: NavigationPublicStartDependencies;
private isSolutionNavEnabled$ = of(false);
private userProfileOptOut$: Observable<boolean | undefined> = of(undefined);
private userProfileMenuItemAdded = false;
private isSolutionNavExperiementEnabled$ = of(false);
constructor(private initializerContext: PluginInitializerContext<ConfigSchema>) {}
constructor(private initializerContext: PluginInitializerContext) {}
public setup(_core: CoreSetup): NavigationPublicSetup {
return {
@ -81,25 +57,10 @@ export class NavigationPublicPlugin
this.coreStart = core;
this.depsStart = depsStart;
const { unifiedSearch, cloud, security, cloudExperiments } = depsStart;
const { unifiedSearch, cloud, cloudExperiments } = depsStart;
const extensions = this.topNavMenuExtensionsRegistry.getAll();
const chrome = core.chrome as InternalChromeStart;
if (security) {
this.userProfileOptOut$ = security.userProfiles.userProfileLoaded$.pipe(
skipWhile((loaded) => {
return !loaded;
}),
switchMap(() => {
return security.userProfiles.userProfile$ as Observable<UserProfileData>;
}),
map((profile) => {
return profile?.userSettings?.solutionNavOptOut;
}),
distinctUntilChanged()
);
}
/*
*
* This helps clients of navigation to create
@ -120,60 +81,22 @@ export class NavigationPublicPlugin
return createTopNav(customUnifiedSearch ?? unifiedSearch, customExtensions ?? extensions);
};
const config = this.initializerContext.config.get();
const {
solutionNavigation: { defaultSolution },
} = config;
const onCloud = cloud !== undefined; // The new side nav will initially only be available to cloud users
const isServerless = this.initializerContext.env.packageInfo.buildFlavor === 'serverless';
let isSolutionNavExperiementEnabled$ = of(false);
this.isSolutionNavEnabled$ = of(false);
if (cloudExperiments) {
isSolutionNavExperiementEnabled$ =
!onCloud || isServerless
? of(false)
: from(
cloudExperiments
.getVariation(SOLUTION_NAV_FEATURE_FLAG_NAME, false)
.catch(() => false)
).pipe(shareReplay(1));
this.isSolutionNavEnabled$ = isSolutionNavExperiementEnabled$.pipe(
switchMap((isFeatureEnabled) => {
return !isFeatureEnabled
? of(false)
: combineLatest([
core.settings.globalClient.get$<boolean>(ENABLE_SOLUTION_NAV_UI_SETTING_ID),
this.userProfileOptOut$,
]).pipe(
takeUntil(this.stop$),
debounceTime(10),
map(([enabled, userOptedOut]) => {
if (!enabled || userOptedOut === true) return false;
return true;
})
);
})
);
if (cloudExperiments && onCloud && !isServerless) {
this.isSolutionNavExperiementEnabled$ = from(
cloudExperiments.getVariation(SOLUTION_NAV_FEATURE_FLAG_NAME, false).catch(() => false)
).pipe(shareReplay(1));
}
this.isSolutionNavEnabled$
.pipe(takeUntil(this.stop$), distinctUntilChanged())
.subscribe((isSolutionNavEnabled) => {
if (isServerless) return; // Serverless already controls the chrome style
chrome.setChromeStyle(isSolutionNavEnabled ? 'project' : 'classic');
});
// Initialize the solution navigation if it is enabled
isSolutionNavExperiementEnabled$.pipe(take(1)).subscribe((isEnabled) => {
this.isSolutionNavExperiementEnabled$.pipe(take(1)).subscribe((isEnabled) => {
this.initiateChromeStyleAndSideNav(chrome, { isFeatureEnabled: isEnabled, isServerless });
if (!isEnabled) return;
chrome.project.setCloudUrls(cloud!);
this.susbcribeToSolutionNavUiSettings({ core, security, defaultSolution });
});
return {
@ -183,12 +106,12 @@ export class NavigationPublicPlugin
createTopNavWithCustomContext: createCustomTopNav,
},
addSolutionNavigation: (solutionNavigation) => {
firstValueFrom(isSolutionNavExperiementEnabled$).then((isEnabled) => {
firstValueFrom(this.isSolutionNavExperiementEnabled$).then((isEnabled) => {
if (!isEnabled) return;
this.addSolutionNavigation(solutionNavigation);
});
},
isSolutionNavEnabled$: this.isSolutionNavEnabled$,
isSolutionNavEnabled$: this.isSolutionNavExperiementEnabled$,
};
}
@ -196,40 +119,6 @@ export class NavigationPublicPlugin
this.stop$.next();
}
private susbcribeToSolutionNavUiSettings({
core,
security,
defaultSolution,
}: {
core: CoreStart;
defaultSolution: SolutionType;
security?: SecurityPluginStart;
}) {
const chrome = core.chrome as InternalChromeStart;
combineLatest([
core.settings.globalClient.get$<boolean>(ENABLE_SOLUTION_NAV_UI_SETTING_ID),
this.userProfileOptOut$,
])
.pipe(takeUntil(this.stop$), debounceTime(10))
.subscribe(([enabled, userOptedOut]) => {
if (enabled) {
// Add menu item in the user profile menu to opt in/out of the new navigation
this.addOptInOutUserProfile({ core, security, userOptedOut });
} else {
// TODO. Remove the user profile menu item if the feature is disabled.
// But first let's wait as maybe there will be a page refresh when opting out.
}
if (!enabled || userOptedOut === true) {
chrome.project.changeActiveSolutionNavigation(null);
chrome.setChromeStyle('classic');
} else {
chrome.project.changeActiveSolutionNavigation(defaultSolution);
}
});
}
private getSideNavComponent({
dataTestSubj,
panelContentProvider,
@ -263,34 +152,27 @@ export class NavigationPublicPlugin
});
}
private addOptInOutUserProfile({
core,
security,
userOptedOut,
}: {
core: CoreStart;
userOptedOut?: boolean;
security?: SecurityPluginStart;
}) {
if (!security || this.userProfileMenuItemAdded) return;
const defaultOptOutValue = userOptedOut !== undefined ? userOptedOut : DEFAULT_OPT_OUT_NEW_NAV;
const menuLink: UserMenuLink = {
content: (
<SolutionNavUserProfileToggle
core={core}
security={security}
defaultOptOutValue={defaultOptOutValue}
/>
),
order: 500,
label: '',
iconType: '',
href: '',
private initiateChromeStyleAndSideNav(
chrome: InternalChromeStart,
{ isFeatureEnabled, isServerless }: { isFeatureEnabled: boolean; isServerless: boolean }
) {
// Here we will read the space state and decide if we are in classic or project style
const mockSpaceState: { solutionView?: 'classic' | 'es' | 'oblt' | 'security' } = {
solutionView: 'security', // Change this value to test different solution views
};
security.navControlService.addUserMenuLinks([menuLink]);
this.userProfileMenuItemAdded = true;
const isProjectNav =
isFeatureEnabled &&
Boolean(mockSpaceState.solutionView) &&
mockSpaceState.solutionView !== 'classic';
// On serverless the chrome style is already set by the serverless plugin
if (!isServerless) {
chrome.setChromeStyle(isProjectNav ? 'project' : 'classic');
}
if (isProjectNav && mockSpaceState.solutionView) {
chrome.project.changeActiveSolutionNavigation(mockSpaceState.solutionView);
}
}
}

View file

@ -1,9 +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 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 or the Server
* Side Public License, v 1.
*/
export { SolutionNavUserProfileToggle } from './solution_nav_userprofile_toggle';

View file

@ -1,86 +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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { of } from 'rxjs';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { coreMock } from '@kbn/core/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { SolutionNavUserProfileToggle } from './solution_nav_userprofile_toggle';
const mockUseUpdateUserProfile = jest.fn();
jest.mock('@kbn/user-profile-components', () => {
const original = jest.requireActual('@kbn/user-profile-components');
return {
...original,
useUpdateUserProfile: () => mockUseUpdateUserProfile(),
};
});
describe('SolutionNavUserProfileToggle', () => {
it('renders correctly and toggles opt out of new nav', () => {
const security = securityMock.createStart();
const core = coreMock.createStart();
const mockUpdate = jest.fn();
mockUseUpdateUserProfile.mockReturnValue({
userProfileData: { userSettings: { solutionNavOptOut: undefined } },
isLoading: false,
update: mockUpdate,
userProfileEnabled: true,
});
const { getByTestId, rerender } = render(
<SolutionNavUserProfileToggle core={core} security={security} defaultOptOutValue={false} />
);
const toggleSwitch = getByTestId('solutionNavToggleSwitch');
fireEvent.click(toggleSwitch);
expect(mockUpdate).toHaveBeenCalledWith({ userSettings: { solutionNavOptOut: true } });
// Now we want to simulate toggling back to light
mockUseUpdateUserProfile.mockReturnValue({
userProfileData: { userSettings: { solutionNavOptOut: true } },
isLoading: false,
update: mockUpdate,
userProfileEnabled: true,
});
// Rerender the component to apply the new props
rerender(
<SolutionNavUserProfileToggle core={core} security={security} defaultOptOutValue={false} />
);
fireEvent.click(toggleSwitch);
expect(mockUpdate).toHaveBeenLastCalledWith({ userSettings: { solutionNavOptOut: false } });
});
it('does not render if user profile is disabled', async () => {
const security = securityMock.createStart();
security.userProfiles.enabled$ = of(false);
const core = coreMock.createStart();
const mockUpdate = jest.fn();
mockUseUpdateUserProfile.mockReturnValue({
userProfileData: { userSettings: { solutionNavOptOut: undefined } },
isLoading: false,
update: mockUpdate,
userProfileEnabled: false,
});
const { queryByTestId } = render(
<SolutionNavUserProfileToggle core={core} security={security} defaultOptOutValue={false} />
);
const toggleSwitch = await queryByTestId('solutionNavToggleSwitch');
expect(toggleSwitch).toBeNull();
});
});

View file

@ -1,91 +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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import {
EuiContextMenuItem,
EuiFlexGroup,
EuiFlexItem,
EuiSwitch,
useEuiTheme,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
import { UserProfilesKibanaProvider } from '@kbn/user-profile-components';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { useSolutionNavUserProfileToggle } from './use_solution_nav_userprofile_toggle';
interface Props {
security: SecurityPluginStart;
core: CoreStart;
defaultOptOutValue: boolean;
}
export const SolutionNavUserProfileToggle = ({ security, core, defaultOptOutValue }: Props) => {
return (
<UserProfilesKibanaProvider core={core} security={security} toMountPoint={toMountPoint}>
<SolutionNavUserProfileToggleUi defaultOptOutValue={defaultOptOutValue} />
</UserProfilesKibanaProvider>
);
};
function SolutionNavUserProfileToggleUi({ defaultOptOutValue }: { defaultOptOutValue: boolean }) {
const toggleTextSwitchId = useGeneratedHtmlId({ prefix: 'toggleSolutionNavSwitch' });
const { euiTheme } = useEuiTheme();
const { userProfileEnabled, toggle, hasOptOut } = useSolutionNavUserProfileToggle({
defaultOptOutValue,
});
if (!userProfileEnabled) {
return null;
}
return (
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="xs">
<EuiFlexItem>
<EuiContextMenuItem
icon="tableOfContents"
size="s"
onClick={() => {
toggle(!hasOptOut);
}}
data-test-subj="solutionNavToggle"
>
{i18n.translate('navigation.userMenuLinks.useClassicNavigation', {
defaultMessage: 'Use classic navigation',
})}
</EuiContextMenuItem>
</EuiFlexItem>
<EuiFlexItem grow={false} css={{ paddingRight: euiTheme.size.m }}>
<EuiSwitch
label={
hasOptOut
? i18n.translate('navigation.userMenuLinks.classicNavigationOnLabel', {
defaultMessage: 'on',
})
: i18n.translate('navigation.userMenuLinks.classicNavigationOffLabel', {
defaultMessage: 'off',
})
}
showLabel={false}
checked={hasOptOut}
onChange={(e) => {
toggle(e.target.checked);
}}
aria-describedby={toggleTextSwitchId}
data-test-subj="solutionNavToggleSwitch"
compressed
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -1,49 +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 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 or the Server
* Side Public License, v 1.
*/
import { useCallback, useEffect, useState } from 'react';
import { useUpdateUserProfile } from '@kbn/user-profile-components';
interface Deps {
defaultOptOutValue: boolean;
}
export const useSolutionNavUserProfileToggle = ({ defaultOptOutValue }: Deps) => {
const [hasOptOut, setHasOptOut] = useState(defaultOptOutValue);
const { userProfileData, isLoading, update, userProfileEnabled } = useUpdateUserProfile();
const { userSettings: { solutionNavOptOut = defaultOptOutValue } = {} } = userProfileData ?? {};
const toggle = useCallback(
(on: boolean) => {
if (isLoading) {
return;
}
// optimistic update
setHasOptOut(on);
update({
userSettings: {
solutionNavOptOut: on,
},
});
},
[isLoading, update]
);
useEffect(() => {
setHasOptOut(solutionNavOptOut);
}, [solutionNavOptOut]);
return {
toggle,
hasOptOut,
userProfileEnabled,
};
};

View file

@ -11,7 +11,6 @@ import type { Observable } from 'rxjs';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common';
import { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation';
@ -47,21 +46,12 @@ export interface NavigationPublicStart {
export interface NavigationPublicSetupDependencies {
cloud?: CloudSetup;
security?: SecurityPluginSetup;
}
export interface NavigationPublicStartDependencies {
unifiedSearch: UnifiedSearchPublicPluginStart;
cloud?: CloudStart;
security?: SecurityPluginStart;
cloudExperiments?: CloudExperimentsPluginStart;
}
export type SolutionType = 'es' | 'oblt' | 'security' | 'analytics';
export interface ConfigSchema {
solutionNavigation: {
enabled: boolean;
defaultSolution: SolutionType;
};
}

View file

@ -1,34 +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 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 or the Server
* Side Public License, v 1.
*/
import { schema, TypeOf } from '@kbn/config-schema';
import type { PluginConfigDescriptor } from '@kbn/core-plugins-server';
const configSchema = schema.object({
solutionNavigation: schema.object({
enabled: schema.boolean({ defaultValue: false }),
defaultSolution: schema.oneOf(
[
schema.literal('es'),
schema.literal('oblt'),
schema.literal('security'),
schema.literal('analytics'),
],
{ defaultValue: 'es' }
),
}),
});
export type NavigationConfig = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<NavigationConfig> = {
exposeToBrowser: {
solutionNavigation: true,
},
schema: configSchema,
};

View file

@ -5,12 +5,9 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { PluginInitializerContext } from '@kbn/core/server';
import { NavigationServerPlugin } from './plugin';
export { config } from './config';
export async function plugin(initializerContext: PluginInitializerContext) {
return new NavigationServerPlugin(initializerContext);
export async function plugin() {
return new NavigationServerPlugin();
}

View file

@ -5,18 +5,14 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server';
import type { UiSettingsParams } from '@kbn/core/types';
import { SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common';
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/server';
import type { NavigationConfig } from './config';
import type {
NavigationServerSetup,
NavigationServerSetupDependencies,
NavigationServerStart,
NavigationServerStartDependencies,
} from './types';
import { getUiSettings } from './ui_settings';
export class NavigationServerPlugin
implements
@ -27,56 +23,16 @@ export class NavigationServerPlugin
NavigationServerStartDependencies
>
{
constructor(private initializerContext: PluginInitializerContext) {}
constructor() {}
setup(
core: CoreSetup<NavigationServerStartDependencies>,
plugins: NavigationServerSetupDependencies
) {
const config = this.initializerContext.config.get<NavigationConfig>();
const isSolutionNavExperiementEnabled =
Boolean(plugins.cloud?.isCloudEnabled) && !this.isServerless();
if (isSolutionNavExperiementEnabled) {
void core.getStartServices().then(([coreStart, deps]) => {
return deps.cloudExperiments
?.getVariation(SOLUTION_NAV_FEATURE_FLAG_NAME, false)
.then(async (enabled) => {
if (enabled) {
core.uiSettings.registerGlobal(getUiSettings(config));
} else {
await this.removeUiSettings(coreStart, getUiSettings(config));
}
});
});
}
return {};
}
start(core: CoreStart, plugins: NavigationServerStartDependencies) {
return {};
}
/**
* Remove UI settings values that might have been set when the feature was enabled.
* If the feature is disabled in kibana.yml, we want to remove the settings from the
* saved objects.
*
* @param core CoreStart
* @param uiSettings Navigation UI settings
*/
private removeUiSettings(core: CoreStart, uiSettings: Record<string, UiSettingsParams>) {
if (this.isServerless()) return;
const savedObjectsClient = core.savedObjects.createInternalRepository();
const uiSettingsClient = core.uiSettings.globalAsScopedToClient(savedObjectsClient);
const keys = Object.keys(uiSettings);
return uiSettingsClient.removeMany(keys, { validateKeys: false, handleWriteErrors: true });
}
private isServerless() {
return this.initializerContext.env.packageInfo.buildFlavor === 'serverless';
}
}

View file

@ -1,38 +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 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 or the Server
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import { UiSettingsParams } from '@kbn/core/types';
import { i18n } from '@kbn/i18n';
import { ENABLE_SOLUTION_NAV_UI_SETTING_ID } from '../common/constants';
import { NavigationConfig } from './config';
const categoryLabel = i18n.translate('navigation.uiSettings.categoryLabel', {
defaultMessage: 'Technical preview',
});
/**
* uiSettings definitions for Navigation
*/
export const getUiSettings = (config: NavigationConfig): Record<string, UiSettingsParams> => {
return {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: {
category: [categoryLabel],
name: i18n.translate('navigation.uiSettings.enableSolutionNav.name', {
defaultMessage: 'Enable solution navigation',
}),
description: i18n.translate('navigation.uiSettings.enableSolutionNav.description', {
defaultMessage: 'Let users opt in or out from the new solution navigation experience.',
}),
schema: schema.boolean(),
value: config.solutionNavigation.enabled,
order: 1,
},
};
};

View file

@ -21,13 +21,8 @@
"@kbn/core-chrome-browser-internal",
"@kbn/shared-ux-chrome-navigation",
"@kbn/cloud-plugin",
"@kbn/config-schema",
"@kbn/core-plugins-server",
"@kbn/i18n",
"@kbn/config",
"@kbn/security-plugin",
"@kbn/user-profile-components",
"@kbn/core-lifecycle-browser",
"@kbn/cloud-experiments-plugin",
],
"exclude": [

View file

@ -161,8 +161,6 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'monitoring.ui.enabled (boolean)',
'monitoring.ui.min_interval_seconds (number)',
'monitoring.ui.show_license_expiration (boolean)',
'navigation.solutionNavigation.enabled (boolean)',
'navigation.solutionNavigation.defaultSolution (alternatives)',
'newsfeed.fetchInterval (duration)',
'newsfeed.mainInterval (duration)',
'newsfeed.service.pathTemplate (string)',

View file

@ -1,55 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrConfigProviderContext } from '@kbn/test';
import { services, pageObjects } from './ftr_provider_context';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const kibanaFunctionalConfig = await readConfigFile(require.resolve('../../config.base.js'));
return {
testFiles: [require.resolve('./tests')],
servers: {
...kibanaFunctionalConfig.get('servers'),
},
services,
pageObjects,
junit: {
reportName: 'X-Pack Navigation Functional Tests',
},
esTestCluster: {
...kibanaFunctionalConfig.get('esTestCluster'),
license: 'trial',
serverArgs: [`xpack.license.self_generated.type='trial'`],
},
apps: {
...kibanaFunctionalConfig.get('apps'),
},
kbnTestServer: {
...kibanaFunctionalConfig.get('kbnTestServer'),
serverArgs: [
...kibanaFunctionalConfig.get('kbnTestServer.serverArgs'),
'--xpack.cloud_integrations.experiments.enabled=true',
'--xpack.cloud_integrations.experiments.launch_darkly.sdk_key=a_string',
'--xpack.cloud_integrations.experiments.launch_darkly.client_id=a_string',
'--xpack.cloud_integrations.experiments.flag_overrides.solutionNavEnabled=true',
'--navigation.solutionNavigation.enabled=true',
'--navigation.solutionNavigation.defaultSolution=es',
// Note: the base64 string in the cloud.id config contains the ES endpoint required in the functional tests
'--xpack.cloud.id=ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=',
'--xpack.cloud.base_url=https://cloud.elastic.co',
'--xpack.cloud.deployment_url=/deployments/deploymentId',
'--xpack.cloud.organization_url=/organization/organizationId',
'--xpack.cloud.billing_url=/billing',
'--xpack.cloud.profile_url=/user/userId',
],
},
};
}

View file

@ -1,13 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { GenericFtrProviderContext } from '@kbn/test';
import { services } from '../../services';
import { pageObjects } from '../../page_objects';
export type FtrProviderContext = GenericFtrProviderContext<typeof services, typeof pageObjects>;
export { services, pageObjects };

View file

@ -1,15 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('navigation - functional tests', function () {
loadTestFile(require.resolve('./solution_nav_switcher'));
loadTestFile(require.resolve('./user_optin_optout'));
});
}

View file

@ -1,38 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['common', 'header', 'discover', 'dashboard']);
const testSubjects = getService('testSubjects');
const navigation = getService('globalNav');
describe('solution navigation switcher', function describeIndexTests() {
it('should be able to switch between solutions', async () => {
await PageObjects.common.navigateToApp('discover');
// Default to "search" solution
await testSubjects.existOrFail('searchSideNav');
await testSubjects.missingOrFail('observabilitySideNav');
// Change to "observability" solution
await navigation.changeSolutionNavigation('oblt');
await testSubjects.existOrFail('observabilitySideNav');
await testSubjects.missingOrFail('searchSideNav');
});
it('should contain links to manage deployment and view all deployments', async () => {
await PageObjects.common.navigateToApp('discover');
await navigation.openSolutionNavSwitcher();
await testSubjects.existOrFail('manageDeploymentBtn', { timeout: 2000 });
await testSubjects.existOrFail('viewDeploymentsBtn', { timeout: 2000 });
});
});
}

View file

@ -1,36 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['common', 'header', 'home', 'dashboard', 'security']);
const testSubjects = getService('testSubjects');
const userMenu = getService('userMenu');
describe('user opt in/out', function describeIndexTests() {
it('should allow the user to opt in or out', async () => {
await PageObjects.common.navigateToApp('home');
// we are in the new nav, search solution
await testSubjects.existOrFail('kibanaProjectHeader');
await userMenu.openMenu();
await testSubjects.existOrFail('solutionNavToggle');
// Opt OUT of the new navigation
await testSubjects.click('solutionNavToggle');
// we are in the old nav
await testSubjects.missingOrFail('kibanaProjectHeader');
// Opt back IN to the new navigation
await userMenu.openMenu();
await testSubjects.click('solutionNavToggle');
await testSubjects.existOrFail('kibanaProjectHeader');
});
});
}

View file

@ -1,32 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrConfigProviderContext } from '@kbn/test';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js'));
return {
...functionalConfig.getAll(),
testFiles: [require.resolve('.')],
kbnTestServer: {
...functionalConfig.get('kbnTestServer'),
serverArgs: [
...functionalConfig.get('kbnTestServer.serverArgs'),
'--xpack.cloud_integrations.experiments.enabled=true',
'--xpack.cloud_integrations.experiments.launch_darkly.sdk_key=a_string',
'--xpack.cloud_integrations.experiments.launch_darkly.client_id=a_string',
'--xpack.cloud_integrations.experiments.flag_overrides.solutionNavEnabled=true',
'--navigation.solutionNavigation.enabled=true',
// Note: the base64 string in the cloud.id config contains the ES endpoint required in the functional tests
'--xpack.cloud.id=ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=',
'--xpack.cloud.base_url=https://cloud.elastic.co',
'--xpack.cloud.deployment_url=/deployments/deploymentId',
],
},
};
}

View file

@ -1,14 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function spacesApp({ loadTestFile }: FtrProviderContext) {
describe('Spaces app - solution navigation', function spacesAppTestSuite() {
loadTestFile(require.resolve('./spaces_selection'));
});
}

View file

@ -1,108 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function spaceSelectorFunctionalTests({
getService,
getPageObjects,
}: FtrProviderContext) {
const PageObjects = getPageObjects([
'common',
'dashboard',
'header',
'home',
'security',
'spaceSelector',
]);
const spacesService = getService('spaces');
// NOTE: Those tests have been copied over from the parent folder "spaces_selection.ts"
// We want to test that under the (upcoming) solution navigation, the spaces selector works as expected
// Once the solution navigation becomes the default we can remove this "in_solution_navigation" folder
// and rely only on the tests in the parent folder.
describe('Spaces', function () {
const testSpacesIds = ['another-space', ...Array.from('123456789', (idx) => `space-${idx}`)];
before(async () => {
for (const testSpaceId of testSpacesIds) {
await spacesService.create({ id: testSpaceId, name: `${testSpaceId} name` });
}
});
after(async () => {
for (const testSpaceId of testSpacesIds) {
await spacesService.delete(testSpaceId);
}
});
describe('Space Navigation Menu', () => {
before(async () => {
await PageObjects.security.forceLogout();
await PageObjects.security.login(undefined, undefined, {
expectSpaceSelector: true,
});
});
after(async () => {
await PageObjects.security.forceLogout();
});
it('allows user to navigate to different spaces', async () => {
const anotherSpaceId = 'another-space';
const defaultSpaceId = 'default';
const space5Id = 'space-5';
await PageObjects.spaceSelector.clickSpaceCard(defaultSpaceId);
await PageObjects.spaceSelector.expectHomePage(defaultSpaceId);
// change spaces with nav menu
await PageObjects.spaceSelector.openSpacesNav();
await PageObjects.spaceSelector.goToSpecificSpace(space5Id);
await PageObjects.spaceSelector.expectHomePage(space5Id);
await PageObjects.spaceSelector.openSpacesNav();
await PageObjects.spaceSelector.goToSpecificSpace(anotherSpaceId);
await PageObjects.spaceSelector.expectHomePage(anotherSpaceId);
await PageObjects.spaceSelector.openSpacesNav();
await PageObjects.spaceSelector.goToSpecificSpace(defaultSpaceId);
await PageObjects.spaceSelector.expectHomePage(defaultSpaceId);
});
});
describe('Search spaces in popover', function () {
const spaceId = 'default';
before(async () => {
await PageObjects.security.forceLogout();
await PageObjects.security.login(undefined, undefined, {
expectSpaceSelector: true,
});
});
after(async () => {
await PageObjects.security.forceLogout();
});
it('allows user to search for spaces', async () => {
await PageObjects.spaceSelector.clickSpaceCard(spaceId);
await PageObjects.spaceSelector.expectHomePage(spaceId);
await PageObjects.spaceSelector.openSpacesNav();
await PageObjects.spaceSelector.expectSearchBoxInSpacesSelector();
});
it('search for "ce-1 name" and find one space', async () => {
await PageObjects.spaceSelector.setSearchBoxInSpacesSelector('ce-1 name');
await PageObjects.spaceSelector.expectToFindThatManySpace(1);
});
it('search for "dog" and find NO space', async () => {
await PageObjects.spaceSelector.setSearchBoxInSpacesSelector('dog');
await PageObjects.spaceSelector.expectToFindThatManySpace(0);
await PageObjects.spaceSelector.expectNoSpacesFound();
});
});
});
}