[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
xpack.spaces.maxSpaces: 1
xpack.spaces.allowFeatureVisibility: false
xpack.spaces.allowSolutionVisibility: false
# Only display console autocomplete suggestions for ES endpoints that are available in serverless
console.autocompleteDefinitions.endpointsAvailability: serverless

View file

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

View file

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

View file

@ -6,4 +6,4 @@
* 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",
"server": true,
"browser": true,
"optionalPlugins": ["cloud", "cloudExperiments", "spaces"],
"optionalPlugins": ["cloud", "spaces"],
"requiredPlugins": ["unifiedSearch"],
"requiredBundles": []
}

View file

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

View file

@ -6,23 +6,13 @@
* Side Public License, v 1.
*/
import React from 'react';
import {
firstValueFrom,
from,
of,
ReplaySubject,
shareReplay,
take,
combineLatest,
map,
} from 'rxjs';
import { of, ReplaySubject, take, map, Observable } from 'rxjs';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { Space } from '@kbn/spaces-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 { SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common';
import type {
NavigationPublicSetup,
NavigationPublicStart,
@ -49,7 +39,7 @@ export class NavigationPublicPlugin
private readonly stop$ = new ReplaySubject<void>(1);
private coreStart?: CoreStart;
private depsStart?: NavigationPublicStartDependencies;
private isSolutionNavExperiementEnabled$ = of(false);
private isSolutionNavEnabled = false;
constructor(private initializerContext: PluginInitializerContext) {}
@ -70,10 +60,14 @@ export class NavigationPublicPlugin
this.coreStart = core;
this.depsStart = depsStart;
const { unifiedSearch, cloud, cloudExperiments, spaces } = depsStart;
const { unifiedSearch, cloud, spaces } = depsStart;
const extensions = this.topNavMenuExtensionsRegistry.getAll();
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);
};
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
combineLatest([this.isSolutionNavExperiementEnabled$, activeSpace$])
.pipe(take(1))
.subscribe(([isEnabled, activeSpace]) => {
activeSpace$.pipe(take(1)).subscribe((activeSpace) => {
this.initiateChromeStyleAndSideNav(chrome, {
isFeatureEnabled: isEnabled,
isServerless,
activeSpace,
});
if (!isEnabled) return;
if (!this.isSolutionNavEnabled) return;
chrome.project.setCloudUrls(cloud!);
});
@ -126,17 +108,12 @@ export class NavigationPublicPlugin
createTopNavWithCustomContext: createCustomTopNav,
},
addSolutionNavigation: (solutionNavigation) => {
firstValueFrom(this.isSolutionNavExperiementEnabled$).then((isEnabled) => {
if (!isEnabled) return;
if (!this.isSolutionNavEnabled) return;
this.addSolutionNavigation(solutionNavigation);
});
},
isSolutionNavEnabled$: combineLatest([
this.isSolutionNavExperiementEnabled$,
activeSpace$,
]).pipe(
map(([isFeatureEnabled, activeSpace]) => {
return getIsProjectNav(isFeatureEnabled, activeSpace?.solution) && !isServerless;
isSolutionNavEnabled$: activeSpace$.pipe(
map((activeSpace) => {
return this.isSolutionNavEnabled && getIsProjectNav(activeSpace?.solution);
})
),
};
@ -181,14 +158,10 @@ export class NavigationPublicPlugin
private initiateChromeStyleAndSideNav(
chrome: InternalChromeStart,
{
isFeatureEnabled,
isServerless,
activeSpace,
}: { isFeatureEnabled: boolean; isServerless: boolean; activeSpace?: Space }
{ isServerless, activeSpace }: { isServerless: boolean; activeSpace?: Space }
) {
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
if (!isServerless) {
@ -201,8 +174,8 @@ export class NavigationPublicPlugin
}
}
function getIsProjectNav(isFeatureEnabled: boolean, solutionView?: string) {
return isFeatureEnabled && Boolean(solutionView) && isKnownSolutionView(solutionView);
function getIsProjectNav(solutionView?: string) {
return Boolean(solutionView) && isKnownSolutionView(solutionView);
}
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 { CloudSetup, CloudStart } from '@kbn/cloud-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 { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup, createTopNav } from './top_nav_menu';
@ -53,7 +52,6 @@ export interface NavigationPublicSetupDependencies {
export interface NavigationPublicStartDependencies {
unifiedSearch: UnifiedSearchPublicPluginStart;
cloud?: CloudStart;
cloudExperiments?: CloudExperimentsPluginStart;
spaces?: SpacesPluginStart;
}

View file

@ -5,7 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* 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 { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server';
@ -21,7 +20,6 @@ export interface NavigationServerSetupDependencies {
}
export interface NavigationServerStartDependencies {
cloudExperiments?: CloudExperimentsPluginStart;
cloud?: CloudStart;
spaces?: SpacesPluginStart;
}

View file

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

View file

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

View file

@ -36,10 +36,6 @@ export enum FEATURE_FLAG_NAMES {
* Options are: `true` and `false`.
*/
'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",
"usageCollection",
"cloud",
"cloudExperiments"
],
"requiredBundles": [
"esUiShared",

View file

@ -8,4 +8,5 @@
export interface ConfigType {
maxSpaces: number;
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}
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();
spacesManager.createSpace = jest.fn(spacesManager.createSpace);
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
@ -130,7 +131,7 @@ describe('ManageSpacePage', () => {
spaces: { manage: true },
}}
allowFeatureVisibility
solutionNavExperiment={Promise.resolve(true)}
allowSolutionVisibility
eventTracker={eventTracker}
/>
);
@ -143,12 +144,11 @@ describe('ManageSpacePage', () => {
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();
spacesManager.createSpace = jest.fn(spacesManager.createSpace);
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
{
const wrapper = mountWithIntl(
<ManageSpacePage
spacesManager={spacesManager as unknown as SpacesManager}
@ -162,6 +162,7 @@ describe('ManageSpacePage', () => {
spaces: { manage: true },
}}
allowFeatureVisibility
allowSolutionVisibility={false}
eventTracker={eventTracker}
/>
);
@ -172,34 +173,6 @@ describe('ManageSpacePage', () => {
});
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 () => {
@ -221,6 +194,7 @@ describe('ManageSpacePage', () => {
}}
eventTracker={eventTracker}
allowFeatureVisibility
allowSolutionVisibility
/>
);
@ -251,6 +225,7 @@ describe('ManageSpacePage', () => {
}}
eventTracker={eventTracker}
allowFeatureVisibility={false}
allowSolutionVisibility
/>
);
@ -297,7 +272,7 @@ describe('ManageSpacePage', () => {
}}
eventTracker={eventTracker}
allowFeatureVisibility
solutionNavExperiment={Promise.resolve(true)}
allowSolutionVisibility
/>
);
@ -375,6 +350,7 @@ describe('ManageSpacePage', () => {
}}
eventTracker={eventTracker}
allowFeatureVisibility
allowSolutionVisibility
/>
);
@ -425,6 +401,7 @@ describe('ManageSpacePage', () => {
}}
eventTracker={eventTracker}
allowFeatureVisibility
allowSolutionVisibility
/>
);
@ -463,6 +440,7 @@ describe('ManageSpacePage', () => {
}}
eventTracker={eventTracker}
allowFeatureVisibility
allowSolutionVisibility
/>
);
@ -525,6 +503,7 @@ describe('ManageSpacePage', () => {
}}
eventTracker={eventTracker}
allowFeatureVisibility
allowSolutionVisibility
/>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,7 @@ import { spacesManagerMock } from '../spaces_manager/mocks';
const config: ConfigType = {
maxSpaces: 1000,
allowFeatureVisibility: true,
allowSolutionVisibility: true,
};
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, {}],
config,
getRolesAPIClient: jest.fn(),
solutionNavExperiment: Promise.resolve(false),
eventTracker,
})
.mount({
@ -79,7 +79,6 @@ describe('spacesManagementApp', () => {
getStartServices: coreMock.createSetup().getStartServices as any,
config,
getRolesAPIClient: jest.fn(),
solutionNavExperiment: Promise.resolve(false),
eventTracker,
})
).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)."
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>
`);
@ -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)."
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>
`);
@ -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)."
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>
`);

View file

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

View file

@ -48,7 +48,7 @@ interface Props {
navigateToApp: ApplicationStart['navigateToApp'];
navigateToUrl: ApplicationStart['navigateToUrl'];
readonly activeSpace: Space | null;
isSolutionNavEnabled: boolean;
allowSolutionVisibility: boolean;
eventTracker: EventTracker;
}
class SpacesMenuUI extends Component<Props> {
@ -97,7 +97,7 @@ class SpacesMenuUI extends Component<Props> {
id={this.props.id}
className={'spcMenu'}
title={i18n.translate('xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', {
defaultMessage: 'Change current space xx',
defaultMessage: 'Change current space',
})}
{...searchableProps}
noMatchesMessage={noSpacesMessage}
@ -140,7 +140,7 @@ class SpacesMenuUI extends Component<Props> {
<LazySpaceAvatar space={space} size={'s'} announceSpaceName={false} />
</Suspense>
),
...(this.props.isSolutionNavEnabled && {
...(this.props.allowSolutionVisibility && {
append: <SpaceSolutionBadge solution={space.solution} />,
}),
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 type { EventTracker } from '../analytics';
import type { ConfigType } from '../config';
import type { SpacesManager } from '../spaces_manager';
export function initSpacesNavControl(
spacesManager: SpacesManager,
core: CoreStart,
solutionNavExperiment: Promise<boolean>,
config: ConfigType,
eventTracker: EventTracker
) {
core.chrome.navControls.registerLeft({
@ -44,7 +45,7 @@ export function initSpacesNavControl(
capabilities={core.application.capabilities}
navigateToApp={core.application.navigateToApp}
navigateToUrl={core.application.navigateToUrl}
solutionNavExperiment={solutionNavExperiment}
allowSolutionVisibility={config.allowSolutionVisibility}
eventTracker={eventTracker}
/>
</Suspense>

View file

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

View file

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

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/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 type { ConfigType } from './config';
import { createSpacesFeatureCatalogueEntry } from './create_feature_catalogue_entry';
import { isSolutionNavEnabled } from './experiments';
import { ManagementService } from './management';
import { initSpacesNavControl } from './nav_control';
import { spaceSelectorApp } from './space_selector';
@ -34,7 +32,6 @@ export interface PluginsStart {
features: FeaturesPluginStart;
management?: ManagementStart;
cloud?: CloudStart;
cloudExperiments?: CloudExperimentsPluginStart;
}
/**
@ -53,9 +50,8 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
private eventTracker!: EventTracker;
private managementService?: ManagementService;
private readonly config: ConfigType;
private config: ConfigType;
private readonly isServerless: boolean;
private solutionNavExperiment = Promise.resolve(false);
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<ConfigType>();
@ -75,18 +71,16 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
hasOnlyDefaultSpace,
};
const onCloud = plugins.cloud !== undefined && plugins.cloud.isCloudEnabled;
if (!onCloud) {
this.config = {
...this.config,
allowSolutionVisibility: false,
};
}
registerSpacesEventTypes(core);
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
if (!(this.isServerless && hasOnlyDefaultSpace)) {
const getRolesAPIClient = async () => {
@ -113,7 +107,6 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
spacesManager: this.spacesManager,
config: this.config,
getRolesAPIClient,
solutionNavExperiment: this.solutionNavExperiment,
eventTracker: this.eventTracker,
});
}
@ -133,7 +126,7 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
public start(core: CoreStart) {
// Only skip spaces navigation if serverless and only one space is allowed
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;

View file

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

View file

@ -40,6 +40,20 @@ export const ConfigSchema = schema.object({
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) {

View file

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

View file

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

View file

@ -6,7 +6,7 @@
*/
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 {
@ -119,8 +119,21 @@ export class SpacesPlugin
private defaultSpaceService?: DefaultSpaceService;
private onCloud$ = new BehaviorSubject<boolean>(false);
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.log = initializerContext.logger.get();
this.spacesService = new SpacesService();
@ -131,6 +144,7 @@ export class SpacesPlugin
}
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 spacesServiceSetup = this.spacesService.setup({

View file

@ -22,6 +22,7 @@ const createMockConfig = (
enabled: true,
maxSpaces: 1000,
allowFeatureVisibility: true,
allowSolutionVisibility: true,
}
) => {
return ConfigSchema.validate(mockConfig, { serverless: !mockConfig.allowFeatureVisibility });
@ -311,6 +312,7 @@ describe('#create', () => {
enabled: true,
maxSpaces,
allowFeatureVisibility: true,
allowSolutionVisibility: true,
});
const client = new SpacesClient(
@ -347,6 +349,7 @@ describe('#create', () => {
enabled: true,
maxSpaces,
allowFeatureVisibility: true,
allowSolutionVisibility: true,
});
const client = new SpacesClient(
@ -382,6 +385,7 @@ describe('#create', () => {
enabled: true,
maxSpaces,
allowFeatureVisibility: true,
allowSolutionVisibility: true,
});
const client = new SpacesClient(
@ -428,6 +432,7 @@ describe('#create', () => {
enabled: true,
maxSpaces,
allowFeatureVisibility: true,
allowSolutionVisibility: true,
});
const client = new SpacesClient(
@ -470,6 +475,7 @@ describe('#create', () => {
enabled: true,
maxSpaces,
allowFeatureVisibility: false,
allowSolutionVisibility: false,
});
const client = new SpacesClient(
@ -506,6 +512,7 @@ describe('#create', () => {
enabled: true,
maxSpaces,
allowFeatureVisibility: false,
allowSolutionVisibility: false,
});
const client = new SpacesClient(
@ -530,6 +537,46 @@ describe('#create', () => {
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', () => {
@ -675,6 +722,7 @@ describe('#update', () => {
enabled: true,
maxSpaces: 1000,
allowFeatureVisibility: false,
allowSolutionVisibility: false,
});
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
@ -700,6 +748,7 @@ describe('#update', () => {
enabled: true,
maxSpaces: 1000,
allowFeatureVisibility: false,
allowSolutionVisibility: false,
});
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
@ -723,6 +772,38 @@ describe('#update', () => {
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', () => {

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')) {
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')) {
throw Boom.badRequest('Unable to update Space, solution property is forbidden in serverless');
}

View file

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

View file

@ -7,6 +7,11 @@
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) {
const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js'));
@ -17,10 +22,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
...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
'--xpack.cloud.id=ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=',
'--xpack.cloud.base_url=https://cloud.elastic.co',

View file

@ -70,6 +70,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
...disabledPlugins
.filter((k) => k !== 'security')
.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',
],
},
};