diff --git a/Composer/packages/client/src/components/AppComponents/MainContainer.tsx b/Composer/packages/client/src/components/AppComponents/MainContainer.tsx index 5b37ffc206..f21dc0c4f2 100644 --- a/Composer/packages/client/src/components/AppComponents/MainContainer.tsx +++ b/Composer/packages/client/src/components/AppComponents/MainContainer.tsx @@ -3,6 +3,8 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; +import { NotificationContainer } from '../NotificationContainer'; + import { SideBar } from './SideBar'; import { RightPanel } from './RightPanel'; import { Assistant } from './Assistant'; @@ -18,6 +20,7 @@ export const MainContainer = () => { + ); }; diff --git a/Composer/packages/client/src/components/NotificationCard.tsx b/Composer/packages/client/src/components/NotificationCard.tsx new file mode 100644 index 0000000000..d865f96301 --- /dev/null +++ b/Composer/packages/client/src/components/NotificationCard.tsx @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css, keyframes } from '@emotion/core'; +import React from 'react'; +import { IconButton, ActionButton } from 'office-ui-fabric-react/lib/Button'; +import { useEffect, useRef, useState } 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'; + +// -------------------- Styles -------------------- // + +const fadeIn = keyframes` + from { opacity: 0; transform: translate3d(40px,0,0) } + to { opacity: 1; translate3d(0,0,0) } +`; + +const fadeOut = (height: number) => keyframes` + from { opacity: 1; height: ${height}px} + to { opacity: 0; height:0} +`; + +const cardContainer = (show: boolean, ref?: HTMLDivElement | null) => () => { + let height = 100; + if (ref) { + height = ref.clientHeight; + } + + return css` + border-left: 4px solid #0078d4; + background: white; + box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108); + width: 340px; + border-radius: 2px; + display: flex; + flex-direction: column; + margin-bottom: 8px; + animation-duration: ${show ? '0.467' : '0.2'}s; + animation-timing-function: ${show ? 'cubic-bezier(0.1, 0.9, 0.2, 1)' : 'linear'}; + animation-fill-mode: both; + animation-name: ${show ? fadeIn : fadeOut(height)}; + `; +}; + +const cancelButton = css` + float: right; + color: #605e5c; + margin-left: auto; + width: 24px; + height: 24px; +`; + +const cardContent = css` + display: flex; + padding: 0 8px 16px 12px; + min-height: 64px; +`; + +const cardDetail = css` + margin-left: 8px; + flex-grow: 1; +`; + +const errorType = css` + margin-top: 4px; + color: #a80000; +`; + +const successType = css` + margin-top: 4px; + color: #27ae60; +`; + +const cardTitle = css` + font-size: ${FontSizes.size16}; + lint-height: 22px; + margin-right: 16px; +`; + +const cardDescription = css` + text-size-adjust: none; + font-size: ${FontSizes.size10}; + margin-top: 8px; + margin-right: 16px; + word-break: break-word; +`; + +const linkButton = css` + color: #0078d4; + float: right; + font-size: 12px; + height: auto; + margin-right: 8px; +`; + +const getShimmerStyles = { + root: { + marginTop: '12px', + marginBottom: '8px', + }, + shimmerWrapper: [ + { + backgroundColor: '#EDEBE9', + }, + ], + shimmerGradient: [ + { + backgroundImage: 'radial-gradient(at 50% 50%, #0078D4 0%, #EDEBE9 100%);', + }, + ], +}; +// -------------------- NotificationCard -------------------- // + +export type NotificationType = 'info' | 'warning' | 'error' | 'pending' | 'success'; + +export type Link = { + label: string; + onClick: () => void; +}; + +export type CardProps = { + type: NotificationType; + title: string; + description?: string; + retentionTime?: number; + link?: Link; + onRenderCardContent?: (props: CardProps) => JSX.Element; +}; + +export type NotificationProps = { + id: string; + cardProps: CardProps; + onDismiss: (id: string) => void; +}; + +const defaultCardContentRenderer = (props: CardProps) => { + const { title, description, type, link } = props; + return ( +
+ {type === 'error' && } + {type === 'success' && } +
+
{title}
+ {description &&
{description}
} + {link && ( + + {link.label} + + )} + {type === 'pending' && ( + + )} +
+
+ ); +}; + +export const NotificationCard = React.memo((props: NotificationProps) => { + const { cardProps, id, onDismiss } = props; + const [show, setShow] = useState(true); + const containerRef = useRef(null); + + const removeNotification = () => { + setShow(false); + }; + + // notification will disappear in 5 secs + const timer = useRef(cardProps.retentionTime ? new Timer(removeNotification, cardProps.retentionTime) : null).current; + + useEffect(() => { + return () => { + if (timer) { + timer.clear(); + } + }; + }, []); + + const handleMouseOver = () => { + // if mouse over stop the time and record the remaining time + if (timer) { + timer.pause(); + } + }; + + const handleMouseLeave = () => { + if (timer) { + timer.resume(); + } + }; + + const handleAnimationEnd = () => { + if (!show) onDismiss(id); + }; + + const renderCard = cardProps.onRenderCardContent || defaultCardContentRenderer; + + return ( +
void 0} + onMouseLeave={handleMouseLeave} + onMouseOver={handleMouseOver} + > + + {renderCard(cardProps)} +
+ ); +}); diff --git a/Composer/packages/client/src/components/NotificationContainer.tsx b/Composer/packages/client/src/components/NotificationContainer.tsx new file mode 100644 index 0000000000..afdc87b5a2 --- /dev/null +++ b/Composer/packages/client/src/components/NotificationContainer.tsx @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @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 { NotificationCard } from './NotificationCard'; + +// -------------------- Styles -------------------- // + +const container = css` + cursor: default; + position: absolute; + right: 0px; + padding: 6px; +`; + +// -------------------- NotificationContainer -------------------- // + +export const NotificationContainer = React.memo(() => { + const notifications = useRecoilValue(notificationsSelector); + const { deleteNotification } = useRecoilValue(dispatcherState); + + return ( +
+ {notifications.map((item) => { + return ; + })} +
+ ); +}); diff --git a/Composer/packages/client/src/components/__tests__/NotificationCard.test.tsx b/Composer/packages/client/src/components/__tests__/NotificationCard.test.tsx new file mode 100644 index 0000000000..fdff6b95e2 --- /dev/null +++ b/Composer/packages/client/src/components/__tests__/NotificationCard.test.tsx @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; + +import { renderWithRecoil } from '../../../__tests__/testUtils/renderWithRecoil'; +import { NotificationCard, CardProps } from '../NotificationCard'; +import Timer from '../../utils/timer'; + +jest.useFakeTimers(); + +describe('', () => { + it('should render the NotificationCard', () => { + const cardProps: CardProps = { + title: 'There was error creating your KB', + description: 'error', + retentionTime: 1, + type: 'error', + }; + const onDismiss = jest.fn(); + const { container } = renderWithRecoil(); + + expect(container).toHaveTextContent('There was error creating your KB'); + }); + + it('should render the customized card', () => { + const cardProps: CardProps = { + title: 'There was error creating your KB', + description: 'error', + retentionTime: 5000, + type: 'error', + onRenderCardContent: () =>
customized
, + }; + const onDismiss = jest.fn(); + const { container } = renderWithRecoil(); + + expect(container).toHaveTextContent('customized'); + }); +}); + +describe('Notification Time Management', () => { + it('should invoke callback', () => { + const callback = jest.fn(); + new Timer(callback, 0); + expect(callback).not.toBeCalled(); + jest.runAllTimers(); + expect(callback).toHaveBeenCalled(); + }); + + it('should pause and resume', () => { + const callback = jest.fn(); + const timer = new Timer(callback, 1); + timer.pause(); + expect(timer.pausing).toBeTruthy(); + timer.resume(); + expect(timer.pausing).toBeFalsy(); + }); +}); diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index 15e51eb8a0..0bd8663c22 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -555,8 +555,7 @@ const DesignPage: React.FC 0) { await importQnAFromUrls({ id: `${dialogId}.${locale}`, urls, projectId }); diff --git a/Composer/packages/client/src/recoilModel/atoms/appState.ts b/Composer/packages/client/src/recoilModel/atoms/appState.ts index 3acebf35ef..ffa97d2f58 100644 --- a/Composer/packages/client/src/recoilModel/atoms/appState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/appState.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { atom } from 'recoil'; +import { atom, atomFamily } from 'recoil'; import { ProjectTemplate, UserSettings } from '@bfc/shared'; import { @@ -10,6 +10,7 @@ import { RuntimeTemplate, AppUpdateState, BoilerplateVersion, + Notification, ExtensionConfig, } from '../../recoilModel/types'; import { getUserSettings } from '../utils'; @@ -152,6 +153,18 @@ export const boilerplateVersionState = atom({ }, }); +export const notificationIdsState = atom({ + key: getFullyQualifiedKey('notificationIds'), + default: [], +}); + +export const notificationsState = atomFamily({ + key: getFullyQualifiedKey('notification'), + default: (id: string): Notification => { + return { id, type: 'info', title: '' }; + }, +}); + export const extensionsState = atom({ key: getFullyQualifiedKey('extensions'), default: [], diff --git a/Composer/packages/client/src/recoilModel/dispatchers/index.ts b/Composer/packages/client/src/recoilModel/dispatchers/index.ts index 983b536f4e..edbae77387 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/index.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/index.ts @@ -18,6 +18,7 @@ import { settingsDispatcher } from './setting'; import { skillDispatcher } from './skill'; import { userDispatcher } from './user'; import { multilangDispatcher } from './multilang'; +import { notificationDispatcher } from './notification'; import { extensionsDispatcher } from './extensions'; const createDispatchers = () => { @@ -39,6 +40,7 @@ const createDispatchers = () => { ...skillDispatcher(), ...userDispatcher(), ...multilangDispatcher(), + ...notificationDispatcher(), ...extensionsDispatcher(), }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/notification.ts b/Composer/packages/client/src/recoilModel/dispatchers/notification.ts new file mode 100644 index 0000000000..7579d19c77 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/notification.ts @@ -0,0 +1,42 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { CallbackInterface, useRecoilCallback } from 'recoil'; +import { v4 as uuid } from 'uuid'; + +import { notificationsState, notificationIdsState } from '../atoms/appState'; +import { CardProps } from '../../components/NotificationCard'; +import { Notification } from '../../recoilModel/types'; + +export const createNotifiction = (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]); +}; + +export const deleteNotificationInternal = ({ reset, set }: CallbackInterface, id: string) => { + reset(notificationsState(id)); + set(notificationIdsState, (notifications) => { + return notifications.filter((notification) => notification !== id); + }); +}; + +export const notificationDispatcher = () => { + const addNotification = useRecoilCallback((callbackHelper: CallbackInterface) => (notification: Notification) => { + return addNotificationInternal(callbackHelper, notification); + }); + + const deleteNotification = useRecoilCallback((callbackHelper: CallbackInterface) => (id: string) => { + deleteNotificationInternal(callbackHelper, id); + }); + + return { + addNotification, + deleteNotification, + }; +}; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts index aac98d04f8..adbbdedfff 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts @@ -5,13 +5,18 @@ import { QnAFile } from '@bfc/shared'; import { useRecoilCallback, CallbackInterface } from 'recoil'; import qnaWorker from '../parsers/qnaWorker'; -import { qnaFilesState, qnaAllUpViewStatusState, localeState, settingsState } from '../atoms/botState'; -import { QnAAllUpViewStatus } from '../types'; +import { qnaFilesState, localeState, settingsState } from '../atoms/botState'; import qnaFileStatusStorage from '../../utils/qnaFileStatusStorage'; import { getBaseName } from '../../utils/fileUtil'; +import { navigateTo } from '../../utils/navigation'; +import { + getQnaFailedNotification, + getQnaSuccessNotification, + getQnaPendingNotification, +} from '../../utils/notifications'; +import httpClient from '../../utils/httpUtil'; -import httpClient from './../../utils/httpUtil'; -import { setError } from './shared'; +import { addNotificationInternal, deleteNotificationInternal, createNotifiction } from './notification'; export const updateQnAFileState = async ( callbackHelpers: CallbackInterface, @@ -114,10 +119,13 @@ export const qnaDispatcher = () => { urls: string[]; projectId: string; }) => { - const { set, snapshot } = callbackHelpers; + const { snapshot } = callbackHelpers; const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); const qnaFile = qnaFiles.find((f) => f.id === id); - set(qnaAllUpViewStatusState(projectId), QnAAllUpViewStatus.Loading); + + const notification = createNotifiction(getQnaPendingNotification(urls)); + addNotificationInternal(callbackHelpers, notification); + try { const response = await httpClient.get(`/utilities/qna/parse`, { params: { urls: encodeURIComponent(urls.join(',')) }, @@ -125,11 +133,20 @@ export const qnaDispatcher = () => { const content = qnaFile ? qnaFile.content + '\n' + response.data : response.data; await updateQnAFileState(callbackHelpers, { id, content, projectId }); - set(qnaAllUpViewStatusState(projectId), QnAAllUpViewStatus.Success); + const notification = createNotifiction( + getQnaSuccessNotification(() => { + navigateTo(`/bot/${projectId}/knowledge-base/${getBaseName(id)}`); + deleteNotificationInternal(callbackHelpers, notification.id); + }) + ); + addNotificationInternal(callbackHelpers, notification); } catch (err) { - setError(callbackHelpers, err); + addNotificationInternal( + callbackHelpers, + createNotifiction(getQnaFailedNotification(err.response?.data?.message)) + ); } finally { - set(qnaAllUpViewStatusState(projectId), QnAAllUpViewStatus.Success); + deleteNotificationInternal(callbackHelpers, notification.id); } } ); diff --git a/Composer/packages/client/src/recoilModel/selectors/notificationsSelector.ts b/Composer/packages/client/src/recoilModel/selectors/notificationsSelector.ts new file mode 100644 index 0000000000..a9a902e5ee --- /dev/null +++ b/Composer/packages/client/src/recoilModel/selectors/notificationsSelector.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { selector } from 'recoil'; + +import { notificationIdsState, notificationsState } from '../atoms/appState'; + +export const notificationsSelector = selector({ + key: 'notificationsSelector', + get: ({ get }) => { + const ids = get(notificationIdsState); + const notifications = ids.map((id) => get(notificationsState(id))); + return notifications; + }, +}); diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts index 38c3748f73..9053bac96f 100644 --- a/Composer/packages/client/src/recoilModel/types.ts +++ b/Composer/packages/client/src/recoilModel/types.ts @@ -5,6 +5,8 @@ import { AppUpdaterSettings, CodeEditorSettings, PromptTab } from '@bfc/shared'; import { AppUpdaterStatus } from '../constants'; +import { CardProps } from './../components/NotificationCard'; + export interface StateError { status?: number; summary: string; @@ -127,3 +129,5 @@ export enum QnAAllUpViewStatus { Success, Failed, } + +export type Notification = CardProps & { id: string }; diff --git a/Composer/packages/client/src/utils/notifications.ts b/Composer/packages/client/src/utils/notifications.ts new file mode 100644 index 0000000000..5ea1b4dc64 --- /dev/null +++ b/Composer/packages/client/src/utils/notifications.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import formatMessage from 'format-message'; + +import { CardProps } from './../components/NotificationCard'; + +export const getQnaPendingNotification = (urls: string[]): CardProps => { + return { + title: formatMessage('Creating your knowledge base'), + description: formatMessage('Extracting QNA pairs from {urls}', { urls: urls.join(' ') }), + type: 'pending', + }; +}; + +export const getQnaSuccessNotification = (callback: () => void): CardProps => { + return { + title: formatMessage('Your knowledge base Surface go FAQ is ready!'), + type: 'success', + retentionTime: 5000, + link: { + label: formatMessage('View KB'), + onClick: callback, + }, + }; +}; + +export const getQnaFailedNotification = (error: string): CardProps => { + return { + title: formatMessage('There was error creating your KB'), + description: error, + type: 'error', + }; +}; diff --git a/Composer/packages/client/src/utils/timer.ts b/Composer/packages/client/src/utils/timer.ts new file mode 100644 index 0000000000..b0077b3344 --- /dev/null +++ b/Composer/packages/client/src/utils/timer.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export default class Timer { + timerId: NodeJS.Timeout; + start: number; + remaining: number; + pausing = false; + callback: () => void; + + constructor(callback: () => void, delay: number) { + this.remaining = delay; + this.callback = callback; + this.start = Date.now(); + this.timerId = setTimeout(callback, this.remaining); + } + + pause() { + if (!this.pausing) { + clearTimeout(this.timerId); + this.remaining -= Date.now() - this.start; + this.pausing = true; + } + } + + resume() { + this.pausing = false; + this.start = Date.now(); + clearTimeout(this.timerId); + this.timerId = setTimeout(this.callback, this.remaining); + } + + clear() { + clearTimeout(this.timerId); + } +}