[Stateful sidenav] Remove Launch Darkly feature flag (#189513)

This commit is contained in:
Sébastien Loix 2024-08-02 13:48:19 +01:00 committed by GitHub
parent 0298013a78
commit 03607ec7e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 425 additions and 467 deletions

View file

@ -152,6 +152,7 @@ server.versioned.strictClientVersionCheck: false
# Enforce single "default" space and disable feature visibility controls # Enforce single "default" space and disable feature visibility controls
xpack.spaces.maxSpaces: 1 xpack.spaces.maxSpaces: 1
xpack.spaces.allowFeatureVisibility: false xpack.spaces.allowFeatureVisibility: false
xpack.spaces.allowSolutionVisibility: false
# Only display console autocomplete suggestions for ES endpoints that are available in serverless # Only display console autocomplete suggestions for ES endpoints that are available in serverless
console.autocompleteDefinitions.endpointsAvailability: serverless console.autocompleteDefinitions.endpointsAvailability: serverless

View file

@ -54,6 +54,7 @@ export type Result = 'ready';
if (serverless) { if (serverless) {
// Satisfy spaces config for serverless: // Satisfy spaces config for serverless:
set(settings, 'xpack.spaces.allowFeatureVisibility', false); set(settings, 'xpack.spaces.allowFeatureVisibility', false);
set(settings, 'xpack.spaces.allowSolutionVisibility', false);
const { startKibana } = createTestServerlessInstances({ const { startKibana } = createTestServerlessInstances({
kibana: { settings, cliArgs }, kibana: { settings, cliArgs },
}); });

View file

@ -6,8 +6,6 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
export const SOLUTION_NAV_FEATURE_FLAG_NAME = 'solutionNavEnabled';
export const DEFAULT_ROUTE_UI_SETTING_ID = 'defaultRoute'; export const DEFAULT_ROUTE_UI_SETTING_ID = 'defaultRoute';
export const DEFAULT_ROUTES = { export const DEFAULT_ROUTES = {

View file

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

View file

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

View file

@ -10,11 +10,9 @@ import { firstValueFrom, of } from 'rxjs';
import { coreMock } from '@kbn/core/public/mocks'; import { coreMock } from '@kbn/core/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks';
import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks'; import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks';
import type { Space } from '@kbn/spaces-plugin/public'; import type { Space } from '@kbn/spaces-plugin/public';
import type { BuildFlavor } from '@kbn/config'; import type { BuildFlavor } from '@kbn/config';
import { SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common';
import { NavigationPublicPlugin } from './plugin'; import { NavigationPublicPlugin } from './plugin';
jest.mock('rxjs', () => { jest.mock('rxjs', () => {
@ -25,16 +23,11 @@ jest.mock('rxjs', () => {
}; };
}); });
const setup = ( const setup = ({
config: {
featureOn: boolean;
},
{
buildFlavor = 'traditional', buildFlavor = 'traditional',
}: { }: {
buildFlavor?: BuildFlavor; buildFlavor?: BuildFlavor;
} = {} } = {}) => {
) => {
const initializerContext = coreMock.createPluginInitializerContext({}, { buildFlavor }); const initializerContext = coreMock.createPluginInitializerContext({}, { buildFlavor });
const plugin = new NavigationPublicPlugin(initializerContext); const plugin = new NavigationPublicPlugin(initializerContext);
@ -42,14 +35,7 @@ const setup = (
const coreStart = coreMock.createStart(); const coreStart = coreMock.createStart();
const unifiedSearch = unifiedSearchPluginMock.createStartContract(); const unifiedSearch = unifiedSearchPluginMock.createStartContract();
const cloud = cloudMock.createStart(); const cloud = cloudMock.createStart();
const cloudExperiments = cloudExperimentsMock.createStartMock();
const spaces = spacesPluginMock.createStartContract(); const spaces = spacesPluginMock.createStartContract();
cloudExperiments.getVariation.mockImplementation((key) => {
if (key === SOLUTION_NAV_FEATURE_FLAG_NAME) {
return Promise.resolve(config.featureOn);
}
return Promise.resolve(false);
});
const getGlobalSetting$ = jest.fn(); const getGlobalSetting$ = jest.fn();
const settingsGlobalClient = { const settingsGlobalClient = {
@ -64,46 +50,20 @@ const setup = (
coreStart, coreStart,
unifiedSearch, unifiedSearch,
cloud, cloud,
cloudExperiments,
spaces, spaces,
config,
setChromeStyle, setChromeStyle,
}; };
}; };
describe('Navigation Plugin', () => { describe('Navigation Plugin', () => {
describe('feature flag disabled', () => {
const featureOn = false;
it('should not add the default solutions nor set the active nav if the feature is disabled', () => {
const { plugin, coreStart, unifiedSearch } = setup({ featureOn });
plugin.start(coreStart, { unifiedSearch });
expect(coreStart.chrome.project.updateSolutionNavigations).not.toHaveBeenCalled();
expect(coreStart.chrome.project.changeActiveSolutionNavigation).not.toHaveBeenCalled();
});
it('should return flag to indicate that the solution navigation is disabled', async () => {
const { plugin, coreStart, unifiedSearch } = setup({ featureOn });
const isEnabled = await firstValueFrom(
plugin.start(coreStart, { unifiedSearch }).isSolutionNavEnabled$
);
expect(isEnabled).toBe(false);
});
});
describe('feature flag enabled', () => {
const featureOn = true;
it('should change the active solution navigation', async () => { it('should change the active solution navigation', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments, spaces } = setup({ const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup();
featureOn,
});
spaces.getActiveSpace$ = jest spaces.getActiveSpace$ = jest
.fn() .fn()
.mockReturnValue(of({ solution: 'es' } as Pick<Space, 'solution'>)); .mockReturnValue(of({ solution: 'es' } as Pick<Space, 'solution'>));
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces }); plugin.start(coreStart, { unifiedSearch, cloud, spaces });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
expect(coreStart.chrome.project.changeActiveSolutionNavigation).toHaveBeenCalledWith('es'); expect(coreStart.chrome.project.changeActiveSolutionNavigation).toHaveBeenCalledWith('es');
@ -111,14 +71,11 @@ describe('Navigation Plugin', () => {
describe('addSolutionNavigation()', () => { describe('addSolutionNavigation()', () => {
it('should update the solution navigation definitions', async () => { it('should update the solution navigation definitions', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup({ const { plugin, coreStart, unifiedSearch, cloud } = setup();
featureOn,
});
const { addSolutionNavigation } = plugin.start(coreStart, { const { addSolutionNavigation } = plugin.start(coreStart, {
unifiedSearch, unifiedSearch,
cloud, cloud,
cloudExperiments,
}); });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
@ -141,34 +98,20 @@ describe('Navigation Plugin', () => {
}); });
describe('set Chrome style', () => { describe('set Chrome style', () => {
it('should set the Chrome style to "classic" when the feature is not enabled', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup(
{ featureOn: false } // feature not enabled
);
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments });
await new Promise((resolve) => setTimeout(resolve));
expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic');
});
it('should set the Chrome style to "classic" when spaces plugin is not available', async () => { it('should set the Chrome style to "classic" when spaces plugin is not available', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( const { plugin, coreStart, unifiedSearch, cloud } = setup();
{ featureOn: true } // feature not enabled but no spaces plugin
);
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); plugin.start(coreStart, { unifiedSearch, cloud });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic');
}); });
it('should set the Chrome style to "classic" when active space solution is "classic"', async () => { it('should set the Chrome style to "classic" when active space solution is "classic"', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments, spaces } = setup({ const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup();
featureOn: true,
});
// Spaces plugin is available but activeSpace is undefined // Spaces plugin is available but activeSpace is undefined
spaces.getActiveSpace$ = jest.fn().mockReturnValue(of(undefined)); spaces.getActiveSpace$ = jest.fn().mockReturnValue(of(undefined));
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces }); plugin.start(coreStart, { unifiedSearch, cloud, spaces });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic');
@ -177,39 +120,34 @@ describe('Navigation Plugin', () => {
spaces.getActiveSpace$ = jest spaces.getActiveSpace$ = jest
.fn() .fn()
.mockReturnValue(of({ solution: 'classic' } as Pick<Space, 'solution'>)); .mockReturnValue(of({ solution: 'classic' } as Pick<Space, 'solution'>));
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces }); plugin.start(coreStart, { unifiedSearch, cloud, spaces });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic');
}); });
it('should NOT set the Chrome style when the feature is enabled BUT on serverless', async () => { it('should NOT set the Chrome style when on serverless', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( const { plugin, coreStart, unifiedSearch, cloud } = setup({ buildFlavor: 'serverless' });
{ featureOn: true }, // feature enabled
{ buildFlavor: 'serverless' }
);
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); plugin.start(coreStart, { unifiedSearch, cloud });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
expect(coreStart.chrome.setChromeStyle).not.toHaveBeenCalled(); expect(coreStart.chrome.setChromeStyle).not.toHaveBeenCalled();
}); });
it('should set the Chrome style to "project" when space solution is a known solution', async () => { it('should set the Chrome style to "project" when space solution is a known solution', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments, spaces } = setup({ const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup();
featureOn: true,
});
for (const solution of ['es', 'oblt', 'security']) { for (const solution of ['es', 'oblt', 'security']) {
spaces.getActiveSpace$ = jest spaces.getActiveSpace$ = jest
.fn() .fn()
.mockReturnValue(of({ solution } as Pick<Space, 'solution'>)); .mockReturnValue(of({ solution } as Pick<Space, 'solution'>));
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces }); plugin.start(coreStart, { unifiedSearch, cloud, spaces });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('project'); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('project');
coreStart.chrome.setChromeStyle.mockReset(); coreStart.chrome.setChromeStyle.mockReset();
} }
spaces.getActiveSpace$ = jest.fn().mockReturnValue(of({ solution: 'unknown' })); spaces.getActiveSpace$ = jest.fn().mockReturnValue(of({ solution: 'unknown' }));
plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces }); plugin.start(coreStart, { unifiedSearch, cloud, spaces });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic');
}); });
@ -218,7 +156,7 @@ describe('Navigation Plugin', () => {
describe('isSolutionNavEnabled$', () => { describe('isSolutionNavEnabled$', () => {
// This test will need to be changed when we remove the feature flag // This test will need to be changed when we remove the feature flag
it('should be off by default', async () => { it('should be off by default', async () => {
const { plugin, coreStart, unifiedSearch, cloud } = setup({ featureOn }); const { plugin, coreStart, unifiedSearch, cloud } = setup();
const { isSolutionNavEnabled$ } = plugin.start(coreStart, { const { isSolutionNavEnabled$ } = plugin.start(coreStart, {
unifiedSearch, unifiedSearch,
@ -230,12 +168,8 @@ describe('Navigation Plugin', () => {
expect(isEnabled).toBe(false); expect(isEnabled).toBe(false);
}); });
it('should be off if feature flag if "ON" but space solution is "classic" or "undefined"', async () => { it('should be off if space solution is "classic" or "undefined"', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments, spaces } = setup({ const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup();
featureOn,
});
cloudExperiments.getVariation.mockResolvedValue(true); // Feature flag ON
{ {
spaces.getActiveSpace$ = jest spaces.getActiveSpace$ = jest
@ -245,7 +179,6 @@ describe('Navigation Plugin', () => {
const { isSolutionNavEnabled$ } = plugin.start(coreStart, { const { isSolutionNavEnabled$ } = plugin.start(coreStart, {
unifiedSearch, unifiedSearch,
cloud, cloud,
cloudExperiments,
spaces, spaces,
}); });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
@ -262,7 +195,6 @@ describe('Navigation Plugin', () => {
const { isSolutionNavEnabled$ } = plugin.start(coreStart, { const { isSolutionNavEnabled$ } = plugin.start(coreStart, {
unifiedSearch, unifiedSearch,
cloud, cloud,
cloudExperiments,
spaces, spaces,
}); });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
@ -272,12 +204,8 @@ describe('Navigation Plugin', () => {
} }
}); });
it('should be on if feature flag if "ON" and space solution is set', async () => { it('should be on if space solution is set', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments, spaces } = setup({ const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup();
featureOn,
});
cloudExperiments.getVariation.mockResolvedValue(true); // Feature flag ON
spaces.getActiveSpace$ = jest spaces.getActiveSpace$ = jest
.fn() .fn()
@ -286,7 +214,6 @@ describe('Navigation Plugin', () => {
const { isSolutionNavEnabled$ } = plugin.start(coreStart, { const { isSolutionNavEnabled$ } = plugin.start(coreStart, {
unifiedSearch, unifiedSearch,
cloud, cloud,
cloudExperiments,
spaces, spaces,
}); });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
@ -295,16 +222,12 @@ describe('Navigation Plugin', () => {
expect(isEnabled).toBe(true); expect(isEnabled).toBe(true);
}); });
it('on serverless should flag must be disabled', async () => { it('on serverless flag must be disabled', async () => {
const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( const { plugin, coreStart, unifiedSearch, cloud } = setup({ buildFlavor: 'serverless' });
{ featureOn },
{ buildFlavor: 'serverless' }
);
const { isSolutionNavEnabled$ } = plugin.start(coreStart, { const { isSolutionNavEnabled$ } = plugin.start(coreStart, {
unifiedSearch, unifiedSearch,
cloud, cloud,
cloudExperiments,
}); });
await new Promise((resolve) => setTimeout(resolve)); await new Promise((resolve) => setTimeout(resolve));
@ -313,4 +236,3 @@ describe('Navigation Plugin', () => {
}); });
}); });
}); });
});

View file

@ -6,23 +6,13 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import React from 'react'; import React from 'react';
import { import { of, ReplaySubject, take, map, Observable } from 'rxjs';
firstValueFrom,
from,
of,
ReplaySubject,
shareReplay,
take,
combineLatest,
map,
} from 'rxjs';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { Space } from '@kbn/spaces-plugin/public'; import type { Space } from '@kbn/spaces-plugin/public';
import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser'; import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser';
import { InternalChromeStart } from '@kbn/core-chrome-browser-internal'; import { InternalChromeStart } from '@kbn/core-chrome-browser-internal';
import type { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation'; import type { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation';
import { SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common';
import type { import type {
NavigationPublicSetup, NavigationPublicSetup,
NavigationPublicStart, NavigationPublicStart,
@ -49,7 +39,7 @@ export class NavigationPublicPlugin
private readonly stop$ = new ReplaySubject<void>(1); private readonly stop$ = new ReplaySubject<void>(1);
private coreStart?: CoreStart; private coreStart?: CoreStart;
private depsStart?: NavigationPublicStartDependencies; private depsStart?: NavigationPublicStartDependencies;
private isSolutionNavExperiementEnabled$ = of(false); private isSolutionNavEnabled = false;
constructor(private initializerContext: PluginInitializerContext) {} constructor(private initializerContext: PluginInitializerContext) {}
@ -70,10 +60,14 @@ export class NavigationPublicPlugin
this.coreStart = core; this.coreStart = core;
this.depsStart = depsStart; this.depsStart = depsStart;
const { unifiedSearch, cloud, cloudExperiments, spaces } = depsStart; const { unifiedSearch, cloud, spaces } = depsStart;
const extensions = this.topNavMenuExtensionsRegistry.getAll(); const extensions = this.topNavMenuExtensionsRegistry.getAll();
const chrome = core.chrome as InternalChromeStart; const chrome = core.chrome as InternalChromeStart;
const activeSpace$ = spaces?.getActiveSpace$() ?? of(undefined); const activeSpace$: Observable<Space | undefined> = spaces?.getActiveSpace$() ?? of(undefined);
const onCloud = cloud !== undefined; // The new side nav will initially only be available to cloud users
const isServerless = this.initializerContext.env.packageInfo.buildFlavor === 'serverless';
this.isSolutionNavEnabled = onCloud && !isServerless;
/* /*
* *
@ -95,26 +89,14 @@ export class NavigationPublicPlugin
return createTopNav(customUnifiedSearch ?? unifiedSearch, customExtensions ?? extensions); return createTopNav(customUnifiedSearch ?? unifiedSearch, customExtensions ?? extensions);
}; };
const onCloud = cloud !== undefined; // The new side nav will initially only be available to cloud users
const isServerless = this.initializerContext.env.packageInfo.buildFlavor === 'serverless';
if (cloudExperiments && onCloud && !isServerless) {
this.isSolutionNavExperiementEnabled$ = from(
cloudExperiments.getVariation(SOLUTION_NAV_FEATURE_FLAG_NAME, false).catch(() => false)
).pipe(shareReplay(1));
}
// Initialize the solution navigation if it is enabled // Initialize the solution navigation if it is enabled
combineLatest([this.isSolutionNavExperiementEnabled$, activeSpace$]) activeSpace$.pipe(take(1)).subscribe((activeSpace) => {
.pipe(take(1))
.subscribe(([isEnabled, activeSpace]) => {
this.initiateChromeStyleAndSideNav(chrome, { this.initiateChromeStyleAndSideNav(chrome, {
isFeatureEnabled: isEnabled,
isServerless, isServerless,
activeSpace, activeSpace,
}); });
if (!isEnabled) return; if (!this.isSolutionNavEnabled) return;
chrome.project.setCloudUrls(cloud!); chrome.project.setCloudUrls(cloud!);
}); });
@ -126,17 +108,12 @@ export class NavigationPublicPlugin
createTopNavWithCustomContext: createCustomTopNav, createTopNavWithCustomContext: createCustomTopNav,
}, },
addSolutionNavigation: (solutionNavigation) => { addSolutionNavigation: (solutionNavigation) => {
firstValueFrom(this.isSolutionNavExperiementEnabled$).then((isEnabled) => { if (!this.isSolutionNavEnabled) return;
if (!isEnabled) return;
this.addSolutionNavigation(solutionNavigation); this.addSolutionNavigation(solutionNavigation);
});
}, },
isSolutionNavEnabled$: combineLatest([ isSolutionNavEnabled$: activeSpace$.pipe(
this.isSolutionNavExperiementEnabled$, map((activeSpace) => {
activeSpace$, return this.isSolutionNavEnabled && getIsProjectNav(activeSpace?.solution);
]).pipe(
map(([isFeatureEnabled, activeSpace]) => {
return getIsProjectNav(isFeatureEnabled, activeSpace?.solution) && !isServerless;
}) })
), ),
}; };
@ -181,14 +158,10 @@ export class NavigationPublicPlugin
private initiateChromeStyleAndSideNav( private initiateChromeStyleAndSideNav(
chrome: InternalChromeStart, chrome: InternalChromeStart,
{ { isServerless, activeSpace }: { isServerless: boolean; activeSpace?: Space }
isFeatureEnabled,
isServerless,
activeSpace,
}: { isFeatureEnabled: boolean; isServerless: boolean; activeSpace?: Space }
) { ) {
const solutionView = activeSpace?.solution; const solutionView = activeSpace?.solution;
const isProjectNav = getIsProjectNav(isFeatureEnabled, solutionView) && !isServerless; const isProjectNav = this.isSolutionNavEnabled && getIsProjectNav(solutionView);
// On serverless the chrome style is already set by the serverless plugin // On serverless the chrome style is already set by the serverless plugin
if (!isServerless) { if (!isServerless) {
@ -201,8 +174,8 @@ export class NavigationPublicPlugin
} }
} }
function getIsProjectNav(isFeatureEnabled: boolean, solutionView?: string) { function getIsProjectNav(solutionView?: string) {
return isFeatureEnabled && Boolean(solutionView) && isKnownSolutionView(solutionView); return Boolean(solutionView) && isKnownSolutionView(solutionView);
} }
function isKnownSolutionView(solution?: string) { function isKnownSolutionView(solution?: string) {

View file

@ -12,7 +12,6 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/
import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser'; import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/public';
import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common';
import { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation'; import { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation';
import { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup, createTopNav } from './top_nav_menu'; import { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup, createTopNav } from './top_nav_menu';
@ -53,7 +52,6 @@ export interface NavigationPublicSetupDependencies {
export interface NavigationPublicStartDependencies { export interface NavigationPublicStartDependencies {
unifiedSearch: UnifiedSearchPublicPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart;
cloud?: CloudStart; cloud?: CloudStart;
cloudExperiments?: CloudExperimentsPluginStart;
spaces?: SpacesPluginStart; spaces?: SpacesPluginStart;
} }

View file

@ -5,7 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server * in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server';
import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server'; import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server';
@ -21,7 +20,6 @@ export interface NavigationServerSetupDependencies {
} }
export interface NavigationServerStartDependencies { export interface NavigationServerStartDependencies {
cloudExperiments?: CloudExperimentsPluginStart;
cloud?: CloudStart; cloud?: CloudStart;
spaces?: SpacesPluginStart; spaces?: SpacesPluginStart;
} }

View file

@ -22,7 +22,6 @@
"@kbn/shared-ux-chrome-navigation", "@kbn/shared-ux-chrome-navigation",
"@kbn/cloud-plugin", "@kbn/cloud-plugin",
"@kbn/config", "@kbn/config",
"@kbn/cloud-experiments-plugin",
"@kbn/spaces-plugin", "@kbn/spaces-plugin",
"@kbn/core-ui-settings-common", "@kbn/core-ui-settings-common",
"@kbn/config-schema", "@kbn/config-schema",

View file

@ -328,6 +328,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.security.roleManagementEnabled (any)', 'xpack.security.roleManagementEnabled (any)',
'xpack.spaces.maxSpaces (number)', 'xpack.spaces.maxSpaces (number)',
'xpack.spaces.allowFeatureVisibility (any)', 'xpack.spaces.allowFeatureVisibility (any)',
'xpack.spaces.allowSolutionVisibility (any)',
'xpack.securitySolution.enableExperimental (array)', 'xpack.securitySolution.enableExperimental (array)',
'xpack.securitySolution.prebuiltRulesPackageVersion (string)', 'xpack.securitySolution.prebuiltRulesPackageVersion (string)',
'xpack.securitySolution.offeringSettings (record)', 'xpack.securitySolution.offeringSettings (record)',

View file

@ -36,10 +36,6 @@ export enum FEATURE_FLAG_NAMES {
* Options are: `true` and `false`. * Options are: `true` and `false`.
*/ */
'observability_onboarding.experimental_onboarding_flow_enabled' = 'observability_onboarding.experimental_onboarding_flow_enabled', 'observability_onboarding.experimental_onboarding_flow_enabled' = 'observability_onboarding.experimental_onboarding_flow_enabled',
/**
* Used to enable the new stack navigation around solutions during the rollout period.
*/
'solutionNavEnabled' = 'solutionNavEnabled',
} }
/** /**

View file

@ -20,7 +20,6 @@
"management", "management",
"usageCollection", "usageCollection",
"cloud", "cloud",
"cloudExperiments"
], ],
"requiredBundles": [ "requiredBundles": [
"esUiShared", "esUiShared",

View file

@ -8,4 +8,5 @@
export interface ConfigType { export interface ConfigType {
maxSpaces: number; maxSpaces: number;
allowFeatureVisibility: boolean; allowFeatureVisibility: boolean;
allowSolutionVisibility: boolean;
} }

View file

@ -1,8 +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.
*/
export { isSolutionNavEnabled } from './is_solution_nav_enabled';

View file

@ -1,20 +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 type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common';
import type { CloudStart } from '@kbn/cloud-plugin/public';
const SOLUTION_NAV_FEATURE_FLAG_NAME = 'solutionNavEnabled';
export const isSolutionNavEnabled = (
cloud?: CloudStart,
cloudExperiments?: CloudExperimentsPluginStart
) => {
return Boolean(cloud?.isCloudEnabled) && cloudExperiments
? cloudExperiments.getVariation(SOLUTION_NAV_FEATURE_FLAG_NAME, false)
: Promise.resolve(false);
};

View file

@ -83,6 +83,7 @@ describe('ManageSpacePage', () => {
}} }}
eventTracker={eventTracker} eventTracker={eventTracker}
allowFeatureVisibility allowFeatureVisibility
allowSolutionVisibility
/> />
); );
@ -112,7 +113,7 @@ describe('ManageSpacePage', () => {
}); });
}); });
it('shows solution view select when enabled', async () => { it('shows solution view select when visible', async () => {
const spacesManager = spacesManagerMock.create(); const spacesManager = spacesManagerMock.create();
spacesManager.createSpace = jest.fn(spacesManager.createSpace); spacesManager.createSpace = jest.fn(spacesManager.createSpace);
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
@ -130,7 +131,7 @@ describe('ManageSpacePage', () => {
spaces: { manage: true }, spaces: { manage: true },
}} }}
allowFeatureVisibility allowFeatureVisibility
solutionNavExperiment={Promise.resolve(true)} allowSolutionVisibility
eventTracker={eventTracker} eventTracker={eventTracker}
/> />
); );
@ -143,12 +144,11 @@ describe('ManageSpacePage', () => {
expect(findTestSubject(wrapper, 'navigationPanel')).toHaveLength(1); expect(findTestSubject(wrapper, 'navigationPanel')).toHaveLength(1);
}); });
it('hides solution view select when not enabled or undefined', async () => { it('hides solution view select when not visible', async () => {
const spacesManager = spacesManagerMock.create(); const spacesManager = spacesManagerMock.create();
spacesManager.createSpace = jest.fn(spacesManager.createSpace); spacesManager.createSpace = jest.fn(spacesManager.createSpace);
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
{
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<ManageSpacePage <ManageSpacePage
spacesManager={spacesManager as unknown as SpacesManager} spacesManager={spacesManager as unknown as SpacesManager}
@ -162,6 +162,7 @@ describe('ManageSpacePage', () => {
spaces: { manage: true }, spaces: { manage: true },
}} }}
allowFeatureVisibility allowFeatureVisibility
allowSolutionVisibility={false}
eventTracker={eventTracker} eventTracker={eventTracker}
/> />
); );
@ -172,34 +173,6 @@ describe('ManageSpacePage', () => {
}); });
expect(findTestSubject(wrapper, 'navigationPanel')).toHaveLength(0); expect(findTestSubject(wrapper, 'navigationPanel')).toHaveLength(0);
}
{
const wrapper = mountWithIntl(
<ManageSpacePage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
history={history}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
allowFeatureVisibility
solutionNavExperiment={Promise.resolve(false)}
eventTracker={eventTracker}
/>
);
await waitFor(() => {
wrapper.update();
expect(wrapper.find('input[name="name"]')).toHaveLength(1);
});
expect(findTestSubject(wrapper, 'navigationPanel')).toHaveLength(0);
}
}); });
it('shows feature visibility controls when allowed', async () => { it('shows feature visibility controls when allowed', async () => {
@ -221,6 +194,7 @@ describe('ManageSpacePage', () => {
}} }}
eventTracker={eventTracker} eventTracker={eventTracker}
allowFeatureVisibility allowFeatureVisibility
allowSolutionVisibility
/> />
); );
@ -251,6 +225,7 @@ describe('ManageSpacePage', () => {
}} }}
eventTracker={eventTracker} eventTracker={eventTracker}
allowFeatureVisibility={false} allowFeatureVisibility={false}
allowSolutionVisibility
/> />
); );
@ -297,7 +272,7 @@ describe('ManageSpacePage', () => {
}} }}
eventTracker={eventTracker} eventTracker={eventTracker}
allowFeatureVisibility allowFeatureVisibility
solutionNavExperiment={Promise.resolve(true)} allowSolutionVisibility
/> />
); );
@ -375,6 +350,7 @@ describe('ManageSpacePage', () => {
}} }}
eventTracker={eventTracker} eventTracker={eventTracker}
allowFeatureVisibility allowFeatureVisibility
allowSolutionVisibility
/> />
); );
@ -425,6 +401,7 @@ describe('ManageSpacePage', () => {
}} }}
eventTracker={eventTracker} eventTracker={eventTracker}
allowFeatureVisibility allowFeatureVisibility
allowSolutionVisibility
/> />
); );
@ -463,6 +440,7 @@ describe('ManageSpacePage', () => {
}} }}
eventTracker={eventTracker} eventTracker={eventTracker}
allowFeatureVisibility allowFeatureVisibility
allowSolutionVisibility
/> />
); );
@ -525,6 +503,7 @@ describe('ManageSpacePage', () => {
}} }}
eventTracker={eventTracker} eventTracker={eventTracker}
allowFeatureVisibility allowFeatureVisibility
allowSolutionVisibility
/> />
); );

