diff --git a/Composer/cypress/integration/NotificationPage.spec.js b/Composer/cypress/integration/NotificationPage.spec.js new file mode 100644 index 0000000000..0618ed70ba --- /dev/null +++ b/Composer/cypress/integration/NotificationPage.spec.js @@ -0,0 +1,25 @@ +/// + +context('check notifications page', () => { + beforeEach(() => { + cy.visit(Cypress.env('COMPOSER_URL')); + cy.createBot('TodoSample'); + }); + + it('can show lg syntax error ', () => { + cy.visitPage("Bot Responses"); + // left nav tree + cy.contains('TodoSample.Main'); + cy.contains('All'); + + cy.get('.toggleEditMode button').click(); + cy.get('textarea').type('test lg syntax error'); + + cy.visitPage("Notifications"); + + cy.get('[data-testid="notifications-table-view"]').within(() => { + cy.getByText('common.lg').should('exist'); + }); + + }); +}); diff --git a/Composer/packages/client/__tests__/components/notificationHeader.test.js b/Composer/packages/client/__tests__/components/notificationHeader.test.js new file mode 100644 index 0000000000..182299bdd8 --- /dev/null +++ b/Composer/packages/client/__tests__/components/notificationHeader.test.js @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { fireEvent, render } from 'react-testing-library'; + +import { NotificationHeader } from '../../src/pages/notifications/NotificationHeader'; + +describe('', () => { + const items = ['test1', 'test2', 'test3']; + it('should render the NotificationHeader', () => { + const mockOnChange = jest.fn(() => null); + const { container } = render(); + + expect(container).toHaveTextContent('Notifications'); + expect(container).toHaveTextContent('All'); + const dropdown = container.querySelector('[data-testid="notifications-dropdown"]'); + fireEvent.click(dropdown); + const test = document.querySelector('.ms-Dropdown-callout'); + expect(test).toHaveTextContent('test1'); + expect(test).toHaveTextContent('test2'); + }); +}); diff --git a/Composer/packages/client/__tests__/components/notificationList.test.js b/Composer/packages/client/__tests__/components/notificationList.test.js new file mode 100644 index 0000000000..32fddf9733 --- /dev/null +++ b/Composer/packages/client/__tests__/components/notificationList.test.js @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { render } from 'react-testing-library'; + +import { NotificationList } from '../../src/pages/notifications/NotificationList'; + +describe('', () => { + const items = [ + { type: 'Error', location: 'test1', message: 'error1' }, + { type: 'Warning', location: 'test2', message: 'error2' }, + { type: 'Error', location: 'test3', message: 'error3' }, + ]; + it('should render the NotificationList', () => { + const { container } = render(); + + expect(container).toHaveTextContent('test1'); + expect(container).toHaveTextContent('test2'); + expect(container).toHaveTextContent('test3'); + }); +}); diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index b2da050ff8..ee7d8f6ef3 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -33,56 +33,57 @@ const topLinks = (botLoaded: boolean) => { to: '/home', iconName: 'Home', labelName: formatMessage('Home'), - activeIfUrlContains: 'home', exact: true, + disabled: false, }, { to: '/dialogs/Main', iconName: 'SplitObject', labelName: formatMessage('Design Flow'), - activeIfUrlContains: 'dialogs', exact: false, - underTest: !botLoaded, + disabled: !botLoaded, }, { to: '/test-conversation', iconName: 'WaitListConfirm', labelName: formatMessage('Test Conversation'), - activeIfUrlContains: '', exact: false, - underTest: true, // will delete + disabled: true, // will delete }, { to: 'language-generation/', iconName: 'Robot', labelName: formatMessage('Bot Responses'), - activeIfUrlContains: 'language-generation', exact: false, - underTest: !botLoaded, + disabled: !botLoaded, }, { to: 'language-understanding/', iconName: 'People', labelName: formatMessage('User Input'), - activeIfUrlContains: 'language-understanding', exact: false, - underTest: !botLoaded, + disabled: !botLoaded, }, { to: '/evaluate-performance', iconName: 'Chart', labelName: formatMessage('Evaluate performance'), - activeIfUrlContains: '', exact: false, - underTest: true, // will delete + disabled: true, + }, + { + to: '/notifications', + iconName: 'Warning', + labelName: formatMessage('Notifications'), + exact: true, + disabled: !botLoaded, }, { to: '/setting/', iconName: 'Settings', labelName: formatMessage('Settings'), - activeIfUrlContains: 'setting', exact: false, - underTest: !botLoaded, + disabled: !botLoaded, }, ]; @@ -98,16 +99,15 @@ const bottomLinks = [ to: '/help', iconName: 'unknown', labelName: formatMessage('Info'), - activeIfUrlContains: '/help', - exact: false, - underTest: true, // will delete + exact: true, + disabled: true, }, { to: '/about', iconName: 'info', labelName: formatMessage('About'), - activeIfUrlContains: '/about', - exact: false, + exact: true, + disabled: false, }, ]; @@ -143,11 +143,8 @@ export const App: React.FC = () => { to={mapNavItemTo(link.to)} iconName={link.iconName} labelName={link.labelName} - labelHide={!sideBarExpand} - index={index} exact={link.exact} - targetUrl={link.activeIfUrlContains} - underTest={link.underTest} + disabled={link.disabled} /> ); })} @@ -161,11 +158,8 @@ export const App: React.FC = () => { to={mapNavItemTo(link.to)} iconName={link.iconName} labelName={link.labelName} - labelHide={!sideBarExpand} - index={index} exact={link.exact} - targetUrl={link.activeIfUrlContains} - underTest={link.underTest} + disabled={link.disabled} /> ); })} diff --git a/Composer/packages/client/src/components/NavItem/index.js b/Composer/packages/client/src/components/NavItem/index.tsx similarity index 56% rename from Composer/packages/client/src/components/NavItem/index.js rename to Composer/packages/client/src/components/NavItem/index.tsx index 9ddbee5a89..19a29389aa 100644 --- a/Composer/packages/client/src/components/NavItem/index.js +++ b/Composer/packages/client/src/components/NavItem/index.tsx @@ -4,8 +4,7 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; import { useCallback, useContext, useState } from 'react'; -import { Link } from '@reach/router'; -import { PropTypes } from 'prop-types'; +import { Link, LinkGetProps } from '@reach/router'; import { CommandBarButton } from 'office-ui-fabric-react/lib/Button'; import { FocusZone } from 'office-ui-fabric-react/lib/FocusZone'; @@ -13,33 +12,43 @@ import { StoreContext } from '../../store'; import { link, outer, commandBarButton } from './styles'; -export const NavItem = props => { +/** + * @param to The string URI to link to. Supports relative and absolute URIs. + * @param exact The uri is exactly the same as the anchor’s href. + * @param iconName The link's icon. + * @param labelName The link's text. + * @param disabled If true, the Link will be unavailable. + */ +export interface INavItemProps { + to: string; + exact: boolean; + iconName: string; + labelName: string; + disabled: boolean; +} + +export const NavItem: React.FC = props => { const { actions: { onboardingAddCoachMarkRef }, } = useContext(StoreContext); - const { to, exact, iconName, labelName, targetUrl, underTest } = props; + const { to, exact, iconName, labelName, disabled } = props; const [active, setActive] = useState(false); const addRef = useCallback(ref => onboardingAddCoachMarkRef({ [`nav${labelName.replace(' ', '')}`]: ref }), []); - const isPartial = (targetUrl, currentUrl) => { - const urlPaths = currentUrl.split('/'); - return urlPaths.indexOf(targetUrl) !== -1; - }; - return ( - + { - const isActive = exact ? isCurrent : isPartial(targetUrl, location.pathname); + css={link(active, disabled)} + getProps={(props: LinkGetProps) => { + const isActive = exact ? props.isCurrent : props.isPartiallyCurrent; setActive(isActive); + return {}; }} data-testid={'LeftNav-CommandBarButton' + labelName} - disabled={underTest} - aria-disabled={underTest} + aria-disabled={disabled} aria-label={labelName} ref={addRef} > @@ -50,7 +59,7 @@ export const NavItem = props => { }} text={labelName} styles={commandBarButton(active)} - disabled={underTest} + disabled={disabled} ariaHidden /> @@ -58,13 +67,3 @@ export const NavItem = props => { ); }; -NavItem.propTypes = { - to: PropTypes.string, - iconName: PropTypes.string, - labelName: PropTypes.string, - exact: PropTypes.bool, - labelHide: PropTypes.bool, - index: PropTypes.number, - targetUrl: PropTypes.string, - underTest: PropTypes.bool, -}; diff --git a/Composer/packages/client/src/components/NavItem/styles.ts b/Composer/packages/client/src/components/NavItem/styles.ts index 682f0074af..f5fa2d02ea 100644 --- a/Composer/packages/client/src/components/NavItem/styles.ts +++ b/Composer/packages/client/src/components/NavItem/styles.ts @@ -6,14 +6,14 @@ import { FontSizes } from '@uifabric/fluent-theme'; import { NeutralColors, CommunicationColors } from '@uifabric/fluent-theme'; import { IButtonStyles } from 'office-ui-fabric-react/lib/Button'; -export const link = (active, underTest) => css` +export const link = (active, disabled) => css` display: block; text-decoration: none; color: #4f4f4f; position: relative; - ${underTest && `pointer-events: none;`} - ${!underTest && + ${disabled && `pointer-events: none;`} + ${!disabled && `&::after { content: ''; position: absolute; diff --git a/Composer/packages/client/src/pages/notifications/NotificationHeader.tsx b/Composer/packages/client/src/pages/notifications/NotificationHeader.tsx new file mode 100644 index 0000000000..92c43f21fe --- /dev/null +++ b/Composer/packages/client/src/pages/notifications/NotificationHeader.tsx @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import formatMessage from 'format-message'; +import { useMemo } from 'react'; + +import { notificationHeader, notificationHeaderText, dropdownStyles } from './styles'; + +const createOptions = (items: string[]): IDropdownOption[] => { + const defaultOptions: IDropdownOption[] = [ + { key: formatMessage('Show All Locations'), text: formatMessage('All'), data: '', isSelected: true }, + ]; + items.forEach(item => { + return defaultOptions.push({ key: item, text: item, data: item }); + }); + return defaultOptions; +}; + +export interface INotificationHeader { + onChange: (text: string) => void; + items: string[]; +} + +export const NotificationHeader: React.FC = props => { + const { onChange, items } = props; + const options = useMemo(() => { + return createOptions(items); + }, [items]); + + return ( +
+
{formatMessage('Notifications')}
+ { + if (option) onChange(option.data); + }} + options={options} + styles={dropdownStyles} + data-testid="notifications-dropdown" + /> +
+ ); +}; diff --git a/Composer/packages/client/src/pages/notifications/NotificationList.tsx b/Composer/packages/client/src/pages/notifications/NotificationList.tsx new file mode 100644 index 0000000000..15b573daf0 --- /dev/null +++ b/Composer/packages/client/src/pages/notifications/NotificationList.tsx @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { DetailsList, DetailsListLayoutMode, SelectionMode, IColumn } from 'office-ui-fabric-react/lib/DetailsList'; +import { FontIcon } from 'office-ui-fabric-react/lib/Icon'; + +import { INotification } from './types'; +import { notification, typeIcon, listRoot, icons } from './styles'; + +export interface INotificationListProps { + items: INotification[]; +} + +const columns: IColumn[] = [ + { + key: 'Icon', + name: '', + className: notification.typeIconCell, + iconClassName: notification.typeIconHeaderIcon, + fieldName: 'icon', + minWidth: 30, + maxWidth: 30, + onRender: (item: INotification) => { + return ; + }, + }, + { + key: 'Notification Type', + name: 'Type', + fieldName: 'type', + minWidth: 70, + maxWidth: 90, + isRowHeader: true, + isResizable: true, + data: 'string', + onRender: (item: INotification) => { + return {item.type}; + }, + isPadded: true, + }, + { + key: 'Notification Location', + name: 'Location', + fieldName: 'location', + minWidth: 70, + maxWidth: 90, + isResizable: true, + data: 'string', + onRender: (item: INotification) => { + return {item.location}; + }, + isPadded: true, + }, + { + key: 'Notification Detail', + name: 'Message', + fieldName: 'message', + minWidth: 70, + maxWidth: 90, + isResizable: true, + isCollapsible: true, + isMultiline: true, + data: 'string', + onRender: (item: INotification) => { + return {item.message}; + }, + isPadded: true, + }, +]; + +export const NotificationList: React.FC = props => { + const { items } = props; + + return ( +
+ +
+ ); +}; diff --git a/Composer/packages/client/src/pages/notifications/index.tsx b/Composer/packages/client/src/pages/notifications/index.tsx new file mode 100644 index 0000000000..84cbbeb375 --- /dev/null +++ b/Composer/packages/client/src/pages/notifications/index.tsx @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { useState } from 'react'; +import { RouteComponentProps } from '@reach/router'; + +import { ToolBar } from './../../components/ToolBar/index'; +import useNotifications from './useNotifications'; +import { NotificationList } from './NotificationList'; +import { NotificationHeader } from './NotificationHeader'; +import { root } from './styles'; + +const Notifications: React.FC = () => { + const [filter, setFilter] = useState(''); + const { notifications, locations } = useNotifications(filter); + return ( +
+ + + +
+ ); +}; + +export default Notifications; diff --git a/Composer/packages/client/src/pages/notifications/styles.ts b/Composer/packages/client/src/pages/notifications/styles.ts new file mode 100644 index 0000000000..980f728ff5 --- /dev/null +++ b/Composer/packages/client/src/pages/notifications/styles.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { mergeStyleSets } from 'office-ui-fabric-react/lib/Styling'; +import { css } from '@emotion/core'; +import { IDropdownStyles } from 'office-ui-fabric-react/lib/Dropdown'; + +export const icons = { + Error: { iconName: 'ErrorBadge', color: '#A80000', background: '#FED9CC' }, + Warning: { iconName: 'Warning', color: '#8A8780', background: '#FFF4CE' }, +}; + +export const notification = mergeStyleSets({ + typeIconHeaderIcon: { + padding: 0, + fontSize: '16px', + }, + typeIconCell: { + textAlign: 'center', + selectors: { + '&:before': { + content: '.', + display: 'inline-block', + verticalAlign: 'middle', + height: '100%', + width: '0px', + visibility: 'hidden', + }, + }, + }, +}); + +export const dropdownStyles: Partial = { + dropdown: { width: 180, marginLeft: 'auto' }, +}; + +export const typeIcon = icon => css` + vertical-align: middle; + font-size: 16px; + width: 24px; + height: 24px; + background: ${icon.background}; + line-height: 24px; + color: ${icon.color}; +`; + +export const notificationHeader = css` + border-bottom: 1px solid #edebe9; + height: 90px; + padding: 14px 38px 8px 29px; + display: flex; + flex-direction: column; + justify-content: space-between; +`; + +export const notificationHeaderText = css` + font-size: 20px; + color: #323130; + font-weight: bold; +`; + +export const root = css` + display: flex; + height: 100%; + flex-direction: column; +`; + +export const listRoot = css` + overflow-y: auto; +`; diff --git a/Composer/packages/client/src/pages/notifications/types.ts b/Composer/packages/client/src/pages/notifications/types.ts new file mode 100644 index 0000000000..d9a1528ce6 --- /dev/null +++ b/Composer/packages/client/src/pages/notifications/types.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export interface INotification { + type: string; + location: string; + message: string; +} diff --git a/Composer/packages/client/src/pages/notifications/useNotifications.tsx b/Composer/packages/client/src/pages/notifications/useNotifications.tsx new file mode 100644 index 0000000000..b35d98d54a --- /dev/null +++ b/Composer/packages/client/src/pages/notifications/useNotifications.tsx @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useContext, useMemo } from 'react'; + +import { StoreContext } from '../../store'; +import { createSingleMessage } from '../../utils/lgUtil'; + +import { INotification } from './types'; + +const DiagnosticSeverity = ['Error', 'Warning']; //'Information', 'Hint' + +export default function useNotifications(filter: string) { + const { state } = useContext(StoreContext); + const { dialogs, luFiles, lgFiles } = state; + + const memoized = useMemo(() => { + const notifactions: INotification[] = []; + const locations = new Set(); + dialogs.forEach(dialog => { + dialog.diagnostics.map(diagnostic => { + const location = dialog.displayName; + locations.add(location); + notifactions.push({ type: 'Error', location, message: diagnostic }); + }); + }); + luFiles.forEach(lufile => { + lufile.diagnostics.map(diagnostic => { + const location = `${lufile.id}.lu`; + locations.add(location); + notifactions.push({ type: 'Error', location, message: diagnostic.text }); + }); + }); + lgFiles.forEach(lgFiles => { + lgFiles.diagnostics.map(diagnostic => { + const location = `${lgFiles.id}.lg`; + locations.add(location); + notifactions.push({ + type: DiagnosticSeverity[diagnostic.Severity], + location, + message: createSingleMessage(diagnostic), + }); + }); + }); + return { notifactions, locations: Array.from(locations) }; + }, [dialogs, luFiles, lgFiles]); + + const notifications: INotification[] = !filter + ? memoized.notifactions + : memoized.notifactions.filter(x => x.location === filter); + + return { notifications, locations: memoized.locations }; +} diff --git a/Composer/packages/client/src/router.tsx b/Composer/packages/client/src/router.tsx index 7e21f73421..c38972c914 100644 --- a/Composer/packages/client/src/router.tsx +++ b/Composer/packages/client/src/router.tsx @@ -19,6 +19,7 @@ const DesignPage = React.lazy(() => import('./pages/design')); const LUPage = React.lazy(() => import('./pages/language-understanding')); const LGPage = React.lazy(() => import('./pages/language-generation')); const SettingPage = React.lazy(() => import('./pages/setting')); +const Notifications = React.lazy(() => import('./pages/notifications')); const Routes = props => { const { actions } = useContext(StoreContext); @@ -49,6 +50,7 @@ const Routes = props => { + diff --git a/Composer/packages/client/src/utils/lgUtil.ts b/Composer/packages/client/src/utils/lgUtil.ts index e40e00303c..482a3f408e 100644 --- a/Composer/packages/client/src/utils/lgUtil.ts +++ b/Composer/packages/client/src/utils/lgUtil.ts @@ -33,12 +33,16 @@ export function parse(content: string, id = ''): LGTemplate[] { return get(resource, 'Templates', []); } +export function createSingleMessage(diagnostic: Diagnostic): string { + const { Start, End } = diagnostic.Range; + const position = `line ${Start.Line}:${Start.Character} - line ${End.Line}:${End.Character}`; + + return `${position} \n ${diagnostic.Message}\n`; +} + export function combineMessage(diagnostics: Diagnostic[]): string { return diagnostics.reduce((msg, d) => { - const { Start, End } = d.Range; - const position = `line ${Start.Line}:${Start.Character} - line ${End.Line}:${End.Character}`; - - msg += `${position} \n ${d.Message}\n`; + msg += createSingleMessage(d); return msg; }, ''); }