diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4cfe7e850d..6780fb09e4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,12 +4,14 @@ /runtime/ @boydc2014 @luhan2017 @carlosscastro @benbrown -/Composer/ @cwhitten @boydc2014 @a-b-r-o-w-n @beyackle @srinaath @tonyanziano @geoffcoxmsft @hatpick @benbrown @pavolum @tdurnford +/Composer/ @cwhitten @boydc2014 @a-b-r-o-w-n @beyackle @srinaath @tonyanziano @geoffcoxmsft @hatpick @benbrown @pavolum @tdurnford @natalgar @vamsimodem /Composer/packages/adaptive-flow @yeze322 @cwhitten @boydc2014 @a-b-r-o-w-n /docs/ @cwhitten @boydc2014 @benbrown @geoffcoxmsft -/extensions/azurePublish @benbrown @geoffcoxmsft @hatpick @tonyanziano +/extensions/azurePublish @benbrown @geoffcoxmsft @hatpick @tonyanziano @natalgar @vamsimodem /extensions/pvaPublish @benbrown @geoffcoxmsft @hatpick @tonyanziano + +/extensions/azurePublishNew @geoffcoxmsft @hatpick @natalgar @vamsimodem diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 16b69802c0..58b082894f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,23 +25,24 @@ jobs: uses: actions/setup-node@v1 with: node-version: 14.15.5 - #- 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: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v2 + id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn + run: yarn - name: yarn build:dev run: yarn build:dev - name: yarn lint run: yarn lint:ci && yarn lint:extensions - - name: yarn test:coverage - run: yarn test:coverage + - name: yarn test:ci + run: yarn test:ci - name: Coveralls uses: coverallsapp/github-action@v1.1.1 continue-on-error: true @@ -49,47 +50,6 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: ./Composer/coverage/lcov.info base-path: ./Composer - - botproject: - name: BotProject-dotnet - runs-on: windows-latest - timeout-minutes: 20 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Set Dotnet Version - uses: actions/setup-dotnet@v1 - with: - dotnet-version: "3.1.102" # SDK Version to use. - - name: dotnet build - run: dotnet build - working-directory: runtime/dotnet - - name: dotnet test - run: dotnet test - working-directory: runtime/dotnet/tests - - nodejs: - name: BotProject-nodejs - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Set Node Version - uses: actions/setup-node@v1 - with: - node-version: 14.15.5 - - name: npm install - run: npm install - working-directory: runtime/node - - name: npm build - run: npm run build - working-directory: runtime/node - - name: npm test - run: npm run test - working-directory: runtime/node # docker-build: # name: Docker Build # timeout-minutes: 20 diff --git a/.vscode/settings.json b/.vscode/settings.json index 0423924aa8..d21cc92b4c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,9 +7,7 @@ }, "files.exclude": { "**/.git": true, - "**/.DS_Store": true, - "**/node_modules": true, - "**/build": true + "**/.DS_Store": true }, "eslint.packageManager": "yarn", "eslint.validate": [ diff --git a/Composer/cypress/.eslintrc.js b/Composer/cypress/.eslintrc.js deleted file mode 100644 index 89d2a80a8c..0000000000 --- a/Composer/cypress/.eslintrc.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - extends: ['../.eslintrc.js', 'plugin:cypress/recommended'], -}; diff --git a/Composer/cypress/fixtures/luPublish/error.json b/Composer/cypress/fixtures/luPublish/error.json deleted file mode 100644 index f084738006..0000000000 --- a/Composer/cypress/fixtures/luPublish/error.json +++ /dev/null @@ -1 +0,0 @@ -{"error":"Access denied due to invalid subscription key. Make sure you are subscribed to an API you are trying to call and provide the right key."} diff --git a/Composer/cypress/fixtures/luPublish/success.json b/Composer/cypress/fixtures/luPublish/success.json deleted file mode 100644 index 8a5ff1bd2e..0000000000 --- a/Composer/cypress/fixtures/luPublish/success.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "status": { - "MyProject_main_en-us_lu": { - "version": "0000000001", - "checksum": "", - "status": 1 - } - }, - "luFiles": [ - { - "content": "#Dummy\r\n--I am Dummy", - "diagnostics": [], - "id": "Main", - "intents": [], - "relativePath": "Main/Main.lu", - "lastPublishTime": 2, - "lastUpdateTime": 1 - } - ] -} diff --git a/Composer/cypress/integration/examples/actions.spec.js b/Composer/cypress/integration/examples/actions.spec.js deleted file mode 100644 index 20e12cc6df..0000000000 --- a/Composer/cypress/integration/examples/actions.spec.js +++ /dev/null @@ -1,272 +0,0 @@ -/// - -context('Actions', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/actions') - }) - - // https://on.cypress.io/interacting-with-elements - - it('.type() - type into a DOM element', () => { - // https://on.cypress.io/type - cy.get('.action-email') - .type('fake@email.com').should('have.value', 'fake@email.com') - - // .type() with special character sequences - .type('{leftarrow}{rightarrow}{uparrow}{downarrow}') - .type('{del}{selectall}{backspace}') - - // .type() with key modifiers - .type('{alt}{option}') //these are equivalent - .type('{ctrl}{control}') //these are equivalent - .type('{meta}{command}{cmd}') //these are equivalent - .type('{shift}') - - // Delay each keypress by 0.1 sec - .type('slow.typing@email.com', { delay: 100 }) - .should('have.value', 'slow.typing@email.com') - - cy.get('.action-disabled') - // Ignore error checking prior to type - // like whether the input is visible or disabled - .type('disabled error checking', { force: true }) - .should('have.value', 'disabled error checking') - }) - - it('.focus() - focus on a DOM element', () => { - // https://on.cypress.io/focus - cy.get('.action-focus').focus() - .should('have.class', 'focus') - .prev().should('have.attr', 'style', 'color: orange;') - }) - - it('.blur() - blur off a DOM element', () => { - // https://on.cypress.io/blur - cy.get('.action-blur').type('About to blur').blur() - .should('have.class', 'error') - .prev().should('have.attr', 'style', 'color: red;') - }) - - it('.clear() - clears an input or textarea element', () => { - // https://on.cypress.io/clear - cy.get('.action-clear').type('Clear this text') - .should('have.value', 'Clear this text') - .clear() - .should('have.value', '') - }) - - it('.submit() - submit a form', () => { - // https://on.cypress.io/submit - cy.get('.action-form') - .find('[type="text"]').type('HALFOFF') - cy.get('.action-form').submit() - .next().should('contain', 'Your form has been submitted!') - }) - - it('.click() - click on a DOM element', () => { - // https://on.cypress.io/click - cy.get('.action-btn').click() - - // You can click on 9 specific positions of an element: - // ----------------------------------- - // | topLeft top topRight | - // | | - // | | - // | | - // | left center right | - // | | - // | | - // | | - // | bottomLeft bottom bottomRight | - // ----------------------------------- - - // clicking in the center of the element is the default - cy.get('#action-canvas').click() - - cy.get('#action-canvas').click('topLeft') - cy.get('#action-canvas').click('top') - cy.get('#action-canvas').click('topRight') - cy.get('#action-canvas').click('left') - cy.get('#action-canvas').click('right') - cy.get('#action-canvas').click('bottomLeft') - cy.get('#action-canvas').click('bottom') - cy.get('#action-canvas').click('bottomRight') - - // .click() accepts an x and y coordinate - // that controls where the click occurs :) - - cy.get('#action-canvas') - .click(80, 75) // click 80px on x coord and 75px on y coord - .click(170, 75) - .click(80, 165) - .click(100, 185) - .click(125, 190) - .click(150, 185) - .click(170, 165) - - // click multiple elements by passing multiple: true - cy.get('.action-labels>.label').click({ multiple: true }) - - // Ignore error checking prior to clicking - cy.get('.action-opacity>.btn').click({ force: true }) - }) - - it('.dblclick() - double click on a DOM element', () => { - // https://on.cypress.io/dblclick - - // Our app has a listener on 'dblclick' event in our 'scripts.js' - // that hides the div and shows an input on double click - cy.get('.action-div').dblclick().should('not.be.visible') - cy.get('.action-input-hidden').should('be.visible') - }) - - it('.check() - check a checkbox or radio element', () => { - // https://on.cypress.io/check - - // By default, .check() will check all - // matching checkbox or radio elements in succession, one after another - cy.get('.action-checkboxes [type="checkbox"]').not('[disabled]') - .check().should('be.checked') - - cy.get('.action-radios [type="radio"]').not('[disabled]') - .check().should('be.checked') - - // .check() accepts a value argument - cy.get('.action-radios [type="radio"]') - .check('radio1').should('be.checked') - - // .check() accepts an array of values - cy.get('.action-multiple-checkboxes [type="checkbox"]') - .check(['checkbox1', 'checkbox2']).should('be.checked') - - // Ignore error checking prior to checking - cy.get('.action-checkboxes [disabled]') - .check({ force: true }).should('be.checked') - - cy.get('.action-radios [type="radio"]') - .check('radio3', { force: true }).should('be.checked') - }) - - it('.uncheck() - uncheck a checkbox element', () => { - // https://on.cypress.io/uncheck - - // By default, .uncheck() will uncheck all matching - // checkbox elements in succession, one after another - cy.get('.action-check [type="checkbox"]') - .not('[disabled]') - .uncheck().should('not.be.checked') - - // .uncheck() accepts a value argument - cy.get('.action-check [type="checkbox"]') - .check('checkbox1') - .uncheck('checkbox1').should('not.be.checked') - - // .uncheck() accepts an array of values - cy.get('.action-check [type="checkbox"]') - .check(['checkbox1', 'checkbox3']) - .uncheck(['checkbox1', 'checkbox3']).should('not.be.checked') - - // Ignore error checking prior to unchecking - cy.get('.action-check [disabled]') - .uncheck({ force: true }).should('not.be.checked') - }) - - it('.select() - select an option in a + + ); +}; diff --git a/Composer/packages/client/src/components/AddRemoteSkillModal/CreateSkillModal.tsx b/Composer/packages/client/src/components/AddRemoteSkillModal/CreateSkillModal.tsx index 2ec494da4d..7c28e28fe3 100644 --- a/Composer/packages/client/src/components/AddRemoteSkillModal/CreateSkillModal.tsx +++ b/Composer/packages/client/src/components/AddRemoteSkillModal/CreateSkillModal.tsx @@ -9,11 +9,12 @@ import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { FontSizes } from '@uifabric/fluent-theme'; import { useRecoilValue } from 'recoil'; import debounce from 'lodash/debounce'; -import { isUsingAdaptiveRuntime, SDKKinds } from '@bfc/shared'; +import { isUsingAdaptiveRuntime, SDKKinds, isManifestJson } from '@bfc/shared'; import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; import { Separator } from 'office-ui-fabric-react/lib/Separator'; import { Dropdown, IDropdownOption, ResponsiveMode } from 'office-ui-fabric-react/lib/Dropdown'; import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { JSZipObject } from 'jszip'; import { LoadingSpinner } from '../../components/LoadingSpinner'; import { @@ -22,19 +23,21 @@ import { dispatcherState, luFilesSelectorFamily, publishTypesState, + botProjectFileState, + rootDialogSelector, } from '../../recoilModel'; import { addSkillDialog } from '../../constants'; import httpClient from '../../utils/httpUtil'; import TelemetryClient from '../../telemetry/TelemetryClient'; import { TriggerFormData } from '../../utils/dialogUtil'; import { selectIntentDialog } from '../../constants'; -import { isShowAuthDialog } from '../../utils/auth'; -import { AuthDialog } from '../Auth/AuthDialog'; import { PublishProfileDialog } from '../../pages/botProject/create-publish-profile/PublishProfileDialog'; +import { skillNameRegex } from '../../utils/skillManifestUtil'; import { SelectIntent } from './SelectIntent'; import { SkillDetail } from './SkillDetail'; import { SetAppId } from './SetAppId'; +import { BrowserModal } from './BrowserModal'; export interface SkillFormDataErrors { endpoint?: string; @@ -42,37 +45,109 @@ export interface SkillFormDataErrors { name?: string; } -export const urlRegex = /^http[s]?:\/\/\w+/; -export const skillNameRegex = /^\w[-\w]*$/; +const urlRegex = /^http[s]?:\/\/\w+/; +const filePathRegex = /([^<>/\\:""]+\.\w+$)/; + +// All endpoints should have endpoint url +const hasEndpointUrl = (content) => { + const endpoints = content.endpoints; + if (endpoints && endpoints.length > 0) { + return endpoints.every((endpoint) => !!endpoint.endpointUrl); + } + return false; +}; + export const msAppIdRegex = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/; export interface CreateSkillModalProps { projectId: string; - addRemoteSkill: (manifestUrl: string, endpointName: string) => Promise; + addRemoteSkill: (manifestUrl: string, endpointName: string, zipContent: Record) => Promise; addTriggerToRoot: (dialogId: string, triggerFormData: TriggerFormData, skillId: string) => Promise; onDismiss: () => void; } -export const validateManifestUrl = async ({ formData, formDataErrors, setFormDataErrors }) => { +export const validateManifestUrl = ({ formData, formDataErrors, setFormDataErrors }, skills: string[] = []) => { const { manifestUrl } = formData; const { manifestUrl: _, ...errors } = formDataErrors; if (!manifestUrl) { setFormDataErrors({ ...errors, manifestUrl: formatMessage('Please input a manifest URL') }); - } else if (!urlRegex.test(manifestUrl)) { - setFormDataErrors({ ...errors, manifestUrl: formatMessage('URL should start with http:// or https://') }); + } else if (!urlRegex.test(manifestUrl) && !filePathRegex.test(manifestUrl)) { + setFormDataErrors({ + ...errors, + manifestUrl: formatMessage('URL should start with http:// or https:// or file path of your system'), + }); + } else if (skills.includes(manifestUrl)) { + setFormDataErrors({ + ...errors, + manifestUrl: formatMessage('The bot is already part of the Bot Project'), + }); } else { setFormDataErrors({}); } }; -export const getSkillManifest = async (projectId: string, manifestUrl: string, setSkillManifest, setFormDataErrors) => { + +export const validateLocalZip = async (files: Record) => { + const result: { error: any; zipContent?: Record; manifestContent?: any; path: string } = { + error: {}, + path: '', + }; + try { + // get manifest + const manifestFiles: JSZipObject[] = []; + const zipContent: Record = {}; + for (const fPath in files) { + zipContent[fPath] = await files[fPath].async('string'); + // eslint-disable-next-line no-useless-escape + if (fPath.match(/\.([^\.]+)$/)?.[1] === 'json' && isManifestJson(zipContent[fPath])) { + manifestFiles.push(files[fPath]); + result.path = fPath.substr(0, fPath.lastIndexOf('/') + 1); + } + } + + // update content for detail panel and show it + if (manifestFiles.length > 1) { + result.error = { manifestUrl: formatMessage('zip folder has multiple manifest json') }; + } else if (manifestFiles.length === 1) { + const content = JSON.parse(await manifestFiles[0].async('string')); + if (hasEndpointUrl(content)) { + result.manifestContent = content; + result.zipContent = zipContent; + } else { + result.error = { + manifestUrl: formatMessage( + 'Endpoints should not be empty or endpoint should have endpoint url field in manifest json' + ), + }; + } + } else { + result.error = { manifestUrl: formatMessage('could not locate manifest.json in zip') }; + } + } catch (err) { + // eslint-disable-next-line format-message/literal-pattern + result.error = { manifestUrl: formatMessage(err.toString()) }; + } + return result; +}; + +const validateSKillName = (skillContent, setSkillManifest) => { + skillContent.name = skillContent.name.replace(skillNameRegex, ''); + setSkillManifest(skillContent); +}; +export const getSkillManifest = async ( + projectId: string, + manifestUrl: string, + setSkillManifest, + setFormDataErrors, + setShowDetail +) => { try { const { data } = await httpClient.get(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { url: manifestUrl, }, }); - setSkillManifest(data); + validateSKillName(data, setSkillManifest); } catch (error) { const httpMessage = error?.response?.data?.message; const message = httpMessage?.match('Unexpected string in JSON') @@ -80,6 +155,7 @@ export const getSkillManifest = async (projectId: string, manifestUrl: string, s : formatMessage('Manifest URL can not be accessed'); setFormDataErrors({ ...error, manifestUrl: message }); + setShowDetail(false); } }; const getTriggerFormData = (intent: string, content: string): TriggerFormData => ({ @@ -126,16 +202,20 @@ export const CreateSkillModal: React.FC = (props) => { const [formDataErrors, setFormDataErrors] = useState({}); const [skillManifest, setSkillManifest] = useState(null); const [showDetail, setShowDetail] = useState(false); - const [showAuthDialog, setShowAuthDialog] = useState(false); const [createSkillDialogHidden, setCreateSkillDialogHidden] = useState(false); + const [manifestDirPath, setManifestDirPath] = useState(''); + const [zipContent, setZipContent] = useState({}); const publishTypes = useRecoilValue(publishTypesState(projectId)); const { languages, luFeatures, runtime, publishTargets = [], MicrosoftAppId } = useRecoilValue( settingsState(projectId) ); const { dialogId } = useRecoilValue(designPageLocationState(projectId)); + const rootDialog = useRecoilValue(rootDialogSelector(projectId)); const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); const { updateRecognizer, setMicrosoftAppProperties, setPublishTargets } = useRecoilValue(dispatcherState); + const { content: botProjectFile } = useRecoilValue(botProjectFileState(projectId)); + const skillUrls = Object.keys(botProjectFile.skills).map((key) => botProjectFile.skills[key].manifest as string); const debouncedValidateManifestURl = useRef(debounce(validateManifestUrl, 500)).current; @@ -157,22 +237,28 @@ export const CreateSkillModal: React.FC = (props) => { const handleManifestUrlChange = (_, currentManifestUrl = '') => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { manifestUrl, ...rest } = formData; - debouncedValidateManifestURl({ - formData: { manifestUrl: currentManifestUrl }, - ...validationHelpers, - }); + debouncedValidateManifestURl( + { + formData: { manifestUrl: currentManifestUrl }, + ...validationHelpers, + }, + skillUrls + ); setFormData({ ...rest, manifestUrl: currentManifestUrl, }); setSkillManifest(null); + setShowDetail(false); }; const validateUrl = useCallback( (event) => { event.preventDefault(); setShowDetail(true); - getSkillManifest(projectId, formData.manifestUrl, setSkillManifest, setFormDataErrors); + const localManifestPath = formData.manifestUrl.replace(/\\/g, '/'); + getSkillManifest(projectId, formData.manifestUrl, setSkillManifest, setFormDataErrors, setShowDetail); + setManifestDirPath(localManifestPath.substring(0, localManifestPath.lastIndexOf('/'))); }, [projectId, formData] ); @@ -180,20 +266,28 @@ export const CreateSkillModal: React.FC = (props) => { const handleSubmit = async (event, content: string, enable: boolean) => { event.preventDefault(); // add a remote skill, add skill identifier into botProj file - await addRemoteSkill(formData.manifestUrl, formData.endpointName); - TelemetryClient.track('AddNewSkillCompleted'); + await addRemoteSkill(formData.manifestUrl, formData.endpointName, zipContent); + TelemetryClient.track('AddNewSkillCompleted', { + from: Object.keys(zipContent).length > 0 ? 'zip' : 'url', + }); // if added remote skill fail, just not addTrigger to root. const skillId = location.href.match(/skill\/([^/]*)/)?.[1]; + + //if the root dialog is orchestrator recoginzer type or user chooses orchestrator type before connecting, + //add the trigger to the root dialog. + const boundId = + rootDialog && (rootDialog.luProvider === SDKKinds.OrchestratorRecognizer || enable) ? rootDialog.id : dialogId; + if (skillId) { // add trigger with connect to skill action to root bot const triggerFormData = getTriggerFormData(skillManifest.name, content); - await addTriggerToRoot(dialogId, triggerFormData, skillId); + await addTriggerToRoot(boundId, triggerFormData, skillId); TelemetryClient.track('AddNewTriggerCompleted', { kind: 'Microsoft.OnIntent' }); } if (enable) { // update recognizor type to orchestrator - await updateRecognizer(projectId, dialogId, SDKKinds.OrchestratorRecognizer); + await updateRecognizer(projectId, boundId, SDKKinds.OrchestratorRecognizer); } }; @@ -219,7 +313,24 @@ export const CreateSkillModal: React.FC = (props) => { }; const handleGotoCreateProfile = () => { - isShowAuthDialog(true) ? setShowAuthDialog(true) : setCreateSkillDialogHidden(true); + setCreateSkillDialogHidden(true); + }; + + const handleBrowseButtonUpdate = async (path: string, files: Record) => { + // update path in input field + setFormData({ + ...formData, + manifestUrl: path, + }); + + const result = await validateLocalZip(files); + setFormDataErrors(result.error); + result.path && setManifestDirPath(result.path); + result.zipContent && setZipContent(result.zipContent); + if (result.manifestContent) { + validateSKillName(result.manifestContent, setSkillManifest); + setShowDetail(true); + } }; useEffect(() => { @@ -267,8 +378,11 @@ export const CreateSkillModal: React.FC = (props) => { languages={languages} luFeatures={luFeatures} manifest={skillManifest} + manifestDirPath={manifestDirPath} projectId={projectId} rootLuFiles={luFiles} + runtime={runtime} + zipContent={zipContent} onBack={() => { setTitle({ subText: '', @@ -289,14 +403,18 @@ export const CreateSkillModal: React.FC = (props) => {
- +
+ + +
{skillManifest?.endpoints?.length > 1 && ( = (props) => { styles={buttonStyle} text={formatMessage('Done')} onClick={(event) => { - addRemoteSkill(formData.manifestUrl, formData.endpointName); + addRemoteSkill(formData.manifestUrl, formData.endpointName, zipContent); }} /> ) @@ -362,17 +480,6 @@ export const CreateSkillModal: React.FC = (props) => { )} - {showAuthDialog && ( - { - setCreateSkillDialogHidden(true); - }} - onDismiss={() => { - setShowAuthDialog(false); - }} - /> - )} {createSkillDialogHidden ? ( { diff --git a/Composer/packages/client/src/components/AddRemoteSkillModal/EnableOrchestrator.tsx b/Composer/packages/client/src/components/AddRemoteSkillModal/EnableOrchestrator.tsx index ca4027e21f..ef877aab62 100644 --- a/Composer/packages/client/src/components/AddRemoteSkillModal/EnableOrchestrator.tsx +++ b/Composer/packages/client/src/components/AddRemoteSkillModal/EnableOrchestrator.tsx @@ -8,6 +8,7 @@ import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; import { Stack, StackItem } from 'office-ui-fabric-react/lib/Stack'; import { PrimaryButton, DefaultButton, Button } from 'office-ui-fabric-react/lib/Button'; import { useRecoilValue } from 'recoil'; +import { DialogSetting } from '@botframework-composer/types'; import { dispatcherState } from '../../recoilModel'; import { enableOrchestratorDialog } from '../../constants'; @@ -21,10 +22,11 @@ type OrchestratorProps = { onSubmit: (event: React.MouseEvent, userSelected?: boolean) => Promise; onBack?: (event: React.MouseEvent) => void; hideBackButton?: boolean; + runtime: DialogSetting['runtime']; }; const EnableOrchestrator: React.FC = (props) => { - const { projectId, onSubmit, onBack, hideBackButton = false } = props; + const { projectId, onSubmit, onBack, hideBackButton = false, runtime } = props; const [enableOrchestrator, setEnableOrchestrator] = useState(true); const { setApplicationLevelError, reloadProject } = useRecoilValue(dispatcherState); const onChange = (ev, check) => { @@ -59,7 +61,7 @@ const EnableOrchestrator: React.FC = (props) => { onSubmit(event, enableOrchestrator); if (enableOrchestrator) { // TODO: Block UI from doing any work until import is complete. Item #7531 - importOrchestrator(projectId, reloadProject, setApplicationLevelError); + importOrchestrator(projectId, runtime, reloadProject, setApplicationLevelError); } }} /> diff --git a/Composer/packages/client/src/components/AddRemoteSkillModal/SelectIntent.tsx b/Composer/packages/client/src/components/AddRemoteSkillModal/SelectIntent.tsx index 7d0e86281c..bdcb22a624 100644 --- a/Composer/packages/client/src/components/AddRemoteSkillModal/SelectIntent.tsx +++ b/Composer/packages/client/src/components/AddRemoteSkillModal/SelectIntent.tsx @@ -1,10 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. /** @jsx jsx */ +import { join, isAbsolute } from 'path'; + import { jsx, css } from '@emotion/core'; import React, { Fragment, useState, useMemo, useEffect, useCallback } from 'react'; import formatMessage from 'format-message'; -import { DetailsList, SelectionMode, CheckboxVisibility } from 'office-ui-fabric-react/lib/DetailsList'; +import { + DetailsList, + SelectionMode, + CheckboxVisibility, + IDetailsRowProps, +} from 'office-ui-fabric-react/lib/DetailsList'; import { Selection } from 'office-ui-fabric-react/lib/Selection'; import { Separator } from 'office-ui-fabric-react/lib/Separator'; import { Stack, StackItem } from 'office-ui-fabric-react/lib/Stack'; @@ -12,8 +19,9 @@ import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button' import { Label } from 'office-ui-fabric-react/lib/Label'; import { LuEditor } from '@bfc/code-editor'; import { ScrollablePane, ScrollbarVisibility } from 'office-ui-fabric-react/lib/ScrollablePane'; -import { LuFile, LuIntentSection, SDKKinds, ILUFeaturesConfig } from '@bfc/shared'; +import { LuFile, LuIntentSection, SDKKinds, ILUFeaturesConfig, DialogSetting } from '@bfc/shared'; import { useRecoilValue } from 'recoil'; +import { IRenderFunction } from 'office-ui-fabric-react/lib/Utilities'; import TelemetryClient from '../../telemetry/TelemetryClient'; import { selectIntentDialog, enableOrchestratorDialog } from '../../constants'; @@ -23,6 +31,7 @@ import { localeState, dispatcherState } from '../../recoilModel'; import { recognizersSelectorFamily } from '../../recoilModel/selectors/recognizers'; import { EnableOrchestrator } from './EnableOrchestrator'; +import { canImportOrchestrator } from './helper'; const detailListContainer = css` width: 100%; @@ -45,6 +54,9 @@ type SelectIntentProps = { luFeatures: ILUFeaturesConfig; rootLuFiles: LuFile[]; dialogId: string; + zipContent: Record; + manifestDirPath: string; + runtime: DialogSetting['runtime']; onSubmit: (event: Event, content: string, enable: boolean) => Promise; onDismiss: () => void; onUpdateTitle: (title: { title: string; subText: string }) => void; @@ -63,35 +75,70 @@ const columns = [ }, ]; -const getRemoteLuFiles = async (skillLanguages: object, composerLangeages: string[], setWarningMsg) => { - const luFilePromise: Promise[] = []; +const getRemoteLuFiles = async ( + skillLanguages: object, + composerLanguages: string[], + setWarningMsg, + zipContent: Record, + manifestDirPath: string, + locale: string +) => { + const luFiles: Record = {}; try { - for (const [key, value] of Object.entries(skillLanguages)) { - if (composerLangeages.includes(key)) { - value.map((item) => { - // get lu file - luFilePromise.push( - httpClient - .get(`/utilities/retrieveRemoteFile`, { + //for each root bot locale, which format is language-locale, we need to find the luFile in matched skill language + //currently the rule is: + //1. find the exact match, root language-locale matches skill language-locale. en-us matches en-us + //2. if no exact match found, root language-locale matches skill language. en-us matches en, zh-cn matches zh. + + for (const cl of composerLanguages) { + let matchedLanguage = ''; + if (skillLanguages[cl]) { + matchedLanguage = cl; + } else { + Object.keys(skillLanguages).forEach((sl) => { + if (cl.startsWith(sl)) { + matchedLanguage = sl; + } + }); + } + if (matchedLanguage && Array.isArray(skillLanguages[matchedLanguage])) { + luFiles[cl] = []; + for (const item of skillLanguages[matchedLanguage]) { + if (/^http[s]?:\/\/\w+/.test(item.url) || isAbsolute(item.url)) { + // get lu file from remote + const { data } = await httpClient.get(`/utilities/retrieveRemoteFile`, { + params: { + url: item.url, + }, + }); + luFiles[cl].push(data); + } else { + // get luFile from local zip folder + const fileKey = join(manifestDirPath, item.url); + if (zipContent[fileKey]) { + luFiles[cl].push({ + id: fileKey.substr(fileKey.lastIndexOf('/') + 1), + content: zipContent[fileKey], + }); + } else { + // get lu file from remote + const { data } = await httpClient.get(`/utilities/retrieveRemoteFile`, { params: { - url: item.url, + url: fileKey, }, - }) - .catch((err) => { - console.error(err); - setWarningMsg('get remote file fail'); - }) - ); - }); + }); + luFiles[cl].push(data); + } + } + } + } else if (locale === cl) { + setWarningMsg(`no matching locale found for ${locale}`); } } - const responses = await Promise.all(luFilePromise); - const files: { id: string; content: string }[] = responses.map((response) => { - return response.data; - }); - return files; + return luFiles; } catch (e) { console.log(e); + setWarningMsg('get remote file fail'); } }; @@ -121,13 +168,16 @@ export const SelectIntent: React.FC = (props) => { projectId, rootLuFiles, dialogId, + runtime, onUpdateTitle, onBack, + zipContent, + manifestDirPath, } = props; const [pageIndex, setPage] = useState(0); const [selectedIntents, setSelectedIntents] = useState>([]); // luFiles from manifest, language was included in root bot languages - const [luFiles, setLufiles] = useState>([]); + const [lufilesOnLocale, setLufilesOnLocale] = useState>([]); // current locale Lufile const [currentLuFile, setCurrentLuFile] = useState(); // selected intents in different languages @@ -154,6 +204,10 @@ export const SelectIntent: React.FC = (props) => { }); }, []); + const onRenderRow = (props?: IDetailsRowProps, defaultRender?: IRenderFunction): JSX.Element => { + return
{defaultRender?.(props)}
; + }; + // intents from manifest, intents can be an object or array. const intentItems = useMemo(() => { let res; @@ -189,24 +243,27 @@ export const SelectIntent: React.FC = (props) => { useEffect(() => { if (locale) { const skillLanguages = manifest.dispatchModels?.languages; - getRemoteLuFiles(skillLanguages, languages, setWarningMsg) + getRemoteLuFiles(skillLanguages, languages, setWarningMsg, zipContent, manifestDirPath, locale) .then((items) => { - items && - getParsedLuFiles(items, luFeatures, []).then((files) => { - setLufiles(files); + const lufilesOnLocale: { locale: string; lufiles: LuFile[] }[] = []; + for (const key in items) { + getParsedLuFiles(items[key], luFeatures, []).then((files) => { + lufilesOnLocale.push({ locale: key, lufiles: files }); files.map((file) => { - if (file.id.includes(locale)) { + if (key === locale && file.id.endsWith('.lu')) { setCurrentLuFile(file); } }); }); + } + setLufilesOnLocale(lufilesOnLocale); }) .catch((e) => { console.log(e); setWarningMsg(formatMessage('get remote file fail')); }); } - }, [manifest.dispatchModels?.languages, languages, locale, luFeatures]); + }, [manifest.dispatchModels?.languages, locale, manifestDirPath]); useEffect(() => { if (selectedIntents.length > 0) { @@ -217,17 +274,16 @@ export const SelectIntent: React.FC = (props) => { intents.push(cur); } }); - for (const file of luFiles) { - const id = file.id.split('.'); - const language = id[id.length - 1]; - multiLanguageIntents[language] = []; - for (const intent of file.intents) { - if (selectedIntents.includes(intent.Name)) { - multiLanguageIntents[language].push(intent); + for (const { locale, lufiles } of lufilesOnLocale) { + multiLanguageIntents[locale] = []; + for (const file of lufiles) { + for (const intent of file.intents) { + if (selectedIntents.includes(intent.Name)) { + multiLanguageIntents[locale].push(intent); + } } } } - setMultiLanguageIntents(multiLanguageIntents); // current locale, selected intent value. const intentsValue = mergeIntentsContent(intents); @@ -236,7 +292,7 @@ export const SelectIntent: React.FC = (props) => { setDisplayContent(''); setMultiLanguageIntents({}); } - }, [selectedIntents, currentLuFile, luFiles]); + }, [selectedIntents, currentLuFile, lufilesOnLocale]); const handleSubmit = async (ev, enableOchestractor) => { // add trigger to root @@ -250,6 +306,7 @@ export const SelectIntent: React.FC = (props) => { {showOrchestratorDialog ? ( { onUpdateTitle(selectIntentDialog.ADD_OR_EDIT_PHRASE(dialogId, manifest.name)); setShowOrchestratorDialog(false); @@ -270,6 +327,7 @@ export const SelectIntent: React.FC = (props) => { items={intentItems} selection={selection} selectionMode={SelectionMode.multiple} + onRenderRow={onRenderRow} />
@@ -309,12 +367,16 @@ export const SelectIntent: React.FC = (props) => { { if (pageIndex === 1) { - if (hasOrchestrator) { + if (hasOrchestrator || !canImportOrchestrator(runtime?.key)) { // skip orchestrator modal - handleSubmit(ev, true); + handleSubmit(ev, false); } else { // show orchestrator onUpdateTitle(enableOrchestratorDialog); diff --git a/Composer/packages/client/src/components/AddRemoteSkillModal/SkillDetail.tsx b/Composer/packages/client/src/components/AddRemoteSkillModal/SkillDetail.tsx index de3a82cd67..baef9f0e8b 100644 --- a/Composer/packages/client/src/components/AddRemoteSkillModal/SkillDetail.tsx +++ b/Composer/packages/client/src/components/AddRemoteSkillModal/SkillDetail.tsx @@ -25,6 +25,8 @@ type SkillDetailProps = { }; const container = css` width: 100%; + height: 100%; + overflow-y: auto; margin: 10px 0px; `; const segment = css` diff --git a/Composer/packages/client/src/components/AddRemoteSkillModal/helper.ts b/Composer/packages/client/src/components/AddRemoteSkillModal/helper.ts index dd6f6a8aeb..61e49d1951 100644 --- a/Composer/packages/client/src/components/AddRemoteSkillModal/helper.ts +++ b/Composer/packages/client/src/components/AddRemoteSkillModal/helper.ts @@ -4,6 +4,8 @@ import formatMessage from 'format-message'; import { luIndexer, combineMessage } from '@bfc/indexers'; import { OpenConfirmModal } from '@bfc/ui-shared'; +import { DialogSetting } from '@botframework-composer/types'; +import { isUsingAdaptiveRuntimeKey, parseRuntimeKey } from '@bfc/shared'; import httpClient from '../../utils/httpUtil'; import TelemetryClient from '../../telemetry/TelemetryClient'; @@ -13,14 +15,40 @@ const conflictConfirmationPrompt = formatMessage( 'This operation will overwrite changes made to previously imported files. Do you want to proceed?' ); -export const importOrchestrator = async (projectId: string, reloadProject, setApplicationLevelError) => { - const reqBody = { - package: 'Microsoft.Bot.Builder.AI.Orchestrator', - version: '4.13.1', - source: 'https://api.nuget.org/v3/index.json', - isUpdating: false, - isPreview: false, - }; +/** + * Orchestrator Nuget Package can only be automatically imported into Adaptive .Net WebApps. + */ +export const canImportOrchestrator = (runtimeKey?: string) => isUsingAdaptiveRuntimeKey(runtimeKey); + +export const importOrchestrator = async ( + projectId: string, + runtime: DialogSetting['runtime'], + reloadProject, + setApplicationLevelError +) => { + const runtimeInfo = parseRuntimeKey(runtime?.key); + + let reqBody; + if (runtimeInfo.runtimeLanguage === 'dotnet') { + reqBody = { + package: 'Microsoft.Bot.Builder.AI.Orchestrator', + version: '', //implicitly use latest nuget package + source: 'https://api.nuget.org/v3/index.json', + isUpdating: false, + isPreview: false, + }; + } else if (runtimeInfo.runtimeLanguage === 'js') { + reqBody = { + package: 'botbuilder-ai-orchestrator', + version: 'latest', + source: 'https://registry.npmjs.org/-/v1/search', + isUpdating: false, + isPreview: false, + }; + } else { + return; + } + try { const results = await httpClient.post(`projects/${projectId}/import`, reqBody); // check to see if there was a conflict that requires confirmation diff --git a/Composer/packages/client/src/components/AppComponents/Assistant.tsx b/Composer/packages/client/src/components/AppComponents/Assistant.tsx index 4e8e0bf965..c8b7fe7fe8 100644 --- a/Composer/packages/client/src/components/AppComponents/Assistant.tsx +++ b/Composer/packages/client/src/components/AppComponents/Assistant.tsx @@ -3,8 +3,7 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; import { useRecoilValue } from 'recoil'; -import { Suspense, Fragment } from 'react'; -import React from 'react'; +import React, { Suspense, Fragment } from 'react'; import { onboardingDisabled } from '../../constants'; import { useLocation } from '../../utils/hooks'; diff --git a/Composer/packages/client/src/components/AppComponents/RightPanel.tsx b/Composer/packages/client/src/components/AppComponents/RightPanel.tsx index a3f48584b4..b058d39f88 100644 --- a/Composer/packages/client/src/components/AppComponents/RightPanel.tsx +++ b/Composer/packages/client/src/components/AppComponents/RightPanel.tsx @@ -6,7 +6,6 @@ import { jsx, css } from '@emotion/core'; import { useRecoilValue } from 'recoil'; import { forwardRef } from 'react'; -import { RequireAuth } from '../RequireAuth'; import { ErrorBoundary } from '../ErrorBoundary'; import { Conversation } from '../Conversation'; @@ -52,9 +51,7 @@ export const RightPanel = () => { fetchProject={() => fetchProjectById(projectId)} setApplicationLevelError={setApplicationLevelError} > - -
{conversation}
-
+
{conversation}
); diff --git a/Composer/packages/client/src/components/AppComponents/SideBar.tsx b/Composer/packages/client/src/components/AppComponents/SideBar.tsx index 1f55ab2e76..f1bcb04811 100644 --- a/Composer/packages/client/src/components/AppComponents/SideBar.tsx +++ b/Composer/packages/client/src/components/AppComponents/SideBar.tsx @@ -16,7 +16,6 @@ import { resolveToBasePath } from '../../utils/fileUtil'; import { BASEPATH } from '../../constants'; import { NavItem } from '../NavItem'; import TelemetryClient from '../../telemetry/TelemetryClient'; -import { PageLink } from '../../utils/pageLinks'; import { DisableFeatureToolTip } from '../DisableFeatureToolTip'; import { currentProjectIdState } from '../../recoilModel'; import { usePVACheck } from '../../hooks/usePVACheck'; @@ -77,7 +76,7 @@ export const SideBar: React.FC = () => { const mapNavItemTo = (relPath: string) => resolveToBasePath(BASEPATH, relPath); const globalNavButtonText = sideBarExpand ? formatMessage('Collapse Navigation') : formatMessage('Expand Navigation'); - const showTooltips = (link: PageLink) => !sideBarExpand && !link.disabled; + return (