View file

@ -57,7 +57,7 @@ interface Props {
capabilities: Capabilities; capabilities: Capabilities;
history: ScopedHistory; history: ScopedHistory;
allowFeatureVisibility: boolean; allowFeatureVisibility: boolean;
solutionNavExperiment?: Promise<boolean>; allowSolutionVisibility: boolean;
eventTracker: EventTracker; eventTracker: EventTracker;
} }
@ -74,7 +74,6 @@ interface State {
isInvalid: boolean; isInvalid: boolean;
error?: string; error?: string;
}; };
isSolutionNavEnabled: boolean;
} }
export class ManageSpacePage extends Component<Props, State> { export class ManageSpacePage extends Component<Props, State> {
@ -91,7 +90,6 @@ export class ManageSpacePage extends Component<Props, State> {
color: getSpaceColor({}), color: getSpaceColor({}),
}, },
features: [], features: [],
isSolutionNavEnabled: false,
haveDisabledFeaturesChanged: false, haveDisabledFeaturesChanged: false,
hasSolutionViewChanged: false, hasSolutionViewChanged: false,
}; };
@ -118,10 +116,6 @@ export class ManageSpacePage extends Component<Props, State> {
}), }),
}); });
} }
this.props.solutionNavExperiment?.then((isEnabled) => {
this.setState({ isSolutionNavEnabled: isEnabled });
});
} }
public async componentDidUpdate(previousProps: Props, prevState: State) { public async componentDidUpdate(previousProps: Props, prevState: State) {
@ -201,7 +195,7 @@ export class ManageSpacePage extends Component<Props, State> {
validator={this.validator} validator={this.validator}
/> />
{this.state.isSolutionNavEnabled && ( {!!this.props.allowSolutionVisibility && (
<> <>
<EuiSpacer size="l" /> <EuiSpacer size="l" />
<SolutionView space={this.state.space} onChange={this.onSpaceChange} /> <SolutionView space={this.state.space} onChange={this.onSpaceChange} />

View file

@ -23,6 +23,7 @@ describe('ManagementService', () => {
const config: ConfigType = { const config: ConfigType = {
maxSpaces: 1000, maxSpaces: 1000,
allowFeatureVisibility: true, allowFeatureVisibility: true,
allowSolutionVisibility: true,
}; };
describe('#setup', () => { describe('#setup', () => {
@ -41,7 +42,6 @@ describe('ManagementService', () => {
spacesManager: spacesManagerMock.create(), spacesManager: spacesManagerMock.create(),
config, config,
getRolesAPIClient: getRolesAPIClientMock, getRolesAPIClient: getRolesAPIClientMock,
solutionNavExperiment: Promise.resolve(false),
eventTracker, eventTracker,
}); });
@ -63,7 +63,6 @@ describe('ManagementService', () => {
spacesManager: spacesManagerMock.create(), spacesManager: spacesManagerMock.create(),
config, config,
getRolesAPIClient: getRolesAPIClientMock, getRolesAPIClient: getRolesAPIClientMock,
solutionNavExperiment: Promise.resolve(false),
eventTracker, eventTracker,
}); });
}); });
@ -86,7 +85,6 @@ describe('ManagementService', () => {
spacesManager: spacesManagerMock.create(), spacesManager: spacesManagerMock.create(),
config, config,
getRolesAPIClient: jest.fn(), getRolesAPIClient: jest.fn(),
solutionNavExperiment: Promise.resolve(false),
eventTracker, eventTracker,
}); });

View file

@ -21,7 +21,6 @@ interface SetupDeps {
spacesManager: SpacesManager; spacesManager: SpacesManager;
config: ConfigType; config: ConfigType;
getRolesAPIClient: () => Promise<RolesAPIClient>; getRolesAPIClient: () => Promise<RolesAPIClient>;
solutionNavExperiment: Promise<boolean>;
eventTracker: EventTracker; eventTracker: EventTracker;
} }
@ -34,7 +33,6 @@ export class ManagementService {
spacesManager, spacesManager,
config, config,
getRolesAPIClient, getRolesAPIClient,
solutionNavExperiment,
eventTracker, eventTracker,
}: SetupDeps) { }: SetupDeps) {
this.registeredSpacesManagementApp = management.sections.section.kibana.registerApp( this.registeredSpacesManagementApp = management.sections.section.kibana.registerApp(
@ -43,7 +41,6 @@ export class ManagementService {
spacesManager, spacesManager,
config, config,
getRolesAPIClient, getRolesAPIClient,
solutionNavExperiment,
eventTracker, eventTracker,
}) })
); );

