diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d6bde3d1c4..8d1da6907f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,15 +25,17 @@ jobs: uses: actions/setup-node@v1 with: node-version: 12.13.0 - - name: Restore yarn cache - uses: actions/cache@preview - with: - path: ~/.cache/yarn - key: ${{ runner.os }}-yarn-${{ hashFiles(format('{0}{1}', github.workspace, '/Composer/yarn.lock')) }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: yarn install - run: yarn + #- name: Restore yarn cache + # uses: actions/cache@v2.1.2 + # with: + # path: ~/.cache/yarn + # key: ${{ runner.os }}-yarn-new-${{ hashFiles(format('{0}{1}', github.workspace, '/Composer/yarn.lock')) }} + # restore-keys: | + # ${{ runner.os }}-yarn-new- + - name: Clear global yarn cache + run: yarn cache clean + - name: yarn --update-checksums + run: yarn --update-checksums - name: yarn build:dev run: yarn build:dev - name: yarn lint diff --git a/.vscode/snippets.json.code-snippets b/.vscode/snippets.json.code-snippets index d4a4e9a0a1..038c4adab4 100644 --- a/.vscode/snippets.json.code-snippets +++ b/.vscode/snippets.json.code-snippets @@ -1,15 +1,15 @@ { - // Place your BotFramework-Composer workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and - // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope - // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is - // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: - // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. - // Placeholders with the same ids are connected. - "React component test scaffolding": { - "prefix": "rct", - "body": [ - "import React from 'react';", - "import { render } from '@bfc/test-utils';", + // Place your BotFramework-Composer workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + "React component test scaffolding": { + "prefix": "rct", + "body": [ + "import React from 'react';", + "import { render } from '@botframework-composer/test-utils';", "import assign from 'lodash/assign';\n", "import { $1 } from '$2';\n", "const defaultProps = {\n $3\n};\n", @@ -17,10 +17,10 @@ " const props = assign({}, defaultProps, overrides);", " return render(<$1 {...props} />);", "}\n", - "describe('<$1 />', () => {", - " it.todo('$0');", - "});\n" - ], - "description": "React component test scaffolding" - } + "describe('<$1 />', () => {", + " it.todo('$0');", + "});\n" + ], + "description": "React component test scaffolding" + } } diff --git a/Composer/.eslintrc.js b/Composer/.eslintrc.js index b0e4f3bd7f..9924da904d 100644 --- a/Composer/.eslintrc.js +++ b/Composer/.eslintrc.js @@ -105,6 +105,7 @@ module.exports = { '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-object-literal-type-assertion': 'off', '@typescript-eslint/unbound-method': 'off', + '@typescript-eslint/no-explicit-any': 'off', 'security/detect-buffer-noassert': 'off', 'security/detect-child-process': 'off', diff --git a/Composer/.npmrc b/Composer/.npmrc index 764364f5c9..5fca0d518b 100644 --- a/Composer/.npmrc +++ b/Composer/.npmrc @@ -1,2 +1 @@ -@bfcomposer:registry=https://botbuilder.myget.org/F/botbuilder-declarative/npm/ scripts-prepend-node-path=true diff --git a/Composer/cypress/integration/LGPage.spec.ts b/Composer/cypress/integration/LGPage.spec.ts index 86dfc94b0e..7f935451ea 100644 --- a/Composer/cypress/integration/LGPage.spec.ts +++ b/Composer/cypress/integration/LGPage.spec.ts @@ -13,7 +13,7 @@ context('LG Page', () => { cy.contains('TodoSample'); cy.contains('All'); - cy.get('.toggleEditMode button').as('switchButton'); + cy.findByTestId('showcode').as('switchButton'); // by default is table view cy.findByTestId('LGPage').findByTestId('table-view').should('exist'); diff --git a/Composer/cypress/integration/LUPage.spec.ts b/Composer/cypress/integration/LUPage.spec.ts index 1b05322669..05704f40a1 100644 --- a/Composer/cypress/integration/LUPage.spec.ts +++ b/Composer/cypress/integration/LUPage.spec.ts @@ -13,7 +13,7 @@ context('LU Page', () => { cy.contains('__TestToDoBotWithLuisSample'); cy.contains('All'); - cy.get('.toggleEditMode button').should('not.exist'); + cy.findByTestId('showcode').should('not.exist'); // by default it goes to table view cy.findByTestId('LUPage').findByTestId('table-view').should('exist'); @@ -25,7 +25,7 @@ context('LU Page', () => { cy.findByTestId('ProjectTree').within(() => { cy.findByText('__TestToDoBotWithLuisSample').click('left'); }); - cy.get('.toggleEditMode button').as('switchButton'); + cy.findByTestId('showcode').as('switchButton'); // goto edit-mode cy.get('@switchButton').click(); cy.findByTestId('LUPage').get('.monaco-editor').should('exist'); diff --git a/Composer/cypress/integration/NotificationPage.spec.ts b/Composer/cypress/integration/NotificationPage.spec.ts index e1c31b72c7..a2c467fc5b 100644 --- a/Composer/cypress/integration/NotificationPage.spec.ts +++ b/Composer/cypress/integration/NotificationPage.spec.ts @@ -9,8 +9,7 @@ context('Notification Page', () => { it('can show lg syntax error ', () => { cy.visitPage('Bot Responses'); - cy.get('.toggleEditMode button').as('switchButton'); - cy.get('@switchButton').click(); + cy.findByTestId('showcode').click(); cy.get('textarea').type('#', { delay: 200 }); cy.findByTestId('LeftNav-CommandBarButtonNotifications').click(); @@ -29,7 +28,7 @@ context('Notification Page', () => { cy.findByText('__TestToDoBotWithLuisSample').click(); }); - cy.get('.toggleEditMode button').click(); + cy.findByTestId('showcode').click(); cy.get('textarea').type('t', { delay: 200 }); cy.findByTestId('LeftNav-CommandBarButtonNotifications').click(); diff --git a/Composer/cypress/integration/TriggerCreation.spec.ts b/Composer/cypress/integration/TriggerCreation.spec.ts index 46b9bd1d91..288f70e84a 100644 --- a/Composer/cypress/integration/TriggerCreation.spec.ts +++ b/Composer/cypress/integration/TriggerCreation.spec.ts @@ -10,7 +10,7 @@ context('Creating a new trigger', () => { it('can create different kinds of triggers ', () => { cy.visitPage('Design'); cy.findByTestId('recognizerTypeDropdown').click(); - cy.findByText('Regular Expression').click(); + cy.findByText('Regular expression recognizer').click(); //onintent trigger cy.findByTestId('AddFlyout').click(); diff --git a/Composer/package.json b/Composer/package.json index f73d7a06f0..25560317c0 100644 --- a/Composer/package.json +++ b/Composer/package.json @@ -12,7 +12,8 @@ "mkdirp": "^0.5.2", "selfsigned": "1.10.8", "serialize-javascript": "^3.1.0", - "set-value": "^3.0.2" + "set-value": "^3.0.2", + "terser-webpack-plugin": "^2.3.7" }, "engines": { "node": ">=12" @@ -24,33 +25,29 @@ "packages/electron-server", "packages/extension", "packages/extension-client", + "packages/form-dialogs", "packages/intellisense", - "packages/lib", "packages/lib/*", "packages/server", "packages/test-utils", - "packages/tools", "packages/tools/built-in-functions", - "packages/tools/language-servers", "packages/tools/language-servers/*", + "packages/types", "packages/ui-plugins/*" ], "scripts": { - "build": "node scripts/begin.js && yarn build:prod", - "build:prod": "yarn build:dev && yarn build:server && yarn build:client && yarn build:electron", - "build:dev": "yarn build:test && yarn build:lib && yarn build:tools && yarn build:extensions && yarn build:plugins && yarn l10n", - "build:test": "yarn workspace @bfc/test-utils build", - "build:lib": "yarn workspace @bfc/libs build:all", + "build": "node scripts/begin.js && yarn build:prod && yarn l10n", + "build:prod": "yarn build:dev && yarn build:client && yarn build:server && yarn build:electron", + "build:dev": "wsrun -ltm -x @bfc/electron-server -x @bfc/client -x @bfc/server -p @botframework-composer/* -p @bfc/* -c build && yarn build:plugins", "build:electron": "yarn workspace @bfc/electron-server build && yarn workspace @bfc/electron-server l10n", - "build:extensions": "wsrun -lt -p @bfc/extension @bfc/intellisense @bfc/extension-client @bfc/adaptive-form @bfc/adaptive-flow @bfc/ui-plugin-* -c build", "build:server": "yarn workspace @bfc/server build", "build:client": "yarn workspace @bfc/client build", - "build:tools": "yarn workspace @bfc/tools build:all", - "build:plugins": "yarn build:plugins:localpublish && yarn build:plugins:samples && yarn build:plugins:azurePublish && yarn build:plugins:runtimes", + "build:plugins": "yarn build:plugins:localpublish && yarn build:plugins:samples && yarn build:plugins:azurePublish && yarn build:plugins:runtimes && yarn build:plugins:vacore", "build:plugins:localpublish": "cd plugins/localPublish && yarn install && yarn build", "build:plugins:samples": "cd plugins/samples && yarn install && yarn build", "build:plugins:azurePublish": "cd plugins/azurePublish && yarn install && yarn build", "build:plugins:runtimes": "cd plugins/runtimes && yarn install && yarn build", + "build:plugins:vacore": "cd plugins/vacore && yarn install && yarn build", "start": "cross-env NODE_ENV=production PORT=3000 yarn start:server", "startall": "yarn start", "start:dev": "concurrently \"npm:start:client\" \"npm:start:server:dev\"", diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/AdaptiveFlowEditor.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/AdaptiveFlowEditor.test.tsx index 5717e536d3..3c411dfb34 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/AdaptiveFlowEditor.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/AdaptiveFlowEditor.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { EditorExtensionContext } from '@bfc/extension-client'; import AdaptiveFlowEditor from '../../src/adaptive-flow-editor/AdaptiveFlowEditor'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/components/IconMenu.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/components/IconMenu.test.tsx index bc2afc50e2..c759bf63c4 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/components/IconMenu.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/components/IconMenu.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent, findByText } from '@bfc/test-utils'; +import { render, fireEvent, findByText } from '@botframework-composer/test-utils'; import { IconMenu } from '../../../src/adaptive-flow-editor/components/IconMenu'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/components/KeyboardZone.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/components/KeyboardZone.test.tsx index 766bc2e211..d636746431 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/components/KeyboardZone.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/components/KeyboardZone.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import { enableKeyboardCommandAttributes } from '../../../src/adaptive-flow-editor/components/KeyboardZone'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/contexts/NodeRendererContext.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/contexts/NodeRendererContext.test.tsx index 40a9108bca..2ea28b9329 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/contexts/NodeRendererContext.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/contexts/NodeRendererContext.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React, { useContext } from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { DialogFactory } from '@bfc/shared'; import { NodeRendererContext } from '../../../src/adaptive-flow-editor/contexts/NodeRendererContext'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/contexts/SelectionContext.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/contexts/SelectionContext.test.tsx index 9f3e15288a..6385eb3b5a 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/contexts/SelectionContext.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/contexts/SelectionContext.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React, { useContext } from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { SelectionContext } from '../../../src/adaptive-flow-editor/contexts/SelectionContext'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/hooks/useEditorEventApi.test.ts b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/hooks/useEditorEventApi.test.ts index 64bfc2d68b..d198f87c69 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/hooks/useEditorEventApi.test.ts +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/hooks/useEditorEventApi.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { renderHook } from '@bfc/test-utils/lib/hooks'; +import { renderHook } from '@botframework-composer/test-utils/lib/hooks'; import { useEditorEventApi } from '../../../src/adaptive-flow-editor/hooks/useEditorEventApi'; import { ShellApiStub } from '../stubs/ShellApiStub'; @@ -9,7 +9,7 @@ import { defaultRendererContextValue } from '../../../src/adaptive-flow-editor/c import { defaultSelectionContextValue } from '../../../src/adaptive-flow-editor/contexts/SelectionContext'; import { NodeEventTypes } from '../../../src/adaptive-flow-renderer/constants/NodeEventTypes'; -describe('useSelectionEffect', () => { +describe('useEditorEventApi', () => { const hook = renderHook(() => useEditorEventApi( { diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/EdgeMenu.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/EdgeMenu.test.tsx index 85619b85f8..3c73a33342 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/EdgeMenu.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/EdgeMenu.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { DialogGroup } from '@bfc/shared'; import { EdgeMenu } from '../../../src/adaptive-flow-editor/renderers/EdgeMenu'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/ElementWrapper.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/ElementWrapper.test.tsx index 81a2850a7b..3452ef2e3f 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/ElementWrapper.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/ElementWrapper.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { ElementWrapper } from '../../../src/adaptive-flow-editor/renderers/ElementWrapper'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/NodeMenu.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/NodeMenu.test.tsx index bc770b8120..8b8d229806 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/NodeMenu.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/NodeMenu.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent, findAllByText } from '@bfc/test-utils'; +import { render, fireEvent, findAllByText } from '@botframework-composer/test-utils'; import { NodeMenu } from '../../../src/adaptive-flow-editor/renderers/NodeMenu'; import { NodeEventTypes } from '../../../src/adaptive-flow-renderer/constants/NodeEventTypes'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/NodeWrapper.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/NodeWrapper.test.tsx index e9fbf7bdb0..a48fb8e335 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/NodeWrapper.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/NodeWrapper.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import { EditorExtensionContext } from '@bfc/extension-client'; import { ActionNodeWrapper } from '../../../src/adaptive-flow-editor/renderers/NodeWrapper'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/stubs/ShellApiStub.ts b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/stubs/ShellApiStub.ts index 71a0939388..d2c5583c85 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/stubs/ShellApiStub.ts +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/stubs/ShellApiStub.ts @@ -21,11 +21,16 @@ export const ShellApiStub: ShellApi = { updateLgTemplate: fnPromise, removeLgTemplate: fnPromise, removeLgTemplates: fnPromise, + updateLgFile: fnPromise, + debouncedUpdateLgTemplate: fnPromise, getLuIntent: fn, getLuIntents: fnList, addLuIntent: fnPromise, updateLuIntent: fnPromise, removeLuIntent: fn, + updateLuFile: fnPromise, + debouncedUpdateLuIntent: fnPromise, + renameLuIntent: fnPromise, updateRegExIntent: fn, createDialog: fnPromise, addCoachMarkRef: fn, @@ -36,6 +41,21 @@ export const ShellApiStub: ShellApi = { addSkillDialog: fnPromise, announce: fn, displayManifestModal: fn, + constructAction: fnPromise, + constructActions: fnPromise, + copyAction: fnPromise, + copyActions: fnPromise, + deleteAction: fnPromise, + deleteActions: fnPromise, + actionsContainLuIntent: fn, + updateQnaContent: fnPromise, + renameRegExIntent: fnPromise, + updateIntentTrigger: fnPromise, + commitChanges: fnPromise, + updateDialogSchema: fnPromise, + createTrigger: fnPromise, + updateSkillSetting: fnPromise, + updateFlowZoomRate: fnPromise, }; describe('ShellApiStub', () => { diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/utils/getCustomSchema.test.ts b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/utils/getCustomSchema.test.ts index cd7e39d8be..320800e809 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/utils/getCustomSchema.test.ts +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/utils/getCustomSchema.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { OBISchema } from '@bfc/shared'; +import { JSONSchema7 } from '@bfc/shared'; import { getCustomSchema } from '../../../src/adaptive-flow-editor/utils/getCustomSchema'; @@ -23,7 +23,7 @@ describe('getCustomSchema', () => { description: 'Send an activity.', }, }, - } as OBISchema; + } as JSONSchema7; expect(getCustomSchema({ oneOf: [], definitions: {} }, ejected)).toEqual({ actions: { oneOf: [ @@ -59,7 +59,7 @@ describe('getCustomSchema', () => { description: 'My Trigger.', }, }, - } as OBISchema; + } as JSONSchema7; expect(getCustomSchema({ oneOf: [], definitions: {} }, ejected)).toEqual({ actions: { oneOf: [ diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/adaptive/AdaptiveDialog.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/adaptive/AdaptiveDialog.test.tsx index 05373342e5..7e86d9de73 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/adaptive/AdaptiveDialog.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/adaptive/AdaptiveDialog.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { AdaptiveKinds } from '../../../src/adaptive-flow-renderer/constants/AdaptiveKinds'; import { AdaptiveDialog } from '../../../src/adaptive-flow-renderer/adaptive/AdaptiveDialog'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/adaptive/AdaptiveTrigger.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/adaptive/AdaptiveTrigger.test.tsx index c78b8af21f..95d0abf1c4 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/adaptive/AdaptiveTrigger.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/adaptive/AdaptiveTrigger.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { AdaptiveTrigger } from '../../../src/adaptive-flow-renderer/adaptive/AdaptiveTrigger'; import { SchemaContext } from '../../../src/adaptive-flow-renderer/contexts/SchemaContext'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/ArrowLine.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/ArrowLine.test.tsx index 3cbf26a596..4b85ca7396 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/ArrowLine.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/ArrowLine.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { ArrowLine } from '../../../src/adaptive-flow-renderer/components/ArrowLine'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/Diamond.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/Diamond.test.tsx index f6c432ebea..2baff4eeab 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/Diamond.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/Diamond.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { Diamond } from '../../../src/adaptive-flow-renderer/components/Diamond'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/ElementMeasurer.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/ElementMeasurer.test.tsx index 3d2ffc038a..422a8ec856 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/ElementMeasurer.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/ElementMeasurer.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { ElementMeasurer } from '../../../src/adaptive-flow-renderer/components/ElementMeasurer'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/FlowEdges.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/FlowEdges.test.tsx index b732a772c8..e614c13bfd 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/FlowEdges.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/FlowEdges.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { FlowEdges } from '../../../src/adaptive-flow-renderer/components/FlowEdges'; import { Edge } from '../../../src/adaptive-flow-renderer/models/EdgeData'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/IconBrick.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/IconBrick.test.tsx index f239f1559a..5e82e52614 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/IconBrick.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/IconBrick.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { IconBrick } from '../../../src/adaptive-flow-renderer/components/IconBrick'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/LoopIndicator.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/LoopIndicator.test.tsx index 91e51ea650..501300dd54 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/LoopIndicator.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/LoopIndicator.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { LoopIndicator } from '../../../src/adaptive-flow-renderer/components/LoopIndicator'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/OffsetContainer.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/OffsetContainer.test.tsx index 52be45c268..7768c425c7 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/OffsetContainer.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/OffsetContainer.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { OffsetContainer } from '../../../src/adaptive-flow-renderer/components/OffsetContainer'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/SVGContainer.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/SVGContainer.test.tsx index 93ff5ff221..b8326b5c6d 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/SVGContainer.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/components/SVGContainer.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { SVGContainer } from '../../../src/adaptive-flow-renderer/components/SVGContainer'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/transformers/todoBot.main.json b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/transformers/todoBot.main.json index a511a6f0e6..b4173bd583 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/transformers/todoBot.main.json +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/transformers/todoBot.main.json @@ -4,7 +4,7 @@ "createdAt": "2019-07-04T08:14:01.139Z", "updatedAt": "2019-08-01T23:45:38.308Z", "id": "288769", - "description": "This is a bot that demonstrates how to manage a ToDo list using Regular Expressions." + "description": "This is a bot that demonstrates how to manage a ToDo list using regular expressions." }, "autoEndDialog": false, "recognizer": { diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/utils/EdgeUtil.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/utils/EdgeUtil.test.tsx index 0152af5054..b25f6f0d6a 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/utils/EdgeUtil.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/utils/EdgeUtil.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { drawSVGEdge } from '../../../src/adaptive-flow-renderer/utils/visual/EdgeUtil'; import { EdgeDirection } from '../../../src/adaptive-flow-renderer/models/EdgeData'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/utils/widgetRenderer.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/utils/widgetRenderer.test.tsx index 5047f26d24..f2a653e0ab 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/utils/widgetRenderer.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/utils/widgetRenderer.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { WidgetComponent, FlowEditorWidgetMap, FlowWidget } from '@bfc/extension-client'; import { renderUIWidget, UIWidgetContext } from '../../../src/adaptive-flow-renderer/utils/visual/widgetRenderer'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/ActionCard.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/ActionCard.test.tsx index 0ff082eae3..f64c3d0a84 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/ActionCard.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/ActionCard.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { ActionCard } from '../../../src/adaptive-flow-renderer/widgets'; import { AdaptiveKinds } from '../../../src/adaptive-flow-renderer/constants/AdaptiveKinds'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/DialogRef.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/DialogRef.test.tsx index 477089175c..fa6a90262d 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/DialogRef.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/DialogRef.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { DialogRef } from '../../../src/adaptive-flow-renderer/widgets'; import { AdaptiveKinds } from '../../../src/adaptive-flow-renderer/constants/AdaptiveKinds'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/ForeachWidget.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/ForeachWidget.test.tsx index c9f3f24d09..79880d665b 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/ForeachWidget.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/ForeachWidget.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { ForeachWidget } from '../../../src/adaptive-flow-renderer/widgets'; import { AdaptiveKinds } from '../../../src/adaptive-flow-renderer/constants/AdaptiveKinds'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/IfConditionWidget.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/IfConditionWidget.test.tsx index a6a872cf52..618405aa52 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/IfConditionWidget.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/IfConditionWidget.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { IfConditionWidget } from '../../../src/adaptive-flow-renderer/widgets'; import { AdaptiveKinds } from '../../../src/adaptive-flow-renderer/constants/AdaptiveKinds'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/PromptWidget.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/PromptWidget.test.tsx index 5d3c89a208..a8b03a7500 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/PromptWidget.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/PromptWidget.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { PromptWidget } from '../../../src/adaptive-flow-renderer/widgets'; import { AdaptiveKinds } from '../../../src/adaptive-flow-renderer/constants/AdaptiveKinds'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/SwitchConditionWidget.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/SwitchConditionWidget.test.tsx index 89c65e65a5..18a4aabde1 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/SwitchConditionWidget.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/SwitchConditionWidget.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { SwitchConditionWidget } from '../../../src/adaptive-flow-renderer/widgets'; import { AdaptiveKinds } from '../../../src/adaptive-flow-renderer/constants/AdaptiveKinds'; diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/icon.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/icon.test.tsx index 4bd4ead327..75269856fc 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/icon.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-renderer/widgets/icon.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { Icon } from '../../../src/adaptive-flow-renderer/widgets/ActionHeader/icon'; diff --git a/Composer/packages/adaptive-flow/demo/src/samples/todo/ToDoBot.main.json b/Composer/packages/adaptive-flow/demo/src/samples/todo/ToDoBot.main.json index 027d46b737..b4173bd583 100644 --- a/Composer/packages/adaptive-flow/demo/src/samples/todo/ToDoBot.main.json +++ b/Composer/packages/adaptive-flow/demo/src/samples/todo/ToDoBot.main.json @@ -4,44 +4,44 @@ "createdAt": "2019-07-04T08:14:01.139Z", "updatedAt": "2019-08-01T23:45:38.308Z", "id": "288769", - "description": "This is a bot that demonstrates how to manage a ToDo list using Regular Expressions." + "description": "This is a bot that demonstrates how to manage a ToDo list using regular expressions." }, "autoEndDialog": false, "recognizer": { "$kind": "Microsoft.RegexRecognizer", "intents": [ { - + "$kind": "Microsoft.IntentPattern", "intent": "AddIntent", - "pattern": "(?i)(?:add|create) .*(?:to-do|todo|task)(?: )?(?:named (?.*))?" + "pattern": "(?i)(?:add|create) .*(?:to-do|todo|task)(?: )?(?:named (?<title>.*))?" }, { - + "$kind": "Microsoft.IntentPattern", "intent": "ClearIntent", - "pattern": "(?i)(?:delete|remove|clear) (?:all|every) (?:to-dos|todos|tasks)" + "pattern": "(?i)(?:delete|remove|clear) (?:all|every) (?:to-dos|todos|tasks)" }, { - + "$kind": "Microsoft.IntentPattern", "intent": "DeleteIntent", - "pattern": "(?i)(?:delete|remove|clear) .*(?:to-do|todo|task)(?: )?(?:named (?<title>.*))?" + "pattern": "(?i)(?:delete|remove|clear) .*(?:to-do|todo|task)(?: )?(?:named (?<title>.*))?" }, { - + "$kind": "Microsoft.IntentPattern", "intent": "ShowIntent", - "pattern": "(?i)(?:show|see|view) .*(?:to-do|todo|task)" + "pattern": "(?i)(?:show|see|view) .*(?:to-do|todo|task)" }, { - + "$kind": "Microsoft.IntentPattern", "intent": "HelpIntent", - "pattern": "(?i)help" + "pattern": "(?i)help" }, { - + "$kind": "Microsoft.IntentPattern", "intent": "CancelIntent", "pattern": "(?i)cancel|never mind" @@ -190,4 +190,3 @@ ], "$schema": "../../app.schema" } - \ No newline at end of file diff --git a/Composer/packages/adaptive-flow/jest.config.js b/Composer/packages/adaptive-flow/jest.config.js index 0ce2e4e9f0..b842fc2841 100644 --- a/Composer/packages/adaptive-flow/jest.config.js +++ b/Composer/packages/adaptive-flow/jest.config.js @@ -1,3 +1,3 @@ -const { createConfig } = require('@bfc/test-utils'); +const { createConfig } = require('@botframework-composer/test-utils'); module.exports = createConfig('adaptive-form', 'react'); diff --git a/Composer/packages/adaptive-flow/package.json b/Composer/packages/adaptive-flow/package.json index adde0e7f0e..6eed65671c 100644 --- a/Composer/packages/adaptive-flow/package.json +++ b/Composer/packages/adaptive-flow/package.json @@ -45,7 +45,7 @@ "react": "16.13.1" }, "devDependencies": { - "@bfc/test-utils": "*", + "@botframework-composer/test-utils": "*", "@types/lodash": "^4.14.146", "@types/react": "16.9.23", "format-message": "^6.2.3", diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/AdaptiveFlowEditor.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/AdaptiveFlowEditor.tsx index 61c04ec80c..50dad3bc4c 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/AdaptiveFlowEditor.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/AdaptiveFlowEditor.tsx @@ -29,6 +29,7 @@ import { VisualEditorElementWrapper, } from './renderers'; import { useFlowUIOptions } from './hooks/useFlowUIOptions'; +import { ZoomZone } from './components/ZoomZone'; formatMessage.setup({ missingTranslation: 'ignore', @@ -46,8 +47,6 @@ const styles = css` left: 0; right: 0; - overflow: scroll; - border: 1px solid transparent; &:focus { @@ -60,8 +59,9 @@ export interface VisualDesignerProps { onFocus?: (event: React.FocusEvent<HTMLDivElement>) => void; onBlur?: (event: React.FocusEvent<HTMLDivElement>) => void; schema?: JSONSchema7; + data?: any; } -const VisualDesigner: React.FC<VisualDesignerProps> = ({ onFocus, onBlur, schema }): JSX.Element => { +const VisualDesigner: React.FC<VisualDesignerProps> = ({ onFocus, onBlur, schema, data: inputData }): JSX.Element => { const { shellApi, ...shellData } = useShellApi(); const { schema: schemaFromPlugins, widgets: widgetsFromPlugins } = useFlowUIOptions(); const { @@ -70,11 +70,13 @@ const VisualDesigner: React.FC<VisualDesignerProps> = ({ onFocus, onBlur, schema focusedActions, focusedTab, clipboardActions, - data: inputData, hosted, schemas, + flowZoomRate, } = shellData; + const { updateFlowZoomRate } = shellApi; + const dataCache = useRef({}); /** @@ -105,7 +107,7 @@ const VisualDesigner: React.FC<VisualDesignerProps> = ({ onFocus, onBlur, schema }; const customFlowSchema: FlowUISchema = nodeContext.customSchemas.reduce((result, s) => { - const definitionKeys: string[] = Object.keys(s.definitions); + const definitionKeys = Object.keys(s.definitions ?? {}); definitionKeys.forEach(($kind) => { result[$kind] = { widget: 'ActionHeader', @@ -116,7 +118,6 @@ const VisualDesigner: React.FC<VisualDesignerProps> = ({ onFocus, onBlur, schema }, {} as FlowUISchema); const divRef = useRef<HTMLDivElement>(null); - // send focus to the keyboard area when navigating to a new trigger useEffect(() => { divRef.current?.focus(); @@ -143,42 +144,44 @@ const VisualDesigner: React.FC<VisualDesignerProps> = ({ onFocus, onBlur, schema {...enableKeyboardCommandAttributes(handleCommand)} data-testid="visualdesigner-container" > - <SelectionContext.Provider value={selectionContext}> - <MarqueeSelection css={{ width: '100%', height: '100%' }} selection={selection}> - <div - className="flow-editor-container" - css={{ - width: '100%', - height: '100%', - padding: '48px 20px', - boxSizing: 'border-box', - }} - data-testid="flow-editor-container" - onClick={(e) => { - e.stopPropagation(); - handleEditorEvent(NodeEventTypes.Focus, { id: '' }); - }} - > - <AdaptiveDialog - activeTrigger={focusedEvent} - dialogData={data} - dialogId={dialogId} - renderers={{ - EdgeMenu: VisualEditorEdgeMenu, - NodeMenu: VisualEditorNodeMenu, - NodeWrapper: VisualEditorNodeWrapper, - ElementWrapper: VisualEditorElementWrapper, + <ZoomZone flowZoomRate={flowZoomRate} focusedId={focusedId} updateFlowZoomRate={updateFlowZoomRate}> + <SelectionContext.Provider value={selectionContext}> + <MarqueeSelection selection={selection}> + <div + className="flow-editor-container" + css={{ + width: '100%', + height: '100%', + padding: '48px 20px', + boxSizing: 'border-box', }} - schema={{ ...schemaFromPlugins, ...customFlowSchema }} - widgets={widgetsFromPlugins} - onEvent={(eventName, eventData) => { - divRef.current?.focus({ preventScroll: true }); - handleEditorEvent(eventName, eventData); + data-testid="flow-editor-container" + onClick={(e) => { + e.stopPropagation(); + handleEditorEvent(NodeEventTypes.Focus, { id: '' }); }} - /> - </div> - </MarqueeSelection> - </SelectionContext.Provider> + > + <AdaptiveDialog + activeTrigger={focusedEvent} + dialogData={data} + dialogId={dialogId} + renderers={{ + EdgeMenu: VisualEditorEdgeMenu, + NodeMenu: VisualEditorNodeMenu, + NodeWrapper: VisualEditorNodeWrapper, + ElementWrapper: VisualEditorElementWrapper, + }} + schema={{ ...schemaFromPlugins, ...customFlowSchema }} + widgets={widgetsFromPlugins} + onEvent={(eventName, eventData) => { + divRef.current?.focus({ preventScroll: true }); + handleEditorEvent(eventName, eventData); + }} + /> + </div> + </MarqueeSelection> + </SelectionContext.Provider> + </ZoomZone> </div> </SelfHostContext.Provider> </NodeRendererContext.Provider> diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/ZoomZone.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/ZoomZone.tsx new file mode 100644 index 0000000000..0f3788a39f --- /dev/null +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/ZoomZone.tsx @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import { useRef, useEffect, ReactNode } from 'react'; +import { ZoomInfo } from '@bfc/shared'; +import { IconButton, IButtonStyles } from 'office-ui-fabric-react/lib/Button'; +import { IIconProps } from 'office-ui-fabric-react/lib/Icon'; + +import { scrollNodeIntoView } from '../utils/scrollNodeIntoView'; +import { AttrNames } from '../constants/ElementAttributes'; + +function scrollZoom(delta: number, rateList: number[], maxRate: number, minRate: number, currentRate: number): number { + let rate: number = currentRate; + + if (delta < 0) { + // Zoom in + rate = rateList[rateList.indexOf(currentRate) + 1] || rate; + rate = Math.min(maxRate, rate); + } else if (delta > 0) { + // Zoom out + rate = rateList[rateList.indexOf(currentRate) - 1] || rate; + rate = Math.max(minRate, rate); + } else { + rate = 1; + } + + return rate; +} + +interface ZoomZoneProps { + flowZoomRate: ZoomInfo; + focusedId: string; + updateFlowZoomRate: (currentRate: number) => void; + children?: ReactNode; +} + +export const ZoomZone: React.FC<ZoomZoneProps> = ({ flowZoomRate, focusedId, updateFlowZoomRate, children }) => { + const divRef = useRef<HTMLDivElement>(null); + const { rateList, maxRate, minRate, currentRate } = flowZoomRate || { + rateList: [0.5, 1, 3], + maxRate: 3, + minRate: 0.5, + currentRate: 1, + }; + const onWheel = (event: WheelEvent) => { + if (event.ctrlKey) { + event.preventDefault(); + event.stopPropagation(); + handleZoom(event.deltaY); + } + }; + + const handleZoom = (delta: number) => { + const rate = scrollZoom(delta, rateList, maxRate, minRate, currentRate); + + updateFlowZoomRate(rate); + }; + + const container = divRef.current as HTMLElement; + useEffect(() => { + if (!container) return; + const target = container.children[0] as HTMLElement; + target.style.transform = `scale(${currentRate})`; + target.style.transformOrigin = 'top left'; + container.scroll({ + top: (container.scrollWidth - container.clientWidth) / 2, + left: (container.scrollHeight - container.clientHeight) / 2, + }); + + if (currentRate === 1) { + scrollNodeIntoView(`[${AttrNames.SelectedId}="${focusedId}"]`); + } + }, [currentRate]); + + const buttonRender = () => { + const buttonBoxStyle = css({ position: 'absolute', left: '25px', bottom: '25px', width: '35px' }); + const iconStyle = (zoom: string): IIconProps => { + return zoom === 'in' + ? { iconName: 'ZoomIn', styles: { root: { color: '#fff' } } } + : { iconName: 'ZoomOut', styles: { root: { color: '#fff' } } }; + }; + const buttonStyle: IButtonStyles = { + root: { + width: '35px', + height: '35px', + background: 'rgba(44, 41, 41, 0.8)', + borderRadius: '2px', + margin: '2.5px 0', + selectors: { + ':disabled': { + backgroundColor: '#BDBDBD', + }, + }, + }, + rootHovered: { + backgroundColor: 'rgba(44, 41, 41, 0.8)', + }, + rootPressed: { + backgroundColor: 'rgba(44, 41, 41, 0.8)', + }, + }; + return ( + <div css={buttonBoxStyle}> + <IconButton + disabled={currentRate === maxRate} + iconProps={iconStyle('in')} + styles={buttonStyle} + onClick={() => handleZoom(-100)} + ></IconButton> + <IconButton + disabled={currentRate === minRate} + iconProps={iconStyle('out')} + styles={buttonStyle} + onClick={() => handleZoom(100)} + ></IconButton> + <IconButton + styles={buttonStyle} + onClick={() => { + handleZoom(0); + container.scrollTo({ top: 0 }); + }} + > + <svg fill="none" height="15" viewBox="0 0 15 15" width="15" xmlns="http://www.w3.org/2000/svg"> + <path + d="M7.5 5.5C7.77604 5.5 8.03385 5.55208 8.27344 5.65625C8.51823 5.76042 8.73177 5.90365 8.91406 6.08594C9.09635 6.26823 9.23958 6.48177 9.34375 6.72656C9.44792 6.96615 9.5 7.22396 9.5 7.5C9.5 7.77604 9.44792 8.03646 9.34375 8.28125C9.23958 8.52083 9.09635 8.73177 8.91406 8.91406C8.73177 9.09635 8.51823 9.23958 8.27344 9.34375C8.03385 9.44792 7.77604 9.5 7.5 9.5C7.22396 9.5 6.96354 9.44792 6.71875 9.34375C6.47917 9.23958 6.26823 9.09635 6.08594 8.91406C5.90365 8.73177 5.76042 8.52083 5.65625 8.28125C5.55208 8.03646 5.5 7.77604 5.5 7.5C5.5 7.22396 5.55208 6.96615 5.65625 6.72656C5.76042 6.48177 5.90365 6.26823 6.08594 6.08594C6.26823 5.90365 6.47917 5.76042 6.71875 5.65625C6.96354 5.55208 7.22396 5.5 7.5 5.5ZM15 8H13.4766C13.4401 8.47917 13.3464 8.94531 13.1953 9.39844C13.0495 9.84635 12.8516 10.2682 12.6016 10.6641C12.3568 11.0547 12.0703 11.4141 11.7422 11.7422C11.4141 12.0703 11.0521 12.3594 10.6562 12.6094C10.2656 12.8542 9.84375 13.0521 9.39062 13.2031C8.94271 13.349 8.47917 13.4401 8 13.4766V15H7V13.4766C6.52083 13.4401 6.05469 13.349 5.60156 13.2031C5.15365 13.0521 4.73177 12.8542 4.33594 12.6094C3.94531 12.3594 3.58594 12.0703 3.25781 11.7422C2.92969 11.4141 2.64062 11.0547 2.39062 10.6641C2.14583 10.2682 1.94792 9.84635 1.79688 9.39844C1.65104 8.95052 1.5599 8.48438 1.52344 8H0V7H1.52344C1.5599 6.52083 1.65104 6.05729 1.79688 5.60938C1.94792 5.15625 2.14583 4.73438 2.39062 4.34375C2.64062 3.94792 2.92969 3.58594 3.25781 3.25781C3.58594 2.92969 3.94531 2.64323 4.33594 2.39844C4.73177 2.14844 5.15365 1.95052 5.60156 1.80469C6.04948 1.65365 6.51562 1.5599 7 1.52344V0H8V1.52344C8.48438 1.5599 8.95052 1.65365 9.39844 1.80469C9.84635 1.95052 10.2656 2.14844 10.6562 2.39844C11.0521 2.64323 11.4141 2.92969 11.7422 3.25781C12.0703 3.58594 12.3568 3.94792 12.6016 4.34375C12.8516 4.73438 13.0495 5.15625 13.1953 5.60938C13.3464 6.05729 13.4401 6.52083 13.4766 7H15V8ZM7.5 12.5C7.95833 12.5 8.40104 12.4401 8.82812 12.3203C9.25521 12.2005 9.65365 12.0339 10.0234 11.8203C10.3932 11.6016 10.7292 11.3411 11.0312 11.0391C11.3385 10.7318 11.599 10.3932 11.8125 10.0234C12.0312 9.65365 12.2005 9.25521 12.3203 8.82812C12.4401 8.40104 12.5 7.95833 12.5 7.5C12.5 7.04167 12.4401 6.59896 12.3203 6.17188C12.2005 5.74479 12.0312 5.34635 11.8125 4.97656C11.599 4.60677 11.3385 4.27083 11.0312 3.96875C10.7292 3.66146 10.3932 3.40104 10.0234 3.1875C9.65365 2.96875 9.25521 2.79948 8.82812 2.67969C8.40104 2.5599 7.95833 2.5 7.5 2.5C7.04167 2.5 6.59896 2.5599 6.17188 2.67969C5.74479 2.79948 5.34635 2.96875 4.97656 3.1875C4.60677 3.40104 4.26823 3.66146 3.96094 3.96875C3.65885 4.27083 3.39844 4.60677 3.17969 4.97656C2.96615 5.34635 2.79948 5.74479 2.67969 6.17188C2.5599 6.59896 2.5 7.04167 2.5 7.5C2.5 7.95833 2.5599 8.40104 2.67969 8.82812C2.79948 9.25521 2.96615 9.65365 3.17969 10.0234C3.39844 10.3932 3.65885 10.7318 3.96094 11.0391C4.26823 11.3411 4.60677 11.6016 4.97656 11.8203C5.34635 12.0339 5.74479 12.2005 6.17188 12.3203C6.59896 12.4401 7.04167 12.5 7.5 12.5Z" + fill="white" + /> + </svg> + </IconButton> + </div> + ); + }; + + // Using ref and eventListener instead of <div @wheel='xxx()' /> because passive property can not be set in <div @wheel='xxx()' /> + useEffect(() => { + if (flowZoomRate) { + divRef.current?.addEventListener('wheel', onWheel, { passive: false }); + } + return () => divRef.current?.removeEventListener('wheel', onWheel); + }, [flowZoomRate]); + + return ( + <div ref={divRef} css={{ overflow: 'scroll', width: '100%', height: '100%' }}> + {children} + {buttonRender()} + </div> + ); +}; diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/contexts/NodeRendererContext.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/contexts/NodeRendererContext.ts index d78c9e64e5..8f2f5f703c 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/contexts/NodeRendererContext.ts +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/contexts/NodeRendererContext.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { DialogFactory, OBISchema } from '@bfc/shared'; +import { DialogFactory, JSONSchema7 } from '@bfc/shared'; export interface NodeRendererContextValue { focusedId?: string; @@ -10,7 +10,7 @@ export interface NodeRendererContextValue { focusedTab?: string; clipboardActions: any[]; dialogFactory: DialogFactory; - customSchemas: OBISchema[]; + customSchemas: JSONSchema7[]; } export const defaultRendererContextValue = { diff --git a/Composer/packages/extension-client/src/hooks/useDialogEditApi.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useDialogEditApi.ts similarity index 90% rename from Composer/packages/extension-client/src/hooks/useDialogEditApi.ts rename to Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useDialogEditApi.ts index 0474e00945..62986695ac 100644 --- a/Composer/packages/extension-client/src/hooks/useDialogEditApi.ts +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useDialogEditApi.ts @@ -1,10 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { BaseSchema, DialogUtils, ShellApi } from '@bfc/shared'; -import { disableNodes, enableNodes } from '@bfc/shared/lib/dialogUtils'; - -import { useActionApi } from './useActionApi'; +import { BaseSchema, ShellApi } from '@botframework-composer/types'; +import { DialogUtils } from '@bfc/shared'; export interface DialogApiContext { copyAction: (actionId: string) => BaseSchema; @@ -13,10 +11,10 @@ export interface DialogApiContext { deleteActions: (actionIds: BaseSchema[]) => BaseSchema[]; } -const { appendNodesAfter, queryNodes, insertNodes, deleteNode, deleteNodes } = DialogUtils; +const { disableNodes, enableNodes, appendNodesAfter, queryNodes, insertNodes, deleteNode, deleteNodes } = DialogUtils; export function useDialogEditApi(shellApi: ShellApi) { - const { constructActions, copyActions, deleteAction, deleteActions } = useActionApi(shellApi); + const { constructActions, copyActions, deleteAction, deleteActions } = shellApi; async function insertActions( dialogId: string, diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useEditorEventApi.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useEditorEventApi.ts index 3477d7573f..a1bbe28bd8 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useEditorEventApi.ts +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useEditorEventApi.ts @@ -3,7 +3,7 @@ import { DialogUtils, SDKKinds, ShellApi, registerEditorAPI } from '@bfc/shared'; import get from 'lodash/get'; -import { useDialogEditApi, useDialogApi, useActionApi } from '@bfc/extension-client'; +import { useDialogApi } from '@bfc/extension-client'; // TODO: leak of visual-sdk domain (designerCache) import { designerCache } from '../../adaptive-flow-renderer/utils/visual/DesignerCache'; @@ -18,10 +18,13 @@ import { NodeRendererContextValue } from '../contexts/NodeRendererContext'; import { SelectionContextData } from '../contexts/SelectionContext'; import { calculateRangeSelection } from '../utils/calculateRangeSelection'; +import { useDialogEditApi } from './useDialogEditApi'; + export const useEditorEventApi = ( state: { path: string; data: any; nodeContext: NodeRendererContextValue; selectionContext: SelectionContextData }, shellApi: ShellApi ) => { + const { actionsContainLuIntent } = shellApi; const { insertAction, insertActions, @@ -35,7 +38,6 @@ export const useEditorEventApi = ( updateRecognizer, } = useDialogEditApi(shellApi); const { createDialog, readDialog, updateDialog } = useDialogApi(shellApi); - const { actionsContainLuIntent } = useActionApi(shellApi); const { path, data, nodeContext, selectionContext } = state; const { focusedId, focusedEvent, clipboardActions, dialogFactory } = nodeContext; const { selectedIds, setSelectedIds, selectableElements } = selectionContext; diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useFlowUIOptions.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useFlowUIOptions.ts index bb4ed6e910..4a1487f310 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useFlowUIOptions.ts +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useFlowUIOptions.ts @@ -7,5 +7,5 @@ export function useFlowUIOptions() { const { plugins } = useShellApi(); const schema = useFlowConfig(); - return { widgets: plugins.flowWidgets ?? {}, schema }; + return { widgets: plugins?.widgets?.flow ?? {}, schema }; } diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/utils/getCustomSchema.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/utils/getCustomSchema.ts index 959cec5e7f..e88810fef8 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/utils/getCustomSchema.ts +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/utils/getCustomSchema.ts @@ -1,24 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { OBISchema, SDKKinds } from '@bfc/shared'; +import { JSONSchema7, SDKKinds } from '@bfc/shared'; import pickBy from 'lodash/pickBy'; interface CustomSchemaSet { - actions?: OBISchema; - triggers?: OBISchema; - recognizers?: OBISchema; + actions?: JSONSchema7; + triggers?: JSONSchema7; + recognizers?: JSONSchema7; } -const pickSchema = ( - picked$kinds: SDKKinds[], - sourceSchema: { [key in SDKKinds]: OBISchema } -): OBISchema | undefined => { +type SourceSchema = { [key in SDKKinds]: JSONSchema7 }; + +const pickSchema = (picked$kinds: SDKKinds[], sourceSchema: SourceSchema): JSONSchema7 | undefined => { if (!Array.isArray(picked$kinds) || picked$kinds.length === 0) return undefined; const pickedSchema = picked$kinds.reduce( (schema, $kind) => { const definition = sourceSchema[$kind]; + schema.definitions = schema.definitions ?? {}; schema.definitions[$kind] = definition; schema.oneOf?.push({ title: definition.title || $kind, @@ -30,11 +30,13 @@ const pickSchema = ( { oneOf: [], definitions: {}, - } as OBISchema + } as JSONSchema7 ); // Sort `oneOf` list alphabetically - pickedSchema.oneOf?.sort((a, b) => (a.$ref < b.$ref ? -1 : 1)); + pickedSchema.oneOf?.sort((a, b) => { + return (a.$ref ?? '') < (b.$ref ?? '') ? -1 : 1; + }); return pickedSchema; }; @@ -47,18 +49,18 @@ const roleImplementsInterface = (interfaceName: SDKKinds, $role?: SchemaRole): b return false; }; -const isActionSchema = (schema: OBISchema) => roleImplementsInterface(SDKKinds.IDialog, schema.$role); -const isTriggerSchema = (schema: OBISchema) => roleImplementsInterface(SDKKinds.ITrigger, schema.$role); -const isRecognizerSchema = (schema: OBISchema) => +const isActionSchema = (schema: JSONSchema7) => roleImplementsInterface(SDKKinds.IDialog, schema.$role); +const isTriggerSchema = (schema: JSONSchema7) => roleImplementsInterface(SDKKinds.ITrigger, schema.$role); +const isRecognizerSchema = (schema: JSONSchema7) => roleImplementsInterface(SDKKinds.IRecognizer, schema.$role) || roleImplementsInterface(SDKKinds.IEntityRecognizer, schema.$role); -export const getCustomSchema = (baseSchema?: OBISchema, ejectedSchema?: OBISchema): CustomSchemaSet => { +export const getCustomSchema = (baseSchema?: JSONSchema7, ejectedSchema?: JSONSchema7): CustomSchemaSet => { if (!baseSchema || !ejectedSchema) return {}; if (typeof baseSchema.definitions !== 'object' || typeof ejectedSchema.definitions !== 'object') return {}; const baseDefinitions = baseSchema.definitions; - const ejectedDefinitions = ejectedSchema.definitions; + const ejectedDefinitions = ejectedSchema.definitions ?? {}; const baseKindHash = Object.keys(baseDefinitions).reduce((hash, $kind) => { hash[$kind] = true; @@ -75,9 +77,9 @@ export const getCustomSchema = (baseSchema?: OBISchema, ejectedSchema?: OBISchem return pickBy( { - actions: pickSchema(actionKinds, ejectedDefinitions), - triggers: pickSchema(triggerKinds, ejectedDefinitions), - recognizers: pickSchema(recognizerKinds, ejectedDefinitions), + actions: pickSchema(actionKinds, ejectedDefinitions as SourceSchema), + triggers: pickSchema(triggerKinds, ejectedDefinitions as SourceSchema), + recognizers: pickSchema(recognizerKinds, ejectedDefinitions as SourceSchema), }, (v) => v !== undefined ); diff --git a/Composer/packages/adaptive-form/jest.config.js b/Composer/packages/adaptive-form/jest.config.js index b5a508c259..a205ac1acd 100644 --- a/Composer/packages/adaptive-form/jest.config.js +++ b/Composer/packages/adaptive-form/jest.config.js @@ -2,7 +2,7 @@ // Licensed under the MIT License. /* eslint-disable @typescript-eslint/no-var-requires */ -const { createConfig } = require('@bfc/test-utils'); +const { createConfig } = require('@botframework-composer/test-utils'); module.exports = createConfig('adaptive-form', 'react', { coveragePathIgnorePatterns: ['defaultRoleSchema.ts', 'defaultUiSchema.ts'], diff --git a/Composer/packages/adaptive-form/package.json b/Composer/packages/adaptive-form/package.json index 2a91119915..1dcbf60460 100644 --- a/Composer/packages/adaptive-form/package.json +++ b/Composer/packages/adaptive-form/package.json @@ -33,7 +33,7 @@ "@bfc/code-editor": "*", "@bfc/extension-client": "*", "@bfc/intellisense": "*", - "@bfc/test-utils": "*", + "@botframework-composer/test-utils": "*", "@types/lodash": "^4.14.149", "@types/react": "16.9.23", "format-message": "^6.2.3", diff --git a/Composer/packages/adaptive-form/src/components/AdaptiveForm.tsx b/Composer/packages/adaptive-form/src/components/AdaptiveForm.tsx index 1899521ae8..c30f3c43cf 100644 --- a/Composer/packages/adaptive-form/src/components/AdaptiveForm.tsx +++ b/Composer/packages/adaptive-form/src/components/AdaptiveForm.tsx @@ -60,7 +60,7 @@ export const AdaptiveForm: React.FC<AdaptiveFormProps> = function AdaptiveForm(p <SchemaField definitions={schema?.definitions} depth={-1} - id="root" + id={formData.$designer?.id ? `root[${formData.$designer?.id}]` : 'root'} name="root" rawErrors={errors} schema={schema} diff --git a/Composer/packages/adaptive-form/src/components/CollapseField.tsx b/Composer/packages/adaptive-form/src/components/CollapseField.tsx index 9a0cf377a2..7869702a8a 100644 --- a/Composer/packages/adaptive-form/src/components/CollapseField.tsx +++ b/Composer/packages/adaptive-form/src/components/CollapseField.tsx @@ -4,31 +4,35 @@ /** @jsx jsx */ import { css, jsx } from '@emotion/core'; import { Fragment, useState, useEffect, useLayoutEffect, useRef } from 'react'; -import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { FontSizes, FontWeights } from 'office-ui-fabric-react/lib/Styling'; import { IconButton } from 'office-ui-fabric-react/lib/Button'; import { Label } from 'office-ui-fabric-react/lib/Label'; -import { Separator } from 'office-ui-fabric-react/lib/Separator'; import { NeutralColors } from '@uifabric/fluent-theme'; import formatMessage from 'format-message'; const styles = { + description: css` + font-size: ${FontSizes.medium}; + `, transition: css` transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; `, header: css` + background-color: #eff6fc; display: flex; - margin: 0 8px; + margin: 4px 0px; align-items: center; `, }; interface CollapseField { defaultExpanded?: boolean; + description?: string; title?: string | boolean; } -export const CollapseField: React.FC<CollapseField> = ({ children, defaultExpanded, title }) => { +export const CollapseField: React.FC<CollapseField> = ({ children, description, defaultExpanded, title }) => { const [isOpen, setIsOpen] = useState(!!defaultExpanded); return ( @@ -45,11 +49,15 @@ export const CollapseField: React.FC<CollapseField> = ({ children, defaultExpand > <IconButton iconProps={{ iconName: isOpen ? 'ChevronDown' : 'ChevronRight' }} - styles={{ root: { color: NeutralColors.gray150 } }} + styles={{ + root: { color: NeutralColors.gray150 }, + rootHovered: { backgroundColor: 'transparent' }, + rootFocused: { backgroundColor: 'transparent' }, + }} /> {title && <Label styles={{ root: { fontWeight: FontWeights.semibold } }}>{title}</Label>} + {description && <span css={styles.description}> - {description}</span>} </div> - <Separator styles={{ root: { height: 0 } }} /> <div> <CollapseContent isOpen={isOpen}>{children}</CollapseContent> </div> diff --git a/Composer/packages/adaptive-form/src/components/__tests__/ErrorMessage.test.tsx b/Composer/packages/adaptive-form/src/components/__tests__/ErrorMessage.test.tsx index 678436d1f0..77262d4655 100644 --- a/Composer/packages/adaptive-form/src/components/__tests__/ErrorMessage.test.tsx +++ b/Composer/packages/adaptive-form/src/components/__tests__/ErrorMessage.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, waitFor } from '@bfc/test-utils'; +import { render, waitFor } from '@botframework-composer/test-utils'; import { ErrorMessage } from '../ErrorMessage'; diff --git a/Composer/packages/adaptive-form/src/components/__tests__/FieldLabel.test.tsx b/Composer/packages/adaptive-form/src/components/__tests__/FieldLabel.test.tsx index 5d029b76f6..6b100d1e34 100644 --- a/Composer/packages/adaptive-form/src/components/__tests__/FieldLabel.test.tsx +++ b/Composer/packages/adaptive-form/src/components/__tests__/FieldLabel.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { FieldLabel } from '../FieldLabel'; diff --git a/Composer/packages/adaptive-form/src/components/__tests__/FormRow.test.tsx b/Composer/packages/adaptive-form/src/components/__tests__/FormRow.test.tsx index c494ac8f94..676d17c175 100644 --- a/Composer/packages/adaptive-form/src/components/__tests__/FormRow.test.tsx +++ b/Composer/packages/adaptive-form/src/components/__tests__/FormRow.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { FormRow, FormRowProps, getRowProps } from '../FormRow'; diff --git a/Composer/packages/adaptive-form/src/components/__tests__/LoadingTimeout.test.tsx b/Composer/packages/adaptive-form/src/components/__tests__/LoadingTimeout.test.tsx index 3ee810fbf4..83ed0f608a 100644 --- a/Composer/packages/adaptive-form/src/components/__tests__/LoadingTimeout.test.tsx +++ b/Composer/packages/adaptive-form/src/components/__tests__/LoadingTimeout.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, act } from '@bfc/test-utils'; +import { render, act } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { LoadingTimeout } from '../LoadingTimeout'; diff --git a/Composer/packages/adaptive-form/src/components/__tests__/SchemaField.test.tsx b/Composer/packages/adaptive-form/src/components/__tests__/SchemaField.test.tsx index 76bd26f4f1..6ec0d591f2 100644 --- a/Composer/packages/adaptive-form/src/components/__tests__/SchemaField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/__tests__/SchemaField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, waitFor, fireEvent } from '@bfc/test-utils'; +import { render, waitFor, fireEvent } from '@botframework-composer/test-utils'; import { FieldProps, useFormConfig } from '@bfc/extension-client'; import assign from 'lodash/assign'; diff --git a/Composer/packages/adaptive-form/src/components/fields/ExpressionField/__tests__/ExpressionField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/ExpressionField/__tests__/ExpressionField.test.tsx index 837860747d..03ebe6efcd 100644 --- a/Composer/packages/adaptive-form/src/components/fields/ExpressionField/__tests__/ExpressionField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/ExpressionField/__tests__/ExpressionField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { ExpressionField } from '../ExpressionField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/ExpressionField/utils.ts b/Composer/packages/adaptive-form/src/components/fields/ExpressionField/utils.ts index 1bf98d71f2..5a4448fcf7 100644 --- a/Composer/packages/adaptive-form/src/components/fields/ExpressionField/utils.ts +++ b/Composer/packages/adaptive-form/src/components/fields/ExpressionField/utils.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { JSONSchema7, JSONSchema7Definition, SchemaDefinitions } from '@bfc/extension-client'; +import { JSONSchema7, SchemaDefinitions } from '@bfc/extension-client'; import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; import { resolveRef, getValueType } from '../../../utils'; @@ -43,7 +43,7 @@ function getOptionLabel(schema: JSONSchema7, parent: JSONSchema7): string { } export function getOneOfOptions( - oneOf: JSONSchema7Definition[], + oneOf: JSONSchema7[], parentSchema: JSONSchema7, definitions?: SchemaDefinitions ): SchemaOption[] { diff --git a/Composer/packages/adaptive-form/src/components/fields/FieldSets.tsx b/Composer/packages/adaptive-form/src/components/fields/FieldSets.tsx index 946207b84d..369a0f1bef 100644 --- a/Composer/packages/adaptive-form/src/components/fields/FieldSets.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/FieldSets.tsx @@ -16,10 +16,11 @@ const Fieldsets: React.FC<FieldProps<object>> = (props) => { return ( <React.Fragment> - {fieldsets.map(({ schema, uiOptions, title, defaultExpanded }, key) => ( + {fieldsets.map(({ schema, uiOptions, description, title, defaultExpanded }, key) => ( <CollapseField key={key} defaultExpanded={defaultExpanded} + description={typeof description === 'function' ? description(value) : description} title={typeof title === 'function' ? title(value) : title} > <ObjectField {...props} schema={schema} uiOptions={uiOptions} /> diff --git a/Composer/packages/adaptive-form/src/components/fields/ObjectArrayField.tsx b/Composer/packages/adaptive-form/src/components/fields/ObjectArrayField.tsx index 169c0a7ef9..51cd4ebedc 100644 --- a/Composer/packages/adaptive-form/src/components/fields/ObjectArrayField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/ObjectArrayField.tsx @@ -6,7 +6,6 @@ import { jsx } from '@emotion/core'; import React, { useState, useMemo, useRef } from 'react'; import { FieldProps, useShellApi } from '@bfc/extension-client'; import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; -import { JSONSchema7 } from 'json-schema'; import { IconButton } from 'office-ui-fabric-react/lib/Button'; import { TextField, ITextField } from 'office-ui-fabric-react/lib/TextField'; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; @@ -91,7 +90,7 @@ const ObjectArrayField: React.FC<FieldProps<any[]>> = (props) => { allOrderProps.length > 2 || orderedProperties.some((property) => Array.isArray(property)) || Object.entries(properties).some(([key, propSchema]) => { - const resolved = resolveRef(propSchema as JSONSchema7, props.definitions); + const resolved = resolveRef(propSchema, props.definitions); return allOrderProps.includes(key) && resolved.$role === 'expression'; }) ); @@ -134,7 +133,7 @@ const ObjectArrayField: React.FC<FieldProps<any[]>> = (props) => { {...props} transparentBorder id={`${id}.${idx}`} - schema={itemSchema as JSONSchema7} + schema={itemSchema} stackArrayItems={stackArrayItems} value={item.value} {...getArrayItemProps(arrayItems, idx, handleChange)} diff --git a/Composer/packages/adaptive-form/src/components/fields/OneOfField/__tests__/OneOfField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/OneOfField/__tests__/OneOfField.test.tsx index e1811559c4..9ca3a80365 100644 --- a/Composer/packages/adaptive-form/src/components/fields/OneOfField/__tests__/OneOfField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/OneOfField/__tests__/OneOfField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent, screen } from '@bfc/test-utils'; +import { render, fireEvent, screen } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { OneOfField } from '../OneOfField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/OneOfField/utils.ts b/Composer/packages/adaptive-form/src/components/fields/OneOfField/utils.ts index 0bfbadf8b7..1e744e55cc 100644 --- a/Composer/packages/adaptive-form/src/components/fields/OneOfField/utils.ts +++ b/Composer/packages/adaptive-form/src/components/fields/OneOfField/utils.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { FieldProps, JSONSchema7, JSONSchema7Definition } from '@bfc/extension-client'; +import { FieldProps, JSONSchema7, SchemaDefinitions } from '@bfc/extension-client'; import merge from 'lodash/merge'; import omit from 'lodash/omit'; import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; @@ -23,10 +23,7 @@ function getOptionLabel(schema: JSONSchema7): string { return type || 'unknown'; } -export function getOptions( - schema: JSONSchema7, - definitions?: { [key: string]: JSONSchema7Definition } -): IDropdownOption[] { +export function getOptions(schema: JSONSchema7, definitions?: SchemaDefinitions): IDropdownOption[] { const { type, oneOf } = schema; if (type && Array.isArray(type)) { diff --git a/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/__tests__/ObjectItem.test.tsx b/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/__tests__/ObjectItem.test.tsx index 1443c42dd3..4c30bed0a5 100644 --- a/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/__tests__/ObjectItem.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/__tests__/ObjectItem.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent, getByText, waitFor } from '@bfc/test-utils'; +import { render, fireEvent, getByText, waitFor } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { ObjectItem } from '../ObjectItem'; diff --git a/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/__tests__/OpenObjectField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/__tests__/OpenObjectField.test.tsx index 2df2741b7b..0e7d2a785c 100644 --- a/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/__tests__/OpenObjectField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/__tests__/OpenObjectField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent, within, getByText } from '@bfc/test-utils'; +import { render, fireEvent, within, getByText } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { OpenObjectField } from '../OpenObjectField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/PivotFieldsets.tsx b/Composer/packages/adaptive-form/src/components/fields/PivotFieldsets.tsx index 8cb471c49a..755a47382d 100644 --- a/Composer/packages/adaptive-form/src/components/fields/PivotFieldsets.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/PivotFieldsets.tsx @@ -5,11 +5,9 @@ import React from 'react'; import { FieldProps } from '@bfc/extension-client'; import { IPivotStyles, Pivot, PivotItem, PivotLinkSize } from 'office-ui-fabric-react/lib/components/Pivot'; -import { getFieldsets } from '../../utils'; +import { getFieldsets, resolveFieldWidget } from '../../utils'; import { useAdaptiveFormContext } from '../../AdaptiveFormContext'; -import { ObjectField } from './ObjectField'; - const styles: { tabs: Partial<IPivotStyles> } = { tabs: { root: { @@ -19,6 +17,9 @@ const styles: { tabs: Partial<IPivotStyles> } = { link: { flex: 1, }, + itemContainer: { + paddingTop: '8px', + }, linkIsSelected: { flex: 1, }, @@ -39,11 +40,15 @@ const PivotFieldsets: React.FC<FieldProps<object>> = (props) => { return ( <div> <Pivot linkSize={PivotLinkSize.large} selectedKey={focusedTab} styles={styles.tabs} onLinkClick={handleTabChange}> - {fieldsets.map(({ schema, uiOptions, title, itemKey }) => ( - <PivotItem key={itemKey} headerText={typeof title === 'function' ? title(value) : title} itemKey={itemKey}> - <ObjectField {...props} schema={schema} uiOptions={uiOptions} /> - </PivotItem> - ))} + {fieldsets.map(({ schema, uiOptions, title, itemKey }) => { + const Field = resolveFieldWidget(schema, uiOptions); + + return ( + <PivotItem key={itemKey} headerText={typeof title === 'function' ? title(value) : title} itemKey={itemKey}> + <Field {...props} schema={schema} uiOptions={uiOptions} /> + </PivotItem> + ); + })} </Pivot> </div> ); diff --git a/Composer/packages/adaptive-form/src/components/fields/RegexIntentField.tsx b/Composer/packages/adaptive-form/src/components/fields/RegexIntentField.tsx index f0a0b395b5..60396d7bf4 100644 --- a/Composer/packages/adaptive-form/src/components/fields/RegexIntentField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/RegexIntentField.tsx @@ -4,13 +4,15 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; import React, { useState, useEffect } from 'react'; -import { FieldProps, useShellApi } from '@bfc/extension-client'; -import { DialogInfo, RegexRecognizer } from '@bfc/shared'; +import { FieldProps, useShellApi, MicrosoftIDialog } from '@bfc/extension-client'; +import { RegexRecognizer } from '@bfc/shared'; + +import { useFormData } from '../../hooks'; import { StringField } from './StringField'; -function getRegexIntentPattern(currentDialog: DialogInfo, intent: string): string { - const recognizer = currentDialog.content.recognizer as RegexRecognizer; +function getRegexIntentPattern(formData: MicrosoftIDialog, intent: string): string { + const recognizer = formData.recognizer as RegexRecognizer; let pattern = ''; if (!recognizer) { @@ -26,14 +28,15 @@ function getRegexIntentPattern(currentDialog: DialogInfo, intent: string): strin const RegexIntentField: React.FC<FieldProps> = ({ value: intentName, ...rest }) => { const { currentDialog, shellApi } = useShellApi(); - const [localValue, setLocalValue] = useState(getRegexIntentPattern(currentDialog, intentName)); + const formData = useFormData(); + const [localValue, setLocalValue] = useState(getRegexIntentPattern(formData, intentName)); // if the intent name changes or intent names in the regex patterns // we need to reset the local value useEffect(() => { - const pattern = getRegexIntentPattern(currentDialog, intentName); + const pattern = getRegexIntentPattern(formData, intentName); setLocalValue(pattern); - }, [intentName, currentDialog.content.recognizer?.intents.map((i) => i.intent)]); + }, [intentName, (formData.recognizer as RegexRecognizer)?.intents.map((i) => i.intent)]); const handleIntentChange = (pattern?: string) => { setLocalValue(pattern ?? ''); diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/ArrayField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/ArrayField.test.tsx index b4355da8a5..18ce6771ff 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/ArrayField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/ArrayField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { ArrayField } from '../ArrayField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/ArrayFieldItem.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/ArrayFieldItem.tsx index 3ffcb51ac6..5d3ee11add 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/ArrayFieldItem.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/ArrayFieldItem.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent, screen } from '@bfc/test-utils'; +import { render, fireEvent, screen } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { ArrayFieldItem } from '../ArrayFieldItem'; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/BooleanField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/BooleanField.test.tsx index ea58cc8318..fe9b22ec32 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/BooleanField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/BooleanField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { BooleanField } from '../BooleanField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/FieldSets.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/FieldSets.test.tsx index ec11b16f44..bc81567a1a 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/FieldSets.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/FieldSets.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent, act } from '@bfc/test-utils'; +import { render, fireEvent, act } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { Fieldsets } from '../FieldSets'; 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 4f1571e169..1636dd6eb1 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 @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { useRecognizerConfig } from '@bfc/extension-client'; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/JsonField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/JsonField.test.tsx index f04e0aade0..734fdabb9b 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/JsonField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/JsonField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { JsonField } from '../JsonField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/NumberField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/NumberField.test.tsx index eef7217830..5a229da4c0 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/NumberField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/NumberField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { NumberField } from '../NumberField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/ObjectArrayField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/ObjectArrayField.test.tsx index 804ead2226..af54442bcd 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/ObjectArrayField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/ObjectArrayField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { useShellApi } from '@bfc/extension-client'; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/ObjectField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/ObjectField.test.tsx index e45d78ba2d..30e9dae405 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/ObjectField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/ObjectField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { ObjectField } from '../ObjectField'; 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 f27b62a2dd..18b9bc371c 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 @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent, screen } from '@bfc/test-utils'; +import { render, fireEvent, screen } from '@botframework-composer/test-utils'; import { useRecognizerConfig, useShellApi } from '@bfc/extension-client'; import assign from 'lodash/assign'; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/RegexIntentField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/RegexIntentField.test.tsx index 496ade1c06..7a8751b850 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/RegexIntentField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/RegexIntentField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { useShellApi } from '@bfc/extension-client'; @@ -21,10 +21,11 @@ function renderSubject(overrides = {}, shellOverrides = {}) { { currentDialog: { content: { recognizer: undefined }, - shellApi: { - updateRegExIntent: jest.fn(), - }, }, + shellApi: { + updateRegExIntent: jest.fn(), + }, + focusedSteps: [], }, shellOverrides ) diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/SelectField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/SelectField.test.tsx index e966b21d3c..5692c77e69 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/SelectField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/SelectField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent, screen } from '@bfc/test-utils'; +import { render, fireEvent, screen } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { SelectField } from '../SelectField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/StringField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/StringField.test.tsx index 2b68aee7c1..abaa31ae2e 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/StringField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/StringField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import { StringField, borderStyles } from '../StringField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/UnsupportedField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/UnsupportedField.test.tsx index d32534b2dd..e20bfaf489 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/UnsupportedField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/UnsupportedField.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import assign from 'lodash/assign'; import { UnsupportedField } from '../UnsupportedField'; diff --git a/Composer/packages/adaptive-form/src/hooks/index.ts b/Composer/packages/adaptive-form/src/hooks/index.ts new file mode 100644 index 0000000000..d5e0d3cf0f --- /dev/null +++ b/Composer/packages/adaptive-form/src/hooks/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './useFormData'; diff --git a/Composer/packages/adaptive-form/src/hooks/useFormData.ts b/Composer/packages/adaptive-form/src/hooks/useFormData.ts new file mode 100644 index 0000000000..e61d8ae610 --- /dev/null +++ b/Composer/packages/adaptive-form/src/hooks/useFormData.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useMemo } from 'react'; +import get from 'lodash/get'; +import { useShellApi, MicrosoftIDialog } from '@bfc/extension-client'; + +/** + * Returns data for current form context + */ +export function useFormData() { + const { ...shellData } = useShellApi(); + const { currentDialog, focusedSteps } = shellData; + + return useMemo(() => { + if (currentDialog?.content) { + return focusedSteps[0] ? get(currentDialog.content, focusedSteps[0]) : currentDialog.content; + } else { + return {}; + } + }, [currentDialog, focusedSteps[0]]) as MicrosoftIDialog; +} diff --git a/Composer/packages/adaptive-form/src/index.ts b/Composer/packages/adaptive-form/src/index.ts index 45459b07f2..5ff633c394 100644 --- a/Composer/packages/adaptive-form/src/index.ts +++ b/Composer/packages/adaptive-form/src/index.ts @@ -4,6 +4,7 @@ import { AdaptiveForm } from './components'; export * from './AdaptiveFormContext'; export * from './components'; +export * from './hooks'; export * from './utils'; export default AdaptiveForm; diff --git a/Composer/packages/adaptive-form/src/utils/__tests__/arrayUtils.test.ts b/Composer/packages/adaptive-form/src/utils/__tests__/arrayUtils.test.ts index 5711894da8..832b609806 100644 --- a/Composer/packages/adaptive-form/src/utils/__tests__/arrayUtils.test.ts +++ b/Composer/packages/adaptive-form/src/utils/__tests__/arrayUtils.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { renderHook, act } from '@bfc/test-utils/lib/hooks'; +import { renderHook, act } from '@botframework-composer/test-utils/lib/hooks'; import { ArrayItem, getArrayItemProps, useArrayItems } from '../arrayUtils'; diff --git a/Composer/packages/adaptive-form/src/utils/__tests__/getFieldSets.test.ts b/Composer/packages/adaptive-form/src/utils/__tests__/getFieldSets.test.ts index caf1590a17..b7c1b25c7f 100644 --- a/Composer/packages/adaptive-form/src/utils/__tests__/getFieldSets.test.ts +++ b/Composer/packages/adaptive-form/src/utils/__tests__/getFieldSets.test.ts @@ -88,6 +88,56 @@ describe('getFieldsets', () => { ]); }); + it('should updated uiOptions for nested field sets', () => { + const uiOptions: any = { + fieldsets: [ + { + title: 'set1', + fields: [ + { title: 'set 1a', fields: ['one'] }, + { title: 'set 1b', fields: ['two'] }, + ], + }, + { + title: 'set2', + fields: ['*'], + }, + ], + }; + + const result = getFieldsets(schema, uiOptions, {}); + + expect(result).toEqual([ + expect.objectContaining({ + title: 'set1', + schema: { + properties: { + one: { type: 'string' }, + two: { type: 'string' }, + }, + }, + uiOptions: expect.objectContaining({ + fieldsets: [ + { title: 'set 1a', fields: ['one'] }, + { title: 'set 1b', fields: ['two'] }, + ], + }), + }), + expect.objectContaining({ + title: 'set2', + schema: { + properties: { + three: { type: 'number' }, + four: { type: 'object' }, + five: { type: 'object' }, + six: { type: 'object' }, + seven: { type: 'boolean' }, + }, + }, + }), + ]); + }); + it('should include additional fields', () => { const uiOptions: any = { fieldsets: [ @@ -174,4 +224,28 @@ describe('getFieldsets', () => { expect(() => getFieldsets(schema, uiOptions, {})).toThrow('duplicate fields'); }); + + it('should throw an error for improper nested fields', () => { + const uiOptions = { + fieldsets: [ + { title: 'set1', fields: ['two', 'four', { title: 'improper' }] }, + { title: 'set2', fields: ['two', '*'] }, + ], + }; + + expect(() => getFieldsets(schema, uiOptions, {})).toThrow( + 'fields must be either all strings or all fieldset objects' + ); + }); + + it('should throw an error for multiple wildcards in nested fieldsets', () => { + const uiOptions = { + fieldsets: [ + { title: 'set1', fields: [{ title: 'improper' }] }, + { title: 'set2', fields: ['two', '*'] }, + ], + }; + + expect(() => getFieldsets(schema, uiOptions, {})).toThrow('multiple wildcards'); + }); }); diff --git a/Composer/packages/adaptive-form/src/utils/__tests__/objectUtils.test.ts b/Composer/packages/adaptive-form/src/utils/__tests__/objectUtils.test.ts index 8bf8147b22..e7fa107d94 100644 --- a/Composer/packages/adaptive-form/src/utils/__tests__/objectUtils.test.ts +++ b/Composer/packages/adaptive-form/src/utils/__tests__/objectUtils.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { renderHook, act } from '@bfc/test-utils/lib/hooks'; +import { renderHook, act } from '@botframework-composer/test-utils/lib/hooks'; import { getPropertyItemProps, useObjectItems } from '../objectUtils'; diff --git a/Composer/packages/adaptive-form/src/utils/getFieldsets.ts b/Composer/packages/adaptive-form/src/utils/getFieldsets.ts index ac497a4448..a363ea0f6a 100644 --- a/Composer/packages/adaptive-form/src/utils/getFieldsets.ts +++ b/Composer/packages/adaptive-form/src/utils/getFieldsets.ts @@ -4,6 +4,7 @@ import { Fieldset, JSONSchema7, UIOptions } from '@bfc/extension-client'; import formatMessage from 'format-message'; import difference from 'lodash/difference'; +import flatMap from 'lodash/flatMap'; import flatten from 'lodash/flatten'; import pick from 'lodash/pick'; import pickBy from 'lodash/pickBy'; @@ -16,6 +17,10 @@ interface FieldSetConfig extends Fieldset { uiOptions: UIOptions; } +const isFieldsetArray = (fields: string[] | Fieldset<string[]>[]): fields is Fieldset<string[]>[] => { + return fields.every((field) => typeof field === 'object'); +}; + export const getFieldsets = (baseSchema: JSONSchema7, baseUiOptions: UIOptions, value: any): FieldSetConfig[] => { const { fieldsets: baseFieldsets = [] } = baseUiOptions; const { properties } = baseSchema; @@ -23,7 +28,21 @@ export const getFieldsets = (baseSchema: JSONSchema7, baseUiOptions: UIOptions, const schema = getSchemaWithAdditionalFields(baseSchema, baseUiOptions); const orderedFields = flatten(getOrderedProperties(schema, baseUiOptions, value)); - const fields: string[] = flatten(baseFieldsets.map(({ fields = ['*'] }) => fields)); + if ( + !baseFieldsets.every( + ({ fields = ['*'] }) => + fields.every((field) => typeof field === 'string') || fields.every((field) => typeof field === 'object') + ) + ) { + throw new Error(formatMessage('fields must be either all strings or all fieldset objects')); + } + + const fields: string[] = flatMap(baseFieldsets, ({ fields = ['*'] }) => { + if (isFieldsetArray(fields)) { + return flatMap(fields, ({ fields: nestedFields = ['*'] }) => nestedFields); + } + return fields; + }); const restFields = difference(orderedFields, fields); if (fields.filter((field) => field === '*').length > 1) { @@ -35,16 +54,30 @@ export const getFieldsets = (baseSchema: JSONSchema7, baseUiOptions: UIOptions, } return baseFieldsets.map(({ fields = ['*'], ...rest }) => { - const restIdx = fields.indexOf('*'); + const fieldsetArray = isFieldsetArray(fields); + const allFields = fieldsetArray + ? flatMap(fields as Fieldset<string[]>[], ({ fields = ['*'] }) => fields) + : (fields as string[]); + const restIdx = allFields.indexOf('*'); if (restIdx > -1) { - fields.splice(restIdx, 1, ...restFields); + allFields.splice(restIdx, 1, ...restFields); } - const uiOptions = pickBy({ ...baseUiOptions, order: fields, properties: pick(baseUiOptions.properties, fields) }); - delete uiOptions.fieldsets; + const uiOptions = pickBy({ + ...baseUiOptions, + order: allFields, + properties: pick(baseUiOptions.properties, allFields), + }); + + if (fieldsetArray) { + uiOptions.fieldsets = fields; + delete uiOptions.pivotFieldsets; + } else { + delete uiOptions.fieldsets; + } - const schema = { ...baseSchema, properties: pick(properties, fields) } as JSONSchema7; + const schema = { ...baseSchema, properties: pick(properties, allFields) } as JSONSchema7; return { ...rest, fields, uiOptions, schema }; }); diff --git a/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts b/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts index 4bfbe1dfac..8e07122f66 100644 --- a/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts +++ b/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts @@ -73,7 +73,10 @@ export function resolveFieldWidget( if (schema.additionalProperties) { return DefaultFields.OpenObjectField; } else if (uiOptions?.fieldsets) { - return uiOptions.pivotFieldsets ? DefaultFields.PivotFieldsets : DefaultFields.Fieldsets; + return uiOptions.pivotFieldsets || + uiOptions.fieldsets.some(({ fields = [] }) => fields.some((field) => typeof field !== 'string')) + ? DefaultFields.PivotFieldsets + : DefaultFields.Fieldsets; } else { return DefaultFields.ObjectField; } diff --git a/Composer/packages/adaptive-form/src/utils/resolvePropSchema.ts b/Composer/packages/adaptive-form/src/utils/resolvePropSchema.ts index 12fb4b1027..7bc605f5d6 100644 --- a/Composer/packages/adaptive-form/src/utils/resolvePropSchema.ts +++ b/Composer/packages/adaptive-form/src/utils/resolvePropSchema.ts @@ -1,15 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { JSONSchema7, JSONSchema7Definition } from '@bfc/extension-client'; +import { JSONSchema7, SchemaDefinitions } from '@bfc/extension-client'; import { resolveRef } from './resolveRef'; export function resolvePropSchema( schema: JSONSchema7, path: string, - definitions: { - [k: string]: JSONSchema7Definition; - } = {} + definitions: SchemaDefinitions = {} ): JSONSchema7 | undefined { const propSchema = schema.properties?.[path]; diff --git a/Composer/packages/adaptive-form/src/utils/resolveRef.ts b/Composer/packages/adaptive-form/src/utils/resolveRef.ts index 5fa870f4d1..433ff85adb 100644 --- a/Composer/packages/adaptive-form/src/utils/resolveRef.ts +++ b/Composer/packages/adaptive-form/src/utils/resolveRef.ts @@ -1,12 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { JSONSchema7, JSONSchema7Definition } from '@bfc/extension-client'; +import { JSONSchema7, SchemaDefinitions } from '@bfc/extension-client'; import formatMessage from 'format-message'; -export function resolveRef( - schema: JSONSchema7 = {}, - definitions: { [key: string]: JSONSchema7Definition } = {} -): JSONSchema7 { +export function resolveRef(schema: JSONSchema7 = {}, definitions: SchemaDefinitions = {}): JSONSchema7 { if (typeof schema?.$ref === 'string') { if (!schema?.$ref?.startsWith('#/definitions/')) { return schema; diff --git a/Composer/packages/client/.gitignore b/Composer/packages/client/.gitignore index de820129fc..b612041ef0 100644 --- a/Composer/packages/client/.gitignore +++ b/Composer/packages/client/.gitignore @@ -1,2 +1,3 @@ build/ public/*-bundle.js +public/plugin-host-preload.js diff --git a/Composer/packages/client/__tests__/components/CreationFlow/CreateOptions/index.test.tsx b/Composer/packages/client/__tests__/components/CreationFlow/CreateOptions/index.test.tsx index 445e84c1dc..865df497fd 100644 --- a/Composer/packages/client/__tests__/components/CreationFlow/CreateOptions/index.test.tsx +++ b/Composer/packages/client/__tests__/components/CreationFlow/CreateOptions/index.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent } from '@bfc/test-utils'; +import { fireEvent } from '@botframework-composer/test-utils'; import { renderWithRecoil } from '../../../testUtils'; import { CreateOptions } from '../../../../src/components/CreationFlow/CreateOptions'; diff --git a/Composer/packages/client/__tests__/components/CreationFlow/DefineConversation/index.test.tsx b/Composer/packages/client/__tests__/components/CreationFlow/DefineConversation/index.test.tsx index edebf38a00..3ce9487e14 100644 --- a/Composer/packages/client/__tests__/components/CreationFlow/DefineConversation/index.test.tsx +++ b/Composer/packages/client/__tests__/components/CreationFlow/DefineConversation/index.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent, act, waitFor } from '@bfc/test-utils'; +import { fireEvent, act, waitFor } from '@botframework-composer/test-utils'; import { renderWithRecoil } from '../../../testUtils'; import { StorageFolder } from '../../../../src/recoilModel/types'; diff --git a/Composer/packages/client/__tests__/components/CreationFlow/LocationBrowser/FileSelector.test.tsx b/Composer/packages/client/__tests__/components/CreationFlow/LocationBrowser/FileSelector.test.tsx index 88c0a3f6f5..62395354c1 100644 --- a/Composer/packages/client/__tests__/components/CreationFlow/LocationBrowser/FileSelector.test.tsx +++ b/Composer/packages/client/__tests__/components/CreationFlow/LocationBrowser/FileSelector.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render, fireEvent, act } from '@bfc/test-utils'; +import { render, fireEvent, act } from '@botframework-composer/test-utils'; import { StorageFolder } from '../../../../src/recoilModel/types'; import { FileSelector } from '../../../../src/components/CreationFlow/FileSelector'; diff --git a/Composer/packages/client/__tests__/components/CreationFlow/LocationBrowser/LocationSelectContent.test.tsx b/Composer/packages/client/__tests__/components/CreationFlow/LocationBrowser/LocationSelectContent.test.tsx index 8f144f50b5..2dee41bf63 100644 --- a/Composer/packages/client/__tests__/components/CreationFlow/LocationBrowser/LocationSelectContent.test.tsx +++ b/Composer/packages/client/__tests__/components/CreationFlow/LocationBrowser/LocationSelectContent.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent } from '@bfc/test-utils'; +import { fireEvent } from '@botframework-composer/test-utils'; import { MutableSnapshot } from 'recoil'; import { StorageFolder } from '../../../../src/recoilModel/types'; diff --git a/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx b/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx index 0d4e8641d8..2a1206494e 100644 --- a/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx +++ b/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render, fireEvent, act } from '@bfc/test-utils'; +import { render, fireEvent, act } from '@botframework-composer/test-utils'; import { createHistory, createMemorySource, LocationProvider } from '@reach/router'; import { RecoilRoot } from 'recoil'; @@ -15,7 +15,7 @@ describe('<CreationFlow/>', () => { const createProjectMock = jest.fn(); const initRecoilState = ({ set }) => { set(dispatcherState, { - createProject: createProjectMock, + createNewBot: createProjectMock, fetchStorages: jest.fn(), fetchTemplateProjects: jest.fn(), onboardingAddCoachMarkRef: jest.fn(), @@ -70,14 +70,20 @@ describe('<CreationFlow/>', () => { act(() => { fireEvent.click(node); }); - expect(createProjectMock).toHaveBeenCalledWith( - 'EchoBot', - 'EchoBot-1', - '', - expect.stringMatching(/(\/|\\)test-folder(\/|\\)Desktop/), - '', - 'en-US', - undefined - ); + + let expectedLocation = '/test-folder/Desktop'; + if (process.platform === 'win32') { + expectedLocation = '\\test-folder\\Desktop'; + } + + expect(createProjectMock).toHaveBeenCalledWith({ + appLocale: 'en-US', + description: '', + location: expectedLocation, + name: 'EchoBot-1', + qnaKbUrls: undefined, + schemaUrl: '', + templateId: 'EchoBot', + }); }); }); diff --git a/Composer/packages/client/__tests__/components/DialogWrapper/index.test.tsx b/Composer/packages/client/__tests__/components/DialogWrapper/index.test.tsx index b6f5a23152..0adbc9e2d3 100644 --- a/Composer/packages/client/__tests__/components/DialogWrapper/index.test.tsx +++ b/Composer/packages/client/__tests__/components/DialogWrapper/index.test.tsx @@ -2,9 +2,8 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; - -import { DialogWrapper, DialogTypes } from '../../../src/components/DialogWrapper'; +import { render } from '@botframework-composer/test-utils'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; describe('<DialogWrapper />', () => { const props = { diff --git a/Composer/packages/client/__tests__/components/MultiLanguage/MultiLanguage.test.tsx b/Composer/packages/client/__tests__/components/MultiLanguage/MultiLanguage.test.tsx index a89f4e6d3b..f14eef616d 100644 --- a/Composer/packages/client/__tests__/components/MultiLanguage/MultiLanguage.test.tsx +++ b/Composer/packages/client/__tests__/components/MultiLanguage/MultiLanguage.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import { AddLanguageModal, DeleteLanguageModal } from '../../../src/components/MultiLanguage'; diff --git a/Composer/packages/client/__tests__/components/TestController/emulatorOpenButton.test.tsx b/Composer/packages/client/__tests__/components/TestController/emulatorOpenButton.test.tsx index 5cd7cd2294..9526e17d51 100644 --- a/Composer/packages/client/__tests__/components/TestController/emulatorOpenButton.test.tsx +++ b/Composer/packages/client/__tests__/components/TestController/emulatorOpenButton.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import { EmulatorOpenButton } from '../../../src/components/TestController/emulatorOpenButton'; import { BotStatus } from '../../../src/constants'; diff --git a/Composer/packages/client/__tests__/components/TestController/errorCallout.test.tsx b/Composer/packages/client/__tests__/components/TestController/errorCallout.test.tsx index a52ad3179d..a2a7411c37 100644 --- a/Composer/packages/client/__tests__/components/TestController/errorCallout.test.tsx +++ b/Composer/packages/client/__tests__/components/TestController/errorCallout.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render, fireEvent, getByText } from '@bfc/test-utils'; +import { render, fireEvent, getByText } from '@botframework-composer/test-utils'; import { ErrorCallout } from '../../../src/components/TestController/errorCallout'; diff --git a/Composer/packages/client/__tests__/components/TestController/errorInfo.test.tsx b/Composer/packages/client/__tests__/components/TestController/errorInfo.test.tsx index 18893b081f..e6b70e9b33 100644 --- a/Composer/packages/client/__tests__/components/TestController/errorInfo.test.tsx +++ b/Composer/packages/client/__tests__/components/TestController/errorInfo.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render, fireEvent } from '@bfc/test-utils'; +import { render, fireEvent } from '@botframework-composer/test-utils'; import { ErrorInfo } from '../../../src/components/TestController/errorInfo'; diff --git a/Composer/packages/client/__tests__/components/TestController/loading.test.tsx b/Composer/packages/client/__tests__/components/TestController/loading.test.tsx index d51b5f40fc..005befb54c 100644 --- a/Composer/packages/client/__tests__/components/TestController/loading.test.tsx +++ b/Composer/packages/client/__tests__/components/TestController/loading.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { Loading } from '../../../src/components/TestController/loading'; import { BotStatus } from '../../../src/constants'; 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 3bb6f18748..dc4b18d626 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 @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent } from '@bfc/test-utils'; +import { fireEvent } from '@botframework-composer/test-utils'; import { PublishDialog } from '../../../src/components/TestController/publishDialog'; -import { botNameState, settingsState, dispatcherState, currentProjectIdState } from '../../../src/recoilModel'; +import { botDisplayNameState, settingsState, dispatcherState, currentProjectIdState } from '../../../src/recoilModel'; import { renderWithRecoil } from '../../testUtils'; jest.useFakeTimers(); @@ -31,7 +31,7 @@ describe('<PublishDialog />', () => { setSettings: setSettingsMock, }); set(currentProjectIdState, projectId); - set(botNameState(projectId), 'sampleBot0'); + set(botDisplayNameState(projectId), 'sampleBot0'); set(settingsState(projectId), { luis: luisConfig, qna: qnaConfig, diff --git a/Composer/packages/client/__tests__/components/conversation.test.jsx b/Composer/packages/client/__tests__/components/conversation.test.jsx index 723f32b6ad..22fee58d48 100644 --- a/Composer/packages/client/__tests__/components/conversation.test.jsx +++ b/Composer/packages/client/__tests__/components/conversation.test.jsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import '../../src/components/Conversation'; diff --git a/Composer/packages/client/__tests__/components/createDialogModal.test.tsx b/Composer/packages/client/__tests__/components/createDialogModal.test.tsx index be19a52005..1c5d5110c8 100644 --- a/Composer/packages/client/__tests__/components/createDialogModal.test.tsx +++ b/Composer/packages/client/__tests__/components/createDialogModal.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent, act, waitFor } from '@bfc/test-utils'; +import { fireEvent, act, waitFor } from '@botframework-composer/test-utils'; import { CreateDialogModal } from '../../src/pages/design/createDialogModal'; import { renderWithRecoil } from '../testUtils'; diff --git a/Composer/packages/client/__tests__/components/createQnAModal.test.tsx b/Composer/packages/client/__tests__/components/createQnAModal.test.tsx new file mode 100644 index 0000000000..c48efaa4fe --- /dev/null +++ b/Composer/packages/client/__tests__/components/createQnAModal.test.tsx @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { fireEvent } from '@botframework-composer/test-utils'; + +import { renderWithRecoil } from '../testUtils/renderWithRecoil'; +import CreateQnAFromUrlModal from '../../src/components/QnA/CreateQnAFromUrlModal'; +import { showCreateQnAFromUrlDialogState, showCreateQnAFromUrlDialogWithScratchState } from '../../src/recoilModel'; + +describe('<CreateQnAFromUrlModal />', () => { + const onDismiss = jest.fn(() => {}); + const onSubmit = jest.fn(() => {}); + const projectId = 'test-create-qna'; + + it('renders <CreateQnAFromUrlModal /> and create from scratch', () => { + const container = renderWithRecoil( + <CreateQnAFromUrlModal + dialogId="test" + projectId={projectId} + qnaFiles={[]} + onDismiss={onDismiss} + onSubmit={onSubmit} + />, + ({ set }) => { + set(showCreateQnAFromUrlDialogState(projectId), true); + set(showCreateQnAFromUrlDialogWithScratchState(projectId), true); + } + ); + + const { getByTestId } = container; + const createFromScratchButton = getByTestId('createKnowledgeBaseFromScratch'); + expect(createFromScratchButton).not.toBeNull(); + fireEvent.click(createFromScratchButton); + // actions tobe called + }); + + it('create with name/url and validate the value', () => { + const container = renderWithRecoil( + <CreateQnAFromUrlModal + dialogId="test" + projectId={projectId} + qnaFiles={[]} + onDismiss={onDismiss} + onSubmit={onSubmit} + />, + () => {} + ); + + const { findByText, getByTestId } = container; + const inputName = getByTestId('knowledgeLocationTextField-name') as HTMLInputElement; + fireEvent.change(inputName, { target: { value: 'test' } }); + + const inputUrl = getByTestId('knowledgeLocationTextField-url') as HTMLInputElement; + fireEvent.change(inputUrl, { target: { value: 'test' } }); + + expect(inputUrl.value).toBe('test'); + expect(findByText(/A valid url should start with/)).not.toBeNull(); + fireEvent.change(inputUrl, { target: { value: 'http://test' } }); + + const createKnowledgeButton = getByTestId('createKnowledgeBase'); + expect(createKnowledgeButton).not.toBeNull(); + fireEvent.click(createKnowledgeButton); + expect(onSubmit).toBeCalled(); + expect(onSubmit).toBeCalledWith({ url: 'http://test', name: 'test', multiTurn: false }); + }); +}); diff --git a/Composer/packages/client/__tests__/components/design.test.tsx b/Composer/packages/client/__tests__/components/design.test.tsx index 3231e33d7a..dd71a51c56 100644 --- a/Composer/packages/client/__tests__/components/design.test.tsx +++ b/Composer/packages/client/__tests__/components/design.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent } from '@bfc/test-utils'; +import { fireEvent } from '@botframework-composer/test-utils'; import { DialogInfo } from '@bfc/shared'; import { renderWithRecoil } from '../testUtils'; @@ -28,7 +28,7 @@ describe('<ProjectTree/>', () => { const { findByText } = renderWithRecoil( <ProjectTree dialogId={dialogId} - dialogs={dialogs as DialogInfo[]} + dialogs={(dialogs as unknown) as DialogInfo[]} selected={selected} onDeleteDialog={handleDeleteDialog} onDeleteTrigger={handleDeleteTrigger} diff --git a/Composer/packages/client/__tests__/components/errorBoundary.test.tsx b/Composer/packages/client/__tests__/components/errorBoundary.test.tsx index fd1e833036..c0d97a0de0 100644 --- a/Composer/packages/client/__tests__/components/errorBoundary.test.tsx +++ b/Composer/packages/client/__tests__/components/errorBoundary.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { ErrorBoundary } from '../../src/components/ErrorBoundary'; diff --git a/Composer/packages/client/__tests__/components/home.test.tsx b/Composer/packages/client/__tests__/components/home.test.tsx index e97917f745..e379b8959d 100644 --- a/Composer/packages/client/__tests__/components/home.test.tsx +++ b/Composer/packages/client/__tests__/components/home.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent, render } from '@bfc/test-utils'; +import { fireEvent, render } from '@botframework-composer/test-utils'; import { ProjectTemplate } from '@bfc/shared'; import { RecentBotList } from '../../src/pages/home/RecentBotList'; diff --git a/Composer/packages/client/__tests__/components/notificationHeader.test.jsx b/Composer/packages/client/__tests__/components/notificationHeader.test.jsx index e3ebcd1bb8..61bbce9e1e 100644 --- a/Composer/packages/client/__tests__/components/notificationHeader.test.jsx +++ b/Composer/packages/client/__tests__/components/notificationHeader.test.jsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent, render } from '@bfc/test-utils'; +import { fireEvent, render } from '@botframework-composer/test-utils'; import { NotificationHeader } from '../../src/pages/notifications/NotificationHeader'; diff --git a/Composer/packages/client/__tests__/components/notificationList.test.jsx b/Composer/packages/client/__tests__/components/notificationList.test.jsx index c8676b4188..cacac42a41 100644 --- a/Composer/packages/client/__tests__/components/notificationList.test.jsx +++ b/Composer/packages/client/__tests__/components/notificationList.test.jsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import formatMessage from 'format-message'; import { NotificationList } from '../../src/pages/notifications/NotificationList'; diff --git a/Composer/packages/client/__tests__/components/projecttree.test.tsx b/Composer/packages/client/__tests__/components/projecttree.test.tsx index 217e1743b5..52d32712b1 100644 --- a/Composer/packages/client/__tests__/components/projecttree.test.tsx +++ b/Composer/packages/client/__tests__/components/projecttree.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent } from '@bfc/test-utils'; +import { fireEvent } from '@botframework-composer/test-utils'; import { dialogs } from '../constants.json'; import { ProjectTree } from '../../src/components/ProjectTree/ProjectTree'; diff --git a/Composer/packages/client/__tests__/components/skill.test.tsx b/Composer/packages/client/__tests__/components/skill.test.tsx index 67b0bdf4b1..287c9b90f3 100644 --- a/Composer/packages/client/__tests__/components/skill.test.tsx +++ b/Composer/packages/client/__tests__/components/skill.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { act, fireEvent, getByLabelText, getByTestId, getByText } from '@bfc/test-utils'; +import { act, fireEvent, getByLabelText, getByTestId, getByText } from '@botframework-composer/test-utils'; import { Skill } from '@bfc/shared'; import httpClient from '../../src//utils/httpUtil'; @@ -223,7 +223,7 @@ describe('<SkillForm />', () => { manifestUrl: 'Validating', }) ); - expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieve-skill-manifest`, { + expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { url: formData.manifestUrl, }, @@ -261,7 +261,7 @@ describe('<SkillForm />', () => { manifestUrl: 'Validating', }) ); - expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieve-skill-manifest`, { + expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { url: formData.manifestUrl, }, diff --git a/Composer/packages/client/__tests__/components/toolbar.test.tsx b/Composer/packages/client/__tests__/components/toolbar.test.tsx index ad7e52932d..1a6d6c4130 100644 --- a/Composer/packages/client/__tests__/components/toolbar.test.tsx +++ b/Composer/packages/client/__tests__/components/toolbar.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent } from '@bfc/test-utils'; +import { fireEvent } from '@botframework-composer/test-utils'; import { renderWithRecoil } from '../testUtils'; import { Toolbar } from '../../src/components/Toolbar'; diff --git a/Composer/packages/client/__tests__/components/triggerCreationModal.test.tsx b/Composer/packages/client/__tests__/components/triggerCreationModal.test.tsx index f42c05e37b..b475031548 100644 --- a/Composer/packages/client/__tests__/components/triggerCreationModal.test.tsx +++ b/Composer/packages/client/__tests__/components/triggerCreationModal.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { fireEvent, waitFor } from '@bfc/test-utils'; +import { fireEvent, waitFor } from '@botframework-composer/test-utils'; import { TriggerCreationModal } from '../../src/components/ProjectTree/TriggerCreationModal'; import { renderWithRecoil } from '../testUtils'; diff --git a/Composer/packages/client/__tests__/constants.json b/Composer/packages/client/__tests__/constants.json index c92080727d..f1861e7810 100644 --- a/Composer/packages/client/__tests__/constants.json +++ b/Composer/packages/client/__tests__/constants.json @@ -96,7 +96,7 @@ "$kind": "Microsoft.AdaptiveDialog", "$designer": { "id": "288769", - "description": "This is a bot that demonstrates how to manage a ToDo list using Regular Expressions." + "description": "This is a bot that demonstrates how to manage a ToDo list using regular expressions." }, "rules": [ { diff --git a/Composer/packages/client/__tests__/hooks/useForm.test.ts b/Composer/packages/client/__tests__/hooks/useForm.test.ts index 8af57e7754..f9fcf676e9 100644 --- a/Composer/packages/client/__tests__/hooks/useForm.test.ts +++ b/Composer/packages/client/__tests__/hooks/useForm.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { renderHook, act } from '@bfc/test-utils/lib/hooks'; +import { renderHook, act } from '@botframework-composer/test-utils/lib/hooks'; import { useForm, FieldConfig } from '../../src/hooks/useForm'; diff --git a/Composer/packages/client/__tests__/notFound.test.jsx b/Composer/packages/client/__tests__/notFound.test.jsx index ba122988db..26ddabe3f5 100644 --- a/Composer/packages/client/__tests__/notFound.test.jsx +++ b/Composer/packages/client/__tests__/notFound.test.jsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { BASEPATH } from '../src/constants'; import { NotFound } from '../src/components/NotFound'; diff --git a/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx b/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx index fcb4d7d0b2..62946c4246 100644 --- a/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx +++ b/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx @@ -3,6 +3,7 @@ /* eslint-disable react-hooks/rules-of-hooks */ import React from 'react'; +import QnAPage from '../../../src/pages/knowledge-base/QnAPage'; import TableView from '../../../src/pages/knowledge-base/table-view'; import CodeEditor from '../../../src/pages/knowledge-base/code-editor'; import { renderWithRecoil } from '../../testUtils'; @@ -26,12 +27,16 @@ answer const state = { projectId: 'test', - dialogs: [{ id: '1' }, { id: '2' }], + dialogs: [ + { id: '1', content: '', skills: [] }, + { id: '2', content: '', skills: [] }, + ], locale: 'en-us', qnaFiles: [ { id: 'a.en-us', content: initialContent, + imports: [], qnaSections: [ { Questions: [{ content: 'question', id: 1 }], @@ -63,16 +68,20 @@ const initRecoilState = ({ set }) => { describe('QnA page all up view', () => { it('should render QnA page table view', () => { - const { getByText, getByTestId } = renderWithRecoil( + const { getByTestId, getByText } = renderWithRecoil( <TableView dialogId={'a'} projectId={state.projectId} />, initRecoilState ); getByTestId('table-view'); - getByText('question (1)'); - getByText('answer'); + getByText('Question'); }); it('should render QnA page code editor', () => { renderWithRecoil(<CodeEditor dialogId={'a'} projectId={state.projectId} />, initRecoilState); }); + + it('should render QnA page', () => { + const { getByTestId } = renderWithRecoil(<QnAPage dialogId={'a'} projectId={state.projectId} />, initRecoilState); + getByTestId('QnAPage'); + }); }); diff --git a/Composer/packages/client/__tests__/pages/language-generation/LGPage.test.tsx b/Composer/packages/client/__tests__/pages/language-generation/LGPage.test.tsx index 7ee7ac4363..81a976f4d7 100644 --- a/Composer/packages/client/__tests__/pages/language-generation/LGPage.test.tsx +++ b/Composer/packages/client/__tests__/pages/language-generation/LGPage.test.tsx @@ -4,11 +4,11 @@ import React from 'react'; import { renderWithRecoil } from '../../testUtils'; +import LGPage from '../../../src/pages/language-generation/LGPage'; import TableView from '../../../src/pages/language-generation/table-view'; import CodeEditor from '../../../src/pages/language-generation/code-editor'; import { localeState, - luFilesState, lgFilesState, settingsState, schemasState, @@ -31,16 +31,16 @@ const initialTemplates = [ const state = { projectId: 'test', - dialogs: [{ id: '1' }, { id: '2' }], + dialogs: [ + { id: '1', content: '', diagnostics: [], skills: [] }, + { id: '2', content: '', diagnostics: [], skills: [] }, + ], locale: 'en-us', lgFiles: [ - { id: 'a.en-us', content: initialContent, templates: initialTemplates }, - { id: 'a.fr-fr', content: initialContent, templates: initialTemplates }, - ], - luFiles: [ - { id: 'a.en-us', content: initialContent, templates: initialTemplates }, - { id: 'a.fr-fr', content: initialContent, templates: initialTemplates }, + { id: 'a.en-us', content: initialContent, templates: initialTemplates, diagnostics: [] }, + { id: 'a.fr-fr', content: initialContent, templates: initialTemplates, diagnostics: [] }, ], + settings: { defaultLanguage: 'en-us', languages: ['en-us', 'fr-fr'], @@ -51,7 +51,6 @@ const initRecoilState = ({ set }) => { set(currentProjectIdState, state.projectId); set(localeState(state.projectId), state.locale); set(dialogsState(state.projectId), state.dialogs); - set(luFilesState(state.projectId), state.luFiles); set(lgFilesState(state.projectId), state.lgFiles); set(settingsState(state.projectId), state.settings); set(schemasState(state.projectId), mockProjectResponse.schemas); @@ -70,4 +69,9 @@ describe('LG page all up view', () => { it('should render lg page code editor', () => { renderWithRecoil(<CodeEditor dialogId={'a'} projectId={state.projectId} />, initRecoilState); }); + + it('should render lg page', () => { + const { getByTestId } = renderWithRecoil(<LGPage dialogId={'a'} projectId={state.projectId} />, initRecoilState); + getByTestId('LGPage'); + }); }); diff --git a/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx b/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx index bd0b29be46..8c7927ddac 100644 --- a/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx +++ b/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; +import LUPage from '../../../src/pages/language-understanding/LUPage'; import TableView from '../../../src/pages/language-understanding/table-view'; import CodeEditor from '../../../src/pages/language-understanding/code-editor'; import { renderWithRecoil } from '../../testUtils'; @@ -10,7 +11,6 @@ import { localeState, dialogsState, luFilesState, - lgFilesState, settingsState, schemasState, currentProjectIdState, @@ -22,17 +22,23 @@ const initialContent = ` - hello `; +const initialIntents = [ + { + Name: 'Greeting', + Body: '- hello', + }, +]; + const state = { projectId: 'test', - dialogs: [{ id: '1' }, { id: '2' }], - locale: 'en-us', - lgFiles: [ - { id: 'a.en-us', content: initialContent }, - { id: 'a.fr-fr', content: initialContent }, + dialogs: [ + { id: '1', content: '', skills: [] }, + { id: '2', content: '', skills: [] }, ], + locale: 'en-us', luFiles: [ - { id: 'a.en-us', content: initialContent }, - { id: 'a.fr-fr', content: initialContent }, + { id: 'a.en-us', content: initialContent, templates: initialIntents, diagnostics: [] }, + { id: 'a.fr-fr', content: initialContent, templates: initialIntents, diagnostics: [] }, ], settings: { defaultLanguage: 'en-us', @@ -45,7 +51,6 @@ const initRecoilState = ({ set }) => { set(localeState(state.projectId), state.locale); set(dialogsState(state.projectId), state.dialogs); set(luFilesState(state.projectId), state.luFiles); - set(lgFilesState(state.projectId), state.lgFiles); set(settingsState(state.projectId), state.settings); set(schemasState(state.projectId), mockProjectResponse.schemas); }; @@ -63,4 +68,8 @@ describe('LU page all up view', () => { it('should render lu page code editor', () => { renderWithRecoil(<CodeEditor dialogId={'a'} projectId={state.projectId} />, initRecoilState); }); + + it('should render lu page', () => { + renderWithRecoil(<LUPage dialogId={'a'} projectId={state.projectId} />, initRecoilState); + }); }); diff --git a/Composer/packages/client/__tests__/pages/notifications/useNotifications.test.tsx b/Composer/packages/client/__tests__/pages/notifications/useNotifications.test.tsx index 326a4d7d06..7cf428539d 100644 --- a/Composer/packages/client/__tests__/pages/notifications/useNotifications.test.tsx +++ b/Composer/packages/client/__tests__/pages/notifications/useNotifications.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { RecoilRoot } from 'recoil'; -import { renderHook } from '@bfc/test-utils/lib/hooks'; +import { renderHook } from '@botframework-composer/test-utils/lib/hooks'; import { Range, Position } from '@bfc/shared'; import useNotifications from '../../../src/pages/notifications/useNotifications'; @@ -15,6 +15,9 @@ import { schemasState, currentProjectIdState, botDiagnosticsState, + jsonSchemaFilesState, + botProjectIdsState, + formDialogSchemaIdsState, } from '../../../src/recoilModel'; import mockProjectResponse from '../../../src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json'; @@ -78,6 +81,12 @@ const state = { ], }, ], + jsonSchemaFiles: [ + { + id: 'schema1.json', + content: 'test', + }, + ], diagnostics: [ { message: 'server error', @@ -94,16 +103,23 @@ const state = { }, }, }, + formDialogSchemas: [{ id: '1', content: '{}' }], }; const initRecoilState = ({ set }) => { set(currentProjectIdState, state.projectId); + set(botProjectIdsState, [state.projectId]); set(dialogsState(state.projectId), state.dialogs); set(luFilesState(state.projectId), state.luFiles); set(lgFilesState(state.projectId), state.lgFiles); + set(jsonSchemaFilesState(state.projectId), state.jsonSchemaFiles); set(botDiagnosticsState(state.projectId), state.diagnostics); set(settingsState(state.projectId), state.settings); set(schemasState(state.projectId), mockProjectResponse.schemas); + set( + formDialogSchemaIdsState(state.projectId), + state.formDialogSchemas.map((fds) => fds.id) + ); }; describe('useNotification hooks', () => { diff --git a/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx b/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx index b9aa12ddfe..5d498305a3 100644 --- a/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx +++ b/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { renderWithRecoil } from '../../testUtils'; import { settingsState, - botNameState, + botDisplayNameState, publishTypesState, publishHistoryState, currentProjectIdState, @@ -53,7 +53,7 @@ const state = { const initRecoilState = ({ set }) => { set(currentProjectIdState, state.projectId); - set(botNameState(state.projectId), state.botName); + set(botDisplayNameState(state.projectId), state.botName); set(publishTypesState(state.projectId), state.publishTypes); set(publishHistoryState(state.projectId), state.publishHistory); set(settingsState(state.projectId), state.settings); diff --git a/Composer/packages/client/__tests__/plugins.test.ts b/Composer/packages/client/__tests__/plugins.test.ts index 102d9536ea..24c9624991 100644 --- a/Composer/packages/client/__tests__/plugins.test.ts +++ b/Composer/packages/client/__tests__/plugins.test.ts @@ -45,7 +45,7 @@ describe('mergePluginConfigs', () => { }, }, }, - flowWidgets: {}, + widgets: {}, }); }); diff --git a/Composer/packages/client/__tests__/routers.test.tsx b/Composer/packages/client/__tests__/routers.test.tsx index cba0ff0a37..57c889a076 100644 --- a/Composer/packages/client/__tests__/routers.test.tsx +++ b/Composer/packages/client/__tests__/routers.test.tsx @@ -2,13 +2,18 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { createHistory, createMemorySource, LocationProvider } from '@reach/router'; import { App } from '../src/App'; import { wrapWithRecoil } from './testUtils'; +jest.mock('axios', () => ({ + create: jest.fn().mockReturnThis(), + get: jest.fn(), +})); + function renderWithRouter(ui, { route = '/dialogs/home', history = createHistory(createMemorySource(route)) } = {}) { return { ...render(<LocationProvider history={history}>{ui}</LocationProvider>), diff --git a/Composer/packages/client/__tests__/shell/lgApi.test.tsx b/Composer/packages/client/__tests__/shell/lgApi.test.tsx index b35ed50a06..13a28adb8f 100644 --- a/Composer/packages/client/__tests__/shell/lgApi.test.tsx +++ b/Composer/packages/client/__tests__/shell/lgApi.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { renderHook } from '@bfc/test-utils/lib/hooks'; +import { renderHook } from '@botframework-composer/test-utils/lib/hooks'; import * as React from 'react'; import { RecoilRoot } from 'recoil'; diff --git a/Composer/packages/client/__tests__/shell/luApi.test.tsx b/Composer/packages/client/__tests__/shell/luApi.test.tsx index 215c2cd7d1..4c910d47eb 100644 --- a/Composer/packages/client/__tests__/shell/luApi.test.tsx +++ b/Composer/packages/client/__tests__/shell/luApi.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { renderHook } from '@bfc/test-utils/lib/hooks'; +import { renderHook } from '@botframework-composer/test-utils/lib/hooks'; import * as React from 'react'; import { RecoilRoot } from 'recoil'; diff --git a/Composer/packages/client/__tests__/shell/triggerApi.test.tsx b/Composer/packages/client/__tests__/shell/triggerApi.test.tsx index 50931022b8..92aef42c06 100644 --- a/Composer/packages/client/__tests__/shell/triggerApi.test.tsx +++ b/Composer/packages/client/__tests__/shell/triggerApi.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { renderHook } from '@bfc/test-utils/lib/hooks'; +import { renderHook } from '@botframework-composer/test-utils/lib/hooks'; import * as React from 'react'; import { RecoilRoot } from 'recoil'; diff --git a/Composer/packages/client/__tests__/testUtils/react-recoil-hooks-testing-library.test.tsx b/Composer/packages/client/__tests__/testUtils/react-recoil-hooks-testing-library.test.tsx index 36fcc9a943..8e77c1e292 100644 --- a/Composer/packages/client/__tests__/testUtils/react-recoil-hooks-testing-library.test.tsx +++ b/Composer/packages/client/__tests__/testUtils/react-recoil-hooks-testing-library.test.tsx @@ -3,7 +3,7 @@ import React, { useContext } from 'react'; import { atom, useRecoilState, useRecoilValue } from 'recoil'; -import { act } from '@bfc/test-utils/lib/hooks'; +import { act } from '@botframework-composer/test-utils/lib/hooks'; import { renderRecoilHook } from './react-recoil-hooks-testing-library'; diff --git a/Composer/packages/client/__tests__/testUtils/react-recoil-hooks-testing-library.tsx b/Composer/packages/client/__tests__/testUtils/react-recoil-hooks-testing-library.tsx index 2c5faa5a86..58206190f0 100644 --- a/Composer/packages/client/__tests__/testUtils/react-recoil-hooks-testing-library.tsx +++ b/Composer/packages/client/__tests__/testUtils/react-recoil-hooks-testing-library.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { renderHook, RenderHookResult } from '@bfc/test-utils/lib/hooks'; +import { renderHook, RenderHookResult } from '@botframework-composer/test-utils/lib/hooks'; import React, { useEffect, useRef } from 'react'; import { RecoilRoot, RecoilState, useSetRecoilState } from 'recoil'; import reduce from 'lodash/reduce'; diff --git a/Composer/packages/client/__tests__/testUtils/renderWithRecoil.tsx b/Composer/packages/client/__tests__/testUtils/renderWithRecoil.tsx index 794aabc9b3..9010ca7e86 100644 --- a/Composer/packages/client/__tests__/testUtils/renderWithRecoil.tsx +++ b/Composer/packages/client/__tests__/testUtils/renderWithRecoil.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { RecoilRoot, MutableSnapshot } from 'recoil'; import noop from 'lodash/noop'; diff --git a/Composer/packages/client/__tests__/testUtils/renderWithRecoilAndContext.tsx b/Composer/packages/client/__tests__/testUtils/renderWithRecoilAndContext.tsx index 90044021de..e41ce3bc7c 100644 --- a/Composer/packages/client/__tests__/testUtils/renderWithRecoilAndContext.tsx +++ b/Composer/packages/client/__tests__/testUtils/renderWithRecoilAndContext.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { render } from '@bfc/test-utils'; +import { render } from '@botframework-composer/test-utils'; import { RecoilRoot, MutableSnapshot } from 'recoil'; import noop from 'lodash/noop'; diff --git a/Composer/packages/client/__tests__/utils/qnaUtil.test.ts b/Composer/packages/client/__tests__/utils/qnaUtil.test.ts deleted file mode 100644 index db964cc45b..0000000000 --- a/Composer/packages/client/__tests__/utils/qnaUtil.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { qnaIndexer } from '@bfc/indexers/src/qnaIndexer'; - -import { - addSection, - updateSection, - removeSection, - insertSection, - addQuestion, - updateQuestion, - updateAnswer, -} from '../../src/utils/qnaUtil'; - -describe('qna utils', () => { - const qnaPair1 = ` -# ?Question1 -\`\`\` -Answer1 -\`\`\``; - const qnaPair2 = ` -# ?Question2 -\`\`\` -Answer2 -\`\`\``; - - it('should add QnA Section', () => { - const newContent = addSection(qnaPair1, qnaPair2); - const res = qnaIndexer.parse(newContent); - const qnaSections = res.qnaSections; - expect(qnaSections.length).toBe(2); - }); - - it('should update QnA Section', () => { - const newContent = updateSection(0, qnaPair1, qnaPair2); - const res = qnaIndexer.parse(newContent); - const qnaSections = res.qnaSections; - expect(qnaSections.length).toBe(1); - expect(qnaSections[0].Questions[0].content).toBe('Question2'); - expect(qnaSections[0].Answer).toBe('Answer2'); - }); - - it('should remove QnA Section', () => { - const content = qnaPair1 + '\n' + qnaPair2; - const newContent = removeSection(0, content); - const res = qnaIndexer.parse(newContent); - const qnaSections = res.qnaSections; - expect(qnaSections.length).toBe(1); - expect(qnaSections[0].Questions[0].content).toBe('Question2'); - expect(qnaSections[0].Answer).toBe('Answer2'); - }); - - it('should insert QnA Section at the beginning', () => { - const newContent = insertSection(0, qnaPair1, qnaPair2); - const res = qnaIndexer.parse(newContent); - const qnaSections = res.qnaSections; - expect(qnaSections.length).toBe(2); - expect(qnaSections[0].Questions[0].content).toBe('Question2'); - expect(qnaSections[0].Answer).toBe('Answer2'); - }); - - it('should add a new Question to QnA Sectionn', () => { - const qnaSections = qnaIndexer.parse(qnaPair1).qnaSections; - const newContent = addQuestion('Question2', qnaSections, 0); - const newQnaSections = qnaIndexer.parse(newContent).qnaSections; - expect(newQnaSections.length).toBe(1); - expect(newQnaSections[0].Questions[0].content).toBe('Question1'); - expect(newQnaSections[0].Questions[1].content).toBe('Question2'); - expect(newQnaSections[0].Answer).toBe('Answer1'); - }); - - it('should update Question to QnA Sectionn', () => { - const qnaSections = qnaIndexer.parse(qnaPair1).qnaSections; - const newContent = updateQuestion('Question2', 0, qnaSections, 0); - const newQnaSections = qnaIndexer.parse(newContent).qnaSections; - expect(newQnaSections.length).toBe(1); - expect(newQnaSections[0].Questions.length).toBe(1); - expect(newQnaSections[0].Questions[0].content).toBe('Question2'); - expect(newQnaSections[0].Answer).toBe('Answer1'); - }); - - it('should update Answer to QnA Sectionn', () => { - const qnaSections = qnaIndexer.parse(qnaPair1).qnaSections; - const newContent = updateAnswer('Answer2', qnaSections, 0); - const newQnaSections = qnaIndexer.parse(newContent).qnaSections; - expect(newQnaSections.length).toBe(1); - expect(newQnaSections[0].Questions.length).toBe(1); - expect(newQnaSections[0].Questions[0].content).toBe('Question1'); - expect(newQnaSections[0].Answer).toBe('Answer2'); - }); -}); diff --git a/Composer/packages/client/config/extensions.config.js b/Composer/packages/client/config/extensions.config.js new file mode 100644 index 0000000000..da8f3122ee --- /dev/null +++ b/Composer/packages/client/config/extensions.config.js @@ -0,0 +1,55 @@ +const path = require('path'); + +const PnpWebpackPlugin = require('pnp-webpack-plugin'); +const TerserPlugin = require('terser-webpack-plugin'); + +module.exports = (webpackEnv) => { + const isEnvProduction = webpackEnv === 'production'; + + return { + mode: isEnvProduction ? 'production' : 'development', + entry: { + 'plugin-host-preload': path.resolve(__dirname, '../extension-container/plugin-host-preload.tsx'), + }, + output: { + path: path.resolve(__dirname, '../public'), + filename: '[name].js', + }, + resolve: { + extensions: ['.js'], + plugins: [PnpWebpackPlugin], + }, + resolveLoader: { + plugins: [ + // Also related to Plug'n'Play, but this time it tells Webpack to load its loaders + // from the current package. + PnpWebpackPlugin.moduleLoader(module), + ], + }, + module: { + rules: [ + { + test: /\.tsx?$/, + loader: require.resolve('ts-loader'), + include: [path.resolve(__dirname, '../extension-container')], + options: PnpWebpackPlugin.tsLoaderOptions({ + transpileOnly: true, + configFile: path.resolve(__dirname, '../tsconfig.build.json'), + }), + }, + ], + }, + optimization: { + minimize: isEnvProduction, + + minimizer: [ + new TerserPlugin({ + extractComments: false, + terserOptions: { + sourceMap: true, + }, + }), + ], + }, + }; +}; diff --git a/Composer/packages/client/config/webpack-react-dom.config.js b/Composer/packages/client/config/webpack-react-dom.config.js deleted file mode 100644 index 4e325e745c..0000000000 --- a/Composer/packages/client/config/webpack-react-dom.config.js +++ /dev/null @@ -1,22 +0,0 @@ -const { resolve } = require('path'); - -module.exports = { - entry: { - 'react-dom-bundle': 'react-dom', - }, - mode: 'production', - // export react-dom globally under a variable named ReactDOM - output: { - path: resolve(__dirname, '../public'), - library: 'ReactDOM', - libraryTarget: 'var', - }, - externals: { - // ReactDOM depends on React, but we need this to resolve to the globally-exposed React variable in react-bundle.js (created by webpack-react.config.js). - // If we don't do this, ReactDom will bundle its own copy of React and we will have 2 copies which breaks hooks. - react: 'React', - }, - resolve: { - extensions: ['.js'], - }, -}; diff --git a/Composer/packages/client/config/webpack-react.config.js b/Composer/packages/client/config/webpack-react.config.js deleted file mode 100644 index 711737994a..0000000000 --- a/Composer/packages/client/config/webpack-react.config.js +++ /dev/null @@ -1,17 +0,0 @@ -const { resolve } = require('path'); - -module.exports = { - entry: { - 'react-bundle': 'react', - }, - mode: 'production', - // export react globally under a variable named React - output: { - path: resolve(__dirname, '../public'), - library: 'React', - libraryTarget: 'var', - }, - resolve: { - extensions: ['.js'], - }, -}; diff --git a/Composer/packages/client/config/webpack.config.js b/Composer/packages/client/config/webpack.config.js index 4014117ae6..f026477c45 100644 --- a/Composer/packages/client/config/webpack.config.js +++ b/Composer/packages/client/config/webpack.config.js @@ -22,6 +22,10 @@ const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPl const getClientEnvironment = require('./env'); const paths = require('./paths'); +new webpack.DefinePlugin({ + 'process.env.COMPOSER_ENABLE_FORMS': JSON.stringify(process.env.COMPOSER_ENABLE_FORMS), +}); + // Source maps are resource heavy and can cause out of memory issue for large source files. const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false'; diff --git a/Composer/packages/client/extension-container/plugin-host-preload.tsx b/Composer/packages/client/extension-container/plugin-host-preload.tsx new file mode 100644 index 0000000000..6f67c236b4 --- /dev/null +++ b/Composer/packages/client/extension-container/plugin-host-preload.tsx @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import ReactDOM from 'react-dom'; +import * as ExtensionClient from '@bfc/extension-client'; +import { syncStore, Shell } from '@bfc/extension-client'; + +if (!document.head.title) { + const title = document.createElement('title'); + title.innerHTML = 'Plugin Host'; + document.head.append(title); +} + +// add default doc styles +if (!document.getElementById('plugin-host-default-styles')) { + const styles = document.createElement('style'); + styles.id = 'plugin-host-default-styles'; + styles.type = 'text/css'; + styles.appendChild( + document.createTextNode(` + html, body { padding: 0; margin: 0; } + #plugin-root { + display: flex; + flex-flow: column nowrap; + height: 100%; + } + `) + ); + document.head.appendChild(styles); +} +// add the react mount point +if (!document.getElementById('plugin-root')) { + const root = document.createElement('div'); + root.id = 'plugin-root'; + document.body.appendChild(root); +} +// initialize the API object +window.React = React; +window.ReactDOM = ReactDOM; +window.ExtensionClient = ExtensionClient; +// eslint-disable-next-line @typescript-eslint/ban-ts-ignore +// @ts-ignore +window.Composer = {}; + +// init the render function +window.Composer.render = function (type: string, shell: Shell, component: React.ReactElement) { + // eslint-disable-next-line no-underscore-dangle + window.Composer.__pluginType = type; + + if (shell) { + syncStore(shell); + } + + ReactDOM.render(component, document.getElementById('plugin-root')); +}; + +window.Composer.sync = function (shell: Shell) { + syncStore(shell); +}; + +window.parent?.postMessage('host-preload-complete', '*'); diff --git a/Composer/packages/client/jest.config.js b/Composer/packages/client/jest.config.js index 608e8d2da2..62e7372e5d 100644 --- a/Composer/packages/client/jest.config.js +++ b/Composer/packages/client/jest.config.js @@ -1,5 +1,5 @@ const path = require('path'); -const { createConfig } = require('@bfc/test-utils'); +const { createConfig } = require('@botframework-composer/test-utils'); module.exports = createConfig('client', 'react', { setupFilesAfterEnv: [path.resolve(__dirname, 'setupTests.ts')], diff --git a/Composer/packages/client/package.json b/Composer/packages/client/package.json index b9417f2435..00f12fa533 100644 --- a/Composer/packages/client/package.json +++ b/Composer/packages/client/package.json @@ -8,9 +8,9 @@ "node": ">=12" }, "scripts": { - "start": "yarn build:react-bundles && node scripts/start.js", + "start": "yarn build:extension-bundles && node scripts/start.js", "build": "node --max_old_space_size=4096 scripts/build.js", - "build:react-bundles": "webpack --config ./config/webpack-react.config.js && webpack --config ./config/webpack-react-dom.config.js", + "build:extension-bundles": "webpack --config ./config/extensions.config.js --env production", "clean": "rimraf build", "test": "jest", "lint": "eslint --quiet --ext .js,.jsx,.ts,.tsx ./src ./__tests__", @@ -23,8 +23,10 @@ "@bfc/adaptive-form": "*", "@bfc/code-editor": "*", "@bfc/extension-client": "*", + "@bfc/form-dialogs": "*", "@bfc/indexers": "*", "@bfc/shared": "*", + "@bfc/ui-shared": "*", "@bfc/ui-plugin-composer": "*", "@bfc/ui-plugin-cross-trained": "*", "@bfc/ui-plugin-dialog-schema-editor": "*", @@ -33,6 +35,8 @@ "@bfc/ui-plugin-prompts": "*", "@bfc/ui-plugin-select-dialog": "*", "@bfc/ui-plugin-select-skill-dialog": "*", + "@bfc/ui-plugin-va-creation": "*", + "@botframework-composer/types": "*", "@emotion/core": "^10.0.27", "@reach/router": "^1.2.1", "@uifabric/fluent-theme": "^7.1.107", @@ -49,13 +53,13 @@ "office-ui-fabric-react": "^7.121.11", "prop-types": "^15.7.2", "query-string": "^6.8.2", - "react-measure": "^2.3.0", "re-resizable": "^6.3.2", "react": "16.13.1", "react-app-polyfill": "^0.2.1", "react-dev-utils": "^7.0.3", "react-dom": "16.13.1", "react-frame-component": "^4.0.2", + "react-measure": "^2.3.0", "react-timeago": "^4.4.0", "recoil": "^0.0.13", "styled-components": "^4.1.3", @@ -72,7 +76,7 @@ "@babel/cli": "7.2.3", "@babel/core": "7.3.4", "@babel/runtime": "7.3.4", - "@bfc/test-utils": "*", + "@botframework-composer/test-utils": "*", "@emotion/babel-preset-css-prop": "^10.0.14", "@svgr/webpack": "4.1.0", "@types/jwt-decode": "^2.2.1", diff --git a/Composer/packages/client/public/index.html b/Composer/packages/client/public/index.html index f07ec70b3f..047330b0d4 100644 --- a/Composer/packages/client/public/index.html +++ b/Composer/packages/client/public/index.html @@ -36,7 +36,7 @@ }; window.CSPSettings = { - nonce: window.__nonce__ + nonce: window.__nonce__ }; </script> <? } ?> diff --git a/Composer/packages/client/public/plugin-host-preload.js b/Composer/packages/client/public/plugin-host-preload.js deleted file mode 100644 index 688fa7b79f..0000000000 --- a/Composer/packages/client/public/plugin-host-preload.js +++ /dev/null @@ -1,29 +0,0 @@ -// add default doc styles -if (!document.getElementById('plugin-host-default-styles')) { - const styles = document.createElement('style'); - styles.id = 'plugin-host-default-styles'; - styles.type = 'text/css'; - styles.appendChild( - document.createTextNode(` - html, body { padding: 0; margin: 0; } - #plugin-root { - display: flex; - flex-flow: column nowrap; - height: 100%; - } - `) - ); - document.head.appendChild(styles); -} -// add the react mount point -if (!document.getElementById('plugin-root')) { - const root = document.createElement('div'); - root.id = 'plugin-root'; - document.body.appendChild(root); -} -// initialize the API object -window.Composer = {}; -// init the render function -window.Composer['render'] = function (component) { - ReactDOM.render(component, document.getElementById('plugin-root')); -}; diff --git a/Composer/packages/client/public/react-bundle.js.LICENSE.txt b/Composer/packages/client/public/react-bundle.js.LICENSE.txt new file mode 100644 index 0000000000..4467f637b9 --- /dev/null +++ b/Composer/packages/client/public/react-bundle.js.LICENSE.txt @@ -0,0 +1,14 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/Composer/packages/client/public/react-dom-bundle.js.LICENSE.txt b/Composer/packages/client/public/react-dom-bundle.js.LICENSE.txt new file mode 100644 index 0000000000..787b51e5d7 --- /dev/null +++ b/Composer/packages/client/public/react-dom-bundle.js.LICENSE.txt @@ -0,0 +1,23 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/** @license React v0.19.1 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index 3a1fd4bbeb..9b69bd3586 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -21,9 +21,10 @@ export const App: React.FC = () => { }, [appLocale]); const { fetchExtensions } = useRecoilValue(dispatcherState); + useEffect(() => { fetchExtensions(); - }); + }, []); return ( <Fragment key={appLocale}> diff --git a/Composer/packages/client/src/Onboarding/Onboarding.tsx b/Composer/packages/client/src/Onboarding/Onboarding.tsx index 81b5c2b20b..50817bf654 100644 --- a/Composer/packages/client/src/Onboarding/Onboarding.tsx +++ b/Composer/packages/client/src/Onboarding/Onboarding.tsx @@ -9,7 +9,7 @@ import { useRecoilValue } from 'recoil'; import onboardingStorage from '../utils/onboardingStorage'; import { OpenConfirmModal } from '../components/Modal/ConfirmDialog'; import { useLocation } from '../utils/hooks'; -import { dispatcherState, onboardingState, botProjectsSpaceState, validateDialogSelectorFamily } from '../recoilModel'; +import { dispatcherState, onboardingState, botProjectIdsState, validateDialogSelectorFamily } from '../recoilModel'; import OnboardingContext from './OnboardingContext'; import TeachingBubbles from './TeachingBubbles/TeachingBubbles'; @@ -20,7 +20,7 @@ const getCurrentSet = (stepSets) => stepSets.findIndex(({ id }) => id === onboar const Onboarding: React.FC = () => { const didMount = useRef(false); - const botProjects = useRecoilValue(botProjectsSpaceState); + const botProjects = useRecoilValue(botProjectIdsState); const rootBotProjectId = botProjects[0]; const dialogs = useRecoilValue(validateDialogSelectorFamily(rootBotProjectId)); const { onboardingSetComplete } = useRecoilValue(dispatcherState); diff --git a/Composer/packages/client/src/components/CreateSkillModal.tsx b/Composer/packages/client/src/components/CreateSkillModal.tsx index f42a21b491..cfe7b95343 100644 --- a/Composer/packages/client/src/components/CreateSkillModal.tsx +++ b/Composer/packages/client/src/components/CreateSkillModal.tsx @@ -12,13 +12,12 @@ import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { useRecoilValue } from 'recoil'; import debounce from 'lodash/debounce'; import { SkillSetting } from '@bfc/shared'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; import { addSkillDialog } from '../constants'; import httpClient from '../utils/httpUtil'; import { skillsState } from '../recoilModel'; -import { DialogWrapper, DialogTypes } from './DialogWrapper'; - export interface SkillFormDataErrors { endpoint?: string; manifestUrl?: string; @@ -83,7 +82,7 @@ export const validateManifestUrl = async ({ } else { try { setValidationState({ ...validationState, manifestUrl: ValidationState.Validating }); - const { data } = await httpClient.get(`/projects/${projectId}/skill/retrieve-skill-manifest`, { + const { data } = await httpClient.get(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { url: manifestUrl, }, diff --git a/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx b/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx index 797c47a34e..208e693c9a 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx @@ -21,10 +21,10 @@ import { } from 'office-ui-fabric-react/lib/DetailsList'; import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky'; import { ProjectTemplate } from '@bfc/shared'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; import { NeutralColors } from '@uifabric/fluent-theme'; import { DialogCreationCopy, EmptyBotTemplateId, QnABotTemplateId } from '../../constants'; -import { DialogWrapper, DialogTypes } from '../DialogWrapper'; // -------------------- Styles -------------------- // diff --git a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx index bcfff92fc8..7a8bba7b38 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx @@ -4,9 +4,11 @@ // TODO: Remove path module import Path from 'path'; -import React, { useEffect, useRef, Fragment, useState } from 'react'; +import React, { useEffect, useRef, Fragment, useState, useMemo } from 'react'; import { RouteComponentProps, Router, navigate } from '@reach/router'; import { useRecoilValue } from 'recoil'; +import VirtualAssistantCreationModal from '@bfc/ui-plugin-va-creation'; +import { PluginConfig, mergePluginConfigs, EditorExtension } from '@bfc/extension-client'; import { CreationFlowStatus } from '../../constants'; import { @@ -19,9 +21,9 @@ import { userSettingsState, } 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 { useShell } from '../../shell'; +import plugins from '../../plugins'; import { CreateOptions } from './CreateOptions'; import { OpenProject } from './OpenProject'; @@ -32,9 +34,6 @@ type CreationFlowProps = RouteComponentProps<{}>; const CreationFlow: React.FC<CreationFlowProps> = () => { const { fetchTemplates, - openProject, - createProject, - saveProjectAs, fetchStorages, fetchFolderItemsByPath, setCreationFlowStatus, @@ -42,9 +41,13 @@ const CreationFlow: React.FC<CreationFlowProps> = () => { updateCurrentPathForStorage, updateFolder, saveTemplateId, - fetchProjectById, fetchRecentProjects, + openProject, + createNewBot, + saveProjectAs, + fetchProjectById, } = useRecoilValue(dispatcherState); + const creationFlowStatus = useRecoilValue(creationFlowStatusState); const projectId = useRecoilValue(currentProjectIdState); const templateProjects = useRecoilValue(templateProjectsState); @@ -55,8 +58,8 @@ const CreationFlow: React.FC<CreationFlowProps> = () => { const currentStorageIndex = useRef(0); const storage = storages[currentStorageIndex.current]; const currentStorageId = storage ? storage.id : 'default'; - const [formData, setFormData] = useState({ name: '' }); - + const [formData, setFormData] = useState({ name: '', description: '', location: '' }); + const shellForCreation = useShell('VaCreation', projectId); useEffect(() => { if (storages && storages.length) { const storageId = storage.id; @@ -66,6 +69,13 @@ const CreationFlow: React.FC<CreationFlowProps> = () => { } }, [storages]); + // Plugin config for VA creation plug in + const pluginConfig: PluginConfig = useMemo(() => { + const sdkUISchema = {}; + const userUISchema = {}; + return mergePluginConfigs({ uiSchema: sdkUISchema }, plugins, { uiSchema: userUISchema }); + }, []); + const fetchResources = async () => { // fetchProject use `gotoSnapshot` which will wipe out all state value. // so here make those methods call in sequence. @@ -102,33 +112,36 @@ const CreationFlow: React.FC<CreationFlowProps> = () => { }; const handleCreateNew = async (formData, templateId: string, qnaKbUrls?: string[]) => { - createProject( - templateId || '', - formData.name, - formData.description, - formData.location, - formData.schemaUrl, + const newBotData = { + templateId: templateId || '', + name: formData.name, + description: formData.description, + location: formData.location, + schemaUrl: formData.schemaUrl, appLocale, - qnaKbUrls - ); + qnaKbUrls, + }; + createNewBot(newBotData); }; const handleSaveAs = (formData) => { saveProjectAs(projectId, formData.name, formData.description, formData.location); }; - const handleCreateQnA = async (urls: string[]) => { - saveTemplateId(QnABotTemplateId); - handleDismiss(); - handleCreateNew(formData, QnABotTemplateId, urls); - }; - - const handleSubmitOrImportQnA = async (formData, templateId: string) => { + const handleDefineConversationSubmit = async (formData, templateId: string) => { + // If selected template is qnaSample then route to QNA import modal if (templateId === 'QnASample') { setFormData(formData); navigate(`./QnASample/importQnA`); return; } + // If selected template is vaCore then route to VA Customization modal + if (templateId === 'va-core') { + setFormData(formData); + navigate(`./vaCore/customize`); + return; + } + handleSubmit(formData, templateId); }; @@ -153,40 +166,47 @@ const CreationFlow: React.FC<CreationFlowProps> = () => { return ( <Fragment> <Home /> - <Router> - <DefineConversation - createFolder={createFolder} - focusedStorageFolder={focusedStorageFolder} - path="create/:templateId" - updateFolder={updateFolder} - onCurrentPathUpdate={updateCurrentPath} - onDismiss={handleDismiss} - onSubmit={handleSubmitOrImportQnA} - /> - <CreateOptions path="create" templates={templateProjects} onDismiss={handleDismiss} onNext={handleCreateNext} /> - <DefineConversation - createFolder={createFolder} - focusedStorageFolder={focusedStorageFolder} - path=":projectId/:templateId/save" - updateFolder={updateFolder} - onCurrentPathUpdate={updateCurrentPath} - onDismiss={handleDismiss} - onSubmit={handleSubmitOrImportQnA} - /> - <OpenProject - focusedStorageFolder={focusedStorageFolder} - path="open" - onCurrentPathUpdate={updateCurrentPath} - onDismiss={handleDismiss} - onOpen={openBot} - /> - <ImportQnAFromUrlModal - dialogId={formData.name.toLowerCase()} - path="create/QnASample/importQnA" - onDismiss={handleDismiss} - onSubmit={handleCreateQnA} - /> - </Router> + <EditorExtension plugins={pluginConfig} projectId={projectId} shell={shellForCreation}> + <Router> + <DefineConversation + createFolder={createFolder} + focusedStorageFolder={focusedStorageFolder} + path="create/:templateId" + updateFolder={updateFolder} + onCurrentPathUpdate={updateCurrentPath} + onDismiss={handleDismiss} + onSubmit={handleDefineConversationSubmit} + /> + <CreateOptions + path="create" + templates={templateProjects} + onDismiss={handleDismiss} + onNext={handleCreateNext} + /> + <DefineConversation + createFolder={createFolder} + focusedStorageFolder={focusedStorageFolder} + path=":projectId/:templateId/save" + updateFolder={updateFolder} + onCurrentPathUpdate={updateCurrentPath} + onDismiss={handleDismiss} + onSubmit={handleDefineConversationSubmit} + /> + <OpenProject + focusedStorageFolder={focusedStorageFolder} + path="open" + onCurrentPathUpdate={updateCurrentPath} + onDismiss={handleDismiss} + onOpen={openBot} + /> + <VirtualAssistantCreationModal + formData={formData} + handleCreateNew={handleCreateNew} + path="create/vaCore/*" + onDismiss={handleDismiss} + /> + </Router> + </EditorExtension> </Fragment> ); }; diff --git a/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx b/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx index 38eabc5fb0..4fcf9292fb 100644 --- a/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx +++ b/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx @@ -13,9 +13,9 @@ import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { RouteComponentProps } from '@reach/router'; import querystring from 'query-string'; import { FontWeights } from '@uifabric/styling'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; import { DialogCreationCopy, QnABotTemplateId, nameRegex } from '../../constants'; -import { DialogWrapper, DialogTypes } from '../DialogWrapper'; import { FieldConfig, useForm } from '../../hooks/useForm'; import { StorageFolder } from '../../recoilModel/types'; diff --git a/Composer/packages/client/src/components/CreationFlow/OpenProject.tsx b/Composer/packages/client/src/components/CreationFlow/OpenProject.tsx index 086c60e2a9..7880b077d2 100644 --- a/Composer/packages/client/src/components/CreationFlow/OpenProject.tsx +++ b/Composer/packages/client/src/components/CreationFlow/OpenProject.tsx @@ -8,10 +8,10 @@ import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; import formatMessage from 'format-message'; import { RouteComponentProps } from '@reach/router'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; import { StorageFolder } from '../../recoilModel/types'; import { DialogCreationCopy } from '../../constants'; -import { DialogWrapper, DialogTypes } from '../DialogWrapper'; import { LocationSelectContent } from './LocationSelectContent'; interface OpenProjectProps extends RouteComponentProps<{}> { diff --git a/Composer/packages/client/src/components/EditableField.tsx b/Composer/packages/client/src/components/EditableField.tsx index 4e449d53ae..ace6c7e24c 100644 --- a/Composer/packages/client/src/components/EditableField.tsx +++ b/Composer/packages/client/src/components/EditableField.tsx @@ -1,42 +1,92 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. - -import React, { useState, useEffect } from 'react'; -import { TextField, ITextFieldStyles, ITextFieldProps } from 'office-ui-fabric-react/lib/TextField'; -import { NeutralColors } from '@uifabric/fluent-theme'; +/** @jsx jsx */ +import { jsx, css, SerializedStyles } from '@emotion/core'; +import React, { useState, useEffect, useRef, Fragment } from 'react'; +import { TextField, ITextFieldStyles, ITextFieldProps, ITextField } from 'office-ui-fabric-react/lib/TextField'; +import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; import { mergeStyleSets } from '@uifabric/styling'; +import { IconButton } from 'office-ui-fabric-react/lib/Button'; +import { IIconProps } from 'office-ui-fabric-react/lib/Icon'; + +import { FieldConfig, useForm } from '../hooks/useForm'; +//------------------------ +const defaultContainerStyle = (hasFocus, hasErrors) => css` + display: flex; + width: 100%; + outline: ${hasErrors + ? `2px solid ${SharedColors.red10}` + : hasFocus + ? `2px solid ${SharedColors.cyanBlue10}` + : undefined}; + background: ${hasFocus || hasErrors ? NeutralColors.white : 'inherit'}; + margin-top: 2px; + :hover .ms-Button-icon { + visibility: visible; + } + .ms-TextField-field { + cursor: pointer; + padding-left: ${hasFocus || hasErrors ? '8px' : '0px'}; + :focus { + cursor: inherit; + } + } +`; + +// turncat to show two line. +const maxCharacterNumbers = 120; + +//------------------------ +type IconProps = { + iconStyles?: Partial<IIconProps>; + iconName: string; + onClick?: () => void; +}; interface EditableFieldProps extends Omit<ITextFieldProps, 'onChange' | 'onFocus' | 'onBlur'> { + expanded?: boolean; + componentFocusOnmount?: boolean; fontSize?: string; styles?: Partial<ITextFieldStyles>; transparentBorder?: boolean; ariaLabel?: string; error?: string | JSX.Element; - + extraContent?: string; + containerStyles?: SerializedStyles; className?: string; depth: number; description?: string; disabled?: boolean; + resizable?: boolean; + requiredMessage?: string; id: string; - name: string; + name?: string; placeholder?: string; readonly?: boolean; required?: boolean; value?: string; - - onChange: (newValue?: string) => void; - onFocus?: (id: string, value?: string) => void; + iconProps?: IconProps; + enableIcon?: boolean; onBlur?: (id: string, value?: string) => void; + onChange: (newValue?: string) => void; + onFocus?: () => void; } const EditableField: React.FC<EditableFieldProps> = (props) => { const { + componentFocusOnmount = false, + containerStyles, depth, + required, + requiredMessage, + extraContent = '', styles = {}, + iconProps, placeholder, fontSize, - multiline = false, + expanded = false, onChange, + onFocus, onBlur, value, id, @@ -44,20 +94,56 @@ const EditableField: React.FC<EditableFieldProps> = (props) => { className, transparentBorder, ariaLabel, + enableIcon = false, } = props; const [editing, setEditing] = useState<boolean>(false); const [hasFocus, setHasFocus] = useState<boolean>(false); - const [localValue, setLocalValue] = useState<string | undefined>(value); const [hasBeenEdited, setHasBeenEdited] = useState<boolean>(false); + const [multiline, setMultiline] = useState<boolean>(true); + const formConfig: FieldConfig<{ value: string }> = { + value: { + required: required, + defaultValue: value, + }, + }; + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + + const fieldRef = useRef<ITextField>(null); + useEffect(() => { + if (componentFocusOnmount) { + fieldRef.current?.focus(); + } + }, []); useEffect(() => { - if (!hasBeenEdited || value !== localValue) { - setLocalValue(value); + if (!hasBeenEdited || value !== formData.value) { + updateField('value', value); } }, [value]); + useEffect(() => { + if (formData.value.length > maxCharacterNumbers) { + setMultiline(true); + return; + } + + if (expanded || hasFocus) { + if (formData.value.length > maxCharacterNumbers) { + setMultiline(true); + } + } + setMultiline(false); + }, [expanded, hasFocus]); + + const resetValue = () => { + updateField('value', ''); + setHasBeenEdited(true); + fieldRef.current?.focus(); + }; + const handleChange = (_e: any, newValue?: string) => { - setLocalValue(newValue); + if (newValue && newValue?.length > maxCharacterNumbers) setMultiline(true); + updateField('value', newValue); setHasBeenEdited(true); onChange(newValue); }; @@ -65,58 +151,131 @@ const EditableField: React.FC<EditableFieldProps> = (props) => { const handleCommit = () => { setHasFocus(false); setEditing(false); - onBlur && onBlur(id, localValue); + + // update view after resetValue + if (!formData.value) { + updateField('value', value); + } + onBlur && onBlur(id, formData.value); + }; + + const handleOnFocus = () => { + setHasFocus(true); + onFocus && onFocus(); + }; + + const cancel = () => { + setHasFocus(false); + setEditing(false); + updateField('value', value); + fieldRef.current?.blur(); + }; + + const handleOnKeyDown = (e) => { + if (e.key === 'Enter' && expanded) { + handleCommit(); + } + if (e.key === 'Escape') { + cancel(); + } }; let borderColor: string | undefined = undefined; if (!editing && !error) { - borderColor = localValue || transparentBorder || depth > 1 ? 'transparent' : NeutralColors.gray30; + borderColor = formData.value || transparentBorder || depth > 1 ? 'transparent' : NeutralColors.gray30; } + const hasEditingErrors = hasErrors && hasBeenEdited; + return ( - <div - className={className} - onMouseEnter={() => setEditing(true)} - onMouseLeave={() => !hasFocus && setEditing(false)} - > - <TextField - ariaLabel={ariaLabel} - autoComplete="off" - errorMessage={error as string} - multiline={multiline} - placeholder={placeholder || value} - styles={ - mergeStyleSets( - { - root: { margin: '0', width: '100%' }, - field: { - fontSize: fontSize, - selectors: { - '::placeholder': { - fontSize: fontSize, + <Fragment> + <div + css={[defaultContainerStyle(hasFocus, hasEditingErrors), containerStyles]} + data-test-id={'EditableFieldContainer'} + > + <TextField + key={`${id}-${expanded}-${multiline}-${hasFocus}`} // force update component to trigger autoAdjustHeight + ariaLabel={ariaLabel} + autoAdjustHeight={expanded} + autoComplete="off" + className={className} + componentRef={fieldRef} + multiline={multiline} + placeholder={placeholder || value} + resizable={false} + styles={ + mergeStyleSets( + { + root: { margin: '0', width: '100%' }, + field: { + fontSize: fontSize, + selectors: { + '::placeholder': { + fontSize: fontSize, + }, + }, + }, + fieldGroup: { + borderColor, + transition: 'border-color 0.1s linear', + selectors: { + ':hover': { + borderColor: hasFocus ? undefined : NeutralColors.gray30, + }, + '.ms-TextField-field': { + background: hasFocus || hasEditingErrors ? NeutralColors.white : 'inherit', + }, }, }, }, - fieldGroup: { - borderColor, - transition: 'border-color 0.1s linear', - selectors: { - ':hover': { - borderColor: hasFocus ? undefined : NeutralColors.gray30, + styles + ) as Partial<ITextFieldStyles> + } + value={ + hasFocus || !extraContent || expanded + ? formData.value + : `${ + formData.value.length > maxCharacterNumbers + ? formData.value.substring(0, maxCharacterNumbers) + '...' + : formData.value + } ${extraContent}` + } + onBlur={handleCommit} + onChange={handleChange} + onFocus={handleOnFocus} + onKeyDown={handleOnKeyDown} + onMouseEnter={() => setEditing(true)} + onMouseLeave={() => !hasFocus && setEditing(false)} + /> + {enableIcon && ( + <IconButton + iconProps={{ + iconName: iconProps?.iconName, + styles: mergeStyleSets( + { + root: { + color: NeutralColors.black, + visibility: 'hidden', }, }, + iconProps?.iconStyles + ), + }} + styles={{ + root: { + background: hasFocus ? NeutralColors.white : 'inherit', }, - }, - styles - ) as Partial<ITextFieldStyles> - } - value={localValue} - onBlur={handleCommit} - onChange={handleChange} - onFocus={() => setHasFocus(true)} - /> - </div> + }} + onClick={iconProps?.onClick || resetValue} + /> + )} + </div> + {hasErrors && hasBeenEdited && ( + <span style={{ color: SharedColors.red20 }}>{requiredMessage || formErrors.value}</span> + )} + {error && <span style={{ color: SharedColors.red20 }}>{error}</span>} + </Fragment> ); }; diff --git a/Composer/packages/client/src/components/Header.tsx b/Composer/packages/client/src/components/Header.tsx index 98519ed5a6..c4bc3796e9 100644 --- a/Composer/packages/client/src/components/Header.tsx +++ b/Composer/packages/client/src/components/Header.tsx @@ -10,7 +10,13 @@ import { useRecoilValue } from 'recoil'; import { SharedColors } from '@uifabric/fluent-theme'; import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; -import { dispatcherState, appUpdateState, botNameState, localeState, currentProjectIdState } from '../recoilModel'; +import { + dispatcherState, + appUpdateState, + botDisplayNameState, + localeState, + currentProjectIdState, +} from '../recoilModel'; import composerIcon from '../images/composerIcon.svg'; import { AppUpdaterStatus } from '../constants'; @@ -75,7 +81,7 @@ const headerTextContainer = css` export const Header = () => { const { setAppUpdateShowing } = useRecoilValue(dispatcherState); const projectId = useRecoilValue(currentProjectIdState); - const projectName = useRecoilValue(botNameState(projectId)); + const projectName = useRecoilValue(botDisplayNameState(projectId)); const locale = useRecoilValue(localeState(projectId)); const appUpdate = useRecoilValue(appUpdateState); const { showing, status } = appUpdate; diff --git a/Composer/packages/client/src/components/MultiLanguage/AddLanguageModal.tsx b/Composer/packages/client/src/components/MultiLanguage/AddLanguageModal.tsx index 5f4c7ea304..547d598577 100644 --- a/Composer/packages/client/src/components/MultiLanguage/AddLanguageModal.tsx +++ b/Composer/packages/client/src/components/MultiLanguage/AddLanguageModal.tsx @@ -14,8 +14,8 @@ import { Stack, StackItem } from 'office-ui-fabric-react/lib/Stack'; import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; import { Label } from 'office-ui-fabric-react/lib/Label'; import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; -import { DialogWrapper, DialogTypes } from '../DialogWrapper'; import { MultiLanguagesDialog } from '../../constants'; import { ILanguageFormData } from './types'; diff --git a/Composer/packages/client/src/components/MultiLanguage/DeleteLanguageModal.tsx b/Composer/packages/client/src/components/MultiLanguage/DeleteLanguageModal.tsx index 31c2f0d4f7..a1373a2310 100644 --- a/Composer/packages/client/src/components/MultiLanguage/DeleteLanguageModal.tsx +++ b/Composer/packages/client/src/components/MultiLanguage/DeleteLanguageModal.tsx @@ -12,8 +12,8 @@ import { ScrollablePane, IScrollablePaneStyles } from 'office-ui-fabric-react/li import { Stack, StackItem } from 'office-ui-fabric-react/lib/Stack'; import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; import { Label } from 'office-ui-fabric-react/lib/Label'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; -import { DialogWrapper, DialogTypes } from '../DialogWrapper'; import { MultiLanguagesDialog } from '../../constants'; import { ILanguageFormData } from './types'; diff --git a/Composer/packages/client/src/components/NavTree.tsx b/Composer/packages/client/src/components/NavTree.tsx index 6bf12de255..26b16ab413 100644 --- a/Composer/packages/client/src/components/NavTree.tsx +++ b/Composer/packages/client/src/components/NavTree.tsx @@ -3,11 +3,14 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; -import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { DefaultButton, CommandBarButton, IButtonStyles } from 'office-ui-fabric-react/lib/Button'; import { FontWeights, FontSizes } from 'office-ui-fabric-react/lib/Styling'; -import { NeutralColors } from '@uifabric/fluent-theme'; -import { IButtonStyles } from 'office-ui-fabric-react/lib/Button'; +import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; +import { OverflowSet, IOverflowSetItemProps } from 'office-ui-fabric-react/lib/OverflowSet'; import { mergeStyleSets } from 'office-ui-fabric-react/lib/Styling'; +import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; +import { IIconProps } from 'office-ui-fabric-react/lib/Icon'; +import formatMessage from 'format-message'; import { navigateTo } from '../utils/navigation'; @@ -22,6 +25,18 @@ const root = css` .ms-List-cell { min-height: 36px; } + .ProjectTreeItem { + display: flex; + .ms-Icon { + color: ${SharedColors.blue10}; + } + &:hover .ms-Button { + background: ${NeutralColors.gray20}; + .ms-Icon { + visibility: inherit; + } + } + } `; const itemBase: IButtonStyles = { @@ -61,6 +76,8 @@ export interface INavTreeItem { name: string; ariaLabel?: string; url: string; + menuItems?: IContextualMenuItem[]; + menuIconProps?: IIconProps; disabled?: boolean; } @@ -72,23 +89,63 @@ interface INavTreeProps { const NavTree: React.FC<INavTreeProps> = (props) => { const { navLinks, regionName } = props; + const onRenderOverflowButton = (isSelected: boolean, item) => ( + menuItems: IOverflowSetItemProps[] | undefined + ): JSX.Element => { + const buttonStyles: Partial<IButtonStyles> = { + root: { + minWidth: 0, + padding: '0 4px', + alignSelf: 'stretch', + height: 'auto', + background: isSelected ? NeutralColors.gray20 : NeutralColors.white, + selectors: { + '.ms-Icon': { + visibility: isSelected ? 'inherit' : 'hidden', + }, + }, + }, + }; + return ( + <CommandBarButton + ariaLabel={formatMessage('Menu items')} + menuIconProps={item.menuIconProps as IIconProps} + menuProps={{ items: menuItems as IContextualMenuItem[] }} + role="menuitem" + styles={buttonStyles} + /> + ); + }; + return ( <div aria-label={regionName} className="ProjectTree" css={root} data-testid="ProjectTree" role="region"> {navLinks.map((item) => { const isSelected = location.pathname.includes(item.url); return ( - <DefaultButton - key={item.id} - disabled={item.disabled} - href={item.url} - styles={isSelected ? itemSelected : itemNotSelected} - text={item.name} - onClick={(e) => { - e.preventDefault(); - navigateTo(item.url); - }} - /> + <div key={item.id} className="ProjectTreeItem"> + <DefaultButton + key={item.id} + disabled={item.disabled} + href={item.url} + styles={isSelected ? itemSelected : itemNotSelected} + text={item.name} + onClick={(e) => { + e.preventDefault(); + navigateTo(item.url); + }} + /> + {item.menuItems && !item.disabled && ( + <OverflowSet + key={item.id + 'menu'} + items={[]} + overflowItems={item.menuItems as IOverflowSetItemProps[]} + role="menubar" + onRenderItem={() => undefined} + onRenderOverflowButton={onRenderOverflowButton(isSelected, item)} + /> + )} + </div> ); })} </div> diff --git a/Composer/packages/client/src/components/Page.tsx b/Composer/packages/client/src/components/Page.tsx index a8bcb2b962..372b04a411 100644 --- a/Composer/packages/client/src/components/Page.tsx +++ b/Composer/packages/client/src/components/Page.tsx @@ -74,7 +74,7 @@ export const content = css` padding: 20px; position: relative; overflow: auto; - height: 100%; + height: calc(100% - 40px); label: PageContent; `; diff --git a/Composer/packages/client/src/components/PluginHost/PluginHost.tsx b/Composer/packages/client/src/components/PluginHost/PluginHost.tsx index 1b801b878e..0df82eabd5 100644 --- a/Composer/packages/client/src/components/PluginHost/PluginHost.tsx +++ b/Composer/packages/client/src/components/PluginHost/PluginHost.tsx @@ -3,26 +3,33 @@ /** @jsx jsx */ import { jsx, SerializedStyles } from '@emotion/core'; -import * as React from 'react'; -import { useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import { Shell } from '@botframework-composer/types'; +import { PluginType } from '@bfc/extension-client'; import { PluginAPI } from '../../plugins/api'; -import { PluginType } from '../../plugins/types'; import { iframeStyle } from './styles'; interface PluginHostProps { extraIframeStyles?: SerializedStyles[]; - pluginName?: string; - pluginType?: PluginType; + pluginName: string; + pluginType: PluginType; + bundleId: string; + shell?: Shell; } /** Binds closures around Composer client code to plugin iframe's window object */ -function attachPluginAPI(win: Window, type: PluginType) { +function attachPluginAPI(win: Window, type: PluginType, shell?: object) { const api = { ...PluginAPI[type], ...PluginAPI.auth }; + for (const method in api) { win.Composer[method] = (...args) => api[method](...args); } + + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + // @ts-ignore + win.Composer.render = win.Composer.render.bind(null, type, shell); } function injectScript(doc: Document, id: string, src: string, async: boolean, onload?: () => any) { @@ -38,37 +45,52 @@ function injectScript(doc: Document, id: string, src: string, async: boolean, on */ export const PluginHost: React.FC<PluginHostProps> = (props) => { const targetRef = useRef<HTMLIFrameElement>(null); - const { extraIframeStyles = [] } = props; + const [isLoaded, setIsLoaded] = useState(false); + const { extraIframeStyles = [], pluginType, pluginName, bundleId, shell } = props; useEffect(() => { - const { pluginName, pluginType } = props; // renders the plugin's UI inside of the iframe - const renderPluginView = async () => { - if (pluginName && pluginType) { - const iframeWindow = targetRef.current?.contentWindow as Window; - const iframeDocument = targetRef.current?.contentDocument as Document; - - // inject the react / react-dom bundles - injectScript(iframeDocument, 'react-bundle', '/react-bundle.js', false); - injectScript(iframeDocument, 'react-dom-bundle', '/react-dom-bundle.js', false); - // // load the preload script to setup the plugin API - injectScript(iframeDocument, 'preload-bundle', '/plugin-host-preload.js', false, () => { - attachPluginAPI(iframeWindow, pluginType); - }); - - //load the bundle for the specified plugin - const pluginScriptId = `plugin-${pluginType}-${pluginName}`; - await new Promise((resolve) => { - const cb = () => { - resolve(); - }; - // If plugin bundles end up being too large and block the client thread due to the load, enable the async flag on this call - injectScript(iframeDocument, pluginScriptId, `/api/extensions/${pluginName}/view/${pluginType}`, false, cb); - }); - } - }; - renderPluginView(); - }, [props.pluginName, props.pluginType, targetRef]); - - return <iframe ref={targetRef} css={[iframeStyle, ...extraIframeStyles]} title={`${props.pluginName} host`}></iframe>; + if (pluginName && pluginType) { + const iframeDocument = targetRef.current?.contentDocument as Document; + + // // load the preload script to setup the plugin API + injectScript(iframeDocument, 'preload-bundle', '/plugin-host-preload.js', false); + + const onPreloaded = (ev) => { + if (ev.data === 'host-preload-complete') { + setIsLoaded(true); + } + }; + + window.addEventListener('message', onPreloaded); + + return () => { + window.removeEventListener('message', onPreloaded); + }; + } + }, [pluginName, pluginType, bundleId, targetRef]); + + useEffect(() => { + if (isLoaded && pluginType && pluginName && bundleId) { + const iframeWindow = targetRef.current?.contentWindow as Window; + const iframeDocument = targetRef.current?.contentDocument as Document; + + attachPluginAPI(iframeWindow, pluginType, shell); + + //load the bundle for the specified plugin + const pluginScriptId = `plugin-${pluginType}-${pluginName}`; + const bundleUri = `/api/extensions/${pluginName}/${bundleId}`; + // If plugin bundles end up being too large and block the client thread due to the load, enable the async flag on this call + injectScript(iframeDocument, pluginScriptId, bundleUri, false); + } + }, [isLoaded]); + + // sync the shell to the iframe store when shell changes + useEffect(() => { + if (isLoaded && targetRef.current) { + targetRef.current.contentWindow?.Composer.sync(shell); + } + }, [isLoaded, shell]); + + return <iframe ref={targetRef} css={[iframeStyle, ...extraIframeStyles]} title={`${pluginName} host`}></iframe>; }; diff --git a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx b/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx index fe1e341d72..ff4707b4be 100644 --- a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx +++ b/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx @@ -15,7 +15,7 @@ import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { luIndexer, combineMessage } from '@bfc/indexers'; import { PlaceHolderSectionName } from '@bfc/indexers/lib/utils/luUtil'; -import { SDKKinds } from '@bfc/shared'; +import { SDKKinds, RegexRecognizer } from '@bfc/shared'; import { LuEditor, inlineModePlaceholder } from '@bfc/code-editor'; import { IComboBoxOption } from 'office-ui-fabric-react/lib/ComboBox'; import { useRecoilValue } from 'recoil'; @@ -111,7 +111,7 @@ const initialFormDataErrors = { const getLuDiagnostics = (intent: string, triggerPhrases: string) => { const content = `#${intent}\n${triggerPhrases}`; - const { diagnostics } = luIndexer.parse(content); + const { diagnostics } = luIndexer.parse(content, '', {}); return combineMessage(diagnostics); }; @@ -214,7 +214,7 @@ export const TriggerCreationModal: React.FC<TriggerCreationModalProps> = (props) const dialogFile = dialogs.find((dialog) => dialog.id === dialogId); const isRegEx = isRegExRecognizerType(dialogFile); const isLUISnQnA = isLUISnQnARecognizerType(dialogFile); - const regexIntents = dialogFile?.content?.recognizer?.intents ?? []; + const regexIntents = (dialogFile?.content?.recognizer as RegexRecognizer)?.intents ?? []; const initialFormData: TriggerFormData = { errors: initialFormDataErrors, $kind: intentTypeKey, @@ -268,7 +268,7 @@ export const TriggerCreationModal: React.FC<TriggerCreationModalProps> = (props) e.preventDefault(); //If still have some errors here, it is a bug. - const errors = validateForm(selectedType, formData, isRegEx, regexIntents); + const errors = validateForm(selectedType, formData, isRegEx, regexIntents as any); if (shouldDisable(errors)) { setFormData({ ...formData, errors }); return; @@ -334,7 +334,7 @@ export const TriggerCreationModal: React.FC<TriggerCreationModalProps> = (props) } setFormData({ ...formData, triggerPhrases: body, errors: { ...formData.errors, ...errors } }); }; - const errors = validateForm(selectedType, formData, isRegEx, regexIntents); + const errors = validateForm(selectedType, formData, isRegEx, regexIntents as any); const disable = shouldDisable(errors); return ( @@ -431,6 +431,7 @@ export const TriggerCreationModal: React.FC<TriggerCreationModalProps> = (props) projectId, fileId: dialogId, sectionId: PlaceHolderSectionName, + luFeatures: {}, }} placeholder={inlineModePlaceholder} value={formData.triggerPhrases} diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx new file mode 100644 index 0000000000..42498906fd --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import { useRecoilValue } from 'recoil'; + +import { showCreateQnAFromScratchDialogState, showCreateQnAFromUrlDialogState } from '../../recoilModel'; + +import CreateQnAFromScratchModal from './CreateQnAFromScratchModal'; +import CreateQnAFromUrlModal from './CreateQnAFromUrlModal'; +import { CreateQnAFromModalProps } from './constants'; + +export const CreateQnAModal: React.FC<CreateQnAFromModalProps> = (props) => { + const { projectId } = props; + const showCreateQnAFromScratchDialog = useRecoilValue(showCreateQnAFromScratchDialogState(projectId)); + const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId)); + + if (showCreateQnAFromScratchDialog) { + return <CreateQnAFromScratchModal {...props}></CreateQnAFromScratchModal>; + } else if (showCreateQnAFromUrlDialog) { + return <CreateQnAFromUrlModal {...props}></CreateQnAFromUrlModal>; + } else { + return null; + } +}; + +export default CreateQnAModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx new file mode 100644 index 0000000000..0885febe59 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { dispatcherState, showCreateQnAFromUrlDialogState } from '../../recoilModel'; + +import { validateName, CreateQnAFromModalProps, CreateQnAFromScratchFormData } from './constants'; +import { subText, styles, dialogWindowMini, textField } from './styles'; + +const formConfig: FieldConfig<CreateQnAFromScratchFormData> = { + name: { + required: true, + defaultValue: '', + }, +}; + +const DialogTitle = () => { + return ( + <div> + {formatMessage('Create new knowledge base from scratch')} + <p> + <span css={subText}>{formatMessage('Manually add question and answer pairs to create a KB')}</span> + </p> + </div> + ); +}; + +export const CreateQnAFromScratchModal: React.FC<CreateQnAFromModalProps> = (props) => { + const { onDismiss, onSubmit, qnaFiles, projectId } = props; + const actions = useRecoilValue(dispatcherState); + const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId)); + + formConfig.name.validate = validateName(qnaFiles); + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + const disabled = hasErrors; + + return ( + <Dialog + dialogContentProps={{ + type: DialogType.normal, + title: <DialogTitle />, + styles: styles.dialog, + }} + hidden={false} + modalProps={{ + isBlocking: false, + styles: styles.modal, + }} + onDismiss={onDismiss} + > + <div css={dialogWindowMini}> + <Stack> + <TextField + data-testid={`knowledgeLocationTextField-name`} + errorMessage={formErrors.name} + label={formatMessage('Knowledge base name')} + placeholder={formatMessage('Type a name that describes this content')} + styles={textField} + value={formData.name} + onChange={(e, name = '') => updateField('name', name)} + /> + </Stack> + </div> + <DialogFooter> + {showCreateQnAFromUrlDialog && ( + <DefaultButton + text={formatMessage('Back')} + onClick={() => { + actions.createQnAFromScratchDialogCancel({ projectId }); + }} + /> + )} + <DefaultButton + text={formatMessage('Cancel')} + onClick={() => { + actions.createQnAFromScratchDialogCancel({ projectId }); + onDismiss && onDismiss(); + }} + /> + <PrimaryButton + data-testid={'createKnowledgeBase'} + disabled={disabled} + text={formatMessage('Create KB')} + onClick={() => { + if (hasErrors) { + return; + } + onSubmit(formData); + }} + /> + </DialogFooter> + </Dialog> + ); +}; + +export default CreateQnAFromScratchModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx new file mode 100644 index 0000000000..9d2104b43b --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { Link } from 'office-ui-fabric-react/lib/Link'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { + dispatcherState, + onCreateQnAFromUrlDialogCompleteState, + showCreateQnAFromUrlDialogWithScratchState, +} from '../../recoilModel'; + +import { + knowledgeBaseSourceUrl, + validateUrl, + validateName, + CreateQnAFromModalProps, + CreateQnAFromUrlFormData, +} from './constants'; +import { subText, styles, dialogWindow, textField, warning } from './styles'; + +const formConfig: FieldConfig<CreateQnAFromUrlFormData> = { + url: { + required: true, + defaultValue: '', + validate: validateUrl, + }, + name: { + required: true, + defaultValue: '', + }, + multiTurn: { + required: false, + defaultValue: false, + }, +}; + +const DialogTitle = () => { + return ( + <div> + {formatMessage('Create new knowledge base')} + <p> + <span css={subText}> + {formatMessage( + 'Extract question-and-answer pairs from an online FAQ, product manuals, or other files. Supported formats are .tsv, .pdf, .doc, .docx, .xlsx, containing questions and answers in sequence. ' + )} + <Link href={knowledgeBaseSourceUrl} target={'_blank'}> + {formatMessage('Learn more about knowledge base sources. ')} + </Link> + </span> + </p> + </div> + ); +}; + +export const CreateQnAFromUrlModal: React.FC<CreateQnAFromModalProps> = (props) => { + const { onDismiss, onSubmit, dialogId, projectId, qnaFiles } = props; + const actions = useRecoilValue(dispatcherState); + const onComplete = useRecoilValue(onCreateQnAFromUrlDialogCompleteState(projectId)); + const showWithScratch = useRecoilValue(showCreateQnAFromUrlDialogWithScratchState(projectId)); + + formConfig.name.validate = validateName(qnaFiles); + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + const isQnAFileselected = !(dialogId === 'all'); + const disabled = hasErrors || !formData.url || !formData.name; + + return ( + <Dialog + dialogContentProps={{ + type: DialogType.normal, + title: <DialogTitle />, + styles: styles.dialog, + }} + hidden={false} + modalProps={{ + isBlocking: false, + styles: styles.modal, + }} + onDismiss={onDismiss} + > + <div css={dialogWindow}> + <Stack> + <TextField + data-testid={`knowledgeLocationTextField-name`} + errorMessage={formErrors.name} + label={formatMessage('Knowledge base name')} + placeholder={formatMessage('Type a name that describes this content')} + styles={textField} + value={formData.name} + onChange={(e, name = '') => updateField('name', name)} + /> + </Stack> + <Stack> + <TextField + data-testid={`knowledgeLocationTextField-url`} + errorMessage={formErrors.url} + label={formatMessage('Knowledge source')} + placeholder={formatMessage('Enter a URL or browse to upload a file ')} + styles={textField} + value={formData.url} + onChange={(e, url = '') => updateField('url', url)} + /> + + {!isQnAFileselected && ( + <div css={warning}> {formatMessage('Please select a specific qna file to import QnA')}</div> + )} + </Stack> + <Stack> + <Checkbox + label={formatMessage('Enable multi-turn extraction')} + onChange={(_e, val) => updateField('multiTurn', val)} + /> + </Stack> + </div> + <DialogFooter> + {showWithScratch && ( + <DefaultButton + data-testid={'createKnowledgeBaseFromScratch'} + styles={{ root: { float: 'left' } }} + text={formatMessage('Create knowledge base from scratch')} + onClick={() => { + // switch to create from scratch flow, pass onComplete callback. + actions.createQnAFromScratchDialogBegin({ projectId, onComplete: onComplete?.func }); + }} + /> + )} + <DefaultButton + text={formatMessage('Cancel')} + onClick={() => { + actions.createQnAFromUrlDialogCancel({ projectId }); + onDismiss && onDismiss(); + }} + /> + <PrimaryButton + data-testid={'createKnowledgeBase'} + disabled={disabled} + text={formatMessage('Create KB')} + onClick={() => { + if (hasErrors) { + return; + } + onSubmit(formData); + }} + /> + </DialogFooter> + </Dialog> + ); +}; + +export default CreateQnAFromUrlModal; diff --git a/Composer/packages/client/src/components/QnA/EditQnAFrom.tsx b/Composer/packages/client/src/components/QnA/EditQnAFrom.tsx new file mode 100644 index 0000000000..613215b79f --- /dev/null +++ b/Composer/packages/client/src/components/QnA/EditQnAFrom.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import { QnAFile } from '@bfc/shared'; + +import { isQnAFileCreatedFromUrl } from '../../utils/qnaUtil'; + +import EditQnAFromScratchModal, { EditQnAFromScratchFormData } from './EditQnAFromScratchModal'; +import EditQnAFromUrlModal, { EditQnAFromUrlFormData } from './EditQnAFromUrlModal'; + +type EditQnAModalProps = { + qnaFiles: QnAFile[]; + qnaFile: QnAFile; + onDismiss: () => void; + onSubmit: (formData: EditQnAFromScratchFormData | EditQnAFromUrlFormData) => void; +}; + +export const EditQnAModal: React.FC<EditQnAModalProps> = (props) => { + if (isQnAFileCreatedFromUrl(props.qnaFile)) { + return <EditQnAFromUrlModal {...props}></EditQnAFromUrlModal>; + } else { + return <EditQnAFromScratchModal {...props}></EditQnAFromScratchModal>; + } +}; + +export default EditQnAModal; diff --git a/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx b/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx new file mode 100644 index 0000000000..5e92ca2dfb --- /dev/null +++ b/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { QnAFile } from '@bfc/shared'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { getBaseName } from '../../utils/fileUtil'; + +import { validateName } from './constants'; +import { styles, dialogWindow, textField } from './styles'; + +type EditQnAFromScratchModalProps = { + qnaFiles: QnAFile[]; + qnaFile: QnAFile; + onDismiss: () => void; + onSubmit: (formData: EditQnAFromScratchFormData) => void; +}; + +export type EditQnAFromScratchFormData = { + name: string; +}; + +const formConfig: FieldConfig<EditQnAFromScratchFormData> = { + name: { + required: true, + defaultValue: '', + }, +}; + +const DialogTitle = () => { + return <div>{formatMessage('Edit KB name')}</div>; +}; + +export const EditQnAFromScratchModal: React.FC<EditQnAFromScratchModalProps> = (props) => { + const { onDismiss, onSubmit, qnaFiles, qnaFile } = props; + + formConfig.name.validate = validateName(qnaFiles.filter(({ id }) => qnaFile.id !== id)); + formConfig.name.defaultValue = getBaseName(qnaFile.id); + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + const disabled = hasErrors; + + const updateName = (name = '') => { + updateField('name', name); + }; + + return ( + <Dialog + dialogContentProps={{ + type: DialogType.normal, + title: <DialogTitle />, + styles: styles.dialog, + }} + hidden={false} + modalProps={{ + isBlocking: false, + styles: styles.modal, + }} + onDismiss={onDismiss} + > + <div css={dialogWindow}> + <Stack> + <TextField + data-testid={`knowledgeLocationTextField-name`} + errorMessage={formErrors.name} + label={formatMessage('Knowledge base name')} + placeholder={formatMessage('Type a name that describes this content')} + styles={textField} + value={formData.name} + onChange={(e, name) => updateName(name)} + /> + </Stack> + </div> + <DialogFooter> + <DefaultButton text={formatMessage('Cancel')} onClick={onDismiss} /> + <PrimaryButton + data-testid={'editKnowledgeBase'} + disabled={disabled} + text={formatMessage('Done')} + onClick={() => { + if (hasErrors) { + return; + } + onSubmit(formData); + }} + /> + </DialogFooter> + </Dialog> + ); +}; + +export default EditQnAFromScratchModal; diff --git a/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx new file mode 100644 index 0000000000..4a65c34de3 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { QnAFile } from '@bfc/shared'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { getBaseName } from '../../utils/fileUtil'; +import { getQnAFileUrlOption } from '../../utils/qnaUtil'; + +import { validateName, validateUrl } from './constants'; +import { styles, dialogWindow, textField } from './styles'; + +type EditQnAFromUrlModalProps = { + qnaFiles: QnAFile[]; + qnaFile: QnAFile; + onDismiss: () => void; + onSubmit: (formData: EditQnAFromUrlFormData) => void; +}; + +export type EditQnAFromUrlFormData = { + name: string; + url: string; +}; + +const formConfig: FieldConfig<EditQnAFromUrlFormData> = { + name: { + required: true, + defaultValue: '', + }, + url: { + required: true, + defaultValue: '', + }, +}; + +const DialogTitle = () => { + return <div>{formatMessage('Edit KB name')}</div>; +}; + +export const EditQnAFromUrlModal: React.FC<EditQnAFromUrlModalProps> = (props) => { + const { onDismiss, onSubmit, qnaFiles, qnaFile } = props; + + formConfig.name.validate = validateName(qnaFiles.filter(({ id }) => qnaFile.id !== id)); + formConfig.name.defaultValue = getBaseName(qnaFile.id); + formConfig.url.validate = validateUrl; + formConfig.url.defaultValue = getQnAFileUrlOption(qnaFile); + + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + const disabled = hasErrors; + + const updateName = (name = '') => { + updateField('name', name); + }; + const updateUrl = (url = '') => { + updateField('url', url); + }; + + return ( + <Dialog + dialogContentProps={{ + type: DialogType.normal, + title: <DialogTitle />, + styles: styles.dialog, + }} + hidden={false} + modalProps={{ + isBlocking: false, + styles: styles.modal, + }} + onDismiss={onDismiss} + > + <div css={dialogWindow}> + <Stack> + <TextField + data-testid={`knowledgeLocationTextField-name`} + errorMessage={formErrors.name} + label={formatMessage('Knowledge base name')} + placeholder={formatMessage('Type a name that describes this content')} + styles={textField} + value={formData.name} + onChange={(e, name) => updateName(name)} + /> + </Stack> + <Stack> + <TextField + disabled + data-testid={`knowledgeLocationTextField-url`} + errorMessage={formErrors.url} + label={formatMessage('Knowledge source')} + placeholder={formatMessage('Enter a URL or browse to upload a file ')} + styles={textField} + value={formData.url} + onChange={(e, url) => updateUrl(url)} + /> + </Stack> + </div> + <DialogFooter> + <DefaultButton text={formatMessage('Cancel')} onClick={onDismiss} /> + <PrimaryButton + data-testid={'editKnowledgeBase'} + disabled={disabled} + text={formatMessage('Done')} + onClick={() => { + if (hasErrors) { + return; + } + onSubmit(formData); + }} + /> + </DialogFooter> + </Dialog> + ); +}; + +export default EditQnAFromUrlModal; diff --git a/Composer/packages/client/src/components/QnA/constants.ts b/Composer/packages/client/src/components/QnA/constants.ts new file mode 100644 index 0000000000..a8049e66d0 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/constants.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { QnAFile } from '@bfc/shared'; +import formatMessage from 'format-message'; + +import { FieldValidator } from '../../hooks/useForm'; + +export type CreateQnAFromScratchFormData = { + name: string; +}; +export type CreateQnAFromUrlFormData = { + url: string; + name: string; + multiTurn: boolean; +}; + +export type CreateQnAFormData = { + url?: string; + name: string; + multiTurn?: boolean; +}; + +export type CreateQnAFromModalProps = { + projectId: string; + dialogId: string; + qnaFiles: QnAFile[]; + subscriptionKey?: string; + onDismiss?: () => void; + onSubmit: (formData: CreateQnAFormData) => void; +}; + +export const validateUrl: FieldValidator = (url: string): string => { + let error = ''; + + if (url && !url.startsWith('http://') && !url.startsWith('https://')) { + error = formatMessage('A valid url should start with http:// or https://'); + } + + return error; +}; + +export const FileNameRegex = /^[a-zA-Z0-9-_]+$/; + +export const validateName = (sources: QnAFile[]): FieldValidator => { + return (name: string) => { + let currentError = ''; + if (name) { + if (!FileNameRegex.test(name)) { + currentError = formatMessage('KB name cannot contain speacial characters.'); + } + + const duplicatedItemIndex = sources.findIndex((item) => item.id.toLowerCase() === `${name.toLowerCase()}.source`); + if (duplicatedItemIndex > -1) { + currentError = formatMessage('You already have a KB with that name.Choose another name and try again.'); + } + } + return currentError; + }; +}; + +export const knowledgeBaseSourceUrl = + 'https://docs.microsoft.com/en-us/azure/cognitive-services/qnamaker/concepts/content-types'; + +export const QnAMakerLearningUrl = 'https://azure.microsoft.com/en-us/pricing/details/cognitive-services/qna-maker/'; diff --git a/Composer/packages/client/src/components/QnA/index.ts b/Composer/packages/client/src/components/QnA/index.ts new file mode 100644 index 0000000000..90507f314d --- /dev/null +++ b/Composer/packages/client/src/components/QnA/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './CreateQnAFromScratchModal'; +export * from './CreateQnAFromUrlModal'; +export * from './CreateQnAFrom'; +export * from './EditQnAFrom'; +export * from './constants'; diff --git a/Composer/packages/client/src/components/QnA/styles.ts b/Composer/packages/client/src/components/QnA/styles.ts new file mode 100644 index 0000000000..522338398d --- /dev/null +++ b/Composer/packages/client/src/components/QnA/styles.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { css } from '@emotion/core'; +import { FontWeights } from '@uifabric/styling'; +import { FontSizes, SharedColors, NeutralColors } from '@uifabric/fluent-theme'; + +export const styles = { + dialog: { + title: { + fontWeight: FontWeights.bold, + fontSize: FontSizes.size20, + paddingTop: '14px', + paddingBottom: '11px', + }, + subText: { + fontSize: FontSizes.size14, + }, + }, + modal: { + main: { + maxWidth: '800px !important', + }, + }, +}; + +export const dialogWindow = css` + display: flex; + flex-direction: column; + width: 400px; + min-height: 200px; +`; + +export const dialogWindowMini = css` + display: flex; + flex-direction: column; + width: 400px; + min-height: 100px; +`; + +export const textField = { + root: { + width: '400px', + paddingBottom: '20px', + }, +}; + +export const warning = { + color: SharedColors.red10, + fontSize: FontSizes.size10, +}; + +export const subText = css` + color: ${NeutralColors.gray130}; + font-size: 14px; + font-weight: 400; +`; diff --git a/Composer/packages/client/src/components/TestController/TestController.tsx b/Composer/packages/client/src/components/TestController/TestController.tsx index 1866dd6d0f..27549b7274 100644 --- a/Composer/packages/client/src/components/TestController/TestController.tsx +++ b/Composer/packages/client/src/components/TestController/TestController.tsx @@ -15,7 +15,7 @@ import { dispatcherState, validateDialogSelectorFamily, botStatusState, - botNameState, + botDisplayNameState, luFilesState, qnaFilesState, settingsState, @@ -62,7 +62,7 @@ export const TestController: React.FC<{ projectId: string }> = (props) => { const dialogs = useRecoilValue(validateDialogSelectorFamily(projectId)); const botStatus = useRecoilValue(botStatusState(projectId)); - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const luFiles = useRecoilValue(luFilesState(projectId)); const settings = useRecoilValue(settingsState(projectId)); const qnaFiles = useRecoilValue(qnaFilesState(projectId)); diff --git a/Composer/packages/client/src/constants.ts b/Composer/packages/client/src/constants.ts index 2f21477913..7a0f8b3855 100644 --- a/Composer/packages/client/src/constants.ts +++ b/Composer/packages/client/src/constants.ts @@ -213,8 +213,3 @@ export const nameRegex = /^[a-zA-Z0-9-_]+$/; export const triggerNotSupportedWarning = formatMessage( 'This trigger type is not supported by the RegEx recognizer. To ensure this trigger is fired, change the recognizer type.' ); - -export const knowledgeBaseSourceUrl = - 'https://docs.microsoft.com/en-us/azure/cognitive-services/qnamaker/concepts/content-types'; - -export const QnAMakerLearningUrl = 'https://azure.microsoft.com/en-us/pricing/details/cognitive-services/qna-maker/'; diff --git a/Composer/packages/client/src/images/emptyQnAIcon.svg b/Composer/packages/client/src/images/emptyQnAIcon.svg new file mode 100644 index 0000000000..d70057b412 --- /dev/null +++ b/Composer/packages/client/src/images/emptyQnAIcon.svg @@ -0,0 +1,284 @@ +<svg width="260" height="217" viewBox="0 0 260 217" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<g clip-path="url(#clip0)"> +<g filter="url(#filter0_b)"> +<path d="M241.446 217H18.5544C6.77537 197.437 0 174.52 0 150.02C0 78.2119 58.203 20 130 20C201.797 20 260 78.2119 260 150.02C260 174.52 253.225 197.437 241.446 217Z" fill="url(#paint0_linear)"/> +</g> +<g style="mix-blend-mode:multiply" filter="url(#filter1_dd)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M60.5 11L53 6L53 175L173 175V6L165.5 11L158 6L150.5 11L143 6L135.5 11L128 6L120.5 11L113 6L105.5 11L98 6L90.5 11L83 6L75.5 11L68 6.00001L68 6.00001L60.5 11Z" fill="white"/> +</g> +<g filter="url(#filter2_b)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M60.5 11L53 6L53 175L173 175V6L165.5 11L158 6L150.5 11L143 6L135.5 11L128 6L120.5 11L113 6L105.5 11L98 6L90.5 11L83 6L75.5 11L68 6.00001L68 6.00001L60.5 11Z" fill="#F2F2F2" fill-opacity="0.8"/> +</g> +<g style="mix-blend-mode:hard-light" filter="url(#filter3_i)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M60.5 11L53 6L53 175L173 175V6L165.5 11L158 6L150.5 11L143 6L135.5 11L128 6L120.5 11L113 6L105.5 11L98 6L90.5 11L83 6L75.5 11L68 6.00001L68 6.00001L60.5 11Z" fill="#808080"/> +</g> +<g style="mix-blend-mode:multiply" filter="url(#filter4_dd)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M93.5 30L86 25L86 194L206 194V25L198.5 30L191 25L183.5 30L176 25L168.5 30L161 25L153.5 30L146 25L138.5 30L131 25L123.5 30L116 25L108.5 30L101 25L101 25L93.5 30Z" fill="white"/> +</g> +<g filter="url(#filter5_b)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M93.5 30L86 25L86 194L206 194V25L198.5 30L191 25L183.5 30L176 25L168.5 30L161 25L153.5 30L146 25L138.5 30L131 25L123.5 30L116 25L108.5 30L101 25L101 25L93.5 30Z" fill="#F2F2F2" fill-opacity="0.8"/> +</g> +<g style="mix-blend-mode:hard-light" filter="url(#filter6_i)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M93.5 30L86 25L86 194L206 194V25L198.5 30L191 25L183.5 30L176 25L168.5 30L161 25L153.5 30L146 25L138.5 30L131 25L123.5 30L116 25L108.5 30L101 25L101 25L93.5 30Z" fill="#808080"/> +</g> +<g style="mix-blend-mode:multiply" filter="url(#filter7_dd)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M77.5 49L70 44L70 213L190 213V44L182.5 49L175 44L167.5 49L160 44L152.5 49L145 44L137.5 49L130 44L122.5 49L115 44L107.5 49L100 44L92.5 49L85 44L85 44L77.5 49Z" fill="white"/> +</g> +<g filter="url(#filter8_b)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M77.5 49L70 44L70 213L190 213V44L182.5 49L175 44L167.5 49L160 44L152.5 49L145 44L137.5 49L130 44L122.5 49L115 44L107.5 49L100 44L92.5 49L85 44L85 44L77.5 49Z" fill="#F2F2F2" fill-opacity="0.8"/> +</g> +<g style="mix-blend-mode:hard-light" filter="url(#filter9_i)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M77.5 49L70 44L70 213L190 213V44L182.5 49L175 44L167.5 49L160 44L152.5 49L145 44L137.5 49L130 44L122.5 49L115 44L107.5 49L100 44L92.5 49L85 44L85 44L77.5 49Z" fill="#808080"/> +</g> +<path d="M90 75H170" stroke="#808080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M90 87H170" stroke="#808080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M90 99H170" stroke="#808080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M90 111H170" stroke="#808080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M90 123H170" stroke="#808080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M90 135H170" stroke="#808080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M90 147H170" stroke="#808080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M90 159H170" stroke="#808080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M90 171H170" stroke="#808080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<g style="mix-blend-mode:multiply" filter="url(#filter10_dd)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M46 83C43.7909 83 42 84.7909 42 87V213C42 215.209 43.7909 217 46 217H213C215.209 217 217 215.209 217 213V101C217 98.7909 215.209 97 213 97H104L86 83H46Z" fill="white"/> +</g> +<g filter="url(#filter11_b)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M46 83C43.7909 83 42 84.7909 42 87V213C42 215.209 43.7909 217 46 217H213C215.209 217 217 215.209 217 213V101C217 98.7909 215.209 97 213 97H104L86 83H46Z" fill="url(#paint1_linear)"/> +</g> +<g style="mix-blend-mode:hard-light" filter="url(#filter12_i)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M46 83C43.7909 83 42 84.7909 42 87V213C42 215.209 43.7909 217 46 217H213C215.209 217 217 215.209 217 213V101C217 98.7909 215.209 97 213 97H104L86 83H46Z" fill="#808080"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M46 83C43.7909 83 42 84.7909 42 87V213C42 215.209 43.7909 217 46 217H213C215.209 217 217 215.209 217 213V101C217 98.7909 215.209 97 213 97H104L86 83H46Z" fill="url(#pattern0)" fill-opacity="0.02"/> +</g> +<path d="M9 138.5C12.3354 139.05 14.9495 141.665 15.5 145C16.0505 141.665 18.6646 139.05 22 138.5C18.6646 137.95 16.0505 135.335 15.5 132C14.9495 135.335 12.3354 137.95 9 138.5Z" fill="#BFBFBF"/> +<path d="M221 64.5C224.335 65.0505 226.95 67.6646 227.5 71C228.05 67.6646 230.665 65.0505 234 64.5C230.665 63.9495 228.05 61.3354 227.5 58C226.95 61.3354 224.335 63.9495 221 64.5Z" fill="#BFBFBF"/> +<path d="M26.1094 153.764C24.0568 153.425 22.4481 151.816 22.1094 149.764C21.7706 151.816 20.1619 153.425 18.1094 153.764C20.1619 154.102 21.7706 155.711 22.1094 157.764C22.4481 155.711 24.0568 154.102 26.1094 153.764Z" fill="#808080"/> +<path d="M248 174.5C244.665 173.95 242.05 171.335 241.5 168C240.95 171.335 238.335 173.95 235 174.5C238.335 175.05 240.95 177.665 241.5 181C242.05 177.665 244.665 175.05 248 174.5Z" fill="#BFBFBF"/> +<path d="M227 164C229.053 164.339 230.661 165.947 231 168C231.339 165.947 232.947 164.339 235 164C232.947 163.661 231.339 162.053 231 160C230.661 162.053 229.053 163.661 227 164Z" fill="#808080"/> +<path d="M35.5469 140.895C33.4943 140.556 31.8856 138.947 31.5469 136.895C31.2081 138.947 29.5994 140.556 27.5469 140.895C29.5994 141.233 31.2081 142.842 31.5469 144.895C31.8856 142.842 33.4943 141.233 35.5469 140.895Z" fill="#808080"/> +</g> +<g style="mix-blend-mode:multiply" filter="url(#filter13_dd)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M137 141C131.477 141 127 145.477 127 151C127 156.523 131.477 161 137 161H151L161 171V161H163C168.523 161 173 156.523 173 151C173 145.477 168.523 141 163 141H137Z" fill="white"/> +</g> +<g filter="url(#filter14_b)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M137 141C131.477 141 127 145.477 127 151C127 156.523 131.477 161 137 161H151L161 171V161H163C168.523 161 173 156.523 173 151C173 145.477 168.523 141 163 141H137Z" fill="#F2F2F2" fill-opacity="0.8"/> +</g> +<g style="mix-blend-mode:hard-light" filter="url(#filter15_i)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M137 141C131.477 141 127 145.477 127 151C127 156.523 131.477 161 137 161H151L161 171V161H163C168.523 161 173 156.523 173 151C173 145.477 168.523 141 163 141H137Z" fill="#808080"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M137 141C131.477 141 127 145.477 127 151C127 156.523 131.477 161 137 161H151L161 171V161H163C168.523 161 173 156.523 173 151C173 145.477 168.523 141 163 141H137Z" fill="url(#pattern1)" fill-opacity="0.02"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M139 154C140.657 154 142 152.657 142 151C142 149.343 140.657 148 139 148C137.343 148 136 149.343 136 151C136 152.657 137.343 154 139 154ZM150 154C151.657 154 153 152.657 153 151C153 149.343 151.657 148 150 148C148.343 148 147 149.343 147 151C147 152.657 148.343 154 150 154ZM164 151C164 152.657 162.657 154 161 154C159.343 154 158 152.657 158 151C158 149.343 159.343 148 161 148C162.657 148 164 149.343 164 151Z" fill="#BFBFBF"/> +<g style="mix-blend-mode:multiply" filter="url(#filter16_dd)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M128 148C133.523 148 138 152.477 138 158C138 163.523 133.523 168 128 168H114L104 178V168H102C96.4772 168 92 163.523 92 158C92 152.477 96.4772 148 102 148H128Z" fill="white"/> +</g> +<g filter="url(#filter17_b)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M128 148C133.523 148 138 152.477 138 158C138 163.523 133.523 168 128 168H114L104 178V168H102C96.4772 168 92 163.523 92 158C92 152.477 96.4772 148 102 148H128Z" fill="#F2F2F2" fill-opacity="0.8"/> +</g> +<g style="mix-blend-mode:hard-light" filter="url(#filter18_i)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M128 148C133.523 148 138 152.477 138 158C138 163.523 133.523 168 128 168H114L104 178V168H102C96.4772 168 92 163.523 92 158C92 152.477 96.4772 148 102 148H128Z" fill="#808080"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M128 148C133.523 148 138 152.477 138 158C138 163.523 133.523 168 128 168H114L104 178V168H102C96.4772 168 92 163.523 92 158C92 152.477 96.4772 148 102 148H128Z" fill="url(#pattern2)" fill-opacity="0.02"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M126 161C124.343 161 123 159.657 123 158C123 156.343 124.343 155 126 155C127.657 155 129 156.343 129 158C129 159.657 127.657 161 126 161ZM115 161C113.343 161 112 159.657 112 158C112 156.343 113.343 155 115 155C116.657 155 118 156.343 118 158C118 159.657 116.657 161 115 161ZM101 158C101 159.657 102.343 161 104 161C105.657 161 107 159.657 107 158C107 156.343 105.657 155 104 155C102.343 155 101 156.343 101 158Z" fill="#BFBFBF"/> +<defs> +<filter id="filter0_b" x="-2" y="18" width="264" height="201" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feGaussianBlur in="BackgroundImage" stdDeviation="1"/> +<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/> +</filter> +<filter id="filter1_dd" x="45" y="-1" width="136" height="185" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="1"/> +<feGaussianBlur stdDeviation="4"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="3"/> +<feGaussianBlur stdDeviation="2"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.14 0"/> +<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape"/> +</filter> +<filter id="filter2_b" x="51" y="4" width="124" height="173" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feGaussianBlur in="BackgroundImage" stdDeviation="1"/> +<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/> +</filter> +<filter id="filter3_i" x="53" y="6" width="120" height="169" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="1"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0.5 0"/> +<feBlend mode="normal" in2="shape" result="effect1_innerShadow"/> +</filter> +<filter id="filter4_dd" x="78" y="18" width="136" height="185" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="1"/> +<feGaussianBlur stdDeviation="4"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="3"/> +<feGaussianBlur stdDeviation="2"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.14 0"/> +<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape"/> +</filter> +<filter id="filter5_b" x="84" y="23" width="124" height="173" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feGaussianBlur in="BackgroundImage" stdDeviation="1"/> +<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/> +</filter> +<filter id="filter6_i" x="86" y="25" width="120" height="169" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="1"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0.5 0"/> +<feBlend mode="normal" in2="shape" result="effect1_innerShadow"/> +</filter> +<filter id="filter7_dd" x="62" y="37" width="136" height="185" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="1"/> +<feGaussianBlur stdDeviation="4"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="3"/> +<feGaussianBlur stdDeviation="2"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.14 0"/> +<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape"/> +</filter> +<filter id="filter8_b" x="68" y="42" width="124" height="173" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feGaussianBlur in="BackgroundImage" stdDeviation="1"/> +<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/> +</filter> +<filter id="filter9_i" x="70" y="44" width="120" height="169" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="1"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0.5 0"/> +<feBlend mode="normal" in2="shape" result="effect1_innerShadow"/> +</filter> +<filter id="filter10_dd" x="34" y="76" width="191" height="150" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="1"/> +<feGaussianBlur stdDeviation="4"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="3"/> +<feGaussianBlur stdDeviation="2"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.14 0"/> +<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape"/> +</filter> +<filter id="filter11_b" x="40" y="81" width="179" height="138" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feGaussianBlur in="BackgroundImage" stdDeviation="1"/> +<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/> +</filter> +<filter id="filter12_i" x="42" y="83" width="175" height="134" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="1"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0.5 0"/> +<feBlend mode="normal" in2="shape" result="effect1_innerShadow"/> +</filter> +<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="0.342857" height="0.447761"> +<use xlink:href="#image0" transform="scale(0.00571429 0.00746269)"/> +</pattern> +<filter id="filter13_dd" x="119" y="134" width="62" height="46" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="1"/> +<feGaussianBlur stdDeviation="4"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="3"/> +<feGaussianBlur stdDeviation="2"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.14 0"/> +<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape"/> +</filter> +<filter id="filter14_b" x="125" y="139" width="50" height="34" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feGaussianBlur in="BackgroundImage" stdDeviation="1"/> +<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/> +</filter> +<filter id="filter15_i" x="127" y="141" width="46" height="30" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="1"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0.5 0"/> +<feBlend mode="normal" in2="shape" result="effect1_innerShadow"/> +</filter> +<pattern id="pattern1" patternContentUnits="objectBoundingBox" width="1.30435" height="2"> +<use xlink:href="#image0" transform="scale(0.0217391 0.0333333)"/> +</pattern> +<filter id="filter16_dd" x="84" y="141" width="62" height="46" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="1"/> +<feGaussianBlur stdDeviation="4"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/> +<feOffset dy="3"/> +<feGaussianBlur stdDeviation="2"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0 0.25098 0 0 0 0.14 0"/> +<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape"/> +</filter> +<filter id="filter17_b" x="90" y="146" width="50" height="34" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feGaussianBlur in="BackgroundImage" stdDeviation="1"/> +<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/> +</filter> +<filter id="filter18_i" x="92" y="148" width="46" height="30" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="1"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0 0.94902 0 0 0 0.5 0"/> +<feBlend mode="normal" in2="shape" result="effect1_innerShadow"/> +</filter> +<pattern id="pattern2" patternContentUnits="objectBoundingBox" width="1.30435" height="2"> +<use xlink:href="#image0" transform="scale(0.0217391 0.0333333)"/> +</pattern> +<linearGradient id="paint0_linear" x1="130" y1="217" x2="130" y2="20" gradientUnits="userSpaceOnUse"> +<stop stop-color="#BFBFBF" stop-opacity="0"/> +<stop offset="1" stop-color="#BFBFBF" stop-opacity="0.3"/> +</linearGradient> +<linearGradient id="paint1_linear" x1="129.5" y1="83" x2="129.5" y2="217" gradientUnits="userSpaceOnUse"> +<stop stop-color="#BFBFBF" stop-opacity="0.5"/> +<stop offset="1" stop-color="#BFBFBF"/> +</linearGradient> +<clipPath id="clip0"> +<rect width="260" height="217" fill="white"/> +</clipPath> +<image id="image0" width="60" height="60" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAA8CAYAAAA6/NlyAAAM9ElEQVRoQ+WbS6iO3RvG1zv/HCcbiRhR2mJESlJbjBwmGwOH0laUHAaEJLXFwCG1FROHgcPEYbRFSaJtRKQYyCltTBxi/v77rc/v+e53efic+fo/k733+z7Peta67+u+7uu+19qNlFKz2Wwmr/fv36e//vorNRqN1NbWll68eJFWrlyZDh06lG/h856enrRq1ap0//79NH78eAbIn3d1deX7+HzcuHH5uSFDhqRdu3alhQsXpkGDBqXDhw/ncXzG927cuDHfV16My707d+5MW7Zsyb9znTx5Mi1atCiPe/r06Wo8xzl48GBavXp1y3Dt7e2pwWp56NSpU3nSXEz8xo0bqbOzM23evDlNnDgxL2Tq1Km1k+3u7k7Lly9PT548SW/evEkPHz7MBtFAdQu8d+9eNsqwYcOyUb2YC9/duXOneteFCxfS3r1708WLFz8a0+eYI89gEO5//fp1unLlSlq3bl3lFNbECrPJuPH58+dpxIgRlRVnzZqVX6K19LjeZIA4MV56/fr1NGDAgDyPjo6O/LyoiBbnfZs2baq8yj19fX1pypQpGRkgQRREBOqciB4dVaKD50Rhb29vdkS1YLyJl7HO4MGD0+XLl7N3gc6RI0eqiTsJ4CECNIgTixPw/hMnTqTFixe3IMTvWKjo0fjRc7t3706zZ8/OhovjAF++8xkd5LOEAWuIKMqQjhN89+5dOn78eMZ/tKyxVOcVJwu0jTPuZyy97SQcUzRhOIwsspxLNKjvNl4/CvQPCPVzx+jv70/Dhw9vCYNGT09Pk/iM+MeaWkUIAV/gFi8GBrY8Sxz698uXL/NnkNvYsWPTnDlzsvEw1s2bN9OBAwfSjBkzEvdJSBDP7du3M19w8TtXJCU9Vs5BD4O0JUuWZNLlAs4gFT7h3aAhQxpPMAm8Ey9wD2yYjGzM9yys9KYLEmJ4yEn7TLzH+OY7Gdh3C1vev2zZsvwxxnFhn+Iax/kc1+QF/2or8z5IZ/To0Wny5MkVAliQ6UYYx3DDyDDvvHnzsievXbuWn/UyhFywiAJtR48e/ZsHOjo6mmPGjKlYkcBnEuREXs53+/fvzy/CyqQMyArvleQUYyjGP+GBxy9dupQhbAqJKOFdkhqxt3Tp0gx7UEdoyCm+M44vWfEew6TUCDq1ysPANl7fwtpYG0P9yazdaGtra0I4JaUbd8ZNhBjW4n48Rkp59OhRTl2ID7xk/kNY4LlJkyZlDnCsFst++IPxZVXfheqD5UuOQRjhfREBhPWsOX/Hjh3p1q1bmf3hAuAMshpdXV1NYgnoMLCyMnpYliXutm/f3pJCmKQpRQka0wKLZTJOKhIPKcN7CZWRI0fmBZaCg3skQaD6+PHjKi8TFuRaWRjCO3/+fItyjA7KkMZbeoeBo3pSUzPpmFejAPF+BUQUIMZsjPlSoGDcuXPn5nDwvihY8CYeYg56TSaPjMwzIMpxTGOm1oxaIU3cQVaICAYDopIMi1aUlyJe65vHhSaeNTfrXYsOoMXCIJlt27alq1evZo/UhZEe5DsLG3OzaUsGVpJqDBELKrhAUKO3t7eJGIg52LSBIGEBwK2MQVHBQMat8GSBa9asqcaMOdkcavEQ41mmLlFmLEeol4a36MCQzI3Uxe979uypNEaultrb23PxwJfKRqSecatVzZHCxXtdJN6bOXNmjnEGJq6ix/gdj1DFYCCIJ5aKpqpoABdYR3LRKHXP+CyG3bdvX34XKM0xzAIV58aXrI3VYWEKCiSaLEjiVyczKESiCHAMIAaBRJXE5Bj72LFj+Z1CG/hj3GfPnuWxuCwzZW0NGHMwnxmjhMi5c+dyWMYFi04MnmNYSieOgTE5mZTiBQxhVLwKgWApjIQ3WZD1rF7XWLEQIMYhp1isx5RXpiyeFQVMHi2P0QkVuCVmB9DBfCLrR/hjCMIshygeVpXwUpnWuDWnxs4GL4OUtCZw9mJQvGbXIzYXuA+CAmJldeQETYexaGBBFC5kEn7yN8al42HBEtWi8tQCyLAk7lsaAN9DDixUIiG+5AVhKHNKiBb4kSPwEuixzuZZ5aIGjfk8xi7jYwAFEQw9bdq0KsXqzJbi4Uf0jEACsBUhpiI8YUcjavA6whGuos1c+yNSZwXpsm78nRUNRmBx9sVMV8Z0LCa4j0ahuiFygWQnpxBm2cNfIypUW8bcv4kK4jl2PCNRxU7ljxAVymLegVGePn2aVVds8LW0eBQIsVBwgU6uLM+IMYp0Cn+L+s/lVJQT+dq2kMwd+1r+zngwMhe/K3+j+lL6RqET05akRjMRovuItMpAjypJQrADSI0aCw7jMXqfOpq45hl+37p1a0ufq+w78Tf3WQkxpjFMakJBlXmdhSAvyRpohvisi1fONvr6+ppQvbm27DQYy7wYZo1dQxaB2ICg4IC6Ij0igglHUVAaKMZfbBZGweF4zENBRNjQf47awWf46TzhgLxgJ+EEzJ0xl5aCwclBGvPnz88vfPXqVe6QELOOESsWPGdBUZaAsSNZVx6a+33euRKrvA+nvX37NlddpCdJr1SROYZLC1owx8/5HQOws/A1iJBRo6W/BxGxHPwWRDQ6OzubeMaghtUspmU7oYq36G4SQxqlbpcA6FL2xfISOFG52EK1X4bxREFdj8wQI+Toczkmc1MolbkctHBNnz49x3VUYRVLl5UJZGWngoeBBpDEILZto4Uj9TMRmvkWG/G+qKycaCwO+MzOSUvh/mFTLcZmWcbyN+Nv2LAh801s8ltdNbq7u5t4jdiQVFRHDG4slOmonAxQw6vuH1nvWgTQRsJQFBEyP0bCqLGJT3HCFcvLWChopG/e5Sy3S7+m5YkUZaEQhdqYCdmWHTp0aO5kfGp30i7nr9ydrCAdC3IW8eDBg5wv3fON8EMEGBdCRZEgOohRvEXJJnKMe4wqm4ucKGwUO3W7FzFemSc9LutquIcGg8qOe4n9s2fP/pMygTS9rP9yjUu4xFqYharWYmcEhZZZOm6GS14xNUWp6HYHrB7jKJZ9piB+ooCsnhAGqrUyLgkD5WHMwyV3MKbziUWEmoH7ESWIEeSuY4FUmhUteVg2jenhd+3UI4YsTGKDXr2u4YR42fZhDeh0LhxaScv+/v4m3YefUXtq3T+pwqo8LDt/yZYjC1iwYEE6c+bMFx9viHu7tn49+CLD1533wDvferzB8tBeeY5ztlp+V7sFlWWeBnolu4K8UkUZbsDVwy8gydZyeX/LcYdG45827Zee6/gv7BCW5zpYNBekWLG0RxrqzlEQ3x5y4UFyLIwH+7rHq6jXwpAEn61fvz5L0oEDB2b1pGwsO6XUudbOP7NTmhsAdvyiTnXikfoVEDAlKooFlydwGAtRYUfDFMGChHCZDWTYsnjwb8VPhG3U/vwuCzM2AmTUqFGV2LAnlp+J57Qc0O5ibNvieSQkXqDqiduacQEMyguoj2NnIhYIHj7hyAIqiIY+hGLlxXimn5hf7a7EZoVHl2JruGxiMJ7FUNXEQ5LZ6I6BXx4g0YJUTbH7EDfUypzIMxhvxYoVLbuEThYDwyE06Lk88KZKgpwwCoxtk93QiOInGj4WL7G533LkAauycz5hwoTKg1iYM5Juf7pgVExdM436k8nTxyIc3IJVKztBJgxSPJjiuwkFDMD35Sm/eEAtanwQw44GIeYxKreP4rZPhjQdj7iJ/L27guXpuF+1K+hxKZzgxh/OscVDSK5du/bvBf/JBbvQjMeZWEhsJrYk63BulMOuHqYDBaCvti8tW8cDYm6nxj5yPJknUaCosLCNANnUOEVdyfagQfaWiHy3pWHcW2IsOcV4jWmQgsH2EqGk17mn2vLhBMDXHu5igK/ZCuF+YyxOtG4rpDzxV9bjEiC9sXgmWiRgEC7LRQmUWCau88E0zyHHDWmMYDzQqfQsZIQPEy4bAHomniLQa/HoBN7lqEXZCCh3DWKOLnNv2QtzbrFvFhsMZKKqeNBysCaTQSXFk3PAxdMAtHacvAornmP2KIMT+JMOuVULLvdpPeMRC3D3kfAMxohNAr3IzygzSWvm0CgIyi5p3PyK7zS2eTfNBOYljyhr4wGZKHvrjlll4VHqYHIg2xj0pNC/ETplH9rOhd60Vfon7lFlp9DT+n84NmwN39LTiv0qvBoLAzwXd+Bl2zqCiN6O8pM9H686eMc+VzyHZQUHlBEP36MEqyMPTORndz3Kfx8w9n7mP3WUXY+PtlpIK7+jyHdLE8N/yz9vxCLffz3yDBhc5GG5iqXVvPG0zZe2Pr/3+DGsj+z7FcePq+KBOtcNKCxTV2zD5ljLPraiwONGQNYyEU/ZuOMnwgUpat/KDkss+n/Ff9RUaclyirxHcX/37t3ctRBeLJZ6lQXZIKh6vR/+oy32gSWzWC19TkXVFQCQFedHovS1Vx1P6cUT+BrwU+dV/gf5nFrpDQ6oIQAAAABJRU5ErkJggg=="/> +</defs> +</svg> diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index 3969cc7eb1..e2bd82afd3 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -8,10 +8,10 @@ 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 { DialogInfo, PromptTab, getEditorAPI, registerEditorAPI, FieldNames } 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'; +import { EditorExtension, PluginConfig } from '@bfc/extension-client'; import { useRecoilValue } from 'recoil'; import { LeftRightSplit } from '../../components/Split/LeftRightSplit'; @@ -48,11 +48,13 @@ import { showCreateDialogModalState, showAddSkillDialogModalState, localeState, + qnaFilesState, } from '../../recoilModel'; -import ImportQnAFromUrlModal from '../knowledge-base/ImportQnAFromUrlModal'; +import { CreateQnAModal } from '../../components/QnA'; import { triggerNotSupported } from '../../utils/dialogValidator'; import { undoFunctionState, undoVersionState } from '../../recoilModel/undo/history'; import { decodeDesignerPathToArrayPath } from '../../utils/convertUtils/designerPathEncoder'; +import { useTriggerApi } from '../../shell/triggerApi'; import { WarningMessage } from './WarningMessage'; import { @@ -111,6 +113,7 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st const { location, dialogId, projectId = '' } = props; const userSettings = useRecoilValue(userSettingsState); + const qnaFiles = useRecoilValue(qnaFilesState(projectId)); const schemas = useRecoilValue(schemasState(projectId)); const dialogs = useRecoilValue(validateDialogSelectorFamily(projectId)); const displaySkillManifest = useRecoilValue(displaySkillManifestState(projectId)); @@ -138,8 +141,11 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st addSkillDialogCancel, exportToZip, onboardingAddCoachMarkRef, - importQnAFromUrls, + createQnAKBFromUrl, + createQnAKBFromScratch, + createQnAFromUrlDialogBegin, addSkill, + updateZoomRate, } = useRecoilValue(dispatcherState); const params = new URLSearchParams(location?.search); @@ -149,16 +155,24 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st ); const [triggerModalVisible, setTriggerModalVisibility] = useState(false); const [dialogJsonVisible, setDialogJsonVisibility] = useState(false); - const [importQnAModalVisibility, setImportQnAModalVisibility] = useState(false); const [currentDialog, setCurrentDialog] = useState<DialogInfo>(dialogs[0]); const [exportSkillModalVisible, setExportSkillModalVisible] = useState(false); const [warningIsVisible, setWarningIsVisible] = useState(true); const shell = useShell('DesignPage', projectId); const shellForFlowEditor = useShell('FlowEditor', projectId); const shellForPropertyEditor = useShell('PropertyEditor', projectId); - const triggerApi = useTriggerApi(shell.api); + const triggerApi = useTriggerApi(projectId); const { createTrigger } = shell.api; + const defaultQnATriggerData = { + $kind: qnaMatcherKey, + errors: { $kind: '', intent: '', event: '', triggerPhrases: '', regEx: '', activity: '' }, + event: '', + intent: '', + regEx: '', + triggerPhrases: '', + }; + useEffect(() => { const currentDialog = dialogs.find(({ id }) => id === dialogId); if (currentDialog) { @@ -177,8 +191,8 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st useEffect(() => { const currentDialog = dialogs.find(({ id }) => id === dialogId); - const dialogContent = currentDialog?.content ? Object.assign({}, currentDialog.content) : { emptyDialog: true }; - if (!dialogContent.emptyDialog && !dialogContent.id) { + const dialogContent = currentDialog?.content ? Object.assign({}, currentDialog.content) : null; + if (dialogContent !== null && !dialogContent.id) { dialogContent.id = dialogId; updateDialog({ id: dialogId, content: dialogContent, projectId }); } @@ -232,10 +246,6 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st return clearUndo; }, []); - const openImportQnAModal = () => { - setImportQnAModalVisibility(true); - }; - const onTriggerCreationDismiss = () => { setTriggerModalVisibility(false); }; @@ -249,6 +259,7 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st }; function handleSelect(projectId, id, selected = '') { + updateZoomRate({ currentRate: 1 }); if (selected) { selectTo(projectId, selected); } else { @@ -313,7 +324,10 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st displayName: currentDialog?.displayName ?? '', }), onClick: () => { - openImportQnAModal(); + createQnAFromUrlDialogBegin({ + projectId, + showFromScratch: true, + }); }, }, ], @@ -546,33 +560,15 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st onboardingAddCoachMarkRef({ addNew }); }, []); - const cancelImportQnAModal = () => { - setImportQnAModalVisibility(false); - }; + const handleCreateQnA = async (data) => { + if (!dialogId) return; + createTrigger(dialogId, defaultQnATriggerData); - const handleCreateQnA = async (urls: string[]) => { - cancelImportQnAModal(); - const formData = { - $kind: qnaMatcherKey, - errors: { $kind: '', intent: '', event: '', triggerPhrases: '', regEx: '', activity: '' }, - event: '', - intent: '', - regEx: '', - triggerPhrases: '', - }; - const dialog = dialogs.find((d) => d.id === dialogId); - if (dialogId && dialog) { - const url = `/bot/${projectId}/knowledge-base/${dialogId}`; - const triggers = get(dialog, FieldNames.Events, []); - if (triggers.some((t) => t.type === qnaMatcherKey)) { - navigateTo(url); - } else { - createTrigger(dialogId, formData, url); - } - // import qna from urls - if (urls.length > 0) { - await importQnAFromUrls({ id: `${dialogId}.${locale}`, urls, projectId }); - } + const { name, url, multiTurn } = data; + if (url) { + await createQnAKBFromUrl({ id: `${dialogId}.${locale}`, name, url, multiTurn, projectId }); + } else { + await createQnAKBFromScratch({ id: `${dialogId}.${locale}`, name, projectId }); } }; @@ -690,9 +686,7 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st onSubmit={onTriggerCreationSubmit} /> )} - {importQnAModalVisibility && ( - <ImportQnAFromUrlModal dialogId={dialogId} onDismiss={cancelImportQnAModal} onSubmit={handleCreateQnA} /> - )} + <CreateQnAModal dialogId={dialogId} projectId={projectId} qnaFiles={qnaFiles} onSubmit={handleCreateQnA} />) {displaySkillManifest && ( <DisplayManifestModal manifestId={displaySkillManifest} diff --git a/Composer/packages/client/src/pages/design/PropertyEditor.tsx b/Composer/packages/client/src/pages/design/PropertyEditor.tsx index 03e887bafc..73c40582d1 100644 --- a/Composer/packages/client/src/pages/design/PropertyEditor.tsx +++ b/Composer/packages/client/src/pages/design/PropertyEditor.tsx @@ -9,6 +9,7 @@ import { FormErrors, JSONSchema7, useFormConfig, useShellApi } from '@bfc/extens import formatMessage from 'format-message'; import isEqual from 'lodash/isEqual'; import debounce from 'lodash/debounce'; +import get from 'lodash/get'; import { MicrosoftAdaptiveDialog } from '@bfc/shared'; import { formEditor } from './styles'; @@ -25,10 +26,18 @@ function resolveBaseSchema(schema: JSONSchema7, $kind: string): JSONSchema7 | un const PropertyEditor: React.FC = () => { const { shellApi, ...shellData } = useShellApi(); - const { currentDialog, data: formData = {}, focusPath, focusedSteps, focusedTab, schemas } = shellData; + const { currentDialog, focusPath, focusedSteps, focusedTab, schemas } = shellData; const { onFocusSteps } = shellApi; - const [localData, setLocalData] = useState(formData as MicrosoftAdaptiveDialog); + const dialogData = useMemo(() => { + if (currentDialog?.content) { + return focusedSteps[0] ? get(currentDialog.content, focusedSteps[0]) : currentDialog.content; + } else { + return {}; + } + }, [currentDialog, focusedSteps[0]]); + + const [localData, setLocalData] = useState(dialogData as MicrosoftAdaptiveDialog); const syncData = useRef( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -40,12 +49,12 @@ const PropertyEditor: React.FC = () => { ).current; useEffect(() => { - syncData(formData, localData); + syncData(dialogData, localData); return () => { syncData.cancel(); }; - }, [formData]); + }, [dialogData]); const formUIOptions = useFormConfig(); @@ -92,7 +101,7 @@ const PropertyEditor: React.FC = () => { useEffect(() => { const id = setTimeout(() => { - if (!isEqual(formData, localData)) { + if (!isEqual(dialogData, localData)) { shellApi.saveData(localData, focusedSteps[0]); } else { shellApi.commitChanges(); diff --git a/Composer/packages/client/src/pages/design/VisualEditor.tsx b/Composer/packages/client/src/pages/design/VisualEditor.tsx index 0d2e012ec0..708cf47a29 100644 --- a/Composer/packages/client/src/pages/design/VisualEditor.tsx +++ b/Composer/packages/client/src/pages/design/VisualEditor.tsx @@ -61,7 +61,7 @@ interface VisualEditorProps { const VisualEditor: React.FC<VisualEditorProps> = (props) => { const { ...shellData } = useShellApi(); - const { projectId } = shellData; + const { projectId, currentDialog } = shellData; const { openNewTriggerModal, onFocus, onBlur } = props; const [triggerButtonVisible, setTriggerButtonVisibility] = useState(false); const { onboardingAddCoachMarkRef } = useRecoilValue(dispatcherState); @@ -86,7 +86,12 @@ const VisualEditor: React.FC<VisualEditorProps> = (props) => { css={visualEditor(triggerButtonVisible || !selected)} data-testid="VisualEditor" > - <VisualDesigner schema={schemas.sdk?.content} onBlur={onBlur} onFocus={onFocus} /> + <VisualDesigner + data={currentDialog.content ?? {}} + schema={schemas.sdk?.content} + onBlur={onBlur} + onFocus={onFocus} + /> </div> {!selected && onRenderBlankVisual(triggerButtonVisible, openNewTriggerModal)} </React.Fragment> diff --git a/Composer/packages/client/src/pages/design/createDialogModal.tsx b/Composer/packages/client/src/pages/design/createDialogModal.tsx index 60ea499164..7a5a3d0ef6 100644 --- a/Composer/packages/client/src/pages/design/createDialogModal.tsx +++ b/Composer/packages/client/src/pages/design/createDialogModal.tsx @@ -9,10 +9,10 @@ import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { useRecoilValue } from 'recoil'; import { RecognizerSchema, useRecognizerConfig, useShellApi } from '@bfc/extension-client'; import { DialogFactory, SDKKinds } from '@bfc/shared'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; import { DialogCreationCopy, nameRegex } from '../../constants'; import { StorageFolder } from '../../recoilModel/types'; -import { DialogWrapper, DialogTypes } from '../../components/DialogWrapper'; import { FieldConfig, useForm } from '../../hooks/useForm'; import { actionsSeedState, schemasState, validateDialogSelectorFamily } from '../../recoilModel'; diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/content/Description.tsx b/Composer/packages/client/src/pages/design/exportSkillModal/content/Description.tsx index 269a8fea45..a05a44f953 100644 --- a/Composer/packages/client/src/pages/design/exportSkillModal/content/Description.tsx +++ b/Composer/packages/client/src/pages/design/exportSkillModal/content/Description.tsx @@ -11,7 +11,7 @@ import { useRecoilValue } from 'recoil'; import { v4 as uuid } from 'uuid'; import { ContentProps } from '../constants'; -import { botNameState } from '../../../../recoilModel'; +import { botDisplayNameState } from '../../../../recoilModel'; const styles = { row: css` @@ -51,7 +51,7 @@ const InlineLabelField: React.FC<FieldProps> = (props) => { }; export const Description: React.FC<ContentProps> = ({ errors, value, schema, onChange, projectId }) => { - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const { $schema, ...rest } = value; const { hidden, properties } = useMemo( diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/content/SaveManifest.tsx b/Composer/packages/client/src/pages/design/exportSkillModal/content/SaveManifest.tsx index 38578069a7..41567316da 100644 --- a/Composer/packages/client/src/pages/design/exportSkillModal/content/SaveManifest.tsx +++ b/Composer/packages/client/src/pages/design/exportSkillModal/content/SaveManifest.tsx @@ -11,7 +11,7 @@ import { useRecoilValue } from 'recoil'; import formatMessage from 'format-message'; import { ContentProps, VERSION_REGEX } from '../constants'; -import { botNameState, skillManifestsState } from '../../../../recoilModel'; +import { botDisplayNameState, skillManifestsState } from '../../../../recoilModel'; const styles = { container: css` @@ -42,7 +42,7 @@ export const getManifestId = ( }; export const SaveManifest: React.FC<ContentProps> = ({ errors, manifest, setSkillManifest, projectId }) => { - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const skillManifests = useRecoilValue(skillManifestsState(projectId)); const { id } = manifest; diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/content/SelectDialogs.tsx b/Composer/packages/client/src/pages/design/exportSkillModal/content/SelectDialogs.tsx index 9f83cdce31..e6373e4e26 100644 --- a/Composer/packages/client/src/pages/design/exportSkillModal/content/SelectDialogs.tsx +++ b/Composer/packages/client/src/pages/design/exportSkillModal/content/SelectDialogs.tsx @@ -58,8 +58,8 @@ const DescriptionColumn: React.FC<DescriptionColumnProps> = (props) => { ).current; useEffect(() => { - if (value !== content.$designer?.description) { - sync(updateDialog, value, content); + if (value !== content?.$designer?.description) { + sync(updateDialog, value ?? '', content); } }, [value, updateDialog]); diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts b/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts index 767006135c..cd893c8e27 100644 --- a/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts +++ b/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts @@ -37,7 +37,7 @@ export const generateSkillManifest = ( const { content } = rootDialog; const triggers = selectedTriggers.reduce((acc: ITrigger[], { id: path }) => { - const trigger = get(content, path); + const trigger = get(content, path) as ITrigger; return trigger ? [...acc, trigger] : acc; }, []); @@ -128,10 +128,10 @@ export const generateDispatchModels = ( const luLanguages = intents.length ? rootLuFiles.reduce((acc, { empty, id }) => { const [name, locale] = id.split('.'); - const { content = {} } = dialogs.find(({ id }) => id === name) || {}; + const { content } = dialogs.find(({ id }) => id === name) || ({ content: {} } as DialogInfo); const { recognizer = '' } = content; - if (!recognizer.includes('.lu') || empty) { + if ((typeof recognizer === 'string' && !recognizer.includes('.lu')) || empty) { return acc; } @@ -152,10 +152,10 @@ export const generateDispatchModels = ( const languages = rootQnAFiles.reduce((acc, { empty, id }) => { const [name, locale] = id.split('.'); - const { content = {} } = dialogs.find(({ id }) => id === name) || {}; + const { content } = dialogs.find(({ id }) => id === name) || ({ content: {} } as DialogInfo); const { recognizer = '' } = content; - if (!recognizer.includes('.qna') || empty) { + if ((typeof recognizer === 'string' && !recognizer.includes('.qna')) || empty) { return acc; } diff --git a/Composer/packages/client/src/pages/form-dialog/CreateFormDialogSchemaModal.tsx b/Composer/packages/client/src/pages/form-dialog/CreateFormDialogSchemaModal.tsx new file mode 100644 index 0000000000..bca1809dce --- /dev/null +++ b/Composer/packages/client/src/pages/form-dialog/CreateFormDialogSchemaModal.tsx @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DialogTypes, DialogWrapper } from '@bfc/ui-shared'; +import formatMessage from 'format-message'; +import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; +import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import React, { useCallback } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { nameRegex } from '../../constants'; +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { dialogsState } from '../../recoilModel'; + +type FormDialogSchemaFormData = { + name: string; +}; + +type Props = { + projectId: string; + isOpen: boolean; + onSubmit: (formDialogName: string) => void; + onDismiss: () => void; +}; + +const CreateFormDialogSchemaModal: React.FC<Props> = (props) => { + const { isOpen, projectId, onSubmit, onDismiss } = props; + + const dialogs = useRecoilValue(dialogsState(projectId)); + + const formConfig: FieldConfig<FormDialogSchemaFormData> = { + name: { + required: true, + validate: (value) => { + if (!nameRegex.test(value)) { + return formatMessage('Spaces and special characters are not allowed. Use letters, numbers, -, or _.'); + } + if (dialogs.some((dialog) => dialog.id === value)) { + return formatMessage('Dialog with the name: {value} already exists.', { value }); + } + }, + }, + }; + + const { formData, formErrors, hasErrors, updateField } = useForm(formConfig); + + const handleSubmit = useCallback( + (e) => { + e.preventDefault(); + if (hasErrors) { + return; + } + + onSubmit(formData.name); + }, + [hasErrors, formData] + ); + + return ( + <DialogWrapper + dialogType={DialogTypes.DesignFlow} + isOpen={isOpen} + subText={formatMessage('A form dialog enables your bot to collect pieces of information .')} + title={formatMessage('Create form dialog')} + onDismiss={onDismiss} + > + <form onSubmit={handleSubmit}> + <input style={{ display: 'none' }} type="submit" /> + <Stack> + <TextField + required + errorMessage={formErrors.name} + label={formatMessage('Name')} + styles={name} + value={formData.name} + onChange={(_e, val) => updateField('name', val)} + /> + </Stack> + + <DialogFooter> + <DefaultButton text={formatMessage('Cancel')} onClick={onDismiss} /> + <PrimaryButton + disabled={hasErrors || formData.name === ''} + text={formatMessage('Create')} + onClick={handleSubmit} + /> + </DialogFooter> + </form> + </DialogWrapper> + ); +}; + +export default CreateFormDialogSchemaModal; diff --git a/Composer/packages/client/src/pages/form-dialog/FormDialogPage.tsx b/Composer/packages/client/src/pages/form-dialog/FormDialogPage.tsx new file mode 100644 index 0000000000..943f073b14 --- /dev/null +++ b/Composer/packages/client/src/pages/form-dialog/FormDialogPage.tsx @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import styled from '@emotion/styled'; +import { navigate, RouteComponentProps } from '@reach/router'; +import formatMessage from 'format-message'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { Text } from 'office-ui-fabric-react/lib/Text'; +import * as React from 'react'; +import { useRecoilValue } from 'recoil'; + +import { OpenConfirmModal } from '../../components/Modal/ConfirmDialog'; +import { LeftRightSplit } from '../../components/Split/LeftRightSplit'; +import { + dispatcherState, + formDialogGenerationProgressingState, + formDialogLibraryTemplatesState, + formDialogSchemaIdsState, +} from '../../recoilModel'; + +import CreateFormDialogSchemaModal from './CreateFormDialogSchemaModal'; +import { FormDialogSchemaList } from './FormDialogSchemaList'; +import { VisualFormDialogSchemaEditor } from './VisualFormDialogSchemaEditor'; + +const EmptyView = styled(Stack)({ + width: '100%', + opacity: 0.5, +}); + +type Props = RouteComponentProps<{ projectId: string; schemaId: string }>; + +const FormDialogPage: React.FC<Props> = React.memo((props: Props) => { + const { projectId = '', schemaId = '' } = props; + const formDialogSchemaIds = useRecoilValue(formDialogSchemaIdsState(projectId)); + const formDialogLibraryTemplates = useRecoilValue(formDialogLibraryTemplatesState); + const formDialogGenerationProgressing = useRecoilValue(formDialogGenerationProgressingState); + const { + removeFormDialogSchema, + generateFormDialog, + createFormDialogSchema, + updateFormDialogSchema, + navigateToGeneratedDialog, + loadFormDialogSchemaTemplates, + } = useRecoilValue(dispatcherState); + + const { 0: createSchemaDialogOpen, 1: setCreateSchemaDialogOpen } = React.useState(false); + + React.useEffect(() => { + loadFormDialogSchemaTemplates(); + }, []); + + const availableTemplates = React.useMemo( + () => formDialogLibraryTemplates.filter((t) => !t.isGlobal).map((t) => t.name), + [formDialogLibraryTemplates] + ); + + const validSchemaId = React.useMemo(() => formDialogSchemaIds.includes(schemaId), [formDialogSchemaIds, schemaId]); + + const createItemStart = React.useCallback(() => setCreateSchemaDialogOpen(true), [setCreateSchemaDialogOpen]); + + const selectItem = React.useCallback((id: string) => { + navigate(`/bot/${projectId}/forms/${id}`); + }, []); + + const deleteItem = React.useCallback( + async (id: string) => { + const res = await OpenConfirmModal( + formatMessage('Delete form dialog schema'), + formatMessage('Are you sure you want to remove form dialog schema "{id}"?', { id }) + ); + if (res) { + removeFormDialogSchema({ id, projectId }); + if (schemaId === id) { + selectItem(''); + } + } + }, + [selectItem, removeFormDialogSchema, schemaId] + ); + + const generateDialog = React.useCallback( + (schemaId: string) => { + if (schemaId) { + generateFormDialog({ projectId, schemaId }); + } + }, + [generateFormDialog, projectId] + ); + + const viewDialog = React.useCallback( + (schemaId: string) => { + if (schemaId) { + navigateToGeneratedDialog({ projectId, schemaId }); + } + }, + [navigateToGeneratedDialog, projectId] + ); + + const updateItem = React.useCallback( + (id: string, content: string) => { + if (id === schemaId) { + updateFormDialogSchema({ id, content, projectId }); + } + }, + [updateFormDialogSchema, schemaId] + ); + + const createItem = React.useCallback( + (formDialogName: string) => { + createFormDialogSchema({ id: formDialogName, projectId }); + setCreateSchemaDialogOpen(false); + }, + [createFormDialogSchema, setCreateSchemaDialogOpen] + ); + + return ( + <> + <Stack horizontal verticalFill> + <LeftRightSplit initialLeftGridWidth={320} minLeftPixels={320} minRightPixels={800}> + <FormDialogSchemaList + items={formDialogSchemaIds} + loading={formDialogGenerationProgressing} + projectId={projectId} + selectedId={schemaId} + onCreateItem={createItemStart} + onDeleteItem={deleteItem} + onGenerate={generateDialog} + onSelectItem={selectItem} + onViewDialog={viewDialog} + /> + {validSchemaId ? ( + <VisualFormDialogSchemaEditor + generationInProgress={formDialogGenerationProgressing} + projectId={projectId} + schemaId={schemaId} + templates={availableTemplates} + onChange={updateItem} + onGenerate={generateDialog} + /> + ) : ( + <EmptyView verticalFill horizontalAlign="center" verticalAlign="center"> + <Text variant="large"> + {schemaId + ? formatMessage(`{schemaId} doesn't exists, select an schema to edit or create a new one`, { + schemaId, + }) + : formatMessage('Select an schema to edit or create a new one')} + </Text> + </EmptyView> + )} + </LeftRightSplit> + </Stack> + {createSchemaDialogOpen ? ( + <CreateFormDialogSchemaModal + isOpen={createSchemaDialogOpen} + projectId={projectId} + onDismiss={() => setCreateSchemaDialogOpen(false)} + onSubmit={createItem} + /> + ) : null} + </> + ); +}); + +export default FormDialogPage; diff --git a/Composer/packages/client/src/pages/form-dialog/FormDialogSchemaList.tsx b/Composer/packages/client/src/pages/form-dialog/FormDialogSchemaList.tsx new file mode 100644 index 0000000000..710a6d436e --- /dev/null +++ b/Composer/packages/client/src/pages/form-dialog/FormDialogSchemaList.tsx @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import styled from '@emotion/styled'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import { DefaultPalette } from '@uifabric/styling'; +import formatMessage from 'format-message'; +import debounce from 'lodash/debounce'; +import { CommandBarButton, IconButton } from 'office-ui-fabric-react/lib/Button'; +import { FocusZone, FocusZoneDirection } from 'office-ui-fabric-react/lib/FocusZone'; +import { IOverflowSetItemProps, OverflowSet } from 'office-ui-fabric-react/lib/OverflowSet'; +import { IStackItemProps, IStackItemStyles, Stack } from 'office-ui-fabric-react/lib/Stack'; +import { DirectionalHint, TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { classNamesFunction } from 'office-ui-fabric-react/lib/Utilities'; +import * as React from 'react'; +import { useRecoilValue } from 'recoil'; + +import { formDialogSchemaDialogExistsSelector, formDialogSchemaState } from '../../recoilModel'; + +import { FormDialogSchemaListHeader } from './FormDialogSchemaListHeader'; + +const isEmptyObject = (objStr: string) => objStr === '{}'; + +const Root = styled(Stack)<{ + loading: boolean; +}>( + { + position: 'relative', + width: '100%', + height: '100%', + borderRight: '1px solid #c4c4c4', + boxSizing: 'border-box', + overflowY: 'auto', + overflowX: 'hidden', + '& .ms-List-cell': { + minHeight: '36px', + }, + }, + (props) => + props.loading + ? { + '&:after': { + content: '""', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + background: 'rgba(255,255,255, 0.6)', + zIndex: 1, + }, + } + : null +); + +const oneLinerStyles = classNamesFunction<IStackItemProps, IStackItemStyles>()({ + root: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, +}); + +const ItemRoot = styled(Stack)(({ selected }: { selected: boolean }) => ({ + padding: '0 12px', + cursor: 'pointer', + background: selected ? DefaultPalette.neutralLighter : 'transparent', +})); + +const EmptyView = styled(Stack)({ + opacity: 0.5, + padding: 8, + fontSize: 14, + textAlign: 'center', +}); + +type FormDialogSchemaItemProps = { + projectId: string; + schemaId: string; + selected: boolean; + onClick: (schemaId: string) => void; + onDelete: (schemaId: string) => void; + onGenerate: (schemaId: string) => void; + onViewDialog: (schemaId: string) => void; +}; + +const FormDialogSchemaItem = React.memo((props: FormDialogSchemaItemProps) => { + const { projectId, schemaId, onClick, onDelete, onGenerate, selected = false, onViewDialog } = props; + + const item = useRecoilValue(formDialogSchemaState({ projectId, schemaId })); + const viewDialogActionDisabled = !useRecoilValue(formDialogSchemaDialogExistsSelector({ projectId, schemaId })); + const generateActionDisabled = item?.content === '' || isEmptyObject(item?.content); + + const clickHandler = React.useCallback(() => { + onClick(schemaId); + }, [schemaId, onClick]); + + const deleteHandler = React.useCallback( + (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { + e.stopPropagation(); + + onDelete(schemaId); + }, + [schemaId, onDelete] + ); + + const generateDialog = React.useCallback( + (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { + e.stopPropagation(); + + onGenerate(schemaId); + }, + [schemaId, onGenerate] + ); + + const viewDialog = React.useCallback( + (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { + e.stopPropagation(); + + onViewDialog(schemaId); + }, + [schemaId, onViewDialog] + ); + + const renderOverflowItem = React.useCallback( + (item: IOverflowSetItemProps) => <CommandBarButton aria-label={item.name} role="menuitem" onClick={item.onClick} />, + [] + ); + + const renderOverflowButton = React.useCallback( + (overflowItems?: IOverflowSetItemProps[]) => ( + <TooltipHost content={formatMessage('Actions')} directionalHint={DirectionalHint.rightCenter}> + <IconButton + data-is-focusable + menuIconProps={{ + iconName: 'MoreVertical', + style: { color: NeutralColors.gray130 }, + }} + menuProps={{ items: overflowItems || [] }} + role="menuitem" + /> + </TooltipHost> + ), + [] + ); + + return ( + <ItemRoot + data-is-focusable + horizontal + selected={selected} + tokens={{ childrenGap: 8 }} + verticalAlign="center" + onClick={clickHandler} + > + <Stack.Item grow styles={oneLinerStyles}> + {schemaId} + </Stack.Item> + <OverflowSet + aria-label={formatMessage('Form dialog schema actions')} + overflowItems={[ + { + key: 'viewDialog', + name: formatMessage('View dialog'), + onClick: viewDialog, + disabled: viewDialogActionDisabled, + }, + { + key: 'generateDialog', + name: formatMessage('Generate dialog'), + onClick: generateDialog, + disabled: generateActionDisabled, + }, + { + key: 'deleteItem', + name: formatMessage('Delete'), + onClick: deleteHandler, + }, + ]} + role="menubar" + onRenderItem={renderOverflowItem} + onRenderOverflowButton={renderOverflowButton} + /> + </ItemRoot> + ); +}); + +type FormDialogSchemaListProps = { + projectId: string; + items: readonly string[]; + selectedId: string | undefined; + loading?: boolean; + onSelectItem: (schemaId: string) => void; + onDeleteItem: (schemaId: string) => void; + onGenerate: (schemaId: string) => void; + onViewDialog: (schemaId: string) => void; + onCreateItem: () => void; +}; + +export const FormDialogSchemaList: React.FC<FormDialogSchemaListProps> = React.memo((props) => { + const { + projectId, + selectedId, + items, + onDeleteItem, + onSelectItem, + onCreateItem, + onGenerate, + onViewDialog, + loading = false, + } = props; + + const { 0: query, 1: setQuery } = React.useState(''); + const delayedSetQuery = debounce((newValue) => setQuery(newValue), 300); + + const filteredItems = React.useMemo(() => { + return items.filter((item) => item.toLowerCase().indexOf(query.toLowerCase()) !== -1); + }, [query, items]); + + const onFilter = (_e?: React.ChangeEvent<HTMLInputElement>, newValue?: string): void => { + if (typeof newValue === 'string') { + delayedSetQuery(newValue); + } + }; + + const renderItem = React.useCallback( + (itemId) => ( + <FormDialogSchemaItem + key={itemId} + projectId={projectId} + schemaId={itemId} + selected={selectedId === itemId} + onClick={onSelectItem} + onDelete={onDeleteItem} + onGenerate={onGenerate} + onViewDialog={onViewDialog} + /> + ), + [selectedId, onSelectItem, onDeleteItem, onGenerate] + ); + + return ( + <Root aria-label={formatMessage('Navigation pane')} loading={loading} role="region" tokens={{ childrenGap: 8 }}> + <FormDialogSchemaListHeader + loading={loading} + searchDisabled={!items.length} + onChangeQuery={onFilter} + onCreateItem={onCreateItem} + /> + + <div + aria-label={formatMessage( + `{ + itemCount, plural, + =0 {No schemas} + =1 {One schema} + other {# schemas} + } have been found. + { + itemCount, select, + 0 {} + other {Press down arrow key to navigate the search results} + }`, + { itemCount: items.length } + )} + aria-live="polite" + /> + {filteredItems.length ? ( + <FocusZone isCircularNavigation direction={FocusZoneDirection.vertical}> + {filteredItems.map(renderItem)} + </FocusZone> + ) : ( + <EmptyView verticalFill horizontalAlign="center" verticalAlign="center"> + {query + ? formatMessage('No form dialog schema matches your filtering criteria!') + : formatMessage('Create a new form dialog schema by clicking + above.')} + </EmptyView> + )} + </Root> + ); +}); diff --git a/Composer/packages/client/src/pages/form-dialog/FormDialogSchemaListHeader.tsx b/Composer/packages/client/src/pages/form-dialog/FormDialogSchemaListHeader.tsx new file mode 100644 index 0000000000..b991de0d21 --- /dev/null +++ b/Composer/packages/client/src/pages/form-dialog/FormDialogSchemaListHeader.tsx @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import styled from '@emotion/styled'; +import formatMessage from 'format-message'; +import { IconButton } from 'office-ui-fabric-react/lib/Button'; +import { Label } from 'office-ui-fabric-react/lib/Label'; +import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator'; +import { ISearchBoxProps, ISearchBoxStyles, SearchBox } from 'office-ui-fabric-react/lib/SearchBox'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { classNamesFunction } from 'office-ui-fabric-react/lib/Utilities'; +import * as React from 'react'; + +const TitleBar = styled(Stack)({ + flex: 1, + height: 45, +}); + +const Title = styled(Label)({}); + +const LoadingIndicator = styled(ProgressIndicator)({ + position: 'absolute', + width: 'calc(100% - 16px)', + left: 8, + top: 25, + zIndex: 2, +}); + +const searchBoxStyles = classNamesFunction<ISearchBoxProps, ISearchBoxStyles>()({ + root: { + borderBottom: '1px solid #edebe9', + width: '100%', + }, + clearButton: { display: 'none' }, +}); + +type ListHeaFormDialogSchemaListHeaderProps = { + loading?: boolean; + searchDisabled?: boolean; + onChangeQuery: (e?: React.ChangeEvent<HTMLInputElement> | undefined, query?: string | undefined) => void; + onCreateItem: () => void; +}; + +export const FormDialogSchemaListHeader = (props: ListHeaFormDialogSchemaListHeaderProps) => { + const { loading = false, searchDisabled = false, onCreateItem, onChangeQuery } = props; + + const { 0: isFilterOn, 1: setFilterOn } = React.useState(false); + return ( + <Stack horizontal styles={{ root: { padding: '0 12px' } }} tokens={{ childrenGap: 8 }} verticalAlign="center"> + <TitleBar horizontal verticalAlign="center"> + {isFilterOn ? ( + <SearchBox + underlined + ariaLabel={formatMessage('Type form dialog schema name')} + iconProps={{ iconName: 'Filter' }} + placeholder={formatMessage('Type form dialog schema name')} + styles={searchBoxStyles} + onChange={onChangeQuery} + onEscape={() => setFilterOn(false)} + /> + ) : ( + <Title> + {formatMessage('Schemas')} + {loading && <LoadingIndicator />} + + )} + + {isFilterOn ? ( + setFilterOn(false)} /> + ) : ( + <> + setFilterOn(true)} /> + + + )} + + ); +}; diff --git a/Composer/packages/client/src/pages/form-dialog/VisualFormDialogSchemaEditor.tsx b/Composer/packages/client/src/pages/form-dialog/VisualFormDialogSchemaEditor.tsx new file mode 100644 index 0000000000..302931e36b --- /dev/null +++ b/Composer/packages/client/src/pages/form-dialog/VisualFormDialogSchemaEditor.tsx @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { JsonEditor } from '@bfc/code-editor'; +import { FormDialogSchemaEditor } from '@bfc/form-dialogs'; +import styled from '@emotion/styled'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import formatMessage from 'format-message'; +import { ActionButton } from 'office-ui-fabric-react/lib/Button'; +import { IStackProps, IStackStyles, Stack } from 'office-ui-fabric-react/lib/Stack'; +import { classNamesFunction } from 'office-ui-fabric-react/lib/Utilities'; +import * as React from 'react'; +import { useRecoilValue } from 'recoil'; + +import { formDialogSchemaState } from '../../recoilModel'; + +const Root = styled(Stack)<{ + inProgress: boolean; +}>( + { + position: 'relative', + width: '100%', + backgroundColor: NeutralColors.gray20, + }, + (props) => + props.inProgress + ? { + '&:after': { + content: '""', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + background: 'rgba(255,255,255, 0.6)', + zIndex: 1, + }, + } + : null +); + +const noop = () => {}; +const defaultValue: object = {}; + +const editorTopBarStyles = classNamesFunction()({ + root: { backgroundColor: '#fff', height: '45px', marginBottom: 1 }, +}); + +type Props = { + projectId: string; + schemaId: string; + generationInProgress?: boolean; + templates: string[]; + onChange: (id: string, content: string) => void; + onGenerate: (schemaId: string) => void; +}; + +export const VisualFormDialogSchemaEditor = React.memo((props: Props) => { + const { projectId, schemaId, templates, onChange, onGenerate, generationInProgress = false } = props; + + const schema = useRecoilValue(formDialogSchemaState({ projectId, schemaId })); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editorRef = React.useRef(); + const getEditorValueRef = React.useRef<() => string>(() => schema.content || JSON.stringify(defaultValue, null, 2)); + const dialogSchemaContentRef = React.useRef(schema.content || JSON.stringify(defaultValue, null, 2)); + + const [showEditor, setShowEditor] = React.useState(false); + + React.useEffect(() => { + if (showEditor) { + dialogSchemaContentRef.current = schema.content || JSON.stringify(defaultValue, null, 2); + } + editorRef.current?.setValue(schema.content); + }, [schema.content]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const onEditorDidMount = (getValue: () => string, editor: any) => { + editorRef.current = editor; + getEditorValueRef.current = getValue; + editorRef.current.setValue(dialogSchemaContentRef.current); + }; + + const onSchemaUpdated = (id: string, content: string) => { + onChange(id, content); + + dialogSchemaContentRef.current = content; + editorRef.current?.setValue(content); + }; + + return ( + + + setShowEditor(!showEditor)}> + {showEditor ? formatMessage('Hide code') : formatMessage('Show code')} + + + + + {!showEditor ? ( + + ) : ( + + )} + + + ); +}); diff --git a/Composer/packages/client/src/pages/home/Home.tsx b/Composer/packages/client/src/pages/home/Home.tsx index 334bf548cc..09c02ed024 100644 --- a/Composer/packages/client/src/pages/home/Home.tsx +++ b/Composer/packages/client/src/pages/home/Home.tsx @@ -12,7 +12,7 @@ import { navigate } from '@reach/router'; import { useRecoilValue } from 'recoil'; import { CreationFlowStatus } from '../../constants'; -import { dispatcherState, botNameState } from '../../recoilModel'; +import { dispatcherState, botDisplayNameState } from '../../recoilModel'; import { recentProjectsState, templateProjectsState, @@ -63,7 +63,7 @@ const tutorials = [ const Home: React.FC = () => { const templateProjects = useRecoilValue(templateProjectsState); const projectId = useRecoilValue(currentProjectIdState); - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const recentProjects = useRecoilValue(recentProjectsState); const templateId = useRecoilValue(templateIdState); const { openProject, setCreationFlowStatus, onboardingAddCoachMarkRef, saveTemplateId } = useRecoilValue( diff --git a/Composer/packages/client/src/pages/knowledge-base/ImportQnAFromUrlModal.tsx b/Composer/packages/client/src/pages/knowledge-base/ImportQnAFromUrlModal.tsx deleted file mode 100644 index b92084a321..0000000000 --- a/Composer/packages/client/src/pages/knowledge-base/ImportQnAFromUrlModal.tsx +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** @jsx jsx */ -import { jsx, css } from '@emotion/core'; -import React, { useState } from 'react'; -import formatMessage from 'format-message'; -import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; -import { Stack } from 'office-ui-fabric-react/lib/Stack'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; -import { PrimaryButton, DefaultButton, ActionButton } from 'office-ui-fabric-react/lib/Button'; -import { Link } from 'office-ui-fabric-react/lib/Link'; -import { FontWeights } from '@uifabric/styling'; -import { FontSizes, SharedColors, NeutralColors } from '@uifabric/fluent-theme'; -import { RouteComponentProps } from '@reach/router'; - -import { QnAMakerLearningUrl, knowledgeBaseSourceUrl } from '../../constants'; -import { FieldConfig, useForm } from '../../hooks/useForm'; - -const styles = { - dialog: { - title: { - fontWeight: FontWeights.bold, - fontSize: FontSizes.size20, - paddingTop: '14px', - paddingBottom: '11px', - }, - subText: { - fontSize: FontSizes.size14, - }, - }, - modal: { - main: { - maxWidth: '800px !important', - }, - }, -}; - -const dialogWindow = css` - display: flex; - flex-direction: column; - width: 400px; - min-height: 200px; -`; - -const textField = { - root: { - width: '400px', - paddingBottom: '20px', - }, -}; - -const warning = { - color: SharedColors.red10, - fontSize: FontSizes.size10, -}; - -const actionButton = css` - font-size: 16px; - padding-left: 0px; - margin-left: -5px; -`; - -const urlContainer = css` - display: flex; - width: 444px; -`; - -const cancel = css` - margin-top: -3px; - margin-left: 10px; -`; - -const subText = css` - color: ${NeutralColors.gray130}; - font-size: 14px; - font-weight: 400; -`; - -interface ImportQnAFromUrlModalProps - extends RouteComponentProps<{ - location: string; - }> { - dialogId: string; - subscriptionKey?: string; - onDismiss: () => void; - onSubmit: (urls: string[]) => void; -} - -const DialogTitle = () => { - return ( -
- {formatMessage('Populate your Knowledge Base')} -

- - {formatMessage( - 'Extract question-and-answer pairs from an online FAQ, product manuals, or other files. Supported formats are .tsv, .pdf, .doc, .docx, .xlsx, containing questions and answers in sequence. ' - )} - - {formatMessage('Learn more about knowledge base sources. ')} - - {formatMessage( - 'Skip this step to add questions and answers manually after creation. The number of sources and file size you can add depends on the QnA service SKU you choose. ' - )} - - {formatMessage('Learn more about QnA Maker SKUs.')} - - -

-
- ); -}; - -interface FormField { - urls: string[]; -} - -const validateUrls = (urls: string[]) => { - const errors = Array(urls.length).fill(''); - - for (let i = 0; i < urls.length; i++) { - const baseUrl = urls[i].replace(/\/$/, ''); - for (let j = 0; j < urls.length; j++) { - const candidateUrl = urls[j].replace(/\/$/, ''); - if (baseUrl && candidateUrl && baseUrl === candidateUrl && i !== j) { - errors[i] = errors[j] = formatMessage('This url is duplicated'); - } - } - } - - for (let i = 0; i < urls.length; i++) { - if (urls[i] && !urls[i].startsWith('http://') && !urls[i].startsWith('https://')) { - errors[i] = formatMessage('A valid url should start with http:// or https://'); - } - } - - return errors; -}; - -const formConfig: FieldConfig = { - urls: { - required: true, - defaultValue: [''], - }, -}; - -export const ImportQnAFromUrlModal: React.FC = (props) => { - const { onDismiss, onSubmit, dialogId } = props; - const [urlErrors, setUrlErrors] = useState(['']); - - const { formData, updateField, hasErrors } = useForm(formConfig); - const isQnAFileselected = !(dialogId === 'all'); - const disabled = hasErrors || urlErrors.some((e) => !!e) || formData.urls.some((url) => !url); - - const addNewUrl = () => { - const urls = [...formData.urls, '']; - updateField('urls', urls); - setUrlErrors(validateUrls(urls)); - }; - - const updateUrl = (index: number, url = '') => { - const urls = [...formData.urls]; - urls[index] = url; - updateField('urls', urls); - setUrlErrors(validateUrls(urls)); - }; - - const removeUrl = (index: number) => { - const urls = [...formData.urls]; - urls.splice(index, 1); - updateField('urls', urls); - setUrlErrors(validateUrls(urls)); - }; - - return ( - , - styles: styles.dialog, - }} - hidden={false} - modalProps={{ - isBlocking: false, - styles: styles.modal, - }} - onDismiss={onDismiss} - > -
- - {formData.urls.map((l, index) => { - return ( -
- updateUrl(index, url)} - /> - {index !== 0 && ( -
- ); - })} - - {formatMessage('Add additional URL')} - - {!isQnAFileselected && ( -
{formatMessage('Please select a specific qna file to import QnA')}
- )} -
-
- - {window.location.href.indexOf('/knowledge-base/') == -1 && ( - { - if (hasErrors) { - return; - } - onSubmit([]); - }} - /> - )} - - { - if (hasErrors) { - return; - } - onSubmit(formData.urls); - }} - /> - -
- ); -}; - -export default ImportQnAFromUrlModal; diff --git a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx index c4b90d6390..9ab350edaa 100644 --- a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx +++ b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx @@ -6,21 +6,19 @@ import { jsx } from '@emotion/core'; import { useRecoilValue } from 'recoil'; import React, { Fragment, useMemo, useCallback, Suspense, useEffect, useState } from 'react'; import formatMessage from 'format-message'; -import { Toggle } from 'office-ui-fabric-react/lib/Toggle'; +import { ActionButton } from 'office-ui-fabric-react/lib/Button'; import { RouteComponentProps, Router } from '@reach/router'; import { LoadingSpinner } from '../../components/LoadingSpinner'; -import { actionButton } from '../language-understanding/styles'; import { navigateTo } from '../../utils/navigation'; import { TestController } from '../../components/TestController/TestController'; import { INavTreeItem } from '../../components/NavTree'; import { Page } from '../../components/Page'; -import { botNameState, dialogsState, qnaAllUpViewStatusState } from '../../recoilModel/atoms/botState'; +import { dialogsState, qnaFilesState } from '../../recoilModel/atoms/botState'; import { dispatcherState } from '../../recoilModel'; -import { QnAAllUpViewStatus } from '../../recoilModel/types'; +import { CreateQnAModal } from '../../components/QnA'; import TableView from './table-view'; -import { ImportQnAFromUrlModal } from './ImportQnAFromUrlModal'; const CodeEditor = React.lazy(() => import('./code-editor')); @@ -31,14 +29,14 @@ interface QnAPageProps extends RouteComponentProps<{}> { const QnAPage: React.FC = (props) => { const { dialogId = '', projectId = '' } = props; + const actions = useRecoilValue(dispatcherState); const dialogs = useRecoilValue(dialogsState(projectId)); - const botName = useRecoilValue(botNameState(projectId)); + const qnaFiles = useRecoilValue(qnaFilesState(projectId)); //To do: support other languages const locale = 'en-us'; //const locale = useRecoilValue(localeState); - const qnaAllUpViewStatus = useRecoilValue(qnaAllUpViewStatusState(projectId)); - const [importQnAFromUrlModalVisiability, setImportQnAFromUrlModalVisiability] = useState(false); + const [createOnDialogId, setCreateOnDialogId] = useState(''); const path = props.location?.pathname ?? ''; @@ -51,6 +49,27 @@ const QnAPage: React.FC = (props) => { name: dialog.displayName, ariaLabel: formatMessage('qna file'), url: `/bot/${projectId}/knowledge-base/${dialog.id}`, + menuIconProps: { + iconName: 'Add', + }, + menuItems: [ + { + name: formatMessage('Create KB from scratch'), + key: 'Create KB from scratch', + onClick: () => { + setCreateOnDialogId(dialog.id); + actions.createQnAFromScratchDialogBegin({ projectId }); + }, + }, + { + name: formatMessage('Create KB from URL or file'), + key: 'Create KB from URL or file', + onClick: () => { + setCreateOnDialogId(dialog.id); + actions.createQnAFromUrlDialogBegin({ projectId, showFromScratch: false }); + }, + }, + ], }; }); const mainDialogIndex = newDialogLinks.findIndex((link) => link.id === 'Main'); @@ -69,13 +88,7 @@ const QnAPage: React.FC = (props) => { }, [dialogs]); useEffect(() => { - const qnaKbUrls: string[] | undefined = props.location?.state?.qnaKbUrls; - if (qnaKbUrls && qnaKbUrls.length > 0) { - actions.importQnAFromUrls({ id: `${botName.toLocaleLowerCase()}.${locale}`, urls: qnaKbUrls, projectId }); - } - }, []); - - useEffect(() => { + setCreateOnDialogId(''); const activeDialog = dialogs.find(({ id }) => id === dialogId); if (!activeDialog && dialogs.length && dialogId !== 'all') { navigateTo(`/bot/${projectId}/knowledge-base/${dialogId}`); @@ -83,36 +96,15 @@ const QnAPage: React.FC = (props) => { }, [dialogId, dialogs, projectId]); const onToggleEditMode = useCallback( - (_e, checked) => { + (_e) => { let url = `/bot/${projectId}/knowledge-base/${dialogId}`; - if (checked) url += `/edit`; + if (!edit) url += `/edit`; navigateTo(url); }, - [dialogId, projectId] + [dialogId, projectId, edit] ); const toolbarItems = [ - { - type: 'dropdown', - text: formatMessage('Add'), - align: 'left', - dataTestid: 'AddFlyout', - buttonProps: { - iconProps: { iconName: 'Add' }, - }, - menuProps: { - items: [ - { - 'data-testid': 'FlyoutNewDialog', - key: 'importQnAFromUrls', - text: formatMessage('Import QnA From Url'), - onClick: () => { - setImportQnAFromUrlModalVisiability(true); - }, - }, - ], - }, - }, { type: 'element', element: , @@ -121,31 +113,16 @@ const QnAPage: React.FC = (props) => { ]; const onRenderHeaderContent = () => { - if (!isRoot || edit) { + if (!isRoot) { return ( - + + {edit ? formatMessage('Hide code') : formatMessage('Show code')} + ); } return null; }; - const onDismiss = () => { - setImportQnAFromUrlModalVisiability(false); - }; - - const onSubmit = async (urls: string[]) => { - onDismiss(); - await actions.importQnAFromUrls({ id: `${dialogId}.${locale}`, urls, projectId }); - }; - return ( = (props) => { }> - {qnaAllUpViewStatus !== QnAAllUpViewStatus.Loading && ( - - )} + - {qnaAllUpViewStatus === QnAAllUpViewStatus.Loading && ( - - )} - {importQnAFromUrlModalVisiability && ( - - )} + { + actions.createQnAFromUrlDialogCancel({ projectId }); + }} + onSubmit={async ({ name, url, multiTurn = false }) => { + if (url) { + await actions.createQnAKBFromUrl({ + id: `${createOnDialogId || dialogId}.${locale}`, + name, + url, + multiTurn, + projectId, + }); + } else { + await actions.createQnAKBFromScratch({ + id: `${createOnDialogId || dialogId}.${locale}`, + name, + projectId, + }); + } + }} + > ); diff --git a/Composer/packages/client/src/pages/knowledge-base/__tests__/importQnAFromUrlModal.test.tsx b/Composer/packages/client/src/pages/knowledge-base/__tests__/importQnAFromUrlModal.test.tsx deleted file mode 100644 index 677776b34a..0000000000 --- a/Composer/packages/client/src/pages/knowledge-base/__tests__/importQnAFromUrlModal.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import React from 'react'; -import { fireEvent } from '@bfc/test-utils'; - -import { renderWithRecoil } from './../../../../__tests__/testUtils/renderWithRecoil'; -import { ImportQnAFromUrlModal } from './../ImportQnAFromUrlModal'; - -describe('', () => { - const onDismiss = jest.fn(() => {}); - const onSubmit = jest.fn(() => {}); - let container; - beforeEach(() => { - container = renderWithRecoil( - , - () => {} - ); - }); - - it('renders and create from scratch', () => { - const { getByText } = container; - expect(getByText('Populate your Knowledge Base')).not.toBeNull(); - const createFromScratchButton = getByText('Create knowledge base from scratch'); - expect(createFromScratchButton).not.toBeNull(); - fireEvent.click(createFromScratchButton); - expect(onSubmit).toBeCalled(); - expect(onSubmit).toBeCalledWith([]); - }); - - it('click cancel', () => { - const { getByText } = container; - const cancelButton = getByText('Cancel'); - expect(cancelButton).not.toBeNull(); - fireEvent.click(cancelButton); - expect(onDismiss).toBeCalled(); - }); - - it('add new url and validate the value', () => { - const { findByText, getByTestId, getByText } = container; - const input0 = getByTestId('knowledgeLocationTextField-0'); - fireEvent.change(input0, { target: { value: 'test' } }); - - expect(input0.value).toBe('test'); - expect(findByText(/A valid url should start with/)).not.toBeNull(); - - const addButton = getByText(/Add additional URL/); - fireEvent.change(input0, { target: { value: 'http://test' } }); - fireEvent.click(addButton); - expect(getByTestId('knowledgeLocationTextField-1')).not.toBeNull(); - - const input1 = getByTestId('knowledgeLocationTextField-1'); - fireEvent.change(input1, { target: { value: 'http://test' } }); - expect(findByText(/This url is duplicated/)).not.toBeNull(); - fireEvent.change(input1, { target: { value: 'http://test1' } }); - - const createKnowledgeButton = getByText('Create knowledge base'); - expect(createKnowledgeButton).not.toBeNull(); - fireEvent.click(createKnowledgeButton); - expect(onSubmit).toBeCalled(); - expect(onSubmit).toBeCalledWith(['http://test', 'http://test1']); - - const deletebutton = getByTestId('deleteImportQnAUrl-1'); - fireEvent.click(deletebutton); - fireEvent.click(createKnowledgeButton); - expect(onSubmit).toBeCalled(); - expect(onSubmit).toBeCalledWith(['http://test']); - }); -}); diff --git a/Composer/packages/client/src/pages/knowledge-base/code-editor.tsx b/Composer/packages/client/src/pages/knowledge-base/code-editor.tsx index bd246f8397..5abad40aa8 100644 --- a/Composer/packages/client/src/pages/knowledge-base/code-editor.tsx +++ b/Composer/packages/client/src/pages/knowledge-base/code-editor.tsx @@ -2,20 +2,23 @@ // Licensed under the MIT License. /* eslint-disable react/display-name */ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, Fragment } from 'react'; import { useRecoilValue } from 'recoil'; -import { EditorDidMount, defaultQnAPlaceholder } from '@bfc/code-editor'; +import { EditorDidMount, defaultQnAPlaceholder, QnAEditor } from '@bfc/code-editor'; import isEmpty from 'lodash/isEmpty'; import { RouteComponentProps } from '@reach/router'; import querystring from 'query-string'; import debounce from 'lodash/debounce'; import get from 'lodash/get'; import { CodeEditorSettings } from '@bfc/shared'; -import { QnAEditor } from '@bfc/code-editor'; +import { ActionButton } from 'office-ui-fabric-react/lib/Button'; + +import { dispatcherState, userSettingsState, qnaFilesState } from '../../recoilModel'; +import { navigateTo } from '../../utils/navigation'; +import { getBaseName } from '../../utils/fileUtil'; + +import { backIcon } from './styles'; -import { qnaFilesState } from '../../recoilModel/atoms/botState'; -import { dispatcherState } from '../../recoilModel'; -import { userSettingsState } from '../../recoilModel'; interface CodeEditorProps extends RouteComponentProps<{}> { dialogId: string; projectId: string; @@ -31,13 +34,20 @@ const CodeEditor: React.FC = (props) => { //const locale = useRecoilValue(localeState); const userSettings = useRecoilValue(userSettingsState); - const file = qnaFiles.find(({ id }) => id === `${dialogId}.${locale}`); + const search = props.location?.search ?? ''; + const searchContainerId = querystring.parse(search).C; + const searchContainerName = + searchContainerId && typeof searchContainerId === 'string' && getBaseName(searchContainerId); + const targetFileId = + searchContainerId && typeof searchContainerId === 'string' ? searchContainerId : `${dialogId}.${locale}`; + const file = qnaFiles.find(({ id }) => id === targetFileId); const hash = props.location?.hash ?? ''; const hashLine = querystring.parse(hash).L; const line = Array.isArray(hashLine) ? +hashLine[0] : typeof hashLine === 'string' ? +hashLine : 0; const [content, setContent] = useState(file?.content); const currentDiagnostics = get(file, 'diagnostics', []); const [qnaEditor, setQnAEditor] = useState(null); + useEffect(() => { if (qnaEditor) { window.requestAnimationFrame(() => { @@ -66,24 +76,39 @@ const CodeEditor: React.FC = (props) => { const onChangeContent = useMemo( () => debounce((newContent: string) => { - actions.updateQnAFile({ id: `${dialogId}.${locale}`, content: newContent, projectId }); + actions.updateQnAFile({ id: targetFileId, content: newContent, projectId }); }, 500), [projectId] ); return ( - + + {searchContainerName && ( + { + navigateTo(`/bot/${projectId}/knowledge-base/${dialogId}`); + }} + > + {searchContainerName} + + )} + + ); }; diff --git a/Composer/packages/client/src/pages/knowledge-base/styles.ts b/Composer/packages/client/src/pages/knowledge-base/styles.ts index e85f004e35..f6b0b92840 100644 --- a/Composer/packages/client/src/pages/knowledge-base/styles.ts +++ b/Composer/packages/client/src/pages/knowledge-base/styles.ts @@ -1,9 +1,33 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { css } from '@emotion/core'; -import { FontWeights } from '@uifabric/styling'; -import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; -import { IIconStyles } from 'office-ui-fabric-react/lib/Icon'; +import { FontWeights, mergeStyleSets } from '@uifabric/styling'; +import { NeutralColors, SharedColors, FontSizes } from '@uifabric/fluent-theme'; +import { IButtonStyles } from 'office-ui-fabric-react/lib/Button'; + +export const classNames = mergeStyleSets({ + groupHeader: { + display: 'flex', + fontSize: FontSizes.size16, + fontWeight: FontWeights.regular, + alignItems: 'center', + }, + emptyTableList: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }, + emptyTableListCenter: { + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + width: '50%', + textAlign: 'center', + marginTop: '-20%', + }, +}); + export const content = css` min-height: 28px; outline: none; @@ -25,6 +49,7 @@ export const formCell = css` white-space: pre-wrap; font-size: 14px; line-height: 28px; + height: 100%; `; export const inlineContainer = (isBold) => css` @@ -56,41 +81,6 @@ export const textFieldAnswer = { }, }; -export const link = { - root: { - fontSize: 14, - lineHeight: 28, - }, -}; - -export const addQnAPairLink = { - root: { - fontSize: 14, - lineHeight: 28, - marginLeft: 72, - }, -}; - -export const actionButton = css` - font-size: 16px; - margin: 0; - margin-left: 15px; -`; - -export const QnAIconStyle = { - root: { - padding: '8px', - boxSizing: 'border-box', - width: '40px', - height: '32px', - }, -} as IIconStyles; - -export const firstLine = css` - display: flex; - flex-direction: row; -`; - export const divider = css` height: 1px; background: ${NeutralColors.gray30}; @@ -98,22 +88,41 @@ export const divider = css` export const rowDetails = { root: { - minHeight: 76, + minHeight: 40, + width: '100%', selectors: { + '.ms-GroupHeader-expand': { + fontSize: 8, + }, '&:hover': { background: NeutralColors.gray30, + selectors: { + '.ms-TextField-fieldGroup': { + background: NeutralColors.gray30, + }, + '.ms-Button--icon': { + visibility: 'visible', + }, + '.ms-Button': { + display: 'block', + }, + }, }, - '&:hover .ms-Button--icon': { - visibility: 'visible', - }, - '&.is-selected .ms-Button--icon': { - visibility: 'visible', - }, - '&:hover .ms-Button': { - visibility: 'visible', + '&.is-selected': { + selectors: { + '.ms-Button--icon': { + visibility: 'visible', + }, + '.ms-Button': { + visibility: 'visible', + }, + '.ms-TextField-fieldGroup': { + background: NeutralColors.gray30, + }, + }, }, - '&.is-selected .ms-Button': { - visibility: 'visible', + '&.is-selected:hover': { + background: NeutralColors.gray30, }, }, }, @@ -126,33 +135,102 @@ export const icon = { }, }; -export const addButtonContainer = css` - z-index: 1; - background: ${NeutralColors.white}; -`; - export const addAlternative = { root: { - fontSize: 16, + fontSize: 12, paddingLeft: 0, marginLeft: -5, color: SharedColors.cyanBlue10, - visibility: 'hidden', + display: 'none', }, -}; +} as IButtonStyles; export const addQnAPair = { root: { - fontSize: 16, + fontSize: 12, paddingLeft: 0, - marginLeft: 68, + marginLeft: 57, + marginTop: -10, color: SharedColors.cyanBlue10, }, }; export const addIcon = { root: { - fontSize: '16px', + fontSize: FontSizes.size10, + margin: 0, color: SharedColors.cyanBlue10, }, }; + +export const backIcon = { + root: { + fontSize: FontSizes.size16, + color: NeutralColors.black, + marginTop: -10, + marginBottom: 10, + }, + icon: { + fontSize: FontSizes.size12, + marginTop: 2, + color: NeutralColors.black, + }, +}; + +export const editableField = { + root: { + height: '100%', + selectors: { + '.ms-TextField-wrapper': { + height: '100%', + }, + }, + }, + fieldGroup: { + height: '100%', + border: '0', + selectors: { + '&.ms-TextField-fieldGroup': { + selectors: { + '::after': { + border: 'none !important', + }, + }, + }, + }, + }, + field: { + overflowY: 'auto' as 'auto', + fontSize: FontSizes.size12, + maxHeight: 500, + selectors: { + '::placeholder': { + fontSize: FontSizes.size12, + }, + }, + }, +}; + +export const groupHeader = { + root: { + selectors: { + '.ms-GroupHeader-expand': { + fontSize: 8, + marginLeft: 16, + }, + }, + }, +}; + +export const groupNameStyle = css` + margin-top: -5px; + margin-left: 8px; + font-size: ${FontSizes.size16}; + font-weight: ${FontWeights.semibold}; +`; + +export const detailsHeaderStyle = css` + .ms-TooltipHost { + background: ${NeutralColors.white}; + } +`; diff --git a/Composer/packages/client/src/pages/knowledge-base/table-view.tsx b/Composer/packages/client/src/pages/knowledge-base/table-view.tsx index 33776ce19f..fa124aa594 100644 --- a/Composer/packages/client/src/pages/knowledge-base/table-view.tsx +++ b/Composer/packages/client/src/pages/knowledge-base/table-view.tsx @@ -4,303 +4,422 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; import { useRecoilValue } from 'recoil'; -import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useCallback, Fragment, useRef } from 'react'; import { DetailsList, DetailsRow, DetailsListLayoutMode, SelectionMode, CheckboxVisibility, + IDetailsGroupRenderProps, + IGroup, + IDetailsList, } from 'office-ui-fabric-react/lib/DetailsList'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { GroupHeader, CollapseAllVisibility } from 'office-ui-fabric-react/lib/GroupedList'; +import { IOverflowSetItemProps, OverflowSet } from 'office-ui-fabric-react/lib/OverflowSet'; +import { Link } from 'office-ui-fabric-react/lib/Link'; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; -import { IconButton, ActionButton } from 'office-ui-fabric-react/lib/Button'; +import { IconButton, ActionButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; import { ScrollablePane, ScrollbarVisibility } from 'office-ui-fabric-react/lib/ScrollablePane'; import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky'; import formatMessage from 'format-message'; import { RouteComponentProps } from '@reach/router'; -import get from 'lodash/get'; - -import { - addQuestion, - updateQuestion, - updateAnswer as updateAnswerUtil, - generateQnAPair, - insertSection, - removeSection, -} from '../../utils/qnaUtil'; -import { dialogsState, qnaFilesState } from '../../recoilModel/atoms/botState'; +import isEqual from 'lodash/isEqual'; +import isEmpty from 'lodash/isEmpty'; +import { QnASection, QnAFile } from '@bfc/shared'; +import { qnaUtil } from '@bfc/indexers'; +import { NeutralColors } from '@uifabric/fluent-theme'; + +import emptyQnAIcon from '../../images/emptyQnAIcon.svg'; +import { navigateTo } from '../../utils/navigation'; +import { dialogsState, qnaFilesState, localeState } from '../../recoilModel/atoms/botState'; import { dispatcherState } from '../../recoilModel'; +import { getBaseName } from '../../utils/fileUtil'; +import { EditableField } from '../../components/EditableField'; +import { EditQnAModal } from '../../components/QnA/EditQnAFrom'; +import { getQnAFileUrlOption } from '../../utils/qnaUtil'; import { formCell, - content, - textFieldQuestion, - textFieldAnswer, - contentAnswer, - addIcon, + addQnAPair, divider, rowDetails, icon, - addButtonContainer, addAlternative, - inlineContainer, - addQnAPair, + editableField, + groupHeader, + groupNameStyle, + detailsHeaderStyle, + classNames, } from './styles'; +interface QnASectionItem extends QnASection { + fileId: string; + dialogId: string | undefined; + used: boolean; + usedIn: { id: string; displayName: string }[]; +} + +const createQnASectionItem = (fileId: string): QnASectionItem => { + return { + fileId, + dialogId: '', + used: false, + usedIn: [], + sectionId: '', + Questions: [], + Answer: '', + Body: qnaUtil.generateQnAPair(), + }; +}; + interface TableViewProps extends RouteComponentProps<{}> { dialogId: string; projectId: string; } -enum EditMode { - None, - Creating, - Updating, -} - const TableView: React.FC = (props) => { const { dialogId = '', projectId = '' } = props; const actions = useRecoilValue(dispatcherState); const dialogs = useRecoilValue(dialogsState(projectId)); const qnaFiles = useRecoilValue(qnaFilesState(projectId)); - //To do: support other languages - const locale = 'en-us'; - - const file = qnaFiles.find(({ id }) => id === `${dialogId}.${locale}`); - const fileRef = useRef(file); - fileRef.current = file; - const dialogIdRef = useRef(dialogId); - dialogIdRef.current = dialogId; - const localeRef = useRef(locale); - localeRef.current = locale; - const limitedNumber = useRef(1).current; - const generateQnASections = (file) => { - return get(file, 'qnaSections', []).map((qnaSection, index) => { + const locale = useRecoilValue(localeState(projectId)); + const { + removeQnAImport, + removeQnAFile, + createQnAPairs, + removeQnAPairs, + createQnAQuestion, + updateQnAAnswer, + updateQnAQuestion, + } = useRecoilValue(dispatcherState); + + const targetFileId = dialogId.endsWith('.source') ? dialogId : `${dialogId}.${locale}`; + const qnaFile = qnaFiles.find(({ id }) => id === targetFileId); + const generateQnASections = (file: QnAFile): QnASectionItem[] => { + if (!file) return []; + const usedInDialog: any[] = []; + dialogs.forEach((dialog) => { + const dialogQnAFile = + qnaFiles.find(({ id }) => id === dialog.qnaFile) || + qnaFiles.find(({ id }) => id === `${dialog.qnaFile}.${locale}`); + if (dialogQnAFile) { + dialogQnAFile.imports.forEach(({ id }) => { + if (id === `${file.id}.qna`) { + usedInDialog.push({ id: dialog.id, displayName: dialog.displayName }); + } + }); + } + }); + + return file.qnaSections.map((qnaSection) => { const qnaDialog = dialogs.find((dialog) => file.id === `${dialog.id}.${locale}`); return { - fileId: file.fileId, - dialogId: qnaDialog?.id || '', - used: !!qnaDialog && qnaDialog, - indexId: index, - key: qnaSection.Body, + fileId: file.id, + dialogId: qnaDialog?.id, + used: !!qnaDialog, + usedIn: usedInDialog, + key: qnaSection.sectionId, ...qnaSection, }; }); }; - const allQnASections = qnaFiles.reduce((result: any[], qnaFile) => { - const res = generateQnASections(qnaFile); - return result.concat(res); - }, []); - const singleFileQnASections = generateQnASections(fileRef.current); - const qnaSections = useMemo(() => { + const detailListRef = useRef(null); + const [editQnAFile, setEditQnAFile] = useState(undefined); + const [expandedIndex, setExpandedIndex] = useState(-1); + const [kthSectionIsCreatingQuestion, setCreatingQuestionInKthSection] = useState(''); + const [creatQnAPairSettings, setCreatQnAPairSettings] = useState<{ + groupKey: string; + sectionIndex: number; + item?: { Qustion: string; Answer: string }; + }>({ + groupKey: '-1', + sectionIndex: -1, + }); + const currentDialogImportedFileIds = qnaFile?.imports.map(({ id }) => getBaseName(id)) || []; + const currentDialogImportedFiles = qnaFiles.filter(({ id }) => currentDialogImportedFileIds.includes(id)); + const currentDialogImportedSourceFiles = currentDialogImportedFiles.filter(({ id }) => id.endsWith('.source')); + const allSourceFiles = qnaFiles.filter(({ id }) => id.endsWith('.source')); + + const initializeQnASections = (qnaFiles, dialogId) => { + if (isEmpty(qnaFiles)) return; + + const allSections = qnaFiles + .filter(({ id }) => id.endsWith('.source')) + .reduce((result: any[], qnaFile) => { + const res = generateQnASections(qnaFile); + return result.concat(res); + }, []); if (dialogId === 'all') { - return allQnASections; + return allSections; } else { - return singleFileQnASections; - } - }, [dialogIdRef.current, qnaFiles]); - const [showQnAPairDetails, setShowQnAPairDetails] = useState(Array(qnaSections.length).fill(false)); - const [qnaSectionIndex, setQnASectionIndex] = useState(-1); - const [questionIndex, setQuestionIndex] = useState(-1); //used in QnASection.Questions array - const [question, setQuestion] = useState(''); - const [editMode, setEditMode] = useState(EditMode.None); - const [isUpdatingAnswer, setIsUpdatingAnswer] = useState(false); - const [answer, setAnswer] = useState(''); - const createOrUpdateQuestion = () => { - if (editMode === EditMode.Creating && question) { - const updatedQnAFileContent = addQuestion(question, qnaSections, qnaSectionIndex); - actions.updateQnAFile({ - id: `${dialogIdRef.current}.${localeRef.current}`, - content: updatedQnAFileContent, - projectId, - }); - } - if (editMode === EditMode.Updating && qnaSections[qnaSectionIndex].Questions[questionIndex].content !== question) { - const updatedQnAFileContent = updateQuestion(question, questionIndex, qnaSections, qnaSectionIndex); - actions.updateQnAFile({ - id: `${dialogIdRef.current}.${localeRef.current}`, - content: updatedQnAFileContent, - projectId, - }); + const dialogSections = allSections.filter((t) => currentDialogImportedFileIds.includes(t.fileId)); + return dialogSections; } - cancelQuestionEditOperation(); }; - const updateAnswer = () => { - if (editMode === EditMode.Updating && qnaSections[qnaSectionIndex].Answer !== answer) { - const updatedQnAFileContent = updateAnswerUtil(answer, qnaSections, qnaSectionIndex); - actions.updateQnAFile({ - id: `${dialogIdRef.current}.${localeRef.current}`, - content: updatedQnAFileContent, - projectId, - }); - } - cancelAnswerEditOperation(); - }; - - const cancelQuestionEditOperation = () => { - setEditMode(EditMode.None); - setQuestion(''); - setQuestionIndex(-1); - setQnASectionIndex(-1); - }; - - const cancelAnswerEditOperation = () => { - setEditMode(EditMode.None); - setAnswer(''); - setIsUpdatingAnswer(false); - setQnASectionIndex(-1); - }; + const [qnaSections, setQnASections] = useState(initializeQnASections(qnaFiles, dialogId)); useEffect(() => { - setShowQnAPairDetails(Array(qnaSections.length).fill(false)); - }, [dialogId, projectId]); - - const toggleShowAll = (index: number) => { - const newArray = showQnAPairDetails.map((element, i) => { - if (i === index) { - return !element; - } else { - return element; - } + if (isEmpty(qnaFiles)) return; + + const allSections = qnaFiles + .filter(({ id }) => id.endsWith('.source')) + .reduce((result: any[], qnaFile) => { + const res = generateQnASections(qnaFile); + return result.concat(res); + }, []); + if (dialogId === 'all') { + setQnASections(allSections); + } else { + const dialogSections = allSections.filter((t) => currentDialogImportedFileIds.includes(t.fileId)); + + setQnASections(dialogSections); + } + }, [qnaFiles, dialogId, projectId]); + + const onUpdateQnAQuestion = (fileId: string, sectionId: string, questionId: string, content: string) => { + if (!fileId) return; + actions.setMessage('item updated'); + updateQnAQuestion({ + id: fileId, + sectionId, + questionId, + content, + projectId, }); - setShowQnAPairDetails(newArray); }; - const expandDetails = (index: number) => { - const newArray = showQnAPairDetails.map((element, i) => { - if (i === index) { - return true; - } else { - return element; - } + const onUpdateQnAAnswer = (fileId: string, sectionId: string, content: string) => { + if (!fileId) return; + actions.setMessage('item updated'); + updateQnAAnswer({ + id: fileId, + sectionId, + content, + projectId, }); - setShowQnAPairDetails(newArray); }; - const handleQuestionKeydown = (e) => { - if (e.key === 'Enter') { - createOrUpdateQuestion(); - setEditMode(EditMode.None); - e.preventDefault(); - } - if (e.key === 'Escape') { - cancelQuestionEditOperation(); - e.preventDefault(); + const onRemoveQnAPairs = (fileId: string, sectionId: string) => { + if (!fileId) return; + actions.setMessage('item deleted'); + const sectionIndex = qnaSections.findIndex((item) => item.fileId === fileId); + removeQnAPairs({ + id: fileId, + sectionId, + projectId, + }); + // update expand status + if (expandedIndex) { + if (sectionIndex < expandedIndex) { + setExpandedIndex(expandedIndex - 1); + } else if (sectionIndex === expandedIndex) { + setExpandedIndex(-1); + } } }; - const handleQuestionOnBlur = (e) => { - createOrUpdateQuestion(); - e.preventDefault(); + const onCreateNewQnAPairsEnd = (fileId, updatedItem) => { + const { Question, Answer } = updatedItem; + if (!Question || !Answer) return; + const createdQnAPair = qnaUtil.generateQnAPair(Question, Answer); + setCreatQnAPairSettings({ groupKey: '', sectionIndex: -1 }); + createQnAPairs({ id: fileId, content: createdQnAPair, projectId }); }; - const handleAddingAlternatives = (e, index: number) => { - e.preventDefault(); - e.stopPropagation(); - setEditMode(EditMode.Creating); - setQnASectionIndex(index); - setQuestionIndex(-1); - expandDetails(index); - }; - - const handleUpdateingAlternatives = (e, qnaSectionIndex: number, questionIndex: number, question: string) => { - e.preventDefault(); - e.stopPropagation(); - setEditMode(EditMode.Updating); - setQuestion(question); - setQnASectionIndex(qnaSectionIndex); - setQuestionIndex(questionIndex); - expandDetails(qnaSectionIndex); - }; - - const handleQuestionOnChange = (newValue, index: number) => { - if (index !== qnaSectionIndex) return; - setQuestion(newValue); - }; - - const handleAnswerKeydown = (e) => { - if (e.key === 'Escape') { - setEditMode(EditMode.None); - setQnASectionIndex(-1); - setIsUpdatingAnswer(false); - e.preventDefault(); + const onCreateNewQnAPairsStart = (fileId: string | undefined) => { + if (!fileId) return; + const groupStartIndex = qnaSections.findIndex((item) => item.fileId === fileId); + // create on empty KB. + let insertPosition = groupStartIndex; + if (groupStartIndex === -1) { + insertPosition = 0; } + const newGroups = getGroups(fileId); + setGroups(newGroups); + const newItem = createQnASectionItem(fileId); + const newQnaSections = [...qnaSections]; + newQnaSections.splice(insertPosition, 0, newItem); + setQnASections(newQnaSections); + setExpandedIndex(insertPosition); + setCreatQnAPairSettings({ groupKey: fileId, sectionIndex: insertPosition, item: { Answer: '', Qustion: '' } }); }; - const handleAnswerOnBlur = (e) => { - updateAnswer(); - setEditMode(EditMode.None); - setQnASectionIndex(-1); - setQuestionIndex(-1); - e.preventDefault(); + const onCreateNewQuestion = (fileId, sectionId, content?: string) => { + if (!fileId || !sectionId) return; + const payload = { + id: fileId, + sectionId, + content: content || 'Add new question', + projectId, + }; + createQnAQuestion(payload); }; - const handleUpdateingAnswer = (e, qnaSectionIndex: number, answer: string) => { - e.preventDefault(); - e.stopPropagation(); - setEditMode(EditMode.Updating); - setAnswer(answer); - setQnASectionIndex(qnaSectionIndex); - setIsUpdatingAnswer(true); - expandDetails(qnaSectionIndex); + const onSubmitEditKB = async ({ name }: { name: string }) => { + if (!editQnAFile) return; + const newId = `${name}.source`; + await actions.renameQnAKB({ id: editQnAFile.id, name: newId, projectId }); + if (!qnaFile) return; + await actions.updateQnAImport({ id: qnaFile.id, sourceId: editQnAFile.id, newSourceId: newId, projectId }); + setEditQnAFile(undefined); }; - const handleAnswerOnChange = (answer, index: number) => { - if (index !== qnaSectionIndex) return; - setAnswer(answer); - }; + const onRenderGroupHeader: IDetailsGroupRenderProps['onRenderHeader'] = useCallback( + (props) => { + const groupName = props?.group?.name || ''; + const containerId = props?.group?.key || ''; + const containerQnAFile = qnaFiles.find(({ id }) => id === containerId); + const isImportedSource = containerId.endsWith('.source'); + const sourceUrl = isImportedSource && containerQnAFile && getQnAFileUrlOption(containerQnAFile); + const isAllTab = dialogId === 'all'; + const isCreatingQnA = creatQnAPairSettings.groupKey === containerId && creatQnAPairSettings.sectionIndex > -1; + const onRenderItem = (item: IOverflowSetItemProps): JSX.Element => { + return ( + + ); + }; - const deleteQnASection = (qnaSectionIndex: number) => { - actions.setMessage('item deleted'); - if (fileRef && fileRef.current) { - const updatedQnAFileContent = removeSection(qnaSectionIndex, fileRef.current.content); - actions.updateQnAFile({ - id: `${dialogIdRef.current}.${localeRef.current}`, - content: updatedQnAFileContent, - projectId, - }); - } - const newArray = [...showQnAPairDetails]; - newArray.splice(qnaSectionIndex, 1); - setShowQnAPairDetails(newArray); - }; + const onRenderOverflowButton = (overflowItems: any[] | undefined): JSX.Element => { + return ( +