diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx index ade55365409cb..893910d1e4e47 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx @@ -30,6 +30,14 @@ import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { findTestSubject } from '@kbn/test-jest-helpers'; import { ChromeService } from './chrome_service'; +const mockhandleSystemColorModeChange = jest.fn(); + +jest.mock('./handle_system_colormode_change', () => { + return { + handleSystemColorModeChange: (...args: any[]) => mockhandleSystemColorModeChange(...args), + }; +}); + class FakeApp implements App { public title: string; public mount = () => () => {}; @@ -205,6 +213,29 @@ describe('start', () => { expect(startDeps.notifications.toasts.addWarning).not.toBeCalled(); }); + it('calls handleSystemColorModeChange() with the correct parameters', async () => { + mockhandleSystemColorModeChange.mockReset(); + await start(); + expect(mockhandleSystemColorModeChange).toHaveBeenCalledTimes(1); + + const [firstCallArg] = mockhandleSystemColorModeChange.mock.calls[0]; + expect(Object.keys(firstCallArg).sort()).toEqual([ + 'coreStart', + 'http', + 'notifications', + 'stop$', + 'uiSettings', + ]); + + expect(mockhandleSystemColorModeChange).toHaveBeenCalledWith({ + http: expect.any(Object), + coreStart: expect.any(Object), + uiSettings: expect.any(Object), + notifications: expect.any(Object), + stop$: expect.any(Object), + }); + }); + describe('getHeaderComponent', () => { it('returns a renderable React component', async () => { const { chrome } = await start(); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx index 511100fff6d40..e8eb19482da7a 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx @@ -14,6 +14,10 @@ import { mergeMap, map, takeUntil, filter } from 'rxjs'; import { parse } from 'url'; import { setEuiDevProviderWarning } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; +import type { I18nStart } from '@kbn/core-i18n-browser'; +import type { ThemeServiceStart } from '@kbn/core-theme-browser'; +import type { UserProfileService } from '@kbn/core-user-profile-browser'; +import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; import type { CoreContext } from '@kbn/core-base-browser-internal'; import type { InternalInjectedMetadataStart } from '@kbn/core-injected-metadata-browser-internal'; @@ -54,6 +58,7 @@ import { Header, LoadingIndicator, ProjectHeader } from './ui'; import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; import type { InternalChromeStart } from './types'; import { HeaderTopBanner } from './ui/header/header_top_banner'; +import { handleSystemColorModeChange } from './handle_system_colormode_change'; const IS_LOCKED_KEY = 'core.chrome.isLocked'; const IS_SIDENAV_COLLAPSED_KEY = 'core.chrome.isSideNavCollapsed'; @@ -76,6 +81,10 @@ export interface StartDeps { injectedMetadata: InternalInjectedMetadataStart; notifications: NotificationsStart; customBranding: CustomBrandingStart; + i18n: I18nStart; + theme: ThemeServiceStart; + userProfile: UserProfileService; + uiSettings: IUiSettingsClient; } /** @internal */ @@ -238,9 +247,21 @@ export class ChromeService { injectedMetadata, notifications, customBranding, + i18n: i18nService, + theme, + userProfile, + uiSettings, }: StartDeps): Promise { this.initVisibility(application); this.handleEuiFullScreenChanges(); + + handleSystemColorModeChange({ + notifications, + coreStart: { i18n: i18nService, theme, userProfile }, + stop$: this.stop$, + http, + uiSettings, + }); // commented out until https://github.com/elastic/kibana/issues/201805 can be fixed // this.handleEuiDevProviderWarning(notifications); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/handle_system_colormode_change.test.ts b/packages/core/chrome/core-chrome-browser-internal/src/handle_system_colormode_change.test.ts new file mode 100644 index 0000000000000..0b190d57d4e1d --- /dev/null +++ b/packages/core/chrome/core-chrome-browser-internal/src/handle_system_colormode_change.test.ts @@ -0,0 +1,266 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18nServiceMock } from '@kbn/core-i18n-browser-mocks'; +import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks'; +import { themeServiceMock } from '@kbn/core-theme-browser-mocks'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; +import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; +import { handleSystemColorModeChange } from './handle_system_colormode_change'; +import { ReplaySubject } from 'rxjs'; +import type { GetUserProfileResponse } from '@kbn/core-user-profile-browser'; +import { UserProfileData } from '@kbn/core-user-profile-common'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; + +const mockbrowsersSupportsSystemTheme = jest.fn(); + +jest.mock('@kbn/core-theme-browser-internal', () => { + const original = jest.requireActual('@kbn/core-theme-browser-internal'); + + return { + ...original, + browsersSupportsSystemTheme: () => mockbrowsersSupportsSystemTheme(), + }; +}); + +describe('handleSystemColorModeChange', () => { + const originalMatchMedia = window.matchMedia; + + afterAll(() => { + window.matchMedia = originalMatchMedia; + }); + + const getDeps = () => { + const coreStart = { + i18n: i18nServiceMock.createStartContract(), + theme: themeServiceMock.createStartContract(), + userProfile: userProfileServiceMock.createStart(), + }; + const notifications = notificationServiceMock.createStartContract(); + const http = httpServiceMock.createStartContract(); + const uiSettings = uiSettingsServiceMock.createStartContract(); + const stop$ = new ReplaySubject(1); + + return { + coreStart, + notifications, + http, + uiSettings, + stop$, + }; + }; + + const mockMatchMedia = (matches: boolean = false, addEventListenerMock = jest.fn()) => { + const removeEventListenerMock = jest.fn(); + window.matchMedia = jest.fn().mockImplementation(() => { + return { + matches, + addEventListener: addEventListenerMock, + removeEventListener: removeEventListenerMock, + }; + }); + + return { addEventListenerMock, removeEventListenerMock }; + }; + + const mockUserProfileResponse = ( + darkMode: 'dark' | 'light' | 'system' | 'space_default' + ): GetUserProfileResponse => + ({ + data: { + userSettings: { + darkMode, + }, + }, + } as any); + + const mockUiSettingsDarkMode = ( + uiSettings: jest.Mocked, + darkMode: 'dark' | 'light' | 'system' + ) => { + uiSettings.get.mockImplementation((key) => { + if (key === 'theme:darkMode') { + return darkMode; + } + + return 'foo'; + }); + }; + + describe('doHandle guard', () => { + it('does not handle if the system color mode is not supported', () => { + const { addEventListenerMock } = mockMatchMedia(); + expect(addEventListenerMock).not.toHaveBeenCalled(); + mockbrowsersSupportsSystemTheme.mockReturnValue(false); + + handleSystemColorModeChange({} as any); + + expect(addEventListenerMock).not.toHaveBeenCalled(); + }); + + it('does not handle on unauthenticated routes', () => { + const { coreStart, notifications, http, uiSettings, stop$ } = getDeps(); + const { addEventListenerMock } = mockMatchMedia(); + expect(addEventListenerMock).not.toHaveBeenCalled(); + + mockbrowsersSupportsSystemTheme.mockReturnValue(true); + http.anonymousPaths.isAnonymous.mockReturnValue(true); + + handleSystemColorModeChange({ coreStart, notifications, http, uiSettings, stop$ }); + + expect(addEventListenerMock).not.toHaveBeenCalled(); + }); + + it('does not handle if user profile darkmode is not "system"', () => { + const { coreStart, notifications, http, uiSettings, stop$ } = getDeps(); + const { addEventListenerMock } = mockMatchMedia(); + expect(addEventListenerMock).not.toHaveBeenCalled(); + + mockbrowsersSupportsSystemTheme.mockReturnValue(true); + http.anonymousPaths.isAnonymous.mockReturnValue(false); + coreStart.userProfile.getCurrent.mockResolvedValue({ + data: { + userSettings: { + darkMode: 'light', + }, + }, + } as any); + + handleSystemColorModeChange({ coreStart, notifications, http, uiSettings, stop$ }); + + expect(addEventListenerMock).not.toHaveBeenCalled(); + }); + + it('does not handle if user profile darkmode is "space_default" but the uiSettings darkmode is not "system"', () => { + const { coreStart, notifications, http, uiSettings, stop$ } = getDeps(); + const { addEventListenerMock } = mockMatchMedia(); + expect(addEventListenerMock).not.toHaveBeenCalled(); + + mockbrowsersSupportsSystemTheme.mockReturnValue(true); + http.anonymousPaths.isAnonymous.mockReturnValue(false); + coreStart.userProfile.getCurrent.mockResolvedValue({ + data: { + userSettings: { + darkMode: 'space_default', + }, + }, + } as any); + + uiSettings.get.mockImplementation((key) => { + if (key === 'theme:darkMode') { + return 'light'; + } + + return 'foo'; + }); + + handleSystemColorModeChange({ coreStart, notifications, http, uiSettings, stop$ }); + + expect(addEventListenerMock).not.toHaveBeenCalled(); + }); + + it('does handle if user profile darkmode is "system"', async () => { + const { coreStart, notifications, http, uiSettings, stop$ } = getDeps(); + const { addEventListenerMock } = mockMatchMedia(false); + expect(addEventListenerMock).not.toHaveBeenCalled(); + + mockbrowsersSupportsSystemTheme.mockReturnValue(true); + http.anonymousPaths.isAnonymous.mockReturnValue(false); + coreStart.userProfile.getCurrent.mockResolvedValue(mockUserProfileResponse('system')); + + await handleSystemColorModeChange({ coreStart, notifications, http, uiSettings, stop$ }); + + expect(addEventListenerMock).toHaveBeenCalled(); + }); + + it('does handle if user profile darkmode is "space_default" and uiSetting darkmode is "system"', async () => { + const { coreStart, notifications, http, uiSettings, stop$ } = getDeps(); + const { addEventListenerMock } = mockMatchMedia(false); + expect(addEventListenerMock).not.toHaveBeenCalled(); + + mockbrowsersSupportsSystemTheme.mockReturnValue(true); + http.anonymousPaths.isAnonymous.mockReturnValue(false); + coreStart.userProfile.getCurrent.mockResolvedValue(mockUserProfileResponse('space_default')); + mockUiSettingsDarkMode(uiSettings, 'system'); + + await handleSystemColorModeChange({ coreStart, notifications, http, uiSettings, stop$ }); + + expect(addEventListenerMock).toHaveBeenCalled(); + }); + }); + + describe('onDarkModeChange()', () => { + it('does show a toast when the system color mode changes', async () => { + const { coreStart, notifications, http, uiSettings, stop$ } = getDeps(); + const currentDarkMode = false; // The system is currently in light mode + const addEventListenerMock = jest + .fn() + .mockImplementation((type: string, cb: (evt: MediaQueryListEvent) => any) => { + expect(notifications.toasts.addSuccess).not.toHaveBeenCalled(); + expect(type).toBe('change'); + cb({ matches: true } as any); // The system changed to dark mode + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.any(Function), + title: 'System color mode updated', + }), + { toastLifeTimeMs: Infinity } + ); + }); + mockMatchMedia(currentDarkMode, addEventListenerMock); + + mockbrowsersSupportsSystemTheme.mockReturnValue(true); + http.anonymousPaths.isAnonymous.mockReturnValue(false); + coreStart.userProfile.getCurrent.mockResolvedValue(mockUserProfileResponse('system')); + + await handleSystemColorModeChange({ coreStart, notifications, http, uiSettings, stop$ }); + expect(addEventListenerMock).toHaveBeenCalled(); + }); + + it('does **not** show a toast when the system color mode changes to the current darkmode value', async () => { + const { coreStart, notifications, http, uiSettings, stop$ } = getDeps(); + const currentDarkMode = true; // The system is currently in dark mode + const addEventListenerMock = jest + .fn() + .mockImplementation((type: string, cb: (evt: MediaQueryListEvent) => any) => { + expect(notifications.toasts.addSuccess).not.toHaveBeenCalled(); + expect(type).toBe('change'); + cb({ matches: true } as any); // The system changed to dark mode + expect(notifications.toasts.addSuccess).not.toHaveBeenCalled(); + }); + mockMatchMedia(currentDarkMode, addEventListenerMock); + + mockbrowsersSupportsSystemTheme.mockReturnValue(true); + http.anonymousPaths.isAnonymous.mockReturnValue(false); + coreStart.userProfile.getCurrent.mockResolvedValue(mockUserProfileResponse('system')); + + await handleSystemColorModeChange({ coreStart, notifications, http, uiSettings, stop$ }); + expect(addEventListenerMock).toHaveBeenCalled(); + }); + + it('stops listening to changes on stop$ change', async () => { + const { coreStart, notifications, http, uiSettings, stop$ } = getDeps(); + const currentDarkMode = false; // The system is currently in light mode + const { addEventListenerMock, removeEventListenerMock } = mockMatchMedia(currentDarkMode); + + mockbrowsersSupportsSystemTheme.mockReturnValue(true); + http.anonymousPaths.isAnonymous.mockReturnValue(false); + coreStart.userProfile.getCurrent.mockResolvedValue(mockUserProfileResponse('system')); + + await handleSystemColorModeChange({ coreStart, notifications, http, uiSettings, stop$ }); + expect(addEventListenerMock).toHaveBeenCalled(); + expect(removeEventListenerMock).not.toHaveBeenCalled(); + + stop$.next(); + + expect(removeEventListenerMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/handle_system_colormode_change.tsx b/packages/core/chrome/core-chrome-browser-internal/src/handle_system_colormode_change.tsx new file mode 100644 index 0000000000000..01504fdfb1570 --- /dev/null +++ b/packages/core/chrome/core-chrome-browser-internal/src/handle_system_colormode_change.tsx @@ -0,0 +1,150 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { NotificationsStart } from '@kbn/core-notifications-browser'; +import type { I18nStart } from '@kbn/core-i18n-browser'; +import type { ThemeServiceStart } from '@kbn/core-theme-browser'; +import type { UserProfileService } from '@kbn/core-user-profile-browser'; +import { take, type Observable } from 'rxjs'; +import { browsersSupportsSystemTheme } from '@kbn/core-theme-browser-internal'; +import type { InternalHttpStart } from '@kbn/core-http-browser-internal'; +import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; + +const doSyncWithSystem = ( + userSettings: { darkMode?: string } = { darkMode: 'space_default' }, + uiSettingsDarkModeValue: string = 'disabled' +): boolean => { + const { darkMode: userProfileDarkModeValue = 'space_default' } = userSettings; + + if (userProfileDarkModeValue.toUpperCase() === 'SYSTEM') { + return true; + } + + if ( + userProfileDarkModeValue.toUpperCase() === 'SPACE_DEFAULT' && + uiSettingsDarkModeValue.toUpperCase() === 'SYSTEM' + ) { + return true; + } + + return false; +}; + +const isUnauthenticated = (http: InternalHttpStart) => { + const { anonymousPaths } = http; + return anonymousPaths.isAnonymous(window.location.pathname); +}; + +const doHandle = async ({ + http, + coreStart, + uiSettings, +}: { + http: InternalHttpStart; + uiSettings: IUiSettingsClient; + coreStart: { + i18n: I18nStart; + theme: ThemeServiceStart; + userProfile: UserProfileService; + }; +}) => { + if (!browsersSupportsSystemTheme()) return false; + if (isUnauthenticated(http)) return false; + + const userProfile = await coreStart.userProfile.getCurrent<{ + userSettings: { darkMode?: string }; + }>({ + dataPath: 'userSettings', + }); + const { userSettings } = userProfile.data; + + if (!doSyncWithSystem(userSettings, uiSettings.get('theme:darkMode'))) return false; + + return true; +}; + +export async function handleSystemColorModeChange({ + notifications, + uiSettings, + coreStart, + stop$, + http, +}: { + notifications: NotificationsStart; + http: InternalHttpStart; + uiSettings: IUiSettingsClient; + coreStart: { + i18n: I18nStart; + theme: ThemeServiceStart; + userProfile: UserProfileService; + }; + stop$: Observable; +}) { + if (!(await doHandle({ http, uiSettings, coreStart }))) { + return; + } + + let currentDarkModeValue: boolean | undefined; + const matchMedia = window.matchMedia('(prefers-color-scheme: dark)'); + + const onDarkModeChange = ({ matches: isDarkMode }: { matches: boolean }) => { + if (currentDarkModeValue === undefined) { + // The current value can only be set on page reload as that's the moment when + // we actually apply set the dark/light color mode of the page. + currentDarkModeValue = isDarkMode; + } else if (currentDarkModeValue !== isDarkMode) { + notifications.toasts.addSuccess( + { + title: i18n.translate('core.ui.chrome.appearanceChange.successNotificationTitle', { + defaultMessage: 'System color mode updated', + }), + text: toMountPoint( + <> +

+ {i18n.translate('core.ui.chrome.appearanceChange.successNotificationText', { + defaultMessage: 'Reload the page to see the changes', + })} +

+ + + window.location.reload()} + data-test-subj="windowReloadButton" + > + {i18n.translate( + 'core.ui.chrome.appearanceChange.requiresPageReloadButtonLabel', + { + defaultMessage: 'Reload page', + } + )} + + + + , + coreStart + ), + }, + { toastLifeTimeMs: Infinity } // leave it on until discard or page reload + ); + } + }; + + onDarkModeChange(matchMedia); + + matchMedia.addEventListener('change', onDarkModeChange); + + stop$.pipe(take(1)).subscribe(() => { + matchMedia.removeEventListener('change', onDarkModeChange); + }); +} diff --git a/packages/core/chrome/core-chrome-browser-internal/tsconfig.json b/packages/core/chrome/core-chrome-browser-internal/tsconfig.json index ca9e5d5576ad9..6960b8961561e 100644 --- a/packages/core/chrome/core-chrome-browser-internal/tsconfig.json +++ b/packages/core/chrome/core-chrome-browser-internal/tsconfig.json @@ -56,6 +56,13 @@ "@kbn/react-kibana-context-render", "@kbn/recently-accessed", "@kbn/core-user-profile-browser-mocks", + "@kbn/core-i18n-browser", + "@kbn/core-theme-browser", + "@kbn/core-user-profile-browser", + "@kbn/core-ui-settings-browser", + "@kbn/core-user-profile-common", + "@kbn/react-kibana-mount", + "@kbn/core-theme-browser-internal" ], "exclude": [ "target/**/*", diff --git a/packages/core/root/core-root-browser-internal/src/core_system.ts b/packages/core/root/core-root-browser-internal/src/core_system.ts index 042017368168c..59ba94d01d8d4 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.ts @@ -360,6 +360,10 @@ export class CoreSystem { injectedMetadata, notifications, customBranding, + i18n, + theme, + userProfile, + uiSettings, }); const deprecations = this.deprecations.start({ http }); diff --git a/packages/core/theme/core-theme-browser-internal/index.ts b/packages/core/theme/core-theme-browser-internal/index.ts index 7f77db8564896..e938ed2eeb5b8 100644 --- a/packages/core/theme/core-theme-browser-internal/index.ts +++ b/packages/core/theme/core-theme-browser-internal/index.ts @@ -10,3 +10,4 @@ export { ThemeService } from './src/theme_service'; export { CoreThemeProvider } from './src/core_theme_provider'; export type { ThemeServiceSetupDeps } from './src/theme_service'; +export { browsersSupportsSystemTheme } from './src/system_theme'; diff --git a/packages/core/user-settings/core-user-settings-server-internal/src/user_settings_service.test.ts b/packages/core/user-settings/core-user-settings-server-internal/src/user_settings_service.test.ts index 9884300d7239c..02e11a20ca36e 100644 --- a/packages/core/user-settings/core-user-settings-server-internal/src/user_settings_service.test.ts +++ b/packages/core/user-settings/core-user-settings-server-internal/src/user_settings_service.test.ts @@ -70,6 +70,23 @@ describe('#setup', () => { }); }); + it('fetches userSettings when client is set and returns `system` when `darkMode` is set to `system`', async () => { + startDeps.userProfile.getCurrent.mockResolvedValue(createUserProfile('system')); + + const { getUserSettingDarkMode } = service.setup(); + service.start(startDeps); + + const kibanaRequest = httpServerMock.createKibanaRequest(); + const darkMode = await getUserSettingDarkMode(kibanaRequest); + + expect(darkMode).toEqual('system'); + expect(startDeps.userProfile.getCurrent).toHaveBeenCalledTimes(1); + expect(startDeps.userProfile.getCurrent).toHaveBeenCalledWith({ + request: kibanaRequest, + dataPath: 'userSettings', + }); + }); + it('fetches userSettings when client is set and returns `undefined` when `darkMode` is set to `` (the default value)', async () => { startDeps.userProfile.getCurrent.mockResolvedValue(createUserProfile('')); @@ -87,6 +104,23 @@ describe('#setup', () => { }); }); + it('fetches userSettings when client is set and returns `undefined` when `darkMode` is set to `space_default`', async () => { + startDeps.userProfile.getCurrent.mockResolvedValue(createUserProfile('space_default')); + + const { getUserSettingDarkMode } = service.setup(); + service.start(startDeps); + + const kibanaRequest = httpServerMock.createKibanaRequest(); + const darkMode = await getUserSettingDarkMode(kibanaRequest); + + expect(darkMode).toEqual(undefined); + expect(startDeps.userProfile.getCurrent).toHaveBeenCalledTimes(1); + expect(startDeps.userProfile.getCurrent).toHaveBeenCalledWith({ + request: kibanaRequest, + dataPath: 'userSettings', + }); + }); + it('does not fetch userSettings when client is not set, returns `undefined`, and logs a debug statement', async () => { const { getUserSettingDarkMode } = service.setup(); diff --git a/packages/core/user-settings/core-user-settings-server-internal/src/user_settings_service.ts b/packages/core/user-settings/core-user-settings-server-internal/src/user_settings_service.ts index ab6eb501e9643..9e8cdfbd04584 100644 --- a/packages/core/user-settings/core-user-settings-server-internal/src/user_settings_service.ts +++ b/packages/core/user-settings/core-user-settings-server-internal/src/user_settings_service.ts @@ -64,11 +64,18 @@ export class UserSettingsService { } } +/** + * Extracts the dark mode setting from the user settings. + * Returning "undefined" means that we will use the space default settings. + */ const getUserSettingDarkMode = ( userSettings: Record ): DarkModeValue | undefined => { - if (userSettings?.darkMode) { - return userSettings.darkMode.toUpperCase() === 'DARK'; + if (userSettings.darkMode) { + const { darkMode } = userSettings; + if (darkMode === 'space_default') return undefined; + + return darkMode.toUpperCase() === 'SYSTEM' ? 'system' : darkMode.toUpperCase() === 'DARK'; } return undefined; }; diff --git a/packages/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx b/packages/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx index 57aeec7a51d5a..72b1bdadb3393 100644 --- a/packages/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx +++ b/packages/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx @@ -55,7 +55,7 @@ export const useUpdateUserProfile = ({ pageReloadChecker, }: Props = {}) => { const { userProfileApiClient, notifySuccess } = useUserProfiles(); - const { userProfile$, enabled$ } = userProfileApiClient; + const { userProfile$, enabled$, userProfileLoaded$ } = userProfileApiClient; const { enabled: notificationSuccessEnabled = true, title: notificationTitle = i18nTexts.notificationSuccess.title, @@ -64,6 +64,7 @@ export const useUpdateUserProfile = ({ const [isLoading, setIsLoading] = useState(false); const userProfileData = useObservable(userProfile$); const userProfileEnabled = useObservable(enabled$); + const userProfileLoaded = useObservable(userProfileLoaded$, false); // Keep a snapshot before updating the user profile so we can compare previous and updated values const userProfileSnapshot = useRef(); const isMounted = useRef(false); @@ -125,9 +126,10 @@ export const useUpdateUserProfile = ({ >(updatedData: D) => { userProfileSnapshot.current = merge({}, userProfileData); setIsLoading(true); - return userProfileApiClient - .partialUpdate(updatedData) - .then(() => onUserProfileUpdate(updatedData)); + return userProfileApiClient.partialUpdate(updatedData).then(() => { + onUserProfileUpdate(updatedData); + return updatedData; + }); }, [userProfileApiClient, onUserProfileUpdate, userProfileData] ); @@ -150,6 +152,8 @@ export const useUpdateUserProfile = ({ isLoading, /** Flag to indicate if user profile is enabled */ userProfileEnabled, + /** Flag to indicate if the user profile has been loaded */ + userProfileLoaded, }; }; diff --git a/packages/kbn-user-profile-components/src/types.ts b/packages/kbn-user-profile-components/src/types.ts index ff74061e0ef39..54b77e63e55f0 100644 --- a/packages/kbn-user-profile-components/src/types.ts +++ b/packages/kbn-user-profile-components/src/types.ts @@ -27,7 +27,7 @@ export interface UserProfileAvatarData { imageUrl?: string | null; } -export type DarkModeValue = '' | 'dark' | 'light'; +export type DarkModeValue = 'system' | 'dark' | 'light' | 'space_default'; /** * User settings stored in the data object of the User Profile @@ -46,5 +46,6 @@ export interface UserProfileData { export interface UserProfileAPIClient { userProfile$: Observable; enabled$: Observable; + userProfileLoaded$: Observable; partialUpdate: >(data: D) => Promise; } diff --git a/packages/react/kibana_mount/mount_point_portal.test.tsx b/packages/react/kibana_mount/mount_point_portal.test.tsx index 405aa5254ad92..b30920c914e4b 100644 --- a/packages/react/kibana_mount/mount_point_portal.test.tsx +++ b/packages/react/kibana_mount/mount_point_portal.test.tsx @@ -9,7 +9,7 @@ import React, { FC } from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { MountPoint, UnmountCallback } from '@kbn/core/public'; +import type { MountPoint, UnmountCallback } from '@kbn/core-mount-utils-browser'; import { MountPointPortal } from './mount_point_portal'; import { act } from 'react-dom/test-utils'; diff --git a/packages/react/kibana_mount/mount_point_portal.tsx b/packages/react/kibana_mount/mount_point_portal.tsx index 5017b57747e48..590862d3d20cd 100644 --- a/packages/react/kibana_mount/mount_point_portal.tsx +++ b/packages/react/kibana_mount/mount_point_portal.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import React, { useRef, useEffect, useState, Component, FC, PropsWithChildren } from 'react'; import ReactDOM from 'react-dom'; -import type { MountPoint } from '@kbn/core/public'; +import type { MountPoint } from '@kbn/core-mount-utils-browser'; import { useIfMounted } from './utils'; export interface MountPointPortalProps { diff --git a/packages/react/kibana_mount/to_mount_point.test.tsx b/packages/react/kibana_mount/to_mount_point.test.tsx index 5dafefa8453ef..7587f253096c8 100644 --- a/packages/react/kibana_mount/to_mount_point.test.tsx +++ b/packages/react/kibana_mount/to_mount_point.test.tsx @@ -12,7 +12,7 @@ import { act } from 'react-dom/test-utils'; import { of, BehaviorSubject } from 'rxjs'; import { useEuiTheme } from '@elastic/eui'; import type { UseEuiTheme } from '@elastic/eui'; -import type { CoreTheme } from '@kbn/core/public'; +import type { CoreTheme } from '@kbn/core-theme-browser'; import { toMountPoint } from './to_mount_point'; import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks'; diff --git a/packages/react/kibana_mount/to_mount_point.tsx b/packages/react/kibana_mount/to_mount_point.tsx index 8968decee726a..449e3ed974cde 100644 --- a/packages/react/kibana_mount/to_mount_point.tsx +++ b/packages/react/kibana_mount/to_mount_point.tsx @@ -9,7 +9,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import type { MountPoint } from '@kbn/core/public'; +import type { MountPoint } from '@kbn/core-mount-utils-browser'; import { KibanaRenderContextProvider, KibanaRenderContextProviderProps, diff --git a/packages/react/kibana_mount/tsconfig.json b/packages/react/kibana_mount/tsconfig.json index 8294fad813c28..15dc55d36b052 100644 --- a/packages/react/kibana_mount/tsconfig.json +++ b/packages/react/kibana_mount/tsconfig.json @@ -16,11 +16,12 @@ "target/**/*" ], "kbn_references": [ - "@kbn/core", "@kbn/i18n", "@kbn/core-i18n-browser-mocks", "@kbn/react-kibana-context-render", "@kbn/core-analytics-browser-mocks", "@kbn/core-user-profile-browser-mocks", + "@kbn/core-mount-utils-browser", + "@kbn/core-theme-browser", ] } diff --git a/x-pack/packages/security/plugin_types_public/src/nav_control/nav_control_service.ts b/x-pack/packages/security/plugin_types_public/src/nav_control/nav_control_service.ts index 39982a753127c..98670b6364af8 100644 --- a/x-pack/packages/security/plugin_types_public/src/nav_control/nav_control_service.ts +++ b/x-pack/packages/security/plugin_types_public/src/nav_control/nav_control_service.ts @@ -16,7 +16,7 @@ export interface UserMenuLink { order?: number; setAsProfile?: boolean; /** Render a custom ReactNode instead of the default */ - content?: ReactNode; + content?: ReactNode | ((args: { closePopover: () => void }) => ReactNode); } export interface SecurityNavControlServiceStart { diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index b794f947c0fa1..d1d7c38033ed5 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -14135,11 +14135,6 @@ "xpack.cloudLinks.helpMenuLinks.support": "Support technique", "xpack.cloudLinks.setupGuide": "Guides de configuration", "xpack.cloudLinks.userMenuLinks.billingLinkText": "Facturation", - "xpack.cloudLinks.userMenuLinks.darkMode.successNotificationText": "Recharger la page pour afficher les modifications", - "xpack.cloudLinks.userMenuLinks.darkMode.successNotificationTitle": "Thème de couleurs actualisé", - "xpack.cloudLinks.userMenuLinks.darkModeOffLabel": "désactivé", - "xpack.cloudLinks.userMenuLinks.darkModeOnLabel": "le", - "xpack.cloudLinks.userMenuLinks.darkModeToggle": "Mode sombre", "xpack.cloudLinks.userMenuLinks.organizationLinkText": "Organisation", "xpack.cloudLinks.userMenuLinks.profileLinkText": "Profil", "xpack.crossClusterReplication.addAutoFollowPatternButtonLabel": "Créer un modèle de suivi automatique", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index eda56781402ff..f5ac3a85279a6 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -14005,11 +14005,6 @@ "xpack.cloudLinks.helpMenuLinks.support": "サポート", "xpack.cloudLinks.setupGuide": "セットアップガイド", "xpack.cloudLinks.userMenuLinks.billingLinkText": "請求", - "xpack.cloudLinks.userMenuLinks.darkMode.successNotificationText": "変更を確認するには、ページを再読み込みしてください", - "xpack.cloudLinks.userMenuLinks.darkMode.successNotificationTitle": "カラーテーマが更新されました", - "xpack.cloudLinks.userMenuLinks.darkModeOffLabel": "オフ", - "xpack.cloudLinks.userMenuLinks.darkModeOnLabel": "日付", - "xpack.cloudLinks.userMenuLinks.darkModeToggle": "ダークモード", "xpack.cloudLinks.userMenuLinks.organizationLinkText": "組織別", "xpack.cloudLinks.userMenuLinks.profileLinkText": "プロフィール", "xpack.crossClusterReplication.addAutoFollowPatternButtonLabel": "自動フォローパターンを作成", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index db29389b8a0eb..998dd0392f5a2 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -13763,11 +13763,6 @@ "xpack.cloudLinks.helpMenuLinks.support": "支持", "xpack.cloudLinks.setupGuide": "设置指南", "xpack.cloudLinks.userMenuLinks.billingLinkText": "帐单", - "xpack.cloudLinks.userMenuLinks.darkMode.successNotificationText": "重新加载页面以查看更改", - "xpack.cloudLinks.userMenuLinks.darkMode.successNotificationTitle": "已更新颜色主题", - "xpack.cloudLinks.userMenuLinks.darkModeOffLabel": "关闭", - "xpack.cloudLinks.userMenuLinks.darkModeOnLabel": "在", - "xpack.cloudLinks.userMenuLinks.darkModeToggle": "深色模式", "xpack.cloudLinks.userMenuLinks.organizationLinkText": "组织", "xpack.cloudLinks.userMenuLinks.profileLinkText": "配置文件", "xpack.crossClusterReplication.addAutoFollowPatternButtonLabel": "创建自动跟随模式", diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/index.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/index.ts index edb43cd0405ca..fcb0f22fff804 100755 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/index.ts +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/index.ts @@ -5,8 +5,9 @@ * 2.0. */ +import type { PluginInitializerContext } from '@kbn/core/public'; import { CloudLinksPlugin } from './plugin'; -export function plugin() { - return new CloudLinksPlugin(); +export function plugin(context: PluginInitializerContext) { + return new CloudLinksPlugin(context); } diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_modal.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_modal.tsx new file mode 100644 index 0000000000000..b29f15a26c8c3 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_modal.tsx @@ -0,0 +1,180 @@ +/* + * 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 React, { type FC } from 'react'; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + useGeneratedHtmlId, + EuiButtonEmpty, + EuiCallOut, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import type { DarkModeValue as ColorMode } from '@kbn/user-profile-components'; +import { type Value, ValuesGroup } from './values_group'; +import { useAppearance } from './use_appearance_hook'; + +const systemLabel = i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalSystemLabel', { + defaultMessage: 'System', +}); + +const colorModeOptions: Array> = [ + { + id: 'system', + label: systemLabel, + icon: 'desktop', + }, + { + id: 'light', + label: i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalLightLabel', { + defaultMessage: 'Light', + }), + icon: 'sun', + }, + { + id: 'dark', + label: i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalDarkLabel', { + defaultMessage: 'Dark', + }), + icon: 'moon', + }, + { + id: 'space_default', + label: i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalSpaceDefaultLabel', { + defaultMessage: 'Space default', + }), + icon: 'spaces', + betaBadgeLabel: i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalBetaBadgeLabel', { + defaultMessage: 'Deprecated', + }), + betaBadgeTooltipContent: i18n.translate( + 'xpack.cloudLinks.userMenuLinks.appearanceModalBetaBadgeTooltip', + { + defaultMessage: 'Space default settings will be deprecated in 10.0.', + } + ), + betaBadgeIconType: 'warning', + }, +]; + +interface Props { + closeModal: () => void; + uiSettingsClient: IUiSettingsClient; + isServerless: boolean; +} + +export const AppearanceModal: FC = ({ closeModal, uiSettingsClient, isServerless }) => { + const modalTitleId = useGeneratedHtmlId(); + + const { onChange, colorMode, isLoading, initialColorModeValue } = useAppearance({ + uiSettingsClient, + defaultColorMode: isServerless ? 'system' : 'space_default', + }); + + return ( + + + + {i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalTitle', { + defaultMessage: 'Appearance', + })} + + + + + + title={i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalColorModeTitle', { + defaultMessage: 'Color mode', + })} + values={ + isServerless + ? colorModeOptions.filter(({ id }) => id !== 'space_default') + : colorModeOptions + } + selectedValue={colorMode} + onChange={(id) => { + onChange({ colorMode: id }, false); + }} + ariaLabel={i18n.translate( + 'xpack.cloudLinks.userMenuLinks.appearanceModalColorModeAriaLabel', + { + defaultMessage: 'Appearance color mode', + } + )} + /> + + {colorMode === 'space_default' && ( + <> + + +

+ {i18n.translate( + 'xpack.cloudLinks.userMenuLinks.appearanceModalDeprecatedSpaceDefaultDescr', + { + defaultMessage: + 'All users with the Space default color mode enabled will be automatically transitioned to the System color mode.', + } + )} +

+
+ + + )} +
+ + + + {i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalDiscardBtnLabel', { + defaultMessage: 'Discard', + })} + + + { + if (colorMode !== initialColorModeValue) { + await onChange({ colorMode }, true); + } + closeModal(); + }} + fill + isLoading={isLoading} + > + {i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalSaveBtnLabel', { + defaultMessage: 'Save changes', + })} + + +
+ ); +}; diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx new file mode 100644 index 0000000000000..5fdd762184a6b --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 React from 'react'; +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 { AppearanceSelector } from './appearance_selector'; + +describe('AppearanceSelector', () => { + const closePopover = jest.fn(); + + it('renders correctly and toggles dark mode', () => { + const security = securityMock.createStart(); + const core = coreMock.createStart(); + + const { getByTestId } = render( + + ); + + const appearanceSelector = getByTestId('appearanceSelector'); + fireEvent.click(appearanceSelector); + + expect(core.overlays.openModal).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx new file mode 100644 index 0000000000000..60eb3f0114443 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx @@ -0,0 +1,88 @@ +/* + * 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 React, { useRef } from 'react'; +import { EuiContextMenuItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { SecurityPluginStart } from '@kbn/security-plugin/public'; +import { UserProfilesKibanaProvider } from '@kbn/user-profile-components'; +import { CoreStart } from '@kbn/core-lifecycle-browser'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import type { OverlayRef } from '@kbn/core-mount-utils-browser'; + +import { AppearanceModal } from './appearance_modal'; +import { useAppearance } from './use_appearance_hook'; + +interface Props { + security: SecurityPluginStart; + core: CoreStart; + closePopover: () => void; + isServerless: boolean; +} + +export const AppearanceSelector = ({ security, core, closePopover, isServerless }: Props) => { + return ( + + + + ); +}; + +function AppearanceSelectorUI({ security, core, closePopover, isServerless }: Props) { + const { isVisible } = useAppearance({ + uiSettingsClient: core.uiSettings, + defaultColorMode: 'space_default', + }); + + const modalRef = useRef(null); + + const closeModal = () => { + modalRef.current?.close(); + modalRef.current = null; + }; + + const openModal = () => { + modalRef.current = core.overlays.openModal( + toMountPoint( + + + , + core + ), + { 'data-test-subj': 'appearanceModal', maxWidth: 600 } + ); + }; + + if (!isVisible) { + return null; + } + + return ( + { + openModal(); + closePopover(); + }} + data-test-subj="appearanceSelector" + > + {i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceLinkText', { + defaultMessage: 'Appearance', + })} + + ); +} diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/index.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/index.ts new file mode 100644 index 0000000000000..cad2bbd3d6ae4 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { AppearanceSelector } from './appearance_selector'; diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/use_appearance_hook.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/use_appearance_hook.ts new file mode 100644 index 0000000000000..797a8dd39e3d0 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/use_appearance_hook.ts @@ -0,0 +1,99 @@ +/* + * 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 { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { + useUpdateUserProfile, + type DarkModeValue as ColorMode, +} from '@kbn/user-profile-components'; + +interface Deps { + uiSettingsClient: IUiSettingsClient; + defaultColorMode: ColorMode; +} + +export const useAppearance = ({ uiSettingsClient, defaultColorMode }: Deps) => { + // 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'); + + const { userProfileData, isLoading, update, userProfileLoaded } = useUpdateUserProfile({ + notificationSuccess: { + title: i18n.translate('xpack.cloudLinks.userMenuLinks.appearance.successNotificationTitle', { + defaultMessage: 'Appearance settings updated', + }), + pageReloadText: i18n.translate( + 'xpack.cloudLinks.userMenuLinks.appearance.successNotificationText', + { + defaultMessage: 'Reload the page to see the changes', + } + ), + }, + pageReloadChecker: (prev, next) => { + return prev?.userSettings?.darkMode !== next.userSettings?.darkMode; + }, + }); + + const { userSettings: { darkMode: colorModeUserProfile = defaultColorMode } = {} } = + userProfileData ?? { + userSettings: {}, + }; + + const [colorMode, setColorMode] = useState(colorModeUserProfile); + const [initialColorModeValue, setInitialColorModeValue] = + useState(colorModeUserProfile); + + const onChange = useCallback( + ({ colorMode: updatedColorMode }: { colorMode?: ColorMode }, persist: boolean) => { + if (isLoading) { + return; + } + + // optimistic update + if (updatedColorMode) { + setColorMode(updatedColorMode); + } + + // TODO: here we will update the contrast when available + + if (!persist) { + return; + } + + return update({ + userSettings: { + darkMode: updatedColorMode, + }, + }); + }, + [isLoading, update] + ); + + useEffect(() => { + setColorMode(colorModeUserProfile); + }, [colorModeUserProfile]); + + useEffect(() => { + if (userProfileLoaded) { + const storedValue = userProfileData?.userSettings?.darkMode; + if (storedValue) { + setInitialColorModeValue(storedValue); + } + } + }, [userProfileData, userProfileLoaded]); + + return { + isVisible: valueSetInKibanaConfig ? false : Boolean(userProfileData), + setColorMode, + colorMode, + onChange, + isLoading, + initialColorModeValue, + }; +}; diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/values_group.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/values_group.tsx new file mode 100644 index 0000000000000..30cce1a5d0e68 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/values_group.tsx @@ -0,0 +1,66 @@ +/* + * 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 React from 'react'; +import { EuiIcon, EuiKeyPadMenuItem, EuiKeyPadMenu } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export interface Value { + label: string; + id: T; + icon: string; + betaBadgeLabel?: string; + betaBadgeTooltipContent?: string; + betaBadgeIconType?: string; +} + +interface Props { + title: string; + values: Array>; + selectedValue: T; + onChange: (id: T) => void; + ariaLabel: string; +} + +export function ValuesGroup({ + title, + values, + onChange, + selectedValue, + ariaLabel, +}: Props) { + return ( + <> + {title}, + }} + css={css` + inline-size: 420px; // Allow for 4 items to fit in a row instead of the default 3 + `} + > + {values.map(({ id, label, icon }) => ( + { + onChange(id); + }} + data-test-subj={`colorModeKeyPadItem${id}`} + > + + + ))} + + + ); +} diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.test.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.test.ts index 5fc4a9549682f..67ad63f2b375b 100644 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.test.ts @@ -21,6 +21,7 @@ describe('maybeAddCloudLinks', () => { security, share: sharePluginMock.createStartContract(), cloud: { ...cloudMock.createStart(), isCloudEnabled: false }, + isServerless: false, }); // Since there's a promise, let's wait for the next tick await new Promise((resolve) => process.nextTick(resolve)); @@ -39,6 +40,7 @@ describe('maybeAddCloudLinks', () => { core, share: sharePluginMock.createStartContract(), cloud: { ...cloudMock.createStart(), isCloudEnabled: true }, + isServerless: false, }); // Since there's a promise, let's wait for the next tick await new Promise((resolve) => process.nextTick(resolve)); @@ -113,6 +115,7 @@ describe('maybeAddCloudLinks', () => { core, share: sharePluginMock.createStartContract(), cloud: { ...cloudMock.createStart(), isCloudEnabled: true }, + isServerless: false, }); // Since there's a promise, let's wait for the next tick await new Promise((resolve) => process.nextTick(resolve)); @@ -188,6 +191,7 @@ describe('maybeAddCloudLinks', () => { core, share: sharePluginMock.createStartContract(), cloud: { ...cloudMock.createStart(), isCloudEnabled: true }, + isServerless: false, }); // Since there's a promise, let's wait for the next tick await new Promise((resolve) => process.nextTick(resolve)); diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.ts index 8bd2bd0ff1cf7..1a56a22c653aa 100644 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.ts +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.ts @@ -20,9 +20,15 @@ export interface MaybeAddCloudLinksDeps { security: SecurityPluginStart; cloud: CloudStart; share: SharePluginStart; + isServerless: boolean; } -export function maybeAddCloudLinks({ core, security, cloud }: MaybeAddCloudLinksDeps): void { +export function maybeAddCloudLinks({ + core, + security, + cloud, + isServerless, +}: MaybeAddCloudLinksDeps): void { const userObservable = defer(() => security.authc.getCurrentUser()).pipe( // Check if user is a cloud user. map((user) => user.elastic_cloud_user), @@ -43,6 +49,7 @@ export function maybeAddCloudLinks({ core, security, cloud }: MaybeAddCloudLinks core, cloud, security, + isServerless, }); security.navControlService.addUserMenuLinks(userMenuLinks); }) diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_hook.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_hook.ts deleted file mode 100644 index 0e062b693a24f..0000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_hook.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { 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; -} - -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'); - - const { userProfileData, isLoading, update } = useUpdateUserProfile({ - notificationSuccess: { - title: i18n.translate('xpack.cloudLinks.userMenuLinks.darkMode.successNotificationTitle', { - defaultMessage: 'Color theme updated', - }), - pageReloadText: i18n.translate( - 'xpack.cloudLinks.userMenuLinks.darkMode.successNotificationText', - { - defaultMessage: 'Reload the page to see the changes', - } - ), - }, - pageReloadChecker: (prev, next) => { - return prev?.userSettings?.darkMode !== next.userSettings?.darkMode; - }, - }); - - 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', - }, - }); - }, - [isLoading, update] - ); - - useEffect(() => { - if (!isMounted()) return; - setIsDarkModeOn(colorScheme === 'dark'); - }, [isMounted, colorScheme]); - - return { - isVisible: valueSetInKibanaConfig ? false : Boolean(userProfileData), - toggle, - isDarkModeOn, - colorScheme, - }; -}; diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.test.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.test.tsx deleted file mode 100644 index 6b06cd64b9e23..0000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -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 { ThemeDarkModeToggle } from './theme_darkmode_toggle'; - -const mockUseUpdateUserProfile = jest.fn(); - -jest.mock('@kbn/user-profile-components', () => { - const original = jest.requireActual('@kbn/user-profile-components'); - return { - ...original, - useUpdateUserProfile: () => mockUseUpdateUserProfile(), - }; -}); - -describe('ThemeDarkModeToggle', () => { - it('renders correctly and toggles dark mode', () => { - const security = securityMock.createStart(); - const core = coreMock.createStart(); - - const mockUpdate = jest.fn(); - mockUseUpdateUserProfile.mockReturnValue({ - userProfileData: { userSettings: { darkMode: 'light' } }, - isLoading: false, - update: mockUpdate, - }); - - const { getByTestId, rerender } = render( - - ); - - const toggleSwitch = getByTestId('darkModeToggleSwitch'); - fireEvent.click(toggleSwitch); - expect(mockUpdate).toHaveBeenCalledWith({ userSettings: { darkMode: 'dark' } }); - - // Now we want to simulate toggling back to light - mockUseUpdateUserProfile.mockReturnValue({ - userProfileData: { userSettings: { darkMode: 'dark' } }, - isLoading: false, - update: mockUpdate, - }); - - // Rerender the component to apply the new props - rerender(); - - fireEvent.click(toggleSwitch); - expect(mockUpdate).toHaveBeenLastCalledWith({ userSettings: { darkMode: 'light' } }); - }); -}); diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.tsx deleted file mode 100644 index 731dc6768c48f..0000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -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 { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; -import { UserProfilesKibanaProvider } from '@kbn/user-profile-components'; -import { CoreStart } from '@kbn/core-lifecycle-browser'; -import { toMountPoint } from '@kbn/react-kibana-mount'; - -import { useThemeDarkmodeToggle } from './theme_darkmode_hook'; - -interface Props { - security: SecurityPluginStart; - core: CoreStart; -} - -export const ThemeDarkModeToggle = ({ security, core }: Props) => { - return ( - - - - ); -}; - -function ThemeDarkModeToggleUi({ uiSettingsClient }: { uiSettingsClient: IUiSettingsClient }) { - const toggleTextSwitchId = useGeneratedHtmlId({ prefix: 'toggleTextSwitch' }); - const { euiTheme } = useEuiTheme(); - - const { isVisible, toggle, isDarkModeOn, colorScheme } = useThemeDarkmodeToggle({ - uiSettingsClient, - }); - - if (!isVisible) { - return null; - } - - return ( - - - { - const on = colorScheme === 'light' ? true : false; - toggle(on); - }} - data-test-subj="darkModeToggle" - > - {i18n.translate('xpack.cloudLinks.userMenuLinks.darkModeToggle', { - defaultMessage: 'Dark mode', - })} - - - - { - toggle(e.target.checked); - }} - aria-describedby={toggleTextSwitchId} - data-test-subj="darkModeToggleSwitch" - compressed - /> - - - ); -} diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx index 16ffa32360f25..a168956cd1c2d 100644 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx @@ -10,16 +10,18 @@ import { i18n } from '@kbn/i18n'; import type { CloudStart } from '@kbn/cloud-plugin/public'; import type { SecurityPluginStart, UserMenuLink } from '@kbn/security-plugin/public'; import type { CoreStart } from '@kbn/core/public'; -import { ThemeDarkModeToggle } from './theme_darkmode_toggle'; +import { AppearanceSelector } from './appearance_selector'; export const createUserMenuLinks = ({ core, cloud, security, + isServerless, }: { core: CoreStart; cloud: CloudStart; security: SecurityPluginStart; + isServerless: boolean; }): UserMenuLink[] => { const { profileUrl, billingUrl, organizationUrl } = cloud; @@ -60,7 +62,14 @@ export const createUserMenuLinks = ({ } userMenuLinks.push({ - content: , + content: ({ closePopover }) => ( + + ), order: 400, label: '', iconType: '', diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.test.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.test.ts index d930d024d2484..5f692320dc1e8 100644 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.test.ts @@ -17,7 +17,7 @@ describe('Cloud Links Plugin - public', () => { let plugin: CloudLinksPlugin; beforeEach(() => { - plugin = new CloudLinksPlugin(); + plugin = new CloudLinksPlugin(coreMock.createPluginInitializerContext()); }); afterEach(() => { diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.tsx index 9f385500b13e8..c03e15c96776e 100755 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.tsx +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.tsx @@ -8,11 +8,13 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import type { PluginInitializerContext } from '@kbn/core-plugins-browser'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import * as connectionDetails from '@kbn/cloud/connection_details'; +import type { BuildFlavor } from '@kbn/config'; import { maybeAddCloudLinks } from './maybe_add_cloud_links'; interface CloudLinksDepsSetup { @@ -30,6 +32,12 @@ interface CloudLinksDepsStart { export class CloudLinksPlugin implements Plugin { + public offering: BuildFlavor; + + constructor(initializerContext: PluginInitializerContext) { + this.offering = initializerContext.env.packageInfo.buildFlavor; + } + public setup({ analytics }: CoreSetup) { analytics.registerEventType({ eventType: 'connection_details_learn_more_clicked', @@ -115,6 +123,7 @@ export class CloudLinksPlugin security, cloud, share, + isServerless: this.offering === 'serverless', }); } } diff --git a/x-pack/plugins/cloud_integrations/cloud_links/tsconfig.json b/x-pack/plugins/cloud_integrations/cloud_links/tsconfig.json index b7759fb7f1c5e..ed8239bd32938 100644 --- a/x-pack/plugins/cloud_integrations/cloud_links/tsconfig.json +++ b/x-pack/plugins/cloud_integrations/cloud_links/tsconfig.json @@ -25,6 +25,9 @@ "@kbn/share-plugin", "@kbn/cloud", "@kbn/react-kibana-mount", + "@kbn/core-mount-utils-browser", + "@kbn/core-plugins-browser", + "@kbn/config", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx index 7a20eaf3eff1c..c473a24e6d668 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -76,7 +76,7 @@ describe('useUserProfileForm', () => { "initials": "fn", }, "userSettings": Object { - "darkMode": "", + "darkMode": "space_default", }, }, "user": Object { @@ -259,7 +259,7 @@ describe('useUserProfileForm', () => { expect(themeMenu).toHaveLength(1); const themeOptions = themeMenu.find('EuiKeyPadMenuItem'); - expect(themeOptions).toHaveLength(3); + expect(themeOptions).toHaveLength(4); themeOptions.forEach((option) => { const menuItemEl = (option.getDOMNode() as unknown as Element[])[1]; expect(menuItemEl.className).not.toContain('disabled'); @@ -343,7 +343,7 @@ describe('useUserProfileForm', () => { expect(themeMenu).toHaveLength(1); const themeOptions = themeMenu.find('EuiKeyPadMenuItem'); - expect(themeOptions).toHaveLength(3); + expect(themeOptions).toHaveLength(4); themeOptions.forEach((option) => { const menuItemEl = (option.getDOMNode() as unknown as Element[])[1]; expect(menuItemEl.className).toContain('disabled'); @@ -379,7 +379,7 @@ describe('useUserProfileForm', () => { expect(themeMenu).toHaveLength(1); const themeOptions = themeMenu.find('EuiKeyPadMenuItem'); - expect(themeOptions).toHaveLength(3); + expect(themeOptions).toHaveLength(4); themeOptions.forEach((option) => { const menuItemEl = (option.getDOMNode() as unknown as Element[])[1]; expect(menuItemEl.className).toContain('disabled'); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index a68cffaf439af..aa66c80953929 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -11,6 +11,7 @@ import { EuiButton, EuiButtonEmpty, EuiButtonGroup, + EuiCallOut, EuiColorPicker, EuiDescribedFormGroup, EuiDescriptionList, @@ -229,7 +230,7 @@ const UserSettingsEditor: FunctionComponent = ({ = ({ ), }} + css={css` + inline-size: 420px; // Allow for 4 items to fit in a row instead of the default 3 + `} > {themeItem({ - id: '', - label: i18n.translate('xpack.security.accountManagement.userProfile.defaultModeButton', { - defaultMessage: 'Space default', + id: 'system', + label: i18n.translate('xpack.security.accountManagement.userProfile.systemModeButton', { + defaultMessage: 'System', }), - icon: 'spaces', + icon: 'desktop', })} {themeItem({ id: 'light', @@ -282,6 +286,13 @@ const UserSettingsEditor: FunctionComponent = ({ }), icon: 'moon', })} + {themeItem({ + id: 'space_default', + label: i18n.translate('xpack.security.accountManagement.userProfile.defaultModeButton', { + defaultMessage: 'Space default', + }), + icon: 'spaces', + })} ); return themeOverridden ? ( @@ -301,6 +312,32 @@ const UserSettingsEditor: FunctionComponent = ({ ); }; + const deprecatedWarning = idSelected === 'space_default' && ( + <> + + +

+ {i18n.translate( + 'xpack.security.accountManagement.userProfile.deprecatedSpaceDefaultDescription', + { + defaultMessage: + 'All users with the Space default color mode enabled will be automatically transitioned to the System color mode.', + } + )} +

+
+ + ); + return ( = ({ } > - {themeMenu(isThemeOverridden)} + <> + {themeMenu(isThemeOverridden)} + {deprecatedWarning} + ); @@ -911,7 +951,7 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { imageUrl: data.avatar?.imageUrl || '', }, userSettings: { - darkMode: data.userSettings?.darkMode || '', + darkMode: data.userSettings?.darkMode || 'space_default', }, } : undefined, diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 08799c1ef910e..aa0a4249a36dd 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -214,6 +214,7 @@ describe('SecurityNavControl', () => { Array [ Object { "content": { Array [ Object { "content": { Array [ Object { "content": { Array [ Object { "content": & { content?: ReactNode }; +type ContextMenuItem = Omit & { + content?: ReactNode | ((args: { closePopover: () => void }) => ReactNode); +}; interface ContextMenuProps { items: ContextMenuItem[]; + closePopover: () => void; } -const ContextMenuContent = ({ items }: ContextMenuProps) => { +const ContextMenuContent = ({ items, closePopover }: ContextMenuProps) => { return ( <> {items.map((item, i) => { if (item.content) { - return {item.content}; + return ( + + {typeof item.content === 'function' ? item.content({ closePopover }) : item.content} + + ); } return ( = ({ { id: 0, title: displayName, - content: , + content: ( + setIsPopoverOpen(false)} /> + ), }, ]} data-test-subj="userMenu" diff --git a/x-pack/test/functional/apps/user_profiles/user_profiles.ts b/x-pack/test/functional/apps/user_profiles/user_profiles.ts index 050c3f1c58b4b..d953cbe5fe7e0 100644 --- a/x-pack/test/functional/apps/user_profiles/user_profiles.ts +++ b/x-pack/test/functional/apps/user_profiles/user_profiles.ts @@ -98,15 +98,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const themeKeyPadMenu = await pageObjects.userProfiles.getThemeKeypadMenu(); expect(themeKeyPadMenu).not.to.be(null); - await pageObjects.userProfiles.changeUserProfileTheme('Dark'); + await pageObjects.userProfiles.changeUserProfileTheme('dark'); const darkModeTag = await pageObjects.userProfiles.getThemeTag(); expect(darkModeTag).to.be('v8dark'); - await pageObjects.userProfiles.changeUserProfileTheme('Light'); + await pageObjects.userProfiles.changeUserProfileTheme('light'); const lightModeTag = await pageObjects.userProfiles.getThemeTag(); expect(lightModeTag).to.be('v8light'); - await pageObjects.userProfiles.changeUserProfileTheme('Space default'); + await pageObjects.userProfiles.changeUserProfileTheme('space_default'); const spaceDefaultModeTag = await pageObjects.userProfiles.getThemeTag(); expect(spaceDefaultModeTag).to.be('v8light'); }); @@ -131,15 +131,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { let spaceDefaultModeTag = await pageObjects.userProfiles.getThemeTag(); expect(spaceDefaultModeTag).to.be('v8dark'); - await pageObjects.userProfiles.changeUserProfileTheme('Light'); + await pageObjects.userProfiles.changeUserProfileTheme('light'); const lightModeTag = await pageObjects.userProfiles.getThemeTag(); expect(lightModeTag).to.be('v8light'); - await pageObjects.userProfiles.changeUserProfileTheme('Dark'); + await pageObjects.userProfiles.changeUserProfileTheme('dark'); const darkModeTag = await pageObjects.userProfiles.getThemeTag(); expect(darkModeTag).to.be('v8dark'); - await pageObjects.userProfiles.changeUserProfileTheme('Space default'); + await pageObjects.userProfiles.changeUserProfileTheme('space_default'); spaceDefaultModeTag = await pageObjects.userProfiles.getThemeTag(); expect(spaceDefaultModeTag).to.be('v8dark'); diff --git a/x-pack/test/functional/page_objects/user_profile_page.ts b/x-pack/test/functional/page_objects/user_profile_page.ts index d777d1c2ffcda..f9513997ac4db 100644 --- a/x-pack/test/functional/page_objects/user_profile_page.ts +++ b/x-pack/test/functional/page_objects/user_profile_page.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import type { DarkModeValue as ColorMode } from '@kbn/user-profile-components'; import { FtrProviderContext } from '../ftr_provider_context'; export function UserProfilePageProvider({ getService }: FtrProviderContext) { @@ -26,9 +27,8 @@ export function UserProfilePageProvider({ getService }: FtrProviderContext) { return await testSubjects.find('windowReloadButton'); }; - const getThemeKeypadButton = async (option: string) => { - option = option[0].toUpperCase() + option.substring(1).toLowerCase(); - return await testSubjects.find(`themeKeyPadItem${option}`); + const getThemeKeypadButton = async (colorMode: ColorMode) => { + return await testSubjects.find(`themeKeyPadItem${colorMode}`); }; const saveUserProfileChanges = async (): Promise => { @@ -40,8 +40,8 @@ export function UserProfilePageProvider({ getService }: FtrProviderContext) { }); }; - const changeUserProfileTheme = async (theme: string): Promise => { - const themeModeButton = await getThemeKeypadButton(theme); + const changeUserProfileTheme = async (colorMode: ColorMode): Promise => { + const themeModeButton = await getThemeKeypadButton(colorMode); expect(themeModeButton).not.to.be(null); await themeModeButton.click(); diff --git a/x-pack/test/functional_cloud/tests/cloud_links.ts b/x-pack/test/functional_cloud/tests/cloud_links.ts index 1534c10002f90..1549db7e9d0bd 100644 --- a/x-pack/test/functional_cloud/tests/cloud_links.ts +++ b/x-pack/test/functional_cloud/tests/cloud_links.ts @@ -6,11 +6,14 @@ */ import expect from '@kbn/expect'; +import type { DarkModeValue as ColorMode } from '@kbn/user-profile-components'; import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); - const PageObjects = getPageObjects(['common', 'header']); + const browser = getService('browser'); + const PageObjects = getPageObjects(['common', 'header', 'userProfiles', 'settings']); + const testSubjects = getService('testSubjects'); describe('Cloud Links integration', function () { before(async () => { @@ -146,10 +149,120 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(cloudLink).to.not.be(null); }); - it('Shows the theme darkMode toggle', async () => { - await PageObjects.common.clickAndValidate('userMenuButton', 'darkModeToggle'); - const darkModeSwitch = await find.byCssSelector('[data-test-subj="darkModeToggleSwitch"]'); - expect(darkModeSwitch).to.not.be(null); + it('Shows the appearance button', async () => { + await PageObjects.common.clickAndValidate('userMenuButton', 'appearanceSelector'); + }); + }); + + describe('Appearance selector modal', () => { + const openAppearanceSelectorModal = async () => { + // Check if the user menu is open + await find.byCssSelector('[data-test-subj="userMenu"]', 1000).catch(async () => { + await testSubjects.click('userMenuButton'); + }); + await testSubjects.click('appearanceSelector'); + const appearanceModal = await find.byCssSelector( + '[data-test-subj="appearanceModal"]', + 1000 + ); + expect(appearanceModal).to.not.be(null); + }; + + const refreshPage = async () => { + await browser.refresh(); + await testSubjects.exists('globalLoadingIndicator-hidden'); + }; + + const changeColorMode = async (colorMode: ColorMode) => { + await openAppearanceSelectorModal(); + await testSubjects.click(`colorModeKeyPadItem${colorMode}`); + await testSubjects.click('appearanceModalSaveButton'); + await testSubjects.missingOrFail('appearanceModal'); + }; + + after(async () => { + await changeColorMode('space_default'); + + await PageObjects.common.navigateToUrl('management', 'kibana/settings', { + basePath: '', + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + shouldUseHashForSubUrl: false, + }); + + // Reset the space default dark mode to "disabled" + await PageObjects.settings.setAdvancedSettingsSelect('theme:darkMode', 'disabled'); + { + const advancedSetting = await PageObjects.settings.getAdvancedSettings('theme:darkMode'); + expect(advancedSetting).to.be('disabled'); + } + + await refreshPage(); + }); + + it('has 4 color mode options to chose from', async () => { + await openAppearanceSelectorModal(); + const colorModes: ColorMode[] = ['light', 'dark', 'system', 'space_default']; + for (const colorMode of colorModes) { + const themeModeButton = await testSubjects.find(`colorModeKeyPadItem${colorMode}`, 1000); + expect(themeModeButton).to.not.be(null); + } + await testSubjects.click('appearanceModalDiscardButton'); + }); + + it('can change the color mode to dark', async () => { + await changeColorMode('dark'); + await refreshPage(); + const colorModeTag = await PageObjects.userProfiles.getThemeTag(); + expect(colorModeTag).to.be('v8dark'); + }); + + it('can change the color mode to light', async () => { + await changeColorMode('light'); + await refreshPage(); + const colorModeTag = await PageObjects.userProfiles.getThemeTag(); + expect(colorModeTag).to.be('v8light'); + }); + + it('can change the color mode to space_default', async () => { + // Let's make sure we are in light mode before changing to space_default + await changeColorMode('light'); + + { + await refreshPage(); + const colorModeTag = await PageObjects.userProfiles.getThemeTag(); + expect(colorModeTag).to.be('v8light'); + } + + // Change the space default dark mode to "enabled" + await PageObjects.common.navigateToUrl('management', 'kibana/settings', { + basePath: '', + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + shouldUseHashForSubUrl: false, + }); + + await PageObjects.settings.setAdvancedSettingsSelect('theme:darkMode', 'enabled'); + { + const advancedSetting = await PageObjects.settings.getAdvancedSettings('theme:darkMode'); + expect(advancedSetting).to.be('enabled'); + } + + // Make sure we are still in light mode as per the User profile + // even after setting the space default to "dark" + { + await refreshPage(); + const colorModeTag = await PageObjects.userProfiles.getThemeTag(); + expect(colorModeTag).to.be('v8light'); + } + + await changeColorMode('space_default'); + + { + await refreshPage(); + const colorModeTag = await PageObjects.userProfiles.getThemeTag(); + expect(colorModeTag).to.be('v8dark'); // We are now in dark mode + } }); }); });