diff --git a/Composer/cypress/integration/LuisDeploy.spec.ts b/Composer/cypress/integration/LuisDeploy.spec.ts index fac2abfa85..1c79844e86 100644 --- a/Composer/cypress/integration/LuisDeploy.spec.ts +++ b/Composer/cypress/integration/LuisDeploy.spec.ts @@ -16,7 +16,7 @@ context('Luis Deploy', () => { cy.url().should('contain', 'language-understanding/all'); cy.route({ method: 'POST', - url: 'api/projects/*/luFiles/publish', + url: 'api/projects/*/build', status: 200, response: 'fixture:luPublish/success', }); @@ -33,7 +33,7 @@ context('Luis Deploy', () => { cy.route({ method: 'POST', - url: 'api/projects/*/luFiles/publish', + url: 'api/projects/*/build', status: 400, response: 'fixture:luPublish/error', }); diff --git a/Composer/cypress/integration/ToDoBot.spec.ts b/Composer/cypress/integration/ToDoBot.spec.ts index 749e6dda3f..6683a52ea8 100644 --- a/Composer/cypress/integration/ToDoBot.spec.ts +++ b/Composer/cypress/integration/ToDoBot.spec.ts @@ -2,9 +2,11 @@ // Licensed under the MIT License. context('ToDo Bot', () => { - beforeEach(() => { + before(() => { cy.visit('/home'); cy.createBot('TodoSample'); + cy.findByTestId('WelcomeModalCloseIcon').click(); + cy.findByText('Yes').click(); }); it('can open the main dialog', () => { @@ -19,7 +21,6 @@ context('ToDo Bot', () => { it('can open the AddToDo dialog', () => { cy.findByTestId('ProjectTree').within(() => { cy.findByText('addtodo').click(); - cy.findByText('addtodo').click(); }); cy.url().should('contain', 'addtodo'); @@ -28,7 +29,6 @@ context('ToDo Bot', () => { it('can open the ClearToDos dialog', () => { cy.findByTestId('ProjectTree').within(() => { cy.findByText('cleartodos').click(); - cy.findByText('cleartodos').click(); }); cy.url().should('contain', 'cleartodos'); @@ -37,7 +37,6 @@ context('ToDo Bot', () => { it('can open the DeleteToDo dialog', () => { cy.findByTestId('ProjectTree').within(() => { cy.findByText('deletetodo').click(); - cy.findByText('deletetodo').click(); }); cy.url().should('contain', 'deletetodo'); @@ -46,7 +45,6 @@ context('ToDo Bot', () => { it('can open the ShowToDos dialog', () => { cy.findByTestId('ProjectTree').within(() => { cy.findByText('showtodos').click(); - cy.findByText('showtodos').click(); }); cy.url().should('contain', 'showtodos'); diff --git a/Composer/packages/client/__tests__/components/TestController/publish-luis-modal.test.tsx b/Composer/packages/client/__tests__/components/TestController/publish-luis-modal.test.tsx index dd01c4bcd5..fbd5cd945c 100644 --- a/Composer/packages/client/__tests__/components/TestController/publish-luis-modal.test.tsx +++ b/Composer/packages/client/__tests__/components/TestController/publish-luis-modal.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { fireEvent } from '@bfc/test-utils'; -import { PublishLuisDialog } from '../../../src/components/TestController/publishDialog'; +import { PublishDialog } from '../../../src/components/TestController/publishDialog'; import { projectIdState, botNameState, settingsState, dispatcherState } from '../../../src/recoilModel'; import { renderWithRecoil } from '../../testUtils'; jest.useFakeTimers(); @@ -18,8 +18,10 @@ const luisConfig = { defaultLanguage: 'en-us', environment: 'composer', }; -describe('', () => { - it('should render the ', () => { +const config = { subscriptionKey: '12345', qnaRegion: 'westus', ...luisConfig }; +const qnaConfig = { subscriptionKey: '12345', endpointKey: '12345', qnaRegion: 'westus' }; +describe('', () => { + it('should render the ', () => { const onDismiss = jest.fn(() => {}); const onPublish = jest.fn(() => {}); const setSettingsMock = jest.fn(() => {}); @@ -31,16 +33,11 @@ describe('', () => { set(botNameState, 'sampleBot0'); set(settingsState, { luis: luisConfig, + qna: qnaConfig, }); }; const { getByText } = renderWithRecoil( - , + , recoilInitState ); @@ -50,14 +47,21 @@ describe('', () => { fireEvent.click(publishButton); expect(onPublish).toBeCalled(); expect(onPublish).toBeCalledWith({ - name: 'sampleBot0', - authoringKey: '12345', - authoringEndpoint: 'testAuthoringEndpoint', - endpointKey: '12345', - endpoint: 'testEndpoint', - authoringRegion: 'westus', - defaultLanguage: 'en-us', - environment: 'composer', + luis: { + name: 'sampleBot0', + authoringKey: '12345', + authoringEndpoint: 'testAuthoringEndpoint', + endpointKey: '12345', + endpoint: 'testEndpoint', + authoringRegion: 'westus', + defaultLanguage: 'en-us', + environment: 'composer', + }, + qna: { + subscriptionKey: '12345', + endpointKey: '', + qnaRegion: 'westus', + }, }); }); }); diff --git a/Composer/packages/client/__tests__/components/skill.test.tsx b/Composer/packages/client/__tests__/components/skill.test.tsx index d253391717..dec5633a25 100644 --- a/Composer/packages/client/__tests__/components/skill.test.tsx +++ b/Composer/packages/client/__tests__/components/skill.test.tsx @@ -51,6 +51,11 @@ const recoilInitState = ({ set }) => { defaultLanguage: 'en-us', environment: 'composer', }, + qna: { + subscriptionKey: '12345', + qnaRegion: 'westus', + endpointKey: '', + }, }); }; diff --git a/Composer/packages/client/__tests__/components/triggerCreationModal.test.tsx b/Composer/packages/client/__tests__/components/triggerCreationModal.test.tsx new file mode 100644 index 0000000000..aa6a82e8c1 --- /dev/null +++ b/Composer/packages/client/__tests__/components/triggerCreationModal.test.tsx @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { fireEvent, waitFor } from '@bfc/test-utils'; + +import { TriggerCreationModal } from '../../src/components/ProjectTree/TriggerCreationModal'; +import { renderWithRecoil } from '../testUtils'; + +describe('', () => { + const onSubmitMock = jest.fn(); + const onDismissMock = jest.fn(); + + function renderComponent() { + return renderWithRecoil( + + ); + } + + it('should render the component', () => { + const component = renderComponent(); + expect(component.container).toBeDefined(); + }); + + it('hould create a Luis Intent recognized', async () => { + const component = renderComponent(); + const triggerType = component.getByTestId('triggerTypeDropDown'); + fireEvent.click(triggerType); + + const luisOption = component.getByTitle('Intent recognized'); + fireEvent.click(luisOption); + const node = await waitFor(() => component.getByTestId('triggerFormSubmit')); + expect(node).toBeDisabled(); + }); + + it('should create a QnA Intent recognized', async () => { + const component = renderComponent(); + const triggerType = component.getByTestId('triggerTypeDropDown'); + fireEvent.click(triggerType); + + const qnaOption = component.getByTitle('QnA Intent recognized'); + fireEvent.click(qnaOption); + + const node = await waitFor(() => component.getByTestId('triggerFormSubmit')); + expect(node).toBeEnabled(); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx b/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx new file mode 100644 index 0000000000..019f174525 --- /dev/null +++ b/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/* eslint-disable react-hooks/rules-of-hooks */ +import React from 'react'; + +import TableView from '../../../src/pages/knowledge-base/table-view'; +import CodeEditor from '../../../src/pages/knowledge-base/code-editor'; +import { renderWithRecoil } from '../../testUtils'; +import { + projectIdState, + localeState, + dialogsState, + qnaFilesState, + settingsState, + schemasState, + dispatcherState, +} from '../../../src/recoilModel'; +import mockProjectResponse from '../../../src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json'; + +const initialContent = ` +# ?question +\`\`\` +answer +\`\`\` +`; + +const state = { + projectId: 'test', + dialogs: [{ id: '1' }, { id: '2' }], + locale: 'en-us', + qnaFiles: [ + { + id: 'a.en-us', + content: initialContent, + qnaSections: [ + { + Questions: [{ content: 'question', id: 1 }], + Answer: 'answer', + uuid: 1, + }, + ], + }, + ], + settings: { + defaultLanguage: 'en-us', + languages: ['en-us', 'fr-fr'], + }, +}; + +const updateQnAFileMock = jest.fn(); + +const initRecoilState = ({ set }) => { + set(projectIdState, state.projectId); + set(localeState, state.locale); + set(dialogsState, state.dialogs); + set(qnaFilesState, state.qnaFiles); + set(settingsState, state.settings); + set(schemasState, mockProjectResponse.schemas); + set(dispatcherState, { + updateQnAFile: updateQnAFileMock, + }); +}; + +describe('QnA page all up view', () => { + it('should render QnA page table view', () => { + const { getByText, getByTestId } = renderWithRecoil(, initRecoilState); + getByTestId('table-view'); + getByText('question (1)'); + getByText('answer'); + }); + + it('should render QnA page code editor', () => { + renderWithRecoil(, initRecoilState); + }); +}); diff --git a/Composer/packages/client/__tests__/plugins.test.ts b/Composer/packages/client/__tests__/plugins.test.ts index 9b9a328549..ea15956780 100644 --- a/Composer/packages/client/__tests__/plugins.test.ts +++ b/Composer/packages/client/__tests__/plugins.test.ts @@ -58,7 +58,7 @@ describe('mergePluginConfigs', () => { }; // @ts-expect-error - expect(mergePluginConfigs(config1, config2).recognizers).toEqual(['recognizer 1', 'recognizer 2']); + expect(mergePluginConfigs(config1, config2).recognizers).toEqual(['recognizer 2', 'recognizer 1']); }); it('replaces other arrays', () => { diff --git a/Composer/packages/client/__tests__/shell/triggerApi.test.tsx b/Composer/packages/client/__tests__/shell/triggerApi.test.tsx new file mode 100644 index 0000000000..d1739b945f --- /dev/null +++ b/Composer/packages/client/__tests__/shell/triggerApi.test.tsx @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { renderHook } from '@bfc/test-utils/lib/hooks'; +import * as React from 'react'; +import { RecoilRoot } from 'recoil'; + +import { useTriggerApi } from '../../src/shell/triggerApi'; +import { + projectIdState, + localeState, + luFilesState, + lgFilesState, + dialogsState, + schemasState, + dispatcherState, +} from '../../src/recoilModel'; +import { Dispatcher } from '../../src/recoilModel/dispatchers'; + +const state = { + dialogs: [ + { + id: 'test', + content: {}, + }, + ], + luFiles: [ + { + content: 'test', + id: 'test.en-us', + intents: [], + }, + ], + lgFiles: [ + { + content: 'test', + id: 'test.en-us', + templates: [], + }, + ], + schemas: { sdk: { content: {} } }, + focusPath: '', + locale: 'en-us', + projectId: 'test', +}; + +describe('use triggerApi hooks', () => { + let selectToMock, updateDialogMock, createLgTemplatesMock, createLuIntentMock, result; + beforeEach(() => { + selectToMock = jest.fn(); + updateDialogMock = jest.fn(); + createLgTemplatesMock = jest.fn(); + createLuIntentMock = jest.fn(); + + const initRecoilState = ({ set }) => { + set(projectIdState, state.projectId); + set(localeState, 'en-us'); + set(luFilesState, state.luFiles); + set(lgFilesState, state.lgFiles); + set(dialogsState, state.dialogs); + set(schemasState, state.schemas); + set(dispatcherState, (current: Dispatcher) => ({ + ...current, + selectTo: selectToMock, + updateDialog: updateDialogMock, + createLgTemplates: createLgTemplatesMock, + createLuIntent: createLuIntentMock, + })); + }; + + const wrapper = (props: { children?: React.ReactNode }) => { + const { children } = props; + return {children}; + }; + const rendered = renderHook(() => useTriggerApi(), { + wrapper, + }); + result = rendered.result; + }); + + it('should create QnA trigger', async () => { + const dialogId = 'test'; + const formData = { + $kind: 'Microsoft.OnQnAMatch', + errors: { $kind: '', intent: '', event: '', triggerPhrases: '', regEx: '', activity: '' }, + event: '', + intent: '', + regEx: '', + triggerPhrases: '', + }; + await result.current.createTrigger(dialogId, formData); + expect(createLgTemplatesMock).toBeCalledTimes(1); + expect(updateDialogMock).toBeCalledTimes(1); + expect(createLgTemplatesMock).toBeCalledTimes(1); + expect(updateDialogMock).toBeCalledTimes(1); + }); +}); diff --git a/Composer/packages/client/__tests__/utils/dialogUtil.test.js b/Composer/packages/client/__tests__/utils/dialogUtil.test.js index b20abdb5ad..2520dc81aa 100644 --- a/Composer/packages/client/__tests__/utils/dialogUtil.test.js +++ b/Composer/packages/client/__tests__/utils/dialogUtil.test.js @@ -186,9 +186,11 @@ describe('getTriggerTypes', () => { const triggerTypes = getTriggerTypes(); expect(triggerTypes).toEqual([ { key: 'Microsoft.OnIntent', text: 'Intent recognized' }, + { key: 'Microsoft.OnQnAMatch', text: 'QnA Intent recognized' }, { key: 'Microsoft.OnUnknownIntent', text: 'Unknown intent' }, { key: 'Microsoft.OnDialogEvent', text: 'Dialog events' }, { key: 'Microsoft.OnActivity', text: 'Activities' }, + { key: 'Microsoft.OnChooseIntent', text: 'Duplicated intents recognized' }, { key: 'OnCustomEvent', text: 'Custom events' }, ]); }); diff --git a/Composer/packages/client/__tests__/utils/luUtil.test.ts b/Composer/packages/client/__tests__/utils/luUtil.test.ts index ebb38e4edc..58f27d7175 100644 --- a/Composer/packages/client/__tests__/utils/luUtil.test.ts +++ b/Composer/packages/client/__tests__/utils/luUtil.test.ts @@ -3,13 +3,13 @@ import { LuFile, DialogInfo, Diagnostic, DiagnosticSeverity } from '@bfc/shared'; -import { getReferredFiles, checkLuisPublish, createCrossTrainConfig } from '../../src/utils/luUtil'; +import { getReferredLuFiles, createCrossTrainConfig, checkLuisBuild } from '../../src/utils/luUtil'; -describe('getReferredFiles', () => { +describe('getReferredLuFiles', () => { it('returns referred luFiles from dialog', () => { const dialogs = [{ luFile: 'a' }]; - const luFiles = [{ id: 'a.en-us' }, { id: 'b.en-us' }, { id: 'c.en-us' }]; - const referred = getReferredFiles(luFiles as LuFile[], dialogs as DialogInfo[]); + const luFiles = [{ id: 'a.en-us', content: 'xxx' }, { id: 'b.en-us' }, { id: 'c.en-us' }]; + const referred = getReferredLuFiles(luFiles as LuFile[], dialogs as DialogInfo[]); expect(referred.length).toEqual(1); expect(referred[0].id).toEqual('a.en-us'); }); @@ -25,7 +25,6 @@ describe('getReferredFiles', () => { { intent: 'dia2_trigger', dialogs: ['dia2'] }, { intent: 'dias_trigger', dialogs: ['dia5', 'dia6'] }, { intent: 'no_dialog', dialogs: [] }, - { intent: 'dialog_without_lu', dialogs: ['dialog_without_lu'] }, { intent: '', dialogs: ['start_dialog_without_intent'] }, ], }, @@ -73,54 +72,53 @@ describe('getReferredFiles', () => { }, ]; const luFiles = [ - { id: 'main.en-us' }, - { id: 'dia1.en-us' }, + { + 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' }, - { id: 'start_dialog_without_intent.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']['']).toEqual('start_dialog_without_intent.en-us.lu'); 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(); - expect(config.triggerRules['main.en-us.lu'].dialog_without_lu).toEqual(''); }); +}); - it('check the lu files before publish', () => { - const dialogs = [{ luFile: 'a' }] as DialogInfo[]; - const diagnostics: Diagnostic[] = []; - const luFiles = [ - { id: 'a.en-us', diagnostics, content: 'test', intents: [{ Name: '1', Body: '1' }], empty: false }, - { id: 'b.en-us', diagnostics }, - { id: 'c.en-us', diagnostics }, - ] as LuFile[]; - const referred = checkLuisPublish(luFiles, dialogs); - expect(referred.length).toEqual(1); - - expect(referred[0].id).toEqual('a.en-us'); - - luFiles[0].diagnostics = [{ message: 'wrong', severity: DiagnosticSeverity.Error }] as Diagnostic[]; - expect(() => { - checkLuisPublish(luFiles, dialogs); - }).toThrowError(/wrong/); +it('check the lu files before publish', () => { + const dialogs = [{ luFile: 'a' }] as DialogInfo[]; + const diagnostics: Diagnostic[] = []; + const luFiles = [ + { id: 'a.en-us', diagnostics, content: 'test', intents: [{ Name: '1', Body: '1' }], empty: false }, + { id: 'b.en-us', diagnostics }, + { id: 'c.en-us', diagnostics }, + ] as LuFile[]; + const referred = checkLuisBuild(luFiles, dialogs); + expect(referred.length).toEqual(1); - luFiles[0].diagnostics = []; - luFiles[0].intents = []; - luFiles[0].empty = true; - expect(() => { - checkLuisPublish(luFiles, dialogs); - }).toThrowError('You have the following empty LuFile(s): a.en-us'); + expect(referred[0].id).toEqual('a.en-us'); - luFiles[0].empty = false; + luFiles[0].diagnostics = [{ message: 'wrong', severity: DiagnosticSeverity.Error }] as Diagnostic[]; + expect(() => { + checkLuisBuild(luFiles, dialogs); + }).toThrowError(/wrong/); - expect(checkLuisPublish(luFiles, dialogs)[0].id).toEqual('a.en-us'); - }); + luFiles[0].empty = false; + luFiles[0].diagnostics = []; + expect(checkLuisBuild(luFiles, dialogs)[0].id).toEqual('a.en-us'); }); diff --git a/Composer/packages/client/package.json b/Composer/packages/client/package.json index 9adb8020b6..b1fe205426 100644 --- a/Composer/packages/client/package.json +++ b/Composer/packages/client/package.json @@ -9,7 +9,7 @@ }, "scripts": { "start": "node scripts/start.js", - "build": "node scripts/build.js", + "build": "node --max_old_space_size=4096 scripts/build.js", "clean": "rimraf build", "test": "jest", "lint": "eslint --quiet --ext .js,.jsx,.ts,.tsx ./src ./__tests__", @@ -29,6 +29,7 @@ "@bfc/ui-plugin-dialog-schema-editor": "*", "@bfc/ui-plugin-lg": "*", "@bfc/ui-plugin-luis": "*", + "@bfc/ui-plugin-cross-trained": "*", "@bfc/ui-plugin-prompts": "*", "@bfc/ui-plugin-select-dialog": "*", "@bfc/ui-plugin-select-skill-dialog": "*", diff --git a/Composer/packages/client/src/Onboarding/WelcomeModal/Expanded/ExpandedWelcomeModal.tsx b/Composer/packages/client/src/Onboarding/WelcomeModal/Expanded/ExpandedWelcomeModal.tsx index a6f2ed07c9..5f57f70e1e 100644 --- a/Composer/packages/client/src/Onboarding/WelcomeModal/Expanded/ExpandedWelcomeModal.tsx +++ b/Composer/packages/client/src/Onboarding/WelcomeModal/Expanded/ExpandedWelcomeModal.tsx @@ -51,7 +51,12 @@ const WelcomeModal = () => { title={formatMessage('Collapse')} onClick={toggleMinimized} /> - + Welcome diff --git a/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx b/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx index 05d17d943f..797c47a34e 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx @@ -23,7 +23,7 @@ import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky'; import { ProjectTemplate } from '@bfc/shared'; import { NeutralColors } from '@uifabric/fluent-theme'; -import { DialogCreationCopy, EmptyBotTemplateId } from '../../constants'; +import { DialogCreationCopy, EmptyBotTemplateId, QnABotTemplateId } from '../../constants'; import { DialogWrapper, DialogTypes } from '../DialogWrapper'; // -------------------- Styles -------------------- // @@ -98,6 +98,7 @@ const content = css` const optionKeys = { createFromScratch: 'createFromScratch', + createFromQnA: 'createFromQnA', createFromTemplate: 'createFromTemplate', }; @@ -146,6 +147,10 @@ export function CreateOptions(props) { routeToTemplate = currentTemplate; } + if (option === optionKeys.createFromQnA) { + routeToTemplate = QnABotTemplateId; + } + if (props.location && props.location.search) { routeToTemplate += props.location.search; } @@ -226,6 +231,13 @@ export function CreateOptions(props) { text: formatMessage('Create from scratch'), onRenderField: SelectOption, }, + { + ariaLabel: formatMessage('Create from QnA') + (option === optionKeys.createFromQnA ? ' selected' : ''), + key: optionKeys.createFromQnA, + 'data-testid': 'Create from QnA', + text: formatMessage('Create from knowledge base (QnA Maker)'), + onRenderField: SelectOption, + }, { ariaLabel: formatMessage('Create from template') + (option === optionKeys.createFromTemplate ? ' selected' : ''), key: optionKeys.createFromTemplate, diff --git a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx index ca298d2238..9189fdadff 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx @@ -4,7 +4,7 @@ // TODO: Remove path module import Path from 'path'; -import React, { useEffect, useRef, Fragment } from 'react'; +import React, { useEffect, useRef, Fragment, useState } from 'react'; import { RouteComponentProps, Router, navigate } from '@reach/router'; import { useRecoilValue } from 'recoil'; @@ -16,8 +16,11 @@ import { templateProjectsState, storagesState, focusedStorageFolderState, + localeState, } from '../../recoilModel'; import Home from '../../pages/home/Home'; +import ImportQnAFromUrlModal from '../../pages/knowledge-base/ImportQnAFromUrlModal'; +import { QnABotTemplateId } from '../../constants'; import { useProjectIdCache } from '../../utils/hooks'; import { CreateOptions } from './CreateOptions'; @@ -39,6 +42,7 @@ const CreationFlow: React.FC = () => { updateCurrentPathForStorage, updateFolder, saveTemplateId, + importQnAFromUrls, fetchProjectById, fetchRecentProjects, } = useRecoilValue(dispatcherState); @@ -47,10 +51,12 @@ const CreationFlow: React.FC = () => { const templateProjects = useRecoilValue(templateProjectsState); const storages = useRecoilValue(storagesState); const focusedStorageFolder = useRecoilValue(focusedStorageFolderState); + const locale = useRecoilValue(localeState); const cachedProjectId = useProjectIdCache(); const currentStorageIndex = useRef(0); const storage = storages[currentStorageIndex.current]; const currentStorageId = storage ? storage.id : 'default'; + const [formData, setFormData] = useState({ name: '' }); useEffect(() => { if (storages && storages.length) { @@ -97,13 +103,29 @@ const CreationFlow: React.FC = () => { }; const handleCreateNew = async (formData, templateId: string) => { - createProject(templateId || '', formData.name, formData.description, formData.location, formData.schemaUrl); + await createProject(templateId || '', formData.name, formData.description, formData.location, formData.schemaUrl); }; const handleSaveAs = (formData) => { saveProjectAs(projectId, formData.name, formData.description, formData.location); }; + const handleCreateQnA = async (urls: string[]) => { + saveTemplateId(QnABotTemplateId); + handleDismiss(); + await handleCreateNew(formData, QnABotTemplateId); + await importQnAFromUrls({ id: `${formData.name.toLocaleLowerCase()}.${locale}`, urls }); + }; + + const handleSubmitOrImportQnA = async (formData, templateId: string) => { + if (templateId === 'QnASample') { + setFormData(formData); + navigate(`./QnASample/importQnA`); + return; + } + handleSubmit(formData, templateId); + }; + const handleSubmit = async (formData, templateId: string) => { handleDismiss(); switch (creationFlowStatus) { @@ -113,7 +135,7 @@ const CreationFlow: React.FC = () => { default: saveTemplateId(templateId); - handleCreateNew(formData, templateId); + await handleCreateNew(formData, templateId); } }; @@ -133,7 +155,7 @@ const CreationFlow: React.FC = () => { updateFolder={updateFolder} onCurrentPathUpdate={updateCurrentPath} onDismiss={handleDismiss} - onSubmit={handleSubmit} + onSubmit={handleSubmitOrImportQnA} /> = () => { updateFolder={updateFolder} onCurrentPathUpdate={updateCurrentPath} onDismiss={handleDismiss} - onSubmit={handleSubmit} + onSubmit={handleSubmitOrImportQnA} /> = () => { onDismiss={handleDismiss} onOpen={openBot} /> + ); diff --git a/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx b/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx index 700a8fc9bc..751bae21d6 100644 --- a/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx +++ b/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx @@ -14,7 +14,7 @@ import { RouteComponentProps } from '@reach/router'; import querystring from 'query-string'; import { FontWeights } from '@uifabric/styling'; -import { DialogCreationCopy, nameRegex } from '../../constants'; +import { DialogCreationCopy, QnABotTemplateId, nameRegex } from '../../constants'; import { DialogWrapper, DialogTypes } from '../DialogWrapper'; import { FieldConfig, useForm } from '../../hooks/useForm'; import { StorageFolder } from '../../recoilModel/types'; @@ -259,7 +259,7 @@ const DefineConversation: React.FC = (props) => { diff --git a/Composer/packages/client/src/components/LoadingSpinner.tsx b/Composer/packages/client/src/components/LoadingSpinner.tsx index f1c305a6c8..befd8a0a10 100644 --- a/Composer/packages/client/src/components/LoadingSpinner.tsx +++ b/Composer/packages/client/src/components/LoadingSpinner.tsx @@ -15,10 +15,15 @@ const container = css` justify-content: center; `; -export const LoadingSpinner: React.FC = () => { +interface LoadingSpinnerProps { + message?: string; +} + +export const LoadingSpinner: React.FC = (props) => { + const { message } = props; return (
- +
); }; diff --git a/Composer/packages/client/src/components/NavItem.tsx b/Composer/packages/client/src/components/NavItem.tsx index d706b0c5f6..233fa13b40 100644 --- a/Composer/packages/client/src/components/NavItem.tsx +++ b/Composer/packages/client/src/components/NavItem.tsx @@ -15,6 +15,7 @@ import { useRecoilValue } from 'recoil'; import { useLocation, useRouterCache } from '../utils/hooks'; import { dispatcherState } from '../recoilModel'; +import { QnAIcon } from './QnAIcon'; // -------------------- Styles -------------------- // const link = (active: boolean, disabled: boolean) => css` @@ -98,9 +99,12 @@ export const NavItem: React.FC = (props) => { const active = pathname.startsWith(to); const addRef = useCallback((ref) => onboardingAddCoachMarkRef({ [`nav${labelName.replace(' ', '')}`]: ref }), []); - - const iconElement = ; - + const iconElement = + iconName === 'QnAIcon' ? ( + + ) : ( + + ); const activeArea = (
= (props) => { tabIndex={-1} > {showTooltip ? ( - + {iconElement} ) : ( diff --git a/Composer/packages/client/src/components/Page.tsx b/Composer/packages/client/src/components/Page.tsx index 9f2b49f93b..e631a2aef8 100644 --- a/Composer/packages/client/src/components/Page.tsx +++ b/Composer/packages/client/src/components/Page.tsx @@ -71,7 +71,7 @@ export const content = css` flex: 4; padding: 20px; position: relative; - overflow: scroll; + overflow: auto; label: PageContent; `; diff --git a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx index 7b013c761d..65399c06b3 100644 --- a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx +++ b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx @@ -24,6 +24,7 @@ import { ISearchBoxStyles } from 'office-ui-fabric-react/lib/SearchBox'; import { dispatcherState, userSettingsState } from '../../recoilModel'; import { createSelectedPath, getFriendlyName } from '../../utils/dialogUtil'; +import { containUnsupportedTriggers, triggerNotSupported } from '../../utils/dialogValidator'; import { TreeItem } from './treeItem'; @@ -58,7 +59,7 @@ const root = css` // -------------------- ProjectTree -------------------- // -function createGroupItem(dialog: DialogInfo, currentId: string, position: number) { +function createGroupItem(dialog: DialogInfo, currentId: string, position: number, warningContent: string): IGroup { return { key: dialog.id, name: dialog.displayName, @@ -67,14 +68,15 @@ function createGroupItem(dialog: DialogInfo, currentId: string, position: number count: dialog.triggers.length, hasMoreData: true, isCollapsed: dialog.id !== currentId, - data: dialog, + data: { ...dialog, warningContent }, }; } -function createItem(trigger: ITrigger, index: number) { +function createItem(trigger: ITrigger, index: number, warningContent: string) { return { ...trigger, index, + warningContent, displayName: trigger.displayName || getFriendlyName({ $kind: trigger.type }), }; } @@ -104,10 +106,12 @@ function createItemsAndGroups( }) .reduce( (result: { items: any[]; groups: IGroup[] }, dialog) => { - result.groups.push(createGroupItem(dialog, dialogId, position)); + const warningContent = containUnsupportedTriggers(dialog); + result.groups.push(createGroupItem(dialog, dialogId, position, warningContent)); position += dialog.triggers.length; dialog.triggers.forEach((item, index) => { - result.items.push(createItem(item, index)); + const warningContent = triggerNotSupported(dialog, item); + result.items.push(createItem(item, index, warningContent)); }); return result; }, diff --git a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx b/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx index 6c98e110aa..4f3611e8fb 100644 --- a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx +++ b/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx @@ -10,11 +10,12 @@ import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button' import { Label } from 'office-ui-fabric-react/lib/Label'; import { Stack } from 'office-ui-fabric-react/lib/Stack'; import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { luIndexer, combineMessage } from '@bfc/indexers'; import { PlaceHolderSectionName } from '@bfc/indexers/lib/utils/luUtil'; -import { DialogInfo, SDKKinds, LuIntentSection } from '@bfc/shared'; +import { SDKKinds } from '@bfc/shared'; import { LuEditor, inlineModePlaceholder } from '@bfc/code-editor'; import { IComboBoxOption } from 'office-ui-fabric-react/lib/ComboBox'; import { useRecoilValue } from 'recoil'; @@ -22,7 +23,6 @@ import { FontWeights } from '@uifabric/styling'; import { FontSizes } from '@uifabric/fluent-theme'; import { - generateNewDialog, getTriggerTypes, TriggerFormData, TriggerFormDataErrors, @@ -32,13 +32,14 @@ import { activityTypeKey, getEventTypes, getActivityTypes, - regexRecognizerKey, + qnaMatcherKey, + onChooseIntentKey, } from '../../utils/dialogUtil'; -import { projectIdState, schemasState } from '../../recoilModel/atoms/botState'; +import { projectIdState } from '../../recoilModel/atoms/botState'; import { userSettingsState } from '../../recoilModel'; import { nameRegex } from '../../constants'; import { validatedDialogsSelector } from '../../recoilModel/selectors/validatedDialogs'; - +import { isRegExRecognizerType, isLUISnQnARecognizerType } from '../../utils/dialogValidator'; // -------------------- Styles -------------------- // const styles = { @@ -86,6 +87,18 @@ const intent = { }, }; +const optionRow = { + display: 'flex', + height: 15, + fontSize: 15, +}; + +export const warningIcon = { + marginLeft: 5, + color: '#BE880A', + fontSize: 5, +}; + // -------------------- Validation Helpers -------------------- // const initialFormDataErrors = { @@ -160,12 +173,8 @@ const validateTriggerPhrases = ( intent: string, triggerPhrases: string ): string | undefined => { - if (selectedType === intentTypeKey && !isRegEx) { - if (triggerPhrases) { - return getLuDiagnostics(intent, triggerPhrases); - } else { - return formatMessage('Please input trigger phrases'); - } + if (selectedType === intentTypeKey && !isRegEx && triggerPhrases) { + return getLuDiagnostics(intent, triggerPhrases); } return undefined; }; @@ -189,18 +198,13 @@ const validateForm = ( return errors; }; -export interface LuFilePayload { - id: string; - content: string; -} - // -------------------- TriggerCreationModal -------------------- // interface TriggerCreationModalProps { dialogId: string; isOpen: boolean; onDismiss: () => void; - onSubmit: (dialog: DialogInfo, intent?: LuIntentSection) => void; + onSubmit: (dialogId: string, formData: TriggerFormData) => void; } export const TriggerCreationModal: React.FC = (props) => { @@ -208,38 +212,51 @@ export const TriggerCreationModal: React.FC = (props) const dialogs = useRecoilValue(validatedDialogsSelector); const projectId = useRecoilValue(projectIdState); - const schemas = useRecoilValue(schemasState); const userSettings = useRecoilValue(userSettingsState); - const dialogFile = dialogs.find((dialog) => dialog.id === dialogId); - const isRegEx = (dialogFile?.content?.recognizer?.$kind ?? '') === regexRecognizerKey; + const isRegEx = isRegExRecognizerType(dialogFile); + const isLUISnQnA = isLUISnQnARecognizerType(dialogFile); const regexIntents = dialogFile?.content?.recognizer?.intents ?? []; - const isNone = !dialogFile?.content?.recognizer; const initialFormData: TriggerFormData = { errors: initialFormDataErrors, - $kind: isNone ? '' : intentTypeKey, + $kind: intentTypeKey, event: '', intent: '', triggerPhrases: '', regEx: '', }; const [formData, setFormData] = useState(initialFormData); - const [selectedType, setSelectedType] = useState(isNone ? '' : intentTypeKey); + const [selectedType, setSelectedType] = useState(intentTypeKey); const showIntentName = selectedType === intentTypeKey; const showRegExDropDown = selectedType === intentTypeKey && isRegEx; - const showTriggerPhrase = selectedType === intentTypeKey && !isRegEx; + const showTriggerPhrase = selectedType === intentTypeKey && isLUISnQnA; const showEventDropDown = selectedType === eventTypeKey; const showActivityDropDown = selectedType === activityTypeKey; const showCustomEvent = selectedType === customEventKey; - const eventTypes: IComboBoxOption[] = getEventTypes(); const activityTypes: IDropdownOption[] = getActivityTypes(); - let triggerTypeOptions: IDropdownOption[] = getTriggerTypes(); + const triggerTypeOptions: IDropdownOption[] = getTriggerTypes(); - if (isNone) { - triggerTypeOptions = triggerTypeOptions.filter((t) => t.key !== intentTypeKey); + if (isRegEx) { + const qnaMatcherOption = triggerTypeOptions.find((t) => t.key === qnaMatcherKey); + if (qnaMatcherOption) { + qnaMatcherOption.data = { icon: 'Warning' }; + } + const onChooseIntentOption = triggerTypeOptions.find((t) => t.key === onChooseIntentKey); + if (onChooseIntentOption) { + onChooseIntentOption.data = { icon: 'Warning' }; + } } + const onRenderOption = (option: IDropdownOption) => { + return ( +
+ {option.text} + {option.data && option.data.icon && } +
+ ); + }; + const shouldDisable = (errors: TriggerFormDataErrors) => { for (const key in errors) { if (errors[key]) { @@ -255,20 +272,11 @@ export const TriggerCreationModal: React.FC = (props) //If still have some errors here, it is a bug. const errors = validateForm(selectedType, formData, isRegEx, regexIntents); if (shouldDisable(errors)) { - setFormData({ - ...formData, - errors, - }); + setFormData({ ...formData, errors }); return; } - const newDialog = generateNewDialog(dialogs, dialogId, formData, schemas.sdk?.content); - if (formData.$kind === intentTypeKey && !isRegEx) { - const newIntent = { Name: formData.intent, Body: formData.triggerPhrases }; - onSubmit(newDialog, newIntent); - } else { - onSubmit(newDialog); - } onDismiss(); + onSubmit(dialogId, formData); }; const onSelectTriggerType = (e, option) => { @@ -306,7 +314,7 @@ export const TriggerCreationModal: React.FC = (props) const onNameChange = (e, name) => { const errors: TriggerFormDataErrors = {}; errors.intent = validateIntentName(selectedType, name); - if (showTriggerPhrase) { + if (showTriggerPhrase && formData.triggerPhrases) { errors.triggerPhrases = getLuDiagnostics(name, formData.triggerPhrases); } setFormData({ ...formData, intent: name, errors: { ...formData.errors, ...errors } }); @@ -318,9 +326,14 @@ export const TriggerCreationModal: React.FC = (props) setFormData({ ...formData, regEx: pattern, errors: { ...formData.errors, ...errors } }); }; + //Trigger phrase is optional const onTriggerPhrasesChange = (body: string) => { const errors: TriggerFormDataErrors = {}; - errors.triggerPhrases = getLuDiagnostics(formData.intent, body); + if (body) { + errors.triggerPhrases = getLuDiagnostics(formData.intent, body); + } else { + errors.triggerPhrases = ''; + } setFormData({ ...formData, triggerPhrases: body, errors: { ...formData.errors, ...errors } }); }; const errors = validateForm(selectedType, formData, isRegEx, regexIntents); @@ -350,6 +363,8 @@ export const TriggerCreationModal: React.FC = (props) options={triggerTypeOptions} styles={dropdownStyles} onChange={onSelectTriggerType} + //@ts-ignore: + onRenderOption={onRenderOption} /> {showEventDropDown && ( = (props) )} {showCustomEvent && ( = (props) label={ isRegEx ? formatMessage('What is the name of this trigger (RegEx)') - : formatMessage('What is the name of this trigger (LUIS)') + : isLUISnQnA + ? formatMessage('What is the name of this trigger (LUIS)') + : formatMessage('What is the name of this trigger') } styles={intent} onChange={onNameChange} diff --git a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx index 7459196f40..fc32818c8b 100644 --- a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx +++ b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx @@ -16,14 +16,14 @@ import { IContextualMenuStyles } from 'office-ui-fabric-react/lib/ContextualMenu import { ICalloutContentStyles } from 'office-ui-fabric-react/lib/Callout'; // -------------------- Styles -------------------- // - +const indent = 16; const itemText = (depth: number) => css` outline: none; :focus { outline: rgb(102, 102, 102) solid 1px; z-index: 1; } - padding-left: ${depth * 16}px; + padding-left: ${depth * indent}px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; @@ -42,6 +42,11 @@ const content = css` label: ProjectTreeItem; `; +const leftIndent = css` + height: 100%; + width: ${indent}px; +`; + const moreMenu: Partial = { root: { marginTop: '-7px', @@ -117,6 +122,12 @@ export const overflowSet = css` justify-content: space-between; `; +const warningIcon = { + marginRight: 5, + color: '#BE880A', + fontSize: 9, +}; + // -------------------- TreeItem -------------------- // interface ITreeItemProps { @@ -129,6 +140,9 @@ interface ITreeItemProps { } const onRenderItem = (item: IOverflowSetItemProps) => { + const warningContent = formatMessage( + 'This trigger type is not supported by the RegEx recognizer and will not be fired.' + ); return (
{ onFocus={item.onFocus} >
+ {item.warningContent ? ( + + + + ) : ( +
+ )} {item.depth !== 0 && ( = (props) => { + const { active, disabled } = props; + + return ( + + + + ); +}; + +export default QnAIcon; diff --git a/Composer/packages/client/src/components/TestController/TestController.tsx b/Composer/packages/client/src/components/TestController/TestController.tsx index a0422f7011..6c7394cdfe 100644 --- a/Composer/packages/client/src/components/TestController/TestController.tsx +++ b/Composer/packages/client/src/components/TestController/TestController.tsx @@ -8,12 +8,13 @@ import { jsx, css } from '@emotion/core'; import { PrimaryButton } from 'office-ui-fabric-react/lib/Button'; import formatMessage from 'format-message'; import { useRecoilValue } from 'recoil'; -import { defaultPublishConfig } from '@bfc/shared'; +import { IConfig, IPublishConfig, defaultPublishConfig } from '@bfc/shared'; import { botNameState, botStatusState, luFilesState, + qnaFilesState, settingsState, projectIdState, botLoadErrorState, @@ -21,14 +22,15 @@ import { dispatcherState, } from '../../recoilModel'; import settingsStorage from '../../utils/dialogSettingStorage'; -import { BotStatus, LuisConfig } from '../../constants'; +import { QnaConfig, BotStatus, LuisConfig } 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 { validatedDialogsSelector } from '../../recoilModel/selectors/validatedDialogs'; -import { getReferredFiles } from './../../utils/luUtil'; -import { PublishLuisDialog } from './publishDialog'; +import { getReferredLuFiles } from './../../utils/luUtil'; +import { PublishDialog } from './publishDialog'; import { ErrorCallout } from './errorCallout'; import { EmulatorOpenButton } from './emulatorOpenButton'; import { Loading } from './loading'; @@ -61,6 +63,7 @@ export const TestController: React.FC = () => { const botStatus = useRecoilValue(botStatusState); const dialogs = useRecoilValue(validatedDialogsSelector); const luFiles = useRecoilValue(luFilesState); + const qnaFiles = useRecoilValue(qnaFilesState); const settings = useRecoilValue(settingsState); const projectId = useRecoilValue(projectIdState); const botLoadErrorMsg = useRecoilValue(botLoadErrorState); @@ -68,10 +71,11 @@ export const TestController: React.FC = () => { const { publishToTarget, onboardingAddCoachMarkRef, - publishLuis, + build, getPublishStatus, setBotStatus, setSettings, + setQnASettings, } = useRecoilValue(dispatcherState); const connected = botStatus === BotStatus.connected; const publishing = botStatus === BotStatus.publishing; @@ -79,6 +83,7 @@ export const TestController: React.FC = () => { const addRef = useCallback((startBot) => onboardingAddCoachMarkRef({ startBot }), []); const errorLength = notifications.filter((n) => n.severity === 'Error').length; const showError = errorLength > 0; + const publishDialogConfig = { subscriptionKey: settings.qna.subscriptionKey, ...settings.luis } as IConfig; const warningLength = notifications.filter((n) => n.severity === 'Warning').length; const showWarning = !showError && warningLength > 0; @@ -147,39 +152,69 @@ export const TestController: React.FC = () => { } } - async function handlePublishLuis(luisConfig) { + async function handlePublish(config: IPublishConfig) { setBotStatus(BotStatus.publishing); dismissDialog(); - await setSettings(projectId, { ...settings, luis: luisConfig }); - await publishLuis(luisConfig, projectId); + const { luis, qna } = config; + await setSettings(projectId, { + ...settings, + luis: luis, + qna: Object.assign({}, settings.qna, qna), + }); + await build(luis, qna, projectId); } async function handleLoadBot() { setBotStatus(BotStatus.reloading); + if (settings.qna && settings.qna.subscriptionKey) { + await setQnASettings(projectId, settings.qna.subscriptionKey); + } const sensitiveSettings = settingsStorage.get(projectId); await publishToTarget(projectId, defaultPublishConfig, { comment: '' }, sensitiveSettings); } - function isLuisConfigComplete(config) { + function isConfigComplete(config) { let complete = true; - for (const key in LuisConfig) { - if (config?.[LuisConfig[key]] === '') { + 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; - break; } } 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 = settings.luis; - - if (!isAbsHosted() && getReferredFiles(luFiles, dialogs).length > 0) { - if (botStatus === BotStatus.failed || botStatus === BotStatus.pending || !isLuisConfigComplete(config)) { + const config = Object.assign( + {}, + { + luis: settings.luis, + qna: { + subscriptionKey: settings.qna.subscriptionKey, + qnaRegion: settings.qna.qnaRegion, + endpointKey: '', + }, + } + ); + if (!isAbsHosted() && needsPublish(dialogs)) { + if (botStatus === BotStatus.failed || botStatus === BotStatus.pending || !isConfigComplete(config)) { openDialog(); } else { - await handlePublishLuis(config); + await handlePublish(config); } } else { await handleLoadBot(); @@ -234,13 +269,13 @@ export const TestController: React.FC = () => { onDismiss={dismissCallout} onTry={handleStart} /> - {settings.luis && ( - )} diff --git a/Composer/packages/client/src/components/TestController/publishDialog.tsx b/Composer/packages/client/src/components/TestController/publishDialog.tsx index 7d44f10a86..c991d8dffa 100644 --- a/Composer/packages/client/src/components/TestController/publishDialog.tsx +++ b/Composer/packages/client/src/components/TestController/publishDialog.tsx @@ -3,7 +3,7 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; -import React, { useCallback } from 'react'; +import React, { useCallback, Fragment } from 'react'; import { Dialog, DialogType } from 'office-ui-fabric-react/lib/Dialog'; import { FontWeights, FontSizes } from 'office-ui-fabric-react/lib/Styling'; import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; @@ -14,10 +14,15 @@ import { IconButton } from 'office-ui-fabric-react/lib/Button'; import { Stack } from 'office-ui-fabric-react/lib/Stack'; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; import formatMessage from 'format-message'; -import { ILuisConfig } from '@bfc/shared'; +import { useRecoilValue } from 'recoil'; +import { IConfig, IPublishConfig } from '@bfc/shared'; +import { Dropdown, ResponsiveMode, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; import { Text, Tips, Links, nameRegex } from '../../constants'; import { FieldConfig, useForm } from '../../hooks/useForm'; +import { dialogsState, luFilesState, qnaFilesState } from '../../recoilModel/atoms/botState'; +import { getReferredQnaFiles } from '../../utils/qnaUtil'; +import { getReferredLuFiles } from '../../utils/luUtil'; // -------------------- Styles -------------------- // const textFieldLabel = css` @@ -45,10 +50,11 @@ const dialogModal = { maxWidth: '450px !important', }, }; - -interface LuisFormData { +interface FormData { name: string; authoringKey: string; + subscriptionKey: string; + qnaRegion: string; endpointKey: string; authoringRegion: string; defaultLanguage: string; @@ -73,28 +79,57 @@ const onRenderLabel = (info) => (props) => ( ); -interface IPublishLuisDialogProps { +const regionOptions: IDropdownOption[] = [ + { + key: 'westus', + text: formatMessage('westus'), + }, + { + key: 'westeurope', + text: formatMessage('westeurope'), + }, + { + key: 'australia', + text: formatMessage('australia'), + }, +]; + +interface IPublishDialogProps { botName: string; isOpen: boolean; - config: ILuisConfig; + config: IConfig; onDismiss: () => void; - onPublish: (data: LuisFormData) => void; + onPublish: (data: IPublishConfig) => void; } -export const PublishLuisDialog: React.FC = (props) => { +export const PublishDialog: React.FC = (props) => { const { isOpen, onDismiss, onPublish, botName, config } = props; + const dialogs = useRecoilValue(dialogsState); + const luFiles = useRecoilValue(luFilesState); + const qnaFiles = useRecoilValue(qnaFilesState); + const qnaConfigShow = getReferredQnaFiles(qnaFiles, dialogs).length > 0; + const luConfigShow = getReferredLuFiles(luFiles, dialogs).length > 0; - const luisFormConfig: FieldConfig = { + const formConfig: FieldConfig = { name: { required: true, validate: validate, defaultValue: config.name || botName, }, authoringKey: { - required: true, + required: luConfigShow, validate: validate, defaultValue: config.authoringKey, }, + subscriptionKey: { + required: qnaConfigShow, + validate: validate, + defaultValue: config.subscriptionKey, + }, + qnaRegion: { + required: true, + defaultValue: config.qnaRegion || 'westus', + }, endpointKey: { required: false, defaultValue: config.endpointKey, @@ -122,7 +157,7 @@ export const PublishLuisDialog: React.FC = (props) => { }, }; - const { formData, formErrors, hasErrors, updateField } = useForm(luisFormConfig, { validateOnMount: true }); + const { formData, formErrors, hasErrors, updateField } = useForm(formConfig, { validateOnMount: true }); const handlePublish = useCallback( (e) => { @@ -130,17 +165,52 @@ export const PublishLuisDialog: React.FC = (props) => { if (hasErrors) { return; } - - onPublish(formData); + const newValue = Object.assign({}, formData); + const subscriptionKey = formData.subscriptionKey; + const qnaRegion = formData.qnaRegion; + delete newValue.subscriptionKey; + delete newValue.qnaRegion; + const publishConfig = { + luis: newValue, + qna: { + subscriptionKey, + qnaRegion, + endpointKey: '', + }, + }; + onPublish(publishConfig); }, [hasErrors, formData] ); + const luisTitleRender = () => { + return ( + +
+ {Text.LUISDEPLOY} + + {formatMessage('Learn more.')} + +
+ ); + }; + + const qnaTitleRender = () => { + return ( + +
+ {Text.QNADEPLOY} + + {formatMessage('Learn more.')} + +
+ ); + }; return (