diff --git a/src/platform/packages/shared/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx b/src/platform/packages/shared/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx index 72b1bdadb3393..61bd46179be61 100644 --- a/src/platform/packages/shared/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx +++ b/src/platform/packages/shared/kbn-user-profile-components/src/hooks/use_update_user_profile.tsx @@ -28,7 +28,7 @@ interface Props { }; /** Predicate to indicate if the update requires a page reload */ pageReloadChecker?: ( - previsous: UserProfileData | null | undefined, + previous: UserProfileData | null | undefined, next: UserProfileData ) => boolean; } diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_modal.test.tsx b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_modal.test.tsx new file mode 100644 index 0000000000000..56a90203fae13 --- /dev/null +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_modal.test.tsx @@ -0,0 +1,203 @@ +/* + * 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 { fireEvent, render } from '@testing-library/react'; +import React from 'react'; + +import { coreMock } from '@kbn/core/public/mocks'; +import { useUpdateUserProfile } from '@kbn/user-profile-components'; +import { AppearanceModal } from './appearance_modal'; + +jest.mock('@kbn/user-profile-components', () => { + const original = jest.requireActual('@kbn/user-profile-components'); + return { + ...original, + useUpdateUserProfile: jest.fn().mockImplementation(() => ({ + userProfileData: { + userSettings: { + darkMode: 'light', + contrastMode: 'standard', + }, + }, + isLoading: false, + update: jest.fn(), + userProfileLoaded: true, + })), + }; +}); + +jest.mock('./values_group', () => ({ + ValuesGroup: jest.fn().mockImplementation(({ title, selectedValue, onChange }) => ( +
+

{title}

+
+ + +
+
Selected: {selectedValue}
+
+ )), +})); + +describe('AppearanceModal', () => { + const closeModal = jest.fn(); + const uiSettingsClient = coreMock.createStart().uiSettings; + let updateMock: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + updateMock = jest.fn(); + (useUpdateUserProfile as jest.Mock).mockImplementation(() => ({ + userProfileData: { + userSettings: { + darkMode: 'light', + contrastMode: 'standard', + }, + }, + isLoading: false, + update: updateMock, + userProfileLoaded: true, + })); + }); + + it('renders both color mode and contrast mode components', () => { + const { getByTestId } = render( + + ); + + // Check that both the color mode and contrast mode components are rendered + expect(getByTestId('values-group-Color mode')).toBeInTheDocument(); + expect(getByTestId('values-group-Interface contrast')).toBeInTheDocument(); + }); + + it('updates color mode when user changes selection', () => { + const { getByTestId } = render( + + ); + + // Click the dark option in the color mode group + fireEvent.click(getByTestId('option-dark-Color mode')); + + // Check that the onChange handler was called with colorMode: dark + expect(getByTestId('values-group-Color mode')).toHaveTextContent('Selected: dark'); + }); + + it('updates contrast mode when user changes selection', () => { + const { getByTestId } = render( + + ); + + // Click the high contrast option + fireEvent.click(getByTestId('option-high-Interface contrast')); + + // Check that the contrast mode was updated + expect(getByTestId('values-group-Interface contrast')).toHaveTextContent('Selected: high'); + }); + + it('saves both color mode and contrast mode when saving changes', async () => { + const { getByText, getByTestId } = render( + + ); + + // Change color mode to dark + fireEvent.click(getByTestId('option-dark-Color mode')); + + // Change contrast mode to high + fireEvent.click(getByTestId('option-high-Interface contrast')); + + // Click save button + fireEvent.click(getByText('Save changes')); + + // Check that the update function was called with both settings + expect(updateMock).toHaveBeenCalledWith({ + userSettings: { + darkMode: 'dark', + contrastMode: 'high', + }, + }); + + // Modal should be closed + expect(closeModal).toHaveBeenCalled(); + }); + + it('discards changes when clicking discard button', () => { + const { getByText, getByTestId } = render( + + ); + + // Change color mode to dark + fireEvent.click(getByTestId('option-dark-Color mode')); + + // Change contrast mode to high + fireEvent.click(getByTestId('option-high-Interface contrast')); + + // Click discard button + fireEvent.click(getByText('Discard')); + + // Check that the update function was not called + expect(updateMock).not.toHaveBeenCalled(); + + // Modal should be closed + expect(closeModal).toHaveBeenCalled(); + }); + + it('does not update settings if no changes were made', () => { + const { getByText } = render( + + ); + + // Click save button without making changes + fireEvent.click(getByText('Save changes')); + + // Update should not be called since no changes were made + expect(updateMock).not.toHaveBeenCalled(); + + // Modal should still be closed + expect(closeModal).toHaveBeenCalled(); + }); + + it('shows contrast options even in serverless mode', () => { + const { getByTestId } = render( + + ); + + // Contrast mode should still be present in serverless mode + expect(getByTestId('values-group-Interface contrast')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_modal.tsx b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_modal.tsx index b29f15a26c8c3..55a734f535cba 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_modal.tsx +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_modal.tsx @@ -20,7 +20,10 @@ import { 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 { + DarkModeValue as ColorMode, + ContrastModeValue as ContrastMode, +} from '@kbn/user-profile-components'; import { type Value, ValuesGroup } from './values_group'; import { useAppearance } from './use_appearance_hook'; @@ -73,12 +76,125 @@ interface Props { isServerless: boolean; } +const ColorModeGroup: FC<{ + isServerless: boolean; + colorMode: ColorMode; + onChange: ({ colorMode }: { colorMode: ColorMode }, updateUserProfile: boolean) => void; +}> = ({ isServerless, colorMode, onChange }) => { + return ( + <> + + 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.', + } + )} +

+
+ + + )} + + ); +}; + +const ContrastModeGroup: FC<{ + contrastMode: ContrastMode; + onChange: ({ contrastMode }: { contrastMode: ContrastMode }, updateUserProfile: boolean) => void; +}> = ({ contrastMode, onChange }) => { + return ( + + title={i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalContrastModeTitle', { + defaultMessage: 'Interface contrast', + })} + values={[ + { + id: 'system', + label: systemLabel, + icon: 'desktop', + }, + { + id: 'standard', + label: i18n.translate( + 'xpack.cloudLinks.userMenuLinks.appearanceModalContrastModeStandard', + { + defaultMessage: 'Normal', + } + ), + icon: 'contrast', + }, + { + id: 'high', + label: i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceModalContrastModeHigh', { + defaultMessage: 'High', + }), + icon: 'contrastHigh', + }, + ]} + selectedValue={contrastMode} + onChange={(id) => { + onChange({ contrastMode: id }, false); + }} + ariaLabel={i18n.translate( + 'xpack.cloudLinks.userMenuLinks.appearanceModalContrastModeAriaLabel', + { + defaultMessage: 'Appearance contrast mode', + } + )} + /> + ); +}; + export const AppearanceModal: FC = ({ closeModal, uiSettingsClient, isServerless }) => { const modalTitleId = useGeneratedHtmlId(); - const { onChange, colorMode, isLoading, initialColorModeValue } = useAppearance({ + const { + colorMode, + initialColorModeValue, + contrastMode, + initialContrastModeValue, + isLoading, + onChange, + } = useAppearance({ uiSettingsClient, defaultColorMode: isServerless ? 'system' : 'space_default', + defaultContrastMode: 'standard', }); return ( @@ -103,53 +219,11 @@ export const AppearanceModal: FC = ({ closeModal, uiSettingsClient, isSer - - 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.', - } - )} -

-
- - - )} + + +
@@ -162,8 +236,8 @@ export const AppearanceModal: FC = ({ closeModal, uiSettingsClient, isSer { - if (colorMode !== initialColorModeValue) { - await onChange({ colorMode }, true); + if (colorMode !== initialColorModeValue || contrastMode !== initialContrastModeValue) { + onChange({ colorMode, contrastMode }, true); } closeModal(); }} diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx index 5fdd762184a6b..9765c930b2fe3 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx @@ -5,21 +5,85 @@ * 2.0. */ +import { fireEvent, render } from '@testing-library/react'; 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 { useUpdateUserProfile } from '@kbn/user-profile-components'; import { AppearanceSelector } from './appearance_selector'; +jest.mock('./appearance_modal', () => ({ + AppearanceModal: jest.fn().mockImplementation(({ closeModal, uiSettingsClient }) => { + return ( +
+
+
+ + +
+ ); + }), +})); + +jest.mock('@kbn/user-profile-components', () => { + const original = jest.requireActual('@kbn/user-profile-components'); + return { + ...original, + useUpdateUserProfile: jest.fn().mockImplementation(() => ({ + userProfileData: { + userSettings: { + darkMode: 'light', + contrastMode: 'standard', + }, + }, + isLoading: false, + update: jest.fn(), + userProfileLoaded: true, + })), + }; +}); + describe('AppearanceSelector', () => { const closePopover = jest.fn(); + let core: ReturnType; + let security: ReturnType; + + beforeEach(() => { + core = coreMock.createStart(); + security = securityMock.createStart(); - it('renders correctly and toggles dark mode', () => { - const security = securityMock.createStart(); - const core = coreMock.createStart(); + (useUpdateUserProfile as jest.Mock).mockImplementation(() => ({ + userProfileData: { + userSettings: { + darkMode: 'light', + contrastMode: 'standard', + }, + }, + isLoading: false, + update: jest.fn(), + userProfileLoaded: true, + })); + // Mock the openModal to return a ref with proper close method + core.overlays.openModal.mockImplementation(() => ({ + close: jest.fn(), + onClose: Promise.resolve(), + })); + }); + + it('renders correctly and opens the appearance modal', () => { const { getByTestId } = render( { fireEvent.click(appearanceSelector); expect(core.overlays.openModal).toHaveBeenCalled(); + expect(closePopover).toHaveBeenCalled(); + }); + + it('does not render when appearance is not visible', () => { + (useUpdateUserProfile as jest.Mock).mockImplementation(() => ({ + userProfileData: null, + isLoading: false, + update: jest.fn(), + userProfileLoaded: true, + })); + + const { queryByTestId } = render( + + ); + + expect(queryByTestId('appearanceSelector')).toBeNull(); }); }); diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx index 60eb3f0114443..3f823f6c46ee6 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx @@ -41,6 +41,7 @@ function AppearanceSelectorUI({ security, core, closePopover, isServerless }: Pr const { isVisible } = useAppearance({ uiSettingsClient: core.uiSettings, defaultColorMode: 'space_default', + defaultContrastMode: 'standard', }); const modalRef = useRef(null); diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/use_appearance_hook.ts b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/use_appearance_hook.ts index 797a8dd39e3d0..8d7296709fabb 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/use_appearance_hook.ts +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/use_appearance_hook.ts @@ -11,14 +11,40 @@ import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; import { useUpdateUserProfile, type DarkModeValue as ColorMode, + type ContrastModeValue as ContrastMode, } from '@kbn/user-profile-components'; interface Deps { uiSettingsClient: IUiSettingsClient; defaultColorMode: ColorMode; + defaultContrastMode: ContrastMode; } -export const useAppearance = ({ uiSettingsClient, defaultColorMode }: Deps) => { +interface AppearanceAPI { + setColorMode: (colorMode: ColorMode, updateUserProfile: boolean) => void; + colorMode: ColorMode; + initialColorModeValue: ColorMode; + setContrastMode: (contrastMode: ContrastMode, updateUserProfile: boolean) => void; + contrastMode: ContrastMode; + initialContrastModeValue: ContrastMode; + isVisible: boolean; + isLoading: boolean; + onChange: ( + opts: { colorMode?: ColorMode; contrastMode?: ContrastMode }, + updateUserProfile: boolean + ) => void; +} + +interface ChangeOpts { + colorMode?: ColorMode; + contrastMode?: ContrastMode; +} + +export const useAppearance = ({ + uiSettingsClient, + defaultColorMode, + defaultContrastMode, +}: Deps): AppearanceAPI => { // 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'); @@ -36,21 +62,35 @@ export const useAppearance = ({ uiSettingsClient, defaultColorMode }: Deps) => { ), }, pageReloadChecker: (prev, next) => { - return prev?.userSettings?.darkMode !== next.userSettings?.darkMode; + const hasChangedDarkMode = prev?.userSettings?.darkMode !== next.userSettings?.darkMode; + const hasChangedContrastMode = + prev?.userSettings?.contrastMode !== next.userSettings?.contrastMode; + return hasChangedDarkMode || hasChangedContrastMode; }, }); - const { userSettings: { darkMode: colorModeUserProfile = defaultColorMode } = {} } = - userProfileData ?? { - userSettings: {}, - }; + const { + userSettings: { + darkMode: colorModeUserProfile = defaultColorMode, + contrastMode: contrastModeUserProfile = defaultContrastMode, + } = {}, + } = userProfileData ?? { + userSettings: {}, + }; const [colorMode, setColorMode] = useState(colorModeUserProfile); const [initialColorModeValue, setInitialColorModeValue] = useState(colorModeUserProfile); + const [contrastMode, setContrastMode] = useState(contrastModeUserProfile); + const [initialContrastModeValue, setInitialContrastModeValue] = + useState(contrastModeUserProfile); + const onChange = useCallback( - ({ colorMode: updatedColorMode }: { colorMode?: ColorMode }, persist: boolean) => { + ( + { colorMode: updatedColorMode, contrastMode: updatedContrastMode }: ChangeOpts, + persist: boolean + ) => { if (isLoading) { return; } @@ -59,8 +99,9 @@ export const useAppearance = ({ uiSettingsClient, defaultColorMode }: Deps) => { if (updatedColorMode) { setColorMode(updatedColorMode); } - - // TODO: here we will update the contrast when available + if (updatedContrastMode) { + setContrastMode(updatedContrastMode); + } if (!persist) { return; @@ -69,6 +110,7 @@ export const useAppearance = ({ uiSettingsClient, defaultColorMode }: Deps) => { return update({ userSettings: { darkMode: updatedColorMode, + contrastMode: updatedContrastMode, }, }); }, @@ -77,23 +119,32 @@ export const useAppearance = ({ uiSettingsClient, defaultColorMode }: Deps) => { useEffect(() => { setColorMode(colorModeUserProfile); - }, [colorModeUserProfile]); + setContrastMode(contrastModeUserProfile); + }, [colorModeUserProfile, contrastModeUserProfile]); useEffect(() => { if (userProfileLoaded) { - const storedValue = userProfileData?.userSettings?.darkMode; - if (storedValue) { - setInitialColorModeValue(storedValue); + const { darkMode: storedValueDarkMode, contrastMode: storedValueContrastMode } = + userProfileData?.userSettings ?? {}; + + if (storedValueDarkMode) { + setInitialColorModeValue(storedValueDarkMode); + } + if (storedValueContrastMode) { + setInitialContrastModeValue(storedValueContrastMode); } } }, [userProfileData, userProfileLoaded]); return { - isVisible: valueSetInKibanaConfig ? false : Boolean(userProfileData), setColorMode, colorMode, - onChange, - isLoading, initialColorModeValue, + setContrastMode, + contrastMode, + initialContrastModeValue, + isVisible: valueSetInKibanaConfig ? false : Boolean(userProfileData), + isLoading, + onChange, }; }; diff --git a/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.ts b/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.ts index a400d0db88b89..e2d880c3baf60 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.ts @@ -15,7 +15,7 @@ import { getPrintableSessionId } from '../../session_management'; import { createLicensedRouteHandler } from '../licensed_route_handler'; /** User profile data keys that are allowed to be updated by Cloud users */ -const ALLOWED_KEYS_UPDATE_CLOUD = ['userSettings.darkMode']; +const ALLOWED_KEYS_UPDATE_CLOUD = ['userSettings.darkMode', 'userSettings.contrastMode']; export function defineUpdateUserProfileDataRoute({ router,