diff --git a/Composer/packages/client/src/components/AppComponents/MainContainer.tsx b/Composer/packages/client/src/components/AppComponents/MainContainer.tsx
index f21dc0c4f2..70ebc52202 100644
--- a/Composer/packages/client/src/components/AppComponents/MainContainer.tsx
+++ b/Composer/packages/client/src/components/AppComponents/MainContainer.tsx
@@ -3,7 +3,7 @@
/** @jsx jsx */
import { jsx, css } from '@emotion/core';
-import { NotificationContainer } from '../NotificationContainer';
+import { NotificationContainer } from '../Notifications/NotificationContainer';
import { SideBar } from './SideBar';
import { RightPanel } from './RightPanel';
diff --git a/Composer/packages/client/src/components/Header.tsx b/Composer/packages/client/src/components/Header.tsx
index c4bc3796e9..882ecbca5f 100644
--- a/Composer/packages/client/src/components/Header.tsx
+++ b/Composer/packages/client/src/components/Header.tsx
@@ -20,19 +20,24 @@ import {
import composerIcon from '../images/composerIcon.svg';
import { AppUpdaterStatus } from '../constants';
+import { NotificationButton } from './Notifications/NotificationButton';
+
// -------------------- Styles -------------------- //
const headerContainer = css`
- position: relative;
background: ${SharedColors.cyanBlue10};
height: 50px;
display: flex;
- flex-direction: row;
- align-items: center;
+ justify-content: space-between;
+ padding-right: 20px;
+ margin: auto;
+`;
+
+const logo = css`
+ display: flex;
`;
const title = css`
- margin-left: 20px;
font-weight: ${FontWeights.semibold};
font-size: 16px;
color: #fff;
@@ -50,17 +55,20 @@ const divider = css`
margin: 0px 0px 0px 20px;
`;
-const updateAvailableIcon = {
+const controls = css`
+ display: flex;
+ align-items: center;
+`;
+
+const buttonStyles: IButtonStyles = {
icon: {
color: '#FFF',
fontSize: '20px',
},
root: {
- position: 'absolute',
height: '20px',
width: '20px',
- top: 'calc(50% - 10px)',
- right: '20px',
+ marginLeft: '16px',
},
rootHovered: {
backgroundColor: 'transparent',
@@ -74,6 +82,8 @@ const headerTextContainer = css`
display: flex;
flex-direction: row;
flex-wrap: wrap;
+ align-items: center;
+ margin-left: 20px;
`;
// -------------------- Header -------------------- //
@@ -94,29 +104,34 @@ export const Header = () => {
return (
-

-
-
{formatMessage('Bot Framework Composer')}
- {projectName && (
-
-
- {`${projectName} (${locale})`}
-
+
+

+
+
{formatMessage('Bot Framework Composer')}
+ {projectName && (
+
+
+ {`${projectName} (${locale})`}
+
+ )}
+
+
+
+ {showUpdateAvailableIcon && (
+
)}
+
- {showUpdateAvailableIcon && (
-
- )}
);
};
diff --git a/Composer/packages/client/src/components/Notifications/NotificationButton.tsx b/Composer/packages/client/src/components/Notifications/NotificationButton.tsx
new file mode 100644
index 0000000000..89c7f97cac
--- /dev/null
+++ b/Composer/packages/client/src/components/Notifications/NotificationButton.tsx
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/** @jsx jsx */
+import { css, jsx } from '@emotion/core';
+import React, { useState } from 'react';
+import { FontWeights } from '@uifabric/styling';
+import { IButtonStyles, IconButton } from 'office-ui-fabric-react/lib/Button';
+import { NeutralColors, SharedColors } from '@uifabric/fluent-theme';
+import { useRecoilValue } from 'recoil';
+import formatMessage from 'format-message';
+
+import { notificationsSelector } from '../../recoilModel/selectors/notificationsSelector';
+import { dispatcherState } from '../../recoilModel';
+
+import { NotificationPanel } from './NotificationPanel';
+
+const styles = {
+ container: css`
+ position: relative;
+ `,
+ count: (visible?: boolean) => css`
+ background-color: ${NeutralColors.white};
+ border: 2px solid ${SharedColors.cyanBlue10};
+ border-radius: 100%;
+ color: ${SharedColors.cyanBlue10};
+ font-size: 8px;
+ font-weight: ${FontWeights.bold};
+ height: 12px;
+ right: -4px;
+ position: absolute;
+ text-align: center;
+ visibility: ${visible ? 'visible' : 'hidden'};
+ width: 12px;
+ `,
+};
+
+type NotificationButtonProps = {
+ buttonStyles?: IButtonStyles;
+};
+
+const NotificationButton: React.FC
= ({ buttonStyles }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { deleteNotification, markNotificationAsRead } = useRecoilValue(dispatcherState);
+ const notifications = useRecoilValue(notificationsSelector);
+ const unreadNotification = notifications.filter(({ read }) => !read);
+
+ const toggleIsOpen = () => {
+ if (!isOpen) {
+ notifications.map(({ id }) => markNotificationAsRead(id));
+ }
+ setIsOpen(!isOpen);
+ };
+
+ return (
+
+
+
+
+ {unreadNotification.length}
+
+
+
+
+
+ );
+};
+
+export { NotificationButton };
diff --git a/Composer/packages/client/src/components/NotificationCard.tsx b/Composer/packages/client/src/components/Notifications/NotificationCard.tsx
similarity index 84%
rename from Composer/packages/client/src/components/NotificationCard.tsx
rename to Composer/packages/client/src/components/Notifications/NotificationCard.tsx
index d865f96301..b1d69c313c 100644
--- a/Composer/packages/client/src/components/NotificationCard.tsx
+++ b/Composer/packages/client/src/components/Notifications/NotificationCard.tsx
@@ -3,15 +3,15 @@
/** @jsx jsx */
import { jsx, css, keyframes } from '@emotion/core';
-import React from 'react';
+import React, { useState } from 'react';
import { IconButton, ActionButton } from 'office-ui-fabric-react/lib/Button';
-import { useEffect, useRef, useState } from 'react';
+import { useRef } from 'react';
import { FontSizes } from '@uifabric/fluent-theme';
import { Shimmer, ShimmerElementType } from 'office-ui-fabric-react/lib/Shimmer';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
import formatMessage from 'format-message';
-import Timer from '../utils/timer';
+import { useInterval } from '../../utils/hooks';
// -------------------- Styles -------------------- //
@@ -129,6 +129,8 @@ export type CardProps = {
description?: string;
retentionTime?: number;
link?: Link;
+ read?: boolean;
+ hidden?: boolean;
onRenderCardContent?: (props: CardProps) => JSX.Element;
};
@@ -136,6 +138,7 @@ export type NotificationProps = {
id: string;
cardProps: CardProps;
onDismiss: (id: string) => void;
+ onHide?: (id: string) => void;
};
const defaultCardContentRenderer = (props: CardProps) => {
@@ -161,50 +164,38 @@ const defaultCardContentRenderer = (props: CardProps) => {
};
export const NotificationCard = React.memo((props: NotificationProps) => {
- const { cardProps, id, onDismiss } = props;
- const [show, setShow] = useState(true);
+ const { cardProps, id, onDismiss, onHide } = props;
+ const { hidden, retentionTime = null } = cardProps;
+
+ const [delay, setDelay] = useState(retentionTime || null);
const containerRef = useRef(null);
const removeNotification = () => {
- setShow(false);
+ typeof onHide === 'function' && onHide(id);
+ setDelay(null);
};
- // notification will disappear in 5 secs
- const timer = useRef(cardProps.retentionTime ? new Timer(removeNotification, cardProps.retentionTime) : null).current;
-
- useEffect(() => {
- return () => {
- if (timer) {
- timer.clear();
- }
- };
- }, []);
+ useInterval(removeNotification, delay);
const handleMouseOver = () => {
- // if mouse over stop the time and record the remaining time
- if (timer) {
- timer.pause();
+ if (retentionTime) {
+ setDelay(null);
}
};
const handleMouseLeave = () => {
- if (timer) {
- timer.resume();
+ if (retentionTime) {
+ setDelay(retentionTime);
}
};
- const handleAnimationEnd = () => {
- if (!show) onDismiss(id);
- };
-
const renderCard = cardProps.onRenderCardContent || defaultCardContentRenderer;
return (
void 0}
onMouseLeave={handleMouseLeave}
onMouseOver={handleMouseOver}
@@ -213,7 +204,7 @@ export const NotificationCard = React.memo((props: NotificationProps) => {
ariaLabel={formatMessage('Close')}
css={cancelButton}
iconProps={{ iconName: 'Cancel', styles: { root: { fontSize: '12px' } } }}
- onClick={removeNotification}
+ onClick={() => onDismiss(id)}
/>
{renderCard(cardProps)}
diff --git a/Composer/packages/client/src/components/NotificationContainer.tsx b/Composer/packages/client/src/components/Notifications/NotificationContainer.tsx
similarity index 55%
rename from Composer/packages/client/src/components/NotificationContainer.tsx
rename to Composer/packages/client/src/components/Notifications/NotificationContainer.tsx
index afdc87b5a2..8166685d40 100644
--- a/Composer/packages/client/src/components/NotificationContainer.tsx
+++ b/Composer/packages/client/src/components/Notifications/NotificationContainer.tsx
@@ -4,10 +4,9 @@
/** @jsx jsx */
import { jsx, css } from '@emotion/core';
import { useRecoilValue } from 'recoil';
-import React from 'react';
-import { dispatcherState } from '../recoilModel';
-import { notificationsSelector } from '../recoilModel/selectors/notificationsSelector';
+import { dispatcherState } from '../../recoilModel';
+import { notificationsSelector } from '../../recoilModel/selectors/notificationsSelector';
import { NotificationCard } from './NotificationCard';
@@ -22,15 +21,23 @@ const container = css`
// -------------------- NotificationContainer -------------------- //
-export const NotificationContainer = React.memo(() => {
+export const NotificationContainer = () => {
const notifications = useRecoilValue(notificationsSelector);
- const { deleteNotification } = useRecoilValue(dispatcherState);
+ const { deleteNotification, hideNotification } = useRecoilValue(dispatcherState);
return (
{notifications.map((item) => {
- return ;
+ return (
+
+ );
})}
);
-});
+};
diff --git a/Composer/packages/client/src/components/Notifications/NotificationPanel.tsx b/Composer/packages/client/src/components/Notifications/NotificationPanel.tsx
new file mode 100644
index 0000000000..9db19059aa
--- /dev/null
+++ b/Composer/packages/client/src/components/Notifications/NotificationPanel.tsx
@@ -0,0 +1,102 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/** @jsx jsx */
+import { css, jsx } from '@emotion/core';
+import React, { useCallback } from 'react';
+import { ActionButton } from 'office-ui-fabric-react/lib/Button';
+import { IPanelProps, Panel, PanelType } from 'office-ui-fabric-react/lib/Panel';
+import { IRenderFunction } from 'office-ui-fabric-react/lib/Utilities';
+import { NeutralColors, SharedColors } from '@uifabric/fluent-theme';
+import formatMessage from 'format-message';
+
+import { Notification } from '../../recoilModel/types';
+
+import { NotificationCard } from './NotificationCard';
+
+const styles = {
+ container: css`
+ display: flex;
+ position: absolute;
+ right: 0px;
+ top: 18px;
+ `,
+ empty: css`
+ color: ${NeutralColors.gray130};
+ height: 100%;
+ margin: 24px 0;
+ text-align: center;
+ width: 100%;
+ `,
+ panelButtons: css`
+ display: flex;
+ justify-content: center;
+ `,
+};
+
+type NotificationPanelProps = {
+ isOpen: boolean;
+ notifications: Notification[];
+ onDismiss: (event?: React.SyntheticEvent) => void;
+ onDeleteNotification: (id: string) => void;
+};
+
+const NotificationPanel: React.FC = ({
+ isOpen,
+ notifications,
+ onDeleteNotification,
+ onDismiss,
+}) => {
+ const handleClearAll = useCallback(() => {
+ notifications.map(({ id }) => onDeleteNotification(id));
+ }, [onDeleteNotification, notifications]);
+
+ const onRenderNavigationContent: IRenderFunction = useCallback(
+ (props, defaultRender) => (
+
+
+ {formatMessage('Clear all')}
+
+ {defaultRender!(props)}
+
+ ),
+ [handleClearAll]
+ );
+
+ return (
+
+ {notifications.length ? (
+
+ {notifications.map(({ hidden, id, retentionTime, ...cardProps }) => {
+ return ;
+ })}
+
+ ) : (
+ {formatMessage('There are no notifications.')}
+ )}
+
+ );
+};
+
+export { NotificationPanel };
diff --git a/Composer/packages/client/src/components/Notifications/__tests__/NotificationButton.test.tsx b/Composer/packages/client/src/components/Notifications/__tests__/NotificationButton.test.tsx
new file mode 100644
index 0000000000..b96b262830
--- /dev/null
+++ b/Composer/packages/client/src/components/Notifications/__tests__/NotificationButton.test.tsx
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import React from 'react';
+import { fireEvent } from '@botframework-composer/test-utils';
+
+import { renderWithRecoil } from '../../../../__tests__/testUtils';
+import { notificationIdsState, notificationsState } from '../../../recoilModel';
+import { NotificationButton } from '../NotificationButton';
+
+jest.mock('../NotificationPanel', () => ({
+ NotificationPanel: ({ isOpen }) => isOpen && ,
+}));
+jest.mock('office-ui-fabric-react/lib/Button', () => ({
+ IconButton: ({ children, onClick }) => (
+
+ ),
+}));
+
+describe('', () => {
+ let recoilInitState;
+
+ beforeEach(() => {
+ recoilInitState = ({ set }) => {
+ set(notificationIdsState, ['1', '2', '3', '4']);
+ set(notificationsState('1'), { read: true });
+ set(notificationsState('2'), {});
+ set(notificationsState('3'), {});
+ set(notificationsState('4'), {});
+ };
+ });
+
+ it('displays the number of unread notifications', async () => {
+ const { findByText } = renderWithRecoil(, recoilInitState);
+ await findByText('3');
+ });
+
+ it('opens the notification panel', async () => {
+ const { findByTestId } = renderWithRecoil(, recoilInitState);
+ const notificationButton = await findByTestId('NotificationButton');
+
+ fireEvent.click(notificationButton);
+
+ await findByTestId('NotificationPanel');
+ });
+});
diff --git a/Composer/packages/client/src/components/__tests__/NotificationCard.test.tsx b/Composer/packages/client/src/components/Notifications/__tests__/NotificationCard.test.tsx
similarity index 74%
rename from Composer/packages/client/src/components/__tests__/NotificationCard.test.tsx
rename to Composer/packages/client/src/components/Notifications/__tests__/NotificationCard.test.tsx
index fdff6b95e2..4cb7b63c34 100644
--- a/Composer/packages/client/src/components/__tests__/NotificationCard.test.tsx
+++ b/Composer/packages/client/src/components/Notifications/__tests__/NotificationCard.test.tsx
@@ -3,9 +3,9 @@
import * as React from 'react';
-import { renderWithRecoil } from '../../../__tests__/testUtils/renderWithRecoil';
+import { renderWithRecoil } from '../../../../__tests__/testUtils/renderWithRecoil';
import { NotificationCard, CardProps } from '../NotificationCard';
-import Timer from '../../utils/timer';
+import Timer from '../../../utils/timer';
jest.useFakeTimers();
@@ -18,7 +18,10 @@ describe('', () => {
type: 'error',
};
const onDismiss = jest.fn();
- const { container } = renderWithRecoil();
+ const handleHide = jest.fn();
+ const { container } = renderWithRecoil(
+
+ );
expect(container).toHaveTextContent('There was error creating your KB');
});
@@ -32,7 +35,10 @@ describe('', () => {
onRenderCardContent: () => customized
,
};
const onDismiss = jest.fn();
- const { container } = renderWithRecoil();
+ const handleHide = jest.fn();
+ const { container } = renderWithRecoil(
+
+ );
expect(container).toHaveTextContent('customized');
});
diff --git a/Composer/packages/client/src/components/Notifications/__tests__/NotificationPanel.test.tsx b/Composer/packages/client/src/components/Notifications/__tests__/NotificationPanel.test.tsx
new file mode 100644
index 0000000000..b7978ae0c7
--- /dev/null
+++ b/Composer/packages/client/src/components/Notifications/__tests__/NotificationPanel.test.tsx
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { fireEvent } from '@botframework-composer/test-utils';
+import React from 'react';
+
+import { renderWithRecoil } from '../../../../__tests__/testUtils';
+import { Notification } from '../../../recoilModel/types';
+import { NotificationPanel } from '../NotificationPanel';
+
+jest.mock('../NotificationCard', () => ({ NotificationCard: () => }));
+jest.mock('office-ui-fabric-react/lib/Button', () => ({
+ ActionButton: ({ onClick }) => (
+
+ ),
+ IconButton: ({ onClick }) => (
+
+ ),
+}));
+
+const notifications = [{ id: '1', read: true }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+describe('', () => {
+ it('displays all the notifications', async () => {
+ const { findAllByTestId } = renderWithRecoil(
+
+ );
+ const notificationCards = await findAllByTestId('NotificationCard');
+ expect(notificationCards.length).toBe(4);
+ });
+
+ it('clears all the notifications', async () => {
+ const deleteNotification = jest.fn();
+ const { findByTestId } = renderWithRecoil(
+
+ );
+ const clearAll = await findByTestId('ClearAll');
+
+ fireEvent.click(clearAll);
+
+ expect(deleteNotification).toBeCalledTimes(4);
+ });
+});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/notification.ts b/Composer/packages/client/src/recoilModel/dispatchers/notification.ts
index 7579d19c77..fcb6416d03 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/notification.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/notification.ts
@@ -6,17 +6,17 @@ import { CallbackInterface, useRecoilCallback } from 'recoil';
import { v4 as uuid } from 'uuid';
import { notificationsState, notificationIdsState } from '../atoms/appState';
-import { CardProps } from '../../components/NotificationCard';
+import { CardProps } from '../../components/Notifications/NotificationCard';
import { Notification } from '../../recoilModel/types';
-export const createNotifiction = (notificationCard: CardProps): Notification => {
+export const createNotification = (notificationCard: CardProps): Notification => {
const id = uuid(6) + '';
return { id, ...notificationCard };
};
export const addNotificationInternal = ({ set }: CallbackInterface, notification: Notification) => {
set(notificationsState(notification.id), notification);
- set(notificationIdsState, (ids) => [...ids, notification.id]);
+ set(notificationIdsState, (ids) => [notification.id, ...ids]);
};
export const deleteNotificationInternal = ({ reset, set }: CallbackInterface, id: string) => {
@@ -35,8 +35,18 @@ export const notificationDispatcher = () => {
deleteNotificationInternal(callbackHelper, id);
});
+ const markNotificationAsRead = useRecoilCallback(({ set }: CallbackInterface) => (id: string) => {
+ set(notificationsState(id), (notification) => ({ ...notification, read: true, hidden: true }));
+ });
+
+ const hideNotification = useRecoilCallback(({ set }: CallbackInterface) => (id: string) => {
+ set(notificationsState(id), (notification) => ({ ...notification, hidden: true }));
+ });
+
return {
addNotification,
deleteNotification,
+ hideNotification,
+ markNotificationAsRead,
};
};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts
index 8daac100ab..0204271568 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts
@@ -26,7 +26,7 @@ import {
} from '../../utils/notifications';
import httpClient from '../../utils/httpUtil';
-import { addNotificationInternal, deleteNotificationInternal, createNotifiction } from './notification';
+import { addNotificationInternal, deleteNotificationInternal, createNotification } from './notification';
export const updateQnAFileState = async (
callbackHelpers: CallbackInterface,
@@ -328,7 +328,7 @@ export const qnaDispatcher = () => {
projectId: string;
}) => {
await dismissCreateQnAModal({ projectId });
- const notification = createNotifiction(getQnaPendingNotification(url));
+ const notification = createNotification(getQnaPendingNotification(url));
addNotificationInternal(callbackHelpers, notification);
let response;
@@ -339,7 +339,7 @@ export const qnaDispatcher = () => {
const content = response.data;
await updateQnAFileState(callbackHelpers, { id, content, projectId });
- const notification = createNotifiction(
+ const notification = createNotification(
getQnaSuccessNotification(() => {
navigateTo(`/bot/${projectId}/knowledge-base/${getBaseName(id)}`);
deleteNotificationInternal(callbackHelpers, notification.id);
@@ -349,7 +349,7 @@ export const qnaDispatcher = () => {
} catch (err) {
addNotificationInternal(
callbackHelpers,
- createNotifiction(getQnaFailedNotification(err.response?.data?.message))
+ createNotification(getQnaFailedNotification(err.response?.data?.message))
);
createQnAFromUrlDialogCancel({ projectId });
return;
@@ -395,7 +395,7 @@ ${response.data}
});
await createQnAFromScratchDialogSuccess({ projectId });
- const notification = createNotifiction(
+ const notification = createNotification(
getQnaSuccessNotification(() => {
navigateTo(`/bot/${projectId}/knowledge-base/${getBaseName(id)}`);
deleteNotificationInternal(callbackHelpers, notification.id);
diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts
index 000d0440e2..8f59646139 100644
--- a/Composer/packages/client/src/recoilModel/types.ts
+++ b/Composer/packages/client/src/recoilModel/types.ts
@@ -5,7 +5,7 @@ import { AppUpdaterSettings, CodeEditorSettings, PromptTab } from '@bfc/shared';
import { AppUpdaterStatus } from '../constants';
-import { CardProps } from './../components/NotificationCard';
+import { CardProps } from './../components/Notifications/NotificationCard';
export interface StateError {
status?: number;
diff --git a/Composer/packages/client/src/utils/hooks.ts b/Composer/packages/client/src/utils/hooks.ts
index c1d442fd07..a67abc4f9f 100644
--- a/Composer/packages/client/src/utils/hooks.ts
+++ b/Composer/packages/client/src/utils/hooks.ts
@@ -78,3 +78,22 @@ export const useProjectIdCache = () => {
return projectId;
};
+
+export const useInterval = (callback, delay) => {
+ const savedCallback = useRef<() => void>();
+
+ useEffect(() => {
+ savedCallback.current = callback;
+ }, [callback]);
+
+ useEffect(() => {
+ if (delay !== null) {
+ const interval = setInterval(() => {
+ if (typeof savedCallback.current === 'function') {
+ savedCallback.current();
+ }
+ }, delay);
+ return () => clearInterval(interval);
+ }
+ }, [delay]);
+};
diff --git a/Composer/packages/client/src/utils/notifications.ts b/Composer/packages/client/src/utils/notifications.ts
index aec925bd4f..f75ed8482c 100644
--- a/Composer/packages/client/src/utils/notifications.ts
+++ b/Composer/packages/client/src/utils/notifications.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import formatMessage from 'format-message';
-import { CardProps } from './../components/NotificationCard';
+import { CardProps } from './../components/Notifications/NotificationCard';
export const getQnaPendingNotification = (url: string): CardProps => {
return {
title: formatMessage('Creating your knowledge base'),