Skip to content

Commit

Permalink
Merge branch '2.x' into backport-note
Browse files Browse the repository at this point in the history
Signed-off-by: tygao <tygao@amazon.com>
  • Loading branch information
raintygao authored Mar 19, 2024
2 parents 4aa3906 + 77a7b65 commit e1fbb49
Show file tree
Hide file tree
Showing 13 changed files with 398 additions and 135 deletions.
5 changes: 0 additions & 5 deletions public/chat_flyout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ describe('<ChatFlyout />', () => {
overrideComponent={null}
flyoutProps={{}}
flyoutFullScreen={false}
toggleFlyoutFullScreen={jest.fn()}
/>
);
expect(screen.getByLabelText('chat panel').classList).not.toContain('llm-chat-hidden');
Expand All @@ -74,7 +73,6 @@ describe('<ChatFlyout />', () => {
overrideComponent={null}
flyoutProps={{}}
flyoutFullScreen={false}
toggleFlyoutFullScreen={jest.fn()}
/>
);
expect(screen.getByLabelText('chat panel').classList).toContain('llm-chat-hidden');
Expand All @@ -94,7 +92,6 @@ describe('<ChatFlyout />', () => {
overrideComponent={null}
flyoutProps={{}}
flyoutFullScreen={false}
toggleFlyoutFullScreen={jest.fn()}
/>
);

Expand All @@ -117,7 +114,6 @@ describe('<ChatFlyout />', () => {
overrideComponent={null}
flyoutProps={{}}
flyoutFullScreen={false}
toggleFlyoutFullScreen={jest.fn()}
/>
);

Expand All @@ -139,7 +135,6 @@ describe('<ChatFlyout />', () => {
overrideComponent={null}
flyoutProps={{}}
flyoutFullScreen={true} // fullscreen
toggleFlyoutFullScreen={jest.fn()}
/>
);

Expand Down
25 changes: 6 additions & 19 deletions public/chat_flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { EuiFlyout, EuiFlyoutHeader, EuiResizableContainer } from '@elastic/eui';
import { EuiResizableContainer } from '@elastic/eui';
import cs from 'classnames';
import React, { useRef } from 'react';
import { useChatContext } from './contexts/chat_context';
Expand All @@ -16,9 +16,7 @@ import { TAB_ID } from './utils/constants';
interface ChatFlyoutProps {
flyoutVisible: boolean;
overrideComponent: React.ReactNode | null;
flyoutProps: Partial<React.ComponentProps<typeof EuiFlyout>>;
flyoutFullScreen: boolean;
toggleFlyoutFullScreen: () => void;
}

export const ChatFlyout = (props: ChatFlyoutProps) => {
Expand Down Expand Up @@ -81,26 +79,15 @@ export const ChatFlyout = (props: ChatFlyoutProps) => {
const rightPanelSize = getRightPanelSize();

return (
<EuiFlyout
<div
className={cs('llm-chat-flyout', {
'llm-chat-fullscreen': props.flyoutFullScreen,
'llm-chat-hidden': !props.flyoutVisible,
})}
type="push"
paddingSize="none"
size="460px"
ownFocus={false}
hideCloseButton
onClose={() => chatContext.setFlyoutVisible(false)}
{...props.flyoutProps}
>
<>
<EuiFlyoutHeader className={cs('llm-chat-flyout-header')}>
<ChatWindowHeader
flyoutFullScreen={props.flyoutFullScreen}
toggleFlyoutFullScreen={props.toggleFlyoutFullScreen}
/>
</EuiFlyoutHeader>
<div className={cs('llm-chat-flyout-header')}>
<ChatWindowHeader />
</div>

{props.overrideComponent}
<EuiResizableContainer style={{ height: '100%', overflow: 'hidden' }}>
Expand Down Expand Up @@ -146,6 +133,6 @@ export const ChatFlyout = (props: ChatFlyoutProps) => {
)}
</EuiResizableContainer>
</>
</EuiFlyout>
</div>
);
};
72 changes: 42 additions & 30 deletions public/chat_header_button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { HeaderChatButton } from './chat_header_button';
import { applicationServiceMock } from '../../../src/core/public/mocks';
import { AssistantActions } from './types';
import { BehaviorSubject } from 'rxjs';
import * as coreContextExports from './contexts/core_context';
import { MountWrapper } from '../../../src/core/public/utils';

let mockSend: jest.Mock;
let mockLoadChat: jest.Mock;
Expand All @@ -32,18 +34,7 @@ jest.mock('./hooks/use_chat_actions', () => {

jest.mock('./chat_flyout', () => {
return {
ChatFlyout: ({
toggleFlyoutFullScreen,
flyoutFullScreen,
}: {
toggleFlyoutFullScreen: () => void;
flyoutFullScreen: boolean;
}) => (
<div aria-label="chat flyout mock">
<button onClick={toggleFlyoutFullScreen}>toggle chat flyout fullscreen</button>
<p>{flyoutFullScreen ? 'fullscreen mode' : 'dock-right mode'}</p>
</div>
),
ChatFlyout: () => <div aria-label="chat flyout mock" />,
};
});

Expand All @@ -57,12 +48,43 @@ jest.mock('./services', () => {
};
});

// mock sidecar open,hide and show
jest.spyOn(coreContextExports, 'useCore').mockReturnValue({
overlays: {
// @ts-ignore
sidecar: () => {
const attachElement = document.createElement('div');
attachElement.id = 'sidecar-mock-div';
return {
open: (mountPoint) => {
document.body.appendChild(attachElement);
render(<MountWrapper mount={mountPoint} />, {
container: attachElement,
});
},
hide: () => {
const element = document.getElementById('sidecar-mock-div');
if (element) {
element.style.display = 'none';
}
},
show: () => {
const element = document.getElementById('sidecar-mock-div');
if (element) {
element.style.display = 'block';
}
},
};
},
},
});

describe('<HeaderChatButton />', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('should open chat flyout and send the initial message', () => {
it('should open chat flyout, send the initial message and hide and show flyout', () => {
const applicationStart = {
...applicationServiceMock.createStartContract(),
currentAppId$: new BehaviorSubject(''),
Expand Down Expand Up @@ -106,24 +128,14 @@ describe('<HeaderChatButton />', () => {
// the input value is cleared after pressing enter
expect(screen.getByLabelText('chat input')).toHaveValue('');
expect(screen.getByLabelText('chat input')).not.toHaveFocus();
});

it('should toggle chat flyout size', () => {
render(
<HeaderChatButton
application={applicationServiceMock.createStartContract()}
userHasAccess={true}
messageRenderers={{}}
actionExecutors={{}}
assistantActions={{} as AssistantActions}
currentAccount={{ username: 'test_user', tenant: 'test_tenant' }}
/>
);
fireEvent.click(screen.getByLabelText('toggle chat flyout icon'));
expect(screen.queryByText('dock-right mode')).toBeInTheDocument();

fireEvent.click(screen.getByText('toggle chat flyout fullscreen'));
expect(screen.queryByText('fullscreen mode')).toBeInTheDocument();
// sidecar show
const toggleButton = screen.getByLabelText('toggle chat flyout icon');
fireEvent.click(toggleButton);
expect(screen.queryByLabelText('chat flyout mock')).not.toBeVisible();
// sidecar hide
fireEvent.click(toggleButton);
expect(screen.queryByLabelText('chat flyout mock')).toBeVisible();
});

it('should focus in chat input when click and press Escape should blur', () => {
Expand Down
76 changes: 53 additions & 23 deletions public/chat_header_button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { EuiBadge, EuiFieldText, EuiIcon } from '@elastic/eui';
import classNames from 'classnames';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffectOnce } from 'react-use';
import { ApplicationStart } from '../../../src/core/public';
import { ApplicationStart, SIDECAR_DOCKED_MODE } from '../../../src/core/public';
// TODO: Replace with getChrome().logos.Chat.url
import chatIcon from './assets/chat.svg';
import { getIncontextInsightRegistry } from './services';
Expand All @@ -17,7 +17,13 @@ import { SetContext } from './contexts/set_context';
import { ChatStateProvider } from './hooks';
import './index.scss';
import { ActionExecutor, AssistantActions, MessageRenderer, TabId, UserAccount } from './types';
import { TAB_ID } from './utils/constants';
import {
TAB_ID,
DEFAULT_SIDECAR_DOCKED_MODE,
DEFAULT_SIDECAR_LEFT_OR_RIGHT_SIZE,
} from './utils/constants';
import { useCore } from './contexts/core_context';
import { MountPointPortal } from '../../../src/plugins/opensearch_dashboards_react/public';

interface HeaderChatButtonProps {
application: ApplicationStart;
Expand All @@ -39,23 +45,20 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => {
const [selectedTabId, setSelectedTabId] = useState<TabId>(TAB_ID.CHAT);
const [preSelectedTabId, setPreSelectedTabId] = useState<TabId | undefined>(undefined);
const [interactionId, setInteractionId] = useState<string | undefined>(undefined);
const [chatSize, setChatSize] = useState<number | 'fullscreen' | 'dock-right'>('dock-right');
const [inputFocus, setInputFocus] = useState(false);
const flyoutFullScreen = chatSize === 'fullscreen';
const inputRef = useRef<HTMLInputElement>(null);
const registry = getIncontextInsightRegistry();

if (!flyoutLoaded && flyoutVisible) flyoutLoaded = true;
const [sidecarDockedMode, setSidecarDockedMode] = useState(DEFAULT_SIDECAR_DOCKED_MODE);
const core = useCore();
const flyoutFullScreen = sidecarDockedMode === SIDECAR_DOCKED_MODE.TAKEOVER;
const flyoutMountPoint = useRef(null);

useEffectOnce(() => {
const subscription = props.application.currentAppId$.subscribe((id) => setAppId(id));
return () => subscription.unsubscribe();
});

const toggleFlyoutFullScreen = useCallback(() => {
setChatSize(flyoutFullScreen ? 'dock-right' : 'fullscreen');
}, [flyoutFullScreen, setChatSize]);

const chatContextValue: IChatContext = useMemo(
() => ({
appId,
Expand All @@ -79,6 +82,8 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => {
setTitle,
interactionId,
setInteractionId,
sidecarDockedMode,
setSidecarDockedMode,
}),
[
appId,
Expand All @@ -95,6 +100,8 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => {
setTitle,
interactionId,
setInteractionId,
sidecarDockedMode,
setSidecarDockedMode,
]
);

Expand All @@ -119,12 +126,36 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => {
}
};

useEffect(() => {
if (!flyoutLoaded && flyoutVisible) {
const mountPoint = flyoutMountPoint.current;
if (mountPoint) {
core.overlays.sidecar().open(mountPoint, {
className: 'chatbot-sidecar',
config: {
dockedMode: SIDECAR_DOCKED_MODE.RIGHT,
paddingSize: DEFAULT_SIDECAR_LEFT_OR_RIGHT_SIZE,
},
});
flyoutLoaded = true;
}
} else if (flyoutLoaded && flyoutVisible) {
core.overlays.sidecar().show();
} else if (flyoutLoaded && !flyoutVisible) {
core.overlays.sidecar().hide();
}
}, [flyoutVisible, flyoutLoaded]);

const onKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
inputRef.current?.blur();
}
};

const setMountPoint = useCallback((mountPoint) => {
flyoutMountPoint.current = mountPoint;
}, []);

useEffect(() => {
if (!props.userHasAccess) {
return;
Expand Down Expand Up @@ -206,21 +237,20 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => {
}
disabled={!props.userHasAccess}
/>
<ChatContext.Provider value={chatContextValue}>
<ChatStateProvider>
<SetContext assistantActions={props.assistantActions} />
{/* Chatbot's DOM consists of two parts. One part is the headerButton inside the OSD, and the other part is the flyout/sidecar outside the OSD. This is to allow the context of the two parts to be shared. */}
<MountPointPortal setMountPoint={setMountPoint}>
<ChatFlyout
flyoutVisible={flyoutVisible}
overrideComponent={flyoutComponent}
flyoutFullScreen={flyoutFullScreen}
/>
</MountPointPortal>
</ChatStateProvider>
</ChatContext.Provider>
</div>
<ChatContext.Provider value={chatContextValue}>
<ChatStateProvider>
<SetContext assistantActions={props.assistantActions} />
{flyoutLoaded ? (
<ChatFlyout
flyoutVisible={flyoutVisible}
overrideComponent={flyoutComponent}
flyoutProps={flyoutFullScreen ? { size: '100%' } : {}}
flyoutFullScreen={flyoutFullScreen}
toggleFlyoutFullScreen={toggleFlyoutFullScreen}
/>
) : null}
</ChatStateProvider>
</ChatContext.Provider>
</>
);
};
37 changes: 37 additions & 0 deletions public/components/__snapshots__/sidecar_icon_menu.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<SidecarIconMenu/> spec renders the component 1`] = `
HTMLCollection [
<div>
<div
class="euiPopover euiPopover--anchorDownRight"
id="sidecarModeIcon"
>
<div
class="euiPopover__anchor"
>
<button
aria-label="setSidecarMode"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
type="button"
>
<svg
aria-hidden="true"
class="euiIcon euiIcon--medium euiIcon--inherit euiIcon-isLoading euiButtonIcon__icon"
focusable="false"
height="16"
role="img"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.277 10.088c.02.014.04.03.057.047.582.55 1.134.812 1.666.812.586 0 1.84-.293 3.713-.88L9 6.212V2H7v4.212l-1.723 3.876Zm-.438.987L3.539 14h8.922l-1.32-2.969C9.096 11.677 7.733 12 7 12c-.74 0-1.463-.315-2.161-.925ZM6 2H5V1h6v1h-1v4l3.375 7.594A1 1 0 0 1 12.461 15H3.54a1 1 0 0 1-.914-1.406L6 6V2Z"
/>
</svg>
</button>
</div>
</div>
</div>,
]
`;
Loading

0 comments on commit e1fbb49

Please sign in to comment.