diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7af231980e..433f6baf11 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,7 +4,7 @@ /runtime/ @boydc2014 @luhan2017 @carlosscastro @a-b-r-o-w-n -/Composer/ @cwhitten @boydc2014 @a-b-r-o-w-n @corinagum @beyackle @srinaath +/Composer/ @cwhitten @boydc2014 @a-b-r-o-w-n @corinagum @beyackle @srinaath @tonyanziano /Composer/packages/extensions/visual-designer @yeze322 @cwhitten @boydc2014 @a-b-r-o-w-n diff --git a/Composer/packages/client/src/components/CreationFlow/LocationBrowser/FileSelector.tsx b/Composer/packages/client/src/components/CreationFlow/LocationBrowser/FileSelector.tsx index 70147ce330..72999b202f 100644 --- a/Composer/packages/client/src/components/CreationFlow/LocationBrowser/FileSelector.tsx +++ b/Composer/packages/client/src/components/CreationFlow/LocationBrowser/FileSelector.tsx @@ -7,6 +7,7 @@ import path from 'path'; import { jsx } from '@emotion/core'; import { useMemo, useState } from 'react'; import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import { Link } from 'office-ui-fabric-react/lib/Link'; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky'; import { ScrollablePane, ScrollbarVisibility } from 'office-ui-fabric-react/lib/ScrollablePane'; @@ -58,13 +59,23 @@ const _renderIcon = (file: File) => { return {`${iconName}; }; -const _renderNameColumn = (file: File) => { +const _renderNameColumn = (onFileChosen: (file: File) => void) => (file: File) => { const iconName = getFileIconName(file); return (
-
+ onFileChosen(file)} + > {file.name} -
+
); }; @@ -99,7 +110,7 @@ export const FileSelector: React.FC = props => { sortAscendingAriaLabel: formatMessage('Sorted A to Z'), sortDescendingAriaLabel: formatMessage('Sorted Z to A'), data: 'string', - onRender: _renderNameColumn, + onRender: _renderNameColumn(onFileChosen), isPadded: true, }, { diff --git a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx b/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx index cd6233d6c2..9312952bc2 100644 --- a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx +++ b/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx @@ -16,7 +16,7 @@ import { luIndexer, combineMessage } from '@bfc/indexers'; import { PlaceHolderSectionName } from '@bfc/indexers/lib/utils/luUtil'; import get from 'lodash/get'; import { DialogInfo } from '@bfc/shared'; -import { LuEditor } from '@bfc/code-editor'; +import { LuEditor, inlineModePlaceholder } from '@bfc/code-editor'; import { generateNewDialog, @@ -262,20 +262,22 @@ export const TriggerCreationModal: React.FC = props = data-testid={'RegExDropDown'} /> )} - {showTriggerPhrase && } {showTriggerPhrase && ( - + + + + )} diff --git a/Composer/packages/client/src/pages/design/PropertyEditor.tsx b/Composer/packages/client/src/pages/design/PropertyEditor.tsx index 11ec29d717..527064b160 100644 --- a/Composer/packages/client/src/pages/design/PropertyEditor.tsx +++ b/Composer/packages/client/src/pages/design/PropertyEditor.tsx @@ -4,7 +4,7 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import AdaptiveForm, { resolveBaseSchema, getUISchema, mergePluginConfigs } from '@bfc/adaptive-form'; +import AdaptiveForm, { resolveBaseSchema, getUIOptions, mergePluginConfigs } from '@bfc/adaptive-form'; import Extension, { FormErrors } from '@bfc/extension'; import formatMessage from 'format-message'; import isEqual from 'lodash/isEqual'; @@ -57,7 +57,7 @@ const PropertyEditor: React.FC = () => { }, []); const $uiSchema = useMemo(() => { - return getUISchema($schema, pluginConfig.formSchema); + return getUIOptions($schema, pluginConfig.formSchema, pluginConfig.roleSchema); }, [$schema, pluginConfig]); const errors = useMemo(() => { diff --git a/Composer/packages/client/src/pages/design/index.tsx b/Composer/packages/client/src/pages/design/index.tsx index 38321a5f3c..bac0231326 100644 --- a/Composer/packages/client/src/pages/design/index.tsx +++ b/Composer/packages/client/src/pages/design/index.tsx @@ -13,6 +13,7 @@ import { PromptTab } from '@bfc/shared'; import { DialogFactory, SDKKinds, DialogInfo } from '@bfc/shared'; import { Link } from 'office-ui-fabric-react/lib/Link'; import { JsonEditor } from '@bfc/code-editor'; +import { useTriggerApi } from '@bfc/extension'; import { LoadingSpinner } from '../../components/LoadingSpinner'; import { TestController } from '../../components/TestController'; @@ -28,6 +29,7 @@ import { ToolBar } from '../../components/ToolBar/index'; import { clearBreadcrumb } from '../../utils/navigation'; import undoHistory from '../../store/middlewares/undo/history'; import { navigateTo } from '../../utils'; +import { useShell } from '../../shell'; import { VisualEditorAPI } from './FrameAPI'; import { @@ -106,6 +108,8 @@ function DesignPage(props) { const [dialogJsonVisible, setDialogJsonVisibility] = useState(false); const [currentDialog, setCurrentDialog] = useState(dialogs[0]); const [exportSkillModalVisible, setExportSkillModalVisible] = useState(false); + const shell = useShell('ProjectTree'); + const triggerApi = useTriggerApi(shell.api); useEffect(() => { const currentDialog = dialogs.find(({ id }) => id === dialogId); @@ -357,7 +361,8 @@ function DesignPage(props) { } async function handleDeleteTrigger(id, index) { - const content = deleteTrigger(dialogs, id, index); + const content = deleteTrigger(dialogs, id, index, trigger => triggerApi.deleteTrigger(id, trigger)); + if (content) { await updateDialog({ id, projectId, content }); const match = /\[(\d+)\]/g.exec(selected); diff --git a/Composer/packages/client/src/pages/home/RecentBotList.tsx b/Composer/packages/client/src/pages/home/RecentBotList.tsx index 2c630d506d..7abc68fac2 100644 --- a/Composer/packages/client/src/pages/home/RecentBotList.tsx +++ b/Composer/packages/client/src/pages/home/RecentBotList.tsx @@ -5,6 +5,7 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { Link } from 'office-ui-fabric-react/lib/Link'; import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky'; import { ScrollablePane, ScrollbarVisibility } from 'office-ui-fabric-react/lib/ScrollablePane'; import { IObjectWithKey } from 'office-ui-fabric-react/lib/MarqueeSelection'; @@ -44,13 +45,12 @@ export function RecentBotList(props: RecentBotListProps): JSX.Element { onRender: item => { return (
-
onItemChosen(item)} > {item.name} -
+
); }, diff --git a/Composer/packages/client/src/pages/language-understanding/code-editor.tsx b/Composer/packages/client/src/pages/language-understanding/code-editor.tsx index cfedafc187..34acf35161 100644 --- a/Composer/packages/client/src/pages/language-understanding/code-editor.tsx +++ b/Composer/packages/client/src/pages/language-understanding/code-editor.tsx @@ -158,7 +158,6 @@ const CodeEditor: React.FC = props => { return ( throttle(fn, 1000, { leading: true, trailing: true }); function createLgApi(state: State, actions: BoundActionHandlers, lgFileResolver: (id: string) => LgFile | undefined) { - const api = { - getLgTemplates: id => { - if (id === undefined) throw new Error('must have a file id'); - const focusedDialogId = state.focusPath.split('#').shift() || id; - const file = lgFileResolver(focusedDialogId); - if (!file) throw new Error(`lg file ${id} not found`); - return file.templates; - }, - - updateLgTemplate: async (id: string, templateName: string, templateBody: string) => { - const file = lgFileResolver(id); - if (!file) throw new Error(`lg file ${id} not found`); - if (!templateName) throw new Error(`templateName is missing or empty`); - const template = { name: templateName, body: templateBody, parameters: [] }; - - const projectId = state.projectId; - - lgUtil.checkSingleLgTemplate(template); - - await actions.updateLgTemplate({ - file, - projectId, - templateName, - template, - }); - }, + const getLgTemplates = id => { + if (id === undefined) throw new Error('must have a file id'); + const focusedDialogId = state.focusPath.split('#').shift() || id; + const file = lgFileResolver(focusedDialogId); + if (!file) throw new Error(`lg file ${id} not found`); + return file.templates; + }; - copyLgTemplate: async (id, fromTemplateName, toTemplateName) => { - const file = lgFileResolver(id); - if (!file) throw new Error(`lg file ${id} not found`); - if (!fromTemplateName || !toTemplateName) throw new Error(`templateName is missing or empty`); + const updateLgTemplate = async (id: string, templateName: string, templateBody: string) => { + const file = lgFileResolver(id); + if (!file) throw new Error(`lg file ${id} not found`); + if (!templateName) throw new Error(`templateName is missing or empty`); + const template = { name: templateName, body: templateBody, parameters: [] }; - const projectId = state.projectId; + const projectId = state.projectId; - return actions.copyLgTemplate({ - file, - projectId, - fromTemplateName, - toTemplateName, - }); - }, - - removeLgTemplate: async (id, templateName) => { - const file = lgFileResolver(id); - if (!file) throw new Error(`lg file ${id} not found`); - if (!templateName) throw new Error(`templateName is missing or empty`); - const projectId = state.projectId; - - return actions.removeLgTemplate({ - file, - projectId, - templateName, - }); - }, - - removeLgTemplates: async (id, templateNames) => { - const file = lgFileResolver(id); - if (!file) throw new Error(`lg file ${id} not found`); - if (!templateNames) throw new Error(`templateName is missing or empty`); - const projectId = state.projectId; - - return actions.removeLgTemplates({ - file, - projectId, - templateNames, - }); - }, + lgUtil.checkSingleLgTemplate(template); + + await actions.updateLgTemplate({ + file, + projectId, + templateName, + template, + }); }; - return mapValues(api, fn => createThrottledFunc(fn)); + const copyLgTemplate = async (id, fromTemplateName, toTemplateName) => { + const file = lgFileResolver(id); + if (!file) throw new Error(`lg file ${id} not found`); + if (!fromTemplateName || !toTemplateName) throw new Error(`templateName is missing or empty`); + + const projectId = state.projectId; + + return actions.copyLgTemplate({ + file, + projectId, + fromTemplateName, + toTemplateName, + }); + }; + + const removeLgTemplate = async (id, templateName) => { + const file = lgFileResolver(id); + if (!file) throw new Error(`lg file ${id} not found`); + if (!templateName) throw new Error(`templateName is missing or empty`); + const projectId = state.projectId; + + return actions.removeLgTemplate({ + file, + projectId, + templateName, + }); + }; + + const removeLgTemplates = async (id, templateNames) => { + const file = lgFileResolver(id); + if (!file) throw new Error(`lg file ${id} not found`); + if (!templateNames) throw new Error(`templateName is missing or empty`); + const projectId = state.projectId; + + return actions.removeLgTemplates({ + file, + projectId, + templateNames, + }); + }; + + return { + addLgTemplate: updateLgTemplate, + getLgTemplates, + updateLgTemplate: createThrottledFunc(updateLgTemplate), + removeLgTemplate, + removeLgTemplates, + copyLgTemplate, + }; } export function useLgApi() { @@ -97,7 +101,9 @@ export function useLgApi() { return () => { Object.keys(newApi).forEach(apiName => { - newApi[apiName].flush(); + if (typeof newApi[apiName].flush === 'function') { + newApi[apiName].flush(); + } }); }; }, [projectId, focusPath]); diff --git a/Composer/packages/client/src/shell/luApi.ts b/Composer/packages/client/src/shell/luApi.ts index 286e11958e..72ccb8f48a 100644 --- a/Composer/packages/client/src/shell/luApi.ts +++ b/Composer/packages/client/src/shell/luApi.ts @@ -4,61 +4,65 @@ import { useContext, useEffect, useState } from 'react'; import { LuFile, LuIntentSection } from '@bfc/shared'; import throttle from 'lodash/throttle'; -import mapValues from 'lodash/mapValues'; import * as luUtil from '../utils/luUtil'; import { State, BoundActionHandlers } from '../store/types'; import { StoreContext } from '../store'; -const createThrottledFunc = fn => throttle(fn, 0, { leading: true, trailing: true }); +const createThrottledFunc = fn => throttle(fn, 1000, { leading: true, trailing: true }); function createLuApi(state: State, actions: BoundActionHandlers, luFileResolver: (id: string) => LuFile | undefined) { - const api = { - addLuIntent: async (id: string, intentName: string, intent: LuIntentSection) => { - const file = luFileResolver(id); - if (!file) throw new Error(`lu file ${id} not found`); - if (!intentName) throw new Error(`intentName is missing or empty`); - - const content = luUtil.addIntent(file.content, intent); - const projectId = state.projectId; - return await actions.updateLuFile({ id: file.id, projectId, content }); - }, - updateLuIntent: async (id: string, intentName: string, intent: LuIntentSection) => { - const file = luFileResolver(id); - if (!file) throw new Error(`lu file ${id} not found`); - if (!intentName) throw new Error(`intentName is missing or empty`); - - const content = luUtil.updateIntent(file.content, intentName, intent); - const projectId = state.projectId; - return await actions.updateLuFile({ id: file.id, projectId, content }); - }, - - removeLuIntent: async (id: string, intentName: string) => { - const file = luFileResolver(id); - if (!file) throw new Error(`lu file ${id} not found`); - if (!intentName) throw new Error(`intentName is missing or empty`); - - const content = luUtil.removeIntent(file.content, intentName); - const projectId = state.projectId; - return await actions.updateLuFile({ id: file.id, projectId, content }); - }, - - getLuIntents: (id: string): LuIntentSection[] => { - if (id === undefined) throw new Error('must have a file id'); - const focusedDialogId = state.focusPath.split('#').shift() || id; - const file = luFileResolver(focusedDialogId); - if (!file) throw new Error(`lu file ${id} not found`); - return file.intents; - }, - - getLuIntent: (id: string, intentName: string): LuIntentSection | undefined => { - const file = luFileResolver(id); - if (!file) throw new Error(`lu file ${id} not found`); - return file.intents.find(({ Name }) => Name === intentName); - }, + const addLuIntent = async (id: string, intentName: string, intent: LuIntentSection) => { + const file = luFileResolver(id); + if (!file) throw new Error(`lu file ${id} not found`); + if (!intentName) throw new Error(`intentName is missing or empty`); + + const content = luUtil.addIntent(file.content, intent); + const projectId = state.projectId; + return await actions.updateLuFile({ id: file.id, projectId, content }); + }; + + const updateLuIntent = async (id: string, intentName: string, intent: LuIntentSection) => { + const file = luFileResolver(id); + if (!file) throw new Error(`lu file ${id} not found`); + if (!intentName) throw new Error(`intentName is missing or empty`); + + const content = luUtil.updateIntent(file.content, intentName, intent); + const projectId = state.projectId; + return await actions.updateLuFile({ id: file.id, projectId, content }); + }; + + const removeLuIntent = async (id: string, intentName: string) => { + const file = luFileResolver(id); + if (!file) throw new Error(`lu file ${id} not found`); + if (!intentName) throw new Error(`intentName is missing or empty`); + + const content = luUtil.removeIntent(file.content, intentName); + const projectId = state.projectId; + return await actions.updateLuFile({ id: file.id, projectId, content }); + }; + + const getLuIntents = (id: string): LuIntentSection[] => { + if (id === undefined) throw new Error('must have a file id'); + const focusedDialogId = state.focusPath.split('#').shift() || id; + const file = luFileResolver(focusedDialogId); + if (!file) throw new Error(`lu file ${id} not found`); + return file.intents; }; - return mapValues(api, fn => createThrottledFunc(fn)); + const getLuIntent = (id: string, intentName: string): LuIntentSection | undefined => { + const file = luFileResolver(id); + if (!file) throw new Error(`lu file ${id} not found`); + return file.intents.find(({ Name }) => Name === intentName); + }; + + return { + addLuIntent, + getLuIntents, + getLuIntent, + updateLuIntent: createThrottledFunc(updateLuIntent), + removeLuIntent, + }; } export function useLuApi() { @@ -73,7 +77,9 @@ export function useLuApi() { return () => { Object.keys(newApi).forEach(apiName => { - newApi[apiName].flush(); + if (typeof newApi[apiName].flush === 'function') { + newApi[apiName].flush(); + } }); }; }, [projectId, focusPath]); diff --git a/Composer/packages/client/src/shell/useShell.ts b/Composer/packages/client/src/shell/useShell.ts index 67a9fe9538..5ee1f113db 100644 --- a/Composer/packages/client/src/shell/useShell.ts +++ b/Composer/packages/client/src/shell/useShell.ts @@ -18,7 +18,7 @@ import { useLuApi } from './luApi'; const FORM_EDITOR = 'PropertyEditor'; -type EventSource = 'VisualEditor' | 'PropertyEditor'; +type EventSource = 'VisualEditor' | 'PropertyEditor' | 'ProjectTree'; export function useShell(source: EventSource): { api: ShellApi; data: ShellData } { const { state, actions } = useContext(StoreContext); diff --git a/Composer/packages/client/src/utils/dialogUtil.ts b/Composer/packages/client/src/utils/dialogUtil.ts index 5efa9ce647..a56597f9bf 100644 --- a/Composer/packages/client/src/utils/dialogUtil.ts +++ b/Composer/packages/client/src/utils/dialogUtil.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { ConceptLabels, DialogGroup, SDKKinds, dialogGroups, DialogInfo, DialogFactory } from '@bfc/shared'; +import { ConceptLabels, DialogGroup, SDKKinds, dialogGroups, DialogInfo, DialogFactory, ITrigger } from '@bfc/shared'; import get from 'lodash/get'; import set from 'lodash/set'; import cloneDeep from 'lodash/cloneDeep'; @@ -128,7 +128,12 @@ export function createSelectedPath(selected: number) { return `triggers[${selected}]`; } -export function deleteTrigger(dialogs: DialogInfo[], dialogId: string, index: number) { +export function deleteTrigger( + dialogs: DialogInfo[], + dialogId: string, + index: number, + callbackOnDeletedTrigger?: (trigger: ITrigger) => any +) { let dialogCopy = getDialog(dialogs, dialogId); if (!dialogCopy) return null; const isRegEx = get(dialogCopy, 'content.recognizer.$kind', '') === regexRecognizerKey; @@ -137,7 +142,8 @@ export function deleteTrigger(dialogs: DialogInfo[], dialogId: string, index: nu dialogCopy = deleteRegExIntent(dialogCopy, regExIntent); } const triggers = get(dialogCopy, 'content.triggers'); - triggers.splice(index, 1); + const removedTriggers = triggers.splice(index, 1); + callbackOnDeletedTrigger && callbackOnDeletedTrigger(removedTriggers[0]); return dialogCopy.content; } diff --git a/Composer/packages/electron-server/electron-builder-config.json b/Composer/packages/electron-server/electron-builder-config.json index 2183ff21cf..85ccb49db1 100644 --- a/Composer/packages/electron-server/electron-builder-config.json +++ b/Composer/packages/electron-server/electron-builder-config.json @@ -32,6 +32,7 @@ { "target": "nsis", "arch": [ + "ia32", "x64" ] } @@ -40,15 +41,18 @@ "artifactName": "BotFramework-Composer-${version}-windows-setup.${ext}" }, "nsis": { + "include": "resources/installer.nsh", "perMachine": false, "allowElevation": true, "allowToChangeInstallationDirectory": true, "packElevateHelper": true, "unicode": true, "runAfterFinish": true, + "installerIcon": "resources/composerIcon.ico", + "uninstallerIcon": "resources/composerIcon.ico", "createDesktopShortcut": true, "createStartMenuShortcut": true, - "shortcutName": "Bot Framework Composer (preview)", + "shortcutName": "Bot Framework Composer", "oneClick": false }, "linux": { diff --git a/Composer/packages/electron-server/resources/installer.nsh b/Composer/packages/electron-server/resources/installer.nsh new file mode 100644 index 0000000000..453668e176 --- /dev/null +++ b/Composer/packages/electron-server/resources/installer.nsh @@ -0,0 +1,15 @@ +!macro customInstall + DetailPrint "Register bfcomposer URI Handler" + DeleteRegKey HKCU "SOFTWARE\Classes\bfcomposer" + WriteRegStr HKCU "SOFTWARE\Classes\bfcomposer" "" "URL:Bot Framework Composer" + WriteRegStr HKCU "SOFTWARE\Classes\bfcomposer" "URL Protocol" "" + WriteRegStr HKCU "SOFTWARE\Classes\bfcomposer\DefaultIcon" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME},1" + WriteRegStr HKCU "SOFTWARE\Classes\bfcomposer\shell" "" "" + WriteRegStr HKCU "SOFTWARE\Classes\bfcomposer\shell\open" "" "" + WriteRegStr HKCU "SOFTWARE\Classes\bfcomposer\shell\open\command" "" `"$INSTDIR\${APP_EXECUTABLE_FILENAME}" "%1"` +!macroend + +!macro customUninstall + DetailPrint "Unregister bfcomposer URI Handler" + DeleteRegKey HKCU "SOFTWARE\Classes\bfcomposer" +!macroend diff --git a/Composer/packages/electron-server/src/main.ts b/Composer/packages/electron-server/src/main.ts index 7ac5e299dc..7c1dc07195 100644 --- a/Composer/packages/electron-server/src/main.ts +++ b/Composer/packages/electron-server/src/main.ts @@ -29,7 +29,7 @@ const getBaseUrl = () => { if (!serverPort) { throw new Error('getBaseUrl() called before serverPort is defined.'); } - return `http://localhost:${serverPort}`; + return `http://localhost:${serverPort}/`; }; function processArgsForWindows(args: string[]): string { diff --git a/Composer/packages/extensions/adaptive-form/package.json b/Composer/packages/extensions/adaptive-form/package.json index b4c2b4d3eb..7756c1366d 100644 --- a/Composer/packages/extensions/adaptive-form/package.json +++ b/Composer/packages/extensions/adaptive-form/package.json @@ -42,4 +42,4 @@ "lodash": "^4.17.15", "react-error-boundary": "^1.2.5" } -} +} \ No newline at end of file diff --git a/Composer/packages/extensions/adaptive-form/src/components/ErrorMessage.tsx b/Composer/packages/extensions/adaptive-form/src/components/ErrorMessage.tsx index f4e2199085..1fe6352d54 100644 --- a/Composer/packages/extensions/adaptive-form/src/components/ErrorMessage.tsx +++ b/Composer/packages/extensions/adaptive-form/src/components/ErrorMessage.tsx @@ -3,25 +3,30 @@ import React from 'react'; import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar'; +import { Link } from 'office-ui-fabric-react/lib/Link'; import formatMessage from 'format-message'; interface ErrorMessageProps { label?: string | false; error?: string; + helpLink?: string; } const ErrorMessage: React.FC = props => { - const { error, label } = props; + const { error, label, helpLink } = props; return ( {[label, error].filter(Boolean).join(' ')} + {helpLink && ( + + {formatMessage('Refer to the syntax documentation here.')} + + )} ); }; diff --git a/Composer/packages/extensions/adaptive-form/src/components/SchemaField.tsx b/Composer/packages/extensions/adaptive-form/src/components/SchemaField.tsx index 6cfdccf156..23de6681cd 100644 --- a/Composer/packages/extensions/adaptive-form/src/components/SchemaField.tsx +++ b/Composer/packages/extensions/adaptive-form/src/components/SchemaField.tsx @@ -3,9 +3,9 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; import React, { useEffect } from 'react'; -import { FieldProps } from '@bfc/extension'; +import { FieldProps, UIOptions } from '@bfc/extension'; -import { getUISchema, resolveFieldWidget, resolveRef, getUiLabel, getUiPlaceholder, getUiDescription } from '../utils'; +import { getUIOptions, resolveFieldWidget, resolveRef, getUiLabel, getUiPlaceholder, getUiDescription } from '../utils'; import { usePluginConfig } from '../hooks'; import { ErrorMessage } from './ErrorMessage'; @@ -34,9 +34,10 @@ const SchemaField: React.FC = props => { ...rest } = props; const pluginConfig = usePluginConfig(); + const schema = resolveRef(baseSchema, definitions); - const uiOptions = { - ...getUISchema(schema, pluginConfig.formSchema), + const uiOptions: UIOptions = { + ...getUIOptions(schema, pluginConfig.formSchema, pluginConfig.roleSchema), ...baseUIOptions, }; @@ -50,7 +51,9 @@ const SchemaField: React.FC = props => { } }, []); - const error = typeof rawErrors === 'string' && ; + const error = typeof rawErrors === 'string' && ( + + ); if (!schema || name.startsWith('$')) { return null; diff --git a/Composer/packages/extensions/adaptive-form/src/utils/__tests__/getUIOptions.test.ts b/Composer/packages/extensions/adaptive-form/src/utils/__tests__/getUIOptions.test.ts new file mode 100644 index 0000000000..c924422e4b --- /dev/null +++ b/Composer/packages/extensions/adaptive-form/src/utils/__tests__/getUIOptions.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { UISchema, JSONSchema7, RoleSchema } from '@bfc/extension'; +import { SDKKinds } from '@bfc/shared'; + +import { getUIOptions } from '../getUIOptions'; + +describe('getUIOptions', () => { + it('returns empty object when type schema not found', () => { + // @ts-ignore - Intentionally passing in an invalid value + expect(getUIOptions('SomeDialog')).toEqual({}); + }); + + it('returns UI schema for $kind', () => { + const schema: JSONSchema7 = { + properties: { + $kind: { + const: SDKKinds.AdaptiveDialog, + }, + }, + }; + expect(getUIOptions(schema)).toMatchInlineSnapshot(`Object {}`); + }); + + it('merges overrides into default schema', () => { + const schema: JSONSchema7 = { + properties: { + $kind: { + const: SDKKinds.AdaptiveDialog, + }, + }, + }; + + const uiSchema: UISchema = { + [SDKKinds.AdaptiveDialog]: { + order: ['*', 'recognizer'], + label: 'First Label', + }, + }; + expect(getUIOptions(schema, uiSchema)).toMatchInlineSnapshot(` +Object { + "label": "First Label", + "order": Array [ + "*", + "recognizer", + ], +} +`); + }); + + it('merges overrides and a plugin into default schema', () => { + const schema: JSONSchema7 = { + $role: 'expression', + properties: { + $kind: { + const: SDKKinds.AdaptiveDialog, + }, + }, + }; + + const uiSchema: UISchema = { + [SDKKinds.AdaptiveDialog]: { + order: ['*', 'recognizer'], + label: 'First Label', + }, + }; + + const plugin: RoleSchema = { + expression: { + helpLink: 'https://example.com/plugin', + }, + }; + + expect(getUIOptions(schema, uiSchema, plugin)).toMatchInlineSnapshot(` +Object { + "helpLink": "https://example.com/plugin", + "label": "First Label", + "order": Array [ + "*", + "recognizer", + ], +} +`); + }); +}); diff --git a/Composer/packages/extensions/adaptive-form/src/utils/__tests__/getUISchema.test.ts b/Composer/packages/extensions/adaptive-form/src/utils/__tests__/getUISchema.test.ts deleted file mode 100644 index 6e4be3ade8..0000000000 --- a/Composer/packages/extensions/adaptive-form/src/utils/__tests__/getUISchema.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -import { UISchema, JSONSchema7 } from '@bfc/extension'; -import { SDKKinds } from '@bfc/shared'; - -import { getUISchema } from '../getUISchema'; - -describe('getUISchema', () => { - it('returns empty object when type schema not found', () => { - // @ts-ignore - Intentionally passing in an invalid value - expect(getUISchema('SomeDialog')).toEqual({}); - }); - - it('returns UI schema for $kind', () => { - const schema: JSONSchema7 = { - properties: { - $kind: { - const: SDKKinds.AdaptiveDialog, - }, - }, - }; - expect(getUISchema(schema)).toMatchInlineSnapshot(`Object {}`); - }); - - it('merges overrides into default schema', () => { - const schema: JSONSchema7 = { - properties: { - $kind: { - const: SDKKinds.AdaptiveDialog, - }, - }, - }; - - const uiSchema: UISchema = { - [SDKKinds.AdaptiveDialog]: { - order: ['*', 'recognizer'], - label: 'First Label', - }, - }; - expect(getUISchema(schema, uiSchema)).toMatchInlineSnapshot(` -Object { - "label": "First Label", - "order": Array [ - "*", - "recognizer", - ], -} -`); - }); -}); diff --git a/Composer/packages/extensions/adaptive-form/src/utils/getUIOptions.ts b/Composer/packages/extensions/adaptive-form/src/utils/getUIOptions.ts new file mode 100644 index 0000000000..80796996d3 --- /dev/null +++ b/Composer/packages/extensions/adaptive-form/src/utils/getUIOptions.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { UISchema, UIOptions, JSONSchema7, RoleSchema } from '@bfc/extension'; +/** + * Merges overrides and plugins into default ui schema and returns the UIOptions + */ +export function getUIOptions(schema?: JSONSchema7, uiSchema?: UISchema, roleSchema?: RoleSchema): UIOptions { + const { $kind } = schema?.properties || {}; + const { $role } = schema || {}; + + const kind = $kind && typeof $kind === 'object' && ($kind.const as string); + + const formOptions = uiSchema && kind && uiSchema[kind] ? uiSchema[kind] : {}; + const roleOptions = roleSchema && $role && roleSchema[$role] ? roleSchema[$role] : {}; + + return { ...roleOptions, ...formOptions }; +} diff --git a/Composer/packages/extensions/adaptive-form/src/utils/getUISchema.ts b/Composer/packages/extensions/adaptive-form/src/utils/getUISchema.ts deleted file mode 100644 index b057f9ddf2..0000000000 --- a/Composer/packages/extensions/adaptive-form/src/utils/getUISchema.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -import { UISchema, UIOptions, JSONSchema7 } from '@bfc/extension'; -/** - * Merges overrides into default ui schema and returns the UIOptions - */ -export function getUISchema(schema?: JSONSchema7, uiSchema?: UISchema): UIOptions { - const { $kind } = schema?.properties || {}; - - const kind = $kind && typeof $kind === 'object' && ($kind.const as string); - - return uiSchema && kind && uiSchema[kind] ? uiSchema[kind] : {}; -} diff --git a/Composer/packages/extensions/adaptive-form/src/utils/index.ts b/Composer/packages/extensions/adaptive-form/src/utils/index.ts index d2bad4fa73..5bbc33ae84 100644 --- a/Composer/packages/extensions/adaptive-form/src/utils/index.ts +++ b/Composer/packages/extensions/adaptive-form/src/utils/index.ts @@ -3,7 +3,7 @@ export * from './arrayUtils'; export * from './getAdaptiveType'; export * from './getOrderedProperties'; -export * from './getUISchema'; +export * from './getUIOptions'; export * from './getValueType'; export * from './mergePluginConfigs'; export * from './resolveBaseSchema'; diff --git a/Composer/packages/extensions/adaptive-form/src/utils/resolveRef.ts b/Composer/packages/extensions/adaptive-form/src/utils/resolveRef.ts index cf08679fcf..46c9f13723 100644 --- a/Composer/packages/extensions/adaptive-form/src/utils/resolveRef.ts +++ b/Composer/packages/extensions/adaptive-form/src/utils/resolveRef.ts @@ -8,7 +8,7 @@ export function resolveRef( ): JSONSchema7 { if (typeof schema?.$ref === 'string') { const defName = schema.$ref.replace('#/definitions/', ''); - const defSchema = typeof definitions?.[defName] === 'object' ? definitions?.[defName] : {}; + const defSchema = typeof definitions?.[defName] === 'object' ? (definitions?.[defName] as JSONSchema7) : {}; const resolvedSchema = { ...defSchema, diff --git a/Composer/packages/extensions/extension/src/hooks/index.ts b/Composer/packages/extensions/extension/src/hooks/index.ts index 22e225d8d6..7bc26ac152 100644 --- a/Composer/packages/extensions/extension/src/hooks/index.ts +++ b/Composer/packages/extensions/extension/src/hooks/index.ts @@ -5,5 +5,6 @@ export * from './useShellApi'; export * from './useLgApi'; export * from './useLuApi'; export * from './useActionApi'; +export * from './useTriggerApi'; export * from './useDialogApi'; export * from './useDialogEditApi'; diff --git a/Composer/packages/extensions/extension/src/hooks/useActionApi.ts b/Composer/packages/extensions/extension/src/hooks/useActionApi.ts index c12832b239..5cc8012697 100644 --- a/Composer/packages/extensions/extension/src/hooks/useActionApi.ts +++ b/Composer/packages/extensions/extension/src/hooks/useActionApi.ts @@ -8,14 +8,15 @@ import { deleteActions as destructActions, FieldProcessorAsync, walkAdaptiveActionList, + ShellApi, } from '@bfc/shared'; import { useLgApi } from './useLgApi'; import { useLuApi } from './useLuApi'; -export const useActionApi = () => { - const { createLgTemplate, readLgTemplate, deleteLgTemplates } = useLgApi(); - const { createLuIntent, readLuIntent, deleteLuIntents } = useLuApi(); +export const useActionApi = (shellApi: ShellApi) => { + const { createLgTemplate, readLgTemplate, deleteLgTemplates } = useLgApi(shellApi); + const { createLuIntent, readLuIntent, deleteLuIntents } = useLuApi(shellApi); const luFieldName = '_lu'; diff --git a/Composer/packages/extensions/extension/src/hooks/useDialogApi.ts b/Composer/packages/extensions/extension/src/hooks/useDialogApi.ts index b637de8eff..302d52eb85 100644 --- a/Composer/packages/extensions/extension/src/hooks/useDialogApi.ts +++ b/Composer/packages/extensions/extension/src/hooks/useDialogApi.ts @@ -1,12 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useShellApi } from './useShellApi'; +import { ShellApi } from '@bfc/shared'; -export const useDialogApi = () => { - const { - shellApi: { getDialog, saveDialog, createDialog }, - } = useShellApi(); +export const useDialogApi = (shellApi: ShellApi) => { + const { getDialog, saveDialog, createDialog } = shellApi; return { createDialog: () => createDialog([]), diff --git a/Composer/packages/extensions/extension/src/hooks/useDialogEditApi.ts b/Composer/packages/extensions/extension/src/hooks/useDialogEditApi.ts index 64f5fa7903..6fa4de130e 100644 --- a/Composer/packages/extensions/extension/src/hooks/useDialogEditApi.ts +++ b/Composer/packages/extensions/extension/src/hooks/useDialogEditApi.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { BaseSchema, DialogUtils } from '@bfc/shared'; +import { BaseSchema, DialogUtils, ShellApi } from '@bfc/shared'; import { useActionApi } from './useActionApi'; @@ -14,8 +14,8 @@ export interface DialogApiContext { const { appendNodesAfter, queryNodes, insertNodes, deleteNode, deleteNodes } = DialogUtils; -export function useDialogEditApi() { - const { constructActions, copyActions, deleteAction, deleteActions } = useActionApi(); +export function useDialogEditApi(shellApi: ShellApi) { + const { constructActions, copyActions, deleteAction, deleteActions } = useActionApi(shellApi); async function insertActions( dialogId: string, diff --git a/Composer/packages/extensions/extension/src/hooks/useLgApi.ts b/Composer/packages/extensions/extension/src/hooks/useLgApi.ts index 1035d18161..d4233d3cd4 100644 --- a/Composer/packages/extensions/extension/src/hooks/useLgApi.ts +++ b/Composer/packages/extensions/extension/src/hooks/useLgApi.ts @@ -1,16 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { LgTemplateRef, LgMetaData, BaseSchema, LgType } from '@bfc/shared'; - -import { useShellApi } from './useShellApi'; +import { LgTemplateRef, LgMetaData, BaseSchema, LgType, ShellApi } from '@bfc/shared'; /** * LG CRUD lib */ -export const useLgApi = () => { - const { shellApi } = useShellApi(); - const { removeLgTemplates, getLgTemplates, updateLgTemplate } = shellApi; +export const useLgApi = (shellApi: ShellApi) => { + const { removeLgTemplates, getLgTemplates, addLgTemplate } = shellApi; const deleteLgTemplates = (lgFileId: string, lgTemplates: string[]) => { const normalizedLgTemplates = lgTemplates @@ -46,7 +43,7 @@ export const useLgApi = () => { const newLgType = new LgType(hostActionData.$kind, hostFieldName).toString(); const newLgTemplateName = new LgMetaData(newLgType, hostActionId).toString(); const newLgTemplateRefStr = new LgTemplateRef(newLgTemplateName).toString(); - await updateLgTemplate(lgFileId, newLgTemplateName, lgText); + await addLgTemplate(lgFileId, newLgTemplateName, lgText); return newLgTemplateRefStr; }; diff --git a/Composer/packages/extensions/extension/src/hooks/useLuApi.ts b/Composer/packages/extensions/extension/src/hooks/useLuApi.ts index f809d5fca6..31580f855b 100644 --- a/Composer/packages/extensions/extension/src/hooks/useLuApi.ts +++ b/Composer/packages/extensions/extension/src/hooks/useLuApi.ts @@ -1,15 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { LuIntentSection, BaseSchema, LuMetaData, LuType } from '@bfc/shared'; - -import { useShellApi } from './useShellApi'; +import { LuIntentSection, BaseSchema, LuMetaData, LuType, ShellApi } from '@bfc/shared'; /** * LU CRUD API */ -export const useLuApi = () => { - const { shellApi } = useShellApi(); +export const useLuApi = (shellApi: ShellApi) => { const { addLuIntent, removeLuIntent, getLuIntent } = shellApi; const createLuIntent = async ( diff --git a/Composer/packages/extensions/extension/src/hooks/useTriggerApi.ts b/Composer/packages/extensions/extension/src/hooks/useTriggerApi.ts new file mode 100644 index 0000000000..56461064fa --- /dev/null +++ b/Composer/packages/extensions/extension/src/hooks/useTriggerApi.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ShellApi, SDKKinds } from '@bfc/shared'; +import get from 'lodash/get'; + +import { useActionApi } from './useActionApi'; +import { useLuApi } from './useLuApi'; + +export const useTriggerApi = (shellAPi: ShellApi) => { + const { deleteActions } = useActionApi(shellAPi); + const { deleteLuIntent } = useLuApi(shellAPi); + + const deleteTrigger = (dialogId: string, trigger) => { + // Clean the lu resource on intent trigger + if (trigger.$kind === SDKKinds.OnIntent) { + const triggerIntent = get(trigger, 'intent', ''); + deleteLuIntent(dialogId, triggerIntent); + } + + // Clean action resources + const actions = get(trigger, 'actions'); + if (!actions || !Array.isArray(actions)) return; + + deleteActions(dialogId, actions); + }; + + return { + deleteTrigger, + }; +}; diff --git a/Composer/packages/extensions/visual-designer/src/components/menus/EdgeMenu.tsx b/Composer/packages/extensions/visual-designer/src/components/menus/EdgeMenu.tsx index 7065463c66..b89118b961 100644 --- a/Composer/packages/extensions/visual-designer/src/components/menus/EdgeMenu.tsx +++ b/Composer/packages/extensions/visual-designer/src/components/menus/EdgeMenu.tsx @@ -2,13 +2,11 @@ // Licensed under the MIT License. /** @jsx jsx */ -import { jsx, css } from '@emotion/core'; +import { jsx } from '@emotion/core'; import { useCallback, useContext, useState } from 'react'; import classnames from 'classnames'; import formatMessage from 'format-message'; -import { createStepMenu, DialogGroup, SDKKinds } from '@bfc/shared'; -import { IContextualMenu, ContextualMenuItemType } from 'office-ui-fabric-react/lib/ContextualMenu'; -import { FontIcon } from 'office-ui-fabric-react/lib/Icon'; +import { DefinitionSummary } from '@bfc/shared'; import { EdgeAddButtonSize } from '../../constants/ElementSizes'; import { NodeRendererContext } from '../../store/NodeRendererContext'; @@ -19,6 +17,7 @@ import { MenuTypes } from '../../constants/MenuTypes'; import { ObiColors } from '../../constants/ElementColors'; import { IconMenu } from './IconMenu'; +import { createActionMenu } from './createSchemaMenu'; interface EdgeMenuProps { id: string; @@ -26,85 +25,8 @@ interface EdgeMenuProps { addCoachMarkRef?: (ref: { [key: string]: HTMLDivElement }) => void; } -const buildEdgeMenuItemsFromClipboardContext = ( - context, - onClick, - filter?: (t: SDKKinds) => boolean -): IContextualMenu[] => { - const { clipboardActions } = context; - const menuItems = createStepMenu( - [ - DialogGroup.RESPONSE, - DialogGroup.INPUT, - DialogGroup.BRANCHING, - DialogGroup.LOOPING, - DialogGroup.STEP, - DialogGroup.MEMORY, - DialogGroup.CODE, - DialogGroup.LOG, - ], - true, - (e, item) => onClick(item ? item.data.$kind : null), - context.dialogFactory, - filter - ); - - const enablePaste = Array.isArray(clipboardActions) && clipboardActions.length > 0; - const menuItemCount = menuItems.length; - menuItems.unshift( - { - key: 'Paste', - name: 'Paste', - ariaLabel: 'Paste', - disabled: !enablePaste, - onRender: () => { - return ( - - ); - }, - }, - { - key: 'divider', - itemType: ContextualMenuItemType.Divider, - } - ); - - return menuItems; -}; - export const EdgeMenu: React.FC = ({ id, addCoachMarkRef, onClick, ...rest }) => { - const nodeContext = useContext(NodeRendererContext); + const { clipboardActions, customSchemas } = useContext(NodeRendererContext); const selfHosted = useContext(SelfHostContext); const { selectedIds } = useContext(SelectionContext); const nodeSelected = selectedIds.includes(`${id}${MenuTypes.EdgeMenu}`); @@ -124,6 +46,19 @@ export const EdgeMenu: React.FC = ({ id, addCoachMarkRef, onClick const handleMenuShow = menuSelected => { setMenuSelected(menuSelected); }; + + const menuItems = createActionMenu( + item => { + if (!item) return; + onClick(item.key); + }, + { + isSelfHosted: selfHosted, + enablePaste: Array.isArray(clipboardActions) && !!clipboardActions.length, + }, + // Custom Action 'oneOf' arrays from schema file + customSchemas.map(x => x.oneOf).filter(oneOf => Array.isArray(oneOf) && oneOf.length) as DefinitionSummary[][] + ); return (
= ({ id, addCoachMarkRef, onClick }} iconSize={7} nodeSelected={nodeSelected} - menuItems={buildEdgeMenuItemsFromClipboardContext( - nodeContext, - onClick, - selfHosted ? x => x !== SDKKinds.LogAction : undefined - )} + menuItems={menuItems} label={formatMessage('Add')} handleMenuShow={handleMenuShow} {...rest} diff --git a/Composer/packages/extensions/visual-designer/src/components/menus/EventMenu.tsx b/Composer/packages/extensions/visual-designer/src/components/menus/EventMenu.tsx deleted file mode 100644 index 7067aebeb2..0000000000 --- a/Composer/packages/extensions/visual-designer/src/components/menus/EventMenu.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import React, { useContext } from 'react'; -import { createStepMenu, DialogGroup } from '@bfc/shared'; - -import { NodeRendererContext } from '../../store/NodeRendererContext'; - -import { IconMenu } from './IconMenu'; - -interface EventMenuProps { - label?: string; - onClick: (item: string | null) => void; -} - -export const EventMenu: React.FC = ({ label, onClick, ...rest }): JSX.Element => { - const { dialogFactory } = useContext(NodeRendererContext); - const eventMenuItems = createStepMenu( - [DialogGroup.EVENTS], - false, - (e, item): any => onClick(item ? item.data.$kind : null), - dialogFactory - ); - - return ( - - ); -}; diff --git a/Composer/packages/extensions/visual-designer/src/components/menus/createSchemaMenu.tsx b/Composer/packages/extensions/visual-designer/src/components/menus/createSchemaMenu.tsx new file mode 100644 index 0000000000..6fc67fc17b --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/components/menus/createSchemaMenu.tsx @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import { + IContextualMenuItem, + ContextualMenuItemType, +} from 'office-ui-fabric-react/lib/components/ContextualMenu/ContextualMenu.types'; +import { ConceptLabels, DialogGroup, dialogGroups, SDKKinds, DefinitionSummary } from '@bfc/shared'; +import { FontIcon } from 'office-ui-fabric-react/lib/Icon'; +import formatMessage from 'format-message'; + +const resolveMenuTitle = ($kind: SDKKinds): string => { + const conceptLabel = ConceptLabels[$kind]; + return conceptLabel?.title || $kind; +}; + +type ActionMenuItemClickHandler = (item?: IContextualMenuItem) => any; +type ActionKindFilter = ($kind: SDKKinds) => boolean; + +const createBaseActionMenu = ( + onClick: ActionMenuItemClickHandler, + filter?: ActionKindFilter +): IContextualMenuItem[] => { + const pickedGroups: DialogGroup[] = [ + DialogGroup.RESPONSE, + DialogGroup.INPUT, + DialogGroup.BRANCHING, + DialogGroup.LOOPING, + DialogGroup.STEP, + DialogGroup.MEMORY, + DialogGroup.CODE, + DialogGroup.LOG, + ]; + const stepMenuItems = pickedGroups + .map(key => dialogGroups[key]) + .filter(groupItem => groupItem && Array.isArray(groupItem.types) && groupItem.types.length) + .map(({ label, types: actionKinds }) => { + const subMenuItems: IContextualMenuItem[] = actionKinds + .filter($kind => (filter ? filter($kind) : true)) + .map($kind => ({ + key: $kind, + name: resolveMenuTitle($kind), + onClick: (e, itemData) => onClick(itemData), + })); + + if (subMenuItems.length === 1) { + // hoists the only item to upper level + return subMenuItems[0]; + } + return createSubMenu(label, onClick, subMenuItems); + }); + return stepMenuItems; +}; + +const createDivider = () => ({ + key: 'divider', + itemType: ContextualMenuItemType.Divider, +}); + +const get$kindFrom$ref = ($ref: string): SDKKinds => { + return $ref.replace('#/definitions/', '') as SDKKinds; +}; + +const createCustomActionSubMenu = ( + customizedActionGroups: DefinitionSummary[][], + onClick: ActionMenuItemClickHandler +): IContextualMenuItem[] => { + if (!Array.isArray(customizedActionGroups) || customizedActionGroups.length === 0) { + return []; + } + + const itemGroups: IContextualMenuItem[][] = customizedActionGroups + .filter(actionGroup => Array.isArray(actionGroup) && actionGroup.length) + .map(actionGroup => { + return actionGroup.map( + ({ title, $ref }) => + ({ + key: get$kindFrom$ref($ref), + name: title, + onClick: (e, itemData) => onClick(itemData), + } as IContextualMenuItem) + ); + }); + + const flatMenuItems: IContextualMenuItem[] = itemGroups.reduce((resultItems, currentGroup, currentIndex) => { + if (currentIndex !== 0) { + // push a sep line ahead. + resultItems.push(createDivider()); + } + resultItems.push(...currentGroup); + return resultItems; + }, []); + + return flatMenuItems; +}; + +const createPasteButtonItem = ( + menuItemCount: number, + disabled: boolean, + onClick: ActionMenuItemClickHandler +): IContextualMenuItem => { + return { + key: 'Paste', + name: 'Paste', + ariaLabel: 'Paste', + disabled: disabled, + onRender: () => { + return ( + + ); + }, + }; +}; + +interface ActionMenuOptions { + isSelfHosted: boolean; + enablePaste: boolean; +} + +const createSubMenu = ( + label: string, + onClick: ActionMenuItemClickHandler, + subItems: IContextualMenuItem[] +): IContextualMenuItem => { + return { + key: label, + text: label, + subMenuProps: { + items: subItems, + onItemClick: (e, itemData) => onClick(itemData), + }, + }; +}; + +export const createActionMenu = ( + onClick: ActionMenuItemClickHandler, + options: ActionMenuOptions, + customActionGroups?: DefinitionSummary[][] +) => { + const resultItems: IContextualMenuItem[] = []; + + // base SDK menu + const baseMenuItems = createBaseActionMenu( + onClick, + options.isSelfHosted ? ($kind: SDKKinds) => $kind !== SDKKinds.LogAction : undefined + ); + resultItems.push(...baseMenuItems); + + // Append a 'Custom Actions' item conditionally. + if (customActionGroups) { + const customActionItems = createCustomActionSubMenu(customActionGroups, onClick); + if (customActionItems.length) { + const customActionTitle = formatMessage('Custom Actions'); + resultItems.push(createSubMenu(customActionTitle, onClick, customActionItems)); + } + } + + // paste button + const pasteButtonDisabled = !options.enablePaste; + const pasteButton = createPasteButtonItem(resultItems.length, pasteButtonDisabled, onClick); + resultItems.unshift(pasteButton, createDivider()); + + return resultItems; +}; diff --git a/Composer/packages/extensions/visual-designer/src/editors/EventsEditor.tsx b/Composer/packages/extensions/visual-designer/src/editors/EventsEditor.tsx index 380553aa20..1765e3c1bd 100644 --- a/Composer/packages/extensions/visual-designer/src/editors/EventsEditor.tsx +++ b/Composer/packages/extensions/visual-designer/src/editors/EventsEditor.tsx @@ -5,7 +5,6 @@ import React, { FC } from 'react'; import { Panel } from '../components/lib/Panel'; import { RuleGroup, CollapsedRuleGroup } from '../components/groups'; -import { EventMenu } from '../components/menus/EventMenu'; import { NodeEventTypes } from '../constants/NodeEventTypes'; import { EditorProps } from './editorProps'; @@ -14,8 +13,6 @@ export const EventsEditor: FC = ({ id, data, onEvent }): JSX.Elemen const ruleCount = data.children.length; const title = `Events (${ruleCount})`; - const onClick = $kind => onEvent(NodeEventTypes.Insert, { id, $kind, position: ruleCount }); - return ( = ({ id, data, onEvent }): JSX.Elemen onEvent(NodeEventTypes.FocusEvent, ''); }} collapsedItems={} - addMenu={} + addMenu={null} > diff --git a/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx b/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx index 2b6a20e29e..d8540f08f2 100644 --- a/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx +++ b/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx @@ -6,7 +6,7 @@ import { jsx } from '@emotion/core'; import { useContext, FC, useEffect, useState, useRef } from 'react'; import { MarqueeSelection, Selection } from 'office-ui-fabric-react/lib/MarqueeSelection'; import { SDKKinds, DialogUtils } from '@bfc/shared'; -import { useDialogApi, useDialogEditApi, useActionApi } from '@bfc/extension'; +import { useDialogApi, useDialogEditApi, useActionApi, useShellApi } from '@bfc/extension'; import get from 'lodash/get'; import { NodeEventTypes } from '../constants/NodeEventTypes'; @@ -41,6 +41,7 @@ export const ObiEditor: FC = ({ let divRef; const { focusedId, focusedEvent, clipboardActions, dialogFactory } = useContext(NodeRendererContext); + const { shellApi } = useShellApi(); const { insertAction, insertActions, @@ -50,9 +51,9 @@ export const ObiEditor: FC = ({ deleteSelectedAction, deleteSelectedActions, updateRecognizer, - } = useDialogEditApi(); - const { createDialog, readDialog, updateDialog } = useDialogApi(); - const { actionsContainLuIntent } = useActionApi(); + } = useDialogEditApi(shellApi); + const { createDialog, readDialog, updateDialog } = useDialogApi(shellApi); + const { actionsContainLuIntent } = useActionApi(shellApi); const trackActionChange = (actionPath: string) => { const affectedPaths = DialogUtils.getParentPaths(actionPath); diff --git a/Composer/packages/extensions/visual-designer/src/index.tsx b/Composer/packages/extensions/visual-designer/src/index.tsx index 906149c786..0e7313021d 100644 --- a/Composer/packages/extensions/visual-designer/src/index.tsx +++ b/Composer/packages/extensions/visual-designer/src/index.tsx @@ -4,7 +4,7 @@ /** @jsx jsx */ import { jsx, css, CacheProvider } from '@emotion/core'; import createCache from '@emotion/cache'; -import React, { useRef } from 'react'; +import React, { useRef, useMemo } from 'react'; import isEqual from 'lodash/isEqual'; import formatMessage from 'format-message'; import { DialogFactory } from '@bfc/shared'; @@ -16,6 +16,7 @@ import { SelfHostContext } from './store/SelfHostContext'; import { FlowSchemaContext } from './store/FlowSchemaContext'; import { FlowSchemaProvider } from './schema/flowSchemaProvider'; import { mergePluginConfig } from './utils/mergePluginConfig'; +import { getCustomSchema } from './utils/getCustomSchema'; formatMessage.setup({ missingTranslation: 'ignore', @@ -41,7 +42,16 @@ export interface VisualDesignerProps { } const VisualDesigner: React.FC = ({ schema }): JSX.Element => { const { shellApi, plugins, ...shellData } = useShellApi(); - const { dialogId, focusedEvent, focusedActions, focusedTab, clipboardActions, data: inputData, hosted } = shellData; + const { + dialogId, + focusedEvent, + focusedActions, + focusedTab, + clipboardActions, + data: inputData, + hosted, + schemas, + } = shellData; const dataCache = useRef({}); @@ -77,6 +87,12 @@ const VisualDesigner: React.FC = ({ schema }): JSX.Element const focusedId = Array.isArray(focusedActions) && focusedActions[0] ? focusedActions[0] : ''; + // Compute schema diff + const customSchema = useMemo(() => getCustomSchema(schemas?.default, schemas?.sdk?.content), [ + schemas?.sdk?.content, + schemas?.default, + ]); + const nodeContext: NodeRendererContextValue = { focusedId, focusedEvent, @@ -89,6 +105,7 @@ const VisualDesigner: React.FC = ({ schema }): JSX.Element removeLgTemplates, removeLuIntent, dialogFactory: new DialogFactory(schema), + customSchemas: customSchema ? [customSchema] : [], }; const visualEditorConfig = mergePluginConfig(...plugins); diff --git a/Composer/packages/extensions/visual-designer/src/store/NodeRendererContext.ts b/Composer/packages/extensions/visual-designer/src/store/NodeRendererContext.ts index 4e7aadd7be..0d45eee0c9 100644 --- a/Composer/packages/extensions/visual-designer/src/store/NodeRendererContext.ts +++ b/Composer/packages/extensions/visual-designer/src/store/NodeRendererContext.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { ShellApi, DialogFactory } from '@bfc/shared'; +import { ShellApi, DialogFactory, OBISchema } from '@bfc/shared'; type ShellApiFuncs = | 'getLgTemplates' @@ -18,6 +18,7 @@ export interface NodeRendererContextValue extends Pick focusedTab?: string; clipboardActions: any[]; dialogFactory: DialogFactory; + customSchemas: OBISchema[]; } export const NodeRendererContext = React.createContext({ @@ -32,4 +33,5 @@ export const NodeRendererContext = React.createContext updateLgTemplate: () => Promise.resolve(), removeLuIntent: () => Promise.resolve(), dialogFactory: new DialogFactory(), + customSchemas: [], }); diff --git a/Composer/packages/extensions/visual-designer/src/utils/getCustomSchema.ts b/Composer/packages/extensions/visual-designer/src/utils/getCustomSchema.ts new file mode 100644 index 0000000000..dd6dac5737 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/utils/getCustomSchema.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { OBISchema } from '@bfc/shared'; + +export const getCustomSchema = (baseSchema?: OBISchema, ejectedSchema?: OBISchema): OBISchema | undefined => { + if (!baseSchema || !ejectedSchema) return; + + const baseDefinitions = baseSchema.definitions; + const baseKindHash = Object.keys(baseDefinitions).reduce((hash, $kind) => { + hash[$kind] = true; + return hash; + }, {}); + + const ejectedDefinitions = ejectedSchema.definitions; + const diffKinds = Object.keys(ejectedDefinitions).filter($kind => !baseKindHash[$kind]); + if (diffKinds.length === 0) return; + + const diffSchema = diffKinds.reduce( + (schema, $kind) => { + const definition = ejectedDefinitions[$kind]; + schema.definitions[$kind] = definition; + schema.oneOf?.push({ + title: definition.title || $kind, + description: definition.description || '', + $ref: `#/definitions/${$kind}`, + }); + return schema; + }, + { + oneOf: [], + definitions: {}, + } as OBISchema + ); + + return diffSchema; +}; diff --git a/Composer/packages/lib/code-editor/src/BaseEditor.tsx b/Composer/packages/lib/code-editor/src/BaseEditor.tsx index 5bf3697054..772346afb1 100644 --- a/Composer/packages/lib/code-editor/src/BaseEditor.tsx +++ b/Composer/packages/lib/code-editor/src/BaseEditor.tsx @@ -225,7 +225,6 @@ const BaseEditor: React.FC = props => { messageBarType={hasError ? MessageBarType.error : hasWarning ? MessageBarType.warning : MessageBarType.info} isMultiline={true} dismissButtonAriaLabel={formatMessage('Close')} - overflowButtonAriaLabel={formatMessage('See more')} > {messageHelp} {syntaxLink} diff --git a/Composer/packages/lib/code-editor/src/LuEditor.tsx b/Composer/packages/lib/code-editor/src/LuEditor.tsx index dfb3133465..6250fefa1c 100644 --- a/Composer/packages/lib/code-editor/src/LuEditor.tsx +++ b/Composer/packages/lib/code-editor/src/LuEditor.tsx @@ -10,10 +10,7 @@ import { EditorDidMount, Monaco } from '@monaco-editor/react'; import { registerLULanguage } from './languages'; import { createUrl, createWebSocket, createLanguageClient } from './utils/lspUtil'; import { BaseEditor, BaseEditorProps, OnInit } from './BaseEditor'; - -const LU_HELP = 'https://aka.ms/lu-file-format'; -const placeholder = `> To learn more about the LU file format, read the documentation at -> ${LU_HELP}`; +import { defaultPlaceholder, LU_HELP } from './constants'; export interface LUOption { projectId?: string; @@ -85,7 +82,7 @@ const LuEditor: React.FC = props => { ...props.options, }; - const { luOption, languageServer, onInit: onInitProp, ...restProps } = props; + const { luOption, languageServer, onInit: onInitProp, placeholder = defaultPlaceholder, ...restProps } = props; const luServer = languageServer || defaultLUServer; const onInit: OnInit = monaco => { diff --git a/Composer/packages/lib/code-editor/src/constants.ts b/Composer/packages/lib/code-editor/src/constants.ts new file mode 100644 index 0000000000..1fa17530b1 --- /dev/null +++ b/Composer/packages/lib/code-editor/src/constants.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import formatMessage from 'format-message'; + +export const inlineModePlaceholder = formatMessage(`> add some example phrases to trigger this intent: +> - please tell me the weather +> - what is the weather like in '{city=Seattle}' +> +> entity definitions: +> @ ml city`); + +export const LU_HELP = 'https://aka.ms/lu-file-format'; +export const defaultPlaceholder = formatMessage( + `> To learn more about the LU file format, read the documentation at +> {LU_HELP}`, + { LU_HELP } +); diff --git a/Composer/packages/lib/code-editor/src/index.ts b/Composer/packages/lib/code-editor/src/index.ts index 71dfe2decd..effdf10018 100644 --- a/Composer/packages/lib/code-editor/src/index.ts +++ b/Composer/packages/lib/code-editor/src/index.ts @@ -11,3 +11,4 @@ export * from './BaseEditor'; export * from './JsonEditor'; export * from './LgEditor'; export * from './LuEditor'; +export * from './constants'; diff --git a/Composer/packages/lib/shared/src/types/schema.ts b/Composer/packages/lib/shared/src/types/schema.ts index 583585bf80..df93d1da8b 100644 --- a/Composer/packages/lib/shared/src/types/schema.ts +++ b/Composer/packages/lib/shared/src/types/schema.ts @@ -121,6 +121,12 @@ export enum SDKRoles { // union_*_ = 'union(*)', } +export interface DefinitionSummary { + title: string; + description: string; + $ref: string; +} + export interface OBISchema extends JSONSchema6 { $schema?: string; $role?: string; @@ -132,6 +138,7 @@ export interface OBISchema extends JSONSchema6 { [key: string]: any; }; description?: string; + oneOf?: DefinitionSummary[]; definitions?: any; title?: string; __additional_property?: boolean; diff --git a/Composer/packages/lib/shared/src/types/shell.ts b/Composer/packages/lib/shared/src/types/shell.ts index ce276bb98b..1e4ed15331 100644 --- a/Composer/packages/lib/shared/src/types/shell.ts +++ b/Composer/packages/lib/shared/src/types/shell.ts @@ -4,6 +4,7 @@ import { DialogInfo, LuFile, LgFile, LuIntentSection, LgTemplate } from './indexers'; import { UserSettings } from './settings'; +import { OBISchema } from './schema'; /** Recursively marks all properties as optional. */ type AllPartial = { @@ -18,6 +19,7 @@ export interface EditorSchema { } export interface BotSchemas { + default?: OBISchema; sdk?: any; diagnostics?: any[]; } @@ -59,13 +61,14 @@ export interface ShellApi { onSelect: (ids: string[]) => void; getLgTemplates: (id: string) => LgTemplate[]; copyLgTemplate: (id: string, fromTemplateName: string, toTemplateName?: string) => Promise; + addLgTemplate: (id: string, templateName: string, templateStr: string) => Promise; updateLgTemplate: (id: string, templateName: string, templateStr: string) => Promise; removeLgTemplate: (id: string, templateName: string) => Promise; removeLgTemplates: (id: string, templateNames: string[]) => Promise; getLuIntent: (id: string, intentName: string) => LuIntentSection | undefined; getLuIntents: (id: string) => LuIntentSection[]; - addLuIntent: (id: string, intentName: string, intent: LuIntentSection | undefined) => Promise; - updateLuIntent: (id: string, intentName: string, intent: LuIntentSection | undefined) => Promise; + addLuIntent: (id: string, intentName: string, intent: LuIntentSection) => Promise; + updateLuIntent: (id: string, intentName: string, intent: LuIntentSection) => Promise; removeLuIntent: (id: string, intentName: string) => void; updateRegExIntent: (id: string, intentName: string, pattern: string) => void; createDialog: (actions: any) => Promise; diff --git a/Composer/packages/lib/shared/src/viewUtils.ts b/Composer/packages/lib/shared/src/viewUtils.ts index 81df4043ad..97224f1a6e 100644 --- a/Composer/packages/lib/shared/src/viewUtils.ts +++ b/Composer/packages/lib/shared/src/viewUtils.ts @@ -1,15 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { - IContextualMenuItem, - IContextualMenuProps, -} from 'office-ui-fabric-react/lib/components/ContextualMenu/ContextualMenu.types'; import get from 'lodash/get'; import { SDKKinds } from './types'; import { ConceptLabels } from './labelMap'; -import { DialogFactory } from './dialogFactory'; export const PROMPT_TYPES = [ SDKKinds.AttachmentInput, @@ -161,106 +156,6 @@ export const dialogGroups: DialogGroupsMap = { }, }; -const menuItemHandler = ( - factory: DialogFactory, - handleType: ( - e: React.MouseEvent | React.KeyboardEvent | undefined, - item: IContextualMenuItem - ) => void -) => ( - e: React.MouseEvent | React.KeyboardEvent | undefined, - item: IContextualMenuItem | undefined -) => { - if (item) { - const name = - ConceptLabels[item.$kind] && ConceptLabels[item.$kind].title ? ConceptLabels[item.$kind].title : item.$kind; - item = { - ...item, - data: { - ...factory.create(item.$kind, { - $designer: { name }, - }), - }, - }; - return handleType(e, item); - } -}; - -export const createStepMenu = ( - stepLabels: DialogGroup[], - subMenu = true, - handleType: ( - e: React.MouseEvent | React.KeyboardEvent | undefined, - item: IContextualMenuItem - ) => void, - factory: DialogFactory, - filter?: (x: SDKKinds) => boolean -): IContextualMenuItem[] => { - if (subMenu) { - const stepMenuItems = stepLabels.map(x => { - const item = dialogGroups[x]; - if (item.types.length === 1) { - const conceptLabel = ConceptLabels[item.types[0]]; - return { - key: item.types[0], - name: conceptLabel && conceptLabel.title ? conceptLabel.title : item.types[0], - $kind: item.types[0], - onClick: menuItemHandler(factory, handleType), - }; - } - const subMenu: IContextualMenuProps = { - items: item.types.filter(filter || (() => true)).map($kind => { - const conceptLabel = ConceptLabels[$kind]; - - return { - key: $kind, - name: conceptLabel && conceptLabel.title ? conceptLabel.title : $kind, - $kind: $kind, - }; - }), - onItemClick: menuItemHandler(factory, handleType), - }; - - const menuItem: IContextualMenuItem = { - key: item.label, - text: item.label, - name: item.label, - subMenuProps: subMenu, - }; - return menuItem; - }); - - return stepMenuItems; - } else { - const stepMenuItems = dialogGroups[stepLabels[0]].types.map(item => { - const conceptLabel = ConceptLabels[item]; - const name = conceptLabel && conceptLabel.title ? conceptLabel.title : item; - const menuItem: IContextualMenuItem = { - key: item, - text: name, - name: name, - $kind: item, - ...factory.create(item, { - $designer: { name }, - }), - data: { - $kind: item, - ...factory.create(item, { - $designer: { name }, - }), - }, - onClick: (e, item: IContextualMenuItem | undefined) => { - if (item) { - return handleType(e, item); - } - }, - }; - return menuItem; - }); - return stepMenuItems; - } -}; - export function getDialogGroupByType(type) { let dialogType = DialogGroup.OTHER; diff --git a/Composer/packages/server/__tests__/controllers/project.test.ts b/Composer/packages/server/__tests__/controllers/project.test.ts index def2c60f4b..c85e08af94 100644 --- a/Composer/packages/server/__tests__/controllers/project.test.ts +++ b/Composer/packages/server/__tests__/controllers/project.test.ts @@ -159,7 +159,7 @@ describe('dialog operation', () => { const mockReq = { params: { projectId }, query: {}, - body: { name: 'test.dialog', content: '' }, + body: { name: 'test2.dialog', content: '' }, } as Request; await ProjectController.createFile(mockReq, mockRes); expect(mockRes.status).toHaveBeenCalledWith(200); @@ -167,7 +167,7 @@ describe('dialog operation', () => { it('should remove dialog', async () => { const mockReq = { - params: { name: 'test.dialog', projectId }, + params: { name: 'test2.dialog', projectId }, query: {}, body: {}, } as Request; diff --git a/Composer/packages/server/src/controllers/project.ts b/Composer/packages/server/src/controllers/project.ts index 8f5cdd32c3..faebddd8e9 100644 --- a/Composer/packages/server/src/controllers/project.ts +++ b/Composer/packages/server/src/controllers/project.ts @@ -80,7 +80,6 @@ async function getProjectById(req: Request, res: Response) { const currentProject = await BotProjectService.getProjectById(projectId, user); if (currentProject !== undefined && (await currentProject.exists())) { - await currentProject.init(); const project = currentProject.getProject(); res.status(200).json({ id: projectId, diff --git a/Composer/packages/server/src/models/bot/botProject.ts b/Composer/packages/server/src/models/bot/botProject.ts index 622f27943d..963f170823 100644 --- a/Composer/packages/server/src/models/bot/botProject.ts +++ b/Composer/packages/server/src/models/bot/botProject.ts @@ -178,6 +178,7 @@ export class BotProject { sdk: { content: sdkSchema, }, + default: this.defaultSDKSchema, diagnostics, }; }; @@ -250,7 +251,7 @@ export class BotProject { }; public deleteFile = async (name: string) => { - if (Path.resolve(name) === 'Main') { + if (Path.basename(name, '.dialog') === this.name) { throw new Error(`Main dialog can't be removed`); } @@ -333,7 +334,7 @@ export class BotProject { try { await this.fileStorage.rmDir(folderPath); } catch (e) { - console.log(e); + // pass } } }; @@ -410,10 +411,12 @@ export class BotProject { if (index === -1) { throw new Error(`no such file at ${relativePath}`); } + const absolutePath = `${this.dir}/${relativePath}`; // only write if the file has actually changed if (this.files[index].content !== content) { + this.files[index].content = content; await this.fileStorage.writeFile(absolutePath, content); } @@ -421,8 +424,6 @@ export class BotProject { // instead of calling stat again which could be expensive const stats = await this.fileStorage.stat(absolutePath); - this.files[index].content = content; - return stats.lastModified; }; @@ -433,10 +434,10 @@ export class BotProject { if (index === -1) { throw new Error(`no such file at ${relativePath}`); } + this.files.splice(index, 1); const absolutePath = `${this.dir}/${relativePath}`; await this.fileStorage.removeFile(absolutePath); - this.files.splice(index, 1); }; // ensure dir exist, dir is a absolute dir path diff --git a/Composer/packages/server/src/services/project.ts b/Composer/packages/server/src/services/project.ts index 0576609c6c..5af12caa27 100644 --- a/Composer/packages/server/src/services/project.ts +++ b/Composer/packages/server/src/services/project.ts @@ -4,6 +4,7 @@ import merge from 'lodash/merge'; import find from 'lodash/find'; import { importResolverGenerator, ResolverResource } from '@bfc/shared'; +import extractMemoryPaths from '@bfc/indexers/lib/dialogUtils/extractMemoryPaths'; import { UserIdentity } from '@bfc/plugin-loader'; import { BotProject } from '../models/bot/botProject'; @@ -35,7 +36,7 @@ export class BotProjectService { public static lgImportResolver(source: string, id: string, projectId: string): ResolverResource { BotProjectService.initialize(); - const project = BotProjectService.currentBotProjects.find(({ id }) => id === projectId); + const project = BotProjectService.getIndexedProjectById(projectId); if (!project) throw new Error('project not found'); const resource = project.files.reduce((result: ResolverResource[], file) => { const { name, content } = file; @@ -50,7 +51,7 @@ export class BotProjectService { public static luImportResolver(source: string, id: string, projectId: string): ResolverResource { BotProjectService.initialize(); - const project = BotProjectService.currentBotProjects.find(({ id }) => id === projectId); + const project = BotProjectService.getIndexedProjectById(projectId); if (!project) throw new Error('project not found'); const resource = project.files.reduce((result: ResolverResource[], file) => { const { name, content } = file; @@ -88,9 +89,13 @@ export class BotProjectService { 'turn.repeatedIds', 'turn.activityProcessed', ]; + const projectVariables = BotProjectService.getIndexedProjectById(projectId) + ?.files.filter(file => file.name.endsWith('.dialog')) + .map(({ content }) => extractMemoryPaths(content)); + const userDefined: string[] = - BotProjectService.currentBotProjects[projectId]?.dialogs.reduce((result: string[], dialog) => { - result = [...dialog.userDefinedVariables, ...result]; + projectVariables?.reduce((result: string[], variables) => { + result = [...variables, ...result]; return result; }, []) || []; return [...defaultProperties, ...userDefined]; @@ -180,9 +185,18 @@ export class BotProjectService { Store.set('projectLocationMap', BotProjectService.projectLocationMap); }; - public static getProjectById = async (projectId: string, user?: UserIdentity) => { + public static getIndexedProjectById(projectId): BotProject | undefined { + // use indexed project + const indexedCurrentProject = BotProjectService.currentBotProjects.find(({ id }) => id === projectId); + if (indexedCurrentProject) return indexedCurrentProject; + } + + public static getProjectById = async (projectId: string, user?: UserIdentity): Promise => { BotProjectService.initialize(); + const cachedProject = BotProjectService.getIndexedProjectById(projectId); + if (cachedProject) return cachedProject; + if (!BotProjectService.projectLocationMap?.[projectId]) { throw new Error('project not found in cache'); } else { diff --git a/Composer/packages/ui-plugins/expressions/src/index.ts b/Composer/packages/ui-plugins/expressions/src/index.ts index 991521568b..e2c9d34412 100644 --- a/Composer/packages/ui-plugins/expressions/src/index.ts +++ b/Composer/packages/ui-plugins/expressions/src/index.ts @@ -10,6 +10,7 @@ const config: PluginConfig = { roleSchema: { [SDKRoles.expression]: { field: ExpressionField, + helpLink: 'https://github.com/microsoft/BotBuilder-Samples/tree/master/experimental/common-expression-language', }, }, }; diff --git a/Composer/packages/ui-plugins/lg/src/LgField.tsx b/Composer/packages/ui-plugins/lg/src/LgField.tsx index 7f094320cc..ec3ef16ca5 100644 --- a/Composer/packages/ui-plugins/lg/src/LgField.tsx +++ b/Composer/packages/ui-plugins/lg/src/LgField.tsx @@ -112,7 +112,7 @@ const LgField: React.FC> = props => { > = props => { - const { onChange, value, schema } = props; + const { onChange, value, schema, placeholder } = props; const { currentDialog, designerId, luFiles, shellApi, locale, projectId, userSettings } = useShellApi(); const luFile = luFiles.find(f => f.id === `${currentDialog.id}.${locale}`); @@ -51,13 +51,14 @@ const LuisIntentEditor: React.FC> = props => { return ( ); }; diff --git a/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx b/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx index 1f18ca6b48..377fd69c70 100644 --- a/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx +++ b/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx @@ -20,16 +20,26 @@ const getOptions = (enumSchema: JSONSchema7) => { return enumSchema.enum.map(o => o as string); }; +const expectedResponsesPlaceholder = () => + formatMessage(`> add some expected user responses: +> - Please remind me to '{itemTitle=buy milk}' +> - remind me to '{itemTitle}' +> - add '{itemTitle}' to my todo list +> +> entity definitions: +> @ ml itemTitle +`); + const UserInput: React.FC> = props => { const { onChange, getSchema, value, id, uiOptions, getError, definitions, depth, schema = {} } = props; const { currentDialog, designerId } = useShellApi(); const { recognizers } = usePluginConfig(); - const { const: $kind } = (schema?.properties?.['$kind'] as any) || {}; + const { const: $kind } = (schema?.properties?.$kind as { const: string }) || {}; const intentName = new LuMetaData(new LuType($kind).toString(), designerId).toString(); const type = recognizerType(currentDialog); - const Editor: any = type === SDKKinds.LuisRecognizer && recognizers.find(r => r.id === type)?.editor; + const Editor = type === SDKKinds.LuisRecognizer && recognizers.find(r => r.id === type)?.editor; const intentLabel = formatMessage('Expected responses (intent: #{intentName})', { intentName }); return ( @@ -72,7 +82,7 @@ const UserInput: React.FC> = props => { {Editor && $kind !== SDKKinds.AttachmentInput && ( - {}} /> + {}} placeholder={expectedResponsesPlaceholder()} /> )} {getSchema('defaultLocale') && ( diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx index b1d8e6bcb7..5e43fee025 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { FieldProps } from '@bfc/extension'; import { getUiLabel, - getUISchema, + getUIOptions, getUiPlaceholder, getUiDescription, schemaField, @@ -20,7 +20,7 @@ export const SkillEndpointField: React.FC = props => { const pluginConfig = usePluginConfig(); const uiOptions = { - ...getUISchema(schema, pluginConfig.formSchema), + ...getUIOptions(schema, pluginConfig.formSchema), ...baseUIOptions, };