[Stateful sidenav] User profile opt in/out (#179270)

This commit is contained in:
Sébastien Loix 2024-04-02 09:51:01 +01:00 committed by GitHub
parent 26d82227d2
commit b61b944a14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1019 additions and 132 deletions

View file

@ -305,6 +305,7 @@ enabled:
- x-pack/test/functional/apps/ml/short_tests/config.ts
- x-pack/test/functional/apps/ml/stack_management_jobs/config.ts
- x-pack/test/functional/apps/monitoring/config.ts
- x-pack/test/functional/apps/navigation/config.ts
- x-pack/test/functional/apps/observability_logs_explorer/config.ts
- x-pack/test/functional/apps/dataset_quality/config.ts
- x-pack/test/functional/apps/painless_lab/config.ts

View file

@ -9,7 +9,7 @@
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { BehaviorSubject, combineLatest, merge, type Observable, of, ReplaySubject } from 'rxjs';
import { mergeMap, map, takeUntil } from 'rxjs/operators';
import { mergeMap, map, takeUntil, filter } from 'rxjs/operators';
import { parse } from 'url';
import { EuiLink } from '@elastic/eui';
import useObservable from 'react-use/lib/useObservable';
@ -199,7 +199,10 @@ export class ChromeService {
const customNavLink$ = new BehaviorSubject<ChromeNavLink | undefined>(undefined);
const helpSupportUrl$ = new BehaviorSubject<string>(docLinks.links.kibana.askElastic);
const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true');
const chromeStyle$ = new BehaviorSubject<ChromeStyle>('classic');
// ChromeStyle is set to undefined by default, which means that no header will be rendered until
// setChromeStyle(). This is to avoid a flickering between the "classic" and "project" header meanwhile
// we load the user profile to check if the user opted out of the new solution navigation.
const chromeStyle$ = new BehaviorSubject<ChromeStyle | undefined>(undefined);
const getKbnVersionClass = () => {
// we assume that the version is valid and has the form 'X.X.X'
@ -283,9 +286,9 @@ export class ChromeService {
LinkId extends AppDeepLinkId = AppDeepLinkId,
Id extends string = string,
ChildrenId extends string = Id
>(navigationTree$: Observable<NavigationTreeDefinition<LinkId, Id, ChildrenId>>) {
>(id: string, navigationTree$: Observable<NavigationTreeDefinition<LinkId, Id, ChildrenId>>) {
validateChromeStyle();
projectNavigation.initNavigation(navigationTree$);
projectNavigation.initNavigation(id, navigationTree$);
}
const setProjectBreadcrumbs = (
@ -361,6 +364,8 @@ export class ChromeService {
);
}
if (chromeStyle === undefined) return null;
// render header
if (chromeStyle === 'project') {
const projectNavigationComponent$ = projectNavigation.getProjectSideNavComponent$();
@ -526,7 +531,11 @@ export class ChromeService {
getBodyClasses$: () => bodyClasses$.pipe(takeUntil(this.stop$)),
setChromeStyle,
getChromeStyle$: () => chromeStyle$.pipe(takeUntil(this.stop$)),
getChromeStyle$: () =>
chromeStyle$.pipe(
filter((style): style is ChromeStyle => style !== undefined),
takeUntil(this.stop$)
),
getIsSideNavCollapsed$: () => this.isSideNavCollapsed$.asObservable(),
project: {
setHome: setProjectHome,

View file

@ -110,6 +110,7 @@ describe('initNavigation()', () => {
beforeAll(() => {
projectNavigation.initNavigation<any>(
'foo',
of({
body: [
{
@ -184,6 +185,7 @@ describe('initNavigation()', () => {
const { projectNavigation: projNavigation, getNavigationTree: getNavTree } =
setupInitNavigation();
projNavigation.initNavigation<any>(
'foo',
of({
body: [
{
@ -208,6 +210,7 @@ describe('initNavigation()', () => {
const { projectNavigation: projNavigation } = setupInitNavigation();
projNavigation.initNavigation<any>(
'foo',
of({
body: [
{
@ -392,6 +395,7 @@ describe('initNavigation()', () => {
// 2. initNavigation() is called
projectNavigation.initNavigation<any>(
'foo',
of({
body: [
{
@ -419,6 +423,7 @@ describe('initNavigation()', () => {
});
projectNavigation.initNavigation<any>(
'foo',
// @ts-expect-error - We pass a non valid cloudLink that is not TS valid
of({
body: [
@ -524,7 +529,7 @@ describe('breadcrumbs', () => {
const obs = subj.asObservable();
if (initiateNavigation) {
projectNavigation.initNavigation(obs);
projectNavigation.initNavigation('foo', obs);
}
return {
@ -737,7 +742,7 @@ describe('breadcrumbs', () => {
{ text: 'custom1', href: '/custom1' },
{ text: 'custom2', href: '/custom1/custom2' },
]);
projectNavigation.initNavigation(of(mockNavigation)); // init navigation
projectNavigation.initNavigation('foo', of(mockNavigation)); // init navigation
const breadcrumbs = await firstValueFrom(projectNavigation.getProjectBreadcrumbs$());
expect(breadcrumbs).toHaveLength(4);
@ -776,6 +781,7 @@ describe('getActiveNodes$()', () => {
expect(activeNodes).toEqual([]);
projectNavigation.initNavigation<any>(
'foo',
of({
body: [
{
@ -831,6 +837,7 @@ describe('getActiveNodes$()', () => {
expect(activeNodes).toEqual([]);
projectNavigation.initNavigation<any>(
'foo',
of({
body: [
{

View file

@ -141,9 +141,10 @@ export class ProjectNavigationService {
return this.projectName$.asObservable();
},
initNavigation: <LinkId extends AppDeepLinkId = AppDeepLinkId>(
navTreeDefinition: Observable<NavigationTreeDefinition<LinkId>>
id: string,
navTreeDefinition$: Observable<NavigationTreeDefinition<LinkId>>
) => {
this.initNavigation(navTreeDefinition);
this.initNavigation(id, navTreeDefinition$);
},
getNavigationTreeUi$: this.getNavigationTreeUi$.bind(this),
getActiveNodes$: () => {
@ -219,18 +220,19 @@ export class ProjectNavigationService {
* Initialize a "serverless style" navigation. For stateful deployments (not serverless), this
* handler initialize one of the solution navigations registered.
*
* @param id Id for the navigation tree definition
* @param navTreeDefinition$ The navigation tree definition
* @param location Optional location to use to detect the active node in the new navigation tree
*/
private initNavigation(
navTreeDefinition$: Observable<NavigationTreeDefinition>,
location?: Location
) {
private initNavigation(id: string, navTreeDefinition$: Observable<NavigationTreeDefinition>) {
if (this.activeSolutionNavDefinitionId$.getValue() === id) return;
this.activeSolutionNavDefinitionId$.next(id);
if (this.navigationChangeSubscription) {
this.navigationChangeSubscription.unsubscribe();
}
let redirectLocation = location;
let initialised = false;
this.projectNavigationNavTreeFlattened = {};
this.navigationChangeSubscription = combineLatest([
navTreeDefinition$,
@ -252,8 +254,11 @@ export class ProjectNavigationService {
this.navigationTreeUi$.next(navigationTreeUI);
this.projectNavigationNavTreeFlattened = flattenNav(navigationTree);
this.updateActiveProjectNavigationNodes(redirectLocation);
redirectLocation = undefined; // we don't want to redirect on subsequent changes, only when initiating
// At initialization, we want to force the update of the active nodes, so 2 empty arrays []
// are not considered equal and we update the Observable value.
this.updateActiveProjectNavigationNodes({ forceUpdate: !initialised });
initialised = true;
},
error: (err) => {
this.logger?.error(err);
@ -292,14 +297,18 @@ export class ProjectNavigationService {
* and update the activeNodes$ Observable.
*
* @param location Optional location to use to detect the active node in the new navigation tree, if not set the current location is used
* @param forceUpdate Optional flag to force the update of the active nodes even if the active nodes are the same
*/
private updateActiveProjectNavigationNodes(location?: Location) {
private updateActiveProjectNavigationNodes({
location,
forceUpdate = false,
}: { location?: Location; forceUpdate?: boolean } = {}) {
const activeNodes = this.findActiveNodes({ location });
// Each time we call findActiveNodes() we create a new array of activeNodes. As this array is used
// in React in useCallback() and useMemo() dependencies arrays it triggers an infinite navigation
// tree registration loop. To avoid that we only notify the listeners when the activeNodes array
// has actually changed.
const requiresUpdate = !deepEqual(activeNodes, this.activeNodes$.value);
const requiresUpdate = forceUpdate ? true : !deepEqual(activeNodes, this.activeNodes$.value);
if (!requiresUpdate) return;
@ -308,7 +317,7 @@ export class ProjectNavigationService {
private onHistoryLocationChange(location: Location) {
this.location$.next(location);
this.updateActiveProjectNavigationNodes(location);
this.updateActiveProjectNavigationNodes({ location });
}
private handleActiveNodesChange() {
@ -411,6 +420,7 @@ export class ProjectNavigationService {
if (id === null) {
this.setChromeStyle('classic');
this.navigationTree$.next(undefined);
this.activeSolutionNavDefinitionId$.next(null);
} else {
const definition = definitions[id];
if (!definition) {
@ -436,14 +446,11 @@ export class ProjectNavigationService {
}
}
// We want to pass the upcoming location where we are going to navigate to
// so we can immediately set the active nodes based on the new location and we
// don't have to wait for the location change event to be triggered.
this.initNavigation(definition.navigationTree$, location);
this.initNavigation(id, definition.navigationTree$);
}
} else if (id !== null) {
this.activeSolutionNavDefinitionId$.next(id);
}
this.activeSolutionNavDefinitionId$.next(id);
}
private getSolutionsNavDefinitions$() {

View file

@ -65,6 +65,7 @@ export interface InternalChromeStart extends ChromeStart {
Id extends string = string,
ChildrenId extends string = Id
>(
id: string,
navigationTree$: Observable<NavigationTreeDefinition<LinkId, Id, ChildrenId>>
): void;

View file

@ -24,7 +24,10 @@ const security = {
bulkGet: jest.fn(),
suggest: jest.fn(),
update: jest.fn(),
partialUpdate: jest.fn(),
userProfile$: of({}),
userProfileLoaded$: of(true),
enabled$: of(true),
},
uiApi: {},
};
@ -42,16 +45,16 @@ const wrapper: WrapperComponent<void> = ({ children }) => (
);
describe('useUpdateUserProfile() hook', () => {
const updateUserProfiles = jest.fn();
const partialUpdateUserProfiles = jest.fn();
beforeEach(() => {
security.userProfiles = {
...security.userProfiles,
update: updateUserProfiles,
partialUpdate: partialUpdateUserProfiles,
userProfile$: of({}),
};
updateUserProfiles.mockReset().mockResolvedValue({});
partialUpdateUserProfiles.mockReset().mockResolvedValue({});
http.get.mockReset();
http.post.mockReset().mockResolvedValue(undefined);
notifications.toasts.addSuccess.mockReset();
@ -65,12 +68,12 @@ describe('useUpdateUserProfile() hook', () => {
update({ userSettings: { darkMode: 'dark' } });
});
expect(updateUserProfiles).toHaveBeenCalledWith({ userSettings: { darkMode: 'dark' } });
expect(partialUpdateUserProfiles).toHaveBeenCalledWith({ userSettings: { darkMode: 'dark' } });
});
test('should update the isLoading state while updating', async () => {
const updateDone = new BehaviorSubject(false);
updateUserProfiles.mockImplementationOnce(async () => {
partialUpdateUserProfiles.mockImplementationOnce(async () => {
await lastValueFrom(updateDone.pipe(first((v) => v === true)));
});

View file

@ -7,9 +7,10 @@
*/
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useRef, useState, useEffect } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { i18n } from '@kbn/i18n';
import { merge } from 'lodash';
import type { UserProfileData } from '../types';
import { useUserProfiles } from '../services';
@ -52,7 +53,7 @@ export const useUpdateUserProfile = ({
pageReloadChecker,
}: Props = {}) => {
const { userProfileApiClient, notifySuccess } = useUserProfiles();
const { userProfile$ } = userProfileApiClient;
const { userProfile$, enabled$ } = userProfileApiClient;
const {
enabled: notificationSuccessEnabled = true,
title: notificationTitle = i18nTexts.notificationSuccess.title,
@ -60,8 +61,10 @@ export const useUpdateUserProfile = ({
} = notificationSuccess;
const [isLoading, setIsLoading] = useState(false);
const userProfileData = useObservable(userProfile$);
const userProfileEnabled = useObservable(enabled$);
// Keep a snapshot before updating the user profile so we can compare previous and updated values
const userProfileSnapshot = useRef<UserProfileData | null>();
const isMounted = useRef(false);
const showSuccessNotification = useCallback(
({ isRefreshRequired = false }: { isRefreshRequired?: boolean } = {}) => {
@ -102,7 +105,9 @@ export const useUpdateUserProfile = ({
const onUserProfileUpdate = useCallback(
(updatedData: UserProfileData) => {
setIsLoading(false);
if (isMounted.current) {
setIsLoading(false);
}
if (notificationSuccessEnabled) {
const isRefreshRequired = pageReloadChecker?.(userProfileSnapshot.current, updatedData);
@ -113,14 +118,23 @@ export const useUpdateUserProfile = ({
);
const update = useCallback(
<D extends UserProfileData>(updatedData: D) => {
userProfileSnapshot.current = userProfileData;
<D extends Partial<UserProfileData>>(updatedData: D) => {
userProfileSnapshot.current = merge({}, userProfileData);
setIsLoading(true);
return userProfileApiClient.update(updatedData).then(() => onUserProfileUpdate(updatedData));
return userProfileApiClient
.partialUpdate(updatedData)
.then(() => onUserProfileUpdate(updatedData));
},
[userProfileApiClient, onUserProfileUpdate, userProfileData]
);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return {
/** Update the user profile */
update,
@ -130,6 +144,8 @@ export const useUpdateUserProfile = ({
userProfileData,
/** Flag to indicate if currently updating */
isLoading,
/** Flag to indicate if user profile is enabled */
userProfileEnabled,
};
};

View file

@ -33,6 +33,7 @@ export type DarkModeValue = '' | 'dark' | 'light';
*/
export interface UserSettingsData {
darkMode?: DarkModeValue;
solutionNavOptOut?: boolean;
}
export interface UserProfileData {
@ -43,5 +44,6 @@ export interface UserProfileData {
export interface UserProfileAPIClient {
userProfile$: Observable<UserProfileData | null>;
update: <D extends UserProfileData>(data: D) => Promise<void>;
enabled$: Observable<boolean>;
partialUpdate: <D extends Partial<UserProfileData>>(data: D) => Promise<void>;
}

View file

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

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { of } from 'rxjs';
import { Plugin } from '.';
export type Setup = jest.Mocked<ReturnType<Plugin['setup']>>;
@ -27,7 +28,7 @@ const createStartContract = (): jest.Mocked<Start> => {
AggregateQueryTopNavMenu: jest.fn(),
},
addSolutionNavigation: jest.fn(),
isSolutionNavigationEnabled: jest.fn(),
isSolutionNavEnabled$: of(false),
};
return startContract;
};

View file

@ -8,8 +8,11 @@
import { coreMock } from '@kbn/core/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { of } from 'rxjs';
import { firstValueFrom, of } from 'rxjs';
import type { BuildFlavor } from '@kbn/config';
import type { UserSettingsData } from '@kbn/user-profile-components';
import {
DEFAULT_SOLUTION_NAV_UI_SETTING_ID,
ENABLE_SOLUTION_NAV_UI_SETTING_ID,
@ -17,7 +20,15 @@ import {
} from '../common';
import { NavigationPublicPlugin } from './plugin';
import { ConfigSchema } from './types';
import type { BuildFlavor } from '@kbn/config';
import { SolutionNavUserProfileToggle } from './solution_nav_userprofile_toggle';
jest.mock('rxjs', () => {
const original = jest.requireActual('rxjs');
return {
...original,
debounceTime: () => (source: any) => source,
};
});
const defaultConfig: ConfigSchema['solutionNavigation'] = {
featureOn: true,
@ -30,7 +41,10 @@ const setup = (
partialConfig: Partial<ConfigSchema['solutionNavigation']> & {
featureOn: boolean;
},
{ buildFlavor = 'traditional' }: { buildFlavor?: BuildFlavor } = {}
{
buildFlavor = 'traditional',
userSettings = {},
}: { buildFlavor?: BuildFlavor; userSettings?: UserSettingsData } = {}
) => {
const initializerContext = coreMock.createPluginInitializerContext(
{
@ -46,6 +60,9 @@ const setup = (
const coreStart = coreMock.createStart();
const unifiedSearch = unifiedSearchPluginMock.createStartContract();
const cloud = cloudMock.createStart();
const security = securityMock.createStart();
security.userProfiles.userProfileLoaded$ = of(true);
security.userProfiles.userProfile$ = of({ userSettings });
const getGlobalSetting$ = jest.fn();
const settingsGlobalClient = {
@ -54,7 +71,7 @@ const setup = (
};
coreStart.settings.globalClient = settingsGlobalClient;
return { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ };
return { plugin, coreStart, unifiedSearch, cloud, security, getGlobalSetting$ };
};
describe('Navigation Plugin', () => {
@ -68,9 +85,12 @@ describe('Navigation Plugin', () => {
expect(coreStart.chrome.project.changeActiveSolutionNavigation).not.toHaveBeenCalled();
});
it('should return flag to indicate that the solution navigation is disabled', () => {
it('should return flag to indicate that the solution navigation is disabled', async () => {
const { plugin, coreStart, unifiedSearch } = setup({ featureOn });
expect(plugin.start(coreStart, { unifiedSearch }).isSolutionNavigationEnabled()).toBe(false);
const isEnabled = await firstValueFrom(
plugin.start(coreStart, { unifiedSearch }).isSolutionNavEnabled$
);
expect(isEnabled).toBe(false);
});
});
@ -146,21 +166,211 @@ describe('Navigation Plugin', () => {
});
});
it('should return flag to indicate that the solution navigation is enabled', () => {
const { plugin, coreStart, unifiedSearch, cloud } = setup({ featureOn });
expect(plugin.start(coreStart, { unifiedSearch, cloud }).isSolutionNavigationEnabled()).toBe(
true
);
it('should add the opt in/out toggle in the user menu', () => {
const { plugin, coreStart, unifiedSearch, cloud, security, getGlobalSetting$ } = setup({
featureOn,
});
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible',
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
};
getGlobalSetting$.mockImplementation((settingId: string) => {
const value = uiSettingsValues[settingId] ?? 'unknown';
return of(value);
});
plugin.start(coreStart, { unifiedSearch, cloud, security });
expect(security.navControlService.addUserMenuLinks).toHaveBeenCalled();
const [menuLink] = security.navControlService.addUserMenuLinks.mock.calls[0][0];
expect((menuLink.content as any)?.type).toBe(SolutionNavUserProfileToggle);
});
it('on serverless should return flag to indicate that the solution navigation is disabled', () => {
const { plugin, coreStart, unifiedSearch, cloud } = setup(
{ featureOn },
{ buildFlavor: 'serverless' }
);
expect(plugin.start(coreStart, { unifiedSearch, cloud }).isSolutionNavigationEnabled()).toBe(
false
);
describe('isSolutionNavEnabled$', () => {
describe('user has not opted in or out (undefined)', () => {
const testCases: Array<[Record<string, any>, string, boolean]> = [
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible',
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
},
'should be enabled',
true,
],
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // feature not enabled
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible',
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
},
'should not be enabled',
false,
],
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'hidden', // not visible
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
},
'should not be enabled',
false,
],
];
testCases.forEach(([uiSettingsValues, description, expected]) => {
it(description, async () => {
const { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ } = setup(
{
featureOn,
},
{ userSettings: { solutionNavOptOut: undefined } } // user has not opted in or out
);
getGlobalSetting$.mockImplementation((settingId: string) => {
const value = uiSettingsValues[settingId] ?? 'unknown';
return of(value);
});
const { isSolutionNavEnabled$ } = plugin.start(coreStart, { unifiedSearch, cloud });
expect(await firstValueFrom(isSolutionNavEnabled$)).toBe(expected);
});
});
});
describe('user has opted in', () => {
const testCases: Array<[Record<string, any>, string, boolean]> = [
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible',
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
},
'should be enabled',
true,
],
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // feature not enabled
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible',
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
},
'should not be enabled',
false,
],
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'hidden', // not visible
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
},
'should be enabled',
true,
],
];
testCases.forEach(([uiSettingsValues, description, expected]) => {
it(description, async () => {
const { plugin, coreStart, unifiedSearch, cloud, security, getGlobalSetting$ } = setup(
{
featureOn,
},
{ userSettings: { solutionNavOptOut: false } } // user has opted in
);
getGlobalSetting$.mockImplementation((settingId: string) => {
const value = uiSettingsValues[settingId] ?? 'unknown';
return of(value);
});
const { isSolutionNavEnabled$ } = plugin.start(coreStart, {
security,
unifiedSearch,
cloud,
});
expect(await firstValueFrom(isSolutionNavEnabled$)).toBe(expected);
});
});
});
describe('user has opted out', () => {
const testCases: Array<[Record<string, any>, string, boolean]> = [
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible',
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
},
'should not be enabled',
false,
],
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // feature not enabled
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible',
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
},
'should not be enabled',
false,
],
[
{
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true,
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'hidden',
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
},
'should not be enabled',
false,
],
];
testCases.forEach(([uiSettingsValues, description, expected]) => {
it(description, async () => {
const { plugin, coreStart, unifiedSearch, cloud, security, getGlobalSetting$ } = setup(
{
featureOn,
},
{ userSettings: { solutionNavOptOut: true } } // user has opted out
);
getGlobalSetting$.mockImplementation((settingId: string) => {
const value = uiSettingsValues[settingId] ?? 'unknown';
return of(value);
});
const { isSolutionNavEnabled$ } = plugin.start(coreStart, {
security,
unifiedSearch,
cloud,
});
expect(await firstValueFrom(isSolutionNavEnabled$)).toBe(expected);
});
});
});
it('on serverless should flag must be disabled', async () => {
const { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ } = setup(
{ featureOn },
{ buildFlavor: 'serverless' }
);
const uiSettingsValues: Record<string, any> = {
[ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, // enabled, but we are on serverless
[OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', // should not matter
[DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es',
};
getGlobalSetting$.mockImplementation((settingId: string) => {
const value = uiSettingsValues[settingId] ?? 'unknown';
return of(value);
});
const { isSolutionNavEnabled$ } = plugin.start(coreStart, { unifiedSearch, cloud });
const isEnabled = await firstValueFrom(isSolutionNavEnabled$);
expect(isEnabled).toBe(false);
});
});
});
});

View file

@ -6,9 +6,21 @@
* Side Public License, v 1.
*/
import React from 'react';
import { combineLatest, debounceTime, ReplaySubject, takeUntil } from 'rxjs';
import {
combineLatest,
debounceTime,
distinctUntilChanged,
map,
Observable,
of,
ReplaySubject,
skipWhile,
switchMap,
takeUntil,
} from 'rxjs';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { SecurityPluginStart, UserMenuLink } from '@kbn/security-plugin/public';
import type {
SolutionNavigationDefinition,
SolutionNavigationDefinitions,
@ -17,22 +29,28 @@ import { InternalChromeStart } from '@kbn/core-chrome-browser-internal';
import { definition as esDefinition } from '@kbn/solution-nav-es';
import { definition as obltDefinition } from '@kbn/solution-nav-oblt';
import type { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation';
import { UserProfileData } from '@kbn/user-profile-components';
import {
ENABLE_SOLUTION_NAV_UI_SETTING_ID,
OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID,
DEFAULT_SOLUTION_NAV_UI_SETTING_ID,
} from '../common';
import {
import type {
NavigationPublicSetup,
NavigationPublicStart,
NavigationPublicSetupDependencies,
NavigationPublicStartDependencies,
ConfigSchema,
SolutionNavigation,
SolutionNavigationOptInStatus,
SolutionType,
} from './types';
import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu';
import { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data';
import { SideNavComponent } from './side_navigation';
import { SolutionNavUserProfileToggle } from './solution_nav_userprofile_toggle';
const DEFAULT_OPT_OUT_NEW_NAV = false;
export class NavigationPublicPlugin
implements
@ -48,6 +66,9 @@ export class NavigationPublicPlugin
private readonly stop$ = new ReplaySubject<void>(1);
private coreStart?: CoreStart;
private depsStart?: NavigationPublicStartDependencies;
private isSolutionNavEnabled$ = of(false);
private userProfileOptOut$: Observable<boolean | undefined> = of(undefined);
private userProfileMenuItemAdded = false;
constructor(private initializerContext: PluginInitializerContext<ConfigSchema>) {}
@ -66,10 +87,25 @@ export class NavigationPublicPlugin
this.coreStart = core;
this.depsStart = depsStart;
const { unifiedSearch, cloud } = depsStart;
const { unifiedSearch, cloud, security } = depsStart;
const extensions = this.topNavMenuExtensionsRegistry.getAll();
const chrome = core.chrome as InternalChromeStart;
if (security) {
this.userProfileOptOut$ = security.userProfiles.userProfileLoaded$.pipe(
skipWhile((loaded) => {
return !loaded;
}),
switchMap(() => {
return security.userProfiles.userProfile$ as Observable<UserProfileData>;
}),
map((profile) => {
return profile?.userSettings?.solutionNavOptOut;
}),
distinctUntilChanged()
);
}
/*
*
* This helps clients of navigation to create
@ -98,11 +134,31 @@ export class NavigationPublicPlugin
const onCloud = cloud !== undefined; // The new side nav will initially only be available to cloud users
const isServerless = this.initializerContext.env.packageInfo.buildFlavor === 'serverless';
const isSolutionNavEnabled = isSolutionNavigationFeatureOn && onCloud && !isServerless;
this.isSolutionNavEnabled$ = of(isSolutionNavEnabled);
if (isSolutionNavEnabled) {
chrome.project.setCloudUrls(cloud);
this.addDefaultSolutionNavigation({ chrome });
this.susbcribeToSolutionNavUiSettings(core);
this.isSolutionNavEnabled$ = combineLatest([
core.settings.globalClient.get$<boolean>(ENABLE_SOLUTION_NAV_UI_SETTING_ID),
core.settings.globalClient.get$<SolutionNavigationOptInStatus>(
OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID
),
this.userProfileOptOut$,
]).pipe(
takeUntil(this.stop$),
debounceTime(10),
map(([enabled, status, userOptedOut]) => {
if (!enabled || userOptedOut === true) return false;
if (status === 'hidden' && userOptedOut === undefined) return false;
return true;
})
);
this.susbcribeToSolutionNavUiSettings({ core, security });
} else if (!isServerless) {
chrome.setChromeStyle('classic');
}
return {
@ -122,7 +178,7 @@ export class NavigationPublicPlugin
if (!isSolutionNavEnabled) return;
return this.addSolutionNavigation(solutionNavigation);
},
isSolutionNavigationEnabled: () => isSolutionNavEnabled,
isSolutionNavEnabled$: this.isSolutionNavEnabled$,
};
}
@ -130,23 +186,46 @@ export class NavigationPublicPlugin
this.stop$.next();
}
private susbcribeToSolutionNavUiSettings(core: CoreStart) {
private susbcribeToSolutionNavUiSettings({
core,
security,
}: {
core: CoreStart;
security?: SecurityPluginStart;
}) {
const chrome = core.chrome as InternalChromeStart;
combineLatest([
core.settings.globalClient.get$(ENABLE_SOLUTION_NAV_UI_SETTING_ID),
core.settings.globalClient.get$(OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID),
core.settings.globalClient.get$(DEFAULT_SOLUTION_NAV_UI_SETTING_ID),
core.settings.globalClient.get$<boolean>(ENABLE_SOLUTION_NAV_UI_SETTING_ID),
core.settings.globalClient.get$<SolutionNavigationOptInStatus>(
OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID
),
core.settings.globalClient.get$<SolutionType>(DEFAULT_SOLUTION_NAV_UI_SETTING_ID),
this.userProfileOptOut$,
])
.pipe(takeUntil(this.stop$), debounceTime(10))
.subscribe(([enabled, status, defaultSolution]) => {
if (!enabled) {
chrome.project.changeActiveSolutionNavigation(null);
.subscribe(([enabled, status, defaultSolution, userOptedOut]) => {
if (enabled) {
// Add menu item in the user profile menu to opt in/out of the new navigation
this.addOptInOutUserProfile({ core, security, optInStatusSetting: status, userOptedOut });
} else {
// TODO: Here we will need to check if the user has opt-in or not.... (value set in their user profile)
const changeImmediately = status === 'visible';
// TODO. Remove the user profile menu item if the feature is disabled.
// But first let's wait as maybe there will be a page refresh when opting out.
}
if (!enabled || userOptedOut === true) {
chrome.project.changeActiveSolutionNavigation(null);
chrome.setChromeStyle('classic');
} else {
const changeToSolutionNav =
status === 'visible' || (status === 'hidden' && userOptedOut === false);
if (!changeToSolutionNav) {
chrome.setChromeStyle('classic');
}
chrome.project.changeActiveSolutionNavigation(
changeImmediately ? defaultSolution : null,
changeToSolutionNav ? defaultSolution : null,
{ onlyIfNotSet: true }
);
}
@ -209,4 +288,42 @@ export class NavigationPublicPlugin
chrome.project.updateSolutionNavigations(solutionNavs, true);
}
private addOptInOutUserProfile({
core,
security,
optInStatusSetting,
userOptedOut,
}: {
core: CoreStart;
userOptedOut?: boolean;
optInStatusSetting?: SolutionNavigationOptInStatus;
security?: SecurityPluginStart;
}) {
if (!security || this.userProfileMenuItemAdded) return;
let defaultOptOutValue = userOptedOut !== undefined ? userOptedOut : DEFAULT_OPT_OUT_NEW_NAV;
if (optInStatusSetting === 'visible' && userOptedOut === undefined) {
defaultOptOutValue = false;
} else if (optInStatusSetting === 'hidden' && userOptedOut === undefined) {
defaultOptOutValue = true;
}
const menuLink: UserMenuLink = {
content: (
<SolutionNavUserProfileToggle
core={core}
security={security}
defaultOptOutValue={defaultOptOutValue}
/>
),
order: 500,
label: '',
iconType: '',
href: '',
};
security.navControlService.addUserMenuLinks([menuLink]);
this.userProfileMenuItemAdded = true;
}
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { SolutionNavUserProfileToggle } from './solution_nav_userprofile_toggle';

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { of } from 'rxjs';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { coreMock } from '@kbn/core/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { SolutionNavUserProfileToggle } from './solution_nav_userprofile_toggle';
const mockUseUpdateUserProfile = jest.fn();
jest.mock('@kbn/user-profile-components', () => {
const original = jest.requireActual('@kbn/user-profile-components');
return {
...original,
useUpdateUserProfile: () => mockUseUpdateUserProfile(),
};
});
describe('SolutionNavUserProfileToggle', () => {
it('renders correctly and toggles opt out of new nav', () => {
const security = securityMock.createStart();
const core = coreMock.createStart();
const mockUpdate = jest.fn();
mockUseUpdateUserProfile.mockReturnValue({
userProfileData: { userSettings: { solutionNavOptOut: undefined } },
isLoading: false,
update: mockUpdate,
userProfileEnabled: true,
});
const { getByTestId, rerender } = render(
<SolutionNavUserProfileToggle core={core} security={security} defaultOptOutValue={false} />
);
const toggleSwitch = getByTestId('solutionNavToggleSwitch');
fireEvent.click(toggleSwitch);
expect(mockUpdate).toHaveBeenCalledWith({ userSettings: { solutionNavOptOut: true } });
// Now we want to simulate toggling back to light
mockUseUpdateUserProfile.mockReturnValue({
userProfileData: { userSettings: { solutionNavOptOut: true } },
isLoading: false,
update: mockUpdate,
userProfileEnabled: true,
});
// Rerender the component to apply the new props
rerender(
<SolutionNavUserProfileToggle core={core} security={security} defaultOptOutValue={false} />
);
fireEvent.click(toggleSwitch);
expect(mockUpdate).toHaveBeenLastCalledWith({ userSettings: { solutionNavOptOut: false } });
});
it('does not render if user profile is disabled', async () => {
const security = securityMock.createStart();
security.userProfiles.enabled$ = of(false);
const core = coreMock.createStart();
const mockUpdate = jest.fn();
mockUseUpdateUserProfile.mockReturnValue({
userProfileData: { userSettings: { solutionNavOptOut: undefined } },
isLoading: false,
update: mockUpdate,
userProfileEnabled: false,
});
const { queryByTestId } = render(
<SolutionNavUserProfileToggle core={core} security={security} defaultOptOutValue={false} />
);
const toggleSwitch = await queryByTestId('solutionNavToggleSwitch');
expect(toggleSwitch).toBeNull();
});
});

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import {
EuiContextMenuItem,
EuiFlexGroup,
EuiFlexItem,
EuiSwitch,
useEuiTheme,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
import { UserProfilesKibanaProvider } from '@kbn/user-profile-components';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { useSolutionNavUserProfileToggle } from './use_solution_nav_userprofile_toggle';
interface Props {
security: SecurityPluginStart;
core: CoreStart;
defaultOptOutValue: boolean;
}
export const SolutionNavUserProfileToggle = ({ security, core, defaultOptOutValue }: Props) => {
return (
<UserProfilesKibanaProvider core={core} security={security} toMountPoint={toMountPoint}>
<SolutionNavUserProfileToggleUi defaultOptOutValue={defaultOptOutValue} />
</UserProfilesKibanaProvider>
);
};
function SolutionNavUserProfileToggleUi({ defaultOptOutValue }: { defaultOptOutValue: boolean }) {
const toggleTextSwitchId = useGeneratedHtmlId({ prefix: 'toggleSolutionNavSwitch' });
const { euiTheme } = useEuiTheme();
const { userProfileEnabled, toggle, hasOptOut } = useSolutionNavUserProfileToggle({
defaultOptOutValue,
});
if (!userProfileEnabled) {
return null;
}
return (
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="xs">
<EuiFlexItem>
<EuiContextMenuItem
icon="tableOfContents"
size="s"
onClick={() => {
toggle(!hasOptOut);
}}
data-test-subj="solutionNavToggle"
>
{i18n.translate('navigation.userMenuLinks.useClassicNavigation', {
defaultMessage: 'Use classic navigation',
})}
</EuiContextMenuItem>
</EuiFlexItem>
<EuiFlexItem grow={false} css={{ paddingRight: euiTheme.size.m }}>
<EuiSwitch
label={
hasOptOut
? i18n.translate('navigation.userMenuLinks.classicNavigationOnLabel', {
defaultMessage: 'on',
})
: i18n.translate('navigation.userMenuLinks.classicNavigationOffLabel', {
defaultMessage: 'off',
})
}
showLabel={false}
checked={hasOptOut}
onChange={(e) => {
toggle(e.target.checked);
}}
aria-describedby={toggleTextSwitchId}
data-test-subj="solutionNavToggleSwitch"
compressed
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useCallback, useEffect, useState } from 'react';
import { useUpdateUserProfile } from '@kbn/user-profile-components';
interface Deps {
defaultOptOutValue: boolean;
}
export const useSolutionNavUserProfileToggle = ({ defaultOptOutValue }: Deps) => {
const [hasOptOut, setHasOptOut] = useState(defaultOptOutValue);
const { userProfileData, isLoading, update, userProfileEnabled } = useUpdateUserProfile();
const { userSettings: { solutionNavOptOut = defaultOptOutValue } = {} } = userProfileData ?? {};
const toggle = useCallback(
(on: boolean) => {
if (isLoading) {
return;
}
// optimistic update
setHasOptOut(on);
update({
userSettings: {
solutionNavOptOut: on,
},
});
},
[isLoading, update]
);
useEffect(() => {
setHasOptOut(solutionNavOptOut);
}, [solutionNavOptOut]);
return {
toggle,
hasOptOut,
userProfileEnabled,
};
};

View file

@ -6,13 +6,15 @@
* Side Public License, v 1.
*/
import { AggregateQuery, Query } from '@kbn/es-query';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { AggregateQuery, Query } from '@kbn/es-query';
import type { Observable } from 'rxjs';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup, createTopNav } from './top_nav_menu';
import { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data';
import type { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data';
export interface NavigationPublicSetup {
registerMenuItem: TopNavMenuExtensionsRegistrySetup['register'];
@ -31,20 +33,19 @@ export interface NavigationPublicStart {
};
/** Add a solution navigation to the header nav switcher. */
addSolutionNavigation: (solutionNavigation: SolutionNavigation) => void;
/**
* Use this handler verify if the solution navigation is enabled.
* @returns true if the solution navigation is enabled, false otherwise.
*/
isSolutionNavigationEnabled: () => boolean;
/** Flag to indicate if the solution navigation is enabled.*/
isSolutionNavEnabled$: Observable<boolean>;
}
export interface NavigationPublicSetupDependencies {
cloud?: CloudSetup;
security?: SecurityPluginSetup;
}
export interface NavigationPublicStartDependencies {
unifiedSearch: UnifiedSearchPublicPluginStart;
cloud?: CloudStart;
security?: SecurityPluginStart;
}
export type SolutionNavigationOptInStatus = 'visible' | 'hidden' | 'ask';

View file

@ -27,6 +27,10 @@
"@kbn/solution-nav-es",
"@kbn/solution-nav-oblt",
"@kbn/config",
"@kbn/kibana-react-plugin",
"@kbn/security-plugin",
"@kbn/user-profile-components",
"@kbn/core-lifecycle-browser",
],
"exclude": [
"target/**/*",

View file

@ -15,6 +15,14 @@ import type { Observable } from 'rxjs';
export interface UserProfileAPIClient {
readonly userProfile$: Observable<UserProfileData | null>;
/**
* Indicates if the user profile data has been loaded from the server.
* Useful to distinguish between the case when the user profile data is `null` because the HTTP
* request has not finished or because there is no user profile data for the current user.
*/
readonly userProfileLoaded$: Observable<boolean>;
/** Flag to indicate if the current user has a user profile. Anonymous users don't have user profiles. */
readonly enabled$: Observable<boolean>;
/**
* Retrieves the user profile of the current user. If the profile isn't available, e.g. for the anonymous users or
* users authenticated via authenticating proxies, the `null` value is returned.
@ -63,6 +71,12 @@ export interface UserProfileAPIClient {
* @param data Application data to be written (merged with existing data).
*/
update<D extends UserProfileData>(data: D): Promise<void>;
/**
* Partially updates user profile data of the current user, merging the previous data with the provided data.
* @param data Application data to be merged with existing data.
*/
partialUpdate<D extends Partial<UserProfileData>>(data: D): Promise<void>;
}
/**

View file

@ -9,6 +9,7 @@ import { useCallback, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { useUpdateUserProfile } from '@kbn/user-profile-components';
import useMountedState from 'react-use/lib/useMountedState';
interface Deps {
uiSettingsClient: IUiSettingsClient;
@ -16,6 +17,8 @@ interface Deps {
export const useThemeDarkmodeToggle = ({ uiSettingsClient }: Deps) => {
const [isDarkModeOn, setIsDarkModeOn] = useState(false);
const isMounted = useMountedState();
// If a value is set in kibana.yml (uiSettings.overrides.theme:darkMode)
// we don't allow the user to change the theme color.
const valueSetInKibanaConfig = uiSettingsClient.isOverridden('theme:darkMode');
@ -37,14 +40,23 @@ export const useThemeDarkmodeToggle = ({ uiSettingsClient }: Deps) => {
},
});
const { userSettings: { darkMode: colorScheme } = { darkMode: undefined } } =
userProfileData ?? {};
const {
userSettings: {
darkMode: colorScheme = uiSettingsClient.get('theme:darkMode') === true ? 'dark' : 'light',
} = {},
} = userProfileData ?? {
userSettings: {},
};
const toggle = useCallback(
(on: boolean) => {
if (isLoading) {
return;
}
// optimistic update
setIsDarkModeOn(on);
update({
userSettings: {
darkMode: on ? 'dark' : 'light',
@ -55,17 +67,9 @@ export const useThemeDarkmodeToggle = ({ uiSettingsClient }: Deps) => {
);
useEffect(() => {
let updatedValue = false;
if (typeof colorScheme !== 'string') {
// User profile does not have yet any preference -> default to space dark mode value
updatedValue = uiSettingsClient.get('theme:darkMode') ?? false;
} else {
updatedValue = colorScheme === 'dark';
}
setIsDarkModeOn(updatedValue);
}, [colorScheme, uiSettingsClient]);
if (!isMounted()) return;
setIsDarkModeOn(colorScheme === 'dark');
}, [isMounted, colorScheme]);
return {
isVisible: valueSetInKibanaConfig ? false : Boolean(userProfileData),

View file

@ -5,7 +5,10 @@
* 2.0.
*/
import { firstValueFrom } from 'rxjs';
import { coreMock } from '@kbn/core/public/mocks';
import { kibanaResponseFactory } from '@kbn/core-http-router-server-internal';
import { UserProfileAPIClient } from './user_profile_api_client';
@ -20,6 +23,35 @@ describe('UserProfileAPIClient', () => {
apiClient = new UserProfileAPIClient(coreStart.http);
});
it('should enable the user profile after fetching the data', async () => {
const promiseEnabled = firstValueFrom(apiClient.enabled$);
apiClient.start(); // Start will fetch the user profile data
const enabled = await promiseEnabled;
expect(enabled).toBe(true);
});
it('should not enable the user profile if we get a 404 from fetching the profile', async () => {
const err = new Error('Awwww');
(err as any).response = kibanaResponseFactory.notFound();
coreStart.http.get.mockRejectedValue(err);
const promiseEnabled = firstValueFrom(apiClient.enabled$);
apiClient.start(); // Start will fetch the user profile data
const enabled = await promiseEnabled;
expect(enabled).toBe(false);
});
it('should enable the user profile for any other error than a 404', async () => {
coreStart.http.get.mockRejectedValue(new Error('Awwww'));
const promiseEnabled = firstValueFrom(apiClient.enabled$);
apiClient.start();
const enabled = await promiseEnabled;
expect(enabled).toBe(true);
});
it('should get user profile without retrieving any user data', async () => {
await apiClient.getCurrent();
expect(coreStart.http.get).toHaveBeenCalledWith('/internal/security/user_profile', {

View file

@ -7,7 +7,7 @@
import { merge } from 'lodash';
import type { Observable } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
import { BehaviorSubject, distinctUntilChanged, skipWhile, Subject, switchMap } from 'rxjs';
import type { HttpStart } from '@kbn/core/public';
import type {
@ -20,6 +20,8 @@ import type { UserProfileData } from '@kbn/user-profile-components';
import type { GetUserProfileResponse, UserProfile } from '../../../common';
const DEFAULT_DATAPATHS = 'avatar,userSettings';
export class UserProfileAPIClient implements UserProfileAPIClientType {
private readonly internalDataUpdates$: Subject<UserProfileData> = new Subject();
@ -30,11 +32,31 @@ export class UserProfileAPIClient implements UserProfileAPIClientType {
this.internalDataUpdates$.asObservable();
private readonly _userProfile$ = new BehaviorSubject<UserProfileData | null>(null);
private readonly _enabled$ = new BehaviorSubject(false);
private readonly _userProfileLoaded$ = new BehaviorSubject(false);
/** Observable of the current user profile data */
public readonly userProfile$ = this._userProfile$.asObservable();
public readonly userProfileLoaded$ = this._userProfileLoaded$
.asObservable()
.pipe(distinctUntilChanged());
public enabled$: Observable<boolean>;
constructor(private readonly http: HttpStart) {}
constructor(private readonly http: HttpStart) {
this.enabled$ = this.userProfileLoaded$.pipe(
skipWhile((loaded) => !loaded),
switchMap(() => this._enabled$.asObservable()),
distinctUntilChanged()
);
}
public start() {
// Fetch the user profile with default path to initialize the user profile observable.
// This will also enable or not the user profile for the user by checking if we receive a 404 on this request.
this.getCurrent({ dataPath: DEFAULT_DATAPATHS }).catch(() => {
// silently ignore the error
});
}
/**
* Retrieves the user profile of the current user. If the profile isn't available, e.g. for the anonymous users or
@ -51,8 +73,20 @@ export class UserProfileAPIClient implements UserProfileAPIClientType {
.then((response) => {
const data = response?.data ?? {};
const updated = merge(this._userProfile$.getValue(), data);
this._userProfile$.next(updated);
this._enabled$.next(true);
this._userProfileLoaded$.next(true);
return response;
})
.catch((err) => {
// If we receive a 404 on the request, it means there are no user profile for the user.
const notFound = err?.response?.status === 404;
this._enabled$.next(notFound ? false : true);
this._userProfileLoaded$.next(true);
return Promise.reject(err);
});
}
@ -112,4 +146,13 @@ export class UserProfileAPIClient implements UserProfileAPIClientType {
return Promise.reject(err);
});
}
/**
* Updates user profile data of the current user.
* @param data Application data to be written (merged with existing data).
*/
public partialUpdate<D extends Partial<UserProfileData>>(data: D) {
const updated = merge(this._userProfile$.getValue(), data);
return this.update(updated);
}
}

View file

@ -31,7 +31,10 @@ function createStartMock() {
bulkGet: jest.fn(),
suggest: jest.fn(),
update: jest.fn(),
partialUpdate: jest.fn(),
userProfile$: of({}),
userProfileLoaded$: of(true),
enabled$: of(true),
},
uiApi: getUiApiMock.createStart(),
};

View file

@ -15,25 +15,29 @@ import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import { managementPluginMock } from '@kbn/management-plugin/public/mocks';
import { stubBroadcastChannel } from '@kbn/test-jest-helpers';
import { UserProfileAPIClient } from './account_management';
import { ManagementService } from './management';
import type { PluginStartDependencies } from './plugin';
import { SecurityPlugin } from './plugin';
stubBroadcastChannel();
const getCoreSetupMock = () => {
const coreSetup = coreMock.createSetup({
basePath: '/some-base-path',
});
coreSetup.http.get.mockResolvedValue({});
return coreSetup;
};
describe('Security Plugin', () => {
describe('#setup', () => {
it('should be able to setup if optional plugins are not available', () => {
const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext());
expect(
plugin.setup(
coreMock.createSetup({
basePath: '/some-base-path',
}) as CoreSetup<PluginStartDependencies>,
{
licensing: licensingMock.createSetup(),
}
)
plugin.setup(getCoreSetupMock(), {
licensing: licensingMock.createSetup(),
})
).toEqual({
authc: { getCurrentUser: expect.any(Function), areAPIKeysEnabled: expect.any(Function) },
authz: { isRoleManagementEnabled: expect.any(Function) },
@ -49,7 +53,7 @@ describe('Security Plugin', () => {
});
it('setups Management Service if `management` plugin is available', () => {
const coreSetupMock = coreMock.createSetup({ basePath: '/some-base-path' });
const coreSetupMock = getCoreSetupMock();
const setupManagementServiceMock = jest
.spyOn(ManagementService.prototype, 'setup')
.mockImplementation(() => {});
@ -81,7 +85,7 @@ describe('Security Plugin', () => {
});
it('calls core.security.registerSecurityApi', () => {
const coreSetupMock = coreMock.createSetup({ basePath: '/some-base-path' });
const coreSetupMock = getCoreSetupMock();
const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext());
@ -96,10 +100,7 @@ describe('Security Plugin', () => {
describe('#start', () => {
it('should be able to setup if optional plugins are not available', () => {
const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext());
plugin.setup(
coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup<PluginStartDependencies>,
{ licensing: licensingMock.createSetup() }
);
plugin.setup(getCoreSetupMock(), { licensing: licensingMock.createSetup() });
expect(
plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), {
@ -127,7 +128,31 @@ describe('Security Plugin', () => {
},
"userProfiles": Object {
"bulkGet": [Function],
"enabled$": Observable {
"operator": [Function],
"source": Observable {
"operator": [Function],
"source": Observable {
"operator": [Function],
"source": Observable {
"operator": [Function],
"source": Observable {
"source": BehaviorSubject {
"_value": false,
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
},
},
},
},
},
"getCurrent": [Function],
"partialUpdate": [Function],
"suggest": [Function],
"update": [Function],
"userProfile$": Observable {
@ -141,6 +166,20 @@ describe('Security Plugin', () => {
"thrownError": null,
},
},
"userProfileLoaded$": Observable {
"operator": [Function],
"source": Observable {
"source": BehaviorSubject {
"_value": false,
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
},
},
},
}
`);
@ -156,13 +195,10 @@ describe('Security Plugin', () => {
const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext());
plugin.setup(
coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup<PluginStartDependencies>,
{
licensing: licensingMock.createSetup(),
management: managementSetupMock,
}
);
plugin.setup(getCoreSetupMock(), {
licensing: licensingMock.createSetup(),
management: managementSetupMock,
});
const coreStart = coreMock.createStart({ basePath: '/some-base-path' });
plugin.start(coreStart, {
@ -173,15 +209,28 @@ describe('Security Plugin', () => {
expect(startManagementServiceMock).toHaveBeenCalledTimes(1);
});
it('calls UserProfileAPIClient start() to fetch the user profile', () => {
const startUserProfileAPIClient = jest
.spyOn(UserProfileAPIClient.prototype, 'start')
.mockImplementation(() => {});
const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext());
plugin.setup(getCoreSetupMock(), { licensing: licensingMock.createSetup() });
const coreStart = coreMock.createStart({ basePath: '/some-base-path' });
plugin.start(coreStart, {
dataViews: {} as DataViewsPublicPluginStart,
features: {} as FeaturesPluginStart,
});
expect(startUserProfileAPIClient).toHaveBeenCalledTimes(1);
});
});
describe('#stop', () => {
it('does not fail if called before `start`.', () => {
const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext());
plugin.setup(
coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup<PluginStartDependencies>,
{ licensing: licensingMock.createSetup() }
);
plugin.setup(getCoreSetupMock(), { licensing: licensingMock.createSetup() });
expect(() => plugin.stop()).not.toThrow();
});
@ -189,10 +238,7 @@ describe('Security Plugin', () => {
it('does not fail if called during normal plugin life cycle.', () => {
const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext());
plugin.setup(
coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup<PluginStartDependencies>,
{ licensing: licensingMock.createSetup() }
);
plugin.setup(getCoreSetupMock(), { licensing: licensingMock.createSetup() });
plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), {
dataViews: {} as DataViewsPublicPluginStart,

View file

@ -200,6 +200,7 @@ export class SecurityPlugin
this.sessionTimeout.start();
this.securityCheckupService.start({ http, notifications, docLinks });
this.securityApiClients.userProfiles.start();
if (management) {
this.managementService.start({
@ -231,7 +232,12 @@ export class SecurityPlugin
update: this.securityApiClients.userProfiles.update.bind(
this.securityApiClients.userProfiles
),
partialUpdate: this.securityApiClients.userProfiles.partialUpdate.bind(
this.securityApiClients.userProfiles
),
userProfile$: this.securityApiClients.userProfiles.userProfile$,
userProfileLoaded$: this.securityApiClients.userProfiles.userProfileLoaded$,
enabled$: this.securityApiClients.userProfiles.enabled$,
},
};
}

View file

@ -75,6 +75,7 @@
"@kbn/code-editor-mock",
"@kbn/core-security-browser",
"@kbn/core-security-server",
"@kbn/core-http-router-server-internal",
],
"exclude": [
"target/**/*",

View file

@ -10,6 +10,9 @@ import { type Services } from '../../common/services';
export const initSideNavigation = (services: Services) => {
import('./project_navigation').then(({ init }) => {
const { navigationTree$, panelContentProvider, dataTestSubj } = init(services);
services.serverless.initNavigation(navigationTree$, { panelContentProvider, dataTestSubj });
services.serverless.initNavigation('security', navigationTree$, {
panelContentProvider,
dataTestSubj,
});
});
};

View file

@ -81,8 +81,8 @@ export class ServerlessPlugin
return {
setSideNavComponentDeprecated: (sideNavigationComponent) =>
project.setSideNavComponent(sideNavigationComponent),
initNavigation: (navigationTree$, { panelContentProvider, dataTestSubj } = {}) => {
project.initNavigation(navigationTree$);
initNavigation: (id, navigationTree$, { panelContentProvider, dataTestSubj } = {}) => {
project.initNavigation(id, navigationTree$);
project.setSideNavComponent(() => (
<SideNavComponent
navProps={{

View file

@ -26,6 +26,7 @@ export interface ServerlessPluginStart {
) => void;
setProjectHome(homeHref: string): void;
initNavigation(
id: string,
navigationTree$: Observable<NavigationTreeDefinition>,
config?: {
dataTestSubj?: string;

View file

@ -55,7 +55,7 @@ export class ServerlessObservabilityPlugin
const navigationTree$ = of(navigationTree);
serverless.setProjectHome('/app/observability/landing');
serverless.initNavigation(navigationTree$, { dataTestSubj: 'svlObservabilitySideNav' });
serverless.initNavigation('oblt', navigationTree$, { dataTestSubj: 'svlObservabilitySideNav' });
const extendCardNavDefinitions = serverless.getNavigationCards(
security.authz.isRoleManagementEnabled(),

View file

@ -125,7 +125,7 @@ export class ServerlessSearchPlugin
serverless.setProjectHome('/app/elasticsearch');
const navigationTree$ = of(navigationTree);
serverless.initNavigation(navigationTree$, { dataTestSubj: 'svlSearchSideNav' });
serverless.initNavigation('search', navigationTree$, { dataTestSubj: 'svlSearchSideNav' });
const extendCardNavDefinitions = serverless.getNavigationCards(
security.authz.isRoleManagementEnabled()

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrConfigProviderContext } from '@kbn/test';
import { services, pageObjects } from './ftr_provider_context';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const kibanaFunctionalConfig = await readConfigFile(require.resolve('../../config.base.js'));
return {
testFiles: [require.resolve('./tests')],
servers: {
...kibanaFunctionalConfig.get('servers'),
},
services,
pageObjects,
junit: {
reportName: 'X-Pack Navigation Functional Tests',
},
esTestCluster: {
...kibanaFunctionalConfig.get('esTestCluster'),
license: 'trial',
serverArgs: [`xpack.license.self_generated.type='trial'`],
},
apps: {
...kibanaFunctionalConfig.get('apps'),
},
kbnTestServer: {
...kibanaFunctionalConfig.get('kbnTestServer'),
serverArgs: [
...kibanaFunctionalConfig.get('kbnTestServer.serverArgs'),
'--navigation.solutionNavigation.featureOn=true',
'--navigation.solutionNavigation.enabled=true',
'--navigation.solutionNavigation.optInStatus=visible',
'--navigation.solutionNavigation.defaultSolution=es',
// Note: the base64 string in the cloud.id config contains the ES endpoint required in the functional tests
'--xpack.cloud.id=ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=',
'--xpack.cloud.base_url=https://cloud.elastic.co',
'--xpack.cloud.deployment_url=/deployments/deploymentId',
'--xpack.cloud.organization_url=/organization/organizationId',
'--xpack.cloud.billing_url=/billing',
'--xpack.cloud.profile_url=/user/userId',
],
},
};
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { GenericFtrProviderContext } from '@kbn/test';
import { services } from '../../services';
import { pageObjects } from '../../page_objects';
export type FtrProviderContext = GenericFtrProviderContext<typeof services, typeof pageObjects>;
export { services, pageObjects };

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('navigation - functional tests', function () {
loadTestFile(require.resolve('./user_optin_optout'));
});
}

View file

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

View file

@ -29,6 +29,10 @@ export function UserMenuProvider({ getService }) {
return await testSubjects.exists('userMenu > logoutLink');
}
async openMenu() {
await this._ensureMenuOpen();
}
async closeMenu() {
if (!(await testSubjects.exists('userMenu'))) {
return;