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);
+ }
+}