View file

@ -84,6 +84,7 @@ describe('SpacesGridPage', () => {
catalogue: {}, catalogue: {},
spaces: { manage: true }, spaces: { manage: true },
}} }}
allowSolutionVisibility={false}
{...spacesGridCommonProps} {...spacesGridCommonProps}
/> />
); );
@ -143,7 +144,7 @@ describe('SpacesGridPage', () => {
catalogue: {}, catalogue: {},
spaces: { manage: true }, spaces: { manage: true },
}} }}
solutionNavExperiment={Promise.resolve(true)} allowSolutionVisibility
{...spacesGridCommonProps} {...spacesGridCommonProps}
/> />
); );
@ -179,7 +180,7 @@ describe('SpacesGridPage', () => {
catalogue: {}, catalogue: {},
spaces: { manage: true }, spaces: { manage: true },
}} }}
solutionNavExperiment={Promise.resolve(true)} allowSolutionVisibility
{...spacesGridCommonProps} {...spacesGridCommonProps}
/> />
); );
@ -212,7 +213,7 @@ describe('SpacesGridPage', () => {
catalogue: {}, catalogue: {},
spaces: { manage: true }, spaces: { manage: true },
}} }}
solutionNavExperiment={Promise.resolve(true)} allowSolutionVisibility
{...spacesGridCommonProps} {...spacesGridCommonProps}
/> />
); );
@ -244,7 +245,7 @@ describe('SpacesGridPage', () => {
catalogue: {}, catalogue: {},
spaces: { manage: true }, spaces: { manage: true },
}} }}
solutionNavExperiment={Promise.resolve(true)} allowSolutionVisibility
{...spacesGridCommonProps} {...spacesGridCommonProps}
/> />
); );
@ -275,6 +276,7 @@ describe('SpacesGridPage', () => {
catalogue: {}, catalogue: {},
spaces: { manage: true }, spaces: { manage: true },
}} }}
allowSolutionVisibility
{...spacesGridCommonProps} {...spacesGridCommonProps}
/> />
); );
@ -305,6 +307,7 @@ describe('SpacesGridPage', () => {
spaces: { manage: true }, spaces: { manage: true },
}} }}
maxSpaces={1} maxSpaces={1}
allowSolutionVisibility
serverBasePath={spacesGridCommonProps.serverBasePath} serverBasePath={spacesGridCommonProps.serverBasePath}
/> />
); );
@ -339,6 +342,7 @@ describe('SpacesGridPage', () => {
catalogue: {}, catalogue: {},
spaces: { manage: true }, spaces: { manage: true },
}} }}
allowSolutionVisibility
{...spacesGridCommonProps} {...spacesGridCommonProps}
/> />
); );
@ -374,6 +378,7 @@ describe('SpacesGridPage', () => {
catalogue: {}, catalogue: {},
spaces: { manage: true }, spaces: { manage: true },
}} }}
allowSolutionVisibility
{...spacesGridCommonProps} {...spacesGridCommonProps}
/> />
); );

View file

@ -57,7 +57,7 @@ interface Props {
history: ScopedHistory; history: ScopedHistory;
getUrlForApp: ApplicationStart['getUrlForApp']; getUrlForApp: ApplicationStart['getUrlForApp'];
maxSpaces: number; maxSpaces: number;
solutionNavExperiment?: Promise<boolean>; allowSolutionVisibility: boolean;
} }
interface State { interface State {
@ -67,7 +67,6 @@ interface State {
loading: boolean; loading: boolean;
showConfirmDeleteModal: boolean; showConfirmDeleteModal: boolean;
selectedSpace: Space | null; selectedSpace: Space | null;
isSolutionNavEnabled: boolean;
} }
export class SpacesGridPage extends Component<Props, State> { export class SpacesGridPage extends Component<Props, State> {
@ -80,7 +79,6 @@ export class SpacesGridPage extends Component<Props, State> {
loading: true, loading: true,
showConfirmDeleteModal: false, showConfirmDeleteModal: false,
selectedSpace: null, selectedSpace: null,
isSolutionNavEnabled: false,
}; };
} }
@ -88,10 +86,6 @@ export class SpacesGridPage extends Component<Props, State> {
if (this.props.capabilities.spaces.manage) { if (this.props.capabilities.spaces.manage) {
this.loadGrid(); this.loadGrid();
} }
this.props.solutionNavExperiment?.then((isEnabled) => {
this.setState({ isSolutionNavEnabled: isEnabled });
});
} }
public render() { public render() {
@ -367,7 +361,7 @@ export class SpacesGridPage extends Component<Props, State> {
}, },
]; ];
if (this.state.isSolutionNavEnabled) { if (this.props.allowSolutionVisibility) {
config.push({ config.push({
field: 'solution', field: 'solution',
name: i18n.translate('xpack.spaces.management.spacesGridPage.solutionColumnName', { name: i18n.translate('xpack.spaces.management.spacesGridPage.solutionColumnName', {

View file

@ -30,6 +30,7 @@ import { spacesManagerMock } from '../spaces_manager/mocks';
const config: ConfigType = { const config: ConfigType = {
maxSpaces: 1000, maxSpaces: 1000,
allowFeatureVisibility: true, allowFeatureVisibility: true,
allowSolutionVisibility: true,
}; };
const eventTracker = new EventTracker({ reportEvent: jest.fn() }); const eventTracker = new EventTracker({ reportEvent: jest.fn() });
@ -56,7 +57,6 @@ async function mountApp(basePath: string, pathname: string, spaceId?: string) {
getStartServices: async () => [coreStart, pluginsStart as PluginsStart, {}], getStartServices: async () => [coreStart, pluginsStart as PluginsStart, {}],
config, config,
getRolesAPIClient: jest.fn(), getRolesAPIClient: jest.fn(),
solutionNavExperiment: Promise.resolve(false),
eventTracker, eventTracker,
}) })
.mount({ .mount({
@ -79,7 +79,6 @@ describe('spacesManagementApp', () => {
getStartServices: coreMock.createSetup().getStartServices as any, getStartServices: coreMock.createSetup().getStartServices as any,
config, config,
getRolesAPIClient: jest.fn(), getRolesAPIClient: jest.fn(),
solutionNavExperiment: Promise.resolve(false),
eventTracker, eventTracker,
}) })
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
@ -105,7 +104,7 @@ describe('spacesManagementApp', () => {
css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)." css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)."
data-test-subj="kbnRedirectAppLink" data-test-subj="kbnRedirectAppLink"
> >
Spaces Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"serverBasePath":"","history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}},"maxSpaces":1000,"solutionNavExperiment":{}} Spaces Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"serverBasePath":"","history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}},"maxSpaces":1000,"allowSolutionVisibility":true}
</div> </div>
</div> </div>
`); `);
@ -132,7 +131,7 @@ describe('spacesManagementApp', () => {
css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)." css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)."
data-test-subj="kbnRedirectAppLink" data-test-subj="kbnRedirectAppLink"
> >
Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/create","search":"","hash":""}},"allowFeatureVisibility":true,"solutionNavExperiment":{},"eventTracker":{"analytics":{}}} Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/create","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true,"eventTracker":{"analytics":{}}}
</div> </div>
</div> </div>
`); `);
@ -165,7 +164,7 @@ describe('spacesManagementApp', () => {
css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)." css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)."
data-test-subj="kbnRedirectAppLink" data-test-subj="kbnRedirectAppLink"
> >
Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"spaceId":"some-space","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/some-space","search":"","hash":""}},"allowFeatureVisibility":true,"solutionNavExperiment":{},"eventTracker":{"analytics":{}}} Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"spaceId":"some-space","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/some-space","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true,"eventTracker":{"analytics":{}}}
</div> </div>
</div> </div>
`); `);

View file

@ -29,19 +29,12 @@ interface CreateParams {
spacesManager: SpacesManager; spacesManager: SpacesManager;
config: ConfigType; config: ConfigType;
getRolesAPIClient: () => Promise<RolesAPIClient>; getRolesAPIClient: () => Promise<RolesAPIClient>;
solutionNavExperiment: Promise<boolean>;
eventTracker: EventTracker; eventTracker: EventTracker;
} }
export const spacesManagementApp = Object.freeze({ export const spacesManagementApp = Object.freeze({
id: 'spaces', id: 'spaces',
create({ create({ getStartServices, spacesManager, config, eventTracker }: CreateParams) {
getStartServices,
spacesManager,
config,
solutionNavExperiment,
eventTracker,
}: CreateParams) {
const title = i18n.translate('xpack.spaces.displayName', { const title = i18n.translate('xpack.spaces.displayName', {
defaultMessage: 'Spaces', defaultMessage: 'Spaces',
}); });
@ -75,7 +68,7 @@ export const spacesManagementApp = Object.freeze({
history={history} history={history}
getUrlForApp={application.getUrlForApp} getUrlForApp={application.getUrlForApp}
maxSpaces={config.maxSpaces} maxSpaces={config.maxSpaces}
solutionNavExperiment={solutionNavExperiment} allowSolutionVisibility={config.allowSolutionVisibility}
/> />
); );
}; };
@ -98,7 +91,7 @@ export const spacesManagementApp = Object.freeze({
spacesManager={spacesManager} spacesManager={spacesManager}
history={history} history={history}
allowFeatureVisibility={config.allowFeatureVisibility} allowFeatureVisibility={config.allowFeatureVisibility}
solutionNavExperiment={solutionNavExperiment} allowSolutionVisibility={config.allowSolutionVisibility}
eventTracker={eventTracker} eventTracker={eventTracker}
/> />
); );
@ -126,7 +119,7 @@ export const spacesManagementApp = Object.freeze({
onLoadSpace={onLoadSpace} onLoadSpace={onLoadSpace}
history={history} history={history}
allowFeatureVisibility={config.allowFeatureVisibility} allowFeatureVisibility={config.allowFeatureVisibility}
solutionNavExperiment={solutionNavExperiment} allowSolutionVisibility={config.allowSolutionVisibility}
eventTracker={eventTracker} eventTracker={eventTracker}
/> />
); );

View file

@ -48,7 +48,7 @@ interface Props {
navigateToApp: ApplicationStart['navigateToApp']; navigateToApp: ApplicationStart['navigateToApp'];
navigateToUrl: ApplicationStart['navigateToUrl']; navigateToUrl: ApplicationStart['navigateToUrl'];
readonly activeSpace: Space | null; readonly activeSpace: Space | null;
isSolutionNavEnabled: boolean; allowSolutionVisibility: boolean;
eventTracker: EventTracker; eventTracker: EventTracker;
} }
class SpacesMenuUI extends Component<Props> { class SpacesMenuUI extends Component<Props> {
@ -97,7 +97,7 @@ class SpacesMenuUI extends Component<Props> {
id={this.props.id} id={this.props.id}
className={'spcMenu'} className={'spcMenu'}
title={i18n.translate('xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', { title={i18n.translate('xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', {
defaultMessage: 'Change current space xx', defaultMessage: 'Change current space',
})} })}
{...searchableProps} {...searchableProps}
noMatchesMessage={noSpacesMessage} noMatchesMessage={noSpacesMessage}
@ -140,7 +140,7 @@ class SpacesMenuUI extends Component<Props> {
<LazySpaceAvatar space={space} size={'s'} announceSpaceName={false} /> <LazySpaceAvatar space={space} size={'s'} announceSpaceName={false} />
</Suspense> </Suspense>
), ),
...(this.props.isSolutionNavEnabled && { ...(this.props.allowSolutionVisibility && {
append: <SpaceSolutionBadge solution={space.solution} />, append: <SpaceSolutionBadge solution={space.solution} />,
}), }),
checked: this.props.activeSpace?.id === space.id ? 'on' : undefined, checked: this.props.activeSpace?.id === space.id ? 'on' : undefined,

View file

@ -13,12 +13,13 @@ import type { CoreStart } from '@kbn/core/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { EventTracker } from '../analytics'; import type { EventTracker } from '../analytics';
import type { ConfigType } from '../config';
import type { SpacesManager } from '../spaces_manager'; import type { SpacesManager } from '../spaces_manager';
export function initSpacesNavControl( export function initSpacesNavControl(
spacesManager: SpacesManager, spacesManager: SpacesManager,
core: CoreStart, core: CoreStart,
solutionNavExperiment: Promise<boolean>, config: ConfigType,
eventTracker: EventTracker eventTracker: EventTracker
) { ) {
core.chrome.navControls.registerLeft({ core.chrome.navControls.registerLeft({
@ -44,7 +45,7 @@ export function initSpacesNavControl(
capabilities={core.application.capabilities} capabilities={core.application.capabilities}
navigateToApp={core.application.navigateToApp} navigateToApp={core.application.navigateToApp}
navigateToUrl={core.application.navigateToUrl} navigateToUrl={core.application.navigateToUrl}
solutionNavExperiment={solutionNavExperiment} allowSolutionVisibility={config.allowSolutionVisibility}
eventTracker={eventTracker} eventTracker={eventTracker}
/> />
</Suspense> </Suspense>

View file

@ -49,7 +49,7 @@ const reportEvent = jest.fn();
const eventTracker = new EventTracker({ reportEvent }); const eventTracker = new EventTracker({ reportEvent });
describe('NavControlPopover', () => { describe('NavControlPopover', () => {
async function setup(spaces: Space[], isSolutionNavEnabled = false, activeSpace?: Space) { async function setup(spaces: Space[], allowSolutionVisibility = false, activeSpace?: Space) {
const spacesManager = spacesManagerMock.create(); const spacesManager = spacesManagerMock.create();
spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces); spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces);
@ -66,7 +66,7 @@ describe('NavControlPopover', () => {
capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }} capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }}
navigateToApp={jest.fn()} navigateToApp={jest.fn()}
navigateToUrl={jest.fn()} navigateToUrl={jest.fn()}
solutionNavExperiment={Promise.resolve(isSolutionNavEnabled)} allowSolutionVisibility={allowSolutionVisibility}
eventTracker={eventTracker} eventTracker={eventTracker}
/> />
); );
@ -89,7 +89,7 @@ describe('NavControlPopover', () => {
capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }} capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }}
navigateToApp={jest.fn()} navigateToApp={jest.fn()}
navigateToUrl={jest.fn()} navigateToUrl={jest.fn()}
solutionNavExperiment={Promise.resolve(false)} allowSolutionVisibility={false}
eventTracker={eventTracker} eventTracker={eventTracker}
/> />
); );
@ -115,7 +115,7 @@ describe('NavControlPopover', () => {
capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }} capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }}
navigateToApp={jest.fn()} navigateToApp={jest.fn()}
navigateToUrl={jest.fn()} navigateToUrl={jest.fn()}
solutionNavExperiment={Promise.resolve(false)} allowSolutionVisibility={false}
eventTracker={eventTracker} eventTracker={eventTracker}
/> />
); );
@ -283,7 +283,7 @@ describe('NavControlPopover', () => {
]; ];
const activeSpace = spaces[0]; const activeSpace = spaces[0];
const wrapper = await setup(spaces, true /** isSolutionEnabled **/, activeSpace); const wrapper = await setup(spaces, true /** allowSolutionVisibility **/, activeSpace);
await act(async () => { await act(async () => {
wrapper.find(EuiHeaderSectionItemButton).find('button').simulate('click'); wrapper.find(EuiHeaderSectionItemButton).find('button').simulate('click');

View file

@ -38,7 +38,7 @@ interface Props {
navigateToUrl: ApplicationStart['navigateToUrl']; navigateToUrl: ApplicationStart['navigateToUrl'];
serverBasePath: string; serverBasePath: string;
theme: WithEuiThemeProps['theme']; theme: WithEuiThemeProps['theme'];
solutionNavExperiment: Promise<boolean>; allowSolutionVisibility: boolean;
eventTracker: EventTracker; eventTracker: EventTracker;
} }
@ -47,7 +47,6 @@ interface State {
loading: boolean; loading: boolean;
activeSpace: Space | null; activeSpace: Space | null;
spaces: Space[]; spaces: Space[];
isSolutionNavEnabled: boolean;
} }
const popoutContentId = 'headerSpacesMenuContent'; const popoutContentId = 'headerSpacesMenuContent';
@ -62,7 +61,6 @@ class NavControlPopoverUI extends Component<Props, State> {
loading: false, loading: false,
activeSpace: null, activeSpace: null,
spaces: [], spaces: [],
isSolutionNavEnabled: false,
}; };
} }
@ -74,10 +72,6 @@ class NavControlPopoverUI extends Component<Props, State> {
}); });
}, },
}); });
this.props.solutionNavExperiment.then((isEnabled) => {
this.setState({ isSolutionNavEnabled: isEnabled });
});
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -110,7 +104,7 @@ class NavControlPopoverUI extends Component<Props, State> {
navigateToApp={this.props.navigateToApp} navigateToApp={this.props.navigateToApp}
navigateToUrl={this.props.navigateToUrl} navigateToUrl={this.props.navigateToUrl}
activeSpace={this.state.activeSpace} activeSpace={this.state.activeSpace}
isSolutionNavEnabled={this.state.isSolutionNavEnabled} allowSolutionVisibility={this.props.allowSolutionVisibility}
eventTracker={this.props.eventTracker} eventTracker={this.props.eventTracker}
/> />
); );

