mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Stateful sidenav] User profile opt in/out (#179270)
This commit is contained in:
parent
26d82227d2
commit
b61b944a14
36 changed files with 1019 additions and 132 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
@ -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$() {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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)));
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
"id": "navigation",
|
||||
"server": true,
|
||||
"browser": true,
|
||||
"optionalPlugins": ["cloud"],
|
||||
"optionalPlugins": ["cloud","security"],
|
||||
"requiredPlugins": ["unifiedSearch"],
|
||||
"requiredBundles": []
|
||||
"requiredBundles": ["kibanaReact"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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$,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
"@kbn/code-editor-mock",
|
||||
"@kbn/core-security-browser",
|
||||
"@kbn/core-security-server",
|
||||
"@kbn/core-http-router-server-internal",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -26,6 +26,7 @@ export interface ServerlessPluginStart {
|
|||
) => void;
|
||||
setProjectHome(homeHref: string): void;
|
||||
initNavigation(
|
||||
id: string,
|
||||
navigationTree$: Observable<NavigationTreeDefinition>,
|
||||
config?: {
|
||||
dataTestSubj?: string;
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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()
|
||||
|
|
53
x-pack/test/functional/apps/navigation/config.ts
Normal file
53
x-pack/test/functional/apps/navigation/config.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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 };
|
14
x-pack/test/functional/apps/navigation/tests/index.ts
Normal file
14
x-pack/test/functional/apps/navigation/tests/index.ts
Normal 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'));
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue