From 66ac5d0125d80e902b387de2d6398825094cb403 Mon Sep 17 00:00:00 2001 From: zeye Date: Thu, 24 Sep 2020 00:18:38 +0800 Subject: [PATCH 1/7] feat: UI Schema - Recognizer (#4135) * chore: turn RecognizerField to folder * extract 'useMigrationEffect' * remove dup & no ref file 'defaultRecognizers' * move out complicated selectedType func && var names * use 'dropdownOption' to replace 'isCustomType' which is anti-pattern * add 'default' and 'disabled' to RecognizerSchema * set CrossTrain to default, disable Luis * impl 'getRecognizerDefinition()' * move DefaultRecognizers to separated file * rename: 'editor' -> 'intentEditor' * impl 'recognizerEditor' in Recognizer schema * adapt to schema's 'disabled' property * sort recognizer options * make renameIntent optional * make handleRecognizerChange optional & impl fallback submit func * impl mappers to map among 'schema', 'dropdown', 'value' * provide 'findRecognizer' as hook's buitin func * apply findRecognizer to RecognizerField * mark isSelected optional * add a todo * merge 'recognizers' to uiSchema * migrate to new RecognizerUISchema * don't show non-recognizer $kinds as dropdown * adjust dropdown order * make the 'Custom Rec' with JSON editor as fallback option * fix tslint error * omi RegexRecognizer's `isSelected` option * resolve intentEditor logic leaks * fix UT * fix UT * impl getDefaultRecognizer() * create default recognizer when seeding new dialog * fix UT * copyright * del 'default' opt from crosstrain plugin Due to uischema merge priority, plugin settings are hard to be overrided. CrossTrain is already set to default in 'useRecongizerConfig.ts/getDefaultRecognizer' * migrate handleRecognizerChange to seedNewRecognizer * mark a todo * update `seedNewRecongnizer` interface & apply to dialog modal * Fix wording issue in comments Co-authored-by: Andy Brown * minor fixes on code style & comments * type the 'disabled' field strictly * more strict `isSelected()` method in CrossTrainReocognizer shema * update default recognizer value * provide 'current' and 'default' recognizer schema in useRecognizerConfig() * fix UTs * remove custom recognizer template * write `displayName` as function to support multi-locale Co-authored-by: Ben Yackley <61990921+beyackle@users.noreply.github.com> Co-authored-by: Andy Brown --- .../packages/adaptive-form/jest.config.js | 2 +- .../src/components/FormTitle.tsx | 17 ++- .../fields/CustomRecognizerField.tsx | 19 +++ .../src/components/fields/IntentField.tsx | 20 +-- .../src/components/fields/RecognizerField.tsx | 129 ------------------ .../RecognizerField/RecognizerField.tsx | 55 ++++++++ .../RecognizerField/defaultRecognizerOrder.ts | 11 ++ .../RecognizerField/getDropdownOptions.ts | 26 ++++ .../fields/RecognizerField/index.ts | 4 + .../fields/RecognizerField/mappers.ts | 15 ++ .../RecognizerField/useMigrationEffect.ts | 29 ++++ .../fields/__tests__/IntentField.test.tsx | 52 +++---- .../fields/__tests__/RecognizerField.test.tsx | 20 ++- .../src/components/fields/index.ts | 1 + .../adaptive-form/src/defaultRecognizers.ts | 66 --------- .../packages/client/__tests__/plugins.test.ts | 25 +++- .../client/src/pages/design/DesignPage.tsx | 30 ++-- .../src/pages/design/createDialogModal.tsx | 39 +++++- Composer/packages/client/src/plugins.ts | 1 - .../packages/client/src/utils/dialogUtil.ts | 1 - .../__tests__/useRecognizerConfig.test.tsx | 17 ++- .../src/hooks/useRecognizerConfig.ts | 83 ++++++++++- .../extension-client/src/types/extension.ts | 5 +- .../extension-client/src/types/formSchema.ts | 24 ++-- .../extension-client/src/types/index.ts | 1 + .../src/types/recognizerSchema.ts | 11 ++ .../__tests__/mergePluginConfigs.test.ts | 28 ++-- .../src/utils/mergePluginConfigs.ts | 1 - Composer/packages/lib/shared/src/types/sdk.ts | 4 +- .../composer/src/defaultRecognizerSchema.ts | 25 ++++ .../packages/ui-plugins/composer/src/index.ts | 86 ++---------- .../ui-plugins/cross-trained/src/index.ts | 38 +++--- .../packages/ui-plugins/luis/src/index.ts | 53 +++---- .../prompts/src/PromptField/UserInput.tsx | 14 +- 34 files changed, 508 insertions(+), 444 deletions(-) create mode 100644 Composer/packages/adaptive-form/src/components/fields/CustomRecognizerField.tsx delete mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField.tsx create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/defaultRecognizerOrder.ts create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/index.ts create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/useMigrationEffect.ts delete mode 100644 Composer/packages/adaptive-form/src/defaultRecognizers.ts create mode 100644 Composer/packages/extension-client/src/types/recognizerSchema.ts create mode 100644 Composer/packages/ui-plugins/composer/src/defaultRecognizerSchema.ts diff --git a/Composer/packages/adaptive-form/jest.config.js b/Composer/packages/adaptive-form/jest.config.js index 2c019947a0..b5a508c259 100644 --- a/Composer/packages/adaptive-form/jest.config.js +++ b/Composer/packages/adaptive-form/jest.config.js @@ -5,5 +5,5 @@ const { createConfig } = require('@bfc/test-utils'); module.exports = createConfig('adaptive-form', 'react', { - coveragePathIgnorePatterns: ['defaultRecognizers.ts', 'defaultRoleSchema.ts', 'defaultUiSchema.ts'], + coveragePathIgnorePatterns: ['defaultRoleSchema.ts', 'defaultUiSchema.ts'], }); diff --git a/Composer/packages/adaptive-form/src/components/FormTitle.tsx b/Composer/packages/adaptive-form/src/components/FormTitle.tsx index 9fe7ba4b38..5946955bbc 100644 --- a/Composer/packages/adaptive-form/src/components/FormTitle.tsx +++ b/Composer/packages/adaptive-form/src/components/FormTitle.tsx @@ -52,9 +52,7 @@ interface FormTitleProps { const FormTitle: React.FC = (props) => { const { description, schema, formData, uiOptions = {} } = props; const { shellApi, ...shellData } = useShellApi(); - const { currentDialog } = shellData; - const recognizers = useRecognizerConfig(); - const selectedRecognizer = recognizers.find((r) => r.isSelected(currentDialog?.content?.recognizer)); + const { currentRecognizer: selectedRecognizer } = useRecognizerConfig(); // use a ref because the syncIntentName is debounced and we need the most current version to invoke the api const shell = useRef({ data: shellData, @@ -69,12 +67,13 @@ const FormTitle: React.FC = (props) => { debounce(async (newIntentName?: string, data?: any) => { if (newIntentName && selectedRecognizer) { const normalizedIntentName = newIntentName?.replace(/[^a-zA-Z0-9-_]+/g, ''); - await selectedRecognizer.renameIntent( - data?.intent, - normalizedIntentName, - shell.current.data, - shell.current.api - ); + typeof selectedRecognizer.renameIntent === 'function' && + (await selectedRecognizer.renameIntent( + data?.intent, + normalizedIntentName, + shell.current.data, + shell.current.api + )); } }, 400), [] diff --git a/Composer/packages/adaptive-form/src/components/fields/CustomRecognizerField.tsx b/Composer/packages/adaptive-form/src/components/fields/CustomRecognizerField.tsx new file mode 100644 index 0000000000..464e6524f3 --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/CustomRecognizerField.tsx @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { FieldProps } from '@bfc/extension-client'; +import { JsonEditor } from '@bfc/code-editor'; + +export const CustomRecognizerField: React.FC = (props) => { + const { value, onChange } = props; + return ( + + ); +}; diff --git a/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx b/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx index 60fb592079..e9bb3a877c 100644 --- a/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx @@ -2,37 +2,29 @@ // Licensed under the MIT License. import React from 'react'; -import { FieldProps, useShellApi, useRecognizerConfig, FieldWidget } from '@bfc/extension-client'; +import { FieldProps, useRecognizerConfig } from '@bfc/extension-client'; import formatMessage from 'format-message'; -import { SDKKinds } from '@bfc/shared'; import { FieldLabel } from '../FieldLabel'; const IntentField: React.FC = (props) => { const { id, description, uiOptions, value, required, onChange } = props; - const { currentDialog } = useShellApi(); - const recognizers = useRecognizerConfig(); + const { currentRecognizer } = useRecognizerConfig(); + + const Editor = currentRecognizer?.intentEditor; + const label = formatMessage('Trigger phrases (intent: #{intentName})', { intentName: value }); const handleChange = () => { onChange(value); }; - const recognizer = recognizers.find((r) => r.isSelected(currentDialog?.content?.recognizer)); - let Editor: FieldWidget | undefined; - if (recognizer && recognizer.id === SDKKinds.CrossTrainedRecognizerSet) { - Editor = recognizers.find((r) => r.id === SDKKinds.LuisRecognizer)?.editor; - } else { - Editor = recognizer?.editor; - } - const label = formatMessage('Trigger phrases (intent: #{intentName})', { intentName: value }); - return ( {Editor ? ( ) : ( - formatMessage('No Editor for {type}', { type: recognizer?.id }) + formatMessage('No Editor for {type}', { type: currentRecognizer?.id }) )} ); diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField.tsx b/Composer/packages/adaptive-form/src/components/fields/RecognizerField.tsx deleted file mode 100644 index 4cb714dd7f..0000000000 --- a/Composer/packages/adaptive-form/src/components/fields/RecognizerField.tsx +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import React, { useMemo, useState, useEffect } from 'react'; -import { FieldProps, useShellApi, useRecognizerConfig } from '@bfc/extension-client'; -import { MicrosoftIRecognizer, SDKKinds } from '@bfc/shared'; -import { Dropdown, ResponsiveMode, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; -import formatMessage from 'format-message'; -import { JsonEditor } from '@bfc/code-editor'; - -import { FieldLabel } from '../FieldLabel'; - -const RecognizerField: React.FC> = (props) => { - const { value, id, label, description, uiOptions, required, onChange } = props; - const { shellApi, ...shellData } = useShellApi(); - const recognizers = useRecognizerConfig(); - const { qnaFiles, luFiles, currentDialog, locale } = shellData; - const [isCustomType, setIsCustomType] = useState(false); - - useEffect(() => { - // this logic is for handling old bot with `recognizer = undefined' - if (value === undefined) { - const qnaFile = qnaFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); - const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); - if (qnaFile && luFile) { - onChange(`${currentDialog.id}.lu.qna`); - } - } - - // transform lu recognizer to crosstrained for old bot - if (value === `${currentDialog.id}.lu`) { - onChange(`${currentDialog.id}.lu.qna`); - } - }, [value]); - - const options = useMemo(() => { - // filter luisRecognizer for dropdown options - return recognizers - .filter((r) => r.id !== SDKKinds.LuisRecognizer) - .map((r) => ({ - key: r.id, - text: typeof r.displayName === 'function' ? r.displayName(value) : r.displayName, - })); - }, [recognizers]); - - const selectedType = useMemo(() => { - if (isCustomType) { - return SDKKinds.CustomRecognizer; - } - const selected = - value === undefined - ? recognizers.length > 0 - ? [recognizers[0].id] - : [] - : recognizers.filter((r) => r.isSelected(value)).map((r) => r.id); - - const involvedCustomItem = selected.find((item) => item !== SDKKinds.CustomRecognizer); - if (involvedCustomItem) { - return involvedCustomItem; - } - if (selected.length < 1) { - /* istanbul ignore next */ - if (process.env.NODE_ENV === 'development') { - console.error( - `Unable to determine selected recognizer.\n - Value: ${JSON.stringify(value)}.\n - Selected Recognizers: [${selected.join(', ')}]` - ); - } - return; - } - - // transform luis recognizer to crosss trained recognizer for old bot. - if (selected[0] === SDKKinds.LuisRecognizer) { - selected[0] = SDKKinds.CrossTrainedRecognizerSet; - } - return selected[0]; - }, [value, isCustomType]); - - const handleChangeRecognizerType = (_, option?: IDropdownOption): void => { - if (option) { - if (option.key === SDKKinds.CustomRecognizer) { - setIsCustomType(true); - return; - } - - setIsCustomType(false); - const handler = recognizers.find((r) => r.id === option.key)?.handleRecognizerChange; - - if (handler) { - handler(props, shellData, shellApi); - } - } - }; - - const handleCustomChange = (value: string): void => { - setIsCustomType(true); - onChange(value); - }; - return ( - - - {selectedType ? ( - - ) : ( - formatMessage('Unable to determine recognizer type from data: {value}', { value }) - )} - {selectedType === SDKKinds.CustomRecognizer && ( - - )} - - ); -}; - -export { RecognizerField }; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx new file mode 100644 index 0000000000..51c1955f6b --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useMemo } from 'react'; +import { FieldProps, useShellApi, useRecognizerConfig } from '@bfc/extension-client'; +import { MicrosoftIRecognizer } from '@bfc/shared'; +import { Dropdown, ResponsiveMode, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import formatMessage from 'format-message'; + +import { FieldLabel } from '../../FieldLabel'; + +import { useMigrationEffect } from './useMigrationEffect'; +import { mapDropdownOptionToRecognizerSchema } from './mappers'; +import { getDropdownOptions } from './getDropdownOptions'; + +export const RecognizerField: React.FC> = (props) => { + const { value, id, label, description, uiOptions, required, onChange } = props; + const { shellApi, ...shellData } = useShellApi(); + + useMigrationEffect(value, onChange); + const { recognizers: recognizerConfigs, currentRecognizer } = useRecognizerConfig(); + const dropdownOptions = useMemo(() => getDropdownOptions(recognizerConfigs), [recognizerConfigs]); + + const RecognizerEditor = currentRecognizer?.recognizerEditor; + const widget = RecognizerEditor ? : null; + + const submit = (_, option?: IDropdownOption): void => { + if (!option) return; + + const recognizerDefinition = mapDropdownOptionToRecognizerSchema(option, recognizerConfigs); + + const seedNewRecognizer = recognizerDefinition?.seedNewRecognizer; + const recognizerInstance = + typeof seedNewRecognizer === 'function' + ? seedNewRecognizer(shellData, shellApi) + : { $kind: option.key as string, intents: [] }; // fallback to default Recognizer instance; + onChange(recognizerInstance); + }; + + return ( + + + + {widget} + + ); +}; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/defaultRecognizerOrder.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/defaultRecognizerOrder.ts new file mode 100644 index 0000000000..7836432d4a --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/defaultRecognizerOrder.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SDKKinds } from '@bfc/shared'; + +export const defaultRecognizerOrder = [SDKKinds.CrossTrainedRecognizerSet, SDKKinds.RegexRecognizer]; + +export const recognizerOrderMap: { [$kind: string]: number } = defaultRecognizerOrder.reduce((result, $kind, index) => { + result[$kind] = index; + return result; +}, {}); diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts new file mode 100644 index 0000000000..eaca2691be --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RecognizerSchema, FallbackRecognizerKey } from '@bfc/extension-client'; + +import { recognizerOrderMap } from './defaultRecognizerOrder'; +import { mapRecognizerSchemaToDropdownOption } from './mappers'; + +const getRankScore = (r: RecognizerSchema) => { + // Always put disabled recognizer behind. Handle 'disabled' before 'default'. + if (r.disabled) return Number.MAX_VALUE; + // Always put default recognzier ahead. + if (r.default) return -1; + // Put fallback recognizer behind. + if (r.id === FallbackRecognizerKey) return Number.MAX_VALUE - 1; + return recognizerOrderMap[r.id] ?? Number.MAX_VALUE - 1; +}; + +export const getDropdownOptions = (recognizerConfigs: RecognizerSchema[]) => { + return recognizerConfigs + .filter((r) => !r.disabled) + .sort((r1, r2) => { + return getRankScore(r1) - getRankScore(r2); + }) + .map(mapRecognizerSchemaToDropdownOption); +}; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/index.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/index.ts new file mode 100644 index 0000000000..24608a9eca --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { RecognizerField } from './RecognizerField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts new file mode 100644 index 0000000000..47c629af22 --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RecognizerSchema } from '@bfc/extension-client'; +import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; + +export const mapDropdownOptionToRecognizerSchema = (option: IDropdownOption, recognizerConfigs: RecognizerSchema[]) => { + return recognizerConfigs.find((r) => r.id === option.key); +}; + +export const mapRecognizerSchemaToDropdownOption = (recognizerSchema: RecognizerSchema): IDropdownOption => { + const { id, displayName } = recognizerSchema; + const recognizerName = typeof displayName === 'function' ? displayName({}) : displayName; + return { key: id, text: recognizerName || id }; +}; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/useMigrationEffect.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/useMigrationEffect.ts new file mode 100644 index 0000000000..e17a19e57c --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/useMigrationEffect.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useShellApi, ChangeHandler } from '@bfc/extension-client'; +import { useEffect } from 'react'; +import { MicrosoftIRecognizer } from '@bfc/shared'; + +export const useMigrationEffect = ( + recognizer: MicrosoftIRecognizer | undefined, + onChangeRecognizer: ChangeHandler +) => { + const { qnaFiles, luFiles, currentDialog, locale } = useShellApi(); + + useEffect(() => { + // this logic is for handling old bot with `recognizer = undefined' + if (recognizer === undefined) { + const qnaFile = qnaFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + if (qnaFile && luFile) { + onChangeRecognizer(`${currentDialog.id}.lu.qna`); + } + } + + // transform lu recognizer to crosstrained for old bot + if (recognizer === `${currentDialog.id}.lu`) { + onChangeRecognizer(`${currentDialog.id}.lu.qna`); + } + }, [recognizer]); +}; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx index 73366dc54e..4f1571e169 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { render, fireEvent } from '@bfc/test-utils'; import assign from 'lodash/assign'; -import { useShellApi, useRecognizerConfig } from '@bfc/extension-client'; +import { useRecognizerConfig } from '@bfc/extension-client'; import { IntentField } from '../IntentField'; @@ -20,28 +20,27 @@ function renderSubject(overrides = {}) { return render(); } -describe('', () => { - beforeEach(() => { - (useRecognizerConfig as jest.Mock).mockReturnValue([ - { - id: 'TestRecognizer', - isSelected: (data) => data?.$kind === 'TestRecognizer', - editor: ({ id, onChange }) => ( -
- Test Recognizer -
- ), - }, - { - id: 'OtherRecognizer', - isSelected: (data) => data?.$kind === 'OtherRecognizer', - }, - ]); - }); +const recognizers = [ + { + id: 'TestRecognizer', + displayName: 'TestRecognizer', + intentEditor: ({ id, onChange }) => ( +
+ Test Recognizer +
+ ), + }, + { + id: 'OtherRecognizer', + displayName: 'OtherRecognizer', + }, +]; +describe('', () => { it('uses a custom label', () => { - (useShellApi as jest.Mock).mockReturnValue({ - currentDialog: { content: { recognizer: { $kind: 'TestRecognizer' } } }, + (useRecognizerConfig as jest.Mock).mockReturnValue({ + recognizers, + currentRecognizer: recognizers[0], }); const { getByLabelText } = renderSubject({ value: 'MyIntent' }); @@ -49,9 +48,11 @@ describe('', () => { }); it('invokes change handler with intent name', () => { - (useShellApi as jest.Mock).mockReturnValue({ - currentDialog: { content: { recognizer: { $kind: 'TestRecognizer' } } }, + (useRecognizerConfig as jest.Mock).mockReturnValue({ + recognizers, + currentRecognizer: recognizers[0], }); + const onChange = jest.fn(); const { getByText } = renderSubject({ onChange, value: 'MyIntent' }); @@ -60,8 +61,9 @@ describe('', () => { }); it('renders message when editor not defined', () => { - (useShellApi as jest.Mock).mockReturnValue({ - currentDialog: { content: { recognizer: { $kind: 'OtherRecognizer' } } }, + (useRecognizerConfig as jest.Mock).mockReturnValue({ + recognizers, + currentRecognizer: recognizers[1], }); const { container } = renderSubject({ value: 'MyIntent' }); diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/RecognizerField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/RecognizerField.test.tsx index 7d0e79090a..f27b62a2dd 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/RecognizerField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/RecognizerField.test.tsx @@ -31,29 +31,27 @@ describe('', () => { }); }); - it('renders error message when no recognizer matched', () => { - (useRecognizerConfig as jest.Mock).mockReturnValue([]); - const { container } = renderSubject(); - expect(container).toHaveTextContent(/Unable to determine recognizer type from data:/); - }); - it('renders a dropdown when recognizer matches', () => { const handleChange = jest.fn(); - (useRecognizerConfig as jest.Mock).mockReturnValue([ + const recognizers = [ { id: 'one', displayName: 'One Recognizer', isSelected: () => false, - handleRecognizerChange: handleChange, + seedNewRecognizer: handleChange, }, { id: 'two', displayName: 'Two Recognizer', isSelected: () => true, - handleRecognizerChange: jest.fn(), + seedNewRecognizer: jest.fn(), }, - ]); - const { getByTestId } = renderSubject({ value: 'one' }); + ]; + (useRecognizerConfig as jest.Mock).mockReturnValue({ + recognizers, + currentRecognizer: recognizers[1], + }); + const { getByTestId } = renderSubject({ value: { $kind: 'two' } }); const dropdown = getByTestId('recognizerTypeDropdown'); expect(dropdown).toHaveTextContent('Two Recognizer'); fireEvent.click(dropdown); diff --git a/Composer/packages/adaptive-form/src/components/fields/index.ts b/Composer/packages/adaptive-form/src/components/fields/index.ts index fdb9c1c115..24246f3a97 100644 --- a/Composer/packages/adaptive-form/src/components/fields/index.ts +++ b/Composer/packages/adaptive-form/src/components/fields/index.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. export * from './ArrayField'; export * from './BooleanField'; +export * from './CustomRecognizerField'; export * from './EditableField'; export * from './ExpressionField/ExpressionField'; export * from './FieldSets'; diff --git a/Composer/packages/adaptive-form/src/defaultRecognizers.ts b/Composer/packages/adaptive-form/src/defaultRecognizers.ts deleted file mode 100644 index c4a245d1e2..0000000000 --- a/Composer/packages/adaptive-form/src/defaultRecognizers.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { RecognizerSchema } from '@bfc/extension-client'; -import { SDKKinds } from '@bfc/shared'; -import formatMessage from 'format-message'; - -import { RegexIntentField } from './components/fields/RegexIntentField'; - -const DefaultRecognizers: RecognizerSchema[] = [ - { - id: SDKKinds.RegexRecognizer, - displayName: () => formatMessage('Regular Expression'), - editor: RegexIntentField, - isSelected: (data) => { - return typeof data === 'object' && data.$kind === SDKKinds.RegexRecognizer; - }, - handleRecognizerChange: (props) => { - props.onChange({ $kind: SDKKinds.RegexRecognizer, intents: [] }); - }, - renameIntent: async (intentName, newIntentName, shellData, shellApi) => { - const { currentDialog } = shellData; - await shellApi.renameRegExIntent(currentDialog.id, intentName, newIntentName); - }, - }, - { - id: SDKKinds.CustomRecognizer, - displayName: () => formatMessage('Custom recognizer'), - isSelected: (data) => typeof data === 'object', - handleRecognizerChange: (props) => - props.onChange({ - $kind: 'Microsoft.MultiLanguageRecognizer', - recognizers: { - 'en-us': { - $kind: 'Microsoft.RegexRecognizer', - intents: [ - { - intent: 'greeting', - pattern: 'hello', - }, - { - intent: 'test', - pattern: 'test', - }, - ], - }, - 'zh-cn': { - $kind: 'Microsoft.RegexRecognizer', - intents: [ - { - intent: 'greeting', - pattern: '你好', - }, - { - intent: 'test', - pattern: '测试', - }, - ], - }, - }, - }), - renameIntent: () => {}, - }, -]; - -export default DefaultRecognizers; diff --git a/Composer/packages/client/__tests__/plugins.test.ts b/Composer/packages/client/__tests__/plugins.test.ts index ea15956780..102d9536ea 100644 --- a/Composer/packages/client/__tests__/plugins.test.ts +++ b/Composer/packages/client/__tests__/plugins.test.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { SDKKinds } from '@bfc/shared'; + import { mergePluginConfigs } from '../src/plugins'; describe('mergePluginConfigs', () => { @@ -43,22 +45,35 @@ describe('mergePluginConfigs', () => { }, }, }, - recognizers: [], flowWidgets: {}, }); }); it('adds recognizers', () => { const config1 = { - recognizers: ['recognizer 1'], + uiSchema: { + [SDKKinds.RegexRecognizer]: { + recognizer: { displayName: 'recognizer1' }, + }, + }, }; const config2 = { - recognizers: ['recognizer 2'], + uiSchema: { + [SDKKinds.LuisRecognizer]: { + recognizer: { displayName: 'recognizer2' }, + }, + }, }; - // @ts-expect-error - expect(mergePluginConfigs(config1, config2).recognizers).toEqual(['recognizer 2', 'recognizer 1']); + expect(mergePluginConfigs(config1, config2).uiSchema).toEqual({ + [SDKKinds.RegexRecognizer]: { + recognizer: { displayName: 'recognizer1' }, + }, + [SDKKinds.LuisRecognizer]: { + recognizer: { displayName: 'recognizer2' }, + }, + }); }); it('replaces other arrays', () => { diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index 09754e1e46..15e51eb8a0 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -8,7 +8,7 @@ import { Breadcrumb, IBreadcrumbItem } from 'office-ui-fabric-react/lib/Breadcru import formatMessage from 'format-message'; import { globalHistory, RouteComponentProps } from '@reach/router'; import get from 'lodash/get'; -import { DialogFactory, SDKKinds, DialogInfo, PromptTab, getEditorAPI, registerEditorAPI } from '@bfc/shared'; +import { DialogInfo, PromptTab, getEditorAPI, registerEditorAPI } from '@bfc/shared'; import { ActionButton } from 'office-ui-fabric-react/lib/Button'; import { JsonEditor } from '@bfc/code-editor'; import { EditorExtension, useTriggerApi, PluginConfig } from '@bfc/extension-client'; @@ -46,7 +46,6 @@ import { focusPathState, showCreateDialogModalState, showAddSkillDialogModalState, - actionsSeedState, localeState, } from '../../recoilModel'; import ImportQnAFromUrlModal from '../knowledge-base/ImportQnAFromUrlModal'; @@ -117,7 +116,6 @@ const DesignPage: React.FC }> {showCreateDialogModal && ( - createDialogCancel(projectId)} - onSubmit={handleCreateDialogSubmit} - /> + + createDialogCancel(projectId)} + onSubmit={handleCreateDialogSubmit} + /> + )} {showAddSkillDialogModal && ( void; + onSubmit: (dialogName: string, dialogContent) => void; onDismiss: () => void; onCurrentPathUpdate?: (newPath?: string, storageId?: string) => void; focusedStorageFolder?: StorageFolder; @@ -32,7 +34,14 @@ interface CreateDialogModalProps { export const CreateDialogModal: React.FC = (props) => { const { onSubmit, onDismiss, isOpen, projectId } = props; + + const schemas = useRecoilValue(schemasState(projectId)); const dialogs = useRecoilValue(validateDialogSelectorFamily(projectId)); + const actionsSeed = useRecoilValue(actionsSeedState(projectId)); + + const { shellApi, ...shellData } = useShellApi(); + const { defaultRecognizer } = useRecognizerConfig(); + const formConfig: FieldConfig = { name: { required: true, @@ -54,6 +63,26 @@ export const CreateDialogModal: React.FC = (props) => { const { formData, formErrors, hasErrors, updateField } = useForm(formConfig); + const seedNewRecognizer = (recognizerSchema?: RecognizerSchema) => { + if (recognizerSchema && typeof recognizerSchema.seedNewRecognizer === 'function') { + return recognizerSchema.seedNewRecognizer(shellData, shellApi); + } + return { $kind: recognizerSchema?.id }; + }; + + const seedNewDialog = (formData: DialogFormData) => { + const seededContent = new DialogFactory(schemas.sdk?.content).create(SDKKinds.AdaptiveDialog, { + $designer: { name: formData.name, description: formData.description }, + generator: `${formData.name}.lg`, + recognizer: seedNewRecognizer(defaultRecognizer), + }); + if (seededContent.triggers?.[0]) { + seededContent.triggers[0].actions = actionsSeed; + } + + return seededContent; + }; + const handleSubmit = useCallback( (e) => { e.preventDefault(); @@ -61,9 +90,9 @@ export const CreateDialogModal: React.FC = (props) => { return; } - onSubmit({ - ...formData, - }); + const dialogData = seedNewDialog(formData); + + onSubmit(formData.name, dialogData); }, [hasErrors, formData] ); diff --git a/Composer/packages/client/src/plugins.ts b/Composer/packages/client/src/plugins.ts index 9a67db6574..cd8b8f9dc4 100644 --- a/Composer/packages/client/src/plugins.ts +++ b/Composer/packages/client/src/plugins.ts @@ -29,7 +29,6 @@ const mergeArrays: MergeWithCustomizer = (objValue, srcValue, key) => { const defaultPlugin: Required = { uiSchema: {}, - recognizers: [], flowWidgets: {}, }; diff --git a/Composer/packages/client/src/utils/dialogUtil.ts b/Composer/packages/client/src/utils/dialogUtil.ts index e890e019bb..235868bd23 100644 --- a/Composer/packages/client/src/utils/dialogUtil.ts +++ b/Composer/packages/client/src/utils/dialogUtil.ts @@ -52,7 +52,6 @@ export const intentTypeKey: string = SDKKinds.OnIntent; export const qnaTypeKey: string = SDKKinds.OnQnAMatch; export const activityTypeKey: string = SDKKinds.OnActivity; export const regexRecognizerKey: string = SDKKinds.RegexRecognizer; -export const crossTrainedRecognizerSetKey: string = SDKKinds.CrossTrainedRecognizerSet; export const customEventKey = 'OnCustomEvent'; export const qnaMatcherKey: string = SDKKinds.OnQnAMatch; export const onChooseIntentKey: string = SDKKinds.OnChooseIntent; diff --git a/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx b/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx index efea8491c1..f13b03d3b9 100644 --- a/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx +++ b/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx @@ -7,29 +7,40 @@ import { renderHook } from '@bfc/test-utils/lib/hooks'; import { useRecognizerConfig } from '../useRecognizerConfig'; import { EditorExtensionContext } from '../../EditorExtensionContext'; +const shellData = { currentDialog: { content: {} } }; const plugins = { uiSchema: { foo: { form: {}, menu: {}, + recognizer: { displayName: 'recognizer 1' }, }, bar: { form: {}, menu: {}, + recognizer: { displayName: 'recognizer 2' }, }, }, - recognizers: ['recognizer 1', 'recognizer 2'], }; const wrapper: React.FC = ({ children }) => ( // @ts-expect-error - {children} + {children} ); describe('useRecognizerConfig', () => { it('returns the configured recognizers', () => { const { result } = renderHook(() => useRecognizerConfig(), { wrapper }); - expect(result.current).toEqual(['recognizer 1', 'recognizer 2']); + expect(result.current.recognizers).toEqual([ + { + id: 'foo', + displayName: 'recognizer 1', + }, + { + id: 'bar', + displayName: 'recognizer 2', + }, + ]); }); }); diff --git a/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts b/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts index 1d28715cff..a90127aa55 100644 --- a/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts +++ b/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts @@ -1,12 +1,87 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useContext } from 'react'; +import { useContext, useMemo } from 'react'; +import { MicrosoftIRecognizer, SDKKinds } from '@bfc/shared'; +import get from 'lodash/get'; import { EditorExtensionContext } from '../EditorExtensionContext'; +import { RecognizerOptions, RecognizerSchema } from '../types'; -export function useRecognizerConfig() { - const { plugins } = useContext(EditorExtensionContext); +export const FallbackRecognizerKey = 'fallback'; - return plugins.recognizers ?? []; +// TODO: (ze) remove this logic after the ui widget PR. [issue #4167] +const reuseLuisIntentEditor = (recognizers: RecognizerSchema[]) => { + const crosstrainRecognizer = recognizers.find((x) => x.id === SDKKinds.CrossTrainedRecognizerSet); + const luisRecognizer = recognizers.find((x) => x.id === SDKKinds.LuisRecognizer); + if (crosstrainRecognizer && luisRecognizer) { + crosstrainRecognizer.intentEditor = luisRecognizer.intentEditor; + } +}; + +const getDefaultRecognizer = (recognizers: RecognizerSchema[]) => { + const defaultRecognizer = recognizers.find((r) => r.default && !r.disabled); + if (defaultRecognizer) return defaultRecognizer; + + // TODO: (ze) remove this logic after recognizer config is port to SDK component schema. + const crosstrainRecognizer = recognizers.find((r) => r.id === SDKKinds.CrossTrainedRecognizerSet); + if (crosstrainRecognizer) return crosstrainRecognizer; + + const firstAvailableRecognizer = recognizers.find((r) => !r.disabled); + return firstAvailableRecognizer; +}; + +const getFallbackRecognizer = (recognizers: RecognizerSchema[]) => { + return recognizers.find((r) => r.id === FallbackRecognizerKey); +}; + +const findRecognizerByValue = (recognizers: RecognizerSchema[], recognizerValue?: MicrosoftIRecognizer) => { + const matchedRecognizer = recognizers.find((r) => { + if (typeof r.isSelected === 'function') { + return r.isSelected(recognizerValue); + } + return r.id === get(recognizerValue, '$kind'); + }); + return matchedRecognizer; +}; + +export interface RecognizerSchemaConfig { + /** All recognizer definitions from uischema. */ + recognizers: RecognizerSchema[]; + /** Current dialog's in-use recognizer definition. */ + currentRecognizer?: RecognizerSchema; + /** Default recognizer's definition, used when creating new dialog. */ + defaultRecognizer?: RecognizerSchema; +} + +export function useRecognizerConfig(): RecognizerSchemaConfig { + const { plugins, shellData } = useContext(EditorExtensionContext); + + const recognizers: RecognizerSchema[] = useMemo(() => { + if (!plugins.uiSchema) return []; + + const schemas = Object.entries(plugins.uiSchema) + .filter(([_, uiOptions]) => uiOptions && uiOptions.recognizer) + .map(([$kind, uiOptions]) => { + const recognizerOptions = uiOptions?.recognizer as RecognizerOptions; + return { + id: $kind, + ...recognizerOptions, + } as RecognizerSchema; + }); + reuseLuisIntentEditor(schemas); + return schemas; + }, [plugins.uiSchema]); + + const defaultRecognizer = getDefaultRecognizer(recognizers); + const fallbackRecognizer = getFallbackRecognizer(recognizers); + + const currentRecognizerValue = shellData.currentDialog?.content?.recognizer; + const currentRecognizer = findRecognizerByValue(recognizers, currentRecognizerValue) ?? fallbackRecognizer; + + return { + recognizers, + currentRecognizer, + defaultRecognizer, + }; } diff --git a/Composer/packages/extension-client/src/types/extension.ts b/Composer/packages/extension-client/src/types/extension.ts index 7032159e6b..d17d56df10 100644 --- a/Composer/packages/extension-client/src/types/extension.ts +++ b/Composer/packages/extension-client/src/types/extension.ts @@ -3,12 +3,12 @@ import { SDKKinds } from '@bfc/shared'; -import { RecognizerSchema, UIOptions } from './formSchema'; +import { UIOptions } from './formSchema'; import { FlowEditorWidgetMap, FlowWidget } from './flowSchema'; import { MenuOptions } from './menuSchema'; +import { RecognizerOptions } from './recognizerSchema'; export interface PluginConfig { - recognizers?: RecognizerSchema[]; uiSchema?: UISchema; flowWidgets?: FlowEditorWidgetMap; } @@ -18,5 +18,6 @@ export type UISchema = { flow?: FlowWidget; form?: UIOptions; menu?: MenuOptions; + recognizer?: RecognizerOptions; }; }; diff --git a/Composer/packages/extension-client/src/types/formSchema.ts b/Composer/packages/extension-client/src/types/formSchema.ts index c07a175cb3..805475717e 100644 --- a/Composer/packages/extension-client/src/types/formSchema.ts +++ b/Composer/packages/extension-client/src/types/formSchema.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { SDKKinds, SDKRoles, ShellApi, ShellData } from '@bfc/shared'; +import { MicrosoftIRecognizer, SDKKinds, SDKRoles, ShellApi, ShellData } from '@bfc/shared'; -import { FieldProps, FieldWidget } from './form'; +import { FieldWidget } from './form'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type UIOptionValue = R | UIOptionFunc; @@ -63,16 +63,24 @@ export type FormUISchema = { [key in SDKKinds]?: UIOptions }; export type RecognizerSchema = { /** Unique id to identify recognizer (SDK $kind) */ id: string; - /** Display name used in the UI */ + /** If is default, will be used as dropdown's default selection */ + default?: boolean; + /** If disabled, cannot be selected from dropdown */ + disabled?: boolean; + /** Display name used in the UI. Recommended to use function over static string to enable multi-locale feature. */ displayName: UIOptionValue; /** An inline editor to edit an intent. If none provided, users will not be able to edit. */ - editor?: FieldWidget; + intentEditor?: FieldWidget; /** A function invoked with the form data to determine if this is the currently selected recognizer */ - isSelected: (data: any) => boolean; - /** Invoked when changing the recognizer type */ - handleRecognizerChange: (fieldProps: FieldProps, shellData: ShellData, shellApi: ShellApi) => void; + isSelected?: (data: any) => boolean; + /** Invoked when constructing a new recognizer instance. + * Make sure the instance can be recognized either by $kind or isSelected(). + */ + seedNewRecognizer?: (shellData: ShellData, shellApi: ShellApi) => MicrosoftIRecognizer | any; + /** An inline editor to edit recognizer value. If none provided, users will not be able to edit its value. */ + recognizerEditor?: FieldWidget; /** Function to rename an intent */ - renameIntent: ( + renameIntent?: ( intentName: string, newIntentName: string, shellData: ShellData, diff --git a/Composer/packages/extension-client/src/types/index.ts b/Composer/packages/extension-client/src/types/index.ts index d51cd232e0..a44ce84f99 100644 --- a/Composer/packages/extension-client/src/types/index.ts +++ b/Composer/packages/extension-client/src/types/index.ts @@ -6,3 +6,4 @@ export * from './form'; export * from './formSchema'; export * from './flowSchema'; export * from './menuSchema'; +export * from './recognizerSchema'; diff --git a/Composer/packages/extension-client/src/types/recognizerSchema.ts b/Composer/packages/extension-client/src/types/recognizerSchema.ts new file mode 100644 index 0000000000..e1585ec68e --- /dev/null +++ b/Composer/packages/extension-client/src/types/recognizerSchema.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SDKKinds } from '@bfc/shared'; + +import { RecognizerSchema } from './formSchema'; + +// Omit the 'id' field because it can be inferred from $kind. +export type RecognizerOptions = Omit; + +export type RecognizerUISchema = { [key in SDKKinds]?: RecognizerOptions }; diff --git a/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts b/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts index 93763a0fd4..125e0006c1 100644 --- a/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts +++ b/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts @@ -56,23 +56,25 @@ describe('mergePluginConfigs', () => { it('merges recognizers into single list', () => { const plugins: Partial[] = [ { - recognizers: [ - { - id: 'default', - displayName: 'Default', - isSelected: () => false, - handleRecognizerChange: jest.fn(), + uiSchema: { + [SDKKinds.LuisRecognizer]: { + recognizer: { + displayName: 'Default', + isSelected: () => false, + seedNewRecognizer: jest.fn(), + }, }, - { - id: 'new', - displayName: 'New Recognizer', - isSelected: () => false, - handleRecognizerChange: jest.fn(), + [SDKKinds.RegexRecognizer]: { + recognizer: { + displayName: 'New Recognizer', + isSelected: () => false, + seedNewRecognizer: jest.fn(), + }, }, - ], + }, }, ]; - expect(mergePluginConfigs(...plugins).recognizers).toHaveLength(2); + expect(Object.keys(mergePluginConfigs(...plugins).uiSchema)).toHaveLength(2); }); }); diff --git a/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts b/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts index 3b08f6dfca..69870f06b4 100644 --- a/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts +++ b/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts @@ -22,7 +22,6 @@ const mergeArrays: MergeWithCustomizer = (objValue, srcValue, key) => { const defaultPlugin: Required = { uiSchema: {}, - recognizers: [], flowWidgets: {}, }; diff --git a/Composer/packages/lib/shared/src/types/sdk.ts b/Composer/packages/lib/shared/src/types/sdk.ts index d31028a3b6..171c0970bb 100644 --- a/Composer/packages/lib/shared/src/types/sdk.ts +++ b/Composer/packages/lib/shared/src/types/sdk.ts @@ -18,8 +18,8 @@ export interface BaseSchema { $copy?: string; /** Extra information for the Bot Framework Composer. */ $designer?: DesignerData; - /** If 'disabled' set to true, runtime will skip this action. */ - disabled: any; + /** If 'disabled' equals to or be evaluated as 'true', runtime will skip this action. */ + disabled?: boolean | string; } /* Union of components which implement the IActivityTemplate interface */ diff --git a/Composer/packages/ui-plugins/composer/src/defaultRecognizerSchema.ts b/Composer/packages/ui-plugins/composer/src/defaultRecognizerSchema.ts new file mode 100644 index 0000000000..d17a2d3fc3 --- /dev/null +++ b/Composer/packages/ui-plugins/composer/src/defaultRecognizerSchema.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RecognizerUISchema, FallbackRecognizerKey, RecognizerOptions } from '@bfc/extension-client'; +import { SDKKinds } from '@bfc/shared'; +import formatMessage from 'format-message'; +import { RegexIntentField, CustomRecognizerField } from '@bfc/adaptive-form'; + +const FallbackRecognizerJsonEditor: RecognizerOptions = { + displayName: () => formatMessage('Custom recognizer'), + seedNewRecognizer: () => ({}), + recognizerEditor: CustomRecognizerField, +}; + +export const DefaultRecognizerSchema: RecognizerUISchema = { + [SDKKinds.RegexRecognizer]: { + displayName: () => formatMessage('Regular Expression'), + intentEditor: RegexIntentField, + renameIntent: (intentName, newIntentName, shellData, shellApi) => { + const { currentDialog } = shellData; + shellApi.renameRegExIntent(currentDialog.id, intentName, newIntentName); + }, + }, + [FallbackRecognizerKey as SDKKinds]: FallbackRecognizerJsonEditor, +}; diff --git a/Composer/packages/ui-plugins/composer/src/index.ts b/Composer/packages/ui-plugins/composer/src/index.ts index 813fb72d63..9fa439562e 100644 --- a/Composer/packages/ui-plugins/composer/src/index.ts +++ b/Composer/packages/ui-plugins/composer/src/index.ts @@ -1,69 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { PluginConfig, FormUISchema, RecognizerSchema, UISchema, MenuUISchema } from '@bfc/extension-client'; +import { PluginConfig, FormUISchema, UISchema, MenuUISchema, RecognizerUISchema } from '@bfc/extension-client'; import { SDKKinds } from '@bfc/shared'; import formatMessage from 'format-message'; -import mapValues from 'lodash/mapValues'; -import { IntentField, RecognizerField, RegexIntentField, QnAActionsField } from '@bfc/adaptive-form'; +import mergeWith from 'lodash/mergeWith'; +import { IntentField, RecognizerField, QnAActionsField } from '@bfc/adaptive-form'; import { DefaultMenuSchema } from './defaultMenuSchema'; - -const DefaultRecognizers: RecognizerSchema[] = [ - { - id: SDKKinds.RegexRecognizer, - displayName: () => formatMessage('Regular Expression'), - editor: RegexIntentField, - isSelected: (data) => { - return typeof data === 'object' && data.$kind === SDKKinds.RegexRecognizer; - }, - handleRecognizerChange: (props) => { - props.onChange({ $kind: SDKKinds.RegexRecognizer, intents: [] }); - }, - renameIntent: (intentName, newIntentName, shellData, shellApi) => { - const { currentDialog } = shellData; - shellApi.renameRegExIntent(currentDialog.id, intentName, newIntentName); - }, - }, - { - id: SDKKinds.CustomRecognizer, - displayName: () => formatMessage('Custom recognizer'), - isSelected: (data) => typeof data === 'object', - handleRecognizerChange: (props) => - props.onChange({ - $kind: 'Microsoft.MultiLanguageRecognizer', - recognizers: { - 'en-us': { - $kind: 'Microsoft.RegexRecognizer', - intents: [ - { - intent: 'greeting', - pattern: 'hello', - }, - { - intent: 'test', - pattern: 'test', - }, - ], - }, - 'zh-cn': { - $kind: 'Microsoft.RegexRecognizer', - intents: [ - { - intent: 'greeting', - pattern: '你好', - }, - { - intent: 'test', - pattern: '测试', - }, - ], - }, - }, - }), - renameIntent: () => {}, - }, -]; +import { DefaultRecognizerSchema } from './defaultRecognizerSchema'; const DefaultFormSchema: FormUISchema = { [SDKKinds.AdaptiveDialog]: { @@ -216,21 +161,20 @@ const DefaultFormSchema: FormUISchema = { }, }; -const synthesizeUISchema = (formSchema: FormUISchema, menuSchema: MenuUISchema): UISchema => { - const uiSchema: UISchema = mapValues(formSchema, (val) => ({ form: val })); - for (const [$kind, menuConfig] of Object.entries(menuSchema)) { - if (uiSchema[$kind]) { - uiSchema[$kind].menu = menuConfig; - } else { - uiSchema[$kind] = { menu: menuConfig }; - } - } - return uiSchema; +const synthesizeUISchema = ( + formSchema: FormUISchema, + menuSchema: MenuUISchema, + 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, recognizerSchema, (origin, opt) => ({ ...origin, recognizer: opt })); + return uischema; }; const config: PluginConfig = { - uiSchema: synthesizeUISchema(DefaultFormSchema, DefaultMenuSchema), - recognizers: DefaultRecognizers, + uiSchema: synthesizeUISchema(DefaultFormSchema, DefaultMenuSchema, DefaultRecognizerSchema), }; export default config; diff --git a/Composer/packages/ui-plugins/cross-trained/src/index.ts b/Composer/packages/ui-plugins/cross-trained/src/index.ts index 220a4054b0..e424e2452a 100644 --- a/Composer/packages/ui-plugins/cross-trained/src/index.ts +++ b/Composer/packages/ui-plugins/cross-trained/src/index.ts @@ -6,29 +6,27 @@ import { SDKKinds } from '@bfc/shared'; import formatMessage from 'format-message'; const config: PluginConfig = { - recognizers: [ - { - id: SDKKinds.CrossTrainedRecognizerSet, - displayName: formatMessage('Default recognizer'), - isSelected: (data) => { - return typeof data === 'string'; - }, - handleRecognizerChange: (props, shellData, _) => { - const { qnaFiles, luFiles, currentDialog, locale } = shellData; - const qnaFile = qnaFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); - const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + uiSchema: { + [SDKKinds.CrossTrainedRecognizerSet]: { + recognizer: { + displayName: () => formatMessage('Default recognizer'), + isSelected: (data) => { + return typeof data === 'string' && data.endsWith('.lu.qna'); + }, + seedNewRecognizer: (shellData) => { + const { qnaFiles, luFiles, currentDialog, locale } = shellData; + const qnaFile = qnaFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + + if (!qnaFile || !luFile) { + alert(formatMessage(`NO LU OR QNA FILE WITH NAME { id }`, { id: currentDialog.id })); + } - if (qnaFile && luFile) { - // strip locale out of id so it doesn't get serialized - // into the .dialog file - props.onChange(`${currentDialog.id}.lu.qna`); - } else { - alert(formatMessage(`NO LU OR QNA FILE WITH NAME { id }`, { id: currentDialog.id })); - } + return `${currentDialog.id}.lu.qna`; + }, }, - renameIntent: () => {}, }, - ], + }, }; export default config; diff --git a/Composer/packages/ui-plugins/luis/src/index.ts b/Composer/packages/ui-plugins/luis/src/index.ts index 36c1dc52fc..732ae29f64 100644 --- a/Composer/packages/ui-plugins/luis/src/index.ts +++ b/Composer/packages/ui-plugins/luis/src/index.ts @@ -8,33 +8,38 @@ import formatMessage from 'format-message'; import { LuisIntentEditor } from './LuisIntentEditor'; const config: PluginConfig = { - recognizers: [ - { - id: SDKKinds.LuisRecognizer, - displayName: formatMessage('LUIS'), - editor: LuisIntentEditor, - isSelected: (data) => { - return typeof data === 'string' && data.endsWith('.lu'); - }, - handleRecognizerChange: (props, shellData) => { - const { luFiles, currentDialog, locale } = shellData; - const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + uiSchema: { + [SDKKinds.LuisRecognizer]: { + recognizer: { + disabled: true, + displayName: () => formatMessage('LUIS'), + intentEditor: LuisIntentEditor, + isSelected: (data) => { + return typeof data === 'string' && data.endsWith('.lu'); + }, + seedNewRecognizer: (shellData) => { + const { luFiles, currentDialog, locale } = shellData; + const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); - if (luFile) { - // strip locale out of id so it doesn't get serialized - // into the .dialog file - props.onChange(`${luFile.id.split('.')[0]}.lu`); - } else { - alert(formatMessage(`NO LU FILE WITH NAME {id}`, { id: currentDialog.id })); - } - }, - renameIntent: async (intentName, newIntentName, shellData, shellApi) => { - const { currentDialog, locale } = shellData; - shellApi.updateIntentTrigger(currentDialog.id, intentName, newIntentName); - await shellApi.renameLuIntent(`${currentDialog.id}.${locale}`, intentName, newIntentName); + if (!luFile) { + alert(formatMessage(`NO LU FILE WITH NAME {id}`, { id: currentDialog.id })); + return ''; + } + + try { + return `${luFile.id.split('.')[0]}.lu`; + } catch (err) { + return ''; + } + }, + renameIntent: async (intentName, newIntentName, shellData, shellApi) => { + const { currentDialog, locale } = shellData; + shellApi.updateIntentTrigger(currentDialog.id, intentName, newIntentName); + await shellApi.renameLuIntent(`${currentDialog.id}.${locale}`, intentName, newIntentName); + }, }, }, - ], + }, }; export default config; diff --git a/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx b/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx index e6b481d525..3f781c2932 100644 --- a/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx +++ b/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx @@ -32,21 +32,15 @@ const expectedResponsesPlaceholder = () => const UserInput: React.FC> = (props) => { const { onChange, getSchema, value, id, uiOptions, getError, definitions, depth, schema = {} } = props; - const { currentDialog, designerId } = useShellApi(); - const recognizers = useRecognizerConfig(); + const { designerId } = useShellApi(); + const { currentRecognizer } = useRecognizerConfig(); const { const: $kind } = (schema?.properties?.$kind as { const: string }) || {}; const intentName = new LuMetaData(new LuType($kind).toString(), designerId).toString(); - - const recognizer = recognizers.find((r) => r.isSelected(currentDialog?.content?.recognizer)); - let Editor; - if (recognizer && recognizer.id === SDKKinds.CrossTrainedRecognizerSet) { - Editor = recognizers.find((r) => r.id === SDKKinds.LuisRecognizer)?.editor; - } else { - Editor = recognizer?.editor; - } const intentLabel = formatMessage('Expected responses (intent: #{intentName})', { intentName }); + const Editor = currentRecognizer?.intentEditor; + return ( Date: Thu, 24 Sep 2020 03:48:43 +0800 Subject: [PATCH 2/7] fix: split qna resource to another template (#4212) Co-authored-by: Andy Brown --- .../DeploymentTemplates/qna-template.json | 221 ++++++++++++++++++ .../template-with-preexisting-rg.json | 133 +---------- .../shared/scripts/provisionComposer.js | 175 +++++++++++++- 3 files changed, 385 insertions(+), 144 deletions(-) create mode 100644 Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/qna-template.json diff --git a/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/qna-template.json b/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/qna-template.json new file mode 100644 index 0000000000..511420d5d8 --- /dev/null +++ b/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/qna-template.json @@ -0,0 +1,221 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "name": { + "type": "string", + "defaultValue": "[resourceGroup().name]" + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "[resourceGroup().name]", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "appInsightsName": { + "type": "string", + "defaultValue": "[resourceGroup().name]" + }, + "appInsightsLocation": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "qnaMakerServiceName": { + "type": "string", + "defaultValue": "[concat(parameters('name'), '-qna')]" + }, + "qnaMakerServiceSku": { + "type": "string", + "defaultValue": "S0" + }, + "qnaMakerServiceLocation": { + "type": "string", + "defaultValue": "westus" + }, + "qnaMakerSearchName": { + "type": "string", + "defaultValue": "[concat(parameters('name'), '-search')]" + }, + "qnaMakerSearchSku": { + "type": "string", + "defaultValue": "standard" + }, + "qnaMakerSearchLocation": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "qnaMakerWebAppName": { + "type": "string", + "defaultValue": "[concat(parameters('name'), '-qnahost')]" + }, + "qnaMakerWebAppLocation": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "qnaMakerSearchName": "[toLower(replace(parameters('qnaMakerSearchName'), '_', ''))]", + "qnaMakerWebAppName": "[replace(parameters('qnaMakerWebAppName'), '_', '')]" + }, + "resources": [ + { + "apiVersion": "2018-02-01", + "name": "1d41002f-62a1-49f3-bd43-2f3f32a19cbb", + "type": "Microsoft.Resources/deployments", + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [] + } + } + }, + { + "comments": "Create a new App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "condition": "[not(variables('useExistingAppServicePlan'))]", + "name": "[variables('servicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "properties": { + "name": "[variables('servicePlanName')]" + } + }, + { + "comments": "app insights", + "type": "Microsoft.Insights/components", + "kind": "web", + "apiVersion": "2015-05-01", + "name": "[parameters('appInsightsName')]", + "location": "[parameters('appInsightsLocation')]", + "properties": { + "Application_Type": "web" + } + }, + { + "comments": "Cognitive service key for all QnA Maker knowledgebases.", + "type": "Microsoft.CognitiveServices/accounts", + "kind": "QnAMaker", + "apiVersion": "2017-04-18", + "name": "[parameters('qnaMakerServiceName')]", + "location": "[parameters('qnaMakerServiceLocation')]", + "sku": { + "name": "[parameters('qnaMakerServiceSku')]" + }, + "properties": { + "apiProperties": { + "qnaRuntimeEndpoint": "[concat('https://',reference(resourceId('Microsoft.Web/sites', variables('qnaMakerWebAppName'))).hostNames[0])]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/Sites', variables('qnaMakerWebAppName'))]", + "[resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName'))]", + "[resourceId('microsoft.insights/components/', parameters('appInsightsName'))]" + ] + }, + { + "comments": "Search service for QnA Maker service.", + "type": "Microsoft.Search/searchServices", + "apiVersion": "2015-08-19", + "name": "[variables('qnaMakerSearchName')]", + "location": "[parameters('qnaMakerSearchLocation')]", + "sku": { + "name": "[parameters('qnaMakerSearchSku')]" + }, + "properties": { + "replicaCount": 1, + "partitionCount": 1, + "hostingMode": "default" + } + }, + { + "comments": "Web app for QnA Maker service.", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('qnaMakerWebAppName')]", + "location": "[parameters('qnaMakerWebAppLocation')]", + "properties": { + "enabled": true, + "name": "[variables('qnaMakerWebAppName')]", + "hostingEnvironment": "", + "serverFarmId": "[concat('/subscriptions/', Subscription().SubscriptionId,'/resourcegroups/', resourceGroup().name, '/providers/Microsoft.Web/serverfarms/', variables('servicePlanName'))]", + "siteConfig": { + "cors": { + "allowedOrigins": [ + "*" + ] + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "resources": [ + { + "apiVersion": "2016-08-01", + "name": "appsettings", + "type": "config", + "dependsOn": [ + "[resourceId('Microsoft.Web/Sites', variables('qnaMakerWebAppName'))]", + "[resourceId('Microsoft.Insights/components', parameters('appInsightsName'))]", + "[resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName'))]" + ], + "properties": { + "AzureSearchName": "[variables('qnaMakerSearchName')]", + "AzureSearchAdminKey": "[listAdminKeys(resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName')), '2015-08-19').primaryKey]", + "UserAppInsightsKey": "[reference(resourceId('Microsoft.Insights/components/', parameters('appInsightsName')), '2015-05-01').InstrumentationKey]", + "UserAppInsightsName": "[parameters('appInsightsName')]", + "UserAppInsightsAppId": "[reference(resourceId('Microsoft.Insights/components/', parameters('appInsightsName')), '2015-05-01').AppId]", + "PrimaryEndpointKey": "[concat(variables('qnaMakerWebAppName'), '-PrimaryEndpointKey')]", + "SecondaryEndpointKey": "[concat(variables('qnaMakerWebAppName'), '-SecondaryEndpointKey')]", + "DefaultAnswer": "No good match found in KB.", + "QNAMAKER_EXTENSION_VERSION": "latest" + } + } + ] + } + ], + "outputs": { + "qna": { + "type": "object", + "value": { + "endpoint": "[concat('https://', reference(resourceId('Microsoft.Web/sites', variables('qnaMakerWebAppName'))).hostNames[0])]", + "subscriptionKey": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', parameters('qnaMakerServiceName')),'2017-04-18').key1]" + } + } + } + } + \ No newline at end of file diff --git a/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/template-with-preexisting-rg.json b/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/template-with-preexisting-rg.json index d385e94444..81c8531adc 100644 --- a/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/template-with-preexisting-rg.json +++ b/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/template-with-preexisting-rg.json @@ -34,10 +34,6 @@ "type": "bool", "defaultValue": true }, - "shouldCreateQnAResource": { - "type": "bool", - "defaultValue": true - }, "cosmosDbName": { "type": "string", "defaultValue": "[resourceGroup().name]" @@ -134,38 +130,6 @@ "luisServiceLocation": { "type": "string", "defaultValue": "[resourceGroup().location]" - }, - "qnaMakerServiceName": { - "type": "string", - "defaultValue": "[concat(parameters('name'), '-qna')]" - }, - "qnaMakerServiceSku": { - "type": "string", - "defaultValue": "S0" - }, - "qnaMakerServiceLocation": { - "type": "string", - "defaultValue": "westus" - }, - "qnaMakerSearchName": { - "type": "string", - "defaultValue": "[concat(parameters('name'), '-search')]" - }, - "qnaMakerSearchSku": { - "type": "string", - "defaultValue": "standard" - }, - "qnaMakerSearchLocation": { - "type": "string", - "defaultValue": "[resourceGroup().location]" - }, - "qnaMakerWebAppName": { - "type": "string", - "defaultValue": "[concat(parameters('name'), '-qnahost')]" - }, - "qnaMakerWebAppLocation": { - "type": "string", - "defaultValue": "[resourceGroup().location]" } }, "variables": { @@ -178,9 +142,7 @@ "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]", "storageAccountName": "[toLower(take(replace(replace(parameters('storageAccountName'), '-', ''), '_', ''), 24))]", - "LuisAuthoringAccountName": "[concat(parameters('luisServiceName'), '-Authoring')]", - "qnaMakerSearchName": "[toLower(replace(parameters('qnaMakerSearchName'), '_', ''))]", - "qnaMakerWebAppName": "[replace(parameters('qnaMakerWebAppName'), '_', '')]" + "LuisAuthoringAccountName": "[concat(parameters('luisServiceName'), '-Authoring')]" }, "resources": [ { @@ -388,92 +350,6 @@ "name": "[parameters('luisServiceRunTimeSku')]" }, "condition": "[parameters('shouldCreateLuisResource')]" - }, - { - "comments": "Cognitive service key for all QnA Maker knowledgebases.", - "type": "Microsoft.CognitiveServices/accounts", - "kind": "QnAMaker", - "apiVersion": "2017-04-18", - "name": "[parameters('qnaMakerServiceName')]", - "location": "[parameters('qnaMakerServiceLocation')]", - "sku": { - "name": "[parameters('qnaMakerServiceSku')]" - }, - "properties": { - "apiProperties": { - "qnaRuntimeEndpoint": "[concat('https://',reference(resourceId('Microsoft.Web/sites', variables('qnaMakerWebAppName'))).hostNames[0])]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/Sites', variables('qnaMakerWebAppName'))]", - "[resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName'))]", - "[resourceId('microsoft.insights/components/', parameters('appInsightsName'))]" - ], - "condition": "[parameters('shouldCreateQnAResource')]" - }, - { - "comments": "Search service for QnA Maker service.", - "type": "Microsoft.Search/searchServices", - "apiVersion": "2015-08-19", - "name": "[variables('qnaMakerSearchName')]", - "location": "[parameters('qnaMakerSearchLocation')]", - "sku": { - "name": "[parameters('qnaMakerSearchSku')]" - }, - "properties": { - "replicaCount": 1, - "partitionCount": 1, - "hostingMode": "default" - }, - "condition": "[parameters('shouldCreateQnAResource')]" - }, - { - "comments": "Web app for QnA Maker service.", - "type": "Microsoft.Web/sites", - "apiVersion": "2016-08-01", - "name": "[variables('qnaMakerWebAppName')]", - "location": "[parameters('qnaMakerWebAppLocation')]", - "properties": { - "enabled": true, - "name": "[variables('qnaMakerWebAppName')]", - "hostingEnvironment": "", - "serverFarmId": "[concat('/subscriptions/', Subscription().SubscriptionId,'/resourcegroups/', resourceGroup().name, '/providers/Microsoft.Web/serverfarms/', variables('servicePlanName'))]", - "siteConfig": { - "cors": { - "allowedOrigins": [ - "*" - ] - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" - ], - "condition": "[parameters('shouldCreateQnAResource')]", - "resources": [ - { - "apiVersion": "2016-08-01", - "name": "appsettings", - "type": "config", - "dependsOn": [ - "[resourceId('Microsoft.Web/Sites', variables('qnaMakerWebAppName'))]", - "[resourceId('Microsoft.Insights/components', parameters('appInsightsName'))]", - "[resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName'))]" - ], - "properties": { - "AzureSearchName": "[variables('qnaMakerSearchName')]", - "AzureSearchAdminKey": "[listAdminKeys(resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName')), '2015-08-19').primaryKey]", - "UserAppInsightsKey": "[reference(resourceId('Microsoft.Insights/components/', parameters('appInsightsName')), '2015-05-01').InstrumentationKey]", - "UserAppInsightsName": "[parameters('appInsightsName')]", - "UserAppInsightsAppId": "[reference(resourceId('Microsoft.Insights/components/', parameters('appInsightsName')), '2015-05-01').AppId]", - "PrimaryEndpointKey": "[concat(variables('qnaMakerWebAppName'), '-PrimaryEndpointKey')]", - "SecondaryEndpointKey": "[concat(variables('qnaMakerWebAppName'), '-SecondaryEndpointKey')]", - "DefaultAnswer": "No good match found in KB.", - "QNAMAKER_EXTENSION_VERSION": "latest" - }, - "condition": "[parameters('shouldCreateQnAResource')]" - } - ] } ], "outputs": { @@ -509,13 +385,6 @@ "endpoint": "[if(parameters('shouldCreateLuisResource'), reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('luisServiceName'))).endpoint, '')]", "authoringEndpoint": "[if(parameters('shouldCreateAuthoringResource'), reference(resourceId('Microsoft.CognitiveServices/accounts', variables('LuisAuthoringAccountName'))).endpoint, '')]" } - }, - "qna": { - "type": "object", - "value": { - "endpoint": "[if(parameters('shouldCreateQnAResource'), concat('https://', reference(resourceId('Microsoft.Web/sites', variables('qnaMakerWebAppName'))).hostNames[0]), '')]", - "subscriptionKey": "[if(parameters('shouldCreateQnAResource'), listKeys(resourceId('Microsoft.CognitiveServices/accounts', parameters('qnaMakerServiceName')),'2017-04-18').key1, '')]" - } } } } diff --git a/Composer/plugins/samples/assets/shared/scripts/provisionComposer.js b/Composer/plugins/samples/assets/shared/scripts/provisionComposer.js index 9e85606907..fbf4f2bdd9 100644 --- a/Composer/plugins/samples/assets/shared/scripts/provisionComposer.js +++ b/Composer/plugins/samples/assets/shared/scripts/provisionComposer.js @@ -43,6 +43,10 @@ const usage = () => { 'customArmTemplate', 'Path to runtime ARM template. By default it will use an Azure WebApp template. Pass `DeploymentTemplates/function-template-with-preexisting-rg.json` for Azure Functions or your own template for a custom deployment.', ], + [ + 'qnaTemplate', + 'Path to qna template. By default it will use `DeploymentTemplates/qna-template.json`' + ] ]; const instructions = [ @@ -53,11 +57,11 @@ const usage = () => { ``, chalk.bold(`Basic Usage:`), chalk.greenBright(`node provisionComposer --subscriptionId=`) + - chalk.yellow('') + - chalk.greenBright(' --name=') + - chalk.yellow('') + - chalk.greenBright(' --appPassword=') + - chalk.yellow('<16 character password>'), + chalk.yellow('') + + chalk.greenBright(' --name=') + + chalk.yellow('') + + chalk.greenBright(' --appPassword=') + + chalk.yellow('<16 character password>'), ``, chalk.bold(`All options:`), ...options.map((option) => { @@ -98,6 +102,8 @@ var tenantId = argv.tenantId ? argv.tenantId : ''; const templatePath = argv.customArmTemplate || path.join(__dirname, 'DeploymentTemplates', 'template-with-preexisting-rg.json'); +const qnaTemplatePath = + argv.qnaTemplate || path.join(__dirname, 'DeploymentTemplates', 'qna-template.json'); const BotProjectDeployLoggerType = { // Logger Type for Provision @@ -206,6 +212,18 @@ const getTenantId = async (accessToken) => { } }; +/** + * + * @param {*} appId the appId of application registration + * @param {*} appPwd the app password of application registration + * @param {*} location the locaiton of all resources + * @param {*} name the name of resource group + * @param {*} shouldCreateAuthoringResource + * @param {*} shouldCreateLuisResource + * @param {*} useAppInsights + * @param {*} useCosmosDb + * @param {*} useStorage + */ const getDeploymentTemplateParam = ( appId, appPwd, @@ -213,7 +231,6 @@ const getDeploymentTemplateParam = ( name, shouldCreateAuthoringResource, shouldCreateLuisResource, - shouldCreateQnAResource, useAppInsights, useCosmosDb, useStorage @@ -225,13 +242,65 @@ const getDeploymentTemplateParam = ( botId: pack(name), shouldCreateAuthoringResource: pack(shouldCreateAuthoringResource), shouldCreateLuisResource: pack(shouldCreateLuisResource), - shouldCreateQnAResource: pack(shouldCreateQnAResource), useAppInsights: pack(useAppInsights), useCosmosDb: pack(useCosmosDb), useStorage: pack(useStorage), }; }; +/** + * Get QnA template param + */ +const getQnaTemplateParam = ( + location, + name +) => { + return { + appServicePlanLocation: pack(location), + name: pack(name) + }; +}; + +/** + * Validate the qna template and the qna template param + */ +const validateQnADeployment = async (client, resourceGroupName, deployName, templateParam) => { + logger({ + status: BotProjectDeployLoggerType.PROVISION_INFO, + message: '> Validating QnA deployment ...', + }); + + const templateFile = await readFile(qnaTemplatePath, { encoding: 'utf-8' }); + const deployParam = { + properties: { + template: JSON.parse(templateFile), + parameters: templateParam, + mode: 'Incremental', + }, + }; + return await client.deployments.validate(resourceGroupName, deployName, deployParam); +}; + +/** + * Create a QnA resource deployment + * @param {*} client + * @param {*} resourceGroupName + * @param {*} deployName + * @param {*} templateParam + */ +const createQnADeployment = async (client, resourceGroupName, deployName, templateParam) => { + const templateFile = await readFile(qnaTemplatePath, { encoding: 'utf-8' }); + const deployParam = { + properties: { + template: JSON.parse(templateFile), + parameters: templateParam, + mode: 'Incremental', + }, + }; + + return await client.deployments.createOrUpdate(resourceGroupName, deployName, deployParam); +}; + /** * Validate the deployment using the Azure API */ @@ -347,6 +416,12 @@ const create = async ( createStorage = true, createAppInsights = true ) => { + + // App insights is a dependency of QnA + if (createQnAResource) { + createAppInsights = true; + } + // If tenantId is empty string, get tenanId from API if (!tenantId) { const token = await creds.getToken(); @@ -422,7 +497,6 @@ const create = async ( location, name, createLuisAuthoringResource, - createQnAResource, createLuisResource, createAppInsights, createCosmosDb, @@ -486,6 +560,75 @@ const create = async ( return provisionFailed(); } + var qnaResult = null; + + // Create qna resources, the reason why seperate the qna resources from others: https://github.com/Azure/azure-sdk-for-js/issues/10186 + if (createQnAResource) { + const qnaDeployName = new Date().getTime().toString(); + const qnaDeploymentTemplateParam = getQnaTemplateParam( + location, + name + ); + const qnaValidation = await validateQnADeployment(client, resourceGroupName, qnaDeployName, qnaDeploymentTemplateParam); + if (qnaValidation.error) { + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: `! Error: ${qnaValidation.error.message}`, + }); + if (qnaValidation.error.details) { + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR_DETAILS, + message: JSON.stringify(qnaValidation.error.details, null, 2), + }); + } + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: `+ To delete this resource group, run 'az group delete -g ${resourceGroupName} --no-wait'`, + }); + return provisionFailed(); + } + + // Create qna deloyment + logger({ + status: BotProjectDeployLoggerType.PROVISION_INFO, + message: `> Deploying QnA Resources (this could take a while)...`, + }); + const spinner = ora().start(); + try { + const qnaDeployment = await createQnADeployment(client, resourceGroupName, qnaDeployName, qnaDeploymentTemplateParam); + // Handle errors + if (qnaDeployment._response.status != 200) { + spinner.fail(); + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: `! QnA Template is not valid with provided parameters. Review the log for more information.`, + }); + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: `! Error: ${qnaValidation.error}`, + }); + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: `+ To delete this resource group, run 'az group delete -g ${resourceGroupName} --no-wait'`, + }); + return provisionFailed(); + } + } catch (err) { + spinner.fail(); + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: getErrorMesssage(err), + }); + return provisionFailed(); + } + + const qnaDeploymentOutput = await client.deployments.get(resourceGroupName, qnaDeployName); + if (qnaDeploymentOutput && qnaDeploymentOutput.properties && qnaDeploymentOutput.properties.outputs) { + const qnaOutputResult = qnaDeploymentOutput.properties.outputs; + qnaResult = unpackObject(qnaOutputResult); + } + } + // If application insights created, update the application insights settings in azure bot service if (createAppInsights) { logger({ @@ -574,10 +717,10 @@ const create = async ( if (failedOperations) { failedOperations.forEach((operation) => { switch ( - operation && - operation.properties && - operation.properties.statusMessage.error.code && - operation.properties.targetResource + operation && + operation.properties && + operation.properties.statusMessage.error.code && + operation.properties.targetResource ) { case 'MissingRegistrationForLocation': logger({ @@ -609,6 +752,14 @@ const create = async ( }); } } + + // Merge qna outputs with other resources' outputs + if (createQnAResource) { + if (qnaResult) { + Object.assign(updateResult, qnaResult); + } + } + return updateResult; }; From e6f5405e6d303b0cc6454c2eb7ff4c028f8c18b2 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Wed, 23 Sep 2020 14:14:25 -0700 Subject: [PATCH 3/7] fix: fix loading of extensions by removing sample-ui-plugin (#4251) * fix loading of extensions by removing sample-ui-plugin * remove builtin extension from manifest if not enabled --- Composer/packages/extension/src/manager/manager.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index 29c80a0ade..15fa510409 100644 --- a/Composer/packages/extension/src/manager/manager.ts +++ b/Composer/packages/extension/src/manager/manager.ts @@ -245,12 +245,15 @@ class ExtensionManager { const extensionInstallPath = path.dirname(fullPath); const packageJson = (await readJson(fullPath)) as PackageJSON; const isEnabled = packageJson?.composer && packageJson.composer.enabled !== false; + const metadata = getExtensionMetadata(extensionInstallPath, packageJson); if (packageJson && (isEnabled || packageJson.extendsComposer === true)) { - const metadata = getExtensionMetadata(extensionInstallPath, packageJson); this.manifest.updateExtensionConfig(packageJson.name, { ...metadata, builtIn: true, }); + } else if (this.manifest.getExtensionConfig(packageJson.name)) { + // remove the extension if it exists in the manifest + this.manifest.removeExtension(packageJson.name); } } } From b8d98efb3c6ae94a3d936a5baef81b9ba009ddf5 Mon Sep 17 00:00:00 2001 From: Ben Yackley <61990921+beyackle@users.noreply.github.com> Date: Wed, 23 Sep 2020 14:52:30 -0700 Subject: [PATCH 4/7] fix: update l10n file (#4247) --- .../packages/server/src/locales/en-US.json | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/Composer/packages/server/src/locales/en-US.json b/Composer/packages/server/src/locales/en-US.json index cc71cbef03..3365f9a962 100644 --- a/Composer/packages/server/src/locales/en-US.json +++ b/Composer/packages/server/src/locales/en-US.json @@ -197,9 +197,6 @@ "any_or_expression_acad7d37": { "message": "Any or expression" }, - "app_id_2f53f1f8": { - "message": "App Id" - }, "append_choices_35c45a2d": { "message": "Append choices" }, @@ -743,6 +740,9 @@ "duplicate_dialog_name_824f9fce": { "message": "Duplicate dialog name" }, + "duplicate_fields_9fd0d3c2": { + "message": "duplicate fields" + }, "duplicate_name_d295a09d": { "message": "Duplicate name" }, @@ -1301,6 +1301,9 @@ "manifest_url_30824e88": { "message": "Manifest url" }, + "manifest_url_can_not_be_accessed_a7f147b2": { + "message": "Manifest url can not be accessed" + }, "manifest_version_1edc004a": { "message": "Manifest Version" }, @@ -1346,6 +1349,9 @@ "missing_definition_for_defname_33f2b594": { "message": "Missing definition for { defName }" }, + "missing_fields_1c88ab71": { + "message": "missing fields" + }, "modification_rejected_6a6e8322": { "message": "Modification Rejected" }, @@ -1394,8 +1400,8 @@ "name_and_save_your_skill_manifest_cfd672b7": { "message": "Name and save your skill manifest." }, - "name_contains_invalid_charactors_d1780987": { - "message": "Name contains invalid charactors" + "name_cannot_include_special_characters_or_spaces_59a1950b": { + "message": "Name cannot include special characters or spaces" }, "name_copy_55d27c1a": { "message": "{ name }_Copy" @@ -1541,9 +1547,6 @@ "open_inline_editor_a5aabcfa": { "message": "Open inline editor" }, - "open_skills_page_for_configuration_details_a2a484ea": { - "message": "Open Skills page for configuration details" - }, "or_4f7d4edb": { "message": "Or: " }, @@ -1580,8 +1583,8 @@ "please_enter_an_event_name_a148275a": { "message": "Please enter an event name" }, - "please_input_a_manifest_url_ef6bab5f": { - "message": "Please input a manifest url" + "please_input_a_manifest_url_d726edbf": { + "message": "Please input a manifest Url" }, "please_input_regex_pattern_5cd659a2": { "message": "Please input regEx pattern" @@ -1601,6 +1604,9 @@ "please_select_a_trigger_type_67417abb": { "message": "Please select a trigger type" }, + "please_select_a_valid_endpoint_bf608af1": { + "message": "Please select a valid endpoint" + }, "please_select_a_version_of_the_manifest_schema_4a3efbb1": { "message": "Please select a version of the manifest schema" }, @@ -1742,8 +1748,8 @@ "redo_363c58b7": { "message": "Redo" }, - "redo_is_not_support_1595aaa4": { - "message": "Redo is not support" + "redo_is_not_supported_b743e4dc": { + "message": "Redo is not supported" }, "refer_to_the_syntax_documentation_here_df8dc9b4": { "message": "Refer to the syntax documentation here." @@ -1949,6 +1955,9 @@ "skill_host_endpoint_b1088d0": { "message": "Skill Host Endpoint" }, + "skill_manifest_endpoint_is_configured_improperly_e083731d": { + "message": "Skill manifest endpoint is configured improperly" + }, "skillname_manifest_ef3d9fed": { "message": "{ skillName } Manifest" }, @@ -2201,8 +2210,8 @@ "undo_a7be8fef": { "message": "Undo" }, - "undo_is_not_support_a27c5281": { - "message": "Undo is not support" + "undo_is_not_supported_ecd6f9fc": { + "message": "Undo is not supported" }, "unknown_intent_44b962ba": { "message": "Unknown intent" @@ -2396,4 +2405,4 @@ "your_bot_is_using_luis_and_qna_for_natural_languag_53830684": { "message": "Your bot is using LUIS and QNA for natural language understanding." } -} +} \ No newline at end of file From 388d4429fadbcbd59ab91885fff3c18ac05c5493 Mon Sep 17 00:00:00 2001 From: Long Alan Date: Thu, 24 Sep 2020 11:34:58 +0800 Subject: [PATCH 5/7] chore: extract build logic from components page (#4153) * add build util to package build logic * refactor * test fix * code refactor Co-authored-by: Dong Lei --- .../client/__tests__/utils/buildUtil.test.ts | 93 +++++++++++ .../client/__tests__/utils/luUtil.test.ts | 87 +--------- .../TestController/TestController.tsx | 52 ++---- .../src/recoilModel/dispatchers/builder.ts | 3 +- .../packages/client/src/utils/buildUtil.ts | 156 ++++++++++++++++++ Composer/packages/client/src/utils/luUtil.ts | 129 +-------------- 6 files changed, 266 insertions(+), 254 deletions(-) create mode 100644 Composer/packages/client/__tests__/utils/buildUtil.test.ts create mode 100644 Composer/packages/client/src/utils/buildUtil.ts diff --git a/Composer/packages/client/__tests__/utils/buildUtil.test.ts b/Composer/packages/client/__tests__/utils/buildUtil.test.ts new file mode 100644 index 0000000000..ce3e9fb67d --- /dev/null +++ b/Composer/packages/client/__tests__/utils/buildUtil.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DialogInfo, LuFile } from '@bfc/shared'; + +import { createCrossTrainConfig } from '../../src/utils/buildUtil'; + +describe('createCrossTrainConfig', () => { + it('should create crosstrain config', () => { + const dialogs = [ + { + id: 'main', + luFile: 'main', + isRoot: true, + intentTriggers: [ + { intent: 'dia1_trigger', dialogs: ['dia1'] }, + { intent: 'dia2_trigger', dialogs: ['dia2'] }, + { intent: 'dias_trigger', dialogs: ['dia5', 'dia6'] }, + { intent: 'no_dialog', dialogs: [] }, + { intent: '', dialogs: ['start_dialog_without_intent'] }, + ], + }, + { + id: 'dia1', + luFile: 'dia1', + intentTriggers: [ + { intent: 'dia3_trigger', dialogs: ['dia3'] }, + { intent: 'dia4_trigger', dialogs: ['dia4'] }, + ], + }, + { + id: 'dia2', + luFile: 'dia2', + intentTriggers: [], + }, + { + id: 'dia3', + luFile: 'dia3', + intentTriggers: [], + }, + { + id: 'dia4', + luFile: 'dia4', + intentTriggers: [], + }, + { + id: 'dia5', + luFile: 'dia5', + intentTriggers: [], + }, + { + id: 'dia6', + luFile: 'dia6', + intentTriggers: [], + }, + { + id: 'start_dialog_without_intent', + luFile: 'start_dialog_without_intent', + intentTriggers: [], + }, + { + id: 'dialog_without_lu', + intentTriggers: [], + }, + ]; + const luFiles = [ + { + id: 'main.en-us', + intents: [ + { Name: 'dia1_trigger' }, + { Name: 'dia2_trigger' }, + { Name: 'dias_trigger' }, + { Name: 'no_dialog' }, + { Name: 'dialog_without_lu' }, + ], + }, + { id: 'dia1.en-us', intents: [{ Name: 'dia3_trigger' }, { Name: 'dia4_trigger' }] }, + { id: 'dia2.en-us' }, + { id: 'dia3.en-us' }, + { id: 'dia5.en-us' }, + { id: 'dia6.en-us' }, + ]; + const config = createCrossTrainConfig(dialogs as DialogInfo[], luFiles as LuFile[]); + expect(config.rootIds.length).toEqual(1); + expect(config.rootIds[0]).toEqual('main.en-us.lu'); + expect(config.triggerRules['main.en-us.lu'].dia1_trigger).toEqual('dia1.en-us.lu'); + expect(config.triggerRules['main.en-us.lu'].no_dialog).toEqual(''); + expect(config.triggerRules['main.en-us.lu'].dia1_trigger).toEqual('dia1.en-us.lu'); + expect(config.triggerRules['main.en-us.lu'].dias_trigger.length).toBe(2); + expect(config.triggerRules['dia1.en-us.lu'].dia3_trigger).toEqual('dia3.en-us.lu'); + expect(config.triggerRules['dia1.en-us.lu']['dia4.en-us.lu']).toBeUndefined(); + }); +}); diff --git a/Composer/packages/client/__tests__/utils/luUtil.test.ts b/Composer/packages/client/__tests__/utils/luUtil.test.ts index 58f27d7175..47301a5e16 100644 --- a/Composer/packages/client/__tests__/utils/luUtil.test.ts +++ b/Composer/packages/client/__tests__/utils/luUtil.test.ts @@ -3,7 +3,7 @@ import { LuFile, DialogInfo, Diagnostic, DiagnosticSeverity } from '@bfc/shared'; -import { getReferredLuFiles, createCrossTrainConfig, checkLuisBuild } from '../../src/utils/luUtil'; +import { getReferredLuFiles, checkLuisBuild } from '../../src/utils/luUtil'; describe('getReferredLuFiles', () => { it('returns referred luFiles from dialog', () => { @@ -13,91 +13,6 @@ describe('getReferredLuFiles', () => { expect(referred.length).toEqual(1); expect(referred[0].id).toEqual('a.en-us'); }); - - it('should create crosstrain config', () => { - const dialogs = [ - { - id: 'main', - luFile: 'main', - isRoot: true, - intentTriggers: [ - { intent: 'dia1_trigger', dialogs: ['dia1'] }, - { intent: 'dia2_trigger', dialogs: ['dia2'] }, - { intent: 'dias_trigger', dialogs: ['dia5', 'dia6'] }, - { intent: 'no_dialog', dialogs: [] }, - { intent: '', dialogs: ['start_dialog_without_intent'] }, - ], - }, - { - id: 'dia1', - luFile: 'dia1', - intentTriggers: [ - { intent: 'dia3_trigger', dialogs: ['dia3'] }, - { intent: 'dia4_trigger', dialogs: ['dia4'] }, - ], - }, - { - id: 'dia2', - luFile: 'dia2', - intentTriggers: [], - }, - { - id: 'dia3', - luFile: 'dia3', - intentTriggers: [], - }, - { - id: 'dia4', - luFile: 'dia4', - intentTriggers: [], - }, - { - id: 'dia5', - luFile: 'dia5', - intentTriggers: [], - }, - { - id: 'dia6', - luFile: 'dia6', - intentTriggers: [], - }, - { - id: 'start_dialog_without_intent', - luFile: 'start_dialog_without_intent', - intentTriggers: [], - }, - { - id: 'dialog_without_lu', - intentTriggers: [], - }, - ]; - const luFiles = [ - { - id: 'main.en-us', - intents: [ - { Name: 'dia1_trigger' }, - { Name: 'dia2_trigger' }, - { Name: 'dias_trigger' }, - { Name: 'no_dialog' }, - { Name: 'dialog_without_lu' }, - ], - }, - { id: 'dia1.en-us', intents: [{ Name: 'dia3_trigger' }, { Name: 'dia4_trigger' }] }, - { id: 'dia2.en-us' }, - { id: 'dia3.en-us' }, - { id: 'dia5.en-us' }, - { id: 'dia6.en-us' }, - ]; - const config = createCrossTrainConfig(dialogs as DialogInfo[], luFiles as LuFile[]); - expect(config.rootIds.length).toEqual(1); - expect(config.rootIds[0]).toEqual('main.en-us.lu'); - expect(config.triggerRules['main.en-us.lu'].dia1_trigger).toEqual('dia1.en-us.lu'); - expect(config.triggerRules['main.en-us.lu'].no_dialog).toEqual(''); - expect(config.triggerRules['main.en-us.lu'].dia1_trigger).toEqual('dia1.en-us.lu'); - expect(config.triggerRules['main.en-us.lu'].dias_trigger.length).toBe(2); - expect(config.triggerRules['dia1.en-us.lu'].dia3_trigger).toEqual('dia3.en-us.lu'); - expect(config.triggerRules['dia1.en-us.lu']['dia4.en-us.lu']).toBeUndefined(); - }); }); it('check the lu files before publish', () => { diff --git a/Composer/packages/client/src/components/TestController/TestController.tsx b/Composer/packages/client/src/components/TestController/TestController.tsx index 0c7f4ed829..1866dd6d0f 100644 --- a/Composer/packages/client/src/components/TestController/TestController.tsx +++ b/Composer/packages/client/src/components/TestController/TestController.tsx @@ -22,13 +22,12 @@ import { botLoadErrorState, } from '../../recoilModel'; import settingsStorage from '../../utils/dialogSettingStorage'; -import { QnaConfig, BotStatus, LuisConfig } from '../../constants'; +import { BotStatus } from '../../constants'; import { isAbsHosted } from '../../utils/envUtil'; import useNotifications from '../../pages/notifications/useNotifications'; import { navigateTo, openInEmulator } from '../../utils/navigation'; -import { getReferredQnaFiles } from '../../utils/qnaUtil'; -import { getReferredLuFiles } from './../../utils/luUtil'; +import { isBuildConfigComplete, needsBuild } from './../../utils/buildUtil'; import { PublishDialog } from './publishDialog'; import { ErrorCallout } from './errorCallout'; import { EmulatorOpenButton } from './emulatorOpenButton'; @@ -153,15 +152,14 @@ export const TestController: React.FC<{ projectId: string }> = (props) => { } } - async function handlePublish(config: IPublishConfig) { + async function handleBuild(config: IPublishConfig) { setBotStatus(BotStatus.publishing, projectId); dismissDialog(); const { luis, qna } = config; - const endpointKey = settings.qna?.endpointKey; await setSettings(projectId, { ...settings, luis: luis, - qna: Object.assign({}, settings.qna, qna, { endpointKey }), + qna: Object.assign({}, settings.qna, qna), }); await build(luis, qna, projectId); } @@ -175,48 +173,24 @@ export const TestController: React.FC<{ projectId: string }> = (props) => { await publishToTarget(projectId, defaultPublishConfig, { comment: '' }, sensitiveSettings); } - function isConfigComplete(config) { - let complete = true; - if (getReferredLuFiles(luFiles, dialogs).length > 0) { - if (Object.values(LuisConfig).some((luisConfigKey) => config.luis[luisConfigKey] === '')) { - complete = false; - } - } - if (getReferredQnaFiles(qnaFiles, dialogs).length > 0) { - if (Object.values(QnaConfig).some((qnaConfigKey) => config.qna[qnaConfigKey] === '')) { - complete = false; - } - } - return complete; - } - - // return true if dialogs have one with default recognizer. - function needsPublish(dialogs) { - let isDefaultRecognizer = false; - if (dialogs.some((dialog) => typeof dialog.content.recognizer === 'string')) { - isDefaultRecognizer = true; - } - return isDefaultRecognizer; - } - async function handleStart() { dismissCallout(); const config = Object.assign( {}, { luis: settings.luis, - qna: { - subscriptionKey: settings.qna?.subscriptionKey, - qnaRegion: settings.qna?.qnaRegion, - endpointKey: settings.qna?.endpointKey, - }, + qna: settings.qna, } ); - if (!isAbsHosted() && needsPublish(dialogs)) { - if (botStatus === BotStatus.failed || botStatus === BotStatus.pending || !isConfigComplete(config)) { + if (!isAbsHosted() && needsBuild(dialogs)) { + if ( + botStatus === BotStatus.failed || + botStatus === BotStatus.pending || + !isBuildConfigComplete(config, dialogs, luFiles, qnaFiles) + ) { openDialog(); } else { - await handlePublish(config); + await handleBuild(config); } } else { await handleLoadBot(); @@ -278,7 +252,7 @@ export const TestController: React.FC<{ projectId: string }> = (props) => { isOpen={modalOpen} projectId={projectId} onDismiss={dismissDialog} - onPublish={handlePublish} + onPublish={handleBuild} /> )} diff --git a/Composer/packages/client/src/recoilModel/dispatchers/builder.ts b/Composer/packages/client/src/recoilModel/dispatchers/builder.ts index 4bf6302f6f..81c92cca33 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/builder.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/builder.ts @@ -6,6 +6,7 @@ import { useRecoilCallback, CallbackInterface } from 'recoil'; import { ILuisConfig, IQnAConfig } from '@bfc/shared'; import * as luUtil from '../../utils/luUtil'; +import * as buildUtil from '../../utils/buildUtil'; import { Text, BotStatus } from '../../constants'; import httpClient from '../../utils/httpUtil'; import luFileStatusStorage from '../../utils/luFileStatusStorage'; @@ -48,7 +49,7 @@ export const builderDispatcher = () => { } try { //TODO crosstrain should add locale - const crossTrainConfig = luUtil.createCrossTrainConfig(dialogs, referredLuFiles); + const crossTrainConfig = buildUtil.createCrossTrainConfig(dialogs, referredLuFiles); await httpClient.post(`/projects/${projectId}/build`, { luisConfig, qnaConfig, diff --git a/Composer/packages/client/src/utils/buildUtil.ts b/Composer/packages/client/src/utils/buildUtil.ts new file mode 100644 index 0000000000..29958f92e0 --- /dev/null +++ b/Composer/packages/client/src/utils/buildUtil.ts @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DialogInfo, LuFile } from '@bfc/shared'; +import keys from 'lodash/keys'; + +import { LuisConfig, QnaConfig } from '../constants'; + +import { getReferredLuFiles } from './luUtil'; +import { getReferredQnaFiles } from './qnaUtil'; +import { getBaseName, getExtension } from './fileUtil'; + +function createConfigId(fileId) { + return `${fileId}.lu`; +} + +function getLuFilesByDialogId(dialogId: string, luFiles: LuFile[]) { + return luFiles.filter((lu) => getBaseName(lu.id) === dialogId).map((lu) => createConfigId(lu.id)); +} + +function getFileLocale(fileName: string) { + //file name = 'a.en-us.lu' + return getExtension(getBaseName(fileName)); +} +//replace the dialogId with luFile's name +function addLocaleToConfig(config: ICrossTrainConfig, luFiles: LuFile[]) { + const { rootIds, triggerRules } = config; + config.rootIds = rootIds.reduce((result: string[], id: string) => { + return [...result, ...getLuFilesByDialogId(id, luFiles)]; + }, []); + config.triggerRules = keys(triggerRules).reduce((result, key) => { + const fileNames = getLuFilesByDialogId(key, luFiles); + return { + ...result, + ...fileNames.reduce((result, name) => { + const locale = getFileLocale(name); + const triggers = triggerRules[key]; + keys(triggers).forEach((trigger) => { + if (!result[name]) result[name] = {}; + const ids = triggers[trigger]; + if (Array.isArray(ids)) { + result[name][trigger] = ids.map((id) => (id ? `${id}.${locale}.lu` : id)); + } else { + result[name][trigger] = ids ? `${ids}.${locale}.lu` : ids; + } + }); + return result; + }, {}), + }; + }, {}); + return config; +} +interface ICrossTrainConfig { + rootIds: string[]; + triggerRules: { [key: string]: any }; + intentName: string; + verbose: boolean; +} + +//generate the cross-train config without locale +/* the config is like + { + rootIds: [ + 'main.en-us.lu', + 'main.fr-fr.lu' + ], + triggerRules: { + 'main.en-us.lu': { + 'dia1_trigger': 'dia1.en-us.lu', + 'dia2_trigger': 'dia2.en-us.lu' + }, + 'dia2.en-us.lu': { + 'dia3_trigger': 'dia3.en-us.lu', + 'dia4_trigger': 'dia4.en-us.lu' + }, + 'main.fr-fr.lu': { + 'dia1_trigger': 'dia1.fr-fr.lu' + } + }, + intentName: '_Interruption', + verbose: true + } + */ + +export function createCrossTrainConfig(dialogs: DialogInfo[], luFiles: LuFile[]): ICrossTrainConfig { + const triggerRules = {}; + const countMap = {}; + + //map all referred lu files + luFiles.forEach((file) => { + countMap[getBaseName(file.id)] = 1; + }); + + let rootId = ''; + dialogs.forEach((dialog) => { + if (dialog.isRoot) rootId = dialog.id; + const luFile = luFiles.find((luFile) => getBaseName(luFile.id) === dialog.luFile); + if (luFile) { + const fileId = dialog.id; + const { intentTriggers } = dialog; + // filter intenttrigger which be involved in lu file + //find the trigger's dialog that use a recognizer + intentTriggers + .filter((intentTrigger) => luFile.intents.find((intent) => intent.Name === intentTrigger.intent)) + .forEach((item) => { + //find all dialogs in trigger that has a luis recognizer + const used = item.dialogs.filter((dialog) => !!countMap[dialog]); + + const deduped = Array.from(new Set(used)); + + const result = {}; + if (deduped.length === 1) { + result[item.intent] = deduped[0]; + } else if (deduped.length) { + result[item.intent] = deduped; + } else { + result[item.intent] = ''; + } + + triggerRules[fileId] = { ...triggerRules[fileId], ...result }; + }); + } + }); + + const crossTrainConfig: ICrossTrainConfig = { + rootIds: [], + triggerRules: {}, + intentName: '_Interruption', + verbose: true, + }; + crossTrainConfig.rootIds = keys(countMap).filter( + (key) => (countMap[key] === 0 || key === rootId) && triggerRules[key] + ); + crossTrainConfig.triggerRules = triggerRules; + return addLocaleToConfig(crossTrainConfig, luFiles); +} + +export function isBuildConfigComplete(config, dialogs, luFiles, qnaFiles) { + let complete = true; + if (getReferredLuFiles(luFiles, dialogs).length > 0) { + if (Object.values(LuisConfig).some((luisConfigKey) => config.luis[luisConfigKey] === '')) { + complete = false; + } + } + if (getReferredQnaFiles(qnaFiles, dialogs).length > 0) { + if (Object.values(QnaConfig).some((qnaConfigKey) => config.qna[qnaConfigKey] === '')) { + complete = false; + } + } + return complete; +} + +// return true if dialogs have one with default recognizer. +export function needsBuild(dialogs) { + return dialogs.some((dialog) => typeof dialog.content.recognizer === 'string'); +} diff --git a/Composer/packages/client/src/utils/luUtil.ts b/Composer/packages/client/src/utils/luUtil.ts index 376c54a6b0..787171513e 100644 --- a/Composer/packages/client/src/utils/luUtil.ts +++ b/Composer/packages/client/src/utils/luUtil.ts @@ -6,12 +6,11 @@ * it's designed have no state, input text file, output text file. * for more usage detail, please check client/__tests__/utils/luUtil.test.ts */ -import keys from 'lodash/keys'; import { createSingleMessage, BotIndexer } from '@bfc/indexers'; import { LuFile, DialogInfo, DiagnosticSeverity } from '@bfc/shared'; import formatMessage from 'format-message'; -import { getBaseName, getExtension } from './fileUtil'; +import { getBaseName } from './fileUtil'; export * from '@bfc/indexers/lib/utils/luUtil'; @@ -26,132 +25,6 @@ export function getReferredLuFiles(luFiles: LuFile[], dialogs: DialogInfo[], che }); } -function createConfigId(fileId) { - return `${fileId}.lu`; -} - -function getLuFilesByDialogId(dialogId: string, luFiles: LuFile[]) { - return luFiles.filter((lu) => getBaseName(lu.id) === dialogId).map((lu) => createConfigId(lu.id)); -} - -function getFileLocale(fileName: string) { - //file name = 'a.en-us.lu' - return getExtension(getBaseName(fileName)); -} - -//replace the dialogId with luFile's name -function addLocaleToConfig(config: ICrossTrainConfig, luFiles: LuFile[]) { - const { rootIds, triggerRules } = config; - config.rootIds = rootIds.reduce((result: string[], id: string) => { - return [...result, ...getLuFilesByDialogId(id, luFiles)]; - }, []); - config.triggerRules = keys(triggerRules).reduce((result, key) => { - const fileNames = getLuFilesByDialogId(key, luFiles); - return { - ...result, - ...fileNames.reduce((result, name) => { - const locale = getFileLocale(name); - const triggers = triggerRules[key]; - keys(triggers).forEach((trigger) => { - if (!result[name]) result[name] = {}; - const ids = triggers[trigger]; - if (Array.isArray(ids)) { - result[name][trigger] = ids.map((id) => (id ? `${id}.${locale}.lu` : id)); - } else { - result[name][trigger] = ids ? `${ids}.${locale}.lu` : ids; - } - }); - return result; - }, {}), - }; - }, {}); - return config; -} - -interface ICrossTrainConfig { - rootIds: string[]; - triggerRules: { [key: string]: any }; - intentName: string; - verbose: boolean; -} - -//generate the cross-train config without locale -/* the config is like - { - rootIds: [ - 'main.en-us.lu', - 'main.fr-fr.lu' - ], - triggerRules: { - 'main.en-us.lu': { - 'dia1_trigger': 'dia1.en-us.lu', - 'dia2_trigger': 'dia2.en-us.lu' - }, - 'dia2.en-us.lu': { - 'dia3_trigger': 'dia3.en-us.lu', - 'dia4_trigger': 'dia4.en-us.lu' - }, - 'main.fr-fr.lu': { - 'dia1_trigger': 'dia1.fr-fr.lu' - } - }, - intentName: '_Interruption', - verbose: true - } - */ -export function createCrossTrainConfig(dialogs: DialogInfo[], luFiles: LuFile[]): ICrossTrainConfig { - const triggerRules = {}; - const countMap = {}; - - //map all referred lu files - luFiles.forEach((file) => { - countMap[getBaseName(file.id)] = 1; - }); - - let rootId = ''; - dialogs.forEach((dialog) => { - if (dialog.isRoot) rootId = dialog.id; - const luFile = luFiles.find((luFile) => getBaseName(luFile.id) === dialog.luFile); - if (luFile) { - const fileId = dialog.id; - const { intentTriggers } = dialog; - // filter intenttrigger which be involved in lu file - //find the trigger's dialog that use a recognizer - intentTriggers - .filter((intentTrigger) => luFile.intents.find((intent) => intent.Name === intentTrigger.intent)) - .forEach((item) => { - //find all dialogs in trigger that has a luis recognizer - const used = item.dialogs.filter((dialog) => !!countMap[dialog]); - - const deduped = Array.from(new Set(used)); - - const result = {}; - if (deduped.length === 1) { - result[item.intent] = deduped[0]; - } else if (deduped.length) { - result[item.intent] = deduped; - } else { - result[item.intent] = ''; - } - - triggerRules[fileId] = { ...triggerRules[fileId], ...result }; - }); - } - }); - - const crossTrainConfig: ICrossTrainConfig = { - rootIds: [], - triggerRules: {}, - intentName: '_Interruption', - verbose: true, - }; - crossTrainConfig.rootIds = keys(countMap).filter( - (key) => (countMap[key] === 0 || key === rootId) && triggerRules[key] - ); - crossTrainConfig.triggerRules = triggerRules; - return addLocaleToConfig(crossTrainConfig, luFiles); -} - function generateErrorMessage(invalidLuFile: LuFile[]) { return invalidLuFile.reduce((msg, file) => { const fileErrorText = file.diagnostics.reduce((text, diagnostic) => { From eb9e10a44aa798574ea355dd4782dfa8f7a5f779 Mon Sep 17 00:00:00 2001 From: liweitian Date: Thu, 24 Sep 2020 23:28:45 +0800 Subject: [PATCH 6/7] fix: allows spaces in bot project path (#4260) --- Composer/packages/client/src/recoilModel/dispatchers/storage.ts | 2 +- Composer/packages/server/src/controllers/storage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Composer/packages/client/src/recoilModel/dispatchers/storage.ts b/Composer/packages/client/src/recoilModel/dispatchers/storage.ts index 6991a6b1f7..39a36ec702 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/storage.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/storage.ts @@ -76,7 +76,7 @@ export const storageDispatcher = () => { (callbackHelpers: CallbackInterface) => async (id: string, path: string) => { const { set } = callbackHelpers; try { - const response = await httpClient.get(`/storages/${id}/blobs`, { params: { path } }); + const response = await httpClient.get(`/storages/${id}/blobs`, { params: { path: encodeURIComponent(path) } }); const fetchedFocusStorage = response.data; fetchedFocusStorage.children = fetchedFocusStorage.children.reduce((files, file) => { if (file.type === FileTypes.FOLDER) { diff --git a/Composer/packages/server/src/controllers/storage.ts b/Composer/packages/server/src/controllers/storage.ts index ac2dece498..fe3375a597 100644 --- a/Composer/packages/server/src/controllers/storage.ts +++ b/Composer/packages/server/src/controllers/storage.ts @@ -51,7 +51,7 @@ async function getBlob(req: Request, res: Response) { if (!req.query.path) { throw new Error('path missing from query'); } - const reqpath = decodeURI(req.query.path); + const reqpath = decodeURIComponent(req.query.path); if (!Path.isAbsolute(reqpath)) { throw new Error('path must be absolute'); } From ad907067829188995a740e6a67f04bbc85c17122 Mon Sep 17 00:00:00 2001 From: LouisEugeneMSFT <66701106+LouisEugeneMSFT@users.noreply.github.com> Date: Thu, 24 Sep 2020 10:31:50 -0700 Subject: [PATCH 7/7] fix: Object examples not properly displayed as placeholders (#4126) * stringifying object examples * adding test * removing format message Co-authored-by: Andy Brown --- .../src/utils/__tests__/uiOptionsHelpers.test.ts | 8 +++++++- .../packages/adaptive-form/src/utils/uiOptionsHelpers.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Composer/packages/adaptive-form/src/utils/__tests__/uiOptionsHelpers.test.ts b/Composer/packages/adaptive-form/src/utils/__tests__/uiOptionsHelpers.test.ts index 5fc2b71b7d..b4eceda30e 100644 --- a/Composer/packages/adaptive-form/src/utils/__tests__/uiOptionsHelpers.test.ts +++ b/Composer/packages/adaptive-form/src/utils/__tests__/uiOptionsHelpers.test.ts @@ -3,7 +3,7 @@ import { FieldProps, UIOptions } from '@bfc/extension-client'; -import { getUiLabel, getUiDescription, getUiPlaceholder } from '../uiOptionsHelpers'; +import { getUiDescription, getUiLabel, getUiPlaceholder } from '../uiOptionsHelpers'; let props; @@ -90,4 +90,10 @@ describe('getUiPlaceholder', () => { 'ex. one, two' ); }); + + it('correctly display examples for non string types', () => { + expect( + getUiPlaceholder({ ...props, placeholder: undefined, schema: { examples: [true, 5, { arg1: 'test' }] } }) + ).toEqual('ex. true, 5, {"arg1":"test"}'); + }); }); diff --git a/Composer/packages/adaptive-form/src/utils/uiOptionsHelpers.ts b/Composer/packages/adaptive-form/src/utils/uiOptionsHelpers.ts index 689ebcea7f..c1461c80ab 100644 --- a/Composer/packages/adaptive-form/src/utils/uiOptionsHelpers.ts +++ b/Composer/packages/adaptive-form/src/utils/uiOptionsHelpers.ts @@ -3,7 +3,6 @@ import { FieldProps } from '@bfc/extension-client'; import startCase from 'lodash/startCase'; -import formatMessage from 'format-message'; export function getUiLabel(props: FieldProps): string | false | undefined { const { uiOptions, schema, name, value, label } = props; @@ -47,7 +46,13 @@ export function getUiPlaceholder(props: FieldProps): string | undefined { } else if (placeholder) { fieldUIPlaceholder = placeholder; } else if (schema && Array.isArray(schema.examples) && schema.examples.length > 0) { - fieldUIPlaceholder = formatMessage('ex. { example }', { example: schema.examples.join(', ') }); + const examplesStrings = schema.examples.map((example) => { + if (typeof example === 'object') { + return JSON.stringify(example); + } + return example; + }); + fieldUIPlaceholder = `ex. ${examplesStrings.join(', ')}`; } if (fieldUIPlaceholder && schema.pattern) {