View file

@ -5,7 +5,6 @@
* 2.0. * 2.0.
*/ */
import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { FeaturesPluginStart } from '@kbn/features-plugin/public'; import type { FeaturesPluginStart } from '@kbn/features-plugin/public';
@ -16,7 +15,6 @@ import type { SecurityPluginStart } from '@kbn/security-plugin-types-public';
import { EventTracker, registerAnalyticsContext, registerSpacesEventTypes } from './analytics'; import { EventTracker, registerAnalyticsContext, registerSpacesEventTypes } from './analytics';
import type { ConfigType } from './config'; import type { ConfigType } from './config';
import { createSpacesFeatureCatalogueEntry } from './create_feature_catalogue_entry'; import { createSpacesFeatureCatalogueEntry } from './create_feature_catalogue_entry';
import { isSolutionNavEnabled } from './experiments';
import { ManagementService } from './management'; import { ManagementService } from './management';
import { initSpacesNavControl } from './nav_control'; import { initSpacesNavControl } from './nav_control';
import { spaceSelectorApp } from './space_selector'; import { spaceSelectorApp } from './space_selector';
@ -34,7 +32,6 @@ export interface PluginsStart {
features: FeaturesPluginStart; features: FeaturesPluginStart;
management?: ManagementStart; management?: ManagementStart;
cloud?: CloudStart; cloud?: CloudStart;
cloudExperiments?: CloudExperimentsPluginStart;
} }
/** /**
@ -53,9 +50,8 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
private eventTracker!: EventTracker; private eventTracker!: EventTracker;
private managementService?: ManagementService; private managementService?: ManagementService;
private readonly config: ConfigType; private config: ConfigType;
private readonly isServerless: boolean; private readonly isServerless: boolean;
private solutionNavExperiment = Promise.resolve(false);
constructor(private readonly initializerContext: PluginInitializerContext) { constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<ConfigType>(); this.config = this.initializerContext.config.get<ConfigType>();
@ -75,18 +71,16 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
hasOnlyDefaultSpace, hasOnlyDefaultSpace,
}; };
const onCloud = plugins.cloud !== undefined && plugins.cloud.isCloudEnabled;
if (!onCloud) {
this.config = {
...this.config,
allowSolutionVisibility: false,
};
}
registerSpacesEventTypes(core); registerSpacesEventTypes(core);
this.eventTracker = new EventTracker(core.analytics); this.eventTracker = new EventTracker(core.analytics);
this.solutionNavExperiment = core
.getStartServices()
.then(([, { cloud, cloudExperiments }]) => isSolutionNavEnabled(cloud, cloudExperiments))
.catch((err) => {
this.initializerContext.logger.get().error(`Failed to retrieve cloud experiment: ${err}`);
return false;
});
// Only skip setup of space selector and management service if serverless and only one space is allowed // Only skip setup of space selector and management service if serverless and only one space is allowed
if (!(this.isServerless && hasOnlyDefaultSpace)) { if (!(this.isServerless && hasOnlyDefaultSpace)) {
const getRolesAPIClient = async () => { const getRolesAPIClient = async () => {
@ -113,7 +107,6 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
spacesManager: this.spacesManager, spacesManager: this.spacesManager,
config: this.config, config: this.config,
getRolesAPIClient, getRolesAPIClient,
solutionNavExperiment: this.solutionNavExperiment,
eventTracker: this.eventTracker, eventTracker: this.eventTracker,
}); });
} }
@ -133,7 +126,7 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
public start(core: CoreStart) { public start(core: CoreStart) {
// Only skip spaces navigation if serverless and only one space is allowed // Only skip spaces navigation if serverless and only one space is allowed
if (!(this.isServerless && this.config.maxSpaces === 1)) { if (!(this.isServerless && this.config.maxSpaces === 1)) {
initSpacesNavControl(this.spacesManager, core, this.solutionNavExperiment, this.eventTracker); initSpacesNavControl(this.spacesManager, core, this.config, this.eventTracker);
} }
return this.spacesApi; return this.spacesApi;

View file

@ -21,6 +21,7 @@ describe('config schema', () => {
expect(ConfigSchema.validate({})).toMatchInlineSnapshot(` expect(ConfigSchema.validate({})).toMatchInlineSnapshot(`
Object { Object {
"allowFeatureVisibility": true, "allowFeatureVisibility": true,
"allowSolutionVisibility": true,
"enabled": true, "enabled": true,
"maxSpaces": 1000, "maxSpaces": 1000,
} }
@ -29,6 +30,7 @@ describe('config schema', () => {
expect(ConfigSchema.validate({}, { dev: false })).toMatchInlineSnapshot(` expect(ConfigSchema.validate({}, { dev: false })).toMatchInlineSnapshot(`
Object { Object {
"allowFeatureVisibility": true, "allowFeatureVisibility": true,
"allowSolutionVisibility": true,
"enabled": true, "enabled": true,
"maxSpaces": 1000, "maxSpaces": 1000,
} }
@ -37,6 +39,7 @@ describe('config schema', () => {
expect(ConfigSchema.validate({}, { dev: true })).toMatchInlineSnapshot(` expect(ConfigSchema.validate({}, { dev: true })).toMatchInlineSnapshot(`
Object { Object {
"allowFeatureVisibility": true, "allowFeatureVisibility": true,
"allowSolutionVisibility": true,
"enabled": true, "enabled": true,
"maxSpaces": 1000, "maxSpaces": 1000,
} }
@ -61,19 +64,36 @@ describe('config schema', () => {
expect(() => ConfigSchema.validate({ allowFeatureVisibility: false }, {})).toThrow(); expect(() => ConfigSchema.validate({ allowFeatureVisibility: false }, {})).toThrow();
}); });
it('should not throw error if allowFeatureVisibility is disabled in serverless offering', () => { it('should not throw error if allowFeatureVisibility and allowSolutionVisibility are disabled in serverless offering', () => {
expect(() => expect(() =>
ConfigSchema.validate({ allowFeatureVisibility: false }, { serverless: true }) ConfigSchema.validate(
{ allowFeatureVisibility: false, allowSolutionVisibility: false },
{ serverless: true }
)
).not.toThrow(); ).not.toThrow();
}); });
it('should not throw error if allowFeatureVisibility is enabled in classic offering', () => { it('should not throw error if allowFeatureVisibility and allowSolutionVisibility are enabled in classic offering', () => {
expect(() => ConfigSchema.validate({ allowFeatureVisibility: true }, {})).not.toThrow(); expect(() =>
ConfigSchema.validate({ allowFeatureVisibility: true, allowSolutionVisibility: true }, {})
).not.toThrow();
}); });
it('should throw error if allowFeatureVisibility is enabled in serverless offering', () => { it('should throw error if allowFeatureVisibility is enabled in serverless offering', () => {
expect(() => expect(() =>
ConfigSchema.validate({ allowFeatureVisibility: true }, { serverless: true }) ConfigSchema.validate(
{ allowFeatureVisibility: true, allowSolutionVisibility: false },
{ serverless: true }
)
).toThrow();
});
it('should throw error if allowSolutionVisibility is enabled in serverless offering', () => {
expect(() =>
ConfigSchema.validate(
{ allowSolutionVisibility: true, allowFeatureVisibility: false },
{ serverless: true }
)
).toThrow(); ).toThrow();
}); });
}); });

View file

@ -40,6 +40,20 @@ export const ConfigSchema = schema.object({
defaultValue: true, defaultValue: true,
}), }),
}), }),
allowSolutionVisibility: offeringBasedSchema({
serverless: schema.literal(false),
traditional: schema.boolean({
validate: (rawValue) => {
// This setting should not be configurable on-prem to avoid bugs when e.g. existing spaces
// have custom solution but admins would be unable to change the navigation solution if the
// UI/APIs are disabled.
if (rawValue === false) {
return 'Solution visibility can only be disabled on serverless';
}
},
defaultValue: true,
}),
}),
}); });
export function createConfig$(context: PluginInitializerContext) { export function createConfig$(context: PluginInitializerContext) {

View file

@ -33,6 +33,7 @@ export const config: PluginConfigDescriptor = {
exposeToBrowser: { exposeToBrowser: {
maxSpaces: true, maxSpaces: true,
allowFeatureVisibility: true, allowFeatureVisibility: true,
allowSolutionVisibility: true,
}, },
}; };

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import { lastValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server';
@ -34,11 +34,14 @@ describe('Spaces plugin', () => {
expect(spacesSetup).toMatchInlineSnapshot(` expect(spacesSetup).toMatchInlineSnapshot(`
Object { Object {
"hasOnlyDefaultSpace$": Observable { "hasOnlyDefaultSpace$": Observable {
"operator": [Function],
"source": Observable {
"operator": [Function], "operator": [Function],
"source": Observable { "source": Observable {
"_subscribe": [Function], "_subscribe": [Function],
}, },
}, },
},
"spacesClient": Object { "spacesClient": Object {
"registerClientWrapper": [Function], "registerClientWrapper": [Function],
"setClientRepositoryFactory": [Function], "setClientRepositoryFactory": [Function],
@ -118,11 +121,14 @@ describe('Spaces plugin', () => {
expect(spacesStart).toMatchInlineSnapshot(` expect(spacesStart).toMatchInlineSnapshot(`
Object { Object {
"hasOnlyDefaultSpace$": Observable { "hasOnlyDefaultSpace$": Observable {
"operator": [Function],
"source": Observable {
"operator": [Function], "operator": [Function],
"source": Observable { "source": Observable {
"_subscribe": [Function], "_subscribe": [Function],
}, },
}, },
},
"spacesService": Object { "spacesService": Object {
"createSpacesClient": [Function], "createSpacesClient": [Function],
"getActiveSpace": [Function], "getActiveSpace": [Function],
@ -150,8 +156,8 @@ describe('Spaces plugin', () => {
const coreStart = coreMock.createStart(); const coreStart = coreMock.createStart();
const spacesStart = plugin.start(coreStart); const spacesStart = plugin.start(coreStart);
await expect(lastValueFrom(spacesSetup.hasOnlyDefaultSpace$)).resolves.toEqual(true); await expect(firstValueFrom(spacesSetup.hasOnlyDefaultSpace$)).resolves.toEqual(true);
await expect(lastValueFrom(spacesStart.hasOnlyDefaultSpace$)).resolves.toEqual(true); await expect(firstValueFrom(spacesStart.hasOnlyDefaultSpace$)).resolves.toEqual(true);
}); });
it('determines hasOnlyDefaultSpace$ correctly when maxSpaces=1000', async () => { it('determines hasOnlyDefaultSpace$ correctly when maxSpaces=1000', async () => {
@ -168,7 +174,7 @@ describe('Spaces plugin', () => {
const coreStart = coreMock.createStart(); const coreStart = coreMock.createStart();
const spacesStart = plugin.start(coreStart); const spacesStart = plugin.start(coreStart);
await expect(lastValueFrom(spacesSetup.hasOnlyDefaultSpace$)).resolves.toEqual(false); await expect(firstValueFrom(spacesSetup.hasOnlyDefaultSpace$)).resolves.toEqual(false);
await expect(lastValueFrom(spacesStart.hasOnlyDefaultSpace$)).resolves.toEqual(false); await expect(firstValueFrom(spacesStart.hasOnlyDefaultSpace$)).resolves.toEqual(false);
}); });
}); });

View file

@ -6,7 +6,7 @@
*/ */
import type { Observable } from 'rxjs'; import type { Observable } from 'rxjs';
import { map } from 'rxjs'; import { BehaviorSubject, combineLatest, map } from 'rxjs';
import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server';
import type { import type {
@ -119,8 +119,21 @@ export class SpacesPlugin
private defaultSpaceService?: DefaultSpaceService; private defaultSpaceService?: DefaultSpaceService;
private onCloud$ = new BehaviorSubject<boolean>(false);
constructor(private readonly initializerContext: PluginInitializerContext) { constructor(private readonly initializerContext: PluginInitializerContext) {
this.config$ = initializerContext.config.create<ConfigType>(); this.config$ = combineLatest([
initializerContext.config.create<ConfigType>(),
this.onCloud$,
]).pipe(
map(
([config, onCloud]): ConfigType => ({
...config,
// We only allow "solution" to be set on cloud environments, not on prem
allowSolutionVisibility: onCloud ? config.allowSolutionVisibility : false,
})
)
);
this.hasOnlyDefaultSpace$ = this.config$.pipe(map(({ maxSpaces }) => maxSpaces === 1)); this.hasOnlyDefaultSpace$ = this.config$.pipe(map(({ maxSpaces }) => maxSpaces === 1));
this.log = initializerContext.logger.get(); this.log = initializerContext.logger.get();
this.spacesService = new SpacesService(); this.spacesService = new SpacesService();
@ -131,6 +144,7 @@ export class SpacesPlugin
} }
public setup(core: CoreSetup<PluginsStart>, plugins: PluginsSetup): SpacesPluginSetup { public setup(core: CoreSetup<PluginsStart>, plugins: PluginsSetup): SpacesPluginSetup {
this.onCloud$.next(plugins.cloud !== undefined && plugins.cloud.isCloudEnabled);
const spacesClientSetup = this.spacesClientService.setup({ config$: this.config$ }); const spacesClientSetup = this.spacesClientService.setup({ config$: this.config$ });
const spacesServiceSetup = this.spacesService.setup({ const spacesServiceSetup = this.spacesService.setup({

View file

@ -22,6 +22,7 @@ const createMockConfig = (
enabled: true, enabled: true,
maxSpaces: 1000, maxSpaces: 1000,
allowFeatureVisibility: true, allowFeatureVisibility: true,
allowSolutionVisibility: true,
} }
) => { ) => {
return ConfigSchema.validate(mockConfig, { serverless: !mockConfig.allowFeatureVisibility }); return ConfigSchema.validate(mockConfig, { serverless: !mockConfig.allowFeatureVisibility });
@ -311,6 +312,7 @@ describe('#create', () => {
enabled: true, enabled: true,
maxSpaces, maxSpaces,
allowFeatureVisibility: true, allowFeatureVisibility: true,
allowSolutionVisibility: true,
}); });
const client = new SpacesClient( const client = new SpacesClient(
@ -347,6 +349,7 @@ describe('#create', () => {
enabled: true, enabled: true,
maxSpaces, maxSpaces,
allowFeatureVisibility: true, allowFeatureVisibility: true,
allowSolutionVisibility: true,
}); });
const client = new SpacesClient( const client = new SpacesClient(
@ -382,6 +385,7 @@ describe('#create', () => {
enabled: true, enabled: true,
maxSpaces, maxSpaces,
allowFeatureVisibility: true, allowFeatureVisibility: true,
allowSolutionVisibility: true,
}); });
const client = new SpacesClient( const client = new SpacesClient(
@ -428,6 +432,7 @@ describe('#create', () => {
enabled: true, enabled: true,
maxSpaces, maxSpaces,
allowFeatureVisibility: true, allowFeatureVisibility: true,
allowSolutionVisibility: true,
}); });
const client = new SpacesClient( const client = new SpacesClient(
@ -470,6 +475,7 @@ describe('#create', () => {
enabled: true, enabled: true,
maxSpaces, maxSpaces,
allowFeatureVisibility: false, allowFeatureVisibility: false,
allowSolutionVisibility: false,
}); });
const client = new SpacesClient( const client = new SpacesClient(
@ -506,6 +512,7 @@ describe('#create', () => {
enabled: true, enabled: true,
maxSpaces, maxSpaces,
allowFeatureVisibility: false, allowFeatureVisibility: false,
allowSolutionVisibility: false,
}); });
const client = new SpacesClient( const client = new SpacesClient(
@ -530,6 +537,46 @@ describe('#create', () => {
expect(mockCallWithRequestRepository.create).not.toHaveBeenCalled(); expect(mockCallWithRequestRepository.create).not.toHaveBeenCalled();
}); });
}); });
describe('when config.allowSolutionVisibility is disabled', () => {
test(`throws bad request when creating space with solution`, async () => {
const maxSpaces = 5;
const mockDebugLogger = createMockDebugLogger();
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.create.mockResolvedValue(savedObject);
mockCallWithRequestRepository.find.mockResolvedValue({
total: maxSpaces - 1,
} as any);
const mockConfig = createMockConfig({
enabled: true,
maxSpaces,
allowFeatureVisibility: false,
allowSolutionVisibility: false,
});
const client = new SpacesClient(
mockDebugLogger,
mockConfig,
mockCallWithRequestRepository,
[],
'traditional'
);
await expect(
client.create({ ...spaceToCreate, solution: 'es' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Unable to create Space, the solution property can not be set when xpack.spaces.allowSolutionVisibility setting is disabled"`
);
expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({
type: 'space',
page: 1,
perPage: 0,
});
expect(mockCallWithRequestRepository.create).not.toHaveBeenCalled();
});
});
}); });
describe('#update', () => { describe('#update', () => {
@ -675,6 +722,7 @@ describe('#update', () => {
enabled: true, enabled: true,
maxSpaces: 1000, maxSpaces: 1000,
allowFeatureVisibility: false, allowFeatureVisibility: false,
allowSolutionVisibility: false,
}); });
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue(savedObject); mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
@ -700,6 +748,7 @@ describe('#update', () => {
enabled: true, enabled: true,
maxSpaces: 1000, maxSpaces: 1000,
allowFeatureVisibility: false, allowFeatureVisibility: false,
allowSolutionVisibility: false,
}); });
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue(savedObject); mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
@ -723,6 +772,38 @@ describe('#update', () => {
expect(mockCallWithRequestRepository.get).not.toHaveBeenCalled(); expect(mockCallWithRequestRepository.get).not.toHaveBeenCalled();
}); });
}); });
describe('when config.allowSolutionVisibility is disabled', () => {
test(`throws bad request when updating space with solution`, async () => {
const mockDebugLogger = createMockDebugLogger();
const mockConfig = createMockConfig({
enabled: true,
maxSpaces: 1000,
allowFeatureVisibility: false,
allowSolutionVisibility: false,
});
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
const client = new SpacesClient(
mockDebugLogger,
mockConfig,
mockCallWithRequestRepository,
[],
'traditional'
);
const id = savedObject.id;
await expect(
client.update(id, { ...spaceToUpdate, solution: 'es' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Unable to update Space, the solution property can not be set when xpack.spaces.allowSolutionVisibility setting is disabled"`
);
expect(mockCallWithRequestRepository.update).not.toHaveBeenCalled();
expect(mockCallWithRequestRepository.get).not.toHaveBeenCalled();
});
});
}); });
describe('#delete', () => { describe('#delete', () => {

View file

@ -136,6 +136,12 @@ export class SpacesClient implements ISpacesClient {
); );
} }
if (Boolean(space.solution) && !this.config.allowSolutionVisibility) {
throw Boom.badRequest(
'Unable to create Space, the solution property can not be set when xpack.spaces.allowSolutionVisibility setting is disabled'
);
}
if (this.isServerless && space.hasOwnProperty('solution')) { if (this.isServerless && space.hasOwnProperty('solution')) {
throw Boom.badRequest('Unable to create Space, solution property is forbidden in serverless'); throw Boom.badRequest('Unable to create Space, solution property is forbidden in serverless');
} }
@ -163,6 +169,12 @@ export class SpacesClient implements ISpacesClient {
); );
} }
if (Boolean(space.solution) && !this.config.allowSolutionVisibility) {
throw Boom.badRequest(
'Unable to update Space, the solution property can not be set when xpack.spaces.allowSolutionVisibility setting is disabled'
);
}
if (this.isServerless && space.hasOwnProperty('solution')) { if (this.isServerless && space.hasOwnProperty('solution')) {
throw Boom.badRequest('Unable to update Space, solution property is forbidden in serverless'); throw Boom.badRequest('Unable to update Space, solution property is forbidden in serverless');
} }

