diff --git a/src/core/packages/overlays/browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap b/src/core/packages/overlays/browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap index 9717c0fea96c0..b24640fded8da 100644 --- a/src/core/packages/overlays/browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap +++ b/src/core/packages/overlays/browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap @@ -126,7 +126,19 @@ Array [ "bulkGet": [MockFunction], "getCurrent": [MockFunction], "getEnabled$": [MockFunction], - "getUserProfile$": [MockFunction], + "getUserProfile$": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + ], + }, "partialUpdate": [MockFunction], "suggest": [MockFunction], "update": [MockFunction], @@ -445,7 +457,26 @@ Array [ "bulkGet": [MockFunction], "getCurrent": [MockFunction], "getEnabled$": [MockFunction], - "getUserProfile$": [MockFunction], + "getUserProfile$": [MockFunction] { + "calls": Array [ + Array [], + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + ], + }, "partialUpdate": [MockFunction], "suggest": [MockFunction], "update": [MockFunction], @@ -841,7 +872,33 @@ Array [ "bulkGet": [MockFunction], "getCurrent": [MockFunction], "getEnabled$": [MockFunction], - "getUserProfile$": [MockFunction], + "getUserProfile$": [MockFunction] { + "calls": Array [ + Array [], + Array [], + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + ], + }, "partialUpdate": [MockFunction], "suggest": [MockFunction], "update": [MockFunction], @@ -1155,7 +1212,33 @@ Array [ "bulkGet": [MockFunction], "getCurrent": [MockFunction], "getEnabled$": [MockFunction], - "getUserProfile$": [MockFunction], + "getUserProfile$": [MockFunction] { + "calls": Array [ + Array [], + Array [], + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + ], + }, "partialUpdate": [MockFunction], "suggest": [MockFunction], "update": [MockFunction], @@ -1474,7 +1557,33 @@ Array [ "bulkGet": [MockFunction], "getCurrent": [MockFunction], "getEnabled$": [MockFunction], - "getUserProfile$": [MockFunction], + "getUserProfile$": [MockFunction] { + "calls": Array [ + Array [], + Array [], + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + ], + }, "partialUpdate": [MockFunction], "suggest": [MockFunction], "update": [MockFunction], @@ -1782,7 +1891,33 @@ Array [ "bulkGet": [MockFunction], "getCurrent": [MockFunction], "getEnabled$": [MockFunction], - "getUserProfile$": [MockFunction], + "getUserProfile$": [MockFunction] { + "calls": Array [ + Array [], + Array [], + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + ], + }, "partialUpdate": [MockFunction], "suggest": [MockFunction], "update": [MockFunction], @@ -2017,7 +2152,19 @@ Array [ "bulkGet": [MockFunction], "getCurrent": [MockFunction], "getEnabled$": [MockFunction], - "getUserProfile$": [MockFunction], + "getUserProfile$": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + ], + }, "partialUpdate": [MockFunction], "suggest": [MockFunction], "update": [MockFunction], @@ -2157,7 +2304,19 @@ Array [ "bulkGet": [MockFunction], "getCurrent": [MockFunction], "getEnabled$": [MockFunction], - "getUserProfile$": [MockFunction], + "getUserProfile$": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + ], + }, "partialUpdate": [MockFunction], "suggest": [MockFunction], "update": [MockFunction], @@ -2302,7 +2461,19 @@ Array [ "bulkGet": [MockFunction], "getCurrent": [MockFunction], "getEnabled$": [MockFunction], - "getUserProfile$": [MockFunction], + "getUserProfile$": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + ], + }, "partialUpdate": [MockFunction], "suggest": [MockFunction], "update": [MockFunction], @@ -2436,7 +2607,19 @@ Array [ "bulkGet": [MockFunction], "getCurrent": [MockFunction], "getEnabled$": [MockFunction], - "getUserProfile$": [MockFunction], + "getUserProfile$": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Observable { + "_subscribe": [Function], + }, + }, + ], + }, "partialUpdate": [MockFunction], "suggest": [MockFunction], "update": [MockFunction], diff --git a/src/platform/packages/shared/kbn-user-profile-components/index.ts b/src/platform/packages/shared/kbn-user-profile-components/index.ts index 5795de64ea1eb..2bd6171d08bd0 100644 --- a/src/platform/packages/shared/kbn-user-profile-components/index.ts +++ b/src/platform/packages/shared/kbn-user-profile-components/index.ts @@ -25,6 +25,7 @@ export type { export type { UserProfileData, UserSettingsData, + ContrastModeValue, DarkModeValue, UserProfileAvatarData, } from './src/types'; diff --git a/src/platform/packages/shared/kbn-user-profile-components/src/types.ts b/src/platform/packages/shared/kbn-user-profile-components/src/types.ts index 54b77e63e55f0..7624be98e6e61 100644 --- a/src/platform/packages/shared/kbn-user-profile-components/src/types.ts +++ b/src/platform/packages/shared/kbn-user-profile-components/src/types.ts @@ -29,11 +29,14 @@ export interface UserProfileAvatarData { export type DarkModeValue = 'system' | 'dark' | 'light' | 'space_default'; +export type ContrastModeValue = 'system' | 'standard' | 'high'; + /** * User settings stored in the data object of the User Profile */ export interface UserSettingsData { darkMode?: DarkModeValue; + contrastMode?: ContrastModeValue; solutionNavOptOut?: boolean; } diff --git a/src/platform/packages/shared/react/kibana_context/root/eui_provider.tsx b/src/platform/packages/shared/react/kibana_context/root/eui_provider.tsx index d0e1249049f50..42ae4b049d6be 100644 --- a/src/platform/packages/shared/react/kibana_context/root/eui_provider.tsx +++ b/src/platform/packages/shared/react/kibana_context/root/eui_provider.tsx @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import * as Rx from 'rxjs'; import React, { FC, PropsWithChildren, useMemo } from 'react'; import useObservable from 'react-use/lib/useObservable'; import createCache from '@emotion/cache'; @@ -22,12 +23,16 @@ import { import type { UserProfileService } from '@kbn/core-user-profile-browser'; import type { ThemeServiceStart } from '@kbn/react-kibana-context-common'; +interface UserSettings { + contrastMode: 'system' | 'standard' | 'high'; +} + /** * Props for the KibanaEuiProvider. */ export interface KibanaEuiProviderProps extends Pick, 'modify' | 'colorMode'> { theme: ThemeServiceStart; - userProfile?: Pick; // TODO: use this to access a "high contrast mode" flag from user settings. Pass the flag to EuiProvider, when it is supported in EUI. + userProfile?: Pick; globalStyles?: boolean; } @@ -66,6 +71,7 @@ const cache = { default: emotionCache, global: globalCache, utility: utilitiesCa */ export const KibanaEuiProvider: FC> = ({ theme, + userProfile, globalStyles: globalStylesProp, colorMode: colorModeProp, modify, @@ -89,6 +95,19 @@ export const KibanaEuiProvider: FC> = // colorMode provided by the `theme`. const colorMode = colorModeProp || themeColorMode; + const getUserProfile$ = useMemo( + () => userProfile?.getUserProfile$ ?? Rx.of, + [userProfile?.getUserProfile$] + ); + const userProfileData = useObservable(getUserProfile$(), null); + + // If the high contrast mode value is undefined, EUI will use the OS level setting. + const userSettings = userProfileData?.userSettings as UserSettings | undefined; + let highContrastMode: boolean | undefined; + if (userSettings?.contrastMode && userSettings?.contrastMode !== 'system') { + highContrastMode = userSettings.contrastMode === 'high'; + } + // This logic was drawn from the Core theme provider, and wasn't present (or even used) // elsewhere. Should be a passive addition to anyone using the older theme provider(s). const globalStyles = globalStylesProp === false ? false : undefined; @@ -101,8 +120,8 @@ export const KibanaEuiProvider: FC> = colorMode, globalStyles, utilityClasses: globalStyles, + highContrastMode, theme: _theme, - highContrastMode: false, }} > {children} diff --git a/x-pack/platform/plugins/shared/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/platform/plugins/shared/security/public/account_management/user_profile/user_profile.test.tsx index 141bb04f4be6b..1bc714599c711 100644 --- a/x-pack/platform/plugins/shared/security/public/account_management/user_profile/user_profile.test.tsx +++ b/x-pack/platform/plugins/shared/security/public/account_management/user_profile/user_profile.test.tsx @@ -76,6 +76,7 @@ describe('useUserProfileForm', () => { "initials": "fn", }, "userSettings": Object { + "contrastMode": "system", "darkMode": "space_default", }, }, @@ -387,6 +388,34 @@ describe('useUserProfileForm', () => { }); }); + describe('Contrast Mode Form', () => { + it('should add special toast after submitting form successfully since contrast mode change requires a refresh', async () => { + const data: UserProfileData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.submitForm(); + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenNthCalledWith( + 1, + { title: 'Profile updated' }, + {} + ); + + await act(async () => { + await result.current.setFieldValue('data.userSettings.contrastMode', 'high'); // default value is 'system' + await result.current.submitForm(); + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ title: 'Profile updated' }), + expect.objectContaining({ toastLifeTimeMs: 300000 }) + ); + }); + }); + describe('User roles section', () => { it('should display the user roles', () => { const data: UserProfileData = {}; diff --git a/x-pack/platform/plugins/shared/security/public/account_management/user_profile/user_profile.tsx b/x-pack/platform/plugins/shared/security/public/account_management/user_profile/user_profile.tsx index 90c528c2c94fd..df6a26ac7901f 100644 --- a/x-pack/platform/plugins/shared/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/platform/plugins/shared/security/public/account_management/user_profile/user_profile.tsx @@ -49,7 +49,11 @@ import { useFormChangesContext, } from '@kbn/security-form-components'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; -import type { DarkModeValue, UserProfileData } from '@kbn/user-profile-components'; +import type { + ContrastModeValue, + DarkModeValue, + UserProfileData, +} from '@kbn/user-profile-components'; import { UserAvatar, useUpdateUserProfile } from '@kbn/user-profile-components'; import { createImageHandler, getRandomColor, VALID_HEX_COLOR } from './utils'; @@ -110,6 +114,7 @@ export interface UserProfileFormValues { }; userSettings: { darkMode: DarkModeValue; + contrastMode: ContrastModeValue; }; }; avatarType: 'initials' | 'image'; @@ -189,13 +194,13 @@ const UserSettingsEditor: FunctionComponent = ({ return null; } - let idSelected = formik.values.data.userSettings.darkMode; + let colorModeIdSelected = formik.values.data.userSettings.darkMode; if (isThemeOverridden) { if (isOverriddenThemeDarkMode) { - idSelected = 'dark'; + colorModeIdSelected = 'dark'; } else { - idSelected = 'light'; + colorModeIdSelected = 'light'; } } @@ -212,7 +217,7 @@ const UserSettingsEditor: FunctionComponent = ({ label={label} data-test-subj={`themeKeyPadItem${id}`} checkable="single" - isSelected={idSelected === id} + isSelected={colorModeIdSelected === id} isDisabled={isThemeOverridden} onChange={() => formik.setFieldValue('data.userSettings.darkMode', id)} > @@ -236,7 +241,7 @@ const UserSettingsEditor: FunctionComponent = ({ ), @@ -292,7 +297,7 @@ const UserSettingsEditor: FunctionComponent = ({ ); }; - const deprecatedWarning = idSelected === 'space_default' && ( + const deprecatedWarning = colorModeIdSelected === 'space_default' && ( <> = ({ ); + const contrastModeIdSelected = formik.values.data.userSettings.contrastMode; + const contrastItem = ({ id, label, icon }: ThemeKeyPadItem) => { + return ( + formik.setFieldValue('data.userSettings.contrastMode', id)} + > + + + ); + }; + + const contrastModeMenu = () => { + return ( + + + + ), + }} + > + {contrastItem({ + id: 'system', + label: i18n.translate( + 'xpack.security.accountManagement.userProfile.contrastModeSystemButton', + { defaultMessage: 'System' } + ), + icon: 'desktop', + })} + {contrastItem({ + id: 'standard', + label: i18n.translate( + 'xpack.security.accountManagement.userProfile.contrastModeStandardButton', + { defaultMessage: 'Normal' } + ), + icon: 'contrast', + })} + {contrastItem({ + id: 'high', + label: i18n.translate( + 'xpack.security.accountManagement.userProfile.contrastModeHighButton', + { defaultMessage: 'High' } + ), + icon: 'contrastHigh', + })} + + ); + }; + return ( = ({

} @@ -343,6 +413,10 @@ const UserSettingsEditor: FunctionComponent = ({ {deprecatedWarning} + + + {contrastModeMenu()} +
); }; @@ -872,6 +946,7 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { }, userSettings: { darkMode: data.userSettings?.darkMode || 'space_default', + contrastMode: data.userSettings?.contrastMode || 'system', }, } : undefined, @@ -924,7 +999,10 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { resetInitialValues(values); let isRefreshRequired = false; - if (initialValues.data?.userSettings.darkMode !== values.data?.userSettings.darkMode) { + if ( + initialValues.data?.userSettings.darkMode !== values.data?.userSettings.darkMode || + initialValues.data?.userSettings.contrastMode !== values.data?.userSettings.contrastMode + ) { isRefreshRequired = true; } showSuccessNotification({ isRefreshRequired });