Skip to content

Commit

Permalink
feat: Added UX to link log items and WC activities (microsoft#8006)
Browse files Browse the repository at this point in the history
* Added UX to link log items and WC activities

* Updated selected visual state of WC activities

* Added some tests

* Factored out WebChat hooks into separate headless component

Co-authored-by: Andy Brown <[email protected]>
  • Loading branch information
tonyanziano and a-b-r-o-w-n authored Jun 7, 2021
1 parent 87d1d33 commit c859da0
Show file tree
Hide file tree
Showing 8 changed files with 229 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/** @jsx jsx */
import { css, jsx } from '@emotion/core';
import React, { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { NeutralColors } from '@uifabric/fluent-theme';

import { rootBotProjectIdSelector, webChatInspectionDataState } from '../../recoilModel';

// apply selected state to the Web Chat message bubbles
const webchatSelectedActivity = css`
div > .webchat__bubble:not(.webchat__bubble--from-user) > .webchat__bubble__content {
border-color: ${NeutralColors.black};
border-width: 2px;
}
div > .webchat__bubble.webchat__bubble--from-user > .webchat__bubble__content {
border-color: ${NeutralColors.black};
border-width: 2px;
}
`;

type ActivityHighlightWrapperProps = { activityId: string };
export const ActivityHighlightWrapper: React.FC<ActivityHighlightWrapperProps> = (props) => {
const { activityId, children } = props;
const currentProjectId = useRecoilValue(rootBotProjectIdSelector);
const webChatInspectionData = useRecoilValue(webChatInspectionDataState(currentProjectId ?? ''));

const isSelected = useMemo(() => {
return (
webChatInspectionData?.item.trafficType === 'activity' &&
webChatInspectionData.item.activity.type === 'message' &&
webChatInspectionData.item.activity.id === activityId
);
}, [activityId, webChatInspectionData]);

return (
<div
css={isSelected ? webchatSelectedActivity : {}}
data-testid={isSelected ? 'composer-wc-activity-selected' : 'composer-wc-activity'}
>
{children}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { CommunicationColors, NeutralColors } from '@uifabric/fluent-theme';
import { ConversationService } from './utils/conversationService';
import webChatStyleOptions from './utils/webChatTheme';
import { ChatData, ActivityType } from './types';
import { ActivityHighlightWrapper } from './ActivityHighlightWrapper';
import { WebChatHooksContainer } from './hooks/WebChatHooksContainer';

const { BasicWebChat, Composer } = Components;

export type WebChatComposerProps = {
Expand Down Expand Up @@ -67,7 +70,11 @@ const createActivityMiddleware = () => (next: unknown) => (...setupArgs) => (...
if (typeof next === 'function') {
const middlewareResult = next(...setupArgs);
if (middlewareResult) {
return middlewareResult(...renderArgs);
return (
<ActivityHighlightWrapper activityId={card.activity.id}>
{middlewareResult(...renderArgs)}
</ActivityHighlightWrapper>
);
}
return false;
}
Expand Down Expand Up @@ -119,6 +126,7 @@ export const WebChatComposer = React.memo((props: WebChatComposerProps) => {
userID={chatData?.user.id}
>
<BasicWebChat />
<WebChatHooksContainer />
</Composer>
);
}, areEqual);
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import React from 'react';

import { ActivityHighlightWrapper } from '../ActivityHighlightWrapper';
import { renderWithRecoil } from '../../../../__tests__/testUtils';
import {
webChatInspectionDataState,
currentProjectIdState,
botProjectIdsState,
projectMetaDataState,
botProjectFileState,
} from '../../../recoilModel';

describe('<ActivityHighlightWrapper />', () => {
const projectId = '1234.5678';
const selectedActivityId = 'activity1';

it('should apply a selected class if the item is selected', () => {
const { getByTestId } = renderWithRecoil(
<ActivityHighlightWrapper activityId={selectedActivityId} />,
({ set }) => {
set(currentProjectIdState, projectId);
set(botProjectIdsState, [projectId]);
set(projectMetaDataState(projectId), { isRootBot: true, isRemote: false });
set(botProjectFileState(projectId), { foo: 'bar' } as any);
set(webChatInspectionDataState(projectId), {
item: {
activity: {
id: selectedActivityId,
type: 'message',
} as any,
id: 'outerId1',
timestamp: Date.now(),
trafficType: 'activity',
},
});
}
);
getByTestId('composer-wc-activity-selected');
});

it('should not apply a selected class if the item is not selected', () => {
const { getByTestId } = renderWithRecoil(
<ActivityHighlightWrapper activityId={'someOtherActivity'} />,
({ set }) => {
set(currentProjectIdState, projectId);
set(botProjectIdsState, [projectId]);
set(projectMetaDataState(projectId), { isRootBot: true, isRemote: false });
set(botProjectFileState(projectId), { foo: 'bar' } as any);
set(webChatInspectionDataState(projectId), {
item: {
activity: {
id: selectedActivityId,
type: 'message',
} as any,
id: 'outerId1',
timestamp: Date.now(),
trafficType: 'activity',
},
});
}
);
getByTestId('composer-wc-activity');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import React from 'react';

import { useActivityInspectionListener } from './useActivityInspectionListener';
import { useTranscriptFocusListener } from './useTranscriptFocusListener';

export const WebChatHooksContainer: React.FC<{}> = () => {
useActivityInspectionListener();
useTranscriptFocusListener();

return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { useEffect } from 'react';
import { hooks } from 'botframework-webchat';
import { useRecoilValue } from 'recoil';

import { rootBotProjectIdSelector, webChatInspectionDataState } from '../../../recoilModel';

const { useScrollTo } = hooks;

export const useActivityInspectionListener = () => {
const currentProjectId = useRecoilValue(rootBotProjectIdSelector);
const webChatInspectionData = useRecoilValue(webChatInspectionDataState(currentProjectId ?? ''));
const scrollToActivity = useScrollTo();

// listen for when an activity item is inspected in the log, and scroll to the activity
useEffect(() => {
if (
webChatInspectionData?.item.trafficType === 'activity' &&
webChatInspectionData.item.activity.type === 'message'
) {
scrollToActivity({ activityID: webChatInspectionData.item.activity.id }, { behavior: 'smooth' });
}
}, [webChatInspectionData]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { useCallback } from 'react';
import { hooks } from 'botframework-webchat';
import { Activity } from 'botframework-schema';
import { useRecoilValue, useSetRecoilState } from 'recoil';

import {
debugPanelActiveTabState,
debugPanelExpansionState,
dispatcherState,
rootBotProjectIdSelector,
webChatTrafficState,
} from '../../../recoilModel';
import { WebChatInspectorTabKey } from '../../../pages/design/DebugPanel/TabExtensions/types';

const { useObserveTranscriptFocus } = hooks;

export const useTranscriptFocusListener = () => {
const currentProjectId = useRecoilValue(rootBotProjectIdSelector);
const rawWebChatTraffic = useRecoilValue(webChatTrafficState(currentProjectId ?? ''));
const setDebugPanelActiveTab = useSetRecoilState(debugPanelActiveTabState);
const setDebugPanelExpansion = useSetRecoilState(debugPanelExpansionState);
const { setWebChatInspectionData } = useRecoilValue(dispatcherState);

// listen for when an activity is focused and inspect the corresponding log item
const onActivityFocused = useCallback(
({ activity }: { activity: Activity }) => {
const trafficItem = rawWebChatTraffic.find((t) => {
if (t.trafficType === 'activity') {
return t.activity.id === activity?.id;
}
});
if (trafficItem && currentProjectId) {
setDebugPanelActiveTab(WebChatInspectorTabKey);
setDebugPanelExpansion(true);
setWebChatInspectionData(currentProjectId, { item: trafficItem });
}
},
[currentProjectId, rawWebChatTraffic, setDebugPanelActiveTab, setDebugPanelExpansion]
);

useObserveTranscriptFocus(onActivityFocused, [onActivityFocused]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
/** @jsx jsx */
import { css, jsx } from '@emotion/core';
import { ConversationActivityTrafficItem } from '@botframework-composer/types';
import { useCallback } from 'react';
import { useCallback, useRef, useEffect } from 'react';
import { useRecoilValue } from 'recoil';

import { WebChatInspectionData } from '../../../../../recoilModel/types';
Expand All @@ -25,21 +25,38 @@ const renderActivityArrow = (activity) => {
};

type WebChatActivityLogItemProps = {
logsContainerRef: React.MutableRefObject<HTMLDivElement | null>;
item: ConversationActivityTrafficItem;
isSelected?: boolean;
onClickTraffic: (data: WebChatInspectionData) => void;
};

export const WebChatActivityLogItem: React.FC<WebChatActivityLogItemProps> = (props) => {
const { item, isSelected = false, onClickTraffic } = props;
const { item, isSelected = false, logsContainerRef, onClickTraffic } = props;
const { appLocale } = useRecoilValue(userSettingsState);
const ref = useRef<HTMLSpanElement | null>(null);

const onClick = useCallback(() => {
onClickTraffic({ item });
}, [item, onClickTraffic]);

useEffect(() => {
// scroll the activity item into view when it is selected
if (isSelected && ref.current && logsContainerRef.current) {
const { bottom: containerBottom, top: containerTop } = logsContainerRef.current.getBoundingClientRect();
const { bottom: itemBottom, top: itemTop } = ref.current.getBoundingClientRect();
if (itemBottom > containerBottom || itemTop < containerTop) {
setTimeout(() => {
// Web Chat will also be trying to scroll the activity into view
// which the browser does not like. Wait for a bit until that scroll is done.
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 500);
}
}
}, [isSelected, logsContainerRef]);

return (
<span css={[clickable, hoverItem(isSelected), logItem]} onClick={onClick}>
<span ref={ref} css={[clickable, hoverItem(isSelected), logItem]} onClick={onClick}>
{renderTimeStamp(item.timestamp, appLocale)}
{renderActivityArrow(item.activity)}
<span css={clickableSegment}>{item.activity.type || 'unknown'}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export const WebChatLogContent: React.FC<DebugPanelTabHeaderProps> = ({ isActive
key={`webchat-activity-item-${index}`}
isSelected={itemIsSelected(item, inspectionData)}
item={item}
logsContainerRef={webChatContainerRef}
onClickTraffic={onClickTraffic}
/>
);
Expand Down

0 comments on commit c859da0

Please sign in to comment.