diff --git a/public/chat_header_button.test.tsx b/public/chat_header_button.test.tsx index f6ebae40..a26f5fbd 100644 --- a/public/chat_header_button.test.tsx +++ b/public/chat_header_button.test.tsx @@ -74,6 +74,9 @@ jest.spyOn(coreContextExports, 'useCore').mockReturnValue({ element.style.display = 'block'; } }, + getSidecarConfig$: () => { + return new BehaviorSubject(undefined); + }, }; }, }, diff --git a/public/chat_header_button.tsx b/public/chat_header_button.tsx index 18e610bb..773363f4 100644 --- a/public/chat_header_button.tsx +++ b/public/chat_header_button.tsx @@ -25,6 +25,7 @@ import { } from './utils/constants'; import { useCore } from './contexts/core_context'; import { MountPointPortal } from '../../../src/plugins/opensearch_dashboards_react/public'; +import { usePatchFixedStyle } from './hooks/use_patch_fixed_style'; interface HeaderChatButtonProps { application: ApplicationStart; @@ -53,6 +54,7 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => { const core = useCore(); const flyoutFullScreen = sidecarDockedMode === SIDECAR_DOCKED_MODE.TAKEOVER; const flyoutMountPoint = useRef(null); + usePatchFixedStyle(); useEffectOnce(() => { const subscription = props.application.currentAppId$.subscribe((id) => setAppId(id)); diff --git a/public/hooks/__snapshots__/use_patch_fixed_style.test.ts.snap b/public/hooks/__snapshots__/use_patch_fixed_style.test.ts.snap new file mode 100644 index 00000000..63cbfe94 --- /dev/null +++ b/public/hooks/__snapshots__/use_patch_fixed_style.test.ts.snap @@ -0,0 +1,175 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`usePatchFixedStyle hook should not subscribe update after unmount 1`] = ` + + + + + + +`; + +exports[`usePatchFixedStyle hook should not subscribe update after unmount 2`] = ` + + + + + +`; + +exports[`usePatchFixedStyle hook should patch corresponding left style when sidecarConfig$ pipe 1`] = ` + + + + + + +`; + +exports[`usePatchFixedStyle hook should patch corresponding right style when sidecarConfig$ pipe 1`] = ` + + + + + + +`; + +exports[`usePatchFixedStyle hook should patch empty style when dockedMode of sidecarConfig$ is takeover 1`] = ` + + + + + + +`; + +exports[`usePatchFixedStyle hook should patch empty style when isHidden of sidecarConfig$ is true 1`] = ` + + + + + + +`; diff --git a/public/hooks/use_patch_fixed_style.test.ts b/public/hooks/use_patch_fixed_style.test.ts new file mode 100644 index 00000000..ff275505 --- /dev/null +++ b/public/hooks/use_patch_fixed_style.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { usePatchFixedStyle } from './use_patch_fixed_style'; +import * as coreHookExports from '../contexts/core_context'; +import { BehaviorSubject } from 'rxjs'; +import { ISidecarConfig, SIDECAR_DOCKED_MODE } from '../../../../src/core/public'; + +describe('usePatchFixedStyle hook', () => { + const sidecarConfig$ = new BehaviorSubject({ + dockedMode: SIDECAR_DOCKED_MODE.RIGHT, + paddingSize: 300, + }); + + beforeEach(() => { + jest.spyOn(coreHookExports, 'useCore').mockReturnValue({ + overlays: { + // @ts-ignore + sidecar: () => { + return { + getSidecarConfig$: () => { + return sidecarConfig$; + }, + }; + }, + }, + }); + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); + }); + + afterEach(() => { + jest.clearAllMocks(); + window.requestAnimationFrame.mockRestore(); + }); + + it('should patch corresponding left style when sidecarConfig$ pipe', async () => { + renderHook(() => usePatchFixedStyle()); + act(() => + sidecarConfig$.next({ + dockedMode: SIDECAR_DOCKED_MODE.LEFT, + paddingSize: 300, + }) + ); + + expect(document.head).toMatchSnapshot(); + }); + + it('should patch corresponding right style when sidecarConfig$ pipe', async () => { + renderHook(() => usePatchFixedStyle()); + act(() => + sidecarConfig$.next({ + dockedMode: SIDECAR_DOCKED_MODE.RIGHT, + paddingSize: 300, + }) + ); + + expect(document.head).toMatchSnapshot(); + }); + + it('should patch empty style when isHidden of sidecarConfig$ is true', async () => { + renderHook(() => usePatchFixedStyle()); + act(() => + sidecarConfig$.next({ + dockedMode: SIDECAR_DOCKED_MODE.LEFT, + paddingSize: 300, + isHidden: true, + }) + ); + + expect(document.head).toMatchSnapshot(); + }); + + it('should patch empty style when dockedMode of sidecarConfig$ is takeover', async () => { + renderHook(() => usePatchFixedStyle()); + act(() => + sidecarConfig$.next({ + dockedMode: SIDECAR_DOCKED_MODE.TAKEOVER, + paddingSize: 300, + }) + ); + + expect(document.head).toMatchSnapshot(); + }); + + it('should not subscribe update after unmount', async () => { + const { unmount } = renderHook(() => usePatchFixedStyle()); + act(() => + sidecarConfig$.next({ + dockedMode: SIDECAR_DOCKED_MODE.RIGHT, + paddingSize: 300, + }) + ); + expect(document.head).toMatchSnapshot(); + + unmount(); + + act(() => + sidecarConfig$.next({ + dockedMode: SIDECAR_DOCKED_MODE.LEFT, + paddingSize: 500, + }) + ); + expect(document.head).toMatchSnapshot(); + }); +}); diff --git a/public/hooks/use_patch_fixed_style.ts b/public/hooks/use_patch_fixed_style.ts new file mode 100644 index 00000000..ea9930e5 --- /dev/null +++ b/public/hooks/use_patch_fixed_style.ts @@ -0,0 +1,90 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { useEffect } from 'react'; + +import { useCore } from '../contexts/core_context'; +import { ISidecarConfig, SIDECAR_DOCKED_MODE } from '../../../../src/core/public'; + +// There are some UI components from library whose position is fixed and are not compatible with each other and also not compatible with sidecar container. +// There is currently no way to provide config for these components at runtime. +// This hook patches a style for all these already known components to make them compatible with sidecar. +// TODO: Use config provider from UI library to make this more reasonable. +export const usePatchFixedStyle = () => { + const core = useCore(); + const sidecarConfig$ = core.overlays.sidecar().getSidecarConfig$(); + + useEffect(() => { + const css = ''; + const style = document.createElement('style'); + const text = document.createTextNode(css); + document.head.appendChild(style); + style.appendChild(text); + + const subscription = sidecarConfig$.subscribe((config) => { + if (!config) return; + updateHeadStyle(config, text); + }); + return () => { + subscription.unsubscribe(); + document.head.removeChild(style); + style.removeChild(text); + }; + }, [sidecarConfig$]); +}; + +function updateHeadStyle(config: ISidecarConfig, text?: Text) { + let css: string; + if (!text) return; + + if ( + // When sidecar is opened and docked position is left or right, we should patch style. + config?.isHidden !== true && + config.paddingSize && + (config.dockedMode === SIDECAR_DOCKED_MODE.LEFT || + config.dockedMode === SIDECAR_DOCKED_MODE.RIGHT) + ) { + const { dockedMode, paddingSize } = config; + if (dockedMode === SIDECAR_DOCKED_MODE.RIGHT) { + // Current applied components include flyout and bottomBar. + // Although the class names of actual rendered component start with eui. We will also apply oui for fallback. + css = ` + .euiFlyout:not(.euiFlyout--left) { + right: ${paddingSize}px + } + .ouiFlyout:not(.ouiFlyout--left) { + right: ${paddingSize}px + } + .euiBottomBar{ + padding-right: ${paddingSize}px + } + .ouiBottomBar{ + padding-right: ${paddingSize}px + } + `; + } else if (dockedMode === SIDECAR_DOCKED_MODE.LEFT) { + css = ` + .euiFlyout--left { + left: ${paddingSize}px + } + .ouiFlyout--left { + left: ${paddingSize}px + } + .euiBottomBar{ + padding-left: ${paddingSize}px + } + .ouiBottomBar{ + padding-left: ${paddingSize}px + } + `; + } + } else { + // If sidecar closes/hides or docked mode changes to takeover, we will set css empty to remove patch style and update. + css = ''; + } + + requestAnimationFrame(() => { + text.textContent = css; + }); +}