= (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;