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 element', () => { - // https://on.cypress.io/select - - // Select option(s) with matching text content - cy.get('.action-select').select('apples') - - cy.get('.action-select-multiple') - .select(['apples', 'oranges', 'bananas']) - - // Select option(s) with matching value - cy.get('.action-select').select('fr-bananas') - - cy.get('.action-select-multiple') - .select(['fr-apples', 'fr-oranges', 'fr-bananas']) - }) - - it('.scrollIntoView() - scroll an element into view', () => { - // https://on.cypress.io/scrollintoview - - // normally all of these buttons are hidden, - // because they're not within - // the viewable area of their parent - // (we need to scroll to see them) - cy.get('#scroll-horizontal button') - .should('not.be.visible') - - // scroll the button into view, as if the user had scrolled - cy.get('#scroll-horizontal button').scrollIntoView() - .should('be.visible') - - cy.get('#scroll-vertical button') - .should('not.be.visible') - - // Cypress handles the scroll direction needed - cy.get('#scroll-vertical button').scrollIntoView() - .should('be.visible') - - cy.get('#scroll-both button') - .should('not.be.visible') - - // Cypress knows to scroll to the right and down - cy.get('#scroll-both button').scrollIntoView() - .should('be.visible') - }) - - it('.trigger() - trigger an event on a DOM element', () => { - // https://on.cypress.io/trigger - - // To interact with a range input (slider) - // we need to set its value & trigger the - // event to signal it changed - - // Here, we invoke jQuery's val() method to set - // the value and trigger the 'change' event - cy.get('.trigger-input-range') - .invoke('val', 25) - .trigger('change') - .get('input[type=range]').siblings('p') - .should('have.text', '25') - }) - - it('cy.scrollTo() - scroll the window or element to a position', () => { - - // https://on.cypress.io/scrollTo - - // You can scroll to 9 specific positions of an element: - // ----------------------------------- - // | topLeft top topRight | - // | | - // | | - // | | - // | left center right | - // | | - // | | - // | | - // | bottomLeft bottom bottomRight | - // ----------------------------------- - - // if you chain .scrollTo() off of cy, we will - // scroll the entire window - cy.scrollTo('bottom') - - cy.get('#scrollable-horizontal').scrollTo('right') - - // or you can scroll to a specific coordinate: - // (x axis, y axis) in pixels - cy.get('#scrollable-vertical').scrollTo(250, 250) - - // or you can scroll to a specific percentage - // of the (width, height) of the element - cy.get('#scrollable-both').scrollTo('75%', '25%') - - // control the easing of the scroll (default is 'swing') - cy.get('#scrollable-vertical').scrollTo('center', { easing: 'linear' }) - - // control the duration of the scroll (in ms) - cy.get('#scrollable-both').scrollTo('center', { duration: 2000 }) - }) -}) diff --git a/Composer/cypress/integration/examples/aliasing.spec.js b/Composer/cypress/integration/examples/aliasing.spec.js deleted file mode 100644 index 95bac735c4..0000000000 --- a/Composer/cypress/integration/examples/aliasing.spec.js +++ /dev/null @@ -1,42 +0,0 @@ -/// - -context('Aliasing', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/aliasing') - }) - - it('.as() - alias a DOM element for later use', () => { - // https://on.cypress.io/as - - // Alias a DOM element for use later - // We don't have to traverse to the element - // later in our code, we reference it with @ - - cy.get('.as-table').find('tbody>tr') - .first().find('td').first() - .find('button').as('firstBtn') - - // when we reference the alias, we place an - // @ in front of its name - cy.get('@firstBtn').click() - - cy.get('@firstBtn') - .should('have.class', 'btn-success') - .and('contain', 'Changed') - }) - - it('.as() - alias a route for later use', () => { - - // Alias the route to wait for its response - cy.server() - cy.route('GET', 'comments/*').as('getComment') - - // we have code that gets a comment when - // the button is clicked in scripts.js - cy.get('.network-btn').click() - - // https://on.cypress.io/wait - cy.wait('@getComment').its('status').should('eq', 200) - - }) -}) diff --git a/Composer/cypress/integration/examples/assertions.spec.js b/Composer/cypress/integration/examples/assertions.spec.js deleted file mode 100644 index 791383b665..0000000000 --- a/Composer/cypress/integration/examples/assertions.spec.js +++ /dev/null @@ -1,168 +0,0 @@ -/// - -context('Assertions', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/assertions') - }) - - describe('Implicit Assertions', () => { - it('.should() - make an assertion about the current subject', () => { - // https://on.cypress.io/should - cy.get('.assertion-table') - .find('tbody tr:last') - .should('have.class', 'success') - .find('td') - .first() - // checking the text of the element in various ways - .should('have.text', 'Column content') - .should('contain', 'Column content') - .should('have.html', 'Column content') - // chai-jquery uses "is()" to check if element matches selector - .should('match', 'td') - // to match text content against a regular expression - // first need to invoke jQuery method text() - // and then match using regular expression - .invoke('text') - .should('match', /column content/i) - - // a better way to check element's text content against a regular expression - // is to use "cy.contains" - // https://on.cypress.io/contains - cy.get('.assertion-table') - .find('tbody tr:last') - // finds first element with text content matching regular expression - .contains('td', /column content/i) - .should('be.visible') - - // for more information about asserting element's text - // see https://on.cypress.io/using-cypress-faq#How-do-I-get-an-element’s-text-contents - }) - - it('.and() - chain multiple assertions together', () => { - // https://on.cypress.io/and - cy.get('.assertions-link') - .should('have.class', 'active') - .and('have.attr', 'href') - .and('include', 'cypress.io') - }) - }) - - describe('Explicit Assertions', () => { - // https://on.cypress.io/assertions - it('expect - make an assertion about a specified subject', () => { - // We can use Chai's BDD style assertions - expect(true).to.be.true - const o = { foo: 'bar' } - - expect(o).to.equal(o) - expect(o).to.deep.equal({ foo: 'bar' }) - // matching text using regular expression - expect('FooBar').to.match(/bar$/i) - }) - - it('pass your own callback function to should()', () => { - // Pass a function to should that can have any number - // of explicit assertions within it. - // The ".should(cb)" function will be retried - // automatically until it passes all your explicit assertions or times out. - cy.get('.assertions-p') - .find('p') - .should(($p) => { - // https://on.cypress.io/$ - // return an array of texts from all of the p's - // @ts-ignore TS6133 unused variable - const texts = $p.map((i, el) => Cypress.$(el).text()) - - // jquery map returns jquery object - // and .get() convert this to simple array - const paragraphs = texts.get() - - // array should have length of 3 - expect(paragraphs, 'has 3 paragraphs').to.have.length(3) - - // use second argument to expect(...) to provide clear - // message with each assertion - expect(paragraphs, 'has expected text in each paragraph').to.deep.eq([ - 'Some text from first p', - 'More text from second p', - 'And even more text from third p', - ]) - }) - }) - - it('finds element by class name regex', () => { - cy.get('.docs-header') - .find('div') - // .should(cb) callback function will be retried - .should(($div) => { - expect($div).to.have.length(1) - - const className = $div[0].className - - expect(className).to.match(/heading-/) - }) - // .then(cb) callback is not retried, - // it either passes or fails - .then(($div) => { - expect($div, 'text content').to.have.text('Introduction') - }) - }) - - it('can throw any error', () => { - cy.get('.docs-header') - .find('div') - .should(($div) => { - if ($div.length !== 1) { - // you can throw your own errors - throw new Error('Did not find 1 element') - } - - const className = $div[0].className - - if (!className.match(/heading-/)) { - throw new Error(`Could not find class "heading-" in ${className}`) - } - }) - }) - - it('matches unknown text between two elements', () => { - /** - * Text from the first element. - * @type {string} - */ - let text - - /** - * Normalizes passed text, - * useful before comparing text with spaces and different capitalization. - * @param {string} s Text to normalize - */ - const normalizeText = (s) => s.replace(/\s/g, '').toLowerCase() - - cy.get('.two-elements') - .find('.first') - .then(($first) => { - // save text from the first element - text = normalizeText($first.text()) - }) - - cy.get('.two-elements') - .find('.second') - .should(($div) => { - // we can massage text before comparing - const secondText = normalizeText($div.text()) - - expect(secondText, 'second text').to.equal(text) - }) - }) - - it('assert - assert shape of an object', () => { - const person = { - name: 'Joe', - age: 20, - } - - assert.isObject(person, 'value is object') - }) - }) -}) diff --git a/Composer/cypress/integration/examples/connectors.spec.js b/Composer/cypress/integration/examples/connectors.spec.js deleted file mode 100644 index 26deb7ade6..0000000000 --- a/Composer/cypress/integration/examples/connectors.spec.js +++ /dev/null @@ -1,56 +0,0 @@ -/// - -context('Connectors', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/connectors') - }) - - it('.each() - iterate over an array of elements', () => { - // https://on.cypress.io/each - cy.get('.connectors-each-ul>li') - .each(($el, index, $list) => { - console.log($el, index, $list) - }) - }) - - it('.its() - get properties on the current subject', () => { - // https://on.cypress.io/its - cy.get('.connectors-its-ul>li') - // calls the 'length' property yielding that value - .its('length') - .should('be.gt', 2) - }) - - it('.invoke() - invoke a function on the current subject', () => { - // our div is hidden in our script.js - // $('.connectors-div').hide() - - // https://on.cypress.io/invoke - cy.get('.connectors-div').should('be.hidden') - // call the jquery method 'show' on the 'div.container' - .invoke('show') - .should('be.visible') - }) - - it('.spread() - spread an array as individual args to callback function', () => { - // https://on.cypress.io/spread - const arr = ['foo', 'bar', 'baz'] - - cy.wrap(arr).spread((foo, bar, baz) => { - expect(foo).to.eq('foo') - expect(bar).to.eq('bar') - expect(baz).to.eq('baz') - }) - }) - - it('.then() - invoke a callback function with the current subject', () => { - // https://on.cypress.io/then - cy.get('.connectors-list > li') - .then(($lis) => { - expect($lis, '3 items').to.have.length(3) - expect($lis.eq(0), 'first item').to.contain('Walk the dog') - expect($lis.eq(1), 'second item').to.contain('Feed the cat') - expect($lis.eq(2), 'third item').to.contain('Write JavaScript') - }) - }) -}) diff --git a/Composer/cypress/integration/examples/cookies.spec.js b/Composer/cypress/integration/examples/cookies.spec.js deleted file mode 100644 index bb540e95eb..0000000000 --- a/Composer/cypress/integration/examples/cookies.spec.js +++ /dev/null @@ -1,78 +0,0 @@ -/// - -context('Cookies', () => { - beforeEach(() => { - Cypress.Cookies.debug(true) - - cy.visit('https://example.cypress.io/commands/cookies') - - // clear cookies again after visiting to remove - // any 3rd party cookies picked up such as cloudflare - cy.clearCookies() - }) - - it('cy.getCookie() - get a browser cookie', () => { - // https://on.cypress.io/getcookie - cy.get('#getCookie .set-a-cookie').click() - - // cy.getCookie() yields a cookie object - cy.getCookie('token').should('have.property', 'value', '123ABC') - }) - - it('cy.getCookies() - get browser cookies', () => { - // https://on.cypress.io/getcookies - cy.getCookies().should('be.empty') - - cy.get('#getCookies .set-a-cookie').click() - - // cy.getCookies() yields an array of cookies - cy.getCookies().should('have.length', 1).should((cookies) => { - - // each cookie has these properties - expect(cookies[0]).to.have.property('name', 'token') - expect(cookies[0]).to.have.property('value', '123ABC') - expect(cookies[0]).to.have.property('httpOnly', false) - expect(cookies[0]).to.have.property('secure', false) - expect(cookies[0]).to.have.property('domain') - expect(cookies[0]).to.have.property('path') - }) - }) - - it('cy.setCookie() - set a browser cookie', () => { - // https://on.cypress.io/setcookie - cy.getCookies().should('be.empty') - - cy.setCookie('foo', 'bar') - - // cy.getCookie() yields a cookie object - cy.getCookie('foo').should('have.property', 'value', 'bar') - }) - - it('cy.clearCookie() - clear a browser cookie', () => { - // https://on.cypress.io/clearcookie - cy.getCookie('token').should('be.null') - - cy.get('#clearCookie .set-a-cookie').click() - - cy.getCookie('token').should('have.property', 'value', '123ABC') - - // cy.clearCookies() yields null - cy.clearCookie('token').should('be.null') - - cy.getCookie('token').should('be.null') - }) - - it('cy.clearCookies() - clear browser cookies', () => { - // https://on.cypress.io/clearcookies - cy.getCookies().should('be.empty') - - cy.get('#clearCookies .set-a-cookie').click() - - cy.getCookies().should('have.length', 1) - - // cy.clearCookies() yields null - cy.clearCookies() - - cy.getCookies().should('be.empty') - }) -}) diff --git a/Composer/cypress/integration/examples/cypress_api.spec.js b/Composer/cypress/integration/examples/cypress_api.spec.js deleted file mode 100644 index 0e46520cd1..0000000000 --- a/Composer/cypress/integration/examples/cypress_api.spec.js +++ /dev/null @@ -1,222 +0,0 @@ -/// - -context('Cypress.Commands', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - // https://on.cypress.io/custom-commands - - it('.add() - create a custom command', () => { - Cypress.Commands.add('console', { - prevSubject: true, - }, (subject, method) => { - // the previous subject is automatically received - // and the commands arguments are shifted - - // allow us to change the console method used - method = method || 'log' - - // log the subject to the console - // @ts-ignore TS7017 - console[method]('The subject is', subject) - - // whatever we return becomes the new subject - // we don't want to change the subject so - // we return whatever was passed in - return subject - }) - - // @ts-ignore TS2339 - cy.get('button').console('info').then(($button) => { - // subject is still $button - }) - }) -}) - - -context('Cypress.Cookies', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - // https://on.cypress.io/cookies - it('.debug() - enable or disable debugging', () => { - Cypress.Cookies.debug(true) - - // Cypress will now log in the console when - // cookies are set or cleared - cy.setCookie('fakeCookie', '123ABC') - cy.clearCookie('fakeCookie') - cy.setCookie('fakeCookie', '123ABC') - cy.clearCookie('fakeCookie') - cy.setCookie('fakeCookie', '123ABC') - }) - - it('.preserveOnce() - preserve cookies by key', () => { - // normally cookies are reset after each test - cy.getCookie('fakeCookie').should('not.be.ok') - - // preserving a cookie will not clear it when - // the next test starts - cy.setCookie('lastCookie', '789XYZ') - Cypress.Cookies.preserveOnce('lastCookie') - }) - - it('.defaults() - set defaults for all cookies', () => { - // now any cookie with the name 'session_id' will - // not be cleared before each new test runs - Cypress.Cookies.defaults({ - whitelist: 'session_id', - }) - }) -}) - - -context('Cypress.Server', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - // Permanently override server options for - // all instances of cy.server() - - // https://on.cypress.io/cypress-server - it('.defaults() - change default config of server', () => { - Cypress.Server.defaults({ - delay: 0, - force404: false, - }) - }) -}) - -context('Cypress.arch', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - it('Get CPU architecture name of underlying OS', () => { - // https://on.cypress.io/arch - expect(Cypress.arch).to.exist - }) -}) - -context('Cypress.config()', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - it('Get and set configuration options', () => { - // https://on.cypress.io/config - let myConfig = Cypress.config() - - expect(myConfig).to.have.property('animationDistanceThreshold', 5) - expect(myConfig).to.have.property('baseUrl', null) - expect(myConfig).to.have.property('defaultCommandTimeout', 4000) - expect(myConfig).to.have.property('requestTimeout', 5000) - expect(myConfig).to.have.property('responseTimeout', 30000) - expect(myConfig).to.have.property('viewportHeight', 660) - expect(myConfig).to.have.property('viewportWidth', 1000) - expect(myConfig).to.have.property('pageLoadTimeout', 60000) - expect(myConfig).to.have.property('waitForAnimations', true) - - expect(Cypress.config('pageLoadTimeout')).to.eq(60000) - - // this will change the config for the rest of your tests! - Cypress.config('pageLoadTimeout', 20000) - - expect(Cypress.config('pageLoadTimeout')).to.eq(20000) - - Cypress.config('pageLoadTimeout', 60000) - }) -}) - -context('Cypress.dom', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - // https://on.cypress.io/dom - it('.isHidden() - determine if a DOM element is hidden', () => { - let hiddenP = Cypress.$('.dom-p p.hidden').get(0) - let visibleP = Cypress.$('.dom-p p.visible').get(0) - - // our first paragraph has css class 'hidden' - expect(Cypress.dom.isHidden(hiddenP)).to.be.true - expect(Cypress.dom.isHidden(visibleP)).to.be.false - }) -}) - -context('Cypress.env()', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - // We can set environment variables for highly dynamic values - - // https://on.cypress.io/environment-variables - it('Get environment variables', () => { - // https://on.cypress.io/env - // set multiple environment variables - Cypress.env({ - host: 'veronica.dev.local', - api_server: 'http://localhost:8888/v1/', - }) - - // get environment variable - expect(Cypress.env('host')).to.eq('veronica.dev.local') - - // set environment variable - Cypress.env('api_server', 'http://localhost:8888/v2/') - expect(Cypress.env('api_server')).to.eq('http://localhost:8888/v2/') - - // get all environment variable - expect(Cypress.env()).to.have.property('host', 'veronica.dev.local') - expect(Cypress.env()).to.have.property('api_server', 'http://localhost:8888/v2/') - }) -}) - -context('Cypress.log', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - it('Control what is printed to the Command Log', () => { - // https://on.cypress.io/cypress-log - }) -}) - - -context('Cypress.platform', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - it('Get underlying OS name', () => { - // https://on.cypress.io/platform - expect(Cypress.platform).to.be.exist - }) -}) - -context('Cypress.version', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - it('Get current version of Cypress being run', () => { - // https://on.cypress.io/version - expect(Cypress.version).to.be.exist - }) -}) - -context('Cypress.spec', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/cypress-api') - }) - - it('Get current spec information', () => { - // https://on.cypress.io/spec - // wrap the object so we can inspect it easily by clicking in the command log - cy.wrap(Cypress.spec).should('have.keys', ['name', 'relative', 'absolute']) - }) -}) diff --git a/Composer/cypress/integration/examples/files.spec.js b/Composer/cypress/integration/examples/files.spec.js deleted file mode 100644 index 016588be18..0000000000 --- a/Composer/cypress/integration/examples/files.spec.js +++ /dev/null @@ -1,86 +0,0 @@ -/// - -context('Files', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/files') - }) - it('cy.fixture() - load a fixture', () => { - // https://on.cypress.io/fixture - - // Instead of writing a response inline you can - // use a fixture file's content. - - cy.server() - cy.fixture('example.json').as('comment') - cy.route('GET', 'comments/*', '@comment').as('getComment') - - // we have code that gets a comment when - // the button is clicked in scripts.js - cy.get('.fixture-btn').click() - - cy.wait('@getComment').its('responseBody') - .should('have.property', 'name') - .and('include', 'Using fixtures to represent data') - - // you can also just write the fixture in the route - cy.route('GET', 'comments/*', 'fixture:example.json').as('getComment') - - // we have code that gets a comment when - // the button is clicked in scripts.js - cy.get('.fixture-btn').click() - - cy.wait('@getComment').its('responseBody') - .should('have.property', 'name') - .and('include', 'Using fixtures to represent data') - - // or write fx to represent fixture - // by default it assumes it's .json - cy.route('GET', 'comments/*', 'fx:example').as('getComment') - - // we have code that gets a comment when - // the button is clicked in scripts.js - cy.get('.fixture-btn').click() - - cy.wait('@getComment').its('responseBody') - .should('have.property', 'name') - .and('include', 'Using fixtures to represent data') - }) - - it('cy.readFile() - read a files contents', () => { - // https://on.cypress.io/readfile - - // You can read a file and yield its contents - // The filePath is relative to your project's root. - cy.readFile('cypress.json').then((json) => { - expect(json).to.be.an('object') - }) - }) - - it('cy.writeFile() - write to a file', () => { - // https://on.cypress.io/writefile - - // You can write to a file - - // Use a response from a request to automatically - // generate a fixture file for use later - cy.request('https://jsonplaceholder.cypress.io/users') - .then((response) => { - cy.writeFile('cypress/fixtures/users.json', response.body) - }) - cy.fixture('users').should((users) => { - expect(users[0].name).to.exist - }) - - // JavaScript arrays and objects are stringified - // and formatted into text. - cy.writeFile('cypress/fixtures/profile.json', { - id: 8739, - name: 'Jane', - email: 'jane@example.com', - }) - - cy.fixture('profile').should((profile) => { - expect(profile.name).to.eq('Jane') - }) - }) -}) diff --git a/Composer/cypress/integration/examples/local_storage.spec.js b/Composer/cypress/integration/examples/local_storage.spec.js deleted file mode 100644 index 076b096fc3..0000000000 --- a/Composer/cypress/integration/examples/local_storage.spec.js +++ /dev/null @@ -1,52 +0,0 @@ -/// - -context('Local Storage', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/local-storage') - }) - // Although local storage is automatically cleared - // in between tests to maintain a clean state - // sometimes we need to clear the local storage manually - - it('cy.clearLocalStorage() - clear all data in local storage', () => { - // https://on.cypress.io/clearlocalstorage - cy.get('.ls-btn').click().should(() => { - expect(localStorage.getItem('prop1')).to.eq('red') - expect(localStorage.getItem('prop2')).to.eq('blue') - expect(localStorage.getItem('prop3')).to.eq('magenta') - }) - - // clearLocalStorage() yields the localStorage object - cy.clearLocalStorage().should((ls) => { - expect(ls.getItem('prop1')).to.be.null - expect(ls.getItem('prop2')).to.be.null - expect(ls.getItem('prop3')).to.be.null - }) - - // Clear key matching string in Local Storage - cy.get('.ls-btn').click().should(() => { - expect(localStorage.getItem('prop1')).to.eq('red') - expect(localStorage.getItem('prop2')).to.eq('blue') - expect(localStorage.getItem('prop3')).to.eq('magenta') - }) - - cy.clearLocalStorage('prop1').should((ls) => { - expect(ls.getItem('prop1')).to.be.null - expect(ls.getItem('prop2')).to.eq('blue') - expect(ls.getItem('prop3')).to.eq('magenta') - }) - - // Clear keys matching regex in Local Storage - cy.get('.ls-btn').click().should(() => { - expect(localStorage.getItem('prop1')).to.eq('red') - expect(localStorage.getItem('prop2')).to.eq('blue') - expect(localStorage.getItem('prop3')).to.eq('magenta') - }) - - cy.clearLocalStorage(/prop1|2/).should((ls) => { - expect(ls.getItem('prop1')).to.be.null - expect(ls.getItem('prop2')).to.be.null - expect(ls.getItem('prop3')).to.eq('magenta') - }) - }) -}) diff --git a/Composer/cypress/integration/examples/location.spec.js b/Composer/cypress/integration/examples/location.spec.js deleted file mode 100644 index 68e755c101..0000000000 --- a/Composer/cypress/integration/examples/location.spec.js +++ /dev/null @@ -1,32 +0,0 @@ -/// - -context('Location', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/location') - }) - - it('cy.hash() - get the current URL hash', () => { - // https://on.cypress.io/hash - cy.hash().should('be.empty') - }) - - it('cy.location() - get window.location', () => { - // https://on.cypress.io/location - cy.location().should((location) => { - expect(location.hash).to.be.empty - expect(location.href).to.eq('https://example.cypress.io/commands/location') - expect(location.host).to.eq('example.cypress.io') - expect(location.hostname).to.eq('example.cypress.io') - expect(location.origin).to.eq('https://example.cypress.io') - expect(location.pathname).to.eq('/commands/location') - expect(location.port).to.eq('') - expect(location.protocol).to.eq('https:') - expect(location.search).to.be.empty - }) - }) - - it('cy.url() - get the current URL', () => { - // https://on.cypress.io/url - cy.url().should('eq', 'https://example.cypress.io/commands/location') - }) -}) diff --git a/Composer/cypress/integration/examples/misc.spec.js b/Composer/cypress/integration/examples/misc.spec.js deleted file mode 100644 index 97edd8be0a..0000000000 --- a/Composer/cypress/integration/examples/misc.spec.js +++ /dev/null @@ -1,83 +0,0 @@ -/// - -context('Misc', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/misc') - }) - - it('.end() - end the command chain', () => { - // https://on.cypress.io/end - - // cy.end is useful when you want to end a chain of commands - // and force Cypress to re-query from the root element - cy.get('.misc-table').within(() => { - // ends the current chain and yields null - cy.contains('Cheryl').click().end() - - // queries the entire table again - cy.contains('Charles').click() - }) - }) - - it('cy.exec() - execute a system command', () => { - // https://on.cypress.io/exec - - // execute a system command. - // so you can take actions necessary for - // your test outside the scope of Cypress. - cy.exec('echo Jane Lane') - .its('stdout').should('contain', 'Jane Lane') - - // we can use Cypress.platform string to - // select appropriate command - // https://on.cypress/io/platform - cy.log(`Platform ${Cypress.platform} architecture ${Cypress.arch}`) - - if (Cypress.platform === 'win32') { - cy.exec('print cypress.json') - .its('stderr').should('be.empty') - } else { - cy.exec('cat cypress.json') - .its('stderr').should('be.empty') - - cy.exec('pwd') - .its('code').should('eq', 0) - } - }) - - it('cy.focused() - get the DOM element that has focus', () => { - // https://on.cypress.io/focused - cy.get('.misc-form').find('#name').click() - cy.focused().should('have.id', 'name') - - cy.get('.misc-form').find('#description').click() - cy.focused().should('have.id', 'description') - }) - - context('Cypress.Screenshot', function () { - it('cy.screenshot() - take a screenshot', () => { - // https://on.cypress.io/screenshot - cy.screenshot('my-image') - }) - - it('Cypress.Screenshot.defaults() - change default config of screenshots', function () { - Cypress.Screenshot.defaults({ - blackout: ['.foo'], - capture: 'viewport', - clip: { x: 0, y: 0, width: 200, height: 200 }, - scale: false, - disableTimersAndAnimations: true, - screenshotOnRunFailure: true, - beforeScreenshot () { }, - afterScreenshot () { }, - }) - }) - }) - - it('cy.wrap() - wrap an object', () => { - // https://on.cypress.io/wrap - cy.wrap({ foo: 'bar' }) - .should('have.property', 'foo') - .and('include', 'bar') - }) -}) diff --git a/Composer/cypress/integration/examples/navigation.spec.js b/Composer/cypress/integration/examples/navigation.spec.js deleted file mode 100644 index bbd9d479c8..0000000000 --- a/Composer/cypress/integration/examples/navigation.spec.js +++ /dev/null @@ -1,56 +0,0 @@ -/// - -context('Navigation', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io') - cy.get('.navbar-nav').contains('Commands').click() - cy.get('.dropdown-menu').contains('Navigation').click() - }) - - it('cy.go() - go back or forward in the browser\'s history', () => { - // https://on.cypress.io/go - - cy.location('pathname').should('include', 'navigation') - - cy.go('back') - cy.location('pathname').should('not.include', 'navigation') - - cy.go('forward') - cy.location('pathname').should('include', 'navigation') - - // clicking back - cy.go(-1) - cy.location('pathname').should('not.include', 'navigation') - - // clicking forward - cy.go(1) - cy.location('pathname').should('include', 'navigation') - }) - - it('cy.reload() - reload the page', () => { - // https://on.cypress.io/reload - cy.reload() - - // reload the page without using the cache - cy.reload(true) - }) - - it('cy.visit() - visit a remote url', () => { - // https://on.cypress.io/visit - - // Visit any sub-domain of your current domain - - // Pass options to the visit - cy.visit('https://example.cypress.io/commands/navigation', { - timeout: 50000, // increase total time for the visit to resolve - onBeforeLoad (contentWindow) { - // contentWindow is the remote page's window object - expect(typeof contentWindow === 'object').to.be.true - }, - onLoad (contentWindow) { - // contentWindow is the remote page's window object - expect(typeof contentWindow === 'object').to.be.true - }, - }) - }) -}) diff --git a/Composer/cypress/integration/examples/network_requests.spec.js b/Composer/cypress/integration/examples/network_requests.spec.js deleted file mode 100644 index 259e9eea57..0000000000 --- a/Composer/cypress/integration/examples/network_requests.spec.js +++ /dev/null @@ -1,140 +0,0 @@ -/// - -context('Network Requests', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/network-requests') - }) - - // Manage AJAX / XHR requests in your app - - it('cy.server() - control behavior of network requests and responses', () => { - // https://on.cypress.io/server - - cy.server().should((server) => { - // the default options on server - // you can override any of these options - expect(server.delay).to.eq(0) - expect(server.method).to.eq('GET') - expect(server.status).to.eq(200) - expect(server.headers).to.be.null - expect(server.response).to.be.null - expect(server.onRequest).to.be.undefined - expect(server.onResponse).to.be.undefined - expect(server.onAbort).to.be.undefined - - // These options control the server behavior - // affecting all requests - - // pass false to disable existing route stubs - expect(server.enable).to.be.true - // forces requests that don't match your routes to 404 - expect(server.force404).to.be.false - // whitelists requests from ever being logged or stubbed - expect(server.whitelist).to.be.a('function') - }) - - cy.server({ - method: 'POST', - delay: 1000, - status: 422, - response: {}, - }) - - // any route commands will now inherit the above options - // from the server. anything we pass specifically - // to route will override the defaults though. - }) - - it('cy.request() - make an XHR request', () => { - // https://on.cypress.io/request - cy.request('https://jsonplaceholder.cypress.io/comments') - .should((response) => { - expect(response.status).to.eq(200) - expect(response.body).to.have.length(500) - expect(response).to.have.property('headers') - expect(response).to.have.property('duration') - }) - }) - - - it('cy.request() - verify response using BDD syntax', () => { - cy.request('https://jsonplaceholder.cypress.io/comments') - .then((response) => { - // https://on.cypress.io/assertions - expect(response).property('status').to.equal(200) - expect(response).property('body').to.have.length(500) - expect(response).to.include.keys('headers', 'duration') - }) - }) - - it('cy.request() with query parameters', () => { - // will execute request - // https://jsonplaceholder.cypress.io/comments?postId=1&id=3 - cy.request({ - url: 'https://jsonplaceholder.cypress.io/comments', - qs: { - postId: 1, - id: 3, - }, - }) - .its('body') - .should('be.an', 'array') - .and('have.length', 1) - .its('0') // yields first element of the array - .should('contain', { - postId: 1, - id: 3, - }) - }) - - it('cy.route() - route responses to matching requests', () => { - // https://on.cypress.io/route - - let message = 'whoa, this comment does not exist' - - cy.server() - - // Listen to GET to comments/1 - cy.route('GET', 'comments/*').as('getComment') - - // we have code that gets a comment when - // the button is clicked in scripts.js - cy.get('.network-btn').click() - - // https://on.cypress.io/wait - cy.wait('@getComment').its('status').should('eq', 200) - - // Listen to POST to comments - cy.route('POST', '/comments').as('postComment') - - // we have code that posts a comment when - // the button is clicked in scripts.js - cy.get('.network-post').click() - cy.wait('@postComment') - - // get the route - cy.get('@postComment').should((xhr) => { - expect(xhr.requestBody).to.include('email') - expect(xhr.requestHeaders).to.have.property('Content-Type') - expect(xhr.responseBody).to.have.property('name', 'Using POST in cy.route()') - }) - - // Stub a response to PUT comments/ **** - cy.route({ - method: 'PUT', - url: 'comments/*', - status: 404, - response: { error: message }, - delay: 500, - }).as('putComment') - - // we have code that puts a comment when - // the button is clicked in scripts.js - cy.get('.network-put').click() - - cy.wait('@putComment') - - // our 404 statusCode logic in scripts.js executed - cy.get('.network-put-comment').should('contain', message) - }) -}) diff --git a/Composer/cypress/integration/examples/querying.spec.js b/Composer/cypress/integration/examples/querying.spec.js deleted file mode 100644 index 484a8ecab8..0000000000 --- a/Composer/cypress/integration/examples/querying.spec.js +++ /dev/null @@ -1,87 +0,0 @@ -/// - -context('Querying', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/querying') - }) - - // The most commonly used query is 'cy.get()', you can - // think of this like the '$' in jQuery - - it('cy.get() - query DOM elements', () => { - // https://on.cypress.io/get - - cy.get('#query-btn').should('contain', 'Button') - - cy.get('.query-btn').should('contain', 'Button') - - cy.get('#querying .well>button:first').should('contain', 'Button') - // ↲ - // Use CSS selectors just like jQuery - - cy.get('[data-test-id="test-example"]').should('have.class', 'example') - - // 'cy.get()' yields jQuery object, you can get its attribute - // by invoking `.attr()` method - cy.get('[data-test-id="test-example"]') - .invoke('attr', 'data-test-id') - .should('equal', 'test-example') - - // or you can get element's CSS property - cy.get('[data-test-id="test-example"]') - .invoke('css', 'position') - .should('equal', 'static') - - // or use assertions directly during 'cy.get()' - // https://on.cypress.io/assertions - cy.get('[data-test-id="test-example"]') - .should('have.attr', 'data-test-id', 'test-example') - .and('have.css', 'position', 'static') - }) - - it('cy.contains() - query DOM elements with matching content', () => { - // https://on.cypress.io/contains - cy.get('.query-list') - .contains('bananas') - .should('have.class', 'third') - - // we can pass a regexp to `.contains()` - cy.get('.query-list') - .contains(/^b\w+/) - .should('have.class', 'third') - - cy.get('.query-list') - .contains('apples') - .should('have.class', 'first') - - // passing a selector to contains will - // yield the selector containing the text - cy.get('#querying') - .contains('ul', 'oranges') - .should('have.class', 'query-list') - - cy.get('.query-button') - .contains('Save Form') - .should('have.class', 'btn') - }) - - it('.within() - query DOM elements within a specific element', () => { - // https://on.cypress.io/within - cy.get('.query-form').within(() => { - cy.get('input:first').should('have.attr', 'placeholder', 'Email') - cy.get('input:last').should('have.attr', 'placeholder', 'Password') - }) - }) - - it('cy.root() - query the root DOM element', () => { - // https://on.cypress.io/root - - // By default, root is the document - cy.root().should('match', 'html') - - cy.get('.query-ul').within(() => { - // In this within, the root is now the ul DOM element - cy.root().should('have.class', 'query-ul') - }) - }) -}) diff --git a/Composer/cypress/integration/examples/spies_stubs_clocks.spec.js b/Composer/cypress/integration/examples/spies_stubs_clocks.spec.js deleted file mode 100644 index 6ba9397207..0000000000 --- a/Composer/cypress/integration/examples/spies_stubs_clocks.spec.js +++ /dev/null @@ -1,69 +0,0 @@ -/// - -context('Spies, Stubs, and Clock', () => { - it('cy.spy() - wrap a method in a spy', () => { - // https://on.cypress.io/spy - cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') - - let obj = { - foo () {}, - } - - let spy = cy.spy(obj, 'foo').as('anyArgs') - - obj.foo() - - expect(spy).to.be.called - }) - - it('cy.stub() - create a stub and/or replace a function with stub', () => { - // https://on.cypress.io/stub - cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') - - let obj = { - /** - * prints both arguments to the console - * @param a {string} - * @param b {string} - */ - foo (a, b) { - console.log('a', a, 'b', b) - }, - } - - let stub = cy.stub(obj, 'foo').as('foo') - - obj.foo('foo', 'bar') - - expect(stub).to.be.called - }) - - it('cy.clock() - control time in the browser', () => { - // https://on.cypress.io/clock - - // create the date in UTC so its always the same - // no matter what local timezone the browser is running in - let now = new Date(Date.UTC(2017, 2, 14)).getTime() - - cy.clock(now) - cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') - cy.get('#clock-div').click() - .should('have.text', '1489449600') - }) - - it('cy.tick() - move time in the browser', () => { - // https://on.cypress.io/tick - - // create the date in UTC so its always the same - // no matter what local timezone the browser is running in - let now = new Date(Date.UTC(2017, 2, 14)).getTime() - - cy.clock(now) - cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') - cy.get('#tick-div').click() - .should('have.text', '1489449600') - cy.tick(10000) // 10 seconds passed - cy.get('#tick-div').click() - .should('have.text', '1489449610') - }) -}) diff --git a/Composer/cypress/integration/examples/traversal.spec.js b/Composer/cypress/integration/examples/traversal.spec.js deleted file mode 100644 index 1082eca6cc..0000000000 --- a/Composer/cypress/integration/examples/traversal.spec.js +++ /dev/null @@ -1,121 +0,0 @@ -/// - -context('Traversal', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/traversal') - }) - - it('.children() - get child DOM elements', () => { - // https://on.cypress.io/children - cy.get('.traversal-breadcrumb') - .children('.active') - .should('contain', 'Data') - }) - - it('.closest() - get closest ancestor DOM element', () => { - // https://on.cypress.io/closest - cy.get('.traversal-badge') - .closest('ul') - .should('have.class', 'list-group') - }) - - it('.eq() - get a DOM element at a specific index', () => { - // https://on.cypress.io/eq - cy.get('.traversal-list>li') - .eq(1).should('contain', 'siamese') - }) - - it('.filter() - get DOM elements that match the selector', () => { - // https://on.cypress.io/filter - cy.get('.traversal-nav>li') - .filter('.active').should('contain', 'About') - }) - - it('.find() - get descendant DOM elements of the selector', () => { - // https://on.cypress.io/find - cy.get('.traversal-pagination') - .find('li').find('a') - .should('have.length', 7) - }) - - it('.first() - get first DOM element', () => { - // https://on.cypress.io/first - cy.get('.traversal-table td') - .first().should('contain', '1') - }) - - it('.last() - get last DOM element', () => { - // https://on.cypress.io/last - cy.get('.traversal-buttons .btn') - .last().should('contain', 'Submit') - }) - - it('.next() - get next sibling DOM element', () => { - // https://on.cypress.io/next - cy.get('.traversal-ul') - .contains('apples').next().should('contain', 'oranges') - }) - - it('.nextAll() - get all next sibling DOM elements', () => { - // https://on.cypress.io/nextall - cy.get('.traversal-next-all') - .contains('oranges') - .nextAll().should('have.length', 3) - }) - - it('.nextUntil() - get next sibling DOM elements until next el', () => { - // https://on.cypress.io/nextuntil - cy.get('#veggies') - .nextUntil('#nuts').should('have.length', 3) - }) - - it('.not() - remove DOM elements from set of DOM elements', () => { - // https://on.cypress.io/not - cy.get('.traversal-disabled .btn') - .not('[disabled]').should('not.contain', 'Disabled') - }) - - it('.parent() - get parent DOM element from DOM elements', () => { - // https://on.cypress.io/parent - cy.get('.traversal-mark') - .parent().should('contain', 'Morbi leo risus') - }) - - it('.parents() - get parent DOM elements from DOM elements', () => { - // https://on.cypress.io/parents - cy.get('.traversal-cite') - .parents().should('match', 'blockquote') - }) - - it('.parentsUntil() - get parent DOM elements from DOM elements until el', () => { - // https://on.cypress.io/parentsuntil - cy.get('.clothes-nav') - .find('.active') - .parentsUntil('.clothes-nav') - .should('have.length', 2) - }) - - it('.prev() - get previous sibling DOM element', () => { - // https://on.cypress.io/prev - cy.get('.birds').find('.active') - .prev().should('contain', 'Lorikeets') - }) - - it('.prevAll() - get all previous sibling DOM elements', () => { - // https://on.cypress.io/prevAll - cy.get('.fruits-list').find('.third') - .prevAll().should('have.length', 2) - }) - - it('.prevUntil() - get all previous sibling DOM elements until el', () => { - // https://on.cypress.io/prevUntil - cy.get('.foods-list').find('#nuts') - .prevUntil('#veggies').should('have.length', 3) - }) - - it('.siblings() - get all sibling DOM elements', () => { - // https://on.cypress.io/siblings - cy.get('.traversal-pills .active') - .siblings().should('have.length', 2) - }) -}) diff --git a/Composer/cypress/integration/examples/utilities.spec.js b/Composer/cypress/integration/examples/utilities.spec.js deleted file mode 100644 index c3e2076dc0..0000000000 --- a/Composer/cypress/integration/examples/utilities.spec.js +++ /dev/null @@ -1,117 +0,0 @@ -/// - -context('Utilities', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/utilities') - }) - - it('Cypress._ - call a lodash method', () => { - // https://on.cypress.io/_ - cy.request('https://jsonplaceholder.cypress.io/users') - .then((response) => { - let ids = Cypress._.chain(response.body).map('id').take(3).value() - - expect(ids).to.deep.eq([1, 2, 3]) - }) - }) - - it('Cypress.$ - call a jQuery method', () => { - // https://on.cypress.io/$ - let $li = Cypress.$('.utility-jquery li:first') - - cy.wrap($li) - .should('not.have.class', 'active') - .click() - .should('have.class', 'active') - }) - - it('Cypress.Blob - blob utilities and base64 string conversion', () => { - // https://on.cypress.io/blob - cy.get('.utility-blob').then(($div) => - // https://github.com/nolanlawson/blob-util#imgSrcToDataURL - // get the dataUrl string for the javascript-logo - Cypress.Blob.imgSrcToDataURL('https://example.cypress.io/assets/img/javascript-logo.png', undefined, 'anonymous') - .then((dataUrl) => { - // create an element and set its src to the dataUrl - let img = Cypress.$('', { src: dataUrl }) - - // need to explicitly return cy here since we are initially returning - // the Cypress.Blob.imgSrcToDataURL promise to our test - // append the image - $div.append(img) - - cy.get('.utility-blob img').click() - .should('have.attr', 'src', dataUrl) - })) - }) - - it('Cypress.minimatch - test out glob patterns against strings', () => { - // https://on.cypress.io/minimatch - let matching = Cypress.minimatch('/users/1/comments', '/users/*/comments', { - matchBase: true, - }) - - expect(matching, 'matching wildcard').to.be.true - - matching = Cypress.minimatch('/users/1/comments/2', '/users/*/comments', { - matchBase: true, - }) - expect(matching, 'comments').to.be.false - - // ** matches against all downstream path segments - matching = Cypress.minimatch('/foo/bar/baz/123/quux?a=b&c=2', '/foo/**', { - matchBase: true, - }) - expect(matching, 'comments').to.be.true - - // whereas * matches only the next path segment - - matching = Cypress.minimatch('/foo/bar/baz/123/quux?a=b&c=2', '/foo/*', { - matchBase: false, - }) - expect(matching, 'comments').to.be.false - }) - - - it('Cypress.moment() - format or parse dates using a moment method', () => { - // https://on.cypress.io/moment - const time = Cypress.moment().utc('2014-04-25T19:38:53.196Z').format('h:mm A') - - expect(time).to.be.a('string') - - cy.get('.utility-moment').contains('3:38 PM') - .should('have.class', 'badge') - }) - - - it('Cypress.Promise - instantiate a bluebird promise', () => { - // https://on.cypress.io/promise - let waited = false - - /** - * @return Bluebird - */ - function waitOneSecond () { - // return a promise that resolves after 1 second - // @ts-ignore TS2351 (new Cypress.Promise) - return new Cypress.Promise((resolve, reject) => { - setTimeout(() => { - // set waited to true - waited = true - - // resolve with 'foo' string - resolve('foo') - }, 1000) - }) - } - - cy.then(() => - // return a promise to cy.then() that - // is awaited until it resolves - // @ts-ignore TS7006 - waitOneSecond().then((str) => { - expect(str).to.eq('foo') - expect(waited).to.be.true - })) - }) -}) diff --git a/Composer/cypress/integration/examples/viewport.spec.js b/Composer/cypress/integration/examples/viewport.spec.js deleted file mode 100644 index 711fe74efb..0000000000 --- a/Composer/cypress/integration/examples/viewport.spec.js +++ /dev/null @@ -1,59 +0,0 @@ -/// - -context('Viewport', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/viewport') - }) - - it('cy.viewport() - set the viewport size and dimension', () => { - // https://on.cypress.io/viewport - - cy.get('#navbar').should('be.visible') - cy.viewport(320, 480) - - // the navbar should have collapse since our screen is smaller - cy.get('#navbar').should('not.be.visible') - cy.get('.navbar-toggle').should('be.visible').click() - cy.get('.nav').find('a').should('be.visible') - - // lets see what our app looks like on a super large screen - cy.viewport(2999, 2999) - - // cy.viewport() accepts a set of preset sizes - // to easily set the screen to a device's width and height - - // We added a cy.wait() between each viewport change so you can see - // the change otherwise it is a little too fast to see :) - - cy.viewport('macbook-15') - cy.wait(200) - cy.viewport('macbook-13') - cy.wait(200) - cy.viewport('macbook-11') - cy.wait(200) - cy.viewport('ipad-2') - cy.wait(200) - cy.viewport('ipad-mini') - cy.wait(200) - cy.viewport('iphone-6+') - cy.wait(200) - cy.viewport('iphone-6') - cy.wait(200) - cy.viewport('iphone-5') - cy.wait(200) - cy.viewport('iphone-4') - cy.wait(200) - cy.viewport('iphone-3') - cy.wait(200) - - // cy.viewport() accepts an orientation for all presets - // the default orientation is 'portrait' - cy.viewport('ipad-2', 'portrait') - cy.wait(200) - cy.viewport('iphone-4', 'landscape') - cy.wait(200) - - // The viewport will be reset back to the default dimensions - // in between tests (the default can be set in cypress.json) - }) -}) diff --git a/Composer/cypress/integration/examples/waiting.spec.js b/Composer/cypress/integration/examples/waiting.spec.js deleted file mode 100644 index e11d9ca938..0000000000 --- a/Composer/cypress/integration/examples/waiting.spec.js +++ /dev/null @@ -1,34 +0,0 @@ -/// - -context('Waiting', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/waiting') - }) - // BE CAREFUL of adding unnecessary wait times. - // https://on.cypress.io/best-practices#Unnecessary-Waiting - - // https://on.cypress.io/wait - it('cy.wait() - wait for a specific amount of time', () => { - cy.get('.wait-input1').type('Wait 1000ms after typing') - cy.wait(1000) - cy.get('.wait-input2').type('Wait 1000ms after typing') - cy.wait(1000) - cy.get('.wait-input3').type('Wait 1000ms after typing') - cy.wait(1000) - }) - - it('cy.wait() - wait for a specific route', () => { - cy.server() - - // Listen to GET to comments/1 - cy.route('GET', 'comments/*').as('getComment') - - // we have code that gets a comment when - // the button is clicked in scripts.js - cy.get('.network-btn').click() - - // wait for GET comments/1 - cy.wait('@getComment').its('status').should('eq', 200) - }) - -}) diff --git a/Composer/cypress/integration/examples/window.spec.js b/Composer/cypress/integration/examples/window.spec.js deleted file mode 100644 index 00bff9f7fa..0000000000 --- a/Composer/cypress/integration/examples/window.spec.js +++ /dev/null @@ -1,22 +0,0 @@ -/// - -context('Window', () => { - beforeEach(() => { - cy.visit('https://example.cypress.io/commands/window') - }) - - it('cy.window() - get the global window object', () => { - // https://on.cypress.io/window - cy.window().should('have.property', 'top') - }) - - it('cy.document() - get the document object', () => { - // https://on.cypress.io/document - cy.document().should('have.property', 'charset').and('eq', 'UTF-8') - }) - - it('cy.title() - get the title', () => { - // https://on.cypress.io/title - cy.title().should('include', 'Kitchen Sink') - }) -}) diff --git a/Composer/jest.config.js b/Composer/jest.config.js index 0aca293414..539cd8b8fa 100644 --- a/Composer/jest.config.js +++ b/Composer/jest.config.js @@ -45,6 +45,8 @@ module.exports = { '/packages/intellisense', '/packages/lib/code-editor', '/packages/lib/shared', + '/packages/lib/indexers', + '/packages/lib/ui-shared', '/packages/server', '/packages/electron-server', '/packages/tools/language-servers/language-generation', diff --git a/Composer/package.json b/Composer/package.json index c4f2fd1069..bf2f76bbbe 100644 --- a/Composer/package.json +++ b/Composer/package.json @@ -6,9 +6,10 @@ "@babel/parser": "^7.11.3", "@types/react": "16.9.23", "bl": "^4.0.3", + "browserslist": "^4.16.5", "elliptic": "^6.5.3", "kind-of": "^6.0.3", - "lodash": "^4.17.12", + "lodash": "^4.17.21", "mkdirp": "^0.5.2", "selfsigned": "1.10.8", "serialize-javascript": "^3.1.0", @@ -22,10 +23,11 @@ "dns-packet": "^1.3.4", "json-ptr": "^2.1", "css-what": "^5.0.1", - "css-select": "^4.1.3" + "css-select": "^4.1.3", + "ejs": "^3.1.6" }, "engines": { - "node": ">=12" + "node": "14.x" }, "workspaces": { "packages": [ @@ -33,13 +35,14 @@ "packages/adaptive-form", "packages/client", "packages/electron-server", - "packages/extension", "packages/extension-client", + "packages/extension", "packages/form-dialogs", + "packages/integration-tests", "packages/intellisense", "packages/lib/*", - "packages/server", "packages/server-workers", + "packages/server", "packages/test-utils", "packages/tools/built-in-functions", "packages/tools/language-servers/*", @@ -47,7 +50,8 @@ "packages/ui-plugins/*" ], "nohoist": [ - "**/server-workers/**" + "**/server-workers/**", + "**/integration-tests/**" ] }, "scripts": { @@ -70,14 +74,12 @@ "start:server": "yarn workspace @bfc/server start", "start:server:dev": "yarn workspace @bfc/server start:dev", "runtime": "cd ../runtime/dotnet/azurewebapp && dotnet build && dotnet run", - "test": "cross-env NODE_OPTIONS=--max-old-space-size=4096 yarn typecheck && jest", + "test": "cross-env NODE_OPTIONS=--max-old-space-size=4096 yarn typecheck && jest --silent", "test:watch": "yarn typecheck && jest --watch", - "test:coverage": "yarn test --coverage --no-cache --forceExit --reporters=default --silent", - "test:integration": "cypress run --browser edge", - "test:integration:start-server": "node scripts/e2e.js", - "test:integration:open": "cypress open", - "test:integration:clean": "node scripts/clean-e2e.js", - "test:integration:clean-all": "node scripts/clean-e2e.js --all", + "test:ci": "cross-env CI=true yarn test --coverage --no-cache --forceExit --reporters=default --reporters=jest-github-actions-reporter --testLocationInResults", + "test:integration": "yarn workspace @bfc/integration-tests start", + "test:integration:start-server": "yarn workspace @bfc/integration-tests start:server", + "test:integration:open": "yarn workspace @bfc/integration-tests open", "lint": "wsrun --exclude-missing --collect-logs --report lint", "lint:ci": "wsrun --exclude-missing --collect-logs --report --no-prefix lint --format github-actions", "lint:fix": "wsrun --exclude-missing --collect-logs --report lint:fix", @@ -118,20 +120,14 @@ "@babel/preset-typescript": "^7.9.0", "@bfc/eslint-plugin-bfcomposer": "*", "@emotion/babel-preset-css-prop": "^10.0.27", - "@testing-library/cypress": "7.0.0-beta.1", - "@testing-library/user-event": "12.6.3", "@typescript-eslint/eslint-plugin": "2.34.0", "@typescript-eslint/parser": "2.34.0", - "chalk": "^4.0.0", "concurrently": "^4.1.0", "coveralls": "^3.1.0", "cross-env": "^6.0.3", - "cypress": "^5.0.0", - "cypress-plugin-tab": "^1.0.5", "eslint": "7.0.0", "eslint-config-prettier": "6.11.0", "eslint-formatter-github-actions": "^1.0.0", - "eslint-plugin-cypress": "2.11.1", "eslint-plugin-emotion": "10.0.27", "eslint-plugin-format-message": "6.2.3", "eslint-plugin-import": "2.20.2", 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 a05943f40e..eae8379d3d 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 @@ -56,6 +56,7 @@ export const ShellApiStub: ShellApi = { updateDialogSchema: fnPromise, createTrigger: fnPromise, updateFlowZoomRate: fnPromise, + toggleComments: fnPromise, }; describe('ShellApiStub', () => { diff --git a/Composer/packages/adaptive-flow/package.json b/Composer/packages/adaptive-flow/package.json index 37bb42a467..b611dbb3a8 100644 --- a/Composer/packages/adaptive-flow/package.json +++ b/Composer/packages/adaptive-flow/package.json @@ -4,7 +4,7 @@ "description": "BotFramework Adaptive Form", "main": "lib/index.js", "engines": { - "node": ">=12" + "node": "14.x" }, "files": [ "css", @@ -29,13 +29,13 @@ "@emotion/core": "^10.0.27", "@emotion/styled": "^10.0.27", "adaptive-expressions": "4.12.0-rc1", - "botbuilder-lg": "4.12.0-rc1", + "botbuilder-lg": "4.14.0-dev.391a2ab", "create-react-class": "^15.6.3", "d3": "^5.9.1", "dagre": "^0.8.4", "dagre-d3": "^0.6.3", "lodash": "^4.17.19", - "office-ui-fabric-react": "^7.88.1", + "office-ui-fabric-react": "7.71.0", "prop-types": "^15.7.2", "react-measure": "^2.3.0", "source-map-loader": "^0.2.4" 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 b528c31b9e..e1c9c725a5 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/AdaptiveFlowEditor.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/AdaptiveFlowEditor.tsx @@ -29,7 +29,7 @@ import { VisualEditorElementWrapper, } from './renderers'; import { useFlowUIOptions } from './hooks/useFlowUIOptions'; -import { ZoomZone } from './components/ZoomZone'; +import { FlowToolbar } from './components/FlowToolbar'; formatMessage.setup({ missingTranslation: 'ignore', @@ -73,11 +73,12 @@ const VisualDesigner: React.FC = ({ onFocus, onBlur, schema hosted, schemas, flowZoomRate, + flowCommentsVisible, topics, dialogs, } = shellData; - const { updateFlowZoomRate } = shellApi; + const { updateFlowZoomRate, toggleFlowComments } = shellApi; const dataCache = useRef({}); @@ -158,7 +159,13 @@ const VisualDesigner: React.FC = ({ onFocus, onBlur, schema > - + = ({ onFocus, onBlur, schema }} /> - + diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/FlowToolbar.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/FlowToolbar.tsx new file mode 100644 index 0000000000..a216b8b5b0 --- /dev/null +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/FlowToolbar.tsx @@ -0,0 +1,208 @@ +// 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 formatMessage from 'format-message'; +import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { DirectionalHint } from 'office-ui-fabric-react/lib/Callout'; +import { NeutralColors } from '@uifabric/fluent-theme'; + +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; +} + +const TooltipWrapper = ({ tooltip, children }: { tooltip: string; children: React.ReactNode }) => ( + + {children} + +); + +type FlowToolbarProps = { + flowZoomRate: ZoomInfo; + focusedId: string; + flowCommentsVisible: boolean; + updateFlowZoomRate: (currentRate: number) => void; + toggleFlowComments: () => void; + children?: ReactNode; +}; + +export const FlowToolbar: React.FC = ({ + flowZoomRate, + focusedId, + flowCommentsVisible, + updateFlowZoomRate, + toggleFlowComments, + 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 = (iconName: string): IIconProps => { + return { iconName, styles: { root: { color: NeutralColors.white, width: '16px' } } }; + }; + const buttonStyle = (overrides: { top?: string; bottom?: string; margin?: string }): IButtonStyles => ({ + root: { + width: '35px', + height: '35px', + background: 'rgba(44, 41, 41, 0.8)', + borderRadius: '0', + margin: overrides.margin ?? '0', + selectors: { + ':disabled': { + backgroundColor: '#BDBDBD', + }, + }, + + // top radius + borderTopLeftRadius: overrides.top ?? 0, + borderTopRightRadius: overrides.top ?? 0, + + // bottom radius + borderBottomLeftRadius: overrides.bottom ?? 0, + borderBottomRightRadius: overrides.bottom ?? 0, + }, + rootHovered: { + backgroundColor: 'rgba(44, 41, 41, 0.8)', + }, + rootPressed: { + backgroundColor: 'rgba(44, 41, 41, 0.8)', + }, + }); + + const commentsLabel = flowCommentsVisible + ? formatMessage('Hide notes on canvas') + : formatMessage('Show notes on canvas'); + + return ( + + + toggleFlowComments()} + /> + + + 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/components/ZoomZone.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/ZoomZone.tsx deleted file mode 100644 index 2918c249fe..0000000000 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/ZoomZone.tsx +++ /dev/null @@ -1,156 +0,0 @@ -// 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 formatMessage from 'format-message'; - -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/hooks/useEditorEventApi.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/hooks/useEditorEventApi.ts index 5fb8949ffc..2556c5d338 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 @@ -216,15 +216,35 @@ export const useEditorEventApi = ( break; case NodeEventTypes.PasteSelection: handler = () => { - const currentSelectedId = selectedIds[0]; - if (currentSelectedId.endsWith('+')) { - const { arrayPath, arrayIndex } = DialogUtils.parseNodePath(currentSelectedId.slice(0, -1)) || {}; - handleEditorEvent(NodeEventTypes.Insert, { - id: arrayPath, - position: arrayIndex, - $kind: MenuEventTypes.Paste, - }); + if (!clipboardActions?.length) return; + + const length = selectedIds.length; + let arrayPath = ''; + let arrayIndex = -1; + + //if there is no selected action, paste it to the end of the current dialog + if (!length && state.nodeContext?.focusedEvent) { + const focused = state.nodeContext.focusedEvent; + const actions = get(data, `${focused}.actions`); + arrayPath = `${focused}.actions`; + arrayIndex = actions ? actions.length : 0; + } + + //If some actions are selected, paste it to the end of the last selected action + if (length) { + const result = DialogUtils.parseNodePath(selectedIds[length - 1]); + if (!result) return; + arrayPath = result.arrayPath; + arrayIndex = result.arrayIndex + 1; } + + if (!arrayPath) return; + + handleEditorEvent(NodeEventTypes.Insert, { + id: arrayPath, + position: arrayIndex, + $kind: MenuEventTypes.Paste, + }); }; break; case NodeEventTypes.MoveSelection: diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/EdgeMenu.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/EdgeMenu.tsx index e75b18649d..0cc642f277 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/EdgeMenu.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/EdgeMenu.tsx @@ -8,6 +8,7 @@ import formatMessage from 'format-message'; import { DefinitionSummary } from '@bfc/shared'; import { TooltipHost, DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; import { useMenuConfig } from '@bfc/extension-client'; +import { IconMenu } from '@bfc/ui-shared'; // TODO: leak of visual-sdk domain (EdgeAddButtonSize) import { EdgeAddButtonSize } from '../../../adaptive-flow-renderer/constants/ElementSizes'; @@ -17,7 +18,6 @@ import { SelfHostContext } from '../../contexts/SelfHostContext'; import { AttrNames } from '../../constants/ElementAttributes'; import { MenuTypes } from '../../constants/MenuTypes'; import { ObiColors } from '../../../adaptive-flow-renderer/constants/ElementColors'; -import { IconMenu } from '../../components/IconMenu'; import { createActionMenu } from './createSchemaMenu'; @@ -84,6 +84,8 @@ export const EdgeMenu: React.FC = ({ id, onClick }) => { > = ({ id, onClick }) => { }} label={moreLabel} menuItems={menuItems} - nodeSelected={nodeSelected} /> diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/NodeMenu.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/NodeMenu.tsx index b558471d0f..c7f84c47ad 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/NodeMenu.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/NodeMenu.tsx @@ -6,13 +6,13 @@ import { jsx } from '@emotion/core'; import { useContext } from 'react'; import formatMessage from 'format-message'; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { IconMenu } from '@bfc/ui-shared'; import { NodeEventTypes, EditorEventHandler } from '../../adaptive-flow-renderer/constants/NodeEventTypes'; import { MenuTypes } from '../constants/MenuTypes'; import { AttrNames } from '../constants/ElementAttributes'; import { SelectionContext } from '../contexts/SelectionContext'; import { ElementColor } from '../../adaptive-flow-renderer/constants/ElementColors'; -import { IconMenu } from '../components/IconMenu'; const declareElementAttributes = (id: string) => { return { @@ -51,6 +51,8 @@ export const NodeMenu: React.FC = ({ colors = { color: 'black' }, > = ({ colors = { color: 'black' }, label={moreLabel} menuItems={menuItems} menuWidth={100} - nodeSelected={nodeSelected} /> diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/NodeWrapper.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/NodeWrapper.tsx index 149f6e06bb..8ae56c2cda 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/NodeWrapper.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/NodeWrapper.tsx @@ -1,11 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; -import { FC, useContext, useCallback, useEffect } from 'react'; +import { useContext, useCallback, useEffect } from 'react'; import { generateActionTitle, PromptTab } from '@bfc/shared'; import { useShellApi } from '@bfc/extension-client'; +import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { DirectionalHint } from 'office-ui-fabric-react/lib/Callout'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; import { AttrNames } from '../constants/ElementAttributes'; import { NodeRendererContext } from '../contexts/NodeRendererContext'; @@ -24,14 +29,40 @@ const nodeBorderSelectedStyle = css` const nodeBorderDoubleSelectedStyle = css` box-shadow: 0px 0px 0px 2px #0078d4, 0px 0px 0px 6px rgba(0, 120, 212, 0.3); `; -export interface NodeWrapperProps { + +/** + * When comments are visible, the tooltip target is invisible. + */ +const tooltipTargetStyle = (showComments = true) => css` + position: absolute; + right: ${showComments ? 0 : '-32px'}; + top: 0; + height: 24px; + width: 24px; + background-color: #fff4ce; + box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1); + border-radius: 2px; + + display: flex; + visibility: ${showComments ? 'hidden' : 'visible'}; + align-items: center; + justify-content: center; +`; + +const escapeId = (id: string) => { + const charsToEscape = /(\[|\]|\.)/g; + return id.replace(charsToEscape, '\\$1'); +}; + +export type NodeWrapperProps = React.PropsWithChildren<{ id: string; tab?: PromptTab; data: any; onEvent: (eventName: NodeEventTypes, eventData: any) => any; -} + hideComment?: boolean; +}>; -export const ActionNodeWrapper: FC = ({ id, tab, data, onEvent, children }): JSX.Element => { +export const ActionNodeWrapper = ({ id, tab, data, onEvent, hideComment, children }: NodeWrapperProps): JSX.Element => { const selectableId = tab ? `${id}${tab}` : id; const { focusedId, focusedEvent, focusedTab } = useContext(NodeRendererContext); const { selectedIds, getNodeIndex } = useContext(SelectionContext); @@ -54,6 +85,7 @@ export const ActionNodeWrapper: FC = ({ id, tab, data, onEvent const { shellApi: { addCoachMarkRef }, + flowCommentsVisible, } = useShellApi(); const actionRef = useCallback( (action) => { @@ -71,7 +103,7 @@ export const ActionNodeWrapper: FC = ({ id, tab, data, onEvent // Set 'use-select' to none to disable browser's default // text selection effect when pressing Shift + Click. - return ( + const content = ( = ({ id, tab, data, onEvent `} data-testid="ActionNodeWrapper" id={nodeId} + // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex tabIndex={0} {...declareElementAttributes(selectableId, id)} aria-label={generateActionTitle(data, '', '', tab)} @@ -110,4 +143,29 @@ export const ActionNodeWrapper: FC = ({ id, tab, data, onEvent {children} ); + + return !hideComment && data.$designer?.comment ? ( + + + + + + {content} + + + ) : ( + content + ); }; diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/index.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/index.tsx index b33b481944..90de591371 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/index.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/index.tsx @@ -29,9 +29,16 @@ export const VisualEditorEdgeMenu: EdgeMenuComponent = ({ arrayId, arrayPosition ); }; -export const VisualEditorNodeWrapper: NodeWrapperComponent = ({ nodeId, nodeData, nodeTab, onEvent, children }) => { +export const VisualEditorNodeWrapper: NodeWrapperComponent = ({ + nodeId, + nodeData, + nodeTab, + onEvent, + children, + ...rest +}) => { return ( - + {children} ); diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/configs/builtinWidgets.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/configs/builtinWidgets.ts index 240fd0d97f..f70734265f 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/configs/builtinWidgets.ts +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/configs/builtinWidgets.ts @@ -6,6 +6,7 @@ import { ListOverview } from '@bfc/ui-shared'; import { ActionCard, + ActionCardBody, DialogRef, PromptWidget, IfConditionWidget, @@ -18,6 +19,7 @@ import { const builtinActionWidgets: FlowEditorWidgetMap = { ActionCard, + ActionCardBody, DialogRef, PromptWidget, IfConditionWidget, diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/constants/ElementSizes.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/constants/ElementSizes.ts index 46f8f0452d..da14ffccd3 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/constants/ElementSizes.ts +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/constants/ElementSizes.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. export const StandardNodeWidth = 300; -export const StandardSectionHeight = 30; +export const StandardSectionHeight = 32; export const HeaderHeight = StandardSectionHeight; export const InitNodeSize = { diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/types/PluggableComponents.types.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/types/PluggableComponents.types.ts index fe232e43b8..bbd02a42e9 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/types/PluggableComponents.types.ts +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/types/PluggableComponents.types.ts @@ -34,6 +34,7 @@ export interface NodeWrapperProps extends EventBasedElement, StyledElement { nodeId: string; nodeData: any; onEvent: EditorEventHandler; + hideComment?: boolean; /** Additional child id for multipart nodes such as 'BotAsks/UserInputs' in TextInput * to fit complicated view features like double-selection. diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/ActionCard.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/ActionCard.tsx index 096711dea4..f70ce26aaa 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/ActionCard.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/ActionCard.tsx @@ -7,6 +7,7 @@ import { WidgetContainerProps, WidgetComponent } from '@bfc/extension-client'; import { ActionHeader } from '../ActionHeader'; import { CardTemplate } from './CardTemplate'; +import { ActionCardBody } from './ActionCardBody'; export interface ActionCardProps extends WidgetContainerProps { header?: ReactNode; @@ -33,6 +34,16 @@ const safeRender = (input: object | React.ReactNode) => { return input; }; +const renderBody = (rawBody: React.ReactNode, ctx: any) => { + const body = safeRender(rawBody); + + if (React.isValidElement(body) && body.type === ActionCardBody) { + return body; + } + + return ; +}; + export const ActionCard: WidgetComponent = ({ header, body, @@ -42,7 +53,9 @@ export const ActionCard: WidgetComponent = ({ }) => { const disabled = widgetContext.data.disabled === true; const headerNode = safeRender(header) || ; - const bodyNode = safeRender(body); + const bodyNode = renderBody(body, widgetContext); const footerNode = hideFooter ? null : safeRender(footer); - return ; + return ( + + ); }; diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/ActionCardBody.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/ActionCardBody.tsx new file mode 100644 index 0000000000..0cec23a361 --- /dev/null +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/ActionCardBody.tsx @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import { WidgetContainerProps, WidgetComponent, useShellApi } from '@bfc/extension-client'; + +import { CardComment } from './CardComment'; + +const containerStyle = (theme?: string) => css` + padding: 8px 8px; + margin: -8px -8px; + background-color: ${theme ? theme : 'inherit'}; +`; + +const textStyles = (color?: string, truncate?: boolean) => css` + color: ${color ? color : 'inherit'}; + overflow: hidden; + + // https://css-tricks.com/line-clampin/#weird-webkit-flexbox-way + ${truncate + ? ` +max-height: 48px; +display: -webkit-box; +-webkit-line-clamp: 3; +-webkit-box-orient: vertical; +` + : undefined} +`; + +interface ActionCardBodyProps extends WidgetContainerProps { + body?: string; + colors?: { + theme?: string; + color?: string; + }; + truncate?: boolean; + hideComment?: boolean; +} + +export const ActionCardBody: WidgetComponent = ({ body, truncate, hideComment, data }) => { + const { flowCommentsVisible } = useShellApi(); + const comment = data.$designer?.comment; + + return ( + + {!hideComment && flowCommentsVisible && comment && } + {body || ' '} + + ); +}; diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardComment.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardComment.tsx new file mode 100644 index 0000000000..c03144566f --- /dev/null +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardComment.tsx @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import React from 'react'; +import { TruncatedText } from '@bfc/ui-shared'; +import { NeutralColors } from '@uifabric/fluent-theme'; + +type CardCommentProps = { + comment?: string; +}; + +const styles = { + container: css` + background-color: #fff4ce; + padding: 8px; + padding-bottom: 10px; + border-radius: 2px; + margin-bottom: 4px; + position: relative; + `, + fold: css` + position: absolute; + bottom: 0; + left: 0; + height: 0; + width: 0; + border-top: 4px solid #ded2a7; + border-left: 4px solid ${NeutralColors.white}; + border-right: 4px solid #ded2a7; + border-bottom: 4px solid ${NeutralColors.white}; + `, +}; + +const CardComment: React.FC = ({ comment }) => { + return ( + + {comment} + + + ); +}; + +export { CardComment }; diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardTemplate.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardTemplate.tsx index dd45c59440..16e9996fc6 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardTemplate.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardTemplate.tsx @@ -5,6 +5,7 @@ import { jsx } from '@emotion/core'; import { FC, ReactNode } from 'react'; import { TextDiv } from '@bfc/ui-shared'; +import { WidgetContainerProps } from '@bfc/extension-client'; import { StandardNodeWidth } from '../../constants/ElementSizes'; import { ObiColors } from '../../constants/ElementColors'; @@ -18,8 +19,9 @@ import { CardContainerCSS, DisabledCardContainerCSS, } from './CardTemplateStyle'; +// import { CardComment } from './CardComment'; -export interface CardTemplateProps { +export interface CardTemplateProps extends WidgetContainerProps { header: ReactNode; body?: ReactNode; footer?: ReactNode; diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardTemplateStyle.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardTemplateStyle.ts index dd9c783ccb..4ac754f2a1 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardTemplateStyle.ts +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/CardTemplateStyle.ts @@ -19,7 +19,7 @@ export const HeaderCSS = css` export const BodyCSS = css` ${fullWidthSection}; min-height: ${StandardSectionHeight}px; - padding: 7px 8px; + padding: 8px 8px; `; export const FooterCSS = css` diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/index.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/index.tsx index 7cc0f3a2e8..00c0cf3b2b 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/index.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/ActionCard/index.tsx @@ -2,3 +2,4 @@ // Licensed under the MIT License. export { ActionCard } from './ActionCard'; +export { ActionCardBody } from './ActionCardBody'; diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/PromptWidget.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/PromptWidget.tsx index 960d115fb1..cecb615f82 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/PromptWidget.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/PromptWidget.tsx @@ -79,7 +79,13 @@ export const PromptWidget: FC = ({ - + { designerCache.cacheBoundary(userAnswersNode.data, boundary); @@ -91,7 +97,7 @@ export const PromptWidget: FC = ({ - + onEvent(NodeEventTypes.Focus, { id, tab: PromptTab.OTHER })} diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/index.ts b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/index.ts index 825ea4bd71..32461fa53a 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/index.ts +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/index.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export { ActionCard } from './ActionCard'; +export { ActionCard, ActionCardBody } from './ActionCard'; export { DialogRef } from './DialogRef'; export { PromptWidget } from './PromptWidget'; export { IfConditionWidget } from './IfConditionWidget'; diff --git a/Composer/packages/adaptive-flow/tsconfig.json b/Composer/packages/adaptive-flow/tsconfig.json index 99600667df..8ed40b0fd1 100644 --- a/Composer/packages/adaptive-flow/tsconfig.json +++ b/Composer/packages/adaptive-flow/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./lib/", - "module": "esnext", + "module": "esnext" }, "include": ["./src/**/*", "./__tests__/**/*"] } diff --git a/Composer/packages/adaptive-form/package.json b/Composer/packages/adaptive-form/package.json index 69fe0acae0..5414f9a690 100644 --- a/Composer/packages/adaptive-form/package.json +++ b/Composer/packages/adaptive-form/package.json @@ -22,20 +22,22 @@ "@uifabric/icons": "^7.3.0", "@uifabric/styling": "^7.7.4", "format-message": "^6.2.3", - "office-ui-fabric-react": "^7.71.0", + "office-ui-fabric-react": "7.71.0", "react": "16.13.1", "react-dom": "16.13.1" }, "devDependencies": { "@botframework-composer/test-utils": "*", "@types/lodash": "^4.14.149", - "@types/react": "16.9.23" + "@types/react": "16.9.23", + "office-ui-fabric-react": "7.71.0" }, "dependencies": { "@bfc/built-in-functions": "*", "@bfc/code-editor": "*", "@bfc/extension-client": "*", "@bfc/intellisense": "*", + "@bfc/ui-shared": "*", "@emotion/core": "^10.0.27", "lodash": "^4.17.19", "react-error-boundary": "^1.2.5" diff --git a/Composer/packages/adaptive-form/src/components/Comment.tsx b/Composer/packages/adaptive-form/src/components/Comment.tsx new file mode 100644 index 0000000000..d529b3e2a4 --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/Comment.tsx @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import formatMessage from 'format-message'; +import { IconMenu } from '@bfc/ui-shared'; +import { TextField, ITextField } from 'office-ui-fabric-react/lib/TextField'; +import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; + +import { ExpandableText } from './ExpandableText'; + +const styles = { + note: css` + background-color: #fff4ce; + padding: 14px 10px 10px 10px; + position: relative; + border-radius: 3px; + `, + menu: css` + position: absolute; + right: 4px; + top: 8px; + `, + noteBody: css` + font-size: 12px; + margin: 0; + padding-right: 18px; + white-space: pre-line; + overflow-wrap: break-word; + `, + showMore: css` + display: flex; + justify-content: flex-end; + margin-top: 5px; + `, +}; + +type CommentProps = { + comment?: string; + onChange: (newComment?: string) => void; +}; + +const Comment: React.FC = ({ comment, onChange }) => { + const [isEditing, setIsEditing] = useState(!comment); + const textfieldRef = useRef(null); + const editingStateRef = useRef(isEditing); + + useEffect(() => { + // send focus to the text field if toggling from view mode to edit mode + if (!editingStateRef.current && isEditing) { + textfieldRef.current?.focus(); + } + editingStateRef.current = isEditing; + }, [isEditing]); + + const handleChange = useCallback( + (_e, val?: string) => { + onChange(val); + }, + [onChange] + ); + + const handleBlur = useCallback(() => { + if (comment && comment.length > 0) { + setIsEditing(false); + } + }, [comment]); + + const menuItems: IContextualMenuItem[] = [ + { + key: 'edit', + name: formatMessage('Edit'), + iconProps: { + iconName: 'Edit', + }, + onClick: () => { + setIsEditing(true); + }, + }, + { + key: 'delete', + name: formatMessage('Delete'), + iconProps: { + iconName: 'Delete', + }, + onClick: () => { + handleChange({}, ''); + setIsEditing(true); + }, + }, + ]; + + return ( + + + {!isEditing && ( + + + + + {comment} + + )} + + ); +}; + +export { Comment }; diff --git a/Composer/packages/adaptive-form/src/components/ExpandableText.tsx b/Composer/packages/adaptive-form/src/components/ExpandableText.tsx new file mode 100644 index 0000000000..2c5ac8c37b --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/ExpandableText.tsx @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import React, { useState, useRef, useEffect } from 'react'; +import formatMessage from 'format-message'; + +import { Link } from './Link'; + +const styles = { + body: (maxLines = 5, isExpanded = false) => css` + // https://css-tricks.com/line-clampin/#weird-webkit-flexbox-way + ${!isExpanded + ? ` + overflow: hidden; + max-height: ${maxLines * 16}px; + display: -webkit-box; + -webkit-line-clamp: ${maxLines}; + -webkit-box-orient: vertical; + ` + : undefined} + `, + showMore: css` + display: flex; + justify-content: flex-end; + margin-top: 5px; + `, +}; + +type ExpandableTextProps = { + /** + * Number of lines allowed before truncation. + * @default 5 + */ + maxLines?: number; + /** + * Optional class name to pass to child wrapper. + */ + className?: string; + /** + * Text to truncate if necessary. + */ + children?: string; +}; + +const ExpandableText = ({ maxLines = 5, className, children }: ExpandableTextProps) => { + const [needsTruncation, setNeedsTruncation] = useState(false); + const [expanded, setExpanded] = useState(false); + const childRef = useRef(null); + + useEffect(() => { + const el = childRef.current; + setNeedsTruncation(Boolean(el && el.scrollHeight > el.clientHeight)); + }, [children]); + + return ( + + + {children} + + {needsTruncation && ( + + setExpanded((current) => !current)}> + {expanded ? formatMessage('Show Less') : formatMessage('Show More')} + + + )} + + ); +}; + +export { ExpandableText }; diff --git a/Composer/packages/adaptive-form/src/components/FormTitle.tsx b/Composer/packages/adaptive-form/src/components/FormTitle.tsx index 94c36c442e..63e8b93369 100644 --- a/Composer/packages/adaptive-form/src/components/FormTitle.tsx +++ b/Composer/packages/adaptive-form/src/components/FormTitle.tsx @@ -14,6 +14,7 @@ import debounce from 'lodash/debounce'; import { EditableField } from './fields/EditableField'; import { Link } from './Link'; +import { Comment } from './Comment'; export const styles = { container: css` @@ -37,6 +38,10 @@ export const styles = { white-space: pre-line; font-size: ${FontSizes.size12}; `, + + comment: css` + margin: 18px 0; + `, }; interface FormTitleProps { @@ -92,6 +97,15 @@ const FormTitle: React.FC = (props) => { }); }; + const handleCommentChange = (newComment?: string) => { + props.onChange({ + $designer: { + ...formData.$designer, + comment: newComment, + }, + }); + }; + const uiLabel = typeof uiOptions?.label === 'function' ? uiOptions.label(formData) : uiOptions.label; const uiSubtitle = typeof uiOptions?.subtitle === 'function' ? uiOptions.subtitle(formData) : uiOptions.subtitle; const initialValue = useMemo(() => { @@ -164,6 +178,13 @@ const FormTitle: React.FC = (props) => { )} + + + {props.children} diff --git a/Composer/packages/adaptive-form/src/components/SchemaField.tsx b/Composer/packages/adaptive-form/src/components/SchemaField.tsx index 4619c27879..11bf1f6392 100644 --- a/Composer/packages/adaptive-form/src/components/SchemaField.tsx +++ b/Composer/packages/adaptive-form/src/components/SchemaField.tsx @@ -14,6 +14,7 @@ const schemaField = { display: flex; flex-direction: column; margin: 10px ${isRoot ? 18 : 0}px; + font-size: 14px; label: SchemaFieldContainer; `, diff --git a/Composer/packages/adaptive-form/src/components/__tests__/Comment.test.tsx b/Composer/packages/adaptive-form/src/components/__tests__/Comment.test.tsx new file mode 100644 index 0000000000..9734fb6f9c --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/__tests__/Comment.test.tsx @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { render, act, faker, userEvent } from '@botframework-composer/test-utils'; + +import { Comment } from '../Comment'; + +describe('', () => { + let onChange: jest.Mock = jest.fn(); + + beforeEach(() => { + onChange = jest.fn(); + }); + + it('renders a text field if no comment present', async () => { + const { findByPlaceholderText, queryByTestId } = render(); + + const textfield = await findByPlaceholderText('Add a note'); + + expect(textfield).toBeVisible(); + + expect(queryByTestId('CommentCard')).not.toBeInTheDocument(); + }); + + it('can edit an existing comment', async () => { + const comment = faker.lorem.paragraph(); + const { findByLabelText, findByPlaceholderText, findByText, queryByTestId } = render( + + ); + + expect(queryByTestId('CommentCard')).toBeVisible(); + const textfield = await findByPlaceholderText('Add a note'); + expect(textfield).not.toBeVisible(); + + const menu = await findByLabelText('Comment menu'); + act(() => { + userEvent.click(menu); + }); + + const editBtn = await findByText('Edit'); + act(() => { + userEvent.click(editBtn); + }); + expect(textfield).toBeVisible(); + expect(textfield).toHaveValue(comment); + expect(queryByTestId('CommentCard')).not.toBeInTheDocument(); + + act(() => { + userEvent.type(textfield, 'a'); + }); + + expect(onChange).toHaveBeenCalledWith(comment + 'a'); + }); + + it('can delete a comment', async () => { + const comment = faker.lorem.paragraph(); + const { findByLabelText, findByPlaceholderText, findByText, queryByTestId } = render( + + ); + + expect(queryByTestId('CommentCard')).toBeInTheDocument(); + let textfield = await findByPlaceholderText('Add a note'); + expect(textfield).not.toBeVisible(); + + const menu = await findByLabelText('Comment menu'); + act(() => { + userEvent.click(menu); + }); + + const deleteBtn = await findByText('Delete'); + act(() => { + userEvent.click(deleteBtn); + }); + textfield = await findByPlaceholderText('Add a note'); + expect(textfield).toBeVisible(); + expect(onChange).toHaveBeenCalledWith(''); + expect(queryByTestId('CommentCard')).not.toBeInTheDocument(); + }); +}); diff --git a/Composer/packages/adaptive-form/src/components/__tests__/ExpandableText.test.tsx b/Composer/packages/adaptive-form/src/components/__tests__/ExpandableText.test.tsx new file mode 100644 index 0000000000..9f0eb966be --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/__tests__/ExpandableText.test.tsx @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { render, act, faker, userEvent } from '@botframework-composer/test-utils'; + +import { ExpandableText } from '../ExpandableText'; + +describe('', () => { + const scrollHeightSpy = jest.spyOn(HTMLElement.prototype, 'scrollHeight', 'get'); + const clientHeightSpy = jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get'); + const text = faker.lorem.sentence(); + + beforeEach(() => { + scrollHeightSpy.mockClear(); + clientHeightSpy.mockClear(); + }); + + it('does not truncate if the text does not overflow max lines', () => { + const maxLines = 2; + scrollHeightSpy.mockReturnValue(maxLines * 16); + clientHeightSpy.mockReturnValue(maxLines * 16); + + const { getByTestId, queryByText } = render({text}); + const content = getByTestId('ExpandableTextContent'); + expect(content.getAttribute('data-istruncated')).toEqual('false'); + expect(queryByText('Show More')).not.toBeInTheDocument(); + }); + + it('truncates if the text overflows max lines', () => { + const maxLines = 2; + scrollHeightSpy.mockReturnValue((maxLines + 1) * 16); + clientHeightSpy.mockReturnValue(maxLines * 16); + + const { getByTestId, queryByText } = render({text}); + const content = getByTestId('ExpandableTextContent'); + expect(content.getAttribute('data-istruncated')).toEqual('true'); + expect(queryByText('Show More')).toBeInTheDocument(); + }); + + it('can expand to show all content when truncated', () => { + const maxLines = 2; + scrollHeightSpy.mockReturnValue((maxLines + 1) * 16); + clientHeightSpy.mockReturnValue(maxLines * 16); + + const { getByTestId, getByText } = render({text}); + const content = getByTestId('ExpandableTextContent'); + expect(content.getAttribute('data-istruncated')).toEqual('true'); + + const showMoreBtn = getByText('Show More'); + + act(() => { + userEvent.click(showMoreBtn); + }); + + expect(content.getAttribute('data-istruncated')).toEqual('false'); + expect(getByText('Show Less')).toBeInTheDocument(); + }); +}); diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx index d8bf399ea7..70d871ae5c 100644 --- a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx @@ -1,59 +1,159 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. /** @jsx jsx */ -import { jsx } from '@emotion/core'; -import React, { useMemo } from 'react'; +import { jsx, css } from '@emotion/core'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { FieldProps, useShellApi, useRecognizerConfig } from '@bfc/extension-client'; import { MicrosoftIRecognizer } from '@bfc/shared'; -import { Dropdown, ResponsiveMode, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; import formatMessage from 'format-message'; +import { Dialog, DialogFooter, DialogType } from 'office-ui-fabric-react/lib/Dialog'; +import { ScrollablePane, ScrollbarVisibility } from 'office-ui-fabric-react/lib/ScrollablePane'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { CheckboxVisibility, DetailsList, SelectionMode, Selection } from 'office-ui-fabric-react/lib/DetailsList'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; import { FieldLabel } from '../../FieldLabel'; import { useMigrationEffect } from './useMigrationEffect'; -import { mapDropdownOptionToRecognizerSchema } from './mappers'; -import { getDropdownOptions } from './getDropdownOptions'; +import { mapListItemsToRecognizerSchema } from './mappers'; +import { getDetailsListItems } from './getDetailsListItems'; +const recognizerStyle = css` + display: flex; + justify-content: space-between; + margin: 5px 0px 10px 0px; +`; +const AzureBlue = '#0078D4'; +const recognizerContainer = css` + position: relative; + height: 500px; + border: 1px solid #f3f2f1; +`; +const learnRecognizerUrl = 'https://docs.microsoft.com/en-us/composer/concept-dialog?tabs=v2x#recognizer'; + +export type RecognizerListItem = { + key: string; + text: string; + description: string; +}; export const RecognizerField: React.FC> = (props) => { const { value, id, label, description, uiOptions, required, onChange } = props; const { shellApi, ...shellData } = useShellApi(); const { telemetryClient } = shellApi; + const [showDialog, setShowDialog] = useState(false); + const [selectedRecognizer, setSelectedRecognizer] = useState(); useMigrationEffect(value, onChange); const { recognizers: recognizerConfigs, currentRecognizer } = useRecognizerConfig(); - const dropdownOptions = useMemo(() => getDropdownOptions(recognizerConfigs, shellData, shellApi), [ + const detailsListItems = useMemo(() => getDetailsListItems(recognizerConfigs, shellData, shellApi), [ recognizerConfigs, ]); const RecognizerEditor = currentRecognizer?.recognizerEditor; const widget = RecognizerEditor ? : null; - const submit = (_, option?: IDropdownOption): void => { - if (!option) return; + const selection = useMemo(() => { + return new Selection({ + onSelectionChanged: () => { + const selectedItems = selection.getSelection() as RecognizerListItem[]; + if (selectedItems?.length > 0) { + setSelectedRecognizer(selectedItems[0]); + } + }, + }); + }, [setSelectedRecognizer]); - const recognizerDefinition = mapDropdownOptionToRecognizerSchema(option, recognizerConfigs); + const submit = useCallback((): void => { + if (!selectedRecognizer) return; + + const recognizerDefinition = mapListItemsToRecognizerSchema(selectedRecognizer, recognizerConfigs); const seedNewRecognizer = recognizerDefinition?.seedNewRecognizer; const recognizerInstance = typeof seedNewRecognizer === 'function' ? seedNewRecognizer(shellData, shellApi) - : { $kind: option.key as string, intents: [] }; // fallback to default Recognizer instance; + : { $kind: selectedRecognizer.key as string, intents: [] }; // fallback to default Recognizer instance; onChange(recognizerInstance); - telemetryClient?.track('RecognizerChanged', { recognizer: option.key as string }); - }; + telemetryClient?.track('RecognizerChanged', { recognizer: selectedRecognizer.key as string }); + }, [selectedRecognizer, recognizerConfigs, shellData, shellApi]); + + useEffect(() => { + if (selection && currentRecognizer) { + selection.setItems(detailsListItems, false); + selection.setKeySelected(currentRecognizer.id, true, false); + } + }, [detailsListItems]); return ( - + {formatMessage('Recognizer/Dispatch type')} + + + {typeof currentRecognizer?.displayName === 'function' + ? currentRecognizer?.displayName({}) + : currentRecognizer?.displayName} + + setShowDialog(true)}> + {formatMessage('Change')} + + + setShowDialog(false)} + > + + + { + return ( + + {item.text} + {item.description} + + ); + }, + }, + ]} + isHeaderVisible={false} + items={detailsListItems} + selection={selection} + selectionMode={SelectionMode.single} + /> + + + + + {formatMessage('Learn more about recognizers')} + + setShowDialog(false)} /> + { + setShowDialog(false); + submit(); + }} + /> + + {widget} ); diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDetailsListItems.ts similarity index 85% rename from Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts rename to Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDetailsListItems.ts index 09fad911d5..cb85cacf57 100644 --- a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDetailsListItems.ts @@ -5,7 +5,7 @@ import { RecognizerSchema, FallbackRecognizerKey, ShellApi, ShellData } from '@b import { checkForPVASchema } from '@bfc/shared'; import { recognizerOrderMap } from './defaultRecognizerOrder'; -import { mapRecognizerSchemaToDropdownOption } from './mappers'; +import { mapRecognizerSchemaToListItems } from './mappers'; const getRankScore = (r: RecognizerSchema, shellData: ShellData, shellApi: ShellApi) => { // Always put disabled recognizer behind. Handle 'disabled' before 'default'. @@ -17,7 +17,7 @@ const getRankScore = (r: RecognizerSchema, shellData: ShellData, shellApi: Shell return recognizerOrderMap[r.id] ?? Number.MAX_VALUE - 1; }; -export const getDropdownOptions = (configs: RecognizerSchema[], shellData: ShellData, shellApi: ShellApi) => { +export const getDetailsListItems = (configs: RecognizerSchema[], shellData: ShellData, shellApi: ShellApi) => { const isPVASchema = checkForPVASchema(shellData.schemas?.sdk); let recognizerConfigs: RecognizerSchema[] = configs; if (isPVASchema) { @@ -31,5 +31,5 @@ export const getDropdownOptions = (configs: RecognizerSchema[], shellData: Shell .sort((r1, r2) => { return getRankScore(r1, shellData, shellApi) - getRankScore(r2, shellData, shellApi); }) - .map(mapRecognizerSchemaToDropdownOption); + .map(mapRecognizerSchemaToListItems); }; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts index 47c629af22..cbee5179d2 100644 --- a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts @@ -2,14 +2,16 @@ // Licensed under the MIT License. import { RecognizerSchema } from '@bfc/extension-client'; -import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; -export const mapDropdownOptionToRecognizerSchema = (option: IDropdownOption, recognizerConfigs: RecognizerSchema[]) => { - return recognizerConfigs.find((r) => r.id === option.key); +import { RecognizerListItem } from './RecognizerField'; + +export const mapListItemsToRecognizerSchema = (item: RecognizerListItem, recognizerConfigs: RecognizerSchema[]) => { + return recognizerConfigs.find((r) => r.id === item.key); }; -export const mapRecognizerSchemaToDropdownOption = (recognizerSchema: RecognizerSchema): IDropdownOption => { - const { id, displayName } = recognizerSchema; +export const mapRecognizerSchemaToListItems = (recognizerSchema: RecognizerSchema) => { + const { id, displayName, description } = recognizerSchema; const recognizerName = typeof displayName === 'function' ? displayName({}) : displayName; - return { key: id, text: recognizerName || id }; + const recognizerDescription = typeof description === 'function' ? description({}) : description; + return { key: id, text: recognizerName || id, description: recognizerDescription }; }; diff --git a/Composer/packages/adaptive-form/src/components/fields/StringField.tsx b/Composer/packages/adaptive-form/src/components/fields/StringField.tsx index 8d8df6de8d..c790c485cc 100644 --- a/Composer/packages/adaptive-form/src/components/fields/StringField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/StringField.tsx @@ -65,14 +65,17 @@ export const StringField: React.FC> = function StringField(pr ( - + intentEditor: ({ id, onChange, label }) => ( + Test Recognizer Update ), 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 18b9bc371c..3ffe676707 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 '@botframework-composer/test-utils'; +import { render, fireEvent, screen, getAllByText } from '@botframework-composer/test-utils'; import { useRecognizerConfig, useShellApi } from '@bfc/extension-client'; import assign from 'lodash/assign'; @@ -37,12 +37,14 @@ describe('', () => { { id: 'one', displayName: 'One Recognizer', + description: 'test1', isSelected: () => false, seedNewRecognizer: handleChange, }, { id: 'two', displayName: 'Two Recognizer', + description: 'test2', isSelected: () => true, seedNewRecognizer: jest.fn(), }, @@ -51,12 +53,17 @@ describe('', () => { recognizers, currentRecognizer: recognizers[1], }); - const { getByTestId } = renderSubject({ value: { $kind: 'two' } }); - const dropdown = getByTestId('recognizerTypeDropdown'); - expect(dropdown).toHaveTextContent('Two Recognizer'); - fireEvent.click(dropdown); + const { getByText, getAllByText } = renderSubject({ value: { $kind: 'two' } }); + // two recognizer already choosed + expect(getByText('Two Recognizer')).not.toBeNull(); + + // click change recognizer, pop up dialog + fireEvent.click(getByText('Change')); + expect(getByText('One Recognizer')).not.toBeNull(); + expect(getAllByText('Two Recognizer').length).toBe(2); fireEvent.click(screen.getByText('One Recognizer')); + fireEvent.click(screen.getByText('Done')); expect(handleChange).toHaveBeenCalled(); }); }); diff --git a/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts b/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts index 157601079d..9b77db9c37 100644 --- a/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts +++ b/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts @@ -92,7 +92,7 @@ export function resolveFieldWidget(params: { return { field: DefaultFields.IntellisenseTextField }; } else if (showIntellisense && !isOneOf) { return { field: IntellisenseTextFieldWithIcon }; - } else if (!showIntellisense && !isOneOf) { + } else if (!showIntellisense && !isOneOf && !uiOptions?.multiline) { return { field: StringFieldWithIcon }; } return { diff --git a/Composer/packages/client/__mocks__/@azure/arm-resources.ts b/Composer/packages/client/__mocks__/@azure/arm-resources.ts new file mode 100644 index 0000000000..12e5477723 --- /dev/null +++ b/Composer/packages/client/__mocks__/@azure/arm-resources.ts @@ -0,0 +1,21 @@ +let mock = jest.createMockFromModule('@azure/arm-resources'); + +function ResourceManagementClient() { + return { + resourceGroups: { + list: async () => { + return [ + { + id: 'mockedGroup', + name: 'mockedGroup', + region: 'westus', + }, + ]; + }, + }, + }; +} + +mock.ResourceManagementClient = ResourceManagementClient; + +module.exports = mock; diff --git a/Composer/packages/client/__mocks__/@azure/arm-subscriptions.ts b/Composer/packages/client/__mocks__/@azure/arm-subscriptions.ts new file mode 100644 index 0000000000..9fcbbabfa6 --- /dev/null +++ b/Composer/packages/client/__mocks__/@azure/arm-subscriptions.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +let mock = jest.createMockFromModule('@azure/arm-subscriptions'); + +function SubscriptionClient() { + return { + subscriptions: { + list: async () => { + return { + _response: { + parsedBody: [ + { + subscriptionId: 'mockSubscription', + displayName: 'mockSubscription', + }, + ], + }, + }; + }, + listLocations: async () => { + return [{ name: 'westus', displayName: 'West US' }]; + }, + }, + }; +} +mock.SubscriptionClient = SubscriptionClient; + +module.exports = mock; diff --git a/Composer/packages/client/__mocks__/@azure/ms-rest-js.ts b/Composer/packages/client/__mocks__/@azure/ms-rest-js.ts new file mode 100644 index 0000000000..8359113469 --- /dev/null +++ b/Composer/packages/client/__mocks__/@azure/ms-rest-js.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +module.exports = jest.createMockFromModule('@azure/ms-rest-js'); diff --git a/Composer/packages/client/__mocks__/axios.ts b/Composer/packages/client/__mocks__/axios.ts new file mode 100644 index 0000000000..7f40a8fba7 --- /dev/null +++ b/Composer/packages/client/__mocks__/axios.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +module.exports = jest.createMockFromModule('axios'); diff --git a/Composer/packages/client/__mocks__/mockManifest.zip b/Composer/packages/client/__mocks__/mockManifest.zip new file mode 100644 index 0000000000..373aa3dd63 Binary files /dev/null and b/Composer/packages/client/__mocks__/mockManifest.zip differ diff --git a/Composer/packages/client/__tests__/components/Adapters/ABSChannels.test.tsx b/Composer/packages/client/__tests__/components/Adapters/ABSChannels.test.tsx new file mode 100644 index 0000000000..a6d5419223 --- /dev/null +++ b/Composer/packages/client/__tests__/components/Adapters/ABSChannels.test.tsx @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { act, fireEvent } from '@botframework-composer/test-utils'; + +import { renderWithRecoil } from '../../testUtils/renderWithRecoil'; +import ABSChannels from '../../../src/pages/botProject/adapters/ABSChannels'; +import { botDisplayNameState, settingsState } from '../../../src/recoilModel'; +import httpClient from '../../../src/utils/httpUtil'; + +jest.mock('../../../src/utils/httpUtil'); + +const mockNavigationTo = jest.fn(); +jest.mock('../../../src/utils/navigation', () => ({ + navigateTo: (...args) => mockNavigationTo(...args), +})); + +jest.mock('../../../src/utils/auth', () => ({ + decodeToken: () => { + return { + upn: 'mockUser@mockDomain.com', + name: 'mockUser', + exp: new Date().getTime(), + tenant: 'mockTenant', + }; + }, + userShouldProvideTokens: jest.fn(), + isShowAuthDialog: jest.fn(), + getTokenFromCache: jest.fn(), + setTenantId: jest.fn(), + getTenantIdFromCache: jest.fn(), + prepareAxios: jest.fn(), +})); + +jest.mock('../../../src/components/Auth/AuthDialog', () => ({ + AuthDialog: ({ children, onClick }) => , +})); + +jest.mock('../../../src/utils/authClient', () => ({ + AuthClient: { + getTenants: async () => { + return [ + { + displayName: 'mockTenant', + tenantId: 'mockTenant', + }, + ]; + }, + getARMTokenForTenant: async () => 'mockToken', + getAccessToken: async () => 'mockToken', + }, +})); + +const CHANNELS = { + TEAMS: 'MsTeamsChannel', + WEBCHAT: 'WebChatChannel', + SPEECH: 'DirectLineSpeechChannel', +}; + +const mockProjId = '123'; +const mockDisplayName = ''; +const mockPublishTargetName = 'target1'; +const mockConfigName = mockPublishTargetName; +const mockBotName = 'mockBotName'; +const mockAppId = '123'; +const mockSubscriptionId = '456'; +const mockTenantId = '123'; +const mockResourceGroup = 'mockResourceGroup'; +const mockTokenValue = 'mockToken'; + +const mockTargetConfig = { + name: mockConfigName, + botName: mockBotName, + MicrosoftAppId: mockAppId, + resourceGroup: mockResourceGroup, + tenantId: mockTenantId, + subscriptionId: mockSubscriptionId, +}; + +const luisConfig = { + name: '', + authoringKey: '12345', + authoringEndpoint: 'testAuthoringEndpoint', + endpointKey: '12345', + endpoint: 'testEndpoint', + authoringRegion: 'westus', + defaultLanguage: 'en-us', + environment: 'composer', +}; + +const qnaConfig = { subscriptionKey: '12345', endpointKey: '12345', qnaRegion: 'westus' }; + +const mockSettingsState = { + luis: luisConfig, + qna: qnaConfig, + defaultLanguage: 'en-us', + languages: ['en-us'], + luFeatures: {}, + runtime: { + key: '', + customRuntime: true, + path: '', + command: '', + }, + importedLibraries: [], + customFunctions: [], + publishTargets: [ + { + name: mockPublishTargetName, + type: 'azurewebapp', + configuration: JSON.stringify(mockTargetConfig), + }, + ], +}; + +describe('', () => { + beforeEach(() => { + (httpClient.get as jest.Mock) = jest + .fn() + .mockResolvedValue({ data: { properties: { properties: { acceptedTerms: true } } } }); + (httpClient.put as jest.Mock) = jest.fn().mockResolvedValue({}); + (httpClient.delete as jest.Mock) = jest.fn().mockResolvedValue({}); + }); + + const recoilInitState = ({ set }) => { + set(botDisplayNameState(mockProjId), mockDisplayName); + set(settingsState(mockProjId), mockSettingsState); + }; + + function renderComponent() { + return renderWithRecoil(, recoilInitState); + } + + async function renderComponentOnInitView() { + const component = renderComponent(); + const dropdown = await component.getByTestId('publishTargetDropDown'); + + await act(async () => { + await fireEvent.click(dropdown); + }); + const publishTarget = await component.getByText(mockPublishTargetName); + + await act(async () => { + await fireEvent.click(publishTarget); + }); + return component; + } + + it('should render the component with all connections', async () => { + const component = await renderComponentOnInitView(); + + expect(await component.findByText('MS Teams')).toBeTruthy(); + expect(await component.findByText('Web Chat')).toBeTruthy(); + expect(await component.findByText('Speech')).toBeTruthy(); + expect( + httpClient.get + ).toBeCalledWith( + `https://management.azure.com/subscriptions/${mockSubscriptionId}/resourceGroups/${mockResourceGroup}/providers/Microsoft.BotService/botServices/${mockBotName}/channels/MsTeamsChannel?api-version=2020-06-02`, + { headers: { Authorization: `Bearer ${mockTokenValue}` } } + ); + }); + + it('should enable/disable Teams connection', async () => { + const mockData = { + location: 'global', + name: `${mockBotName}/${CHANNELS.TEAMS}`, + properties: { + channelName: CHANNELS.TEAMS, + location: 'global', + properties: { + acceptedTerms: undefined, + isEnabled: true, + }, + }, + }; + + const component = await renderComponentOnInitView(); + + // assert that documentation and manifest link are present when enabled + expect(component.container).toHaveTextContent('Learn more'); + expect(component.container).toHaveTextContent('Open manifest'); + + const teamsToggle = await component.getByTestId(`${CHANNELS.TEAMS}_toggle`); + await act(async () => { + await fireEvent.click(teamsToggle); + }); + expect( + httpClient.delete + ).toBeCalledWith( + `https://management.azure.com/subscriptions/${mockSubscriptionId}/resourceGroups/${mockResourceGroup}/providers/Microsoft.BotService/botServices/${mockBotName}/channels/MsTeamsChannel?api-version=2020-06-02`, + { headers: { Authorization: `Bearer ${mockTokenValue}` } } + ); + await act(async () => { + await fireEvent.click(teamsToggle); + }); + expect( + httpClient.put + ).toBeCalledWith( + `https://management.azure.com/subscriptions/${mockSubscriptionId}/resourceGroups/${mockResourceGroup}/providers/Microsoft.BotService/botServices/${mockBotName}/channels/MsTeamsChannel?api-version=2020-06-02`, + mockData, + { headers: { Authorization: `Bearer ${mockTokenValue}` } } + ); + }); + + it('should call webchat endpoint to enable/disable', async () => { + const mockData = { + name: `${mockBotName}/${CHANNELS.WEBCHAT}`, + type: 'Microsoft.BotService/botServices/channels', + location: 'global', + properties: { + properties: { + webChatEmbedCode: null, + sites: [ + { + siteName: 'Default Site', + isEnabled: true, + isWebchatPreviewEnabled: true, + }, + ], + }, + channelName: 'WebChatChannel', + location: 'global', + }, + }; + const component = await renderComponentOnInitView(); + const webChatToggle = component.getByTestId(`${CHANNELS.WEBCHAT}_toggle`); + await act(async () => { + await fireEvent.click(webChatToggle); + }); + expect( + httpClient.delete + ).toBeCalledWith( + `https://management.azure.com/subscriptions/${mockSubscriptionId}/resourceGroups/${mockResourceGroup}/providers/Microsoft.BotService/botServices/${mockBotName}/channels/${CHANNELS.WEBCHAT}?api-version=2020-06-02`, + { headers: { Authorization: `Bearer ${mockTokenValue}` } } + ); + await act(async () => { + await fireEvent.click(webChatToggle); + }); + expect( + httpClient.put + ).toBeCalledWith( + `https://management.azure.com/subscriptions/${mockSubscriptionId}/resourceGroups/${mockResourceGroup}/providers/Microsoft.BotService/botServices/${mockBotName}/channels/${CHANNELS.WEBCHAT}?api-version=2020-06-02`, + mockData, + { headers: { Authorization: `Bearer ${mockTokenValue}` } } + ); + }); + + it('should call Speech endpoint to disable', async () => { + const component = await renderComponentOnInitView(); + const speechToggle = component.getByTestId(`${CHANNELS.SPEECH}_toggle`); + await act(async () => { + await fireEvent.click(speechToggle); + }); + expect( + httpClient.delete + ).toBeCalledWith( + `https://management.azure.com/subscriptions/${mockSubscriptionId}/resourceGroups/${mockResourceGroup}/providers/Microsoft.BotService/botServices/${mockBotName}/channels/${CHANNELS.SPEECH}?api-version=2020-06-02`, + { headers: { Authorization: `Bearer ${mockTokenValue}` } } + ); + }); + + it('should nav to provision profile', async () => { + const component = renderComponent(); + const dropdown = component.getByTestId('publishTargetDropDown'); + + await act(async () => { + await fireEvent.click(dropdown); + }); + await act(async () => { + await fireEvent.click(component.getByText('Manage profiles')); + }); + + expect(mockNavigationTo).toHaveBeenCalledWith(`/bot/${mockProjId}/publish/all/#addNewPublishProfile`); + }); +}); diff --git a/Composer/packages/client/__tests__/components/Adapters/AdapterSection.test.tsx b/Composer/packages/client/__tests__/components/Adapters/AdapterSection.test.tsx new file mode 100644 index 0000000000..781bd7610b --- /dev/null +++ b/Composer/packages/client/__tests__/components/Adapters/AdapterSection.test.tsx @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { fireEvent } from '@botframework-composer/test-utils'; + +import AdapterSection from '../../../src/pages/botProject/adapters/AdapterSection'; +import { renderWithRecoil } from '../../testUtils/renderWithRecoil'; + +const mockProjId = '123'; +// jest.mock('../../../src/pages/botProject/adapters/ABSChannels', () => 'mock ABSChannel component'); + +const mockNavigationTo = jest.fn(); +jest.mock('../../../src/utils/navigation', () => ({ + navigateTo: (...args) => mockNavigationTo(...args), +})); +describe('', () => { + function renderComponent() { + return renderWithRecoil(); + } + + it('should render the component', async () => { + const component = renderComponent(); + const containerNode = await component.queryByTestId('adapterSectionContainer'); + expect(containerNode).toBeTruthy(); + }); + + it('deep link should nav to package manager', async () => { + const { getByText } = renderComponent(); + + fireEvent.click(getByText('package manager')); + + expect(mockNavigationTo).toHaveBeenCalledWith(`/bot/${mockProjId}/plugin/package-manager/package-manager`); + }); +}); diff --git a/Composer/packages/client/__tests__/components/Adapters/TeamsManifestGenerator.test.tsx b/Composer/packages/client/__tests__/components/Adapters/TeamsManifestGenerator.test.tsx new file mode 100644 index 0000000000..00bf8ac49a --- /dev/null +++ b/Composer/packages/client/__tests__/components/Adapters/TeamsManifestGenerator.test.tsx @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; + +import { TeamsManifestGeneratorModal } from '../../../src/components/Adapters/TeamsManifestGeneratorModal'; +import { renderWithRecoil } from '../../testUtils/renderWithRecoil'; + +const mockAppId = '123'; +const mockDisplayName = 'mockDisplayName'; + +describe('', () => { + function renderComponent() { + return renderWithRecoil( + + ); + } + + it('should render the component', async () => { + const component = renderComponent(); + const downloadBtn = await component.queryByTestId('teamsDownloadIcon'); + expect(downloadBtn).toBeTruthy(); + }); + + it('should render valid json teams manifest with dynamic values', async () => { + const component = renderComponent(); + + const textField = await component.queryByTestId('teamsManifestTextField'); + const manifestString = (textField as HTMLTextAreaElement).value; + const manifestObj = JSON.parse(manifestString); + expect(manifestObj).toBeTruthy(); + expect(manifestObj.name.short).toBe(mockDisplayName); + expect(manifestObj.name.full).toBe(mockDisplayName); + expect(manifestObj.bots[0].botId).toBe(mockAppId); + expect(manifestObj.id).toBeTruthy(); + expect(manifestObj.packageName).toBe(mockDisplayName); + expect(manifestObj.description.short).toBe(`short description for ${mockDisplayName}`); + expect(manifestObj.description.full).toBe(`full description for ${mockDisplayName}`); + + const checkedLuis = await component.queryByTestId('teamsDownloadIcon'); + expect(checkedLuis).toBeTruthy(); + }); +}); diff --git a/Composer/packages/client/__tests__/components/BotRuntimeController/emulatorOpenButton.test.tsx b/Composer/packages/client/__tests__/components/BotRuntimeController/emulatorOpenButton.test.tsx index fd5ebf8fb6..24b7fe0ead 100644 --- a/Composer/packages/client/__tests__/components/BotRuntimeController/emulatorOpenButton.test.tsx +++ b/Composer/packages/client/__tests__/components/BotRuntimeController/emulatorOpenButton.test.tsx @@ -8,12 +8,11 @@ import { OpenEmulatorButton } from '../../../src/components/BotRuntimeController import { botEndpointsState, botStatusState, settingsState } from '../../../src/recoilModel'; import { BotStatus } from '../../../src/constants'; import { renderWithRecoil } from '../../testUtils'; - -const mockCallEmulator = jest.fn(); +import { openInEmulator } from '../../../src/utils/navigation'; jest.mock('../../../src/utils/navigation', () => { return { - openInEmulator: mockCallEmulator, + openInEmulator: jest.fn(), }; }); @@ -44,7 +43,7 @@ const initialState = ({ currentStatus = BotStatus.connected } = {}) => ({ set }) describe('', () => { it('should show the button to open emulator', async () => { - mockCallEmulator.mockImplementationOnce((url) => { + (openInEmulator as jest.Mock).mockImplementationOnce((url) => { expect(url).toBeDefined(); }); const { findByTestId } = renderWithRecoil(, initialState()); 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 55f9f73f50..f13c8166fd 100644 --- a/Composer/packages/client/__tests__/components/CreationFlow/CreateOptions/index.test.tsx +++ b/Composer/packages/client/__tests__/components/CreationFlow/CreateOptions/index.test.tsx @@ -12,6 +12,7 @@ describe('', () => { const handleCreateNextMock = jest.fn(); const handleJumpToOpenModal = jest.fn(); const handleFetchReadMeMock = jest.fn(); + const onUpdateLocalTemplatePathMock = jest.fn(); const templates = [ { @@ -25,6 +26,7 @@ describe('', () => { packageName: 'generator-conversational-core', packageSource: 'npm', packageVersion: '1.0.9', + availableVersions: [''], }, }, ]; @@ -33,11 +35,13 @@ describe('', () => { return renderWithRecoil( ); }; @@ -46,7 +50,7 @@ describe('', () => { const component = renderComponent(); const conversationalCoreBot = await component.findByTestId('generator-conversational-core'); fireEvent.click(conversationalCoreBot); - const nextButton = await component.findByText('Next'); + const nextButton = await component.findByTestId('CreateBotNextStepButton'); fireEvent.click(nextButton); expect(handleCreateNextMock).toBeCalledWith('generator-conversational-core', 'dotnet'); }); 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 5552256bd8..109531f1a9 100644 --- a/Composer/packages/client/__tests__/components/CreationFlow/LocationBrowser/FileSelector.test.tsx +++ b/Composer/packages/client/__tests__/components/CreationFlow/LocationBrowser/FileSelector.test.tsx @@ -90,10 +90,12 @@ describe('', () => { it('should create a new folder', async () => { const component = renderComponent(); const createFolderBtn = await component.findByText('Create new folder'); - fireEvent.click(createFolderBtn); - const textField = await component.findByTestId('newFolderTextField'); - fireEvent.change(textField, { target: { value: 'newFolder' } }); - fireEvent.keyDown(textField, { key: 'Enter' }); + await act(async () => { + fireEvent.click(createFolderBtn); + const textField = await component.findByTestId('newFolderTextField'); + fireEvent.change(textField, { target: { value: 'newFolder' } }); + fireEvent.keyDown(textField, { key: 'Enter' }); + }); //locally this should be 'C:\\test-folder\\Desktop', but online it should be 'C:/test-folder/Desktop' expect( createFolder.mock.calls[0][0] === 'C:/test-folder/Desktop' || diff --git a/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx b/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx index 8acfd6cdaa..988c1b0aef 100644 --- a/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx +++ b/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx @@ -13,6 +13,7 @@ import { dispatcherState, featureFlagsState, templateProjectsState, + selectedTemplateVersionState, } from '../../../src/recoilModel'; import { CreationFlowStatus } from '../../../src/constants'; import CreationFlow from '../../../src/components/CreationFlow/CreationFlow'; @@ -61,6 +62,8 @@ describe('', () => { }, ], }); + + set(selectedTemplateVersionState, '1.0.0'); }; function renderWithRouter(ui, { route = '', history = createHistory(createMemorySource(route)) } = {}) { @@ -77,6 +80,7 @@ describe('', () => { it('should render the component', async () => { const { findByText, + findByTestId, history: { navigate }, } = renderWithRouter( @@ -84,11 +88,26 @@ describe('', () => { ); - navigate('create/dotnet/%40microsoft%2Fgenerator-bot-empty'); - const node = await findByText('Create'); + act(() => { + navigate('create/dotnet/%40microsoft%2Fgenerator-bot-empty'); + }); + + const dropdown = await await findByTestId('NewDialogRuntimeType'); + + expect(dropdown).toBeDefined(); + + await act(async () => { + if (dropdown) { + fireEvent.click(dropdown); + fireEvent.click(await findByText('Azure Web App')); + } + }); + + const createButton = await findByText('Create'); + expect(createButton).toBeDefined(); act(() => { - fireEvent.click(node); + fireEvent.click(createButton); }); let expectedLocation = '/test-folder/Desktop'; @@ -106,14 +125,14 @@ describe('', () => { alias: undefined, eTag: undefined, preserveRoot: undefined, - qnqKbUrls: undefined, + qnaKbUrls: undefined, runtimeType: 'webapp', templateDir: undefined, urlSuffix: undefined, profile: undefined, - qnaKbUrls: undefined, runtimeLanguage: 'dotnet', source: undefined, + isLocalGenerator: false, }); }); }); diff --git a/Composer/packages/client/__tests__/components/ImportModal/ImportModal.test.tsx b/Composer/packages/client/__tests__/components/ImportModal/ImportModal.test.tsx new file mode 100644 index 0000000000..18be17c36e --- /dev/null +++ b/Composer/packages/client/__tests__/components/ImportModal/ImportModal.test.tsx @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { render } from '@botframework-composer/test-utils'; + +import { ImportModal, importAsNewProject } from '../../../src/components/ImportModal/ImportModal'; +import { dispatcherState } from '../../../src/recoilModel'; + +describe('', () => { + let locationMock; + const initRecoilState = ({ set }) => { + set(dispatcherState, { + addNotification: jest.fn(), + createNotification: jest.fn(), + }); + }; + + it('should render the component', async () => { + const { findByTestId } = render( + + + + ); + + // connecting state + const connecting = await findByTestId('importModalConnecting'); + expect(connecting).not.toBeNull(); + expect(connecting).toHaveTextContent('Connecting to external service to import bot content...'); + }); + + it('test importAsNewProject', async () => { + const mockInfo = { + alias: 'test', + description: 'test', + eTag: 'test', + name: 'test', + source: 'test', + templateDir: 'test', + urlSuffix: 'test', + }; + const { creationUrl, state } = importAsNewProject(mockInfo); + expect(creationUrl).toBe('/projects/create/test?name=test&description=test'); + expect(state).toEqual({ + alias: 'test', + eTag: 'test', + imported: true, + templateDir: 'test', + urlSuffix: 'test', + }); + }); +}); diff --git a/Composer/packages/client/__tests__/components/ImportModal/ImportSuccessNotification.test.tsx b/Composer/packages/client/__tests__/components/ImportModal/ImportSuccessNotification.test.tsx new file mode 100644 index 0000000000..6338e3bed6 --- /dev/null +++ b/Composer/packages/client/__tests__/components/ImportModal/ImportSuccessNotification.test.tsx @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { render } from '@botframework-composer/test-utils'; +import React from 'react'; + +import { ImportSuccessNotificationWrapper } from '../../../src/components/ImportModal/ImportSuccessNotification'; + +describe('', () => { + it('should render the component', async () => { + const locationMock = './existBot'; + const mockImportedToExisting = true; + const Notification = ImportSuccessNotificationWrapper({ + location: locationMock, + importedToExisting: mockImportedToExisting, + }); + const { findByText } = render(); + + expect(findByText('./existBot')).not.toBeUndefined(); + }); +}); diff --git a/Composer/packages/client/__tests__/components/ManageLuis/ManageLuis.test.tsx b/Composer/packages/client/__tests__/components/ManageLuis/ManageLuis.test.tsx new file mode 100644 index 0000000000..ee8d0d6db7 --- /dev/null +++ b/Composer/packages/client/__tests__/components/ManageLuis/ManageLuis.test.tsx @@ -0,0 +1,340 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { fireEvent, act } from '@botframework-composer/test-utils'; +import React from 'react'; + +import { renderWithRecoil } from '../../testUtils'; +import { ManageLuis } from '../../../src/components/ManageLuis/ManageLuis'; + +const serviceName = 'Language Understanding'; +const DOWN_ARROW = { keyCode: 40 }; + +jest.mock('@azure/arm-cognitiveservices', () => ({ + CognitiveServicesManagementClient: function CognitiveServicesManagementClient() { + return { + accounts: { + create: async () => {}, + list: async () => { + return [ + { + kind: 'LUIS.Authoring', + id: '/stuff/resourceGroups/mockedGroup/stuff', + name: 'mockedAccount', + location: 'westus', + }, + ]; + }, + listKeys: async () => { + return { + key1: 'mockedKey', + }; + }, + }, + }; + }, +})); + +jest.mock('../../../src/components/Auth/AuthDialog', () => ({ + AuthDialog: ({ children, onClick }) => , +})); + +jest.mock('../../../src/utils/authClient', () => ({ + AuthClient: { + getTenants: async () => { + return [ + { + displayName: 'mockTenant', + tenantId: 'mockTenant', + }, + ]; + }, + getARMTokenForTenant: async () => 'mockToken', + getAccessToken: async () => 'mockToken', + }, +})); + +jest.mock('../../../src/utils/auth', () => ({ + decodeToken: () => { + return { + upn: 'mockUser@mockDomain.com', + name: 'mockUser', + exp: new Date().getTime(), + tenant: 'mockTenant', + }; + }, + userShouldProvideTokens: jest.fn(), + isShowAuthDialog: jest.fn(), + getTokenFromCache: jest.fn(), + setTenantId: jest.fn(), + getTenantIdFromCache: jest.fn(), + prepareAxios: jest.fn(), +})); + +describe('', () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + it('displays correct ui copy', async () => { + const { baseElement } = renderWithRecoil( + + ); + + // confirm the text of the UI contains the dynamic values + expect(baseElement).toHaveTextContent(`Set up ${serviceName}`); + }); + + it('calls close method when closed', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { findByText } = renderWithRecoil( + + ); + + const cancelButton = await findByText('Cancel'); + fireEvent.click(cancelButton); + expect(onDismiss).toBeCalled(); + }); + + it('it should navigate to the selection page', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByTestId, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + + const subscriptionOption = await findByTestId('service-useexisting-subscription-selection'); + expect(subscriptionOption).toBeDefined(); + expect(subscriptionOption).toBeEnabled(); + + expect(baseElement).toHaveTextContent(`Select ${serviceName} resources`); + expect(baseElement).toHaveTextContent( + `Choose the subscription where your existing ${serviceName} resource is located.` + ); + + // ensure that since a subscription hasn't been selected + // this button is disabled + const nextButton2 = await findByRole('button', { name: 'Next' }); + expect(nextButton2).toBeDefined(); + expect(nextButton2).toBeDisabled(); + + // select a subscription + await act(async () => { + await fireEvent.keyDown(subscriptionOption, DOWN_ARROW); + }); + + const mySub = await findByText('mockSubscription'); + expect(mySub).toBeDefined(); + + await act(async () => { + await fireEvent.click(mySub); + }); + + // select a resource group + const resourceOption = await findByTestId('service-useexisting-key-selection'); + expect(resourceOption).toBeDefined(); + expect(resourceOption).toBeEnabled(); + await act(async () => { + await fireEvent.keyDown(resourceOption, DOWN_ARROW); + }); + + // select the key + const myKey = await findByText('mockedAccount'); + expect(myKey).toBeDefined(); + await act(async () => { + await fireEvent.click(myKey); + }); + + // make sure the next button is appropriately enabled + expect(nextButton2).toBeEnabled(); + + // click next + await act(async () => { + await fireEvent.click(nextButton2); + }); + + // ensure that the final callback was called + expect(onGetKey).toBeCalledWith({ + region: 'westus', + key: 'mockedKey', + }); + }); + + it('it should navigate to the create page', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByTestId, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // change selection + const createOption = await findByText('Create and configure new Azure resources'); + fireEvent.click(createOption); + + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + expect(baseElement).toHaveTextContent(`Create ${serviceName} resources`); + + // ensure that since a subscription hasn't been selected + // this button is disabled + const nextButton2 = await findByRole('button', { name: 'Next' }); + expect(nextButton2).toBeDefined(); + expect(nextButton2).toBeDisabled(); + + const subscriptionOption = await findByTestId('service-create-subscription-selection'); + expect(subscriptionOption).toBeDefined(); + expect(subscriptionOption).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.keyDown(subscriptionOption, DOWN_ARROW); + }); + + const mySub = await findByText('mockSubscription'); + expect(mySub).toBeDefined(); + + await act(async () => { + await fireEvent.click(mySub); + }); + + // next button should now be enabled + expect(nextButton2).toBeEnabled(); + + await act(async () => { + await fireEvent.click(nextButton2); + }); + + const nextButton3 = await findByRole('button', { name: 'Next' }); + expect(nextButton3).toBeDefined(); + expect(nextButton3).toBeDisabled(); + + const resourceOption = await findByTestId('service-create-resource-selection'); + expect(resourceOption).toBeDefined(); + expect(resourceOption).toBeEnabled(); + + const resourceName = await findByTestId('resourceName'); + expect(resourceName).toBeDefined(); + expect(resourceName).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.click(resourceOption); + }); + + const myGroup = await findByText('mockedGroup'); + expect(myGroup).toBeDefined(); + + await act(async () => { + await fireEvent.click(myGroup); + await fireEvent.change(resourceName, { target: { value: 'mockedResource' } }); + }); + + // select region + const regionOption = await findByTestId('rootRegion'); + expect(regionOption).toBeDefined(); + expect(regionOption).toBeEnabled(); + // choose subscription + await act(async () => { + await fireEvent.keyDown(regionOption, DOWN_ARROW); + }); + + const myRegion = await findByText('West US'); + expect(myRegion).toBeDefined(); + + await act(async () => { + await fireEvent.click(myRegion); + }); + + expect(nextButton3).toBeEnabled(); + await act(async () => { + await fireEvent.click(nextButton3); + }); + + // ensure that the final callback was called + expect(onGetKey).toBeCalledWith({ + region: 'westus', + key: 'mockedKey', + }); + }); + + it('it should show handoff instructions', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // change selection + const generateOption = await findByText('Generate instructions for Azure administrator'); + fireEvent.click(generateOption); + + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + + expect(baseElement).toHaveTextContent( + `I am creating a conversational experience using Microsoft Bot Framework project.` + ); + }); +}); diff --git a/Composer/packages/client/__tests__/components/ManageQNA/ManageQNA.test.tsx b/Composer/packages/client/__tests__/components/ManageQNA/ManageQNA.test.tsx new file mode 100644 index 0000000000..ab5a35b821 --- /dev/null +++ b/Composer/packages/client/__tests__/components/ManageQNA/ManageQNA.test.tsx @@ -0,0 +1,395 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { fireEvent, act } from '@botframework-composer/test-utils'; +import React from 'react'; + +import { renderWithRecoil } from '../../testUtils'; +import { ManageQNA } from '../../../src/components/ManageQNA/ManageQNA'; + +const serviceName = 'QnA Maker'; +const DOWN_ARROW = { keyCode: 40 }; + +jest.mock('@azure/arm-appservice', () => ({ + WebSiteManagementClient: function WebSiteManagementClient() { + return { + appServicePlans: { + createOrUpdate: jest.fn(), + }, + webApps: { + createOrUpdate: jest.fn(() => { + 'https://mockedHostName'; + }), + }, + adminKeys: { + get: jest.fn(() => { + 'mockedPrimaryKey'; + }), + }, + }; + }, +})); +jest.mock('@azure/arm-search', () => ({ + SearchManagementClient: function SearchManagementClient() { + return { + services: { + createOrUpdate: jest.fn(), + }, + adminKeys: { + get: jest.fn(() => { + 'mockedPrimaryKey'; + }), + }, + }; + }, +})); +jest.mock('@azure/arm-cognitiveservices', () => ({ + CognitiveServicesManagementClient: function CognitiveServicesManagementClient() { + return { + accounts: { + create: async () => {}, + list: async () => { + return [ + { + kind: 'QnAMaker', + id: '/stuff/resourceGroups/mockedGroup/stuff', + name: 'mockedAccount', + location: 'westus', + }, + ]; + }, + listKeys: async () => { + return { + key1: 'mockedKey', + }; + }, + }, + }; + }, +})); + +jest.mock('../../../src/components/Auth/AuthDialog', () => ({ + AuthDialog: ({ children, onClick }) => , +})); + +jest.mock('../../../src/utils/authClient', () => ({ + AuthClient: { + getTenants: async () => { + return [ + { + displayName: 'mockTenant', + tenantId: 'mockTenant', + }, + ]; + }, + getARMTokenForTenant: async () => 'mockToken', + getAccessToken: async () => 'mockToken', + }, +})); + +jest.mock('../../../src/utils/auth', () => ({ + decodeToken: () => { + return { + upn: 'mockUser@mockDomain.com', + name: 'mockUser', + exp: new Date().getTime(), + tenant: 'mockTenant', + }; + }, + userShouldProvideTokens: jest.fn(), + isShowAuthDialog: jest.fn(), + getTokenFromCache: jest.fn(), + setTenantId: jest.fn(), + getTenantIdFromCache: jest.fn(), + prepareAxios: jest.fn(), +})); + +describe('', () => { + it('displays correct ui copy', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement } = renderWithRecoil( + + ); + + // confirm the text of the UI contains the dynamic values + expect(baseElement).toHaveTextContent(`Set up ${serviceName}`); + }); + + it('calls close method when closed', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { findByText } = renderWithRecoil( + + ); + + const cancelButton = await findByText('Cancel'); + fireEvent.click(cancelButton); + expect(onDismiss).toBeCalled(); + }); + + it('it should navigate to the selection page', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByTestId, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + + const subscriptionOption = await findByTestId('service-useexisting-subscription-selection'); + expect(subscriptionOption).toBeDefined(); + expect(subscriptionOption).toBeEnabled(); + + expect(baseElement).toHaveTextContent(`Select ${serviceName} resources`); + expect(baseElement).toHaveTextContent( + `Choose the subscription where your existing ${serviceName} resource is located.` + ); + + // ensure that since a subscription hasn't been selected + // this button is disabled + const nextButton2 = await findByRole('button', { name: 'Next' }); + expect(nextButton2).toBeDefined(); + expect(nextButton2).toBeDisabled(); + + // select a subscription + await act(async () => { + await fireEvent.keyDown(subscriptionOption, DOWN_ARROW); + }); + + const mySub = await findByText('mockSubscription'); + expect(mySub).toBeDefined(); + + await act(async () => { + await fireEvent.click(mySub); + }); + + // select a resource group + const resourceOption = await findByTestId('service-useexisting-key-selection'); + expect(resourceOption).toBeDefined(); + expect(resourceOption).toBeEnabled(); + await act(async () => { + await fireEvent.keyDown(resourceOption, DOWN_ARROW); + }); + + // select the key + const myKey = await findByText('mockedAccount'); + expect(myKey).toBeDefined(); + await act(async () => { + await fireEvent.click(myKey); + }); + + // make sure the next button is appropriately enabled + expect(nextButton2).toBeEnabled(); + + // click next + await act(async () => { + await fireEvent.click(nextButton2); + }); + + // ensure that the final callback was called + expect(onGetKey).toBeCalledWith({ + region: 'westus', + key: 'mockedKey', + }); + }); + + it('it should handle tier option during creation', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByTestId, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // change selection + const createOption = await findByText('Create and configure new Azure resources'); + fireEvent.click(createOption); + + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + expect(baseElement).toHaveTextContent(`Create ${serviceName} resources`); + + // ensure that since a subscription hasn't been selected + // this button is disabled + const nextButton2 = await findByRole('button', { name: 'Next' }); + expect(nextButton2).toBeDefined(); + expect(nextButton2).toBeDisabled(); + + const subscriptionOption = await findByTestId('service-create-subscription-selection'); + expect(subscriptionOption).toBeDefined(); + expect(subscriptionOption).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.keyDown(subscriptionOption, DOWN_ARROW); + }); + + const mySub = await findByText('mockSubscription'); + expect(mySub).toBeDefined(); + + await act(async () => { + await fireEvent.click(mySub); + }); + + // next button should now be enabled + expect(nextButton2).toBeEnabled(); + + await act(async () => { + await fireEvent.click(nextButton2); + }); + + const nextButton3 = await findByRole('button', { name: 'Next' }); + expect(nextButton3).toBeDefined(); + expect(nextButton3).toBeDisabled(); + + const resourceOption = await findByTestId('service-create-resource-selection'); + expect(resourceOption).toBeDefined(); + expect(resourceOption).toBeEnabled(); + + const resourceName = await findByTestId('resourceName'); + expect(resourceName).toBeDefined(); + expect(resourceName).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.click(resourceOption); + }); + + const myGroup = await findByText('mockedGroup'); + expect(myGroup).toBeDefined(); + + await act(async () => { + await fireEvent.click(myGroup); + await fireEvent.change(resourceName, { target: { value: 'mockedResource' } }); + }); + + // select region + const regionOption = await findByTestId('rootRegion'); + expect(regionOption).toBeDefined(); + expect(regionOption).toBeEnabled(); + // choose subscription + await act(async () => { + await fireEvent.click(regionOption); + }); + + const myRegion = await findByText('West US'); + expect(myRegion).toBeDefined(); + + await act(async () => { + await fireEvent.click(myRegion); + }); + + // NEXT BUTTON SHOULD STILL BE DISABLED! need to do tier selection! + expect(nextButton3).toBeDisabled(); + + const tierOption = await findByTestId('tier'); + expect(tierOption).toBeDefined(); + expect(tierOption).toBeEnabled(); + // choose subscription + await act(async () => { + await fireEvent.keyDown(tierOption, DOWN_ARROW); + }); + + const myTier = await findByText('Free'); + expect(myTier).toBeDefined(); + + await act(async () => { + await fireEvent.click(myTier); + }); + + // finally the button should now be enabled + expect(nextButton3).toBeEnabled(); + + await act(async () => { + await fireEvent.click(nextButton3); + }); + + // since QNA is async, the modal closes at the end ... + expect(onToggleVisibility).toBeCalled(); + + // since QNA is async, onGetKey is not called here. + // instead, these values are updated directly in the recoil state. + + // TODO: how to test that the recoil state was updated as expected? + }); + + it('it should show handoff instructions', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // change selection + const generateOption = await findByText('Generate instructions for Azure administrator'); + fireEvent.click(generateOption); + + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + + expect(baseElement).toHaveTextContent( + `I am creating a conversational experience using Microsoft Bot Framework project.` + ); + }); +}); diff --git a/Composer/packages/client/__tests__/components/ManageService/ManageService.test.tsx b/Composer/packages/client/__tests__/components/ManageService/ManageService.test.tsx new file mode 100644 index 0000000000..4c784f7bd1 --- /dev/null +++ b/Composer/packages/client/__tests__/components/ManageService/ManageService.test.tsx @@ -0,0 +1,716 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { fireEvent, act } from '@botframework-composer/test-utils'; +import React from 'react'; + +import { renderWithRecoil } from '../../testUtils'; +import { ManageService } from '../../../src/components/ManageService/ManageService'; + +const regions = [{ key: 'westus', text: 'West US' }]; +const tiers = [{ key: 'mockedTier', text: 'mockedTier' }]; +const serviceName = 'serviceName'; +const introtext = 'introtext'; +const learnMore = 'learnmore'; +const serviceKeyType = 'keytype'; +const handoffInstructions = 'handoffInstructions'; +const DOWN_ARROW = { keyCode: 40 }; + +jest.mock('../../../src/utils/auth', () => ({ + decodeToken: () => { + return { + upn: 'mockUser@mockDomain.com', + name: 'mockUser', + exp: new Date().getTime(), + tenant: 'mockTenant', + }; + }, + userShouldProvideTokens: jest.fn(), + isShowAuthDialog: jest.fn(), + getTokenFromCache: jest.fn(), + setTenantId: jest.fn(), + getTenantIdFromCache: jest.fn(), + prepareAxios: jest.fn(), +})); + +jest.mock('@azure/arm-cognitiveservices', () => ({ + CognitiveServicesManagementClient: function CognitiveServicesManagementClient() { + return { + accounts: { + list: async () => { + return [ + { + kind: 'keytype', + id: '/stuff/resourceGroups/mockedGroup/stuff', + name: 'mockedAccount', + location: 'westus', + }, + ]; + }, + listKeys: async () => { + return { + key1: 'mockedKey', + }; + }, + }, + }; + }, +})); + +jest.mock('../../../src/components/Auth/AuthDialog', () => ({ + AuthDialog: ({ children, onClick }) => , +})); + +jest.mock('../../../src/utils/authClient', () => ({ + AuthClient: { + getTenants: async () => { + return [ + { + displayName: 'mockTenant', + tenantId: 'mockTenant', + }, + ]; + }, + getARMTokenForTenant: async () => 'mockToken', + getAccessToken: async () => 'mockToken', + }, +})); + +describe('', () => { + it('displays correct ui copy', async () => { + const createService = jest.fn(async () => 'mockedKey'); + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByTestId } = renderWithRecoil( + + ); + + // confirm the text of the UI contains the dynamic values + expect(baseElement).toHaveTextContent(`Set up ${serviceName}`); + expect(baseElement).toHaveTextContent(introtext); + const learnmore = await findByTestId('manageservice-learnmore'); + expect(learnmore).toBeDefined(); + expect(learnmore).toHaveAttribute('href', learnMore); + }); + + it('calls close method when closed', async () => { + const createService = jest.fn(async () => 'mockedKey'); + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { findByText } = renderWithRecoil( + + ); + + const cancelButton = await findByText('Cancel'); + fireEvent.click(cancelButton); + expect(onDismiss).toBeCalled(); + }); + + it('it should navigate to the selection page', async () => { + const createService = jest.fn(async () => 'mockedKey'); + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByTestId, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + + const subscriptionOption = await findByTestId('service-useexisting-subscription-selection'); + expect(subscriptionOption).toBeDefined(); + expect(subscriptionOption).toBeEnabled(); + + expect(baseElement).toHaveTextContent(`Select ${serviceName} resources`); + expect(baseElement).toHaveTextContent( + `Choose the subscription where your existing ${serviceName} resource is located.` + ); + + // ensure that since a subscription hasn't been selected + // this button is disabled + const nextButton2 = await findByRole('button', { name: 'Next' }); + expect(nextButton2).toBeDefined(); + expect(nextButton2).toBeDisabled(); + + // select a subscription + await act(async () => { + await fireEvent.keyDown(subscriptionOption, DOWN_ARROW); + }); + + const mySub = await findByText('mockSubscription'); + expect(mySub).toBeDefined(); + + await act(async () => { + await fireEvent.click(mySub); + }); + + // select a resource group + const resourceOption = await findByTestId('service-useexisting-key-selection'); + expect(resourceOption).toBeDefined(); + expect(resourceOption).toBeEnabled(); + await act(async () => { + await fireEvent.keyDown(resourceOption, DOWN_ARROW); + }); + + // select the key + const myKey = await findByText('mockedAccount'); + expect(myKey).toBeDefined(); + await act(async () => { + await fireEvent.click(myKey); + }); + + // make sure the next button is appropriately enabled + expect(nextButton2).toBeEnabled(); + + // click next + await act(async () => { + await fireEvent.click(nextButton2); + }); + + // let promises flush + await Promise.resolve(); + + // ensure that the final callback was called + expect(onGetKey).toBeCalledWith({ + region: 'westus', + key: 'mockedKey', + }); + }); + + it('it should navigate to the create page', async () => { + const createService = jest.fn(async () => 'mockedKey'); + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByTestId, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // change selection + const createOption = await findByText('Create and configure new Azure resources'); + await act(async () => { + await fireEvent.click(createOption); + }); + + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + expect(baseElement).toHaveTextContent(`Create ${serviceName} resources`); + + // ensure that since a subscription hasn't been selected + // this button is disabled + const nextButton2 = await findByRole('button', { name: 'Next' }); + expect(nextButton2).toBeDefined(); + expect(nextButton2).toBeDisabled(); + + const subscriptionOption = await findByTestId('service-create-subscription-selection'); + expect(subscriptionOption).toBeDefined(); + expect(subscriptionOption).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.keyDown(subscriptionOption, DOWN_ARROW); + }); + + const mySub = await findByText('mockSubscription'); + expect(mySub).toBeDefined(); + + await act(async () => { + await fireEvent.click(mySub); + }); + + // next button should now be enabled + expect(nextButton2).toBeEnabled(); + + await act(async () => { + await fireEvent.click(nextButton2); + }); + + const nextButton3 = await findByRole('button', { name: 'Next' }); + expect(nextButton3).toBeDefined(); + expect(nextButton3).toBeDisabled(); + + const resourceOption = await findByTestId('service-create-resource-selection'); + expect(resourceOption).toBeDefined(); + expect(resourceOption).toBeEnabled(); + + const resourceName = await findByTestId('resourceName'); + expect(resourceName).toBeDefined(); + expect(resourceName).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.click(resourceOption); + }); + + const myGroup = await findByText('mockedGroup'); + expect(myGroup).toBeDefined(); + + await act(async () => { + await fireEvent.click(myGroup); + await fireEvent.change(resourceName, { target: { value: 'mockedResource' } }); + }); + + // select region + const regionOption = await findByTestId('rootRegion'); + expect(regionOption).toBeDefined(); + expect(regionOption).toBeEnabled(); + // choose subscription + await act(async () => { + await fireEvent.keyDown(regionOption, DOWN_ARROW); + }); + + const myRegion = await findByText('West US'); + expect(myRegion).toBeDefined(); + + await act(async () => { + await fireEvent.click(myRegion); + }); + + expect(nextButton3).toBeEnabled(); + await act(async () => { + await fireEvent.click(nextButton3); + }); + + // let promises flush + await Promise.resolve(); + + expect(createService).toBeCalledWith( + expect.anything(), + 'mockSubscription', + 'mockedGroup', + 'mockedResource', + 'westus', + expect.anything() + ); + + // ensure that the final callback was called + expect(onGetKey).toBeCalledWith({ + region: 'westus', + key: 'mockedKey', + }); + }); + + it('it should handle tier option during creation', async () => { + const createService = jest.fn(async () => 'mockedKey'); + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByTestId, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // change selection + const createOption = await findByText('Create and configure new Azure resources'); + await act(async () => { + await fireEvent.click(createOption); + }); + + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + expect(baseElement).toHaveTextContent(`Create ${serviceName} resources`); + + // ensure that since a subscription hasn't been selected + // this button is disabled + const nextButton2 = await findByRole('button', { name: 'Next' }); + expect(nextButton2).toBeDefined(); + expect(nextButton2).toBeDisabled(); + + const subscriptionOption = await findByTestId('service-create-subscription-selection'); + expect(subscriptionOption).toBeDefined(); + expect(subscriptionOption).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.keyDown(subscriptionOption, DOWN_ARROW); + }); + + const mySub = await findByText('mockSubscription'); + expect(mySub).toBeDefined(); + + await act(async () => { + await fireEvent.click(mySub); + }); + + // next button should now be enabled + expect(nextButton2).toBeEnabled(); + + await act(async () => { + await fireEvent.click(nextButton2); + }); + + const nextButton3 = await findByRole('button', { name: 'Next' }); + expect(nextButton3).toBeDefined(); + expect(nextButton3).toBeDisabled(); + + const resourceOption = await findByTestId('service-create-resource-selection'); + expect(resourceOption).toBeDefined(); + expect(resourceOption).toBeEnabled(); + + const resourceName = await findByTestId('resourceName'); + expect(resourceName).toBeDefined(); + expect(resourceName).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.click(resourceOption); + }); + + const myGroup = await findByText('mockedGroup'); + expect(myGroup).toBeDefined(); + + await act(async () => { + await fireEvent.click(myGroup); + await fireEvent.change(resourceName, { target: { value: 'mockedResource' } }); + }); + + // select region + const regionOption = await findByTestId('rootRegion'); + expect(regionOption).toBeDefined(); + expect(regionOption).toBeEnabled(); + // choose subscription + await act(async () => { + await fireEvent.keyDown(regionOption, DOWN_ARROW); + }); + + const myRegion = await findByText('West US'); + expect(myRegion).toBeDefined(); + + await act(async () => { + await fireEvent.click(myRegion); + }); + + // NEXT BUTTON SHOULD STILL BE DISABLED! need to do tier selection! + expect(nextButton3).toBeDisabled(); + + const tierOption = await findByTestId('tier'); + expect(tierOption).toBeDefined(); + expect(tierOption).toBeEnabled(); + // choose subscription + await act(async () => { + await fireEvent.keyDown(tierOption, DOWN_ARROW); + }); + + const myTier = await findByText('mockedTier'); + expect(myTier).toBeDefined(); + + await act(async () => { + await fireEvent.click(myTier); + }); + + // finally the button should now be enabled + expect(nextButton3).toBeEnabled(); + + await act(async () => { + await fireEvent.click(nextButton3); + }); + + // let promises flush + await Promise.resolve(); + + expect(createService).toBeCalledWith( + expect.anything(), + 'mockSubscription', + 'mockedGroup', + 'mockedResource', + 'westus', + 'mockedTier' + ); + + // ensure that the final callback was called + expect(onGetKey).toBeCalledWith({ + region: 'westus', + key: 'mockedKey', + }); + }); + + it('it should handle tier + dynamic regions option during creation', async () => { + const createService = jest.fn(async () => 'mockedKey'); + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByTestId, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // change selection + const createOption = await findByText('Create and configure new Azure resources'); + await act(async () => { + await fireEvent.click(createOption); + }); + + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + expect(baseElement).toHaveTextContent(`Create ${serviceName} resources`); + + // ensure that since a subscription hasn't been selected + // this button is disabled + const nextButton2 = await findByRole('button', { name: 'Next' }); + expect(nextButton2).toBeDefined(); + expect(nextButton2).toBeDisabled(); + + const subscriptionOption = await findByTestId('service-create-subscription-selection'); + expect(subscriptionOption).toBeDefined(); + expect(subscriptionOption).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.keyDown(subscriptionOption, DOWN_ARROW); + }); + + const mySub = await findByText('mockSubscription'); + expect(mySub).toBeDefined(); + + await act(async () => { + await fireEvent.click(mySub); + }); + + // next button should now be enabled + expect(nextButton2).toBeEnabled(); + + await act(async () => { + await fireEvent.click(nextButton2); + }); + + const nextButton3 = await findByRole('button', { name: 'Next' }); + expect(nextButton3).toBeDefined(); + expect(nextButton3).toBeDisabled(); + + const resourceOption = await findByTestId('service-create-resource-selection'); + expect(resourceOption).toBeDefined(); + expect(resourceOption).toBeEnabled(); + + const resourceName = await findByTestId('resourceName'); + expect(resourceName).toBeDefined(); + expect(resourceName).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.click(resourceOption); + }); + + const myGroup = await findByText('mockedGroup'); + expect(myGroup).toBeDefined(); + + await act(async () => { + await fireEvent.click(myGroup); + await fireEvent.change(resourceName, { target: { value: 'mockedResource' } }); + }); + + // select region + const regionOption = await findByTestId('rootRegion'); + expect(regionOption).toBeDefined(); + expect(regionOption).toBeEnabled(); + // choose subscription + await act(async () => { + await fireEvent.keyDown(regionOption, DOWN_ARROW); + }); + + const myRegion = await findByText('West US'); + expect(myRegion).toBeDefined(); + + await act(async () => { + await fireEvent.click(myRegion); + }); + + // NEXT BUTTON SHOULD STILL BE DISABLED! need to do tier selection! + expect(nextButton3).toBeDisabled(); + + const tierOption = await findByTestId('tier'); + expect(tierOption).toBeDefined(); + expect(tierOption).toBeEnabled(); + // choose subscription + await act(async () => { + await fireEvent.keyDown(tierOption, DOWN_ARROW); + }); + + const myTier = await findByText('mockedTier'); + expect(myTier).toBeDefined(); + + await act(async () => { + await fireEvent.click(myTier); + }); + + // finally the button should now be enabled + expect(nextButton3).toBeEnabled(); + + await act(async () => { + await fireEvent.click(nextButton3); + }); + + // let promises flush + await Promise.resolve(); + + expect(createService).toBeCalledWith( + expect.anything(), + 'mockSubscription', + 'mockedGroup', + 'mockedResource', + 'westus', + 'mockedTier' + ); + + // ensure that the final callback was called + expect(onGetKey).toBeCalledWith({ + region: 'westus', + key: 'mockedKey', + }); + }); + + it('it should show handoff instructions', async () => { + const createService = jest.fn(); + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // change selection + const generateOption = await findByText('Generate instructions for Azure administrator'); + await act(async () => { + await fireEvent.click(generateOption); + }); + + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + + expect(baseElement).toHaveTextContent( + `I am creating a conversational experience using Microsoft Bot Framework project.` + ); + expect(baseElement).toHaveTextContent(handoffInstructions); + }); +}); diff --git a/Composer/packages/client/__tests__/components/ManageSpeech/ManageSpeech.test.tsx b/Composer/packages/client/__tests__/components/ManageSpeech/ManageSpeech.test.tsx new file mode 100644 index 0000000000..71fd0b8abf --- /dev/null +++ b/Composer/packages/client/__tests__/components/ManageSpeech/ManageSpeech.test.tsx @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { fireEvent, act } from '@botframework-composer/test-utils'; +import React from 'react'; + +import { renderWithRecoil } from '../../testUtils'; +import { ManageSpeech } from '../../../src/components/ManageSpeech/ManageSpeech'; + +const serviceName = 'Speech'; +const DOWN_ARROW = { keyCode: 40 }; + +jest.mock('@azure/arm-cognitiveservices', () => ({ + CognitiveServicesManagementClient: function CognitiveServicesManagementClient() { + return { + accounts: { + create: async () => {}, + list: async () => { + return [ + { + kind: 'SpeechServices', + id: '/stuff/resourceGroups/mockedGroup/stuff', + name: 'mockedAccount', + location: 'westus', + }, + ]; + }, + listKeys: async () => { + return { + key1: 'mockedKey', + }; + }, + }, + }; + }, +})); + +jest.mock('../../../src/components/Auth/AuthDialog', () => ({ + AuthDialog: ({ children, onClick }) => , +})); + +jest.mock('../../../src/utils/authClient', () => ({ + AuthClient: { + getTenants: async () => { + return [ + { + displayName: 'mockTenant', + tenantId: 'mockTenant', + }, + ]; + }, + getARMTokenForTenant: async () => 'mockToken', + getAccessToken: async () => 'mockToken', + }, +})); + +jest.mock('../../../src/utils/auth', () => ({ + decodeToken: () => { + return { + upn: 'mockUser@mockDomain.com', + name: 'mockUser', + exp: new Date().getTime(), + tenant: 'mockTenant', + }; + }, + userShouldProvideTokens: jest.fn(), + isShowAuthDialog: jest.fn(), + getTokenFromCache: jest.fn(), + setTenantId: jest.fn(), + getTenantIdFromCache: jest.fn(), + prepareAxios: jest.fn(), +})); + +describe('', () => { + it('displays correct ui copy', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement } = renderWithRecoil( + + ); + + // confirm the text of the UI contains the dynamic values + expect(baseElement).toHaveTextContent(`Set up ${serviceName}`); + }); + + it('calls close method when closed', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { findByText } = renderWithRecoil( + + ); + + const cancelButton = await findByText('Cancel'); + fireEvent.click(cancelButton); + expect(onDismiss).toBeCalled(); + }); + + it('it should navigate to the selection page', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByTestId, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + act(() => { + fireEvent.click(nextButton); + }); + + const subscriptionOption = await findByTestId('service-useexisting-subscription-selection'); + expect(subscriptionOption).toBeDefined(); + expect(subscriptionOption).toBeEnabled(); + + expect(baseElement).toHaveTextContent(`Select ${serviceName} resources`); + expect(baseElement).toHaveTextContent( + `Choose the subscription where your existing ${serviceName} resource is located.` + ); + + // ensure that since a subscription hasn't been selected + // this button is disabled + const nextButton2 = await findByRole('button', { name: 'Next' }); + expect(nextButton2).toBeDefined(); + expect(nextButton2).toBeDisabled(); + + // select a subscription + await act(async () => { + await fireEvent.keyDown(subscriptionOption, DOWN_ARROW); + }); + + const mySub = await findByText('mockSubscription'); + expect(mySub).toBeDefined(); + + await act(async () => { + await fireEvent.click(mySub); + }); + + // select a resource group + const resourceOption = await findByTestId('service-useexisting-key-selection'); + expect(resourceOption).toBeDefined(); + expect(resourceOption).toBeEnabled(); + await act(async () => { + await fireEvent.keyDown(resourceOption, DOWN_ARROW); + }); + + // select the key + const myKey = await findByText('mockedAccount'); + expect(myKey).toBeDefined(); + await act(async () => { + await fireEvent.click(myKey); + }); + + // make sure the next button is appropriately enabled + expect(nextButton2).toBeEnabled(); + + // click next + await act(async () => { + await fireEvent.click(nextButton2); + }); + + // ensure that the final callback was called + expect(onGetKey).toBeCalledWith({ + region: 'westus', + key: 'mockedKey', + }); + }); + + it('it should navigate to the create page', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByTestId, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // change selection + const createOption = await findByText('Create and configure new Azure resources'); + fireEvent.click(createOption); + + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + expect(baseElement).toHaveTextContent(`Create ${serviceName} resources`); + + // ensure that since a subscription hasn't been selected + // this button is disabled + const nextButton2 = await findByRole('button', { name: 'Next' }); + expect(nextButton2).toBeDefined(); + expect(nextButton2).toBeDisabled(); + + const subscriptionOption = await findByTestId('service-create-subscription-selection'); + expect(subscriptionOption).toBeDefined(); + expect(subscriptionOption).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.keyDown(subscriptionOption, DOWN_ARROW); + }); + + const mySub = await findByText('mockSubscription'); + expect(mySub).toBeDefined(); + + await act(async () => { + await fireEvent.click(mySub); + }); + + // next button should now be enabled + expect(nextButton2).toBeEnabled(); + + await act(async () => { + await fireEvent.click(nextButton2); + }); + + const nextButton3 = await findByRole('button', { name: 'Next' }); + expect(nextButton3).toBeDefined(); + expect(nextButton3).toBeDisabled(); + + const resourceOption = await findByTestId('service-create-resource-selection'); + expect(resourceOption).toBeDefined(); + expect(resourceOption).toBeEnabled(); + + const resourceName = await findByTestId('resourceName'); + expect(resourceName).toBeDefined(); + expect(resourceName).toBeEnabled(); + + // choose subscription + await act(async () => { + await fireEvent.click(resourceOption); + }); + + const myGroup = await findByText('mockedGroup'); + expect(myGroup).toBeDefined(); + + await act(async () => { + await fireEvent.click(myGroup); + await fireEvent.change(resourceName, { target: { value: 'mockedResource' } }); + }); + + // select region + const regionOption = await findByTestId('rootRegion'); + expect(regionOption).toBeDefined(); + expect(regionOption).toBeEnabled(); + // choose subscription + await act(async () => { + // await fireEvent.keyDown(regionOption, DOWN_ARROW); + await fireEvent.click(regionOption); + }); + + const myRegion = await findByText('West US'); + expect(myRegion).toBeDefined(); + + await act(async () => { + await fireEvent.click(myRegion); + }); + + expect(nextButton3).toBeEnabled(); + await act(async () => { + await fireEvent.click(nextButton3); + }); + + // ensure that the final callback was called + expect(onGetKey).toBeCalledWith({ + region: 'westus', + key: 'mockedKey', + }); + }); + + it('it should show handoff instructions', async () => { + const onDismiss = jest.fn(); + const onGetKey = jest.fn(); + const onNext = jest.fn(); + const onToggleVisibility = jest.fn(); + + const { baseElement, findByText, findByRole } = renderWithRecoil( + + ); + + // test the default option (choose existing) + // change selection + const generateOption = await findByText('Generate instructions for Azure administrator'); + fireEvent.click(generateOption); + + // click the next button, ensure the title changes + const nextButton = await findByRole('button', { name: 'Next' }); + expect(nextButton).toBeDefined(); + await act(async () => { + await fireEvent.click(nextButton); + }); + + expect(baseElement).toHaveTextContent( + `I am creating a conversational experience using Microsoft Bot Framework project.` + ); + }); +}); diff --git a/Composer/packages/client/__tests__/components/createQnAModal.test.tsx b/Composer/packages/client/__tests__/components/createQnAModal.test.tsx deleted file mode 100644 index f0e5cf0fe5..0000000000 --- a/Composer/packages/client/__tests__/components/createQnAModal.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -// 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 CreateQnAFromScratchModal from '../../src/components/QnA/CreateQnAFromScratchModal'; -import { showCreateQnAFromUrlDialogState } from '../../src/recoilModel'; - -describe('', () => { - const onDismiss = jest.fn(() => {}); - const onSubmit = jest.fn(() => {}); - const projectId = 'test-create-qna'; - const locales = ['en-us', 'zh-cn']; - const defaultLocale = 'en-us'; - - it('renders and create from scratch', () => { - const container = renderWithRecoil( - , - ({ set }) => { - set(showCreateQnAFromUrlDialogState(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(`adden-usInCreateQnAFromUrlModal`) 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({ urls: ['http://test'], locales: ['en-us'], name: 'test', multiTurn: false }); - }); - - it('create qna from scratch with name and validate the value', () => { - const container = renderWithRecoil( - , - () => {} - ); - - const { getByTestId } = container; - const inputName = getByTestId('knowledgeLocationTextField-name') as HTMLInputElement; - fireEvent.change(inputName, { target: { value: 'test' } }); - const createKnowledgeButton = getByTestId('createKnowledgeBase'); - expect(createKnowledgeButton).not.toBeNull(); - fireEvent.click(createKnowledgeButton); - expect(onSubmit).toBeCalledWith({ name: 'test' }); - }); -}); diff --git a/Composer/packages/client/__tests__/components/getStarted.test.tsx b/Composer/packages/client/__tests__/components/getStarted.test.tsx new file mode 100644 index 0000000000..c76459ad11 --- /dev/null +++ b/Composer/packages/client/__tests__/components/getStarted.test.tsx @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { fireEvent } from '@botframework-composer/test-utils'; +import React from 'react'; + +import { DialogSetting } from '../../../types/lib'; +import { GetStarted } from '../../src/components/GetStarted/GetStarted'; +import { GetStartedLearn } from '../../src/components/GetStarted/GetStartedLearn'; +import { + currentProjectIdState, + botProjectIdsState, + dialogsSelectorFamily, + schemasState, + projectMetaDataState, + botProjectFileState, + projectReadmeState, + settingsState, +} from '../../src/recoilModel'; +import { SAMPLE_DIALOG } from '../mocks/sampleDialog'; +import { renderWithRecoil } from '../testUtils'; + +const projectId = '12345.6789'; +const dialogs = [SAMPLE_DIALOG]; + +const luisConfig = { + name: '', + authoringKey: '12345', + authoringEndpoint: 'testAuthoringEndpoint', + endpointKey: '12345', + endpoint: 'testEndpoint', + authoringRegion: 'westus', + defaultLanguage: 'en-us', + environment: 'composer', +}; + +const qnaConfig = { subscriptionKey: '12345', endpointKey: '12345', qnaRegion: 'westus' }; + +const linkToPackageManager = `/bot/${projectId}/plugin/package-manager/package-manager`; +const linkToConnections = `/bot/${projectId}/botProjectsSettings/#connections`; +const linkToPublish = `/bot/${projectId}/publish/all`; +const linkToLUISSettings = `/bot/${projectId}/botProjectsSettings/#luisKey`; +const linktoQNASettings = `/bot/${projectId}/botProjectsSettings/#qnaKey`; +const linkToLGEditor = `/bot/${projectId}/language-generation`; +const linkToLUEditor = `/bot/${projectId}/language-understanding`; +const linkToReadme = `/bot/${projectId}/botProjectsSettings`; + +const mockNavigationTo = jest.fn(); +jest.mock('../../src/utils/navigation', () => ({ + navigateTo: (...args) => mockNavigationTo(...args), +})); + +const getMockSettingsState = (completePublishProf: boolean): DialogSetting => { + const publishTargetConfig = completePublishProf ? '{}' : '{"hostname":""}'; + return { + luis: luisConfig, + qna: qnaConfig, + defaultLanguage: 'en-us', + languages: ['en-us'], + luFeatures: {}, + runtime: { + key: '', + customRuntime: true, + path: '', + command: '', + }, + importedLibraries: [], + customFunctions: [], + publishTargets: [{ name: 'target1', type: 'azurewebapp', configuration: publishTargetConfig }], + }; +}; + +const getStartedProps = { + requiresLUIS: true, + requiresQNA: true, + showTeachingBubble: false, + projectId: projectId, + isOpen: true, + onBotReady: jest.fn(), + onDismiss: jest.fn(), +}; + +describe('', () => { + function renderComponent() { + return renderWithRecoil(); + } + + it('should render the component', async () => { + const component = renderComponent(); + expect(component.container).toHaveTextContent('Get started'); + expect(component.container).toHaveTextContent('Quick references'); + }); +}); + +describe('', () => { + const applyBaseState = (set: any) => { + set(currentProjectIdState, projectId); + set(botProjectIdsState, [projectId]); + set(dialogsSelectorFamily(projectId), dialogs); + set(schemasState(projectId), { sdk: { content: {} } }); + set(projectMetaDataState(projectId), { isRootBot: true }); + set(botProjectFileState(projectId), { foo: 'bar' }); + }; + + it('should render the component', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + }); + expect(component.container).toBeDefined(); + }); + + it('should render lg and lu steps (unconditional required steps)', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + }); + const lgNode = await component.queryByText('Edit bot responses'); + expect(lgNode).toBeTruthy(); + const luNode = await component.queryByText('Edit user input and triggers'); + expect(luNode).toBeTruthy(); + }); + + it('should render packages and insights steps (unconditional optional steps)', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + }); + const packagesNode = await component.queryByText('Add packages'); + expect(packagesNode).toBeTruthy(); + const appInsightsNode = await component.queryByText('Enable App Insights'); + expect(appInsightsNode).toBeTruthy(); + }); + + it('should render readMe next step', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + set(projectReadmeState(projectId), '## test markdown'); + }); + const node = await component.findByText('Review your template readme'); + expect(node).toBeTruthy(); + }); + + it('should not render readMe next step', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + }); + const node = await component.queryByText('Review your template readme'); + expect(node).toBeFalsy(); + }); + + it('should render LUIS and QNA', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + }); + const luisNode = await component.queryByText('Set up Language Understanding'); + expect(luisNode).toBeTruthy(); + const qnaNode = await component.queryByText('Set up QnA Maker'); + expect(qnaNode).toBeTruthy(); + }); + + it('should not render LUIS and QNA', async () => { + const component = renderWithRecoil( + , + ({ set }) => { + applyBaseState(set); + } + ); + const luisNode = await component.queryByText('Set up Language Understanding'); + expect(luisNode).toBeNull(); + const qnaNode = await component.queryByText('Set up QnA Maker'); + expect(qnaNode).toBeNull(); + }); + + it('should render lu and qna checked', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + set(settingsState(projectId), getMockSettingsState(true)); + }); + const checkedQna = await component.queryByTestId('qna-checked'); + expect(checkedQna).toBeTruthy(); + const checkedLuis = await component.queryByTestId('luis-checked'); + expect(checkedLuis).toBeTruthy(); + }); + + it('should render lu and qna unchecked', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + }); + const unCheckedQna = await component.queryByTestId('qna-unChecked'); + + expect(unCheckedQna).toBeTruthy(); + const unCheckedLuis = await component.queryByTestId('luis-unChecked'); + expect(unCheckedLuis).toBeTruthy(); + }); + + it('should not render create publish profile step', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + set(settingsState(projectId), getMockSettingsState(true)); + }); + const createPublishNode = await component.queryByText('Create a publishing profile'); + + expect(createPublishNode).toBeFalsy(); + }); + + it('should render create publish profile step and not complete profile step', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + }); + const createPublishNode = await component.queryByText('Create a publishing profile'); + expect(createPublishNode).toBeTruthy(); + const completePublishNode = await component.queryByText('Complete your publishing profile'); + expect(completePublishNode).toBeFalsy(); + }); + it('should render complete profile step', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + set(settingsState(projectId), getMockSettingsState(false)); + }); + const createPublishNode = await component.queryByText('Complete your publishing profile'); + + expect(createPublishNode).toBeTruthy(); + }); + + it('should render publish step and add Connections step', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + set(settingsState(projectId), getMockSettingsState(true)); + }); + const createPublishNode = await component.queryByText('Publish your bot'); + expect(createPublishNode).toBeTruthy(); + const addConnectionsNode = await component.queryByText('Add connections'); + expect(addConnectionsNode).toBeTruthy(); + }); + + it('Deep links should nave to correct page', async () => { + const component = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + set(settingsState(projectId), getMockSettingsState(true)); + }); + const createPublishNode = await component.queryByText('Publish your bot'); + expect(createPublishNode).toBeTruthy(); + const addConnectionsNode = await component.queryByText('Add connections'); + expect(addConnectionsNode).toBeTruthy(); + }); + + it('should have working deep links', () => { + const { getByTestId } = renderWithRecoil(, ({ set }) => { + applyBaseState(set); + set(settingsState(projectId), getMockSettingsState(true)); + set(projectReadmeState(projectId), '## test markdown'); + }); + fireEvent.click(getByTestId('readme-unChecked')); + expect(mockNavigationTo).toHaveBeenCalledWith(linkToReadme); + fireEvent.click(getByTestId('luis-checked')); + expect(mockNavigationTo).nthCalledWith(2, linkToLUISSettings); + fireEvent.click(getByTestId('qna-checked')); + expect(mockNavigationTo).nthCalledWith(3, linktoQNASettings); + fireEvent.click(getByTestId('editlg-unChecked')); + expect(mockNavigationTo).nthCalledWith(4, linkToLGEditor); + fireEvent.click(getByTestId('editlu-unChecked')); + expect(mockNavigationTo).nthCalledWith(5, linkToLUEditor); + fireEvent.click(getByTestId('publish-unChecked')); + expect(mockNavigationTo).nthCalledWith(6, linkToPublish); + fireEvent.click(getByTestId('connections-unChecked')); + expect(mockNavigationTo).nthCalledWith(7, linkToConnections); + fireEvent.click(getByTestId('packages-unChecked')); + expect(mockNavigationTo).nthCalledWith(8, linkToPackageManager); + }); +}); diff --git a/Composer/packages/client/__tests__/components/skill.test.tsx b/Composer/packages/client/__tests__/components/skill.test.tsx index 529ab55e62..20afa38035 100644 --- a/Composer/packages/client/__tests__/components/skill.test.tsx +++ b/Composer/packages/client/__tests__/components/skill.test.tsx @@ -1,22 +1,65 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { resolve } from 'path'; + import * as React from 'react'; import { act, fireEvent } from '@botframework-composer/test-utils'; +import * as JSZip from 'jszip'; +import { readFile } from 'fs-extra'; import httpClient from '../../src/utils/httpUtil'; import { renderWithRecoil } from '../testUtils'; import CreateSkillModal, { validateManifestUrl, + validateLocalZip, getSkillManifest, } from '../../src/components/AddRemoteSkillModal/CreateSkillModal'; -import { currentProjectIdState, settingsState } from '../../src/recoilModel'; - -jest.mock('../../src//utils/httpUtil'); +import { botProjectFileState, currentProjectIdState, settingsState } from '../../src/recoilModel'; jest.mock('../../src/components/Modal/dialogStyle', () => ({})); const projectId = '123a.234'; +const mockManifest = `{ + "$schema": "https://schemas.botframework.com/schemas/skills/v2.1/skill-manifest.json", + "$id": "djbfskill1-d2410f83-fc1b-4d34-a4ba-aa2582418306", + "endpoints": [ + { + "protocol": "BotFrameworkV3", + "name": "azure", + "endpointUrl": "https://djtest6.azurewebsites.net/api/messages", + "description": "", + "msAppId": "331877e9-da6e-4a7e-a398-79610b331cb0" + } + ], + "name": "djbfskill1", + "version": "1.0", + "publisherName": "Darren Jefford", + "activities": { + "djbfskill1": { + "type": "event", + "name": "djbfskill1" + }, + "message": { + "type": "message" + } + }, + "dispatchModels": { + "languages": { + "en-us": [ + { + "name": "djbfskill1", + "contentType": "application/lu", + "url": "https://djtest6.azurewebsites.net/manifests/skill-djbfskill1.en-us.lu", + "description": "" + } + ] + }, + "intents": [ + "Weather" + ] + } +}`; describe('', () => { const recoilInitState = ({ set }) => { @@ -62,6 +105,18 @@ describe('', () => { }, ], }); + + set(botProjectFileState(projectId), { + content: { + skills: { + oneNoteSync: { + manifest: 'https://xxx.json', + remote: true, + endpointName: 'default', + }, + }, + }, + }); }; it('should render the skill form, and update skill manifest URL', () => { @@ -86,13 +141,15 @@ describe('', () => { const nextButton = getByTestId('SetAppIdNext'); nextButton.click(); - const urlInput = getByLabelText('Skill Manifest URL'); + const urlInput = getByLabelText('Skill Manifest'); act(() => { fireEvent.change(urlInput, { target: { value: 'https://onenote-dev.azurewebsites.net/manifests/OneNoteSync-2-1-preview-1-manifest.json', }, }); + // allow validatation debounce to execute + jest.runAllTimers(); }); expect(urlInput.getAttribute('value')).toBe( @@ -107,11 +164,13 @@ describe('', () => { let formDataErrors; let setFormDataErrors; let setSkillManifest; + let showDetail; beforeEach(() => { formDataErrors = {}; setFormDataErrors = jest.fn(); setSkillManifest = jest.fn(); + showDetail = jest.fn(); }); describe('validateManifestUrl', () => { @@ -125,13 +184,12 @@ describe('', () => { }); expect(setFormDataErrors).toBeCalledWith( - expect.objectContaining({ manifestUrl: 'URL should start with http:// or https://' }) + expect.objectContaining({ + manifestUrl: 'URL should start with http:// or https:// or file path of your system', + }) ); expect(setSkillManifest).not.toBeCalled(); }); - }); - - describe('validateManifestUrl', () => { it('should set an error for a missing manifest URL', () => { const formData = {}; @@ -145,25 +203,51 @@ describe('', () => { }); it('should try and retrieve manifest', async () => { - (httpClient.get as jest.Mock) = jest.fn().mockResolvedValue({ data: 'skill manifest' }); + (httpClient.get as jest.Mock) = jest.fn().mockResolvedValue({ data: { name: 'skill manifest' } }); const manifestUrl = 'https://skill'; - await getSkillManifest(projectId, manifestUrl, setSkillManifest, setFormDataErrors); + await getSkillManifest(projectId, manifestUrl, setSkillManifest, setFormDataErrors, showDetail); expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { url: manifestUrl, }, }); - expect(setSkillManifest).toBeCalledWith('skill manifest'); + expect(setSkillManifest).toBeCalledWith({ name: 'skill manifest' }); + }); + + it('should try and retrieve manifest with local manifest', async () => { + (httpClient.get as jest.Mock) = jest.fn().mockResolvedValue({ data: JSON.parse(mockManifest) }); + + const manifestUrl = '/local/mock.json'; + + await getSkillManifest(projectId, manifestUrl, setSkillManifest, setFormDataErrors, showDetail); + expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieveSkillManifest`, { + params: { + url: manifestUrl, + }, + }); + expect(setSkillManifest).toBeCalledWith(JSON.parse(mockManifest)); + }); + + it('should try and retrieve manifest with zip manifest', async () => { + // create zip instance + const filep = resolve(__dirname, '../../__mocks__/mockManifest.zip'); + const zipFile = await readFile(filep); + const { files } = await JSZip.loadAsync(zipFile); + const result = await validateLocalZip(files); + expect(result.manifestContent).not.toBeNull(); + expect(result.error).toStrictEqual({}); + expect(result.path).toBe('relativeUris/'); + expect(result.zipContent).not.toBeNull(); }); it('should show error when it could not retrieve skill manifest', async () => { - (httpClient.get as jest.Mock) = jest.fn().mockRejectedValue({ message: 'skill manifest' }); + (httpClient.get as jest.Mock) = jest.fn().mockRejectedValue({ message: 'error message' }); const manifestUrl = 'https://skill'; - await getSkillManifest(projectId, manifestUrl, setSkillManifest, setFormDataErrors); + await getSkillManifest(projectId, manifestUrl, setSkillManifest, setFormDataErrors, showDetail); expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { url: manifestUrl, diff --git a/Composer/packages/client/__tests__/notFound.test.jsx b/Composer/packages/client/__tests__/notFound.test.tsx similarity index 94% rename from Composer/packages/client/__tests__/notFound.test.jsx rename to Composer/packages/client/__tests__/notFound.test.tsx index 218dc0dbd6..5f406d9800 100644 --- a/Composer/packages/client/__tests__/notFound.test.jsx +++ b/Composer/packages/client/__tests__/notFound.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import * as React from 'react'; +import React from 'react'; import { render } from '@botframework-composer/test-utils'; import { BASEPATH } from '../src/constants'; diff --git a/Composer/packages/client/__tests__/pages/botProjectsSettings/AllowedCallers.test.tsx b/Composer/packages/client/__tests__/pages/botProjectsSettings/AllowedCallers.test.tsx new file mode 100644 index 0000000000..062dd71b61 --- /dev/null +++ b/Composer/packages/client/__tests__/pages/botProjectsSettings/AllowedCallers.test.tsx @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { act, fireEvent } from '@botframework-composer/test-utils'; + +import { renderWithRecoilAndCustomDispatchers } from '../../testUtils'; +import { dispatcherState } from '../../../src/recoilModel'; +import { settingsState, currentProjectIdState } from '../../../src/recoilModel'; +import { AllowedCallers } from '../../../src/pages/botProject/AllowedCallers'; + +const state = { + projectId: '123', + settings: { + defaultLanguage: 'en-us', + languages: ['en-us', 'fr-fr'], + runtimeSettings: { + skills: { + allowedCallers: [], + }, + }, + }, +}; + +describe('Allowed Callers', () => { + it('should submit settings with new caller added/deleted', async () => { + const setSettingsMock = jest.fn(); + const initRecoilState = ({ set }) => { + set(currentProjectIdState, state.projectId); + set(settingsState(state.projectId), state.settings); + set(dispatcherState, { + setSettings: setSettingsMock, + }); + }; + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + + // Create new caller + const addCallerBtn = component.getByTestId('addNewAllowedCaller'); + await act(async () => { + await fireEvent.click(addCallerBtn); + }); + const addCallerField = component.getByTestId('addCallerInputField'); + await act(async () => { + await fireEvent.change(addCallerField, { + target: { value: 'newCaller' }, + }); + await fireEvent.blur(addCallerField); + }); + expect(setSettingsMock).toBeCalledWith(state.projectId, { + defaultLanguage: 'en-us', + languages: ['en-us', 'fr-fr'], + luis: { + authoringKey: '', + authoringRegion: '', + endpointKey: '', + }, + qna: { + subscriptionKey: '', + }, + runtimeSettings: { skills: { allowedCallers: ['newCaller'] } }, + }); + + // Delete Caller + const deleteCallerBtn = component.getByTestId('addCallerRemoveBtn'); + await act(async () => { + await fireEvent.click(deleteCallerBtn); + }); + expect(setSettingsMock).toBeCalledWith(state.projectId, { + defaultLanguage: 'en-us', + languages: ['en-us', 'fr-fr'], + luis: { + authoringKey: '', + authoringRegion: '', + endpointKey: '', + }, + qna: { + subscriptionKey: '', + }, + runtimeSettings: { skills: { allowedCallers: [] } }, + }); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/botProjectsSettings/BotProjectInfo.test.tsx b/Composer/packages/client/__tests__/pages/botProjectsSettings/BotProjectInfo.test.tsx new file mode 100644 index 0000000000..07d09eb7e0 --- /dev/null +++ b/Composer/packages/client/__tests__/pages/botProjectsSettings/BotProjectInfo.test.tsx @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { act, fireEvent } from '@botframework-composer/test-utils'; + +import { renderWithRecoilAndCustomDispatchers } from '../../testUtils'; +import { + botProjectFileState, + botProjectIdsState, + dialogsSelectorFamily, + dispatcherState, + locationState, + projectMetaDataState, + projectReadmeState, + schemasState, +} from '../../../src/recoilModel'; +import { currentProjectIdState } from '../../../src/recoilModel'; +import { SAMPLE_DIALOG } from '../../mocks/sampleDialog'; +import BotProjectInfo from '../../../src/pages/botProject/BotProjectInfo'; + +const projectId = '12345.6789'; +const dialogs = [SAMPLE_DIALOG]; +const mockLocation = 'foo/bar'; +const mockReadMeContent = 'mock read me content'; + +const setSettingsMock = jest.fn(); + +const initRecoilState = ({ set }) => { + set(currentProjectIdState, projectId); + set(botProjectIdsState, [projectId]); + set(dialogsSelectorFamily(projectId), dialogs); + set(schemasState(projectId), { sdk: { content: {} } }); + set(projectMetaDataState(projectId), { isRootBot: true }); + set(botProjectFileState(projectId), { foo: 'bar' }); + set(locationState(projectId), mockLocation); + set(projectReadmeState(projectId), mockReadMeContent); + set(dispatcherState, { + setSettings: setSettingsMock, + }); +}; + +describe('', () => { + it('should render correct bot location', async () => { + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + const locationNode = await component.findByTestId('botLocationString'); + expect(locationNode.textContent).toBe(mockLocation); + }); + + it('should open read me', async () => { + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + + const readMeBtn = component.getByTestId('settingsReadMeBtn'); + await act(async () => { + await fireEvent.click(readMeBtn); + }); + expect(component.findByText(mockReadMeContent)).toBeTruthy(); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/botProjectsSettings/BotProjectSettings.test.tsx b/Composer/packages/client/__tests__/pages/botProjectsSettings/BotProjectSettings.test.tsx new file mode 100644 index 0000000000..0b41fd2894 --- /dev/null +++ b/Composer/packages/client/__tests__/pages/botProjectsSettings/BotProjectSettings.test.tsx @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { act, fireEvent } from '@botframework-composer/test-utils'; + +import { renderWithRecoilAndCustomDispatchers } from '../../testUtils'; +import { + botProjectFileState, + botProjectIdsState, + dialogsSelectorFamily, + dispatcherState, + locationState, + projectMetaDataState, + projectReadmeState, + schemasState, +} from '../../../src/recoilModel'; +import { currentProjectIdState } from '../../../src/recoilModel'; +import { SAMPLE_DIALOG } from '../../mocks/sampleDialog'; +import BotProjectSettings from '../../../src/pages/botProject/BotProjectSettings'; + +const projectId = '12345.6789'; +const dialogs = [SAMPLE_DIALOG]; +const mockLocation = 'foo/bar'; +const mockReadMeContent = 'mock read me content'; + +const setSettingsMock = jest.fn(); +const mockNavigationTo = jest.fn(); + +enum PivotItemKey { + Basics = 'Basics', + LuisQna = 'LuisQna', + Connections = 'Connections', + SkillConfig = 'SkillConfig', + Language = 'Language', +} + +jest.mock('../../../src/utils/navigation', () => ({ + navigateTo: (...args) => mockNavigationTo(...args), + createBotSettingUrl: jest.fn(), +})); +const initRecoilState = ({ set }) => { + set(currentProjectIdState, projectId); + set(botProjectIdsState, [projectId]); + set(dialogsSelectorFamily(projectId), dialogs); + set(schemasState(projectId), { sdk: { content: {} } }); + set(projectMetaDataState(projectId), { isRootBot: true }); + set(botProjectFileState(projectId), { foo: 'bar' }); + set(locationState(projectId), mockLocation); + set(projectReadmeState(projectId), mockReadMeContent); + set(dispatcherState, { + setSettings: setSettingsMock, + setPageElementState: jest.fn(), + }); +}; +describe('', () => { + it('should toggle JSON view', async () => { + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + const jsonToggleNode = await component.findByTestId('advancedSettingsToggle'); + await act(async () => { + await fireEvent.click(jsonToggleNode); + }); + const editorNode = await component.findByTestId('BaseEditor'); + expect(editorNode).toBeTruthy(); + + await act(async () => { + await fireEvent.click(jsonToggleNode); + }); + + const tabViewContainer = await component.findByTestId('settingsTabView'); + expect(tabViewContainer).toBeTruthy(); + }); +}); + +describe('', () => { + const getRouteString = (itemKey: string) => { + return `/bot/${projectId}/botProjectsSettings/#${itemKey}`; + }; + it('should nav to all tabs', async () => { + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + const overviewTabNode = await component.findByText('Overview'); + await act(async () => { + await fireEvent.click(overviewTabNode); + }); + expect(mockNavigationTo).toHaveBeenCalledWith(getRouteString(PivotItemKey.Basics)); + + const devResourcesTabNode = await component.findByText('Development resources'); + await act(async () => { + await fireEvent.click(devResourcesTabNode); + }); + expect(mockNavigationTo).toHaveBeenCalledWith(getRouteString(PivotItemKey.LuisQna)); + + const connectionsTabNode = await component.findByText('Connections'); + await act(async () => { + await fireEvent.click(connectionsTabNode); + }); + expect(mockNavigationTo).toHaveBeenCalledWith(getRouteString(PivotItemKey.Connections)); + + const skillsTabNode = await component.findByText('Skill configuration'); + await act(async () => { + await fireEvent.click(skillsTabNode); + }); + expect(mockNavigationTo).toHaveBeenCalledWith(getRouteString(PivotItemKey.SkillConfig)); + + const localizationTabNode = await component.findByText('Localization'); + await act(async () => { + await fireEvent.click(localizationTabNode); + }); + expect(mockNavigationTo).toHaveBeenCalledWith(getRouteString(PivotItemKey.Language)); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/botProjectsSettings/AdapterSettings.test.tsx b/Composer/packages/client/__tests__/pages/botProjectsSettings/ExternalAdapterSettings.test.tsx similarity index 87% rename from Composer/packages/client/__tests__/pages/botProjectsSettings/AdapterSettings.test.tsx rename to Composer/packages/client/__tests__/pages/botProjectsSettings/ExternalAdapterSettings.test.tsx index 0a8a518ebd..03f0c9a12c 100644 --- a/Composer/packages/client/__tests__/pages/botProjectsSettings/AdapterSettings.test.tsx +++ b/Composer/packages/client/__tests__/pages/botProjectsSettings/ExternalAdapterSettings.test.tsx @@ -2,8 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { act, fireEvent, within } from '@botframework-composer/test-utils'; -import userEvent from '@testing-library/user-event'; +import { act, fireEvent, within, userEvent } from '@botframework-composer/test-utils'; import ExternalAdapterSettings from '../../../src/pages/botProject/adapters/ExternalAdapterSettings'; import { renderWithRecoilAndCustomDispatchers } from '../../testUtils'; @@ -63,6 +62,11 @@ const mockSchemas = { const setSettingsMock = jest.fn(); +const mockNavigationTo = jest.fn(); +jest.mock('../../../src/utils/navigation', () => ({ + navigateTo: (...args) => mockNavigationTo(...args), +})); + const makeInitialState = (newSettings: {}) => ({ set }) => { set(currentProjectIdState, PROJECT_ID); set(settingsState(PROJECT_ID), newSettings); @@ -140,7 +144,7 @@ describe('ExternalAdapterSettings', () => { }); }); - it('does not proceed if required settings are missing', async () => { + it('does not proceed if required settings are missing', () => { const { getByTestId } = renderWithRecoilAndCustomDispatchers( , initRecoilState @@ -152,10 +156,10 @@ describe('ExternalAdapterSettings', () => { }); const modal = getByTestId('adapterModal'); - expect(within(modal).getByText('Configure')).toBeDisabled(); + expect(within(modal).getByRole('button', { name: 'Configure' })).toBeDisabled(); }); - it('disables an adapter', async () => { + it('disables an adapter', () => { const initStateWithAdapter = { runtimeSettings: { adapters: [{ name: 'Adapter.Mock', enabled: true, route: 'mock', type: 'Adapter.Full.Type.Mock' }], @@ -176,7 +180,7 @@ describe('ExternalAdapterSettings', () => { const toggle = queryByTestId('toggle_Adapter.Mock'); expect(toggle).not.toBeNull(); - await act(async () => { + act(() => { fireEvent.click(toggle!); }); @@ -190,7 +194,7 @@ describe('ExternalAdapterSettings', () => { ); }); - it('enables an adapter', async () => { + it('enables an adapter', () => { const initStateWithAdapter = { runtimeSettings: { adapters: [{ name: 'Adapter.Mock', enabled: false, route: 'mock', type: 'Adapter.Full.Type.Mock' }], @@ -210,7 +214,7 @@ describe('ExternalAdapterSettings', () => { const toggle = queryByTestId('toggle_Adapter.Mock'); expect(toggle).not.toBeNull(); - await act(async () => { + act(() => { fireEvent.click(toggle!); }); @@ -223,4 +227,15 @@ describe('ExternalAdapterSettings', () => { }) ); }); + + it('deep link should nav to package manager', async () => { + const { getByTestId } = renderWithRecoilAndCustomDispatchers( + , + makeInitialState({}) + ); + + fireEvent.click(getByTestId('packageManagerDeepLink')); + + expect(mockNavigationTo).toHaveBeenCalledWith(`/bot/${PROJECT_ID}/plugin/package-manager/package-manager`); + }); }); diff --git a/Composer/packages/client/__tests__/pages/botProjectsSettings/ExternalService.test.tsx b/Composer/packages/client/__tests__/pages/botProjectsSettings/ExternalService.test.tsx new file mode 100644 index 0000000000..2c2af4086a --- /dev/null +++ b/Composer/packages/client/__tests__/pages/botProjectsSettings/ExternalService.test.tsx @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; + +import { renderWithRecoilAndCustomDispatchers } from '../../testUtils'; +import { botProjectFileState, botProjectIdsState, projectMetaDataState } from '../../../src/recoilModel'; +import { ExternalService } from '../../../src/pages/botProject/ExternalService'; + +const mockRootProjId = '123'; +const mockSkillProjId = '456'; + +describe('', () => { + const initRecoilState = ({ set }) => { + set(botProjectIdsState, [mockRootProjId, mockSkillProjId]); + set(projectMetaDataState(mockRootProjId), { isRootBot: true }); + set(botProjectFileState(mockRootProjId), { foo: 'bar' }); + set(projectMetaDataState(mockSkillProjId), { isRootBot: false }); + set(botProjectFileState(mockSkillProjId), { foo: 'bar' }); + }; + + it('should render root external service view', async () => { + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + const skillBotTextNode = await component.queryByTestId('skillQnaAuthoringBtn'); + expect(skillBotTextNode).toBeFalsy(); + }); + + it('should render skill external service view', async () => { + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + const skillBotTextNode = await component.queryByTestId('skillQnaAuthoringBtn'); + expect(skillBotTextNode).toBeTruthy(); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/botProjectsSettings/GetAppInfoFromPublishProfileDialog.test.tsx b/Composer/packages/client/__tests__/pages/botProjectsSettings/GetAppInfoFromPublishProfileDialog.test.tsx new file mode 100644 index 0000000000..50ad68ffa7 --- /dev/null +++ b/Composer/packages/client/__tests__/pages/botProjectsSettings/GetAppInfoFromPublishProfileDialog.test.tsx @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { fireEvent, screen } from '@botframework-composer/test-utils'; + +import { renderWithRecoilAndCustomDispatchers } from '../../testUtils'; +import { dispatcherState } from '../../../src/recoilModel'; +import { settingsState, currentProjectIdState } from '../../../src/recoilModel'; +import { GetAppInfoFromPublishProfileDialog } from '../../../src/pages/botProject/GetAppInfoFromPublishProfileDialog'; +import { PublishTarget } from '../../../../types/lib'; + +const projectId = '123'; +const publishTargetConfig = { settings: { MicrosoftAppId: 'mockAppId', MicrosoftAppPassword: 'mockPass' } }; +const mockProfileName = 'target1'; +const getMockSettings = (mockPublishTargets: boolean) => { + const result = { + defaultLanguage: 'en-us', + languages: ['en-us', 'fr-fr'], + publishTargets: [] as PublishTarget[], + }; + if (mockPublishTargets) { + result.publishTargets.push({ + name: mockProfileName, + type: 'azurewebapp', + configuration: JSON.stringify(publishTargetConfig), + }); + } + return result; +}; + +describe('', () => { + it('should render error message if there are no publish profiles', async () => { + const setSettingsMock = jest.fn(); + const onOkayMock = jest.fn(); + const onCancelMock = jest.fn(); + const initRecoilState = ({ set }) => { + set(currentProjectIdState, projectId); + set(settingsState(projectId), getMockSettings(false)); + set(dispatcherState, { + setSettings: setSettingsMock, + }); + }; + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + const errorNode = await component.findByText('No profiles were found containing a Microsoft App ID.'); + expect(errorNode).toBeTruthy(); + }); + + it('should render available publish profiles', async () => { + const setSettingsMock = jest.fn(); + const onOkayMock = jest.fn(); + const onCancelMock = jest.fn(); + const initRecoilState = ({ set }) => { + set(currentProjectIdState, projectId); + set(settingsState(projectId), getMockSettings(true)); + set(dispatcherState, { + setSettings: setSettingsMock, + }); + }; + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + const dropdown = component.getByTestId('publishProfileDropdown'); + fireEvent.click(dropdown); + const options = screen.getAllByRole('option').slice(1); + expect(options[0]).toHaveTextContent(mockProfileName); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/botProjectsSettings/RuntimeSettings.test.tsx b/Composer/packages/client/__tests__/pages/botProjectsSettings/RuntimeSettings.test.tsx new file mode 100644 index 0000000000..af3083af45 --- /dev/null +++ b/Composer/packages/client/__tests__/pages/botProjectsSettings/RuntimeSettings.test.tsx @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { act, fireEvent } from '@botframework-composer/test-utils'; + +import { renderWithRecoilAndCustomDispatchers } from '../../testUtils'; +import { + botDisplayNameState, + botProjectFileState, + botProjectIdsState, + dialogsSelectorFamily, + dispatcherState, + projectMetaDataState, + schemasState, + settingsState, +} from '../../../src/recoilModel'; +import { currentProjectIdState } from '../../../src/recoilModel'; +import { SAMPLE_DIALOG } from '../../mocks/sampleDialog'; +import { RuntimeSettings } from '../../../src/pages/botProject/RuntimeSettings'; + +const projectId = '12345.6789'; +const dialogs = [SAMPLE_DIALOG]; +const mockRuntimeCommand = 'dotnet run --project foo'; +const mockSettings = { + defaultLanguage: 'en-us', + languages: ['en-us', 'fr-fr'], + runtime: { + command: mockRuntimeCommand, + }, +}; + +const setRuntimeFieldMock = jest.fn(); + +const initRecoilState = ({ set }) => { + set(settingsState(projectId), mockSettings); + set(currentProjectIdState, projectId); + set(botProjectIdsState, [projectId]); + set(dialogsSelectorFamily(projectId), dialogs); + set(schemasState(projectId), { sdk: { content: {} } }); + set(projectMetaDataState(projectId), { isRootBot: true }); + set(botProjectFileState(projectId), { foo: 'bar' }); + set(botDisplayNameState(projectId), 'mockBot'); + set(dispatcherState, { + setRuntimeField: setRuntimeFieldMock, + }); +}; + +describe('', () => { + it('should render existing custom path', async () => { + const component = renderWithRecoilAndCustomDispatchers(, initRecoilState); + + const startCommandNode = await component.findByTestId('runtimeCommand'); + + expect(startCommandNode).toHaveValue(mockRuntimeCommand); + }); + + it('change custom path in settings state and error if empty', async () => { + const component = renderWithRecoilAndCustomDispatchers(, initRecoilState); + + const startCommandNode = await component.findByTestId('runtimeCommand'); + await act(async () => { + await fireEvent.change(startCommandNode, { target: { value: '' } }); + await fireEvent.blur(startCommandNode); + }); + expect(setRuntimeFieldMock).toBeCalledWith(projectId, 'command', ''); + const errorNode = await component.findByTestId('runtimeErrorText'); + expect(errorNode).toBeTruthy(); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/botProjectsSettings/SkillBotExternalService.test.tsx b/Composer/packages/client/__tests__/pages/botProjectsSettings/SkillBotExternalService.test.tsx new file mode 100644 index 0000000000..cd2fe63aa7 --- /dev/null +++ b/Composer/packages/client/__tests__/pages/botProjectsSettings/SkillBotExternalService.test.tsx @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { act, fireEvent } from '@botframework-composer/test-utils'; + +import { renderWithRecoilAndCustomDispatchers } from '../../testUtils'; +import { dialogIdsState, dispatcherState } from '../../../src/recoilModel'; +import { + settingsState, + currentProjectIdState, + projectMetaDataState, + botProjectIdsState, + dialogState, + luFilesSelectorFamily, +} from '../../../src/recoilModel'; +import { SkillBotExternalService } from '../../../src/pages/botProject/SkillBotExternalService'; + +const rootProjId = '123'; +const skillProjId = '456'; +const mockDialogId1 = 'dialog1'; +const mockDialogId2 = 'dialog2'; +const sharedState = { + dialogs: [ + { + content: { + recognizer: '', + }, + id: mockDialogId1, + }, + { + content: { + recognizer: '', + }, + id: mockDialogId2, + }, + ], + qnaFiles: [ + { + content: '', + empty: true, + id: 'dialog1.en-us', + }, + ], + luFiles: [ + { + content: '', + empty: true, + id: 'dialog1.en-us', + }, + ], + botProjectIdsState: [rootProjId, skillProjId], +}; + +const rootBotState = { + ...sharedState, + projectId: rootProjId, + settings: {}, + projectMetaDataState: { + isRootBot: true, + isRemote: false, + }, +}; + +const skillBotState = { + ...sharedState, + projectId: skillProjId, + settings: {}, + projectMetaDataState: { + isRootBot: true, + isRemote: false, + }, +}; + +jest.mock('@bfc/indexers', () => ({ + BotIndexer: { + shouldUseLuis: jest.fn().mockReturnValue(true), + shouldUseQnA: jest.fn().mockReturnValue(true), + }, +})); + +const setSettingsMock = jest.fn(); +const setQnASettingsMock = jest.fn(); +const initRecoilState = ({ set }) => { + // Skill state set up + set(currentProjectIdState, skillProjId); + set(dialogIdsState(skillProjId), [mockDialogId1, mockDialogId2]); + set(dialogState({ projectId: skillProjId, dialogId: sharedState.dialogs[0].id }), sharedState.dialogs[0]); + set(dialogState({ projectId: skillProjId, dialogId: sharedState.dialogs[1].id }), sharedState.dialogs[1]); + set(luFilesSelectorFamily(skillProjId), sharedState.luFiles); + set(projectMetaDataState(skillProjId), skillBotState.projectMetaDataState); + set(settingsState(skillProjId), skillBotState.settings); + // root bot state set up + set(dialogState({ projectId: rootProjId, dialogId: sharedState.dialogs[0].id }), sharedState.dialogs[0]); + set(dialogState({ projectId: rootProjId, dialogId: sharedState.dialogs[1].id }), sharedState.dialogs[1]); + set(luFilesSelectorFamily(rootProjId), sharedState.luFiles); + set(projectMetaDataState(rootProjId), rootBotState.projectMetaDataState); + set(settingsState(rootProjId), rootBotState.settings); + + // shared state set up + set(dispatcherState, { + setSettings: setSettingsMock, + setQnASettings: setQnASettingsMock, + }); + set(botProjectIdsState, sharedState.botProjectIdsState); +}; + +describe('', () => { + it('should disable text field and show errors if root bot luis keys are empty', async () => { + //test + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + + const textFieldAuthoring = await component.getByTestId('skillLUISAuthoringKeyField'); + expect(textFieldAuthoring).toBeDisabled(); + const endpointErrorMessage = await component.getByText('Root Bot LUIS region is empty'); + expect(endpointErrorMessage).toBeTruthy(); + const regionErrorMessage = await component.getByText('Root Bot LUIS region is empty'); + expect(regionErrorMessage).toBeTruthy(); + }); + it('should allow skill specific luis key that is updated in settings', async () => { + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + const useSkillLuisKeyBtn = await component.getByTestId('skillLUISAuthoringKeyBtn'); + await act(async () => { + await fireEvent.click(useSkillLuisKeyBtn); + }); + const skillLuisKeyField = await component.getByTestId('skillLUISAuthoringKeyField'); + await act(async () => { + await fireEvent.change(skillLuisKeyField, { target: { value: 'newLuisKey' } }); + await fireEvent.blur(skillLuisKeyField); + }); + expect(setSettingsMock).toBeCalledWith(skillProjId, { + luis: { + authoringKey: 'newLuisKey', + authoringRegion: '', + endpointKey: '', + }, + qna: { + subscriptionKey: '', + }, + }); + }); + + it('should allow skill specific qna key that is updated in settings', async () => { + const component = renderWithRecoilAndCustomDispatchers( + , + initRecoilState + ); + const useSkillQnaKeyBtn = await component.getByTestId('skillQnaAuthoringBtn'); + await act(async () => { + await fireEvent.click(useSkillQnaKeyBtn); + }); + const skillQnaKeyField = await component.getByTestId('skillQnaAuthoringField'); + await act(async () => { + await fireEvent.change(skillQnaKeyField, { target: { value: 'newQnaKey' } }); + await fireEvent.blur(skillQnaKeyField); + }); + expect(setSettingsMock).toBeCalledWith(skillProjId, { + luis: { + authoringKey: '', + authoringRegion: '', + endpointKey: '', + }, + qna: { + subscriptionKey: 'newQnaKey', + }, + }); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/design/DebugPanel/diagnosticList.test.tsx b/Composer/packages/client/__tests__/pages/design/DebugPanel/DiagnosticsContent.test.tsx similarity index 70% rename from Composer/packages/client/__tests__/pages/design/DebugPanel/diagnosticList.test.tsx rename to Composer/packages/client/__tests__/pages/design/DebugPanel/DiagnosticsContent.test.tsx index a8d24a5c0d..8274def0dd 100644 --- a/Composer/packages/client/__tests__/pages/design/DebugPanel/diagnosticList.test.tsx +++ b/Composer/packages/client/__tests__/pages/design/DebugPanel/DiagnosticsContent.test.tsx @@ -2,6 +2,8 @@ // Licensed under the MIT License. import * as React from 'react'; +import { fireEvent } from '@botframework-composer/test-utils'; +import { DiagnosticSeverity } from '@botframework-composer/types'; import { Range, Position } from '@bfc/shared'; import { renderWithRecoil } from '../../../testUtils'; @@ -17,8 +19,16 @@ import { luFilesSelectorFamily, schemasState, settingsState, + projectMetaDataState, + dialogState, } from '../../../../src/recoilModel'; import mockProjectResponse from '../../../../src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json'; +import { DiagnosticsContent } from '../../../../src/pages/design/DebugPanel/TabExtensions/DiagnosticsTab/DiagnosticsTabContent'; + +const mockNavigationTo = jest.fn(); +jest.mock('../../../../src/utils/navigation', () => ({ + navigateTo: (...args) => mockNavigationTo(...args), +})); const state = { projectId: 'test', @@ -29,6 +39,7 @@ const state = { luFile: 'test', referredLuIntents: [], skills: [`=settings.skill['Email-Skill'].endpointUrl`], + projectId: 'test', }, ], luFiles: [ @@ -46,7 +57,7 @@ const state = { diagnostics: [ { message: 'lu syntax error', - severity: 'Error', + severity: DiagnosticSeverity.Error, location: 'test.en-us', range: { end: { character: 2, line: 7 }, @@ -70,7 +81,7 @@ const state = { diagnostics: [ { message: 'lg syntax error', - severity: 'Error', + severity: DiagnosticSeverity.Error, location: 'test.en-us', range: { end: { character: 2, line: 13 }, @@ -89,7 +100,7 @@ const state = { diagnostics: [ { message: 'server error', - severity: 'Error', + severity: DiagnosticSeverity.Error, location: 'server', }, ], @@ -101,6 +112,14 @@ const state = { name: 'Email-Skill', }, }, + luis: { + name: 'luis', + endpointKey: 'asds', + }, + qna: { + subscriptionKey: 'asd', + endpointKey: 'asds', + }, }, formDialogSchemas: [{ id: '1', content: '{}' }], }; @@ -109,7 +128,11 @@ describe('', () => { const initRecoilState = ({ set }) => { set(currentProjectIdState, state.projectId); set(botProjectIdsState, [state.projectId]); - set(dialogIdsState(state.projectId), []); + set(projectMetaDataState(state.projectId), { + isRootBot: true, + }); + set(dialogState({ projectId: state.projectId, dialogId: state.dialogs[0].id }), state.dialogs[0]); + set(dialogIdsState(state.projectId), ['test']); set(luFilesSelectorFamily(state.projectId), state.luFiles); set(lgFilesSelectorFamily(state.projectId), state.lgFiles); set(jsonSchemaFilesState(state.projectId), state.jsonSchemaFiles); @@ -129,4 +152,13 @@ describe('', () => { ); expect(container).toHaveTextContent('server'); }); + + it('should render the Diagnostics', () => { + const { getByText } = renderWithRecoil(, initRecoilState); + + fireEvent.click(getByText(/test.en-us.lg/)); + expect(mockNavigationTo).toBeCalledWith('/bot/test/language-generation/test/edit#L=13'); + fireEvent.click(getByText(/test.en-us.lu/)); + expect(mockNavigationTo).nthCalledWith(2, '/bot/test/language-understanding/test/edit#L=7'); + }); }); diff --git a/Composer/packages/client/__tests__/pages/design/Design.test.tsx b/Composer/packages/client/__tests__/pages/design/Design.test.tsx index 507ca29aa9..d3b12856af 100644 --- a/Composer/packages/client/__tests__/pages/design/Design.test.tsx +++ b/Composer/packages/client/__tests__/pages/design/Design.test.tsx @@ -16,9 +16,6 @@ import { undoFunctionState } from '../../../src/recoilModel/undo/history'; import mockProjectResponse from '../../../src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json'; import DesignPage from '../../../src/pages/design/DesignPage'; import { SAMPLE_DIALOG } from '../../mocks/sampleDialog'; -import ResizeObserver from '../../mocks/ResizeObserver'; - -(global as any).ResizeObserver = ResizeObserver; const projectId = '12345.6789'; const dialogId = SAMPLE_DIALOG.id; diff --git a/Composer/packages/client/__tests__/pages/knowledge-base/CreateQnAModal.test.tsx b/Composer/packages/client/__tests__/pages/knowledge-base/CreateQnAModal.test.tsx new file mode 100644 index 0000000000..7393f62acd --- /dev/null +++ b/Composer/packages/client/__tests__/pages/knowledge-base/CreateQnAModal.test.tsx @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/* eslint-disable react-hooks/rules-of-hooks */ +import React from 'react'; +import { fireEvent } from '@botframework-composer/test-utils'; + +import CreateQnAModal from '../../../src/components/QnA/CreateQnAModal'; +import { renderWithRecoil } from '../../testUtils'; +import { + localeState, + dialogsSelectorFamily, + qnaFilesSelectorFamily, + settingsState, + schemasState, + dispatcherState, + currentProjectIdState, + createQnAOnState, + showCreateQnADialogState, +} from '../../../src/recoilModel'; +import mockProjectResponse from '../../../src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json'; + +const initialContent = ` +# ?question +\`\`\` +answer +\`\`\` +`; + +const handleSubmit = jest.fn(); + +const state = { + projectId: 'test', + skillId: '', + dialogs: [ + { id: '1', content: '', skills: [] }, + { id: '2', content: '', skills: [] }, + ], + locale: 'en-us', + qnaFiles: [ + { + id: 'a.source.en-us', + content: '', + imports: [], + options: [], + diagnostics: [], + qnaSections: [ + { + Questions: [{ content: 'question', id: '2' }], + Answer: 'answer', + sectionId: '2', + Body: '', + }, + ], + empty: true, + resource: { Errors: [], Content: '', Sections: [] }, + isContentUnparsed: true, + }, + { + id: 'a.en-us', + content: initialContent, + imports: [], + options: [], + diagnostics: [], + qnaSections: [ + { + Questions: [{ content: 'question', id: '1' }], + Answer: 'answer', + sectionId: '1', + Body: '', + }, + ], + empty: true, + resource: { Errors: [], Content: '', Sections: [] }, + isContentUnparsed: true, + }, + ], + settings: { + defaultLanguage: 'en-us', + languages: ['en-us', 'zh-cn'], + }, +}; + +const updateQnAFileMock = jest.fn(); + +const initRecoilState = ({ set }) => { + set(currentProjectIdState, state.projectId); + set(localeState(state.projectId), state.locale); + set(dialogsSelectorFamily(state.projectId), state.dialogs); + set(qnaFilesSelectorFamily(state.projectId), state.qnaFiles); + set(settingsState(state.projectId), state.settings); + set(schemasState(state.projectId), mockProjectResponse.schemas); + set(dispatcherState, { + updateQnAFile: updateQnAFileMock, + }); + set(createQnAOnState, { projectId: state.projectId, dialogId: state.dialogs[0].id }); + set(showCreateQnADialogState(state.projectId), true); +}; + +describe('QnA creation flow', () => { + it('should create qna from url', () => { + const { getByTestId, getByText, getAllByText } = renderWithRecoil( + , + initRecoilState + ); + + const nameField = getByTestId('knowledgeLocationTextField-name'); + fireEvent.change(nameField, { target: { value: 'name' } }); + const next1 = getByText('Next'); + fireEvent.click(next1); + + const option = getAllByText('Create new knowledge base from URL'); + fireEvent.click(option[0]); + + const urlField = getByTestId('adden-usInCreateQnAFromUrlModal'); + fireEvent.change(urlField, { target: { value: 'http://newUrl.pdf' } }); + const next = getByText('Next'); + fireEvent.click(next); + expect(handleSubmit).toBeCalled(); + }); + + it('should create qna from portal', () => { + const { getByTestId, getByText } = renderWithRecoil( + , + initRecoilState + ); + + const nameField = getByTestId('knowledgeLocationTextField-name'); + fireEvent.change(nameField, { target: { value: 'name' } }); + const next1 = getByText('Next'); + fireEvent.click(next1); + + const option = getByText('Import existing knowledge base from QnA maker portal'); + fireEvent.click(option); + + const next = getByText('Next'); + fireEvent.click(next); + getByText('Select source knowledge base location'); + }); + + it('should create qna from scratch', () => { + const { getByTestId, getByText } = renderWithRecoil( + , + initRecoilState + ); + + const nameField = getByTestId('knowledgeLocationTextField-name'); + fireEvent.change(nameField, { target: { value: 'name' } }); + const next1 = getByText('Next'); + fireEvent.click(next1); + + const next = getByText('Skip & Create blank knowledge base'); + fireEvent.click(next); + expect(handleSubmit).toBeCalled(); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/knowledge-base/ReplaceQnAFromModal.test.tsx b/Composer/packages/client/__tests__/pages/knowledge-base/ReplaceQnAFromModal.test.tsx new file mode 100644 index 0000000000..751bf6d9d2 --- /dev/null +++ b/Composer/packages/client/__tests__/pages/knowledge-base/ReplaceQnAFromModal.test.tsx @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/* eslint-disable react-hooks/rules-of-hooks */ +import React from 'react'; +import { fireEvent } from '@botframework-composer/test-utils'; + +import ReplaceQnAFromModal from '../../../src/components/QnA/ReplaceQnAFromModal'; +import { renderWithRecoil } from '../../testUtils'; + +const handleSubmit = jest.fn(); +const onDismiss = jest.fn(); + +const qnaFile = { + id: 'a.source.en-us', + content: '', + imports: [], + options: [], + diagnostics: [], + qnaSections: [], + empty: true, + resource: { Errors: [], Content: '', Sections: [] }, + isContentUnparsed: true, +}; + +describe('Replace QnA from portal Modal', () => { + it('should import QnA from url', () => { + const { getByText, getByTestId } = renderWithRecoil( + + ); + const urlField = getByTestId('ImportNewUrlToOverwriteQnAFile'); + fireEvent.change(urlField, { target: { value: 'http://newUrl.pdf' } }); + const submitButton = getByText('Next'); + fireEvent.click(submitButton); + expect(handleSubmit).toBeCalled(); + }); + + it('should render QnA from portal Modal', () => { + const { getByText } = renderWithRecoil( + + ); + const secondOption = getByText('Replace with an existing knowledge base from QnA maker portal'); + fireEvent.click(secondOption); + const next = getByText('Next'); + fireEvent.click(next); + + expect( + getByText('Select the subscription and resource you want to choose a knowledge base from') + ).toBeInTheDocument(); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/knowledge-base/table-view.test.tsx b/Composer/packages/client/__tests__/pages/knowledge-base/table-view.test.tsx index 679e4025a7..425072fc02 100644 --- a/Composer/packages/client/__tests__/pages/knowledge-base/table-view.test.tsx +++ b/Composer/packages/client/__tests__/pages/knowledge-base/table-view.test.tsx @@ -80,7 +80,7 @@ describe('QnA page all up view', () => { ); const more = getByTestId('knowledgeBaseMore'); fireEvent.click(more); - getByText('Import new URL and overwrite'); + getByText('Replace content'); getByText('Delete knowledge base'); getByText('Show code'); }); diff --git a/Composer/packages/client/__tests__/pages/notifications/diagnosticFilter.test.tsx b/Composer/packages/client/__tests__/pages/notifications/diagnosticFilter.test.tsx deleted file mode 100644 index 54061f30e9..0000000000 --- a/Composer/packages/client/__tests__/pages/notifications/diagnosticFilter.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import * as React from 'react'; -import { fireEvent, render } from '@botframework-composer/test-utils'; - -import { DiagnosticFilter } from '../../../src/pages/diagnostics/DiagnosticFilter'; - -describe('', () => { - it('should render the DiagnosticFilter', () => { - const mockOnChange = jest.fn(() => null); - const { container } = render(); - - expect(container).toHaveTextContent('All'); - const dropdown: any = container.querySelector('[data-testid="notifications-dropdown"]'); - fireEvent.click(dropdown); - const test = document.querySelector('.ms-Dropdown-callout'); - expect(test).toHaveTextContent('Error'); - expect(test).toHaveTextContent('Warning'); - }); -}); diff --git a/Composer/packages/client/__tests__/pages/notifications/diagnosticList.test.tsx b/Composer/packages/client/__tests__/pages/notifications/diagnosticList.test.tsx deleted file mode 100644 index eb788bc841..0000000000 --- a/Composer/packages/client/__tests__/pages/notifications/diagnosticList.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import * as React from 'react'; -import { Range, Position } from '@bfc/shared'; - -import { renderWithRecoil } from '../../testUtils'; -import { - botDiagnosticsState, - botProjectIdsState, - currentProjectIdState, - dialogIdsState, - formDialogSchemaIdsState, - jsonSchemaFilesState, - lgFilesSelectorFamily, - luFilesSelectorFamily, - schemasState, - settingsState, -} from '../../../src/recoilModel'; -import mockProjectResponse from '../../../src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json'; -import { DiagnosticList } from '../../../src/pages/diagnostics/DiagnosticList'; - -const state = { - projectId: 'test', - dialogs: [ - { - id: 'test', - content: 'test', - luFile: 'test', - referredLuIntents: [], - skills: [`=settings.skill['Email-Skill'].endpointUrl`], - }, - ], - luFiles: [ - { - content: 'test', - id: 'test.en-us', - intents: [ - { - Body: '- test12345 ss', - Entities: [], - Name: 'test', - range: new Range(new Position(4, 0), new Position(7, 14)), - }, - ], - diagnostics: [ - { - message: 'lu syntax error', - severity: 'Error', - location: 'test.en-us', - range: { - end: { character: 2, line: 7 }, - start: { character: 0, line: 7 }, - }, - }, - ], - }, - ], - lgFiles: [ - { - content: 'test', - id: 'test.en-us', - templates: [ - { - body: '- ${add(1,2)}', - name: 'bar', - range: new Range(new Position(0, 0), new Position(2, 14)), - }, - ], - diagnostics: [ - { - message: 'lg syntax error', - severity: 'Error', - location: 'test.en-us', - range: { - end: { character: 2, line: 13 }, - start: { character: 0, line: 13 }, - }, - }, - ], - }, - ], - jsonSchemaFiles: [ - { - id: 'schema1.json', - content: 'test', - }, - ], - diagnostics: [ - { - message: 'server error', - severity: 'Error', - location: 'server', - }, - ], - settings: { - skill: { - 'Email-Skill': { - manifestUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json', - endpointUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/api/messages', - name: 'Email-Skill', - }, - }, - }, - formDialogSchemas: [{ id: '1', content: '{}' }], -}; - -describe('', () => { - const initRecoilState = ({ set }) => { - set(currentProjectIdState, state.projectId); - set(botProjectIdsState, [state.projectId]); - set(dialogIdsState(state.projectId), []); - set(luFilesSelectorFamily(state.projectId), state.luFiles); - set(lgFilesSelectorFamily(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) - ); - }; - - it('should render the DiagnosticList', () => { - const { container } = renderWithRecoil( - , - initRecoilState - ); - expect(container).toHaveTextContent('server'); - }); -}); diff --git a/Composer/packages/client/__tests__/pages/notifications/diagnostics.test.tsx b/Composer/packages/client/__tests__/pages/notifications/diagnostics.test.tsx deleted file mode 100644 index 792e59b3b3..0000000000 --- a/Composer/packages/client/__tests__/pages/notifications/diagnostics.test.tsx +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import * as React from 'react'; -import { Range, Position } from '@bfc/shared'; -import { fireEvent } from '@botframework-composer/test-utils'; - -import { - botDiagnosticsState, - botProjectIdsState, - currentProjectIdState, - dialogIdsState, - dialogState, - formDialogSchemaIdsState, - jsonSchemaFilesState, - lgFilesSelectorFamily, - luFilesSelectorFamily, - qnaFilesSelectorFamily, - schemasState, - settingsState, -} from '../../../src/recoilModel'; -import mockProjectResponse from '../../../src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json'; -import Diagnostics from '../../../src/pages/diagnostics/Diagnostics'; -import { renderWithRecoil } from '../../testUtils/renderWithRecoil'; - -const state = { - projectId: 'testproj', - dialogs: [ - { - id: 'test', - content: { recognizer: {} }, - luFile: 'test', - referredLuIntents: [], - skills: [`=settings.skill['Email-Skill'].endpointUrl`], - diagnostics: [ - { - message: 'dialog expression error', - severity: 0, - source: 'test', - }, - ], - }, - ], - qnaFiles: [ - { - content: `# ? tell a joke`, - id: 'test.en-us', - diagnostics: [ - { - message: 'qna syntax error', - severity: 0, - source: 'test.en-us', - range: { - end: { character: 2, line: 7 }, - start: { character: 0, line: 7 }, - }, - }, - ], - }, - ], - luFiles: [ - { - content: 'test', - id: 'test.en-us', - intents: [ - { - Body: '- test12345 ss', - Entities: [], - Name: 'test', - range: new Range(new Position(4, 0), new Position(7, 14)), - }, - ], - diagnostics: [ - { - message: 'lu syntax error', - severity: 0, - source: 'test.en-us', - range: { - end: { character: 2, line: 7 }, - start: { character: 0, line: 7 }, - }, - }, - ], - }, - ], - lgFiles: [ - { - content: 'test', - id: 'test.en-us', - templates: [ - { - body: '- ${add(1,2)}', - name: 'bar', - range: new Range(new Position(0, 0), new Position(2, 14)), - }, - ], - diagnostics: [ - { - message: 'lg syntax error', - severity: 1, - source: 'test.en-us', - range: { - end: { character: 2, line: 13 }, - start: { character: 0, line: 13 }, - }, - }, - ], - }, - ], - jsonSchemaFiles: [ - { - id: 'schema1.json', - content: 'test', - }, - ], - diagnostics: [ - { - message: 'server error', - severity: 0, - source: 'server', - }, - ], - settings: { - skill: { - 'Email-Skill': { - manifestUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json', - endpointUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/api/messages', - name: 'Email-Skill', - }, - }, - languages: ['en-us'], - }, - formDialogSchemas: [{ id: '1', content: '{}' }], -}; -const mockNavigationTo = jest.fn(); -jest.mock('../../../src/utils/navigation', () => ({ - navigateTo: (...args) => mockNavigationTo(...args), -})); -describe('', () => { - const initRecoilState = ({ set }) => { - set(currentProjectIdState, state.projectId); - set(botProjectIdsState, [state.projectId]); - set(dialogIdsState(state.projectId), ['test']); - set(dialogState({ projectId: state.projectId, dialogId: 'test' }), state.dialogs[0]); - set(luFilesSelectorFamily(state.projectId), state.luFiles); - set(lgFilesSelectorFamily(state.projectId), state.lgFiles); - set(qnaFilesSelectorFamily(state.projectId), state.qnaFiles); - 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) - ); - }; - - it('should render the Diagnostics', () => { - const { container, getByText } = renderWithRecoil( - , - initRecoilState - ); - expect(container).toHaveTextContent('Diagnostics'); - fireEvent.doubleClick(getByText(/test.en-us.lg/)); - expect(mockNavigationTo).toBeCalledWith('/bot/testproj/language-generation/test/edit#L=13'); - fireEvent.doubleClick(getByText(/test.en-us.lu/)); - expect(mockNavigationTo).nthCalledWith(2, '/bot/testproj/language-understanding/test/edit#L=7'); - fireEvent.doubleClick(getByText(/test.en-us.qna/)); - expect(mockNavigationTo).nthCalledWith(3, '/bot/testproj/knowledge-base/test/edit#L=7'); - }); -}); diff --git a/Composer/packages/client/__tests__/pages/publish/CreatePublishProfileDialog.test.tsx b/Composer/packages/client/__tests__/pages/publish/CreatePublishProfileDialog.test.tsx new file mode 100644 index 0000000000..bdd5e3cda6 --- /dev/null +++ b/Composer/packages/client/__tests__/pages/publish/CreatePublishProfileDialog.test.tsx @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { act, fireEvent, render } from '@botframework-composer/test-utils'; + +import { CreatePublishProfileDialog } from '../../../src/pages/botProject/CreatePublishProfileDialog'; + +describe('CreatePublishProfileDialog', () => { + it("Call param function when 'Create new publish profile' is clicked", async () => { + const onShowPublishProfileDialogMock = jest.fn(); + const component = render( + + ); + const createNewBtn = component.getByTestId('addNewPublishProfile'); + await act(async () => { + await fireEvent.click(createNewBtn); + }); + expect(onShowPublishProfileDialogMock).toBeCalled(); + }); +}); diff --git a/Composer/packages/client/__tests__/plugins.test.ts b/Composer/packages/client/__tests__/plugins.test.ts index 24c9624991..4b4cb2b954 100644 --- a/Composer/packages/client/__tests__/plugins.test.ts +++ b/Composer/packages/client/__tests__/plugins.test.ts @@ -53,7 +53,7 @@ describe('mergePluginConfigs', () => { const config1 = { uiSchema: { [SDKKinds.RegexRecognizer]: { - recognizer: { displayName: 'recognizer1' }, + recognizer: { displayName: 'recognizer1', description: 'recognizer1' }, }, }, }; @@ -61,17 +61,17 @@ describe('mergePluginConfigs', () => { const config2 = { uiSchema: { [SDKKinds.LuisRecognizer]: { - recognizer: { displayName: 'recognizer2' }, + recognizer: { displayName: 'recognizer2', description: 'recognizer2' }, }, }, }; expect(mergePluginConfigs(config1, config2).uiSchema).toEqual({ [SDKKinds.RegexRecognizer]: { - recognizer: { displayName: 'recognizer1' }, + recognizer: { displayName: 'recognizer1', description: 'recognizer1' }, }, [SDKKinds.LuisRecognizer]: { - recognizer: { displayName: 'recognizer2' }, + recognizer: { displayName: 'recognizer2', description: 'recognizer2' }, }, }); }); diff --git a/Composer/packages/client/__tests__/recognizer.test.ts b/Composer/packages/client/__tests__/recognizer.test.ts index d6e09cb250..3b3bfd0c4a 100644 --- a/Composer/packages/client/__tests__/recognizer.test.ts +++ b/Composer/packages/client/__tests__/recognizer.test.ts @@ -20,6 +20,7 @@ describe('Test the generated recognizer dialogs', () => { { id: 'test.fr-fr', empty: false }, ], 'qna', + false, QnALocales ); diff --git a/Composer/packages/client/__tests__/shell/lgApi.test.tsx b/Composer/packages/client/__tests__/shell/lgApi.test.tsx index 6d7e4038a4..12f13c6c6d 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, HookResult } from '@botframework-composer/test-utils/lib/hooks'; +import { renderHook, RenderResult } from '@botframework-composer/test-utils/lib/hooks'; import * as React from 'react'; import { RecoilRoot } from 'recoil'; @@ -31,7 +31,7 @@ const state = { describe('use lgApi hooks', () => { let removeLgTemplatesMock, initRecoilState, copyLgTemplateMock, updateLgTemplateMock; - let result: HookResult; + let result: RenderResult; beforeEach(() => { updateLgTemplateMock = jest.fn(); diff --git a/Composer/packages/client/package.json b/Composer/packages/client/package.json index 81c26cd653..a603e63651 100644 --- a/Composer/packages/client/package.json +++ b/Composer/packages/client/package.json @@ -5,7 +5,7 @@ "version": "0.1.0", "private": true, "engines": { - "node": ">=12" + "node": "14.x" }, "scripts": { "start": "node --max_old_space_size=6114 --max-http-header-size=16000 scripts/start.js", @@ -20,11 +20,12 @@ "proxy": "http://localhost:5000", "dependencies": { "@azure/arm-appinsights": "^3.0.0", - "@azure/arm-subscriptions": "^3.0.0", - "@azure/arm-cognitiveservices": "^5.2.0", - "@azure/arm-search": "^1.3.0", "@azure/arm-appservice": "^6.0.0", + "@azure/arm-cognitiveservices": "^5.2.0", "@azure/arm-resources": "^4.0.0", + "@azure/arm-search": "^1.3.0", + "@azure/arm-subscriptions": "^3.0.0", + "@azure/cognitiveservices-qnamaker": "^3.2.0", "@bfc/adaptive-flow": "*", "@bfc/adaptive-form": "*", "@bfc/code-editor": "*", @@ -53,12 +54,13 @@ "ansi_up": "5.0.0", "axios": "^0.21.1", "babel-plugin-extract-format-message": "^6.2.3", - "botframework-webchat": "4.12.0", - "botframework-webchat-core": "4.12.0", + "botframework-webchat": "4.14.0", + "botframework-webchat-core": "4.14.0", "format-message": "^6.2.3", "format-message-generate-id": "^6.2.3", "immer": "^8.0.1", "jsonwebtoken": "^8.5.1", + "jszip": "^3.6.0", "jwt-decode": "^2.2.0", "lodash": "^4.17.19", "office-ui-fabric-react": "^7.121.11", @@ -77,7 +79,7 @@ "sanitize-html": "2.3.3", "styled-components": "^4.1.3", "uuid": "^8.3.0", - "webpack-bundle-analyzer": "^3.8.0" + "webpack-bundle-analyzer": "^4.4.2" }, "browserslist": [ ">0.2%", @@ -105,7 +107,7 @@ "babel-plugin-named-asset-import": "^0.3.1", "babel-preset-react-app": "^7.0.1", "bfj": "6.1.1", - "browserslist": "^4.7.3", + "browserslist": "^4.16.5", "case-sensitive-paths-webpack-plugin": "2.2.0", "css-loader": "3.2.0", "dotenv": "6.0.0", diff --git a/Composer/packages/client/setupTests.ts b/Composer/packages/client/setupTests.ts index 18cf9ed954..f56b1db07f 100644 --- a/Composer/packages/client/setupTests.ts +++ b/Composer/packages/client/setupTests.ts @@ -1,5 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +/// + // for tests using Electron IPC to talk to main process (window as any).ipcRenderer = { on: jest.fn() }; + +jest.mock('./src/utils/httpUtil'); diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index ecc68da714..746f786135 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -23,9 +23,13 @@ const { ipcRenderer } = window; export const App: React.FC = () => { const { appLocale } = useRecoilValue(userSettingsState); - const { fetchExtensions, fetchFeatureFlags, checkNodeVersion, performAppCleanupOnQuit } = useRecoilValue( - dispatcherState - ); + const { + fetchExtensions, + fetchFeatureFlags, + checkNodeVersion, + performAppCleanupOnQuit, + setMachineInfo, + } = useRecoilValue(dispatcherState); useEffect(() => { loadLocale(appLocale); @@ -38,6 +42,10 @@ export const App: React.FC = () => { ipcRenderer?.on('cleanup', (_event) => { performAppCleanupOnQuit(); }); + + ipcRenderer?.on('machine-info', (_event, info) => { + setMachineInfo(info); + }); }, []); return ( diff --git a/Composer/packages/client/src/components/Adapters/TeamsManifestGeneratorModal.tsx b/Composer/packages/client/src/components/Adapters/TeamsManifestGeneratorModal.tsx index 1f5c04b4a9..add7d44395 100644 --- a/Composer/packages/client/src/components/Adapters/TeamsManifestGeneratorModal.tsx +++ b/Composer/packages/client/src/components/Adapters/TeamsManifestGeneratorModal.tsx @@ -95,6 +95,7 @@ export const TeamsManifestGeneratorModal = (props: TeamsManifestGeneratorModalPr {formatMessage('Teams manifest for your bot:')} { + const inputFileRef = useRef(null); + + const onClickOpen = () => { + inputFileRef?.current?.click(); + }; + + const onChange = async (event) => { + const zipFile = event.target.files?.item(0); + if (zipFile) { + if (zipFile.size > FILE_SIZE_LIMIT) { + props.onError({ manifestUrl: '.zip file max size is 1MB' }); + } else { + // create zip instance + const jszip = new JSZip(); + jszip + .loadAsync(zipFile) + .then((zip) => { + props.onUpdate(zipFile.name, zip.files); + }) + .catch((error) => { + props.onError({ manifestUrl: error.toString() }); + }); + } + event.target.value = ''; + } + }; + + return ( + <> + + + > + ); +}; 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 ( @@ -105,7 +104,7 @@ export const SideBar: React.FC = () => { iconName={link.iconName} labelName={link.labelName} match={link.match} - showTooltip={showTooltips(link)} + showTooltip={!sideBarExpand} to={mapNavItemTo(link.to)} /> ); @@ -126,7 +125,7 @@ export const SideBar: React.FC = () => { disabled={link.disabled} iconName={link.iconName} labelName={link.labelName} - showTooltip={showTooltips(link)} + showTooltip={!sideBarExpand} to={mapNavItemTo(link.to)} /> ); diff --git a/Composer/packages/client/src/components/Auth/AuthCard.tsx b/Composer/packages/client/src/components/Auth/AuthCard.tsx new file mode 100644 index 0000000000..c7131796d1 --- /dev/null +++ b/Composer/packages/client/src/components/Auth/AuthCard.tsx @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import formatMessage from 'format-message'; +import { Fragment, useEffect, useState, useRef } from 'react'; +import { ActionButton } from 'office-ui-fabric-react/lib/Button'; +import { Callout } from 'office-ui-fabric-react/lib/Callout'; +import { Persona, PersonaSize } from 'office-ui-fabric-react/lib/Persona'; +import { ILinkStyles, Link } from 'office-ui-fabric-react/lib/Link'; +import { useRecoilValue } from 'recoil'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { FontSizes } from 'office-ui-fabric-react/lib/Styling'; + +import { + currentUserState, + isAuthenticatedState, + dispatcherState, + showAuthDialogState, + showTenantDialogState, + requiresGraphState, +} from '../../recoilModel/atoms'; +import { zIndices } from '../../utils/zIndices'; + +import { AuthDialog } from './AuthDialog'; +import { TenantDialog } from './TenantDialog'; + +const styles = { + username: { + fontWeight: 600, + }, + email: { + display: 'block', + fontSize: FontSizes.small, + }, + link: { + root: { + display: 'block', + fontSize: FontSizes.small, + marginTop: 5, + }, + }, + logoutLink: { + root: { + display: 'block', + fontSize: FontSizes.small, + }, + }, +}; + +export const AuthCard: React.FC = () => { + const [authCardVisible, setAuthCardVisible] = useState(false); + const { refreshLoginStatus, requireUserLogin, logoutUser, setShowAuthDialog, setShowTenantDialog } = useRecoilValue( + dispatcherState + ); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + const currentUser = useRecoilValue(currentUserState); + const showAuthDialog = useRecoilValue(showAuthDialogState); + const showTenantDialog = useRecoilValue(showTenantDialogState); + const requiresGraph = useRecoilValue(requiresGraphState); + + const personaRef = useRef(null); + + useEffect(() => { + refreshLoginStatus(); + }, []); + + const logout = () => { + logoutUser(); + }; + + const toggleAuthCardVisibility = () => { + setAuthCardVisible(!authCardVisible); + }; + + const switchTenants = () => { + requireUserLogin('', { chooseTenant: true }); + toggleAuthCardVisibility(); + }; + + return ( + + {/* this is the icon that appears at the top of the header bar */} + + + + + {/* this is the actual login card */} + {authCardVisible && ( + + {isAuthenticated ? ( + + + + {formatMessage('Sign out')} + + + + + + + + + {currentUser.name} + {currentUser.email} + + {formatMessage('View account on Azure')} + + + + {formatMessage('Switch directory')} + + + + + + + ) : ( + + + {formatMessage('Sign in to your Azure account')} + + + )} + + )} + + {showAuthDialog && ( + { + setShowAuthDialog(false, false); + }} + /> + )} + + {showTenantDialog && ( + { + setShowTenantDialog(false); + }} + /> + )} + + ); +}; diff --git a/Composer/packages/client/src/components/Auth/AuthDialog.tsx b/Composer/packages/client/src/components/Auth/AuthDialog.tsx index d6b398d741..085e6766bf 100644 --- a/Composer/packages/client/src/components/Auth/AuthDialog.tsx +++ b/Composer/packages/client/src/components/Auth/AuthDialog.tsx @@ -3,23 +3,48 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; import formatMessage from 'format-message'; -import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { useCallback, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { FontSizes } from '@uifabric/fluent-theme'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { CopyableText } from '@bfc/ui-shared'; -import storage from '../../utils/storage'; +import { dispatcherState } from '../../recoilModel/atoms'; import { isTokenExpired } from '../../utils/auth'; export interface AuthDialogProps { needGraph: boolean; onDismiss: () => void; - next: () => void; } + +const authDialogStyles = { + dialog: { + title: { + fontWeight: FontWeights.bold, + fontSize: FontSizes.size20, + paddingTop: '14px', + paddingBottom: '11px', + }, + subText: { + fontSize: FontSizes.size14, + }, + }, + modal: { + main: { + maxWidth: '80% !important', + width: '960px !important', + }, + }, +}; export const AuthDialog: React.FC = (props) => { - const [graphToken, setGraphToken] = useState(''); + const { setCurrentUser } = useRecoilValue(dispatcherState); + + const [graphToken, setLocalGraphToken] = useState(''); const [accessToken, setAccessToken] = useState(''); const [tokenError, setTokenError] = useState(''); const [graphError, setGraphError] = useState(''); @@ -36,21 +61,37 @@ export const AuthDialog: React.FC = (props) => { return true; }, [accessToken, graphToken]); + const renderLabel = (props, defaultRender) => { + return ( + + {defaultRender(props)} + + + ); + }; + return ( - { @@ -61,24 +102,25 @@ export const AuthDialog: React.FC = (props) => { setTokenError(''); } }} + onRenderLabel={renderLabel} /> {props.needGraph ? ( { - newValue && setGraphToken(newValue); + newValue && setLocalGraphToken(newValue); if (isTokenExpired(newValue || '')) { setGraphError('Token Expire or token invalid'); } else { setGraphError(''); } }} + onRenderLabel={renderLabel} /> ) : null} @@ -88,13 +130,10 @@ export const AuthDialog: React.FC = (props) => { text={formatMessage('Continue')} onClick={() => { props.onDismiss(); - // cache tokens - storage.set('accessToken', accessToken); - storage.set('graphToken', graphToken); - props.next(); + setCurrentUser(accessToken, graphToken); }} /> - + ); }; diff --git a/Composer/packages/client/src/components/Auth/TenantDialog.tsx b/Composer/packages/client/src/components/Auth/TenantDialog.tsx new file mode 100644 index 0000000000..da04734d19 --- /dev/null +++ b/Composer/packages/client/src/components/Auth/TenantDialog.tsx @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; +import { FontSizes } from '@uifabric/fluent-theme'; +import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import formatMessage from 'format-message'; +import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { ChoiceGroup } from 'office-ui-fabric-react/lib/ChoiceGroup'; +import { useState } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { + availableTenantsState, + dispatcherState, + currentTenantIdState, + isAuthenticatedState, +} from '../../recoilModel/atoms'; + +export interface TenantDialogProps { + onDismiss: () => void; +} +export const TenantDialog: React.FC = (props) => { + const availableTenants = useRecoilValue(availableTenantsState); + const { setCurrentTenant } = useRecoilValue(dispatcherState); + const currentTenant = useRecoilValue(currentTenantIdState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + // select current or default to first + const [tenant, setTenant] = useState(currentTenant || availableTenants[0]?.tenantId); + + return ( + + + { + return { key: tenant.tenantId, text: tenant.displayName }; + })} + selectedKey={tenant} + onChange={(ev, choice) => { + setTenant(choice?.key ?? ''); + }} + /> + + + {isAuthenticated && ( + { + props.onDismiss(); + }} + /> + )} + { + props.onDismiss(); + setCurrentTenant(tenant); + }} + /> + + + ); +}; diff --git a/Composer/packages/client/src/components/BotRuntimeController/BotController.tsx b/Composer/packages/client/src/components/BotRuntimeController/BotController.tsx index 93b0e373a4..672a75c7a7 100644 --- a/Composer/packages/client/src/components/BotRuntimeController/BotController.tsx +++ b/Composer/packages/client/src/components/BotRuntimeController/BotController.tsx @@ -5,12 +5,14 @@ import { jsx } from '@emotion/core'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DefaultButton, IconButton } from 'office-ui-fabric-react/lib/Button'; +import { TooltipHost, DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; import { useRecoilValue } from 'recoil'; import formatMessage from 'format-message'; import { css } from '@emotion/core'; import { NeutralColors, CommunicationColors } from '@uifabric/fluent-theme'; import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'; +import { DiagnosticSeverity } from '@botframework-composer/types'; import { DisableFeatureToolTip } from '../DisableFeatureToolTip'; import TelemetryClient from '../../telemetry/TelemetryClient'; @@ -25,6 +27,7 @@ import { BotStatus } from '../../constants'; import { useClickOutsideOutsideTarget } from '../../utils/hooks'; import { usePVACheck } from '../../hooks/usePVACheck'; +import { isBotStarting } from './utils'; import { BotControllerMenu } from './BotControllerMenu'; import { useBotOperations } from './useBotOperations'; import { BotRuntimeStatus } from './BotRuntimeStatus'; @@ -65,7 +68,7 @@ type BotControllerProps = { const BotController: React.FC = ({ onHideController, isControllerHidden }: BotControllerProps) => { const runningBots = useRecoilValue(runningBotsSelector); const projectCollection = useRecoilValue(buildConfigurationSelector); - const errors = useRecoilValue(allDiagnosticsSelectorFamily('Error')); + const errors = useRecoilValue(allDiagnosticsSelectorFamily([DiagnosticSeverity.Error])); const { onboardingAddCoachMarkRef } = useRecoilValue(dispatcherState); const onboardRef = useCallback((startBot) => onboardingAddCoachMarkRef({ startBot }), []); const [disableStartBots, setDisableOnStartBotsWidget] = useState(false); @@ -101,16 +104,7 @@ const BotController: React.FC = ({ onHideController, isContr useEffect(() => { const botsProcessing = startAllBotsOperationQueued || - projectCollection.some(({ status }) => { - return ( - status === BotStatus.publishing || - status === BotStatus.published || - status == BotStatus.pending || - status == BotStatus.queued || - status == BotStatus.starting || - status == BotStatus.stopping - ); - }); + projectCollection.some(({ status }) => isBotStarting(status) || status === BotStatus.stopping); setBotsProcessing(botsProcessing); const botOperationsCompleted = projectCollection.some( @@ -156,14 +150,10 @@ const BotController: React.FC = ({ onHideController, isContr setStatusIconClass('Refresh'); setStartPanelButtonText( - formatMessage( - `{ - total, plural, - =1 {Restart bot} - other {Restart all bots ({running}/{total} running)} - }`, - { running: runningBots.projectIds.length, total: runningBots.totalBots } - ) + formatMessage(`{ total, plural, =1 {Restart bot}other {Restart all bots ({running}/{total} running)}}`, { + running: runningBots.projectIds.length, + total: runningBots.totalBots, + }) ); return; } @@ -220,6 +210,8 @@ const BotController: React.FC = ({ onHideController, isContr }); }, [projectCollection, rootBotId]); + const startStopLabel = formatMessage('Start and stop local bot runtimes'); + return ( {projectCollection.map(({ projectId }) => { @@ -235,7 +227,6 @@ const BotController: React.FC = ({ onHideController, isContr = ({ onHideController, isContr font: '62px', }, }} - title={startPanelButtonText} onClick={handleClick} > {areBotsProcessing && ( @@ -288,31 +278,32 @@ const BotController: React.FC = ({ onHideController, isContr - + + rootHovered: { background: transparentBackground, color: NeutralColors.white }, + }} + onClick={onSplitButtonClick} + /> + { setProjectsToTrack([]); await updateSettingsForSkillsWithoutManifest(); - const { projectId, configuration, buildRequired, status, sensitiveSettings } = builderEssentials[0]; - if (status !== BotStatus.connected) { - let isBuildRequired = buildRequired; - if (skipBuild) { - isBuildRequired = false; + if (builderEssentials.length) { + const { projectId, configuration, buildRequired, status, sensitiveSettings } = builderEssentials[0]; + if (status !== BotStatus.connected) { + let isBuildRequired = buildRequired; + if (skipBuild) { + isBuildRequired = false; + } + handleBotStart(projectId, configuration, sensitiveSettings, isBuildRequired); } - handleBotStart(projectId, configuration, sensitiveSettings, isBuildRequired); } }; diff --git a/Composer/packages/client/src/components/BotRuntimeController/utils.ts b/Composer/packages/client/src/components/BotRuntimeController/utils.ts new file mode 100644 index 0000000000..3f52db5a01 --- /dev/null +++ b/Composer/packages/client/src/components/BotRuntimeController/utils.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { BotStatus } from '../../constants'; + +export const isBotStarting = (currentStatus: BotStatus) => { + return ( + currentStatus === BotStatus.publishing || + currentStatus === BotStatus.published || + currentStatus == BotStatus.pending || + currentStatus === BotStatus.queued || + currentStatus === BotStatus.starting + ); +}; diff --git a/Composer/packages/client/src/components/Conversation.jsx b/Composer/packages/client/src/components/Conversation.tsx similarity index 86% rename from Composer/packages/client/src/components/Conversation.jsx rename to Composer/packages/client/src/components/Conversation.tsx index 982f1111f0..f5fc683de9 100644 --- a/Composer/packages/client/src/components/Conversation.jsx +++ b/Composer/packages/client/src/components/Conversation.tsx @@ -13,12 +13,6 @@ const container = css` position: relative; `; -const top = css` - width: 100%; - height: 10px; - background-color: #efeaf5; -`; - // -------------------- Conversation -------------------- // const Conversation = (props) => { diff --git a/Composer/packages/client/src/components/CreationFlow/AzureBotDialog.tsx b/Composer/packages/client/src/components/CreationFlow/AzureBotDialog.tsx new file mode 100644 index 0000000000..66c493852c --- /dev/null +++ b/Composer/packages/client/src/components/CreationFlow/AzureBotDialog.tsx @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React, { useState } from 'react'; +import formatMessage from 'format-message'; +import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { FontSizes } from '@uifabric/fluent-theme'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; +import { DialogFooter, IDialogContentStyles } from 'office-ui-fabric-react/lib/Dialog'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; +import { RouteComponentProps } from '@reach/router'; + +import TelemetryClient from '../../telemetry/TelemetryClient'; +import { DialogCreationCopy } from '../../constants'; + +type Props = { + isOpen: boolean; + onDismiss: () => void; + onJumpToOpenModal: (search?: string) => void; + onToggleCreateModal: (boolean) => void; +} & RouteComponentProps<{}>; + +const dialogWrapperProps = DialogCreationCopy.CREATE_OPTIONS; + +const dialogStyle: { dialog: Partial; modal: {} } = { + dialog: { + title: { + fontWeight: FontWeights.bold, + fontSize: FontSizes.size20, + paddingTop: '14px', + paddingBottom: '11px', + }, + subText: { + fontSize: FontSizes.size14, + marginBottom: '8px', + }, + button: { + marginTop: 0, + }, + }, + modal: { + main: { + maxWidth: '80% !important', + width: '480px !important', + }, + }, +}; + +export const AzureBotDialog = (props: Props) => { + const [option, setOption] = useState<'Create' | 'Connect'>('Create'); + + const { isOpen, onDismiss, onJumpToOpenModal, onToggleCreateModal: setIsOpenCreateModal } = props; + + const options: IChoiceGroupOption[] = [ + { key: 'Create', text: formatMessage('Create a new bot project') }, + { key: 'Connect', text: formatMessage('Use an existing bot project') }, + ]; + + const handleChange = (e, option) => { + setOption(option.key); + }; + + const handleJumpToNext = () => { + if (option === 'Create') { + TelemetryClient.track('NewBotDialogOpened', { + isSkillBot: false, + fromAbsHandoff: true, + }); + setIsOpenCreateModal(true); + } else { + onJumpToOpenModal(props.location?.search); + } + }; + + return ( + + + {formatMessage('Learn more.')} + + + + + + + + ); +}; diff --git a/Composer/packages/client/src/components/CreationFlow/CreateBot.tsx b/Composer/packages/client/src/components/CreationFlow/CreateBot.tsx index 985ac4e600..67430c0c4e 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreateBot.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreateBot.tsx @@ -16,9 +16,9 @@ import { CheckboxVisibility, DetailsRow, } from 'office-ui-fabric-react/lib/DetailsList'; -import { BotTemplate } from '@bfc/shared'; +import { BotTemplate, localTemplateId } from '@bfc/shared'; import { DialogWrapper, DialogTypes, LoadingSpinner } from '@bfc/ui-shared'; -import { NeutralColors } from '@uifabric/fluent-theme'; +import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; import { WindowLocation } from '@reach/router'; import { IPivotItemProps, Pivot, PivotItem } from 'office-ui-fabric-react/lib/Pivot'; import { Link } from 'office-ui-fabric-react/lib/Link'; @@ -116,6 +116,8 @@ type CreateBotProps = { isOpen: boolean; templates: BotTemplate[]; location?: WindowLocation | undefined; + localTemplatePath: string; + onUpdateLocalTemplatePath: (path: string) => void; onDismiss: () => void; onNext: (templateName: string, templateLanguage: string, urlData?: string) => void; fetchReadMe: (moduleName: string) => {}; @@ -124,11 +126,12 @@ type CreateBotProps = { export function CreateBot(props: CreateBotProps) { const [option] = useState(optionKeys.createFromTemplate); const [disabled] = useState(false); - const { isOpen, templates, onDismiss, onNext } = props; + const { isOpen, templates, onDismiss, onNext, localTemplatePath, onUpdateLocalTemplatePath } = props; const [currentTemplateId, setCurrentTemplateId] = useState(''); const [selectedProgLang, setSelectedProgLang] = useState<{ props: IPivotItemProps }>({ props: { itemKey: csharpFeedKey }, }); + const [localTemplatePathValid, setLocalTemplatePathValid] = useState(false); const [displayedTemplates, setDisplayedTemplates] = useState([]); const [readMe] = useRecoilState(selectedTemplateReadMeState); const fetchReadMePending = useRecoilValue(fetchReadMePendingState); @@ -157,6 +160,35 @@ export function CreateBot(props: CreateBotProps) { } }; + const renderTemplateIcon = (item: BotTemplate) => { + if (item.id === localTemplateId) { + return ( + + ); + } else { + const labelText = formatMessage('Microsoft Logo'); + return ( + + ); + } + }; + const tableColumns = [ { key: 'name', @@ -170,12 +202,7 @@ export function CreateBot(props: CreateBotProps) { onRender: (item) => ( - + {renderTemplateIcon(item)} {item.name} @@ -274,7 +301,17 @@ export function CreateBot(props: CreateBotProps) { - {fetchReadMePending ? : } + {fetchReadMePending ? ( + + ) : ( + + )} @@ -289,10 +326,13 @@ export function CreateBot(props: CreateBotProps) { {formatMessage('Need another template? Send us a request')} - + diff --git a/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx b/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx index 310ee4486c..fdd36d8b92 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx @@ -5,29 +5,25 @@ import { jsx } from '@emotion/core'; import { useState, Fragment, useEffect } from 'react'; import formatMessage from 'format-message'; -import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; -import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; -import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; -import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; -import { FontSizes } from '@uifabric/fluent-theme'; import { BotTemplate } from '@bfc/shared'; -import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; import { navigate, RouteComponentProps } from '@reach/router'; import querystring from 'query-string'; import axios from 'axios'; import { useRecoilValue } from 'recoil'; -import { DialogCreationCopy } from '../../constants'; import { getAliasFromPayload, isElectron } from '../../utils/electronUtil'; import { creationFlowTypeState, userHasNodeInstalledState } from '../../recoilModel'; import { InstallDepModal } from '../InstallDepModal'; import TelemetryClient from '../../telemetry/TelemetryClient'; +import { AzureBotDialog } from './AzureBotDialog'; import { CreateBot } from './CreateBot'; // -------------------- CreateOptions -------------------- // type CreateOptionsProps = { templates: BotTemplate[]; + localTemplatePath: string; + onUpdateLocalTemplatePath: (path: string) => void; onDismiss: () => void; onNext: (templateName: string, templateLanguage: string, urlData?: string) => void; onJumpToOpenModal: (search?: string) => void; @@ -35,10 +31,17 @@ type CreateOptionsProps = { } & RouteComponentProps<{}>; export function CreateOptions(props: CreateOptionsProps) { - const [isOpenOptionsModal, setIsOpenOptionsModal] = useState(false); - const [option, setOption] = useState('Create'); + const [isOpenOptionsModal, setIsOpenOptionsModal] = useState(true); const [isOpenCreateModal, setIsOpenCreateModal] = useState(false); - const { templates, onDismiss, onNext, onJumpToOpenModal, fetchReadMe } = props; + const { + templates, + onDismiss, + onNext, + onJumpToOpenModal, + fetchReadMe, + onUpdateLocalTemplatePath, + localTemplatePath, + } = props; const [showNodeModal, setShowNodeModal] = useState(false); const userHasNode = useRecoilValue(userHasNodeInstalledState); const creationFlowType = useRecoilValue(creationFlowTypeState); @@ -73,48 +76,6 @@ export function CreateOptions(props: CreateOptionsProps) { }); setIsOpenCreateModal(true); }, [props.location?.search]); - const dialogWrapperProps = DialogCreationCopy.CREATE_OPTIONS; - - const customerStyle = { - dialog: { - title: { - fontWeight: FontWeights.bold, - fontSize: FontSizes.size20, - paddingTop: '14px', - paddingBottom: '11px', - }, - subText: { - fontSize: FontSizes.size14, - }, - }, - modal: { - main: { - maxWidth: '80% !important', - width: '480px !important', - }, - }, - }; - - const options: IChoiceGroupOption[] = [ - { key: 'Create', text: formatMessage('Use Azure Bot to create a new conversation') }, - { key: 'Connect', text: formatMessage('Apply my Azure Bot resources for an existing bot') }, - ]; - - const handleChange = (e, option) => { - setOption(option.key); - }; - - const handleJumpToNext = () => { - if (option === 'Create') { - TelemetryClient.track('NewBotDialogOpened', { - isSkillBot: false, - fromAbsHandoff: true, - }); - setIsOpenCreateModal(true); - } else { - onJumpToOpenModal(props.location?.search); - } - }; useEffect(() => { if (!userHasNode) { @@ -124,26 +85,21 @@ export function CreateOptions(props: CreateOptionsProps) { return ( - - - - - - - + onJumpToOpenModal={onJumpToOpenModal} + onToggleCreateModal={setIsOpenCreateModal} + /> {isElectron() && showNodeModal && ( = () => { const currentStorageIndex = useRef(0); const storage = storages[currentStorageIndex.current]; const currentStorageId = storage ? storage.id : 'default'; + const [localTemplatePath, setLocalTemplatePath] = useState(''); + const selectedTemplateVersion = useRecoilValue(selectedTemplateVersionState); useEffect(() => { if (storages?.length) { @@ -100,6 +103,7 @@ const CreationFlow: React.FC = () => { }; const handleDismiss = () => { + setLocalTemplatePath(''); setCreationFlowStatus(CreationFlowStatus.CLOSE); navigate(`/home`); }; @@ -123,9 +127,11 @@ const CreationFlow: React.FC = () => { }; const handleCreateNew = async (formData, templateId: string, qnaKbUrls?: string[]) => { - const templateVersion = templateProjects.find((template: BotTemplate) => { - return template.id == templateId; - })?.package?.packageVersion; + const templateVersion = + selectedTemplateVersion ?? + templateProjects.find((template: BotTemplate) => { + return template.id == templateId; + })?.package?.packageVersion; const newBotData = { templateId: templateId || '', templateVersion: templateVersion || '', @@ -144,6 +150,7 @@ const CreationFlow: React.FC = () => { alias: formData?.alias, profile: formData?.profile, source: formData?.source, + isLocalGenerator: formData?.isLocalGenerator, }; TelemetryClient.track('CreateNewBotProjectStarted', { template: templateId }); @@ -197,6 +204,7 @@ const CreationFlow: React.FC = () => { = () => { /> { @@ -228,6 +237,7 @@ const CreationFlow: React.FC = () => { }} onJumpToOpenModal={handleJumpToOpenModal} onNext={handleCreateNext} + onUpdateLocalTemplatePath={setLocalTemplatePath} /> void; onCurrentPathUpdate: (newPath?: string, storageId?: string) => void; onGetErrorMessage?: (text: string) => void; + localTemplatePath?: string; focusedStorageFolder: StorageFolder; } & RouteComponentProps<{ templateId: string; @@ -115,6 +123,7 @@ const DefineConversation: React.FC = (props) => { focusedStorageFolder, createFolder, updateFolder, + localTemplatePath, } = props; const files = focusedStorageFolder?.children ?? []; const writable = focusedStorageFolder.writable; @@ -165,6 +174,15 @@ const DefineConversation: React.FC = (props) => { }; const { addNotification } = useRecoilValue(dispatcherState); + const [isImported, setIsImported] = useState(false); + + useEffect(() => { + if (props.location?.state) { + const { imported } = props.location.state; + setIsImported(imported); + } + }, [props.location?.state]); + const formConfig: FieldConfig = { name: { required: true, @@ -214,14 +232,6 @@ const DefineConversation: React.FC = (props) => { }, }; const { formData, formErrors, hasErrors, updateField, updateForm, validateForm } = useForm(formConfig); - const [isImported, setIsImported] = useState(false); - - useEffect(() => { - if (props.location?.state) { - const { imported } = props.location.state; - setIsImported(imported); - } - }, [props.location?.state]); useEffect(() => { const formData: DefineConversationFormData = { @@ -309,7 +319,10 @@ const DefineConversation: React.FC = (props) => { isPva: isImported, isAbs: !!dataToSubmit?.source, }); - onSubmit({ ...dataToSubmit }, templateId || ''); + const isLocalGenerator = templateId === localTemplateId; + const generatorName = isLocalGenerator ? localTemplatePath : templateId; + dataToSubmit.isLocalGenerator = isLocalGenerator; + onSubmit({ ...dataToSubmit }, generatorName || ''); }, [hasErrors, formData] ); @@ -319,22 +332,47 @@ const DefineConversation: React.FC = (props) => { updateField('location', newPath); }; + const renderRuntimeDropdownOption = (props) => { + return ( + + {props.text} + {props.data?.description} + + ); + }; + + const webAppRuntimeOption = { + key: webAppRuntimeKey, + text: formatMessage('Azure Web App'), + data: { + description: formatMessage( + 'Fully managed compute platform that is optimized for hosting websites and web applications.' + ), + }, + }; + + const functionsRuntimeOption = { + key: functionsRuntimeKey, + text: formatMessage('Azure Functions'), + data: { + description: formatMessage( + 'Azure Functions is a solution for easily running small pieces of code, or "functions," in the cloud. ' + ), + }, + }; + const getSupportedRuntimesForTemplate = (): IDropdownOption[] => { const result: IDropdownOption[] = []; if (inBotMigration) { - result.push({ key: webAppRuntimeKey, text: formatMessage('Azure Web App') }); - result.push({ key: functionsRuntimeKey, text: formatMessage('Azure Functions') }); + result.push(webAppRuntimeOption); + result.push(functionsRuntimeOption); } else if (currentTemplate) { if (runtimeLanguage === csharpFeedKey) { - currentTemplate.dotnetSupport?.functionsSupported && - result.push({ key: functionsRuntimeKey, text: formatMessage('Azure Functions') }); - currentTemplate.dotnetSupport?.webAppSupported && - result.push({ key: webAppRuntimeKey, text: formatMessage('Azure Web App') }); + currentTemplate.dotnetSupport?.functionsSupported && result.push(functionsRuntimeOption); + currentTemplate.dotnetSupport?.webAppSupported && result.push(webAppRuntimeOption); } else if (runtimeLanguage === nodeFeedKey) { - currentTemplate.nodeSupport?.functionsSupported && - result.push({ key: functionsRuntimeKey, text: formatMessage('Azure Functions') }); - currentTemplate.nodeSupport?.webAppSupported && - result.push({ key: webAppRuntimeKey, text: formatMessage('Azure Web App') }); + currentTemplate.nodeSupport?.functionsSupported && result.push(functionsRuntimeOption); + currentTemplate.nodeSupport?.webAppSupported && result.push(webAppRuntimeOption); } } @@ -367,6 +405,7 @@ const DefineConversation: React.FC = (props) => { /> ); }, [focusedStorageFolder]); + const dialogCopy = isImported ? DialogCreationCopy.IMPORT_BOT_PROJECT : DialogCreationCopy.DEFINE_BOT_PROJECT; return ( @@ -395,8 +434,33 @@ const DefineConversation: React.FC = (props) => { label={formatMessage('Runtime type')} options={getSupportedRuntimesForTemplate()} selectedKey={formData.runtimeType} - styles={{ root: { width: inBotMigration ? '200px' : '420px' } }} + styles={{ + root: { width: inBotMigration ? '200px' : '420px' }, + dropdownItem: { height: '100px' }, + dropdownItemSelected: { height: '100px' }, + }} onChange={(_e, option) => updateField('runtimeType', option?.key.toString())} + onRenderLabel={(props) => ( + + {props?.label} + + + + + )} + onRenderOption={renderRuntimeDropdownOption} /> {inBotMigration && ( diff --git a/Composer/packages/client/src/components/CreationFlow/OpenProject.tsx b/Composer/packages/client/src/components/CreationFlow/OpenProject.tsx index fc2da2f084..e55930d65b 100644 --- a/Composer/packages/client/src/components/CreationFlow/OpenProject.tsx +++ b/Composer/packages/client/src/components/CreationFlow/OpenProject.tsx @@ -71,7 +71,7 @@ export const OpenProject: React.FC = (props) => { onOpen={handleOpen} /> - + diff --git a/Composer/packages/client/src/components/CreationFlow/TemplateDetailView.tsx b/Composer/packages/client/src/components/CreationFlow/TemplateDetailView.tsx index 2e46fc3456..be4a3ae0d4 100644 --- a/Composer/packages/client/src/components/CreationFlow/TemplateDetailView.tsx +++ b/Composer/packages/client/src/components/CreationFlow/TemplateDetailView.tsx @@ -3,15 +3,26 @@ // Licensed under the MIT License. /** @jsx jsx */ -import { BotTemplate } from '@bfc/shared'; +import { BotTemplate, localTemplateId } from '@bfc/shared'; import { css, jsx } from '@emotion/core'; import formatMessage from 'format-message'; -import React from 'react'; +import { CommandButton } from 'office-ui-fabric-react/lib/components/Button'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import React, { useEffect, Fragment } from 'react'; import ReactMarkdown from 'react-markdown'; +import { Text } from 'office-ui-fabric-react/lib/Text'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { TextField } from 'office-ui-fabric-react/lib/components/TextField'; +import { FontIcon } from 'office-ui-fabric-react/lib/Icon'; +import { SharedColors } from '@uifabric/fluent-theme/lib/fluent/FluentColors'; +import { useRecoilValue } from 'recoil'; import composerIcon from '../../images/composerIcon.svg'; +import httpClient from '../../utils/httpUtil'; +import { dispatcherState, selectedTemplateVersionState } from '../../recoilModel'; +import { useFeatureFlag } from '../../utils/hooks'; -const templateTitleContainer = css` +const templateTitleContainer = (isLocalTemplate: boolean) => css` width: 100%; padding-right: 2%; height: fit-content @@ -19,14 +30,17 @@ const templateTitleContainer = css` flex-grow: 1; float: left; word-break: break-all; + padding-top: ${isLocalTemplate ? '15px' : '0px'}; + padding-bottom: ${isLocalTemplate ? '15px' : '0px'}; + margin-bottom: 10px; `; -const templateTitle = css` +const templateTitle = (isLocalTemplate: boolean) => css` position: relative; - bottom: 18px; + bottom: ${isLocalTemplate ? '4px' : '0px'}; font-size: 19px; font-weight: 550; - margin-left: 10px; + margin-left: 7px; `; const templateVersion = css` @@ -34,35 +48,144 @@ const templateVersion = css` font-size: 12px; font-weight: 100; display: block; - left: 55px; width: fit-content; - bottom: 18px; + height: 10px; + padding: 0px; + margin-left: 6px; `; type TemplateDetailViewProps = { template?: BotTemplate; readMe: string; + localTemplatePath: string; + onValidateLocalTemplatePath: (isValid: boolean) => void; + onUpdateLocalTemplatePath: (path: string) => void; }; +const templateDocUrl = 'https://aka.ms/localComposerTemplateDoc'; + export const TemplateDetailView: React.FC = (props) => { + const { setSelectedTemplateVersion } = useRecoilValue(dispatcherState); + const selectedTemplateVersion = useRecoilValue(selectedTemplateVersionState); + const advancedTemplateOptionsEnabled = useFeatureFlag('ADVANCED_TEMPLATE_OPTIONS'); + + useEffect(() => { + props.template?.package?.packageVersion && setSelectedTemplateVersion(props.template.package.packageVersion); + }, [props.template]); + + const renderVersionButton = () => { + if (!advancedTemplateOptionsEnabled) { + return {props.template?.package?.packageVersion}; + } + const availableVersions = props.template?.package?.availableVersions || ([] as string[]); + const versionOptions = { + items: availableVersions.map((version: string) => { + return { key: version, text: version }; + }), + onItemClick: (ev, item) => setSelectedTemplateVersion(item.key), + calloutProps: { + calloutMaxHeight: 300, + }, + }; + return ( + + ); + }; + + const { localTemplatePath, onUpdateLocalTemplatePath, onValidateLocalTemplatePath, template } = props; + const isLocalTemplate = template?.id === localTemplateId; + // Composer formats and displays its own template title and strips out title from read me to avoid redundant titles const getStrippedReadMe = () => { return props.readMe.replace(/^(#|##) (.*)/, '').trim(); }; + const validatePath = async (path) => { + if (path === '') { + onValidateLocalTemplatePath(false); + return ''; + } + const response = await httpClient.get(`/storages/validate/${encodeURIComponent(path)}`); + const validateMessage = response.data.errorMsg; + if (typeof validateMessage === 'string' && validateMessage.includes('path')) { + // Result is not a valid path + onValidateLocalTemplatePath(false); + return formatMessage('This path does not exist'); + } else if (validateMessage) { + // Result is a non dir path + onValidateLocalTemplatePath(true); + return ''; + } + // result is a dir path + onValidateLocalTemplatePath(false); + return formatMessage( + "Generator not found. Please enter the full path to the generator's index.js file including the filename" + ); + }; + + const renderLocalTemplateForm = () => ( + + + {formatMessage.rich( + `To create a bot from your own Bot Framework Template you need to add a path to your local templates index.js file. Learn more`, + { + templateDocLink: ({ children }) => ( + + {children} + + ), + } + )} + + onUpdateLocalTemplatePath(val || '')} + onGetErrorMessage={validatePath} + /> + + ); + + const renderTemplateIcon = () => { + return isLocalTemplate ? ( + + ) : ( + + ); + }; + return ( - - - {props.template?.name} - {props.template?.package?.packageVersion} + + + {renderTemplateIcon()} + + + {props.template?.name ? props.template.name : formatMessage('Template undefined')} + + {!isLocalTemplate && renderVersionButton()} + + - {getStrippedReadMe()} + {isLocalTemplate ? ( + renderLocalTemplateForm() + ) : ( + {getStrippedReadMe()} + )} ); }; diff --git a/Composer/packages/client/src/components/DataCollectionDialog.tsx b/Composer/packages/client/src/components/DataCollectionDialog.tsx index a1ce1a1ef5..a468caad26 100644 --- a/Composer/packages/client/src/components/DataCollectionDialog.tsx +++ b/Composer/packages/client/src/components/DataCollectionDialog.tsx @@ -9,11 +9,14 @@ import React from 'react'; import { useRecoilValue } from 'recoil'; import { dispatcherState } from '../recoilModel'; +import TelemetryService from '../telemetry/TelemetryClient'; const DataCollectionDialog: React.FC = () => { const { updateUserSettings } = useRecoilValue(dispatcherState); const handleDataCollectionChange = (allowDataCollection: boolean) => () => { + TelemetryService.track('TelemetryOptInOut', { enabled: allowDataCollection }); + updateUserSettings({ telemetry: { allowDataCollection, diff --git a/Composer/packages/client/src/components/DiagnosticsHeader.tsx b/Composer/packages/client/src/components/DiagnosticsHeader.tsx index aabf54b44e..296b906b97 100644 --- a/Composer/packages/client/src/components/DiagnosticsHeader.tsx +++ b/Composer/packages/client/src/components/DiagnosticsHeader.tsx @@ -5,6 +5,7 @@ import { jsx, css } from '@emotion/core'; import React from 'react'; import { useRecoilValue } from 'recoil'; +import { DiagnosticSeverity } from '@botframework-composer/types'; import { allDiagnosticsSelectorFamily } from '../recoilModel'; @@ -20,8 +21,8 @@ type DiagnosticsHeaderProps = { }; export const DiagnosticsHeader: React.FC = React.memo(({ onClick = () => {} }) => { - const errors = useRecoilValue(allDiagnosticsSelectorFamily('Error')); - const warnings = useRecoilValue(allDiagnosticsSelectorFamily('Warning')); + const errors = useRecoilValue(allDiagnosticsSelectorFamily([DiagnosticSeverity.Error])); + const warnings = useRecoilValue(allDiagnosticsSelectorFamily([DiagnosticSeverity.Warning])); return ( diff --git a/Composer/packages/client/src/components/DropdownWithAllOption/DropdownWithAllOption.tsx b/Composer/packages/client/src/components/DropdownWithAllOption/DropdownWithAllOption.tsx new file mode 100644 index 0000000000..f98b117150 --- /dev/null +++ b/Composer/packages/client/src/components/DropdownWithAllOption/DropdownWithAllOption.tsx @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { Dropdown, IDropdownOption, IDropdownStyles, IDropdownProps } from 'office-ui-fabric-react/lib/Dropdown'; +import React, { useMemo } from 'react'; + +const dropdownStyles: Partial = { + dropdown: { width: 300 }, +}; + +export interface DropdownWithAllOptionProps extends Omit { + selectedKeys: string[]; + onChange: (event: React.FormEvent, selectedItems: string[]) => void; + optionAll: { + key: string; + text: string; + }; +} + +export const DropdownWithAllOption: React.FC = (props) => { + const { selectedKeys, onChange, placeholder, options: dropdownOptions, optionAll } = props; + + const currentOptions = useMemo(() => { + return [ + { + key: optionAll.key, + text: optionAll.text, + }, + ...dropdownOptions, + ]; + }, [dropdownOptions]); + + const onOptionSelectionChange = (event: React.FormEvent, item: IDropdownOption | undefined): void => { + if (item) { + if (item.key === optionAll.key) { + if (!item.selected) { + onChange(event, []); + } else { + const allOptions = currentOptions.map((option) => option.key as string); + onChange(event, allOptions); + } + return; + } + + const tempState = [...selectedKeys]; + const allIndex = tempState.findIndex((option) => option === optionAll.key); + if (allIndex !== -1) { + tempState.splice(allIndex, 1); + } + onChange(event, item.selected ? [...tempState, item.key as string] : tempState.filter((key) => key !== item.key)); + } + }; + + const onRenderTitle = (selectedItems: IDropdownOption[] | undefined): JSX.Element | null => { + const allIndex = selectedKeys.findIndex((option) => option === optionAll.key); + if (allIndex !== -1) { + return currentOptions[0].text as any; + } + if (selectedItems?.length) { + const items = selectedItems.map((item) => item.text); + return items.join(', ') as any; + } + return null; + }; + + return ( + + ); +}; diff --git a/Composer/packages/client/src/components/ErrorBoundary.tsx b/Composer/packages/client/src/components/ErrorBoundary.tsx index d9f0637154..d72b9df65d 100644 --- a/Composer/packages/client/src/components/ErrorBoundary.tsx +++ b/Composer/packages/client/src/components/ErrorBoundary.tsx @@ -35,6 +35,8 @@ interface ErrorBoundaryState { }; } +const patternsToIgnore = [/ResizeObserver/g]; + // only class component can be a error boundary export class ErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { @@ -58,7 +60,11 @@ export class ErrorBoundary extends Component regex.test(message))) { + this.props.setApplicationLevelError(formatToStateError(message)); + } return true; } diff --git a/Composer/packages/client/src/components/FieldWithCustomButton.tsx b/Composer/packages/client/src/components/FieldWithCustomButton.tsx index 55f0d388a9..405e4753ee 100644 --- a/Composer/packages/client/src/components/FieldWithCustomButton.tsx +++ b/Composer/packages/client/src/components/FieldWithCustomButton.tsx @@ -99,6 +99,8 @@ type Props = { required?: boolean; id?: string; options?: IDropdownOption[]; + fieldDataTestId?: string; + buttonDataTestId?: string; }; const errorElement = (errorText: string) => { @@ -136,6 +138,8 @@ export const FieldWithCustomButton: React.FC = (props) => { errorMessage, id = '', options, + fieldDataTestId = '', + buttonDataTestId = '', } = props; const [isDisabled, setDisabled] = useState(!value); const fieldComponentRef = useRef(null); @@ -178,11 +182,17 @@ export const FieldWithCustomButton: React.FC = (props) => { const disabledField = options == null ? ( - + ) : ( @@ -193,6 +203,7 @@ export const FieldWithCustomButton: React.FC = (props) => { { setLocalValue(value ?? ''); @@ -203,6 +214,7 @@ export const FieldWithCustomButton: React.FC = (props) => { { @@ -216,6 +228,7 @@ export const FieldWithCustomButton: React.FC = (props) => { {isDisabled ? disabledField : enabledField} { setDisabled(false); diff --git a/Composer/packages/client/src/components/GetStarted/GetStartedLearn.tsx b/Composer/packages/client/src/components/GetStarted/GetStartedLearn.tsx index dd4fb5c4bf..673d2950f1 100644 --- a/Composer/packages/client/src/components/GetStarted/GetStartedLearn.tsx +++ b/Composer/packages/client/src/components/GetStarted/GetStartedLearn.tsx @@ -51,7 +51,9 @@ export const GetStartedLearn: React.FC = ({ projectId, onDismiss }) => { return ( - {formatMessage('Get started')} + + {formatMessage('Get started')} + @@ -90,7 +92,9 @@ export const GetStartedLearn: React.FC = ({ projectId, onDismiss }) => { - {formatMessage('Quick references')} + + {formatMessage('Quick references')} + diff --git a/Composer/packages/client/src/components/GetStarted/GetStartedNextSteps.tsx b/Composer/packages/client/src/components/GetStarted/GetStartedNextSteps.tsx index 06e6476e31..c2c5eee2ca 100644 --- a/Composer/packages/client/src/components/GetStarted/GetStartedNextSteps.tsx +++ b/Composer/packages/client/src/components/GetStarted/GetStartedNextSteps.tsx @@ -36,22 +36,24 @@ type GetStartedProps = { export const GetStartedNextSteps: React.FC = (props) => { const projectId = useRecoilValue(currentProjectIdState); + const schemaDiagnostics = useRecoilValue(schemaDiagnosticsSelectorFamily(projectId)); + const { setSettings, setQnASettings, setShowGetStartedTeachingBubble } = useRecoilValue(dispatcherState); + const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector) || ''; + const settings = useRecoilValue(settingsState(projectId)); + const readme = useRecoilValue(projectReadmeState(projectId)); const botProjects = useRecoilValue(localBotsDataSelector); + const setExpansion = useSetRecoilState(debugPanelExpansionState); + const setActiveTab = useSetRecoilState(debugPanelActiveTabState); + const botProject = useMemo(() => botProjects.find((b) => b.projectId === projectId), [botProjects, projectId]); const [displayManageLuis, setDisplayManageLuis] = useState(false); const [displayManageQNA, setDisplayManageQNA] = useState(false); - const readme = useRecoilValue(projectReadmeState(projectId)); const [readmeHidden, setReadmeHidden] = useState(true); - const schemaDiagnostics = useRecoilValue(schemaDiagnosticsSelectorFamily(projectId)); - const { setSettings, setQnASettings } = useRecoilValue(dispatcherState); - const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector) || ''; - const settings = useRecoilValue(settingsState(projectId)); + const mergedSettings = mergePropertiesManagedByRootBot(projectId, rootBotProjectId, settings); const [requiredNextSteps, setRequiredNextSteps] = useState([]); const [recommendedNextSteps, setRecommendedNextSteps] = useState([]); const [optionalSteps, setOptionalSteps] = useState([]); - const setExpansion = useSetRecoilState(debugPanelExpansionState); - const setActiveTab = useSetRecoilState(debugPanelActiveTabState); const [highlightLUIS, setHighlightLUIS] = useState(false); const [highlightQNA, setHighlightQNA] = useState(false); @@ -325,7 +327,7 @@ export const GetStartedNextSteps: React.FC = (props) => { if (hasPublishingProfile) { if (!hasPartialPublishingProfile) { - optSteps.push({ + newRecomendedSteps.push({ key: 'publish', label: formatMessage('Publish your bot'), description: formatMessage('Once you publish your bot to Azure you will be ready to add connections.'), @@ -337,20 +339,20 @@ export const GetStartedNextSteps: React.FC = (props) => { }, hideFeatureStep: isPVABot, }); - } - optSteps.push({ - key: 'connections', - label: formatMessage('Add connections'), - description: formatMessage('Connect your bot to Teams, external channels, or enable speech.'), - learnMore: 'https://aka.ms/composer-connections-learnmore', - checked: false, - onClick: () => { - TelemetryClient.track('GettingStartedActionClicked', { taskName: 'connections', priority: 'optional' }); - openLink(linkToConnections); - }, - hideFeatureStep: isPVABot, - }); + newRecomendedSteps.push({ + key: 'connections', + label: formatMessage('Add connections'), + description: formatMessage('Connect your bot to Teams, external channels, or enable speech.'), + learnMore: 'https://aka.ms/composer-connections-learnmore', + checked: false, + onClick: () => { + TelemetryClient.track('GettingStartedActionClicked', { taskName: 'connections', priority: 'optional' }); + openLink(linkToConnections); + }, + hideFeatureStep: isPVABot, + }); + } } setOptionalSteps(optSteps); @@ -362,10 +364,9 @@ export const GetStartedNextSteps: React.FC = (props) => { } return null; }; - return ( - + = (props) => { target="#luis" onDismiss={() => { setHighlightLUIS(false); + setShowGetStartedTeachingBubble(false); }} > {formatMessage('Continue setting up your development environment by adding LUIS keys.')} @@ -415,6 +417,7 @@ export const GetStartedNextSteps: React.FC = (props) => { target="#qna" onDismiss={() => { setHighlightQNA(false); + setShowGetStartedTeachingBubble(false); }} > {formatMessage('Just add a QnA key and you’ll be ready to talk to your bot.')} diff --git a/Composer/packages/client/src/components/GetStarted/GetStartedTask.tsx b/Composer/packages/client/src/components/GetStarted/GetStartedTask.tsx index 159d1703a0..d13feda993 100644 --- a/Composer/packages/client/src/components/GetStarted/GetStartedTask.tsx +++ b/Composer/packages/client/src/components/GetStarted/GetStartedTask.tsx @@ -31,6 +31,7 @@ const stepDescriptionStyle = css` export const GetStartedTask: React.FC = (props) => { const icon = props.step.checked ? 'CompletedSolid' : props.step.required ? 'Error' : 'Completed'; + const iconTestId = props.step.checked ? `${props.step.key}-checked` : `${props.step.key}-unChecked`; const color = props.step.checked ? FluentTheme.palette.green : props.step.required @@ -39,6 +40,7 @@ export const GetStartedTask: React.FC = (props) => { return ( { const locale = useRecoilValue(localeState(projectId)); const appUpdate = useRecoilValue(appUpdateState); const [teachingBubbleVisibility, setTeachingBubbleVisibility] = useState(); - - const [showGetStartedTeachingBubble, setShowGetStartedTeachingBubble] = useState(false); + const showGetStartedTeachingBubble = useRecoilValue(showGetStartedTeachingBubbleState); const settings = useRecoilValue(settingsState(projectId)); const isWebChatPanelVisible = useRecoilValue(isWebChatPanelVisibleState); const botProjectSolutionLoaded = useRecoilValue(botProjectSpaceLoadedState); @@ -151,16 +152,14 @@ export const Header = () => { const { showing, status } = appUpdate; const rootBotId = useRecoilValue(rootBotProjectIdSelector) ?? ''; const webchatEssentials = useRecoilValue(webChatEssentialsSelector(rootBotId)); + const { setWebChatPanelVisibility, setShowGetStartedTeachingBubble } = useRecoilValue(dispatcherState); - const { setWebChatPanelVisibility } = useRecoilValue(dispatcherState); const [hideBotController, hideBotStartController] = useState(true); const [showGetStarted, setShowGetStarted] = useState(false); - const [showTeachingBubble, setShowTeachingBubble] = useState(false); + const [showStartBotTeachingBubble, setShowStartBotTeachingBubble] = useState(false); const [requiresLUIS, setRequiresLUIS] = useState(false); const [requiresQNA, setRequiresQNA] = useState(false); - const { location } = useLocation(); - // These are needed to determine if the bot needs LUIS or QNA // this data is passed into the GetStarted widget // ... if the get started widget moves, this code should too! @@ -187,22 +186,20 @@ export const Header = () => { }, []); const hideTeachingBubble = () => { - setShowTeachingBubble(false); + setShowStartBotTeachingBubble(false); }; const toggleGetStarted = (newvalue) => { hideTeachingBubble(); + setShowGetStartedTeachingBubble(false); setShowGetStarted(newvalue); }; // pop out get started if #getstarted is in the URL useEffect(() => { - if (location.hash === '#getstarted') { - setShowGetStartedTeachingBubble(true); + if (showGetStartedTeachingBubble) { setShowGetStarted(true); - } else { - setShowGetStartedTeachingBubble(false); } - }, [location]); + }, [showGetStartedTeachingBubble]); useEffect(() => { if (isWebChatPanelVisible) { @@ -249,14 +246,14 @@ export const Header = () => { } }; + const logoLabel = formatMessage('Composer Logo'); + const testLabel = formatMessage('Test your bot'); + const rocketLabel = formatMessage('Recommended actions'); + const updateLabel = formatMessage('Update available'); + return ( - + {projectName && ( @@ -276,7 +273,6 @@ export const Header = () => { )} - {isShow && ( { )} {isShow && ( - { - const currentWebChatVisibility = !isWebChatPanelVisible; - setWebChatPanelVisibility(currentWebChatVisibility); - if (currentWebChatVisibility) { - TelemetryClient.track('WebChatPaneOpened'); - } else { - TelemetryClient.track('WebChatPaneClosed'); - } - }} - /> + + { + const currentWebChatVisibility = !isWebChatPanelVisible; + setWebChatPanelVisibility(currentWebChatVisibility); + if (currentWebChatVisibility) { + TelemetryClient.track('WebChatPaneOpened'); + } else { + TelemetryClient.track('WebChatPaneClosed'); + } + }} + /> + )} {isShow && ( - toggleGetStarted(true)} - /> + + toggleGetStarted(!showGetStarted)} + /> + )} - {isShow && showTeachingBubble && ( + {isShow && showStartBotTeachingBubble && ( { )} {showUpdateAvailableIcon && ( - + + + )} + {teachingBubbleVisibility && ( { requiresQNA={requiresQNA} showTeachingBubble={botProjectSolutionLoaded && showGetStartedTeachingBubble} onBotReady={() => { - setShowTeachingBubble(true); + setShowStartBotTeachingBubble(true); }} onDismiss={() => { toggleGetStarted(false); diff --git a/Composer/packages/client/src/components/ImportModal/ImportModal.tsx b/Composer/packages/client/src/components/ImportModal/ImportModal.tsx index 743a9059b1..4c070944c4 100644 --- a/Composer/packages/client/src/components/ImportModal/ImportModal.tsx +++ b/Composer/packages/client/src/components/ImportModal/ImportModal.tsx @@ -50,6 +50,51 @@ type ImportModalState = const CONNECTING_STATUS_DISPLAY_TIME = 2000; +export const signIn = async ( + importSource: ExternalContentProviderType | undefined, + importPayload: ImportPayload, + setModalState +) => { + try { + await axios.post( + `/api/import/${importSource}/authenticate?payload=${encodeURIComponent(JSON.stringify(importPayload))}` + ); + setModalState('downloadingContent'); + } catch (e) { + // something went wrong, abort and navigate to the home page + console.error(`Something went wrong during authenticating import: ${e}`); + navigate('/home'); + } +}; + +export const importAsNewProject = (info: ImportedProjectInfo) => { + // navigate to creation flow with template selected + const { alias, description, eTag, name, source, templateDir, urlSuffix } = info; + const state = { + alias, + eTag, + imported: true, + templateDir, + urlSuffix, + }; + + let creationUrl = `/projects/create/${encodeURIComponent(source)}`; + + const searchParams = new URLSearchParams(); + if (name) { + const validName = source === 'pva' ? name.replace(invalidNameCharRegex, '-') : name; + searchParams.set('name', encodeURIComponent(validName)); + } + if (description) { + searchParams.set('description', encodeURIComponent(description)); + } + if (searchParams.toString()) { + creationUrl += `?${searchParams.toString()}`; + } + + return { creationUrl, state }; +}; + export const ImportModal: React.FC = (props) => { const { location } = props; const [importSource, setImportSource] = useState(undefined); @@ -61,34 +106,6 @@ export const ImportModal: React.FC = (props) => { const [backupLocation, setBackupLocation] = useState(''); const { addNotification } = useRecoilValue(dispatcherState); - const importAsNewProject = useCallback((info: ImportedProjectInfo) => { - // navigate to creation flow with template selected - const { alias, description, eTag, name, source, templateDir, urlSuffix } = info; - const state = { - alias, - eTag, - imported: true, - templateDir, - urlSuffix, - }; - - let creationUrl = `/projects/create/${encodeURIComponent(source)}`; - - const searchParams = new URLSearchParams(); - if (name) { - const validName = source === 'pva' ? name.replace(invalidNameCharRegex, '-') : name; - searchParams.set('name', encodeURIComponent(validName)); - } - if (description) { - searchParams.set('description', encodeURIComponent(description)); - } - if (searchParams.toString()) { - creationUrl += `?${searchParams.toString()}`; - } - - navigate(creationUrl, { state }); - }, []); - const importToExistingProject = useCallback(async () => { if (importedProjectInfo && existingProject) { setModalState('copyingContent'); @@ -170,7 +187,8 @@ export const ImportModal: React.FC = (props) => { return; } } - importAsNewProject(projectInfo); + const { creationUrl, state } = importAsNewProject(projectInfo); + navigate(creationUrl, { state }); } catch (e) { // something went wrong, abort and navigate to the home page console.error(`Something went wrong during import: ${e}`); @@ -184,19 +202,7 @@ export const ImportModal: React.FC = (props) => { useEffect(() => { if (modalState === 'signingIn') { - const signIn = async () => { - try { - await axios.post( - `/api/import/${importSource}/authenticate?payload=${encodeURIComponent(JSON.stringify(importPayload))}` - ); - setModalState('downloadingContent'); - } catch (e) { - // something went wrong, abort and navigate to the home page - console.error(`Something went wrong during authenticating import: ${e}`); - navigate('/home'); - } - }; - signIn(); + signIn(importSource, importPayload, setModalState); } }, [modalState, importSource, importPayload]); @@ -234,9 +240,10 @@ export const ImportModal: React.FC = (props) => { const createNewProxy = useCallback(() => { if (importedProjectInfo) { - importAsNewProject(importedProjectInfo); + const { creationUrl, state } = importAsNewProject(importedProjectInfo); + navigate(creationUrl, { state }); } - }, [importedProjectInfo, importAsNewProject]); + }, [importedProjectInfo]); const modalContent = useMemo(() => { switch (modalState) { @@ -267,6 +274,7 @@ export const ImportModal: React.FC = (props) => { // block but don't show anything other than the login window return ( = ( switch (state) { case 'connecting': { const label = ( - + {formatMessage.rich('Connecting to { source } to import bot content...', { b: BoldBlue, source: getUserFriendlySource(source), diff --git a/Composer/packages/client/src/components/ManageService/ManageService.tsx b/Composer/packages/client/src/components/ManageService/ManageService.tsx index 34a9867b27..e5520c9a57 100644 --- a/Composer/packages/client/src/components/ManageService/ManageService.tsx +++ b/Composer/packages/client/src/components/ManageService/ManageService.tsx @@ -22,14 +22,9 @@ import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/Choi import { ProvisionHandoff } from '@bfc/ui-shared'; import sortBy from 'lodash/sortBy'; import { NeutralColors } from '@uifabric/fluent-theme'; -import { AzureTenant } from '@botframework-composer/types'; -import jwtDecode from 'jwt-decode'; import TelemetryClient from '../../telemetry/TelemetryClient'; -import { AuthClient } from '../../utils/authClient'; -import { AuthDialog } from '../../components/Auth/AuthDialog'; -import { getTokenFromCache, isShowAuthDialog, userShouldProvideTokens } from '../../utils/auth'; -import { dispatcherState } from '../../recoilModel/atoms'; +import { dispatcherState, currentUserState, isAuthenticatedState, showAuthDialogState } from '../../recoilModel/atoms'; type ManageServiceProps = { createService: ( @@ -72,21 +67,19 @@ const mainElementStyle = { marginBottom: 20 }; const dialogBodyStyles = { height: 400 }; const CREATE_NEW_KEY = 'CREATE_NEW'; -export const ManageService = (props: ManageServiceProps) => { - const [showAuthDialog, setShowAuthDialog] = useState(false); - const [token, setToken] = useState(); +export const ManageService: React.FC = (props: ManageServiceProps) => { + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + const showAuthDialog = useRecoilValue(showAuthDialogState); - const { setApplicationLevelError } = useRecoilValue(dispatcherState); + const { setApplicationLevelError, requireUserLogin } = useRecoilValue(dispatcherState); const [subscriptionId, setSubscription] = useState(''); - const [tenantId, setTenantId] = useState(''); const [resourceGroups, setResourceGroups] = useState([]); const [createResourceGroup, setCreateResourceGroup] = useState(false); const [newResourceGroupName, setNewResourceGroupName] = useState(''); const [resourceGroupKey, setResourceGroupKey] = useState(''); const [resourceGroup, setResourceGroup] = useState(''); const [tier, setTier] = useState(''); - const [allTenants, setAllTenants] = useState([]); - const [tenantsErrorMessage, setTenantsErrorMessage] = useState(undefined); const [showHandoff, setShowHandoff] = useState(false); const [resourceName, setResourceName] = useState(''); const [loading, setLoading] = useState(undefined); @@ -99,7 +92,6 @@ export const ManageService = (props: ManageServiceProps) => { const [keys, setKeys] = useState([]); const [dialogTitle, setDialogTitle] = useState(''); - const [userProvidedTokens, setUserProvidedTokens] = useState(false); const [currentStep, setCurrentStep] = useState('intro'); const [outcomeDescription, setOutcomeDescription] = useState(''); const [outcomeSummary, setOutcomeSummary] = useState(); @@ -116,8 +108,8 @@ export const ManageService = (props: ManageServiceProps) => { ]; const fetchLocations = async (subscriptionId) => { - if (token) { - const tokenCredentials = new TokenCredentials(token); + if (isAuthenticated) { + const tokenCredentials = new TokenCredentials(currentUser.token); const subscriptionClient = new SubscriptionClient(tokenCredentials); const locations = await subscriptionClient.subscriptions.listLocations(subscriptionId); setLocationList( @@ -141,64 +133,12 @@ export const ManageService = (props: ManageServiceProps) => { return []; } }; - const decodeToken = (token: string) => { - try { - return jwtDecode(token); - } catch (err) { - console.error('decode token error in ', err); - return null; - } - }; - - useEffect(() => { - if (currentStep === 'subscription' && !userShouldProvideTokens()) { - AuthClient.getTenants() - .then((tenants) => { - setAllTenants(tenants); - if (tenants.length === 0) { - setTenantsErrorMessage(formatMessage('No Azure Directories were found.')); - } else if (tenants.length >= 1) { - setTenantId(tenants[0].tenantId); - } else { - setTenantsErrorMessage(undefined); - } - }) - .catch((err) => { - setTenantsErrorMessage( - formatMessage('There was a problem loading Azure directories. {errMessage}', { - errMessage: err.message || err.toString(), - }) - ); - }); - } - }, [currentStep]); - - useEffect(() => { - if (tenantId) { - AuthClient.getARMTokenForTenant(tenantId) - .then((token) => { - setToken(token); - setTenantsErrorMessage(undefined); - }) - .catch((err) => { - setTenantsErrorMessage( - formatMessage( - 'There was a problem getting the access token for the current Azure directory. {errMessage}', - { - errMessage: err.message || err.toString(), - } - ) - ); - setTenantsErrorMessage(err.message || err.toString()); - }); - } - }, [tenantId]); useEffect(() => { - if (token) { + if (isAuthenticated && !props.hidden) { setAvailableSubscriptions([]); setSubscriptionsErrorMessage(undefined); - getSubscriptions(token) + getSubscriptions(currentUser.token) .then((data) => { setAvailableSubscriptions(data); if (data.length === 0) { @@ -213,33 +153,7 @@ export const ManageService = (props: ManageServiceProps) => { setSubscriptionsErrorMessage(err.message); }); } - }, [token]); - - const hasAuth = async () => { - let newtoken = ''; - if (userShouldProvideTokens()) { - if (isShowAuthDialog(false)) { - setShowAuthDialog(true); - } - newtoken = getTokenFromCache('accessToken'); - if (newtoken) { - const decoded = decodeToken(newtoken); - if (decoded) { - setToken(newtoken); - setUserProvidedTokens(true); - } else { - setTenantsErrorMessage( - formatMessage( - 'There was a problem with the authentication access token. Close this dialog and try again. To be prompted to provide the access token again, clear it from application local storage.' - ) - ); - } - } - } else { - setUserProvidedTokens(false); - } - setCurrentStep('subscription'); - }; + }, [isAuthenticated, props.hidden]); useEffect(() => { // reset the ui @@ -270,14 +184,18 @@ export const ManageService = (props: ManageServiceProps) => { const resourceGroup = accounts[account].id?.replace(/.*?\/resourceGroups\/(.*?)\/.*/, '$1'); const name = accounts[account].name; if (resourceGroup && name) { - const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); - if (keys?.key1) { - keyList.push({ - name: name, - resourceGroup: resourceGroup, - region: accounts[account].location || '', - key: keys?.key1 || '', - }); + try { + const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); + if (keys?.key1) { + keyList.push({ + name, + resourceGroup, + region: accounts[account].location || '', + key: keys?.key1 || '', + }); + } + } catch (_err) { + // pass, filter no authorization resource } } } @@ -285,10 +203,10 @@ export const ManageService = (props: ManageServiceProps) => { }; const fetchAccounts = async (subscriptionId) => { - if (token) { + if (isAuthenticated) { setLoading(formatMessage('Loading keys...')); setNoKeys(false); - const tokenCredentials = new TokenCredentials(token); + const tokenCredentials = new TokenCredentials(currentUser.token); const cognitiveServicesManagementClient = new CognitiveServicesManagementClient(tokenCredentials, subscriptionId); const accounts = await cognitiveServicesManagementClient.accounts.list(); @@ -307,11 +225,11 @@ export const ManageService = (props: ManageServiceProps) => { }; const fetchResourceGroups = async (subscriptionId) => { - if (token) { - const tokenCredentials = new TokenCredentials(token); + if (isAuthenticated) { + const tokenCredentials = new TokenCredentials(currentUser.token); const resourceClient = new ResourceManagementClient(tokenCredentials, subscriptionId); - const groups = sortBy(await resourceClient.resourceGroups.list(), ['name']); - + const results = await resourceClient.resourceGroups.list(); + const groups = sortBy(results, ['name']); setResourceGroups([ { id: CREATE_NEW_KEY, @@ -324,10 +242,10 @@ export const ManageService = (props: ManageServiceProps) => { }; const createService = async () => { - if (token) { + if (isAuthenticated) { setLoading(formatMessage('Creating resources...')); - const tokenCredentials = new TokenCredentials(token); + const tokenCredentials = new TokenCredentials(currentUser.token); const resourceGroupName = resourceGroupKey === CREATE_NEW_KEY ? newResourceGroupName : resourceGroup; if (resourceGroupKey === CREATE_NEW_KEY) { @@ -437,8 +355,9 @@ export const ManageService = (props: ManageServiceProps) => { const onChangeSubscription = async (_, opt) => { // get list of keys for this subscription setSubscription(opt.key); - fetchAccounts(opt.key); setLoading(formatMessage('Loading subscription...')); + fetchAccounts(opt.key); + // if we don't have a list of regions already passed in if (!props.regions) { fetchLocations(opt.key); @@ -508,7 +427,8 @@ export const ManageService = (props: ManageServiceProps) => { setShowHandoff(true); props.onDismiss(); } else { - hasAuth(); + requireUserLogin(); + setCurrentStep('subscription'); } }; @@ -531,7 +451,7 @@ export const ManageService = (props: ManageServiceProps) => { {props.introText} {props.learnMore ? ( - + {formatMessage('Learn more')} ) : null} @@ -557,10 +477,9 @@ export const ManageService = (props: ManageServiceProps) => { - {formatMessage( - 'Select your Azure directory, then choose the subscription where your existing {service} resource is located.', - { service: props.serviceName } - )} + {formatMessage('Choose the subscription where your existing {service} resource is located.', { + service: props.serviceName, + })} {props.learnMore ? ( {formatMessage('Learn more')} @@ -570,18 +489,7 @@ export const ManageService = (props: ManageServiceProps) => { ({ key: t.tenantId, text: t.displayName }))} - selectedKey={tenantId} - styles={dropdownStyles} - onChange={(_e, o) => { - setTenantId(o?.key as string); - }} - /> - 0)} errorMessage={subscriptionsErrorMessage} label={formatMessage('Azure subscription')} @@ -612,6 +520,7 @@ export const ManageService = (props: ManageServiceProps) => { {!noKeys && subscriptionId && ( 0) || nextAction !== 'choose'} label={formatMessage('{service} resource name', { service: props.serviceName })} options={ @@ -653,6 +562,7 @@ export const ManageService = (props: ManageServiceProps) => { { placeholder={formatMessage('Enter name for new resources')} styles={inputStyles} value={resourceName} - onChange={(e, val) => setResourceName(val || '')} + onChange={(e, val) => { + setResourceName(val || ''); + }} /> {props.tiers && ( { - {formatMessage( - 'Select your Azure directory, then choose the subscription where you’d like your new {service} resource.', - { service: props.serviceName } - )} + {formatMessage('Choose the subscription where you’d like your new {service} resource.', { + service: props.serviceName, + })} {props.learnMore ? ( {formatMessage('Learn more')} @@ -787,18 +698,7 @@ export const ManageService = (props: ManageServiceProps) => { ({ key: t.tenantId, text: t.displayName }))} - selectedKey={tenantId} - styles={dropdownStyles} - onChange={(_e, o) => { - setTenantId(o?.key as string); - }} - /> - { {loading && } setCurrentStep('intro')} /> setCurrentStep('resourceCreation')} /> @@ -869,15 +769,6 @@ export const ManageService = (props: ManageServiceProps) => { return ( - {showAuthDialog && ( - { - setShowAuthDialog(false); - }} - /> - )} css` background-color: transparent; `} - ${disabled - ? `pointer-events: none;` - : `&:hover { + ${!disabled + ? `&:hover { background-color: ${NeutralColors.gray50}; } @@ -53,8 +52,8 @@ const link = (active: boolean, disabled: boolean) => css` border-image: initial; outline: rgb(102, 102, 102) solid 1px; } - } - `} + }` + : ''} `; const icon = (active: boolean, disabled: boolean) => diff --git a/Composer/packages/client/src/components/NotFound.jsx b/Composer/packages/client/src/components/NotFound.tsx similarity index 100% rename from Composer/packages/client/src/components/NotFound.jsx rename to Composer/packages/client/src/components/NotFound.tsx diff --git a/Composer/packages/client/src/components/Notifications/NotificationButton.tsx b/Composer/packages/client/src/components/Notifications/NotificationButton.tsx index ab3c0bf539..78eba6c93a 100644 --- a/Composer/packages/client/src/components/Notifications/NotificationButton.tsx +++ b/Composer/packages/client/src/components/Notifications/NotificationButton.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import { FontWeights } from '@uifabric/styling'; import { IButtonStyles, IconButton } from 'office-ui-fabric-react/lib/Button'; import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; +import { TooltipHost, DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; import { useRecoilValue } from 'recoil'; import formatMessage from 'format-message'; @@ -54,20 +55,19 @@ const NotificationButton: React.FC = ({ buttonStyles }) setIsOpen(!isOpen); }; + const label = formatMessage('Open notification panel'); + return ( - - - - {unreadNotification.length} + + + + + {unreadNotification.length} + - - + + () => { border-left: 4px solid #0078d4; background: white; box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108); - min-width: 340px; + width: 340px; border-radius: 2px; display: flex; flex-direction: column; @@ -68,21 +69,38 @@ const cardDetail = css` flex-grow: 1; `; +const iconMargin = '4px'; + +// Error Block Icon from Messaging Colors const errorType = css` - margin-top: 4px; + margin-top: ${iconMargin}; color: #a80000; `; +// Success Icon from Messaging Colors const successType = css` - margin-top: 4px; - color: #27ae60; + margin-top: ${iconMargin}; + color: #107c10; `; +// #fce100 const warningType = css` - margin-top: 4px; + margin-top: ${iconMargin}; color: ${SharedColors.yellow10}; `; +// #0078d4 +const questionType = css` + margin-top: ${iconMargin}; + color: ${SharedColors.cyanBlue10}; +`; + +// #c19c00 +const congratulationType = css` + margin-top: ${iconMargin}; + color: ${SharedColors.orangeYellow10}; +`; + const cardTitle = css` font-size: ${FontSizes.size16}; lint-height: 22px; @@ -97,13 +115,20 @@ const cardDescription = css` word-break: break-word; `; -const linkButton = css` - color: #0078d4; - float: right; - font-size: 12px; - height: auto; - margin-right: 8px; -`; +const linkButton = { + root: { + padding: '0', + border: '0', + }, + label: { + fontSize: '12px', + color: SharedColors.cyanBlue10, + margin: '0', + }, + textContainer: { + height: '16px', + }, +}; const getShimmerStyles = { root: { @@ -132,21 +157,59 @@ export type NotificationProps = { onHide?: (id: string) => void; }; +const makeLinkLabel = ({ label, onClick }: NotificationLink) => ( + + {label} + +); + const defaultCardContentRenderer = (props: CardProps) => { - const { title, description, type, link } = props; + const { title, description, type, link, links, leftLinks, rightLinks } = props; + + const rightLinkList = rightLinks ?? links ?? [link]; + const leftLinkList = leftLinks ?? []; + + const stackProps: IStackProps = { + horizontal: true, + horizontalAlign: 'space-between', + tokens: { + childrenGap: '20px', + padding: '0 16px 0 0', + maxHeight: '24px', + }, + }; + return ( {type === 'error' && } {type === 'success' && } {type === 'warning' && } + {type === 'question' && } + {type === 'congratulation' && } + {type === 'custom' && ( + + )} {title} {description && {description}} - {link && ( - - {link.label} - - )} + + + {leftLinkList.map((link) => ( + {makeLinkLabel(link)} + ))} + + + {rightLinkList.map( + (link) => link != null && {makeLinkLabel(link)} + )} + + {type === 'pending' && ( )} @@ -196,7 +259,13 @@ export const NotificationCard = React.memo((props: NotificationProps) => { ariaLabel={formatMessage('Close')} css={cancelButton} iconProps={{ iconName: 'Cancel', styles: { root: { fontSize: '12px' } } }} - onClick={() => onDismiss(id)} + onClick={() => { + // This lets us add custom actions to closing a card if we want to. + // For instance, telemetry to track when the user dismisses a specific + // type of card. + cardProps?.onDismiss?.(id); + onDismiss(id); + }} /> {renderCard(cardProps)} diff --git a/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx b/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx index 6d3f0e7e87..7138769bf5 100644 --- a/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx +++ b/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx @@ -2,47 +2,30 @@ // Licensed under the MIT License. /** @jsx jsx */ -import { jsx, css } from '@emotion/core'; -import React from 'react'; +import { CopyableText } from '@bfc/ui-shared'; +import { css, jsx } from '@emotion/core'; +import { FontSizes } from '@uifabric/fluent-theme'; +import { FontWeights } from '@uifabric/styling'; import formatMessage from 'format-message'; -import { IconButton, IButtonStyles } from 'office-ui-fabric-react/lib/Button'; -import { NeutralColors, FontSizes, FluentTheme } from '@uifabric/fluent-theme'; import { Link } from 'office-ui-fabric-react/lib/Link'; -import { FontWeights } from '@uifabric/styling'; +import React from 'react'; -import { platform, OS } from '../../utils/os'; +import { OS, platform } from '../../utils/os'; import { CardProps } from './NotificationCard'; const container = css` - padding: 0 16px 16px 40px; - position: relative; -`; - -const commandContainer = css` - display: flex; - flex-flow: row nowrap; + padding: 0 8px 16px 12px; position: relative; - padding: 4px 28px 4px 8px; - background-color: ${NeutralColors.gray20}; - line-height: 22px; - margin: 1rem 0; `; -const copyContainer = css` +const header = css` margin: 0; margin-bottom: 4px; font-size: ${FontSizes.size16}; font-weight: ${FontWeights.semibold}; `; -const copyIconColor = FluentTheme.palette.themeDark; -const copyIconStyles: IButtonStyles = { - root: { position: 'absolute', right: 0, color: copyIconColor, height: '22px' }, - rootHovered: { backgroundColor: 'transparent', color: copyIconColor }, - rootPressed: { backgroundColor: 'transparent', color: copyIconColor }, -}; - const linkContainer = css` margin: 0; `; @@ -61,18 +44,9 @@ export const TunnelingSetupNotification: React.FC = (props) => { const port = data?.port; const command = `${getNgrok()} http ${port} --host-header=localhost`; - const copyLocationToClipboard = async () => { - try { - await window.navigator.clipboard.writeText(command); - } catch (e) { - // eslint-disable-next-line no-console - console.error('Something went wrong when trying to copy the command to clipboard.', e); - } - }; - return ( - {title} + {title} {formatMessage.rich('Install ngrok and run the following command to continue', { a: ({ children }) => ( @@ -82,16 +56,11 @@ export const TunnelingSetupNotification: React.FC = (props) => { ), })} - - {command} - - + { describe('', () => { it('should render the NotificationCard', () => { const cardProps: CardProps = { - title: 'There was error creating your KB', + title: 'There was error creating your knowledge base', description: 'error', retentionTime: 1, type: 'error', @@ -29,12 +29,12 @@ describe('', () => { ); - expect(container).toHaveTextContent('There was error creating your KB'); + expect(container).toHaveTextContent('There was error creating your knowledge base'); }); it('should render the customized card', () => { const cardProps: CardProps = { - title: 'There was error creating your KB', + title: 'There was error creating your knowledge base', description: 'error', retentionTime: 5000, type: 'error', diff --git a/Composer/packages/client/src/components/Notifications/__tests__/useSurveyNotification.test.tsx b/Composer/packages/client/src/components/Notifications/__tests__/useSurveyNotification.test.tsx new file mode 100644 index 0000000000..a256342f16 --- /dev/null +++ b/Composer/packages/client/src/components/Notifications/__tests__/useSurveyNotification.test.tsx @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; + +import { renderWithRecoil } from '../../../../__tests__/testUtils'; +import { NotificationContainer } from '../NotificationContainer'; +import { useSurveyNotification } from '../useSurveyNotification'; +import { machineInfoState } from '../../../recoilModel'; +import { ClientStorage } from '../../../utils/storage'; +import { LAST_SURVEY_KEY } from '../../../constants'; + +let savedVersion: string | undefined = ''; +const MOCK_VERSION = '2.3.4_jest'; + +let surveyStorage: ClientStorage; + +beforeAll(() => { + process.env.NODE_ENV = 'jest'; + savedVersion = process.env.COMPOSER_VERSION; + process.env.COMPOSER_VERSION = MOCK_VERSION; + + surveyStorage = new ClientStorage(window.localStorage, 'survey'); +}); + +afterAll(() => { + process.env.NODE_ENV = 'test'; + process.env.COMPOSER_VERSION = savedVersion; +}); + +describe('useSurveyNotification', () => { + const id = 'machineID12345'; + const os = 'TestOS'; + + const mockOpen = jest.fn(); + + const initRecoilState = ({ set }) => { + set(machineInfoState, { os, id }); + }; + + window.open = mockOpen; + + const TestHarness = () => { + useSurveyNotification(); + return ; + }; + + describe('building the URL', () => { + beforeEach(() => { + surveyStorage.set('optedOut', false); + surveyStorage.set('days', 12345); + surveyStorage.set(LAST_SURVEY_KEY, null); + }); + + it('builds a URL given parameters', async () => { + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = await page.findByText('Take survey'); + surveyButton.click(); + + // We know these should all occur, but we don't care about the order + const patterns = [ + 'https://aka.ms/bfcomposersurvey', + 'Source=Composer', + `machineId=${id}`, + `os=${os}`, + `version=${MOCK_VERSION}`, + ]; + + for (const pattern of patterns) { + expect(mockOpen).toHaveBeenCalledWith(expect.stringContaining(pattern), '_blank'); + } + }); + + it('builds a URL given no OS', async () => { + const newRecoilState = ({ set }) => { + set(machineInfoState, { os: null, id }); + }; + + const page = renderWithRecoil(, newRecoilState); + + const surveyButton = await page.findByText('Take survey'); + surveyButton.click(); + + const patterns = [ + 'https://aka.ms/bfcomposersurvey', + 'Source=Composer', + `machineId=${id}`, + `os=Unknown`, + `version=${MOCK_VERSION}`, + ]; + + for (const pattern of patterns) { + expect(mockOpen).toHaveBeenCalledWith(expect.stringContaining(pattern), '_blank'); + } + }); + }); + + describe('determining eligibility', () => { + beforeEach(() => { + surveyStorage.set('optedOut', false); + surveyStorage.set('days', 12345); + surveyStorage.set(LAST_SURVEY_KEY, null); + }); + + it('shows the box under normal conditions', async () => { + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = await page.findByText('Take survey'); + expect(surveyButton).not.toBeNull(); + }); + + it("doesn't show the box when the user has opted out", () => { + surveyStorage.set('optedOut', true); + + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = page.queryByText('Take survey'); + expect(surveyButton).toBeNull(); + }); + + it("doesn't show the box when the user hasn't spent enough days with Composer", () => { + // logically impossible, but makes a good test case + surveyStorage.set('days', -1); + + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = page.queryByText('Take survey'); + expect(surveyButton).toBeNull(); + }); + + it("returns false when it hasn't been long enough since the last survey", () => { + // also logically impossible, but makes a good test case + surveyStorage.set(LAST_SURVEY_KEY, Date.now() + 10000); + + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = page.queryByText('Take survey'); + expect(surveyButton).toBeNull(); + }); + }); +}); diff --git a/Composer/packages/client/src/components/Notifications/useSurveyNotification.ts b/Composer/packages/client/src/components/Notifications/useSurveyNotification.ts new file mode 100644 index 0000000000..edb8a8ebd6 --- /dev/null +++ b/Composer/packages/client/src/components/Notifications/useSurveyNotification.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import querystring from 'query-string'; + +import { ClientStorage } from '../../utils/storage'; +import { dispatcherState, machineInfoState } from '../../recoilModel/atoms/appState'; +import { MachineInfo } from '../../recoilModel/types'; +import { LAST_SURVEY_KEY, SURVEY_URL_BASE, SURVEY_PARAMETERS } from '../../constants'; +import TelemetryClient from '../../telemetry/TelemetryClient'; + +const buildUrl = (info: MachineInfo) => { + // User OS + // hashed machineId + // composer version + // maybe include subscription ID; wait for global sign-in feature + // session ID (global telemetry GUID) + const version = process.env.COMPOSER_VERSION; + + const parameters = { + Source: 'Composer', + os: info?.os || 'Unknown', + machineId: info?.id, + version, + }; + + return `${SURVEY_URL_BASE}?${querystring.stringify(parameters)}`; +}; + +const getSurveyEligibility = () => { + const surveyStorage = new ClientStorage(window.localStorage, 'survey'); + + const optedOut = surveyStorage.get('optedOut', false); + + if (optedOut) { + return false; + } + + let days = surveyStorage.get('days', 0); + const lastUsed = surveyStorage.get('dateLastUsed', null); + const lastTaken = surveyStorage.get(LAST_SURVEY_KEY, null); + const today = new Date().toDateString(); + if (lastUsed !== today) { + days += 1; + surveyStorage.set('days', days); + } + surveyStorage.set('dateLastUsed', today); + + if ( + // To be eligible for the survey, the user needs to have used Composer + // some minimum number of days. + days >= SURVEY_PARAMETERS.daysUntilEligible && + // Also, either the user must have never taken the survey before or + // the last time they took it must be long enough in the past. + (lastTaken == null || Date.now() - lastTaken > SURVEY_PARAMETERS.timeUntilNextSurvey) + ) { + // If the above conditions are true, there's a fixed chance the card will appear. + return process.env.NODE_ENV === 'jest' || Math.random() < SURVEY_PARAMETERS.chanceToAppear; + } else { + return false; + } +}; + +export const useSurveyNotification = () => { + const { addNotification, deleteNotification } = useRecoilValue(dispatcherState); + const machineInfo = useRecoilValue(machineInfoState); + + useEffect(() => { + const url = buildUrl(machineInfo); + deleteNotification('survey'); + + if (getSurveyEligibility()) { + const surveyStorage = new ClientStorage(window.localStorage, 'survey'); + + TelemetryClient.track('HATSSurveyOffered'); + + addNotification({ + id: 'survey', + type: 'question', + title: formatMessage('Would you mind taking a quick survey?'), + description: formatMessage('We read every response and will use your feedback to improve Composer.'), + leftLinks: [ + { + label: formatMessage('Take survey'), + onClick: () => { + // This is safe; we control the URL that gets built + // eslint-disable-next-line security/detect-non-literal-fs-filename + window.open(url, '_blank'); + surveyStorage.set(LAST_SURVEY_KEY, Date.now()); + TelemetryClient.track('HATSSurveyAccepted'); + deleteNotification('survey'); + }, + }, + + { + // this is functionally identical to clicking the close box + label: formatMessage('Remind me later'), + onClick: () => { + TelemetryClient.track('HATSSurveyDismissed'); + deleteNotification('survey'); + }, + }, + ], + rightLinks: [ + { + label: formatMessage('No thanks'), + onClick: () => { + TelemetryClient.track('HATSSurveyRejected'); + surveyStorage.set('optedOut', true); + deleteNotification('survey'); + }, + }, + ], + onDismiss: () => TelemetryClient.track('HATSSurveyDismissed'), + }); + } + }, []); +}; diff --git a/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx b/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx index 0ab13292bc..9fb623adab 100644 --- a/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx +++ b/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx @@ -4,7 +4,7 @@ import { DialogTypes, DialogWrapper } from '@bfc/ui-shared/lib/components/DialogWrapper'; import { SDKKinds } from '@botframework-composer/types'; import { Button } from 'office-ui-fabric-react/lib/components/Button/Button'; -import React, { useMemo } from 'react'; +import React, { useMemo, useEffect } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { enableOrchestratorDialog } from '../../constants'; @@ -14,9 +14,11 @@ import { localeState, orchestratorForSkillsDialogState, rootBotProjectIdSelector, + settingsState, } from '../../recoilModel'; import { recognizersSelectorFamily } from '../../recoilModel/selectors/recognizers'; import { EnableOrchestrator } from '../AddRemoteSkillModal/EnableOrchestrator'; +import { canImportOrchestrator } from '../AddRemoteSkillModal/helper'; export const OrchestratorForSkillsDialog = () => { const [showOrchestratorDialog, setShowOrchestratorDialog] = useRecoilState(orchestratorForSkillsDialogState); @@ -24,6 +26,7 @@ export const OrchestratorForSkillsDialog = () => { const { dialogId } = useRecoilValue(designPageLocationState(rootProjectId)); const locale = useRecoilValue(localeState(rootProjectId)); const curRecognizers = useRecoilValue(recognizersSelectorFamily(rootProjectId)); + const setting = useRecoilValue(settingsState(rootProjectId)); const { updateRecognizer } = useRecoilValue(dispatcherState); @@ -32,6 +35,12 @@ export const OrchestratorForSkillsDialog = () => { return curRecognizers.some((f) => f.id === fileName && f.content.$kind === SDKKinds.OrchestratorRecognizer); }, [curRecognizers, dialogId, locale]); + useEffect(() => { + if (showOrchestratorDialog && hasOrchestrator) { + setShowOrchestratorDialog(false); + } + }, [hasOrchestrator, showOrchestratorDialog]); + const handleOrchestratorSubmit = async (event: React.MouseEvent, enable?: boolean) => { event.preventDefault(); if (enable) { @@ -43,7 +52,7 @@ export const OrchestratorForSkillsDialog = () => { const setVisibility = () => { if (showOrchestratorDialog) { - if (hasOrchestrator) { + if (hasOrchestrator || !canImportOrchestrator(setting?.runtime?.key)) { setShowOrchestratorDialog(false); return false; } @@ -63,7 +72,12 @@ export const OrchestratorForSkillsDialog = () => { title={enableOrchestratorDialog.title} onDismiss={onDismissHandler} > - + ); }; diff --git a/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx b/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx index bdd6ef5181..d24c4472d3 100644 --- a/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx +++ b/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { act, getQueriesForElement, within } from '@botframework-composer/test-utils'; +import { act, screen, userEvent } from '@botframework-composer/test-utils'; import { SDKKinds } from '@botframework-composer/types'; import * as React from 'react'; -import userEvent from '@testing-library/user-event'; +import { within } from '@testing-library/dom'; import { renderWithRecoil } from '../../../../__tests__/testUtils/renderWithRecoil'; import { @@ -14,12 +14,19 @@ import { localeState, orchestratorForSkillsDialogState, projectMetaDataState, + settingsState, } from '../../../recoilModel'; import { recognizersSelectorFamily } from '../../../recoilModel/selectors/recognizers'; import { OrchestratorForSkillsDialog } from '../OrchestratorForSkillsDialog'; import { importOrchestrator } from '../../AddRemoteSkillModal/helper'; -jest.mock('../../AddRemoteSkillModal/helper'); +jest.mock('../../AddRemoteSkillModal/helper', () => { + const helper = jest.requireActual('../../AddRemoteSkillModal/helper'); + return { + ...helper, + importOrchestrator: jest.fn(), + }; +}); // mimick a project setup with a rootbot and dialog files, and provide conditions for orchestrator skill dialog to be visible const makeInitialState = (set: any) => { @@ -29,6 +36,7 @@ const makeInitialState = (set: any) => { set(projectMetaDataState('rootBotId'), { isRootBot: true, isRemote: false }); set(designPageLocationState('rootBotId'), { dialogId: 'rootBotRootDialogId', focused: 'na', selected: 'na' }); set(localeState('rootBotId'), 'en-us'); + set(settingsState('rootBotId'), { runtime: { key: 'adaptive-runtime-dotnet-webapp' } }); set(recognizersSelectorFamily('rootBotId'), [ { id: 'rootBotRootDialogId.en-us.lu.dialog', content: { $kind: SDKKinds.LuisRecognizer } }, ]); @@ -42,55 +50,153 @@ describe('', () => { }); it('should not open OrchestratorForSkillsDialog if orchestratorForSkillsDialogState is false', () => { - const { baseElement } = renderWithRecoil(, ({ set }) => { + renderWithRecoil(, ({ set }) => { makeInitialState(set); set(orchestratorForSkillsDialogState, false); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeNull(); }); it('should not open OrchestratorForSkillsDialog if orchestrator already being used in root', () => { - const { baseElement } = renderWithRecoil(, ({ set }) => { + renderWithRecoil(, ({ set }) => { makeInitialState(set); set(recognizersSelectorFamily('rootBotId'), [ { id: 'rootBotRootDialogId.en-us.lu.dialog', content: { $kind: SDKKinds.OrchestratorRecognizer } }, ]); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeNull(); }); - it('open OrchestratorForSkillsDialog if orchestratorForSkillsDialogState and Orchestrator not used in Root Bot Root Dialog', () => { + it('should not render OrchestratorForSkillsDialog if runtime is not supported', () => { const { baseElement } = renderWithRecoil(, ({ set }) => { makeInitialState(set); + set(settingsState('rootBotId'), { + defaultLanguage: 'en-us', + languages: ['en-us'], + luis: { + authoringEndpoint: '', + name: '', + authoringKey: '', + defaultLanguage: '', + endpoint: '', + endpointKey: '', + environment: '', + }, + qna: { endpointKey: '', subscriptionKey: '' }, + luFeatures: { enableCompositeEntities: false }, + customFunctions: [], + importedLibraries: [], + runtime: { key: 'node-webapp-v1', command: '', path: '', customRuntime: false }, + }); + }); + const dialog = within(baseElement as HTMLElement).queryByTestId(orchestratorTestId); + expect(dialog).toBeNull(); + }); + + it('should not render OrchestratorForSkillsDialog if runtime is missing or invalid', () => { + const { baseElement } = renderWithRecoil(, ({ set }) => { + makeInitialState(set); + set(settingsState('rootBotId'), { + defaultLanguage: 'en-us', + languages: ['en-us'], + luis: { + authoringEndpoint: '', + name: '', + authoringKey: '', + defaultLanguage: '', + endpoint: '', + endpointKey: '', + environment: '', + }, + qna: { endpointKey: '', subscriptionKey: '' }, + luFeatures: { enableCompositeEntities: false }, + customFunctions: [], + importedLibraries: [], + runtime: { key: '', command: '', path: '', customRuntime: false }, + }); + }); + const dialog = within(baseElement as HTMLElement).queryByTestId(orchestratorTestId); + expect(dialog).toBeNull(); + }); + + it('open OrchestratorForSkillsDialog if orchestratorForSkillsDialogState and Orchestrator not used in Root Bot Root Dialog', () => { + renderWithRecoil(, ({ set }) => { + makeInitialState(set); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeTruthy(); }); it('should install Orchestrator package when user clicks Continue', async () => { + renderWithRecoil(, ({ set }) => { + makeInitialState(set); + }); + + await act(async () => { + userEvent.click(screen.getByTestId('import-orchestrator')); + }); + + expect(importOrchestrator).toBeCalledWith( + 'rootBotId', + { key: 'adaptive-runtime-dotnet-webapp' }, + expect.anything(), + expect.anything() + ); + }); + + it('should install Orchestrator package with adaptive node runtime', async () => { const { baseElement } = renderWithRecoil(, ({ set }) => { makeInitialState(set); + + set(settingsState('rootBotId'), { + defaultLanguage: 'en-us', + languages: ['en-us'], + luis: { + authoringEndpoint: '', + name: '', + authoringKey: '', + defaultLanguage: '', + endpoint: '', + endpointKey: '', + environment: '', + }, + qna: { endpointKey: '', subscriptionKey: '' }, + luFeatures: { enableCompositeEntities: false }, + customFunctions: [], + importedLibraries: [], + runtime: { key: 'adaptive-runtime-js-functions', command: '', path: '', customRuntime: false }, + }); }); await act(async () => { - userEvent.click(within(baseElement).getByTestId('import-orchestrator')); + userEvent.click(within(baseElement as HTMLElement).getByTestId('import-orchestrator')); }); - expect(importOrchestrator).toBeCalledWith('rootBotId', expect.anything(), expect.anything()); + expect(importOrchestrator).toBeCalledWith( + 'rootBotId', + { + key: 'adaptive-runtime-js-functions', + path: expect.anything(), + customRuntime: expect.anything(), + command: expect.anything(), + }, + expect.anything(), + expect.anything() + ); }); it('should not install Orchestrator package when user clicks skip', async () => { - const { baseElement } = renderWithRecoil(, ({ set }) => { + renderWithRecoil(, ({ set }) => { makeInitialState(set); }); await act(async () => { - userEvent.click(await within(baseElement).findByText('Skip')); + userEvent.click(await screen.findByText('Skip')); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeNull(); expect(importOrchestrator).toBeCalledTimes(0); diff --git a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx index 258d3392f7..c7a8949107 100644 --- a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx +++ b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx @@ -144,7 +144,7 @@ export const ProjectTree: React.FC = ({ onboardingAddCoachMarkRef, navigateToFormDialogSchema, setPageElementState, - createQnAFromUrlDialogBegin, + createQnADialogBegin, } = useRecoilValue(dispatcherState); const treeRef = useRef(null); @@ -233,7 +233,7 @@ export const ProjectTree: React.FC = ({ label: formatMessage('Add QnA Maker knowledge base'), icon: 'Add', onClick: () => { - createQnAFromUrlDialogBegin({ projectId: skillId, dialogId: dialog.id }); + createQnADialogBegin({ projectId: skillId, dialogId: dialog.id }); TelemetryClient.track('AddNewKnowledgeBaseStarted'); }, }; diff --git a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx index d4a575a8c2..e9120b1d8f 100644 --- a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx +++ b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx @@ -4,7 +4,7 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; import React, { useState, useCallback } from 'react'; -import { FontSizes } from '@uifabric/fluent-theme'; +import { FontSizes, FluentTheme } from '@uifabric/fluent-theme'; import { DefaultPalette } from '@uifabric/styling'; import { OverflowSet, IOverflowSetItemProps } from 'office-ui-fabric-react/lib/OverflowSet'; import { TooltipHost, DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; @@ -184,7 +184,7 @@ const statusIcon = { const warningIcon = { ...statusIcon, - color: '#BE880A', + color: FluentTheme.palette.yellow, }; const errorIcon = { diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx deleted file mode 100644 index c2f7b5d980..0000000000 --- a/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import React, { useState } from 'react'; -import { useRecoilValue } from 'recoil'; - -import { - createQnAOnState, - showCreateQnAFromScratchDialogState, - showCreateQnAFromUrlDialogState, - settingsState, -} from '../../recoilModel'; - -import CreateQnAFromScratchModal from './CreateQnAFromScratchModal'; -import CreateQnAFromUrlModal from './CreateQnAFromUrlModal'; -import { CreateQnAFromModalProps } from './constants'; - -export const CreateQnAModal: React.FC = (props) => { - const { projectId } = useRecoilValue(createQnAOnState); - const settings = useRecoilValue(settingsState(projectId)); - const locales = settings.languages; - const defaultLocale = settings.defaultLanguage; - const showCreateQnAFromScratchDialog = useRecoilValue(showCreateQnAFromScratchDialogState(projectId)); - const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId)); - const [initialName, setInitialName] = useState(''); - if (showCreateQnAFromScratchDialog) { - return ; - } else if (showCreateQnAFromUrlDialog) { - return ( - - ); - } else { - return null; - } -}; - -export default CreateQnAModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromQnAMaker.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromQnAMaker.tsx new file mode 100644 index 0000000000..f36a17d815 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromQnAMaker.tsx @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect } from 'react'; +import formatMessage from 'format-message'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { Text } from 'office-ui-fabric-react/lib/Text'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; + +import { validateName, CreateQnAFromFormProps, CreateQnAFromQnAMakerFormData } from './constants'; +import { knowledgeBaseStyle, subText } from './styles'; + +const formConfig: FieldConfig = { + name: { + required: true, + defaultValue: '', + }, +}; + +export const CreateQnAFromQnAMaker: React.FC = (props) => { + const { onChange, qnaFiles, initialName } = props; + + formConfig.name.validate = validateName(qnaFiles); + formConfig.name.defaultValue = initialName || ''; + const { formData, hasErrors } = useForm(formConfig); + + useEffect(() => { + const disabled = hasErrors || !formData.name; + onChange(formData, disabled); + }, [formData]); + + return ( + + + + {formatMessage('Import content from an existing knowledge base on the QnA maker portal')} + + + + {formatMessage( + 'Import content from an existing knowledge base on the QnA maker portal. Your knowledge base will downloaded locally and source knowledge base will remain as-is.' + )} + + + + + ); +}; + +export default CreateQnAFromQnAMaker; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromScratch.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromScratch.tsx new file mode 100644 index 0000000000..7a0c70e901 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromScratch.tsx @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect } from 'react'; +import formatMessage from 'format-message'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; + +import { validateName, CreateQnAFromFormProps, CreateQnAFromScratchFormData } from './constants'; +import { textFieldKBNameFromScratch } from './styles'; + +const formConfig: FieldConfig = { + name: { + required: true, + defaultValue: '', + }, +}; + +export const CreateQnAFromScratch: React.FC = (props) => { + const { onChange, qnaFiles, initialName, onUpdateInitialName } = props; + + formConfig.name.validate = validateName(qnaFiles); + formConfig.name.defaultValue = initialName || ''; + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + + useEffect(() => { + const disabled = hasErrors || !formData.name; + onChange(formData, disabled); + }, [formData, hasErrors]); + + return ( + + + { + updateField('name', name); + onUpdateInitialName?.(name); + }} + /> + + + ); +}; + +export default CreateQnAFromScratch; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx deleted file mode 100644 index 19dd8d7236..0000000000 --- a/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx +++ /dev/null @@ -1,120 +0,0 @@ -// 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 TelemetryClient from '../../telemetry/TelemetryClient'; - -import { validateName, CreateQnAFromModalProps, CreateQnAFromScratchFormData } from './constants'; -import { subText, styles, dialogWindowMini, textFieldKBNameFromScratch } from './styles'; - -const formConfig: FieldConfig = { - name: { - required: true, - defaultValue: '', - }, -}; - -const DialogTitle = () => { - return ( - - {formatMessage('Add QnA Maker knowledge base')} - - {formatMessage('Manually add question and answer pairs to create a knowledge base')} - - - ); -}; - -export const CreateQnAFromScratchModal: React.FC = (props) => { - const { onDismiss, onSubmit, qnaFiles, projectId, initialName, onUpdateInitialName } = props; - const actions = useRecoilValue(dispatcherState); - const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId)); - - formConfig.name.validate = validateName(qnaFiles); - formConfig.name.defaultValue = initialName || ''; - const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); - const disabled = hasErrors || !formData.name; - - const handleDismiss = () => { - onDismiss?.(); - onUpdateInitialName?.(''); - actions.createQnAFromScratchDialogCancel({ projectId }); - TelemetryClient.track('AddNewKnowledgeBaseCanceled'); - }; - - return ( - , - styles: styles.dialog, - }} - hidden={false} - modalProps={{ - isBlocking: false, - styles: styles.modalCreateFromScratch, - }} - onDismiss={handleDismiss} - > - - - { - updateField('name', name); - onUpdateInitialName?.(name); - }} - /> - - - - {showCreateQnAFromUrlDialog && ( - { - actions.createQnAFromScratchDialogBack({ projectId }); - }} - /> - )} - { - handleDismiss(); - }} - /> - { - if (hasErrors) { - return; - } - onSubmit(formData); - onUpdateInitialName?.(''); - TelemetryClient.track('AddNewKnowledgeBaseCompleted', { scratch: true }); - }} - /> - - - ); -}; - -export default CreateQnAFromScratchModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromUrl.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromUrl.tsx new file mode 100644 index 0000000000..0d7495d44c --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromUrl.tsx @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useState, useMemo, Fragment, useEffect } from 'react'; +import formatMessage from 'format-message'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { Text } from 'office-ui-fabric-react/lib/Text'; +import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; + +import { Locales } from '../../locales'; + +import { initializeLocales } from './utilities'; +import { + validateUrl, + CreateQnAFromFormProps, + CreateQnAFromUrlFormData, + CreateQnAFromUrlFormDataErrors, +} from './constants'; +import { textFieldUrl, warning, urlPairStyle, knowledgeBaseStyle, urlStackStyle, subText } from './styles'; + +const hasErrors = (errors: CreateQnAFromUrlFormDataErrors) => { + return !!errors.name || errors.urls.some((e) => !!e); +}; + +export const CreateQnAFromUrl: React.FC = (props) => { + const { onChange, dialogId, locales, defaultLocale, initialName } = props; + + const index = Locales.findIndex((l) => l.locale === locales[0]); + const initialLanguage = index > -1 ? Locales[index].language : locales[0]; + const [formData, setFormData] = useState({ + urls: [], + locales: initializeLocales(locales, defaultLocale), + language: initialLanguage, + name: initialName || '', + multiTurn: false, + }); + + const [formDataErrors, setFormDataErrors] = useState({ + urls: [], + name: '', + }); + + const usedLocales = useMemo(() => { + return locales.map((fl) => { + const index = Locales.findIndex((l) => l.locale === fl); + if (index > -1) { + return { text: Locales[index].language, locale: fl, key: fl }; + } else { + return { text: fl, locale: fl, key: fl }; + } + }); + }, [locales]); + + const isQnAFileselected = !(dialogId === 'all'); + + const onChangeUrlsField = (value: string | undefined) => { + const urls = [...formData.urls]; + urls[0] = value ?? ''; + updateUrlsField(urls); + updateUrlsError(urls); + }; + + const onChangeMultiTurn = (value: boolean | undefined) => { + setFormData({ + ...formData, + multiTurn: value ?? false, + }); + }; + + const updateUrlsField = (urls: string[]) => { + setFormData({ + ...formData, + urls: urls, + }); + }; + + const updateUrlsError = (urls: string[]) => { + const urlErrors = urls.map((url) => { + return validateUrl(url); + }) as string[]; + setFormDataErrors({ ...formDataErrors, urls: urlErrors }); + }; + + const onChangeLanguageField = (option) => { + setFormData({ + ...formData, + language: option.text, + locales: [option.key], + }); + }; + + useEffect(() => { + const disabled = hasErrors(formDataErrors) || !formData.urls[0] || !formData.name; + onChange(formData, disabled); + }, [formData, formDataErrors]); + + return ( + + + {formatMessage('Create new knowledge base from URL')} + + + {formatMessage( + 'Select this option if you want to create a knowledge base from content hosted online such as an FAQ or document link (.csv, .xls or .doc format)' + )} + + + + + {formatMessage('Source URL')} + + onChangeUrlsField(url)} + /> + + { + onChangeLanguageField(o); + }} + /> + + {!isQnAFileselected && ( + {formatMessage('Please select a specific qna file to import QnA')} + )} + + + onChangeMultiTurn(val)} + /> + + + ); +}; + +export default CreateQnAFromUrl; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx deleted file mode 100644 index 1e12dee3a8..0000000000 --- a/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx +++ /dev/null @@ -1,269 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import React, { useState, useMemo } 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 { Text } from 'office-ui-fabric-react/lib/Text'; -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 { Locales } from '../../locales'; -import { dispatcherState, onCreateQnAFromUrlDialogCompleteState } from '../../recoilModel'; -import TelemetryClient from '../../telemetry/TelemetryClient'; - -import { - knowledgeBaseSourceUrl, - validateUrl, - validateName, - CreateQnAFromUrlModalProps, - CreateQnAFromUrlFormData, - CreateQnAFromUrlFormDataErrors, -} from './constants'; -import { - subText, - styles, - dialogWindow, - textFieldUrl, - textFieldKBNameFromUrl, - warning, - urlPairStyle, - knowledgeBaseStyle, - urlStackStyle, -} from './styles'; - -const DialogTitle = () => { - return ( - - {formatMessage('Add QnA Maker knowledge base')} - - - {formatMessage('Use Azure QnA Maker to extract question-and-answer pairs from an online FAQ. ')} - - {formatMessage('Learn more')} - - - - - ); -}; - -const hasErrors = (errors: CreateQnAFromUrlFormDataErrors) => { - return !!errors.name || errors.urls.some((e) => !!e); -}; - -const initializeLocales = (locales: string[], defaultLocale: string) => { - const newLocales = [...locales]; - const index = newLocales.findIndex((l) => l === defaultLocale); - if (index < 0) throw new Error(`default language ${defaultLocale} does not exist in languages`); - newLocales.splice(index, 1); - newLocales.sort(); - newLocales.unshift(defaultLocale); - return newLocales; -}; - -export const CreateQnAFromUrlModal: React.FC = (props) => { - const { - onDismiss, - onSubmit, - dialogId, - projectId, - qnaFiles, - locales, - defaultLocale, - initialName, - onUpdateInitialName, - } = props; - const actions = useRecoilValue(dispatcherState); - const onComplete = useRecoilValue(onCreateQnAFromUrlDialogCompleteState(projectId)); - - const [formData, setFormData] = useState({ - urls: [], - locales: initializeLocales(locales, defaultLocale), - name: initialName || '', - multiTurn: false, - }); - - const [formDataErrors, setFormDataErrors] = useState({ - urls: [], - name: '', - }); - - const usedLocales = useMemo(() => { - return formData.locales.map((fl) => { - const index = Locales.findIndex((l) => l.locale === fl); - if (index > -1) { - return Locales[index].language; - } - }); - }, [formData.locales]); - - const isQnAFileselected = !(dialogId === 'all'); - const disabled = hasErrors(formDataErrors) || !formData.urls[0] || !formData.name; - const validFormDataName = validateName(qnaFiles); - - const onChangeNameField = (value: string | undefined) => { - updateNameField(value); - onUpdateInitialName?.(value ?? ''); - updateNameError(value); - }; - - const onChangeUrlsField = (value: string | undefined, index: number) => { - const urls = [...formData.urls]; - urls[index] = value ?? ''; - updateUrlsField(urls); - updateUrlsError(urls); - }; - - const onChangeMultiTurn = (value: boolean | undefined) => { - setFormData({ - ...formData, - multiTurn: value ?? false, - }); - }; - - const updateNameField = (value: string | undefined) => { - setFormData({ - ...formData, - name: value ?? '', - }); - }; - - const updateNameError = (value: string | undefined) => { - const error = validFormDataName(value) as string; - setFormDataErrors({ ...formDataErrors, name: error ?? '' }); - }; - - const updateUrlsField = (urls: string[]) => { - setFormData({ - ...formData, - urls: urls, - }); - }; - - const updateUrlsError = (urls: string[]) => { - const urlErrors = urls.map((url) => { - return validateUrl(url); - }) as string[]; - setFormDataErrors({ ...formDataErrors, urls: urlErrors }); - }; - - const handleDismiss = () => { - onDismiss?.(); - onUpdateInitialName?.(''); - actions.createQnAFromUrlDialogCancel({ projectId }); - TelemetryClient.track('AddNewKnowledgeBaseCanceled'); - }; - - const removeEmptyUrls = (formData: CreateQnAFromUrlFormData) => { - const urls: string[] = []; - const locales: string[] = []; - for (let i = 0; i < formData.urls.length; i++) { - if (formData.urls[i]) { - urls.push(formData.urls[i]); - locales.push(formData.locales[i]); - } - } - return { - ...formData, - locales, - urls, - }; - }; - - return ( - , - styles: styles.dialog, - }} - hidden={false} - modalProps={{ - isBlocking: false, - styles: styles.modalCreateFromUrl, - }} - onDismiss={handleDismiss} - > - - - onChangeNameField(name)} - /> - {formatMessage('FAQ website (source)')} - {formData.locales.map((locale, i) => { - return ( - - onChangeUrlsField(url, i)} - /> - - ); - })} - - {!isQnAFileselected && ( - {formatMessage('Please select a specific qna file to import QnA')} - )} - - - onChangeMultiTurn(val)} - /> - - - - { - // switch to create from scratch flow, pass onComplete callback. - actions.createQnAFromScratchDialogBegin({ projectId, dialogId, onComplete: onComplete?.func }); - }} - /> - { - handleDismiss(); - }} - /> - { - if (hasErrors(formDataErrors)) { - return; - } - onSubmit(removeEmptyUrls(formData)); - onUpdateInitialName?.(''); - TelemetryClient.track('AddNewKnowledgeBaseCompleted', { scratch: false }); - }} - /> - - - ); -}; - -export default CreateQnAFromUrlModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAModal.tsx new file mode 100644 index 0000000000..83ffbbc86e --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAModal.tsx @@ -0,0 +1,683 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { + DetailsList, + SelectionMode, + DetailsListLayoutMode, + IColumn, + Selection, + DetailsRow, + IDetailsRowProps, + CheckboxVisibility, +} from 'office-ui-fabric-react/lib/DetailsList'; +import { Spinner } from 'office-ui-fabric-react/lib/Spinner'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; +import { SubscriptionClient } from '@azure/arm-subscriptions'; +import { Subscription } from '@azure/arm-subscriptions/esm/models'; +import { TokenCredentials } from '@azure/ms-rest-js'; +import { CognitiveServicesManagementClient } from '@azure/arm-cognitiveservices'; +import { CognitiveServicesCredentials } from '@azure/ms-rest-azure-js'; +import { QnAMakerClient } from '@azure/cognitiveservices-qnamaker'; +import sortBy from 'lodash/sortBy'; +import uniq from 'lodash/uniq'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import { IRenderFunction } from '@uifabric/utilities'; + +import TelemetryClient from '../../telemetry/TelemetryClient'; +import { + createQnAOnState, + showCreateQnADialogState, + settingsState, + dispatcherState, + localeState, + currentUserState, + isAuthenticatedState, + showAuthDialogState, +} from '../../recoilModel'; + +import { CreateQnAFormData, CreateQnAModalProps, QnAMakerLearnMoreUrl } from './constants'; +import { + subText, + styles, + contentBox, + formContainer, + choiceContainer, + nameStepContainer, + resourceDropdown, + dialogBodyStyles, +} from './styles'; +import { CreateQnAFromUrl } from './CreateQnAFromUrl'; +import { CreateQnAFromScratch } from './CreateQnAFromScratch'; +import { CreateQnAFromQnAMaker } from './CreateQnAFromQnAMaker'; +import { localeToLanguage } from './utilities'; +import { PersonaCard } from './PersonaCard'; + +type KeyRec = { + name: string; + region: string; + resourceGroup: string; + key: string; + endpoint: string; +}; + +type KBRec = { + id: string; + name: string; + language: string; + lastChangedTimestamp: string; +}; + +type Step = 'name' | 'intro' | 'resource' | 'knowledge-base' | 'outcome'; + +const mainElementStyle = { marginBottom: 20 }; +const serviceName = 'QnA Maker'; +const serviceKeyType = 'QnAMaker'; + +export const CreateQnAModal: React.FC = (props) => { + const { onDismiss, onSubmit } = props; + const { projectId } = useRecoilValue(createQnAOnState); + const settings = useRecoilValue(settingsState(projectId)); + const actions = useRecoilValue(dispatcherState); + const locales = settings.languages; + const defaultLocale = settings.defaultLanguage; + const currentLocale = useRecoilValue(localeState(projectId)); + const showCreateQnAFrom = useRecoilValue(showCreateQnADialogState(projectId)); + const [initialName, setInitialName] = useState(''); + const [formData, setFormData] = useState(); + const [disabled, setDisabled] = useState(true); + const { setApplicationLevelError, requireUserLogin } = useRecoilValue(dispatcherState); + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + const showAuthDialog = useRecoilValue(showAuthDialogState); + + const [subscriptionId, setSubscription] = useState(''); + + const [loading, setLoading] = useState(undefined); + const [noKeys, setNoKeys] = useState(false); + const [nextAction, setNextAction] = useState('url'); + const [key, setKey] = useState(); + const [region, setRegion] = useState(''); + const [availableSubscriptions, setAvailableSubscriptions] = useState([]); + const [subscriptionsErrorMessage, setSubscriptionsErrorMessage] = useState(); + const [keys, setKeys] = useState([]); + const [kbs, setKbs] = useState<{ [key: string]: KBRec[] }>({}); + const [selectedKb, setSelectedKb] = useState(); + const [dialogTitle, setDialogTitle] = useState(''); + + const [currentStep, setCurrentStep] = useState('name'); + + const currentAuthoringLanuage = localeToLanguage(currentLocale); + const defaultLanuage = localeToLanguage(defaultLocale); + const avaliableLanguages = uniq(locales.map((item) => localeToLanguage(item))); + + const actionOptions: IChoiceGroupOption[] = [ + { key: 'url', text: formatMessage('Create new knowledge base from URL') }, + { + key: 'portal', + text: formatMessage('Import existing knowledge base from QnA maker portal'), + }, + ]; + + /* Copied from Azure Publishing extension */ + const getSubscriptions = async (token: string): Promise> => { + const tokenCredentials = new TokenCredentials(token); + try { + const subscriptionClient = new SubscriptionClient(tokenCredentials); + const subscriptionsResult = await subscriptionClient.subscriptions.list(); + // eslint-disable-next-line no-underscore-dangle + return sortBy(subscriptionsResult._response.parsedBody, ['displayName']); + } catch (err) { + setApplicationLevelError(err); + return []; + } + }; + + const selectedKB = useMemo(() => { + return new Selection({ + onSelectionChanged: () => { + const t = selectedKB.getSelection()[0] as KBRec; + + if (t) { + setSelectedKb(t); + } + }, + }); + }, []); + + useEffect(() => { + if (isAuthenticated && showCreateQnAFrom) { + setAvailableSubscriptions([]); + setSubscriptionsErrorMessage(undefined); + getSubscriptions(currentUser.token) + .then((data) => { + setAvailableSubscriptions(data); + if (data.length === 0) { + setSubscriptionsErrorMessage( + formatMessage( + 'Your subscription list is empty, please add your subscription, or login with another account.' + ) + ); + } + }) + .catch((err) => { + setSubscriptionsErrorMessage(err.message); + }); + } + }, [currentUser, isAuthenticated, showCreateQnAFrom]); + + useEffect(() => { + // reset the ui + setSubscription(''); + setKeys([]); + setCurrentStep('name'); + setSelectedKb(undefined); + setKbs({}); + }, [showCreateQnAFrom]); + + const fetchKeys = async (cognitiveServicesManagementClient, accounts) => { + const keyList: KeyRec[] = []; + for (const account in accounts) { + const resourceGroup = accounts[account].id?.replace(/.*?\/resourceGroups\/(.*?)\/.*/, '$1'); + const name = accounts[account].name; + if (resourceGroup && name) { + try { + const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); + if (keys?.key1) { + keyList.push({ + name, + resourceGroup, + region: accounts[account].location || '', + key: keys?.key1 || '', + endpoint: accounts[account]?.properties?.endpoint ?? '', + }); + } + } catch (_err) { + // pass, filter no authorization resource + } + } + } + return keyList; + }; + + const fetchAccounts = async (subscriptionId) => { + if (isAuthenticated) { + setLoading(formatMessage('Loading keys...')); + setNoKeys(false); + const tokenCredentials = new TokenCredentials(currentUser.token); + const cognitiveServicesManagementClient = new CognitiveServicesManagementClient(tokenCredentials, subscriptionId); + const accounts = await cognitiveServicesManagementClient.accounts.list(); + + const keylist: KeyRec[] = await fetchKeys( + cognitiveServicesManagementClient, + accounts.filter((a) => a.kind === serviceKeyType) + ); + + const kbsMap = {}; + const avaliableKeys: KeyRec[] = []; + for (const keyItem of keylist) { + const kbGroups = await fetchKBGroups(keyItem); + kbsMap[keyItem.name] = kbGroups; + if (kbGroups.length) { + avaliableKeys.push(keyItem); + } + } + setKbs(kbsMap); + setLoading(undefined); + if (avaliableKeys.length == 0) { + setNoKeys(true); + } else { + setNoKeys(false); + setKeys(avaliableKeys); + } + } + }; + + const fetchKBGroups = async (key: KeyRec) => { + let kblist: KBRec[] = []; + if (isAuthenticated && key) { + const cognitiveServicesCredentials = new CognitiveServicesCredentials(key.key); + const resourceClient = new QnAMakerClient(cognitiveServicesCredentials, key.endpoint); + + const result = await resourceClient.knowledgebase.listAll(); + + if (result.knowledgebases) { + kblist = result.knowledgebases.map((item: any) => { + return { + id: item.id || '', + name: item.name || '', + language: item.language || '', + lastChangedTimestamp: item.lastChangedTimestamp || '', + }; + }); + } + } + return kblist; + }; + // allow a user to provide a subscription id if one is missing + const onChangeSubscription = async (_, opt) => { + // get list of keys for this subscription + setSubscription(opt.key); + fetchAccounts(opt.key); + setLoading(formatMessage('Loading subscription...')); + }; + + const onChangeKey = async (_, opt) => { + // get list of keys for this subscription + setKey(opt); + setRegion(opt.region); + }; + + const onChangeAction = async (_, opt) => { + setNextAction(opt.key); + }; + + const chooseExistingKey = () => { + TelemetryClient.track('SettingsGetKeysExistingResourceSelected', { + subscriptionId, + resourceType: serviceName, + }); + setCurrentStep('knowledge-base'); + }; + + const performNextAction = () => { + if (nextAction === 'url') { + onSubmitFormData(nextAction); + } else { + requireUserLogin(); + setCurrentStep('resource'); + } + }; + + const renderNameStep = () => { + return ( + + + + + {formatMessage('Use Azure QnA Maker to extract question-and-answer pairs from an online FAQ. ')} + + {formatMessage('Learn more')} + + + + + + + setCurrentStep('intro')} + /> + + + + ); + }; + + const renderIntroStep = () => { + return ( + + + + {formatMessage('Create a knowledge base from a URL or import content from an existing knowledge base')} + + + + + + + + {nextAction === 'url' ? ( + + ) : ( + + )} + + + + onSubmitFormData('scratch')} + /> + setCurrentStep('name')} /> + + + + + ); + }; + + const renderChooseResourceStep = () => { + return ( + + + + {formatMessage('Select the subscription and resource you want to choose a knowledge base from')} + + + 0)} + errorMessage={subscriptionsErrorMessage} + label={formatMessage('Azure subscription')} + options={ + availableSubscriptions + ?.filter((p) => p.subscriptionId && p.displayName) + .map((p) => { + return { key: p.subscriptionId ?? '', text: p.displayName ?? formatMessage('Unnamed') }; + }) ?? [] + } + placeholder={formatMessage('Select a subscription')} + selectedKey={subscriptionId} + styles={resourceDropdown} + onChange={onChangeSubscription} + /> + + + {noKeys && subscriptionId && ( + + {formatMessage( + 'No existing QnA Maker resources were found in this subscription. Select a different subscription, or click “Back” to create a new resource or generate a resource request to handoff to your Azure admin.' + )} + + )} + {!noKeys && subscriptionId && ( + + 0)} + label={formatMessage('QnA Maker resource name')} + options={ + keys.map((p) => { + return { text: p.name, ...p }; + }) ?? [] + } + placeholder={formatMessage('Select resource')} + styles={resourceDropdown} + onChange={onChangeKey} + /> + + )} + + + + + + + {loading && } + setCurrentStep('intro')} /> + + + + + ); + }; + + const renderKnowledgeBaseSelectionStep = () => { + const columns: IColumn[] = [ + { + key: 'column2', + name: 'Name', + fieldName: 'name', + minWidth: 50, + maxWidth: 350, + isRowHeader: true, + isResizable: true, + isSorted: true, + isSortedDescending: false, + sortAscendingAriaLabel: 'Sorted A to Z', + sortDescendingAriaLabel: 'Sorted Z to A', + data: 'string', + isPadded: true, + }, + { + key: 'column3', + name: 'Last modified', + fieldName: 'lastModified', + minWidth: 200, + maxWidth: 300, + isResizable: true, + data: 'string', + isPadded: true, + onRender: (item) => { + const dt = new Date(item.lastChangedTimestamp); + return ( + + {' '} + {dt.toDateString()} {dt.toLocaleTimeString()}{' '} + + ); + }, + }, + { + key: 'column4', + name: 'Language', + fieldName: 'language', + minWidth: 100, + maxWidth: 200, + isResizable: true, + isCollapsible: true, + data: 'string', + isPadded: true, + }, + ]; + + const currentLanguageKbs: KBRec[] = []; + const defaultLanuageKbs: KBRec[] = []; + const avaliableLanguageKbs: KBRec[] = []; + const disabledLanguageKbs: KBRec[] = []; + + const currentKbs = key ? kbs[key.name] : []; + currentKbs.forEach((item) => { + if (item.language === currentAuthoringLanuage) { + currentLanguageKbs.push(item); + } else if (item.language === defaultLanuage) { + defaultLanuageKbs.push(item); + } else if (avaliableLanguages.includes(item.language)) { + avaliableLanguageKbs.push(item); + } else { + disabledLanguageKbs.push(item); + } + }); + + const sortedKbs = [...currentLanguageKbs, ...defaultLanuageKbs, ...avaliableLanguageKbs, ...disabledLanguageKbs]; + + const onRenderRow: IRenderFunction = (props) => { + if (!props) return null; + if (avaliableLanguages.includes(props.item.language)) { + return ; + } else { + return ( + + + + ); + } + }; + + return ( + + + + {formatMessage('Select one or more knowledge base to import into your bot project')} + + + item.name} + items={sortedKbs} + layoutMode={DetailsListLayoutMode.justified} + selection={selectedKB} + selectionMode={SelectionMode.single} + onRenderRow={onRenderRow} + /> + + + + {loading && } + setCurrentStep('resource')} /> + + + + + ); + }; + + const renderCurrentStep = () => { + switch (currentStep) { + case 'name': + return renderNameStep(); + case 'intro': + return renderIntroStep(); + case 'resource': { + if (nextAction === 'portal') { + return renderChooseResourceStep(); + } + break; + } + case 'knowledge-base': + return renderKnowledgeBaseSelectionStep(); + default: + return null; + } + }; + + useEffect(() => { + switch (currentStep) { + case 'name': + setDialogTitle(formatMessage('Add QnA Maker knowledge base')); + break; + case 'intro': + setDialogTitle(formatMessage(`Select a source for your knowledge base's content`)); + break; + case 'resource': + if (nextAction === 'portal') { + setDialogTitle(formatMessage('Select source knowledge base location')); + } + break; + case 'knowledge-base': + setSelectedKb(undefined); + setDialogTitle(formatMessage('Choose a knowledge base to import')); + break; + } + }, [currentStep]); + + const handleDismiss = () => { + onDismiss?.(); + setInitialName(''); + actions.createQnADialogCancel({ projectId }); + TelemetryClient.track('AddNewKnowledgeBaseCanceled'); + }; + + const onFormDataChange = (data, disabled) => { + setFormData(data); + setDisabled(disabled); + }; + + const onSubmitFormData = (createFrom: string) => { + if (!formData) return; + if (createFrom === 'url' && disabled) return; + + onSubmit(formData); + setInitialName(''); + TelemetryClient.track('AddNewKnowledgeBaseCompleted', { source: formData.urls?.length ? 'url' : 'none' }); + }; + + const onSubmitImportKB = async () => { + if (key && isAuthenticated && selectedKb && formData) { + // TODO: add to all matched language or ask user for specific locale. + const createdOnLocales = locales.filter((item) => localeToLanguage(item) === selectedKb.language); + onSubmit({ + ...formData, + locales: createdOnLocales, + endpoint: key.endpoint, + kbId: selectedKb.id, + kbName: selectedKb.name, + subscriptionKey: key.key, + }); + setInitialName(''); + TelemetryClient.track('AddNewKnowledgeBaseCompleted', { source: 'kb' }); + } + }; + + return ( + + {} : handleDismiss} + > + {renderCurrentStep()} + + + ); +}; + +export default CreateQnAModal; diff --git a/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx b/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx index d1f39a0090..30161fba45 100644 --- a/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx +++ b/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx @@ -40,7 +40,7 @@ const formConfig: FieldConfig = { }; const DialogTitle = () => { - return {formatMessage('Edit KB name')}; + return {formatMessage('Edit knowledge base name')}; }; export const EditQnAFromScratchModal: React.FC = (props) => { diff --git a/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx index d787ad230b..0012a524f5 100644 --- a/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx +++ b/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx @@ -46,7 +46,7 @@ const formConfig: FieldConfig = { }; const DialogTitle = () => { - return {formatMessage('Edit KB name')}; + return {formatMessage('Edit knowledge base name')}; }; export const EditQnAFromUrlModal: React.FC = (props) => { diff --git a/Composer/packages/client/src/components/QnA/ImportQnAFromUrl.tsx b/Composer/packages/client/src/components/QnA/ImportQnAFromUrl.tsx new file mode 100644 index 0000000000..179fdd5778 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/ImportQnAFromUrl.tsx @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useEffect } from 'react'; +import formatMessage from 'format-message'; +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 { QnAFile } from '@bfc/shared'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { getQnAFileUrlOption, getQnAFileMultiTurnOption } from '../../utils/qnaUtil'; +import { getExtension } from '../../utils/fileUtil'; +import { Locales } from '../../locales'; + +import { validateUrl } from './constants'; +import { header, titleStyle, descriptionStyle, dialogWindow, textFieldKBNameFromScratch } from './styles'; + +type ImportQnAFromUrlModalProps = { + qnaFile: QnAFile; + onChange: (data, disabled: boolean) => void; +}; + +export type ImportQnAFromUrlFormData = { + url: string; + multiTurn: boolean; +}; + +const formConfig: FieldConfig = { + url: { + required: true, + defaultValue: '', + }, + multiTurn: { + defaultValue: false, + }, +}; + +const title = {formatMessage('Replace knowledge base from URL')}; + +const description = ( + + {formatMessage( + 'Select this option if you want to replace current knowledge base from content hosted online such as an FAQ or document link (.csv, .xls or .doc format)' + )} + +); + +export const ImportQnAFromUrl: React.FC = (props) => { + const { qnaFile, onChange } = props; + const locale = getExtension(qnaFile.id); + formConfig.url.validate = validateUrl; + formConfig.url.defaultValue = getQnAFileUrlOption(qnaFile); + formConfig.multiTurn.defaultValue = getQnAFileMultiTurnOption(qnaFile); + const { formData, updateField, formErrors, hasErrors } = useForm(formConfig); + + const getLanguage = (locale) => { + const index = Locales.findIndex((l) => l.locale === locale); + if (index > -1) { + return Locales[index].language; + } + }; + + const updateUrl = (url = '') => { + updateField('url', url); + }; + + const updateMultiTurn = (multiTurn = false) => { + updateField('multiTurn', multiTurn); + }; + + useEffect(() => { + onChange(formData, hasErrors); + }, [formData, hasErrors]); + + return ( + + + + {title} + {description} + + + updateUrl(url)} + /> + + + updateMultiTurn(val)} + /> + + + ); +}; + +export default ImportQnAFromUrl; diff --git a/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx index 27c10a7d90..d031e7dec8 100644 --- a/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx +++ b/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx @@ -78,7 +78,7 @@ export const ImportQnAFromUrlModal: React.FC = (prop { + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + + const { logoutUser } = useRecoilValue(dispatcherState); + + const onLogout = async () => { + const confirmed = await OpenConfirmModal( + formatMessage('Sign out of Azure'), + formatMessage( + 'By signing out of Azure, your operation will be canceled and this dialog will close. Do you want to continue?' + ), + { + onRenderContent: (subtitle: string) => {subtitle}, + confirmText: formatMessage('Sign out'), + cancelText: formatMessage('Cancel'), + } + ); + if (confirmed) { + await logoutUser(); + } + }; + + const onRenderSecondaryText: IRenderFunction = (props) => { + if (!props) return null; + return ( + + {props.secondaryText} + + ); + }; + + return isAuthenticated && currentUser ? ( + + ) : null; +}; diff --git a/Composer/packages/client/src/components/QnA/ReplaceQnAFromModal.tsx b/Composer/packages/client/src/components/QnA/ReplaceQnAFromModal.tsx new file mode 100644 index 0000000000..f623f40be5 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/ReplaceQnAFromModal.tsx @@ -0,0 +1,605 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { + DetailsList, + SelectionMode, + DetailsListLayoutMode, + IColumn, + Selection, + IDetailsRowProps, + DetailsRow, + CheckboxVisibility, +} from 'office-ui-fabric-react/lib/DetailsList'; +import { Spinner } from 'office-ui-fabric-react/lib/Spinner'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; +import { SubscriptionClient } from '@azure/arm-subscriptions'; +import { Subscription } from '@azure/arm-subscriptions/esm/models'; +import { TokenCredentials } from '@azure/ms-rest-js'; +import { CognitiveServicesManagementClient } from '@azure/arm-cognitiveservices'; +import { CognitiveServicesCredentials } from '@azure/ms-rest-azure-js'; +import { QnAMakerClient } from '@azure/cognitiveservices-qnamaker'; +import sortBy from 'lodash/sortBy'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import { IRenderFunction } from '@uifabric/utilities'; + +import TelemetryClient from '../../telemetry/TelemetryClient'; +import { getKBName, getFileLocale } from '../../utils/qnaUtil'; +import { dispatcherState, currentUserState, isAuthenticatedState, showAuthDialogState } from '../../recoilModel'; + +import { localeToLanguage } from './utilities'; +import { ReplaceQnAModalFormData, ReplaceQnAModalProps } from './constants'; +import { + styles, + subText, + contentBox, + formContainer, + choiceContainer, + titleStyle, + descriptionStyle, + resourceDropdown, + dialogBodyStyles, +} from './styles'; +import { ImportQnAFromUrl } from './ImportQnAFromUrl'; +import { PersonaCard } from './PersonaCard'; + +type KeyRec = { + name: string; + region: string; + resourceGroup: string; + key: string; + endpoint: string; +}; + +type KBRec = { + name: string; + language: string; + id: string; + lastChangedTimestamp: string; +}; + +type Step = 'intro' | 'resource' | 'knowledge-base' | 'outcome'; + +const mainElementStyle = { marginBottom: 20 }; +const serviceName = 'QnA Maker'; +const serviceKeyType = 'QnAMaker'; + +export const ReplaceQnAFromModal: React.FC = (props) => { + const { onDismiss, onSubmit, hidden, qnaFile, projectId, containerId } = props; + const actions = useRecoilValue(dispatcherState); + const [formData, setFormData] = useState(); + const [disabled, setDisabled] = useState(true); + const { setApplicationLevelError, requireUserLogin } = useRecoilValue(dispatcherState); + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + const showAuthDialog = useRecoilValue(showAuthDialogState); + + const [subscriptionId, setSubscription] = useState(''); + const [loading, setLoading] = useState(undefined); + const [noKeys, setNoKeys] = useState(false); + const [nextAction, setNextAction] = useState('url'); + const [key, setKey] = useState(); + const [region, setRegion] = useState(''); + const [availableSubscriptions, setAvailableSubscriptions] = useState([]); + const [subscriptionsErrorMessage, setSubscriptionsErrorMessage] = useState(); + const [keys, setKeys] = useState([]); + const [kbs, setKbs] = useState<{ [key: string]: KBRec[] }>({}); + + const [selectedKb, setSelectedKb] = useState(); + const [dialogTitle, setDialogTitle] = useState(''); + + const [currentStep, setCurrentStep] = useState('intro'); + + const currentLocale = getFileLocale(containerId); + const currentAuthoringLanuage = localeToLanguage(currentLocale); + + const actionOptions: IChoiceGroupOption[] = [ + { key: 'url', text: formatMessage('Replace knowledge base from URL') }, + { + key: 'portal', + text: formatMessage('Replace with an existing knowledge base from QnA maker portal'), + }, + ]; + + /* Copied from Azure Publishing extension */ + const getSubscriptions = async (token: string): Promise> => { + const tokenCredentials = new TokenCredentials(token); + try { + const subscriptionClient = new SubscriptionClient(tokenCredentials); + const subscriptionsResult = await subscriptionClient.subscriptions.list(); + // eslint-disable-next-line no-underscore-dangle + return sortBy(subscriptionsResult._response.parsedBody, ['displayName']); + } catch (err) { + setApplicationLevelError(err); + return []; + } + }; + + const selectedKB = useMemo(() => { + return new Selection({ + onSelectionChanged: () => { + const t = selectedKB.getSelection()[0] as KBRec; + + if (t) { + setSelectedKb(t); + } + }, + }); + }, []); + + useEffect(() => { + if (isAuthenticated && !hidden) { + setAvailableSubscriptions([]); + setSubscriptionsErrorMessage(undefined); + getSubscriptions(currentUser.token) + .then((data) => { + setAvailableSubscriptions(data); + if (data.length === 0) { + setSubscriptionsErrorMessage( + formatMessage( + 'Your subscription list is empty, please add your subscription, or login with another account.' + ) + ); + } + }) + .catch((err) => { + setSubscriptionsErrorMessage(err.message); + }); + } + }, [currentUser, isAuthenticated, hidden]); + + useEffect(() => { + // reset the ui + if (!hidden) { + setSubscription(''); + setKeys([]); + setCurrentStep('intro'); + } + }, [hidden]); + + const fetchKeys = async (cognitiveServicesManagementClient, accounts) => { + const keyList: KeyRec[] = []; + for (const account in accounts) { + const resourceGroup = accounts[account].id?.replace(/.*?\/resourceGroups\/(.*?)\/.*/, '$1'); + const name = accounts[account].name; + if (resourceGroup && name) { + try { + const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); + if (keys?.key1) { + keyList.push({ + name: name, + resourceGroup: resourceGroup, + region: accounts[account].location || '', + key: keys?.key1 || '', + endpoint: accounts[account]?.properties?.endpoint ?? '', + }); + } + } catch (_err) { + // pass, filter no authorization resource + } + } + } + return keyList; + }; + + const fetchAccounts = async (subscriptionId) => { + if (isAuthenticated) { + setLoading(formatMessage('Loading keys...')); + setNoKeys(false); + const tokenCredentials = new TokenCredentials(currentUser.token); + const cognitiveServicesManagementClient = new CognitiveServicesManagementClient(tokenCredentials, subscriptionId); + const accounts = await cognitiveServicesManagementClient.accounts.list(); + const keylist: KeyRec[] = await fetchKeys( + cognitiveServicesManagementClient, + accounts.filter((a) => a.kind === serviceKeyType) + ); + + const kbsMap = {}; + const avaliableKeys: KeyRec[] = []; + for (const keyItem of keylist) { + const kbGroups = await fetchKBGroups(keyItem); + kbsMap[keyItem.name] = kbGroups; + if (kbGroups.length) { + avaliableKeys.push(keyItem); + } + } + setKbs(kbsMap); + setLoading(undefined); + if (avaliableKeys.length == 0) { + setNoKeys(true); + } else { + setNoKeys(false); + setKeys(avaliableKeys); + } + } + }; + + const fetchKBGroups = async (key: KeyRec) => { + let kblist: KBRec[] = []; + if (isAuthenticated && key) { + const cognitiveServicesCredentials = new CognitiveServicesCredentials(key.key); + const resourceClient = new QnAMakerClient(cognitiveServicesCredentials, key.endpoint); + const result = await resourceClient.knowledgebase.listAll(); + if (result.knowledgebases) { + kblist = result.knowledgebases.map((item: any) => { + return { + id: item.id || '', + name: item.name || '', + language: item.language || '', + lastChangedTimestamp: item.lastChangedTimestamp || '', + }; + }); + } + } + return kblist; + }; + + // allow a user to provide a subscription id if one is missing + const onChangeSubscription = async (_, opt) => { + // get list of keys for this subscription + setSubscription(opt.key); + fetchAccounts(opt.key); + setLoading(formatMessage('Loading subscription...')); + }; + + const onChangeKey = async (_, opt) => { + // get list of keys for this subscription + setKey(opt); + setRegion(opt.region); + }; + + const onChangeAction = async (_, opt) => { + setNextAction(opt.key); + if (opt.key === 'url') { + setDisabled(true); + } else { + setDisabled(false); + } + }; + + const chooseExistingKey = () => { + TelemetryClient.track('SettingsGetKeysExistingResourceSelected', { + subscriptionId, + resourceType: serviceName, + }); + setCurrentStep('knowledge-base'); + }; + + const performNextAction = () => { + if (nextAction !== 'portal') { + onSubmitFormData(); + } else { + requireUserLogin(); + setCurrentStep('resource'); + } + }; + + const renderIntroStep = () => { + return ( + + + + {formatMessage('Create a knowledge base from a URL or import content from an existing knowledge base')} + + + + + + + + {nextAction === 'url' ? ( + qnaFile && + ) : ( + +
+ {children} +
+
{formatMessage.rich('Connecting to { source } to import bot content...', { b: BoldBlue, source: getUserFriendlySource(source), diff --git a/Composer/packages/client/src/components/ManageService/ManageService.tsx b/Composer/packages/client/src/components/ManageService/ManageService.tsx index 34a9867b27..e5520c9a57 100644 --- a/Composer/packages/client/src/components/ManageService/ManageService.tsx +++ b/Composer/packages/client/src/components/ManageService/ManageService.tsx @@ -22,14 +22,9 @@ import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/Choi import { ProvisionHandoff } from '@bfc/ui-shared'; import sortBy from 'lodash/sortBy'; import { NeutralColors } from '@uifabric/fluent-theme'; -import { AzureTenant } from '@botframework-composer/types'; -import jwtDecode from 'jwt-decode'; import TelemetryClient from '../../telemetry/TelemetryClient'; -import { AuthClient } from '../../utils/authClient'; -import { AuthDialog } from '../../components/Auth/AuthDialog'; -import { getTokenFromCache, isShowAuthDialog, userShouldProvideTokens } from '../../utils/auth'; -import { dispatcherState } from '../../recoilModel/atoms'; +import { dispatcherState, currentUserState, isAuthenticatedState, showAuthDialogState } from '../../recoilModel/atoms'; type ManageServiceProps = { createService: ( @@ -72,21 +67,19 @@ const mainElementStyle = { marginBottom: 20 }; const dialogBodyStyles = { height: 400 }; const CREATE_NEW_KEY = 'CREATE_NEW'; -export const ManageService = (props: ManageServiceProps) => { - const [showAuthDialog, setShowAuthDialog] = useState(false); - const [token, setToken] = useState(); +export const ManageService: React.FC = (props: ManageServiceProps) => { + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + const showAuthDialog = useRecoilValue(showAuthDialogState); - const { setApplicationLevelError } = useRecoilValue(dispatcherState); + const { setApplicationLevelError, requireUserLogin } = useRecoilValue(dispatcherState); const [subscriptionId, setSubscription] = useState(''); - const [tenantId, setTenantId] = useState(''); const [resourceGroups, setResourceGroups] = useState([]); const [createResourceGroup, setCreateResourceGroup] = useState(false); const [newResourceGroupName, setNewResourceGroupName] = useState(''); const [resourceGroupKey, setResourceGroupKey] = useState(''); const [resourceGroup, setResourceGroup] = useState(''); const [tier, setTier] = useState(''); - const [allTenants, setAllTenants] = useState([]); - const [tenantsErrorMessage, setTenantsErrorMessage] = useState(undefined); const [showHandoff, setShowHandoff] = useState(false); const [resourceName, setResourceName] = useState(''); const [loading, setLoading] = useState(undefined); @@ -99,7 +92,6 @@ export const ManageService = (props: ManageServiceProps) => { const [keys, setKeys] = useState([]); const [dialogTitle, setDialogTitle] = useState(''); - const [userProvidedTokens, setUserProvidedTokens] = useState(false); const [currentStep, setCurrentStep] = useState('intro'); const [outcomeDescription, setOutcomeDescription] = useState(''); const [outcomeSummary, setOutcomeSummary] = useState(); @@ -116,8 +108,8 @@ export const ManageService = (props: ManageServiceProps) => { ]; const fetchLocations = async (subscriptionId) => { - if (token) { - const tokenCredentials = new TokenCredentials(token); + if (isAuthenticated) { + const tokenCredentials = new TokenCredentials(currentUser.token); const subscriptionClient = new SubscriptionClient(tokenCredentials); const locations = await subscriptionClient.subscriptions.listLocations(subscriptionId); setLocationList( @@ -141,64 +133,12 @@ export const ManageService = (props: ManageServiceProps) => { return []; } }; - const decodeToken = (token: string) => { - try { - return jwtDecode(token); - } catch (err) { - console.error('decode token error in ', err); - return null; - } - }; - - useEffect(() => { - if (currentStep === 'subscription' && !userShouldProvideTokens()) { - AuthClient.getTenants() - .then((tenants) => { - setAllTenants(tenants); - if (tenants.length === 0) { - setTenantsErrorMessage(formatMessage('No Azure Directories were found.')); - } else if (tenants.length >= 1) { - setTenantId(tenants[0].tenantId); - } else { - setTenantsErrorMessage(undefined); - } - }) - .catch((err) => { - setTenantsErrorMessage( - formatMessage('There was a problem loading Azure directories. {errMessage}', { - errMessage: err.message || err.toString(), - }) - ); - }); - } - }, [currentStep]); - - useEffect(() => { - if (tenantId) { - AuthClient.getARMTokenForTenant(tenantId) - .then((token) => { - setToken(token); - setTenantsErrorMessage(undefined); - }) - .catch((err) => { - setTenantsErrorMessage( - formatMessage( - 'There was a problem getting the access token for the current Azure directory. {errMessage}', - { - errMessage: err.message || err.toString(), - } - ) - ); - setTenantsErrorMessage(err.message || err.toString()); - }); - } - }, [tenantId]); useEffect(() => { - if (token) { + if (isAuthenticated && !props.hidden) { setAvailableSubscriptions([]); setSubscriptionsErrorMessage(undefined); - getSubscriptions(token) + getSubscriptions(currentUser.token) .then((data) => { setAvailableSubscriptions(data); if (data.length === 0) { @@ -213,33 +153,7 @@ export const ManageService = (props: ManageServiceProps) => { setSubscriptionsErrorMessage(err.message); }); } - }, [token]); - - const hasAuth = async () => { - let newtoken = ''; - if (userShouldProvideTokens()) { - if (isShowAuthDialog(false)) { - setShowAuthDialog(true); - } - newtoken = getTokenFromCache('accessToken'); - if (newtoken) { - const decoded = decodeToken(newtoken); - if (decoded) { - setToken(newtoken); - setUserProvidedTokens(true); - } else { - setTenantsErrorMessage( - formatMessage( - 'There was a problem with the authentication access token. Close this dialog and try again. To be prompted to provide the access token again, clear it from application local storage.' - ) - ); - } - } - } else { - setUserProvidedTokens(false); - } - setCurrentStep('subscription'); - }; + }, [isAuthenticated, props.hidden]); useEffect(() => { // reset the ui @@ -270,14 +184,18 @@ export const ManageService = (props: ManageServiceProps) => { const resourceGroup = accounts[account].id?.replace(/.*?\/resourceGroups\/(.*?)\/.*/, '$1'); const name = accounts[account].name; if (resourceGroup && name) { - const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); - if (keys?.key1) { - keyList.push({ - name: name, - resourceGroup: resourceGroup, - region: accounts[account].location || '', - key: keys?.key1 || '', - }); + try { + const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); + if (keys?.key1) { + keyList.push({ + name, + resourceGroup, + region: accounts[account].location || '', + key: keys?.key1 || '', + }); + } + } catch (_err) { + // pass, filter no authorization resource } } } @@ -285,10 +203,10 @@ export const ManageService = (props: ManageServiceProps) => { }; const fetchAccounts = async (subscriptionId) => { - if (token) { + if (isAuthenticated) { setLoading(formatMessage('Loading keys...')); setNoKeys(false); - const tokenCredentials = new TokenCredentials(token); + const tokenCredentials = new TokenCredentials(currentUser.token); const cognitiveServicesManagementClient = new CognitiveServicesManagementClient(tokenCredentials, subscriptionId); const accounts = await cognitiveServicesManagementClient.accounts.list(); @@ -307,11 +225,11 @@ export const ManageService = (props: ManageServiceProps) => { }; const fetchResourceGroups = async (subscriptionId) => { - if (token) { - const tokenCredentials = new TokenCredentials(token); + if (isAuthenticated) { + const tokenCredentials = new TokenCredentials(currentUser.token); const resourceClient = new ResourceManagementClient(tokenCredentials, subscriptionId); - const groups = sortBy(await resourceClient.resourceGroups.list(), ['name']); - + const results = await resourceClient.resourceGroups.list(); + const groups = sortBy(results, ['name']); setResourceGroups([ { id: CREATE_NEW_KEY, @@ -324,10 +242,10 @@ export const ManageService = (props: ManageServiceProps) => { }; const createService = async () => { - if (token) { + if (isAuthenticated) { setLoading(formatMessage('Creating resources...')); - const tokenCredentials = new TokenCredentials(token); + const tokenCredentials = new TokenCredentials(currentUser.token); const resourceGroupName = resourceGroupKey === CREATE_NEW_KEY ? newResourceGroupName : resourceGroup; if (resourceGroupKey === CREATE_NEW_KEY) { @@ -437,8 +355,9 @@ export const ManageService = (props: ManageServiceProps) => { const onChangeSubscription = async (_, opt) => { // get list of keys for this subscription setSubscription(opt.key); - fetchAccounts(opt.key); setLoading(formatMessage('Loading subscription...')); + fetchAccounts(opt.key); + // if we don't have a list of regions already passed in if (!props.regions) { fetchLocations(opt.key); @@ -508,7 +427,8 @@ export const ManageService = (props: ManageServiceProps) => { setShowHandoff(true); props.onDismiss(); } else { - hasAuth(); + requireUserLogin(); + setCurrentStep('subscription'); } }; @@ -531,7 +451,7 @@ export const ManageService = (props: ManageServiceProps) => { {props.introText} {props.learnMore ? ( - + {formatMessage('Learn more')} ) : null} @@ -557,10 +477,9 @@ export const ManageService = (props: ManageServiceProps) => { - {formatMessage( - 'Select your Azure directory, then choose the subscription where your existing {service} resource is located.', - { service: props.serviceName } - )} + {formatMessage('Choose the subscription where your existing {service} resource is located.', { + service: props.serviceName, + })} {props.learnMore ? ( {formatMessage('Learn more')} @@ -570,18 +489,7 @@ export const ManageService = (props: ManageServiceProps) => { ({ key: t.tenantId, text: t.displayName }))} - selectedKey={tenantId} - styles={dropdownStyles} - onChange={(_e, o) => { - setTenantId(o?.key as string); - }} - /> - 0)} errorMessage={subscriptionsErrorMessage} label={formatMessage('Azure subscription')} @@ -612,6 +520,7 @@ export const ManageService = (props: ManageServiceProps) => { {!noKeys && subscriptionId && ( 0) || nextAction !== 'choose'} label={formatMessage('{service} resource name', { service: props.serviceName })} options={ @@ -653,6 +562,7 @@ export const ManageService = (props: ManageServiceProps) => { { placeholder={formatMessage('Enter name for new resources')} styles={inputStyles} value={resourceName} - onChange={(e, val) => setResourceName(val || '')} + onChange={(e, val) => { + setResourceName(val || ''); + }} /> {props.tiers && ( { - {formatMessage( - 'Select your Azure directory, then choose the subscription where you’d like your new {service} resource.', - { service: props.serviceName } - )} + {formatMessage('Choose the subscription where you’d like your new {service} resource.', { + service: props.serviceName, + })} {props.learnMore ? ( {formatMessage('Learn more')} @@ -787,18 +698,7 @@ export const ManageService = (props: ManageServiceProps) => { ({ key: t.tenantId, text: t.displayName }))} - selectedKey={tenantId} - styles={dropdownStyles} - onChange={(_e, o) => { - setTenantId(o?.key as string); - }} - /> - { {loading && } setCurrentStep('intro')} /> setCurrentStep('resourceCreation')} /> @@ -869,15 +769,6 @@ export const ManageService = (props: ManageServiceProps) => { return ( - {showAuthDialog && ( - { - setShowAuthDialog(false); - }} - /> - )} css` background-color: transparent; `} - ${disabled - ? `pointer-events: none;` - : `&:hover { + ${!disabled + ? `&:hover { background-color: ${NeutralColors.gray50}; } @@ -53,8 +52,8 @@ const link = (active: boolean, disabled: boolean) => css` border-image: initial; outline: rgb(102, 102, 102) solid 1px; } - } - `} + }` + : ''} `; const icon = (active: boolean, disabled: boolean) => diff --git a/Composer/packages/client/src/components/NotFound.jsx b/Composer/packages/client/src/components/NotFound.tsx similarity index 100% rename from Composer/packages/client/src/components/NotFound.jsx rename to Composer/packages/client/src/components/NotFound.tsx diff --git a/Composer/packages/client/src/components/Notifications/NotificationButton.tsx b/Composer/packages/client/src/components/Notifications/NotificationButton.tsx index ab3c0bf539..78eba6c93a 100644 --- a/Composer/packages/client/src/components/Notifications/NotificationButton.tsx +++ b/Composer/packages/client/src/components/Notifications/NotificationButton.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import { FontWeights } from '@uifabric/styling'; import { IButtonStyles, IconButton } from 'office-ui-fabric-react/lib/Button'; import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; +import { TooltipHost, DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; import { useRecoilValue } from 'recoil'; import formatMessage from 'format-message'; @@ -54,20 +55,19 @@ const NotificationButton: React.FC = ({ buttonStyles }) setIsOpen(!isOpen); }; + const label = formatMessage('Open notification panel'); + return ( - - - - {unreadNotification.length} + + + + + {unreadNotification.length} + - - + + () => { border-left: 4px solid #0078d4; background: white; box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108); - min-width: 340px; + width: 340px; border-radius: 2px; display: flex; flex-direction: column; @@ -68,21 +69,38 @@ const cardDetail = css` flex-grow: 1; `; +const iconMargin = '4px'; + +// Error Block Icon from Messaging Colors const errorType = css` - margin-top: 4px; + margin-top: ${iconMargin}; color: #a80000; `; +// Success Icon from Messaging Colors const successType = css` - margin-top: 4px; - color: #27ae60; + margin-top: ${iconMargin}; + color: #107c10; `; +// #fce100 const warningType = css` - margin-top: 4px; + margin-top: ${iconMargin}; color: ${SharedColors.yellow10}; `; +// #0078d4 +const questionType = css` + margin-top: ${iconMargin}; + color: ${SharedColors.cyanBlue10}; +`; + +// #c19c00 +const congratulationType = css` + margin-top: ${iconMargin}; + color: ${SharedColors.orangeYellow10}; +`; + const cardTitle = css` font-size: ${FontSizes.size16}; lint-height: 22px; @@ -97,13 +115,20 @@ const cardDescription = css` word-break: break-word; `; -const linkButton = css` - color: #0078d4; - float: right; - font-size: 12px; - height: auto; - margin-right: 8px; -`; +const linkButton = { + root: { + padding: '0', + border: '0', + }, + label: { + fontSize: '12px', + color: SharedColors.cyanBlue10, + margin: '0', + }, + textContainer: { + height: '16px', + }, +}; const getShimmerStyles = { root: { @@ -132,21 +157,59 @@ export type NotificationProps = { onHide?: (id: string) => void; }; +const makeLinkLabel = ({ label, onClick }: NotificationLink) => ( + + {label} + +); + const defaultCardContentRenderer = (props: CardProps) => { - const { title, description, type, link } = props; + const { title, description, type, link, links, leftLinks, rightLinks } = props; + + const rightLinkList = rightLinks ?? links ?? [link]; + const leftLinkList = leftLinks ?? []; + + const stackProps: IStackProps = { + horizontal: true, + horizontalAlign: 'space-between', + tokens: { + childrenGap: '20px', + padding: '0 16px 0 0', + maxHeight: '24px', + }, + }; + return ( {type === 'error' && } {type === 'success' && } {type === 'warning' && } + {type === 'question' && } + {type === 'congratulation' && } + {type === 'custom' && ( + + )} {title} {description && {description}} - {link && ( - - {link.label} - - )} + + + {leftLinkList.map((link) => ( + {makeLinkLabel(link)} + ))} + + + {rightLinkList.map( + (link) => link != null && {makeLinkLabel(link)} + )} + + {type === 'pending' && ( )} @@ -196,7 +259,13 @@ export const NotificationCard = React.memo((props: NotificationProps) => { ariaLabel={formatMessage('Close')} css={cancelButton} iconProps={{ iconName: 'Cancel', styles: { root: { fontSize: '12px' } } }} - onClick={() => onDismiss(id)} + onClick={() => { + // This lets us add custom actions to closing a card if we want to. + // For instance, telemetry to track when the user dismisses a specific + // type of card. + cardProps?.onDismiss?.(id); + onDismiss(id); + }} /> {renderCard(cardProps)} diff --git a/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx b/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx index 6d3f0e7e87..7138769bf5 100644 --- a/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx +++ b/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx @@ -2,47 +2,30 @@ // Licensed under the MIT License. /** @jsx jsx */ -import { jsx, css } from '@emotion/core'; -import React from 'react'; +import { CopyableText } from '@bfc/ui-shared'; +import { css, jsx } from '@emotion/core'; +import { FontSizes } from '@uifabric/fluent-theme'; +import { FontWeights } from '@uifabric/styling'; import formatMessage from 'format-message'; -import { IconButton, IButtonStyles } from 'office-ui-fabric-react/lib/Button'; -import { NeutralColors, FontSizes, FluentTheme } from '@uifabric/fluent-theme'; import { Link } from 'office-ui-fabric-react/lib/Link'; -import { FontWeights } from '@uifabric/styling'; +import React from 'react'; -import { platform, OS } from '../../utils/os'; +import { OS, platform } from '../../utils/os'; import { CardProps } from './NotificationCard'; const container = css` - padding: 0 16px 16px 40px; - position: relative; -`; - -const commandContainer = css` - display: flex; - flex-flow: row nowrap; + padding: 0 8px 16px 12px; position: relative; - padding: 4px 28px 4px 8px; - background-color: ${NeutralColors.gray20}; - line-height: 22px; - margin: 1rem 0; `; -const copyContainer = css` +const header = css` margin: 0; margin-bottom: 4px; font-size: ${FontSizes.size16}; font-weight: ${FontWeights.semibold}; `; -const copyIconColor = FluentTheme.palette.themeDark; -const copyIconStyles: IButtonStyles = { - root: { position: 'absolute', right: 0, color: copyIconColor, height: '22px' }, - rootHovered: { backgroundColor: 'transparent', color: copyIconColor }, - rootPressed: { backgroundColor: 'transparent', color: copyIconColor }, -}; - const linkContainer = css` margin: 0; `; @@ -61,18 +44,9 @@ export const TunnelingSetupNotification: React.FC = (props) => { const port = data?.port; const command = `${getNgrok()} http ${port} --host-header=localhost`; - const copyLocationToClipboard = async () => { - try { - await window.navigator.clipboard.writeText(command); - } catch (e) { - // eslint-disable-next-line no-console - console.error('Something went wrong when trying to copy the command to clipboard.', e); - } - }; - return ( - {title} + {title} {formatMessage.rich('Install ngrok and run the following command to continue', { a: ({ children }) => ( @@ -82,16 +56,11 @@ export const TunnelingSetupNotification: React.FC = (props) => { ), })} - - {command} - - + { describe('', () => { it('should render the NotificationCard', () => { const cardProps: CardProps = { - title: 'There was error creating your KB', + title: 'There was error creating your knowledge base', description: 'error', retentionTime: 1, type: 'error', @@ -29,12 +29,12 @@ describe('', () => { ); - expect(container).toHaveTextContent('There was error creating your KB'); + expect(container).toHaveTextContent('There was error creating your knowledge base'); }); it('should render the customized card', () => { const cardProps: CardProps = { - title: 'There was error creating your KB', + title: 'There was error creating your knowledge base', description: 'error', retentionTime: 5000, type: 'error', diff --git a/Composer/packages/client/src/components/Notifications/__tests__/useSurveyNotification.test.tsx b/Composer/packages/client/src/components/Notifications/__tests__/useSurveyNotification.test.tsx new file mode 100644 index 0000000000..a256342f16 --- /dev/null +++ b/Composer/packages/client/src/components/Notifications/__tests__/useSurveyNotification.test.tsx @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; + +import { renderWithRecoil } from '../../../../__tests__/testUtils'; +import { NotificationContainer } from '../NotificationContainer'; +import { useSurveyNotification } from '../useSurveyNotification'; +import { machineInfoState } from '../../../recoilModel'; +import { ClientStorage } from '../../../utils/storage'; +import { LAST_SURVEY_KEY } from '../../../constants'; + +let savedVersion: string | undefined = ''; +const MOCK_VERSION = '2.3.4_jest'; + +let surveyStorage: ClientStorage; + +beforeAll(() => { + process.env.NODE_ENV = 'jest'; + savedVersion = process.env.COMPOSER_VERSION; + process.env.COMPOSER_VERSION = MOCK_VERSION; + + surveyStorage = new ClientStorage(window.localStorage, 'survey'); +}); + +afterAll(() => { + process.env.NODE_ENV = 'test'; + process.env.COMPOSER_VERSION = savedVersion; +}); + +describe('useSurveyNotification', () => { + const id = 'machineID12345'; + const os = 'TestOS'; + + const mockOpen = jest.fn(); + + const initRecoilState = ({ set }) => { + set(machineInfoState, { os, id }); + }; + + window.open = mockOpen; + + const TestHarness = () => { + useSurveyNotification(); + return ; + }; + + describe('building the URL', () => { + beforeEach(() => { + surveyStorage.set('optedOut', false); + surveyStorage.set('days', 12345); + surveyStorage.set(LAST_SURVEY_KEY, null); + }); + + it('builds a URL given parameters', async () => { + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = await page.findByText('Take survey'); + surveyButton.click(); + + // We know these should all occur, but we don't care about the order + const patterns = [ + 'https://aka.ms/bfcomposersurvey', + 'Source=Composer', + `machineId=${id}`, + `os=${os}`, + `version=${MOCK_VERSION}`, + ]; + + for (const pattern of patterns) { + expect(mockOpen).toHaveBeenCalledWith(expect.stringContaining(pattern), '_blank'); + } + }); + + it('builds a URL given no OS', async () => { + const newRecoilState = ({ set }) => { + set(machineInfoState, { os: null, id }); + }; + + const page = renderWithRecoil(, newRecoilState); + + const surveyButton = await page.findByText('Take survey'); + surveyButton.click(); + + const patterns = [ + 'https://aka.ms/bfcomposersurvey', + 'Source=Composer', + `machineId=${id}`, + `os=Unknown`, + `version=${MOCK_VERSION}`, + ]; + + for (const pattern of patterns) { + expect(mockOpen).toHaveBeenCalledWith(expect.stringContaining(pattern), '_blank'); + } + }); + }); + + describe('determining eligibility', () => { + beforeEach(() => { + surveyStorage.set('optedOut', false); + surveyStorage.set('days', 12345); + surveyStorage.set(LAST_SURVEY_KEY, null); + }); + + it('shows the box under normal conditions', async () => { + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = await page.findByText('Take survey'); + expect(surveyButton).not.toBeNull(); + }); + + it("doesn't show the box when the user has opted out", () => { + surveyStorage.set('optedOut', true); + + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = page.queryByText('Take survey'); + expect(surveyButton).toBeNull(); + }); + + it("doesn't show the box when the user hasn't spent enough days with Composer", () => { + // logically impossible, but makes a good test case + surveyStorage.set('days', -1); + + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = page.queryByText('Take survey'); + expect(surveyButton).toBeNull(); + }); + + it("returns false when it hasn't been long enough since the last survey", () => { + // also logically impossible, but makes a good test case + surveyStorage.set(LAST_SURVEY_KEY, Date.now() + 10000); + + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = page.queryByText('Take survey'); + expect(surveyButton).toBeNull(); + }); + }); +}); diff --git a/Composer/packages/client/src/components/Notifications/useSurveyNotification.ts b/Composer/packages/client/src/components/Notifications/useSurveyNotification.ts new file mode 100644 index 0000000000..edb8a8ebd6 --- /dev/null +++ b/Composer/packages/client/src/components/Notifications/useSurveyNotification.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import querystring from 'query-string'; + +import { ClientStorage } from '../../utils/storage'; +import { dispatcherState, machineInfoState } from '../../recoilModel/atoms/appState'; +import { MachineInfo } from '../../recoilModel/types'; +import { LAST_SURVEY_KEY, SURVEY_URL_BASE, SURVEY_PARAMETERS } from '../../constants'; +import TelemetryClient from '../../telemetry/TelemetryClient'; + +const buildUrl = (info: MachineInfo) => { + // User OS + // hashed machineId + // composer version + // maybe include subscription ID; wait for global sign-in feature + // session ID (global telemetry GUID) + const version = process.env.COMPOSER_VERSION; + + const parameters = { + Source: 'Composer', + os: info?.os || 'Unknown', + machineId: info?.id, + version, + }; + + return `${SURVEY_URL_BASE}?${querystring.stringify(parameters)}`; +}; + +const getSurveyEligibility = () => { + const surveyStorage = new ClientStorage(window.localStorage, 'survey'); + + const optedOut = surveyStorage.get('optedOut', false); + + if (optedOut) { + return false; + } + + let days = surveyStorage.get('days', 0); + const lastUsed = surveyStorage.get('dateLastUsed', null); + const lastTaken = surveyStorage.get(LAST_SURVEY_KEY, null); + const today = new Date().toDateString(); + if (lastUsed !== today) { + days += 1; + surveyStorage.set('days', days); + } + surveyStorage.set('dateLastUsed', today); + + if ( + // To be eligible for the survey, the user needs to have used Composer + // some minimum number of days. + days >= SURVEY_PARAMETERS.daysUntilEligible && + // Also, either the user must have never taken the survey before or + // the last time they took it must be long enough in the past. + (lastTaken == null || Date.now() - lastTaken > SURVEY_PARAMETERS.timeUntilNextSurvey) + ) { + // If the above conditions are true, there's a fixed chance the card will appear. + return process.env.NODE_ENV === 'jest' || Math.random() < SURVEY_PARAMETERS.chanceToAppear; + } else { + return false; + } +}; + +export const useSurveyNotification = () => { + const { addNotification, deleteNotification } = useRecoilValue(dispatcherState); + const machineInfo = useRecoilValue(machineInfoState); + + useEffect(() => { + const url = buildUrl(machineInfo); + deleteNotification('survey'); + + if (getSurveyEligibility()) { + const surveyStorage = new ClientStorage(window.localStorage, 'survey'); + + TelemetryClient.track('HATSSurveyOffered'); + + addNotification({ + id: 'survey', + type: 'question', + title: formatMessage('Would you mind taking a quick survey?'), + description: formatMessage('We read every response and will use your feedback to improve Composer.'), + leftLinks: [ + { + label: formatMessage('Take survey'), + onClick: () => { + // This is safe; we control the URL that gets built + // eslint-disable-next-line security/detect-non-literal-fs-filename + window.open(url, '_blank'); + surveyStorage.set(LAST_SURVEY_KEY, Date.now()); + TelemetryClient.track('HATSSurveyAccepted'); + deleteNotification('survey'); + }, + }, + + { + // this is functionally identical to clicking the close box + label: formatMessage('Remind me later'), + onClick: () => { + TelemetryClient.track('HATSSurveyDismissed'); + deleteNotification('survey'); + }, + }, + ], + rightLinks: [ + { + label: formatMessage('No thanks'), + onClick: () => { + TelemetryClient.track('HATSSurveyRejected'); + surveyStorage.set('optedOut', true); + deleteNotification('survey'); + }, + }, + ], + onDismiss: () => TelemetryClient.track('HATSSurveyDismissed'), + }); + } + }, []); +}; diff --git a/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx b/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx index 0ab13292bc..9fb623adab 100644 --- a/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx +++ b/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx @@ -4,7 +4,7 @@ import { DialogTypes, DialogWrapper } from '@bfc/ui-shared/lib/components/DialogWrapper'; import { SDKKinds } from '@botframework-composer/types'; import { Button } from 'office-ui-fabric-react/lib/components/Button/Button'; -import React, { useMemo } from 'react'; +import React, { useMemo, useEffect } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { enableOrchestratorDialog } from '../../constants'; @@ -14,9 +14,11 @@ import { localeState, orchestratorForSkillsDialogState, rootBotProjectIdSelector, + settingsState, } from '../../recoilModel'; import { recognizersSelectorFamily } from '../../recoilModel/selectors/recognizers'; import { EnableOrchestrator } from '../AddRemoteSkillModal/EnableOrchestrator'; +import { canImportOrchestrator } from '../AddRemoteSkillModal/helper'; export const OrchestratorForSkillsDialog = () => { const [showOrchestratorDialog, setShowOrchestratorDialog] = useRecoilState(orchestratorForSkillsDialogState); @@ -24,6 +26,7 @@ export const OrchestratorForSkillsDialog = () => { const { dialogId } = useRecoilValue(designPageLocationState(rootProjectId)); const locale = useRecoilValue(localeState(rootProjectId)); const curRecognizers = useRecoilValue(recognizersSelectorFamily(rootProjectId)); + const setting = useRecoilValue(settingsState(rootProjectId)); const { updateRecognizer } = useRecoilValue(dispatcherState); @@ -32,6 +35,12 @@ export const OrchestratorForSkillsDialog = () => { return curRecognizers.some((f) => f.id === fileName && f.content.$kind === SDKKinds.OrchestratorRecognizer); }, [curRecognizers, dialogId, locale]); + useEffect(() => { + if (showOrchestratorDialog && hasOrchestrator) { + setShowOrchestratorDialog(false); + } + }, [hasOrchestrator, showOrchestratorDialog]); + const handleOrchestratorSubmit = async (event: React.MouseEvent, enable?: boolean) => { event.preventDefault(); if (enable) { @@ -43,7 +52,7 @@ export const OrchestratorForSkillsDialog = () => { const setVisibility = () => { if (showOrchestratorDialog) { - if (hasOrchestrator) { + if (hasOrchestrator || !canImportOrchestrator(setting?.runtime?.key)) { setShowOrchestratorDialog(false); return false; } @@ -63,7 +72,12 @@ export const OrchestratorForSkillsDialog = () => { title={enableOrchestratorDialog.title} onDismiss={onDismissHandler} > - + ); }; diff --git a/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx b/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx index bdd6ef5181..d24c4472d3 100644 --- a/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx +++ b/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { act, getQueriesForElement, within } from '@botframework-composer/test-utils'; +import { act, screen, userEvent } from '@botframework-composer/test-utils'; import { SDKKinds } from '@botframework-composer/types'; import * as React from 'react'; -import userEvent from '@testing-library/user-event'; +import { within } from '@testing-library/dom'; import { renderWithRecoil } from '../../../../__tests__/testUtils/renderWithRecoil'; import { @@ -14,12 +14,19 @@ import { localeState, orchestratorForSkillsDialogState, projectMetaDataState, + settingsState, } from '../../../recoilModel'; import { recognizersSelectorFamily } from '../../../recoilModel/selectors/recognizers'; import { OrchestratorForSkillsDialog } from '../OrchestratorForSkillsDialog'; import { importOrchestrator } from '../../AddRemoteSkillModal/helper'; -jest.mock('../../AddRemoteSkillModal/helper'); +jest.mock('../../AddRemoteSkillModal/helper', () => { + const helper = jest.requireActual('../../AddRemoteSkillModal/helper'); + return { + ...helper, + importOrchestrator: jest.fn(), + }; +}); // mimick a project setup with a rootbot and dialog files, and provide conditions for orchestrator skill dialog to be visible const makeInitialState = (set: any) => { @@ -29,6 +36,7 @@ const makeInitialState = (set: any) => { set(projectMetaDataState('rootBotId'), { isRootBot: true, isRemote: false }); set(designPageLocationState('rootBotId'), { dialogId: 'rootBotRootDialogId', focused: 'na', selected: 'na' }); set(localeState('rootBotId'), 'en-us'); + set(settingsState('rootBotId'), { runtime: { key: 'adaptive-runtime-dotnet-webapp' } }); set(recognizersSelectorFamily('rootBotId'), [ { id: 'rootBotRootDialogId.en-us.lu.dialog', content: { $kind: SDKKinds.LuisRecognizer } }, ]); @@ -42,55 +50,153 @@ describe('', () => { }); it('should not open OrchestratorForSkillsDialog if orchestratorForSkillsDialogState is false', () => { - const { baseElement } = renderWithRecoil(, ({ set }) => { + renderWithRecoil(, ({ set }) => { makeInitialState(set); set(orchestratorForSkillsDialogState, false); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeNull(); }); it('should not open OrchestratorForSkillsDialog if orchestrator already being used in root', () => { - const { baseElement } = renderWithRecoil(, ({ set }) => { + renderWithRecoil(, ({ set }) => { makeInitialState(set); set(recognizersSelectorFamily('rootBotId'), [ { id: 'rootBotRootDialogId.en-us.lu.dialog', content: { $kind: SDKKinds.OrchestratorRecognizer } }, ]); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeNull(); }); - it('open OrchestratorForSkillsDialog if orchestratorForSkillsDialogState and Orchestrator not used in Root Bot Root Dialog', () => { + it('should not render OrchestratorForSkillsDialog if runtime is not supported', () => { const { baseElement } = renderWithRecoil(, ({ set }) => { makeInitialState(set); + set(settingsState('rootBotId'), { + defaultLanguage: 'en-us', + languages: ['en-us'], + luis: { + authoringEndpoint: '', + name: '', + authoringKey: '', + defaultLanguage: '', + endpoint: '', + endpointKey: '', + environment: '', + }, + qna: { endpointKey: '', subscriptionKey: '' }, + luFeatures: { enableCompositeEntities: false }, + customFunctions: [], + importedLibraries: [], + runtime: { key: 'node-webapp-v1', command: '', path: '', customRuntime: false }, + }); + }); + const dialog = within(baseElement as HTMLElement).queryByTestId(orchestratorTestId); + expect(dialog).toBeNull(); + }); + + it('should not render OrchestratorForSkillsDialog if runtime is missing or invalid', () => { + const { baseElement } = renderWithRecoil(, ({ set }) => { + makeInitialState(set); + set(settingsState('rootBotId'), { + defaultLanguage: 'en-us', + languages: ['en-us'], + luis: { + authoringEndpoint: '', + name: '', + authoringKey: '', + defaultLanguage: '', + endpoint: '', + endpointKey: '', + environment: '', + }, + qna: { endpointKey: '', subscriptionKey: '' }, + luFeatures: { enableCompositeEntities: false }, + customFunctions: [], + importedLibraries: [], + runtime: { key: '', command: '', path: '', customRuntime: false }, + }); + }); + const dialog = within(baseElement as HTMLElement).queryByTestId(orchestratorTestId); + expect(dialog).toBeNull(); + }); + + it('open OrchestratorForSkillsDialog if orchestratorForSkillsDialogState and Orchestrator not used in Root Bot Root Dialog', () => { + renderWithRecoil(, ({ set }) => { + makeInitialState(set); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeTruthy(); }); it('should install Orchestrator package when user clicks Continue', async () => { + renderWithRecoil(, ({ set }) => { + makeInitialState(set); + }); + + await act(async () => { + userEvent.click(screen.getByTestId('import-orchestrator')); + }); + + expect(importOrchestrator).toBeCalledWith( + 'rootBotId', + { key: 'adaptive-runtime-dotnet-webapp' }, + expect.anything(), + expect.anything() + ); + }); + + it('should install Orchestrator package with adaptive node runtime', async () => { const { baseElement } = renderWithRecoil(, ({ set }) => { makeInitialState(set); + + set(settingsState('rootBotId'), { + defaultLanguage: 'en-us', + languages: ['en-us'], + luis: { + authoringEndpoint: '', + name: '', + authoringKey: '', + defaultLanguage: '', + endpoint: '', + endpointKey: '', + environment: '', + }, + qna: { endpointKey: '', subscriptionKey: '' }, + luFeatures: { enableCompositeEntities: false }, + customFunctions: [], + importedLibraries: [], + runtime: { key: 'adaptive-runtime-js-functions', command: '', path: '', customRuntime: false }, + }); }); await act(async () => { - userEvent.click(within(baseElement).getByTestId('import-orchestrator')); + userEvent.click(within(baseElement as HTMLElement).getByTestId('import-orchestrator')); }); - expect(importOrchestrator).toBeCalledWith('rootBotId', expect.anything(), expect.anything()); + expect(importOrchestrator).toBeCalledWith( + 'rootBotId', + { + key: 'adaptive-runtime-js-functions', + path: expect.anything(), + customRuntime: expect.anything(), + command: expect.anything(), + }, + expect.anything(), + expect.anything() + ); }); it('should not install Orchestrator package when user clicks skip', async () => { - const { baseElement } = renderWithRecoil(, ({ set }) => { + renderWithRecoil(, ({ set }) => { makeInitialState(set); }); await act(async () => { - userEvent.click(await within(baseElement).findByText('Skip')); + userEvent.click(await screen.findByText('Skip')); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeNull(); expect(importOrchestrator).toBeCalledTimes(0); diff --git a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx index 258d3392f7..c7a8949107 100644 --- a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx +++ b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx @@ -144,7 +144,7 @@ export const ProjectTree: React.FC = ({ onboardingAddCoachMarkRef, navigateToFormDialogSchema, setPageElementState, - createQnAFromUrlDialogBegin, + createQnADialogBegin, } = useRecoilValue(dispatcherState); const treeRef = useRef(null); @@ -233,7 +233,7 @@ export const ProjectTree: React.FC = ({ label: formatMessage('Add QnA Maker knowledge base'), icon: 'Add', onClick: () => { - createQnAFromUrlDialogBegin({ projectId: skillId, dialogId: dialog.id }); + createQnADialogBegin({ projectId: skillId, dialogId: dialog.id }); TelemetryClient.track('AddNewKnowledgeBaseStarted'); }, }; diff --git a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx index d4a575a8c2..e9120b1d8f 100644 --- a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx +++ b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx @@ -4,7 +4,7 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; import React, { useState, useCallback } from 'react'; -import { FontSizes } from '@uifabric/fluent-theme'; +import { FontSizes, FluentTheme } from '@uifabric/fluent-theme'; import { DefaultPalette } from '@uifabric/styling'; import { OverflowSet, IOverflowSetItemProps } from 'office-ui-fabric-react/lib/OverflowSet'; import { TooltipHost, DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; @@ -184,7 +184,7 @@ const statusIcon = { const warningIcon = { ...statusIcon, - color: '#BE880A', + color: FluentTheme.palette.yellow, }; const errorIcon = { diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx deleted file mode 100644 index c2f7b5d980..0000000000 --- a/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import React, { useState } from 'react'; -import { useRecoilValue } from 'recoil'; - -import { - createQnAOnState, - showCreateQnAFromScratchDialogState, - showCreateQnAFromUrlDialogState, - settingsState, -} from '../../recoilModel'; - -import CreateQnAFromScratchModal from './CreateQnAFromScratchModal'; -import CreateQnAFromUrlModal from './CreateQnAFromUrlModal'; -import { CreateQnAFromModalProps } from './constants'; - -export const CreateQnAModal: React.FC = (props) => { - const { projectId } = useRecoilValue(createQnAOnState); - const settings = useRecoilValue(settingsState(projectId)); - const locales = settings.languages; - const defaultLocale = settings.defaultLanguage; - const showCreateQnAFromScratchDialog = useRecoilValue(showCreateQnAFromScratchDialogState(projectId)); - const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId)); - const [initialName, setInitialName] = useState(''); - if (showCreateQnAFromScratchDialog) { - return ; - } else if (showCreateQnAFromUrlDialog) { - return ( - - ); - } else { - return null; - } -}; - -export default CreateQnAModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromQnAMaker.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromQnAMaker.tsx new file mode 100644 index 0000000000..f36a17d815 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromQnAMaker.tsx @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect } from 'react'; +import formatMessage from 'format-message'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { Text } from 'office-ui-fabric-react/lib/Text'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; + +import { validateName, CreateQnAFromFormProps, CreateQnAFromQnAMakerFormData } from './constants'; +import { knowledgeBaseStyle, subText } from './styles'; + +const formConfig: FieldConfig = { + name: { + required: true, + defaultValue: '', + }, +}; + +export const CreateQnAFromQnAMaker: React.FC = (props) => { + const { onChange, qnaFiles, initialName } = props; + + formConfig.name.validate = validateName(qnaFiles); + formConfig.name.defaultValue = initialName || ''; + const { formData, hasErrors } = useForm(formConfig); + + useEffect(() => { + const disabled = hasErrors || !formData.name; + onChange(formData, disabled); + }, [formData]); + + return ( + + + + {formatMessage('Import content from an existing knowledge base on the QnA maker portal')} + + + + {formatMessage( + 'Import content from an existing knowledge base on the QnA maker portal. Your knowledge base will downloaded locally and source knowledge base will remain as-is.' + )} + + + + + ); +}; + +export default CreateQnAFromQnAMaker; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromScratch.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromScratch.tsx new file mode 100644 index 0000000000..7a0c70e901 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromScratch.tsx @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect } from 'react'; +import formatMessage from 'format-message'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; + +import { validateName, CreateQnAFromFormProps, CreateQnAFromScratchFormData } from './constants'; +import { textFieldKBNameFromScratch } from './styles'; + +const formConfig: FieldConfig = { + name: { + required: true, + defaultValue: '', + }, +}; + +export const CreateQnAFromScratch: React.FC = (props) => { + const { onChange, qnaFiles, initialName, onUpdateInitialName } = props; + + formConfig.name.validate = validateName(qnaFiles); + formConfig.name.defaultValue = initialName || ''; + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + + useEffect(() => { + const disabled = hasErrors || !formData.name; + onChange(formData, disabled); + }, [formData, hasErrors]); + + return ( + + + { + updateField('name', name); + onUpdateInitialName?.(name); + }} + /> + + + ); +}; + +export default CreateQnAFromScratch; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx deleted file mode 100644 index 19dd8d7236..0000000000 --- a/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx +++ /dev/null @@ -1,120 +0,0 @@ -// 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 TelemetryClient from '../../telemetry/TelemetryClient'; - -import { validateName, CreateQnAFromModalProps, CreateQnAFromScratchFormData } from './constants'; -import { subText, styles, dialogWindowMini, textFieldKBNameFromScratch } from './styles'; - -const formConfig: FieldConfig = { - name: { - required: true, - defaultValue: '', - }, -}; - -const DialogTitle = () => { - return ( - - {formatMessage('Add QnA Maker knowledge base')} - - {formatMessage('Manually add question and answer pairs to create a knowledge base')} - - - ); -}; - -export const CreateQnAFromScratchModal: React.FC = (props) => { - const { onDismiss, onSubmit, qnaFiles, projectId, initialName, onUpdateInitialName } = props; - const actions = useRecoilValue(dispatcherState); - const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId)); - - formConfig.name.validate = validateName(qnaFiles); - formConfig.name.defaultValue = initialName || ''; - const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); - const disabled = hasErrors || !formData.name; - - const handleDismiss = () => { - onDismiss?.(); - onUpdateInitialName?.(''); - actions.createQnAFromScratchDialogCancel({ projectId }); - TelemetryClient.track('AddNewKnowledgeBaseCanceled'); - }; - - return ( - , - styles: styles.dialog, - }} - hidden={false} - modalProps={{ - isBlocking: false, - styles: styles.modalCreateFromScratch, - }} - onDismiss={handleDismiss} - > - - - { - updateField('name', name); - onUpdateInitialName?.(name); - }} - /> - - - - {showCreateQnAFromUrlDialog && ( - { - actions.createQnAFromScratchDialogBack({ projectId }); - }} - /> - )} - { - handleDismiss(); - }} - /> - { - if (hasErrors) { - return; - } - onSubmit(formData); - onUpdateInitialName?.(''); - TelemetryClient.track('AddNewKnowledgeBaseCompleted', { scratch: true }); - }} - /> - - - ); -}; - -export default CreateQnAFromScratchModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromUrl.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromUrl.tsx new file mode 100644 index 0000000000..0d7495d44c --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromUrl.tsx @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useState, useMemo, Fragment, useEffect } from 'react'; +import formatMessage from 'format-message'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { Text } from 'office-ui-fabric-react/lib/Text'; +import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; + +import { Locales } from '../../locales'; + +import { initializeLocales } from './utilities'; +import { + validateUrl, + CreateQnAFromFormProps, + CreateQnAFromUrlFormData, + CreateQnAFromUrlFormDataErrors, +} from './constants'; +import { textFieldUrl, warning, urlPairStyle, knowledgeBaseStyle, urlStackStyle, subText } from './styles'; + +const hasErrors = (errors: CreateQnAFromUrlFormDataErrors) => { + return !!errors.name || errors.urls.some((e) => !!e); +}; + +export const CreateQnAFromUrl: React.FC = (props) => { + const { onChange, dialogId, locales, defaultLocale, initialName } = props; + + const index = Locales.findIndex((l) => l.locale === locales[0]); + const initialLanguage = index > -1 ? Locales[index].language : locales[0]; + const [formData, setFormData] = useState({ + urls: [], + locales: initializeLocales(locales, defaultLocale), + language: initialLanguage, + name: initialName || '', + multiTurn: false, + }); + + const [formDataErrors, setFormDataErrors] = useState({ + urls: [], + name: '', + }); + + const usedLocales = useMemo(() => { + return locales.map((fl) => { + const index = Locales.findIndex((l) => l.locale === fl); + if (index > -1) { + return { text: Locales[index].language, locale: fl, key: fl }; + } else { + return { text: fl, locale: fl, key: fl }; + } + }); + }, [locales]); + + const isQnAFileselected = !(dialogId === 'all'); + + const onChangeUrlsField = (value: string | undefined) => { + const urls = [...formData.urls]; + urls[0] = value ?? ''; + updateUrlsField(urls); + updateUrlsError(urls); + }; + + const onChangeMultiTurn = (value: boolean | undefined) => { + setFormData({ + ...formData, + multiTurn: value ?? false, + }); + }; + + const updateUrlsField = (urls: string[]) => { + setFormData({ + ...formData, + urls: urls, + }); + }; + + const updateUrlsError = (urls: string[]) => { + const urlErrors = urls.map((url) => { + return validateUrl(url); + }) as string[]; + setFormDataErrors({ ...formDataErrors, urls: urlErrors }); + }; + + const onChangeLanguageField = (option) => { + setFormData({ + ...formData, + language: option.text, + locales: [option.key], + }); + }; + + useEffect(() => { + const disabled = hasErrors(formDataErrors) || !formData.urls[0] || !formData.name; + onChange(formData, disabled); + }, [formData, formDataErrors]); + + return ( + + + {formatMessage('Create new knowledge base from URL')} + + + {formatMessage( + 'Select this option if you want to create a knowledge base from content hosted online such as an FAQ or document link (.csv, .xls or .doc format)' + )} + + + + + {formatMessage('Source URL')} + + onChangeUrlsField(url)} + /> + + { + onChangeLanguageField(o); + }} + /> + + {!isQnAFileselected && ( + {formatMessage('Please select a specific qna file to import QnA')} + )} + + + onChangeMultiTurn(val)} + /> + + + ); +}; + +export default CreateQnAFromUrl; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx deleted file mode 100644 index 1e12dee3a8..0000000000 --- a/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx +++ /dev/null @@ -1,269 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import React, { useState, useMemo } 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 { Text } from 'office-ui-fabric-react/lib/Text'; -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 { Locales } from '../../locales'; -import { dispatcherState, onCreateQnAFromUrlDialogCompleteState } from '../../recoilModel'; -import TelemetryClient from '../../telemetry/TelemetryClient'; - -import { - knowledgeBaseSourceUrl, - validateUrl, - validateName, - CreateQnAFromUrlModalProps, - CreateQnAFromUrlFormData, - CreateQnAFromUrlFormDataErrors, -} from './constants'; -import { - subText, - styles, - dialogWindow, - textFieldUrl, - textFieldKBNameFromUrl, - warning, - urlPairStyle, - knowledgeBaseStyle, - urlStackStyle, -} from './styles'; - -const DialogTitle = () => { - return ( - - {formatMessage('Add QnA Maker knowledge base')} - - - {formatMessage('Use Azure QnA Maker to extract question-and-answer pairs from an online FAQ. ')} - - {formatMessage('Learn more')} - - - - - ); -}; - -const hasErrors = (errors: CreateQnAFromUrlFormDataErrors) => { - return !!errors.name || errors.urls.some((e) => !!e); -}; - -const initializeLocales = (locales: string[], defaultLocale: string) => { - const newLocales = [...locales]; - const index = newLocales.findIndex((l) => l === defaultLocale); - if (index < 0) throw new Error(`default language ${defaultLocale} does not exist in languages`); - newLocales.splice(index, 1); - newLocales.sort(); - newLocales.unshift(defaultLocale); - return newLocales; -}; - -export const CreateQnAFromUrlModal: React.FC = (props) => { - const { - onDismiss, - onSubmit, - dialogId, - projectId, - qnaFiles, - locales, - defaultLocale, - initialName, - onUpdateInitialName, - } = props; - const actions = useRecoilValue(dispatcherState); - const onComplete = useRecoilValue(onCreateQnAFromUrlDialogCompleteState(projectId)); - - const [formData, setFormData] = useState({ - urls: [], - locales: initializeLocales(locales, defaultLocale), - name: initialName || '', - multiTurn: false, - }); - - const [formDataErrors, setFormDataErrors] = useState({ - urls: [], - name: '', - }); - - const usedLocales = useMemo(() => { - return formData.locales.map((fl) => { - const index = Locales.findIndex((l) => l.locale === fl); - if (index > -1) { - return Locales[index].language; - } - }); - }, [formData.locales]); - - const isQnAFileselected = !(dialogId === 'all'); - const disabled = hasErrors(formDataErrors) || !formData.urls[0] || !formData.name; - const validFormDataName = validateName(qnaFiles); - - const onChangeNameField = (value: string | undefined) => { - updateNameField(value); - onUpdateInitialName?.(value ?? ''); - updateNameError(value); - }; - - const onChangeUrlsField = (value: string | undefined, index: number) => { - const urls = [...formData.urls]; - urls[index] = value ?? ''; - updateUrlsField(urls); - updateUrlsError(urls); - }; - - const onChangeMultiTurn = (value: boolean | undefined) => { - setFormData({ - ...formData, - multiTurn: value ?? false, - }); - }; - - const updateNameField = (value: string | undefined) => { - setFormData({ - ...formData, - name: value ?? '', - }); - }; - - const updateNameError = (value: string | undefined) => { - const error = validFormDataName(value) as string; - setFormDataErrors({ ...formDataErrors, name: error ?? '' }); - }; - - const updateUrlsField = (urls: string[]) => { - setFormData({ - ...formData, - urls: urls, - }); - }; - - const updateUrlsError = (urls: string[]) => { - const urlErrors = urls.map((url) => { - return validateUrl(url); - }) as string[]; - setFormDataErrors({ ...formDataErrors, urls: urlErrors }); - }; - - const handleDismiss = () => { - onDismiss?.(); - onUpdateInitialName?.(''); - actions.createQnAFromUrlDialogCancel({ projectId }); - TelemetryClient.track('AddNewKnowledgeBaseCanceled'); - }; - - const removeEmptyUrls = (formData: CreateQnAFromUrlFormData) => { - const urls: string[] = []; - const locales: string[] = []; - for (let i = 0; i < formData.urls.length; i++) { - if (formData.urls[i]) { - urls.push(formData.urls[i]); - locales.push(formData.locales[i]); - } - } - return { - ...formData, - locales, - urls, - }; - }; - - return ( - , - styles: styles.dialog, - }} - hidden={false} - modalProps={{ - isBlocking: false, - styles: styles.modalCreateFromUrl, - }} - onDismiss={handleDismiss} - > - - - onChangeNameField(name)} - /> - {formatMessage('FAQ website (source)')} - {formData.locales.map((locale, i) => { - return ( - - onChangeUrlsField(url, i)} - /> - - ); - })} - - {!isQnAFileselected && ( - {formatMessage('Please select a specific qna file to import QnA')} - )} - - - onChangeMultiTurn(val)} - /> - - - - { - // switch to create from scratch flow, pass onComplete callback. - actions.createQnAFromScratchDialogBegin({ projectId, dialogId, onComplete: onComplete?.func }); - }} - /> - { - handleDismiss(); - }} - /> - { - if (hasErrors(formDataErrors)) { - return; - } - onSubmit(removeEmptyUrls(formData)); - onUpdateInitialName?.(''); - TelemetryClient.track('AddNewKnowledgeBaseCompleted', { scratch: false }); - }} - /> - - - ); -}; - -export default CreateQnAFromUrlModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAModal.tsx new file mode 100644 index 0000000000..83ffbbc86e --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAModal.tsx @@ -0,0 +1,683 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { + DetailsList, + SelectionMode, + DetailsListLayoutMode, + IColumn, + Selection, + DetailsRow, + IDetailsRowProps, + CheckboxVisibility, +} from 'office-ui-fabric-react/lib/DetailsList'; +import { Spinner } from 'office-ui-fabric-react/lib/Spinner'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; +import { SubscriptionClient } from '@azure/arm-subscriptions'; +import { Subscription } from '@azure/arm-subscriptions/esm/models'; +import { TokenCredentials } from '@azure/ms-rest-js'; +import { CognitiveServicesManagementClient } from '@azure/arm-cognitiveservices'; +import { CognitiveServicesCredentials } from '@azure/ms-rest-azure-js'; +import { QnAMakerClient } from '@azure/cognitiveservices-qnamaker'; +import sortBy from 'lodash/sortBy'; +import uniq from 'lodash/uniq'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import { IRenderFunction } from '@uifabric/utilities'; + +import TelemetryClient from '../../telemetry/TelemetryClient'; +import { + createQnAOnState, + showCreateQnADialogState, + settingsState, + dispatcherState, + localeState, + currentUserState, + isAuthenticatedState, + showAuthDialogState, +} from '../../recoilModel'; + +import { CreateQnAFormData, CreateQnAModalProps, QnAMakerLearnMoreUrl } from './constants'; +import { + subText, + styles, + contentBox, + formContainer, + choiceContainer, + nameStepContainer, + resourceDropdown, + dialogBodyStyles, +} from './styles'; +import { CreateQnAFromUrl } from './CreateQnAFromUrl'; +import { CreateQnAFromScratch } from './CreateQnAFromScratch'; +import { CreateQnAFromQnAMaker } from './CreateQnAFromQnAMaker'; +import { localeToLanguage } from './utilities'; +import { PersonaCard } from './PersonaCard'; + +type KeyRec = { + name: string; + region: string; + resourceGroup: string; + key: string; + endpoint: string; +}; + +type KBRec = { + id: string; + name: string; + language: string; + lastChangedTimestamp: string; +}; + +type Step = 'name' | 'intro' | 'resource' | 'knowledge-base' | 'outcome'; + +const mainElementStyle = { marginBottom: 20 }; +const serviceName = 'QnA Maker'; +const serviceKeyType = 'QnAMaker'; + +export const CreateQnAModal: React.FC = (props) => { + const { onDismiss, onSubmit } = props; + const { projectId } = useRecoilValue(createQnAOnState); + const settings = useRecoilValue(settingsState(projectId)); + const actions = useRecoilValue(dispatcherState); + const locales = settings.languages; + const defaultLocale = settings.defaultLanguage; + const currentLocale = useRecoilValue(localeState(projectId)); + const showCreateQnAFrom = useRecoilValue(showCreateQnADialogState(projectId)); + const [initialName, setInitialName] = useState(''); + const [formData, setFormData] = useState(); + const [disabled, setDisabled] = useState(true); + const { setApplicationLevelError, requireUserLogin } = useRecoilValue(dispatcherState); + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + const showAuthDialog = useRecoilValue(showAuthDialogState); + + const [subscriptionId, setSubscription] = useState(''); + + const [loading, setLoading] = useState(undefined); + const [noKeys, setNoKeys] = useState(false); + const [nextAction, setNextAction] = useState('url'); + const [key, setKey] = useState(); + const [region, setRegion] = useState(''); + const [availableSubscriptions, setAvailableSubscriptions] = useState([]); + const [subscriptionsErrorMessage, setSubscriptionsErrorMessage] = useState(); + const [keys, setKeys] = useState([]); + const [kbs, setKbs] = useState<{ [key: string]: KBRec[] }>({}); + const [selectedKb, setSelectedKb] = useState(); + const [dialogTitle, setDialogTitle] = useState(''); + + const [currentStep, setCurrentStep] = useState('name'); + + const currentAuthoringLanuage = localeToLanguage(currentLocale); + const defaultLanuage = localeToLanguage(defaultLocale); + const avaliableLanguages = uniq(locales.map((item) => localeToLanguage(item))); + + const actionOptions: IChoiceGroupOption[] = [ + { key: 'url', text: formatMessage('Create new knowledge base from URL') }, + { + key: 'portal', + text: formatMessage('Import existing knowledge base from QnA maker portal'), + }, + ]; + + /* Copied from Azure Publishing extension */ + const getSubscriptions = async (token: string): Promise> => { + const tokenCredentials = new TokenCredentials(token); + try { + const subscriptionClient = new SubscriptionClient(tokenCredentials); + const subscriptionsResult = await subscriptionClient.subscriptions.list(); + // eslint-disable-next-line no-underscore-dangle + return sortBy(subscriptionsResult._response.parsedBody, ['displayName']); + } catch (err) { + setApplicationLevelError(err); + return []; + } + }; + + const selectedKB = useMemo(() => { + return new Selection({ + onSelectionChanged: () => { + const t = selectedKB.getSelection()[0] as KBRec; + + if (t) { + setSelectedKb(t); + } + }, + }); + }, []); + + useEffect(() => { + if (isAuthenticated && showCreateQnAFrom) { + setAvailableSubscriptions([]); + setSubscriptionsErrorMessage(undefined); + getSubscriptions(currentUser.token) + .then((data) => { + setAvailableSubscriptions(data); + if (data.length === 0) { + setSubscriptionsErrorMessage( + formatMessage( + 'Your subscription list is empty, please add your subscription, or login with another account.' + ) + ); + } + }) + .catch((err) => { + setSubscriptionsErrorMessage(err.message); + }); + } + }, [currentUser, isAuthenticated, showCreateQnAFrom]); + + useEffect(() => { + // reset the ui + setSubscription(''); + setKeys([]); + setCurrentStep('name'); + setSelectedKb(undefined); + setKbs({}); + }, [showCreateQnAFrom]); + + const fetchKeys = async (cognitiveServicesManagementClient, accounts) => { + const keyList: KeyRec[] = []; + for (const account in accounts) { + const resourceGroup = accounts[account].id?.replace(/.*?\/resourceGroups\/(.*?)\/.*/, '$1'); + const name = accounts[account].name; + if (resourceGroup && name) { + try { + const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); + if (keys?.key1) { + keyList.push({ + name, + resourceGroup, + region: accounts[account].location || '', + key: keys?.key1 || '', + endpoint: accounts[account]?.properties?.endpoint ?? '', + }); + } + } catch (_err) { + // pass, filter no authorization resource + } + } + } + return keyList; + }; + + const fetchAccounts = async (subscriptionId) => { + if (isAuthenticated) { + setLoading(formatMessage('Loading keys...')); + setNoKeys(false); + const tokenCredentials = new TokenCredentials(currentUser.token); + const cognitiveServicesManagementClient = new CognitiveServicesManagementClient(tokenCredentials, subscriptionId); + const accounts = await cognitiveServicesManagementClient.accounts.list(); + + const keylist: KeyRec[] = await fetchKeys( + cognitiveServicesManagementClient, + accounts.filter((a) => a.kind === serviceKeyType) + ); + + const kbsMap = {}; + const avaliableKeys: KeyRec[] = []; + for (const keyItem of keylist) { + const kbGroups = await fetchKBGroups(keyItem); + kbsMap[keyItem.name] = kbGroups; + if (kbGroups.length) { + avaliableKeys.push(keyItem); + } + } + setKbs(kbsMap); + setLoading(undefined); + if (avaliableKeys.length == 0) { + setNoKeys(true); + } else { + setNoKeys(false); + setKeys(avaliableKeys); + } + } + }; + + const fetchKBGroups = async (key: KeyRec) => { + let kblist: KBRec[] = []; + if (isAuthenticated && key) { + const cognitiveServicesCredentials = new CognitiveServicesCredentials(key.key); + const resourceClient = new QnAMakerClient(cognitiveServicesCredentials, key.endpoint); + + const result = await resourceClient.knowledgebase.listAll(); + + if (result.knowledgebases) { + kblist = result.knowledgebases.map((item: any) => { + return { + id: item.id || '', + name: item.name || '', + language: item.language || '', + lastChangedTimestamp: item.lastChangedTimestamp || '', + }; + }); + } + } + return kblist; + }; + // allow a user to provide a subscription id if one is missing + const onChangeSubscription = async (_, opt) => { + // get list of keys for this subscription + setSubscription(opt.key); + fetchAccounts(opt.key); + setLoading(formatMessage('Loading subscription...')); + }; + + const onChangeKey = async (_, opt) => { + // get list of keys for this subscription + setKey(opt); + setRegion(opt.region); + }; + + const onChangeAction = async (_, opt) => { + setNextAction(opt.key); + }; + + const chooseExistingKey = () => { + TelemetryClient.track('SettingsGetKeysExistingResourceSelected', { + subscriptionId, + resourceType: serviceName, + }); + setCurrentStep('knowledge-base'); + }; + + const performNextAction = () => { + if (nextAction === 'url') { + onSubmitFormData(nextAction); + } else { + requireUserLogin(); + setCurrentStep('resource'); + } + }; + + const renderNameStep = () => { + return ( + + + + + {formatMessage('Use Azure QnA Maker to extract question-and-answer pairs from an online FAQ. ')} + + {formatMessage('Learn more')} + + + + + + + setCurrentStep('intro')} + /> + + + + ); + }; + + const renderIntroStep = () => { + return ( + + + + {formatMessage('Create a knowledge base from a URL or import content from an existing knowledge base')} + + + + + + + + {nextAction === 'url' ? ( + + ) : ( + + )} + + + + onSubmitFormData('scratch')} + /> + setCurrentStep('name')} /> + + + + + ); + }; + + const renderChooseResourceStep = () => { + return ( + + + + {formatMessage('Select the subscription and resource you want to choose a knowledge base from')} + + + 0)} + errorMessage={subscriptionsErrorMessage} + label={formatMessage('Azure subscription')} + options={ + availableSubscriptions + ?.filter((p) => p.subscriptionId && p.displayName) + .map((p) => { + return { key: p.subscriptionId ?? '', text: p.displayName ?? formatMessage('Unnamed') }; + }) ?? [] + } + placeholder={formatMessage('Select a subscription')} + selectedKey={subscriptionId} + styles={resourceDropdown} + onChange={onChangeSubscription} + /> + + + {noKeys && subscriptionId && ( + + {formatMessage( + 'No existing QnA Maker resources were found in this subscription. Select a different subscription, or click “Back” to create a new resource or generate a resource request to handoff to your Azure admin.' + )} + + )} + {!noKeys && subscriptionId && ( + + 0)} + label={formatMessage('QnA Maker resource name')} + options={ + keys.map((p) => { + return { text: p.name, ...p }; + }) ?? [] + } + placeholder={formatMessage('Select resource')} + styles={resourceDropdown} + onChange={onChangeKey} + /> + + )} + + + + + + + {loading && } + setCurrentStep('intro')} /> + + + + + ); + }; + + const renderKnowledgeBaseSelectionStep = () => { + const columns: IColumn[] = [ + { + key: 'column2', + name: 'Name', + fieldName: 'name', + minWidth: 50, + maxWidth: 350, + isRowHeader: true, + isResizable: true, + isSorted: true, + isSortedDescending: false, + sortAscendingAriaLabel: 'Sorted A to Z', + sortDescendingAriaLabel: 'Sorted Z to A', + data: 'string', + isPadded: true, + }, + { + key: 'column3', + name: 'Last modified', + fieldName: 'lastModified', + minWidth: 200, + maxWidth: 300, + isResizable: true, + data: 'string', + isPadded: true, + onRender: (item) => { + const dt = new Date(item.lastChangedTimestamp); + return ( + + {' '} + {dt.toDateString()} {dt.toLocaleTimeString()}{' '} + + ); + }, + }, + { + key: 'column4', + name: 'Language', + fieldName: 'language', + minWidth: 100, + maxWidth: 200, + isResizable: true, + isCollapsible: true, + data: 'string', + isPadded: true, + }, + ]; + + const currentLanguageKbs: KBRec[] = []; + const defaultLanuageKbs: KBRec[] = []; + const avaliableLanguageKbs: KBRec[] = []; + const disabledLanguageKbs: KBRec[] = []; + + const currentKbs = key ? kbs[key.name] : []; + currentKbs.forEach((item) => { + if (item.language === currentAuthoringLanuage) { + currentLanguageKbs.push(item); + } else if (item.language === defaultLanuage) { + defaultLanuageKbs.push(item); + } else if (avaliableLanguages.includes(item.language)) { + avaliableLanguageKbs.push(item); + } else { + disabledLanguageKbs.push(item); + } + }); + + const sortedKbs = [...currentLanguageKbs, ...defaultLanuageKbs, ...avaliableLanguageKbs, ...disabledLanguageKbs]; + + const onRenderRow: IRenderFunction = (props) => { + if (!props) return null; + if (avaliableLanguages.includes(props.item.language)) { + return ; + } else { + return ( + + + + ); + } + }; + + return ( + + + + {formatMessage('Select one or more knowledge base to import into your bot project')} + + + item.name} + items={sortedKbs} + layoutMode={DetailsListLayoutMode.justified} + selection={selectedKB} + selectionMode={SelectionMode.single} + onRenderRow={onRenderRow} + /> + + + + {loading && } + setCurrentStep('resource')} /> + + + + + ); + }; + + const renderCurrentStep = () => { + switch (currentStep) { + case 'name': + return renderNameStep(); + case 'intro': + return renderIntroStep(); + case 'resource': { + if (nextAction === 'portal') { + return renderChooseResourceStep(); + } + break; + } + case 'knowledge-base': + return renderKnowledgeBaseSelectionStep(); + default: + return null; + } + }; + + useEffect(() => { + switch (currentStep) { + case 'name': + setDialogTitle(formatMessage('Add QnA Maker knowledge base')); + break; + case 'intro': + setDialogTitle(formatMessage(`Select a source for your knowledge base's content`)); + break; + case 'resource': + if (nextAction === 'portal') { + setDialogTitle(formatMessage('Select source knowledge base location')); + } + break; + case 'knowledge-base': + setSelectedKb(undefined); + setDialogTitle(formatMessage('Choose a knowledge base to import')); + break; + } + }, [currentStep]); + + const handleDismiss = () => { + onDismiss?.(); + setInitialName(''); + actions.createQnADialogCancel({ projectId }); + TelemetryClient.track('AddNewKnowledgeBaseCanceled'); + }; + + const onFormDataChange = (data, disabled) => { + setFormData(data); + setDisabled(disabled); + }; + + const onSubmitFormData = (createFrom: string) => { + if (!formData) return; + if (createFrom === 'url' && disabled) return; + + onSubmit(formData); + setInitialName(''); + TelemetryClient.track('AddNewKnowledgeBaseCompleted', { source: formData.urls?.length ? 'url' : 'none' }); + }; + + const onSubmitImportKB = async () => { + if (key && isAuthenticated && selectedKb && formData) { + // TODO: add to all matched language or ask user for specific locale. + const createdOnLocales = locales.filter((item) => localeToLanguage(item) === selectedKb.language); + onSubmit({ + ...formData, + locales: createdOnLocales, + endpoint: key.endpoint, + kbId: selectedKb.id, + kbName: selectedKb.name, + subscriptionKey: key.key, + }); + setInitialName(''); + TelemetryClient.track('AddNewKnowledgeBaseCompleted', { source: 'kb' }); + } + }; + + return ( + + {} : handleDismiss} + > + {renderCurrentStep()} + + + ); +}; + +export default CreateQnAModal; diff --git a/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx b/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx index d1f39a0090..30161fba45 100644 --- a/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx +++ b/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx @@ -40,7 +40,7 @@ const formConfig: FieldConfig = { }; const DialogTitle = () => { - return {formatMessage('Edit KB name')}; + return {formatMessage('Edit knowledge base name')}; }; export const EditQnAFromScratchModal: React.FC = (props) => { diff --git a/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx index d787ad230b..0012a524f5 100644 --- a/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx +++ b/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx @@ -46,7 +46,7 @@ const formConfig: FieldConfig = { }; const DialogTitle = () => { - return {formatMessage('Edit KB name')}; + return {formatMessage('Edit knowledge base name')}; }; export const EditQnAFromUrlModal: React.FC = (props) => { diff --git a/Composer/packages/client/src/components/QnA/ImportQnAFromUrl.tsx b/Composer/packages/client/src/components/QnA/ImportQnAFromUrl.tsx new file mode 100644 index 0000000000..179fdd5778 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/ImportQnAFromUrl.tsx @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useEffect } from 'react'; +import formatMessage from 'format-message'; +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 { QnAFile } from '@bfc/shared'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { getQnAFileUrlOption, getQnAFileMultiTurnOption } from '../../utils/qnaUtil'; +import { getExtension } from '../../utils/fileUtil'; +import { Locales } from '../../locales'; + +import { validateUrl } from './constants'; +import { header, titleStyle, descriptionStyle, dialogWindow, textFieldKBNameFromScratch } from './styles'; + +type ImportQnAFromUrlModalProps = { + qnaFile: QnAFile; + onChange: (data, disabled: boolean) => void; +}; + +export type ImportQnAFromUrlFormData = { + url: string; + multiTurn: boolean; +}; + +const formConfig: FieldConfig = { + url: { + required: true, + defaultValue: '', + }, + multiTurn: { + defaultValue: false, + }, +}; + +const title = {formatMessage('Replace knowledge base from URL')}; + +const description = ( + + {formatMessage( + 'Select this option if you want to replace current knowledge base from content hosted online such as an FAQ or document link (.csv, .xls or .doc format)' + )} + +); + +export const ImportQnAFromUrl: React.FC = (props) => { + const { qnaFile, onChange } = props; + const locale = getExtension(qnaFile.id); + formConfig.url.validate = validateUrl; + formConfig.url.defaultValue = getQnAFileUrlOption(qnaFile); + formConfig.multiTurn.defaultValue = getQnAFileMultiTurnOption(qnaFile); + const { formData, updateField, formErrors, hasErrors } = useForm(formConfig); + + const getLanguage = (locale) => { + const index = Locales.findIndex((l) => l.locale === locale); + if (index > -1) { + return Locales[index].language; + } + }; + + const updateUrl = (url = '') => { + updateField('url', url); + }; + + const updateMultiTurn = (multiTurn = false) => { + updateField('multiTurn', multiTurn); + }; + + useEffect(() => { + onChange(formData, hasErrors); + }, [formData, hasErrors]); + + return ( + + + + {title} + {description} + + + updateUrl(url)} + /> + + + updateMultiTurn(val)} + /> + + + ); +}; + +export default ImportQnAFromUrl; diff --git a/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx index 27c10a7d90..d031e7dec8 100644 --- a/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx +++ b/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx @@ -78,7 +78,7 @@ export const ImportQnAFromUrlModal: React.FC = (prop { + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + + const { logoutUser } = useRecoilValue(dispatcherState); + + const onLogout = async () => { + const confirmed = await OpenConfirmModal( + formatMessage('Sign out of Azure'), + formatMessage( + 'By signing out of Azure, your operation will be canceled and this dialog will close. Do you want to continue?' + ), + { + onRenderContent: (subtitle: string) => {subtitle}, + confirmText: formatMessage('Sign out'), + cancelText: formatMessage('Cancel'), + } + ); + if (confirmed) { + await logoutUser(); + } + }; + + const onRenderSecondaryText: IRenderFunction = (props) => { + if (!props) return null; + return ( + + {props.secondaryText} + + ); + }; + + return isAuthenticated && currentUser ? ( + + ) : null; +}; diff --git a/Composer/packages/client/src/components/QnA/ReplaceQnAFromModal.tsx b/Composer/packages/client/src/components/QnA/ReplaceQnAFromModal.tsx new file mode 100644 index 0000000000..f623f40be5 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/ReplaceQnAFromModal.tsx @@ -0,0 +1,605 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { + DetailsList, + SelectionMode, + DetailsListLayoutMode, + IColumn, + Selection, + IDetailsRowProps, + DetailsRow, + CheckboxVisibility, +} from 'office-ui-fabric-react/lib/DetailsList'; +import { Spinner } from 'office-ui-fabric-react/lib/Spinner'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; +import { SubscriptionClient } from '@azure/arm-subscriptions'; +import { Subscription } from '@azure/arm-subscriptions/esm/models'; +import { TokenCredentials } from '@azure/ms-rest-js'; +import { CognitiveServicesManagementClient } from '@azure/arm-cognitiveservices'; +import { CognitiveServicesCredentials } from '@azure/ms-rest-azure-js'; +import { QnAMakerClient } from '@azure/cognitiveservices-qnamaker'; +import sortBy from 'lodash/sortBy'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import { IRenderFunction } from '@uifabric/utilities'; + +import TelemetryClient from '../../telemetry/TelemetryClient'; +import { getKBName, getFileLocale } from '../../utils/qnaUtil'; +import { dispatcherState, currentUserState, isAuthenticatedState, showAuthDialogState } from '../../recoilModel'; + +import { localeToLanguage } from './utilities'; +import { ReplaceQnAModalFormData, ReplaceQnAModalProps } from './constants'; +import { + styles, + subText, + contentBox, + formContainer, + choiceContainer, + titleStyle, + descriptionStyle, + resourceDropdown, + dialogBodyStyles, +} from './styles'; +import { ImportQnAFromUrl } from './ImportQnAFromUrl'; +import { PersonaCard } from './PersonaCard'; + +type KeyRec = { + name: string; + region: string; + resourceGroup: string; + key: string; + endpoint: string; +}; + +type KBRec = { + name: string; + language: string; + id: string; + lastChangedTimestamp: string; +}; + +type Step = 'intro' | 'resource' | 'knowledge-base' | 'outcome'; + +const mainElementStyle = { marginBottom: 20 }; +const serviceName = 'QnA Maker'; +const serviceKeyType = 'QnAMaker'; + +export const ReplaceQnAFromModal: React.FC = (props) => { + const { onDismiss, onSubmit, hidden, qnaFile, projectId, containerId } = props; + const actions = useRecoilValue(dispatcherState); + const [formData, setFormData] = useState(); + const [disabled, setDisabled] = useState(true); + const { setApplicationLevelError, requireUserLogin } = useRecoilValue(dispatcherState); + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + const showAuthDialog = useRecoilValue(showAuthDialogState); + + const [subscriptionId, setSubscription] = useState(''); + const [loading, setLoading] = useState(undefined); + const [noKeys, setNoKeys] = useState(false); + const [nextAction, setNextAction] = useState('url'); + const [key, setKey] = useState(); + const [region, setRegion] = useState(''); + const [availableSubscriptions, setAvailableSubscriptions] = useState([]); + const [subscriptionsErrorMessage, setSubscriptionsErrorMessage] = useState(); + const [keys, setKeys] = useState([]); + const [kbs, setKbs] = useState<{ [key: string]: KBRec[] }>({}); + + const [selectedKb, setSelectedKb] = useState(); + const [dialogTitle, setDialogTitle] = useState(''); + + const [currentStep, setCurrentStep] = useState('intro'); + + const currentLocale = getFileLocale(containerId); + const currentAuthoringLanuage = localeToLanguage(currentLocale); + + const actionOptions: IChoiceGroupOption[] = [ + { key: 'url', text: formatMessage('Replace knowledge base from URL') }, + { + key: 'portal', + text: formatMessage('Replace with an existing knowledge base from QnA maker portal'), + }, + ]; + + /* Copied from Azure Publishing extension */ + const getSubscriptions = async (token: string): Promise> => { + const tokenCredentials = new TokenCredentials(token); + try { + const subscriptionClient = new SubscriptionClient(tokenCredentials); + const subscriptionsResult = await subscriptionClient.subscriptions.list(); + // eslint-disable-next-line no-underscore-dangle + return sortBy(subscriptionsResult._response.parsedBody, ['displayName']); + } catch (err) { + setApplicationLevelError(err); + return []; + } + }; + + const selectedKB = useMemo(() => { + return new Selection({ + onSelectionChanged: () => { + const t = selectedKB.getSelection()[0] as KBRec; + + if (t) { + setSelectedKb(t); + } + }, + }); + }, []); + + useEffect(() => { + if (isAuthenticated && !hidden) { + setAvailableSubscriptions([]); + setSubscriptionsErrorMessage(undefined); + getSubscriptions(currentUser.token) + .then((data) => { + setAvailableSubscriptions(data); + if (data.length === 0) { + setSubscriptionsErrorMessage( + formatMessage( + 'Your subscription list is empty, please add your subscription, or login with another account.' + ) + ); + } + }) + .catch((err) => { + setSubscriptionsErrorMessage(err.message); + }); + } + }, [currentUser, isAuthenticated, hidden]); + + useEffect(() => { + // reset the ui + if (!hidden) { + setSubscription(''); + setKeys([]); + setCurrentStep('intro'); + } + }, [hidden]); + + const fetchKeys = async (cognitiveServicesManagementClient, accounts) => { + const keyList: KeyRec[] = []; + for (const account in accounts) { + const resourceGroup = accounts[account].id?.replace(/.*?\/resourceGroups\/(.*?)\/.*/, '$1'); + const name = accounts[account].name; + if (resourceGroup && name) { + try { + const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); + if (keys?.key1) { + keyList.push({ + name: name, + resourceGroup: resourceGroup, + region: accounts[account].location || '', + key: keys?.key1 || '', + endpoint: accounts[account]?.properties?.endpoint ?? '', + }); + } + } catch (_err) { + // pass, filter no authorization resource + } + } + } + return keyList; + }; + + const fetchAccounts = async (subscriptionId) => { + if (isAuthenticated) { + setLoading(formatMessage('Loading keys...')); + setNoKeys(false); + const tokenCredentials = new TokenCredentials(currentUser.token); + const cognitiveServicesManagementClient = new CognitiveServicesManagementClient(tokenCredentials, subscriptionId); + const accounts = await cognitiveServicesManagementClient.accounts.list(); + const keylist: KeyRec[] = await fetchKeys( + cognitiveServicesManagementClient, + accounts.filter((a) => a.kind === serviceKeyType) + ); + + const kbsMap = {}; + const avaliableKeys: KeyRec[] = []; + for (const keyItem of keylist) { + const kbGroups = await fetchKBGroups(keyItem); + kbsMap[keyItem.name] = kbGroups; + if (kbGroups.length) { + avaliableKeys.push(keyItem); + } + } + setKbs(kbsMap); + setLoading(undefined); + if (avaliableKeys.length == 0) { + setNoKeys(true); + } else { + setNoKeys(false); + setKeys(avaliableKeys); + } + } + }; + + const fetchKBGroups = async (key: KeyRec) => { + let kblist: KBRec[] = []; + if (isAuthenticated && key) { + const cognitiveServicesCredentials = new CognitiveServicesCredentials(key.key); + const resourceClient = new QnAMakerClient(cognitiveServicesCredentials, key.endpoint); + const result = await resourceClient.knowledgebase.listAll(); + if (result.knowledgebases) { + kblist = result.knowledgebases.map((item: any) => { + return { + id: item.id || '', + name: item.name || '', + language: item.language || '', + lastChangedTimestamp: item.lastChangedTimestamp || '', + }; + }); + } + } + return kblist; + }; + + // allow a user to provide a subscription id if one is missing + const onChangeSubscription = async (_, opt) => { + // get list of keys for this subscription + setSubscription(opt.key); + fetchAccounts(opt.key); + setLoading(formatMessage('Loading subscription...')); + }; + + const onChangeKey = async (_, opt) => { + // get list of keys for this subscription + setKey(opt); + setRegion(opt.region); + }; + + const onChangeAction = async (_, opt) => { + setNextAction(opt.key); + if (opt.key === 'url') { + setDisabled(true); + } else { + setDisabled(false); + } + }; + + const chooseExistingKey = () => { + TelemetryClient.track('SettingsGetKeysExistingResourceSelected', { + subscriptionId, + resourceType: serviceName, + }); + setCurrentStep('knowledge-base'); + }; + + const performNextAction = () => { + if (nextAction !== 'portal') { + onSubmitFormData(); + } else { + requireUserLogin(); + setCurrentStep('resource'); + } + }; + + const renderIntroStep = () => { + return ( + + + + {formatMessage('Create a knowledge base from a URL or import content from an existing knowledge base')} + + + + + + + + {nextAction === 'url' ? ( + qnaFile && + ) : ( + +
{props.introText} {props.learnMore ? ( - + {formatMessage('Learn more')} ) : null} @@ -557,10 +477,9 @@ export const ManageService = (props: ManageServiceProps) => {
- {formatMessage( - 'Select your Azure directory, then choose the subscription where your existing {service} resource is located.', - { service: props.serviceName } - )} + {formatMessage('Choose the subscription where your existing {service} resource is located.', { + service: props.serviceName, + })} {props.learnMore ? ( {formatMessage('Learn more')} @@ -570,18 +489,7 @@ export const ManageService = (props: ManageServiceProps) => {
- {formatMessage( - 'Select your Azure directory, then choose the subscription where you’d like your new {service} resource.', - { service: props.serviceName } - )} + {formatMessage('Choose the subscription where you’d like your new {service} resource.', { + service: props.serviceName, + })} {props.learnMore ? ( {formatMessage('Learn more')} @@ -787,18 +698,7 @@ export const ManageService = (props: ManageServiceProps) => {
{formatMessage.rich('Install ngrok and run the following command to continue', { a: ({ children }) => ( @@ -82,16 +56,11 @@ export const TunnelingSetupNotification: React.FC = (props) => { ), })}
{ describe('', () => { it('should render the NotificationCard', () => { const cardProps: CardProps = { - title: 'There was error creating your KB', + title: 'There was error creating your knowledge base', description: 'error', retentionTime: 1, type: 'error', @@ -29,12 +29,12 @@ describe('', () => { ); - expect(container).toHaveTextContent('There was error creating your KB'); + expect(container).toHaveTextContent('There was error creating your knowledge base'); }); it('should render the customized card', () => { const cardProps: CardProps = { - title: 'There was error creating your KB', + title: 'There was error creating your knowledge base', description: 'error', retentionTime: 5000, type: 'error', diff --git a/Composer/packages/client/src/components/Notifications/__tests__/useSurveyNotification.test.tsx b/Composer/packages/client/src/components/Notifications/__tests__/useSurveyNotification.test.tsx new file mode 100644 index 0000000000..a256342f16 --- /dev/null +++ b/Composer/packages/client/src/components/Notifications/__tests__/useSurveyNotification.test.tsx @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; + +import { renderWithRecoil } from '../../../../__tests__/testUtils'; +import { NotificationContainer } from '../NotificationContainer'; +import { useSurveyNotification } from '../useSurveyNotification'; +import { machineInfoState } from '../../../recoilModel'; +import { ClientStorage } from '../../../utils/storage'; +import { LAST_SURVEY_KEY } from '../../../constants'; + +let savedVersion: string | undefined = ''; +const MOCK_VERSION = '2.3.4_jest'; + +let surveyStorage: ClientStorage; + +beforeAll(() => { + process.env.NODE_ENV = 'jest'; + savedVersion = process.env.COMPOSER_VERSION; + process.env.COMPOSER_VERSION = MOCK_VERSION; + + surveyStorage = new ClientStorage(window.localStorage, 'survey'); +}); + +afterAll(() => { + process.env.NODE_ENV = 'test'; + process.env.COMPOSER_VERSION = savedVersion; +}); + +describe('useSurveyNotification', () => { + const id = 'machineID12345'; + const os = 'TestOS'; + + const mockOpen = jest.fn(); + + const initRecoilState = ({ set }) => { + set(machineInfoState, { os, id }); + }; + + window.open = mockOpen; + + const TestHarness = () => { + useSurveyNotification(); + return ; + }; + + describe('building the URL', () => { + beforeEach(() => { + surveyStorage.set('optedOut', false); + surveyStorage.set('days', 12345); + surveyStorage.set(LAST_SURVEY_KEY, null); + }); + + it('builds a URL given parameters', async () => { + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = await page.findByText('Take survey'); + surveyButton.click(); + + // We know these should all occur, but we don't care about the order + const patterns = [ + 'https://aka.ms/bfcomposersurvey', + 'Source=Composer', + `machineId=${id}`, + `os=${os}`, + `version=${MOCK_VERSION}`, + ]; + + for (const pattern of patterns) { + expect(mockOpen).toHaveBeenCalledWith(expect.stringContaining(pattern), '_blank'); + } + }); + + it('builds a URL given no OS', async () => { + const newRecoilState = ({ set }) => { + set(machineInfoState, { os: null, id }); + }; + + const page = renderWithRecoil(, newRecoilState); + + const surveyButton = await page.findByText('Take survey'); + surveyButton.click(); + + const patterns = [ + 'https://aka.ms/bfcomposersurvey', + 'Source=Composer', + `machineId=${id}`, + `os=Unknown`, + `version=${MOCK_VERSION}`, + ]; + + for (const pattern of patterns) { + expect(mockOpen).toHaveBeenCalledWith(expect.stringContaining(pattern), '_blank'); + } + }); + }); + + describe('determining eligibility', () => { + beforeEach(() => { + surveyStorage.set('optedOut', false); + surveyStorage.set('days', 12345); + surveyStorage.set(LAST_SURVEY_KEY, null); + }); + + it('shows the box under normal conditions', async () => { + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = await page.findByText('Take survey'); + expect(surveyButton).not.toBeNull(); + }); + + it("doesn't show the box when the user has opted out", () => { + surveyStorage.set('optedOut', true); + + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = page.queryByText('Take survey'); + expect(surveyButton).toBeNull(); + }); + + it("doesn't show the box when the user hasn't spent enough days with Composer", () => { + // logically impossible, but makes a good test case + surveyStorage.set('days', -1); + + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = page.queryByText('Take survey'); + expect(surveyButton).toBeNull(); + }); + + it("returns false when it hasn't been long enough since the last survey", () => { + // also logically impossible, but makes a good test case + surveyStorage.set(LAST_SURVEY_KEY, Date.now() + 10000); + + const page = renderWithRecoil(, initRecoilState); + + const surveyButton = page.queryByText('Take survey'); + expect(surveyButton).toBeNull(); + }); + }); +}); diff --git a/Composer/packages/client/src/components/Notifications/useSurveyNotification.ts b/Composer/packages/client/src/components/Notifications/useSurveyNotification.ts new file mode 100644 index 0000000000..edb8a8ebd6 --- /dev/null +++ b/Composer/packages/client/src/components/Notifications/useSurveyNotification.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import querystring from 'query-string'; + +import { ClientStorage } from '../../utils/storage'; +import { dispatcherState, machineInfoState } from '../../recoilModel/atoms/appState'; +import { MachineInfo } from '../../recoilModel/types'; +import { LAST_SURVEY_KEY, SURVEY_URL_BASE, SURVEY_PARAMETERS } from '../../constants'; +import TelemetryClient from '../../telemetry/TelemetryClient'; + +const buildUrl = (info: MachineInfo) => { + // User OS + // hashed machineId + // composer version + // maybe include subscription ID; wait for global sign-in feature + // session ID (global telemetry GUID) + const version = process.env.COMPOSER_VERSION; + + const parameters = { + Source: 'Composer', + os: info?.os || 'Unknown', + machineId: info?.id, + version, + }; + + return `${SURVEY_URL_BASE}?${querystring.stringify(parameters)}`; +}; + +const getSurveyEligibility = () => { + const surveyStorage = new ClientStorage(window.localStorage, 'survey'); + + const optedOut = surveyStorage.get('optedOut', false); + + if (optedOut) { + return false; + } + + let days = surveyStorage.get('days', 0); + const lastUsed = surveyStorage.get('dateLastUsed', null); + const lastTaken = surveyStorage.get(LAST_SURVEY_KEY, null); + const today = new Date().toDateString(); + if (lastUsed !== today) { + days += 1; + surveyStorage.set('days', days); + } + surveyStorage.set('dateLastUsed', today); + + if ( + // To be eligible for the survey, the user needs to have used Composer + // some minimum number of days. + days >= SURVEY_PARAMETERS.daysUntilEligible && + // Also, either the user must have never taken the survey before or + // the last time they took it must be long enough in the past. + (lastTaken == null || Date.now() - lastTaken > SURVEY_PARAMETERS.timeUntilNextSurvey) + ) { + // If the above conditions are true, there's a fixed chance the card will appear. + return process.env.NODE_ENV === 'jest' || Math.random() < SURVEY_PARAMETERS.chanceToAppear; + } else { + return false; + } +}; + +export const useSurveyNotification = () => { + const { addNotification, deleteNotification } = useRecoilValue(dispatcherState); + const machineInfo = useRecoilValue(machineInfoState); + + useEffect(() => { + const url = buildUrl(machineInfo); + deleteNotification('survey'); + + if (getSurveyEligibility()) { + const surveyStorage = new ClientStorage(window.localStorage, 'survey'); + + TelemetryClient.track('HATSSurveyOffered'); + + addNotification({ + id: 'survey', + type: 'question', + title: formatMessage('Would you mind taking a quick survey?'), + description: formatMessage('We read every response and will use your feedback to improve Composer.'), + leftLinks: [ + { + label: formatMessage('Take survey'), + onClick: () => { + // This is safe; we control the URL that gets built + // eslint-disable-next-line security/detect-non-literal-fs-filename + window.open(url, '_blank'); + surveyStorage.set(LAST_SURVEY_KEY, Date.now()); + TelemetryClient.track('HATSSurveyAccepted'); + deleteNotification('survey'); + }, + }, + + { + // this is functionally identical to clicking the close box + label: formatMessage('Remind me later'), + onClick: () => { + TelemetryClient.track('HATSSurveyDismissed'); + deleteNotification('survey'); + }, + }, + ], + rightLinks: [ + { + label: formatMessage('No thanks'), + onClick: () => { + TelemetryClient.track('HATSSurveyRejected'); + surveyStorage.set('optedOut', true); + deleteNotification('survey'); + }, + }, + ], + onDismiss: () => TelemetryClient.track('HATSSurveyDismissed'), + }); + } + }, []); +}; diff --git a/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx b/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx index 0ab13292bc..9fb623adab 100644 --- a/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx +++ b/Composer/packages/client/src/components/Orchestrator/OrchestratorForSkillsDialog.tsx @@ -4,7 +4,7 @@ import { DialogTypes, DialogWrapper } from '@bfc/ui-shared/lib/components/DialogWrapper'; import { SDKKinds } from '@botframework-composer/types'; import { Button } from 'office-ui-fabric-react/lib/components/Button/Button'; -import React, { useMemo } from 'react'; +import React, { useMemo, useEffect } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { enableOrchestratorDialog } from '../../constants'; @@ -14,9 +14,11 @@ import { localeState, orchestratorForSkillsDialogState, rootBotProjectIdSelector, + settingsState, } from '../../recoilModel'; import { recognizersSelectorFamily } from '../../recoilModel/selectors/recognizers'; import { EnableOrchestrator } from '../AddRemoteSkillModal/EnableOrchestrator'; +import { canImportOrchestrator } from '../AddRemoteSkillModal/helper'; export const OrchestratorForSkillsDialog = () => { const [showOrchestratorDialog, setShowOrchestratorDialog] = useRecoilState(orchestratorForSkillsDialogState); @@ -24,6 +26,7 @@ export const OrchestratorForSkillsDialog = () => { const { dialogId } = useRecoilValue(designPageLocationState(rootProjectId)); const locale = useRecoilValue(localeState(rootProjectId)); const curRecognizers = useRecoilValue(recognizersSelectorFamily(rootProjectId)); + const setting = useRecoilValue(settingsState(rootProjectId)); const { updateRecognizer } = useRecoilValue(dispatcherState); @@ -32,6 +35,12 @@ export const OrchestratorForSkillsDialog = () => { return curRecognizers.some((f) => f.id === fileName && f.content.$kind === SDKKinds.OrchestratorRecognizer); }, [curRecognizers, dialogId, locale]); + useEffect(() => { + if (showOrchestratorDialog && hasOrchestrator) { + setShowOrchestratorDialog(false); + } + }, [hasOrchestrator, showOrchestratorDialog]); + const handleOrchestratorSubmit = async (event: React.MouseEvent, enable?: boolean) => { event.preventDefault(); if (enable) { @@ -43,7 +52,7 @@ export const OrchestratorForSkillsDialog = () => { const setVisibility = () => { if (showOrchestratorDialog) { - if (hasOrchestrator) { + if (hasOrchestrator || !canImportOrchestrator(setting?.runtime?.key)) { setShowOrchestratorDialog(false); return false; } @@ -63,7 +72,12 @@ export const OrchestratorForSkillsDialog = () => { title={enableOrchestratorDialog.title} onDismiss={onDismissHandler} > - + ); }; diff --git a/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx b/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx index bdd6ef5181..d24c4472d3 100644 --- a/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx +++ b/Composer/packages/client/src/components/Orchestrator/__tests__/OrchestratorForSkillsDialog.test.tsx @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { act, getQueriesForElement, within } from '@botframework-composer/test-utils'; +import { act, screen, userEvent } from '@botframework-composer/test-utils'; import { SDKKinds } from '@botframework-composer/types'; import * as React from 'react'; -import userEvent from '@testing-library/user-event'; +import { within } from '@testing-library/dom'; import { renderWithRecoil } from '../../../../__tests__/testUtils/renderWithRecoil'; import { @@ -14,12 +14,19 @@ import { localeState, orchestratorForSkillsDialogState, projectMetaDataState, + settingsState, } from '../../../recoilModel'; import { recognizersSelectorFamily } from '../../../recoilModel/selectors/recognizers'; import { OrchestratorForSkillsDialog } from '../OrchestratorForSkillsDialog'; import { importOrchestrator } from '../../AddRemoteSkillModal/helper'; -jest.mock('../../AddRemoteSkillModal/helper'); +jest.mock('../../AddRemoteSkillModal/helper', () => { + const helper = jest.requireActual('../../AddRemoteSkillModal/helper'); + return { + ...helper, + importOrchestrator: jest.fn(), + }; +}); // mimick a project setup with a rootbot and dialog files, and provide conditions for orchestrator skill dialog to be visible const makeInitialState = (set: any) => { @@ -29,6 +36,7 @@ const makeInitialState = (set: any) => { set(projectMetaDataState('rootBotId'), { isRootBot: true, isRemote: false }); set(designPageLocationState('rootBotId'), { dialogId: 'rootBotRootDialogId', focused: 'na', selected: 'na' }); set(localeState('rootBotId'), 'en-us'); + set(settingsState('rootBotId'), { runtime: { key: 'adaptive-runtime-dotnet-webapp' } }); set(recognizersSelectorFamily('rootBotId'), [ { id: 'rootBotRootDialogId.en-us.lu.dialog', content: { $kind: SDKKinds.LuisRecognizer } }, ]); @@ -42,55 +50,153 @@ describe('', () => { }); it('should not open OrchestratorForSkillsDialog if orchestratorForSkillsDialogState is false', () => { - const { baseElement } = renderWithRecoil(, ({ set }) => { + renderWithRecoil(, ({ set }) => { makeInitialState(set); set(orchestratorForSkillsDialogState, false); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeNull(); }); it('should not open OrchestratorForSkillsDialog if orchestrator already being used in root', () => { - const { baseElement } = renderWithRecoil(, ({ set }) => { + renderWithRecoil(, ({ set }) => { makeInitialState(set); set(recognizersSelectorFamily('rootBotId'), [ { id: 'rootBotRootDialogId.en-us.lu.dialog', content: { $kind: SDKKinds.OrchestratorRecognizer } }, ]); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeNull(); }); - it('open OrchestratorForSkillsDialog if orchestratorForSkillsDialogState and Orchestrator not used in Root Bot Root Dialog', () => { + it('should not render OrchestratorForSkillsDialog if runtime is not supported', () => { const { baseElement } = renderWithRecoil(, ({ set }) => { makeInitialState(set); + set(settingsState('rootBotId'), { + defaultLanguage: 'en-us', + languages: ['en-us'], + luis: { + authoringEndpoint: '', + name: '', + authoringKey: '', + defaultLanguage: '', + endpoint: '', + endpointKey: '', + environment: '', + }, + qna: { endpointKey: '', subscriptionKey: '' }, + luFeatures: { enableCompositeEntities: false }, + customFunctions: [], + importedLibraries: [], + runtime: { key: 'node-webapp-v1', command: '', path: '', customRuntime: false }, + }); + }); + const dialog = within(baseElement as HTMLElement).queryByTestId(orchestratorTestId); + expect(dialog).toBeNull(); + }); + + it('should not render OrchestratorForSkillsDialog if runtime is missing or invalid', () => { + const { baseElement } = renderWithRecoil(, ({ set }) => { + makeInitialState(set); + set(settingsState('rootBotId'), { + defaultLanguage: 'en-us', + languages: ['en-us'], + luis: { + authoringEndpoint: '', + name: '', + authoringKey: '', + defaultLanguage: '', + endpoint: '', + endpointKey: '', + environment: '', + }, + qna: { endpointKey: '', subscriptionKey: '' }, + luFeatures: { enableCompositeEntities: false }, + customFunctions: [], + importedLibraries: [], + runtime: { key: '', command: '', path: '', customRuntime: false }, + }); + }); + const dialog = within(baseElement as HTMLElement).queryByTestId(orchestratorTestId); + expect(dialog).toBeNull(); + }); + + it('open OrchestratorForSkillsDialog if orchestratorForSkillsDialogState and Orchestrator not used in Root Bot Root Dialog', () => { + renderWithRecoil(, ({ set }) => { + makeInitialState(set); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeTruthy(); }); it('should install Orchestrator package when user clicks Continue', async () => { + renderWithRecoil(, ({ set }) => { + makeInitialState(set); + }); + + await act(async () => { + userEvent.click(screen.getByTestId('import-orchestrator')); + }); + + expect(importOrchestrator).toBeCalledWith( + 'rootBotId', + { key: 'adaptive-runtime-dotnet-webapp' }, + expect.anything(), + expect.anything() + ); + }); + + it('should install Orchestrator package with adaptive node runtime', async () => { const { baseElement } = renderWithRecoil(, ({ set }) => { makeInitialState(set); + + set(settingsState('rootBotId'), { + defaultLanguage: 'en-us', + languages: ['en-us'], + luis: { + authoringEndpoint: '', + name: '', + authoringKey: '', + defaultLanguage: '', + endpoint: '', + endpointKey: '', + environment: '', + }, + qna: { endpointKey: '', subscriptionKey: '' }, + luFeatures: { enableCompositeEntities: false }, + customFunctions: [], + importedLibraries: [], + runtime: { key: 'adaptive-runtime-js-functions', command: '', path: '', customRuntime: false }, + }); }); await act(async () => { - userEvent.click(within(baseElement).getByTestId('import-orchestrator')); + userEvent.click(within(baseElement as HTMLElement).getByTestId('import-orchestrator')); }); - expect(importOrchestrator).toBeCalledWith('rootBotId', expect.anything(), expect.anything()); + expect(importOrchestrator).toBeCalledWith( + 'rootBotId', + { + key: 'adaptive-runtime-js-functions', + path: expect.anything(), + customRuntime: expect.anything(), + command: expect.anything(), + }, + expect.anything(), + expect.anything() + ); }); it('should not install Orchestrator package when user clicks skip', async () => { - const { baseElement } = renderWithRecoil(, ({ set }) => { + renderWithRecoil(, ({ set }) => { makeInitialState(set); }); await act(async () => { - userEvent.click(await within(baseElement).findByText('Skip')); + userEvent.click(await screen.findByText('Skip')); }); - const dialog = getQueriesForElement(baseElement).queryByTestId(orchestratorTestId); + const dialog = screen.queryByTestId(orchestratorTestId); expect(dialog).toBeNull(); expect(importOrchestrator).toBeCalledTimes(0); diff --git a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx index 258d3392f7..c7a8949107 100644 --- a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx +++ b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx @@ -144,7 +144,7 @@ export const ProjectTree: React.FC = ({ onboardingAddCoachMarkRef, navigateToFormDialogSchema, setPageElementState, - createQnAFromUrlDialogBegin, + createQnADialogBegin, } = useRecoilValue(dispatcherState); const treeRef = useRef(null); @@ -233,7 +233,7 @@ export const ProjectTree: React.FC = ({ label: formatMessage('Add QnA Maker knowledge base'), icon: 'Add', onClick: () => { - createQnAFromUrlDialogBegin({ projectId: skillId, dialogId: dialog.id }); + createQnADialogBegin({ projectId: skillId, dialogId: dialog.id }); TelemetryClient.track('AddNewKnowledgeBaseStarted'); }, }; diff --git a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx index d4a575a8c2..e9120b1d8f 100644 --- a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx +++ b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx @@ -4,7 +4,7 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; import React, { useState, useCallback } from 'react'; -import { FontSizes } from '@uifabric/fluent-theme'; +import { FontSizes, FluentTheme } from '@uifabric/fluent-theme'; import { DefaultPalette } from '@uifabric/styling'; import { OverflowSet, IOverflowSetItemProps } from 'office-ui-fabric-react/lib/OverflowSet'; import { TooltipHost, DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; @@ -184,7 +184,7 @@ const statusIcon = { const warningIcon = { ...statusIcon, - color: '#BE880A', + color: FluentTheme.palette.yellow, }; const errorIcon = { diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx deleted file mode 100644 index c2f7b5d980..0000000000 --- a/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import React, { useState } from 'react'; -import { useRecoilValue } from 'recoil'; - -import { - createQnAOnState, - showCreateQnAFromScratchDialogState, - showCreateQnAFromUrlDialogState, - settingsState, -} from '../../recoilModel'; - -import CreateQnAFromScratchModal from './CreateQnAFromScratchModal'; -import CreateQnAFromUrlModal from './CreateQnAFromUrlModal'; -import { CreateQnAFromModalProps } from './constants'; - -export const CreateQnAModal: React.FC = (props) => { - const { projectId } = useRecoilValue(createQnAOnState); - const settings = useRecoilValue(settingsState(projectId)); - const locales = settings.languages; - const defaultLocale = settings.defaultLanguage; - const showCreateQnAFromScratchDialog = useRecoilValue(showCreateQnAFromScratchDialogState(projectId)); - const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId)); - const [initialName, setInitialName] = useState(''); - if (showCreateQnAFromScratchDialog) { - return ; - } else if (showCreateQnAFromUrlDialog) { - return ( - - ); - } else { - return null; - } -}; - -export default CreateQnAModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromQnAMaker.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromQnAMaker.tsx new file mode 100644 index 0000000000..f36a17d815 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromQnAMaker.tsx @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect } from 'react'; +import formatMessage from 'format-message'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { Text } from 'office-ui-fabric-react/lib/Text'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; + +import { validateName, CreateQnAFromFormProps, CreateQnAFromQnAMakerFormData } from './constants'; +import { knowledgeBaseStyle, subText } from './styles'; + +const formConfig: FieldConfig = { + name: { + required: true, + defaultValue: '', + }, +}; + +export const CreateQnAFromQnAMaker: React.FC = (props) => { + const { onChange, qnaFiles, initialName } = props; + + formConfig.name.validate = validateName(qnaFiles); + formConfig.name.defaultValue = initialName || ''; + const { formData, hasErrors } = useForm(formConfig); + + useEffect(() => { + const disabled = hasErrors || !formData.name; + onChange(formData, disabled); + }, [formData]); + + return ( + + + + {formatMessage('Import content from an existing knowledge base on the QnA maker portal')} + + + + {formatMessage( + 'Import content from an existing knowledge base on the QnA maker portal. Your knowledge base will downloaded locally and source knowledge base will remain as-is.' + )} + + + + + ); +}; + +export default CreateQnAFromQnAMaker; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromScratch.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromScratch.tsx new file mode 100644 index 0000000000..7a0c70e901 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromScratch.tsx @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect } from 'react'; +import formatMessage from 'format-message'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; + +import { validateName, CreateQnAFromFormProps, CreateQnAFromScratchFormData } from './constants'; +import { textFieldKBNameFromScratch } from './styles'; + +const formConfig: FieldConfig = { + name: { + required: true, + defaultValue: '', + }, +}; + +export const CreateQnAFromScratch: React.FC = (props) => { + const { onChange, qnaFiles, initialName, onUpdateInitialName } = props; + + formConfig.name.validate = validateName(qnaFiles); + formConfig.name.defaultValue = initialName || ''; + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + + useEffect(() => { + const disabled = hasErrors || !formData.name; + onChange(formData, disabled); + }, [formData, hasErrors]); + + return ( + + + { + updateField('name', name); + onUpdateInitialName?.(name); + }} + /> + + + ); +}; + +export default CreateQnAFromScratch; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx deleted file mode 100644 index 19dd8d7236..0000000000 --- a/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx +++ /dev/null @@ -1,120 +0,0 @@ -// 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 TelemetryClient from '../../telemetry/TelemetryClient'; - -import { validateName, CreateQnAFromModalProps, CreateQnAFromScratchFormData } from './constants'; -import { subText, styles, dialogWindowMini, textFieldKBNameFromScratch } from './styles'; - -const formConfig: FieldConfig = { - name: { - required: true, - defaultValue: '', - }, -}; - -const DialogTitle = () => { - return ( - - {formatMessage('Add QnA Maker knowledge base')} - - {formatMessage('Manually add question and answer pairs to create a knowledge base')} - - - ); -}; - -export const CreateQnAFromScratchModal: React.FC = (props) => { - const { onDismiss, onSubmit, qnaFiles, projectId, initialName, onUpdateInitialName } = props; - const actions = useRecoilValue(dispatcherState); - const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId)); - - formConfig.name.validate = validateName(qnaFiles); - formConfig.name.defaultValue = initialName || ''; - const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); - const disabled = hasErrors || !formData.name; - - const handleDismiss = () => { - onDismiss?.(); - onUpdateInitialName?.(''); - actions.createQnAFromScratchDialogCancel({ projectId }); - TelemetryClient.track('AddNewKnowledgeBaseCanceled'); - }; - - return ( - , - styles: styles.dialog, - }} - hidden={false} - modalProps={{ - isBlocking: false, - styles: styles.modalCreateFromScratch, - }} - onDismiss={handleDismiss} - > - - - { - updateField('name', name); - onUpdateInitialName?.(name); - }} - /> - - - - {showCreateQnAFromUrlDialog && ( - { - actions.createQnAFromScratchDialogBack({ projectId }); - }} - /> - )} - { - handleDismiss(); - }} - /> - { - if (hasErrors) { - return; - } - onSubmit(formData); - onUpdateInitialName?.(''); - TelemetryClient.track('AddNewKnowledgeBaseCompleted', { scratch: true }); - }} - /> - - - ); -}; - -export default CreateQnAFromScratchModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromUrl.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromUrl.tsx new file mode 100644 index 0000000000..0d7495d44c --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromUrl.tsx @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useState, useMemo, Fragment, useEffect } from 'react'; +import formatMessage from 'format-message'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { Text } from 'office-ui-fabric-react/lib/Text'; +import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; + +import { Locales } from '../../locales'; + +import { initializeLocales } from './utilities'; +import { + validateUrl, + CreateQnAFromFormProps, + CreateQnAFromUrlFormData, + CreateQnAFromUrlFormDataErrors, +} from './constants'; +import { textFieldUrl, warning, urlPairStyle, knowledgeBaseStyle, urlStackStyle, subText } from './styles'; + +const hasErrors = (errors: CreateQnAFromUrlFormDataErrors) => { + return !!errors.name || errors.urls.some((e) => !!e); +}; + +export const CreateQnAFromUrl: React.FC = (props) => { + const { onChange, dialogId, locales, defaultLocale, initialName } = props; + + const index = Locales.findIndex((l) => l.locale === locales[0]); + const initialLanguage = index > -1 ? Locales[index].language : locales[0]; + const [formData, setFormData] = useState({ + urls: [], + locales: initializeLocales(locales, defaultLocale), + language: initialLanguage, + name: initialName || '', + multiTurn: false, + }); + + const [formDataErrors, setFormDataErrors] = useState({ + urls: [], + name: '', + }); + + const usedLocales = useMemo(() => { + return locales.map((fl) => { + const index = Locales.findIndex((l) => l.locale === fl); + if (index > -1) { + return { text: Locales[index].language, locale: fl, key: fl }; + } else { + return { text: fl, locale: fl, key: fl }; + } + }); + }, [locales]); + + const isQnAFileselected = !(dialogId === 'all'); + + const onChangeUrlsField = (value: string | undefined) => { + const urls = [...formData.urls]; + urls[0] = value ?? ''; + updateUrlsField(urls); + updateUrlsError(urls); + }; + + const onChangeMultiTurn = (value: boolean | undefined) => { + setFormData({ + ...formData, + multiTurn: value ?? false, + }); + }; + + const updateUrlsField = (urls: string[]) => { + setFormData({ + ...formData, + urls: urls, + }); + }; + + const updateUrlsError = (urls: string[]) => { + const urlErrors = urls.map((url) => { + return validateUrl(url); + }) as string[]; + setFormDataErrors({ ...formDataErrors, urls: urlErrors }); + }; + + const onChangeLanguageField = (option) => { + setFormData({ + ...formData, + language: option.text, + locales: [option.key], + }); + }; + + useEffect(() => { + const disabled = hasErrors(formDataErrors) || !formData.urls[0] || !formData.name; + onChange(formData, disabled); + }, [formData, formDataErrors]); + + return ( + + + {formatMessage('Create new knowledge base from URL')} + + + {formatMessage( + 'Select this option if you want to create a knowledge base from content hosted online such as an FAQ or document link (.csv, .xls or .doc format)' + )} + + + + + {formatMessage('Source URL')} + + onChangeUrlsField(url)} + /> + + { + onChangeLanguageField(o); + }} + /> + + {!isQnAFileselected && ( + {formatMessage('Please select a specific qna file to import QnA')} + )} + + + onChangeMultiTurn(val)} + /> + + + ); +}; + +export default CreateQnAFromUrl; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx deleted file mode 100644 index 1e12dee3a8..0000000000 --- a/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx +++ /dev/null @@ -1,269 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import React, { useState, useMemo } 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 { Text } from 'office-ui-fabric-react/lib/Text'; -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 { Locales } from '../../locales'; -import { dispatcherState, onCreateQnAFromUrlDialogCompleteState } from '../../recoilModel'; -import TelemetryClient from '../../telemetry/TelemetryClient'; - -import { - knowledgeBaseSourceUrl, - validateUrl, - validateName, - CreateQnAFromUrlModalProps, - CreateQnAFromUrlFormData, - CreateQnAFromUrlFormDataErrors, -} from './constants'; -import { - subText, - styles, - dialogWindow, - textFieldUrl, - textFieldKBNameFromUrl, - warning, - urlPairStyle, - knowledgeBaseStyle, - urlStackStyle, -} from './styles'; - -const DialogTitle = () => { - return ( - - {formatMessage('Add QnA Maker knowledge base')} - - - {formatMessage('Use Azure QnA Maker to extract question-and-answer pairs from an online FAQ. ')} - - {formatMessage('Learn more')} - - - - - ); -}; - -const hasErrors = (errors: CreateQnAFromUrlFormDataErrors) => { - return !!errors.name || errors.urls.some((e) => !!e); -}; - -const initializeLocales = (locales: string[], defaultLocale: string) => { - const newLocales = [...locales]; - const index = newLocales.findIndex((l) => l === defaultLocale); - if (index < 0) throw new Error(`default language ${defaultLocale} does not exist in languages`); - newLocales.splice(index, 1); - newLocales.sort(); - newLocales.unshift(defaultLocale); - return newLocales; -}; - -export const CreateQnAFromUrlModal: React.FC = (props) => { - const { - onDismiss, - onSubmit, - dialogId, - projectId, - qnaFiles, - locales, - defaultLocale, - initialName, - onUpdateInitialName, - } = props; - const actions = useRecoilValue(dispatcherState); - const onComplete = useRecoilValue(onCreateQnAFromUrlDialogCompleteState(projectId)); - - const [formData, setFormData] = useState({ - urls: [], - locales: initializeLocales(locales, defaultLocale), - name: initialName || '', - multiTurn: false, - }); - - const [formDataErrors, setFormDataErrors] = useState({ - urls: [], - name: '', - }); - - const usedLocales = useMemo(() => { - return formData.locales.map((fl) => { - const index = Locales.findIndex((l) => l.locale === fl); - if (index > -1) { - return Locales[index].language; - } - }); - }, [formData.locales]); - - const isQnAFileselected = !(dialogId === 'all'); - const disabled = hasErrors(formDataErrors) || !formData.urls[0] || !formData.name; - const validFormDataName = validateName(qnaFiles); - - const onChangeNameField = (value: string | undefined) => { - updateNameField(value); - onUpdateInitialName?.(value ?? ''); - updateNameError(value); - }; - - const onChangeUrlsField = (value: string | undefined, index: number) => { - const urls = [...formData.urls]; - urls[index] = value ?? ''; - updateUrlsField(urls); - updateUrlsError(urls); - }; - - const onChangeMultiTurn = (value: boolean | undefined) => { - setFormData({ - ...formData, - multiTurn: value ?? false, - }); - }; - - const updateNameField = (value: string | undefined) => { - setFormData({ - ...formData, - name: value ?? '', - }); - }; - - const updateNameError = (value: string | undefined) => { - const error = validFormDataName(value) as string; - setFormDataErrors({ ...formDataErrors, name: error ?? '' }); - }; - - const updateUrlsField = (urls: string[]) => { - setFormData({ - ...formData, - urls: urls, - }); - }; - - const updateUrlsError = (urls: string[]) => { - const urlErrors = urls.map((url) => { - return validateUrl(url); - }) as string[]; - setFormDataErrors({ ...formDataErrors, urls: urlErrors }); - }; - - const handleDismiss = () => { - onDismiss?.(); - onUpdateInitialName?.(''); - actions.createQnAFromUrlDialogCancel({ projectId }); - TelemetryClient.track('AddNewKnowledgeBaseCanceled'); - }; - - const removeEmptyUrls = (formData: CreateQnAFromUrlFormData) => { - const urls: string[] = []; - const locales: string[] = []; - for (let i = 0; i < formData.urls.length; i++) { - if (formData.urls[i]) { - urls.push(formData.urls[i]); - locales.push(formData.locales[i]); - } - } - return { - ...formData, - locales, - urls, - }; - }; - - return ( - , - styles: styles.dialog, - }} - hidden={false} - modalProps={{ - isBlocking: false, - styles: styles.modalCreateFromUrl, - }} - onDismiss={handleDismiss} - > - - - onChangeNameField(name)} - /> - {formatMessage('FAQ website (source)')} - {formData.locales.map((locale, i) => { - return ( - - onChangeUrlsField(url, i)} - /> - - ); - })} - - {!isQnAFileselected && ( - {formatMessage('Please select a specific qna file to import QnA')} - )} - - - onChangeMultiTurn(val)} - /> - - - - { - // switch to create from scratch flow, pass onComplete callback. - actions.createQnAFromScratchDialogBegin({ projectId, dialogId, onComplete: onComplete?.func }); - }} - /> - { - handleDismiss(); - }} - /> - { - if (hasErrors(formDataErrors)) { - return; - } - onSubmit(removeEmptyUrls(formData)); - onUpdateInitialName?.(''); - TelemetryClient.track('AddNewKnowledgeBaseCompleted', { scratch: false }); - }} - /> - - - ); -}; - -export default CreateQnAFromUrlModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAModal.tsx new file mode 100644 index 0000000000..83ffbbc86e --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAModal.tsx @@ -0,0 +1,683 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { + DetailsList, + SelectionMode, + DetailsListLayoutMode, + IColumn, + Selection, + DetailsRow, + IDetailsRowProps, + CheckboxVisibility, +} from 'office-ui-fabric-react/lib/DetailsList'; +import { Spinner } from 'office-ui-fabric-react/lib/Spinner'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; +import { SubscriptionClient } from '@azure/arm-subscriptions'; +import { Subscription } from '@azure/arm-subscriptions/esm/models'; +import { TokenCredentials } from '@azure/ms-rest-js'; +import { CognitiveServicesManagementClient } from '@azure/arm-cognitiveservices'; +import { CognitiveServicesCredentials } from '@azure/ms-rest-azure-js'; +import { QnAMakerClient } from '@azure/cognitiveservices-qnamaker'; +import sortBy from 'lodash/sortBy'; +import uniq from 'lodash/uniq'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import { IRenderFunction } from '@uifabric/utilities'; + +import TelemetryClient from '../../telemetry/TelemetryClient'; +import { + createQnAOnState, + showCreateQnADialogState, + settingsState, + dispatcherState, + localeState, + currentUserState, + isAuthenticatedState, + showAuthDialogState, +} from '../../recoilModel'; + +import { CreateQnAFormData, CreateQnAModalProps, QnAMakerLearnMoreUrl } from './constants'; +import { + subText, + styles, + contentBox, + formContainer, + choiceContainer, + nameStepContainer, + resourceDropdown, + dialogBodyStyles, +} from './styles'; +import { CreateQnAFromUrl } from './CreateQnAFromUrl'; +import { CreateQnAFromScratch } from './CreateQnAFromScratch'; +import { CreateQnAFromQnAMaker } from './CreateQnAFromQnAMaker'; +import { localeToLanguage } from './utilities'; +import { PersonaCard } from './PersonaCard'; + +type KeyRec = { + name: string; + region: string; + resourceGroup: string; + key: string; + endpoint: string; +}; + +type KBRec = { + id: string; + name: string; + language: string; + lastChangedTimestamp: string; +}; + +type Step = 'name' | 'intro' | 'resource' | 'knowledge-base' | 'outcome'; + +const mainElementStyle = { marginBottom: 20 }; +const serviceName = 'QnA Maker'; +const serviceKeyType = 'QnAMaker'; + +export const CreateQnAModal: React.FC = (props) => { + const { onDismiss, onSubmit } = props; + const { projectId } = useRecoilValue(createQnAOnState); + const settings = useRecoilValue(settingsState(projectId)); + const actions = useRecoilValue(dispatcherState); + const locales = settings.languages; + const defaultLocale = settings.defaultLanguage; + const currentLocale = useRecoilValue(localeState(projectId)); + const showCreateQnAFrom = useRecoilValue(showCreateQnADialogState(projectId)); + const [initialName, setInitialName] = useState(''); + const [formData, setFormData] = useState(); + const [disabled, setDisabled] = useState(true); + const { setApplicationLevelError, requireUserLogin } = useRecoilValue(dispatcherState); + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + const showAuthDialog = useRecoilValue(showAuthDialogState); + + const [subscriptionId, setSubscription] = useState(''); + + const [loading, setLoading] = useState(undefined); + const [noKeys, setNoKeys] = useState(false); + const [nextAction, setNextAction] = useState('url'); + const [key, setKey] = useState(); + const [region, setRegion] = useState(''); + const [availableSubscriptions, setAvailableSubscriptions] = useState([]); + const [subscriptionsErrorMessage, setSubscriptionsErrorMessage] = useState(); + const [keys, setKeys] = useState([]); + const [kbs, setKbs] = useState<{ [key: string]: KBRec[] }>({}); + const [selectedKb, setSelectedKb] = useState(); + const [dialogTitle, setDialogTitle] = useState(''); + + const [currentStep, setCurrentStep] = useState('name'); + + const currentAuthoringLanuage = localeToLanguage(currentLocale); + const defaultLanuage = localeToLanguage(defaultLocale); + const avaliableLanguages = uniq(locales.map((item) => localeToLanguage(item))); + + const actionOptions: IChoiceGroupOption[] = [ + { key: 'url', text: formatMessage('Create new knowledge base from URL') }, + { + key: 'portal', + text: formatMessage('Import existing knowledge base from QnA maker portal'), + }, + ]; + + /* Copied from Azure Publishing extension */ + const getSubscriptions = async (token: string): Promise> => { + const tokenCredentials = new TokenCredentials(token); + try { + const subscriptionClient = new SubscriptionClient(tokenCredentials); + const subscriptionsResult = await subscriptionClient.subscriptions.list(); + // eslint-disable-next-line no-underscore-dangle + return sortBy(subscriptionsResult._response.parsedBody, ['displayName']); + } catch (err) { + setApplicationLevelError(err); + return []; + } + }; + + const selectedKB = useMemo(() => { + return new Selection({ + onSelectionChanged: () => { + const t = selectedKB.getSelection()[0] as KBRec; + + if (t) { + setSelectedKb(t); + } + }, + }); + }, []); + + useEffect(() => { + if (isAuthenticated && showCreateQnAFrom) { + setAvailableSubscriptions([]); + setSubscriptionsErrorMessage(undefined); + getSubscriptions(currentUser.token) + .then((data) => { + setAvailableSubscriptions(data); + if (data.length === 0) { + setSubscriptionsErrorMessage( + formatMessage( + 'Your subscription list is empty, please add your subscription, or login with another account.' + ) + ); + } + }) + .catch((err) => { + setSubscriptionsErrorMessage(err.message); + }); + } + }, [currentUser, isAuthenticated, showCreateQnAFrom]); + + useEffect(() => { + // reset the ui + setSubscription(''); + setKeys([]); + setCurrentStep('name'); + setSelectedKb(undefined); + setKbs({}); + }, [showCreateQnAFrom]); + + const fetchKeys = async (cognitiveServicesManagementClient, accounts) => { + const keyList: KeyRec[] = []; + for (const account in accounts) { + const resourceGroup = accounts[account].id?.replace(/.*?\/resourceGroups\/(.*?)\/.*/, '$1'); + const name = accounts[account].name; + if (resourceGroup && name) { + try { + const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); + if (keys?.key1) { + keyList.push({ + name, + resourceGroup, + region: accounts[account].location || '', + key: keys?.key1 || '', + endpoint: accounts[account]?.properties?.endpoint ?? '', + }); + } + } catch (_err) { + // pass, filter no authorization resource + } + } + } + return keyList; + }; + + const fetchAccounts = async (subscriptionId) => { + if (isAuthenticated) { + setLoading(formatMessage('Loading keys...')); + setNoKeys(false); + const tokenCredentials = new TokenCredentials(currentUser.token); + const cognitiveServicesManagementClient = new CognitiveServicesManagementClient(tokenCredentials, subscriptionId); + const accounts = await cognitiveServicesManagementClient.accounts.list(); + + const keylist: KeyRec[] = await fetchKeys( + cognitiveServicesManagementClient, + accounts.filter((a) => a.kind === serviceKeyType) + ); + + const kbsMap = {}; + const avaliableKeys: KeyRec[] = []; + for (const keyItem of keylist) { + const kbGroups = await fetchKBGroups(keyItem); + kbsMap[keyItem.name] = kbGroups; + if (kbGroups.length) { + avaliableKeys.push(keyItem); + } + } + setKbs(kbsMap); + setLoading(undefined); + if (avaliableKeys.length == 0) { + setNoKeys(true); + } else { + setNoKeys(false); + setKeys(avaliableKeys); + } + } + }; + + const fetchKBGroups = async (key: KeyRec) => { + let kblist: KBRec[] = []; + if (isAuthenticated && key) { + const cognitiveServicesCredentials = new CognitiveServicesCredentials(key.key); + const resourceClient = new QnAMakerClient(cognitiveServicesCredentials, key.endpoint); + + const result = await resourceClient.knowledgebase.listAll(); + + if (result.knowledgebases) { + kblist = result.knowledgebases.map((item: any) => { + return { + id: item.id || '', + name: item.name || '', + language: item.language || '', + lastChangedTimestamp: item.lastChangedTimestamp || '', + }; + }); + } + } + return kblist; + }; + // allow a user to provide a subscription id if one is missing + const onChangeSubscription = async (_, opt) => { + // get list of keys for this subscription + setSubscription(opt.key); + fetchAccounts(opt.key); + setLoading(formatMessage('Loading subscription...')); + }; + + const onChangeKey = async (_, opt) => { + // get list of keys for this subscription + setKey(opt); + setRegion(opt.region); + }; + + const onChangeAction = async (_, opt) => { + setNextAction(opt.key); + }; + + const chooseExistingKey = () => { + TelemetryClient.track('SettingsGetKeysExistingResourceSelected', { + subscriptionId, + resourceType: serviceName, + }); + setCurrentStep('knowledge-base'); + }; + + const performNextAction = () => { + if (nextAction === 'url') { + onSubmitFormData(nextAction); + } else { + requireUserLogin(); + setCurrentStep('resource'); + } + }; + + const renderNameStep = () => { + return ( + + + + + {formatMessage('Use Azure QnA Maker to extract question-and-answer pairs from an online FAQ. ')} + + {formatMessage('Learn more')} + + + + + + + setCurrentStep('intro')} + /> + + + + ); + }; + + const renderIntroStep = () => { + return ( + + + + {formatMessage('Create a knowledge base from a URL or import content from an existing knowledge base')} + + + + + + + + {nextAction === 'url' ? ( + + ) : ( + + )} + + + + onSubmitFormData('scratch')} + /> + setCurrentStep('name')} /> + + + + + ); + }; + + const renderChooseResourceStep = () => { + return ( + + + + {formatMessage('Select the subscription and resource you want to choose a knowledge base from')} + + + 0)} + errorMessage={subscriptionsErrorMessage} + label={formatMessage('Azure subscription')} + options={ + availableSubscriptions + ?.filter((p) => p.subscriptionId && p.displayName) + .map((p) => { + return { key: p.subscriptionId ?? '', text: p.displayName ?? formatMessage('Unnamed') }; + }) ?? [] + } + placeholder={formatMessage('Select a subscription')} + selectedKey={subscriptionId} + styles={resourceDropdown} + onChange={onChangeSubscription} + /> + + + {noKeys && subscriptionId && ( + + {formatMessage( + 'No existing QnA Maker resources were found in this subscription. Select a different subscription, or click “Back” to create a new resource or generate a resource request to handoff to your Azure admin.' + )} + + )} + {!noKeys && subscriptionId && ( + + 0)} + label={formatMessage('QnA Maker resource name')} + options={ + keys.map((p) => { + return { text: p.name, ...p }; + }) ?? [] + } + placeholder={formatMessage('Select resource')} + styles={resourceDropdown} + onChange={onChangeKey} + /> + + )} + + + + + + + {loading && } + setCurrentStep('intro')} /> + + + + + ); + }; + + const renderKnowledgeBaseSelectionStep = () => { + const columns: IColumn[] = [ + { + key: 'column2', + name: 'Name', + fieldName: 'name', + minWidth: 50, + maxWidth: 350, + isRowHeader: true, + isResizable: true, + isSorted: true, + isSortedDescending: false, + sortAscendingAriaLabel: 'Sorted A to Z', + sortDescendingAriaLabel: 'Sorted Z to A', + data: 'string', + isPadded: true, + }, + { + key: 'column3', + name: 'Last modified', + fieldName: 'lastModified', + minWidth: 200, + maxWidth: 300, + isResizable: true, + data: 'string', + isPadded: true, + onRender: (item) => { + const dt = new Date(item.lastChangedTimestamp); + return ( + + {' '} + {dt.toDateString()} {dt.toLocaleTimeString()}{' '} + + ); + }, + }, + { + key: 'column4', + name: 'Language', + fieldName: 'language', + minWidth: 100, + maxWidth: 200, + isResizable: true, + isCollapsible: true, + data: 'string', + isPadded: true, + }, + ]; + + const currentLanguageKbs: KBRec[] = []; + const defaultLanuageKbs: KBRec[] = []; + const avaliableLanguageKbs: KBRec[] = []; + const disabledLanguageKbs: KBRec[] = []; + + const currentKbs = key ? kbs[key.name] : []; + currentKbs.forEach((item) => { + if (item.language === currentAuthoringLanuage) { + currentLanguageKbs.push(item); + } else if (item.language === defaultLanuage) { + defaultLanuageKbs.push(item); + } else if (avaliableLanguages.includes(item.language)) { + avaliableLanguageKbs.push(item); + } else { + disabledLanguageKbs.push(item); + } + }); + + const sortedKbs = [...currentLanguageKbs, ...defaultLanuageKbs, ...avaliableLanguageKbs, ...disabledLanguageKbs]; + + const onRenderRow: IRenderFunction = (props) => { + if (!props) return null; + if (avaliableLanguages.includes(props.item.language)) { + return ; + } else { + return ( + + + + ); + } + }; + + return ( + + + + {formatMessage('Select one or more knowledge base to import into your bot project')} + + + item.name} + items={sortedKbs} + layoutMode={DetailsListLayoutMode.justified} + selection={selectedKB} + selectionMode={SelectionMode.single} + onRenderRow={onRenderRow} + /> + + + + {loading && } + setCurrentStep('resource')} /> + + + + + ); + }; + + const renderCurrentStep = () => { + switch (currentStep) { + case 'name': + return renderNameStep(); + case 'intro': + return renderIntroStep(); + case 'resource': { + if (nextAction === 'portal') { + return renderChooseResourceStep(); + } + break; + } + case 'knowledge-base': + return renderKnowledgeBaseSelectionStep(); + default: + return null; + } + }; + + useEffect(() => { + switch (currentStep) { + case 'name': + setDialogTitle(formatMessage('Add QnA Maker knowledge base')); + break; + case 'intro': + setDialogTitle(formatMessage(`Select a source for your knowledge base's content`)); + break; + case 'resource': + if (nextAction === 'portal') { + setDialogTitle(formatMessage('Select source knowledge base location')); + } + break; + case 'knowledge-base': + setSelectedKb(undefined); + setDialogTitle(formatMessage('Choose a knowledge base to import')); + break; + } + }, [currentStep]); + + const handleDismiss = () => { + onDismiss?.(); + setInitialName(''); + actions.createQnADialogCancel({ projectId }); + TelemetryClient.track('AddNewKnowledgeBaseCanceled'); + }; + + const onFormDataChange = (data, disabled) => { + setFormData(data); + setDisabled(disabled); + }; + + const onSubmitFormData = (createFrom: string) => { + if (!formData) return; + if (createFrom === 'url' && disabled) return; + + onSubmit(formData); + setInitialName(''); + TelemetryClient.track('AddNewKnowledgeBaseCompleted', { source: formData.urls?.length ? 'url' : 'none' }); + }; + + const onSubmitImportKB = async () => { + if (key && isAuthenticated && selectedKb && formData) { + // TODO: add to all matched language or ask user for specific locale. + const createdOnLocales = locales.filter((item) => localeToLanguage(item) === selectedKb.language); + onSubmit({ + ...formData, + locales: createdOnLocales, + endpoint: key.endpoint, + kbId: selectedKb.id, + kbName: selectedKb.name, + subscriptionKey: key.key, + }); + setInitialName(''); + TelemetryClient.track('AddNewKnowledgeBaseCompleted', { source: 'kb' }); + } + }; + + return ( + + {} : handleDismiss} + > + {renderCurrentStep()} + + + ); +}; + +export default CreateQnAModal; diff --git a/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx b/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx index d1f39a0090..30161fba45 100644 --- a/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx +++ b/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx @@ -40,7 +40,7 @@ const formConfig: FieldConfig = { }; const DialogTitle = () => { - return {formatMessage('Edit KB name')}; + return {formatMessage('Edit knowledge base name')}; }; export const EditQnAFromScratchModal: React.FC = (props) => { diff --git a/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx index d787ad230b..0012a524f5 100644 --- a/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx +++ b/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx @@ -46,7 +46,7 @@ const formConfig: FieldConfig = { }; const DialogTitle = () => { - return {formatMessage('Edit KB name')}; + return {formatMessage('Edit knowledge base name')}; }; export const EditQnAFromUrlModal: React.FC = (props) => { diff --git a/Composer/packages/client/src/components/QnA/ImportQnAFromUrl.tsx b/Composer/packages/client/src/components/QnA/ImportQnAFromUrl.tsx new file mode 100644 index 0000000000..179fdd5778 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/ImportQnAFromUrl.tsx @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useEffect } from 'react'; +import formatMessage from 'format-message'; +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 { QnAFile } from '@bfc/shared'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { getQnAFileUrlOption, getQnAFileMultiTurnOption } from '../../utils/qnaUtil'; +import { getExtension } from '../../utils/fileUtil'; +import { Locales } from '../../locales'; + +import { validateUrl } from './constants'; +import { header, titleStyle, descriptionStyle, dialogWindow, textFieldKBNameFromScratch } from './styles'; + +type ImportQnAFromUrlModalProps = { + qnaFile: QnAFile; + onChange: (data, disabled: boolean) => void; +}; + +export type ImportQnAFromUrlFormData = { + url: string; + multiTurn: boolean; +}; + +const formConfig: FieldConfig = { + url: { + required: true, + defaultValue: '', + }, + multiTurn: { + defaultValue: false, + }, +}; + +const title = {formatMessage('Replace knowledge base from URL')}; + +const description = ( + + {formatMessage( + 'Select this option if you want to replace current knowledge base from content hosted online such as an FAQ or document link (.csv, .xls or .doc format)' + )} + +); + +export const ImportQnAFromUrl: React.FC = (props) => { + const { qnaFile, onChange } = props; + const locale = getExtension(qnaFile.id); + formConfig.url.validate = validateUrl; + formConfig.url.defaultValue = getQnAFileUrlOption(qnaFile); + formConfig.multiTurn.defaultValue = getQnAFileMultiTurnOption(qnaFile); + const { formData, updateField, formErrors, hasErrors } = useForm(formConfig); + + const getLanguage = (locale) => { + const index = Locales.findIndex((l) => l.locale === locale); + if (index > -1) { + return Locales[index].language; + } + }; + + const updateUrl = (url = '') => { + updateField('url', url); + }; + + const updateMultiTurn = (multiTurn = false) => { + updateField('multiTurn', multiTurn); + }; + + useEffect(() => { + onChange(formData, hasErrors); + }, [formData, hasErrors]); + + return ( + + + + {title} + {description} + + + updateUrl(url)} + /> + + + updateMultiTurn(val)} + /> + + + ); +}; + +export default ImportQnAFromUrl; diff --git a/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx index 27c10a7d90..d031e7dec8 100644 --- a/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx +++ b/Composer/packages/client/src/components/QnA/ImportQnAFromUrlModal.tsx @@ -78,7 +78,7 @@ export const ImportQnAFromUrlModal: React.FC = (prop { + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + + const { logoutUser } = useRecoilValue(dispatcherState); + + const onLogout = async () => { + const confirmed = await OpenConfirmModal( + formatMessage('Sign out of Azure'), + formatMessage( + 'By signing out of Azure, your operation will be canceled and this dialog will close. Do you want to continue?' + ), + { + onRenderContent: (subtitle: string) => {subtitle}, + confirmText: formatMessage('Sign out'), + cancelText: formatMessage('Cancel'), + } + ); + if (confirmed) { + await logoutUser(); + } + }; + + const onRenderSecondaryText: IRenderFunction = (props) => { + if (!props) return null; + return ( + + {props.secondaryText} + + ); + }; + + return isAuthenticated && currentUser ? ( + + ) : null; +}; diff --git a/Composer/packages/client/src/components/QnA/ReplaceQnAFromModal.tsx b/Composer/packages/client/src/components/QnA/ReplaceQnAFromModal.tsx new file mode 100644 index 0000000000..f623f40be5 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/ReplaceQnAFromModal.tsx @@ -0,0 +1,605 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { + DetailsList, + SelectionMode, + DetailsListLayoutMode, + IColumn, + Selection, + IDetailsRowProps, + DetailsRow, + CheckboxVisibility, +} from 'office-ui-fabric-react/lib/DetailsList'; +import { Spinner } from 'office-ui-fabric-react/lib/Spinner'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; +import { SubscriptionClient } from '@azure/arm-subscriptions'; +import { Subscription } from '@azure/arm-subscriptions/esm/models'; +import { TokenCredentials } from '@azure/ms-rest-js'; +import { CognitiveServicesManagementClient } from '@azure/arm-cognitiveservices'; +import { CognitiveServicesCredentials } from '@azure/ms-rest-azure-js'; +import { QnAMakerClient } from '@azure/cognitiveservices-qnamaker'; +import sortBy from 'lodash/sortBy'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import { IRenderFunction } from '@uifabric/utilities'; + +import TelemetryClient from '../../telemetry/TelemetryClient'; +import { getKBName, getFileLocale } from '../../utils/qnaUtil'; +import { dispatcherState, currentUserState, isAuthenticatedState, showAuthDialogState } from '../../recoilModel'; + +import { localeToLanguage } from './utilities'; +import { ReplaceQnAModalFormData, ReplaceQnAModalProps } from './constants'; +import { + styles, + subText, + contentBox, + formContainer, + choiceContainer, + titleStyle, + descriptionStyle, + resourceDropdown, + dialogBodyStyles, +} from './styles'; +import { ImportQnAFromUrl } from './ImportQnAFromUrl'; +import { PersonaCard } from './PersonaCard'; + +type KeyRec = { + name: string; + region: string; + resourceGroup: string; + key: string; + endpoint: string; +}; + +type KBRec = { + name: string; + language: string; + id: string; + lastChangedTimestamp: string; +}; + +type Step = 'intro' | 'resource' | 'knowledge-base' | 'outcome'; + +const mainElementStyle = { marginBottom: 20 }; +const serviceName = 'QnA Maker'; +const serviceKeyType = 'QnAMaker'; + +export const ReplaceQnAFromModal: React.FC = (props) => { + const { onDismiss, onSubmit, hidden, qnaFile, projectId, containerId } = props; + const actions = useRecoilValue(dispatcherState); + const [formData, setFormData] = useState(); + const [disabled, setDisabled] = useState(true); + const { setApplicationLevelError, requireUserLogin } = useRecoilValue(dispatcherState); + const currentUser = useRecoilValue(currentUserState); + const isAuthenticated = useRecoilValue(isAuthenticatedState); + const showAuthDialog = useRecoilValue(showAuthDialogState); + + const [subscriptionId, setSubscription] = useState(''); + const [loading, setLoading] = useState(undefined); + const [noKeys, setNoKeys] = useState(false); + const [nextAction, setNextAction] = useState('url'); + const [key, setKey] = useState(); + const [region, setRegion] = useState(''); + const [availableSubscriptions, setAvailableSubscriptions] = useState([]); + const [subscriptionsErrorMessage, setSubscriptionsErrorMessage] = useState(); + const [keys, setKeys] = useState([]); + const [kbs, setKbs] = useState<{ [key: string]: KBRec[] }>({}); + + const [selectedKb, setSelectedKb] = useState(); + const [dialogTitle, setDialogTitle] = useState(''); + + const [currentStep, setCurrentStep] = useState('intro'); + + const currentLocale = getFileLocale(containerId); + const currentAuthoringLanuage = localeToLanguage(currentLocale); + + const actionOptions: IChoiceGroupOption[] = [ + { key: 'url', text: formatMessage('Replace knowledge base from URL') }, + { + key: 'portal', + text: formatMessage('Replace with an existing knowledge base from QnA maker portal'), + }, + ]; + + /* Copied from Azure Publishing extension */ + const getSubscriptions = async (token: string): Promise> => { + const tokenCredentials = new TokenCredentials(token); + try { + const subscriptionClient = new SubscriptionClient(tokenCredentials); + const subscriptionsResult = await subscriptionClient.subscriptions.list(); + // eslint-disable-next-line no-underscore-dangle + return sortBy(subscriptionsResult._response.parsedBody, ['displayName']); + } catch (err) { + setApplicationLevelError(err); + return []; + } + }; + + const selectedKB = useMemo(() => { + return new Selection({ + onSelectionChanged: () => { + const t = selectedKB.getSelection()[0] as KBRec; + + if (t) { + setSelectedKb(t); + } + }, + }); + }, []); + + useEffect(() => { + if (isAuthenticated && !hidden) { + setAvailableSubscriptions([]); + setSubscriptionsErrorMessage(undefined); + getSubscriptions(currentUser.token) + .then((data) => { + setAvailableSubscriptions(data); + if (data.length === 0) { + setSubscriptionsErrorMessage( + formatMessage( + 'Your subscription list is empty, please add your subscription, or login with another account.' + ) + ); + } + }) + .catch((err) => { + setSubscriptionsErrorMessage(err.message); + }); + } + }, [currentUser, isAuthenticated, hidden]); + + useEffect(() => { + // reset the ui + if (!hidden) { + setSubscription(''); + setKeys([]); + setCurrentStep('intro'); + } + }, [hidden]); + + const fetchKeys = async (cognitiveServicesManagementClient, accounts) => { + const keyList: KeyRec[] = []; + for (const account in accounts) { + const resourceGroup = accounts[account].id?.replace(/.*?\/resourceGroups\/(.*?)\/.*/, '$1'); + const name = accounts[account].name; + if (resourceGroup && name) { + try { + const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); + if (keys?.key1) { + keyList.push({ + name: name, + resourceGroup: resourceGroup, + region: accounts[account].location || '', + key: keys?.key1 || '', + endpoint: accounts[account]?.properties?.endpoint ?? '', + }); + } + } catch (_err) { + // pass, filter no authorization resource + } + } + } + return keyList; + }; + + const fetchAccounts = async (subscriptionId) => { + if (isAuthenticated) { + setLoading(formatMessage('Loading keys...')); + setNoKeys(false); + const tokenCredentials = new TokenCredentials(currentUser.token); + const cognitiveServicesManagementClient = new CognitiveServicesManagementClient(tokenCredentials, subscriptionId); + const accounts = await cognitiveServicesManagementClient.accounts.list(); + const keylist: KeyRec[] = await fetchKeys( + cognitiveServicesManagementClient, + accounts.filter((a) => a.kind === serviceKeyType) + ); + + const kbsMap = {}; + const avaliableKeys: KeyRec[] = []; + for (const keyItem of keylist) { + const kbGroups = await fetchKBGroups(keyItem); + kbsMap[keyItem.name] = kbGroups; + if (kbGroups.length) { + avaliableKeys.push(keyItem); + } + } + setKbs(kbsMap); + setLoading(undefined); + if (avaliableKeys.length == 0) { + setNoKeys(true); + } else { + setNoKeys(false); + setKeys(avaliableKeys); + } + } + }; + + const fetchKBGroups = async (key: KeyRec) => { + let kblist: KBRec[] = []; + if (isAuthenticated && key) { + const cognitiveServicesCredentials = new CognitiveServicesCredentials(key.key); + const resourceClient = new QnAMakerClient(cognitiveServicesCredentials, key.endpoint); + const result = await resourceClient.knowledgebase.listAll(); + if (result.knowledgebases) { + kblist = result.knowledgebases.map((item: any) => { + return { + id: item.id || '', + name: item.name || '', + language: item.language || '', + lastChangedTimestamp: item.lastChangedTimestamp || '', + }; + }); + } + } + return kblist; + }; + + // allow a user to provide a subscription id if one is missing + const onChangeSubscription = async (_, opt) => { + // get list of keys for this subscription + setSubscription(opt.key); + fetchAccounts(opt.key); + setLoading(formatMessage('Loading subscription...')); + }; + + const onChangeKey = async (_, opt) => { + // get list of keys for this subscription + setKey(opt); + setRegion(opt.region); + }; + + const onChangeAction = async (_, opt) => { + setNextAction(opt.key); + if (opt.key === 'url') { + setDisabled(true); + } else { + setDisabled(false); + } + }; + + const chooseExistingKey = () => { + TelemetryClient.track('SettingsGetKeysExistingResourceSelected', { + subscriptionId, + resourceType: serviceName, + }); + setCurrentStep('knowledge-base'); + }; + + const performNextAction = () => { + if (nextAction !== 'portal') { + onSubmitFormData(); + } else { + requireUserLogin(); + setCurrentStep('resource'); + } + }; + + const renderIntroStep = () => { + return ( + + + + {formatMessage('Create a knowledge base from a URL or import content from an existing knowledge base')} + + + + + + + + {nextAction === 'url' ? ( + qnaFile && + ) : ( + +
+ + {formatMessage( + 'Import content from an existing knowledge base on the QnA maker portal. Your knowledge base will downloaded locally and source knowledge base will remain as-is.' + )} + +
- {formatMessage('Manually add question and answer pairs to create a knowledge base')} -
+ + {formatMessage( + 'Select this option if you want to create a knowledge base from content hosted online such as an FAQ or document link (.csv, .xls or .doc format)' + )} + +
- - {formatMessage('Use Azure QnA Maker to extract question-and-answer pairs from an online FAQ. ')} - - {formatMessage('Learn more')} - - -