View file

@ -37,7 +37,6 @@
"@kbn/utility-types-jest", "@kbn/utility-types-jest",
"@kbn/security-plugin-types-public", "@kbn/security-plugin-types-public",
"@kbn/cloud-plugin", "@kbn/cloud-plugin",
"@kbn/cloud-experiments-plugin",
"@kbn/core-analytics-browser" "@kbn/core-analytics-browser"
], ],
"exclude": [ "exclude": [

View file

@ -7,6 +7,11 @@
import { FtrConfigProviderContext } from '@kbn/test'; import { FtrConfigProviderContext } from '@kbn/test';
/**
* NOTE: The solution view is currently only available in the cloud environment.
* This test suite fakes a cloud environement by setting the cloud.id and cloud.base_url
*/
export default async function ({ readConfigFile }: FtrConfigProviderContext) { export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js'));
@ -17,10 +22,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
...functionalConfig.get('kbnTestServer'), ...functionalConfig.get('kbnTestServer'),
serverArgs: [ serverArgs: [
...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',
// Note: the base64 string in the cloud.id config contains the ES endpoint required in the functional tests // 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.id=ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=',
'--xpack.cloud.base_url=https://cloud.elastic.co', '--xpack.cloud.base_url=https://cloud.elastic.co',

View file

@ -70,6 +70,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
...disabledPlugins ...disabledPlugins
.filter((k) => k !== 'security') .filter((k) => k !== 'security')
.map((key) => `--xpack.${key}.enabled=false`), .map((key) => `--xpack.${key}.enabled=false`),
// Note: we fake a cloud deployment as the solution view is only available in cloud
'--xpack.cloud.id=ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=',
'--xpack.cloud.base_url=https://cloud.elastic.co',
'--xpack.cloud.deployment_url=/deployments/deploymentId',
], ],
}, },
}; };