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('Composer -
-
{formatMessage('Bot Framework Composer')}
- {projectName && ( - -
- {`${projectName} (${locale})`} - +
+ {formatMessage('Composer +
+
{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'),