diff --git a/Composer/packages/client/__tests__/components/design.test.tsx b/Composer/packages/client/__tests__/components/design.test.tsx index dd71a51c56..c893547ab9 100644 --- a/Composer/packages/client/__tests__/components/design.test.tsx +++ b/Composer/packages/client/__tests__/components/design.test.tsx @@ -3,39 +3,40 @@ import * as React from 'react'; import { fireEvent } from '@botframework-composer/test-utils'; -import { DialogInfo } from '@bfc/shared'; import { renderWithRecoil } from '../testUtils'; -import { dialogs } from '../constants.json'; +import { SAMPLE_DIALOG } from '../mocks/sampleDialog'; import { ProjectTree } from '../../src/components/ProjectTree/ProjectTree'; import { TriggerCreationModal } from '../../src/components/ProjectTree/TriggerCreationModal'; import { CreateDialogModal } from '../../src/pages/design/createDialogModal'; +import { dialogsState, currentProjectIdState, botProjectIdsState, schemasState } from '../../src/recoilModel'; jest.mock('@bfc/code-editor', () => { return { LuEditor: () =>
, }; }); -const projectId = '1234a-324234'; +const projectId = '12345.6789'; +const dialogs = [SAMPLE_DIALOG]; + +const initRecoilState = ({ set }) => { + set(currentProjectIdState, projectId); + set(botProjectIdsState, [projectId]); + set(dialogsState(projectId), dialogs); + set(schemasState(projectId), { sdk: { content: {} } }); +}; describe('', () => { it('should render the ProjectTree', async () => { - const dialogId = 'todobot'; - const selected = 'triggers[0]'; const handleSelect = jest.fn(() => {}); const handleDeleteDialog = jest.fn(() => {}); const handleDeleteTrigger = jest.fn(() => {}); - const { findByText } = renderWithRecoil( - + + const { findByTestId } = renderWithRecoil( + , + initRecoilState ); - const node = await findByText('addtodo'); + const node = await findByTestId('EchoBot-1_Greeting'); fireEvent.click(node); expect(handleSelect).toHaveBeenCalledTimes(1); }); diff --git a/Composer/packages/client/__tests__/components/projecttree.test.tsx b/Composer/packages/client/__tests__/components/projecttree.test.tsx index 52d32712b1..b23859a3d4 100644 --- a/Composer/packages/client/__tests__/components/projecttree.test.tsx +++ b/Composer/packages/client/__tests__/components/projecttree.test.tsx @@ -4,41 +4,57 @@ import * as React from 'react'; import { fireEvent } from '@botframework-composer/test-utils'; -import { dialogs } from '../constants.json'; import { ProjectTree } from '../../src/components/ProjectTree/ProjectTree'; import { renderWithRecoil } from '../testUtils'; +import { SAMPLE_DIALOG } from '../mocks/sampleDialog'; +import { dialogsState, currentProjectIdState, botProjectIdsState, schemasState } from '../../src/recoilModel'; + +const projectId = '12345.6789'; +const dialogs = [SAMPLE_DIALOG]; + +const initRecoilState = ({ set }) => { + set(currentProjectIdState, projectId); + set(botProjectIdsState, [projectId]); + set(dialogsState(projectId), dialogs); + set(schemasState(projectId), { sdk: { content: {} } }); +}; describe('', () => { it('should render the projecttree', async () => { const { findByText } = renderWithRecoil( - {}} - onDeleteTrigger={() => {}} - onSelect={() => {}} - /> + {}} onDeleteTrigger={() => {}} onSelect={() => {}} />, + initRecoilState ); - await findByText('ToDoBot'); + await findByText('EchoBot-1'); }); it('should handle project tree item click', async () => { const mockFileSelect = jest.fn(() => null); + const component = renderWithRecoil( + {}} onDeleteTrigger={() => {}} onSelect={mockFileSelect} />, + initRecoilState + ); + + const node = await component.findByTestId('EchoBot-1_Greeting'); + fireEvent.click(node); + expect(mockFileSelect).toHaveBeenCalledTimes(1); + }); + + it('fires the onSelectAll event', async () => { + const mockOnSelected = jest.fn(); const { findByText } = renderWithRecoil( {}} onDeleteTrigger={() => {}} - onSelect={mockFileSelect} - /> + onSelect={() => {}} + onSelectAllLink={mockOnSelected} + />, + initRecoilState ); - const node = await findByText('addtodo'); + const node = await findByText('All'); fireEvent.click(node); - expect(mockFileSelect).toHaveBeenCalledTimes(1); + expect(mockOnSelected).toHaveBeenCalledTimes(1); }); }); diff --git a/Composer/packages/client/__tests__/mocks/sampleDialog.ts b/Composer/packages/client/__tests__/mocks/sampleDialog.ts new file mode 100644 index 0000000000..a63b624a70 --- /dev/null +++ b/Composer/packages/client/__tests__/mocks/sampleDialog.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This is a copy of the JSON that defines the EchoBot sample plus some +// additional dialogs and triggers, including a trigger with a syntax +// error for use with testing error messages. + +export const SAMPLE_DIALOG = { + isRoot: true, + displayName: 'EchoBot-1', + id: 'echobot-1', + content: { + $kind: 'Microsoft.AdaptiveDialog', + $designer: { id: '433224', description: '', name: 'EchoBot-1' }, + autoEndDialog: true, + defaultResultProperty: 'dialog.result', + triggers: [ + { + $kind: 'Microsoft.OnUnknownIntent', + $designer: { id: '821845' }, + actions: [ + { $kind: 'Microsoft.SendActivity', $designer: { id: '003038' }, activity: '${SendActivity_003038()}' }, + ], + }, + { + $kind: 'Microsoft.OnConversationUpdateActivity', + $designer: { id: '376720' }, + actions: [ + { + $kind: 'Microsoft.Foreach', + $designer: { id: '518944', name: 'Loop: for each item' }, + itemsProperty: 'turn.Activity.membersAdded', + actions: [ + { + $kind: 'Microsoft.IfCondition', + $designer: { id: '641773', name: 'Branch: if/else' }, + condition: 'string(dialog.foreach.value.id) != string(turn.Activity.Recipient.id)', + actions: [ + { + $kind: 'Microsoft.SendActivity', + $designer: { id: '859266', name: 'Send a response' }, + activity: '${SendActivity_Welcome()}', + }, + ], + }, + ], + }, + ], + }, + { $kind: 'Microsoft.OnError', $designer: { id: 'XVSGCI' } }, + { + $kind: 'Microsoft.OnIntent', + $designer: { id: 'QIgTMy', name: 'more errors' }, + intent: 'test', + actions: [{ $kind: 'Microsoft.SetProperty', $designer: { id: 'VyWC7G' }, value: '=[' }], + }, + ], + generator: 'echobot-1.lg', + $schema: + 'https://raw.githubusercontent.com/microsoft/BotFramework-Composer/stable/Composer/packages/server/schemas/sdk.schema', + id: 'EchoBot-1', + recognizer: 'echobot-1.lu.qna', + }, + diagnostics: [ + { + message: + "must be an expression: syntax error at line 1:1 mismatched input '' expecting {STRING_INTERPOLATION_START, '+', '-', '!', '(', '[', ']', '{', NUMBER, IDENTIFIER, STRING}", + source: 'echobot-1', + severity: 0, + path: 'echobot-1.triggers[3].actions[0]#Microsoft.SetProperty#value', + }, + ], + referredDialogs: [], + lgTemplates: [ + { name: 'SendActivity_003038', path: 'echobot-1.triggers[0].actions[0]' }, + { name: 'SendActivity_Welcome', path: 'echobot-1.triggers[1].actions[0].actions[0].actions[0]' }, + ], + referredLuIntents: [{ name: 'test', path: 'echobot-1.triggers[3]#Microsoft.OnIntent' }], + luFile: 'echobot-1', + qnaFile: 'echobot-1', + lgFile: 'echobot-1', + triggers: [ + { id: 'triggers[0]', displayName: '', type: 'Microsoft.OnUnknownIntent', isIntent: false }, + { id: 'triggers[1]', displayName: '', type: 'Microsoft.OnConversationUpdateActivity', isIntent: false }, + { id: 'triggers[2]', displayName: '', type: 'Microsoft.OnError', isIntent: false }, + { id: 'triggers[3]', displayName: 'more errors', type: 'Microsoft.OnIntent', isIntent: true }, + ], + intentTriggers: [ + { intent: 'test', dialogs: [] }, + { intent: 'test', dialogs: [] }, + ], + skills: [], +}; diff --git a/Composer/packages/client/__tests__/utils/navigation.test.ts b/Composer/packages/client/__tests__/utils/navigation.test.ts index a13499e194..c28892c8aa 100644 --- a/Composer/packages/client/__tests__/utils/navigation.test.ts +++ b/Composer/packages/client/__tests__/utils/navigation.test.ts @@ -14,6 +14,7 @@ import { } from './../../src/utils/navigation'; const projectId = '123a-sdf123'; +const skillId = '98765.4321'; describe('getFocusPath', () => { it('return focus path', () => { @@ -94,13 +95,22 @@ describe('composer url util', () => { }); it('convert path to url', () => { - const result1 = convertPathToUrl(projectId, 'main'); - expect(result1).toEqual(`/bot/${projectId}/dialogs/main`); - const result2 = convertPathToUrl(projectId, 'main', 'main.triggers[0].actions[0]'); - expect(result2).toEqual(`/bot/${projectId}/dialogs/main?selected=triggers[0]&focused=triggers[0].actions[0]`); - const result3 = convertPathToUrl(projectId, 'main', 'main.triggers[0].actions[0]#Microsoft.TextInput#prompt'); + const result1 = convertPathToUrl(projectId, skillId, 'main'); + expect(result1).toEqual(`/bot/${projectId}/skill/${skillId}/dialogs/main`); + const result2 = convertPathToUrl(projectId, skillId, 'main', 'main.triggers[0].actions[0]'); + expect(result2).toEqual( + `/bot/${projectId}/skill/${skillId}/dialogs/main?selected=triggers[0]&focused=triggers[0].actions[0]` + ); + const result3 = convertPathToUrl( + projectId, + skillId, + 'main', + 'main.triggers[0].actions[0]#Microsoft.TextInput#prompt' + ); expect(result3).toEqual( - `/bot/${projectId}/dialogs/main?selected=triggers[0]&focused=triggers[0].actions[0]#botAsks` + `/bot/${projectId}/skill/${skillId}/dialogs/main?selected=triggers[0]&focused=triggers[0].actions[0]#botAsks` ); + const result4 = convertPathToUrl(projectId, null, 'main'); + expect(result4).toEqual(`/bot/${projectId}/dialogs/main`); }); }); diff --git a/Composer/packages/client/src/components/AppComponents/RightPanel.tsx b/Composer/packages/client/src/components/AppComponents/RightPanel.tsx index 0d13f00346..bcf2bf3064 100644 --- a/Composer/packages/client/src/components/AppComponents/RightPanel.tsx +++ b/Composer/packages/client/src/components/AppComponents/RightPanel.tsx @@ -5,12 +5,21 @@ import { jsx, css } from '@emotion/core'; import { useRecoilValue } from 'recoil'; import { forwardRef } from 'react'; +// import formatMessage from 'format-message'; import { RequireAuth } from '../RequireAuth'; import { ErrorBoundary } from '../ErrorBoundary'; +import { Conversation } from '../Conversation'; +//import { ProjectTree } from '../ProjectTree/ProjectTree'; +//import { LeftRightSplit } from '../Split/LeftRightSplit'; import Routes from './../../router'; -import { applicationErrorState, dispatcherState, currentProjectIdState } from './../../recoilModel'; +import { + applicationErrorState, + dispatcherState, + currentProjectIdState, + // currentModeState, +} from './../../recoilModel'; // -------------------- Styles -------------------- // @@ -33,10 +42,20 @@ const content = css` const Content = forwardRef((props, ref) =>
); +// const SHOW_TREE = ['design']; + export const RightPanel = () => { const applicationError = useRecoilValue(applicationErrorState); const { setApplicationLevelError, fetchProjectById } = useRecoilValue(dispatcherState); const projectId = useRecoilValue(currentProjectIdState); + //const currentMode = useRecoilValue(currentModeState); + + const conversation = ( + + + + ); + return (
{ setApplicationLevelError={setApplicationLevelError} > - +
+ {/* + {SHOW_TREE.includes(currentMode) ? ( + + + {conversation} + + ) : ( */} + {conversation} + {/* })} */} +
diff --git a/Composer/packages/client/src/components/ProjectTree/ExpandableNode.tsx b/Composer/packages/client/src/components/ProjectTree/ExpandableNode.tsx new file mode 100644 index 0000000000..cf9d64f5df --- /dev/null +++ b/Composer/packages/client/src/components/ProjectTree/ExpandableNode.tsx @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import { useState, MouseEvent, KeyboardEvent } from 'react'; + +type Props = { + children: React.ReactNode; + summary: React.ReactNode; + depth?: number; + detailsRef?: (el: HTMLElement | null) => void; +}; + +const summaryStyle = css` + label: summary; + display: flex; + padding-left: 12px; + padding-top: 6px; +`; + +const nodeStyle = (depth: number) => css` + margin-left: ${depth * 16}px; +`; + +export const ExpandableNode = ({ children, summary, detailsRef, depth = 0 }: Props) => { + const [isExpanded, setExpanded] = useState(true); + + function handleClick(ev: MouseEvent) { + if ((ev.target as Element)?.tagName.toLowerCase() === 'summary') { + setExpanded(!isExpanded); + } + ev.preventDefault(); + } + + function handleKey(ev: KeyboardEvent) { + if (ev.key === 'Enter' || ev.key === 'Space') setExpanded(!isExpanded); + } + + return ( +
+
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */} + + {summary} + + {children} +
+
+ ); +}; diff --git a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx index 45d4910a26..fe9a06b5dc 100644 --- a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx +++ b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx @@ -2,40 +2,27 @@ // Licensed under the MIT License. /** @jsx jsx */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { jsx, css } from '@emotion/core'; -import { - GroupedList, - IGroup, - IGroupHeaderProps, - IGroupRenderProps, - IGroupedList, -} from 'office-ui-fabric-react/lib/GroupedList'; import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox'; import { FocusZone, FocusZoneDirection } from 'office-ui-fabric-react/lib/FocusZone'; import cloneDeep from 'lodash/cloneDeep'; import formatMessage from 'format-message'; -import { DialogInfo, ITrigger } from '@bfc/shared'; +import { DialogInfo, ITrigger, Diagnostic, DiagnosticSeverity } from '@bfc/shared'; import debounce from 'lodash/debounce'; import { useRecoilValue } from 'recoil'; -import { IGroupedListStyles } from 'office-ui-fabric-react/lib/GroupedList'; import { ISearchBoxStyles } from 'office-ui-fabric-react/lib/SearchBox'; +import isEqual from 'lodash/isEqual'; -import { dispatcherState } from '../../recoilModel'; -import { createSelectedPath, getFriendlyName } from '../../utils/dialogUtil'; -import { containUnsupportedTriggers, triggerNotSupported } from '../../utils/dialogValidator'; +import { dispatcherState, currentProjectIdState, botProjectSpaceSelector } from '../../recoilModel'; +import { getFriendlyName } from '../../utils/dialogUtil'; +import { triggerNotSupported } from '../../utils/dialogValidator'; import { TreeItem } from './treeItem'; +import { ExpandableNode } from './ExpandableNode'; // -------------------- Styles -------------------- // -const groupListStyle: Partial = { - root: { - width: '100%', - boxSizing: 'border-box', - }, -}; - const searchBox: ISearchBoxStyles = { root: { borderBottom: '1px solid #edebe9', @@ -55,28 +42,48 @@ const root = css` } `; +const icons = { + TRIGGER: 'LightningBolt', + DIALOG: 'Org', + BOT: 'CubeShape', + EXTERNAL_SKILL: 'Globe', + FORM_DIALOG: '', + FORM_FIELD: 'Variable2', // x in parentheses + FORM_TRIGGER: 'TriggerAuto', // lightning bolt with gear + FILTER: 'Filter', +}; + +const tree = css` + height: 100%; + overflow-x: hidden; + overflow-y: auto; + height: 100%; + label: tree; +`; + +const SUMMARY_ARROW_SPACE = 28; // the rough pixel size of the dropdown arrow to the left of a Details/Summary element + // -------------------- ProjectTree -------------------- // -function createGroupItem(dialog: DialogInfo, currentId: string, position: number, warningContent: string): IGroup { - return { - key: dialog.id, - name: dialog.displayName, - level: 1, - startIndex: position, - count: dialog.triggers.length, - hasMoreData: true, - isCollapsed: dialog.id !== currentId, - data: { ...dialog, warningContent }, - }; -} +export type TreeLink = { + displayName: string; + isRoot: boolean; + warningContent?: string; + errorContent?: string; + projectId: string; + skillId: string | null; + dialogName?: string; + trigger?: number; +}; -function createItem(trigger: ITrigger, index: number, warningContent: string) { - return { - ...trigger, - index, - warningContent, - displayName: trigger.displayName || getFriendlyName({ $kind: trigger.type }), - }; +export type TreeMenuItem = { + icon?: string; + label: string; // leave this blank to place a separator + onClick?: (link: TreeLink) => void; +}; + +function getTriggerName(trigger: ITrigger) { + return trigger.displayName || getFriendlyName({ $kind: trigger.type }); } function sortDialog(dialogs: DialogInfo[]) { @@ -92,88 +99,201 @@ function sortDialog(dialogs: DialogInfo[]) { }); } -function createItemsAndGroups( - dialogs: DialogInfo[], - dialogId: string, - filter: string -): { items: any[]; groups: IGroup[] } { - let position = 0; - const result = dialogs - .filter((dialog) => { - return dialog.displayName.toLowerCase().includes(filter.toLowerCase()); - }) - .reduce( - (result: { items: any[]; groups: IGroup[] }, dialog) => { - const warningContent = containUnsupportedTriggers(dialog); - result.groups.push(createGroupItem(dialog, dialogId, position, warningContent)); - position += dialog.triggers.length; - dialog.triggers.forEach((item, index) => { - const warningContent = triggerNotSupported(dialog, item); - result.items.push(createItem(item, index, warningContent)); - }); - return result; - }, - { items: [], groups: [] } - ); - return result; -} - -interface IProjectTreeProps { +type BotInProject = { dialogs: DialogInfo[]; - dialogId: string; - selected: string; - onSelect: (id: string, selected?: string) => void; + projectId: string; + name: string; + isRemote: boolean; +}; + +type Props = { + onSelect?: (link: TreeLink) => void; + onSelectAllLink?: () => void; + showTriggers?: boolean; + showDialogs?: boolean; + navLinks?: TreeLink[]; onDeleteTrigger: (id: string, index: number) => void; onDeleteDialog: (id: string) => void; -} +}; -export const ProjectTree: React.FC = (props) => { - const { onboardingAddCoachMarkRef } = useRecoilValue(dispatcherState); +export const ProjectTree: React.FC = ({ + onSelectAllLink: onAllSelected = undefined, + showTriggers = true, + showDialogs = true, + onDeleteDialog, + onDeleteTrigger, + onSelect, +}) => { + const { onboardingAddCoachMarkRef, selectTo, navTo } = useRecoilValue(dispatcherState); - const groupRef: React.RefObject = useRef(null); - const { dialogs, dialogId, selected, onSelect, onDeleteTrigger, onDeleteDialog } = props; const [filter, setFilter] = useState(''); + const [selectedLink, setSelectedLink] = useState(); const delayedSetFilter = debounce((newValue) => setFilter(newValue), 1000); const addMainDialogRef = useCallback((mainDialog) => onboardingAddCoachMarkRef({ mainDialog }), []); + const projectCollection = useRecoilValue(botProjectSpaceSelector).map((bot) => ({ + ...bot, + hasWarnings: false, + })); + const currentProjectId = useRecoilValue(currentProjectIdState); + const botProjectSpace = useRecoilValue(botProjectSpaceSelector); + + const notificationMap: { [projectId: string]: { [dialogId: string]: Diagnostic[] } } = {}; + for (const bot of projectCollection) { + notificationMap[bot.projectId] = {}; + + const matchingBot = botProjectSpace.filter((project) => project.projectId === bot.projectId)[0]; + if (matchingBot == null) continue; // should never happen, but just to be safe + + for (const dialog of matchingBot.dialogs) { + const dialogId = dialog.id; + notificationMap[bot.projectId][dialogId] = dialog.diagnostics; + } + } + + const dialogHasWarnings = (dialog: DialogInfo) => { + notificationMap[currentProjectId][dialog.id].some((diag) => diag.severity === DiagnosticSeverity.Warning); + }; + + const botHasWarnings = (bot: BotInProject) => { + return bot.dialogs.some(dialogHasWarnings); + }; - const sortedDialogs = useMemo(() => { - return sortDialog(dialogs); - }, [dialogs]); + const dialogHasErrors = (dialog: DialogInfo) => { + notificationMap[currentProjectId][dialog.id].some((diag) => diag.severity === DiagnosticSeverity.Error); + }; + + const botHasErrors = (bot: BotInProject) => { + return bot.dialogs.some(dialogHasErrors); + }; - const onRenderHeader = (props: IGroupHeaderProps) => { - const toggleCollapse = (): void => { - groupRef.current?.toggleCollapseAll(true); - props.onToggleCollapse?.(props.group!); - onSelect(props.group!.key); + const handleOnSelect = (link: TreeLink) => { + setSelectedLink(link); + onSelect?.(link); // if we've defined a custom onSelect, use it + if (link.dialogName != null) { + if (link.trigger != null) { + selectTo(link.projectId, link.skillId, link.dialogName, `triggers[${link.trigger}]`); + } else { + navTo(link.projectId, link.skillId, link.dialogName); + } + } + }; + + const renderBotHeader = (bot: BotInProject) => { + const link: TreeLink = { + displayName: bot.name, + projectId: currentProjectId, + skillId: bot.projectId, + isRoot: true, + warningContent: botHasWarnings(bot) ? formatMessage('This bot has warnings') : undefined, + errorContent: botHasErrors(bot) ? formatMessage('This bot has errors') : undefined, }; + return ( - + {} }]} /> ); }; - function onRenderCell(nestingDepth?: number, item?: any): React.ReactNode { + const renderDialogHeader = (skillId: string, dialog: DialogInfo) => { + const warningContent = notificationMap[currentProjectId][dialog.id] + .filter((diag) => diag.severity === DiagnosticSeverity.Warning) + .map((diag) => diag.message) + .join(','); + const errorContent = notificationMap[currentProjectId][dialog.id] + .filter((diag) => diag.severity === DiagnosticSeverity.Error) + .map((diag) => diag.message) + .join(','); + + const link: TreeLink = { + dialogName: dialog.id, + displayName: dialog.displayName, + isRoot: dialog.isRoot, + projectId: currentProjectId, + skillId: null, + errorContent, + warningContent, + }; + return ( + + { + onDeleteDialog(link.dialogName ?? ''); + }, + }, + ]} + onSelect={handleOnSelect} + /> + + ); + }; + + const renderTrigger = (projectId: string, item: any, dialog: DialogInfo): React.ReactNode => { + // NOTE: put the form-dialog detection here when it's ready + const link: TreeLink = { + displayName: item.displayName, + warningContent: item.warningContent, + errorContent: item.errorContent, + trigger: item.index, + dialogName: dialog.id, + isRoot: false, + projectId: currentProjectId, + skillId: null, + }; + return ( onDeleteTrigger(dialogId, item.index)} - onSelect={() => onSelect(dialogId, createSelectedPath(item.index))} + key={`${item.id}_${item.index}`} + dialogName={dialog.displayName} + forceIndent={48} + icon={icons.TRIGGER} + isActive={isEqual(link, selectedLink)} + link={link} + menu={[ + { + label: formatMessage('Remove this trigger'), + icon: 'Delete', + onClick: (link) => { + onDeleteTrigger(link.dialogName ?? '', link.trigger ?? 0); + }, + }, + ]} + onSelect={handleOnSelect} /> ); - } - - const onRenderShowAll = () => { - return null; }; const onFilter = (_e?: any, newValue?: string): void => { @@ -182,7 +302,69 @@ export const ProjectTree: React.FC = (props) => { } }; - const itemsAndGroups: { items: any[]; groups: IGroup[] } = createItemsAndGroups(sortedDialogs, dialogId, filter); + const filterMatch = (scope: string): boolean => { + return scope.toLowerCase().includes(filter.toLowerCase()); + }; + + const createDetailsTree = (bot: BotInProject, startDepth: number) => { + const { projectId } = bot; + const dialogs = sortDialog(bot.dialogs); + + const filteredDialogs = + filter == null || filter.length === 0 + ? dialogs + : dialogs.filter( + (dialog) => + filterMatch(dialog.displayName) || dialog.triggers.some((trigger) => filterMatch(getTriggerName(trigger))) + ); + + if (showTriggers) { + return filteredDialogs.map((dialog: DialogInfo) => { + const triggerList = dialog.triggers + .filter((tr) => filterMatch(dialog.displayName) || filterMatch(getTriggerName(tr))) + .map((tr, index) => { + const warningContent = triggerNotSupported(dialog, tr); + const errorContent = notificationMap[projectId][dialog.id].some( + (diag) => diag.severity === DiagnosticSeverity.Error && diag.path?.match(RegExp(`triggers\\[${index}\\]`)) + ); + return renderTrigger( + projectId, + { ...tr, index, displayName: getTriggerName(tr), warningContent, errorContent }, + dialog + ); + }); + return ( + +
{triggerList}
+
+ ); + }); + } else { + return filteredDialogs.map((dialog: DialogInfo) => renderDialogHeader(projectId, dialog)); + } + }; + + const createBotSubtree = (bot: BotInProject & { hasWarnings: boolean }) => { + if (showDialogs && !bot.isRemote) { + return ( + +
{createDetailsTree(bot, 1)}
+
+ ); + } else { + return renderBotHeader(bot); + } + }; + + const projectTree = + projectCollection.length === 1 + ? createDetailsTree(projectCollection[0], 0) + : projectCollection.map(createBotSubtree); return (
= (props) => { = (props) => { aria-label={formatMessage( `{ dialogNum, plural, - =0 {No dialogs} - =1 {One dialog} - other {# dialogs} + =0 {No bots} + =1 {One bot} + other {# bots} } have been found. { dialogNum, select, 0 {} other {Press down arrow key to navigate the search results} }`, - { dialogNum: itemsAndGroups.groups.length } + { dialogNum: projectCollection.length } )} aria-live={'polite'} /> - - } - styles={groupListStyle} - onRenderCell={onRenderCell} - /> +
+ {onAllSelected != null ? ( + + ) : null} + {projectTree} +
); diff --git a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx index fc32818c8b..a4ba777ae9 100644 --- a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx +++ b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { FontWeights } from '@uifabric/styling'; import { OverflowSet, IOverflowSetItemProps } from 'office-ui-fabric-react/lib/OverflowSet'; import { TooltipHost, DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; +import { ContextualMenuItemType, IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; import { IconButton } from 'office-ui-fabric-react/lib/Button'; import { Icon } from 'office-ui-fabric-react/lib/Icon'; import formatMessage from 'format-message'; @@ -15,15 +16,17 @@ import { IButtonStyles } from 'office-ui-fabric-react/lib/Button'; import { IContextualMenuStyles } from 'office-ui-fabric-react/lib/ContextualMenu'; import { ICalloutContentStyles } from 'office-ui-fabric-react/lib/Callout'; +import { TreeLink, TreeMenuItem } from './ProjectTree'; + // -------------------- Styles -------------------- // -const indent = 16; -const itemText = (depth: number) => css` +const indent = 8; +const itemText = css` outline: none; :focus { outline: rgb(102, 102, 102) solid 1px; z-index: 1; } - padding-left: ${depth * indent}px; + padding-left: ${indent}px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; @@ -42,15 +45,9 @@ const content = css` label: ProjectTreeItem; `; -const leftIndent = css` - height: 100%; - width: ${indent}px; -`; - const moreMenu: Partial = { root: { - marginTop: '-7px', - width: '100px', + marginTop: '-1px', }, }; @@ -77,13 +74,14 @@ const moreButton = (isActive: boolean): IButtonStyles => { }; }; -const navItem = (isActive: boolean, isSubItemActive: boolean) => css` - width: 100%; +const navItem = (isActive: boolean, shift: number) => css` + width: calc(100%-${shift}px); position: relative; - height: 36px; + height: 24px; font-size: 12px; - color: #545454; - background: ${isActive && !isSubItemActive ? '#f2f2f2' : 'transparent'}; + margin-left: ${shift}px; + color: ${isActive ? '#ffffff' : '#545454'}; + background: ${isActive ? '#0078d4' : 'transparent'}; font-weight: ${isActive ? FontWeights.semibold : FontWeights.regular}; &:hover { color: #545454; @@ -113,58 +111,81 @@ const navItem = (isActive: boolean, isSubItemActive: boolean) => css` export const overflowSet = css` width: 100%; height: 100%; - padding-left: 12px; padding-right: 12px; box-sizing: border-box; - line-height: 36px; + line-height: 24px; justify-content: space-between; display: flex; - justify-content: space-between; `; +const statusIcon = { + width: '24px', + height: '18px', + fontSize: 16, + marginLeft: 6, +}; + const warningIcon = { - marginRight: 5, + ...statusIcon, color: '#BE880A', - fontSize: 9, +}; + +const errorIcon = { + ...statusIcon, + color: '#CC3F3F', }; // -------------------- TreeItem -------------------- // interface ITreeItemProps { - link: any; - isActive: boolean; + link: TreeLink; + isActive?: boolean; isSubItemActive?: boolean; - depth: number | undefined; - onDelete: (id: string) => void; - onSelect: (id: string) => void; + menu?: TreeMenuItem[]; + onSelect?: (link: TreeLink) => void; + icon?: string; + dialogName?: string; + showProps?: boolean; + forceIndent?: number; // needed to make an outline look right; should be the size of the "details" reveal arrow } +const renderTreeMenuItem = (link: TreeLink) => (item: TreeMenuItem) => { + if (item.label === '') { + return { + key: 'divider', + itemType: ContextualMenuItemType.Divider, + }; + } + return { + key: item.label, + ariaLabel: item.label, + text: item.label, + iconProps: { iconName: item.icon }, + onClick: () => { + item.onClick?.(link); + }, + }; +}; + const onRenderItem = (item: IOverflowSetItemProps) => { - const warningContent = formatMessage( - 'This trigger type is not supported by the RegEx recognizer and will not be fired.' - ); + const { warningContent, errorContent } = item; return (
-
- {item.warningContent ? ( - - - - ) : ( -
- )} - {item.depth !== 0 && ( +
+ {item.icon != null && ( { /> )} {item.displayName} + {item.errorContent && ( + + + + )} + {item.warningContent && ( + + + + )}
); }; -const onRenderOverflowButton = (isRoot: boolean, isActive: boolean) => { +const onRenderOverflowButton = (isActive: boolean) => { const moreLabel = formatMessage('Actions'); - const showIcon = !isRoot; - return (overflowItems) => { - return showIcon ? ( + return (overflowItems: IContextualMenuItem[] | undefined) => { + if (overflowItems == null) return null; + return ( { }} /> - ) : null; + ); }; }; -export const TreeItem: React.FC = (props) => { - const { link, isActive, isSubItemActive, depth, onDelete, onSelect } = props; +export const TreeItem: React.FC = ({ + link, + isActive = false, + icon, + dialogName, + forceIndent: shiftOut, + onSelect, + menu = [], +}) => { + const a11yLabel = `${dialogName ?? '$Root'}_${link.displayName}`; + + const overflowMenu = menu.map(renderTreeMenuItem(link)); + + const linkString = `${link.projectId}_DialogTreeItem${link.dialogName}_${link.trigger ?? ''}`; return (
{ - onSelect(link.id); + onSelect?.(link); }} onKeyDown={(e) => { if (e.key === 'Enter') { - onSelect(link.id); + onSelect?.(link); } }} > @@ -225,25 +271,19 @@ export const TreeItem: React.FC = (props) => { //remove this at that time doNotContainWithinFocusZone css={overflowSet} - data-testid={`DialogTreeItem${link.id}`} + data-testid={linkString} items={[ { - key: link.id, - depth, + key: linkString, + icon, ...link, }, ]} - overflowItems={[ - { - key: 'delete', - name: formatMessage('Delete'), - onClick: () => onDelete(link.id), - }, - ]} + overflowItems={overflowMenu} role="row" styles={{ item: { flex: 1 } }} onRenderItem={onRenderItem} - onRenderOverflowButton={onRenderOverflowButton(link.isRoot, isActive)} + onRenderOverflowButton={onRenderOverflowButton(!!isActive)} />
); diff --git a/Composer/packages/client/src/components/Split/LeftRightSplit.tsx b/Composer/packages/client/src/components/Split/LeftRightSplit.tsx index 8b88b48e3d..50f5bb550c 100644 --- a/Composer/packages/client/src/components/Split/LeftRightSplit.tsx +++ b/Composer/packages/client/src/components/Split/LeftRightSplit.tsx @@ -33,6 +33,7 @@ const Left = styled.div` outline: none; overflow: hidden; grid-area: left; + label: LeftSplit; `; const Split = styled.div` @@ -65,6 +66,7 @@ const Right = styled.div` outline: none; overflow: hidden; grid-area: right; + label: SplitRight; `; // ensures a value can be used in gridTemplateColumns diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index e2bd82afd3..9947337650 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -48,6 +48,7 @@ import { showCreateDialogModalState, showAddSkillDialogModalState, localeState, + rootBotProjectIdSelector, qnaFilesState, } from '../../recoilModel'; import { CreateQnAModal } from '../../components/QnA'; @@ -124,6 +125,7 @@ const DesignPage: React.FC { if (location && props.dialogId && props.projectId) { const { dialogId, projectId } = props; + + // TODO: swap to the commented-out block once we're working on skills for real (issue #4429) + // let { skillId } = props; + // if (skillId == null) skillId = projectId; + const params = new URLSearchParams(location.search); const dialogMap = dialogs.reduce((acc, { content, id }) => ({ ...acc, [id]: content }), {}); const dialogData = getDialogData(dialogMap, dialogId); @@ -218,7 +225,7 @@ const DesignPage: React.FC { if (newDialog) { - navTo(projectId, newDialog, []); + navTo(projectId, null, newDialog, []); } }; @@ -455,7 +462,7 @@ const DesignPage: React.FC triggerApi.deleteTrigger(id, trigger)); + async function handleDeleteTrigger(dialogId: string, index: number) { + const content = deleteTrigger(dialogs, dialogId, index, (trigger) => triggerApi.deleteTrigger(dialogId, trigger)); if (content) { - updateDialog({ id, content, projectId }); + updateDialog({ id: dialogId, content, projectId }); const match = /\[(\d+)\]/g.exec(selected); const current = match && match[1]; if (!current) return; @@ -545,14 +552,14 @@ const DesignPage: React.FC= 0) { //if the deleted node is selected and the selected one is not the first one, navTo the previous trigger; - selectTo(projectId, createSelectedPath(currentIdx - 1)); + selectTo(projectId, null, dialogId, createSelectedPath(currentIdx - 1)); } else { //if the deleted node is selected and the selected one is the first one, navTo the first trigger; - navTo(projectId, id, []); + navTo(projectId, null, dialogId, []); } } else if (index < currentIdx) { //if the deleted node is at the front, navTo the current one; - selectTo(projectId, createSelectedPath(currentIdx - 1)); + selectTo(projectId, null, dialogId, createSelectedPath(currentIdx - 1)); } } } @@ -590,9 +597,6 @@ const DesignPage: React.FC handleSelect(projectId, ...props)} diff --git a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx index 9ab350edaa..e3a7bdaa32 100644 --- a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx +++ b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx @@ -104,6 +104,10 @@ const QnAPage: React.FC = (props) => { [dialogId, projectId, edit] ); + useEffect(() => { + actions.setCurrentPageMode('qna'); + }, []); + const toolbarItems = [ { type: 'element', diff --git a/Composer/packages/client/src/pages/notifications/Notifications.tsx b/Composer/packages/client/src/pages/notifications/Notifications.tsx index d449358bb5..89d0377eed 100644 --- a/Composer/packages/client/src/pages/notifications/Notifications.tsx +++ b/Composer/packages/client/src/pages/notifications/Notifications.tsx @@ -47,7 +47,7 @@ const Notifications: React.FC> = (pro //path is like main.trigers[0].actions[0] //uri = id?selected=triggers[0]&focused=triggers[0].actions[0] const { projectId, id, dialogPath } = item; - const uri = convertPathToUrl(projectId, id, dialogPath); + const uri = convertPathToUrl(projectId, id, dialogPath ?? ''); navigateTo(uri); }, [NotificationType.SKILL]: (item: INotification) => { diff --git a/Composer/packages/client/src/pages/publish/Publish.tsx b/Composer/packages/client/src/pages/publish/Publish.tsx index ac89b47bbe..fd02c2523a 100644 --- a/Composer/packages/client/src/pages/publish/Publish.tsx +++ b/Composer/packages/client/src/pages/publish/Publish.tsx @@ -48,6 +48,7 @@ const Publish: React.FC { + setCurrentPageMode('notifications'); + }, []); + return ( = () => { addLanguages, deleteLanguages, fetchProjectById, + setCurrentPageMode, } = useRecoilValue(dispatcherState); const locale = useRecoilValue(localeState(projectId)); const showDelLanguageModal = useRecoilValue(showDelLanguageModalState(projectId)); @@ -58,6 +59,7 @@ const SettingPage: React.FC = () => { // use cached projectId do fetch. const cachedProjectId = useProjectIdCache(); useEffect(() => { + setCurrentPageMode('settings'); if (!projectId && cachedProjectId) { fetchProjectById(cachedProjectId); } diff --git a/Composer/packages/client/src/recoilModel/atoms/appState.ts b/Composer/packages/client/src/recoilModel/atoms/appState.ts index 906ffd8832..22c585d39a 100644 --- a/Composer/packages/client/src/recoilModel/atoms/appState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/appState.ts @@ -30,6 +30,18 @@ export type CurrentUser = { sessionExpired: boolean; }; +export type PageMode = + | 'home' + | 'design' + | 'lg' + | 'lu' + | 'qna' + | 'notifications' + | 'publish' + | 'skills' + | 'settings' + | 'about'; + const getFullyQualifiedKey = (value: string) => { return `App_${value}_State`; }; @@ -180,6 +192,11 @@ export const currentProjectIdState = atom({ default: '', }); +export const currentModeState = atom({ + key: getFullyQualifiedKey('currentMode'), + default: 'home', +}); + export const botProjectSpaceLoadedState = atom({ key: getFullyQualifiedKey('botProjectSpaceLoadedState'), default: false, diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/navigation.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/navigation.test.tsx index 7ef6a2d6c4..0ad85f9161 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/navigation.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/navigation.test.tsx @@ -175,27 +175,27 @@ describe('navigation dispatcher', () => { it('navigates to a destination', async () => { mockConvertPathToUrl.mockReturnValue(`/bot/${projectId}/dialogs/dialogId`); await act(async () => { - await dispatcher.navTo(projectId, 'dialogId', []); + await dispatcher.navTo(projectId, null, 'dialogId', []); }); expectNavTo(`/bot/${projectId}/dialogs/dialogId`); - expect(mockConvertPathToUrl).toBeCalledWith(projectId, 'dialogId', undefined); + expect(mockConvertPathToUrl).toBeCalledWith(projectId, null, 'dialogId', undefined); }); it('redirects to the begin dialog trigger', async () => { mockConvertPathToUrl.mockReturnValue(`/bot/${projectId}/dialogs/newDialogId?selection=triggers[0]`); mockCreateSelectedPath.mockReturnValue('triggers[0]'); await act(async () => { - await dispatcher.navTo(projectId, 'newDialogId', []); + await dispatcher.navTo(projectId, null, 'newDialogId', []); }); expectNavTo(`/bot/${projectId}/dialogs/newDialogId?selection=triggers[0]`); - expect(mockConvertPathToUrl).toBeCalledWith(projectId, 'newDialogId', 'triggers[0]'); + expect(mockConvertPathToUrl).toBeCalledWith(projectId, null, 'newDialogId', 'triggers[0]'); expect(mockCreateSelectedPath).toBeCalledWith(0); }); it("doesn't navigate to a destination where we already are", async () => { mockCheckUrl.mockReturnValue(true); await act(async () => { - await dispatcher.navTo(projectId, 'dialogId', []); + await dispatcher.navTo(projectId, null, 'dialogId', []); }); expect(mockNavigateTo).not.toBeCalled(); }); @@ -204,7 +204,7 @@ describe('navigation dispatcher', () => { describe('selectTo', () => { it("doesn't go anywhere without a selection", async () => { await act(async () => { - await dispatcher.selectTo(projectId, ''); + await dispatcher.selectTo(projectId, null, null, ''); }); expect(mockNavigateTo).not.toBeCalled(); }); @@ -212,16 +212,16 @@ describe('navigation dispatcher', () => { it('navigates to a default URL with selected path', async () => { mockConvertPathToUrl.mockReturnValue(`/bot/${projectId}/dialogs/dialogId?selected=selection`); await act(async () => { - await dispatcher.selectTo(projectId, 'selection'); + await dispatcher.selectTo(projectId, null, null, 'selection'); }); expectNavTo(`/bot/${projectId}/dialogs/dialogId?selected=selection`); - expect(mockConvertPathToUrl).toBeCalledWith(projectId, 'dialogId', 'selection'); + expect(mockConvertPathToUrl).toBeCalledWith(projectId, null, 'dialogId', 'selection'); }); it("doesn't go anywhere if we're already there", async () => { mockCheckUrl.mockReturnValue(true); await act(async () => { - await dispatcher.selectTo(projectId, 'selection'); + await dispatcher.selectTo(projectId, null, null, 'selection'); }); expect(mockNavigateTo).not.toBeCalled(); }); @@ -271,7 +271,7 @@ describe('navigation dispatcher', () => { it('sets selection and focus with a valud search', async () => { mockGetUrlSearch.mockReturnValue('?foo=bar&baz=quux'); await act(async () => { - await dispatcher.selectAndFocus(projectId, 'dialogId', 'select', 'focus'); + await dispatcher.selectAndFocus(projectId, null, 'dialogId', 'select', 'focus'); }); expectNavTo(`/bot/${projectId}/dialogs/dialogId?foo=bar&baz=quux`); }); @@ -279,7 +279,7 @@ describe('navigation dispatcher', () => { it("doesn't go anywhere if we're already there", async () => { mockCheckUrl.mockReturnValue(true); await act(async () => { - await dispatcher.selectAndFocus(projectId, 'dialogId', 'select', 'focus'); + await dispatcher.selectAndFocus(projectId, null, 'dialogId', 'select', 'focus'); }); expect(mockNavigateTo).not.toBeCalled(); }); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx index 3800f10c2e..b554051af9 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx @@ -95,7 +95,7 @@ describe('QnA dispatcher', () => { }); }); - expect(renderedComponent.current.qnaFiles[0].content).toContain(content); + expect(renderedComponent.current.qnaFiles[0].content.replace(/\s/g, '')).toContain(content.replace(/\s/g, '')); }); it('should update a qna file', async () => { diff --git a/Composer/packages/client/src/recoilModel/dispatchers/application.ts b/Composer/packages/client/src/recoilModel/dispatchers/application.ts index fe1cd90038..9bd22e28f7 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/application.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/application.ts @@ -5,7 +5,14 @@ import { CallbackInterface, useRecoilCallback } from 'recoil'; import debounce from 'lodash/debounce'; -import { appUpdateState, announcementState, onboardingState, creationFlowStatusState } from '../atoms/appState'; +import { + appUpdateState, + announcementState, + onboardingState, + creationFlowStatusState, + currentModeState, + PageMode, +} from '../atoms/appState'; import { AppUpdaterStatus, CreationFlowStatus } from '../../constants'; import OnboardingState from '../../utils/onboardingStorage'; import { StateError, AppUpdateState } from '../../recoilModel/types'; @@ -68,6 +75,10 @@ export const applicationDispatcher = () => { set(announcementState, message); }); + const setCurrentPageMode = useRecoilCallback(({ set }: CallbackInterface) => (mode: PageMode) => { + set(currentModeState, mode); + }); + const onboardingAddCoachMarkRef = useRecoilCallback( ({ set }: CallbackInterface) => (coachMarkRef: { [key: string]: any }) => { set(onboardingState, (onboardingObj) => ({ @@ -108,5 +119,6 @@ export const applicationDispatcher = () => { onboardingAddCoachMarkRef, setCreationFlowStatus, setApplicationLevelError, + setCurrentPageMode, }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/navigation.ts b/Composer/packages/client/src/recoilModel/dispatchers/navigation.ts index a019472004..95ee4d4598 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/navigation.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/navigation.ts @@ -50,6 +50,7 @@ export const navigationDispatcher = () => { const navTo = useRecoilCallback( ({ snapshot, set }: CallbackInterface) => async ( projectId: string, + skillId: string | null, dialogId: string, breadcrumb: BreadcrumbItem[] = [] ) => { @@ -70,7 +71,7 @@ export const navigationDispatcher = () => { } } - const currentUri = convertPathToUrl(projectId, dialogId, path); + const currentUri = convertPathToUrl(projectId, skillId, dialogId, path); if (checkUrl(currentUri, projectId, designPageLocation)) return; @@ -79,21 +80,24 @@ export const navigationDispatcher = () => { ); const selectTo = useRecoilCallback( - ({ snapshot, set }: CallbackInterface) => async (projectId: string, selectPath: string) => { + ({ snapshot, set }: CallbackInterface) => async ( + projectId: string, + skillId: string | null, + destinationDialogId: string | null, + selectPath: string + ) => { if (!selectPath) return; set(currentProjectIdState, projectId); const designPageLocation = await snapshot.getPromise(designPageLocationState(projectId)); const breadcrumb = await snapshot.getPromise(breadcrumbState(projectId)); - // initial dialogId, projectId maybe empty string "" - let { dialogId } = designPageLocation; - - if (!dialogId) dialogId = 'Main'; + // target dialogId, projectId maybe empty string "" + const dialogId = destinationDialogId ?? designPageLocation.dialogId ?? 'Main'; const dialogs = await snapshot.getPromise(dialogsState(projectId)); const currentDialog = dialogs.find(({ id }) => id === dialogId); const encodedSelectPath = encodeArrayPathToDesignerPath(currentDialog?.content, selectPath); - const currentUri = convertPathToUrl(projectId, dialogId, encodedSelectPath); + const currentUri = convertPathToUrl(projectId, skillId, dialogId, encodedSelectPath); if (checkUrl(currentUri, projectId, designPageLocation)) return; navigateTo(currentUri, { state: { breadcrumb: updateBreadcrumb(breadcrumb, BreadcrumbUpdateType.Selected) } }); @@ -138,6 +142,7 @@ export const navigationDispatcher = () => { const selectAndFocus = useRecoilCallback( ({ snapshot, set }: CallbackInterface) => async ( projectId: string, + skillId: string | null, dialogId: string, selectPath: string, focusPath: string, @@ -157,7 +162,7 @@ export const navigationDispatcher = () => { if (checkUrl(currentUri, projectId, designPageLocation)) return; navigateTo(currentUri, { state: { breadcrumb } }); } else { - navTo(projectId, dialogId, breadcrumb); + navTo(projectId, skillId, dialogId, breadcrumb); } } ); diff --git a/Composer/packages/client/src/shell/triggerApi.ts b/Composer/packages/client/src/shell/triggerApi.ts index f56b9f62d1..48e6fb7a84 100644 --- a/Composer/packages/client/src/shell/triggerApi.ts +++ b/Composer/packages/client/src/shell/triggerApi.ts @@ -103,7 +103,7 @@ function createTriggerApi( }; await updateDialog(dialogPayload); if (autoSelected) { - selectTo(projectId, `triggers[${index}]`); + selectTo(projectId, null, null, `triggers[${index}]`); } }; diff --git a/Composer/packages/client/src/shell/useShell.ts b/Composer/packages/client/src/shell/useShell.ts index 772bbfe669..624cfc90e1 100644 --- a/Composer/packages/client/src/shell/useShell.ts +++ b/Composer/packages/client/src/shell/useShell.ts @@ -135,11 +135,11 @@ export function useShell(source: EventSource, projectId: string): Shell { } function navigationTo(path) { - navTo(projectId, path, breadcrumb); + navTo(projectId, null, path, breadcrumb); } function focusEvent(subPath) { - selectTo(projectId, subPath); + selectTo(projectId, null, null, subPath); } function focusSteps(subPaths: string[] = [], fragment?: string) { diff --git a/Composer/packages/client/src/utils/navigation.ts b/Composer/packages/client/src/utils/navigation.ts index e988b801c9..773e5b0c6b 100644 --- a/Composer/packages/client/src/utils/navigation.ts +++ b/Composer/packages/client/src/utils/navigation.ts @@ -83,11 +83,14 @@ export interface NavigationState { qnaKbUrls?: string[]; } -export function convertPathToUrl(projectId: string, dialogId: string, path?: string): string { +export function convertPathToUrl(projectId: string, skillId: string | null, dialogId: string, path?: string): string { //path is like main.triggers[0].actions[0] //uri = id?selected=triggers[0]&focused=triggers[0].actions[0] - let uri = `/bot/${projectId}/dialogs/${dialogId}`; + let uri = + skillId == null + ? `/bot/${projectId}/dialogs/${dialogId}` + : `/bot/${projectId}/skill/${skillId}/dialogs/${dialogId}`; if (!path) return uri; const items = path.split('#'); diff --git a/Composer/packages/server/src/locales/en-US.json b/Composer/packages/server/src/locales/en-US.json index 432bf1eb90..ec843ebc05 100644 --- a/Composer/packages/server/src/locales/en-US.json +++ b/Composer/packages/server/src/locales/en-US.json @@ -653,6 +653,9 @@ "create_copy_to_translate_bot_content_efc872c": { "message": "Create copy to translate bot content" }, + "create_edit_skill_manifest_1c1b14fe": { + "message": "Create/edit skill manifest" + }, "create_folder_error_38aa86f5": { "message": "Create Folder Error" }, @@ -857,8 +860,8 @@ "dialogfactory_missing_schema_5c3255c4": { "message": "DialogFactory missing schema." }, - "dialognum_plural_0_no_dialogs_1_one_dialog_other_d_1b86909b": { - "message": "{ dialogNum, plural,\n =0 {No dialogs}\n =1 {One dialog}\n other {# dialogs}\n} have been found.\n { dialogNum, select,\n 0 {}\n other {Press down arrow key to navigate the search results}\n}" + "dialognum_plural_0_no_bots_1_one_bot_other_bots_ha_1cf10787": { + "message": "{ dialogNum, plural,\n =0 {No bots}\n =1 {One bot}\n other {# bots}\n} have been found.\n { dialogNum, select,\n 0 {}\n other {Press down arrow key to navigate the search results}\n}" }, "disable_a5c05db3": { "message": "Disable" @@ -2072,6 +2075,12 @@ "remove_f47dc62a": { "message": "Remove" }, + "remove_this_dialog_6146716c": { + "message": "Remove this dialog" + }, + "remove_this_trigger_622d866d": { + "message": "Remove this trigger" + }, "repeat_this_dialog_83ca994e": { "message": "Repeat this dialog" }, @@ -2444,6 +2453,12 @@ "these_tasks_will_be_used_to_generate_the_manifest__2791be0e": { "message": "These tasks will be used to generate the manifest and describe the capabilities of this skill to those who may want to use it." }, + "this_bot_has_errors_72fb40d5": { + "message": "This bot has errors" + }, + "this_bot_has_warnings_b6735e2e": { + "message": "This bot has warnings" + }, "this_configures_a_data_driven_dialog_via_a_collect_c7fa4389": { "message": "This configures a data driven dialog via a collection of events and actions." }, @@ -2468,9 +2483,6 @@ "this_option_allows_your_users_to_give_multiple_val_d2dd0d58": { "message": "This option allows your users to give multiple values for this property." }, - "this_trigger_type_is_not_supported_by_the_regex_re_2c3b7c46": { - "message": "This trigger type is not supported by the RegEx recognizer and will not be fired." - }, "this_trigger_type_is_not_supported_by_the_regex_re_dc3eefa2": { "message": "This trigger type is not supported by the RegEx recognizer. To ensure this trigger is fired, change the recognizer type." },