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 (?.*))?"
},
{
-
+
"$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 (?.*))?"
+ "pattern": "(?i)(?:delete|remove|clear) .*(?:to-do|todo|task)(?: )?(?:named (?.*))?"
},
{
-
+
"$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) => void;
onBlur?: (event: React.FocusEvent) => void;
schema?: JSONSchema7;
+ data?: any;
}
-const VisualDesigner: React.FC = ({ onFocus, onBlur, schema }): JSX.Element => {
+const VisualDesigner: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ onFocus, onBlur, schema
}, {} as FlowUISchema);
const divRef = useRef(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 = ({ onFocus, onBlur, schema
{...enableKeyboardCommandAttributes(handleCommand)}
data-testid="visualdesigner-container"
>
-
-
- {
- e.stopPropagation();
- handleEditorEvent(NodeEventTypes.Focus, { id: '' });
- }}
- >
-
+
+
+ {
- divRef.current?.focus({ preventScroll: true });
- handleEditorEvent(eventName, eventData);
+ data-testid="flow-editor-container"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleEditorEvent(NodeEventTypes.Focus, { id: '' });
}}
- />
-
-
-
+ >
+ {
+ divRef.current?.focus({ preventScroll: true });
+ handleEditorEvent(eventName, eventData);
+ }}
+ />
+
+
+
+
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 = ({ flowZoomRate, focusedId, updateFlowZoomRate, children }) => {
+ const divRef = useRef(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 (
+
+
handleZoom(-100)}
+ >
+
handleZoom(100)}
+ >
+
{
+ handleZoom(0);
+ container.scrollTo({ top: 0 });
+ }}
+ >
+
+
+
+
+
+ );
+ };
+
+ // Using ref and eventListener instead of
because passive property can not be set in
+ useEffect(() => {
+ if (flowZoomRate) {
+ divRef.current?.addEventListener('wheel', onWheel, { passive: false });
+ }
+ return () => divRef.current?.removeEventListener('wheel', onWheel);
+ }, [flowZoomRate]);
+
+ return (
+
+ {children}
+ {buttonRender()}
+
+ );
+};
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 = function AdaptiveForm(p
= ({ children, defaultExpanded, title }) => {
+export const CollapseField: React.FC = ({ children, description, defaultExpanded, title }) => {
const [isOpen, setIsOpen] = useState(!!defaultExpanded);
return (
@@ -45,11 +49,15 @@ export const CollapseField: React.FC = ({ children, defaultExpand
>
{title && {title} }
+ {description && - {description} }
-
{children}
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> = (props) => {
return (
- {fieldsets.map(({ schema, uiOptions, title, defaultExpanded }, key) => (
+ {fieldsets.map(({ schema, uiOptions, description, title, defaultExpanded }, key) => (
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> = (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> = (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 } = {
tabs: {
root: {
@@ -19,6 +17,9 @@ const styles: { tabs: Partial } = {
link: {
flex: 1,
},
+ itemContainer: {
+ paddingTop: '8px',
+ },
linkIsSelected: {
flex: 1,
},
@@ -39,11 +40,15 @@ const PivotFieldsets: React.FC> = (props) => {
return (
- {fieldsets.map(({ schema, uiOptions, title, itemKey }) => (
-
-
-
- ))}
+ {fieldsets.map(({ schema, uiOptions, title, itemKey }) => {
+ const Field = resolveFieldWidget(schema, uiOptions);
+
+ return (
+
+
+
+ );
+ })}
);
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 = ({ 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[]): fields is Fieldset[] => {
+ 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[], ({ 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(' ', () => {
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(' ', () => {
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(' ', () => {
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(' ', () => {
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(' ', () => {
+ const onDismiss = jest.fn(() => {});
+ const onSubmit = jest.fn(() => {});
+ const projectId = 'test-create-qna';
+
+ it('renders and create from scratch', () => {
+ const container = renderWithRecoil(
+ ,
+ ({ 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(
+ ,
+ () => {}
+ );
+
+ 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(' ', () => {
const { findByText } = renderWithRecoil(
', () => {
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(' ', () => {
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(
,
initRecoilState
);
getByTestId('table-view');
- getByText('question (1)');
- getByText('answer');
+ getByText('Question');
});
it('should render QnA page code editor', () => {
renderWithRecoil( , initRecoilState);
});
+
+ it('should render QnA page', () => {
+ const { getByTestId } = renderWithRecoil( , 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( , initRecoilState);
});
+
+ it('should render lg page', () => {
+ const { getByTestId } = renderWithRecoil( , 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( , initRecoilState);
});
+
+ it('should render lu page', () => {
+ renderWithRecoil( , 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({ui} ),
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__
};
} ?>
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 (
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 = () => {
const {
fetchTemplates,
- openProject,
- createProject,
- saveProjectAs,
fetchStorages,
fetchFolderItemsByPath,
setCreationFlowStatus,
@@ -42,9 +41,13 @@ const CreationFlow: React.FC = () => {
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 = () => {
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 = () => {
}
}, [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 = () => {
};
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 = () => {
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
};
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;
+ iconName: string;
+ onClick?: () => void;
+};
interface EditableFieldProps extends Omit {
+ expanded?: boolean;
+ componentFocusOnmount?: boolean;
fontSize?: string;
styles?: Partial;
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 = (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 = (props) => {
className,
transparentBorder,
ariaLabel,
+ enableIcon = false,
} = props;
const [editing, setEditing] = useState(false);
const [hasFocus, setHasFocus] = useState(false);
- const [localValue, setLocalValue] = useState(value);
const [hasBeenEdited, setHasBeenEdited] = useState(false);
+ const [multiline, setMultiline] = useState(true);
+ const formConfig: FieldConfig<{ value: string }> = {
+ value: {
+ required: required,
+ defaultValue: value,
+ },
+ };
+ const { formData, updateField, hasErrors, formErrors } = useForm(formConfig);
+
+ const fieldRef = useRef(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 = (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 (
- setEditing(true)}
- onMouseLeave={() => !hasFocus && setEditing(false)}
- >
-
+
+
+ }
+ 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 && (
+
- }
- value={localValue}
- onBlur={handleCommit}
- onChange={handleChange}
- onFocus={() => setHasFocus(true)}
- />
-
+ }}
+ onClick={iconProps?.onClick || resetValue}
+ />
+ )}
+
+ {hasErrors && hasBeenEdited && (
+ {requiredMessage || formErrors.value}
+ )}
+ {error && {error} }
+
);
};
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 = (props) => {
const { navLinks, regionName } = props;
+ const onRenderOverflowButton = (isSelected: boolean, item) => (
+ menuItems: IOverflowSetItemProps[] | undefined
+ ): JSX.Element => {
+ const buttonStyles: Partial = {
+ root: {
+ minWidth: 0,
+ padding: '0 4px',
+ alignSelf: 'stretch',
+ height: 'auto',
+ background: isSelected ? NeutralColors.gray20 : NeutralColors.white,
+ selectors: {
+ '.ms-Icon': {
+ visibility: isSelected ? 'inherit' : 'hidden',
+ },
+ },
+ },
+ };
+ return (
+
+ );
+ };
+
return (
{navLinks.map((item) => {
const isSelected = location.pathname.includes(item.url);
return (
-
{
- e.preventDefault();
- navigateTo(item.url);
- }}
- />
+
+ {
+ e.preventDefault();
+ navigateTo(item.url);
+ }}
+ />
+ {item.menuItems && !item.disabled && (
+ undefined}
+ onRenderOverflowButton={onRenderOverflowButton(isSelected, item)}
+ />
+ )}
+
);
})}
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 = (props) => {
const targetRef = useRef(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 ;
+ 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 ;
};
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 = (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 = (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 = (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 = (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 = (props) => {
+ const { projectId } = props;
+ const showCreateQnAFromScratchDialog = useRecoilValue(showCreateQnAFromScratchDialogState(projectId));
+ const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId));
+
+ if (showCreateQnAFromScratchDialog) {
+ return ;
+ } else if (showCreateQnAFromUrlDialog) {
+ return ;
+ } 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 = {
+ name: {
+ required: true,
+ defaultValue: '',
+ },
+};
+
+const DialogTitle = () => {
+ return (
+
+ {formatMessage('Create new knowledge base from scratch')}
+
+ {formatMessage('Manually add question and answer pairs to create a KB')}
+
+
+ );
+};
+
+export const CreateQnAFromScratchModal: React.FC = (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 (
+ ,
+ styles: styles.dialog,
+ }}
+ hidden={false}
+ modalProps={{
+ isBlocking: false,
+ styles: styles.modal,
+ }}
+ onDismiss={onDismiss}
+ >
+
+
+ updateField('name', name)}
+ />
+
+
+
+ {showCreateQnAFromUrlDialog && (
+ {
+ actions.createQnAFromScratchDialogCancel({ projectId });
+ }}
+ />
+ )}
+ {
+ actions.createQnAFromScratchDialogCancel({ projectId });
+ onDismiss && onDismiss();
+ }}
+ />
+ {
+ if (hasErrors) {
+ return;
+ }
+ onSubmit(formData);
+ }}
+ />
+
+
+ );
+};
+
+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 = {
+ url: {
+ required: true,
+ defaultValue: '',
+ validate: validateUrl,
+ },
+ name: {
+ required: true,
+ defaultValue: '',
+ },
+ multiTurn: {
+ required: false,
+ defaultValue: false,
+ },
+};
+
+const DialogTitle = () => {
+ return (
+
+ {formatMessage('Create new 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. ')}
+
+
+
+
+ );
+};
+
+export const CreateQnAFromUrlModal: React.FC = (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 (
+ ,
+ styles: styles.dialog,
+ }}
+ hidden={false}
+ modalProps={{
+ isBlocking: false,
+ styles: styles.modal,
+ }}
+ onDismiss={onDismiss}
+ >
+
+
+ updateField('name', name)}
+ />
+
+
+ updateField('url', url)}
+ />
+
+ {!isQnAFileselected && (
+ {formatMessage('Please select a specific qna file to import QnA')}
+ )}
+
+
+ updateField('multiTurn', val)}
+ />
+
+
+
+ {showWithScratch && (
+ {
+ // switch to create from scratch flow, pass onComplete callback.
+ actions.createQnAFromScratchDialogBegin({ projectId, onComplete: onComplete?.func });
+ }}
+ />
+ )}
+ {
+ actions.createQnAFromUrlDialogCancel({ projectId });
+ onDismiss && onDismiss();
+ }}
+ />
+ {
+ if (hasErrors) {
+ return;
+ }
+ onSubmit(formData);
+ }}
+ />
+
+
+ );
+};
+
+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 = (props) => {
+ if (isQnAFileCreatedFromUrl(props.qnaFile)) {
+ return ;
+ } else {
+ return ;
+ }
+};
+
+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 = {
+ name: {
+ required: true,
+ defaultValue: '',
+ },
+};
+
+const DialogTitle = () => {
+ return {formatMessage('Edit KB name')}
;
+};
+
+export const EditQnAFromScratchModal: React.FC = (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 (
+ ,
+ styles: styles.dialog,
+ }}
+ hidden={false}
+ modalProps={{
+ isBlocking: false,
+ styles: styles.modal,
+ }}
+ onDismiss={onDismiss}
+ >
+
+
+ updateName(name)}
+ />
+
+
+
+
+ {
+ if (hasErrors) {
+ return;
+ }
+ onSubmit(formData);
+ }}
+ />
+
+
+ );
+};
+
+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 = {
+ name: {
+ required: true,
+ defaultValue: '',
+ },
+ url: {
+ required: true,
+ defaultValue: '',
+ },
+};
+
+const DialogTitle = () => {
+ return {formatMessage('Edit KB name')}
;
+};
+
+export const EditQnAFromUrlModal: React.FC = (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 (
+ ,
+ styles: styles.dialog,
+ }}
+ hidden={false}
+ modalProps={{
+ isBlocking: false,
+ styles: styles.modal,
+ }}
+ onDismiss={onDismiss}
+ >
+
+
+ updateName(name)}
+ />
+
+
+ updateUrl(url)}
+ />
+
+
+
+
+ {
+ if (hasErrors) {
+ return;
+ }
+ onSubmit(formData);
+ }}
+ />
+
+
+ );
+};
+
+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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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(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 {
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 {
- setImportQnAModalVisibility(true);
- };
-
const onTriggerCreationDismiss = () => {
setTriggerModalVisibility(false);
};
@@ -249,6 +259,7 @@ const DesignPage: React.FC {
- openImportQnAModal();
+ createQnAFromUrlDialogBegin({
+ projectId,
+ showFromScratch: true,
+ });
},
},
],
@@ -546,33 +560,15 @@ const DesignPage: React.FC {
- 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
)}
- {importQnAModalVisibility && (
-
- )}
+ )
{displaySkillManifest && (
{
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 = (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 = (props) => {
css={visualEditor(triggerButtonVisible || !selected)}
data-testid="VisualEditor"
>
-
+
{!selected && onRenderBlankVisual(triggerButtonVisible, openNewTriggerModal)}
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 = (props) => {
};
export const Description: React.FC = ({ 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 = ({ 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 = (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) => {
+ const { isOpen, projectId, onSubmit, onDismiss } = props;
+
+ const dialogs = useRecoilValue(dialogsState(projectId));
+
+ const formConfig: FieldConfig = {
+ 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 (
+
+
+
+ );
+};
+
+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 = 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 (
+ <>
+
+
+
+ {validSchemaId ? (
+
+ ) : (
+
+
+ {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')}
+
+
+ )}
+
+
+ {createSchemaDialogOpen ? (
+ 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()({
+ 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) => {
+ e.stopPropagation();
+
+ onDelete(schemaId);
+ },
+ [schemaId, onDelete]
+ );
+
+ const generateDialog = React.useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+
+ onGenerate(schemaId);
+ },
+ [schemaId, onGenerate]
+ );
+
+ const viewDialog = React.useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+
+ onViewDialog(schemaId);
+ },
+ [schemaId, onViewDialog]
+ );
+
+ const renderOverflowItem = React.useCallback(
+ (item: IOverflowSetItemProps) => ,
+ []
+ );
+
+ const renderOverflowButton = React.useCallback(
+ (overflowItems?: IOverflowSetItemProps[]) => (
+
+
+
+ ),
+ []
+ );
+
+ return (
+
+
+ {schemaId}
+
+
+
+ );
+});
+
+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 = 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, newValue?: string): void => {
+ if (typeof newValue === 'string') {
+ delayedSetQuery(newValue);
+ }
+ };
+
+ const renderItem = React.useCallback(
+ (itemId) => (
+
+ ),
+ [selectedId, onSelectItem, onDeleteItem, onGenerate]
+ );
+
+ return (
+
+
+
+
+ {filteredItems.length ? (
+
+ {filteredItems.map(renderItem)}
+
+ ) : (
+
+ {query
+ ? formatMessage('No form dialog schema matches your filtering criteria!')
+ : formatMessage('Create a new form dialog schema by clicking + above.')}
+
+ )}
+
+ );
+});
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()({
+ root: {
+ borderBottom: '1px solid #edebe9',
+ width: '100%',
+ },
+ clearButton: { display: 'none' },
+});
+
+type ListHeaFormDialogSchemaListHeaderProps = {
+ loading?: boolean;
+ searchDisabled?: boolean;
+ onChangeQuery: (e?: React.ChangeEvent | 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 (
+
+
+ {isFilterOn ? (
+ setFilterOn(false)}
+ />
+ ) : (
+
+ {formatMessage('Schemas')}
+ {loading && }
+
+ )}
+
+ {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 && (
- removeUrl(index)}
- />
- )}
-
- );
- })}
-
- {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 (
+
+ );
+ };
- const isUpdatingIthQnASectionKthQuestion = (ithQnASection: number, kthQuestion: number, operationMode: EditMode) => {
- return qnaSectionIndex === ithQnASection && questionIndex === kthQuestion && operationMode === EditMode.Updating;
- };
+ const onRenderTitle = () => {
+ return (
+
+ {isImportedSource && (
+
+ {sourceUrl && (
+
+
+ {groupName}
+
+
+ )}
+ {!sourceUrl && {groupName}
}
+
+ {
+ setEditQnAFile(containerQnAFile);
+ },
+ },
+ ]}
+ overflowItems={
+ [
+ {
+ key: 'edit',
+ name: formatMessage('Show code'),
+ iconProps: { iconName: 'CodeEdit' },
+ onClick: () => {
+ navigateTo(`/bot/${projectId}/knowledge-base/${dialogId}/edit?C=${containerId}`);
+ },
+ },
+ {
+ key: 'delete',
+ iconProps: { iconName: 'Delete' },
+ name: formatMessage('Delete knowledge base'),
+ disabled: dialogId === 'all',
+ onClick: async () => {
+ if (!qnaFile) return;
+ await removeQnAImport({ id: qnaFile.id, sourceId: containerId, projectId });
+ await removeQnAFile({ id: containerId, projectId });
+ },
+ },
+ ] as IOverflowSetItemProps[]
+ }
+ role="menubar"
+ onRenderItem={onRenderItem}
+ onRenderOverflowButton={onRenderOverflowButton}
+ />
+
+ )}
+ {!isImportedSource &&
{groupName}
}
+
+ );
+ };
+ if (props) {
+ return (
+
+
+ {!isCreatingQnA && (
+ {
+ onCreateNewQnAPairsStart(props.group?.key);
+ actions.setMessage('item added');
+ }}
+ >
+ {formatMessage('+ Add QnA Pair')}
+
+ )}
- const isCreatingNewQuestionOnIthQnASection = (ithQnASection: number, operationMode: EditMode) => {
- return operationMode === EditMode.Creating && qnaSectionIndex === ithQnASection;
- };
+
+
+ );
+ }
- const isUpdateingIthQnASectionAnswer = (
- ithQnASection: number,
- isUpdatingAnswer: boolean,
- operationMode: EditMode
- ) => {
- return qnaSectionIndex === ithQnASection && isUpdatingAnswer && operationMode === EditMode.Updating;
- };
+ return null;
+ },
+ [dialogId, qnaSections]
+ );
const getTableColums = () => {
const tableColums = [
{
key: 'ToggleShowAll',
name: '',
- fieldName: 'Chevron',
- minWidth: 40,
- maxWidth: 40,
+ fieldName: 'ToggleShowAll',
+ minWidth: 30,
+ maxWidth: 30,
isResizable: true,
- onRender: (item, qnaIndex) => {
+ onRender: (item, index) => {
return (
toggleShowAll(qnaIndex)}
+ iconProps={{ iconName: expandedIndex === index ? 'ChevronDown' : 'ChevronRight' }}
+ styles={{
+ root: { ...icon.root, marginTop: 2, marginLeft: 7, fontSize: 12 },
+ icon: { fontSize: 8, color: NeutralColors.black },
+ }}
+ title={formatMessage('Toggle show all')}
+ onClick={() => setExpandedIndex(expandedIndex === index ? -1 : index)}
/>
);
},
@@ -313,81 +432,118 @@ const TableView: React.FC = (props) => {
maxWidth: 450,
isResizable: true,
data: 'string',
- onRender: (item, qnaIndex) => {
- const questions = get(item, 'Questions', []);
- const showingQuestions = showQnAPairDetails[qnaIndex] ? questions : questions.slice(0, limitedNumber);
- //This question content of this qna Section is '#?'
- const isQuestionEmpty = showingQuestions.length === 1 && showingQuestions[0].content === '';
+ onRender: (item: QnASectionItem, index) => {
+ const questions = item.Questions;
+ const isExpanded = expandedIndex === index;
+ const isSourceSectionInDialog = item.fileId.endsWith('.source') && !dialogId.endsWith('.source');
+ const isAllowEdit = dialogId !== 'all' && !isSourceSectionInDialog;
+ const isCreatingQnA =
+ item.fileId === creatQnAPairSettings.groupKey && index === creatQnAPairSettings.sectionIndex;
+
+ const addQuestionButton = (
+ setCreatingQuestionInKthSection(item.sectionId)}>
+ {formatMessage('+ Add alternative phrasing')}
+
+ );
+
return (
- {showingQuestions.map((q, qIndex) => {
- if (!isUpdatingIthQnASectionKthQuestion(qnaIndex, qIndex, editMode)) {
- return (
-
- dialogId !== 'all' ? handleUpdateingAlternatives(e, qnaIndex, qIndex, q.content) : () => {}
- }
- onKeyDown={(e) => {
- e.preventDefault();
- if (e.key === 'Enter') {
- handleUpdateingAlternatives(e, qnaIndex, qIndex, q.content);
- }
- }}
- >
- {isQuestionEmpty &&
{formatMessage('Enter a question')}
}
- {!isQuestionEmpty && (
-
- {`${q.content} ${
- qIndex === 0 && !showQnAPairDetails[qnaIndex] ? `(${questions.length})` : ''
- }`}
-
- )}
-
- );
- //It is updating this qnaSection's qIndex-th Question
- } else if (isUpdatingIthQnASectionKthQuestion(qnaIndex, qIndex, editMode)) {
- return (
-
{
- handleQuestionOnBlur(e);
+ {questions.map((question, qIndex: number) => {
+ const isQuestionEmpty = question.content === '';
+ const isOnlyQuestion = questions.length === 1 && qIndex === 0;
+ return (
+
+ {
- handleQuestionOnChange(newValue, qnaIndex);
+ id={question.id}
+ name={question.content}
+ placeholder={formatMessage('Add new question')}
+ required={isOnlyQuestion}
+ requiredMessage={formatMessage('At least one question is required')}
+ resizable={false}
+ styles={editableField}
+ value={question.content}
+ onBlur={(_id, value = '') => {
+ const newValue = value?.trim();
+ const isChanged = question.content !== newValue;
+ if ((!newValue && isOnlyQuestion) || !isChanged) return;
+
+ if (isCreatingQnA) {
+ const creatingQnAItem = creatQnAPairSettings.item;
+ const fileId = creatQnAPairSettings.groupKey;
+ if (!creatingQnAItem) return;
+ const updatedItem = {
+ ...creatingQnAItem,
+ Question: newValue,
+ };
+ setCreatQnAPairSettings({
+ ...creatQnAPairSettings,
+ item: updatedItem,
+ });
+ onCreateNewQnAPairsEnd(fileId, updatedItem);
+ } else {
+ onUpdateQnAQuestion(item.fileId, item.sectionId, question.id, newValue);
+ }
}}
- onKeyDown={(e) => handleQuestionKeydown(e)}
+ onChange={() => {}}
+ onFocus={() => setExpandedIndex(index)}
/>
- );
- }
+
+ );
})}
- {isCreatingNewQuestionOnIthQnASection(qnaIndex, editMode) && dialogId !== 'all' && (
- {
- handleQuestionOnBlur(e);
- }}
- onChange={(e, newValue) => {
- e.preventDefault();
- handleQuestionOnChange(newValue, qnaIndex);
+ {kthSectionIsCreatingQuestion === item.sectionId ? (
+ {
+ const newValue = value?.trim();
+ if (!newValue) {
+ setCreatingQuestionInKthSection('');
+ return;
+ }
+
+ if (isCreatingQnA) {
+ const creatingQnAItem = creatQnAPairSettings.item;
+ const fileId = creatQnAPairSettings.groupKey;
+ if (!creatingQnAItem) return;
+ const updatedItem = {
+ ...creatingQnAItem,
+ Question: newValue,
+ };
+ setCreatQnAPairSettings({
+ ...creatQnAPairSettings,
+ item: updatedItem,
+ });
+ onCreateNewQnAPairsEnd(fileId, updatedItem);
+ } else {
+ onCreateNewQuestion(item.fileId, item.sectionId, newValue);
+ }
+
+ setCreatingQuestionInKthSection('');
}}
- onKeyDown={(e) => handleQuestionKeydown(e)}
+ onChange={() => {}}
+ onFocus={() => setExpandedIndex(index)}
/>
- )}
- {!isCreatingNewQuestionOnIthQnASection(qnaIndex, editMode) && dialogId !== 'all' && (
- handleAddingAlternatives(e, qnaIndex)}
- >
- {formatMessage('add alternative phrasing')}
-
+ ) : (
+ addQuestionButton
)}
);
@@ -397,54 +553,92 @@ const TableView: React.FC = (props) => {
key: 'Answer',
name: formatMessage('Answer'),
fieldName: 'answer',
- minWidth: 250,
- maxWidth: 450,
+ minWidth: 350,
isResizable: true,
data: 'string',
- onRender: (item, qnaIndex) => {
+ onRender: (item, index) => {
+ const isSourceSectionInDialog = item.fileId.endsWith('.source') && !dialogId.endsWith('.source');
+ const isAllowEdit = dialogId !== 'all' && !isSourceSectionInDialog;
+ const isExpanded = expandedIndex === index;
+ const isCreatingQnA =
+ item.fileId === creatQnAPairSettings.groupKey && index === creatQnAPairSettings.sectionIndex;
+
return (
- {!isUpdateingIthQnASectionAnswer(qnaIndex, isUpdatingAnswer, editMode) && (
-
(dialogId !== 'all' ? handleUpdateingAnswer(e, qnaIndex, item.Answer) : () => {})}
- onKeyDown={(e) => {
- e.preventDefault();
- if (e.key === 'Enter') {
- handleUpdateingAnswer(e, qnaIndex, item.Answer);
- }
- }}
- >
- {item.Answer || formatMessage('Enter an answer')}
-
- )}
- {isUpdateingIthQnASectionAnswer(qnaIndex, isUpdatingAnswer, editMode) && (
-
{
- handleAnswerOnBlur(e);
- }}
- onChange={(e, newValue) => {
- handleAnswerOnChange(newValue, qnaIndex);
- }}
- onKeyDown={(e) => handleAnswerKeydown(e)}
- />
- )}
+ {
+ const newValue = value?.trim();
+ const isChanged = item.Answer !== newValue;
+ if (!newValue || !isChanged) return;
+
+ if (isCreatingQnA) {
+ const creatingQnAItem = creatQnAPairSettings.item;
+ const fileId = creatQnAPairSettings.groupKey;
+ if (!creatingQnAItem) return;
+ const updatedItem = {
+ ...creatingQnAItem,
+ Answer: newValue,
+ };
+ setCreatQnAPairSettings({
+ ...creatQnAPairSettings,
+ item: updatedItem,
+ });
+ onCreateNewQnAPairsEnd(fileId, updatedItem);
+ } else {
+ onUpdateQnAAnswer(item.fileId, item.sectionId, newValue);
+ }
+ }}
+ onChange={() => {}}
+ onFocus={() => setExpandedIndex(index)}
+ />
);
},
},
- ];
- if (dialogId !== 'all') {
- const extraOperations = {
+ {
+ key: 'UsedIn',
+ name: formatMessage('Used In'),
+ fieldName: 'UsedIn',
+ minWidth: 150,
+ maxWidth: 200,
+ isResizable: true,
+ data: 'string',
+ onRender: (item) => {
+ return (
+
+ {item.usedIn.map(({ id, displayName }) => {
+ return (
+ {
+ navigateTo(`/bot/${projectId}/knowledge-base/${id}`);
+ }}
+ >
+ {displayName}
+
+ );
+ })}
+
+ );
+ },
+ },
+ {
key: 'buttons',
name: '',
minWidth: 50,
@@ -452,106 +646,149 @@ const TableView: React.FC = (props) => {
isResizable: true,
fieldName: 'buttons',
data: 'string',
- onRender: (item, qnaIndex) => {
+ onRender: (item) => {
return (
{
- deleteQnASection(qnaIndex);
+ onRemoveQnAPairs(item.fileId, item.sectionId);
}}
/>
);
},
- };
- tableColums.splice(3, 0, extraOperations);
+ },
+ ];
+ if (dialogId !== 'all') {
+ tableColums.splice(3, 1);
}
- // all view, show used in column
- if (dialogIdRef.current === 'all') {
- const beenUsedColumn = {
- key: 'usedIn',
- name: formatMessage('Used In'),
- fieldName: 'usedIn',
- minWidth: 100,
- maxWidth: 100,
- isResizable: true,
- isCollapsable: true,
- data: 'string',
- onRender: (item) => {
- return (
-
- );
- },
- };
- tableColums.splice(3, 0, beenUsedColumn);
- }
return tableColums;
};
+ const [groups, setGroups] = useState(undefined);
+ const getGroups = (createOnGroupId = ''): IGroup[] | undefined => {
+ let containerFiles = currentDialogImportedSourceFiles;
+ if (dialogId === 'all') {
+ containerFiles = allSourceFiles;
+ } else {
+ if (!qnaFile) return undefined;
+ }
+
+ const newGroups: IGroup[] = [];
+ containerFiles.forEach((currentFile) => {
+ const lastGroup = newGroups[newGroups.length - 1];
+ const startIndex = lastGroup ? lastGroup.startIndex + lastGroup.count : 0;
+ const { id } = currentFile;
+ let count = currentFile.qnaSections.length;
+ // create on file, insert a place-holder section.
+ if (createOnGroupId === id) {
+ count += 1;
+ }
+ const name = getBaseName(id);
+
+ // restore last group collapse state
+ const prevGroup = groups?.find(({ key }) => key === id);
+ const newGroup = prevGroup || { isCollapsed: false };
+ newGroups.push({
+ ...newGroup,
+ key: id,
+ name,
+ startIndex,
+ count,
+ level: 0,
+ });
+ });
+ return newGroups;
+ };
+ useEffect(() => {
+ const newGroups = getGroups();
+ const isChanged = !isEqual(groups, newGroups);
+ if (isChanged) setGroups(newGroups);
+ }, [dialogId, qnaFiles]);
+
+ useEffect(() => {
+ if (groups) {
+ const newGroup = [...groups];
+ const toExpandGroup = groups.find((g) => g.key === creatQnAPairSettings.groupKey);
+ if (toExpandGroup) {
+ toExpandGroup.isCollapsed = false;
+ setGroups(newGroup);
+ }
+ }
+ }, [creatQnAPairSettings]);
+
const onRenderDetailsHeader = useCallback(
(props, defaultRender) => {
return (
-
+
{defaultRender({
...props,
+ isCollapsable: false,
onRenderColumnHeaderTooltip: (tooltipHostProps) => ,
})}
-
- {dialogIdRef.current !== 'all' && (
-
-
{
- onCreateNewTemplate();
- actions.setMessage('item added');
- }}
- >
- {formatMessage('Add QnA Pair')}
-
-
- )}
-
);
},
- [dialogIdRef, showQnAPairDetails]
+ [dialogId]
);
- const onRenderRow = (props) => {
- if (props) {
- return
;
- }
- return null;
- };
-
- const onCreateNewTemplate = () => {
- const newQnAPair = generateQnAPair();
- const content = get(fileRef.current, 'content', '');
- const newContent = insertSection(0, content, newQnAPair);
- actions.updateQnAFile({ id: `${dialogIdRef.current}.${localeRef.current}`, content: newContent, projectId });
- const newArray = [false, ...showQnAPairDetails];
- setShowQnAPairDetails(newArray);
- };
+ const onRenderRow = useCallback(
+ (props) => {
+ if (props) {
+ return (
+
+ );
+ }
+ return null;
+ },
+ [dialogId, expandedIndex]
+ );
- const getKeyCallback = useCallback((item) => item.uuid, []);
+ if (qnaFile?.empty) {
+ return (
+
+
+
+
{formatMessage('Create a knowledge base from scratch or import knowledge from a URL or PDF files')}
+
{
+ actions.createQnAFromUrlDialogBegin({ projectId, showFromScratch: true });
+ }}
+ />
+
+
+ );
+ }
return (
= (props) => {
onRenderRow={onRenderRow}
/>
+ {editQnAFile && (
+ {
+ setEditQnAFile(undefined);
+ }}
+ onSubmit={onSubmitEditKB}
+ >
+ )}
);
};
diff --git a/Composer/packages/client/src/pages/language-generation/LGPage.tsx b/Composer/packages/client/src/pages/language-generation/LGPage.tsx
index d786f33b01..66921b4237 100644
--- a/Composer/packages/client/src/pages/language-generation/LGPage.tsx
+++ b/Composer/packages/client/src/pages/language-generation/LGPage.tsx
@@ -5,12 +5,11 @@
import { jsx } from '@emotion/core';
import React, { Fragment, useMemo, useCallback, Suspense, useEffect } 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 { useRecoilValue } from 'recoil';
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';
@@ -74,12 +73,12 @@ const LGPage: React.FC
> = (props: RouteComponen
}, [dialogId, dialogs, projectId]);
const onToggleEditMode = useCallback(
- (_e, checked) => {
+ (_e) => {
let url = `/bot/${projectId}/language-generation/${dialogId}`;
- if (checked) url += `/edit`;
+ if (!edit) url += `/edit`;
navigateTo(url);
},
- [dialogId, projectId]
+ [dialogId, projectId, edit]
);
const toolbarItems = [
@@ -92,14 +91,9 @@ const LGPage: React.FC> = (props: RouteComponen
const onRenderHeaderContent = () => {
return (
-
+
+ {edit ? formatMessage('Hide code') : formatMessage('Show code')}
+
);
};
diff --git a/Composer/packages/client/src/pages/language-generation/code-editor.tsx b/Composer/packages/client/src/pages/language-generation/code-editor.tsx
index 30e5ec6aba..aa6cfd03ac 100644
--- a/Composer/packages/client/src/pages/language-generation/code-editor.tsx
+++ b/Composer/packages/client/src/pages/language-generation/code-editor.tsx
@@ -13,7 +13,7 @@ import { RouteComponentProps } from '@reach/router';
import querystring from 'query-string';
import { CodeEditorSettings } from '@bfc/shared';
import { useRecoilValue } from 'recoil';
-import { LgFile } from '@bfc/shared/src/types/indexers';
+import { LgFile } from '@bfc/extension-client';
import { localeState, lgFilesState, settingsState } from '../../recoilModel/atoms/botState';
import { userSettingsState, dispatcherState } from '../../recoilModel';
diff --git a/Composer/packages/client/src/pages/language-generation/table-view.tsx b/Composer/packages/client/src/pages/language-generation/table-view.tsx
index 11dccf3da1..cdead54f70 100644
--- a/Composer/packages/client/src/pages/language-generation/table-view.tsx
+++ b/Composer/packages/client/src/pages/language-generation/table-view.tsx
@@ -21,11 +21,11 @@ import { lgUtil } from '@bfc/indexers';
import { EditableField } from '../../components/EditableField';
import { navigateTo } from '../../utils/navigation';
-import { actionButton, formCell } from '../language-understanding/styles';
+import { actionButton, formCell, editableFieldContainer } from '../language-understanding/styles';
import {
dispatcherState,
- localeState,
lgFilesState,
+ localeState,
settingsState,
validateDialogSelectorFamily,
} from '../../recoilModel';
@@ -57,7 +57,7 @@ const TableView: React.FC = (props) => {
const activeDialog = dialogs.find(({ id }) => id === dialogId);
- const [focusedIndex, setFocusedIndex] = useState(0);
+ //const [focusedIndex, setFocusedIndex] = useState(0);
useEffect(() => {
if (!file || isEmpty(file)) return;
@@ -86,7 +86,7 @@ const TableView: React.FC = (props) => {
} as LgTemplate,
};
createLgTemplate(payload);
- setFocusedIndex(file.templates.length);
+ //setFocusedIndex(file.templates.length);
}
}, [file]);
@@ -99,7 +99,7 @@ const TableView: React.FC = (props) => {
projectId,
};
removeLgTemplate(payload);
- setFocusedIndex(file.templates.findIndex((item) => item.name === name));
+ //setFocusedIndex(file.templates.findIndex((item) => item.name === name));
}
},
[file]
@@ -116,7 +116,7 @@ const TableView: React.FC = (props) => {
projectId,
};
copyLgTemplate(payload);
- setFocusedIndex(file.templates.length);
+ //setFocusedIndex(file.templates.length);
}
},
[file]
@@ -209,6 +209,7 @@ const TableView: React.FC = (props) => {
= (props) => {
= (props) => {
= (props) => {
= (props) => {
columns={getTableColums()}
componentRef={listRef}
getKey={getKeyCallback}
- initialFocusedIndex={focusedIndex}
+ //initialFocusedIndex={focusedIndex}
items={templatesToRender}
// getKey={item => item.name}
layoutMode={DetailsListLayoutMode.justified}
diff --git a/Composer/packages/client/src/pages/language-understanding/LUPage.tsx b/Composer/packages/client/src/pages/language-understanding/LUPage.tsx
index 4cad612016..87ed81d6be 100644
--- a/Composer/packages/client/src/pages/language-understanding/LUPage.tsx
+++ b/Composer/packages/client/src/pages/language-understanding/LUPage.tsx
@@ -4,7 +4,7 @@
import { jsx } from '@emotion/core';
import React, { Fragment, useMemo, Suspense, useCallback, useEffect } 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 { useRecoilValue } from 'recoil';
@@ -16,7 +16,6 @@ import { Page } from '../../components/Page';
import { validateDialogSelectorFamily } from '../../recoilModel';
import TableView from './table-view';
-import { actionButton } from './styles';
const CodeEditor = React.lazy(() => import('./code-editor'));
const LUPage: React.FC {
+ (_e) => {
let url = `/bot/${projectId}/language-understanding/${dialogId}`;
- if (checked) url += `/edit`;
+ if (!edit) url += `/edit`;
navigateTo(url);
},
- [dialogId, projectId]
+ [dialogId, projectId, edit]
);
const toolbarItems = [
@@ -83,19 +82,13 @@ const LUPage: React.FC {
- if (!isRoot || edit) {
+ if (!isRoot) {
return (
-
+
+ {edit ? formatMessage('Hide code') : formatMessage('Show code')}
+
);
}
-
return null;
};
diff --git a/Composer/packages/client/src/pages/language-understanding/code-editor.tsx b/Composer/packages/client/src/pages/language-understanding/code-editor.tsx
index 739dbd022d..c695cc5a7f 100644
--- a/Composer/packages/client/src/pages/language-understanding/code-editor.tsx
+++ b/Composer/packages/client/src/pages/language-understanding/code-editor.tsx
@@ -125,10 +125,12 @@ const CodeEditor: React.FC = (props) => {
[file, intent, projectId]
);
+ const luFeatures = settings?.luFeatures || {};
const luOption = {
projectId,
fileId: file?.id || dialogId,
sectionId: intent?.Name,
+ luFeatures,
};
const handleSettingsChange = (settings: Partial) => {
@@ -158,6 +160,7 @@ const CodeEditor: React.FC = (props) => {
editorSettings={userSettings.codeEditor}
luOption={{
fileId: dialogId,
+ luFeatures,
}}
options={{
readOnly: true,
diff --git a/Composer/packages/client/src/pages/language-understanding/styles.ts b/Composer/packages/client/src/pages/language-understanding/styles.ts
index f28f91f98f..879c07e14c 100644
--- a/Composer/packages/client/src/pages/language-understanding/styles.ts
+++ b/Composer/packages/client/src/pages/language-understanding/styles.ts
@@ -105,3 +105,7 @@ export const diffEditorContent = css`
export const dropdown = {
dropdown: { width: '50%', maxWidth: 300, minWidth: 100 },
};
+
+export const editableFieldContainer = css`
+ outline: none;
+`;
diff --git a/Composer/packages/client/src/pages/language-understanding/table-view.tsx b/Composer/packages/client/src/pages/language-understanding/table-view.tsx
index bdbf22bdfb..9466ec6644 100644
--- a/Composer/packages/client/src/pages/language-understanding/table-view.tsx
+++ b/Composer/packages/client/src/pages/language-understanding/table-view.tsx
@@ -32,7 +32,7 @@ import {
validateDialogSelectorFamily,
} from '../../recoilModel';
-import { formCell, luPhraseCell, tableCell } from './styles';
+import { formCell, luPhraseCell, tableCell, editableFieldContainer } from './styles';
interface TableViewProps extends RouteComponentProps<{ dialogId: string; projectId: string }> {
dialogId: string;
projectId: string;
@@ -177,6 +177,7 @@ const TableView: React.FC = (props) => {
= (props) => {
= (props) => {
= (props) => {
{
@@ -70,25 +80,25 @@ export default function useNotifications(projectId: string, filter?: string) {
});
dialogs.forEach((dialog) => {
- dialog.diagnostics.map((diagnostic) => {
+ dialog.diagnostics.forEach((diagnostic) => {
const location = `${dialog.id}.dialog`;
notifications.push(new DialogNotification(projectId, dialog.id, location, diagnostic));
});
});
getReferredLuFiles(luFiles, dialogs).forEach((lufile) => {
- lufile.diagnostics.map((diagnostic) => {
+ lufile.diagnostics.forEach((diagnostic) => {
const location = `${lufile.id}.lu`;
notifications.push(new LuNotification(projectId, lufile.id, location, diagnostic, lufile, dialogs));
});
});
lgFiles.forEach((lgFile) => {
- lgFile.diagnostics.map((diagnostic) => {
+ lgFile.diagnostics.forEach((diagnostic) => {
const location = `${lgFile.id}.lg`;
notifications.push(new LgNotification(projectId, lgFile.id, location, diagnostic, lgFile, dialogs));
});
});
qnaFiles.forEach((qnaFile) => {
- get(qnaFile, 'diagnostics', []).map((diagnostic) => {
+ get(qnaFile, 'diagnostics', []).forEach((diagnostic) => {
const location = `${qnaFile.id}.qna`;
notifications.push(new QnANotification(projectId, qnaFile.id, location, diagnostic));
});
diff --git a/Composer/packages/client/src/pages/plugin/PluginPageContainer.tsx b/Composer/packages/client/src/pages/plugin/PluginPageContainer.tsx
new file mode 100644
index 0000000000..fd101bb17d
--- /dev/null
+++ b/Composer/packages/client/src/pages/plugin/PluginPageContainer.tsx
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import React from 'react';
+import { RouteComponentProps } from '@reach/router';
+
+import { PluginHost } from '../../components/PluginHost/PluginHost';
+import { useShell } from '../../shell';
+
+const PluginPageContainer: React.FC> = (
+ props
+) => {
+ const { pluginId, bundleId, projectId } = props;
+ const shell = useShell('DesignPage', projectId as string);
+
+ if (!pluginId || !bundleId || !projectId) {
+ return null;
+ }
+
+ return ;
+};
+
+export { PluginPageContainer };
diff --git a/Composer/packages/client/src/pages/plugin/pluginPageContainer.tsx b/Composer/packages/client/src/pages/plugin/pluginPageContainer.tsx
deleted file mode 100644
index 662b0ca484..0000000000
--- a/Composer/packages/client/src/pages/plugin/pluginPageContainer.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import React from 'react';
-import { RouteComponentProps } from '@reach/router';
-
-import { PluginHost } from '../../components/PluginHost/PluginHost';
-
-const PluginPageContainer: React.FC> = (props) => {
- const { pluginId } = props;
-
- return ;
-};
-
-export { PluginPageContainer };
diff --git a/Composer/packages/client/src/pages/publish/Publish.tsx b/Composer/packages/client/src/pages/publish/Publish.tsx
index a378885ed1..ac89b47bbe 100644
--- a/Composer/packages/client/src/pages/publish/Publish.tsx
+++ b/Composer/packages/client/src/pages/publish/Publish.tsx
@@ -17,7 +17,7 @@ import { projectContainer } from '../design/styles';
import {
dispatcherState,
settingsState,
- botNameState,
+ botDisplayNameState,
publishTypesState,
publishHistoryState,
} from '../../recoilModel';
@@ -36,7 +36,7 @@ const Publish: React.FC();
const settings = useRecoilValue(settingsState(projectId));
- const botName = useRecoilValue(botNameState(projectId));
+ const botName = useRecoilValue(botDisplayNameState(projectId));
const publishTypes = useRecoilValue(publishTypesState(projectId));
const publishHistory = useRecoilValue(publishHistoryState(projectId));
diff --git a/Composer/packages/client/src/pages/publish/createPublishTarget.tsx b/Composer/packages/client/src/pages/publish/createPublishTarget.tsx
index cc9fe43042..f897f60595 100644
--- a/Composer/packages/client/src/pages/publish/createPublishTarget.tsx
+++ b/Composer/packages/client/src/pages/publish/createPublishTarget.tsx
@@ -73,8 +73,8 @@ const CreatePublishTarget: React.FC = (props) => {
return targetType ? props.types.find((t) => t.name === targetType)?.schema : undefined;
}, [props.targets, targetType]);
- const hasView = useMemo(() => {
- return targetType ? props.types.find((t) => t.name === targetType)?.hasView : undefined;
+ const targetBundleId = useMemo(() => {
+ return targetType ? props.types.find((t) => t.name === targetType)?.bundleId : undefined;
}, [props.targets, targetType]);
const updateName = (e, newName) => {
@@ -85,7 +85,7 @@ const CreatePublishTarget: React.FC = (props) => {
const saveDisabled = useMemo(() => {
const disabled = !targetType || !name || !!errorMessage;
- if (hasView) {
+ if (targetBundleId) {
// plugin config must also be valid
return disabled || !pluginConfigIsValid;
}
@@ -107,13 +107,14 @@ const CreatePublishTarget: React.FC = (props) => {
};
const publishTargetContent = useMemo(() => {
- if (hasView && targetType) {
+ if (targetBundleId && targetType) {
// render custom plugin view
return (
);
}
@@ -133,7 +134,7 @@ const CreatePublishTarget: React.FC = (props) => {
);
- }, [targetType, instructions, schema, hasView, saveDisabled]);
+ }, [targetType, instructions, schema, targetBundleId, saveDisabled]);
return (
diff --git a/Composer/packages/client/src/pages/setting/SettingsPage.tsx b/Composer/packages/client/src/pages/setting/SettingsPage.tsx
index 350fa2ed46..37b12d8b4b 100644
--- a/Composer/packages/client/src/pages/setting/SettingsPage.tsx
+++ b/Composer/packages/client/src/pages/setting/SettingsPage.tsx
@@ -37,7 +37,7 @@ const getProjectLink = (path: string, id?: string) => {
const SettingPage: React.FC = () => {
const projectId = useRecoilValue(currentProjectIdState);
const {
- deleteBotProject,
+ deleteBot: deleteBotProject,
addLanguageDialogBegin,
addLanguageDialogCancel,
delLanguageDialogBegin,
diff --git a/Composer/packages/client/src/pages/setting/dialog-settings/DialogSettings.tsx b/Composer/packages/client/src/pages/setting/dialog-settings/DialogSettings.tsx
index d4c781d095..4af89f7a76 100644
--- a/Composer/packages/client/src/pages/setting/dialog-settings/DialogSettings.tsx
+++ b/Composer/packages/client/src/pages/setting/dialog-settings/DialogSettings.tsx
@@ -14,7 +14,13 @@ import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown';
import cloneDeep from 'lodash/cloneDeep';
import { Label } from 'office-ui-fabric-react/lib/Label';
-import { dispatcherState, userSettingsState, botNameState, localeState, settingsState } from '../../../recoilModel';
+import {
+ dispatcherState,
+ userSettingsState,
+ botDisplayNameState,
+ localeState,
+ settingsState,
+} from '../../../recoilModel';
import { languageListTemplates } from '../../../components/MultiLanguage';
import { settingsEditor, toolbar } from './style';
@@ -22,7 +28,7 @@ import { BotSettings } from './constants';
export const DialogSettings: React.FC> = (props) => {
const { projectId = '' } = props;
- const botName = useRecoilValue(botNameState(projectId));
+ const botName = useRecoilValue(botDisplayNameState(projectId));
const locale = useRecoilValue(localeState(projectId));
const settings = useRecoilValue(settingsState(projectId));
const userSettings = useRecoilValue(userSettingsState);
diff --git a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx
index e687032207..16913d5f23 100644
--- a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx
+++ b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx
@@ -16,15 +16,7 @@ import {
import { ScrollablePane } from 'office-ui-fabric-react/lib/ScrollablePane';
import { Sticky } from 'office-ui-fabric-react/lib/Sticky';
import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList';
-
-// TODO: extract to shared?
-export type ExtensionSearchResult = {
- id: string;
- keywords: string[];
- version: string;
- description: string;
- url: string;
-};
+import { ExtensionSearchResult } from '@bfc/extension-client';
type ExtensionSearchResultsProps = {
results: ExtensionSearchResult[];
diff --git a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx
index fc15946802..a2b26e6913 100644
--- a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx
+++ b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx
@@ -20,13 +20,12 @@ import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetail
import formatMessage from 'format-message';
import { useRecoilValue, selector } from 'recoil';
import { NeutralColors } from '@uifabric/fluent-theme';
+import { ExtensionMetadata, ExtensionSearchResult } from '@bfc/extension-client';
-import { ExtensionConfig } from '../../../recoilModel/types';
import { Toolbar, IToolbarItem } from '../../../components/Toolbar';
import { dispatcherState, extensionsState } from '../../../recoilModel';
import { InstallExtensionDialog } from './InstallExtensionDialog';
-import { ExtensionSearchResult } from './ExtensionSearchResults';
const remoteExtensionsState = selector({
key: 'remoteExtensions',
@@ -45,11 +44,11 @@ const Extensions: React.FC = () => {
// if a string, its the id of the extension being updated
const [isUpdating, setIsUpdating] = useState(false);
const [showNewModal, setShowNewModal] = useState(false);
- const [selectedExtensions, setSelectedExtensions] = useState([]);
+ const [selectedExtensions, setSelectedExtensions] = useState([]);
const selection = useRef(
new Selection({
onSelectionChanged: () => {
- setSelectedExtensions(selection.getSelection() as ExtensionConfig[]);
+ setSelectedExtensions(selection.getSelection() as ExtensionMetadata[]);
},
})
).current;
@@ -91,7 +90,7 @@ const Extensions: React.FC = () => {
minWidth: 100,
maxWidth: 150,
isResizable: true,
- onRender: (item: ExtensionConfig) => {
+ onRender: (item: ExtensionMetadata) => {
return (
> = (props) => {
const { projectId = '' } = props;
- const botName = useRecoilValue(botNameState(projectId));
+ const botName = useRecoilValue(botDisplayNameState(projectId));
const settings = useRecoilValue(settingsState(projectId));
const ejectedRuntimeExists = useRecoilValue(isEjectRuntimeExistState(projectId));
diff --git a/Composer/packages/client/src/pages/skills/index.tsx b/Composer/packages/client/src/pages/skills/index.tsx
index bdfa8467b9..12df39ccaf 100644
--- a/Composer/packages/client/src/pages/skills/index.tsx
+++ b/Composer/packages/client/src/pages/skills/index.tsx
@@ -9,7 +9,7 @@ import formatMessage from 'format-message';
import { useRecoilValue } from 'recoil';
import { SkillSetting } from '@bfc/shared';
-import { dispatcherState, settingsState, botNameState } from '../../recoilModel';
+import { dispatcherState, settingsState, botDisplayNameState } from '../../recoilModel';
import { Toolbar, IToolbarItem } from '../../components/Toolbar';
import { TestController } from '../../components/TestController/TestController';
import { CreateSkillModal } from '../../components/CreateSkillModal';
@@ -22,7 +22,7 @@ const Skills: React.FC> = (props) =>
const { projectId = '' } = props;
const [showAddSkillDialogModal, setShowAddSkillDialogModal] = useState(false);
- const botName = useRecoilValue(botNameState(projectId));
+ const botName = useRecoilValue(botDisplayNameState(projectId));
const settings = useRecoilValue(settingsState(projectId));
const { addSkill, setSettings } = useRecoilValue(dispatcherState);
diff --git a/Composer/packages/client/src/plugins.ts b/Composer/packages/client/src/plugins.ts
index cd8b8f9dc4..f6db7a8b0c 100644
--- a/Composer/packages/client/src/plugins.ts
+++ b/Composer/packages/client/src/plugins.ts
@@ -29,7 +29,7 @@ const mergeArrays: MergeWithCustomizer = (objValue, srcValue, key) => {
const defaultPlugin: Required = {
uiSchema: {},
- flowWidgets: {},
+ widgets: {},
};
export function mergePluginConfigs(...plugins: PluginConfig[]): Required {
diff --git a/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx b/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx
index 892e7b7455..c6f7852039 100644
--- a/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx
+++ b/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx
@@ -14,7 +14,6 @@ import { UndoRoot } from './undo/history';
import { prepareAxios } from './../utils/auth';
import createDispatchers, { Dispatcher } from './dispatchers';
import {
- botProjectsSpaceState,
dialogsState,
luFilesState,
qnaFilesState,
@@ -23,7 +22,10 @@ import {
dialogSchemasState,
settingsState,
filePersistenceState,
+ botProjectFileState,
+ jsonSchemaFilesState,
} from './atoms';
+import { botsForFilePersistenceSelector, formDialogSchemasSelectorFamily } from './selectors';
const getBotAssets = async (projectId, snapshot: Snapshot): Promise => {
const result = await Promise.all([
@@ -34,6 +36,9 @@ const getBotAssets = async (projectId, snapshot: Snapshot): Promise =
snapshot.getPromise(skillManifestsState(projectId)),
snapshot.getPromise(settingsState(projectId)),
snapshot.getPromise(dialogSchemasState(projectId)),
+ snapshot.getPromise(botProjectFileState(projectId)),
+ snapshot.getPromise(formDialogSchemasSelectorFamily(projectId)),
+ snapshot.getPromise(jsonSchemaFilesState(projectId)),
]);
return {
projectId,
@@ -44,6 +49,9 @@ const getBotAssets = async (projectId, snapshot: Snapshot): Promise =
skillManifests: result[4],
setting: result[5],
dialogSchemas: result[6],
+ botProjectFile: result[7],
+ formDialogSchemas: result[8],
+ jsonSchemaFiles: result[9],
};
};
@@ -85,10 +93,11 @@ const InitDispatcher = ({ onLoad }) => {
export const DispatcherWrapper = ({ children }) => {
const [loaded, setLoaded] = useState(false);
- const botProjects = useRecoilValue(botProjectsSpaceState);
+ const botProjects = useRecoilValue(botsForFilePersistenceSelector);
useRecoilTransactionObserver_UNSTABLE(async ({ snapshot, previousSnapshot }) => {
- for (const projectId of botProjects) {
+ const botsForFilePersistence = await snapshot.getPromise(botsForFilePersistenceSelector);
+ for (const projectId of botsForFilePersistence) {
const assets = await getBotAssets(projectId, snapshot);
const previousAssets = await getBotAssets(projectId, previousSnapshot);
const filePersistence = await snapshot.getPromise(filePersistenceState(projectId));
diff --git a/Composer/packages/client/src/recoilModel/atoms/appState.ts b/Composer/packages/client/src/recoilModel/atoms/appState.ts
index ffa97d2f58..906ffd8832 100644
--- a/Composer/packages/client/src/recoilModel/atoms/appState.ts
+++ b/Composer/packages/client/src/recoilModel/atoms/appState.ts
@@ -2,7 +2,8 @@
// Licensed under the MIT License.
import { atom, atomFamily } from 'recoil';
-import { ProjectTemplate, UserSettings } from '@bfc/shared';
+import { FormDialogSchemaTemplate, ProjectTemplate, UserSettings } from '@bfc/shared';
+import { ExtensionMetadata } from '@bfc/extension-client';
import {
StorageFolder,
@@ -11,7 +12,6 @@ import {
AppUpdateState,
BoilerplateVersion,
Notification,
- ExtensionConfig,
} from '../../recoilModel/types';
import { getUserSettings } from '../utils';
import onboardingStorage from '../../utils/onboardingStorage';
@@ -165,22 +165,37 @@ export const notificationsState = atomFamily({
},
});
-export const extensionsState = atom({
+export const extensionsState = atom[]>({
key: getFullyQualifiedKey('extensions'),
default: [],
});
+export const botProjectIdsState = atom({
+ key: getFullyQualifiedKey('botProjectIdsState'),
+ default: [],
+});
+
+export const currentProjectIdState = atom({
+ key: getFullyQualifiedKey('currentProjectId'),
+ default: '',
+});
+
+export const botProjectSpaceLoadedState = atom({
+ key: getFullyQualifiedKey('botProjectSpaceLoadedState'),
+ default: false,
+});
+
export const botOpeningState = atom({
- key: getFullyQualifiedKey('botOpening'),
+ key: getFullyQualifiedKey('botOpeningState'),
default: false,
});
-export const botProjectsSpaceState = atom({
- key: getFullyQualifiedKey('botProjectsSpace'),
+export const formDialogLibraryTemplatesState = atom({
+ key: getFullyQualifiedKey('formDialogLibraryTemplates'),
default: [],
});
-export const currentProjectIdState = atom({
- key: getFullyQualifiedKey('currentProjectId'),
- default: '',
+export const formDialogGenerationProgressingState = atom({
+ key: getFullyQualifiedKey('formDialogGenerationProgressing'),
+ default: false,
});
diff --git a/Composer/packages/client/src/recoilModel/atoms/botState.ts b/Composer/packages/client/src/recoilModel/atoms/botState.ts
index 5880e99a3d..0ecd54c188 100644
--- a/Composer/packages/client/src/recoilModel/atoms/botState.ts
+++ b/Composer/packages/client/src/recoilModel/atoms/botState.ts
@@ -1,24 +1,29 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { atomFamily } from 'recoil';
import {
+ BotProjectFile,
+ BotProjectSpace,
+ BotSchemas,
+ Diagnostic,
DialogInfo,
DialogSchemaFile,
- Diagnostic,
+ DialogSetting,
+ FormDialogSchema,
+ JsonSchemaFile,
LgFile,
LuFile,
QnAFile,
- BotSchemas,
Skill,
- DialogSetting,
} from '@bfc/shared';
+import { atomFamily } from 'recoil';
-import { BotLoadError, DesignPageLocation, QnAAllUpViewStatus } from '../../recoilModel/types';
+import { BotLoadError, DesignPageLocation } from '../../recoilModel/types';
import FilePersistence from '../persistence/FilePersistence';
-import { PublishType, BreadcrumbItem } from './../../recoilModel/types';
import { BotStatus } from './../../constants';
+import { BreadcrumbItem, PublishType } from './../../recoilModel/types';
+
const getFullyQualifiedKey = (value: string) => {
return `Bot_${value}_State`;
};
@@ -42,8 +47,8 @@ export const dialogSchemasState = atomFamily({
default: [],
});
-export const botNameState = atomFamily({
- key: getFullyQualifiedKey('botName'),
+export const botDisplayNameState = atomFamily({
+ key: getFullyQualifiedKey('botDisplayName'),
default: (id) => {
return '';
},
@@ -218,10 +223,13 @@ export const onDelLanguageDialogCompleteState = atomFamily({
default: { func: undefined },
});
-export const projectMetaDataState = atomFamily({
+export const projectMetaDataState = atomFamily<{ isRootBot: boolean; isRemote: boolean }, string>({
key: getFullyQualifiedKey('projectsMetaDataState'),
- default: (id) => {
- return {};
+ default: () => {
+ return {
+ isRootBot: false,
+ isRemote: false,
+ };
},
});
@@ -234,9 +242,27 @@ export const designPageLocationState = atomFamily({
},
});
-export const qnaAllUpViewStatusState = atomFamily({
- key: getFullyQualifiedKey('qnaAllUpViewStatusState'),
- default: QnAAllUpViewStatus.Success,
+export const showCreateQnAFromUrlDialogState = atomFamily({
+ key: getFullyQualifiedKey('showCreateQnAFromUrlDialog'),
+ default: false,
+});
+
+export const showCreateQnAFromUrlDialogWithScratchState = atomFamily({
+ key: getFullyQualifiedKey('showCreateQnAFromUrlDialogWithScratch'),
+ default: false,
+});
+
+export const showCreateQnAFromScratchDialogState = atomFamily({
+ key: getFullyQualifiedKey('showCreateQnAFromScratchDialog'),
+ default: false,
+});
+export const onCreateQnAFromUrlDialogCompleteState = atomFamily<{ func: undefined | (() => void) }, string>({
+ key: getFullyQualifiedKey('onCreateQnAFromUrlDialogCompleteState'),
+ default: { func: undefined },
+});
+export const onCreateQnAFromScratchDialogCompleteState = atomFamily<{ func: undefined | (() => void) }, string>({
+ key: getFullyQualifiedKey('onCreateQnAFromScratchDialogCompleteState'),
+ default: { func: undefined },
});
export const isEjectRuntimeExistState = atomFamily({
@@ -249,8 +275,46 @@ export const qnaFilesState = atomFamily({
default: [],
});
+export const jsonSchemaFilesState = atomFamily({
+ key: getFullyQualifiedKey('jsonSchemaFiles'),
+ default: [],
+});
+
export const filePersistenceState = atomFamily({
key: getFullyQualifiedKey('filePersistence'),
default: {} as FilePersistence,
dangerouslyAllowMutability: true,
});
+
+export const formDialogSchemaIdsState = atomFamily({
+ key: getFullyQualifiedKey('formDialogSchemaIds'),
+ default: [],
+});
+
+export const formDialogSchemaState = atomFamily({
+ key: getFullyQualifiedKey('formDialogSchema'),
+ default: {
+ id: '',
+ content: '',
+ } as FormDialogSchema,
+});
+
+export const botProjectFileState = atomFamily({
+ key: getFullyQualifiedKey('botProjectFile'),
+ default: {
+ content: {} as BotProjectSpace,
+ id: '',
+ lastModified: '',
+ },
+});
+
+export const botErrorState = atomFamily({
+ key: getFullyQualifiedKey('botError'),
+ default: undefined,
+});
+
+// Object key to identify the skill in BotProject file and settings.skill
+export const botNameIdentifierState = atomFamily({
+ key: getFullyQualifiedKey('botNameIdentifier'),
+ default: '',
+});
diff --git a/Composer/packages/client/src/recoilModel/atoms/index.ts b/Composer/packages/client/src/recoilModel/atoms/index.ts
index 35425c3954..6e08aef7b9 100644
--- a/Composer/packages/client/src/recoilModel/atoms/index.ts
+++ b/Composer/packages/client/src/recoilModel/atoms/index.ts
@@ -3,3 +3,4 @@
export * from './appState';
export * from './botState';
+export * from './zoomState';
diff --git a/Composer/packages/client/src/recoilModel/atoms/zoomState.ts b/Composer/packages/client/src/recoilModel/atoms/zoomState.ts
new file mode 100644
index 0000000000..f574293598
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/atoms/zoomState.ts
@@ -0,0 +1,19 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { atom } from 'recoil';
+import { ZoomInfo } from '@bfc/shared';
+
+const getFullyQualifiedKey = (value: string) => {
+ return `Zoom_${value}_State`;
+};
+
+export const rateInfoState = atom({
+ key: getFullyQualifiedKey('rateInfo'),
+ default: {
+ rateList: [0.25, 0.33, 0.5, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.75, 2, 2.5, 3, 4, 5],
+ maxRate: 3,
+ minRate: 0.5,
+ currentRate: 1,
+ },
+});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/application.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/application.test.tsx
index e41bf2110f..0609f4fd61 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/application.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/application.test.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useRecoilState, useRecoilValue } from 'recoil';
-import { act, RenderHookResult, HookResult } from '@bfc/test-utils/lib/hooks';
+import { act, RenderHookResult, HookResult } from '@botframework-composer/test-utils/lib/hooks';
// eslint-disable-next-line lodash/import-scope
import debounce from 'lodash/debounce';
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx
new file mode 100644
index 0000000000..678d906e3d
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx
@@ -0,0 +1,170 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { selector, useRecoilValue, selectorFamily, useRecoilState } from 'recoil';
+import { act, RenderHookResult, HookResult } from '@botframework-composer/test-utils/lib/hooks';
+import noop from 'lodash/noop';
+
+import { botProjectFileDispatcher } from '../botProjectFile';
+import { renderRecoilHook } from '../../../../__tests__/testUtils';
+import {
+ botDisplayNameState,
+ botErrorState,
+ botNameIdentifierState,
+ botProjectFileState,
+ botProjectIdsState,
+ currentProjectIdState,
+ locationState,
+ projectMetaDataState,
+} from '../../atoms';
+import { dispatcherState } from '../../DispatcherWrapper';
+import { Dispatcher } from '..';
+
+jest.mock('../../../utils/httpUtil');
+const rootBotProjectId = '2345.32324';
+const testSkillId = '123.1sd23';
+
+describe('Bot Project File dispatcher', () => {
+ const skillsDataSelector = selectorFamily({
+ key: 'skillsDataSelector-botProjectFile',
+ get: (skillId: string) => noop,
+ set: (skillId: string) => ({ set }, stateUpdater: any) => {
+ const { botNameIdentifier, location } = stateUpdater;
+ set(botNameIdentifierState(skillId), botNameIdentifier);
+ set(locationState(skillId), location);
+ },
+ });
+
+ const botStatesSelector = selector({
+ key: 'botStatesSelector',
+ get: ({ get }) => {
+ const botProjectIds = get(botProjectIdsState);
+ const botProjectData: { [projectName: string]: { botDisplayName: string; botError: any; location: string } } = {};
+ botProjectIds.map((projectId) => {
+ const botDisplayName = get(botDisplayNameState(projectId));
+ const botNameIdentifier = get(botNameIdentifierState(projectId));
+ const botError = get(botErrorState(projectId));
+ const location = get(locationState(projectId));
+ if (botNameIdentifier) {
+ botProjectData[botNameIdentifier] = {
+ botDisplayName,
+ location,
+ botError,
+ };
+ }
+ });
+ return botProjectData;
+ },
+ });
+
+ const useRecoilTestHook = () => {
+ const botName = useRecoilValue(botDisplayNameState(rootBotProjectId));
+ const botProjectFile = useRecoilValue(botProjectFileState(rootBotProjectId));
+ const currentDispatcher = useRecoilValue(dispatcherState);
+ const botStates = useRecoilValue(botStatesSelector);
+ const [skillsData, setSkillsData] = useRecoilState(skillsDataSelector(testSkillId));
+
+ return {
+ botName,
+ currentDispatcher,
+ botProjectFile,
+ botStates,
+ skillsData,
+ setSkillsData,
+ };
+ };
+
+ let renderedComponent: HookResult>, dispatcher: Dispatcher;
+ beforeEach(() => {
+ const rendered: RenderHookResult> = renderRecoilHook(
+ useRecoilTestHook,
+ {
+ states: [
+ { recoilState: currentProjectIdState, initialValue: rootBotProjectId },
+ {
+ recoilState: botProjectFileState(rootBotProjectId),
+ initialValue: {
+ content: {
+ $schema: '',
+ name: 'TesterBot',
+ workspace: 'file:///Users/tester/Desktop/LoadedBotProject/TesterBot',
+ skills: {},
+ },
+ },
+ },
+ {
+ recoilState: projectMetaDataState(rootBotProjectId),
+ initialValue: {
+ isRootBot: true,
+ },
+ },
+ { recoilState: botProjectIdsState, initialValue: [rootBotProjectId] },
+ ],
+ dispatcher: {
+ recoilState: dispatcherState,
+ initialValue: {
+ botProjectFileDispatcher,
+ },
+ },
+ }
+ );
+ renderedComponent = rendered.result;
+ dispatcher = renderedComponent.current.currentDispatcher;
+ });
+
+ it('should add a local skill to bot project file', async () => {
+ await act(async () => {
+ renderedComponent.current.setSkillsData({
+ location: 'Users/tester/Desktop/LoadedBotProject/Todo-Skill',
+ botNameIdentifier: 'todoSkill',
+ });
+ });
+
+ await act(async () => {
+ dispatcher.addLocalSkillToBotProjectFile(testSkillId);
+ });
+
+ expect(renderedComponent.current.botProjectFile.content.skills.todoSkill.workspace).toBe(
+ 'file:///Users/tester/Desktop/LoadedBotProject/Todo-Skill'
+ );
+ expect(renderedComponent.current.botProjectFile.content.skills.todoSkill.remote).toBeFalsy();
+ });
+
+ it('should add a remote skill to bot project file', async () => {
+ const manifestUrl = 'https://test-dev.azurewebsites.net/manifests/test-2-1-preview-1-manifest.json';
+ await act(async () => {
+ renderedComponent.current.setSkillsData({
+ location: manifestUrl,
+ botNameIdentifier: 'oneNoteSkill',
+ });
+ });
+
+ await act(async () => {
+ dispatcher.addRemoteSkillToBotProjectFile(testSkillId, manifestUrl, 'remote');
+ });
+
+ expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill.manifest).toBe(manifestUrl);
+ expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill.workspace).toBeUndefined();
+ expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill.endpointName).toBe('remote');
+ });
+
+ it('should remove a skill from the bot project file', async () => {
+ const manifestUrl = 'https://test-dev.azurewebsites.net/manifests/test-2-1-preview-1-manifest.json';
+ await act(async () => {
+ renderedComponent.current.setSkillsData({
+ location: manifestUrl,
+ botNameIdentifier: 'oneNoteSkill',
+ });
+ });
+
+ await act(async () => {
+ dispatcher.addRemoteSkillToBotProjectFile(testSkillId, manifestUrl, 'remote');
+ });
+ expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill.manifest).toBe(manifestUrl);
+
+ await act(async () => {
+ dispatcher.removeSkillFromBotProjectFile(testSkillId);
+ });
+ expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill).toBeUndefined();
+ });
+});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx
index 6e07c5aca1..ba7ea87a79 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx
@@ -3,7 +3,7 @@
import { useRecoilValue } from 'recoil';
import test from '@bfc/indexers';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import { dialogsDispatcher } from '../dialogs';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
@@ -45,6 +45,12 @@ jest.mock('@bfc/indexers', () => {
content,
}),
},
+ qnaIndexer: {
+ parse: (id, content) => ({
+ id,
+ content,
+ }),
+ },
lgUtil: {
parse: (id, content) => ({
id,
@@ -57,6 +63,12 @@ jest.mock('@bfc/indexers', () => {
content,
}),
},
+ qnaUtil: {
+ parse: (id, content) => ({
+ id,
+ content,
+ }),
+ },
};
});
@@ -120,8 +132,9 @@ describe('dialog dispatcher', () => {
states: [
{ recoilState: dialogsState(projectId), initialValue: [{ id: '1' }, { id: '2' }] },
{ recoilState: dialogSchemasState(projectId), initialValue: [{ id: '1' }, { id: '2' }] },
- { recoilState: lgFilesState(projectId), initialValue: [{ id: '1.lg' }, { id: '2' }] },
- { recoilState: luFilesState(projectId), initialValue: [{ id: '1.lu' }, { id: '2' }] },
+ { recoilState: lgFilesState(projectId), initialValue: [{ id: '1.en-us' }, { id: '2.en-us' }] },
+ { recoilState: luFilesState(projectId), initialValue: [{ id: '1.en-us' }, { id: '2.en-us' }] },
+ { recoilState: qnaFilesState(projectId), initialValue: [{ id: '1.en-us' }, { id: '2.en-us' }] },
{ recoilState: schemasState(projectId), initialValue: { sdk: { content: '' } } },
],
dispatcher: {
@@ -137,16 +150,17 @@ describe('dialog dispatcher', () => {
it('removes a dialog file', async () => {
await act(async () => {
- await dispatcher.createDialog({ id: '1', content: 'abcde', projectId });
+ await dispatcher.createDialog({ id: '3', content: 'abcde', projectId });
});
await act(async () => {
- await dispatcher.removeDialog('1', projectId);
+ await dispatcher.removeDialog('3', projectId);
});
- expect(renderedComponent.current.dialogs).toEqual([{ id: '2' }]);
- expect(renderedComponent.current.dialogSchemas).toEqual([{ id: '2' }]);
- expect(renderedComponent.current.lgFiles).toEqual([{ id: '2' }]);
- expect(renderedComponent.current.luFiles).toEqual([{ id: '2' }]);
+ expect(renderedComponent.current.dialogs).toEqual([{ id: '1' }, { id: '2' }]);
+ expect(renderedComponent.current.dialogSchemas).toEqual([{ id: '1' }, { id: '2' }]);
+ expect(renderedComponent.current.lgFiles).toEqual([{ id: '1.en-us' }, { id: '2.en-us' }]);
+ expect(renderedComponent.current.luFiles).toEqual([{ id: '1.en-us' }, { id: '2.en-us' }]);
+ expect(renderedComponent.current.qnaFiles).toEqual([{ id: '1.en-us' }, { id: '2.en-us' }]);
});
it('updates a dialog file', async () => {
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialogSchema.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialogSchema.test.tsx
index 6a5070e9dd..ae3c6139fb 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialogSchema.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialogSchema.test.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useRecoilValue } from 'recoil';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import { dialogSchemaDispatcher } from '../dialogSchema';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/editor.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/editor.test.tsx
index 29e2e22403..c18a592d26 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/editor.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/editor.test.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useRecoilState, useRecoilValue } from 'recoil';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import { editorDispatcher } from '../editor';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/export.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/export.test.tsx
index c02ff75ea7..62a3a2b590 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/export.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/export.test.tsx
@@ -2,12 +2,12 @@
// Licensed under the MIT License.
import { useRecoilValue } from 'recoil';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import httpClient from '../../../utils/httpUtil';
import { exportDispatcher } from '../export';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
-import { botNameState, currentProjectIdState } from '../../atoms';
+import { botDisplayNameState, currentProjectIdState } from '../../atoms';
import { dispatcherState } from '../../../recoilModel/DispatcherWrapper';
import { Dispatcher } from '../../../recoilModel/dispatchers';
@@ -22,7 +22,7 @@ describe('Export dispatcher', () => {
prevAppendChild = document.body.appendChild;
const useRecoilTestHook = () => {
- const botName = useRecoilValue(botNameState(projectId));
+ const botName = useRecoilValue(botDisplayNameState(projectId));
const currentDispatcher = useRecoilValue(dispatcherState);
return {
botName,
@@ -33,7 +33,7 @@ describe('Export dispatcher', () => {
const { result } = renderRecoilHook(useRecoilTestHook, {
states: [
{ recoilState: currentProjectIdState, initialValue: projectId },
- { recoilState: botNameState(projectId), initialValue: 'emptybot-1' },
+ { recoilState: botDisplayNameState(projectId), initialValue: 'emptybot-1' },
],
dispatcher: {
recoilState: dispatcherState,
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx
index 34b42bf1d0..913b182e2e 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx
@@ -4,7 +4,7 @@
import { useRecoilState } from 'recoil';
import { LgFile, LgTemplate } from '@bfc/shared';
import { useRecoilValue } from 'recoil';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import { lgDispatcher } from '../lg';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lu.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lu.test.tsx
index dd7f2df8f8..6e6cd0df73 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lu.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lu.test.tsx
@@ -4,7 +4,7 @@
import { useRecoilState } from 'recoil';
import { LuIntentSection, LuFile } from '@bfc/shared';
import { useRecoilValue } from 'recoil';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import { luUtil } from '@bfc/indexers';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
@@ -13,9 +13,11 @@ import { dispatcherState } from '../../../recoilModel/DispatcherWrapper';
import { Dispatcher } from '..';
import { luDispatcher } from '../lu';
+const luFeatures = {};
+
jest.mock('../../parsers/luWorker', () => {
return {
- parse: (id, content) => ({ id, content }),
+ parse: (id, content, luFeatures) => ({ id, content, luFeatures }),
addIntent: require('@bfc/indexers/lib/utils/luUtil').addIntent,
addIntents: require('@bfc/indexers/lib/utils/luUtil').addIntents,
updateIntent: require('@bfc/indexers/lib/utils/luUtil').updateIntent,
@@ -29,7 +31,7 @@ const file1 = {
content: `\r\n# Hello\r\n-hi`,
};
-const luFiles = [luUtil.parse(file1.id, file1.content)] as LuFile[];
+const luFiles = [luUtil.parse(file1.id, file1.content, luFeatures)] as LuFile[];
const getLuIntent = (Name, Body): LuIntentSection =>
({
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json
new file mode 100644
index 0000000000..b1bf6ae908
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "",
+ "name": "TesterBot",
+ "workspace": "file:///Users/tester/Desktop/LoadedBotProject/TesterBot",
+ "skills": {
+ "todoSkill": {
+ "workspace": "file:///Users/tester/Desktop/LoadedBotProject/Todo-Skill",
+ "manifest": "Todo-Skill-2-1-preview-1-manifest",
+ "remote": false,
+ "endpointName": "default"
+ },
+ "googleKeepSync": {
+ "workspace": "file:///Users/tester/Desktop/LoadedBotProject/GoogleKeepSync",
+ "remote": false
+ }
+ }
+}
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockManifest.json b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockManifest.json
new file mode 100644
index 0000000000..839e03f889
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockManifest.json
@@ -0,0 +1,25 @@
+
+{
+ "$schema": "https://schemas.botframework.com/schemas/skills/skill-manifest-2.1.preview-1.json",
+ "$id": "OneNoteSync",
+ "name": "OneNoteSync",
+ "version": "1.0",
+ "publisherName": "Microsoft",
+ "description": "Sync notes to OneNote",
+ "endpoints": [
+ {
+ "name": "default",
+ "protocol": "BotFrameworkV3",
+ "description": "Local endpoint for SkillBot.",
+ "endpointUrl": "http://localhost:3988/api/messages",
+ "msAppId": "123-b33a9-4b2bb-9d6d-21"
+ },
+ {
+ "name": "remote",
+ "protocol": "BotFrameworkV3",
+ "description": "Production endpoint for SkillBot.",
+ "endpointUrl": "https://test.net/api/messages",
+ "msAppId": "123-8138c-43144-8676-21"
+ }
+ ]
+}
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json
index 1f752cb915..e815347024 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json
@@ -6,8 +6,8 @@
{
"name": "emptybot-1.dialog",
"content": "{\n \"$kind\": \"Microsoft.AdaptiveDialog\",\n \"$designer\": {\n \"name\": \"AddItem\",\n \"id\": \"225905\"\n },\n \"autoEndDialog\": true,\n \"defaultResultProperty\": \"dialog.result\",\n \"triggers\": [\n {\n \"$kind\": \"Microsoft.OnBeginDialog\",\n \"$designer\": {\n \"name\": \"BeginDialog\",\n \"id\": \"479346\"\n },\n \"actions\": [\n {\n \"$kind\": \"Microsoft.SetProperties\",\n \"$designer\": {\n \"id\": \"811190\",\n \"name\": \"Set properties\"\n },\n \"assignments\": [\n {\n \"property\": \"dialog.itemTitle\",\n \"value\": \"=coalesce(@itemTitle, $itemTitle)\"\n },\n {\n \"property\": \"dialog.listType\",\n \"value\": \"=coalesce(@listType, $listType)\"\n }\n ]\n },\n {\n \"$kind\": \"Microsoft.TextInput\",\n \"$designer\": {\n \"id\": \"282825\",\n \"name\": \"AskForTitle\"\n },\n \"prompt\": \"${TextInput_Prompt_282825()}\",\n \"maxTurnCount\": \"3\",\n \"property\": \"dialog.itemTitle\",\n \"value\": \"=coalesce(@itemTitle, $itemTitle)\",\n \"allowInterruptions\": \"!@itemTitle && #_Interruption.Score >= 0.9\"\n },\n {\n \"$kind\": \"Microsoft.ChoiceInput\",\n \"$designer\": {\n \"id\": \"878594\",\n \"name\": \"AskForListType\"\n },\n \"prompt\": \"${TextInput_Prompt_878594()}\",\n \"maxTurnCount\": \"3\",\n \"property\": \"dialog.listType\",\n \"value\": \"=@listType\",\n \"allowInterruptions\": \"!@listType\",\n \"outputFormat\": \"value\",\n \"choices\": [\n {\n \"value\": \"todo\",\n \"synonyms\": [\n \"to do\"\n ]\n },\n {\n \"value\": \"grocery\",\n \"synonyms\": [\n \"groceries\"\n ]\n },\n {\n \"value\": \"shopping\",\n \"synonyms\": [\n \"shoppers\"\n ]\n }\n ],\n \"appendChoices\": \"true\",\n \"defaultLocale\": \"en-us\",\n \"style\": \"Auto\",\n \"choiceOptions\": {\n \"inlineSeparator\": \", \",\n \"inlineOr\": \" or \",\n \"inlineOrMore\": \", or \",\n \"includeNumbers\": true\n },\n \"recognizerOptions\": {\n \"noValue\": false\n }\n },\n {\n \"$kind\": \"Microsoft.EditArray\",\n \"$designer\": {\n \"id\": \"733511\",\n \"name\": \"Edit an Array property\"\n },\n \"changeType\": \"push\",\n \"itemsProperty\": \"user.lists[dialog.listType]\",\n \"value\": \"=$itemTitle\"\n },\n {\n \"$kind\": \"Microsoft.SendActivity\",\n \"$designer\": {\n \"id\": \"139532\",\n \"name\": \"Send a response\"\n },\n \"activity\": \"${SendActivity_139532()}\"\n }\n ]\n }\n ],\n \"generator\": \"additem.lg\",\n \"recognizer\": \"additem.lu\"\n}\n",
- "path": "/Users/tester/Desktop/EmptyBot-1/dialogs/additem/additem.dialog",
- "relativePath": "dialogs/additem/additem.dialog",
+ "path": "/Users/tester/Desktop/EmptyBot-1/additem.dialog",
+ "relativePath": "",
"lastModified": "Thu Jul 09 2020 10:19:09 GMT-0700 (Pacific Daylight Time)"
},
{
@@ -23,6 +23,12 @@
"path": "/Users/tester/Desktop/EmptyBot-1/dialogs/additem/language-understanding/en-us/additem.en-us.lu",
"relativePath": "dialogs/additem/language-understanding/en-us/additem.en-us.lu",
"lastModified": "Thu Jul 09 2020 10:19:09 GMT-0700 (Pacific Daylight Time)"
+ },{
+ "name": "EmptyBot-1.botproj",
+ "content": "{\"$schema\":\"https:\/\/schemas.botframework.com\/schemas\/botprojects\/v0.1\/botproject-schema.json\",\"name\":\"echobot-0\",\"workspace\":\"\/Users\/tester\/Desktop\/samples\/EchoBot-0\",\"skills\":{}}",
+ "path": "/Users/tester/Desktop/EmptyBot-1/EmptyBot-1.botproj",
+ "relativePath": "dialogs/additem/language-understanding/en-us/additem.en-us.lu",
+ "lastModified": "Thu Jul 09 2020 10:19:09 GMT-0700 (Pacific Daylight Time)"
}
],
"location": "/Users/tester/Desktop/EmptyBot-1",
@@ -8623,7 +8629,7 @@
},
"diagnostics": []
},
- "skills": [],
+ "skills": {},
"diagnostics": [],
"settings": {
"feature": {
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/multilang.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/multilang.test.tsx
index 2383f43e87..4d0cd03677 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/multilang.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/multilang.test.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useRecoilValue } from 'recoil';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
import {
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/navigation.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/navigation.test.tsx
index 44385b6fc4..7ef6a2d6c4 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/navigation.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/navigation.test.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useRecoilValue } from 'recoil';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import { SDKKinds } from '@bfc/shared';
import { navigationDispatcher } from '../navigation';
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx
index b572b7f35d..b29387b55a 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx
@@ -1,12 +1,17 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { useRecoilValue } from 'recoil';
-import { act, RenderHookResult, HookResult } from '@bfc/test-utils/lib/hooks';
+import { selector, useRecoilValue } from 'recoil';
+import { v4 as uuid } from 'uuid';
+import { act, RenderHookResult, HookResult } from '@botframework-composer/test-utils/lib/hooks';
import { useRecoilState } from 'recoil';
+import cloneDeep from 'lodash/cloneDeep';
+import endsWith from 'lodash/endsWith';
+import findIndex from 'lodash/findIndex';
import httpClient from '../../../utils/httpUtil';
import { projectDispatcher } from '../project';
+import { botProjectFileDispatcher } from '../botProjectFile';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
import {
recentProjectsState,
@@ -28,15 +33,22 @@ import {
schemasState,
locationState,
skillsState,
- botOpeningState,
botStatusState,
- botNameState,
+ botDisplayNameState,
+ botOpeningState,
+ botProjectFileState,
+ botProjectIdsState,
+ botNameIdentifierState,
+ botErrorState,
+ botProjectSpaceLoadedState,
} from '../../atoms';
import { dispatcherState } from '../../../recoilModel/DispatcherWrapper';
import { Dispatcher } from '../../dispatchers';
import { BotStatus } from '../../../constants';
-import mockProjectResponse from './mocks/mockProjectResponse.json';
+import mockProjectData from './mocks/mockProjectResponse.json';
+import mockManifestData from './mocks/mockManifest.json';
+import mockBotProjectFileData from './mocks/mockBotProjectFile.json';
// let httpMocks;
let navigateTo;
@@ -73,11 +85,35 @@ jest.mock('../../persistence/FilePersistence', () => {
});
describe('Project dispatcher', () => {
+ let mockProjectResponse, mockManifestResponse, mockBotProjectResponse;
+ const botStatesSelector = selector({
+ key: 'botStatesSelector',
+ get: ({ get }) => {
+ const botProjectIds = get(botProjectIdsState);
+ const botProjectData: { [projectName: string]: any } = {};
+ botProjectIds.map((projectId) => {
+ const botDisplayName = get(botDisplayNameState(projectId));
+ const botNameIdentifier = get(botNameIdentifierState(projectId));
+ const botError = get(botErrorState(projectId));
+ const location = get(locationState(projectId));
+ if (botNameIdentifier) {
+ botProjectData[botNameIdentifier] = {
+ botDisplayName,
+ location,
+ botError,
+ projectId,
+ };
+ }
+ });
+ return botProjectData;
+ },
+ });
+
const useRecoilTestHook = () => {
const schemas = useRecoilValue(schemasState(projectId));
const location = useRecoilValue(locationState(projectId));
const skills = useRecoilValue(skillsState(projectId));
- const botName = useRecoilValue(botNameState(projectId));
+ const botName = useRecoilValue(botDisplayNameState(projectId));
const skillManifests = useRecoilValue(skillManifestsState(projectId));
const luFiles = useRecoilValue(luFilesState(projectId));
const lgFiles = useRecoilValue(lgFilesState(projectId));
@@ -87,8 +123,9 @@ describe('Project dispatcher', () => {
const diagnostics = useRecoilValue(botDiagnosticsState(projectId));
const locale = useRecoilValue(localeState(projectId));
const botStatus = useRecoilValue(botStatusState(projectId));
+ const botStates = useRecoilValue(botStatesSelector);
+ const botProjectSpaceLoaded = useRecoilValue(botProjectSpaceLoadedState);
- const botOpening = useRecoilValue(botOpeningState);
const currentDispatcher = useRecoilValue(dispatcherState);
const [recentProjects, setRecentProjects] = useRecoilState(recentProjectsState);
const appError = useRecoilValue(applicationErrorState);
@@ -97,9 +134,10 @@ describe('Project dispatcher', () => {
const boilerplateVersion = useRecoilValue(boilerplateVersionState);
const templates = useRecoilValue(templateProjectsState);
const runtimeTemplates = useRecoilValue(runtimeTemplatesState);
+ const botOpening = useRecoilValue(botOpeningState);
+ const [botProjectFile, setBotProjectFile] = useRecoilState(botProjectFileState(projectId));
return {
- botOpening,
skillManifests,
luFiles,
lgFiles,
@@ -117,19 +155,27 @@ describe('Project dispatcher', () => {
currentDispatcher,
recentProjects,
appError,
- setRecentProjects,
templateId,
announcement,
boilerplateVersion,
templates,
runtimeTemplates,
+ botOpening,
+ botProjectFile,
+ setBotProjectFile,
+ setRecentProjects,
+ botStates,
+ botProjectSpaceLoaded,
};
};
let renderedComponent: HookResult>, dispatcher: Dispatcher;
- beforeEach(() => {
+ beforeEach(async () => {
navigateTo.mockReset();
+ mockProjectResponse = cloneDeep(mockProjectData);
+ mockManifestResponse = cloneDeep(mockManifestData);
+ mockBotProjectResponse = cloneDeep(mockBotProjectFileData);
const rendered: RenderHookResult> = renderRecoilHook(
useRecoilTestHook,
{
@@ -138,6 +184,7 @@ describe('Project dispatcher', () => {
recoilState: dispatcherState,
initialValue: {
projectDispatcher,
+ botProjectFileDispatcher,
},
},
}
@@ -146,13 +193,25 @@ describe('Project dispatcher', () => {
dispatcher = renderedComponent.current.currentDispatcher;
});
+ it('should throw an error if no bot project file is present in the bot', async () => {
+ const cloned = cloneDeep(mockProjectResponse);
+ const filtered = cloned.files.filter((file) => !endsWith(file.name, '.botproj'));
+ cloned.files = filtered;
+ (httpClient.put as jest.Mock).mockResolvedValueOnce({
+ data: cloned,
+ });
+ await act(async () => {
+ await dispatcher.openProject('../test/empty-bot', 'default');
+ });
+ expect(navigateTo).toHaveBeenLastCalledWith(`/home`);
+ });
+
it('should open bot project', async () => {
- let result;
(httpClient.put as jest.Mock).mockResolvedValueOnce({
data: mockProjectResponse,
});
await act(async () => {
- result = await dispatcher.openProject('../test/empty-bot', 'default');
+ await dispatcher.openProject('../test/empty-bot', 'default');
});
expect(renderedComponent.current.projectId).toBe(mockProjectResponse.id);
@@ -166,8 +225,7 @@ describe('Project dispatcher', () => {
expect(renderedComponent.current.schemas.sdk).toBeDefined();
expect(renderedComponent.current.schemas.default).toBeDefined();
expect(renderedComponent.current.schemas.diagnostics?.length).toBe(0);
- expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/`);
- expect(result).toBe(renderedComponent.current.projectId);
+ expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/emptybot-1`);
});
it('should handle project failure if project does not exist', async () => {
@@ -187,7 +245,7 @@ describe('Project dispatcher', () => {
expect(renderedComponent.current.botOpening).toBeFalsy();
expect(renderedComponent.current.appError).toEqual(errorObj);
expect(renderedComponent.current.recentProjects.length).toBe(0);
- expect(navigateTo).not.toHaveBeenCalled();
+ expect(navigateTo).toHaveBeenLastCalledWith(`/home`);
});
it('should fetch recent projects', async () => {
@@ -200,36 +258,6 @@ describe('Project dispatcher', () => {
expect(renderedComponent.current.recentProjects).toEqual(recentProjects);
});
- it('should get runtime templates', async () => {
- const templates = [
- { id: 'EchoBot', index: 1, name: 'Echo Bot' },
- { id: 'EmptyBot', index: 2, name: 'Empty Bot' },
- ];
- (httpClient.get as jest.Mock).mockResolvedValue({
- data: templates,
- });
- await act(async () => {
- await dispatcher.fetchRuntimeTemplates();
- });
-
- expect(renderedComponent.current.runtimeTemplates).toEqual(templates);
- });
-
- it('should get templates', async () => {
- const templates = [
- { id: 'EchoBot', index: 1, name: 'Echo Bot' },
- { id: 'EmptyBot', index: 2, name: 'Empty Bot' },
- ];
- (httpClient.get as jest.Mock).mockResolvedValue({
- data: templates,
- });
- await act(async () => {
- await dispatcher.fetchTemplates();
- });
-
- expect(renderedComponent.current.templates).toEqual(templates);
- });
-
it('should delete a project', async () => {
(httpClient.delete as jest.Mock).mockResolvedValue({ data: {} });
(httpClient.put as jest.Mock).mockResolvedValueOnce({
@@ -237,7 +265,7 @@ describe('Project dispatcher', () => {
});
await act(async () => {
await dispatcher.openProject('../test/empty-bot', 'default');
- await dispatcher.deleteBotProject(projectId);
+ await dispatcher.deleteBot(projectId);
});
expect(renderedComponent.current.botName).toEqual('');
@@ -277,7 +305,7 @@ describe('Project dispatcher', () => {
expect(renderedComponent.current.announcement).toEqual('Scripts successfully updated.');
});
- it('should get bolierplate version', async () => {
+ it('should get boilerplate version', async () => {
const version = { updateRequired: true, latestVersion: '3', currentVersion: '2' };
(httpClient.get as jest.Mock).mockResolvedValue({
data: version,
@@ -288,4 +316,208 @@ describe('Project dispatcher', () => {
expect(renderedComponent.current.boilerplateVersion).toEqual(version);
});
+
+ it('should be able to add an existing skill to Botproject', async () => {
+ (httpClient.get as jest.Mock).mockResolvedValueOnce({
+ data: {},
+ });
+ const skills = [
+ { botName: 'Echo-Skill-1', id: '40876.502871204648', location: '/Users/tester/Desktop/Echo-Skill-1' },
+ { botName: 'Echo-Skill-2', id: '50876.502871204648', location: '/Users/tester/Desktop/Echo-Skill-2' },
+ ];
+ const mappedSkills = skills.map(({ botName, id, location }) => {
+ const cloned = cloneDeep(mockProjectResponse);
+ return {
+ ...cloned,
+ botName,
+ id,
+ location,
+ };
+ });
+
+ await act(async () => {
+ (httpClient.put as jest.Mock).mockResolvedValueOnce({
+ data: mockProjectResponse,
+ });
+ await dispatcher.openProject('../test/empty-bot', 'default');
+ });
+
+ await act(async () => {
+ (httpClient.put as jest.Mock).mockResolvedValueOnce({
+ data: mappedSkills[0],
+ });
+ await dispatcher.addExistingSkillToBotProject(mappedSkills[0].location, 'default');
+ });
+
+ expect(renderedComponent.current.botStates.echoSkill1).toBeDefined();
+ expect(renderedComponent.current.botStates.echoSkill1.botDisplayName).toBe('Echo-Skill-1');
+
+ await act(async () => {
+ (httpClient.put as jest.Mock).mockResolvedValueOnce({
+ data: mappedSkills[1],
+ });
+ await dispatcher.addExistingSkillToBotProject(mappedSkills[1].location, 'default');
+ });
+
+ expect(renderedComponent.current.botStates.echoSkill2).toBeDefined();
+ expect(renderedComponent.current.botStates.echoSkill2.botDisplayName).toBe('Echo-Skill-2');
+
+ await act(async () => {
+ await dispatcher.addRemoteSkillToBotProject('https://test.net/api/manifest/man', 'test-skill', 'remote');
+ });
+
+ expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/emptybot-1`);
+ });
+
+ it('should be able to add a remote skill to Botproject', async () => {
+ const mockImplementation = (httpClient.get as jest.Mock).mockImplementation((url: string) => {
+ if (endsWith(url, '/projects/generateProjectId')) {
+ return {
+ data: '1234.1123213',
+ };
+ } else {
+ return {
+ data: mockManifestResponse,
+ };
+ }
+ });
+
+ await act(async () => {
+ (httpClient.put as jest.Mock).mockResolvedValueOnce({
+ data: mockProjectResponse,
+ });
+ await dispatcher.openProject('../test/empty-bot', 'default');
+ });
+
+ await act(async () => {
+ await dispatcher.addRemoteSkillToBotProject(
+ 'https://test-dev.azurewebsites.net/manifests/onenote-2-1-preview-1-manifest.json',
+ 'one-note',
+ 'remote'
+ );
+ });
+
+ expect(renderedComponent.current.botStates.oneNote).toBeDefined();
+ expect(renderedComponent.current.botStates.oneNote.botDisplayName).toBe('OneNoteSync');
+ expect(renderedComponent.current.botStates.oneNote.location).toBe(
+ 'https://test-dev.azurewebsites.net/manifests/onenote-2-1-preview-1-manifest.json'
+ );
+ expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/emptybot-1`);
+ mockImplementation.mockClear();
+ });
+
+ it('should remove a skill from bot project', async () => {
+ const mockImplementation = (httpClient.get as jest.Mock).mockImplementation((url: string) => {
+ if (endsWith(url, '/projects/generateProjectId')) {
+ return {
+ data: uuid(),
+ };
+ } else {
+ return {
+ data: mockManifestResponse,
+ };
+ }
+ });
+
+ await act(async () => {
+ (httpClient.put as jest.Mock).mockResolvedValueOnce({
+ data: mockProjectResponse,
+ });
+ await dispatcher.openProject('../test/empty-bot', 'default');
+ });
+
+ await act(async () => {
+ await dispatcher.addRemoteSkillToBotProject(
+ 'https://test-dev.azurewebsites.net/manifests/onenote-2-1-preview-1-manifest.json',
+ 'one-note',
+ 'remote'
+ );
+ });
+
+ await act(async () => {
+ await dispatcher.addRemoteSkillToBotProject(
+ 'https://test-dev.azurewebsites.net/manifests/onenote-second-manifest.json',
+ 'one-note-2',
+ 'remote'
+ );
+ });
+
+ const oneNoteProjectId = renderedComponent.current.botStates.oneNote.projectId;
+ mockImplementation.mockClear();
+
+ await act(async () => {
+ dispatcher.removeSkillFromBotProject(oneNoteProjectId);
+ });
+ expect(renderedComponent.current.botStates.oneNote).toBeUndefined();
+ });
+
+ it('should be able to add a new skill to Botproject', async () => {
+ await act(async () => {
+ (httpClient.put as jest.Mock).mockResolvedValueOnce({
+ data: mockProjectResponse,
+ });
+ await dispatcher.openProject('../test/empty-bot', 'default');
+ });
+
+ const newProjectDataClone = cloneDeep(mockProjectResponse);
+ newProjectDataClone.botName = 'new-bot';
+ await act(async () => {
+ (httpClient.post as jest.Mock).mockResolvedValueOnce({
+ data: newProjectDataClone,
+ });
+ await dispatcher.addNewSkillToBotProject({
+ name: 'new-bot',
+ description: '',
+ schemaUrl: '',
+ location: '/Users/tester/Desktop/samples',
+ templateId: 'InterruptionSample',
+ locale: 'us-en',
+ qnaKbUrls: [],
+ });
+ });
+
+ expect(renderedComponent.current.botStates.newBot).toBeDefined();
+ expect(renderedComponent.current.botStates.newBot.botDisplayName).toBe('new-bot');
+ expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/emptybot-1`);
+ });
+
+ it('should be able to open a project and its skills in Bot project file', async (done) => {
+ let callIndex = 0;
+ (httpClient.put as jest.Mock).mockImplementation(() => {
+ let mockSkillData: any;
+ callIndex++;
+ switch (callIndex) {
+ case 1:
+ return Promise.resolve({ data: mockProjectResponse });
+ case 2: {
+ mockSkillData = cloneDeep(mockProjectResponse);
+ mockSkillData.botName = 'todo-skill';
+ mockSkillData.id = '20876.502871204648';
+ return Promise.resolve({ data: mockSkillData });
+ }
+ case 3: {
+ mockSkillData = cloneDeep(mockProjectResponse);
+ mockSkillData.botName = 'google-keep-sync';
+ mockSkillData.id = '50876.502871204648';
+ return Promise.resolve({ data: mockSkillData });
+ }
+ }
+ });
+ const matchIndex = findIndex(mockProjectResponse.files, (file: any) => endsWith(file.name, '.botproj'));
+ mockProjectResponse.files[matchIndex] = {
+ ...mockProjectResponse.files[matchIndex],
+ content: JSON.stringify(mockBotProjectResponse),
+ };
+ expect(renderedComponent.current.botProjectSpaceLoaded).toBeFalsy();
+
+ await act(async () => {
+ await dispatcher.openProject('../test/empty-bot', 'default');
+ });
+ setImmediate(() => {
+ expect(renderedComponent.current.botStates.todoSkill.botDisplayName).toBe('todo-skill');
+ expect(renderedComponent.current.botStates.googleKeepSync.botDisplayName).toBe('google-keep-sync');
+ expect(renderedComponent.current.botProjectSpaceLoaded).toBeTruthy();
+ done();
+ });
+ });
});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx
new file mode 100644
index 0000000000..3800f10c2e
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx
@@ -0,0 +1,276 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { useRecoilState } from 'recoil';
+import { QnAFile } from '@bfc/shared';
+import { qnaUtil } from '@bfc/indexers';
+import { useRecoilValue } from 'recoil';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
+
+import { qnaDispatcher } from '../qna';
+import { renderRecoilHook } from '../../../../__tests__/testUtils';
+import { qnaFilesState, currentProjectIdState, localeState } from '../../atoms';
+import { dispatcherState } from '../../../recoilModel/DispatcherWrapper';
+import { Dispatcher } from '..';
+
+jest.mock('../../parsers/qnaWorker', () => {
+ const filterParseResult = (qnaFile: QnAFile) => {
+ const cloned = { ...qnaFile, resource: '' };
+ return cloned;
+ };
+ return {
+ parse: (id, content) => ({ id, content }),
+ removeSection: (projectId, qnaFile, sectionId) =>
+ filterParseResult(require('@bfc/indexers/lib/utils/qnaUtil').removeSection(qnaFile, sectionId)),
+ insertSection: (projectId, qnaFile, position, sectionContent) =>
+ filterParseResult(require('@bfc/indexers/lib/utils/qnaUtil').insertSection(qnaFile, position, sectionContent)),
+ createQnAQuestion: (projectId, qnaFile, sectionId, questionContent) =>
+ filterParseResult(
+ require('@bfc/indexers/lib/utils/qnaUtil').createQnAQuestion(qnaFile, sectionId, questionContent)
+ ),
+ updateQnAQuestion: (projectId, qnaFile, sectionId, questionId, questionContent) =>
+ filterParseResult(
+ require('@bfc/indexers/lib/utils/qnaUtil').updateQnAQuestion(qnaFile, sectionId, questionId, questionContent)
+ ),
+ removeQnAQuestion: (projectId, qnaFile, sectionId, questionId) =>
+ filterParseResult(require('@bfc/indexers/lib/utils/qnaUtil').removeQnAQuestion(qnaFile, sectionId, questionId)),
+ updateQnAAnswer: (projectId, qnaFile, sectionId, answerContent) =>
+ filterParseResult(require('@bfc/indexers/lib/utils/qnaUtil').updateQnAAnswer(qnaFile, sectionId, answerContent)),
+ addImport: (projectId, qnaFile, path) =>
+ filterParseResult(require('@bfc/indexers/lib/utils/qnaUtil').addImport(qnaFile, path)),
+ removeImport: (projectId, qnaFile, path) =>
+ filterParseResult(require('@bfc/indexers/lib/utils/qnaUtil').removeImport(qnaFile, path)),
+ };
+});
+
+const projectId = '123asad.123sad';
+const locale = 'en-us';
+
+const content = `# ? What's your name?
+\`\`\`
+Zoidberg
+\`\`\``;
+
+const qna1 = qnaUtil.parse('common.en-us', content);
+const qnaFiles = [qna1];
+
+describe('QnA dispatcher', () => {
+ let renderedComponent, dispatcher: Dispatcher;
+ beforeEach(() => {
+ const useRecoilTestHook = () => {
+ const [qnaFiles, setQnAFiles] = useRecoilState(qnaFilesState(projectId));
+ const currentDispatcher = useRecoilValue(dispatcherState);
+
+ return {
+ qnaFiles,
+ setQnAFiles,
+ currentDispatcher,
+ };
+ };
+
+ const { result } = renderRecoilHook(useRecoilTestHook, {
+ states: [
+ { recoilState: qnaFilesState(projectId), initialValue: qnaFiles },
+ { recoilState: currentProjectIdState, initialValue: projectId },
+ { recoilState: localeState(projectId), initialValue: locale },
+ ],
+ dispatcher: {
+ recoilState: dispatcherState,
+ initialValue: {
+ qnaDispatcher,
+ },
+ },
+ });
+ renderedComponent = result;
+ dispatcher = renderedComponent.current.currentDispatcher;
+ });
+
+ it('should create a qna pairs', async () => {
+ const content = qnaUtil.generateQnAPair('Test', '-add');
+ await act(async () => {
+ await dispatcher.createQnAPairs({
+ id: 'common.en-us',
+ content,
+ projectId,
+ });
+ });
+
+ expect(renderedComponent.current.qnaFiles[0].content).toContain(content);
+ });
+
+ it('should update a qna file', async () => {
+ const content = qnaUtil.generateQnAPair('Test', '-update');
+ await act(async () => {
+ await dispatcher.updateQnAFile({ id: 'common.en-us', content, projectId });
+ });
+
+ expect(renderedComponent.current.qnaFiles[0].content).toBe(content);
+ });
+
+ it('should update a qna question', async () => {
+ await act(async () => {
+ await dispatcher.updateQnAQuestion({
+ id: 'common.en-us',
+ sectionId: qna1.qnaSections[0].sectionId,
+ questionId: qna1.qnaSections[0].Questions[0].id,
+ content: 'What is your name, my friend?',
+ projectId,
+ });
+ });
+
+ expect(renderedComponent.current.qnaFiles[0].qnaSections[0].Questions[0].content).toContain(
+ 'What is your name, my friend?'
+ );
+ });
+
+ it('should create/remove a qna question', async () => {
+ await act(async () => {
+ await dispatcher.createQnAQuestion({
+ id: 'common.en-us',
+ sectionId: qna1.qnaSections[0].sectionId,
+ content: 'What is your name, my friend?',
+ projectId,
+ });
+ });
+ const section = renderedComponent.current.qnaFiles[0].qnaSections[0];
+
+ expect(section.Questions[1].content).toContain('What is your name, my friend?');
+
+ await act(async () => {
+ await dispatcher.removeQnAQuestion({
+ id: 'common.en-us',
+ sectionId: section.sectionId,
+ questionId: section.Questions[1].id,
+ projectId,
+ });
+ });
+
+ expect(renderedComponent.current.qnaFiles[0].content).not.toContain('What is your name, my friend?');
+ });
+
+ it('should update a qna answer', async () => {
+ await act(async () => {
+ await dispatcher.updateQnAAnswer({
+ id: 'common.en-us',
+ sectionId: qna1.qnaSections[0].sectionId,
+ content: 'Bender',
+ projectId,
+ });
+ });
+
+ expect(renderedComponent.current.qnaFiles[0].qnaSections[0].Answer).toBe('Bender');
+ });
+
+ it('should remove a qna pair', async () => {
+ await act(async () => {
+ await dispatcher.removeQnAPairs({
+ id: 'common.en-us',
+ sectionId: qna1.qnaSections[0].sectionId,
+ projectId,
+ });
+ });
+
+ expect(renderedComponent.current.qnaFiles[0].content).toBe(``);
+ });
+
+ it('should create/remove import', async () => {
+ await act(async () => {
+ await dispatcher.createQnAImport({
+ id: 'common.en-us',
+ sourceId: 'guide.source',
+ projectId,
+ });
+ });
+
+ expect(renderedComponent.current.qnaFiles[0].content).toContain('[import](guide.source.qna)');
+
+ await act(async () => {
+ await dispatcher.removeQnAImport({
+ id: 'common.en-us',
+ sourceId: 'guide.source',
+ projectId,
+ });
+ });
+
+ expect(renderedComponent.current.qnaFiles[0].content).not.toContain('[import](guide.source.qna)');
+ });
+
+ it('should create/remove qna file', async () => {
+ await act(async () => {
+ await dispatcher.createQnAFile({
+ id: 'guide',
+ content: '> guide',
+ projectId,
+ });
+ });
+
+ const createdFile = renderedComponent.current.qnaFiles.find(({ id }) => id === 'guide.en-us');
+ expect(createdFile).not.toBeFalsy();
+ expect(createdFile.content).toBe('> guide');
+
+ await act(async () => {
+ await dispatcher.removeQnAFile({
+ id: 'guide',
+ projectId,
+ });
+ });
+
+ const createdFileAfterRemove = renderedComponent.current.qnaFiles.find(({ id }) => id === 'guide.en-us');
+ expect(createdFileAfterRemove).toBeFalsy();
+ expect(renderedComponent.current.qnaFiles.length).toBe(1);
+ });
+
+ it('should create qna kb from scratch and auto create import', async () => {
+ await act(async () => {
+ await dispatcher.createQnAKBFromScratch({
+ id: 'common.en-us',
+ name: 'guide',
+ projectId,
+ });
+ });
+
+ const createdFile = renderedComponent.current.qnaFiles.find(({ id }) => id === 'guide.source');
+ expect(createdFile).not.toBeFalsy();
+
+ const commonFile = renderedComponent.current.qnaFiles.find(({ id }) => id === 'common.en-us');
+ expect(commonFile.content).toContain('[import](guide.source.qna)');
+ });
+
+ it('should rename qna kb and re-create import', async () => {
+ await act(async () => {
+ await dispatcher.createQnAKBFromScratch({
+ id: 'common.en-us',
+ name: 'guide',
+ projectId,
+ });
+
+ await dispatcher.renameQnAKB({
+ id: 'guide.source',
+ name: 'guide2.source',
+ projectId,
+ });
+
+ await dispatcher.removeQnAImport({
+ id: 'common.en-us',
+ sourceId: 'guide.source',
+ projectId,
+ });
+
+ await dispatcher.createQnAImport({
+ id: 'common.en-us',
+ sourceId: 'guide2.source',
+ projectId,
+ });
+ });
+
+ const createdFile1 = renderedComponent.current.qnaFiles.find(({ id }) => id === 'guide.source');
+ expect(createdFile1).toBeFalsy();
+
+ const createdFile2 = renderedComponent.current.qnaFiles.find(({ id }) => id === 'guide2.source');
+ expect(createdFile2).not.toBeFalsy();
+
+ const commonFile = renderedComponent.current.qnaFiles.find(({ id }) => id === 'common.en-us');
+ expect(commonFile.content).toContain('[import](guide2.source.qna)');
+ expect(commonFile.content).not.toContain('[import](guide.source.qna)');
+ });
+});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/setting.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/setting.test.tsx
index d5676354c2..8c6f4b3a4d 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/setting.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/setting.test.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useRecoilValue } from 'recoil';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
import { settingsState, currentProjectIdState, skillsState } from '../../atoms';
@@ -109,6 +109,7 @@ describe('setting dispatcher', () => {
MicrosoftAppPassword: 'test',
luis: { ...settings.luis, authoringKey: 'test', endpointKey: 'test' },
qna: { ...settings.qna, subscriptionKey: 'test', endpointKey: 'test' },
+ luFeatures: {},
});
});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts
index 61ebcd3c70..ffeced8fc7 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useRecoilValue } from 'recoil';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import { Skill } from '@bfc/shared';
import { skillDispatcher } from '../skill';
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/storage.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/storage.test.tsx
new file mode 100644
index 0000000000..29b27e6163
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/storage.test.tsx
@@ -0,0 +1,93 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { useRecoilValue } from 'recoil';
+import { act, RenderHookResult, HookResult } from '@botframework-composer/test-utils/lib/hooks';
+
+import httpClient from '../../../utils/httpUtil';
+import { storageDispatcher } from '../storage';
+import { renderRecoilHook } from '../../../../__tests__/testUtils';
+import { runtimeTemplatesState, currentProjectIdState } from '../../atoms';
+import { dispatcherState } from '../../../recoilModel/DispatcherWrapper';
+import { Dispatcher } from '../../dispatchers';
+
+// let httpMocks;
+let navigateTo;
+
+const projectId = '30876.502871204648';
+
+jest.mock('../../../utils/navigation', () => {
+ const navigateMock = jest.fn();
+ navigateTo = navigateMock;
+ return {
+ navigateTo: navigateMock,
+ };
+});
+
+jest.mock('../../../utils/httpUtil');
+
+jest.mock('../../parsers/lgWorker', () => {
+ return {
+ flush: () => new Promise((resolve) => resolve()),
+ addProject: () => new Promise((resolve) => resolve()),
+ };
+});
+
+jest.mock('../../parsers/luWorker', () => {
+ return {
+ flush: () => new Promise((resolve) => resolve()),
+ };
+});
+
+jest.mock('../../persistence/FilePersistence', () => {
+ return jest.fn().mockImplementation(() => {
+ return { flush: () => new Promise((resolve) => resolve()) };
+ });
+});
+
+describe('Storage dispatcher', () => {
+ const useRecoilTestHook = () => {
+ const runtimeTemplates = useRecoilValue(runtimeTemplatesState);
+ const currentDispatcher = useRecoilValue(dispatcherState);
+
+ return {
+ runtimeTemplates,
+ currentDispatcher,
+ };
+ };
+
+ let renderedComponent: HookResult>, dispatcher: Dispatcher;
+
+ beforeEach(() => {
+ navigateTo.mockReset();
+ const rendered: RenderHookResult> = renderRecoilHook(
+ useRecoilTestHook,
+ {
+ states: [{ recoilState: currentProjectIdState, initialValue: projectId }],
+ dispatcher: {
+ recoilState: dispatcherState,
+ initialValue: {
+ storageDispatcher,
+ },
+ },
+ }
+ );
+ renderedComponent = rendered.result;
+ dispatcher = renderedComponent.current.currentDispatcher;
+ });
+
+ it('should get runtime templates', async () => {
+ const templates = [
+ { id: 'EchoBot', index: 1, name: 'Echo Bot' },
+ { id: 'EmptyBot', index: 2, name: 'Empty Bot' },
+ ];
+ (httpClient.get as jest.Mock).mockResolvedValue({
+ data: templates,
+ });
+ await act(async () => {
+ await dispatcher.fetchRuntimeTemplates();
+ });
+
+ expect(renderedComponent.current.runtimeTemplates).toEqual(templates);
+ });
+});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/user.test.ts b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/user.test.ts
index 8db361f49a..343cffd0f6 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/user.test.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/user.test.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useRecoilValue } from 'recoil';
-import { act } from '@bfc/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import jwtDecode from 'jwt-decode';
import { userDispatcher } from '../user';
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts b/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts
new file mode 100644
index 0000000000..ad523b1498
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts
@@ -0,0 +1,80 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { CallbackInterface, useRecoilCallback } from 'recoil';
+import { produce } from 'immer';
+import { BotProjectSpaceSkill, convertAbsolutePathToFileProtocol } from '@bfc/shared';
+
+import { botNameIdentifierState, botProjectFileState, locationState } from '../atoms';
+import { rootBotProjectIdSelector } from '../selectors';
+
+export const botProjectFileDispatcher = () => {
+ const addLocalSkillToBotProjectFile = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async (skillId: string) => {
+ const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector);
+ if (!rootBotProjectId) {
+ return;
+ }
+ const skillLocation = await snapshot.getPromise(locationState(skillId));
+ const botName = await snapshot.getPromise(botNameIdentifierState(skillId));
+
+ set(botProjectFileState(rootBotProjectId), (current) => {
+ const result = produce(current, (draftState) => {
+ const skill: BotProjectSpaceSkill = {
+ workspace: convertAbsolutePathToFileProtocol(skillLocation),
+ remote: false,
+ };
+ draftState.content.skills[botName] = skill;
+ });
+ return result;
+ });
+ }
+ );
+
+ const addRemoteSkillToBotProjectFile = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async (skillId: string, manifestUrl: string, endpointName: string) => {
+ const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector);
+ if (!rootBotProjectId) {
+ return;
+ }
+ const botName = await snapshot.getPromise(botNameIdentifierState(skillId));
+
+ set(botProjectFileState(rootBotProjectId), (current) => {
+ const result = produce(current, (draftState) => {
+ const skill: BotProjectSpaceSkill = {
+ manifest: manifestUrl,
+ remote: true,
+ endpointName,
+ };
+
+ draftState.content.skills[botName] = skill;
+ });
+ return result;
+ });
+ }
+ );
+
+ const removeSkillFromBotProjectFile = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async (skillId: string) => {
+ const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector);
+ if (!rootBotProjectId) {
+ return;
+ }
+
+ const botName = await snapshot.getPromise(botNameIdentifierState(skillId));
+ set(botProjectFileState(rootBotProjectId), (current) => {
+ const result = produce(current, (draftState) => {
+ delete draftState.content.skills[botName];
+ });
+ return result;
+ });
+ }
+ );
+
+ return {
+ addLocalSkillToBotProjectFile,
+ removeSkillFromBotProjectFile,
+ addRemoteSkillToBotProjectFile,
+ };
+};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/export.ts b/Composer/packages/client/src/recoilModel/dispatchers/export.ts
index 3a00d0f35a..ed5eb0ed33 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/export.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/export.ts
@@ -5,13 +5,13 @@
import { CallbackInterface, useRecoilCallback } from 'recoil';
import httpClient from '../../utils/httpUtil';
-import { botNameState } from '../atoms';
+import { botDisplayNameState } from '../atoms';
import { logMessage } from './shared';
export const exportDispatcher = () => {
const exportToZip = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => {
- const botName = await callbackHelpers.snapshot.getPromise(botNameState(projectId));
+ const botName = await callbackHelpers.snapshot.getPromise(botDisplayNameState(projectId));
try {
const response = await httpClient.get(`/projects/${projectId}/export/`, { responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([response.data]));
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts b/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts
index b7196583f5..c89454c4fb 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts
@@ -3,10 +3,10 @@
// Licensed under the MIT License.
import { CallbackInterface, useRecoilCallback } from 'recoil';
+import { ExtensionMetadata } from '@bfc/extension-client';
import httpClient from '../../utils/httpUtil';
import { extensionsState } from '../atoms';
-import { ExtensionConfig } from '../types';
export const extensionsDispatcher = () => {
const fetchExtensions = useRecoilCallback((callbackHelpers: CallbackInterface) => async () => {
@@ -16,6 +16,7 @@ export const extensionsDispatcher = () => {
set(extensionsState, res.data);
} catch (err) {
+ // eslint-disable-next-line no-console
console.error(err);
}
});
@@ -25,7 +26,7 @@ export const extensionsDispatcher = () => {
const { set } = callbackHelpers;
try {
const res = await httpClient.post('/extensions', { id: extensionName, version });
- const addedExtension: ExtensionConfig = res.data;
+ const addedExtension: ExtensionMetadata = res.data;
set(extensionsState, (extensions) => {
if (extensions.find((p) => p.id === addedExtension.id)) {
@@ -40,6 +41,7 @@ export const extensionsDispatcher = () => {
}
});
} catch (err) {
+ // eslint-disable-next-line no-console
console.error(err);
}
}
@@ -52,6 +54,7 @@ export const extensionsDispatcher = () => {
set(extensionsState, res.data);
} catch (err) {
+ // eslint-disable-next-line no-console
console.error(err);
}
});
@@ -64,7 +67,7 @@ export const extensionsDispatcher = () => {
id: extensionId,
enabled: Boolean(enabled),
});
- const toggledExtension: ExtensionConfig = res.data;
+ const toggledExtension: ExtensionMetadata = res.data;
set(extensionsState, (extensions) => {
return (extensions = extensions.map((p) => {
@@ -76,7 +79,7 @@ export const extensionsDispatcher = () => {
}));
});
} catch (err) {
- // do nothing
+ // eslint-disable-next-line no-console
console.error(err);
}
}
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/formDialogs.ts b/Composer/packages/client/src/recoilModel/dispatchers/formDialogs.ts
new file mode 100644
index 0000000000..3b1257c9bd
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/dispatchers/formDialogs.ts
@@ -0,0 +1,102 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+/* eslint-disable react-hooks/rules-of-hooks */
+
+import { FormDialogSchemaTemplate } from '@bfc/shared';
+import { navigate } from '@reach/router';
+import formatMessage from 'format-message';
+import { CallbackInterface, useRecoilCallback } from 'recoil';
+
+import httpClient from '../../utils/httpUtil';
+import {
+ applicationErrorState,
+ formDialogGenerationProgressingState,
+ formDialogLibraryTemplatesState,
+} from '../atoms/appState';
+import { formDialogSchemaIdsState, formDialogSchemaState } from '../atoms/botState';
+import { dispatcherState } from '../DispatcherWrapper';
+
+export const formDialogsDispatcher = () => {
+ const createFormDialogSchema = useRecoilCallback(({ set }: CallbackInterface) => ({ id, projectId }) => {
+ set(formDialogSchemaIdsState(projectId), (formDialogSchemaIds) => {
+ return [...formDialogSchemaIds, id];
+ });
+
+ set(formDialogSchemaState({ projectId, schemaId: id }), { id, content: JSON.stringify({}, null, 4) });
+ });
+
+ const updateFormDialogSchema = useRecoilCallback(({ set }: CallbackInterface) => ({ id, content, projectId }) =>
+ set(formDialogSchemaState({ projectId, schemaId: id }), { id, content })
+ );
+
+ const removeFormDialogSchema = useRecoilCallback(({ set, reset }: CallbackInterface) => async ({ id, projectId }) => {
+ set(formDialogSchemaIdsState(projectId), (formDialogSchemas) => {
+ return formDialogSchemas.filter((fdId) => fdId !== id);
+ });
+ reset(formDialogSchemaState({ projectId, schemaId: id }));
+ });
+
+ const loadFormDialogSchemaTemplates = useRecoilCallback((callbackHelpers: CallbackInterface) => async () => {
+ const { set, snapshot } = callbackHelpers;
+ const templates = await snapshot.getPromise(formDialogLibraryTemplatesState);
+ // If templates are already loaded, don't reload.
+ if (templates.length) {
+ return;
+ }
+
+ try {
+ const { data } = await httpClient.get('/formDialogs/templateSchemas');
+ const templates = Object.keys(data).map((key) => ({
+ name: key,
+ isGlobal: data[key].$global,
+ }));
+
+ set(formDialogLibraryTemplatesState, templates);
+ } catch (error) {
+ set(applicationErrorState, {
+ message: error.message,
+ summary: formatMessage('Load form dialog schema templates Error'),
+ });
+ }
+ });
+
+ const generateFormDialog = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async ({ projectId, schemaId }) => {
+ const { set, snapshot } = callbackHelpers;
+ const { reloadProject } = await snapshot.getPromise(dispatcherState);
+ try {
+ set(formDialogGenerationProgressingState, true);
+
+ const formDialogSchema = await snapshot.getPromise(formDialogSchemaState({ projectId, schemaId }));
+ if (!formDialogSchema) {
+ return;
+ }
+
+ const response = await httpClient.post(`/formDialogs/${projectId}/generate`, {
+ name: schemaId,
+ });
+ await reloadProject(callbackHelpers, response);
+ } catch (error) {
+ set(applicationErrorState, {
+ message: error.message,
+ summary: formatMessage('Generating form dialog using ${schemaId} schema failed.', { schemaId }),
+ });
+ } finally {
+ set(formDialogGenerationProgressingState, false);
+ }
+ }
+ );
+
+ const navigateToGeneratedDialog = ({ projectId, schemaId }) => {
+ navigate(`/bot/${projectId}/dialogs/${schemaId}`);
+ };
+
+ return {
+ createFormDialogSchema,
+ updateFormDialogSchema,
+ loadFormDialogSchemaTemplates,
+ removeFormDialogSchema,
+ generateFormDialog,
+ navigateToGeneratedDialog,
+ };
+};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/index.ts b/Composer/packages/client/src/recoilModel/dispatchers/index.ts
index edbae77387..da12d18bc6 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/index.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/index.ts
@@ -20,6 +20,9 @@ import { userDispatcher } from './user';
import { multilangDispatcher } from './multilang';
import { notificationDispatcher } from './notification';
import { extensionsDispatcher } from './extensions';
+import { formDialogsDispatcher } from './formDialogs';
+import { botProjectFileDispatcher } from './botProjectFile';
+import { zoomDispatcher } from './zoom';
const createDispatchers = () => {
return {
@@ -42,6 +45,9 @@ const createDispatchers = () => {
...multilangDispatcher(),
...notificationDispatcher(),
...extensionsDispatcher(),
+ ...formDialogsDispatcher(),
+ ...botProjectFileDispatcher(),
+ ...zoomDispatcher(),
};
};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/lg.ts b/Composer/packages/client/src/recoilModel/dispatchers/lg.ts
index dd4d866f25..95107d37fa 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/lg.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/lg.ts
@@ -112,14 +112,18 @@ export const removeLgFileState = async (
callbackHelpers: CallbackInterface,
{ id, projectId }: { id: string; projectId: string }
) => {
- try {
- const { set, snapshot } = callbackHelpers;
- let lgFiles = await snapshot.getPromise(lgFilesState(projectId));
- lgFiles = lgFiles.filter((file) => getBaseName(file.id) !== id);
- set(lgFilesState(projectId), lgFiles);
- } catch (error) {
- setError(callbackHelpers, error);
+ const { set, snapshot } = callbackHelpers;
+ let lgFiles = await snapshot.getPromise(lgFilesState(projectId));
+ const locale = await snapshot.getPromise(localeState(projectId));
+
+ const targetLgFile = lgFiles.find((item) => item.id === id) || lgFiles.find((item) => item.id === `${id}.${locale}`);
+ if (!targetLgFile) {
+ setError(callbackHelpers, new Error(`remove lg file ${id} not exist`));
+ return;
}
+
+ lgFiles = lgFiles.filter((file) => file.id !== targetLgFile.id);
+ set(lgFilesState(projectId), lgFiles);
};
export const lgDispatcher = () => {
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/lu.ts b/Composer/packages/client/src/recoilModel/dispatchers/lu.ts
index 249f6cf38f..a9d66cfb02 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/lu.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/lu.ts
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
/* eslint-disable react-hooks/rules-of-hooks */
-import { LuFile, LuIntentSection } from '@bfc/shared';
+import { LuFile, LuIntentSection, ILUFeaturesConfig } from '@bfc/shared';
import { useRecoilCallback, CallbackInterface } from 'recoil';
import differenceBy from 'lodash/differenceBy';
import formatMessage from 'format-message';
@@ -19,7 +19,12 @@ const intentIsNotEmpty = ({ Name, Body }) => {
// fill other locale luFile new added intent with '- '
const initialBody = '- ';
-export const updateLuFileState = async (luFiles: LuFile[], updatedLuFile: LuFile, projectId: string) => {
+const updateLuFileState = async (
+ luFiles: LuFile[],
+ updatedLuFile: LuFile,
+ projectId: string,
+ luFeatures: ILUFeaturesConfig
+) => {
const { id } = updatedLuFile;
const dialogId = getBaseName(id);
const locale = getExtension(id);
@@ -50,10 +55,11 @@ export const updateLuFileState = async (luFiles: LuFile[], updatedLuFile: LuFile
// sync add/remove intents
if (onlyAdds || onlyDeletes) {
for (const item of sameIdOtherLocaleFiles) {
- let newLuFile = (await luWorker.addIntents(item, addedIntents)) as LuFile;
+ let newLuFile = (await luWorker.addIntents(item, addedIntents, luFeatures)) as LuFile;
newLuFile = (await luWorker.removeIntents(
newLuFile,
- deletedIntents.map(({ Name }) => Name)
+ deletedIntents.map(({ Name }) => Name),
+ luFeatures
)) as LuFile;
changes.push(newLuFile);
}
@@ -76,11 +82,12 @@ export const createLuFileState = async (
const { set, snapshot } = callbackHelpers;
const luFiles = await snapshot.getPromise(luFilesState(projectId));
const locale = await snapshot.getPromise(localeState(projectId));
- const { languages } = await snapshot.getPromise(settingsState(projectId));
+ const { languages, luFeatures } = await snapshot.getPromise(settingsState(projectId));
const createdLuId = `${id}.${locale}`;
- const createdLuFile = (await luWorker.parse(id, content)) as LuFile;
+ const createdLuFile = (await luWorker.parse(id, content, luFeatures)) as LuFile;
if (luFiles.find((lu) => lu.id === createdLuId)) {
- throw new Error('lu file already exist');
+ setError(callbackHelpers, new Error(formatMessage('lu file already exist')));
+ return;
}
const changes: LuFile[] = [];
@@ -103,14 +110,22 @@ export const removeLuFileState = async (
) => {
const { set, snapshot } = callbackHelpers;
let luFiles = await snapshot.getPromise(luFilesState(projectId));
+ const locale = await snapshot.getPromise(localeState(projectId));
+
+ const targetLuFile = luFiles.find((item) => item.id === id) || luFiles.find((item) => item.id === `${id}.${locale}`);
+ if (!targetLuFile) {
+ // eslint-disable-next-line format-message/literal-pattern
+ setError(callbackHelpers, new Error(formatMessage(`remove lu file ${id} not exist`)));
+ return;
+ }
luFiles.forEach((file) => {
- if (getBaseName(file.id) === getBaseName(id)) {
- luFileStatusStorage.removeFileStatus(projectId, id);
+ if (file.id === targetLuFile.id) {
+ luFileStatusStorage.removeFileStatus(projectId, targetLuFile.id);
}
});
- luFiles = luFiles.filter((file) => getBaseName(file.id) !== id);
+ luFiles = luFiles.filter((file) => file.id !== targetLuFile.id);
set(luFilesState(projectId), luFiles);
};
@@ -127,9 +142,11 @@ export const luDispatcher = () => {
}) => {
const { set, snapshot } = callbackHelpers;
const luFiles = await snapshot.getPromise(luFilesState(projectId));
+ const { luFeatures } = await snapshot.getPromise(settingsState(projectId));
+
try {
- const updatedFile = (await luWorker.parse(id, content)) as LuFile;
- const result = await updateLuFileState(luFiles, updatedFile, projectId);
+ const updatedFile = (await luWorker.parse(id, content, luFeatures)) as LuFile;
+ const result = await updateLuFileState(luFiles, updatedFile, projectId, luFeatures);
set(luFilesState(projectId), result);
} catch (error) {
setError(callbackHelpers, error);
@@ -151,6 +168,8 @@ export const luDispatcher = () => {
}) => {
const { set, snapshot } = callbackHelpers;
const luFiles = await snapshot.getPromise(luFilesState(projectId));
+ const { luFeatures } = await snapshot.getPromise(settingsState(projectId));
+
const luFile = luFiles.find((temp) => temp.id === id);
if (!luFile) return luFiles;
@@ -168,7 +187,12 @@ export const luDispatcher = () => {
if (intent.Name !== intentName) {
const changes: LuFile[] = [];
for (const item of sameIdOtherLocaleFiles) {
- const updatedFile = (await luWorker.updateIntent(item, intentName, { Name: intent.Name })) as LuFile;
+ const updatedFile = (await luWorker.updateIntent(
+ item,
+ intentName,
+ { Name: intent.Name },
+ luFeatures
+ )) as LuFile;
changes.push(updatedFile);
}
@@ -180,7 +204,12 @@ export const luDispatcher = () => {
});
// body change, only update current locale file
} else {
- const updatedFile = (await luWorker.updateIntent(luFile, intentName, { Body: intent.Body })) as LuFile;
+ const updatedFile = (await luWorker.updateIntent(
+ luFile,
+ intentName,
+ { Body: intent.Body },
+ luFeatures
+ )) as LuFile;
set(luFilesState(projectId), (luFiles) => {
return luFiles.map((file) => {
return file.id === id ? updatedFile : file;
@@ -205,11 +234,13 @@ export const luDispatcher = () => {
}) => {
const { set, snapshot } = callbackHelpers;
const luFiles = await snapshot.getPromise(luFilesState(projectId));
+ const { luFeatures } = await snapshot.getPromise(settingsState(projectId));
+
const file = luFiles.find((temp) => temp.id === id);
if (!file) return luFiles;
try {
- const updatedFile = (await luWorker.addIntent(file, intent)) as LuFile;
- const result = await updateLuFileState(luFiles, updatedFile, projectId);
+ const updatedFile = (await luWorker.addIntent(file, intent, luFeatures)) as LuFile;
+ const result = await updateLuFileState(luFiles, updatedFile, projectId, luFeatures);
set(luFilesState(projectId), result);
} catch (error) {
setError(callbackHelpers, error);
@@ -229,11 +260,13 @@ export const luDispatcher = () => {
}) => {
const { set, snapshot } = callbackHelpers;
const luFiles = await snapshot.getPromise(luFilesState(projectId));
+ const { luFeatures } = await snapshot.getPromise(settingsState(projectId));
+
const file = luFiles.find((temp) => temp.id === id);
if (!file) return luFiles;
try {
- const updatedFile = (await luWorker.removeIntent(file, intentName)) as LuFile;
- const result = await updateLuFileState(luFiles, updatedFile, projectId);
+ const updatedFile = (await luWorker.removeIntent(file, intentName, luFeatures)) as LuFile;
+ const result = await updateLuFileState(luFiles, updatedFile, projectId, luFeatures);
set(luFilesState(projectId), result);
} catch (error) {
setError(callbackHelpers, error);
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts b/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts
index 395748390a..ad03d0afdf 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts
@@ -18,7 +18,7 @@ import {
onAddLanguageDialogCompleteState,
onDelLanguageDialogCompleteState,
showDelLanguageModalState,
- botNameState,
+ botDisplayNameState,
} from './../atoms/botState';
const copyLanguageResources = (files: any[], fromLanguage: string, toLanguages: string[]): any[] => {
@@ -58,7 +58,7 @@ const deleteLanguageResources = (
export const multilangDispatcher = () => {
const setLocale = useRecoilCallback(
({ set, snapshot }: CallbackInterface) => async (locale: string, projectId: string) => {
- const botName = await snapshot.getPromise(botNameState(projectId));
+ const botName = await snapshot.getPromise(botDisplayNameState(projectId));
set(localeState(projectId), locale);
languageStorage.setLocale(botName, locale);
@@ -68,7 +68,7 @@ export const multilangDispatcher = () => {
const addLanguages = useRecoilCallback(
(callbackHelpers: CallbackInterface) => async ({ languages, defaultLang, switchTo = false, projectId }) => {
const { set, snapshot } = callbackHelpers;
- const botName = await snapshot.getPromise(botNameState(projectId));
+ const botName = await snapshot.getPromise(botDisplayNameState(projectId));
const prevlgFiles = await snapshot.getPromise(lgFilesState(projectId));
const prevluFiles = await snapshot.getPromise(luFilesState(projectId));
const prevSettings = await snapshot.getPromise(settingsState(projectId));
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/project.ts
index 506b2a5ff9..32c1e4f51d 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/project.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/project.ts
@@ -1,395 +1,305 @@
/* eslint-disable react-hooks/rules-of-hooks */
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { useRecoilCallback, CallbackInterface } from 'recoil';
-import {
- dereferenceDefinitions,
- LuFile,
- QnAFile,
- DialogInfo,
- SensitiveProperties,
- DialogSetting,
- convertSkillsToDictionary,
-} from '@bfc/shared';
-import { indexer, validateDialog } from '@bfc/indexers';
-import objectGet from 'lodash/get';
-import objectSet from 'lodash/set';
+
import formatMessage from 'format-message';
+import findIndex from 'lodash/findIndex';
+import { CallbackInterface, useRecoilCallback } from 'recoil';
-import lgWorker from '../parsers/lgWorker';
-import luWorker from '../parsers/luWorker';
-import qnaWorker from '../parsers/qnaWorker';
-import httpClient from '../../utils/httpUtil';
import { BotStatus } from '../../constants';
-import { getReferredLuFiles } from '../../utils/luUtil';
-import luFileStatusStorage from '../../utils/luFileStatusStorage';
-import { getReferredQnaFiles } from '../../utils/qnaUtil';
-import qnaFileStatusStorage from '../../utils/qnaFileStatusStorage';
import settingStorage from '../../utils/dialogSettingStorage';
-import filePersistence from '../persistence/FilePersistence';
+import { getFileNameFromPath } from '../../utils/fileUtil';
+import httpClient from '../../utils/httpUtil';
+import luFileStatusStorage from '../../utils/luFileStatusStorage';
import { navigateTo } from '../../utils/navigation';
-import languageStorage from '../../utils/languageStorage';
import { projectIdCache } from '../../utils/projectCache';
+import qnaFileStatusStorage from '../../utils/qnaFileStatusStorage';
import {
- designPageLocationState,
- botDiagnosticsState,
- botProjectsSpaceState,
- projectMetaDataState,
- filePersistenceState,
+ botErrorState,
+ botNameIdentifierState,
+ botOpeningState,
+ botProjectIdsState,
+ botProjectSpaceLoadedState,
+ botStatusState,
currentProjectIdState,
+ projectMetaDataState,
} from '../atoms';
-import { QnABotTemplateId } from '../../constants';
-import FilePersistence from '../persistence/FilePersistence';
-import UndoHistory from '../undo/undoHistory';
-import { undoHistoryState } from '../undo/history';
+import { dispatcherState } from '../DispatcherWrapper';
-import {
- skillManifestsState,
- settingsState,
- localeState,
- luFilesState,
- qnaFilesState,
- skillsState,
- schemasState,
- lgFilesState,
- locationState,
- botStatusState,
- botNameState,
- botEnvironmentState,
- dialogsState,
- botOpeningState,
- recentProjectsState,
- templateProjectsState,
- runtimeTemplatesState,
- applicationErrorState,
- templateIdState,
- announcementState,
- boilerplateVersionState,
- dialogSchemasState,
-} from './../atoms';
+import { announcementState, boilerplateVersionState, recentProjectsState, templateIdState } from './../atoms';
import { logMessage, setError } from './../dispatchers/shared';
+import {
+ checkIfBotExistsInBotProjectFile,
+ createNewBotFromTemplate,
+ flushExistingTasks,
+ getSkillNameIdentifier,
+ handleProjectFailure,
+ initBotState,
+ loadProjectData,
+ navigateToBot,
+ openLocalSkill,
+ openRemoteSkill,
+ openRootBotAndSkillsByPath,
+ openRootBotAndSkillsByProjectId,
+ removeRecentProject,
+ resetBotStates,
+ saveProject,
+} from './utils/project';
-const handleProjectFailure = (callbackHelpers: CallbackInterface, ex) => {
- callbackHelpers.set(botOpeningState, false);
- setError(callbackHelpers, ex);
-};
-
-const processSchema = (projectId: string, schema: any) => ({
- ...schema,
- definitions: dereferenceDefinitions(schema.definitions),
-});
-
-// if user set value in terminal or appsetting.json, it should update the value in localStorage
-const refreshLocalStorage = (projectId: string, settings: DialogSetting) => {
- for (const property of SensitiveProperties) {
- const value = objectGet(settings, property);
- if (value) {
- settingStorage.setField(projectId, property, value);
- }
- }
-};
+export const projectDispatcher = () => {
+ const removeSkillFromBotProject = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async (projectIdToRemove: string) => {
+ try {
+ const { set, snapshot } = callbackHelpers;
+ const dispatcher = await snapshot.getPromise(dispatcherState);
+ await dispatcher.removeSkillFromBotProjectFile(projectIdToRemove);
-// merge sensitive values in localStorage
-const mergeLocalStorage = (projectId: string, settings: DialogSetting) => {
- const localSetting = settingStorage.get(projectId);
- const mergedSettings = { ...settings };
- if (localSetting) {
- for (const property of SensitiveProperties) {
- const value = objectGet(localSetting, property);
- if (value) {
- objectSet(mergedSettings, property, value);
- } else {
- objectSet(mergedSettings, property, ''); // set those key back, because that were omit after persisited
+ set(botProjectIdsState, (currentProjects) => {
+ const filtered = currentProjects.filter((id) => id !== projectIdToRemove);
+ return filtered;
+ });
+ resetBotStates(callbackHelpers, projectIdToRemove);
+ } catch (ex) {
+ setError(callbackHelpers, ex);
}
}
- }
- return mergedSettings;
-};
-
-const updateLuFilesStatus = (projectId: string, luFiles: LuFile[]) => {
- const status = luFileStatusStorage.get(projectId);
- return luFiles.map((luFile) => {
- if (typeof status[luFile.id] === 'boolean') {
- return { ...luFile, published: status[luFile.id] };
- } else {
- return { ...luFile, published: false };
- }
- });
-};
-
-const initLuFilesStatus = (projectId: string, luFiles: LuFile[], dialogs: DialogInfo[]) => {
- luFileStatusStorage.checkFileStatus(
- projectId,
- getReferredLuFiles(luFiles, dialogs).map((file) => file.id)
);
- return updateLuFilesStatus(projectId, luFiles);
-};
-const updateQnaFilesStatus = (projectId: string, qnaFiles: QnAFile[]) => {
- const status = qnaFileStatusStorage.get(projectId);
- return qnaFiles.map((qnaFile) => {
- if (typeof status[qnaFile.id] === 'boolean') {
- return { ...qnaFile, published: status[qnaFile.id] };
- } else {
- return { ...qnaFile, published: false };
+ const replaceSkillInBotProject = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async (projectIdToRemove: string, path: string, storageId = 'default') => {
+ try {
+ const { snapshot } = callbackHelpers;
+ const dispatcher = await snapshot.getPromise(dispatcherState);
+ const projectIds = await snapshot.getPromise(botProjectIdsState);
+ const indexToReplace = findIndex(projectIds, (id) => id === projectIdToRemove);
+ if (indexToReplace === -1) {
+ return;
+ }
+ await dispatcher.removeSkillFromBotProject(projectIdToRemove);
+ await dispatcher.addExistingSkillToBotProject(path, storageId);
+ } catch (ex) {
+ setError(callbackHelpers, ex);
+ }
}
- });
-};
-
-const initQnaFilesStatus = (projectId: string, qnaFiles: QnAFile[], dialogs: DialogInfo[]) => {
- qnaFileStatusStorage.checkFileStatus(
- projectId,
- getReferredQnaFiles(qnaFiles, dialogs).map((file) => file.id)
);
- return updateQnaFilesStatus(projectId, qnaFiles);
-};
-export const projectDispatcher = () => {
- const initBotState = async (
- callbackHelpers: CallbackInterface,
- data: any,
- jump: boolean,
- templateId: string,
- qnaKbUrls?: string[]
- ) => {
- const { snapshot, gotoSnapshot, set } = callbackHelpers;
- const {
- files,
- botName,
- botEnvironment,
- location,
- schemas,
- settings,
- id: projectId,
- diagnostics,
- skills: skillContent,
- } = data;
- const curLocation = await snapshot.getPromise(locationState(projectId));
- const storedLocale = languageStorage.get(botName)?.locale;
- const locale = settings.languages.includes(storedLocale) ? storedLocale : settings.defaultLanguage;
-
- // cache current projectId in session, resolve page refresh caused state lost.
- projectIdCache.set(projectId);
-
- const mergedSettings = mergeLocalStorage(projectId, settings);
- if (Array.isArray(mergedSettings.skill)) {
- const skillsArr = mergedSettings.skill.map((skillData) => ({ ...skillData }));
- mergedSettings.skill = convertSkillsToDictionary(skillsArr);
- }
-
- try {
- schemas.sdk.content = processSchema(projectId, schemas.sdk.content);
- } catch (err) {
- const diagnostics = schemas.diagnostics ?? [];
- diagnostics.push(err.message);
- schemas.diagnostics = diagnostics;
- }
-
- try {
- const { dialogs, dialogSchemas, luFiles, lgFiles, qnaFiles, skillManifestFiles, skills } = indexer.index(
- files,
- botName,
- locale,
- skillContent,
- mergedSettings
- );
-
- let mainDialog = '';
- const verifiedDialogs = dialogs.map((dialog) => {
- if (dialog.isRoot) {
- mainDialog = dialog.id;
+ const addExistingSkillToBotProject = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async (path: string, storageId = 'default'): Promise => {
+ const { set, snapshot } = callbackHelpers;
+ try {
+ set(botOpeningState, true);
+ const dispatcher = await snapshot.getPromise(dispatcherState);
+ const botExists = await checkIfBotExistsInBotProjectFile(callbackHelpers, path);
+ if (botExists) {
+ throw new Error(
+ formatMessage('This operation cannot be completed. The skill is already part of the Bot Project')
+ );
}
- dialog.diagnostics = validateDialog(dialog, schemas.sdk.content, lgFiles, luFiles);
- return dialog;
- });
-
- await lgWorker.addProject(projectId, lgFiles);
- set(botProjectsSpaceState, []);
+ const skillNameIdentifier: string = await getSkillNameIdentifier(callbackHelpers, getFileNameFromPath(path));
- // Important: gotoSnapshot will wipe all states.
- const newSnapshot = snapshot.map(({ set }) => {
- set(skillManifestsState(projectId), skillManifestFiles);
- set(luFilesState(projectId), initLuFilesStatus(botName, luFiles, dialogs));
- set(lgFilesState(projectId), lgFiles);
- set(dialogsState(projectId), verifiedDialogs);
- set(dialogSchemasState(projectId), dialogSchemas);
- set(botEnvironmentState(projectId), botEnvironment);
- set(botNameState(projectId), botName);
- set(qnaFilesState(projectId), initQnaFilesStatus(botName, qnaFiles, dialogs));
- if (location !== curLocation) {
- set(botStatusState(projectId), BotStatus.unConnected);
- set(locationState(projectId), location);
+ const { projectId, mainDialog } = await openLocalSkill(callbackHelpers, path, storageId, skillNameIdentifier);
+ if (!mainDialog) {
+ const error = await snapshot.getPromise(botErrorState(projectId));
+ throw error;
}
- set(skillsState(projectId), skills);
- set(schemasState(projectId), schemas);
- set(localeState(projectId), locale);
- set(botDiagnosticsState(projectId), diagnostics);
- refreshLocalStorage(projectId, settings);
- set(settingsState(projectId), mergedSettings);
- set(filePersistenceState(projectId), new FilePersistence(projectId));
- set(undoHistoryState(projectId), new UndoHistory(projectId));
- //TODO: Botprojects space will be populated for now with just the rootbot. Once, BotProjects UI is hookedup this will be refactored to use addToBotProject
- set(botProjectsSpaceState, (current) => [...current, projectId]);
- set(projectMetaDataState(projectId), {
- isRootBot: true,
- });
+ set(botProjectIdsState, (current) => [...current, projectId]);
+ await dispatcher.addLocalSkillToBotProjectFile(projectId);
+ } catch (ex) {
+ handleProjectFailure(callbackHelpers, ex);
+ } finally {
set(botOpeningState, false);
- });
-
- gotoSnapshot(newSnapshot);
+ }
+ }
+ );
- if (jump && projectId) {
- // TODO: Refactor to set it always on init to the root bot
- set(currentProjectIdState, projectId);
- let url = `/bot/${projectId}/dialogs/${mainDialog}`;
- if (templateId === QnABotTemplateId) {
- url = `/bot/${projectId}/knowledge-base/${mainDialog}`;
- navigateTo(url, { state: { qnaKbUrls } });
- return;
+ const addRemoteSkillToBotProject = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async (manifestUrl: string, name: string, endpointName: string) => {
+ const { set, snapshot } = callbackHelpers;
+ try {
+ const dispatcher = await snapshot.getPromise(dispatcherState);
+ const botExists = await checkIfBotExistsInBotProjectFile(callbackHelpers, manifestUrl, true);
+ if (botExists) {
+ throw new Error(
+ formatMessage('This operation cannot be completed. The skill is already part of the Bot Project')
+ );
}
- navigateTo(url);
+ const skillNameIdentifier: string = await getSkillNameIdentifier(callbackHelpers, name);
+ set(botOpeningState, true);
+ const { projectId } = await openRemoteSkill(callbackHelpers, manifestUrl, skillNameIdentifier);
+ set(botProjectIdsState, (current) => [...current, projectId]);
+ await dispatcher.addRemoteSkillToBotProjectFile(projectId, manifestUrl, endpointName);
+ } catch (ex) {
+ handleProjectFailure(callbackHelpers, ex);
+ } finally {
+ set(botOpeningState, false);
}
- } catch (err) {
- callbackHelpers.set(botOpeningState, false);
- setError(callbackHelpers, err);
- navigateTo('/home');
}
- };
+ );
- const removeRecentProject = async (callbackHelpers: CallbackInterface, path: string) => {
- try {
- const {
- set,
- snapshot: { getPromise },
- } = callbackHelpers;
- const currentRecentProjects = await getPromise(recentProjectsState);
- const filtered = currentRecentProjects.filter((p) => p.path !== path);
- set(recentProjectsState, filtered);
- } catch (ex) {
- logMessage(callbackHelpers, `Error removing recent project: ${ex}`);
- }
- };
+ const addNewSkillToBotProject = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async (newProjectData: any) => {
+ const { set, snapshot } = callbackHelpers;
+ const dispatcher = await snapshot.getPromise(dispatcherState);
+ try {
+ const { templateId, name, description, location, schemaUrl, locale, qnaKbUrls } = newProjectData;
+ set(botOpeningState, true);
- const setBotOpeningStatus = async (callbackHelpers: CallbackInterface) => {
- const { set, snapshot } = callbackHelpers;
- set(botOpeningState, true);
- const botProjectSpace = await snapshot.getPromise(botProjectsSpaceState);
- const filePersistenceHandlers: filePersistence[] = [];
- for (const projectId of botProjectSpace) {
- const fp = await snapshot.getPromise(filePersistenceState(projectId));
- filePersistenceHandlers.push(fp);
+ const { projectId, mainDialog } = await createNewBotFromTemplate(
+ callbackHelpers,
+ templateId,
+ name,
+ description,
+ location,
+ schemaUrl,
+ locale
+ );
+ const skillNameIdentifier: string = await getSkillNameIdentifier(callbackHelpers, getFileNameFromPath(name));
+ set(botNameIdentifierState(projectId), skillNameIdentifier);
+ set(projectMetaDataState(projectId), {
+ isRemote: false,
+ isRootBot: false,
+ });
+ set(botProjectIdsState, (current) => [...current, projectId]);
+ await dispatcher.addLocalSkillToBotProjectFile(projectId);
+ navigateToBot(callbackHelpers, projectId, mainDialog, qnaKbUrls, templateId);
+ return projectId;
+ } catch (ex) {
+ handleProjectFailure(callbackHelpers, ex);
+ } finally {
+ set(botOpeningState, false);
+ }
}
- const workers = [lgWorker, luWorker, qnaWorker, ...filePersistenceHandlers];
- return Promise.all(workers.map((w) => w.flush()));
- };
+ );
const openProject = useRecoilCallback(
(callbackHelpers: CallbackInterface) => async (path: string, storageId = 'default') => {
+ const { set } = callbackHelpers;
try {
- await setBotOpeningStatus(callbackHelpers);
- const response = await httpClient.put(`/projects/open`, { path, storageId });
- await initBotState(callbackHelpers, response.data, true, '');
- return response.data.id;
+ set(botOpeningState, true);
+ await flushExistingTasks(callbackHelpers);
+ const { projectId, mainDialog } = await openRootBotAndSkillsByPath(callbackHelpers, path, storageId);
+
+ // Post project creation
+ set(projectMetaDataState(projectId), {
+ isRootBot: true,
+ isRemote: false,
+ });
+ projectIdCache.set(projectId);
+ navigateToBot(callbackHelpers, projectId, mainDialog);
} catch (ex) {
+ set(botProjectIdsState, []);
removeRecentProject(callbackHelpers, path);
handleProjectFailure(callbackHelpers, ex);
+ navigateTo('/home');
+ } finally {
+ set(botOpeningState, false);
}
}
);
const fetchProjectById = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => {
+ const { set } = callbackHelpers;
try {
- const response = await httpClient.get(`/projects/${projectId}`);
- await initBotState(callbackHelpers, response.data, false, '');
+ await flushExistingTasks(callbackHelpers);
+ set(botOpeningState, true);
+ await openRootBotAndSkillsByProjectId(callbackHelpers, projectId);
+
+ // Post project creation
+ set(projectMetaDataState(projectId), {
+ isRootBot: true,
+ isRemote: false,
+ });
+ projectIdCache.set(projectId);
} catch (ex) {
+ set(botProjectIdsState, []);
handleProjectFailure(callbackHelpers, ex);
navigateTo('/home');
+ } finally {
+ set(botOpeningState, false);
}
});
- const createProject = useRecoilCallback(
- (callbackHelpers: CallbackInterface) => async (
- templateId: string,
- name: string,
- description: string,
- location: string,
- schemaUrl?: string,
- locale?: string,
- qnaKbUrls?: string[]
- ) => {
+ const createNewBot = useRecoilCallback((callbackHelpers: CallbackInterface) => async (newProjectData: any) => {
+ const { set } = callbackHelpers;
+ try {
+ await flushExistingTasks(callbackHelpers);
+ set(botOpeningState, true);
+ const { templateId, name, description, location, schemaUrl, locale, qnaKbUrls } = newProjectData;
+ const { projectId, mainDialog } = await createNewBotFromTemplate(
+ callbackHelpers,
+ templateId,
+ name,
+ description,
+ location,
+ schemaUrl,
+ locale
+ );
+ set(botProjectIdsState, [projectId]);
+
+ // Post project creation
+ set(projectMetaDataState(projectId), {
+ isRootBot: true,
+ isRemote: false,
+ });
+ projectIdCache.set(projectId);
+ navigateToBot(callbackHelpers, projectId, mainDialog, qnaKbUrls, templateId);
+ } catch (ex) {
+ set(botProjectIdsState, []);
+ handleProjectFailure(callbackHelpers, ex);
+ navigateTo('/home');
+ } finally {
+ set(botOpeningState, false);
+ }
+ });
+
+ const saveProjectAs = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async (oldProjectId, name, description, location) => {
+ const { set } = callbackHelpers;
try {
- await setBotOpeningStatus(callbackHelpers);
- const response = await httpClient.post(`/projects`, {
- storageId: 'default',
- templateId,
+ await flushExistingTasks(callbackHelpers);
+ set(botOpeningState, true);
+ const { projectId, mainDialog } = await saveProject(callbackHelpers, {
+ oldProjectId,
name,
description,
location,
- schemaUrl,
- locale,
});
- const projectId = response.data.id;
- if (settingStorage.get(projectId)) {
- settingStorage.remove(projectId);
- }
- await initBotState(callbackHelpers, response.data, true, templateId, qnaKbUrls);
- return projectId;
+
+ // Post project creation
+ set(projectMetaDataState(projectId), {
+ isRootBot: true,
+ isRemote: false,
+ });
+ projectIdCache.set(projectId);
+ navigateToBot(callbackHelpers, projectId, mainDialog);
} catch (ex) {
+ set(botProjectIdsState, []);
handleProjectFailure(callbackHelpers, ex);
+ navigateTo('/home');
+ } finally {
+ set(botOpeningState, false);
}
}
);
- const deleteBotProject = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => {
- const { reset } = callbackHelpers;
+ const deleteBot = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => {
try {
+ const { reset } = callbackHelpers;
await httpClient.delete(`/projects/${projectId}`);
luFileStatusStorage.removeAllStatuses(projectId);
qnaFileStatusStorage.removeAllStatuses(projectId);
settingStorage.remove(projectId);
projectIdCache.clear();
- reset(dialogsState(projectId));
- reset(botEnvironmentState(projectId));
- reset(botNameState(projectId));
- reset(botStatusState(projectId));
- reset(locationState(projectId));
- reset(lgFilesState(projectId));
- reset(skillsState(projectId));
- reset(schemasState(projectId));
- reset(luFilesState(projectId));
- reset(settingsState(projectId));
- reset(localeState(projectId));
- reset(skillManifestsState(projectId));
- reset(designPageLocationState(projectId));
- reset(filePersistenceState(projectId));
- reset(undoHistoryState(projectId));
- reset(botProjectsSpaceState);
+ resetBotStates(callbackHelpers, projectId);
+ reset(botProjectIdsState);
reset(currentProjectIdState);
+ reset(botProjectSpaceLoadedState);
} catch (e) {
logMessage(callbackHelpers, e.message);
}
});
- const saveProjectAs = useRecoilCallback(
- (callbackHelpers: CallbackInterface) => async (projectId, name, description, location) => {
- try {
- await setBotOpeningStatus(callbackHelpers);
- const response = await httpClient.post(`/projects/${projectId}/project/saveAs`, {
- storageId: 'default',
- name,
- description,
- location,
- });
- await initBotState(callbackHelpers, response.data, true, '');
- return response.data.id;
- } catch (ex) {
- handleProjectFailure(callbackHelpers, ex);
- logMessage(callbackHelpers, ex.message);
- }
- }
- );
-
const fetchRecentProjects = useRecoilCallback((callbackHelpers: CallbackInterface) => async () => {
const { set } = callbackHelpers;
try {
@@ -401,70 +311,12 @@ export const projectDispatcher = () => {
}
});
- const fetchRuntimeTemplates = useRecoilCallback<[], Promise>(
- (callbackHelpers: CallbackInterface) => async () => {
- const { set } = callbackHelpers;
- try {
- const response = await httpClient.get(`/runtime/templates`);
- if (Array.isArray(response.data)) {
- set(runtimeTemplatesState, [...response.data]);
- }
- } catch (ex) {
- // TODO: Handle exceptions
- logMessage(callbackHelpers, `Error fetching runtime templates: ${ex}`);
- }
- }
- );
-
- const fetchTemplates = useRecoilCallback<[], Promise>((callbackHelpers: CallbackInterface) => async () => {
- try {
- const response = await httpClient.get(`/assets/projectTemplates`);
-
- const data = response && response.data;
-
- if (data && Array.isArray(data) && data.length > 0) {
- callbackHelpers.set(templateProjectsState, data);
- }
- } catch (err) {
- // TODO: Handle exceptions
- logMessage(callbackHelpers, `Error fetching runtime templates: ${err}`);
- }
- });
-
const setBotStatus = useRecoilCallback<[BotStatus, string], void>(
({ set }: CallbackInterface) => (status: BotStatus, projectId: string) => {
set(botStatusState(projectId), status);
}
);
- const createFolder = useRecoilCallback<[string, string], Promise>(
- ({ set }: CallbackInterface) => async (path, name) => {
- const storageId = 'default';
- try {
- await httpClient.post(`/storages/folder`, { path, name, storageId });
- } catch (err) {
- set(applicationErrorState, {
- message: err.message,
- summary: formatMessage('Create Folder Error'),
- });
- }
- }
- );
-
- const updateFolder = useRecoilCallback<[string, string, string], Promise>(
- ({ set }: CallbackInterface) => async (path, oldName, newName) => {
- const storageId = 'default';
- try {
- await httpClient.put(`/storages/folder`, { path, oldName, newName, storageId });
- } catch (err) {
- set(applicationErrorState, {
- message: err.message,
- summary: formatMessage('Update Folder Name Error'),
- });
- }
- }
- );
-
const saveTemplateId = useRecoilCallback<[string], void>(({ set }: CallbackInterface) => (templateId) => {
if (templateId) {
set(templateIdState, templateId);
@@ -490,20 +342,28 @@ export const projectDispatcher = () => {
}
});
+ const reloadProject = async (callbackHelpers: CallbackInterface, response: any) => {
+ const { projectData, botFiles } = loadProjectData(response);
+
+ await initBotState(callbackHelpers, projectData, botFiles);
+ };
+
return {
openProject,
- createProject,
- deleteBotProject,
+ createNewBot,
+ deleteBot,
saveProjectAs,
- fetchTemplates,
fetchProjectById,
fetchRecentProjects,
- fetchRuntimeTemplates,
setBotStatus,
- updateFolder,
- createFolder,
saveTemplateId,
updateBoilerplate,
getBoilerplateVersion,
+ removeSkillFromBotProject,
+ addNewSkillToBotProject,
+ addExistingSkillToBotProject,
+ addRemoteSkillToBotProject,
+ replaceSkillInBotProject,
+ reloadProject,
};
};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts
index c9eeac9e33..e4ec8f5a2e 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts
@@ -217,6 +217,7 @@ export const publisherDispatcher = () => {
}
}
);
+
return {
getPublishTargetTypes,
publishToTarget,
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts
index adbbdedfff..8daac100ab 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts
@@ -3,9 +3,19 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { QnAFile } from '@bfc/shared';
import { useRecoilCallback, CallbackInterface } from 'recoil';
+import { qnaUtil } from '@bfc/indexers';
import qnaWorker from '../parsers/qnaWorker';
-import { qnaFilesState, localeState, settingsState } from '../atoms/botState';
+import {
+ qnaFilesState,
+ localeState,
+ settingsState,
+ showCreateQnAFromScratchDialogState,
+ showCreateQnAFromUrlDialogState,
+ showCreateQnAFromUrlDialogWithScratchState,
+ onCreateQnAFromScratchDialogCompleteState,
+ onCreateQnAFromUrlDialogCompleteState,
+} from '../atoms/botState';
import qnaFileStatusStorage from '../../utils/qnaFileStatusStorage';
import { getBaseName } from '../../utils/fileUtil';
import { navigateTo } from '../../utils/navigation';
@@ -63,24 +73,205 @@ export const createQnAFileState = async (
set(qnaFilesState(projectId), [...qnaFiles, ...changes]);
};
+/**
+ * id can be
+ * 1. dialogId, no locale
+ * 2. qna file id, with locale
+ * 3. source qna file id, no locale
+ */
export const removeQnAFileState = async (
callbackHelpers: CallbackInterface,
{ id, projectId }: { id: string; projectId: string }
) => {
const { set, snapshot } = callbackHelpers;
let qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const locale = await snapshot.getPromise(localeState(projectId));
+
+ const targetQnAFile =
+ qnaFiles.find((item) => item.id === id) || qnaFiles.find((item) => item.id === `${id}.${locale}`);
+ if (!targetQnAFile) {
+ throw new Error(`remove qna file ${id} not exist`);
+ }
qnaFiles.forEach((file) => {
- if (getBaseName(file.id) === getBaseName(id)) {
- qnaFileStatusStorage.removeFileStatus(projectId, id);
+ if (file.id === targetQnAFile.id) {
+ qnaFileStatusStorage.removeFileStatus(projectId, targetQnAFile.id);
}
});
- qnaFiles = qnaFiles.filter((file) => getBaseName(file.id) !== id);
+ qnaFiles = qnaFiles.filter((file) => file.id !== targetQnAFile.id);
set(qnaFilesState(projectId), qnaFiles);
};
+export const createKBFileState = async (
+ callbackHelpers: CallbackInterface,
+ { id, name, content, projectId }: { id: string; name: string; content: string; projectId: string }
+) => {
+ const { set, snapshot } = callbackHelpers;
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const createdSourceQnAId = `${name}.source`;
+
+ if (qnaFiles.find((qna) => qna.id === createdSourceQnAId)) {
+ throw new Error(`source qna file ${createdSourceQnAId}.qna already exist`);
+ }
+
+ const createdQnAFile = (await qnaWorker.parse(createdSourceQnAId, content)) as QnAFile;
+
+ let newQnAFiles = [...qnaFiles];
+
+ // if created on a dialog, need update this dialog's all locale qna ref
+ if (id.includes('.source') === false) {
+ const updatedQnAId = id;
+ if (!qnaFiles.find((f) => f.id === updatedQnAId)) {
+ throw new Error(`update qna file ${updatedQnAId}.qna not exist`);
+ }
+
+ newQnAFiles = qnaFiles.map((file) => {
+ if (!file.id.endsWith('.source') && getBaseName(file.id) === getBaseName(updatedQnAId)) {
+ return qnaUtil.addImport(file, `${createdSourceQnAId}.qna`);
+ }
+ return file;
+ });
+
+ qnaFileStatusStorage.updateFileStatus(projectId, updatedQnAId);
+ }
+
+ qnaFileStatusStorage.updateFileStatus(projectId, createdSourceQnAId);
+ set(qnaFilesState(projectId), [createdQnAFile, ...newQnAFiles]);
+};
+
+export const removeKBFileState = async (
+ callbackHelpers: CallbackInterface,
+ { id, projectId }: { id: string; projectId: string }
+) => {
+ const { set, snapshot } = callbackHelpers;
+ let qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const locale = await snapshot.getPromise(localeState(projectId));
+
+ const targetQnAFile =
+ qnaFiles.find((item) => item.id === id) || qnaFiles.find((item) => item.id === `${id}.${locale}`);
+ if (!targetQnAFile) {
+ throw new Error(`remove qna container file ${id} not exist`);
+ }
+
+ qnaFiles.forEach((file) => {
+ if (file.id === targetQnAFile.id) {
+ qnaFileStatusStorage.removeFileStatus(projectId, targetQnAFile.id);
+ }
+ });
+
+ qnaFiles = qnaFiles.filter((file) => file.id !== targetQnAFile.id);
+ set(qnaFilesState(projectId), qnaFiles);
+};
+
+export const renameKBFileState = async (
+ callbackHelpers: CallbackInterface,
+ { id, name, projectId }: { id: string; name: string; projectId: string }
+) => {
+ const { set, snapshot } = callbackHelpers;
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const locale = await snapshot.getPromise(localeState(projectId));
+
+ const targetQnAFile =
+ qnaFiles.find((item) => item.id === id) || qnaFiles.find((item) => item.id === `${id}.${locale}`);
+ if (!targetQnAFile) {
+ throw new Error(`rename qna container file ${id} not exist`);
+ }
+
+ const existQnAFile =
+ qnaFiles.find((item) => item.id === name) || qnaFiles.find((item) => item.id === `${name}.${locale}`);
+ if (existQnAFile) {
+ throw new Error(`rename qna container file to ${name} already exist`);
+ }
+ qnaFileStatusStorage.removeFileStatus(projectId, targetQnAFile.id);
+
+ const newQnAFiles = qnaFiles.map((file) => {
+ if (file.id === targetQnAFile.id) {
+ return {
+ ...file,
+ id: name,
+ };
+ }
+ return file;
+ });
+
+ set(qnaFilesState(projectId), newQnAFiles);
+};
+
export const qnaDispatcher = () => {
+ const createQnAFromUrlDialogBegin = useRecoilCallback(
+ ({ set }: CallbackInterface) => async ({
+ onComplete,
+ projectId,
+ showFromScratch,
+ }: {
+ onComplete?: () => void;
+ showFromScratch: boolean;
+ projectId: string;
+ }) => {
+ set(showCreateQnAFromUrlDialogState(projectId), true);
+ if (showFromScratch) {
+ set(showCreateQnAFromUrlDialogWithScratchState(projectId), true);
+ } else {
+ set(showCreateQnAFromUrlDialogWithScratchState(projectId), false);
+ }
+ set(onCreateQnAFromUrlDialogCompleteState(projectId), { func: onComplete });
+ }
+ );
+
+ const createQnAFromUrlDialogCancel = useRecoilCallback(
+ ({ set }: CallbackInterface) => ({ projectId }: { projectId: string }) => {
+ set(showCreateQnAFromUrlDialogState(projectId), false);
+ set(onCreateQnAFromUrlDialogCompleteState(projectId), { func: undefined });
+ }
+ );
+
+ const createQnAFromScratchDialogBegin = useRecoilCallback(
+ ({ set }: CallbackInterface) => async ({
+ onComplete,
+ projectId,
+ }: {
+ onComplete?: () => void;
+ projectId: string;
+ }) => {
+ set(showCreateQnAFromScratchDialogState(projectId), true);
+ set(onCreateQnAFromScratchDialogCompleteState(projectId), { func: onComplete });
+ }
+ );
+
+ const createQnAFromScratchDialogCancel = useRecoilCallback(
+ ({ set }: CallbackInterface) => async ({ projectId }: { projectId: string }) => {
+ set(showCreateQnAFromScratchDialogState(projectId), false);
+ set(onCreateQnAFromScratchDialogCompleteState(projectId), { func: undefined });
+ }
+ );
+
+ const createQnAFromUrlDialogSuccess = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({ projectId }: { projectId: string }) => {
+ const onCreateQnAFromUrlDialogComplete = (
+ await snapshot.getPromise(onCreateQnAFromUrlDialogCompleteState(projectId))
+ ).func;
+ if (typeof onCreateQnAFromUrlDialogComplete === 'function') {
+ onCreateQnAFromUrlDialogComplete();
+ }
+ set(showCreateQnAFromUrlDialogState(projectId), false);
+ set(onCreateQnAFromUrlDialogCompleteState(projectId), { func: undefined });
+ }
+ );
+
+ const createQnAFromScratchDialogSuccess = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({ projectId }: { projectId: string }) => {
+ const onCreateQnAFromScratchDialogComplete = (
+ await snapshot.getPromise(onCreateQnAFromScratchDialogCompleteState(projectId))
+ ).func;
+ if (typeof onCreateQnAFromScratchDialogComplete === 'function') {
+ onCreateQnAFromScratchDialogComplete();
+ }
+ set(showCreateQnAFromScratchDialogState(projectId), false);
+ set(onCreateQnAFromScratchDialogCompleteState(projectId), { func: undefined });
+ }
+ );
+
const updateQnAFile = useRecoilCallback(
(callbackHelpers: CallbackInterface) => async ({
id,
@@ -109,28 +300,43 @@ export const qnaDispatcher = () => {
}
);
- const importQnAFromUrls = useRecoilCallback(
+ const removeQnAFile = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async ({ id, projectId }: { id: string; projectId: string }) => {
+ await removeQnAFileState(callbackHelpers, { id, projectId });
+ }
+ );
+
+ const dismissCreateQnAModal = useRecoilCallback(
+ ({ set }: CallbackInterface) => async ({ projectId }: { projectId: string }) => {
+ set(showCreateQnAFromUrlDialogState(projectId), false);
+ set(showCreateQnAFromScratchDialogState(projectId), false);
+ }
+ );
+
+ const createQnAKBFromUrl = useRecoilCallback(
(callbackHelpers: CallbackInterface) => async ({
id,
- urls,
+ name,
+ url,
+ multiTurn,
projectId,
}: {
- id: string;
- urls: string[];
+ id: string; // dialogId.locale
+ name: string;
+ url: string;
+ multiTurn: boolean;
projectId: string;
}) => {
- const { snapshot } = callbackHelpers;
- const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
- const qnaFile = qnaFiles.find((f) => f.id === id);
-
- const notification = createNotifiction(getQnaPendingNotification(urls));
+ await dismissCreateQnAModal({ projectId });
+ const notification = createNotifiction(getQnaPendingNotification(url));
addNotificationInternal(callbackHelpers, notification);
+ let response;
try {
- const response = await httpClient.get(`/utilities/qna/parse`, {
- params: { urls: encodeURIComponent(urls.join(',')) },
+ response = await httpClient.get(`/utilities/qna/parse`, {
+ params: { url: encodeURIComponent(url), multiTurn },
});
- const content = qnaFile ? qnaFile.content + '\n' + response.data : response.data;
+ const content = response.data;
await updateQnAFileState(callbackHelpers, { id, content, projectId });
const notification = createNotifiction(
@@ -145,15 +351,318 @@ export const qnaDispatcher = () => {
callbackHelpers,
createNotifiction(getQnaFailedNotification(err.response?.data?.message))
);
+ createQnAFromUrlDialogCancel({ projectId });
+ return;
} finally {
deleteNotificationInternal(callbackHelpers, notification.id);
}
+
+ const contentForSourceQnA = `> !# @source.url=${url}
+> !# @source.multiTurn=${multiTurn}
+${response.data}
+`;
+
+ await createKBFileState(callbackHelpers, {
+ id,
+ name,
+ content: contentForSourceQnA,
+ projectId,
+ });
+
+ await createQnAFromUrlDialogSuccess({ projectId });
+ }
+ );
+
+ const createQnAKBFromScratch = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async ({
+ id,
+ name,
+ projectId,
+ content = '',
+ }: {
+ id: string; // dialogId.locale
+ name: string;
+ content?: string;
+ projectId: string;
+ }) => {
+ await dismissCreateQnAModal({ projectId });
+
+ await createKBFileState(callbackHelpers, {
+ id,
+ name,
+ content,
+ projectId,
+ });
+ await createQnAFromScratchDialogSuccess({ projectId });
+
+ const notification = createNotifiction(
+ getQnaSuccessNotification(() => {
+ navigateTo(`/bot/${projectId}/knowledge-base/${getBaseName(id)}`);
+ deleteNotificationInternal(callbackHelpers, notification.id);
+ })
+ );
+ addNotificationInternal(callbackHelpers, notification);
+ }
+ );
+
+ const updateQnAQuestion = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({
+ id,
+ sectionId,
+ questionId,
+ content,
+ projectId,
+ }: {
+ id: string;
+ sectionId: string;
+ questionId: string;
+ content: string;
+ projectId: string;
+ }) => {
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const qnaFile = qnaFiles.find((temp) => temp.id === id);
+ if (!qnaFile) return qnaFiles;
+
+ // const updatedFile = await updateQnAFileState(callbackHelpers, { id, content });
+ const updatedFile = qnaUtil.updateQnAQuestion(qnaFile, sectionId, questionId, content);
+ set(qnaFilesState(projectId), (qnaFiles) => {
+ return qnaFiles.map((file) => {
+ return file.id === id ? updatedFile : file;
+ });
+ });
+ }
+ );
+
+ const updateQnAAnswer = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({
+ id,
+ sectionId,
+ content,
+ projectId,
+ }: {
+ id: string;
+ sectionId: string;
+ content: string;
+ projectId: string;
+ }) => {
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const qnaFile = qnaFiles.find((temp) => temp.id === id);
+ if (!qnaFile) return qnaFiles;
+
+ const updatedFile = qnaUtil.updateQnAAnswer(qnaFile, sectionId, content);
+ set(qnaFilesState(projectId), (qnaFiles) => {
+ return qnaFiles.map((file) => {
+ return file.id === id ? updatedFile : file;
+ });
+ });
+ }
+ );
+
+ const createQnAQuestion = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({
+ id,
+ sectionId,
+ content,
+ projectId,
+ }: {
+ id: string;
+ sectionId: string;
+ content: string;
+ projectId: string;
+ }) => {
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const qnaFile = qnaFiles.find((temp) => temp.id === id);
+ if (!qnaFile) return qnaFiles;
+
+ const updatedFile = qnaUtil.createQnAQuestion(qnaFile, sectionId, content);
+ set(qnaFilesState(projectId), (qnaFiles) => {
+ return qnaFiles.map((file) => {
+ return file.id === id ? updatedFile : file;
+ });
+ });
+ }
+ );
+
+ const removeQnAQuestion = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({
+ id,
+ sectionId,
+ questionId,
+ projectId,
+ }: {
+ id: string;
+ sectionId: string;
+ questionId: string;
+ projectId: string;
+ }) => {
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const qnaFile = qnaFiles.find((temp) => temp.id === id);
+ if (!qnaFile) return qnaFiles;
+
+ const updatedFile = qnaUtil.removeQnAQuestion(qnaFile, sectionId, questionId);
+ set(qnaFilesState(projectId), (qnaFiles) => {
+ return qnaFiles.map((file) => {
+ return file.id === id ? updatedFile : file;
+ });
+ });
+ }
+ );
+
+ const createQnAPairs = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({
+ id,
+ content,
+ projectId,
+ }: {
+ id: string;
+ content: string;
+ projectId: string;
+ }) => {
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const qnaFile = qnaFiles.find((temp) => temp.id === id);
+ if (!qnaFile) return qnaFiles;
+
+ // insert into head, need investigate
+ const updatedFile = qnaUtil.insertSection(qnaFile, 0, content);
+ set(qnaFilesState(projectId), (qnaFiles) => {
+ return qnaFiles.map((file) => {
+ return file.id === id ? updatedFile : file;
+ });
+ });
+ }
+ );
+
+ const removeQnAPairs = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({
+ id,
+ sectionId,
+ projectId,
+ }: {
+ id: string;
+ sectionId: string;
+ projectId: string;
+ }) => {
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const qnaFile = qnaFiles.find((temp) => temp.id === id);
+ if (!qnaFile) return qnaFiles;
+
+ const updatedFile = qnaUtil.removeSection(qnaFile, sectionId);
+ set(qnaFilesState(projectId), (qnaFiles) => {
+ return qnaFiles.map((file) => {
+ return file.id === id ? updatedFile : file;
+ });
+ });
+ }
+ );
+
+ const createQnAImport = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({
+ id,
+ sourceId,
+ projectId,
+ }: {
+ id: string;
+ sourceId: string;
+ projectId: string;
+ }) => {
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const qnaFile = qnaFiles.find((temp) => temp.id === id);
+ if (!qnaFile) return qnaFiles;
+
+ const updatedFile = qnaUtil.addImport(qnaFile, `${sourceId}.qna`);
+ set(qnaFilesState(projectId), (qnaFiles) => {
+ return qnaFiles.map((file) => {
+ return file.id === id ? updatedFile : file;
+ });
+ });
+ }
+ );
+ const removeQnAImport = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({
+ id,
+ sourceId,
+ projectId,
+ }: {
+ id: string;
+ sourceId: string;
+ projectId: string;
+ }) => {
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const qnaFile = qnaFiles.find((temp) => temp.id === id);
+ if (!qnaFile) return qnaFiles;
+
+ const updatedFile = qnaUtil.removeImport(qnaFile, `${sourceId}.qna`);
+ set(qnaFilesState(projectId), (qnaFiles) => {
+ return qnaFiles.map((file) => {
+ return file.id === id ? updatedFile : file;
+ });
+ });
+ }
+ );
+ const updateQnAImport = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async ({
+ id,
+ sourceId,
+ newSourceId,
+ projectId,
+ }: {
+ id: string;
+ sourceId: string;
+ newSourceId: string;
+ projectId: string;
+ }) => {
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+ const qnaFile = qnaFiles.find((temp) => temp.id === id);
+ if (!qnaFile) return qnaFiles;
+
+ let updatedFile = qnaUtil.removeImport(qnaFile, `${sourceId}.qna`);
+ updatedFile = qnaUtil.addImport(updatedFile, `${newSourceId}.qna`);
+ set(qnaFilesState(projectId), (qnaFiles) => {
+ return qnaFiles.map((file) => {
+ return file.id === id ? updatedFile : file;
+ });
+ });
+ }
+ );
+ const removeQnAKB = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async ({ id, projectId }: { id: string; projectId: string }) => {
+ await removeKBFileState(callbackHelpers, { id, projectId });
+ }
+ );
+ const renameQnAKB = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async ({
+ id,
+ name,
+ projectId,
+ }: {
+ id: string;
+ name: string;
+ projectId: string;
+ }) => {
+ await renameKBFileState(callbackHelpers, { id, name, projectId });
}
);
return {
+ createQnAImport,
+ removeQnAImport,
+ updateQnAImport,
+ createQnAPairs,
+ removeQnAPairs,
+ createQnAQuestion,
+ removeQnAQuestion,
+ updateQnAQuestion,
+ updateQnAAnswer,
createQnAFile,
+ removeQnAFile,
updateQnAFile,
- importQnAFromUrls,
+ removeQnAKB,
+ renameQnAKB,
+ createQnAKBFromUrl,
+ createQnAKBFromScratch,
+ createQnAFromScratchDialogBegin,
+ createQnAFromScratchDialogCancel,
+ createQnAFromUrlDialogBegin,
+ createQnAFromUrlDialogCancel,
};
};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts
index d13761e46b..df4b388635 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts
@@ -30,7 +30,7 @@ export const setSettingState = async (
keys(settings.skill).map(async (id) => {
if (settings?.skill?.[id]?.manifestUrl !== previousSettings?.skill?.[id]?.manifestUrl) {
try {
- const { data: content } = await httpClient.get(`/projects/${projectId}/skill/retrieve-skill-manifest`, {
+ const { data: content } = await httpClient.get(`/projects/${projectId}/skill/retrieveSkillManifest`, {
params: {
url: settings?.skill?.[id]?.manifestUrl,
},
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/shared.ts b/Composer/packages/client/src/recoilModel/dispatchers/shared.ts
index 811de1fd41..a930d50b10 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/shared.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/shared.ts
@@ -41,12 +41,18 @@ export const setError = (callbackHelpers: CallbackInterface, payload) => {
),
summary: formatMessage('Modification Rejected'),
});
+ } else if (payload?.response?.data?.message) {
+ callbackHelpers.set(applicationErrorState, payload.response.data);
+ } else if (payload instanceof Error) {
+ callbackHelpers.set(applicationErrorState, {
+ summary: payload.name,
+ message: payload.message,
+ });
} else {
- if (payload?.response?.data?.message) {
- callbackHelpers.set(applicationErrorState, payload.response.data);
- } else {
- callbackHelpers.set(applicationErrorState, payload);
- }
+ callbackHelpers.set(applicationErrorState, payload);
+ }
+ if (payload != null) {
+ const message = JSON.stringify(payload);
+ logMessage(callbackHelpers, `Error: ${message}`);
}
- if (payload != null) logMessage(callbackHelpers, `Error: ${JSON.stringify(payload)}`);
};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/storage.ts b/Composer/packages/client/src/recoilModel/dispatchers/storage.ts
index 39a36ec702..2c8352e52c 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/storage.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/storage.ts
@@ -3,9 +3,17 @@
// Licensed under the MIT License.
import { useRecoilCallback, CallbackInterface } from 'recoil';
import isArray from 'lodash/isArray';
+import formatMessage from 'format-message';
import httpClient from '../../utils/httpUtil';
-import { storagesState, storageFileLoadingStatusState, focusedStorageFolderState } from '../atoms/appState';
+import {
+ storagesState,
+ storageFileLoadingStatusState,
+ focusedStorageFolderState,
+ applicationErrorState,
+ templateProjectsState,
+ runtimeTemplatesState,
+} from '../atoms/appState';
import { FileTypes } from '../../constants';
import { getExtension } from '../../utils/fileUtil';
@@ -101,6 +109,64 @@ export const storageDispatcher = () => {
}
);
+ const createFolder = useRecoilCallback<[string, string], Promise>(
+ ({ set }: CallbackInterface) => async (path, name) => {
+ const storageId = 'default';
+ try {
+ await httpClient.post(`/storages/folder`, { path, name, storageId });
+ } catch (err) {
+ set(applicationErrorState, {
+ message: err.message,
+ summary: formatMessage('Create Folder Error'),
+ });
+ }
+ }
+ );
+
+ const updateFolder = useRecoilCallback<[string, string, string], Promise>(
+ ({ set }: CallbackInterface) => async (path, oldName, newName) => {
+ const storageId = 'default';
+ try {
+ await httpClient.put(`/storages/folder`, { path, oldName, newName, storageId });
+ } catch (err) {
+ set(applicationErrorState, {
+ message: err.message,
+ summary: formatMessage('Update Folder Name Error'),
+ });
+ }
+ }
+ );
+
+ const fetchTemplates = useRecoilCallback<[], Promise>((callbackHelpers: CallbackInterface) => async () => {
+ try {
+ const response = await httpClient.get(`/assets/projectTemplates`);
+
+ const data = response && response.data;
+
+ if (data && Array.isArray(data) && data.length > 0) {
+ callbackHelpers.set(templateProjectsState, data);
+ }
+ } catch (err) {
+ // TODO: Handle exceptions
+ logMessage(callbackHelpers, `Error fetching runtime templates: ${err}`);
+ }
+ });
+
+ const fetchRuntimeTemplates = useRecoilCallback<[], Promise>(
+ (callbackHelpers: CallbackInterface) => async () => {
+ const { set } = callbackHelpers;
+ try {
+ const response = await httpClient.get(`/runtime/templates`);
+ if (Array.isArray(response.data)) {
+ set(runtimeTemplatesState, [...response.data]);
+ }
+ } catch (ex) {
+ // TODO: Handle exceptions
+ logMessage(callbackHelpers, `Error fetching runtime templates: ${ex}`);
+ }
+ }
+ );
+
return {
fetchStorages,
updateCurrentPathForStorage,
@@ -108,5 +174,9 @@ export const storageDispatcher = () => {
fetchStorageByName,
fetchFolderItemsByPath,
setStorageFileLoadingStatus,
+ createFolder,
+ updateFolder,
+ fetchTemplates,
+ fetchRuntimeTemplates,
};
};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts
new file mode 100644
index 0000000000..e0bffa6dd2
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts
@@ -0,0 +1,585 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { indexer, validateDialog } from '@bfc/indexers';
+import {
+ BotProjectFile,
+ BotProjectSpace,
+ BotProjectSpaceSkill,
+ convertFileProtocolToPath,
+ convertSkillsToDictionary,
+ dereferenceDefinitions,
+ DialogInfo,
+ DialogSetting,
+ LuFile,
+ QnAFile,
+ SensitiveProperties,
+} from '@bfc/shared';
+import formatMessage from 'format-message';
+import camelCase from 'lodash/camelCase';
+import objectGet from 'lodash/get';
+import objectSet from 'lodash/set';
+import { stringify } from 'query-string';
+import { CallbackInterface } from 'recoil';
+import { v4 as uuid } from 'uuid';
+
+import { BotStatus, QnABotTemplateId } from '../../../constants';
+import settingStorage from '../../../utils/dialogSettingStorage';
+import { getUniqueName } from '../../../utils/fileUtil';
+import httpClient from '../../../utils/httpUtil';
+import languageStorage from '../../../utils/languageStorage';
+import luFileStatusStorage from '../../../utils/luFileStatusStorage';
+import { getReferredLuFiles } from '../../../utils/luUtil';
+import { navigateTo } from '../../../utils/navigation';
+import qnaFileStatusStorage from '../../../utils/qnaFileStatusStorage';
+import { getReferredQnaFiles, reformQnAToContainerKB } from '../../../utils/qnaUtil';
+import {
+ botDiagnosticsState,
+ botDisplayNameState,
+ botEnvironmentState,
+ botErrorState,
+ botNameIdentifierState,
+ botProjectFileState,
+ botProjectIdsState,
+ botProjectSpaceLoadedState,
+ botStatusState,
+ currentProjectIdState,
+ dialogSchemasState,
+ dialogsState,
+ filePersistenceState,
+ formDialogSchemaIdsState,
+ formDialogSchemaState,
+ jsonSchemaFilesState,
+ lgFilesState,
+ localeState,
+ locationState,
+ luFilesState,
+ projectMetaDataState,
+ qnaFilesState,
+ recentProjectsState,
+ schemasState,
+ settingsState,
+ skillManifestsState,
+ skillsState,
+ showCreateQnAFromUrlDialogState,
+} from '../../atoms';
+import * as botstates from '../../atoms/botState';
+import lgWorker from '../../parsers/lgWorker';
+import luWorker from '../../parsers/luWorker';
+import qnaWorker from '../../parsers/qnaWorker';
+import FilePersistence from '../../persistence/FilePersistence';
+import { rootBotProjectIdSelector } from '../../selectors';
+import { undoHistoryState } from '../../undo/history';
+import UndoHistory from '../../undo/undoHistory';
+import { logMessage, setError } from '../shared';
+
+export const resetBotStates = async ({ reset }: CallbackInterface, projectId: string) => {
+ const botStates = Object.keys(botstates);
+ botStates.forEach((state) => {
+ const currentRecoilAtom: any = botstates[state];
+ reset(currentRecoilAtom(projectId));
+ });
+};
+
+export const setErrorOnBotProject = async (
+ callbackHelpers: CallbackInterface,
+ projectId: string,
+ botName: string,
+ payload: any
+) => {
+ const { set } = callbackHelpers;
+ if (payload?.response?.data?.message) {
+ set(botErrorState(projectId), payload.response.data);
+ } else {
+ set(botErrorState(projectId), payload);
+ }
+ if (payload != null) logMessage(callbackHelpers, `Error loading ${botName}: ${JSON.stringify(payload)}`);
+};
+
+export const flushExistingTasks = async (callbackHelpers) => {
+ const { snapshot, reset } = callbackHelpers;
+ reset(botProjectSpaceLoadedState);
+ const projectIds = await snapshot.getPromise(botProjectIdsState);
+ reset(botProjectIdsState, []);
+ for (const projectId of projectIds) {
+ resetBotStates(callbackHelpers, projectId);
+ }
+ const workers = [lgWorker, luWorker, qnaWorker];
+
+ return Promise.all([workers.map((w) => w.flush())]);
+};
+
+// merge sensitive values in localStorage
+const mergeLocalStorage = (projectId: string, settings: DialogSetting) => {
+ const localSetting = settingStorage.get(projectId);
+ const mergedSettings = { ...settings };
+ if (localSetting) {
+ for (const property of SensitiveProperties) {
+ const value = objectGet(localSetting, property);
+ if (value) {
+ objectSet(mergedSettings, property, value);
+ } else {
+ objectSet(mergedSettings, property, ''); // set those key back, because that were omit after persisited
+ }
+ }
+ }
+ return mergedSettings;
+};
+
+export const getMergedSettings = (projectId, settings): DialogSetting => {
+ const mergedSettings = mergeLocalStorage(projectId, settings);
+ if (Array.isArray(mergedSettings.skill)) {
+ const skillsArr = mergedSettings.skill.map((skillData) => ({ ...skillData }));
+ mergedSettings.skill = convertSkillsToDictionary(skillsArr);
+ }
+ return mergedSettings;
+};
+
+export const navigateToBot = (
+ callbackHelpers: CallbackInterface,
+ projectId: string,
+ mainDialog: string,
+ qnaKbUrls?: string[],
+ templateId?: string
+) => {
+ if (projectId) {
+ const { set } = callbackHelpers;
+ set(currentProjectIdState, projectId);
+ let url = `/bot/${projectId}/dialogs/${mainDialog}`;
+ if (templateId === QnABotTemplateId) {
+ url = `/bot/${projectId}/knowledge-base/${mainDialog}`;
+ navigateTo(url, { state: { qnaKbUrls } });
+ return;
+ }
+ navigateTo(url);
+ }
+};
+
+export const loadProjectData = (response) => {
+ const { files, botName, settings, skills: skillContent, id: projectId } = response.data;
+ const mergedSettings = getMergedSettings(projectId, settings);
+ const storedLocale = languageStorage.get(botName)?.locale;
+ const locale = settings.languages.includes(storedLocale) ? storedLocale : settings.defaultLanguage;
+ const indexedFiles = indexer.index(files, botName, locale, skillContent, mergedSettings);
+
+ // migrate script move qna pairs in *.qna to *-manual.source.qna.
+ // TODO: remove after a period of time.
+ const updateQnAFiles = reformQnAToContainerKB(projectId, indexedFiles.qnaFiles);
+
+ return {
+ botFiles: { ...indexedFiles, qnaFiles: updateQnAFiles, mergedSettings },
+ projectData: response.data,
+ error: undefined,
+ };
+};
+
+export const fetchProjectDataByPath = async (
+ path: string,
+ storageId
+): Promise<{ botFiles: any; projectData: any; error: any }> => {
+ try {
+ const response = await httpClient.put(`/projects/open`, { path, storageId });
+ const projectData = loadProjectData(response);
+ return projectData;
+ } catch (ex) {
+ return {
+ botFiles: undefined,
+ projectData: undefined,
+ error: ex,
+ };
+ }
+};
+
+export const fetchProjectDataById = async (projectId): Promise<{ botFiles: any; projectData: any; error: any }> => {
+ try {
+ const response = await httpClient.get(`/projects/${projectId}`);
+ const projectData = loadProjectData(response);
+ return projectData;
+ } catch (ex) {
+ return {
+ botFiles: undefined,
+ projectData: undefined,
+ error: ex,
+ };
+ }
+};
+
+export const handleProjectFailure = (callbackHelpers: CallbackInterface, ex) => {
+ setError(callbackHelpers, ex);
+};
+
+export const processSchema = (projectId: string, schema: any) => ({
+ ...schema,
+ definitions: dereferenceDefinitions(schema.definitions),
+});
+
+// if user set value in terminal or appsetting.json, it should update the value in localStorage
+export const refreshLocalStorage = (projectId: string, settings: DialogSetting) => {
+ for (const property of SensitiveProperties) {
+ const value = objectGet(settings, property);
+ if (value) {
+ settingStorage.setField(projectId, property, value);
+ }
+ }
+};
+
+export const updateLuFilesStatus = (projectId: string, luFiles: LuFile[]) => {
+ const status = luFileStatusStorage.get(projectId);
+ return luFiles.map((luFile) => {
+ if (typeof status[luFile.id] === 'boolean') {
+ return { ...luFile, published: status[luFile.id] };
+ } else {
+ return { ...luFile, published: false };
+ }
+ });
+};
+
+export const initLuFilesStatus = (projectId: string, luFiles: LuFile[], dialogs: DialogInfo[]) => {
+ luFileStatusStorage.checkFileStatus(
+ projectId,
+ getReferredLuFiles(luFiles, dialogs).map((file) => file.id)
+ );
+ return updateLuFilesStatus(projectId, luFiles);
+};
+
+export const updateQnaFilesStatus = (projectId: string, qnaFiles: QnAFile[]) => {
+ const status = qnaFileStatusStorage.get(projectId);
+ return qnaFiles.map((qnaFile) => {
+ if (typeof status[qnaFile.id] === 'boolean') {
+ return { ...qnaFile, published: status[qnaFile.id] };
+ } else {
+ return { ...qnaFile, published: false };
+ }
+ });
+};
+
+export const initQnaFilesStatus = (projectId: string, qnaFiles: QnAFile[], dialogs: DialogInfo[]) => {
+ qnaFileStatusStorage.checkFileStatus(
+ projectId,
+ getReferredQnaFiles(qnaFiles, dialogs).map((file) => file.id)
+ );
+ return updateQnaFilesStatus(projectId, qnaFiles);
+};
+
+export const initBotState = async (callbackHelpers: CallbackInterface, data: any, botFiles: any) => {
+ const { snapshot, set } = callbackHelpers;
+ const { botName, botEnvironment, location, schemas, settings, id: projectId, diagnostics } = data;
+ const {
+ dialogs,
+ dialogSchemas,
+ luFiles,
+ lgFiles,
+ qnaFiles,
+ jsonSchemaFiles,
+ formDialogSchemas,
+ skillManifestFiles,
+ skills,
+ mergedSettings,
+ } = botFiles;
+ const curLocation = await snapshot.getPromise(locationState(projectId));
+ const storedLocale = languageStorage.get(botName)?.locale;
+ const locale = settings.languages.includes(storedLocale) ? storedLocale : settings.defaultLanguage;
+
+ try {
+ schemas.sdk.content = processSchema(projectId, schemas.sdk.content);
+ } catch (err) {
+ const diagnostics = schemas.diagnostics ?? [];
+ diagnostics.push(err.message);
+ schemas.diagnostics = diagnostics;
+ }
+
+ let mainDialog = '';
+ const verifiedDialogs = dialogs.map((dialog) => {
+ if (dialog.isRoot) {
+ mainDialog = dialog.id;
+ }
+ dialog.diagnostics = validateDialog(dialog, schemas.sdk.content, lgFiles, luFiles);
+ return dialog;
+ });
+
+ await lgWorker.addProject(projectId, lgFiles);
+
+ // Form dialogs
+ set(
+ formDialogSchemaIdsState(projectId),
+ formDialogSchemas.map((f) => f.id)
+ );
+ formDialogSchemas.forEach(({ id, content }) => {
+ set(formDialogSchemaState({ projectId, schemaId: id }), { id, content });
+ });
+
+ set(skillManifestsState(projectId), skillManifestFiles);
+ set(luFilesState(projectId), initLuFilesStatus(botName, luFiles, dialogs));
+ set(lgFilesState(projectId), lgFiles);
+ set(jsonSchemaFilesState(projectId), jsonSchemaFiles);
+ set(dialogsState(projectId), verifiedDialogs);
+ set(dialogSchemasState(projectId), dialogSchemas);
+ set(botEnvironmentState(projectId), botEnvironment);
+ set(botDisplayNameState(projectId), botName);
+ set(qnaFilesState(projectId), initQnaFilesStatus(botName, qnaFiles, dialogs));
+ if (location !== curLocation) {
+ set(botStatusState(projectId), BotStatus.unConnected);
+ set(locationState(projectId), location);
+ }
+ set(skillsState(projectId), skills);
+ set(schemasState(projectId), schemas);
+ set(localeState(projectId), locale);
+ set(botDiagnosticsState(projectId), diagnostics);
+
+ refreshLocalStorage(projectId, settings);
+ set(settingsState(projectId), mergedSettings);
+ set(filePersistenceState(projectId), new FilePersistence(projectId));
+ set(undoHistoryState(projectId), new UndoHistory(projectId));
+ return mainDialog;
+};
+
+export const removeRecentProject = async (callbackHelpers: CallbackInterface, path: string) => {
+ try {
+ const {
+ set,
+ snapshot: { getPromise },
+ } = callbackHelpers;
+ const currentRecentProjects = await getPromise(recentProjectsState);
+ const filtered = currentRecentProjects.filter((p) => p.path !== path);
+ set(recentProjectsState, filtered);
+ } catch (ex) {
+ logMessage(callbackHelpers, `Error removing recent project: ${ex}`);
+ }
+};
+
+export const openRemoteSkill = async (
+ callbackHelpers: CallbackInterface,
+ manifestUrl: string,
+ botNameIdentifier: string
+) => {
+ const { set } = callbackHelpers;
+
+ const response = await httpClient.get(`/projects/generateProjectId`);
+ const projectId = response.data;
+ const stringified = stringify({
+ url: manifestUrl,
+ });
+ const manifestResponse = await httpClient.get(
+ `/projects/${projectId}/skill/retrieveSkillManifest?${stringified}&ignoreProjectValidation=true`
+ );
+ set(projectMetaDataState(projectId), {
+ isRootBot: false,
+ isRemote: true,
+ });
+ set(botNameIdentifierState(projectId), botNameIdentifier);
+ set(botDisplayNameState(projectId), manifestResponse.data.name);
+ set(locationState(projectId), manifestUrl);
+ return { projectId, manifestResponse: manifestResponse.data };
+};
+
+export const openLocalSkill = async (callbackHelpers, pathToBot: string, storageId, botNameIdentifier: string) => {
+ const { set } = callbackHelpers;
+ const { projectData, botFiles, error } = await fetchProjectDataByPath(pathToBot, storageId);
+
+ if (error) {
+ throw error;
+ }
+ const mainDialog = await initBotState(callbackHelpers, projectData, botFiles);
+ set(projectMetaDataState(projectData.id), {
+ isRootBot: false,
+ isRemote: false,
+ });
+ set(botNameIdentifierState(projectData.id), botNameIdentifier);
+
+ return {
+ projectId: projectData.id,
+ mainDialog,
+ };
+};
+
+export const createNewBotFromTemplate = async (
+ callbackHelpers,
+ templateId: string,
+ name: string,
+ description: string,
+ location: string,
+ schemaUrl?: string,
+ locale?: string
+) => {
+ const { set } = callbackHelpers;
+ const response = await httpClient.post(`/projects`, {
+ storageId: 'default',
+ templateId,
+ name,
+ description,
+ location,
+ schemaUrl,
+ locale,
+ });
+ const { botFiles, projectData } = loadProjectData(response);
+ const projectId = response.data.id;
+ if (settingStorage.get(projectId)) {
+ settingStorage.remove(projectId);
+ }
+ const currentBotProjectFileIndexed: BotProjectFile = botFiles.botProjectSpaceFiles[0];
+ set(botProjectFileState(projectId), currentBotProjectFileIndexed);
+
+ const mainDialog = await initBotState(callbackHelpers, projectData, botFiles);
+ // if create from QnATemplate, continue creation flow.
+ if (templateId === QnABotTemplateId) {
+ set(showCreateQnAFromUrlDialogState(projectId), true);
+ }
+
+ return { projectId, mainDialog };
+};
+
+const addProjectToBotProjectSpace = (set, projectId: string, skillCt: number) => {
+ let isBotProjectLoaded = false;
+ set(botProjectIdsState, (current: string[]) => {
+ const botProjectIds = [...current, projectId];
+ if (botProjectIds.length === skillCt) {
+ isBotProjectLoaded = true;
+ }
+ return botProjectIds;
+ });
+ if (isBotProjectLoaded) {
+ set(botProjectSpaceLoadedState, true);
+ }
+};
+
+const handleSkillLoadingFailure = (callbackHelpers, { ex, skillNameIdentifier }) => {
+ const { set } = callbackHelpers;
+ // Generating a dummy project id which will be replaced by the user from the UI.
+ const projectId = uuid();
+ set(botDisplayNameState(projectId), skillNameIdentifier);
+ set(botNameIdentifierState(projectId), skillNameIdentifier);
+ setErrorOnBotProject(callbackHelpers, projectId, skillNameIdentifier, ex);
+ return projectId;
+};
+
+const openRootBotAndSkills = async (callbackHelpers: CallbackInterface, data, storageId = 'default') => {
+ const { projectData, botFiles } = data;
+ const { set } = callbackHelpers;
+
+ const mainDialog = await initBotState(callbackHelpers, projectData, botFiles);
+ const rootBotProjectId = projectData.id;
+ const { name } = projectData;
+ set(botNameIdentifierState(rootBotProjectId), camelCase(name));
+
+ if (botFiles.botProjectSpaceFiles && botFiles.botProjectSpaceFiles.length) {
+ const currentBotProjectFileIndexed: BotProjectFile = botFiles.botProjectSpaceFiles[0];
+ set(botProjectFileState(rootBotProjectId), currentBotProjectFileIndexed);
+ const currentBotProjectFile: BotProjectSpace = currentBotProjectFileIndexed.content;
+
+ const skills: { [skillId: string]: BotProjectSpaceSkill } = {
+ ...currentBotProjectFile.skills,
+ };
+
+ // RootBot loads first + skills load async
+ const totalProjectsCount = Object.keys(skills).length + 1;
+ if (totalProjectsCount > 0) {
+ for (const nameIdentifier in skills) {
+ const skill = skills[nameIdentifier];
+ let skillPromise;
+ if (!skill.remote && skill.workspace) {
+ const skillPath = convertFileProtocolToPath(skill.workspace);
+ skillPromise = openLocalSkill(callbackHelpers, skillPath, storageId, nameIdentifier);
+ } else if (skill.manifest) {
+ skillPromise = openRemoteSkill(callbackHelpers, skill.manifest, nameIdentifier);
+ }
+ if (skillPromise) {
+ skillPromise
+ .then(({ projectId }) => {
+ addProjectToBotProjectSpace(set, projectId, totalProjectsCount);
+ })
+ .catch((ex) => {
+ const projectId = handleSkillLoadingFailure(callbackHelpers, {
+ skillNameIdentifier: nameIdentifier,
+ ex,
+ });
+ addProjectToBotProjectSpace(set, projectId, totalProjectsCount);
+ });
+ }
+ }
+ }
+ } else {
+ // Should never hit here as all projects should have a botproject file
+ throw new Error(formatMessage('Bot project file does not exist.'));
+ }
+ set(botProjectIdsState, [rootBotProjectId]);
+ set(currentProjectIdState, rootBotProjectId);
+ return {
+ mainDialog,
+ projectId: rootBotProjectId,
+ };
+};
+
+export const openRootBotAndSkillsByPath = async (callbackHelpers: CallbackInterface, path: string, storageId) => {
+ const data = await fetchProjectDataByPath(path, storageId);
+ if (data.error) {
+ throw data.error;
+ }
+ return await openRootBotAndSkills(callbackHelpers, data, storageId);
+};
+
+export const openRootBotAndSkillsByProjectId = async (callbackHelpers: CallbackInterface, projectId: string) => {
+ const data = await fetchProjectDataById(projectId);
+ if (data.error) {
+ throw data.error;
+ }
+ return await openRootBotAndSkills(callbackHelpers, data);
+};
+
+export const saveProject = async (callbackHelpers, oldProjectData) => {
+ const { oldProjectId, name, description, location } = oldProjectData;
+ const response = await httpClient.post(`/projects/${oldProjectId}/project/saveAs`, {
+ storageId: 'default',
+ name,
+ description,
+ location,
+ });
+ const data = loadProjectData(response);
+ if (data.error) {
+ throw data.error;
+ }
+ const result = openRootBotAndSkills(callbackHelpers, data);
+ return result;
+};
+
+export const getSkillNameIdentifier = async (
+ callbackHelpers: CallbackInterface,
+ displayName: string
+): Promise => {
+ const { snapshot } = callbackHelpers;
+ const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector);
+ if (rootBotProjectId) {
+ const { content: botProjectFile } = await snapshot.getPromise(botProjectFileState(rootBotProjectId));
+ return getUniqueName(Object.keys(botProjectFile.skills), camelCase(displayName));
+ }
+ return '';
+};
+
+export const checkIfBotExistsInBotProjectFile = async (
+ callbackHelpers: CallbackInterface,
+ pathOrManifest: string,
+ remote?: boolean
+) => {
+ const { snapshot } = callbackHelpers;
+ const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector);
+ if (!rootBotProjectId) {
+ throw new Error(formatMessage('The root bot is not a bot project'));
+ }
+ const { content: botProjectFile } = await snapshot.getPromise(botProjectFileState(rootBotProjectId));
+
+ for (const uniqueSkillName in botProjectFile.skills) {
+ const { manifest, workspace } = botProjectFile.skills[uniqueSkillName];
+ if (remote) {
+ if (manifest === pathOrManifest) {
+ return true;
+ }
+ } else {
+ if (workspace) {
+ const resolvedPath = convertFileProtocolToPath(workspace);
+ if (pathOrManifest === resolvedPath) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/zoom.ts b/Composer/packages/client/src/recoilModel/dispatchers/zoom.ts
new file mode 100644
index 0000000000..cc1def19d6
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/dispatchers/zoom.ts
@@ -0,0 +1,18 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { CallbackInterface, useRecoilCallback } from 'recoil';
+
+import { rateInfoState } from '../atoms/zoomState';
+
+export const zoomDispatcher = () => {
+ const updateZoomRate = useRecoilCallback(({ set }: CallbackInterface) => async ({ currentRate }) => {
+ set(rateInfoState, (rateInfo) => {
+ return { ...rateInfo, currentRate };
+ });
+ });
+ return {
+ updateZoomRate,
+ };
+};
diff --git a/Composer/packages/client/src/recoilModel/index.ts b/Composer/packages/client/src/recoilModel/index.ts
index 8105c6ee56..b74182652c 100644
--- a/Composer/packages/client/src/recoilModel/index.ts
+++ b/Composer/packages/client/src/recoilModel/index.ts
@@ -3,4 +3,4 @@
export * from './atoms';
export * from './DispatcherWrapper';
-export * from './selectors/';
+export * from './selectors';
diff --git a/Composer/packages/client/src/recoilModel/parsers/__test__/luWorker.test.ts b/Composer/packages/client/src/recoilModel/parsers/__test__/luWorker.test.ts
index db94ae135b..a37cdb70c6 100644
--- a/Composer/packages/client/src/recoilModel/parsers/__test__/luWorker.test.ts
+++ b/Composer/packages/client/src/recoilModel/parsers/__test__/luWorker.test.ts
@@ -5,6 +5,8 @@ import { Range, Position, LuIntentSection } from '@bfc/shared';
import luWorker from '../luWorker';
+const luFeatures = {};
+
jest.mock('./../workers/luParser.worker.ts', () => {
class Test {
onmessage = (data) => data;
@@ -29,7 +31,7 @@ describe('test lu worker', () => {
it('get expected parse result', async () => {
const content = `# Hello
- hi`;
- const result: any = await luWorker.parse('', content);
+ const result: any = await luWorker.parse('', content, luFeatures);
const expected = [
{ Body: '- hi', Entities: [], Name: 'Hello', range: new Range(new Position(1, 0), new Position(2, 4)) },
];
@@ -45,7 +47,7 @@ hi
@ simple friendsName
`;
- const { intents, diagnostics }: any = await luWorker.parse('', content);
+ const { intents, diagnostics }: any = await luWorker.parse('', content, luFeatures);
expect(intents.length).toEqual(1);
expect(diagnostics.length).toEqual(1);
expect(diagnostics[0].range.start.line).toEqual(2);
@@ -55,7 +57,7 @@ hi
});
it('get expected add intent result', async () => {
- const result: any = await luWorker.addIntent(luFile, getLuIntent('New', '-IntentValue'));
+ const result: any = await luWorker.addIntent(luFile, getLuIntent('New', '-IntentValue'), luFeatures);
const expected = {
Body: '-IntentValue',
Entities: [],
@@ -68,10 +70,11 @@ hi
});
it('get expected add intents result', async () => {
- const result: any = await luWorker.addIntents(luFile, [
- getLuIntent('New1', '-IntentValue1'),
- getLuIntent('New2', '-IntentValue2'),
- ]);
+ const result: any = await luWorker.addIntents(
+ luFile,
+ [getLuIntent('New1', '-IntentValue1'), getLuIntent('New2', '-IntentValue2')],
+ luFeatures
+ );
const expected = {
Body: '-IntentValue2',
Entities: [],
@@ -84,7 +87,7 @@ hi
});
it('get expected update intent result', async () => {
- const result: any = await luWorker.updateIntent(luFile, 'New', getLuIntent('New', '-update'));
+ const result: any = await luWorker.updateIntent(luFile, 'New', getLuIntent('New', '-update'), luFeatures);
const expected = {
Body: '-update',
Entities: [],
@@ -97,14 +100,14 @@ hi
});
it('get expected remove intent result', async () => {
- const result: any = await luWorker.removeIntent(luFile, 'New2');
+ const result: any = await luWorker.removeIntent(luFile, 'New2', luFeatures);
expect(result.intents.length).toBe(3);
expect(result.intents[3]).toBeUndefined();
luFile = result;
});
it('get expected remove intent result', async () => {
- const result: any = await luWorker.removeIntents(luFile, ['New1', 'New']);
+ const result: any = await luWorker.removeIntents(luFile, ['New1', 'New'], luFeatures);
expect(result.intents.length).toBe(1);
});
});
diff --git a/Composer/packages/client/src/recoilModel/parsers/luWorker.ts b/Composer/packages/client/src/recoilModel/parsers/luWorker.ts
index 9973a9bab4..fd43f85c8b 100644
--- a/Composer/packages/client/src/recoilModel/parsers/luWorker.ts
+++ b/Composer/packages/client/src/recoilModel/parsers/luWorker.ts
@@ -15,33 +15,33 @@ import {
} from './types';
// Wrapper class
class LuWorker extends BaseWorker {
- parse(id: string, content: string) {
- const payload = { id, content };
+ parse(id: string, content: string, luFeatures) {
+ const payload = { id, content, luFeatures };
return this.sendMsg(LuActionType.Parse, payload);
}
- addIntent(luFile: LuFile, intent: LuIntentSection) {
- const payload = { luFile, intent };
+ addIntent(luFile: LuFile, intent: LuIntentSection, luFeatures) {
+ const payload = { luFile, intent, luFeatures };
return this.sendMsg(LuActionType.AddIntent, payload);
}
- updateIntent(luFile: LuFile, intentName: string, intent?: { Name?: string; Body?: string }) {
- const payload = { luFile, intentName, intent };
+ updateIntent(luFile: LuFile, intentName: string, intent: { Name?: string; Body?: string }, luFeatures) {
+ const payload = { luFile, intentName, intent, luFeatures };
return this.sendMsg(LuActionType.UpdateIntent, payload);
}
- removeIntent(luFile: LuFile, intentName: string) {
- const payload = { luFile, intentName };
+ removeIntent(luFile: LuFile, intentName: string, luFeatures) {
+ const payload = { luFile, intentName, luFeatures };
return this.sendMsg(LuActionType.RemoveIntent, payload);
}
- addIntents(luFile: LuFile, intents: LuIntentSection[]) {
- const payload = { luFile, intents };
+ addIntents(luFile: LuFile, intents: LuIntentSection[], luFeatures) {
+ const payload = { luFile, intents, luFeatures };
return this.sendMsg(LuActionType.AddIntents, payload);
}
- removeIntents(luFile: LuFile, intentNames: string[]) {
- const payload = { luFile, intentNames };
+ removeIntents(luFile: LuFile, intentNames: string[], luFeatures) {
+ const payload = { luFile, intentNames, luFeatures };
return this.sendMsg(LuActionType.RemoveIntents, payload);
}
}
diff --git a/Composer/packages/client/src/recoilModel/parsers/types.ts b/Composer/packages/client/src/recoilModel/parsers/types.ts
index 6c96a2e370..a5e0c484fb 100644
--- a/Composer/packages/client/src/recoilModel/parsers/types.ts
+++ b/Composer/packages/client/src/recoilModel/parsers/types.ts
@@ -1,36 +1,42 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { LuIntentSection, LgFile, LuFile, QnASection, FileInfo, LgTemplate } from '@bfc/shared';
+import { LuIntentSection, LgFile, LuFile, QnASection, FileInfo, LgTemplate, ILUFeaturesConfig } from '@bfc/shared';
export type LuParsePayload = {
id: string;
content: string;
+ luFeatures: ILUFeaturesConfig;
};
export type LuAddIntentPayload = {
luFile: LuFile;
intent: LuIntentSection;
+ luFeatures: ILUFeaturesConfig;
};
export type LuAddIntentsPayload = {
luFile: LuFile;
intents: LuIntentSection[];
+ luFeatures: ILUFeaturesConfig;
};
export type LuUpdateIntentPayload = {
luFile: LuFile;
intentName: string;
intent?: { Name?: string; Body?: string };
+ luFeatures: ILUFeaturesConfig;
};
export type LuRemoveIntentPayload = {
luFile: LuFile;
intentName: string;
+ luFeatures: ILUFeaturesConfig;
};
export type LuRemoveIntentsPayload = {
luFile: LuFile;
intentNames: string[];
+ luFeatures: ILUFeaturesConfig;
};
export type LgParsePayload = {
@@ -98,6 +104,7 @@ export type IndexPayload = {
botName: string;
schemas: any;
locale: string;
+ luFeatures: { key: string; value: boolean };
};
export type QnAPayload = {
diff --git a/Composer/packages/client/src/recoilModel/parsers/workers/luParser.worker.ts b/Composer/packages/client/src/recoilModel/parsers/workers/luParser.worker.ts
index ed4a8b8437..c304e994f9 100644
--- a/Composer/packages/client/src/recoilModel/parsers/workers/luParser.worker.ts
+++ b/Composer/packages/client/src/recoilModel/parsers/workers/luParser.worker.ts
@@ -56,38 +56,38 @@ export const handleMessage = (msg: LuMessageEvent) => {
let result: any = null;
switch (msg.type) {
case LuActionType.Parse: {
- const { id, content } = msg.payload;
- result = luUtil.parse(id, content);
+ const { id, content, luFeatures } = msg.payload;
+ result = luUtil.parse(id, content, luFeatures);
break;
}
case LuActionType.AddIntent: {
- const { luFile, intent } = msg.payload;
- result = luUtil.addIntent(luFile, intent);
+ const { luFile, intent, luFeatures } = msg.payload;
+ result = luUtil.addIntent(luFile, intent, luFeatures);
break;
}
case LuActionType.AddIntents: {
- const { luFile, intents } = msg.payload;
- result = luUtil.addIntents(luFile, intents);
+ const { luFile, intents, luFeatures } = msg.payload;
+ result = luUtil.addIntents(luFile, intents, luFeatures);
break;
}
case LuActionType.UpdateIntent: {
- const { luFile, intentName, intent } = msg.payload;
- result = luUtil.updateIntent(luFile, intentName, intent || null);
+ const { luFile, intentName, intent, luFeatures } = msg.payload;
+ result = luUtil.updateIntent(luFile, intentName, intent || null, luFeatures);
break;
}
case LuActionType.RemoveIntent: {
- const { luFile, intentName } = msg.payload;
- result = luUtil.removeIntent(luFile, intentName);
+ const { luFile, intentName, luFeatures } = msg.payload;
+ result = luUtil.removeIntent(luFile, intentName, luFeatures);
break;
}
case LuActionType.RemoveIntents: {
- const { luFile, intentNames } = msg.payload;
- result = luUtil.removeIntents(luFile, intentNames);
+ const { luFile, intentNames, luFeatures } = msg.payload;
+ result = luUtil.removeIntents(luFile, intentNames, luFeatures);
break;
}
}
diff --git a/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts b/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts
index 7dc3481f4a..cc463d5f9d 100644
--- a/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts
+++ b/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts
@@ -1,15 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { qnaIndexer } from '@bfc/indexers';
import * as qnaUtil from '@bfc/indexers/lib/utils/qnaUtil';
import { QnAActionType } from './../types';
const ctx: Worker = self as any;
-const parse = (content: string, id: string) => {
- return { id, content, ...qnaIndexer.parse(content, id) };
-};
-
ctx.onmessage = function (msg) {
const { id: msgId, type, payload } = msg.data;
const { content, id, file, indexId } = payload;
@@ -17,7 +12,7 @@ ctx.onmessage = function (msg) {
try {
switch (type) {
case QnAActionType.Parse: {
- result = parse(content, id);
+ result = qnaUtil.parse(id, content);
break;
}
case QnAActionType.AddSection: {
diff --git a/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts b/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts
index 16a098ea40..378842a540 100644
--- a/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts
+++ b/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts
@@ -1,13 +1,24 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import keys from 'lodash/keys';
+
import differenceWith from 'lodash/differenceWith';
import isEqual from 'lodash/isEqual';
-import { DialogInfo, DialogSchemaFile, DialogSetting, SkillManifest, BotAssets } from '@bfc/shared';
+import {
+ DialogInfo,
+ DialogSchemaFile,
+ DialogSetting,
+ SkillManifest,
+ BotAssets,
+ BotProjectFile,
+ LuFile,
+ LgFile,
+ QnAFile,
+ FormDialogSchema,
+} from '@bfc/shared';
+import keys from 'lodash/keys';
-import { LuFile, LgFile, QnAFile } from './../../../../lib/shared/src/types/indexers';
import * as client from './http';
-import { IFileChange, ChangeType, FileExtensions } from './types';
+import { ChangeType, FileExtensions, IFileChange } from './types';
class FilePersistence {
private _taskQueue: { [id: string]: IFileChange[] } = {};
@@ -184,6 +195,20 @@ class FilePersistence {
return changes;
}
+ private getBotProjectFileChanges(current: BotProjectFile, previous: BotProjectFile) {
+ if (!isEqual(current, previous)) {
+ return [
+ {
+ id: `${current.id}${FileExtensions.BotProject}`,
+ change: JSON.stringify(current.content, null, 2),
+ type: ChangeType.UPDATE,
+ projectId: this._projectId,
+ },
+ ];
+ }
+ return [];
+ }
+
private getSettingsChanges(current: DialogSetting, previous: DialogSetting) {
if (!isEqual(current, previous)) {
return [
@@ -198,6 +223,12 @@ class FilePersistence {
return [];
}
+ private getFormDialogSchemaFileChanges(current: FormDialogSchema[], previous: FormDialogSchema[]) {
+ const changeItems = this.getDifferenceItems(current, previous);
+ const changes = this.getFileChanges(FileExtensions.FormDialog, changeItems);
+ return changes;
+ }
+
private getAssetsChanges(currentAssets: BotAssets, previousAssets: BotAssets): IFileChange[] {
const dialogChanges = this.getDialogChanges(currentAssets.dialogs, previousAssets.dialogs);
const dialogSchemaChanges = this.getDialogSchemaChanges(currentAssets.dialogSchemas, previousAssets.dialogSchemas);
@@ -209,6 +240,17 @@ class FilePersistence {
previousAssets.skillManifests
);
const settingChanges = this.getSettingsChanges(currentAssets.setting, previousAssets.setting);
+
+ const formDialogChanges = this.getFormDialogSchemaFileChanges(
+ currentAssets.formDialogSchemas,
+ previousAssets.formDialogSchemas
+ );
+
+ const botProjectFileChanges = this.getBotProjectFileChanges(
+ currentAssets.botProjectFile,
+ previousAssets.botProjectFile
+ );
+
const fileChanges: IFileChange[] = [
...dialogChanges,
...dialogSchemaChanges,
@@ -217,6 +259,8 @@ class FilePersistence {
...lgChanges,
...skillManifestChanges,
...settingChanges,
+ ...formDialogChanges,
+ ...botProjectFileChanges,
];
return fileChanges;
}
diff --git a/Composer/packages/client/src/recoilModel/persistence/__test__/FilePersistence.test.ts b/Composer/packages/client/src/recoilModel/persistence/__test__/FilePersistence.test.ts
index 64b27a9cfb..854b2a7591 100644
--- a/Composer/packages/client/src/recoilModel/persistence/__test__/FilePersistence.test.ts
+++ b/Composer/packages/client/src/recoilModel/persistence/__test__/FilePersistence.test.ts
@@ -26,7 +26,7 @@ describe('test persistence layer', () => {
it('test notify update', async () => {
const previous = {
projectId: 'test',
- dialogs: [{ id: 'a', content: { a: 'old' } }] as DialogInfo[],
+ dialogs: ([{ id: 'a', content: { a: 'old' } }] as unknown) as DialogInfo[],
dialogSchemas: [{ id: 'a', content: { a: 'old schema' } }] as DialogSchemaFile[],
lgFiles: [{ id: 'a.en-us', content: '' }] as LgFile[],
luFiles: [{ id: 'a.en-us', content: '' }] as LuFile[],
@@ -34,7 +34,7 @@ describe('test persistence layer', () => {
const current = {
projectId: 'test',
- dialogs: [{ id: 'a', content: { a: 'new' } }] as DialogInfo[],
+ dialogs: ([{ id: 'a', content: { a: 'new' } }] as unknown) as DialogInfo[],
dialogSchemas: [{ id: 'a', content: { a: 'new schema' } }] as DialogSchemaFile[],
lgFiles: [{ id: 'a.en-us', content: 'a.lg' }] as LgFile[],
luFiles: [{ id: 'a.en-us', content: 'a.lu' }] as LuFile[],
@@ -51,7 +51,7 @@ describe('test persistence layer', () => {
it('test notify create', async () => {
const previous = {
projectId: 'test',
- dialogs: [{ id: 'a', content: { a: 'a' } }] as DialogInfo[],
+ dialogs: ([{ id: 'a', content: { a: 'a' } }] as unknown) as DialogInfo[],
dialogSchemas: [{ id: 'a', content: { a: 'a' } }] as DialogSchemaFile[],
lgFiles: [{ id: 'a.en-us', content: 'a' }] as LgFile[],
luFiles: [{ id: 'a.en-us', content: 'a' }] as LuFile[],
@@ -59,10 +59,10 @@ describe('test persistence layer', () => {
const current = {
projectId: 'test',
- dialogs: [
+ dialogs: ([
{ id: 'a', content: { a: 'a' } },
{ id: 'b', content: { b: 'b' } },
- ] as DialogInfo[],
+ ] as unknown) as DialogInfo[],
dialogSchemas: [
{ id: 'a', content: { a: 'a' } },
{ id: 'b', content: { b: 'b' } },
@@ -88,10 +88,10 @@ describe('test persistence layer', () => {
it('test notify remove', async () => {
const previous = {
projectId: 'test',
- dialogs: [
+ dialogs: ([
{ id: 'a', content: { a: 'a' } },
{ id: 'b', content: { b: 'b.pre' } },
- ] as DialogInfo[],
+ ] as unknown) as DialogInfo[],
dialogSchemas: [
{ id: 'a', content: { a: 'a' } },
{ id: 'b', content: { b: 'b.pre' } },
@@ -108,7 +108,7 @@ describe('test persistence layer', () => {
const current = {
projectId: 'test',
- dialogs: [{ id: 'a', content: { a: 'a' } }] as DialogInfo[],
+ dialogs: ([{ id: 'a', content: { a: 'a' } }] as unknown) as DialogInfo[],
dialogSchemas: [{ id: 'a', content: { a: 'a' } }] as DialogSchemaFile[],
lgFiles: [{ id: 'a.en-us', content: 'a' }] as LgFile[],
luFiles: [{ id: 'a.en-us', content: 'a' }] as LuFile[],
diff --git a/Composer/packages/client/src/recoilModel/persistence/types.ts b/Composer/packages/client/src/recoilModel/persistence/types.ts
index d1e344c4d0..941f6a119d 100644
--- a/Composer/packages/client/src/recoilModel/persistence/types.ts
+++ b/Composer/packages/client/src/recoilModel/persistence/types.ts
@@ -9,12 +9,15 @@ export enum ChangeType {
export enum FileExtensions {
Dialog = '.dialog',
+ FormDialog = '.form-dialog',
DialogSchema = '.dialog.schema',
Manifest = '.json',
Lu = '.lu',
Lg = '.lg',
QnA = '.qna',
+ SourceQnA = '.source.qna',
Setting = 'appsettings.json',
+ BotProject = '.botproj',
}
export type FileErrorHandler = (error) => void;
diff --git a/Composer/packages/client/src/recoilModel/selectors/design.ts b/Composer/packages/client/src/recoilModel/selectors/design.ts
deleted file mode 100644
index 7814bdfd45..0000000000
--- a/Composer/packages/client/src/recoilModel/selectors/design.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import { selector } from 'recoil';
-
-import { botNameState, botProjectsSpaceState } from '../atoms';
-
-//TODO: This selector will be used when BotProjects is implemented
-export const botProjectSpaceSelector = selector({
- key: 'botProjectSpaceSelector',
- get: ({ get }) => {
- const botProjects = get(botProjectsSpaceState);
- const result = botProjects.map((botProjectId: string) => {
- const name = get(botNameState(botProjectId));
- return { projectId: botProjectId, name };
- });
- return result;
- },
-});
diff --git a/Composer/packages/client/src/recoilModel/selectors/extensions.ts b/Composer/packages/client/src/recoilModel/selectors/extensions.ts
new file mode 100644
index 0000000000..32ab01aadb
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/selectors/extensions.ts
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { selector } from 'recoil';
+
+import { extensionsState } from '../atoms/appState';
+import { ExtensionPageConfig } from '../../utils/pageLinks';
+
+export const enabledExtensionsSelector = selector({
+ key: 'enabledExtensionsSelector',
+ get: ({ get }) => {
+ const extensions = get(extensionsState);
+
+ return extensions.filter((e) => e.enabled);
+ },
+});
+
+export const pluginPagesSelector = selector({
+ key: 'pluginPagesSelector',
+ get: ({ get }) => {
+ const extensions = get(enabledExtensionsSelector);
+
+ return extensions.reduce((pages, p) => {
+ const pagesConfig = p.contributes?.views?.pages;
+ if (Array.isArray(pagesConfig) && pagesConfig.length > 0) {
+ pages.push(...pagesConfig.map((page) => ({ ...page, id: p.id })));
+ }
+ return pages;
+ }, [] as ExtensionPageConfig[]);
+ },
+});
diff --git a/Composer/packages/client/src/recoilModel/selectors/index.ts b/Composer/packages/client/src/recoilModel/selectors/index.ts
index 2679b02214..2dbb6d83cb 100644
--- a/Composer/packages/client/src/recoilModel/selectors/index.ts
+++ b/Composer/packages/client/src/recoilModel/selectors/index.ts
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-export * from '../selectors/eject';
-export * from '../selectors/design';
-export * from '../selectors/validatedDialogs';
+export * from './project';
+export * from './eject';
+export * from './extensions';
+export * from './validatedDialogs';
diff --git a/Composer/packages/client/src/recoilModel/selectors/project.ts b/Composer/packages/client/src/recoilModel/selectors/project.ts
new file mode 100644
index 0000000000..cebb4de1a9
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/selectors/project.ts
@@ -0,0 +1,78 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { selector, selectorFamily } from 'recoil';
+import isEmpty from 'lodash/isEmpty';
+import { FormDialogSchema } from '@bfc/shared';
+
+import {
+ botErrorState,
+ botDisplayNameState,
+ botProjectFileState,
+ botProjectIdsState,
+ dialogsState,
+ projectMetaDataState,
+ botNameIdentifierState,
+ formDialogSchemaIdsState,
+ formDialogSchemaState,
+} from '../atoms';
+
+// Actions
+export const botsForFilePersistenceSelector = selector({
+ key: 'botsForFilePersistenceSelector',
+ get: ({ get }) => {
+ const botProjectIds = get(botProjectIdsState);
+ return botProjectIds.filter((projectId: string) => {
+ const { isRemote } = get(projectMetaDataState(projectId));
+ const botError = get(botErrorState(projectId));
+ return !botError && !isRemote;
+ });
+ },
+});
+
+// TODO: This selector would be modfied and leveraged by the project tree
+export const botProjectSpaceSelector = selector({
+ key: 'botProjectSpaceSelector',
+ get: ({ get }) => {
+ const botProjects = get(botProjectIdsState);
+ const result = botProjects.map((projectId: string) => {
+ const dialogs = get(dialogsState(projectId));
+ const metaData = get(projectMetaDataState(projectId));
+ const botError = get(botErrorState(projectId));
+ const name = get(botDisplayNameState(projectId));
+ const botNameId = get(botNameIdentifierState(projectId));
+ return { dialogs, projectId, name, ...metaData, error: botError, botNameId };
+ });
+ return result;
+ },
+});
+
+export const rootBotProjectIdSelector = selector({
+ key: 'rootBotProjectIdSelector',
+ get: ({ get }) => {
+ const projectIds = get(botProjectIdsState);
+ const rootBotId = projectIds[0];
+ const botProjectFile = get(botProjectFileState(rootBotId));
+
+ const metaData = get(projectMetaDataState(rootBotId));
+ if (metaData.isRootBot && !isEmpty(botProjectFile)) {
+ return rootBotId;
+ }
+ },
+});
+
+export const formDialogSchemasSelectorFamily = selectorFamily({
+ key: 'formDialogSchemasSelector',
+ get: (projectId: string) => ({ get }) => {
+ const formDialogSchemaIds = get(formDialogSchemaIdsState(projectId));
+ return formDialogSchemaIds.map((schemaId) => get(formDialogSchemaState({ projectId, schemaId })));
+ },
+});
+
+export const formDialogSchemaDialogExistsSelector = selectorFamily({
+ key: 'formDialogSchemasSelector',
+ get: ({ projectId, schemaId }: { projectId: string; schemaId: string }) => ({ get }) => {
+ const dialogs = get(dialogsState(projectId));
+ return !!dialogs.find((d) => d.id === schemaId);
+ },
+});
diff --git a/Composer/packages/client/src/recoilModel/selectors/validatedDialogs.ts b/Composer/packages/client/src/recoilModel/selectors/validatedDialogs.ts
index cf92edf094..131926fbd6 100644
--- a/Composer/packages/client/src/recoilModel/selectors/validatedDialogs.ts
+++ b/Composer/packages/client/src/recoilModel/selectors/validatedDialogs.ts
@@ -4,11 +4,15 @@
import { selectorFamily } from 'recoil';
import { validateDialog } from '@bfc/indexers';
-import { dialogsState, schemasState, lgFilesState, luFilesState } from '../atoms/botState';
+import { botProjectIdsState, dialogsState, schemasState, lgFilesState, luFilesState } from '../atoms';
export const validateDialogSelectorFamily = selectorFamily({
key: 'validateDialogSelectorFamily',
get: (projectId: string) => ({ get }) => {
+ const loadedProjects = get(botProjectIdsState);
+ if (!loadedProjects.includes(projectId)) {
+ return [];
+ }
const dialogs = get(dialogsState(projectId));
const schemas = get(schemasState(projectId));
const lgFiles = get(lgFilesState(projectId));
diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts
index 63ddebe513..4659e14df8 100644
--- a/Composer/packages/client/src/recoilModel/types.ts
+++ b/Composer/packages/client/src/recoilModel/types.ts
@@ -30,7 +30,7 @@ export interface StorageFolder extends File {
export interface PublishType {
name: string;
description: string;
- hasView?: boolean;
+ bundleId?: string;
instructions?: string;
schema?: JSONSchema7;
features: {
@@ -41,38 +41,6 @@ export interface PublishType {
};
}
-type ExtensionPublishContribution = {
- bundleId: string;
-};
-
-export type ExtensionPageContribution = {
- /** plugin id */
- id: string;
- bundleId: string;
- label: string;
- icon?: string;
-};
-
-// TODO: move this definition to a shared spot
-export interface ExtensionConfig {
- id: string;
- name: string;
- description: string;
- enabled: boolean;
- version: string;
- /** Special property only used in the in-memory representation of extensions to flag as a built-in. Not written to disk. */
- builtIn?: boolean;
- /** Path where module is installed */
- path: string;
- bundles: any; // TODO: needed?
- contributes?: {
- views?: {
- publish?: ExtensionPublishContribution;
- pages?: ExtensionPageContribution[];
- };
- };
-}
-
export interface RuntimeTemplate {
/** internal use key */
key: string;
@@ -142,10 +110,4 @@ export type BoilerplateVersion = {
updateRequired?: boolean;
};
-export enum QnAAllUpViewStatus {
- Loading,
- Success,
- Failed,
-}
-
export type Notification = CardProps & { id: string };
diff --git a/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx b/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx
index e64a06987e..b1ce067af0 100644
--- a/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx
+++ b/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx
@@ -13,14 +13,14 @@ import {
luFilesState,
projectMetaDataState,
currentProjectIdState,
- botProjectsSpaceState,
+ botProjectIdsState,
} from '../../atoms';
import { renderRecoilHook } from '../../../../__tests__/testUtils/react-recoil-hooks-testing-library';
import UndoHistory from '../undoHistory';
const projectId = '123-asd';
export const UndoRedoWrapper = () => {
- const botProjects = useRecoilValue(botProjectsSpaceState);
+ const botProjects = useRecoilValue(botProjectIdsState);
return botProjects.length > 0 ? : null;
};
@@ -59,7 +59,7 @@ describe(' ', () => {
);
},
states: [
- { recoilState: botProjectsSpaceState, initialValue: [projectId] },
+ { recoilState: botProjectIdsState, initialValue: [projectId] },
{ recoilState: dialogsState(projectId), initialValue: [{ id: '1' }] },
{ recoilState: lgFilesState(projectId), initialValue: [{ id: '1.lg' }, { id: '2' }] },
{ recoilState: luFilesState(projectId), initialValue: [{ id: '1.lu' }, { id: '2' }] },
diff --git a/Composer/packages/client/src/recoilModel/undo/history.ts b/Composer/packages/client/src/recoilModel/undo/history.ts
index b36e4f16e4..1a431b8212 100644
--- a/Composer/packages/client/src/recoilModel/undo/history.ts
+++ b/Composer/packages/client/src/recoilModel/undo/history.ts
@@ -9,6 +9,7 @@ import {
} from 'recoil';
import { atomFamily, Snapshot, useRecoilCallback, CallbackInterface, useSetRecoilState } from 'recoil';
import uniqueId from 'lodash/uniqueId';
+import isEmpty from 'lodash/isEmpty';
import { navigateTo, getUrlSearch } from '../../utils/navigation';
@@ -140,10 +141,12 @@ export const UndoRoot = React.memo((props: UndoRootProps) => {
});
const setInitialProjectState = useRecoilCallback(({ snapshot }: CallbackInterface) => () => {
- undoHistory.clear();
- const assetMap = getAtomAssetsMap(snapshot, projectId);
- undoHistory.add(assetMap);
- setInitialStateLoaded(true);
+ if (!isEmpty(undoHistory)) {
+ undoHistory.clear();
+ const assetMap = getAtomAssetsMap(snapshot, projectId);
+ undoHistory.add(assetMap);
+ setInitialStateLoaded(true);
+ }
});
useEffect(() => {
diff --git a/Composer/packages/client/src/router.tsx b/Composer/packages/client/src/router.tsx
index f24f4013a8..037bc9eff8 100644
--- a/Composer/packages/client/src/router.tsx
+++ b/Composer/packages/client/src/router.tsx
@@ -12,11 +12,11 @@ import { resolveToBasePath } from './utils/fileUtil';
import { data } from './styles';
import { NotFound } from './components/NotFound';
import { BASEPATH } from './constants';
-import { dispatcherState, schemasState, botProjectsSpaceState, botOpeningState } from './recoilModel';
+import { dispatcherState, schemasState, botProjectIdsState, botOpeningState, pluginPagesSelector } from './recoilModel';
import { openAlertModal } from './components/Modal/AlertDialog';
import { dialogStyle } from './components/Modal/dialogStyle';
import { LoadingSpinner } from './components/LoadingSpinner';
-import { PluginPageContainer } from './pages/plugin/pluginPageContainer';
+import { PluginPageContainer } from './pages/plugin/PluginPageContainer';
const DesignPage = React.lazy(() => import('./pages/design/DesignPage'));
const LUPage = React.lazy(() => import('./pages/language-understanding/LUPage'));
@@ -27,9 +27,12 @@ const Notifications = React.lazy(() => import('./pages/notifications/Notificatio
const Publish = React.lazy(() => import('./pages/publish/Publish'));
const Skills = React.lazy(() => import('./pages/skills'));
const BotCreationFlowRouter = React.lazy(() => import('./components/CreationFlow/CreationFlow'));
+const FormDialogPage = React.lazy(() => import('./pages/form-dialog/FormDialogPage'));
const Routes = (props) => {
const botOpening = useRecoilValue(botOpeningState);
+ const pluginPages = useRecoilValue(pluginPagesSelector);
+
return (
}>
@@ -55,12 +58,21 @@ const Routes = (props) => {
+
+
+ {pluginPages.map((page) => (
+
+ ))}
-
@@ -89,8 +101,7 @@ const ProjectRouter: React.FC
> = (pro
const { projectId = '' } = props;
const schemas = useRecoilValue(schemasState(projectId));
const { fetchProjectById } = useRecoilValue(dispatcherState);
- const botProjects = useRecoilValue(botProjectsSpaceState);
- const botOpening = useRecoilValue(botOpeningState);
+ const botProjects = useRecoilValue(botProjectIdsState);
useEffect(() => {
if (props.projectId && !botProjects.includes(props.projectId)) {
@@ -107,7 +118,7 @@ const ProjectRouter: React.FC> = (pro
}
}, [schemas, projectId]);
- if (props.projectId && !botOpening && botProjects.includes(props.projectId)) {
+ if (props.projectId && botProjects.includes(props.projectId)) {
return {props.children}
;
}
return ;
diff --git a/Composer/packages/client/src/shell/actionApi.ts b/Composer/packages/client/src/shell/actionApi.ts
new file mode 100644
index 0000000000..308d03c236
--- /dev/null
+++ b/Composer/packages/client/src/shell/actionApi.ts
@@ -0,0 +1,153 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import {
+ deepCopyActions,
+ deleteAction as destructAction,
+ deleteActions as destructActions,
+ FieldProcessorAsync,
+ walkAdaptiveActionList,
+ LgType,
+ LgMetaData,
+ LgTemplateRef,
+ LuType,
+ LuMetaData,
+} from '@bfc/shared';
+import { LuIntentSection, MicrosoftIDialog } from '@botframework-composer/types';
+
+import { useLgApi } from './lgApi';
+import { useLuApi } from './luApi';
+
+export const useActionApi = (projectId: string) => {
+ const { getLgTemplates, removeLgTemplates, addLgTemplate } = useLgApi(projectId);
+ const { addLuIntent, getLuIntent, removeLuIntent } = useLuApi(projectId);
+
+ const luFieldName = '_lu';
+
+ function actionsContainLuIntent(actions: MicrosoftIDialog[]): boolean {
+ let containLuIntents = false;
+ walkAdaptiveActionList(actions, (action) => {
+ if (action[luFieldName]) {
+ containLuIntents = true;
+ }
+ });
+ return containLuIntents;
+ }
+
+ const createLgTemplate = async (
+ lgFileId: string,
+ lgText: string,
+ hostActionId: string,
+ hostActionData: MicrosoftIDialog,
+ hostFieldName: string
+ ): Promise => {
+ if (!lgText) return '';
+ const newLgType = new LgType(hostActionData.$kind, hostFieldName).toString();
+ const newLgTemplateName = new LgMetaData(newLgType, hostActionId).toString();
+ const newLgTemplateRefStr = new LgTemplateRef(newLgTemplateName).toString();
+ await addLgTemplate(lgFileId, newLgTemplateName, lgText);
+ return newLgTemplateRefStr;
+ };
+
+ const readLgTemplate = (lgText: string) => {
+ if (!lgText) return '';
+
+ const inputLgRef = LgTemplateRef.parse(lgText);
+ if (!inputLgRef) return lgText;
+
+ const lgTemplates = getLgTemplates(inputLgRef.name);
+ if (!Array.isArray(lgTemplates) || !lgTemplates.length) return lgText;
+
+ const targetTemplate = lgTemplates.find((x) => x.name === inputLgRef.name);
+ return targetTemplate ? targetTemplate.body : lgText;
+ };
+
+ const createLuIntent = async (
+ luFildId: string,
+ intent: LuIntentSection | undefined,
+ hostResourceId: string,
+ hostResourceData: MicrosoftIDialog
+ ) => {
+ if (!intent) return;
+
+ const newLuIntentType = new LuType(hostResourceData.$kind).toString();
+ const newLuIntentName = new LuMetaData(newLuIntentType, hostResourceId).toString();
+ const newLuIntent: LuIntentSection = { ...intent, Name: newLuIntentName };
+ await addLuIntent(luFildId, newLuIntentName, newLuIntent);
+ return newLuIntentName;
+ };
+
+ const readLuIntent = (luFileId: string, hostResourceId: string, hostResourceData: MicrosoftIDialog) => {
+ const relatedLuIntentType = new LuType(hostResourceData.$kind).toString();
+ const relatedLuIntentName = new LuMetaData(relatedLuIntentType, hostResourceId).toString();
+ return getLuIntent(luFileId, relatedLuIntentName);
+ };
+
+ async function constructActions(dialogId: string, actions: MicrosoftIDialog[]) {
+ // '- hi' -> 'SendActivity_1234'
+ const referenceLgText: FieldProcessorAsync = async (fromId, fromAction, toId, toAction, lgFieldName) =>
+ createLgTemplate(dialogId, fromAction[lgFieldName] as string, toId, toAction, lgFieldName);
+
+ // LuIntentSection -> 'TextInput_Response_1234'
+ const referenceLuIntent: FieldProcessorAsync = async (fromId, fromAction, toId, toAction) => {
+ fromAction[luFieldName] &&
+ (await createLuIntent(dialogId, fromAction[luFieldName] as LuIntentSection, toId, toAction));
+ // during construction, remove the virtual LU field after intents persisted in file
+ delete toAction[luFieldName];
+ };
+
+ return deepCopyActions(actions, referenceLgText, referenceLuIntent);
+ }
+
+ async function copyActions(dialogId: string, actions: MicrosoftIDialog[]) {
+ // 'SendActivity_1234' -> '- hi'
+ const dereferenceLg: FieldProcessorAsync = async (fromId, fromAction, toId, toAction, lgFieldName) =>
+ readLgTemplate(fromAction[lgFieldName] as string);
+
+ // 'TextInput_Response_1234' -> LuIntentSection | undefined
+ const dereferenceLu: FieldProcessorAsync = async (fromId, fromAction, toId, toAction) => {
+ const luValue = readLuIntent(dialogId, fromId, fromAction);
+ // during copy, carry the LU data in virtual field
+ toAction[luFieldName] = luValue;
+ return luValue;
+ };
+
+ return deepCopyActions(actions, dereferenceLg, dereferenceLu);
+ }
+
+ async function constructAction(dialogId: string, action: MicrosoftIDialog) {
+ const [newAction] = await constructActions(dialogId, [action]);
+ return newAction;
+ }
+
+ async function copyAction(dialogId: string, action: MicrosoftIDialog) {
+ const [copiedAction] = await copyActions(dialogId, [action]);
+ return copiedAction;
+ }
+
+ async function deleteAction(dialogId: string, action: MicrosoftIDialog) {
+ return destructAction(
+ action,
+ (templates: string[]) => removeLgTemplates(dialogId, templates),
+ (luIntents: string[]) => Promise.all(luIntents.map((intent) => removeLuIntent(dialogId, intent)))
+ );
+ }
+
+ async function deleteActions(dialogId: string, actions: MicrosoftIDialog[]) {
+ return destructActions(
+ actions,
+ (templates: string[]) => removeLgTemplates(dialogId, templates),
+ (luIntents: string[]) => Promise.all(luIntents.map((intent) => removeLuIntent(dialogId, intent)))
+ );
+ }
+
+ return {
+ constructAction,
+ constructActions,
+ copyAction,
+ copyActions,
+ deleteAction,
+ deleteActions,
+ actionsContainLuIntent,
+ };
+};
diff --git a/Composer/packages/client/src/shell/lgApi.ts b/Composer/packages/client/src/shell/lgApi.ts
index 76168af704..5013c87caf 100644
--- a/Composer/packages/client/src/shell/lgApi.ts
+++ b/Composer/packages/client/src/shell/lgApi.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useEffect, useState } from 'react';
-import { LgFile } from '@bfc/shared';
+import { LgFile, LgContextApi, LgTemplateRef } from '@bfc/shared';
import { useRecoilValue } from 'recoil';
import debounce from 'lodash/debounce';
import formatMessage from 'format-message';
@@ -20,7 +20,7 @@ function createLgApi(
state: { focusPath: string; projectId: string },
actions: Dispatcher,
lgFileResolver: (id: string) => LgFile | undefined
-) {
+): LgContextApi {
const getLgTemplates = (id) => {
if (id === undefined) throw new Error('must have a file id');
const focusedDialogId = state.focusPath.split('#').shift() || id;
@@ -29,6 +29,13 @@ function createLgApi(
return file.templates;
};
+ const updateLgFile = async (id: string, content: string) => {
+ const file = lgFileResolver(id);
+ if (!file) throw new Error(fileNotFound(id));
+
+ await actions.updateLgFile({ id, content, projectId: state.projectId });
+ };
+
const updateLgTemplate = async (id: string, templateName: string, templateBody: string) => {
const file = lgFileResolver(id);
if (!file) throw new Error(fileNotFound(id));
@@ -68,23 +75,31 @@ function createLgApi(
});
};
- const removeLgTemplates = async (id, templateNames) => {
+ const removeLgTemplates = async (id: string, templateNames: string[]) => {
const file = lgFileResolver(id);
if (!file) throw new Error(fileNotFound(id));
if (!templateNames) throw new Error(TEMPLATE_ERROR);
+ const normalizedLgTemplates = templateNames
+ .map((x) => {
+ const lgTemplateRef = LgTemplateRef.parse(x);
+ return lgTemplateRef ? lgTemplateRef.name : x;
+ })
+ .filter((x) => !!x);
+
return await actions.removeLgTemplates({
id: file.id,
- templateNames,
+ templateNames: normalizedLgTemplates,
projectId: state.projectId,
});
};
return {
+ updateLgFile,
addLgTemplate: updateLgTemplate,
getLgTemplates,
updateLgTemplate,
- deboucedUpdateLgTemplate: debounce(updateLgTemplate, 250),
+ debouncedUpdateLgTemplate: debounce(updateLgTemplate, 250),
removeLgTemplate,
removeLgTemplates,
copyLgTemplate,
diff --git a/Composer/packages/client/src/shell/luApi.ts b/Composer/packages/client/src/shell/luApi.ts
index 7581c402ba..e94a6178c4 100644
--- a/Composer/packages/client/src/shell/luApi.ts
+++ b/Composer/packages/client/src/shell/luApi.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useEffect, useState } from 'react';
-import { LuFile, LuIntentSection } from '@bfc/shared';
+import { LuFile, LuIntentSection, LuContextApi } from '@botframework-composer/types';
import { useRecoilValue } from 'recoil';
import formatMessage from 'format-message';
import debounce from 'lodash/debounce';
@@ -20,7 +20,14 @@ function createLuApi(
state: { focusPath: string; projectId: string },
dispatchers: Dispatcher,
luFileResolver: (id: string) => LuFile | undefined
-) {
+): LuContextApi {
+ const updateLuFile = async (id: string, content: string) => {
+ const file = luFileResolver(id);
+ if (!file) throw new Error(fileNotFound(id));
+
+ await dispatchers.updateLuFile({ id, content, projectId: state.projectId });
+ };
+
const addLuIntent = async (id: string, intentName: string, intent: LuIntentSection) => {
const file = luFileResolver(id);
if (!file) throw new Error(fileNotFound(id));
@@ -73,11 +80,12 @@ function createLuApi(
};
return {
+ updateLuFile,
addLuIntent,
getLuIntents,
getLuIntent,
updateLuIntent,
- deboucedUpdateLuIntent: debounce(updateLuIntent, 250),
+ debouncedUpdateLuIntent: debounce(updateLuIntent, 250),
renameLuIntent,
removeLuIntent,
};
diff --git a/Composer/packages/client/src/shell/triggerApi.ts b/Composer/packages/client/src/shell/triggerApi.ts
index 79cc9e64f2..f56b9f62d1 100644
--- a/Composer/packages/client/src/shell/triggerApi.ts
+++ b/Composer/packages/client/src/shell/triggerApi.ts
@@ -2,25 +2,37 @@
// Licensed under the MIT License.
import { useEffect, useState } from 'react';
-import { LuFile, LgFile, DialogInfo, LgTemplateSamples } from '@bfc/shared';
+import {
+ LgTemplate,
+ LuFile,
+ LgFile,
+ DialogInfo,
+ ITriggerCondition,
+ SDKKinds,
+ BaseSchema,
+ MicrosoftIDialog,
+} from '@botframework-composer/types';
import { useRecoilValue } from 'recoil';
-import { LgTemplate } from '@bfc/shared';
+import { LgTemplateSamples } from '@bfc/shared';
import get from 'lodash/get';
import { useResolvers } from '../hooks/useResolver';
import { onChooseIntentKey, generateNewDialog, intentTypeKey, qnaMatcherKey } from '../utils/dialogUtil';
-import { navigateTo } from '../utils/navigation';
import { schemasState, lgFilesState, dialogsState, localeState } from '../recoilModel';
import { Dispatcher } from '../recoilModel/dispatchers';
import { dispatcherState } from './../recoilModel/DispatcherWrapper';
+import { useActionApi } from './actionApi';
+import { useLuApi } from './luApi';
function createTriggerApi(
state: { projectId; schemas; dialogs; locale; lgFiles },
dispatchers: Dispatcher, //TODO
luFileResolver: (id: string) => LuFile | undefined,
lgFileResolver: (id: string) => LgFile | undefined,
- dialogResolver: (id: string) => DialogInfo | undefined
+ dialogResolver: (id: string) => DialogInfo | undefined,
+ deleteActions: (dialogId: string, actions: MicrosoftIDialog[]) => Promise,
+ removeLuIntent: (id: string, intentName: string) => void
) {
const getDesignerIdFromDialogPath = (dialog, path) => {
const value = get(dialog, path, '');
@@ -29,7 +41,7 @@ function createTriggerApi(
return value.substring(startIndex + 1, endIndex);
};
- const createTriggerHandler = async (id, formData, url) => {
+ const createTriggerHandler = async (id, formData, autoSelected = true) => {
const luFile = luFileResolver(id);
const lgFile = lgFileResolver(id);
const dialog = dialogResolver(id);
@@ -90,14 +102,30 @@ function createTriggerApi(
content: newDialog.content,
};
await updateDialog(dialogPayload);
- if (url) {
- navigateTo(url);
- } else {
+ if (autoSelected) {
selectTo(projectId, `triggers[${index}]`);
}
};
+
+ const deleteTrigger = (dialogId: string, trigger: ITriggerCondition) => {
+ if (!trigger) return;
+
+ // Clean the lu resource on intent trigger
+ if (get(trigger, '$kind') === SDKKinds.OnIntent) {
+ const triggerIntent = get(trigger, 'intent', '') as string;
+ removeLuIntent(dialogId, triggerIntent);
+ }
+
+ // Clean action resources
+ const actions = get(trigger, 'actions') as BaseSchema[];
+ if (!actions || !Array.isArray(actions)) return;
+
+ deleteActions(dialogId, actions);
+ };
+
return {
createTrigger: createTriggerHandler,
+ deleteTrigger,
};
}
@@ -106,6 +134,8 @@ export function useTriggerApi(projectId: string) {
const lgFiles = useRecoilValue(lgFilesState(projectId));
const dialogs = useRecoilValue(dialogsState(projectId));
const locale = useRecoilValue(localeState(projectId));
+ const { deleteActions } = useActionApi(projectId);
+ const { removeLuIntent } = useLuApi(projectId);
const dispatchers = useRecoilValue(dispatcherState);
const { luFileResolver, lgFileResolver, dialogResolver } = useResolvers(projectId);
@@ -115,7 +145,9 @@ export function useTriggerApi(projectId: string) {
dispatchers,
luFileResolver,
lgFileResolver,
- dialogResolver
+ dialogResolver,
+ deleteActions,
+ removeLuIntent
)
);
@@ -125,7 +157,9 @@ export function useTriggerApi(projectId: string) {
dispatchers,
luFileResolver,
lgFileResolver,
- dialogResolver
+ dialogResolver,
+ deleteActions,
+ removeLuIntent
);
setApi(newApi);
return () => {
diff --git a/Composer/packages/client/src/shell/useShell.ts b/Composer/packages/client/src/shell/useShell.ts
index cbb8b68da1..772bbfe669 100644
--- a/Composer/packages/client/src/shell/useShell.ts
+++ b/Composer/packages/client/src/shell/useShell.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useMemo, useRef } from 'react';
-import { ShellApi, ShellData, Shell, DialogSchemaFile } from '@bfc/shared';
+import { ShellApi, ShellData, Shell, DialogSchemaFile, DialogInfo } from '@botframework-composer/types';
import { useRecoilValue } from 'recoil';
import formatMessage from 'format-message';
@@ -22,10 +22,11 @@ import {
localeState,
qnaFilesState,
designPageLocationState,
- botNameState,
+ botDisplayNameState,
dialogSchemasState,
lgFilesState,
luFilesState,
+ rateInfoState,
} from '../recoilModel';
import { undoFunctionState } from '../recoilModel/undo/history';
@@ -33,10 +34,30 @@ import { useLgApi } from './lgApi';
import { useLuApi } from './luApi';
import { useQnaApi } from './qnaApi';
import { useTriggerApi } from './triggerApi';
+import { useActionApi } from './actionApi';
const FORM_EDITOR = 'PropertyEditor';
-type EventSource = 'FlowEditor' | 'PropertyEditor' | 'DesignPage';
+type EventSource = 'FlowEditor' | 'PropertyEditor' | 'DesignPage' | 'VaCreation';
+
+const stubDialog = (): DialogInfo => ({
+ content: {
+ $kind: '',
+ },
+ diagnostics: [],
+ displayName: '',
+ id: '',
+ isRoot: true,
+ lgFile: '',
+ lgTemplates: [],
+ luFile: '',
+ qnaFile: '',
+ referredLuIntents: [],
+ referredDialogs: [],
+ triggers: [],
+ intentTriggers: [],
+ skills: [],
+});
export function useShell(source: EventSource, projectId: string): Shell {
const dialogMapRef = useRef({});
@@ -54,8 +75,9 @@ export function useShell(source: EventSource, projectId: string): Shell {
const luFiles = useRecoilValue(luFilesState(projectId));
const lgFiles = useRecoilValue(lgFilesState(projectId));
const dialogSchemas = useRecoilValue(dialogSchemasState(projectId));
- const botName = useRecoilValue(botNameState(projectId));
+ const botName = useRecoilValue(botDisplayNameState(projectId));
const settings = useRecoilValue(settingsState(projectId));
+ const flowZoomRate = useRecoilValue(rateInfoState);
const userSettings = useRecoilValue(userSettingsState);
const clipboardActions = useRecoilValue(clipboardActionsState);
@@ -74,12 +96,14 @@ export function useShell(source: EventSource, projectId: string): Shell {
setMessage,
displayManifestModal,
updateSkill,
+ updateZoomRate,
} = useRecoilValue(dispatcherState);
const lgApi = useLgApi(projectId);
const luApi = useLuApi(projectId);
const qnaApi = useQnaApi(projectId);
const triggerApi = useTriggerApi(projectId);
+ const actionApi = useActionApi(projectId);
const { dialogId, selected, focused, promptTab } = designPageLocation;
const dialogsMap = useMemo(() => {
@@ -133,6 +157,10 @@ export function useShell(source: EventSource, projectId: string): Shell {
focusTo(projectId, dataPath, fragment ?? '');
}
+ function updateFlowZoomRate(currentRate) {
+ updateZoomRate({ currentRate });
+ }
+
dialogMapRef.current = dialogsMap;
const api: ShellApi = {
@@ -163,10 +191,6 @@ export function useShell(source: EventSource, projectId: string): Shell {
updateDialog(payload);
commitChanges();
},
- ...lgApi,
- ...luApi,
- ...qnaApi,
- ...triggerApi,
updateRegExIntent: updateRegExIntentHandler,
renameRegExIntent: renameRegExIntentHandler,
updateIntentTrigger: updateIntentTriggerHandler,
@@ -204,42 +228,50 @@ export function useShell(source: EventSource, projectId: string): Shell {
updateDialogSchema(dialogSchema, projectId);
},
updateSkillSetting: (...params) => updateSkill(projectId, ...params),
+ updateFlowZoomRate,
+ ...lgApi,
+ ...luApi,
+ ...qnaApi,
+ ...triggerApi,
+ ...actionApi,
};
- const currentDialog = useMemo(() => dialogs.find((d) => d.id === dialogId), [dialogs, dialogId]);
+ const currentDialog = useMemo(() => dialogs.find((d) => d.id === dialogId) ?? stubDialog(), [
+ dialogs,
+ dialogId,
+ ]) as DialogInfo;
const editorData = useMemo(() => {
return source === 'PropertyEditor'
? getDialogData(dialogsMap, dialogId, focused || selected || '')
: getDialogData(dialogsMap, dialogId);
}, [source, dialogsMap, dialogId, focused, selected]);
- const data: ShellData = currentDialog
- ? {
- data: editorData,
- locale,
- botName,
- projectId,
- dialogs,
- dialogSchemas,
- dialogId,
- focusPath,
- schemas,
- lgFiles,
- luFiles,
- qnaFiles,
- currentDialog,
- userSettings,
- designerId: editorData?.$designer?.id,
- focusedEvent: selected,
- focusedActions: focused ? [focused] : [],
- focusedSteps: focused ? [focused] : selected ? [selected] : [],
- focusedTab: promptTab,
- clipboardActions,
- hosted: !!isAbsHosted(),
- skills,
- skillsSettings: settings.skill || {},
- }
- : ({} as ShellData);
+ const data: ShellData = {
+ locale,
+ botName,
+ projectId,
+ dialogs,
+ dialogSchemas,
+ dialogId,
+ focusPath,
+ schemas,
+ lgFiles,
+ luFiles,
+ qnaFiles,
+ currentDialog,
+ userSettings,
+ designerId: editorData?.$designer?.id,
+ focusedEvent: selected,
+ focusedActions: focused ? [focused] : [],
+ focusedSteps: focused ? [focused] : selected ? [selected] : [],
+ focusedTab: promptTab,
+ clipboardActions,
+ hosted: !!isAbsHosted(),
+ luFeatures: settings.luFeatures,
+ skills,
+ skillsSettings: settings.skill || {},
+ flowZoomRate,
+ };
return {
api,
diff --git a/Composer/packages/client/src/types/window.d.ts b/Composer/packages/client/src/types/window.d.ts
index 75e2cc6501..18e8c289d8 100644
--- a/Composer/packages/client/src/types/window.d.ts
+++ b/Composer/packages/client/src/types/window.d.ts
@@ -1,19 +1,30 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-interface Window {
- /**
- * Electron mechanism used for communication from renderer to main process.
- */
- ipcRenderer: IPCRenderer;
+import * as ExtensionClient from '@bfc/extension-client';
- /**
- * Flag that is set on the window object when the client is embedded within Electron.
- */
- __IS_ELECTRON__?: boolean;
+declare global {
+ interface Window {
+ /**
+ * Electron mechanism used for communication from renderer to main process.
+ */
+ ipcRenderer: IPCRenderer;
- /**
- * Composer UI Extension API
- */
- Composer: any;
+ /**
+ * Flag that is set on the window object when the client is embedded within Electron.
+ */
+ __IS_ELECTRON__?: boolean;
+
+ /**
+ * Composer UI Extension API
+ */
+ Composer: {
+ __pluginType: string;
+ render: (type: string, shell: Shell, component: React.ReactElement) => void;
+ sync: (shell: Shell) => void;
+ [key: string]: any;
+ };
+
+ ExtensionClient: typeof ExtensionClient;
+ }
}
diff --git a/Composer/packages/client/src/utils/__test__/fileUtil.test.ts b/Composer/packages/client/src/utils/__test__/fileUtil.test.ts
new file mode 100644
index 0000000000..fe5e476715
--- /dev/null
+++ b/Composer/packages/client/src/utils/__test__/fileUtil.test.ts
@@ -0,0 +1,11 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { getUniqueName } from '../fileUtil';
+
+describe('File utils', () => {
+ it('should get a unique name', () => {
+ const uniqueName = getUniqueName(['test', 'test-1', 'test-2', 'test-3'], 'test');
+ expect(uniqueName).toBe('test-4');
+ });
+});
diff --git a/Composer/packages/client/src/utils/dialogUtil.ts b/Composer/packages/client/src/utils/dialogUtil.ts
index 235868bd23..1db948b322 100644
--- a/Composer/packages/client/src/utils/dialogUtil.ts
+++ b/Composer/packages/client/src/utils/dialogUtil.ts
@@ -95,9 +95,8 @@ function createTrigger(dialog: DialogInfo, data: TriggerFormData, factory: Dialo
export function updateIntentTrigger(dialog: DialogInfo, intentName: string, newIntentName: string): DialogInfo {
const dialogCopy = cloneDeep(dialog);
- const trigger = (dialogCopy.content?.triggers ?? []).find(
- (t) => t.$kind === SDKKinds.OnIntent && t.intent === intentName
- );
+ const triggers = dialogCopy.content?.triggers as ITriggerCondition[];
+ const trigger = (triggers ?? []).find((t) => t.$kind === SDKKinds.OnIntent && t.intent === intentName);
if (trigger) {
trigger.intent = newIntentName;
diff --git a/Composer/packages/client/src/utils/fileUtil.ts b/Composer/packages/client/src/utils/fileUtil.ts
index 24f8256f28..6fadad98d1 100644
--- a/Composer/packages/client/src/utils/fileUtil.ts
+++ b/Composer/packages/client/src/utils/fileUtil.ts
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+import path from 'path';
+
import moment from 'moment';
import formatMessage from 'format-message';
import generate from 'format-message-generate-id';
@@ -103,3 +105,21 @@ export async function loadLocale(locale: string) {
});
}
}
+
+export const getUniqueName = (list: string[], currentName: string, seperator = '-') => {
+ let uniqueName = currentName;
+ let i = 1;
+ while (list.includes(uniqueName)) {
+ uniqueName = `${currentName}${seperator}${i}`;
+ i++;
+ }
+ return uniqueName;
+};
+
+export const getFileNameFromPath = (param: string, ext: string | undefined = undefined) => {
+ return path.basename(param, ext).replace(/\\/g, '/');
+};
+
+export const getAbsolutePath = (basePath: string, relativePath: string) => {
+ return path.resolve(basePath, relativePath);
+};
diff --git a/Composer/packages/client/src/utils/hooks.ts b/Composer/packages/client/src/utils/hooks.ts
index 6e4aa3a1f3..2eaac47ea4 100644
--- a/Composer/packages/client/src/utils/hooks.ts
+++ b/Composer/packages/client/src/utils/hooks.ts
@@ -1,15 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useRef, useMemo } from 'react';
import { globalHistory } from '@reach/router';
import replace from 'lodash/replace';
import find from 'lodash/find';
import { useRecoilValue } from 'recoil';
-import { ExtensionPageContribution } from '../recoilModel/types';
+import { designPageLocationState, currentProjectIdState, pluginPagesSelector } from '../recoilModel';
-import { designPageLocationState, extensionsState, currentProjectIdState } from './../recoilModel';
import { bottomLinks, topLinks } from './pageLinks';
import routerCache from './routerCache';
import { projectIdCache } from './projectCache';
@@ -26,19 +25,14 @@ export const useLocation = () => {
export const useLinks = () => {
const projectId = useRecoilValue(currentProjectIdState);
const designPageLocation = useRecoilValue(designPageLocationState(projectId));
- const extensions = useRecoilValue(extensionsState);
+ const pluginPages = useRecoilValue(pluginPagesSelector);
const openedDialogId = designPageLocation.dialogId || 'Main';
- // add page-contributing extensions
- const pluginPages = extensions.reduce((pages, p) => {
- const pagesConfig = p.contributes?.views?.pages;
- if (Array.isArray(pagesConfig) && pagesConfig.length > 0) {
- pages.push(...pagesConfig.map((page) => ({ ...page, id: p.id })));
- }
- return pages;
- }, [] as ExtensionPageContribution[]);
+ const pageLinks = useMemo(() => {
+ return topLinks(projectId, openedDialogId, pluginPages);
+ }, [projectId, openedDialogId, pluginPages]);
- return { topLinks: topLinks(projectId, openedDialogId, pluginPages), bottomLinks };
+ return { topLinks: pageLinks, bottomLinks };
};
export const useRouterCache = (to: string) => {
diff --git a/Composer/packages/client/src/utils/notifications.ts b/Composer/packages/client/src/utils/notifications.ts
index b9b5c2df04..aec925bd4f 100644
--- a/Composer/packages/client/src/utils/notifications.ts
+++ b/Composer/packages/client/src/utils/notifications.ts
@@ -3,11 +3,10 @@
import formatMessage from 'format-message';
import { CardProps } from './../components/NotificationCard';
-
-export const getQnaPendingNotification = (urls: string[]): CardProps => {
+export const getQnaPendingNotification = (url: string): CardProps => {
return {
title: formatMessage('Creating your knowledge base'),
- description: formatMessage('Extracting QNA pairs from {urls}', { urls: urls.join(' ') }),
+ description: formatMessage('Extracting QNA pairs from {url}', { url }),
type: 'pending',
};
};
diff --git a/Composer/packages/client/src/utils/pageLinks.ts b/Composer/packages/client/src/utils/pageLinks.ts
index 3573c6993a..7e9deb52e6 100644
--- a/Composer/packages/client/src/utils/pageLinks.ts
+++ b/Composer/packages/client/src/utils/pageLinks.ts
@@ -1,10 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import formatMessage from 'format-message';
+import { ExtensionPageContribution } from '@bfc/extension-client';
-import { ExtensionPageContribution } from '../recoilModel/types';
+export type ExtensionPageConfig = ExtensionPageContribution & { id: string };
-export const topLinks = (projectId: string, openedDialogId: string, pluginPages: ExtensionPageContribution[]) => {
+export const topLinks = (projectId: string, openedDialogId: string, pluginPages: ExtensionPageConfig[]) => {
const botLoaded = !!projectId;
let links = [
{
@@ -63,6 +64,17 @@ export const topLinks = (projectId: string, openedDialogId: string, pluginPages:
exact: true,
disabled: !botLoaded,
},
+ ...(process.env.COMPOSER_ENABLE_FORMS
+ ? [
+ {
+ to: `/bot/${projectId}/forms`,
+ iconName: 'Table',
+ labelName: formatMessage('Forms'),
+ exact: false,
+ disabled: !botLoaded,
+ },
+ ]
+ : []),
];
if (process.env.COMPOSER_AUTH_PROVIDER === 'abs-h') {
@@ -72,11 +84,11 @@ export const topLinks = (projectId: string, openedDialogId: string, pluginPages:
if (pluginPages.length > 0) {
pluginPages.forEach((p) => {
links.push({
- to: `page/${p.id}`,
+ to: `/bot/${projectId}/plugin/${p.id}/${p.bundleId}`,
iconName: p.icon ?? 'StatusCircleQuestionMark',
labelName: p.label,
exact: true,
- disabled: false,
+ disabled: !projectId,
});
});
}
diff --git a/Composer/packages/client/src/utils/qnaUtil.ts b/Composer/packages/client/src/utils/qnaUtil.ts
index 32144f4e07..2d9a484875 100644
--- a/Composer/packages/client/src/utils/qnaUtil.ts
+++ b/Composer/packages/client/src/utils/qnaUtil.ts
@@ -7,17 +7,80 @@
* for more usage detail, please check client/__tests__/utils/luUtil.test.ts
*/
import { QnAFile, DialogInfo } from '@bfc/shared';
+import { qnaUtil } from '@bfc/indexers';
+
+import { createFile, updateFile } from '../recoilModel/persistence/http';
import { getBaseName, getExtension } from './fileUtil';
-export * from '@bfc/indexers/lib/utils/qnaUtil';
-export function getFileLocale(fileName: string) {
+export const getFileLocale = (fileName: string) => {
//file name = 'a.en-us.qna'
return getExtension(getBaseName(fileName));
-}
-export function getReferredQnaFiles(qnaFiles: QnAFile[], dialogs: DialogInfo[]) {
+};
+export const getReferredQnaFiles = (qnaFiles: QnAFile[], dialogs: DialogInfo[]) => {
return qnaFiles.filter((file) => {
const idWithOutLocale = getBaseName(file.id);
return dialogs.some((dialog) => dialog.qnaFile === idWithOutLocale && !!file.content);
});
-}
+};
+// substring text file by lines
+export const substringTextByLine = (text: string, start?: number, end?: number): string => {
+ return text.split('\n').slice(start, end).join('\n');
+};
+/**
+ * Migrate qna pair in .qna to container KB -munual.source.qna file.
+ * @param qnaFiles
+ */
+export const reformQnAToContainerKB = (projectId: string, qnaFiles: QnAFile[]): QnAFile[] => {
+ const qnaFilesNeedMigrate = qnaFiles.filter((file) => {
+ return !file.id.endsWith('.source') && file.qnaSections.length;
+ });
+ if (!qnaFilesNeedMigrate.length) return qnaFiles;
+ const updatedFiles: QnAFile[] = [];
+ const createdFiles: QnAFile[] = [];
+ qnaFilesNeedMigrate.forEach((file) => {
+ const { id, content } = file;
+ const qnaSectionStartLine = file.qnaSections[0].range?.start.line || 0;
+ const originQnAFileContent = substringTextByLine(content, 0, qnaSectionStartLine - 1);
+ const manualContainerContent = substringTextByLine(content, qnaSectionStartLine - 1);
+ let originQnAFile = qnaUtil.parse(id, originQnAFileContent);
+
+ const manualContainerFileId = `${getBaseName(id)}-manual.source`;
+ const manualContainerFullFileId = `${manualContainerFileId}.qna`;
+
+ // if container file not be imported, do import
+ if (!originQnAFile.imports.find(({ id }) => manualContainerFullFileId === id)) {
+ originQnAFile = qnaUtil.addImport(originQnAFile, manualContainerFullFileId);
+ }
+ updateFile(projectId, `${originQnAFile.id}.qna`, originQnAFile.content);
+ updatedFiles.push(originQnAFile);
+
+ // if container file not exist, create it. if exist, update it
+ const originManualContainerFile = qnaFiles.find((item) => item.id === manualContainerFileId);
+ if (originManualContainerFile) {
+ const updatedContent = originManualContainerFile.content + '\n' + manualContainerContent;
+ const updatedFile = qnaUtil.parse(manualContainerFileId, updatedContent);
+ updateFile(projectId, `${updatedFile.id}.qna`, updatedFile.content);
+ updatedFiles.push(updatedFile);
+ } else {
+ const createdFile = qnaUtil.parse(manualContainerFileId, manualContainerContent);
+ createFile(projectId, `${createdFile.id}.qna`, createdFile.content);
+ createdFiles.push(createdFile);
+ }
+ });
+
+ const newQnAfiles: QnAFile[] = qnaFiles.map((file) => {
+ const updated = updatedFiles.find((item) => item.id === file.id);
+ return updated || file;
+ });
+ newQnAfiles.push(...createdFiles);
+ return newQnAfiles;
+};
+
+export const getQnAFileUrlOption = (file: QnAFile): string | undefined => {
+ return file.options.find((opt) => opt.name === 'url')?.value;
+};
+
+export const isQnAFileCreatedFromUrl = (file: QnAFile): boolean => {
+ return getQnAFileUrlOption(file) ? true : false;
+};
diff --git a/Composer/packages/client/tsconfig.build.json b/Composer/packages/client/tsconfig.build.json
index 4f74314591..9b67f35799 100644
--- a/Composer/packages/client/tsconfig.build.json
+++ b/Composer/packages/client/tsconfig.build.json
@@ -1,5 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["./src/**/*"],
- "exclude": ["__mocks__"]
+ "exclude": ["__mocks__", "./extension-container"]
}
diff --git a/Composer/packages/client/tsconfig.json b/Composer/packages/client/tsconfig.json
index 0022640b44..2db7715527 100644
--- a/Composer/packages/client/tsconfig.json
+++ b/Composer/packages/client/tsconfig.json
@@ -5,5 +5,5 @@
"allowJs": true,
"module": "esnext"
},
- "include": ["./src/**/*", "./__tests__/**/*"]
+ "include": ["./src/**/*", "./__tests__/**/*", "./extension-container/**/*"]
}
diff --git a/Composer/packages/electron-server/jest.config.js b/Composer/packages/electron-server/jest.config.js
index 537ea3eede..216428d9b5 100644
--- a/Composer/packages/electron-server/jest.config.js
+++ b/Composer/packages/electron-server/jest.config.js
@@ -1,3 +1,3 @@
-const { createConfig } = require('@bfc/test-utils');
+const { createConfig } = require('@botframework-composer/test-utils');
module.exports = createConfig('electron-server', 'node');
diff --git a/Composer/packages/electron-server/src/main.ts b/Composer/packages/electron-server/src/main.ts
index 3887eda3b0..5b9f4c14f5 100644
--- a/Composer/packages/electron-server/src/main.ts
+++ b/Composer/packages/electron-server/src/main.ts
@@ -63,7 +63,7 @@ async function createAppDataDir() {
const azurePublishPath: string = join(composerAppDataPath, 'publishBots');
process.env.COMPOSER_APP_DATA = join(composerAppDataPath, 'data.json'); // path to the actual data file
process.env.COMPOSER_EXTENSION_DATA = join(composerAppDataPath, 'extensions.json');
- process.env.COMPOSER_REMOTE_EXTENSIONS_DIR = join(composerAppDataPath, '.composer');
+ process.env.COMPOSER_REMOTE_EXTENSIONS_DIR = join(composerAppDataPath, 'extensions');
log('creating composer app data path at: ', composerAppDataPath);
diff --git a/Composer/packages/extension-client/jest.config.js b/Composer/packages/extension-client/jest.config.js
index cace2769b9..62a5bb67ba 100644
--- a/Composer/packages/extension-client/jest.config.js
+++ b/Composer/packages/extension-client/jest.config.js
@@ -2,6 +2,6 @@
// 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('extension', 'react');
diff --git a/Composer/packages/extension-client/package.json b/Composer/packages/extension-client/package.json
index 0948d57580..8297063b21 100644
--- a/Composer/packages/extension-client/package.json
+++ b/Composer/packages/extension-client/package.json
@@ -1,7 +1,7 @@
{
"name": "@bfc/extension-client",
"version": "1.0.0",
- "description": "Composer extension library to be consumed by the client bundle of a Composer extension.",
+ "description": "Extension library to be consumed by the client bundle of a Botframework Composer extension.",
"main": "lib/index.js",
"license": "MIT",
"private": true,
@@ -15,7 +15,7 @@
"react-dom": "16.13.1"
},
"devDependencies": {
- "@bfc/test-utils": "*",
+ "@botframework-composer/test-utils": "*",
"@types/react": "16.9.23",
"react": "16.13.1",
"react-dom": "16.13.1",
@@ -23,7 +23,7 @@
"typescript": "3.9.2"
},
"dependencies": {
- "@bfc/shared": "*",
+ "@botframework-composer/types": "*",
"debug": "^4.1.1",
"lodash": "^4.17.19"
}
diff --git a/Composer/packages/extension-client/src/EditorExtensionContext.ts b/Composer/packages/extension-client/src/EditorExtensionContext.ts
index 64b2052719..6c00d2a49b 100644
--- a/Composer/packages/extension-client/src/EditorExtensionContext.ts
+++ b/Composer/packages/extension-client/src/EditorExtensionContext.ts
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import React from 'react';
-import { ShellApi, ShellData } from '@bfc/shared';
+import { ShellApi, ShellData } from '@botframework-composer/types';
import { PluginConfig } from './types';
diff --git a/Composer/packages/extension-client/src/components/EditorExtension.tsx b/Composer/packages/extension-client/src/components/EditorExtension.tsx
index a46a72f200..d4096a25b9 100644
--- a/Composer/packages/extension-client/src/components/EditorExtension.tsx
+++ b/Composer/packages/extension-client/src/components/EditorExtension.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import React, { useMemo } from 'react';
-import { Shell } from '@bfc/shared';
+import { Shell } from '@botframework-composer/types';
import { EditorExtensionContext } from '../EditorExtensionContext';
import { PluginConfig } from '../types';
diff --git a/Composer/packages/extension-client/src/hooks/__tests__/useFlowConfig.test.tsx b/Composer/packages/extension-client/src/hooks/__tests__/useFlowConfig.test.tsx
index bae08b5459..892b833e03 100644
--- a/Composer/packages/extension-client/src/hooks/__tests__/useFlowConfig.test.tsx
+++ b/Composer/packages/extension-client/src/hooks/__tests__/useFlowConfig.test.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import React from 'react';
-import { renderHook } from '@bfc/test-utils/lib/hooks';
+import { renderHook } from '@botframework-composer/test-utils/lib/hooks';
import { useFlowConfig } from '../useFlowConfig';
import { EditorExtensionContext } from '../../EditorExtensionContext';
diff --git a/Composer/packages/extension-client/src/hooks/__tests__/useFormConfig.test.tsx b/Composer/packages/extension-client/src/hooks/__tests__/useFormConfig.test.tsx
index b5ae3e59c2..bda3782947 100644
--- a/Composer/packages/extension-client/src/hooks/__tests__/useFormConfig.test.tsx
+++ b/Composer/packages/extension-client/src/hooks/__tests__/useFormConfig.test.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import React from 'react';
-import { renderHook } from '@bfc/test-utils/lib/hooks';
+import { renderHook } from '@botframework-composer/test-utils/lib/hooks';
import { useFormConfig } from '../useFormConfig';
import { EditorExtensionContext } from '../../EditorExtensionContext';
diff --git a/Composer/packages/extension-client/src/hooks/__tests__/useMenuConfig.test.tsx b/Composer/packages/extension-client/src/hooks/__tests__/useMenuConfig.test.tsx
index 96fb9b543d..bf78dbc363 100644
--- a/Composer/packages/extension-client/src/hooks/__tests__/useMenuConfig.test.tsx
+++ b/Composer/packages/extension-client/src/hooks/__tests__/useMenuConfig.test.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import React from 'react';
-import { renderHook } from '@bfc/test-utils/lib/hooks';
+import { renderHook } from '@botframework-composer/test-utils/lib/hooks';
import { useMenuConfig } from '../useMenuConfig';
import { EditorExtensionContext } from '../../EditorExtensionContext';
diff --git a/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx b/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx
index f13b03d3b9..c36a29e6e4 100644
--- a/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx
+++ b/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import React from 'react';
-import { renderHook } from '@bfc/test-utils/lib/hooks';
+import { renderHook } from '@botframework-composer/test-utils/lib/hooks';
import { useRecognizerConfig } from '../useRecognizerConfig';
import { EditorExtensionContext } from '../../EditorExtensionContext';
diff --git a/Composer/packages/extension-client/src/hooks/index.ts b/Composer/packages/extension-client/src/hooks/index.ts
index 232e81e2e5..423f77167f 100644
--- a/Composer/packages/extension-client/src/hooks/index.ts
+++ b/Composer/packages/extension-client/src/hooks/index.ts
@@ -1,14 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-export * from './useActionApi';
-export * from './useDebounce';
export * from './useDialogApi';
-export * from './useDialogEditApi';
export * from './useFlowConfig';
export * from './useFormConfig';
-export * from './useLgApi';
-export * from './useLuApi';
export * from './useMenuConfig';
export * from './useRecognizerConfig';
+export * from './useProjectApi';
export * from './useShellApi';
-export * from './useTriggerApi';
diff --git a/Composer/packages/extension-client/src/hooks/useActionApi.ts b/Composer/packages/extension-client/src/hooks/useActionApi.ts
deleted file mode 100644
index 0d1208b9a4..0000000000
--- a/Composer/packages/extension-client/src/hooks/useActionApi.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import {
- BaseSchema,
- deepCopyActions,
- deleteAction as destructAction,
- deleteActions as destructActions,
- FieldProcessorAsync,
- walkAdaptiveActionList,
- ShellApi,
-} from '@bfc/shared';
-
-import { useLgApi } from './useLgApi';
-import { useLuApi } from './useLuApi';
-
-export const useActionApi = (shellApi: ShellApi) => {
- const { createLgTemplate, readLgTemplate, deleteLgTemplates } = useLgApi(shellApi);
- const { createLuIntent, readLuIntent, deleteLuIntents } = useLuApi(shellApi);
-
- const luFieldName = '_lu';
-
- function actionsContainLuIntent(actions: BaseSchema[]): boolean {
- let containLuIntents = false;
- walkAdaptiveActionList(actions, (action) => {
- if (action[luFieldName]) {
- containLuIntents = true;
- }
- });
- return containLuIntents;
- }
-
- async function constructActions(dialogId: string, actions: BaseSchema[]) {
- // '- hi' -> 'SendActivity_1234'
- const referenceLgText: FieldProcessorAsync = async (fromId, fromAction, toId, toAction, lgFieldName) =>
- createLgTemplate(dialogId, fromAction[lgFieldName], toId, toAction, lgFieldName);
-
- // LuIntentSection -> 'TextInput_Response_1234'
- const referenceLuIntent: FieldProcessorAsync = async (fromId, fromAction, toId, toAction) => {
- fromAction[luFieldName] && (await createLuIntent(dialogId, fromAction[luFieldName], toId, toAction));
- // during construction, remove the virtual LU field after intents persisted in file
- delete toAction[luFieldName];
- };
-
- return deepCopyActions(actions, referenceLgText, referenceLuIntent);
- }
-
- async function copyActions(dialogId: string, actions: BaseSchema[]) {
- // 'SendActivity_1234' -> '- hi'
- const dereferenceLg: FieldProcessorAsync = async (fromId, fromAction, toId, toAction, lgFieldName) =>
- readLgTemplate(dialogId, fromAction[lgFieldName]);
-
- // 'TextInput_Response_1234' -> LuIntentSection | undefined
- const dereferenceLu: FieldProcessorAsync = async (fromId, fromAction, toId, toAction) => {
- const luValue = readLuIntent(dialogId, fromId, fromAction);
- // during copy, carry the LU data in virtual field
- toAction[luFieldName] = luValue;
- return luValue;
- };
-
- return deepCopyActions(actions, dereferenceLg, dereferenceLu);
- }
-
- async function constructAction(dialogId: string, action: BaseSchema) {
- return await constructActions(dialogId, [action]);
- }
-
- async function copyAction(dialogId: string, action: BaseSchema) {
- return await copyActions(dialogId, [action]);
- }
-
- async function deleteAction(dialogId: string, action: BaseSchema) {
- return destructAction(
- action,
- (templates: string[]) => deleteLgTemplates(dialogId, templates),
- (luIntents: string[]) => deleteLuIntents(dialogId, luIntents)
- );
- }
-
- async function deleteActions(dialogId: string, actions: BaseSchema[]) {
- return destructActions(
- actions,
- (templates: string[]) => deleteLgTemplates(dialogId, templates),
- (luIntents: string[]) => deleteLuIntents(dialogId, luIntents)
- );
- }
-
- return {
- constructAction,
- constructActions,
- copyAction,
- copyActions,
- deleteAction,
- deleteActions,
- actionsContainLuIntent,
- };
-};
diff --git a/Composer/packages/extension-client/src/hooks/useDebounce.ts b/Composer/packages/extension-client/src/hooks/useDebounce.ts
deleted file mode 100644
index 47b4f79c5f..0000000000
--- a/Composer/packages/extension-client/src/hooks/useDebounce.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-import { useMemo } from 'react';
-import debounce from 'lodash/debounce';
-
-export const useDebounce: typeof debounce = (fn, options) => {
- return useMemo(() => debounce(fn, options), [fn]);
-};
diff --git a/Composer/packages/extension-client/src/hooks/useDialogApi.ts b/Composer/packages/extension-client/src/hooks/useDialogApi.ts
index 302d52eb85..9c8a92d88c 100644
--- a/Composer/packages/extension-client/src/hooks/useDialogApi.ts
+++ b/Composer/packages/extension-client/src/hooks/useDialogApi.ts
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { ShellApi } from '@bfc/shared';
+import { ShellApi } from '@botframework-composer/types';
export const useDialogApi = (shellApi: ShellApi) => {
const { getDialog, saveDialog, createDialog } = shellApi;
diff --git a/Composer/packages/extension-client/src/hooks/useLgApi.ts b/Composer/packages/extension-client/src/hooks/useLgApi.ts
deleted file mode 100644
index dc914c4c8a..0000000000
--- a/Composer/packages/extension-client/src/hooks/useLgApi.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import { LgTemplateRef, LgMetaData, BaseSchema, LgType, ShellApi } from '@bfc/shared';
-
-/**
- * LG CRUD lib
- */
-export const useLgApi = (shellApi: ShellApi) => {
- const { removeLgTemplates, getLgTemplates, addLgTemplate } = shellApi;
-
- const deleteLgTemplates = (lgFileId: string, lgTemplates: string[]) => {
- const normalizedLgTemplates = lgTemplates
- .map((x) => {
- const lgTemplateRef = LgTemplateRef.parse(x);
- return lgTemplateRef ? lgTemplateRef.name : '';
- })
- .filter((x) => !!x);
- return removeLgTemplates(lgFileId, normalizedLgTemplates);
- };
-
- const readLgTemplate = (lgFileId: string, lgText: string) => {
- if (!lgText) return '';
-
- const inputLgRef = LgTemplateRef.parse(lgText);
- if (!inputLgRef) return lgText;
-
- const lgTemplates = getLgTemplates(inputLgRef.name);
- if (!Array.isArray(lgTemplates) || !lgTemplates.length) return lgText;
-
- const targetTemplate = lgTemplates.find((x) => x.name === inputLgRef.name);
- return targetTemplate ? targetTemplate.body : lgText;
- };
-
- const createLgTemplate = async (
- lgFileId: string,
- lgText: string,
- hostActionId: string,
- hostActionData: BaseSchema,
- hostFieldName: string
- ): Promise => {
- if (!lgText) return '';
- const newLgType = new LgType(hostActionData.$kind, hostFieldName).toString();
- const newLgTemplateName = new LgMetaData(newLgType, hostActionId).toString();
- const newLgTemplateRefStr = new LgTemplateRef(newLgTemplateName).toString();
- await addLgTemplate(lgFileId, newLgTemplateName, lgText);
- return newLgTemplateRefStr;
- };
-
- return {
- createLgTemplate,
- readLgTemplate,
- deleteLgTemplate: (lgFileId: string, lgTemplate: string) => deleteLgTemplates(lgFileId, [lgTemplate]),
- deleteLgTemplates,
- };
-};
diff --git a/Composer/packages/extension-client/src/hooks/useLuApi.ts b/Composer/packages/extension-client/src/hooks/useLuApi.ts
deleted file mode 100644
index 74c1d0d724..0000000000
--- a/Composer/packages/extension-client/src/hooks/useLuApi.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import { LuIntentSection, BaseSchema, LuMetaData, LuType, ShellApi } from '@bfc/shared';
-
-/**
- * LU CRUD API
- */
-export const useLuApi = (shellApi: ShellApi) => {
- const { addLuIntent, removeLuIntent, getLuIntent } = shellApi;
-
- const createLuIntent = async (
- luFildId: string,
- intent: LuIntentSection | undefined,
- hostResourceId: string,
- hostResourceData: BaseSchema
- ) => {
- if (!intent) return;
-
- const newLuIntentType = new LuType(hostResourceData.$kind).toString();
- const newLuIntentName = new LuMetaData(newLuIntentType, hostResourceId).toString();
- const newLuIntent: LuIntentSection = { ...intent, Name: newLuIntentName };
- await addLuIntent(luFildId, newLuIntentName, newLuIntent);
- return newLuIntentName;
- };
-
- const readLuIntent = (luFileId: string, hostResourceId: string, hostResourceData: BaseSchema) => {
- const relatedLuIntentType = new LuType(hostResourceData.$kind).toString();
- const relatedLuIntentName = new LuMetaData(relatedLuIntentType, hostResourceId).toString();
- return getLuIntent(luFileId, relatedLuIntentName);
- };
-
- const deleteLuIntents = (luFileId: string, luIntents: string[]) => {
- return Promise.all(luIntents.map((intent) => removeLuIntent(luFileId, intent)));
- };
-
- return {
- createLuIntent,
- readLuIntent,
- deleteLuIntent: (luFileId: string, luIntent: string) => deleteLuIntents(luFileId, [luIntent]),
- deleteLuIntents,
- };
-};
diff --git a/Composer/packages/extension-client/src/hooks/useProjectApi.ts b/Composer/packages/extension-client/src/hooks/useProjectApi.ts
new file mode 100644
index 0000000000..049ecf4d57
--- /dev/null
+++ b/Composer/packages/extension-client/src/hooks/useProjectApi.ts
@@ -0,0 +1,53 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { useMemo } from 'react';
+import pick from 'lodash/pick';
+import { ProjectContext, ProjectContextApi } from '@botframework-composer/types';
+
+import { validateHookContext } from '../utils/validateHookContext';
+
+import { useStore } from './useStore';
+
+const PROJECT_KEYS = [
+ 'data.botName',
+ 'data.projectId',
+ 'data.dialogs',
+ 'data.dialogSchemas',
+ 'data.lgFiles',
+ 'data.luFiles',
+ 'data.qnaFiles',
+ 'data.skills',
+ 'data.skillsSettings',
+ 'data.schemas',
+
+ 'api.getDialog',
+ 'api.saveDialog',
+ 'api.updateQnaContent',
+ 'api.updateRegExIntent',
+ 'api.renameRegExIntent',
+ 'api.updateIntentTrigger',
+ 'api.createDialog',
+ 'api.commitChanges',
+ 'api.addSkillDialog',
+ 'api.displayManifestModal',
+ 'api.updateDialogSchema',
+ 'api.createTrigger',
+ 'api.updateSkillSetting',
+];
+
+export function useProjectApi(): ProjectContext & ProjectContextApi {
+ const shell = useStore();
+
+ const projectContext = useMemo(() => {
+ const ctx = pick(shell, PROJECT_KEYS);
+ return {
+ ...ctx.api,
+ ...ctx.data,
+ } as ProjectContext & ProjectContextApi;
+ }, [pick(shell, PROJECT_KEYS)]);
+
+ validateHookContext('project');
+
+ return projectContext;
+}
diff --git a/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts b/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts
index a90127aa55..1c3815beb5 100644
--- a/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts
+++ b/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { useContext, useMemo } from 'react';
-import { MicrosoftIRecognizer, SDKKinds } from '@bfc/shared';
+import { MicrosoftIRecognizer, SDKKinds } from '@botframework-composer/types';
import get from 'lodash/get';
import { EditorExtensionContext } from '../EditorExtensionContext';
@@ -10,13 +10,11 @@ import { RecognizerOptions, RecognizerSchema } from '../types';
export const FallbackRecognizerKey = 'fallback';
-// TODO: (ze) remove this logic after the ui widget PR. [issue #4167]
-const reuseLuisIntentEditor = (recognizers: RecognizerSchema[]) => {
- const crosstrainRecognizer = recognizers.find((x) => x.id === SDKKinds.CrossTrainedRecognizerSet);
- const luisRecognizer = recognizers.find((x) => x.id === SDKKinds.LuisRecognizer);
- if (crosstrainRecognizer && luisRecognizer) {
- crosstrainRecognizer.intentEditor = luisRecognizer.intentEditor;
+const resolveRecognizerWidget = (widgetValue: any, recognizerWidgets: { [name: string]: any }) => {
+ if (typeof widgetValue === 'string' && recognizerWidgets[widgetValue]) {
+ return recognizerWidgets[widgetValue];
}
+ return widgetValue;
};
const getDefaultRecognizer = (recognizers: RecognizerSchema[]) => {
@@ -60,23 +58,25 @@ export function useRecognizerConfig(): RecognizerSchemaConfig {
const recognizers: RecognizerSchema[] = useMemo(() => {
if (!plugins.uiSchema) return [];
+ const recognizerWidgets = plugins.widgets?.recognizer ?? {};
const schemas = Object.entries(plugins.uiSchema)
.filter(([_, uiOptions]) => uiOptions && uiOptions.recognizer)
.map(([$kind, uiOptions]) => {
const recognizerOptions = uiOptions?.recognizer as RecognizerOptions;
+ const intentEditor = resolveRecognizerWidget(recognizerOptions.intentEditor, recognizerWidgets);
return {
id: $kind,
...recognizerOptions,
+ intentEditor,
} as RecognizerSchema;
});
- reuseLuisIntentEditor(schemas);
return schemas;
}, [plugins.uiSchema]);
const defaultRecognizer = getDefaultRecognizer(recognizers);
const fallbackRecognizer = getFallbackRecognizer(recognizers);
- const currentRecognizerValue = shellData.currentDialog?.content?.recognizer;
+ const currentRecognizerValue = shellData.currentDialog?.content?.recognizer as MicrosoftIRecognizer;
const currentRecognizer = findRecognizerByValue(recognizers, currentRecognizerValue) ?? fallbackRecognizer;
return {
diff --git a/Composer/packages/extension-client/src/hooks/useShellApi.ts b/Composer/packages/extension-client/src/hooks/useShellApi.ts
index a2887200a1..edf675c6ee 100644
--- a/Composer/packages/extension-client/src/hooks/useShellApi.ts
+++ b/Composer/packages/extension-client/src/hooks/useShellApi.ts
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { useContext } from 'react';
-import { ShellApi, ShellData } from '@bfc/shared';
+import { ShellApi, ShellData } from '@botframework-composer/types';
import { EditorExtensionContext } from '../EditorExtensionContext';
import { PluginConfig } from '../types';
diff --git a/Composer/packages/extension-client/src/hooks/useStore.ts b/Composer/packages/extension-client/src/hooks/useStore.ts
new file mode 100644
index 0000000000..31f90fd8b5
--- /dev/null
+++ b/Composer/packages/extension-client/src/hooks/useStore.ts
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { useState, useEffect } from 'react';
+
+import { State, __store__, Store } from '../store';
+
+export function useStore() {
+ const [, dispatch] = useState>(__store__.getState());
+
+ useEffect(() => {
+ __store__.addListener(dispatch);
+
+ return () => {
+ __store__.removeListener(dispatch);
+ };
+ }, []);
+
+ const store: Store = {
+ getState: __store__.getState.bind(__store__),
+ setState: __store__.setState.bind(__store__),
+ };
+
+ return store.getState();
+}
diff --git a/Composer/packages/extension-client/src/hooks/useTriggerApi.ts b/Composer/packages/extension-client/src/hooks/useTriggerApi.ts
deleted file mode 100644
index 4a6f35fc57..0000000000
--- a/Composer/packages/extension-client/src/hooks/useTriggerApi.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import { ShellApi, SDKKinds, ITriggerCondition, BaseSchema } from '@bfc/shared';
-import get from 'lodash/get';
-
-import { useActionApi } from './useActionApi';
-import { useLuApi } from './useLuApi';
-
-export const useTriggerApi = (shellApi: ShellApi) => {
- const { deleteActions } = useActionApi(shellApi);
- const { deleteLuIntent } = useLuApi(shellApi);
-
- const deleteTrigger = (dialogId: string, trigger: ITriggerCondition) => {
- if (!trigger) return;
-
- // Clean the lu resource on intent trigger
- if (get(trigger, '$kind') === SDKKinds.OnIntent) {
- const triggerIntent = get(trigger, 'intent', '');
- deleteLuIntent(dialogId, triggerIntent);
- }
-
- // Clean action resources
- const actions = get(trigger, 'actions') as BaseSchema[];
- if (!actions || !Array.isArray(actions)) return;
-
- deleteActions(dialogId, actions);
- };
-
- return {
- deleteTrigger,
- };
-};
diff --git a/Composer/packages/extension-client/src/index.ts b/Composer/packages/extension-client/src/index.ts
index e0a8bf146b..4118d0c005 100644
--- a/Composer/packages/extension-client/src/index.ts
+++ b/Composer/packages/extension-client/src/index.ts
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+export * from '@botframework-composer/types';
+
export * from './auth';
export * from './common';
export * from './components';
@@ -9,3 +11,5 @@ export * from './hooks';
export * from './publish';
export * from './types';
export * from './utils';
+
+export { syncStore } from './store';
diff --git a/Composer/packages/extension-client/src/store.ts b/Composer/packages/extension-client/src/store.ts
new file mode 100644
index 0000000000..e8dc39a07f
--- /dev/null
+++ b/Composer/packages/extension-client/src/store.ts
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { Dispatch } from 'react';
+import { Shell } from '@botframework-composer/types';
+
+export type State = Shell;
+
+export type Store = {
+ getState: () => State;
+ setState: (newState: Partial) => void;
+};
+
+class ExtensionStore implements Store {
+ private state: State = {} as Shell;
+ private listeners: Dispatch>[] = [];
+
+ constructor(initialState = {}) {
+ this.setState(initialState);
+ }
+
+ getState() {
+ return this.state;
+ }
+
+ setState(newState: Partial) {
+ this.state = { ...this.state, ...newState };
+
+ this.listeners.forEach((dispatch) => {
+ dispatch(this.state);
+ });
+ }
+
+ addListener(listener: Dispatch>) {
+ this.listeners.push(listener);
+ }
+
+ removeListener(listener: Dispatch>) {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ }
+}
+
+// eslint-disable-next-line no-underscore-dangle
+const __store__ = new ExtensionStore();
+
+export function syncStore(data = {}) {
+ __store__.setState(data);
+}
+
+export { __store__ };
diff --git a/Composer/packages/extension-client/src/types/extension.ts b/Composer/packages/extension-client/src/types/extension.ts
index fe1a047371..f7d7705cf5 100644
--- a/Composer/packages/extension-client/src/types/extension.ts
+++ b/Composer/packages/extension-client/src/types/extension.ts
@@ -1,16 +1,20 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { SDKKinds } from '@bfc/shared';
+import { SDKKinds } from '@botframework-composer/types';
import { UIOptions } from './formSchema';
import { FlowEditorWidgetMap, FlowWidget } from './flowSchema';
import { MenuOptions } from './menuSchema';
import { RecognizerOptions } from './recognizerSchema';
+import { FieldWidget } from './form';
export interface PluginConfig {
uiSchema?: UISchema;
- flowWidgets?: FlowEditorWidgetMap;
+ widgets?: {
+ flow?: FlowEditorWidgetMap;
+ recognizer?: { [name: string]: FieldWidget };
+ };
}
export type UISchema = {
diff --git a/Composer/packages/extension-client/src/types/flowSchema.ts b/Composer/packages/extension-client/src/types/flowSchema.ts
index 44560009eb..172bb57965 100644
--- a/Composer/packages/extension-client/src/types/flowSchema.ts
+++ b/Composer/packages/extension-client/src/types/flowSchema.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { FC, ComponentClass } from 'react';
-import { BaseSchema, SDKKinds } from '@bfc/shared';
+import { BaseSchema, SDKKinds } from '@botframework-composer/types';
export type FlowEditorWidgetMap = { [widgetName: string]: WidgetComponent };
export enum FlowSchemaBuiltinKeys {
diff --git a/Composer/packages/extension-client/src/types/form.ts b/Composer/packages/extension-client/src/types/form.ts
index 2de9d5a4d3..1c4adf0fdf 100644
--- a/Composer/packages/extension-client/src/types/form.ts
+++ b/Composer/packages/extension-client/src/types/form.ts
@@ -1,30 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { JSONSchema7, JSONSchema7Definition } from 'json-schema';
+import { JSONSchema7, SchemaDefinitions } from '@botframework-composer/types';
import React from 'react';
import { UIOptions } from './formSchema';
-declare module 'json-schema' {
- interface JSONSchema7 {
- $copy?: string;
- $id?: string;
- $kind?: string;
- $role?: string;
- $designer?: {
- id: string;
- [key: string]: any;
- };
- }
-}
-
-export interface SchemaDefinitions {
- [key: string]: JSONSchema7Definition;
-}
-
-// Re-export monkey patched json schema interfaces
-export { JSONSchema7, JSONSchema7Definition };
-
export type FormErrors = {
[key: string]: string | FormErrors;
};
@@ -34,7 +14,7 @@ export type ChangeHandler = (newValue?: T) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface FieldProps {
className?: string;
- definitions: { [key: string]: JSONSchema7Definition } | undefined;
+ definitions: SchemaDefinitions | undefined;
depth: number;
description?: string;
disabled?: boolean;
diff --git a/Composer/packages/extension-client/src/types/formSchema.ts b/Composer/packages/extension-client/src/types/formSchema.ts
index bebf908b80..e4e3ad6e09 100644
--- a/Composer/packages/extension-client/src/types/formSchema.ts
+++ b/Composer/packages/extension-client/src/types/formSchema.ts
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { MicrosoftIRecognizer, SDKKinds, SDKRoles, ShellApi, ShellData } from '@bfc/shared';
+import { MicrosoftIRecognizer, SDKKinds, SDKRoles, ShellApi, ShellData } from '@botframework-composer/types';
import { FieldWidget } from './form';
@@ -9,10 +9,11 @@ type UIOptionValue = R | UIOptionFunc;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type UIOptionFunc = (data: D) => R;
-export interface Fieldset {
+export interface Fieldset[]> {
title: UIOptionValue;
- fields?: string[];
+ fields?: F;
defaultExpanded?: boolean;
+ description?: UIOptionValue;
itemKey?: string;
}
@@ -75,7 +76,7 @@ export type RecognizerSchema = {
/** Display name used in the UI. Recommended to use function over static string to enable multi-locale feature. */
displayName: UIOptionValue;
/** An inline editor to edit an intent. If none provided, users will not be able to edit. */
- intentEditor?: FieldWidget;
+ intentEditor?: FieldWidget | string;
/** A function invoked with the form data to determine if this is the currently selected recognizer */
isSelected?: (data: any) => boolean;
/** Invoked when constructing a new recognizer instance.
diff --git a/Composer/packages/extension-client/src/types/index.ts b/Composer/packages/extension-client/src/types/index.ts
index a44ce84f99..bc071a2148 100644
--- a/Composer/packages/extension-client/src/types/index.ts
+++ b/Composer/packages/extension-client/src/types/index.ts
@@ -7,3 +7,4 @@ export * from './formSchema';
export * from './flowSchema';
export * from './menuSchema';
export * from './recognizerSchema';
+export * from './pluginType';
diff --git a/Composer/packages/extension-client/src/types/menuSchema.ts b/Composer/packages/extension-client/src/types/menuSchema.ts
index b27094c4a3..ba284764b0 100644
--- a/Composer/packages/extension-client/src/types/menuSchema.ts
+++ b/Composer/packages/extension-client/src/types/menuSchema.ts
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { SDKKinds } from '@bfc/shared';
+import { SDKKinds } from '@botframework-composer/types';
export interface MenuOptions {
label?: string;
diff --git a/Composer/packages/client/src/plugins/types.ts b/Composer/packages/extension-client/src/types/pluginType.ts
similarity index 55%
rename from Composer/packages/client/src/plugins/types.ts
rename to Composer/packages/extension-client/src/types/pluginType.ts
index 1dfa876955..6ec1bfaf9b 100644
--- a/Composer/packages/client/src/plugins/types.ts
+++ b/Composer/packages/extension-client/src/types/pluginType.ts
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-export type PluginType = 'publish' | 'page' | 'storage' | 'create';
+export type PluginType = 'page' | 'publish' | 'storage' | 'create';
diff --git a/Composer/packages/extension-client/src/types/recognizerSchema.ts b/Composer/packages/extension-client/src/types/recognizerSchema.ts
index e1585ec68e..976a98df6d 100644
--- a/Composer/packages/extension-client/src/types/recognizerSchema.ts
+++ b/Composer/packages/extension-client/src/types/recognizerSchema.ts
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { SDKKinds } from '@bfc/shared';
+import { SDKKinds } from '@botframework-composer/types';
import { RecognizerSchema } from './formSchema';
diff --git a/Composer/packages/extension-client/src/types/window.d.ts b/Composer/packages/extension-client/src/types/window.d.ts
new file mode 100644
index 0000000000..255114bbb6
--- /dev/null
+++ b/Composer/packages/extension-client/src/types/window.d.ts
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { Shell } from '@botframework-composer/types';
+
+import * as ExtensionClient from '../index';
+
+declare global {
+ interface Window {
+ /**
+ * Electron mechanism used for communication from renderer to main process.
+ */
+ ipcRenderer: IPCRenderer;
+
+ /**
+ * Flag that is set on the window object when the client is embedded within Electron.
+ */
+ __IS_ELECTRON__?: boolean;
+
+ /**
+ * Composer UI Extension API
+ */
+ Composer: {
+ __pluginType: string;
+ render: (component: React.ReactElement) => void;
+ sync: (shell: Shell) => void;
+ [key: string]: any;
+ };
+
+ ExtensionClient: typeof ExtensionClient;
+ }
+}
diff --git a/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts b/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts
index 125e0006c1..66efdc2109 100644
--- a/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts
+++ b/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { SDKKinds } from '@bfc/shared';
+import { SDKKinds } from '@botframework-composer/types';
import { PluginConfig } from '../../types';
import { mergePluginConfigs } from '../mergePluginConfigs';
diff --git a/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts b/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts
index 69870f06b4..daea09b712 100644
--- a/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts
+++ b/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts
@@ -22,7 +22,7 @@ const mergeArrays: MergeWithCustomizer = (objValue, srcValue, key) => {
const defaultPlugin: Required = {
uiSchema: {},
- flowWidgets: {},
+ widgets: {},
};
export function mergePluginConfigs(...plugins: PluginConfig[]): Required {
diff --git a/Composer/packages/extension-client/src/utils/validateHookContext.ts b/Composer/packages/extension-client/src/utils/validateHookContext.ts
new file mode 100644
index 0000000000..7ca3e51185
--- /dev/null
+++ b/Composer/packages/extension-client/src/utils/validateHookContext.ts
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { PluginType } from '../types/pluginType';
+
+type HookContext = 'project';
+
+const pluginTypeToContextMap: { [key in PluginType]: HookContext[] } = {
+ page: ['project'],
+ publish: [],
+ storage: ['project'],
+ create: [],
+};
+
+export function validateHookContext(targetContext: HookContext) {
+ // eslint-disable-next-line no-underscore-dangle
+ const validContexts = pluginTypeToContextMap[window.Composer.__pluginType] ?? [];
+
+ if (!validContexts.includes(targetContext)) {
+ const msg = Object.entries(pluginTypeToContextMap)
+ .reduce((types, [type, ctxs]) => {
+ if (ctxs.includes(targetContext)) {
+ types.push(`- ${type}`);
+ }
+
+ return types;
+ }, [] as string[])
+ .join('\n');
+ throw Error(`Invalid use of ${targetContext} api. Only available in these contexts:\n${msg}`);
+ }
+}
diff --git a/Composer/packages/extension/jest.config.js b/Composer/packages/extension/jest.config.js
index 81af8fc3ab..b2605de44a 100644
--- a/Composer/packages/extension/jest.config.js
+++ b/Composer/packages/extension/jest.config.js
@@ -1,6 +1,6 @@
const path = require('path');
-const { createConfig } = require('@bfc/test-utils');
+const { createConfig } = require('@botframework-composer/test-utils');
module.exports = createConfig('extension', 'node', {
setupFiles: [path.resolve(__dirname, 'src/__tests__/setupEnv.ts')],
diff --git a/Composer/packages/extension/package.json b/Composer/packages/extension/package.json
index cd99329ead..fd9df55fb3 100644
--- a/Composer/packages/extension/package.json
+++ b/Composer/packages/extension/package.json
@@ -8,25 +8,29 @@
"scripts": {
"build": "yarn build:clean && yarn build:ts",
"build:ts": "tsc -p tsconfig.build.json",
- "build:clean": "rimraf lib && rimraf build",
+ "build:clean": "rimraf lib",
"lint": "eslint --quiet ./src",
"test": "jest"
},
"devDependencies": {
- "@bfc/test-utils": "*",
+ "@botframework-composer/test-utils": "*",
"@types/express": "^4.17.6",
"@types/fs-extra": "^9.0.1",
"@types/passport": "^1.0.3",
"@types/path-to-regexp": "^1.7.0",
+ "@types/tar": "^4.0.3",
"json-schema": "^0.2.5",
"rimraf": "^3.0.2",
"typescript": "^3.8.3"
},
"dependencies": {
+ "@botframework-composer/types": "*",
"debug": "^4.1.1",
"fs-extra": "^9.0.1",
"globby": "^11.0.0",
+ "node-fetch": "^2.6.1",
"passport": "^0.4.1",
- "path-to-regexp": "^6.1.0"
+ "path-to-regexp": "^6.1.0",
+ "tar": "^6.0.5"
}
}
diff --git a/Composer/packages/extension/src/extensionContext.ts b/Composer/packages/extension/src/extensionContext.ts
index 3ece1f7681..6cab68154b 100644
--- a/Composer/packages/extension/src/extensionContext.ts
+++ b/Composer/packages/extension/src/extensionContext.ts
@@ -9,12 +9,13 @@ import { Express } from 'express';
import { pathToRegexp } from 'path-to-regexp';
import glob from 'globby';
import formatMessage from 'format-message';
+import { UserIdentity, ExtensionCollection, RuntimeTemplate } from '@botframework-composer/types';
-import { UserIdentity, ExtensionCollection, RuntimeTemplate, DEFAULT_RUNTIME } from './types/types';
import logger from './logger';
import { ExtensionRegistration } from './extensionRegistration';
const log = logger.extend('extension-context');
+export const DEFAULT_RUNTIME = 'csharp-azurewebapp';
class ExtensionContext {
private _passport: passport.PassportStatic;
@@ -63,7 +64,7 @@ class ExtensionContext {
return this.extensions.authentication.middleware(req, res, next);
}
}
- next();
+ next && next();
});
}
@@ -88,7 +89,7 @@ class ExtensionContext {
const packageJSON = fs.readFileSync(packageJsonPath, 'utf8');
const json = JSON.parse(packageJSON);
- if (json.extendsComposer) {
+ if (json.composer?.enabled !== false) {
const modulePath = path.dirname(packageJsonPath);
try {
// eslint-disable-next-line security/detect-non-literal-require, @typescript-eslint/no-var-requires
diff --git a/Composer/packages/extension/src/extensionRegistration.ts b/Composer/packages/extension/src/extensionRegistration.ts
index 8a3d90de86..1ce6e33cf4 100644
--- a/Composer/packages/extension/src/extensionRegistration.ts
+++ b/Composer/packages/extension/src/extensionRegistration.ts
@@ -3,9 +3,9 @@
import { RequestHandler } from 'express-serve-static-core';
import { Debugger } from 'debug';
+import { PublishPlugin, RuntimeTemplate, BotTemplate } from '@botframework-composer/types';
import logger from './logger';
-import { PublishPlugin, RuntimeTemplate, BotTemplate } from './types/types';
import { ExtensionContext } from './extensionContext';
const log = logger.extend('extension-registration');
@@ -64,7 +64,7 @@ export class ExtensionRegistration {
name: plugin.customName || this.name,
description: plugin.customDescription || this.description,
instructions: plugin.instructions,
- hasView: plugin.hasView,
+ bundleId: plugin.bundleId,
schema: plugin.schema,
},
methods: plugin,
@@ -157,7 +157,7 @@ export class ExtensionRegistration {
// bind a basic auth middleware. this can be overridden. see setAuthMiddleware below
this.context.extensions.authentication.middleware = (req, res, next) => {
if (req.isAuthenticated()) {
- next();
+ next && next();
} else {
log('Rejecting access to ', req.url);
res.redirect(this.context.loginUri);
diff --git a/Composer/packages/extension/src/index.ts b/Composer/packages/extension/src/index.ts
index 1c381ecc22..45a1dea81f 100644
--- a/Composer/packages/extension/src/index.ts
+++ b/Composer/packages/extension/src/index.ts
@@ -3,8 +3,9 @@
export { JSONSchema7 } from 'json-schema';
+export * from '@botframework-composer/types';
+
export * from './manager';
export * from './storage';
-export * from './types/types';
export * from './extensionContext';
export * from './extensionRegistration';
diff --git a/Composer/packages/extension/src/manager/__tests__/manager.test.ts b/Composer/packages/extension/src/manager/__tests__/manager.test.ts
index 2f80727d89..b64c3b9216 100644
--- a/Composer/packages/extension/src/manager/__tests__/manager.test.ts
+++ b/Composer/packages/extension/src/manager/__tests__/manager.test.ts
@@ -1,15 +1,25 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+import path from 'path';
-import { writeJsonSync } from 'fs-extra';
+import { readJson, ensureDir, remove } from 'fs-extra';
+import glob from 'globby';
-import { ExtensionManager } from '../manager';
+import { search, downloadPackage } from '../../utils/npm';
+import { ExtensionManifestStore, ExtensionManifest } from '../../storage/extensionManifestStore';
+import { ExtensionManagerImp } from '../manager';
-const mockManifest = {
+const mockManifest = ({
extension1: {
id: 'extension1',
builtIn: true,
enabled: true,
+ bundles: [
+ {
+ id: 'bundleId',
+ path: '/some/path',
+ },
+ ],
},
extension2: {
id: 'extension2',
@@ -19,20 +29,42 @@ const mockManifest = {
id: 'extension3',
enabled: false,
},
-};
+} as unknown) as ExtensionManifest;
+
+jest.mock('../../storage/extensionManifestStore');
+
+jest.mock('globby', () => jest.fn());
+
+jest.mock('fs-extra', () => ({
+ ensureDir: jest.fn(),
+ readJson: jest.fn(),
+ remove: jest.fn(),
+}));
+
+jest.mock('../../utils/npm');
+
+let manager: ExtensionManagerImp;
+let manifest: ExtensionManifestStore;
beforeEach(() => {
- writeJsonSync(process.env.COMPOSER_EXTENSION_DATA as string, mockManifest);
- ExtensionManager.reloadManifest();
+ manifest = new ExtensionManifestStore('/some/path');
});
describe('#getAll', () => {
it('return an array of all extensions', () => {
- expect(ExtensionManager.getAll()).toEqual([
+ (manifest.getExtensions as jest.Mock).mockReturnValue(mockManifest);
+ manager = new ExtensionManagerImp(manifest);
+ expect(manager.getAll()).toEqual([
{
id: 'extension1',
builtIn: true,
enabled: true,
+ bundles: [
+ {
+ id: 'bundleId',
+ path: '/some/path',
+ },
+ ],
},
{
id: 'extension2',
@@ -48,32 +80,294 @@ describe('#getAll', () => {
describe('#find', () => {
it('returns extension metadata for id', () => {
- expect(ExtensionManager.find('extension1')).toEqual({ id: 'extension1', builtIn: true, enabled: true });
- expect(ExtensionManager.find('does-not-exist')).toBeUndefined();
+ (manifest.getExtensionConfig as jest.Mock).mockImplementation((id) => {
+ return mockManifest[id];
+ });
+ manager = new ExtensionManagerImp(manifest);
+ expect(manager.find('extension1')).toEqual({
+ id: 'extension1',
+ builtIn: true,
+ enabled: true,
+ bundles: [
+ {
+ id: 'bundleId',
+ path: '/some/path',
+ },
+ ],
+ });
+ expect(manager.find('does-not-exist')).toBeUndefined();
});
});
describe('#loadAll', () => {
- it('loads built-in extensions and remote extensions that are enabled', async () => {
- const loadSpy = jest.spyOn(ExtensionManager, 'load');
+ let loadSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ (manifest.getExtensions as jest.Mock).mockReturnValue({});
+
+ manager = new ExtensionManagerImp(manifest);
+ loadSpy = jest.spyOn(manager, 'loadFromDir');
loadSpy.mockReturnValue(Promise.resolve());
+ });
+
+ it('ensures remote dir is created', async () => {
+ await manager.loadAll();
+
+ expect(ensureDir).toHaveBeenCalledWith(process.env.COMPOSER_REMOTE_EXTENSIONS_DIR);
+ });
- await ExtensionManager.loadAll();
+ it('loads built-in extensions and remote extensions that are enabled', async () => {
+ await manager.loadAll();
expect(loadSpy).toHaveBeenCalledTimes(2);
+ expect(loadSpy).toHaveBeenNthCalledWith(1, process.env.COMPOSER_BUILTIN_EXTENSIONS_DIR, true);
+ expect(loadSpy).toHaveBeenNthCalledWith(2, process.env.COMPOSER_REMOTE_EXTENSIONS_DIR);
+ });
+});
+
+describe('#loadFromDir', () => {
+ it('finds all package.json files in dir', async () => {
+ ((glob as unknown) as jest.Mock).mockReturnValue([]);
+ manager = new ExtensionManagerImp(manifest);
+
+ await manager.loadFromDir('/some/dir');
+ expect(glob).toHaveBeenCalledWith('*/package.json', { cwd: '/some/dir' });
+ });
+
+ it('updates the extension manifest and loads each extension found', async () => {
+ ((glob as unknown) as jest.Mock).mockReturnValue(['extension1/package.json', 'extension2/package.json']);
+ (readJson as jest.Mock).mockImplementation((path) => {
+ if (path.includes('extension1')) {
+ return {
+ name: 'extension1',
+ };
+ } else if (path.includes('extension2')) {
+ return {
+ name: 'extension2',
+ };
+ }
+
+ return {};
+ });
+
+ manager = new ExtensionManagerImp(manifest);
+ const loadSpy = jest.spyOn(manager, 'load');
+ loadSpy.mockResolvedValue(undefined);
+
+ await manager.loadFromDir('/some/dir');
+
+ expect(readJson).toHaveBeenCalledWith('/some/dir/extension1/package.json');
+ expect(readJson).toHaveBeenCalledWith('/some/dir/extension2/package.json');
+
+ expect(manifest.updateExtensionConfig).toHaveBeenCalledWith(
+ 'extension1',
+ expect.objectContaining({ id: 'extension1' })
+ );
+ expect(manifest.updateExtensionConfig).toHaveBeenCalledWith(
+ 'extension2',
+ expect.objectContaining({ id: 'extension2' })
+ );
+
expect(loadSpy).toHaveBeenCalledWith('extension1');
expect(loadSpy).toHaveBeenCalledWith('extension2');
});
+
+ it('removes the extension from the manifest if not enabled', async () => {
+ ((glob as unknown) as jest.Mock).mockReturnValue(['extension1/package.json']);
+ (readJson as jest.Mock).mockResolvedValue({
+ name: 'extension1',
+ composer: {
+ enabled: false,
+ },
+ });
+ (manifest.getExtensionConfig as jest.Mock).mockReturnValueOnce('extension1');
+
+ manager = new ExtensionManagerImp(manifest);
+ const loadSpy = jest.spyOn(manager, 'load');
+ loadSpy.mockResolvedValue(undefined);
+
+ await manager.loadFromDir('/some/dir');
+
+ expect(manifest.updateExtensionConfig).not.toHaveBeenCalled();
+ expect(loadSpy).not.toHaveBeenCalled();
+ expect(manifest.removeExtension).toHaveBeenCalledTimes(1);
+ expect(manifest.removeExtension).toHaveBeenCalledWith('extension1');
+ });
+});
+
+describe('#installRemote', () => {
+ it('ensures remote dir exists', async () => {
+ manager = new ExtensionManagerImp(manifest);
+ await manager.installRemote('extension1');
+
+ expect(ensureDir).toHaveBeenLastCalledWith(process.env.COMPOSER_REMOTE_EXTENSIONS_DIR);
+ });
+
+ it('validates destination directory', () => {
+ manager = new ExtensionManagerImp(manifest);
+ expect(manager.installRemote('../extension')).rejects.toThrow();
+ expect(manager.installRemote('../../extension')).rejects.toThrow();
+ });
+
+ it('downloads package and updates the manifest', async () => {
+ (readJson as jest.Mock).mockResolvedValue({
+ name: 'extension1',
+ });
+
+ manager = new ExtensionManagerImp(manifest);
+ await manager.installRemote('extension1');
+
+ expect(downloadPackage).toHaveBeenCalledWith(
+ 'extension1',
+ 'latest',
+ path.join(process.env.COMPOSER_REMOTE_EXTENSIONS_DIR as string, 'extension1')
+ );
+
+ expect(manifest.updateExtensionConfig).toHaveBeenCalledWith(
+ 'extension1',
+ expect.objectContaining({ id: 'extension1' })
+ );
+ });
+
+ it('throws an error if problem downloading', () => {
+ (downloadPackage as jest.Mock).mockRejectedValue(undefined);
+ manager = new ExtensionManagerImp(manifest);
+
+ expect(manager.installRemote('extension1', '2.0.0')).rejects.toThrow(/Unable to install/);
+ });
});
-// describe('#installRemote', () => {});
-// describe('#loadBuiltinExtensions', () => {});
-// describe('#loadRemotePlugins', () => {});
// describe('#load', () => {});
-// describe('#enable', () => {});
-// describe('#disable', () => {});
-// describe('#remove', () => {});
-// describe('#search', () => {});
-// describe('#getAllBundles', () => {});
-// describe('#getBundle', () => {});
+
+describe('#enable', () => {
+ it('updates the manifest and reloads the extension', async () => {
+ manager = new ExtensionManagerImp(manifest);
+ const loadSpy = jest.spyOn(manager, 'load');
+ (loadSpy as jest.Mock).mockResolvedValue(undefined);
+
+ await manager.enable('extension1');
+
+ expect(manifest.updateExtensionConfig).toHaveBeenCalledWith('extension1', { enabled: true });
+ expect(loadSpy).toHaveBeenCalledWith('extension1');
+ });
+});
+
+describe('#disable', () => {
+ it('updates the manifest', async () => {
+ manager = new ExtensionManagerImp(manifest);
+
+ await manager.disable('extension1');
+
+ expect(manifest.updateExtensionConfig).toHaveBeenCalledWith('extension1', { enabled: false });
+ });
+});
+
+describe('#remove', () => {
+ it('throws an error if extension not found', () => {
+ (manifest.getExtensionConfig as jest.Mock).mockReturnValue(undefined);
+ manager = new ExtensionManagerImp(manifest);
+
+ expect(manager.remove('extension1')).rejects.toThrow(/Unable to remove extension/);
+ });
+
+ it('is a no-op if the extension is builtin', async () => {
+ (manifest.getExtensionConfig as jest.Mock).mockReturnValue({ builtIn: true });
+ manager = new ExtensionManagerImp(manifest);
+
+ await manager.remove('extension1');
+
+ expect(remove).not.toHaveBeenCalled();
+ expect(manifest.removeExtension).not.toHaveBeenCalled();
+ });
+
+ it('removes the extension from the manifest and cleans up', async () => {
+ (manifest.getExtensionConfig as jest.Mock).mockReturnValue({ builtIn: false, path: '/some/path' });
+ manager = new ExtensionManagerImp(manifest);
+
+ await manager.remove('extension1');
+
+ expect(remove).toHaveBeenCalledWith('/some/path');
+ expect(manifest.removeExtension).toHaveBeenCalledWith('extension1');
+ });
+});
+
+describe('#search', () => {
+ beforeEach(() => {
+ (search as jest.Mock).mockResolvedValue([
+ {
+ id: 'extension1',
+ keywords: ['botframework-composer', 'extension', 'foo', 'bar'],
+ description: 'LOREM ipsum',
+ version: '1.0.0',
+ url: 'extension1 npm link',
+ },
+ {
+ id: 'extension-2',
+ keywords: ['botframework-composer', 'extension', 'bar'],
+ description: 'foo',
+ version: '1.0.0',
+ url: 'extension-2 npm link',
+ },
+ {
+ id: 'foo',
+ keywords: ['botframework-composer'],
+ description: 'lorem ipsum',
+ version: '1.0.0',
+ url: 'foo npm link',
+ },
+ {
+ id: 'bar',
+ keywords: ['botframework-composer'],
+ description: '',
+ version: '1.0.0',
+ url: 'bar npm link',
+ },
+ ]);
+ });
+
+ it('filters the search results by the extension keyword', async () => {
+ manager = new ExtensionManagerImp(manifest);
+
+ const results = await manager.search('foo');
+
+ expect(search).toHaveBeenCalledWith('foo');
+ expect(results).toHaveLength(2);
+ });
+
+ it('omits currently installed extensions', async () => {
+ (manifest.getExtensionConfig as jest.Mock).mockImplementation((id) => {
+ return mockManifest[id];
+ });
+
+ manager = new ExtensionManagerImp(manifest);
+
+ const results = await manager.search('foo');
+
+ expect(search).toHaveBeenCalledWith('foo');
+ expect(results).toHaveLength(1);
+ });
+});
+
+describe('#getBundle', () => {
+ beforeEach(() => {
+ (manifest.getExtensionConfig as jest.Mock).mockImplementation((id) => {
+ return mockManifest[id];
+ });
+ });
+
+ it('throws an error if extension not found', () => {
+ manager = new ExtensionManagerImp(manifest);
+ expect(() => manager.getBundle('does-not-exist', 'bundleId')).toThrow('extension not found');
+ });
+
+ it('throws an error if bundle not found', () => {
+ manager = new ExtensionManagerImp(manifest);
+ expect(() => manager.getBundle('extension1', 'does-not-exist')).toThrow('bundle not found');
+ });
+
+ it('returns the bundle path', () => {
+ manager = new ExtensionManagerImp(manifest);
+ expect(manager.getBundle('extension1', 'bundleId')).toEqual('/some/path');
+ });
+});
diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts
index 1fd4578297..6735a6370c 100644
--- a/Composer/packages/extension/src/manager/manager.ts
+++ b/Composer/packages/extension/src/manager/manager.ts
@@ -4,18 +4,17 @@
import path from 'path';
import glob from 'globby';
-import { readJson, ensureDir, existsSync } from 'fs-extra';
+import { readJson, ensureDir, remove, pathExists } from 'fs-extra';
+import { ExtensionBundle, PackageJSON, ExtensionMetadata } from '@botframework-composer/types';
import { ExtensionContext } from '../extensionContext';
import logger from '../logger';
import { ExtensionManifestStore } from '../storage/extensionManifestStore';
-import { ExtensionBundle, PackageJSON, ExtensionMetadata, ExtensionSearchResult } from '../types/extension';
-import { npm } from '../utils/npm';
+import { search, downloadPackage } from '../utils/npm';
+import { isSubdirectory } from '../utils/isSubdirectory';
const log = logger.extend('manager');
-const SEARCH_CACHE_TIMEOUT = 5 * 60000; // 5 minutes
-
function processBundles(extensionPath: string, bundles: ExtensionBundle[]) {
return bundles.map((b) => ({
...b,
@@ -36,10 +35,8 @@ function getExtensionMetadata(extensionPath: string, packageJson: PackageJSON):
};
}
-class ExtensionManager {
- private searchCache = new Map();
- private _manifest: ExtensionManifestStore | undefined;
- private _lastSearchTimestamp: Date | undefined;
+export class ExtensionManagerImp {
+ public constructor(private _manifest?: ExtensionManifestStore) {}
/**
* Returns all extensions currently in the extension manifest
@@ -61,14 +58,37 @@ class ExtensionManager {
* Loads all builtin extensions and remote extensions.
*/
public async loadAll() {
- await this.seedBuiltinExtensions();
await ensureDir(this.remoteDir);
- const extensions = Object.entries(this.manifest.getExtensions());
+ await this.loadFromDir(this.builtinDir, true);
+ await this.loadFromDir(this.remoteDir);
+
+ await this.cleanManifest();
+ }
- for (const [id, metadata] of extensions) {
- if (metadata?.enabled) {
- await this.load(id);
+ /**
+ * Loads extensions from a given directory
+ * @param dir directory to load extensions from
+ * @param isBuiltin used to set extension metadata
+ */
+ public async loadFromDir(dir: string, isBuiltin = false) {
+ log('Loading extensions from %s', dir);
+ const extensions = await glob('*/package.json', { cwd: dir });
+ for (const extensionPackageJsonPath of extensions) {
+ const fullPath = path.join(dir, extensionPackageJsonPath);
+ const extensionInstallPath = path.dirname(fullPath);
+ const packageJson = (await readJson(fullPath)) as PackageJSON;
+ const isEnabled = packageJson.composer?.enabled !== false;
+ const metadata = getExtensionMetadata(extensionInstallPath, packageJson);
+ if (isEnabled) {
+ this.manifest.updateExtensionConfig(metadata.id, {
+ ...metadata,
+ builtIn: isBuiltin,
+ });
+ await this.load(metadata.id);
+ } else if (this.manifest.getExtensionConfig(metadata.id)) {
+ // remove the extension if it exists in the manifest
+ this.manifest.removeExtension(metadata.id);
}
}
}
@@ -80,65 +100,28 @@ class ExtensionManager {
* @returns id of installed package
*/
public async installRemote(name: string, version?: string) {
+ await ensureDir(this.remoteDir);
const packageNameAndVersion = version ? `${name}@${version}` : `${name}@latest`;
log('Installing %s to %s', packageNameAndVersion, this.remoteDir);
try {
- const { stdout } = await npm(
- 'install',
- packageNameAndVersion,
- { '--prefix': this.remoteDir },
- { cwd: this.remoteDir }
- );
-
- log('%s', stdout);
-
- const packageJson = await this.getPackageJson(name, this.remoteDir);
-
- if (packageJson) {
- const extensionPath = path.resolve(this.remoteDir, 'node_modules', name);
- this.manifest.updateExtensionConfig(name, getExtensionMetadata(extensionPath, packageJson));
+ const destination = path.join(this.remoteDir, name);
- return packageJson.name;
- } else {
- throw new Error(`Unable to install ${packageNameAndVersion}`);
+ if (!isSubdirectory(this.remoteDir, destination)) {
+ throw new Error('Cannot install outside of the configured directory.');
}
- } catch (err) {
- if (err?.stderr) {
- log('%s', err.stderr);
- }
- throw new Error(`Unable to install ${packageNameAndVersion}`);
- }
- }
- /**
- * Installs a local extension at path
- * @param path Path of directory where extension is
- */
- public async installLocal(extPath: string) {
- try {
- const packageJsonPath = path.join(extPath, 'package.json');
+ await downloadPackage(name, version ?? 'latest', destination);
- if (!existsSync(packageJsonPath)) {
- throw new Error(`Extension not found at path: ${extPath}`);
+ const packageJson = await this.getPackageJson(name, this.remoteDir);
+ if (packageJson) {
+ this.manifest.updateExtensionConfig(packageJson.name, getExtensionMetadata(destination, packageJson));
}
- const packageJson = await readJson(packageJsonPath);
-
- log('Linking %s', packageJson.name);
- await npm('link', '.', {}, { cwd: extPath });
-
- log('Installing %s@local to %s', packageJson.name, this.remoteDir);
- await npm('link', packageJson.name, { '--prefix': this.remoteDir }, { cwd: this.remoteDir });
-
- const extensionPath = path.resolve(this.remoteDir, 'node_modules', packageJson.name);
- this.manifest.updateExtensionConfig(packageJson.name, getExtensionMetadata(extensionPath, packageJson));
-
- return packageJson.name;
+ return name;
} catch (err) {
- log('%s', err.msg ?? err.stderr ?? err);
- // eslint-disable-next-line no-console
- console.error(err);
+ log('%O', err);
+ throw new Error(`Unable to install ${packageNameAndVersion}`);
}
}
@@ -190,14 +173,16 @@ class ExtensionManager {
public async remove(id: string) {
log('Removing %s', id);
- try {
- const { stdout } = await npm('uninstall', id, { '--prefix': this.remoteDir }, { cwd: this.remoteDir });
+ const metadata = this.find(id);
- log('%s', stdout);
+ if (metadata) {
+ if (metadata.builtIn) {
+ return;
+ }
+ await remove(metadata.path);
this.manifest.removeExtension(id);
- } catch (err) {
- log('%s', err);
+ } else {
throw new Error(`Unable to remove extension: ${id}`);
}
}
@@ -207,33 +192,12 @@ class ExtensionManager {
* @param query The search query
*/
public async search(query: string) {
- await this.updateSearchCache();
- const normalizedQuery = query.toLowerCase();
-
- const results = Array.from(this.searchCache.values()).filter((result) => {
- return (
- !this.find(result.id) &&
- [result.id, result.description, ...result.keywords].some((target) =>
- target.toLowerCase().includes(normalizedQuery)
- )
- );
- });
-
- return results;
- }
-
- /**
- * Returns a list of all of an extension's bundles
- * @param id The ID of the extension for which we will fetch the list of bundles
- */
- public async getAllBundles(id: string) {
- const info = this.find(id);
-
- if (!info) {
- throw new Error('extension not found');
- }
+ const results = await search(query);
- return info.bundles ?? [];
+ return results.filter((searchResult) => {
+ const { id, keywords } = searchResult;
+ return !this.find(id) && keywords.includes('extension');
+ });
}
/**
@@ -257,54 +221,38 @@ class ExtensionManager {
return bundle.path;
}
+ private async cleanManifest() {
+ for (const ext of this.getAll()) {
+ if (!(await pathExists(ext.path))) {
+ log('Removing %s. It is in the manifest but could not be located.', ext.id);
+ this.remove(ext.id);
+ }
+ }
+ }
+
private async getPackageJson(id: string, dir: string): Promise {
try {
- const extensionPackagePath = path.resolve(dir, 'node_modules', id, 'package.json');
+ const extensionPackagePath = path.resolve(dir, id, 'package.json');
log('fetching package.json for %s at %s', id, extensionPackagePath);
const packageJson = await readJson(extensionPackagePath);
return packageJson as PackageJSON;
- } catch (err) {
+ } catch (err) /* istanbul ignore next */ {
log('Error getting package json for %s', id);
- // eslint-disable-next-line no-console
- console.error(err);
- }
- }
-
- public async seedBuiltinExtensions() {
- const extensions = await glob('*/package.json', { cwd: this.builtinDir, dot: true });
- for (const extensionPackageJsonPath of extensions) {
- // go through each extension, make sure to add it to the manager store then load it as usual
- const fullPath = path.join(this.builtinDir, extensionPackageJsonPath);
- const extensionInstallPath = path.dirname(fullPath);
- const packageJson = (await readJson(fullPath)) as PackageJSON;
- const isEnabled = packageJson?.composer && packageJson.composer.enabled !== false;
- const metadata = getExtensionMetadata(extensionInstallPath, packageJson);
- if (packageJson && (isEnabled || packageJson.extendsComposer === true)) {
- this.manifest.updateExtensionConfig(packageJson.name, {
- ...metadata,
- builtIn: true,
- });
- } else if (this.manifest.getExtensionConfig(packageJson.name)) {
- // remove the extension if it exists in the manifest
- this.manifest.removeExtension(packageJson.name);
- }
+ log('%O', err);
}
}
- public reloadManifest() {
- this._manifest = undefined;
- }
-
private get manifest() {
- if (this._manifest) {
- return this._manifest;
+ /* istanbul ignore next */
+ if (!this._manifest) {
+ this._manifest = new ExtensionManifestStore(process.env.COMPOSER_EXTENSION_DATA as string);
}
- this._manifest = new ExtensionManifestStore(process.env.COMPOSER_EXTENSION_DATA as string);
return this._manifest;
}
private get builtinDir() {
+ /* istanbul ignore next */
if (!process.env.COMPOSER_BUILTIN_EXTENSIONS_DIR) {
throw new Error('COMPOSER_BUILTIN_EXTENSIONS_DIR must be set.');
}
@@ -313,45 +261,15 @@ class ExtensionManager {
}
private get remoteDir() {
+ /* istanbul ignore next */
if (!process.env.COMPOSER_REMOTE_EXTENSIONS_DIR) {
throw new Error('COMPOSER_REMOTE_EXTENSIONS_DIR must be set.');
}
return process.env.COMPOSER_REMOTE_EXTENSIONS_DIR;
}
-
- private async updateSearchCache() {
- const timeout = new Date(new Date().getTime() - SEARCH_CACHE_TIMEOUT);
- if (!this._lastSearchTimestamp || this._lastSearchTimestamp < timeout) {
- const { stdout } = await npm('search', '', {
- '--json': '',
- '--searchopts': '"keywords:botframework-composer extension"',
- });
-
- try {
- const result = JSON.parse(stdout);
- if (Array.isArray(result)) {
- result.forEach((searchResult) => {
- const { name, keywords = [], version, description, links } = searchResult;
- if (keywords.includes('botframework-composer') && keywords.includes('extension')) {
- const url = links?.npm ?? '';
- this.searchCache.set(name, {
- id: name,
- version,
- description,
- keywords,
- url,
- });
- }
- });
- }
- } catch (err) {
- log('%O', err);
- }
- }
- }
}
-const manager = new ExtensionManager();
+const ExtensionManager = new ExtensionManagerImp();
-export { manager as ExtensionManager };
+export { ExtensionManager };
diff --git a/Composer/packages/extension/src/storage/extensionManifestStore.ts b/Composer/packages/extension/src/storage/extensionManifestStore.ts
index 4483d9555c..cb529c053e 100644
--- a/Composer/packages/extension/src/storage/extensionManifestStore.ts
+++ b/Composer/packages/extension/src/storage/extensionManifestStore.ts
@@ -2,9 +2,9 @@
// Licensed under the MIT License.
import { existsSync, writeJsonSync, readJsonSync } from 'fs-extra';
+import { ExtensionMap, ExtensionMetadata } from '@botframework-composer/types';
import logger from '../logger';
-import { ExtensionMap, ExtensionMetadata } from '../types/extension';
const log = logger.extend('plugins');
diff --git a/Composer/packages/extension/src/types/types.ts b/Composer/packages/extension/src/types/types.ts
deleted file mode 100644
index 888ce72c08..0000000000
--- a/Composer/packages/extension/src/types/types.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import { RequestHandler } from 'express-serve-static-core';
-import { JSONSchema7 } from 'json-schema';
-import { DialogSetting } from '@bfc/shared';
-import { IBotProject } from '@bfc/shared';
-
-// TODO: this will be possible when ifilestorage is in a shared module
-
-export interface PublishResult {
- message: string;
- comment?: string;
- log?: string;
- id?: string;
- time?: Date;
- endpointURL?: string;
- status?: number;
-}
-
-export interface PublishResponse {
- status: number;
- result: PublishResult;
-}
-
-export interface BotTemplate {
- id: string;
- name: string;
- description: string;
- /* absolute path */
- path: string;
- /* tags for further grouping and search secenario */
- tags?: string[];
- /* list of supported runtime versions */
- support?: string[];
-}
-
-// TODO: Add types for project, metadata
-export interface PublishPlugin {
- // methods plugins should support
- publish: (config: Config, project: IBotProject, metadata: any, user?: UserIdentity) => Promise;
- getStatus?: (config: Config, project: IBotProject, user?: UserIdentity) => Promise;
- getHistory?: (config: Config, project: IBotProject, user?: UserIdentity) => Promise;
- rollback?: (
- config: Config,
- project: IBotProject,
- rollbackToVersion: string,
- user?: UserIdentity
- ) => Promise;
-
- // other properties
- schema?: JSONSchema7;
- instructions?: string;
- customName?: string;
- customDescription?: string;
- hasView?: boolean;
- [key: string]: any;
-}
-
-export const DEFAULT_RUNTIME = 'csharp-azurewebapp';
-
-export interface RuntimeTemplate {
- /** method used to eject the runtime into a project. returns resulting path of runtime! */
- eject: (project: IBotProject, localDisk?: any, isReplace?: boolean) => Promise;
-
- /** build method used for local publish */
- build: (runtimePath: string, project: IBotProject) => Promise;
-
- run: (project: IBotProject, localDisk?: any) => Promise;
-
- /** build for deploy method */
- buildDeploy: (
- runtimePath: string,
- project: IBotProject,
- settings: DialogSetting,
- profileName: string
- ) => Promise;
-
- /** set skill manifest, different folder for different runtime */
- setSkillManifest: (
- dstRuntimePath: string,
- dstStorage: IFileStorage,
- srcManifestDir: string,
- srcStorage: IFileStorage,
- mode: string
- ) => Promise;
-
- /** path to code template */
- path: string;
-
- /** internal use key */
- key: string;
-
- /** name of runtime template to display in interface */
- name: string;
-
- /** command used to start runtime */
- startCommand: string;
-}
-
-// todo: is there some existing Passport user typedef?
-export interface UserIdentity {
- [key: string]: any;
-}
-
-export interface ExtensionCollection {
- storage: {
- [key: string]: any;
- };
- publish: {
- [key: string]: {
- plugin: {
- name: string;
- description: string;
- /** (Optional instructions displayed in the UI) */
- instructions?: string;
- /** (Optional) Schema for publishing configuration. */
- schema?: JSONSchema7;
- /** Whether or not the plugin has custom UI to host in the publish surface */
- hasView?: boolean;
- };
- methods: PublishPlugin;
- };
- };
- authentication: {
- middleware?: RequestHandler;
- serializeUser?: (user: any, next: any) => void;
- deserializeUser?: (user: any, next: any) => void;
- allowedUrls: string[];
- [key: string]: any;
- };
- runtimeTemplates: RuntimeTemplate[];
- botTemplates: BotTemplate[];
- baseTemplates: BotTemplate[];
-}
-
-export interface FileInfo {
- name: string;
- content: string;
- path: string;
- relativePath: string;
- lastModified: string;
-}
-
-interface IFileStorage {
- stat(path: string): Promise;
- readFile(path: string): Promise;
- readDir(path: string): Promise;
- exists(path: string): Promise;
- writeFile(path: string, content: any): Promise;
- removeFile(path: string): Promise;
- mkDir(path: string, options?: MakeDirectoryOptions): Promise;
- rmDir(path: string): Promise;
- rmrfDir(path: string): Promise;
- glob(pattern: string | string[], path: string): Promise;
- copyFile(src: string, dest: string): Promise;
- rename(oldPath: string, newPath: string): Promise;
- zip(source: string, cb: any): unknown;
-}
-
-interface Stat {
- isDir: boolean;
- isFile: boolean;
- isWritable: boolean;
- lastModified: string;
- size: string;
-}
-
-interface MakeDirectoryOptions {
- recursive?: boolean;
-}
diff --git a/Composer/packages/extension/src/utils/__tests__/isSubdirectory.test.ts b/Composer/packages/extension/src/utils/__tests__/isSubdirectory.test.ts
new file mode 100644
index 0000000000..39139c6195
--- /dev/null
+++ b/Composer/packages/extension/src/utils/__tests__/isSubdirectory.test.ts
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { isSubdirectory } from '../isSubdirectory';
+
+const testCases = [
+ ['/foo/bar', '/foo/bar', false],
+ ['/foo/bar', '/bar/foo', false],
+ ['/foo/bar', '/foo/bar/../baz', false],
+ ['/foo/bar', '/foo/bar/../', false],
+ ['/foo/bar', '/foo/bar/baz/../', false],
+ ['/foo/bar', '/foo/baz/bar/', false],
+ ['/foo/bar', '/foo', false],
+ ['/foo/bar', '/', false],
+
+ ['/foo/bar', '/foo/bar/baz', true],
+ ['/foo/bar', '/foo/bar/baz/../qux', true],
+ ['/foo/bar', '/foo/bar/baz/qux/foo/../../bar', true],
+];
+
+if (process.platform === 'win32') {
+ testCases.push(['C:\\Foo', 'C:\\Foo\\Bar', true], ['C:\\Foo', 'C:\\Bar', false], ['C:\\Foo', 'D:\\Foo\\Bar', false]);
+}
+
+it.each(testCases)('isSubDir(%s, %s) -> %s', (parent, dir, expected) => {
+ expect(isSubdirectory(parent as string, dir as string)).toBe(expected);
+});
diff --git a/Composer/packages/extension/src/utils/__tests__/npm.test.ts b/Composer/packages/extension/src/utils/__tests__/npm.test.ts
new file mode 100644
index 0000000000..fb9d55f6bd
--- /dev/null
+++ b/Composer/packages/extension/src/utils/__tests__/npm.test.ts
@@ -0,0 +1,145 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+/* eslint-disable no-underscore-dangle */
+import { Readable, Writable } from 'stream';
+
+import fetch from 'node-fetch';
+import { mkdir, remove } from 'fs-extra';
+import tar from 'tar';
+
+import { search, downloadPackage } from '../npm';
+
+class MockBody extends Readable {
+ _read() {
+ setTimeout(() => {
+ this.emit('end');
+ }, 1);
+ }
+}
+
+class MockExtractor extends Writable {
+ _write(chunk: any, enc: string, next) {
+ next();
+ }
+}
+
+jest.mock('node-fetch', () => jest.fn());
+jest.mock('fs-extra', () => ({
+ mkdir: jest.fn(),
+ remove: jest.fn(),
+}));
+jest.mock('tar', () => ({
+ extract: jest.fn(),
+}));
+
+describe('search', () => {
+ const data = [
+ {
+ name: 'package1',
+ description: 'package1 description',
+ version: '0.0.1',
+ keywords: ['foo'],
+ links: { npm: 'package1 npm link' },
+ },
+ { name: 'package2', description: 'package2 description', version: '0.0.2' },
+ ];
+
+ beforeEach(() => {
+ const mockRes = {
+ json: jest.fn(),
+ };
+ ((fetch as unknown) as jest.Mock).mockImplementation(() => mockRes);
+ mockRes.json.mockResolvedValue({ objects: data.map((d) => ({ package: d })) });
+ });
+
+ it('returns results of npm search', async () => {
+ const results = await search('my query');
+
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining('registry.npmjs.org/-/v1/search?text=my+query+keywords:botframework-composer')
+ );
+
+ expect(results).toEqual([
+ {
+ id: 'package1',
+ description: 'package1 description',
+ version: '0.0.1',
+ keywords: ['foo'],
+ url: 'package1 npm link',
+ },
+ {
+ id: 'package2',
+ description: 'package2 description',
+ version: '0.0.2',
+ keywords: [],
+ url: '',
+ },
+ ]);
+ });
+});
+
+describe('downloadPackage', () => {
+ const packageName = 'some-package';
+
+ const packageMetadata = {
+ 'dist-tags': {
+ latest: '1.0.1',
+ },
+ versions: {
+ '1.0.0': {
+ dist: {
+ tarball: 'tarball url 1.0.0',
+ },
+ },
+ '1.0.1': {
+ dist: {
+ tarball: 'tarball url 1.0.1',
+ },
+ },
+ },
+ };
+
+ beforeEach(() => {
+ const mockRes = {
+ json: jest.fn(),
+ body: new MockBody(),
+ };
+ ((fetch as unknown) as jest.Mock).mockImplementation(() => mockRes);
+ mockRes.json.mockResolvedValue(packageMetadata);
+
+ const extractor = new MockExtractor();
+ (tar.extract as jest.Mock).mockReturnValue(extractor);
+
+ mockRes.body.push('some data');
+ });
+
+ it('gets package metadata from npm', async () => {
+ await downloadPackage(packageName, 'latest', '/some/path');
+
+ expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/some-package');
+ });
+
+ it.each([
+ ['latest', '1.0.1'],
+ ['1.0.0', '1.0.0'],
+ ])('can resolve %s to %s', async (tagOrVersion, expectedVersion) => {
+ await downloadPackage(packageName, tagOrVersion, '/some/path');
+ expect(fetch).toHaveBeenCalledWith(`tarball url ${expectedVersion}`);
+ });
+
+ it('throws when version not found', () => {
+ expect(downloadPackage(packageName, '2.0.0', '/some/path')).rejects.toThrow(/Could not find/);
+ });
+
+ it('prepares the destination directory', async () => {
+ await downloadPackage(packageName, 'latest', '/some/path');
+ expect(remove).toHaveBeenCalledWith('/some/path');
+ expect(mkdir).toHaveBeenCalledWith('/some/path');
+ });
+
+ it('extracts the tarball into destination', async () => {
+ await downloadPackage(packageName, 'latest', '/some/path');
+
+ expect(tar.extract).toHaveBeenCalledWith({ strip: 1, C: '/some/path', strict: true });
+ });
+});
diff --git a/Composer/packages/extension/src/utils/isSubdirectory.ts b/Composer/packages/extension/src/utils/isSubdirectory.ts
new file mode 100644
index 0000000000..eb1c4f5c08
--- /dev/null
+++ b/Composer/packages/extension/src/utils/isSubdirectory.ts
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import path from 'path';
+
+/**
+ * Returns true if dir is a descendant of parent, false otherwise.
+ * @param parent parent directory
+ * @param dir directory to test
+ */
+export function isSubdirectory(parent, dir) {
+ const relative = path.relative(parent, dir);
+ return Boolean(relative && !relative.startsWith('..') && !path.isAbsolute(relative));
+}
diff --git a/Composer/packages/extension/src/utils/npm.ts b/Composer/packages/extension/src/utils/npm.ts
index 60685b7c98..3dd005ec2f 100644
--- a/Composer/packages/extension/src/utils/npm.ts
+++ b/Composer/packages/extension/src/utils/npm.ts
@@ -1,65 +1,80 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { spawn, SpawnOptionsWithoutStdio } from 'child_process';
+import { promisify } from 'util';
+
+import { mkdir, remove } from 'fs-extra';
+import fetch from 'node-fetch';
+import tar from 'tar';
+import { ExtensionSearchResult } from '@botframework-composer/types';
import logger from '../logger';
+const streamPipeline = promisify(require('stream').pipeline);
+
const log = logger.extend('npm');
-type NpmOutput = {
- stdout: string;
- stderr: string;
- code: number;
-};
-type NpmCommand = 'install' | 'uninstall' | 'search' | 'link';
-type NpmOptions = {
- [key: string]: string;
-};
-
-function processOptions(opts: NpmOptions) {
- return Object.entries({ '--no-fund': '', '--no-audit': '', '--quiet': '', ...opts }).map(([flag, value]) => {
- return value ? `${flag}=${value}` : flag;
- });
-}
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function search(query = ''): Promise {
+ try {
+ log('Searching for %s', query);
+ const queryString = query.replace(' ', '+');
+ const res = await fetch(
+ `https://registry.npmjs.org/-/v1/search?text=${queryString}+keywords:botframework-composer&size=100&from=0&quality=0.65&popularity=0.98&maintenance=0.5`
+ );
+ const data = await res.json();
-/**
- * Executes npm commands that include user input safely
- * @param `command` npm command to execute.
- * @param `args` cli arguments
- * @param `opts` cli flags
- * @param `spawnOpts` options to pass to spawn command
- * @returns Object with stdout, stderr, and exit code from command
- */
-export async function npm(
- command: NpmCommand,
- args: string,
- opts: NpmOptions = {},
- spawnOpts: SpawnOptionsWithoutStdio = {}
-): Promise {
- return new Promise((resolve, reject) => {
- const cmdOptions = processOptions(opts);
- const spawnArgs = [command, ...cmdOptions, args];
- log('npm %s', spawnArgs.join(' '));
- let stdout = '';
- let stderr = '';
-
- const proc = spawn('npm', spawnArgs, { ...spawnOpts, shell: process.platform === 'win32' });
-
- proc.stdout.on('data', (data) => {
- stdout += data;
- });
+ log('Got %d result(s).', data.objects?.length ?? 0);
- proc.stderr.on('data', (data) => {
- stderr += data;
- });
+ return data.objects.map((result) => {
+ const { name, version, description = '', keywords = [], links = {} } = result.package;
- proc.on('close', (code) => {
- if (code > 0) {
- reject({ stdout, stderr, code });
- } else {
- resolve({ stdout, stderr, code });
- }
+ return {
+ id: name,
+ version,
+ description,
+ keywords,
+ url: links.npm ?? '',
+ } as ExtensionSearchResult;
});
+ } catch (err) {
+ log('%O', err);
+
+ return [];
+ }
+}
+
+export async function downloadPackage(name: string, versionOrTag: string, destination: string) {
+ const dLog = log.extend(name);
+ dLog('Starting download.');
+ const res = await fetch(`https://registry.npmjs.org/${name}`);
+ const metadata = await res.json();
+ const targetVersion = metadata['dist-tags'][versionOrTag] ?? versionOrTag;
+
+ dLog('Resolved version %s to %s', versionOrTag, targetVersion);
+
+ const tarballUrl = metadata.versions[targetVersion]?.dist.tarball;
+
+ if (!tarballUrl) {
+ dLog('Unable to get tarball url.');
+ throw new Error(`Could not find ${name}@${targetVersion} on npm.`);
+ }
+
+ dLog('Fetching tarball.');
+ const tarball = (await fetch(tarballUrl)).body;
+ // clean up previous version
+ // lgtm[js/path-injection]
+ await remove(destination);
+ // lgtm[js/path-injection]
+ await mkdir(destination);
+
+ const extractor = tar.extract({
+ strip: 1,
+ C: destination,
+ strict: true,
});
+
+ dLog('Extracting tarball.');
+ await streamPipeline(tarball, extractor);
+ dLog('Done downloading.');
}
diff --git a/Composer/packages/form-dialogs/.eslintrc.js b/Composer/packages/form-dialogs/.eslintrc.js
new file mode 100644
index 0000000000..17b57b8148
--- /dev/null
+++ b/Composer/packages/form-dialogs/.eslintrc.js
@@ -0,0 +1,7 @@
+module.exports = {
+ extends: ['../../.eslintrc.react.js'],
+ parserOptions: {
+ project: './tsconfig.lib.json',
+ tsconfigRootDir: __dirname,
+ },
+};
diff --git a/Composer/packages/form-dialogs/.gitignore b/Composer/packages/form-dialogs/.gitignore
new file mode 100644
index 0000000000..d6bff14868
--- /dev/null
+++ b/Composer/packages/form-dialogs/.gitignore
@@ -0,0 +1,9 @@
+node_modules
+dist
+lib
+
+packages
+bin
+obj
+
+**/*.log
diff --git a/Composer/packages/form-dialogs/package.json b/Composer/packages/form-dialogs/package.json
new file mode 100644
index 0000000000..d477d07517
--- /dev/null
+++ b/Composer/packages/form-dialogs/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "@bfc/form-dialogs",
+ "version": "0.0.1",
+ "license": "MIT",
+ "author": "Microsoft",
+ "description": "Form Dialog components for Bot Framework Composer",
+ "main": "./lib/index.js",
+ "typings": "./lib/VisualSchemaEditor.d.ts",
+ "files": [
+ "lib/*index*",
+ "lib/FormDialogSchemaEditor.d.ts"
+ ],
+ "scripts": {
+ "clean": "rimraf lib dist",
+ "start": "node tools/devServer.js",
+ "build": "rimraf lib && webpack --config webpack.lib.config.js --mode production",
+ "lint": "eslint --quiet ./src",
+ "lint:fix": "yarn lint --fix"
+ },
+ "dependencies": {
+ "react-beautiful-dnd": "^13.0.0"
+ },
+ "peerDependencies": {
+ "recoil": "^0.0.13",
+ "react": "16.13.1",
+ "react-dom": "16.13.1",
+ "lodash": "^4.17.19",
+ "format-message": "^6.2.3",
+ "office-ui-fabric-react": "^7.121.11",
+ "@emotion/core": "^10.0.27",
+ "@emotion/styled": "^10.0.27",
+ "@uifabric/fluent-theme": "^7.1.107",
+ "@uifabric/react-hooks": "^7.4.12"
+ },
+ "devDependencies": {
+ "@types/react": "^16.8.18",
+ "@types/react-beautiful-dnd": "^13.0.0",
+ "@types/react-dom": "^16.8.4",
+ "@types/lodash": "^4.14.146",
+ "@types/recoil": "^0.0.1",
+ "cross-env": "^5.2.0",
+ "css-loader": "^2.1.1",
+ "eslint": "^7.5.0",
+ "eslint-loader": "4.0.0",
+ "express": "^4.14.0",
+ "html-webpack-plugin": "^3.2.0",
+ "mini-css-extract-plugin": "^0.6.0",
+ "optimize-css-assets-webpack-plugin": "^5.0.1",
+ "peer-deps-externals-webpack-plugin": "^1.0.4",
+ "react-dev-utils": "7.0.3",
+ "rimraf": "^2.6.3",
+ "source-map-loader": "^0.2.4",
+ "style-loader": "^0.23.1",
+ "ts-loader": "^6.0.1",
+ "typescript": "^3.5.3",
+ "webpack": "^4.32.0",
+ "webpack-cli": "^3.3.2",
+ "webpack-dev-middleware": "^3.7.0",
+ "webpack-hot-middleware": "^2.25.0",
+ "webpack-notifier": "^1.7.0"
+ }
+}
diff --git a/Composer/packages/form-dialogs/src/FormDialogSchemaEditor.tsx b/Composer/packages/form-dialogs/src/FormDialogSchemaEditor.tsx
new file mode 100644
index 0000000000..5bc6430821
--- /dev/null
+++ b/Composer/packages/form-dialogs/src/FormDialogSchemaEditor.tsx
@@ -0,0 +1,103 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import * as React from 'react';
+import { useRecoilValue } from 'recoil';
+// eslint-disable-next-line @typescript-eslint/camelcase
+import { RecoilRoot, useRecoilTransactionObserver_UNSTABLE } from 'recoil';
+import { formDialogSchemaJsonSelector, trackedAtomsSelector } from 'src/atoms/appState';
+import { useHandlers } from 'src/atoms/handlers';
+import { FormDialogPropertiesEditor } from 'src/components/FormDialogPropertiesEditor';
+import { UndoRoot } from 'src/undo/UndoRoot';
+
+export type FormDialogSchemaEditorProps = {
+ /**
+ * Unique id for the visual editor.
+ */
+ editorId: string;
+ /**
+ * Initial json schema content.
+ */
+ schema: { id: string; content: string };
+ /**
+ * Enables the undo/redo.
+ */
+ allowUndo?: boolean;
+ /**
+ * Form dialog schema file extension.
+ */
+ schemaExtension?: string;
+ /**
+ * Record of available schema templates.
+ */
+ templates?: string[];
+ /**
+ * Indicates of caller is running generation logic.
+ */
+ isGenerating?: boolean;
+ /**
+ * Callback for when the json schema update is updated.
+ */
+ onSchemaUpdated: (id: string, content: string) => void;
+ /**
+ * Callback for generating dialog using current valid form dialog schema.
+ */
+ onGenerateDialog: (schemaId: string) => void;
+};
+
+const InternalFormDialogSchemaEditor = React.memo((props: FormDialogSchemaEditorProps) => {
+ const {
+ editorId,
+ schema,
+ templates = [],
+ schemaExtension = '.schema',
+ isGenerating = false,
+ onSchemaUpdated,
+ onGenerateDialog,
+ allowUndo = false,
+ } = props;
+
+ const trackedAtoms = useRecoilValue(trackedAtomsSelector);
+ const { setTemplates, reset, importSchemaString } = useHandlers();
+
+ React.useEffect(() => {
+ setTemplates({ templates });
+ }, [templates]);
+
+ React.useEffect(() => {
+ importSchemaString(schema);
+ }, [editorId]);
+
+ const startOver = React.useCallback(() => {
+ reset({ name: editorId });
+ }, [reset, editorId]);
+
+ useRecoilTransactionObserver_UNSTABLE(async ({ snapshot, previousSnapshot }) => {
+ const content = await snapshot.getPromise(formDialogSchemaJsonSelector);
+ const prevContent = await previousSnapshot.getPromise(formDialogSchemaJsonSelector);
+ if (content !== prevContent) {
+ onSchemaUpdated(schema.id, content);
+ }
+ });
+
+ return (
+
+
+
+ );
+});
+
+export const FormDialogSchemaEditor = (props: FormDialogSchemaEditorProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/Composer/packages/form-dialogs/src/atoms/appState.ts b/Composer/packages/form-dialogs/src/atoms/appState.ts
new file mode 100644
index 0000000000..e99bc898b3
--- /dev/null
+++ b/Composer/packages/form-dialogs/src/atoms/appState.ts
@@ -0,0 +1,163 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/* eslint-disable @typescript-eslint/consistent-type-assertions */
+
+import { atom, atomFamily, RecoilState, selector, selectorFamily } from 'recoil';
+import { FormDialogProperty, FormDialogSchema } from 'src/atoms/types';
+import { spreadSchemaPropertyStore, validateSchemaPropertyStore } from 'src/atoms/utils';
+
+const schemaDraftUrl = 'http://json-schema.org/draft-07/schema';
+
+/**
+ * This atom represents a form dialog schema.
+ */
+export const formDialogSchemaAtom = atom({
+ key: 'FormDialogSchemaAtom',
+ default: {
+ id: '',
+ name: '',
+ requiredPropertyIds: [],
+ optionalPropertyIds: [],
+ },
+});
+
+/**
+ * This atom family represent a form dialog schema property.
+ */
+export const formDialogPropertyAtom = atomFamily({
+ key: 'FormDialogPropertyAtom',
+ default: (id) => ({
+ id,
+ name: '',
+ kind: 'string',
+ payload: { kind: 'string' },
+ required: true,
+ array: false,
+ examples: [],
+ }),
+});
+
+/**
+ * This selector separates required and optional properties within a form dialog schema.
+ */
+export const allFormDialogPropertyIdsSelector = selector({
+ key: 'RequiredFormDialogPropertyIdsSelector',
+ get: ({ get }) => {
+ const { requiredPropertyIds, optionalPropertyIds } = get(formDialogSchemaAtom);
+ return [...requiredPropertyIds, ...optionalPropertyIds];
+ },
+});
+
+/**
+ * This selector computes the names of all properties within a form dialog schema.
+ */
+export const formDialogSchemaPropertyNamesSelector = selector({
+ key: 'FormDialogSchemaPropertyNamesSelector',
+ get: ({ get }) => {
+ const propertyIds = get(allFormDialogPropertyIdsSelector);
+ return propertyIds.map((pId) => get(formDialogPropertyAtom(pId)).name);
+ },
+});
+
+/**
+ * This selector computes the json representing a form dialog property.
+ */
+export const formDialogPropertyJsonSelector = selectorFamily({
+ key: 'FormDialogPropertyJsonSelector',
+ get: (id) => ({ get }) => {
+ const schemaPropertyStore = get(formDialogPropertyAtom(id));
+ return spreadSchemaPropertyStore(schemaPropertyStore);
+ },
+});
+
+/**
+ * This selector computes if a form dialog property is valid.
+ */
+export const formDialogPropertyValidSelector = selectorFamily({
+ key: 'FormDialogPropertyValidSelector',
+ get: (id) => ({ get }) => {
+ const schemaPropertyStore = get(formDialogPropertyAtom(id));
+ return validateSchemaPropertyStore(schemaPropertyStore);
+ },
+});
+
+/**
+ * This selector computes if a form dialog schema is valid.
+ */
+export const formDialogSchemaValidSelector = selector({
+ key: 'FormDialogSchemaValidSelector',
+ get: ({ get }) => {
+ const propertyIds = get(allFormDialogPropertyIdsSelector);
+ return propertyIds.every((pId) => get(formDialogPropertyValidSelector(pId)));
+ },
+});
+
+/**
+ * This selector computes the json representing a form dialog schema.
+ */
+export const formDialogSchemaJsonSelector = selector({
+ key: 'FormDialogSchemaJsonSelector',
+ get: ({ get }) => {
+ const propertyIds = get(allFormDialogPropertyIdsSelector);
+ const schemaPropertyStores = propertyIds.map((pId) => get(formDialogPropertyAtom(pId)));
+
+ let jsonObject: object = {
+ schema: schemaDraftUrl,
+ type: 'object',
+ $requires: ['standard.schema'],
+ };
+
+ if (schemaPropertyStores.length) {
+ jsonObject = {
+ ...jsonObject,
+ properties: propertyIds.reduce>((acc, propId, idx) => {
+ const property = schemaPropertyStores[idx];
+ acc[property.name] = get(formDialogPropertyJsonSelector(propId));
+ return acc;
+ }, >{}),
+ };
+ }
+
+ const required = schemaPropertyStores.filter((property) => property.required).map((property) => property.name);
+ const examples = schemaPropertyStores.reduce>((acc, property) => {
+ if (property.examples?.length) {
+ acc[property.name] = property.examples;
+ }
+ return acc;
+ }, >{});
+
+ if (required.length) {
+ jsonObject = { ...jsonObject, required };
+ }
+
+ if (Object.keys(examples)?.length) {
+ jsonObject = { ...jsonObject, $examples: examples };
+ }
+
+ return JSON.stringify(jsonObject, null, 2);
+ },
+});
+
+/**
+ * This atom represents the list of the available templates.
+ */
+export const formDialogTemplatesAtom = atom({
+ key: 'FormDialogTemplatesAtom',
+ default: [],
+});
+
+export const activePropertyIdAtom = atom({
+ key: 'ActivePropertyIdAtom',
+ default: '',
+});
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const trackedAtomsSelector = selector[]>({
+ key: 'TrackedAtoms',
+ get: ({ get }) => {
+ const propIds = get(allFormDialogPropertyIdsSelector) || [];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return [formDialogSchemaAtom, activePropertyIdAtom, ...propIds.map((pId) => formDialogPropertyAtom(pId))];
+ },
+});
diff --git a/Composer/packages/form-dialogs/src/atoms/handlers.ts b/Composer/packages/form-dialogs/src/atoms/handlers.ts
new file mode 100644
index 0000000000..ccdb768be0
--- /dev/null
+++ b/Composer/packages/form-dialogs/src/atoms/handlers.ts
@@ -0,0 +1,244 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/* eslint-disable react-hooks/rules-of-hooks */
+
+import * as React from 'react';
+import { useRecoilCallback } from 'recoil';
+import {
+ activePropertyIdAtom,
+ allFormDialogPropertyIdsSelector,
+ formDialogPropertyAtom,
+ formDialogSchemaAtom,
+ formDialogSchemaPropertyNamesSelector,
+ formDialogTemplatesAtom,
+} from 'src/atoms/appState';
+import { FormDialogPropertyKind, FormDialogPropertyPayload, PropertyRequiredKind } from 'src/atoms/types';
+import { createSchemaStoreFromJson, getDefaultPayload, getDuplicateName } from 'src/atoms/utils';
+import { generateId } from 'src/utils/base';
+import { readFileContent } from 'src/utils/file';
+
+const getHandlers = () => {
+ const importSchemaString = useRecoilCallback(({ set }) => ({ id, content }: { id: string; content: string }) => {
+ const schema = createSchemaStoreFromJson(id, content);
+
+ set(formDialogSchemaAtom, {
+ id: schema.name,
+ name: schema.name,
+ requiredPropertyIds: schema.properties.filter((p) => p.required).map((p) => p.id),
+ optionalPropertyIds: schema.properties.filter((p) => !p.required).map((p) => p.id),
+ });
+ schema.properties.forEach((property) => set(formDialogPropertyAtom(property.id), property));
+ });
+
+ const importSchema = useRecoilCallback(() => async ({ id, file }: { id: string; file: File }) => {
+ const content = await readFileContent(file);
+ importSchemaString({ id, content });
+ });
+
+ const activatePropertyId = useRecoilCallback(({ set }) => ({ id }: { id: string }) => {
+ set(activePropertyIdAtom, id);
+ });
+
+ const addProperty = useRecoilCallback(({ set }) => () => {
+ const newPropertyId = generateId();
+ set(formDialogSchemaAtom, (currentSchema) => {
+ return { ...currentSchema, requiredPropertyIds: [newPropertyId, ...currentSchema.requiredPropertyIds] };
+ });
+ set(formDialogPropertyAtom(newPropertyId), {
+ id: newPropertyId,
+ kind: 'string',
+ name: '',
+ payload: { kind: 'string' },
+ examples: [],
+ required: true,
+ array: false,
+ });
+ activatePropertyId({ id: newPropertyId });
+ });
+
+ const changePropertyKind = useRecoilCallback(
+ ({ set }) => ({
+ id,
+ kind,
+ payload,
+ }: {
+ id: string;
+ kind: FormDialogPropertyKind;
+ payload: FormDialogPropertyPayload;
+ }) => {
+ set(formDialogPropertyAtom(id), (currentProperty) => {
+ return { ...currentProperty, kind, examples: [], payload: payload || getDefaultPayload(kind) };
+ });
+ }
+ );
+
+ const changePropertyRequired = useRecoilCallback(
+ ({ set }) => ({ id, required }: { id: string; required: boolean }) => {
+ set(formDialogPropertyAtom(id), (currentProperty) => {
+ return { ...currentProperty, required };
+ });
+ }
+ );
+
+ const changePropertyName = useRecoilCallback(({ set }) => ({ id, name }: { id: string; name: string }) => {
+ set(formDialogPropertyAtom(id), (currentProperty) => {
+ return { ...currentProperty, name };
+ });
+ });
+
+ const changePropertyPayload = useRecoilCallback(
+ ({ set }) => ({ id, payload }: { id: string; payload: FormDialogPropertyPayload }) => {
+ set(formDialogPropertyAtom(id), (currentProperty) => {
+ return { ...currentProperty, payload };
+ });
+ }
+ );
+
+ const changePropertyArray = useRecoilCallback(({ set }) => ({ id, isArray }: { id: string; isArray: boolean }) => {
+ set(formDialogPropertyAtom(id), (currentProperty) => {
+ return { ...currentProperty, array: isArray };
+ });
+ });
+
+ const moveProperty = useRecoilCallback(
+ ({ set }) => ({
+ id,
+ source,
+ destination,
+ fromIndex,
+ toIndex,
+ }: {
+ id: string;
+ source: PropertyRequiredKind;
+ destination: PropertyRequiredKind;
+ fromIndex: number;
+ toIndex: number;
+ }) => {
+ const toggleRequired = source !== destination;
+
+ if (toggleRequired) {
+ changePropertyRequired({ id, required: source === 'optional' });
+ }
+
+ set(formDialogSchemaAtom, (currentSchema) => {
+ if (toggleRequired) {
+ //Move between two lists
+ const requiredPropertyIds = currentSchema.requiredPropertyIds.slice();
+ const optionalPropertyIds = currentSchema.optionalPropertyIds.slice();
+
+ if (source === 'required') {
+ requiredPropertyIds.splice(fromIndex, 1);
+ optionalPropertyIds.splice(toIndex, 0, id);
+ } else {
+ optionalPropertyIds.splice(fromIndex, 1);
+ requiredPropertyIds.splice(toIndex, 0, id);
+ }
+
+ return { ...currentSchema, requiredPropertyIds, optionalPropertyIds };
+ } else {
+ // Move within a list
+ const propertyIds = (source === 'required'
+ ? currentSchema.requiredPropertyIds
+ : currentSchema.optionalPropertyIds
+ ).slice();
+
+ propertyIds.splice(fromIndex, 1);
+ propertyIds.splice(toIndex, 0, id);
+
+ return source === 'required'
+ ? { ...currentSchema, requiredPropertyIds: propertyIds }
+ : { ...currentSchema, optionalPropertyIds: propertyIds };
+ }
+ });
+ }
+ );
+
+ const removeProperty = useRecoilCallback(({ set, reset, snapshot }) => async ({ id }: { id: string }) => {
+ const property = await snapshot.getPromise(formDialogPropertyAtom(id));
+
+ set(formDialogSchemaAtom, (currentSchema) => ({
+ ...currentSchema,
+ requiredPropertyIds: property.required
+ ? currentSchema.requiredPropertyIds.filter((pId) => pId !== id)
+ : currentSchema.requiredPropertyIds,
+ optionalPropertyIds: !property.required
+ ? currentSchema.optionalPropertyIds.filter((pId) => pId !== id)
+ : currentSchema.optionalPropertyIds,
+ }));
+
+ reset(formDialogPropertyAtom(id));
+
+ const activePropertyId = await snapshot.getPromise(activePropertyIdAtom);
+
+ if (activePropertyId === id) {
+ activatePropertyId({ id: '' });
+ }
+ });
+
+ const duplicateProperty = useRecoilCallback(({ set, snapshot }) => async ({ id }: { id: string }) => {
+ const newId = generateId();
+
+ const property = await snapshot.getPromise(formDialogPropertyAtom(id));
+ const propertyNames = await snapshot.getPromise(formDialogSchemaPropertyNamesSelector);
+ const name = getDuplicateName(property.name, propertyNames);
+
+ set(formDialogSchemaAtom, (currentSchema) => {
+ const propertyIds = (property.required
+ ? currentSchema.requiredPropertyIds
+ : currentSchema.optionalPropertyIds
+ ).slice();
+
+ if (property.required) {
+ return { ...currentSchema, requiredPropertyIds: [...propertyIds, newId] };
+ } else {
+ return { ...currentSchema, optionalPropertyIds: [...propertyIds, newId] };
+ }
+ });
+
+ set(formDialogPropertyAtom(newId), { ...property, name, id: newId });
+ activatePropertyId({ id: newId });
+ });
+
+ const changePropertyExamples = useRecoilCallback(
+ ({ set }) => ({ id, examples }: { id: string; examples: readonly string[] }) => {
+ set(formDialogPropertyAtom(id), (currentProperty) => {
+ return { ...currentProperty, examples: examples.slice() };
+ });
+ }
+ );
+
+ const reset = useRecoilCallback(({ reset, set, snapshot }) => async ({ name }: { name: string }) => {
+ const propertyIds = await snapshot.getPromise(allFormDialogPropertyIdsSelector);
+ propertyIds.forEach((pId) => reset(formDialogPropertyAtom(pId)));
+ set(formDialogSchemaAtom, { id: name, name, requiredPropertyIds: [], optionalPropertyIds: [] });
+ });
+
+ const setTemplates = useRecoilCallback(({ set }) => ({ templates }: { templates: string[] }) => {
+ set(formDialogTemplatesAtom, templates);
+ });
+
+ return {
+ activatePropertyId,
+ addProperty,
+ changePropertyExamples,
+ changePropertyKind,
+ changePropertyName,
+ changePropertyPayload,
+ changePropertyArray,
+ reset,
+ setTemplates,
+ removeProperty,
+ duplicateProperty,
+ moveProperty,
+ importSchema,
+ importSchemaString,
+ };
+};
+
+type Handler = ReturnType;
+
+export const useHandlers = () => {
+ const handlerFuncsRef = React.useRef(getHandlers());
+ return { ...handlerFuncsRef.current };
+};
diff --git a/Composer/packages/form-dialogs/src/atoms/types.ts b/Composer/packages/form-dialogs/src/atoms/types.ts
new file mode 100644
index 0000000000..d819057ab8
--- /dev/null
+++ b/Composer/packages/form-dialogs/src/atoms/types.ts
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+export type PropertyRequiredKind = 'required' | 'optional';
+
+export type FormDialogPropertyKind = 'ref' | 'number' | 'integer' | 'string' | 'array';
+
+export type RefPropertyPayload = TypedPropertyPayload & {
+ ref: string;
+};
+
+export type TypedPropertyPayload = {
+ kind: FormDialogPropertyKind;
+ entities?: string[];
+};
+
+const builtInFormats = ['date-time', 'date', 'time', 'email', 'uri', 'iri'] as const;
+
+export type BuiltInStringFormat = typeof builtInFormats[number];
+
+export type StringFormatItem = { displayName: string; value: BuiltInStringFormat };
+
+export const builtInStringFormats: readonly StringFormatItem[] = [
+ { displayName: 'date-time', value: 'date-time' },
+ { displayName: 'date', value: 'date' },
+ { displayName: 'time', value: 'time' },
+ { displayName: 'email', value: 'email' },
+ { displayName: 'uri', value: 'uri' },
+ { displayName: 'iri', value: 'iri' },
+];
+
+export type StringPropertyPayload = TypedPropertyPayload & {
+ kind: 'string';
+ enums?: string[];
+ pattern?: string;
+ format?: BuiltInStringFormat;
+};
+
+export type NumberPropertyPayload = TypedPropertyPayload & {
+ kind: 'number';
+ minimum: number;
+ maximum: number;
+};
+
+export type IntegerPropertyPayload = TypedPropertyPayload & {
+ kind: 'integer';
+ minimum: number;
+ maximum: number;
+};
+
+export type ArrayPropertyPayload = Pick & {
+ kind: 'array';
+ items:
+ | (IntegerPropertyPayload & { maxItems?: number })
+ | (NumberPropertyPayload & { maxItems?: number })
+ | (StringPropertyPayload & { maxItems?: number })
+ | RefPropertyPayload;
+};
+
+export type FormDialogPropertyPayload =
+ | RefPropertyPayload
+ | StringPropertyPayload
+ | NumberPropertyPayload
+ | IntegerPropertyPayload
+ | ArrayPropertyPayload;
+
+export type FormDialogProperty = {
+ id: string;
+ kind: FormDialogPropertyKind;
+ name: string;
+ payload: FormDialogPropertyPayload;
+ required: boolean;
+ array: boolean;
+ examples: string[];
+};
+
+export type FormDialogSchema = {
+ id: string;
+ name: string;
+ requiredPropertyIds: string[];
+ optionalPropertyIds: string[];
+};
diff --git a/Composer/packages/form-dialogs/src/atoms/utils.ts b/Composer/packages/form-dialogs/src/atoms/utils.ts
new file mode 100644
index 0000000000..168f359861
--- /dev/null
+++ b/Composer/packages/form-dialogs/src/atoms/utils.ts
@@ -0,0 +1,289 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/* eslint-disable @typescript-eslint/consistent-type-assertions */
+
+import formatMessage from 'format-message';
+import {
+ builtInStringFormats,
+ FormDialogProperty,
+ FormDialogPropertyPayload,
+ IntegerPropertyPayload,
+ NumberPropertyPayload,
+ RefPropertyPayload,
+ FormDialogPropertyKind,
+ StringPropertyPayload,
+ TypedPropertyPayload,
+} from 'src/atoms/types';
+import { generateId } from 'src/utils/base';
+
+export const getDefaultPayload = (kind: FormDialogPropertyKind) => {
+ switch (kind) {
+ case 'ref':
+ return { kind: 'ref' };
+ case 'string':
+ return { kind: 'string', entities: [] };
+ case 'number':
+ return { kind: 'number', entities: [] };
+ case 'integer':
+ return { kind: 'integer', entities: [] };
+ default:
+ throw new Error(`Property type: "${kind}" is not supported!`);
+ }
+};
+
+const $refToRef = ($ref: string) => {
+ const [, ref] = $ref.match('template:(.*)');
+ return ref;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const retrievePayload = (kind: FormDialogPropertyKind, payloadData: any, array = false): FormDialogPropertyPayload => {
+ if (array) {
+ return retrievePayload(payloadData.items.type || 'ref', payloadData.items);
+ }
+ switch (kind) {
+ case 'ref':
+ return { ref: $refToRef(payloadData.$ref) };
+ case 'string':
+ return { kind: 'string', entities: payloadData.$entities, enums: payloadData.enum };
+ case 'number':
+ return {
+ kind: 'number',
+ minimum: payloadData.minimum,
+ maximum: payloadData.maximum,
+ };
+ case 'integer':
+ return {
+ kind: 'integer',
+ minimum: payloadData.minimum,
+ maximum: payloadData.maximum,
+ };
+ default:
+ throw new Error(`Property of type: ${kind} is not currently supported!`);
+ }
+};
+
+export const createSchemaStoreFromJson = (schemaName: string, jsonString: string) => {
+ const json = JSON.parse(jsonString);
+
+ const properties = json.properties || [];
+ const requiredArray = (json.required || []);
+ const examplesRecord = >(json.$examples || {});
+
+ const propertyStores = Object.keys(properties).map((name) => {
+ const propertyData = properties[name];
+
+ const propertyType = propertyData?.type || 'ref';
+ const array = propertyType === 'array';
+ const payload = retrievePayload(propertyType, propertyData, array);
+ const kind = (propertyType === 'array' ? payload.kind : propertyType);
+ const required = requiredArray.indexOf(name) !== -1;
+ const examples = examplesRecord[name] || [];
+
+ return {
+ id: generateId(),
+ name,
+ kind,
+ required,
+ examples,
+ array,
+ payload,
+ };
+ });
+
+ return { name: schemaName, properties: propertyStores };
+};
+
+const findFirstMissingIndex = (arr: number[], start: number, end: number): number => {
+ if (start > end) return end + 1;
+
+ if (start + 1 !== arr[start]) return start;
+
+ const mid = Math.floor(start + (end - start) / 2);
+
+ if (arr[mid] === mid + 1) {
+ return findFirstMissingIndex(arr, mid + 1, end);
+ }
+
+ return findFirstMissingIndex(arr, start, mid);
+};
+
+export const getDuplicateName = (name: string, allNames: readonly string[]) => {
+ if (!name) {
+ return '';
+ }
+
+ const getBestIndex = (origName: string) => {
+ const pattern = `${origName} - copy `;
+ const otherNames = allNames.filter((n) => n.startsWith(pattern) && n.endsWith(')'));
+ const indices: number[] = [];
+ for (const otherName of otherNames) {
+ const idx = otherName.indexOf(pattern);
+ const openPIdx = otherName.indexOf('(', idx);
+ const closePIdx = otherName.length - 1;
+
+ try {
+ if (openPIdx !== -1 && closePIdx !== -1) {
+ const otherIdx = parseInt(otherName.substring(openPIdx + 1, closePIdx), 10);
+ indices.push(otherIdx);
+ }
+ } catch {
+ continue;
+ }
+ }
+
+ if (!indices.length) {
+ return 1;
+ }
+
+ indices.sort((a, b) => a - b);
+ const maxIdx = Math.max(...indices);
+
+ const firstAvailableIdx = findFirstMissingIndex(indices, 0, indices.length - 1);
+
+ return firstAvailableIdx === -1 ? maxIdx + 1 : firstAvailableIdx + 1;
+ };
+
+ const cpIndex = name.indexOf(' - copy ');
+ const originalName = cpIndex === -1 ? name : name.substring(0, cpIndex);
+
+ const bestIndex = getBestIndex(originalName);
+
+ return `${originalName} - copy (${bestIndex})`;
+};
+
+//----------------------------JSON spreading----------------------------
+
+const spreadEntities = (payload: TypedPropertyPayload) =>
+ payload?.entities?.length ? { $entities: payload.entities } : {};
+
+const spreadStringSchemaProperty = (payload: StringPropertyPayload) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const payloadJson: any = payload?.enums?.length ? { enum: payload.enums } : {};
+ if (payload.format) {
+ payloadJson.format = payload.format;
+ }
+
+ return payloadJson;
+};
+
+const spreadNumberSchemaProperty = (payload: NumberPropertyPayload | IntegerPropertyPayload) => {
+ return { minimum: payload.minimum, maximum: payload.maximum };
+};
+
+const spreadRefSchemaProperty = (payload: RefPropertyPayload) => ({ $ref: `template:${payload.ref}` });
+
+const spreadArraySchemaProperty = (payload: FormDialogPropertyPayload) => {
+ const helper = () => {
+ switch (payload.kind) {
+ case 'string': {
+ return {
+ type: 'string',
+ ...spreadStringSchemaProperty(payload),
+ };
+ }
+ case 'number': {
+ return {
+ type: 'number',
+ ...spreadNumberSchemaProperty(payload),
+ };
+ }
+ case 'integer': {
+ return {
+ type: 'integer',
+ ...spreadNumberSchemaProperty(payload),
+ };
+ }
+ default:
+ case 'ref':
+ return spreadRefSchemaProperty(payload);
+ }
+ };
+ return {
+ type: 'array',
+ items: helper(),
+ };
+};
+
+export const spreadSchemaPropertyStore = (property: FormDialogProperty) => {
+ if (property.array) {
+ return spreadArraySchemaProperty(property.payload);
+ }
+ switch (property.kind) {
+ case 'ref':
+ return spreadRefSchemaProperty(property.payload);
+ case 'string':
+ return {
+ type: property.kind,
+ ...spreadEntities(