diff --git a/Composer/packages/client/__tests__/components/design.test.tsx b/Composer/packages/client/__tests__/components/design.test.tsx index b702e4b4b6..26f3cbf4dd 100644 --- a/Composer/packages/client/__tests__/components/design.test.tsx +++ b/Composer/packages/client/__tests__/components/design.test.tsx @@ -8,7 +8,7 @@ import { DialogInfo } from '@bfc/shared'; import { renderWithRecoil } from '../testUtils'; import { dialogs } from '../constants.json'; 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'; jest.mock('@bfc/code-editor', () => { diff --git a/Composer/packages/client/__tests__/utils/dialogUtil.test.js b/Composer/packages/client/__tests__/utils/dialogUtil.test.js index b20abdb5ad..b55d27e31b 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,50 +178,6 @@ describe('deleteTrigger', () => { }); }); -describe('getTriggerTypes', () => { - it('return trigger types', () => { - const triggerTypes = getTriggerTypes(); - expect(triggerTypes).toEqual([ - { key: 'Microsoft.OnIntent', text: 'Intent recognized' }, - { key: 'Microsoft.OnUnknownIntent', text: 'Unknown intent' }, - { key: 'Microsoft.OnDialogEvent', text: 'Dialog events' }, - { key: 'Microsoft.OnActivity', text: 'Activities' }, - { 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/src/components/ProjectTree/TriggerCreationModal.tsx b/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx deleted file mode 100644 index 6c98e110aa..0000000000 --- a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx +++ /dev/null @@ -1,440 +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 { 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 { DialogInfo, SDKKinds, LuIntentSection } 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 { - generateNewDialog, - getTriggerTypes, - TriggerFormData, - TriggerFormDataErrors, - eventTypeKey, - customEventKey, - intentTypeKey, - activityTypeKey, - getEventTypes, - getActivityTypes, - regexRecognizerKey, -} from '../../utils/dialogUtil'; -import { projectIdState, schemasState } from '../../recoilModel/atoms/botState'; -import { userSettingsState } from '../../recoilModel'; -import { nameRegex } from '../../constants'; -import { validatedDialogsSelector } from '../../recoilModel/selectors/validatedDialogs'; - -// -------------------- 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', - }, -}; - -// -------------------- 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) { - if (triggerPhrases) { - return getLuDiagnostics(intent, triggerPhrases); - } else { - return formatMessage('Please input trigger phrases'); - } - } - 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; -}; - -export interface LuFilePayload { - id: string; - content: string; -} - -// -------------------- TriggerCreationModal -------------------- // - -interface TriggerCreationModalProps { - dialogId: string; - isOpen: boolean; - onDismiss: () => void; - onSubmit: (dialog: DialogInfo, intent?: LuIntentSection) => void; -} - -export const TriggerCreationModal: React.FC = (props) => { - const { isOpen, onDismiss, onSubmit, dialogId } = props; - const dialogs = useRecoilValue(validatedDialogsSelector); - - const projectId = useRecoilValue(projectIdState); - const schemas = useRecoilValue(schemasState); - const userSettings = useRecoilValue(userSettingsState); - - const dialogFile = dialogs.find((dialog) => dialog.id === dialogId); - const isRegEx = (dialogFile?.content?.recognizer?.$kind ?? '') === regexRecognizerKey; - const regexIntents = dialogFile?.content?.recognizer?.intents ?? []; - const isNone = !dialogFile?.content?.recognizer; - const initialFormData: TriggerFormData = { - errors: initialFormDataErrors, - $kind: isNone ? '' : intentTypeKey, - event: '', - intent: '', - triggerPhrases: '', - regEx: '', - }; - const [formData, setFormData] = useState(initialFormData); - const [selectedType, setSelectedType] = useState(isNone ? '' : 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 (isNone) { - triggerTypeOptions = triggerTypeOptions.filter((t) => t.key !== intentTypeKey); - } - - 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); - if (shouldDisable(errors)) { - setFormData({ - ...formData, - errors, - }); - return; - } - const newDialog = generateNewDialog(dialogs, dialogId, formData, schemas.sdk?.content); - if (formData.$kind === intentTypeKey && !isRegEx) { - const newIntent = { Name: formData.intent, Body: formData.triggerPhrases }; - onSubmit(newDialog, newIntent); - } else { - onSubmit(newDialog); - } - onDismiss(); - }; - - const onSelectTriggerType = (e, 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, name) => { - const errors: TriggerFormDataErrors = {}; - errors.intent = validateIntentName(selectedType, name); - if (showTriggerPhrase) { - errors.triggerPhrases = getLuDiagnostics(name, formData.triggerPhrases); - } - setFormData({ ...formData, intent: name, errors: { ...formData.errors, ...errors } }); - }; - - const onChangeRegEx = (e, pattern) => { - const errors: TriggerFormDataErrors = {}; - errors.regEx = validateRegExPattern(selectedType, isRegEx, pattern); - setFormData({ ...formData, regEx: pattern, errors: { ...formData.errors, ...errors } }); - }; - - const onTriggerPhrasesChange = (body: string) => { - const errors: TriggerFormDataErrors = {}; - errors.triggerPhrases = getLuDiagnostics(formData.intent, body); - setFormData({ ...formData, triggerPhrases: body, errors: { ...formData.errors, ...errors } }); - }; - const errors = validateForm(selectedType, formData, isRegEx, regexIntents); - 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..1429eeedcd --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/TriggerCreationModal.tsx @@ -0,0 +1,288 @@ +// 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 { 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 { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { PlaceHolderSectionName } from '@bfc/indexers/lib/utils/luUtil'; +import { DialogInfo, SDKKinds, LuIntentSection } from '@bfc/shared'; +import { LuEditor, inlineModePlaceholder } from '@bfc/code-editor'; +import { useRecoilValue } from 'recoil'; + +import { generateNewDialog } from '../../utils/dialogUtil'; +import { projectIdState, schemasState } from '../../recoilModel/atoms/botState'; +import { userSettingsState } from '../../recoilModel'; +import { validatedDialogsSelector } from '../../recoilModel/selectors/validatedDialogs'; + +import { TriggerFormData } from './types/TriggerFormData'; +import { TriggerFormDataErrors } from './types/TriggerFormDataErrors'; +import { customEventKey, EventTypes, ActivityTypes } from './constants'; +import { getTriggerTypes } from './getTriggerTypes'; +import { modalStyles, dialogStyles, triggerFormStyles, dropdownStyles, textInputStyles } from './style'; +import { + getLuDiagnostics, + validateEventName, + validateEventKind, + validateForm, + validateIntentName, + validateRegExPattern, + validateActivityKind, + validateTriggerKind, +} from './validators'; + +const shouldDisable = (errors: TriggerFormDataErrors) => { + for (const key in errors) { + if (errors[key]) { + return true; + } + } + return false; +}; + +interface TriggerCreationModalProps { + dialogId: string; + isOpen: boolean; + onDismiss: () => void; + onSubmit: (dialog: DialogInfo, intent?: LuIntentSection) => void; +} + +export const TriggerCreationModal: React.FC = (props) => { + const { isOpen, onDismiss, onSubmit, dialogId } = props; + + const dialogs = useRecoilValue(validatedDialogsSelector); + const projectId = useRecoilValue(projectIdState); + const schemas = useRecoilValue(schemasState); + const userSettings = useRecoilValue(userSettingsState); + + const dialogFile = dialogs.find((dialog) => dialog.id === dialogId); + const recognizer = dialogFile?.content?.recognizer; + const regexIntents = recognizer?.intents ?? []; + const [selectedType, setSelectedType] = useState(recognizer ? SDKKinds.OnIntent : ''); + const initialFormData: TriggerFormData = { + errors: {}, + $kind: recognizer ? SDKKinds.OnIntent : '', + event: '', + intent: '', + triggerPhrases: '', + regEx: '', + }; + const [formData, setFormData] = useState(initialFormData); + + const showEventDropDown = selectedType === SDKKinds.OnDialogEvent; + const showActivityDropDown = selectedType === SDKKinds.OnActivity; + + const isCustomEvent = selectedType === customEventKey; + const isOnIntent = selectedType === SDKKinds.OnIntent; + const isRegexRecognizer = recognizer?.$kind === SDKKinds.RegexRecognizer; + const isLuisRecognizer = !isRegexRecognizer; + + const onClickSubmitButton = (e) => { + e.preventDefault(); + + //If still have some errors here, it is a bug. + const errors = validateForm(selectedType, formData, isRegexRecognizer, regexIntents); + if (shouldDisable(errors)) { + setFormData({ + ...formData, + errors, + }); + return; + } + const newDialog = generateNewDialog(dialogs, dialogId, formData, schemas.sdk?.content); + if (formData.$kind === SDKKinds.OnIntent && !isRegexRecognizer) { + const newIntent = { Name: formData.intent, Body: formData.triggerPhrases }; + onSubmit(newDialog, newIntent); + } else { + onSubmit(newDialog); + } + onDismiss(); + }; + + const onSelectTriggerType = (e, option) => { + setSelectedType(option.key || ''); + const compoundTypes = [SDKKinds.OnActivity, SDKKinds.OnDialogEvent]; + 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 }; + } + newFormData.errors.triggerType = validateTriggerKind(option.key); + setFormData(newFormData); + }; + + const handleEventNameChange = (event: React.FormEvent, value?: string) => { + const errors: TriggerFormDataErrors = {}; + errors.customEventName = validateEventName(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(option.key as string); + setFormData({ ...formData, $kind: option.key as string, errors: { ...formData.errors, ...errors } }); + } + }; + + const handleActivityTypeChange = (e: React.FormEvent, option?: IDropdownOption) => { + if (option) { + const errors: TriggerFormDataErrors = {}; + errors.activity = validateActivityKind(option.key as string); + setFormData({ ...formData, $kind: option.key as string, errors: { ...formData.errors, ...errors } }); + } + }; + + const onIntentNameChange = (e, name) => { + const errors: TriggerFormDataErrors = {}; + errors.intent = validateIntentName(name); + if (isLuisRecognizer) { + errors.triggerPhrases = getLuDiagnostics(name, formData.triggerPhrases); + } + setFormData({ ...formData, intent: name, errors: { ...formData.errors, ...errors } }); + }; + + const onChangeRegEx = (e, pattern) => { + const errors: TriggerFormDataErrors = {}; + errors.regEx = validateRegExPattern(pattern); + setFormData({ ...formData, regEx: pattern, errors: { ...formData.errors, ...errors } }); + }; + + const onTriggerPhrasesChange = (body: string) => { + const errors: TriggerFormDataErrors = {}; + errors.triggerPhrases = getLuDiagnostics(formData.intent, body); + setFormData({ ...formData, triggerPhrases: body, errors: { ...formData.errors, ...errors } }); + }; + const errors = validateForm(selectedType, formData, isRegexRecognizer, regexIntents); + const preventSubmit = shouldDisable(errors); + + const customEventWidget = ( + + ); + + const onIntentWidget = ( + + + {isRegexRecognizer && ( + + )} + {isLuisRecognizer && ( + + + + + )} + + ); + + return ( + + ); +}; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/constants.ts b/Composer/packages/client/src/components/TriggerCreationModal/constants.ts new file mode 100644 index 0000000000..1ab645ab02 --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/constants.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import formatMessage from 'format-message'; +import { SDKKinds } from '@bfc/shared'; + +export const eventTypeKey = SDKKinds.OnDialogEvent; +export const intentTypeKey = SDKKinds.OnIntent; +export const activityTypeKey = SDKKinds.OnActivity; +export const customEventKey = 'CustomEvents'; + +export const TriggerTypes: IDropdownOption[] = [ + { key: 'Microsoft.OnIntent', text: formatMessage('Intent recognized') }, + { key: 'Microsoft.OnUnknownIntent', text: formatMessage('Unknown intent') }, + { key: 'Microsoft.OnDialogEvent', text: formatMessage('Dialog events') }, + { key: 'Microsoft.OnActivity', text: formatMessage('Activities') }, + { key: customEventKey, text: formatMessage('Custom events') }, +]; + +export const ActivityTypes: IDropdownOption[] = [ + { 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)' }, +]; + +export const EventTypes: IDropdownOption[] = [ + { 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)' }, +]; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/getTriggerTypes.ts b/Composer/packages/client/src/components/TriggerCreationModal/getTriggerTypes.ts new file mode 100644 index 0000000000..5e43fee261 --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/getTriggerTypes.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import { MicrosoftIRecognizer, SDKKinds } from '@bfc/shared'; + +import { TriggerTypes } from './constants'; + +export function getTriggerTypes(recognizer?: MicrosoftIRecognizer): IDropdownOption[] { + if (!recognizer) { + return TriggerTypes.filter((t) => t.key !== SDKKinds.OnIntent); + } + return TriggerTypes; +} 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..8a0a2fac13 --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/index.tsx @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TriggerCreationModal } from './TriggerCreationModal'; +export default TriggerCreationModal; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/style.ts b/Composer/packages/client/src/components/TriggerCreationModal/style.ts new file mode 100644 index 0000000000..1f8e3ff3eb --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/style.ts @@ -0,0 +1,49 @@ +// 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 dialogStyles = { + title: { + fontWeight: FontWeights.bold, + fontSize: FontSizes.size20, + paddingTop: '14px', + paddingBottom: '11px', + }, + subText: { + fontSize: FontSizes.size14, + }, +}; +export const modalStyles = { + main: { + maxWidth: '600px !important', + }, +}; + +export const triggerFormStyles = css` + display: flex; + flex-direction: column; + width: 400px; + min-height: 300px; +`; + +export const dropdownStyles = { + label: { + fontWeight: FontWeights.semibold, + }, + dropdown: { + width: '400px', + }, + root: { + marginBottom: '20px', + }, +}; + +export const textInputStyles = { + root: { + width: '400px', + paddingBottom: '20px', + }, +}; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/types/TriggerFormData.ts b/Composer/packages/client/src/components/TriggerCreationModal/types/TriggerFormData.ts new file mode 100644 index 0000000000..893ff3d2f2 --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/types/TriggerFormData.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TriggerFormDataErrors } from './TriggerFormDataErrors'; + +export interface TriggerFormData { + errors: TriggerFormDataErrors; + $kind: string; + event: string; + intent: string; + triggerPhrases: string; + regEx: string; +} diff --git a/Composer/packages/client/src/components/TriggerCreationModal/types/TriggerFormDataErrors.ts b/Composer/packages/client/src/components/TriggerCreationModal/types/TriggerFormDataErrors.ts new file mode 100644 index 0000000000..3ba86de19b --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/types/TriggerFormDataErrors.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export interface TriggerFormDataErrors { + /** Error msg of L1 dropdown 'type of Trigger'. */ + triggerType?: string; + + /** Error msg of L2 dropdown 'Dialog events'. */ + event?: string; + + /** Error msg of L2 dropdown 'Activities'. */ + activity?: string; + + /** Error msg of [trigger=OnDialogEvent] custom event name value. */ + customEventName?: string; + + /** Error msg of [tirgger=OnIntent, recognizer=RegEx] regex value. */ + regEx?: string; + + /** Error msg of [tirgger=OnIntent, recognizer=LUIS] intent name. */ + intent?: string; + + /** Error msg of [tirgger=OnIntent, recognizer=LUIS] lu phrases. */ + triggerPhrases?: string; +} 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..f42f90e42a --- /dev/null +++ b/Composer/packages/client/src/components/TriggerCreationModal/validators.ts @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import formatMessage from 'format-message'; +import { luIndexer, combineMessage } from '@bfc/indexers'; + +import { nameRegex } from '../../constants'; + +import { TriggerFormData } from './types/TriggerFormData'; +import { TriggerFormDataErrors } from './types/TriggerFormDataErrors'; +import { eventTypeKey, customEventKey, intentTypeKey, activityTypeKey } from './constants'; + +export const getLuDiagnostics = (intent: string, triggerPhrases: string) => { + const content = `#${intent}\n${triggerPhrases}`; + const { diagnostics } = luIndexer.parse(content); + return combineMessage(diagnostics); +}; + +export const validateTriggerKind = (selectedType: string): string | undefined => { + if (!selectedType) { + return formatMessage('Please select a trigger type'); + } + return undefined; +}; + +export const validateEventKind = ($kind: string): string | undefined => { + if (!$kind) { + return formatMessage('Please select a event type'); + } + return undefined; +}; + +export const validateActivityKind = ($kind: string): string | undefined => { + if (!$kind) { + return formatMessage('Please select an activity type'); + } + return undefined; +}; + +export const validateEventName = (eventName: string): string | undefined => { + if (!eventName) { + return formatMessage('Please enter an event name'); + } + return undefined; +}; + +export const validateIntentName = (intent: string): string | undefined => { + if (!intent || !nameRegex.test(intent)) { + return formatMessage('Spaces and special characters are not allowed. Use letters, numbers, -, or _.'); + } + return undefined; +}; + +export const validateRegExPattern = (regEx: string): string | undefined => { + if (!regEx) { + return formatMessage('Please input regEx pattern'); + } + return undefined; +}; + +export const validateDupRegExIntent = ( + intent: string, + regExIntents: [{ intent: string; pattern: string }] +): string | undefined => { + if (regExIntents.find((ri) => ri.intent === intent)) { + return formatMessage(`RegEx {intent} is already defined`, { intent }); + } + return undefined; +}; + +export const validateTriggerPhrases = (intent: string, triggerPhrases: string): string | undefined => { + if (triggerPhrases) { + return getLuDiagnostics(intent, triggerPhrases); + } else { + return formatMessage('Please input trigger phrases'); + } +}; + +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.triggerType = validateTriggerKind(selectedType); + + switch (selectedType) { + case eventTypeKey: + errors.event = validateEventKind($kind); + break; + case activityTypeKey: + errors.activity = validateActivityKind($kind); + break; + case customEventKey: + errors.customEventName = validateEventName(eventName); + break; + case intentTypeKey: + errors.intent = validateIntentName(intent); + if (isRegEx) { + errors.regEx = validateDupRegExIntent(intent, regExIntents) ?? validateRegExPattern(regEx); + } else { + errors.triggerPhrases = validateTriggerPhrases(intent, triggerPhrases); + } + break; + } + + return errors; +}; diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index 48891fd4f1..a9bab0f03d 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -72,7 +72,7 @@ const CreateSkillModal = React.lazy(() => import('../../components/CreateSkillMo 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 ( diff --git a/Composer/packages/client/src/utils/dialogUtil.ts b/Composer/packages/client/src/utils/dialogUtil.ts index d8697263de..549fec6edd 100644 --- a/Composer/packages/client/src/utils/dialogUtil.ts +++ b/Composer/packages/client/src/utils/dialogUtil.ts @@ -1,21 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { - ConceptLabels, - DialogGroup, - SDKKinds, - dialogGroups, - DialogInfo, - DialogFactory, - ITriggerCondition, -} from '@bfc/shared'; +import { ConceptLabels, SDKKinds, DialogInfo, DialogFactory, ITriggerCondition } from '@bfc/shared'; import get from 'lodash/get'; import set from 'lodash/set'; import cloneDeep from 'lodash/cloneDeep'; -import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; -import { IComboBoxOption } from 'office-ui-fabric-react/lib/ComboBox'; -import formatMessage from 'format-message'; + +import { TriggerFormData } from '../components/TriggerCreationModal/types/TriggerFormData'; import { getFocusPath } from './navigation'; import { upperCaseName } from './fileUtil'; @@ -24,35 +15,11 @@ interface DialogsMap { [dialogId: string]: any; } -export interface TriggerFormData { - errors: TriggerFormDataErrors; - $kind: string; - event: string; - intent: string; - triggerPhrases: string; - regEx: string; -} - -export interface TriggerFormDataErrors { - $kind?: string; - intent?: string; - event?: string; - triggerPhrases?: string; - regEx?: string; - activity?: string; -} - export function getDialog(dialogs: DialogInfo[], dialogId: string) { const dialog = dialogs.find((item) => item.id === dialogId); return cloneDeep(dialog); } -export const eventTypeKey: string = SDKKinds.OnDialogEvent; -export const intentTypeKey: string = SDKKinds.OnIntent; -export const activityTypeKey: string = SDKKinds.OnActivity; -export const regexRecognizerKey: string = SDKKinds.RegexRecognizer; -export const customEventKey = 'OnCustomEvent'; - function insert(content, path: string, position: number | undefined, data: any) { const current = get(content, path, []); const insertAt = typeof position === 'undefined' ? current.length : position; @@ -178,7 +145,7 @@ export function deleteTrigger( ) { let dialogCopy = getDialog(dialogs, dialogId); if (!dialogCopy) return null; - const isRegEx = get(dialogCopy, 'content.recognizer.$kind', '') === regexRecognizerKey; + const isRegEx = get(dialogCopy, 'content.recognizer.$kind', '') === SDKKinds.RegexRecognizer; if (isRegEx) { const regExIntent = get(dialogCopy, `content.triggers[${index}].intent`, ''); dialogCopy = deleteRegExIntent(dialogCopy, regExIntent); @@ -191,66 +158,6 @@ export function deleteTrigger( return dialogCopy.content; } -export function getTriggerTypes(): IDropdownOption[] { - const triggerTypes: IDropdownOption[] = [ - ...dialogGroups[DialogGroup.EVENTS].types.map((t) => { - let name = t as string; - const labelOverrides = ConceptLabels[t]; - - if (labelOverrides && labelOverrides.title) { - name = labelOverrides.title; - } - - return { key: t, text: name || t }; - }), - { - key: customEventKey, - text: formatMessage('Custom events'), - }, - ]; - return triggerTypes; -} - -export function getEventTypes(): IComboBoxOption[] { - const eventTypes: IComboBoxOption[] = [ - ...dialogGroups[DialogGroup.DIALOG_EVENT_TYPES].types.map((t) => { - let name = t as string; - const labelOverrides = ConceptLabels[t]; - - if (labelOverrides && 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 activityTypes: IDropdownOption[] = [ - ...dialogGroups[DialogGroup.ADVANCED_EVENTS].types.map((t) => { - let name = t as string; - const labelOverrides = ConceptLabels[t]; - - if (labelOverrides && 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, dialog) => { result[dialog.id] = dialog.content;