diff --git a/Composer/cypress/integration/TriggerCreation.spec.ts b/Composer/cypress/integration/TriggerCreation.spec.ts index 589f0a1324..3302c3bde6 100644 --- a/Composer/cypress/integration/TriggerCreation.spec.ts +++ b/Composer/cypress/integration/TriggerCreation.spec.ts @@ -32,7 +32,7 @@ context('Creating a new trigger', () => { cy.findAllByText('Add a trigger').click({ force: true }); cy.findByTestId('triggerTypeDropDown').click(); cy.get('[title="Dialog events"]').click(); - cy.findByTestId('eventTypeDropDown').click(); + cy.findByText('Select an event type').click(); cy.findByText('Dialog started (Begin dialog event)').click(); cy.findByTestId('triggerFormSubmit').click(); cy.findAllByText('Begin dialog event').should('exist'); @@ -55,7 +55,7 @@ context('Creating a new trigger', () => { cy.findAllByText('Add a trigger').click({ force: true }); cy.findByTestId('triggerTypeDropDown').click(); cy.get('[title="Activities"]').click(); - cy.findByTestId('activityTypeDropDown').click(); + cy.findByText('Select an activity type').click(); cy.findByText('Activities (Activity received)').click(); cy.findByTestId('triggerFormSubmit').click(); cy.findAllByText('Activities').should('exist'); diff --git a/Composer/packages/client/__tests__/components/triggerCreationModal.test.tsx b/Composer/packages/client/__tests__/components/TriggerCreationModal/triggerCreationModal.test.tsx similarity index 59% rename from Composer/packages/client/__tests__/components/triggerCreationModal.test.tsx rename to Composer/packages/client/__tests__/components/TriggerCreationModal/triggerCreationModal.test.tsx index b475031548..94642b4d0b 100644 --- a/Composer/packages/client/__tests__/components/triggerCreationModal.test.tsx +++ b/Composer/packages/client/__tests__/components/TriggerCreationModal/triggerCreationModal.test.tsx @@ -3,9 +3,10 @@ import * as React from 'react'; import { fireEvent, waitFor } from '@botframework-composer/test-utils'; +import { EditorExtension, PluginConfig } from '@bfc/extension-client'; -import { TriggerCreationModal } from '../../src/components/ProjectTree/TriggerCreationModal'; -import { renderWithRecoil } from '../testUtils'; +import { TriggerCreationModal } from '../../../src/components/TriggerCreationModal'; +import { renderWithRecoil } from '../../testUtils'; const projectId = '123a-bv3c4'; @@ -13,15 +14,34 @@ describe('', () => { const onSubmitMock = jest.fn(); const onDismissMock = jest.fn(); + const pluginsStub: PluginConfig = { + uiSchema: { + 'Microsoft.OnIntent': { + trigger: { + label: 'Intent recognized', + order: 1, + }, + }, + 'Microsoft.OnQnAMatch': { + trigger: { + label: 'QnA Intent recognized', + order: 2, + }, + }, + }, + }; + function renderComponent() { return renderWithRecoil( - + + + ); } @@ -30,7 +50,7 @@ describe('', () => { expect(component.container).toBeDefined(); }); - it('hould create a Luis Intent recognized', async () => { + it('should create a Luis Intent recognized', async () => { const component = renderComponent(); const triggerType = component.getByTestId('triggerTypeDropDown'); fireEvent.click(triggerType); diff --git a/Composer/packages/client/__tests__/components/TriggerCreationModal/triggerOptionTree.test.ts b/Composer/packages/client/__tests__/components/TriggerCreationModal/triggerOptionTree.test.ts new file mode 100644 index 0000000000..adb91d2738 --- /dev/null +++ b/Composer/packages/client/__tests__/components/TriggerCreationModal/triggerOptionTree.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TriggerUISchema } from '@bfc/extension-client'; +import { SDKKinds } from '@botframework-composer/types'; + +import { + generateTriggerOptionTree, + TriggerOptionGroupNode, +} from '../../../src/components/TriggerCreationModal/TriggerOptionTree'; + +describe('generateTriggerOptionTree()', () => { + it('can generate one layer tree.', () => { + const simpleTriggerUIOptions: TriggerUISchema = { + [SDKKinds.OnIntent]: { + label: '1.OnIntent', + order: 1, + }, + [SDKKinds.OnInvokeActivity]: { + label: '2.OnInvokeActivity', + order: 2, + }, + }; + const tree = generateTriggerOptionTree(simpleTriggerUIOptions, 'Select a trigger', 'Which trigger?'); + + expect(tree.prompt).toEqual('Select a trigger'); + expect(tree.placeholder).toEqual('Which trigger?'); + + expect(tree.parent).toBeNull(); + expect(tree.children.length).toEqual(2); + + expect(tree.children[0].label).toEqual('1.OnIntent'); + expect(tree.children[0].parent).toEqual(tree); + + expect(tree.children[1].label).toEqual('2.OnInvokeActivity'); + expect(tree.children[1].parent).toEqual(tree); + }); + + it('can generate tree with submenu.', () => { + const advancedTriggerUIOptions: TriggerUISchema = { + [SDKKinds.OnIntent]: { + label: '1.OnIntent', + order: 1, + }, + [SDKKinds.OnTypingActivity]: { + label: '2.1.OnTypingActivity', + order: 2.1, + submenu: { + label: '2.Activities', + prompt: 'Select an activity trigger', + placeholder: 'Which activity?', + }, + }, + [SDKKinds.OnEventActivity]: { + label: '2.2OnEventActivity', + order: 2.2, + submenu: '2.Activities', + }, + [SDKKinds.OnInvokeActivity]: { + label: '2.3OnInvokeActivity', + order: 2.3, + submenu: '2.Activities', + }, + }; + const tree = generateTriggerOptionTree(advancedTriggerUIOptions, 'Select a trigger', 'Which trigger?'); + + expect(tree.children.length).toEqual(2); + + expect(tree.children[0].label).toEqual('1.OnIntent'); + expect(tree.children[0].parent).toEqual(tree); + + const secondChild = tree.children[1] as TriggerOptionGroupNode; + expect(secondChild.label).toEqual('2.Activities'); + expect(secondChild.prompt).toEqual('Select an activity trigger'); + expect(secondChild.children.length).toEqual(3); + + expect(secondChild.children[0].label).toEqual('2.1.OnTypingActivity'); + expect(secondChild.children[0].parent).toEqual(secondChild); + }); +}); diff --git a/Composer/packages/client/__tests__/components/design.test.tsx b/Composer/packages/client/__tests__/components/design.test.tsx index e1c259473e..58084c5a69 100644 --- a/Composer/packages/client/__tests__/components/design.test.tsx +++ b/Composer/packages/client/__tests__/components/design.test.tsx @@ -7,7 +7,7 @@ import { fireEvent } from '@botframework-composer/test-utils'; import { renderWithRecoil } from '../testUtils'; import { SAMPLE_DIALOG } from '../mocks/sampleDialog'; import { ProjectTree } from '../../src/components/ProjectTree/ProjectTree'; -import { TriggerCreationModal } from '../../src/components/ProjectTree/TriggerCreationModal'; +import { TriggerCreationModal } from '../../src/components/TriggerCreationModal'; import { CreateDialogModal } from '../../src/pages/design/createDialogModal'; import { dialogsSelectorFamily, diff --git a/Composer/packages/client/__tests__/utils/dialogUtil.test.js b/Composer/packages/client/__tests__/utils/dialogUtil.test.js index 718bcbe1e6..1438f5df6e 100644 --- a/Composer/packages/client/__tests__/utils/dialogUtil.test.js +++ b/Composer/packages/client/__tests__/utils/dialogUtil.test.js @@ -9,9 +9,6 @@ import { updateRegExIntent, createSelectedPath, deleteTrigger, - getTriggerTypes, - getEventTypes, - getActivityTypes, getFriendlyName, getBreadcrumbLabel, getSelected, @@ -181,52 +178,6 @@ describe('deleteTrigger', () => { }); }); -describe('getTriggerTypes', () => { - it('return trigger types', () => { - const triggerTypes = getTriggerTypes(); - expect(triggerTypes).toEqual([ - { key: 'Microsoft.OnIntent', text: 'Intent recognized' }, - { key: 'Microsoft.OnQnAMatch', text: 'QnA Intent recognized' }, - { key: 'Microsoft.OnUnknownIntent', text: 'Unknown intent' }, - { key: 'Microsoft.OnDialogEvent', text: 'Dialog events' }, - { key: 'Microsoft.OnActivity', text: 'Activities' }, - { key: 'Microsoft.OnChooseIntent', text: 'Duplicated intents recognized' }, - { key: 'OnCustomEvent', text: 'Custom events' }, - ]); - }); -}); - -describe('getEventTypes', () => { - it('return event types', () => { - const eventTypes = getEventTypes(); - expect(eventTypes).toEqual([ - { key: 'Microsoft.OnBeginDialog', text: 'Dialog started (Begin dialog event)' }, - { key: 'Microsoft.OnCancelDialog', text: 'Dialog cancelled (Cancel dialog event)' }, - { key: 'Microsoft.OnError', text: 'Error occurred (Error event)' }, - { key: 'Microsoft.OnRepromptDialog', text: 'Re-prompt for input (Reprompt dialog event)' }, - ]); - }); -}); - -describe('getActivityTypes', () => { - it('return activity types', () => { - const activityTypes = getActivityTypes(); - expect(activityTypes).toEqual([ - { key: 'Microsoft.OnActivity', text: 'Activities (Activity received)' }, - { key: 'Microsoft.OnConversationUpdateActivity', text: 'Greeting (ConversationUpdate activity)' }, - { key: 'Microsoft.OnEndOfConversationActivity', text: 'Conversation ended (EndOfConversation activity)' }, - { key: 'Microsoft.OnEventActivity', text: 'Event received (Event activity)' }, - { key: 'Microsoft.OnHandoffActivity', text: 'Handover to human (Handoff activity)' }, - { key: 'Microsoft.OnInvokeActivity', text: 'Conversation invoked (Invoke activity)' }, - { key: 'Microsoft.OnTypingActivity', text: 'User is typing (Typing activity)' }, - { key: 'Microsoft.OnMessageActivity', text: 'Message received (Message received activity)' }, - { key: 'Microsoft.OnMessageDeleteActivity', text: 'Message deleted (Message deleted activity)' }, - { key: 'Microsoft.OnMessageReactionActivity', text: 'Message reaction (Message reaction activity)' }, - { key: 'Microsoft.OnMessageUpdateActivity', text: 'Message updated (Message updated activity)' }, - ]); - }); -}); - describe('getFriendlyName', () => { it('return friendly name', () => { const name = getFriendlyName(dialogs[0].content); diff --git a/Composer/packages/client/config/env.js b/Composer/packages/client/config/env.js index 4af967119d..44abd13c3c 100644 --- a/Composer/packages/client/config/env.js +++ b/Composer/packages/client/config/env.js @@ -22,7 +22,7 @@ const dotenvFiles = [ // since normally you expect tests to produce the same // results for everyone NODE_ENV !== 'test' && `${paths.dotenv}.local`, - paths.dotenv + paths.dotenv, ].filter(Boolean); // Load environment variables from .env* files. Suppress warnings using silent @@ -30,11 +30,11 @@ const dotenvFiles = [ // that have already been set. Variable expansion is supported in .env files. // https://github.com/motdotla/dotenv // https://github.com/motdotla/dotenv-expand -dotenvFiles.forEach(dotenvFile => { +dotenvFiles.forEach((dotenvFile) => { if (fs.existsSync(dotenvFile)) { require('dotenv-expand')( require('dotenv').config({ - path: dotenvFile + path: dotenvFile, }) ); } @@ -61,8 +61,8 @@ function getGitSha() { const appDirectory = fs.realpathSync(process.cwd()); process.env.NODE_PATH = (process.env.NODE_PATH || '') .split(path.delimiter) - .filter(folder => folder && !path.isAbsolute(folder)) - .map(folder => path.resolve(appDirectory, folder)) + .filter((folder) => folder && !path.isAbsolute(folder)) + .map((folder) => path.resolve(appDirectory, folder)) .join(path.delimiter); // Grab NODE_ENV and COMPOSER_* environment variables and prepare them to be @@ -71,7 +71,7 @@ const COMPOSER = /^COMPOSER_/i; function getClientEnvironment(publicUrl) { const raw = Object.keys(process.env) - .filter(key => COMPOSER.test(key)) + .filter((key) => COMPOSER.test(key)) .reduce( (env, key) => { env[key] = process.env[key]; @@ -86,16 +86,14 @@ function getClientEnvironment(publicUrl) { // This should only be used as an escape hatch. Normally you would put // images into the `src` and `import` them in code to get their paths. PUBLIC_URL: publicUrl, - GIT_SHA: getGitSha() - .toString() - .replace('\n', ''), + GIT_SHA: getGitSha().toString().replace('\n', ''), SDK_PACKAGE_VERSION: '4.11.0', // TODO: change this when Composer supports custom schema/custom runtime COMPOSER_VERSION: '1.3.1', LOCAL_PUBLISH_PATH: process.env.LOCAL_PUBLISH_PATH || path.resolve(process.cwd(), '../../../extensions/localPublish/hostedBots'), WEBLOGIN_CLIENTID: process.env.WEBLOGIN_CLIENTID, WEBLOGIN_TENANTID: process.env.WEBLOGIN_TENANTID, - WEBLOGIN_REDIRECTURL: process.env.WEBLOGIN_REDIRECTURL + WEBLOGIN_REDIRECTURL: process.env.WEBLOGIN_REDIRECTURL, } ); // Stringify all values so we can feed into Webpack DefinePlugin @@ -103,7 +101,7 @@ function getClientEnvironment(publicUrl) { 'process.env': Object.keys(raw).reduce((env, key) => { env[key] = JSON.stringify(raw[key]); return env; - }, {}) + }, {}), }; return { raw, stringified }; diff --git a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx b/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx deleted file mode 100644 index 0f20a0ba41..0000000000 --- a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx +++ /dev/null @@ -1,469 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** @jsx jsx */ -import { jsx, css } from '@emotion/core'; -import React, { useState } from 'react'; -import formatMessage from 'format-message'; -import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; -import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; -import { Label } from 'office-ui-fabric-react/lib/Label'; -import { Stack } from 'office-ui-fabric-react/lib/Stack'; -import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; -import { Icon } from 'office-ui-fabric-react/lib/Icon'; -import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; -import { luIndexer, combineMessage } from '@bfc/indexers'; -import { PlaceHolderSectionName } from '@bfc/indexers/lib/utils/luUtil'; -import { SDKKinds, RegexRecognizer, checkForPVASchema } from '@bfc/shared'; -import { LuEditor, inlineModePlaceholder } from '@bfc/code-editor'; -import { IComboBoxOption } from 'office-ui-fabric-react/lib/ComboBox'; -import { useRecoilValue } from 'recoil'; -import { FontWeights } from '@uifabric/styling'; -import { FontSizes } from '@uifabric/fluent-theme'; - -import { - getTriggerTypes, - TriggerFormData, - TriggerFormDataErrors, - eventTypeKey, - customEventKey, - intentTypeKey, - activityTypeKey, - getEventTypes, - getActivityTypes, - qnaMatcherKey, - onChooseIntentKey, -} from '../../utils/dialogUtil'; -import { schemasState, userSettingsState } from '../../recoilModel/atoms'; -import { nameRegex } from '../../constants'; -import { isRegExRecognizerType, isLUISnQnARecognizerType } from '../../utils/dialogValidator'; -import { dialogsSelectorFamily } from '../../recoilModel'; -import TelemetryClient from '../../telemetry/TelemetryClient'; -// -------------------- Styles -------------------- // - -const styles = { - dialog: { - title: { - fontWeight: FontWeights.bold, - fontSize: FontSizes.size20, - paddingTop: '14px', - paddingBottom: '11px', - }, - subText: { - fontSize: FontSizes.size14, - }, - }, - modal: { - main: { - maxWidth: '600px !important', - }, - }, -}; - -const dropdownStyles = { - label: { - fontWeight: FontWeights.semibold, - }, - dropdown: { - width: '400px', - }, - root: { - marginBottom: '20px', - }, -}; - -const dialogWindow = css` - display: flex; - flex-direction: column; - width: 400px; - min-height: 300px; -`; - -const intent = { - root: { - width: '400px', - paddingBottom: '20px', - }, -}; - -const optionRow = { - display: 'flex', - height: 15, - fontSize: 15, -}; - -export const warningIcon = { - marginLeft: 5, - color: '#BE880A', - fontSize: 12, -}; - -// -------------------- Validation Helpers -------------------- // - -const initialFormDataErrors = { - $kind: '', - intent: '', - event: '', - triggerPhrases: '', - regEx: '', - activity: '', -}; - -const getLuDiagnostics = (intent: string, triggerPhrases: string) => { - const content = `#${intent}\n${triggerPhrases}`; - const { diagnostics } = luIndexer.parse(content, '', {}); - return combineMessage(diagnostics); -}; - -const validateIntentName = (selectedType: string, intent: string): string | undefined => { - if (selectedType === intentTypeKey && (!intent || !nameRegex.test(intent))) { - return formatMessage('Spaces and special characters are not allowed. Use letters, numbers, -, or _.'); - } - return undefined; -}; - -const validateDupRegExIntent = ( - selectedType: string, - intent: string, - isRegEx: boolean, - regExIntents: [{ intent: string; pattern: string }] -): string | undefined => { - if (selectedType === intentTypeKey && isRegEx && regExIntents.find((ri) => ri.intent === intent)) { - return formatMessage(`RegEx {intent} is already defined`, { intent }); - } - return undefined; -}; - -const validateRegExPattern = (selectedType: string, isRegEx: boolean, regEx: string): string | undefined => { - if (selectedType === intentTypeKey && isRegEx && !regEx) { - return formatMessage('Please input regEx pattern'); - } - return undefined; -}; - -const validateEventName = (selectedType: string, $kind: string, eventName: string): string | undefined => { - if (selectedType === customEventKey && $kind === eventTypeKey && !eventName) { - return formatMessage('Please enter an event name'); - } - return undefined; -}; - -const validateEventKind = (selectedType: string, $kind: string): string | undefined => { - if (selectedType === eventTypeKey && !$kind) { - return formatMessage('Please select a event type'); - } - - if (selectedType === activityTypeKey && !$kind) { - return formatMessage('Please select an activity type'); - } - return undefined; -}; - -const validateTriggerKind = (selectedType: string): string | undefined => { - if (!selectedType) { - return formatMessage('Please select a trigger type'); - } - return undefined; -}; - -const validateTriggerPhrases = ( - selectedType: string, - isRegEx: boolean, - intent: string, - triggerPhrases: string -): string | undefined => { - if (selectedType === intentTypeKey && !isRegEx && triggerPhrases) { - return getLuDiagnostics(intent, triggerPhrases); - } - return undefined; -}; - -const validateForm = ( - selectedType: string, - data: TriggerFormData, - isRegEx: boolean, - regExIntents: [{ intent: string; pattern: string }] -): TriggerFormDataErrors => { - const errors: TriggerFormDataErrors = {}; - const { $kind, event: eventName, intent, regEx, triggerPhrases } = data; - - errors.event = validateEventName(selectedType, $kind, eventName) ?? validateEventKind(selectedType, $kind); - errors.$kind = validateTriggerKind(selectedType); - errors.intent = validateIntentName(selectedType, intent); - errors.regEx = - validateDupRegExIntent(selectedType, intent, isRegEx, regExIntents) ?? - validateRegExPattern(selectedType, isRegEx, regEx); - errors.triggerPhrases = validateTriggerPhrases(selectedType, isRegEx, intent, triggerPhrases); - return errors; -}; - -// -------------------- TriggerCreationModal -------------------- // - -interface TriggerCreationModalProps { - projectId: string; - dialogId: string; - isOpen: boolean; - onDismiss: () => void; - onSubmit: (dialogId: string, formData: TriggerFormData) => void; -} - -export const TriggerCreationModal: React.FC = (props) => { - const { isOpen, onDismiss, onSubmit, dialogId, projectId } = props; - const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); - const schemas = useRecoilValue(schemasState(projectId)); - const userSettings = useRecoilValue(userSettingsState); - const dialogFile = dialogs.find((dialog) => dialog.id === dialogId); - const isRegEx = isRegExRecognizerType(dialogFile); - const isLUISnQnA = isLUISnQnARecognizerType(dialogFile); - const regexIntents = (dialogFile?.content?.recognizer as RegexRecognizer)?.intents ?? []; - const initialFormData: TriggerFormData = { - errors: initialFormDataErrors, - $kind: intentTypeKey, - event: '', - intent: '', - triggerPhrases: '', - regEx: '', - }; - const [formData, setFormData] = useState(initialFormData); - const [selectedType, setSelectedType] = useState(intentTypeKey); - const showIntentName = selectedType === intentTypeKey; - const showRegExDropDown = selectedType === intentTypeKey && isRegEx; - const showTriggerPhrase = selectedType === intentTypeKey && !isRegEx; - const showEventDropDown = selectedType === eventTypeKey; - const showActivityDropDown = selectedType === activityTypeKey; - const showCustomEvent = selectedType === customEventKey; - const eventTypes: IComboBoxOption[] = getEventTypes(); - const activityTypes: IDropdownOption[] = getActivityTypes(); - let triggerTypeOptions: IDropdownOption[] = getTriggerTypes(); - - if (schemas && checkForPVASchema(schemas.sdk)) { - triggerTypeOptions = triggerTypeOptions.filter( - (elem) => - elem.text.indexOf('QnA Intent recognized') == -1 && elem.text.indexOf('Duplicated intents recognized') == -1 - ); - } - - if (isRegEx) { - const qnaMatcherOption = triggerTypeOptions.find((t) => t.key === qnaMatcherKey); - if (qnaMatcherOption) { - qnaMatcherOption.data = { icon: 'Warning' }; - } - const onChooseIntentOption = triggerTypeOptions.find((t) => t.key === onChooseIntentKey); - if (onChooseIntentOption) { - onChooseIntentOption.data = { icon: 'Warning' }; - } - } - - const onRenderOption = (option?: IDropdownOption) => { - if (option == null) return null; - return ( -
- {option.text} - {option.data?.icon && } -
- ); - }; - - const shouldDisable = (errors: TriggerFormDataErrors) => { - for (const key in errors) { - if (errors[key]) { - return true; - } - } - return false; - }; - - const onClickSubmitButton = (e) => { - e.preventDefault(); - - //If still have some errors here, it is a bug. - const errors = validateForm(selectedType, formData, isRegEx, regexIntents as any); - if (shouldDisable(errors)) { - setFormData({ ...formData, errors }); - return; - } - onDismiss(); - onSubmit(dialogId, formData); - TelemetryClient.track('AddNewTriggerCompleted', { kind: formData.$kind }); - }; - - const onSelectTriggerType = (e: React.FormEvent, option) => { - setSelectedType(option.key || ''); - const compoundTypes = [activityTypeKey, eventTypeKey]; - const isCompound = compoundTypes.some((t) => option.key === t); - let newFormData: TriggerFormData = initialFormData; - if (isCompound) { - newFormData = { ...newFormData, $kind: '' }; - } else { - newFormData = { ...newFormData, $kind: option.key === customEventKey ? SDKKinds.OnDialogEvent : option.key }; - } - setFormData({ ...newFormData, errors: initialFormDataErrors }); - }; - - const handleEventNameChange = (event: React.FormEvent, value?: string) => { - const errors: TriggerFormDataErrors = {}; - errors.event = validateEventName(selectedType, SDKKinds.OnDialogEvent, value || ''); - setFormData({ - ...formData, - $kind: SDKKinds.OnDialogEvent, - event: value || '', - errors: { ...formData.errors, ...errors }, - }); - }; - - const handleEventTypeChange = (e: React.FormEvent, option?: IDropdownOption) => { - if (option) { - const errors: TriggerFormDataErrors = {}; - errors.event = validateEventKind(selectedType, option.key as string); - setFormData({ ...formData, $kind: option.key as string, errors: { ...formData.errors, ...errors } }); - } - }; - - const onNameChange = (e: React.FormEvent, name: string | undefined) => { - const errors: TriggerFormDataErrors = {}; - if (name == null) return; - errors.intent = validateIntentName(selectedType, name); - if (showTriggerPhrase && formData.triggerPhrases) { - errors.triggerPhrases = getLuDiagnostics(name, formData.triggerPhrases); - } - setFormData({ ...formData, intent: name, errors: { ...formData.errors, ...errors } }); - }; - - const onChangeRegEx = (e: React.FormEvent, pattern: string | undefined) => { - const errors: TriggerFormDataErrors = {}; - if (pattern == null) return; - errors.regEx = validateRegExPattern(selectedType, isRegEx, pattern); - setFormData({ ...formData, regEx: pattern, errors: { ...formData.errors, ...errors } }); - }; - - //Trigger phrase is optional - const onTriggerPhrasesChange = (body: string) => { - const errors: TriggerFormDataErrors = {}; - if (body) { - errors.triggerPhrases = getLuDiagnostics(formData.intent, body); - } else { - errors.triggerPhrases = ''; - } - setFormData({ ...formData, triggerPhrases: body, errors: { ...formData.errors, ...errors } }); - }; - const errors = validateForm(selectedType, formData, isRegEx, regexIntents as any); - const disable = shouldDisable(errors); - - return ( - - ); -}; - -export default TriggerCreationModal; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/TriggerCreationModal.tsx b/Composer/packages/client/src/components/TriggerCreationModal/TriggerCreationModal.tsx new file mode 100644 index 0000000000..3cc727d1ed --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/TriggerCreationModal.tsx @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useState } from 'react'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { SDKKinds, RegexRecognizer } from '@bfc/shared'; +import { useRecoilValue } from 'recoil'; + +import { TriggerFormData, TriggerFormDataErrors } from '../../utils/dialogUtil'; +import { userSettingsState } from '../../recoilModel/atoms'; +import { dialogsSelectorFamily } from '../../recoilModel'; +import { isRegExRecognizerType, resolveRecognizer$kind } from '../../utils/dialogValidator'; +import TelemetryClient from '../../telemetry/TelemetryClient'; + +import { dialogContentStyles, modalStyles, dialogWindowStyles } from './styles'; +import { validateForm } from './validators'; +import { resolveTriggerWidget } from './resolveTriggerWidget'; +import { TriggerDropdownGroup } from './TriggerDropdownGroup'; + +const hasError = (errors: TriggerFormDataErrors) => Object.values(errors).some((msg) => !!msg); + +export const initialFormData: TriggerFormData = { + errors: {}, + $kind: SDKKinds.OnIntent, + event: '', + intent: '', + triggerPhrases: '', + regEx: '', +}; + +interface TriggerCreationModalProps { + projectId: string; + dialogId: string; + isOpen: boolean; + onDismiss: () => void; + onSubmit: (dialogId: string, formData: TriggerFormData) => void; +} + +export const TriggerCreationModal: React.FC = (props) => { + const { isOpen, onDismiss, onSubmit, dialogId, projectId } = props; + const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); + + const userSettings = useRecoilValue(userSettingsState); + const dialogFile = dialogs.find((dialog) => dialog.id === dialogId); + const recognizer$kind = resolveRecognizer$kind(dialogFile); + const isRegEx = isRegExRecognizerType(dialogFile); + const regexIntents = (dialogFile?.content?.recognizer as RegexRecognizer)?.intents ?? []; + + const [formData, setFormData] = useState(initialFormData); + const [selectedType, setSelectedType] = useState(SDKKinds.OnIntent); + + const onClickSubmitButton = (e) => { + e.preventDefault(); + + const errors = validateForm(selectedType, formData, isRegEx, regexIntents as any); + if (hasError(errors)) { + setFormData({ ...formData, errors }); + return; + } + onDismiss(); + onSubmit(dialogId, { ...formData, $kind: selectedType }); + TelemetryClient.track('AddNewTriggerCompleted', { kind: formData.$kind }); + }; + + const errors = validateForm(selectedType, formData, isRegEx, regexIntents as any); + const disable = hasError(errors); + + const triggerWidget = resolveTriggerWidget( + selectedType, + dialogFile, + formData, + setFormData, + userSettings, + projectId, + dialogId + ); + + return ( + + ); +}; + +export default TriggerCreationModal; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/TriggerDropdownGroup.tsx b/Composer/packages/client/src/components/TriggerCreationModal/TriggerDropdownGroup.tsx new file mode 100644 index 0000000000..1b72b61d00 --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/TriggerDropdownGroup.tsx @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { FC, ReactNode, useCallback, useMemo, useState } from 'react'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; +import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import { SDKKinds } from '@bfc/shared'; +import { useTriggerConfig } from '@bfc/extension-client'; +import formatMessage from 'format-message'; + +import { dropdownStyles, optionStyles, warningIconStyles } from './styles'; +import { + generateTriggerOptionTree, + TriggerOptionGroupNode, + TriggerOptionLeafNode, + TriggerOptionTreeNode, +} from './TriggerOptionTree'; +import { checkRecognizerCompatibility } from './checkRecognizerCompatibility'; + +export interface TriggerDropwdownGroupProps { + recognizerType: SDKKinds | undefined; + triggerType: string; + setTriggerType: (type: string) => void; +} + +export const TriggerDropdownGroup: FC = ({ recognizerType, setTriggerType }) => { + const renderDropdownOption = useCallback( + (option?: IDropdownOption) => { + if (!option) return null; + const compatible = checkRecognizerCompatibility(option.key as SDKKinds, recognizerType); + return ( +
+ {option.text} + {!compatible && } +
+ ); + }, + [recognizerType] + ); + + const triggerUISchema = useTriggerConfig(); + const triggerOptionTree = useMemo(() => { + return generateTriggerOptionTree( + triggerUISchema, + formatMessage('What is the type of this trigger?'), + formatMessage('Select a trigger type') + ); + }, []); + + const [activeNode, setActiveNode] = useState(triggerOptionTree); + const onClickNode = (node: TriggerOptionTreeNode) => { + setActiveNode(node); + if (node instanceof TriggerOptionLeafNode) { + setTriggerType(node.$kind); + } else { + setTriggerType(''); + } + }; + + const getDropdownList = (activeNode: TriggerOptionTreeNode) => { + const treePath: TriggerOptionTreeNode[] = [activeNode]; + while (treePath[0].parent) { + treePath.unshift(treePath[0].parent); + } + + const dropdownList: ReactNode[] = []; + + const getKey = (x: TriggerOptionTreeNode) => (x instanceof TriggerOptionLeafNode ? x.$kind : x.label); + + // Render every group node as a dropdown until meet a leaf node. + for (let i = 0; i < treePath.length; i++) { + const currentNode: TriggerOptionTreeNode = treePath[i]; + if (!(currentNode instanceof TriggerOptionGroupNode)) break; + + const nextNode = treePath[i + 1]; + const selectedKey = nextNode ? getKey(nextNode) : ''; + const dropdown = ( + { + return { + key: getKey(x), + text: x.label, + node: x, + }; + })} + placeholder={currentNode.placeholder} + selectedKey={selectedKey} + styles={dropdownStyles} + onChange={(e, opt: any) => { + onClickNode(opt.node); + }} + onRenderOption={renderDropdownOption} + /> + ); + dropdownList.push(dropdown); + } + + return dropdownList; + }; + + const dropdownList = getDropdownList(activeNode); + return {dropdownList}; +}; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/TriggerOptionTree.ts b/Composer/packages/client/src/components/TriggerCreationModal/TriggerOptionTree.ts new file mode 100644 index 0000000000..f342206a3b --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/TriggerOptionTree.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TriggerUISchema, TriggerUIOption } from '@bfc/extension-client'; + +export class TriggerOptionLeafNode { + label: string; + order: number; + $kind: string; + parent: TriggerOptionGroupNode | null = null; + + constructor(label: string, $kind: string, order?: number) { + this.label = label; + this.$kind = $kind; + this.order = order ?? Number.MAX_SAFE_INTEGER; + } +} + +export class TriggerOptionGroupNode { + label: string; + order: number; + /** Title of a dropdown. 'Which activity type?' */ + prompt?: string; + /** Placeholder of a dropdown input. 'Select an activity type' */ + placeholder?: string; + children: (TriggerOptionLeafNode | TriggerOptionGroupNode)[] = []; + parent: TriggerOptionGroupNode | null = null; + + constructor(label: string, prompt?: string, placeholder?: string) { + this.label = label; + this.prompt = prompt; + this.placeholder = placeholder; + this.order = Number.MAX_SAFE_INTEGER; + } +} + +export type TriggerOptionTree = TriggerOptionGroupNode; + +export type TriggerOptionTreeNode = TriggerOptionGroupNode | TriggerOptionLeafNode; + +const getGroupKey = (submenu) => (typeof submenu === 'object' ? submenu.label : submenu || ''); + +export const generateTriggerOptionTree = ( + triggerUIOptions: TriggerUISchema, + rootPrompt: string, + rootPlaceHolder: string +): TriggerOptionTree => { + const root = new TriggerOptionGroupNode('triggerTypeDropDown', rootPrompt, rootPlaceHolder); + + const allOptionEntries = Object.entries(triggerUIOptions).filter(([, option]) => Boolean(option)) as [ + string, + TriggerUIOption + ][]; + const leafEntries = allOptionEntries.filter(([, options]) => !options.submenu); + const nonLeafEntries = allOptionEntries.filter(([, options]) => options.submenu); + + // Build leaf nodes whose depth = 1. + const leafNodeList = leafEntries.map( + ([$kind, options]) => new TriggerOptionLeafNode(options?.label ?? '', $kind, options?.order) + ); + + // Insert depth 1 leaf nodes to tree. + root.children.push(...leafNodeList); + leafNodeList.forEach((leaf) => (leaf.parent = root)); + + // Build group nodes. + const groups = nonLeafEntries + .map(([, options]) => options.submenu) + .reduce((result, submenu) => { + const name = getGroupKey(submenu); + if (!result[name]) result[name] = new TriggerOptionGroupNode(name, '', ''); + if (typeof submenu === 'object') { + const tree: TriggerOptionGroupNode = result[name]; + tree.prompt = submenu.prompt; + tree.placeholder = submenu.placeholder; + tree.parent = root; + } + return result; + }, {} as { [key: string]: TriggerOptionGroupNode }); + + // Insert depth 1 group nodes to tree. + root.children.push(...Object.values(groups)); + + // Build other leaf nodes whose depth = 2 and mount to related group node + nonLeafEntries.forEach(([$kind, options]) => { + const { label, submenu, order } = options; + const node = new TriggerOptionLeafNode(label, $kind, order); + + const groupName = getGroupKey(submenu); + const groupParent = groups[groupName]; + + groupParent.children.push(node); + node.parent = groupParent; + // Apply minimum child node order to group node for sorting. + groupParent.order = Math.min(groupParent.order, order ?? Number.MAX_SAFE_INTEGER); + }); + + // Sort by node's 'order'. + root.children.sort((a, b) => a.order - b.order); + Object.values(groups).forEach((x) => x.children.sort((a, b) => a.order - b.order)); + + return root; +}; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/checkRecognizerCompatibility.ts b/Composer/packages/client/src/components/TriggerCreationModal/checkRecognizerCompatibility.ts new file mode 100644 index 0000000000..0ebccac3e4 --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/checkRecognizerCompatibility.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SDKKinds } from '@bfc/shared'; + +// TODO (zeye): define triggers compatibility in sdk schema +/** + * Returns 'false' if recognizer and trigger is not compatible. + */ +export const checkRecognizerCompatibility = (triggerType: SDKKinds, recognizerType?: SDKKinds): boolean => { + if (recognizerType === SDKKinds.RegexRecognizer) { + if (triggerType === SDKKinds.OnQnAMatch) return false; + if (triggerType === SDKKinds.OnChooseIntent) return false; + } + return true; +}; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/index.tsx b/Composer/packages/client/src/components/TriggerCreationModal/index.tsx new file mode 100644 index 0000000000..54b2120951 --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/index.tsx @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TriggerCreationModal } from './TriggerCreationModal'; + +// default export required by React.lazy() +export default TriggerCreationModal; + +export { TriggerCreationModal } from './TriggerCreationModal'; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/resolveTriggerWidget.tsx b/Composer/packages/client/src/components/TriggerCreationModal/resolveTriggerWidget.tsx new file mode 100644 index 0000000000..025908beca --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/resolveTriggerWidget.tsx @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import formatMessage from 'format-message'; +import { Label } from 'office-ui-fabric-react/lib/Label'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { PlaceHolderSectionName } from '@bfc/indexers/lib/utils/luUtil'; +import { UserSettings, DialogInfo, SDKKinds } from '@bfc/shared'; +import { LuEditor, inlineModePlaceholder } from '@bfc/code-editor'; + +import { TriggerFormData, TriggerFormDataErrors } from '../../utils/dialogUtil'; +import { isRegExRecognizerType, isLUISnQnARecognizerType, isPVARecognizerType } from '../../utils/dialogValidator'; + +import { intentStyles } from './styles'; +import { validateEventName, validateIntentName, getLuDiagnostics, validateRegExPattern } from './validators'; + +export function resolveTriggerWidget( + selectedType: string, + dialogFile: DialogInfo | undefined, + formData: TriggerFormData, + setFormData: (data: TriggerFormData) => void, + userSettings: UserSettings, + projectId: string, + dialogId: string +) { + const isRegEx = isRegExRecognizerType(dialogFile); + const isLUISnQnA = isLUISnQnARecognizerType(dialogFile) || isPVARecognizerType(dialogFile); + const showTriggerPhrase = selectedType === SDKKinds.OnIntent && !isRegEx; + + const onNameChange = (e: React.FormEvent, name: string | undefined) => { + const errors: TriggerFormDataErrors = {}; + if (name == null) return; + errors.intent = validateIntentName(selectedType, name); + if (showTriggerPhrase && formData.triggerPhrases) { + errors.triggerPhrases = getLuDiagnostics(name, formData.triggerPhrases); + } + setFormData({ ...formData, intent: name, errors: { ...formData.errors, ...errors } }); + }; + + const onChangeRegEx = (e: React.FormEvent, pattern: string | undefined) => { + const errors: TriggerFormDataErrors = {}; + if (pattern == null) return; + errors.regEx = validateRegExPattern(selectedType, isRegEx, pattern); + setFormData({ ...formData, regEx: pattern, errors: { ...formData.errors, ...errors } }); + }; + + //Trigger phrase is optional + const onTriggerPhrasesChange = (body: string) => { + const errors: TriggerFormDataErrors = {}; + if (body) { + errors.triggerPhrases = getLuDiagnostics(formData.intent, body); + } else { + errors.triggerPhrases = ''; + } + setFormData({ ...formData, triggerPhrases: body, errors: { ...formData.errors, ...errors } }); + }; + + const handleEventNameChange = (event: React.FormEvent, value?: string) => { + const errors: TriggerFormDataErrors = {}; + errors.event = validateEventName(selectedType, SDKKinds.OnDialogEvent, value || ''); + setFormData({ + ...formData, + $kind: SDKKinds.OnDialogEvent, + event: value || '', + errors: { ...formData.errors, ...errors }, + }); + }; + + const onIntentWidgetRegex = ( + + + + + ); + + const onIntentWidgetLUISQnA = ( + + + + + + ); + + const onIntentWidgetCustom = ( + + ); + + const onIntentWidget = isRegEx ? onIntentWidgetRegex : isLUISnQnA ? onIntentWidgetLUISQnA : onIntentWidgetCustom; + + const onEventWidget = ( + + ); + + let widget; + switch (selectedType) { + case SDKKinds.OnIntent: + widget = onIntentWidget; + break; + case SDKKinds.OnDialogEvent: + widget = onEventWidget; + break; + default: + break; + } + return widget; +} diff --git a/Composer/packages/client/src/components/TriggerCreationModal/styles.ts b/Composer/packages/client/src/components/TriggerCreationModal/styles.ts new file mode 100644 index 0000000000..d7e0bcba1c --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/styles.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { css } from '@emotion/core'; +import { FontWeights } from '@uifabric/styling'; +import { FontSizes } from '@uifabric/fluent-theme'; + +export const dialogContentStyles = { + title: { + fontWeight: FontWeights.bold, + fontSize: FontSizes.size20, + paddingTop: '14px', + paddingBottom: '11px', + }, + subText: { + fontSize: FontSizes.size14, + }, +}; +export const modalStyles = { + main: { + maxWidth: '600px !important', + }, +}; + +export const dropdownStyles = { + label: { + fontWeight: FontWeights.semibold, + }, + dropdown: { + width: '400px', + }, + root: { + marginBottom: '20px', + }, +}; + +export const dialogWindowStyles = css` + display: flex; + flex-direction: column; + width: 400px; + min-height: 300px; +`; + +export const intentStyles = { + root: { + width: '400px', + paddingBottom: '20px', + }, +}; + +export const optionStyles = { + display: 'flex', + height: '15px', + fontSize: '15px', +}; + +export const warningIconStyles = { + marginLeft: '5px', + color: '#BE880A', + fontSize: '12px', +}; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/validators.ts b/Composer/packages/client/src/components/TriggerCreationModal/validators.ts new file mode 100644 index 0000000000..f0d6bbb84f --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/validators.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import formatMessage from 'format-message'; +import { luIndexer, combineMessage } from '@bfc/indexers'; +import { SDKKinds } from '@bfc/shared'; + +import { TriggerFormData, TriggerFormDataErrors } from '../../utils/dialogUtil'; +import { nameRegex } from '../../constants'; + +export const getLuDiagnostics = (intent: string, triggerPhrases: string) => { + const content = `#${intent}\n${triggerPhrases}`; + const { diagnostics } = luIndexer.parse(content, '', {}); + return combineMessage(diagnostics); +}; +export const validateIntentName = (selectedType: string, intent: string): string | undefined => { + if (selectedType === SDKKinds.OnIntent && (!intent || !nameRegex.test(intent))) { + return formatMessage('Spaces and special characters are not allowed. Use letters, numbers, -, or _.'); + } + return undefined; +}; +const validateDupRegExIntent = ( + selectedType: string, + intent: string, + isRegEx: boolean, + regExIntents: [{ intent: string; pattern: string }] +): string | undefined => { + if (selectedType === SDKKinds.OnIntent && isRegEx && regExIntents.find((ri) => ri.intent === intent)) { + return formatMessage(`RegEx {intent} is already defined`, { intent }); + } + return undefined; +}; +export const validateRegExPattern = (selectedType: string, isRegEx: boolean, regEx: string): string | undefined => { + if (selectedType === SDKKinds.OnIntent && isRegEx && !regEx) { + return formatMessage('Please input regEx pattern'); + } + return undefined; +}; +export const validateEventName = (selectedType: string, $kind: string, eventName: string): string | undefined => { + if (selectedType === SDKKinds.OnDialogEvent && !eventName) { + return formatMessage('Please enter an event name'); + } + return undefined; +}; +const validateTriggerKind = (selectedType: string): string | undefined => { + if (!selectedType) { + return formatMessage('Please select a trigger type'); + } + return undefined; +}; + +const validateTriggerPhrases = ( + selectedType: string, + isRegEx: boolean, + intent: string, + triggerPhrases: string +): string | undefined => { + if (selectedType === SDKKinds.OnIntent && !isRegEx && triggerPhrases) { + return getLuDiagnostics(intent, triggerPhrases); + } + return undefined; +}; +export const validateForm = ( + selectedType: string, + data: TriggerFormData, + isRegEx: boolean, + regExIntents: [{ intent: string; pattern: string }] +): TriggerFormDataErrors => { + const errors: TriggerFormDataErrors = {}; + const { $kind, event: eventName, intent, regEx, triggerPhrases } = data; + + errors.event = validateEventName(selectedType, $kind, eventName); + errors.$kind = validateTriggerKind(selectedType); + errors.intent = validateIntentName(selectedType, intent); + errors.regEx = + validateDupRegExIntent(selectedType, intent, isRegEx, regExIntents) ?? + validateRegExPattern(selectedType, isRegEx, regEx); + errors.triggerPhrases = validateTriggerPhrases(selectedType, isRegEx, intent, triggerPhrases); + return errors; +}; diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index f8d59c7239..e75627fbc4 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -70,7 +70,7 @@ const RepairSkillModal = React.lazy(() => import('../../components/RepairSkillMo const CreateDialogModal = React.lazy(() => import('./createDialogModal')); const DisplayManifestModal = React.lazy(() => import('../../components/Modal/DisplayManifestModal')); const ExportSkillModal = React.lazy(() => import('./exportSkillModal')); -const TriggerCreationModal = React.lazy(() => import('../../components/ProjectTree/TriggerCreationModal')); +const TriggerCreationModal = React.lazy(() => import('../../components/TriggerCreationModal')); function onRenderContent(subTitle, style) { return ( @@ -718,16 +718,18 @@ const DesignPage: React.FC )} {triggerModalInfo && ( - { - await createTrigger(triggerModalInfo.projectId, dialogId, formData); - commitChanges(); - }} - /> + + { + await createTrigger(triggerModalInfo.projectId, dialogId, formData); + commitChanges(); + }} + /> + )} { - let name = t as string; - const labelOverrides = conceptLabels[t]; - - if (labelOverrides?.title) { - name = labelOverrides.title; - } - - return { key: t, text: name || t }; - }), - { - key: customEventKey, - text: formatMessage('Custom events'), - }, - ]; - return triggerTypes; -} - -export function getEventTypes(): IComboBoxOption[] { - const conceptLabels = conceptLabelsFn(); - const eventTypes: IComboBoxOption[] = [ - ...dialogGroups[DialogGroup.DIALOG_EVENT_TYPES].types.map((t) => { - let name = t as string; - const labelOverrides = conceptLabels[t]; - - if (labelOverrides?.title) { - if (labelOverrides.subtitle) { - name = `${labelOverrides.title} (${labelOverrides.subtitle})`; - } else { - name = labelOverrides.title; - } - } - - return { key: t, text: name || t }; - }), - ]; - return eventTypes; -} - -export function getActivityTypes(): IDropdownOption[] { - const conceptLabels = conceptLabelsFn(); - const activityTypes: IDropdownOption[] = [ - ...dialogGroups[DialogGroup.ADVANCED_EVENTS].types.map((t) => { - let name = t as string; - const labelOverrides = conceptLabels[t]; - - if (labelOverrides?.title) { - if (labelOverrides.subtitle) { - name = `${labelOverrides.title} (${labelOverrides.subtitle})`; - } else { - name = labelOverrides.title; - } - } - - return { key: t, text: name || t }; - }), - ]; - return activityTypes; -} - function getDialogsMap(dialogs: DialogInfo[]): DialogsMap { return dialogs.reduce((result: { [key: string]: {} }, dialog: DialogInfo) => { result[dialog.id] = dialog.content; diff --git a/Composer/packages/client/src/utils/dialogValidator.ts b/Composer/packages/client/src/utils/dialogValidator.ts index fe7e850374..af7b2660da 100644 --- a/Composer/packages/client/src/utils/dialogValidator.ts +++ b/Composer/packages/client/src/utils/dialogValidator.ts @@ -1,14 +1,32 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import get from 'lodash/get'; -import { DialogInfo, ITrigger } from '@bfc/shared'; +import { DialogInfo, ITrigger, SDKKinds } from '@bfc/shared'; -import { regexRecognizerKey, onChooseIntentKey, qnaMatcherKey } from '../utils/dialogUtil'; import { triggerNotSupportedWarning } from '../constants'; +export const resolveRecognizer$kind = (dialog: DialogInfo | undefined): SDKKinds | undefined => { + if (!dialog) return undefined; + + const recognizer = get(dialog, 'content.recognizer'); + const $kind = get(recognizer, '$kind', undefined); + + if ($kind) return $kind; + + if (typeof recognizer === 'string') { + return recognizer.endsWith('.lu.qna') ? SDKKinds.LuisRecognizer : undefined; + } + return; +}; + export const isRegExRecognizerType = (dialog: DialogInfo | undefined) => { if (!dialog) return false; - return get(dialog, 'content.recognizer.$kind', '') === regexRecognizerKey; + return get(dialog, 'content.recognizer.$kind', '') === SDKKinds.RegexRecognizer; +}; + +export const isPVARecognizerType = (dialog: DialogInfo | undefined) => { + if (!dialog) return false; + return get(dialog, 'content.recognizer.$kind', '') === 'Microsoft.VirtualAgents.Recognizer'; }; export const isLUISnQnARecognizerType = (dialog: DialogInfo | undefined) => { @@ -22,7 +40,7 @@ export const containUnsupportedTriggers = (dialog: DialogInfo | undefined) => { if ( isRegExRecognizerType(dialog) && - dialog.triggers.some((t) => t.type === qnaMatcherKey || t.type === onChooseIntentKey) + dialog.triggers.some((t) => t.type === SDKKinds.OnQnAMatch || t.type === SDKKinds.OnChooseIntent) ) { return triggerNotSupportedWarning; } @@ -31,7 +49,10 @@ export const containUnsupportedTriggers = (dialog: DialogInfo | undefined) => { export const triggerNotSupported = (dialog: DialogInfo | undefined, trigger: ITrigger | undefined) => { if (!dialog || !trigger) return ''; - if (isRegExRecognizerType(dialog) && (trigger.type === qnaMatcherKey || trigger.type === onChooseIntentKey)) { + if ( + isRegExRecognizerType(dialog) && + (trigger.type === SDKKinds.OnQnAMatch || trigger.type === SDKKinds.OnChooseIntent) + ) { return triggerNotSupportedWarning; } return ''; diff --git a/Composer/packages/extension-client/src/hooks/index.ts b/Composer/packages/extension-client/src/hooks/index.ts index 798538cdd8..8ddc3748b8 100644 --- a/Composer/packages/extension-client/src/hooks/index.ts +++ b/Composer/packages/extension-client/src/hooks/index.ts @@ -4,6 +4,7 @@ export * from './useDialogApi'; export * from './useFlowConfig'; export * from './useFormConfig'; export * from './useMenuConfig'; +export * from './useTriggerConfig'; export * from './useRecognizerConfig'; export * from './useShellApi'; diff --git a/Composer/packages/extension-client/src/hooks/useTriggerConfig.ts b/Composer/packages/extension-client/src/hooks/useTriggerConfig.ts new file mode 100644 index 0000000000..9c6e203a43 --- /dev/null +++ b/Composer/packages/extension-client/src/hooks/useTriggerConfig.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useContext, useMemo } from 'react'; + +import { EditorExtensionContext } from '../EditorExtensionContext'; +import { TriggerUISchema } from '../types'; + +export function useTriggerConfig() { + const { plugins } = useContext(EditorExtensionContext); + + const triggerConfig: TriggerUISchema = useMemo(() => { + const implementedTriggerSchema: TriggerUISchema = {}; + Object.entries(plugins.uiSchema ?? {}).forEach(([$kind, options]) => { + if (options?.trigger) { + implementedTriggerSchema[$kind] = options.trigger; + } + }); + return implementedTriggerSchema; + }, [plugins.uiSchema]); + + return triggerConfig; +} diff --git a/Composer/packages/extension-client/src/types/extension.ts b/Composer/packages/extension-client/src/types/extension.ts index f7d7705cf5..99172af739 100644 --- a/Composer/packages/extension-client/src/types/extension.ts +++ b/Composer/packages/extension-client/src/types/extension.ts @@ -6,6 +6,7 @@ import { SDKKinds } from '@botframework-composer/types'; import { UIOptions } from './formSchema'; import { FlowEditorWidgetMap, FlowWidget } from './flowSchema'; import { MenuOptions } from './menuSchema'; +import { TriggerUIOption } from './triggerSchema'; import { RecognizerOptions } from './recognizerSchema'; import { FieldWidget } from './form'; @@ -22,6 +23,7 @@ export type UISchema = { flow?: FlowWidget; form?: UIOptions; menu?: MenuOptions | MenuOptions[]; + trigger?: TriggerUIOption; recognizer?: RecognizerOptions; }; }; diff --git a/Composer/packages/extension-client/src/types/index.ts b/Composer/packages/extension-client/src/types/index.ts index bc071a2148..e84e4bc608 100644 --- a/Composer/packages/extension-client/src/types/index.ts +++ b/Composer/packages/extension-client/src/types/index.ts @@ -6,5 +6,6 @@ export * from './form'; export * from './formSchema'; export * from './flowSchema'; export * from './menuSchema'; +export * from './triggerSchema'; export * from './recognizerSchema'; export * from './pluginType'; diff --git a/Composer/packages/extension-client/src/types/triggerSchema.ts b/Composer/packages/extension-client/src/types/triggerSchema.ts new file mode 100644 index 0000000000..4b4a77f350 --- /dev/null +++ b/Composer/packages/extension-client/src/types/triggerSchema.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SDKKinds } from '@botframework-composer/types'; + +export interface TriggerUIOption { + label: string; + order?: number; + submenu?: TriggerSubmenuInfo | string | false; +} + +export interface TriggerSubmenuInfo { + label: string; + prompt?: string; + placeholder?: string; +} + +export type TriggerUISchema = { [key in SDKKinds]?: TriggerUIOption }; diff --git a/Composer/packages/ui-plugins/composer/src/defaultTriggerSchema.ts b/Composer/packages/ui-plugins/composer/src/defaultTriggerSchema.ts new file mode 100644 index 0000000000..487c3cdc1f --- /dev/null +++ b/Composer/packages/ui-plugins/composer/src/defaultTriggerSchema.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TriggerUISchema } from '@bfc/extension-client'; +import { SDKKinds } from '@bfc/shared'; +import formatMessage from 'format-message'; + +export const DefaultTriggerSchema: TriggerUISchema = { + [SDKKinds.OnIntent]: { + label: formatMessage('Intent recognized'), + order: 1, + }, + [SDKKinds.OnQnAMatch]: { + label: formatMessage('QnA Intent recognized'), + order: 2, + }, + [SDKKinds.OnUnknownIntent]: { + label: formatMessage('Unknown intent'), + order: 3, + }, + [SDKKinds.OnChooseIntent]: { + label: formatMessage('Duplicated intents recognized'), + order: 6, + }, + [SDKKinds.OnDialogEvent]: { + label: formatMessage('Custom events'), + order: 7, + }, + // Subgroup - Dialog events + [SDKKinds.OnBeginDialog]: { + label: formatMessage('Dialog started (Begin dialog event)'), + order: 4.1, + submenu: { + label: formatMessage('Dialog events'), + prompt: formatMessage('Which event?'), + placeholder: formatMessage('Select an event type'), + }, + }, + [SDKKinds.OnCancelDialog]: { + label: formatMessage('Dialog cancelled (Cancel dialog event)'), + order: 4.2, + submenu: formatMessage('Dialog events'), + }, + [SDKKinds.OnError]: { + label: formatMessage('Error occurred (Error event)'), + order: 4.3, + submenu: formatMessage('Dialog events'), + }, + [SDKKinds.OnRepromptDialog]: { + label: formatMessage('Re-prompt for input (Reprompt dialog event)'), + order: 4.4, + submenu: formatMessage('Dialog events'), + }, + // Subgroup - Activities + [SDKKinds.OnActivity]: { + label: formatMessage('Activities (Activity received)'), + order: 5.1, + submenu: { + label: formatMessage('Activities'), + prompt: formatMessage('Which activity type?'), + placeholder: formatMessage('Select an activity type'), + }, + }, + [SDKKinds.OnConversationUpdateActivity]: { + label: formatMessage('Greeting (ConversationUpdate activity)'), + order: 5.2, + submenu: formatMessage('Activities'), + }, + [SDKKinds.OnEndOfConversationActivity]: { + label: formatMessage('Conversation ended (EndOfConversation activity)'), + order: 5.3, + submenu: formatMessage('Activities'), + }, + [SDKKinds.OnEventActivity]: { + label: formatMessage('Event received (Event activity)'), + order: 5.4, + submenu: formatMessage('Activities'), + }, + [SDKKinds.OnHandoffActivity]: { + label: formatMessage('Handover to human (Handoff activity)'), + order: 5.5, + submenu: formatMessage('Activities'), + }, + [SDKKinds.OnInvokeActivity]: { + label: formatMessage('Conversation invoked (Invoke activity)'), + order: 5.6, + submenu: formatMessage('Activities'), + }, + [SDKKinds.OnTypingActivity]: { + label: formatMessage('User is typing (Typing activity)'), + order: 5.7, + submenu: formatMessage('Activities'), + }, + [SDKKinds.OnMessageActivity]: { + label: formatMessage('Message received (Message received activity)'), + order: 5.81, + submenu: formatMessage('Activities'), + }, + [SDKKinds.OnMessageDeleteActivity]: { + label: formatMessage('Message deleted (Message deleted activity)'), + order: 5.82, + submenu: formatMessage('Activities'), + }, + [SDKKinds.OnMessageReactionActivity]: { + label: formatMessage('Message reaction (Message reaction activity)'), + order: 5.83, + submenu: formatMessage('Activities'), + }, + [SDKKinds.OnMessageUpdateActivity]: { + label: formatMessage('Message updated (Message updated activity)'), + order: 5.84, + submenu: formatMessage('Activities'), + }, +}; diff --git a/Composer/packages/ui-plugins/composer/src/index.ts b/Composer/packages/ui-plugins/composer/src/index.ts index 43d5faff27..2bf304f9bb 100644 --- a/Composer/packages/ui-plugins/composer/src/index.ts +++ b/Composer/packages/ui-plugins/composer/src/index.ts @@ -9,6 +9,7 @@ import { MenuUISchema, FlowUISchema, RecognizerUISchema, + TriggerUISchema, } from '@bfc/extension-client'; import { SDKKinds } from '@bfc/shared'; import formatMessage from 'format-message'; @@ -17,6 +18,7 @@ import { IntentField, RecognizerField, QnAActionsField } from '@bfc/adaptive-for import { DefaultMenuSchema } from './defaultMenuSchema'; import { DefaultFlowSchema } from './defaultFlowSchema'; import { DefaultRecognizerSchema } from './defaultRecognizerSchema'; +import { DefaultTriggerSchema } from './defaultTriggerSchema'; const DefaultFormSchema: FormUISchema = { [SDKKinds.AdaptiveDialog]: { @@ -173,18 +175,26 @@ const synthesizeUISchema = ( formSchema: FormUISchema, menuSchema: MenuUISchema, flowSchema: FlowUISchema, + triggerSchema: TriggerUISchema, recognizerSchema: RecognizerUISchema ): UISchema => { let uischema: UISchema = {}; uischema = mergeWith(uischema, formSchema, (origin, formOption) => ({ ...origin, form: formOption })); uischema = mergeWith(uischema, menuSchema, (origin, menuOption) => ({ ...origin, menu: menuOption })); uischema = mergeWith(uischema, flowSchema, (origin, flowOption) => ({ ...origin, flow: flowOption })); + uischema = mergeWith(uischema, triggerSchema, (origin, triggerOption) => ({ ...origin, trigger: triggerOption })); uischema = mergeWith(uischema, recognizerSchema, (origin, opt) => ({ ...origin, recognizer: opt })); return uischema; }; const config: PluginConfig = { - uiSchema: synthesizeUISchema(DefaultFormSchema, DefaultMenuSchema, DefaultFlowSchema, DefaultRecognizerSchema), + uiSchema: synthesizeUISchema( + DefaultFormSchema, + DefaultMenuSchema, + DefaultFlowSchema, + DefaultTriggerSchema, + DefaultRecognizerSchema + ), }; export default config;