diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d04c0e272..1ed269a846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `component`: Allow font family and adaptive cards text color to be set via styleOptions, by [@a-b-r-o-w-n](https://github.com/a-b-r-o-w-n), in PR [#1670](https://github.com/Microsoft/BotFramework-WebChat/pull/1670) - `component`: Add fallback logic to browser which do not support `window.Intl`, by [@compulim](https://github.com/compulim), in PR [#1696](https://github.com/Microsoft/BotFramework-WebChat/pull/1696) - `*`: Added `username` back to activity, fixed [#1321](https://github.com/Microsoft/BotFramework-WebChat/issues/1321), by [@compulim](https://github.com/compulim), in PR [#1682](https://github.com/Microsoft/BotFramework-DirectLineJS/pull/1682) -- `component`: Allow root component height & width customization via `styleOptions.rootHeight` and `styleOptions.rootWidth`, by [@tonyanziano](https://github.com/tonyanziano), in PR [#1702](https://github.com/Microsoft/BotFramework-WebChat/pull/1702) +- `component`: Allow root component height and width customization via `styleOptions.rootHeight` and `styleOptions.rootWidth`, by [@tonyanziano](https://github.com/tonyanziano), in PR [#1702](https://github.com/Microsoft/BotFramework-WebChat/pull/1702) +- `component`: Added `cardActionMiddleware` to customize the behavior of card action, by [@compulim](https://github.com/compulim), in PR [#1704](https://github.com/Microsoft/BotFramework-WebChat/pull/1704) ### Changed - Moved `botAvatarImage` and `userAvatarImage` to `styleOptions.botAvatarImage` and `styleOptions.userAvatarImage` respectively, in PR [#1486](https://github.com/Microsoft/BotFramework-WebChat/pull/1486) @@ -88,6 +89,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `component`: [Selectable Activity](https://microsoft.github.io/BotFramework-WebChat/16.customization-selectable-activity/), in [#1624](https://github.com/Microsoft/BotFramework-WebChat/pull/1624) - `component`: [Chat Send History](https://microsoft.github.io/BotFramework-WebChat/17.chat-send-history/), in [#1678](https://github.com/Microsoft/BotFramework-WebChat/pull/1678) - `*`: Update `README.md`'s for samples 05-10 [#1444](https://github.com/Microsoft/BotFramework-WebChat/issues/1444) and improve accessibility of anchors [#1681](https://github.com/Microsoft/BotFramework-WebChat/issues/1681), by [@corinagum](https://github.com/corinagum) in PR [#1710](https://github.com/Microsoft/BotFramework-WebChat/pull/1710) +- `component`: [Customizing open URL behavior](https://microsoft.github.io/BotFramework-WebChat/18.customization-open-url), in PR [#1704](https://github.com/Microsoft/BotFramework-WebChat/pull/1704) ## [4.2.0] - 2018-12-11 ### Added diff --git a/README.md b/README.md index 4f6ed88f82..666cd85f68 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ npm run prepublishOnly | [`15.d.backchannel-send-welcome-event`](https://github.com/Microsoft/BotFramework-WebChat/tree/master/samples/15.d.backchannel-send-welcome-event) | Advanced tutorial: Demonstrates how to send welcome event with client capabilities such as browser language. | [Welcome Event Demo](https://microsoft.github.io/BotFramework-WebChat/15.d.backchannel-send-welcome-event) | | [`16.customization-selectable-activity`](https://github.com/Microsoft/BotFramework-WebChat/tree/master/samples/16.customization-selectable-activity) | Advanced tutorial: Demonstrates how to add custom click behavior to each activity. | [Selectable Activity Demo](https://microsoft.github.io/BotFramework-WebChat/16.customization-selectable-activity) | | [`17.chat-send-history`](https://github.com/Microsoft/BotFramework-WebChat/tree/master/samples/17.chat-send-history) | Advanced tutorial: Demonstrates the ability to save user input and allow the user to step back through previous sent messages. | [Chat Send History Demo](https://microsoft.github.io/BotFramework-WebChat/17.chat-send-history) | +| [`18.customization-open-url`](https://github.com/Microsoft/BotFramework-WebChat/tree/master/samples/18.customization-open-url) | Advanced tutorial: Demonstrates how to customize the open URL behavior. | [Customize Open URL Demo](https://microsoft.github.io/BotFramework-WebChat/18.customization-open-url) | # Contributions diff --git a/__tests__/__image_snapshots__/chrome-docker/card-action-middleware-js-card-action-open-url-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/card-action-middleware-js-card-action-open-url-1-snap.png new file mode 100644 index 0000000000..0a2e04c399 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/card-action-middleware-js-card-action-open-url-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/card-action-middleware-js-card-action-signin-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/card-action-middleware-js-card-action-signin-1-snap.png new file mode 100644 index 0000000000..5251aebd19 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/card-action-middleware-js-card-action-signin-1-snap.png differ diff --git a/__tests__/cardActionMiddleware.js b/__tests__/cardActionMiddleware.js new file mode 100644 index 0000000000..1b4cf63f64 --- /dev/null +++ b/__tests__/cardActionMiddleware.js @@ -0,0 +1,93 @@ +import { By, Key } from 'selenium-webdriver'; + +import { imageSnapshotOptions, timeouts } from './constants.json'; + +import allOutgoingActivitiesSent from './setup/conditions/allOutgoingActivitiesSent'; +import botConnected from './setup/conditions/botConnected'; +import suggestedActionsShowed from './setup/conditions/suggestedActionsShowed'; +import minNumActivitiesShown from './setup/conditions/minNumActivitiesShown.js'; + +// selenium-webdriver API doc: +// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html + +test('card action "openUrl"', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { + cardActionMiddleware: ({ dispatch }) => next => ({ cardAction }) => { + if (cardAction.type === 'openUrl') { + dispatch({ + type: 'WEB_CHAT/SEND_MESSAGE', + payload: { + text: `Navigating to ${ cardAction.value }` + } + }); + } else { + return next(cardAction); + } + } + } + }); + + await driver.wait(botConnected(), timeouts.directLine); + + const input = await driver.findElement(By.css('input[type="text"]')); + + await input.sendKeys('card-actions', Key.RETURN); + await driver.wait(allOutgoingActivitiesSent(), timeouts.directLine); + await driver.wait(suggestedActionsShowed(), timeouts.directLine); + + const openUrlButton = await driver.findElement(By.css('[role="form"] ul > li:first-child button')); + + await openUrlButton.click(); + await driver.wait(allOutgoingActivitiesSent(), timeouts.directLine); + await driver.wait(minNumActivitiesShown(5), timeouts.directLine); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); +}, 60000); + +test('card action "signin"', async () => { + const { driver } = await setupWebDriver({ + props: { + cardActionMiddleware: ({ dispatch }) => next => ({ cardAction, getSignInUrl }) => { + if (cardAction.type === 'signin') { + getSignInUrl().then(url => { + dispatch({ + type: 'WEB_CHAT/SEND_MESSAGE', + payload: { + text: `Signing into ${ new URL(url).host }` + } + }); + }); + } else { + return next(cardAction); + } + } + } + }); + + await driver.wait(botConnected(), timeouts.directLine); + + const input = await driver.findElement(By.css('input[type="text"]')); + + await input.sendKeys('oauth', Key.RETURN); + await driver.wait(allOutgoingActivitiesSent(), timeouts.directLine); + + const openUrlButton = await driver.findElement(By.css('[role="log"] ul > li button')); + + await openUrlButton.click(); + await driver.wait(minNumActivitiesShown(5), timeouts.directLine); + await driver.wait(allOutgoingActivitiesSent(), timeouts.directLine); + + // When the "Sign in" button is clicked, the focus move to it, need to blur it. + await driver.executeScript(() => { + for (let element of document.querySelectorAll(':focus')) { + element.blur(); + } + }); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); +}, 60000); diff --git a/__tests__/setup/setupTestFramework.js b/__tests__/setup/setupTestFramework.js index 159247819e..69ff5eb447 100644 --- a/__tests__/setup/setupTestFramework.js +++ b/__tests__/setup/setupTestFramework.js @@ -17,6 +17,23 @@ const BROWSER_NAME = process.env.WEBCHAT_TEST_ENV || 'chrome-docker'; // const BROWSER_NAME = 'chrome-docker'; // const BROWSER_NAME = 'chrome-local'; +function marshal(props) { + return props && Object.keys(props).reduce((nextProps, key) => { + const { [key]: value } = props; + + if (typeof value === 'function') { + nextProps[key] = `() => ${ value.toString() }`; + nextProps.__evalKeys.push(key); + } else { + nextProps[key] = value; + } + + return nextProps; + }, { + __evalKeys: [] + }); +} + expect.extend({ toMatchImageSnapshot: configureToMatchImageSnapshot({ customSnapshotsDir: join(__dirname, '../__image_snapshots__', BROWSER_NAME) @@ -49,28 +66,18 @@ global.setupWebDriver = async options => { } await driver.executeAsyncScript( - (coverage, props, createDirectLineFnString, setupFnString, callback) => { + (coverage, options, callback) => { window.__coverage__ = coverage; - const setupPromise = setupFnString ? eval(`() => ${ setupFnString }`)()() : Promise.resolve(); - - setupPromise.then(() => { - main({ - createDirectLine: createDirectLineFnString && eval(`() => ${ createDirectLineFnString }`)(), - props - }); - - callback(); - }); + main(options).then(() => callback(), callback); }, global.__coverage__, - options.props, - options.createDirectLine && options.createDirectLine.toString(), - options.setup && options.setup.toString() + marshal({ + ...options, + props: marshal(options.props) + }) ); - await driver.wait(webChatLoaded(), timeouts.navigation); - const pageObjects = createPageObjects(driver); options.pingBotOnLoad && await pageObjects.pingBot(); diff --git a/__tests__/setup/web/index.html b/__tests__/setup/web/index.html index 99a6cba0bc..f61d77431e 100644 --- a/__tests__/setup/web/index.html +++ b/__tests__/setup/web/index.html @@ -55,42 +55,54 @@
+ + +diff --git a/packages/component/src/Composer.js b/packages/component/src/Composer.js index 3fec486a8b..de65d6735e 100644 --- a/packages/component/src/Composer.js +++ b/packages/component/src/Composer.js @@ -33,11 +33,14 @@ import { submitSendBox } from 'botframework-webchat-core'; +import concatMiddleware from './Middleware/concatMiddleware'; import Context from './Context'; +import createCoreCardActionMiddleware from './Middleware/CardAction/createCoreMiddleware'; import createStyleSet from './Styles/createStyleSet'; import defaultAdaptiveCardHostConfig from './Styles/adaptiveCardHostConfig'; import Dictation from './Dictation'; import mapMap from './Utils/mapMap'; +import observableToPromise from './Utils/observableToPromise'; import shallowEquals from './Utils/shallowEquals'; // Flywheel object @@ -66,69 +69,27 @@ function styleSetToClassNames(styleSet) { return mapMap(styleSet, (style, key) => key === 'options' ? style : css(style)); } -function createCardActionLogic({ directLine, dispatch }) { +function createCardActionLogic({ cardActionMiddleware, directLine, dispatch }) { + const runMiddleware = concatMiddleware(cardActionMiddleware, createCoreCardActionMiddleware())({ dispatch }); + return { - onCardAction: (({ displayText, text, type, value }) => { - switch (type) { - case 'imBack': - if (typeof value === 'string') { - // TODO: [P4] Instead of calling dispatch, we should move to dispatchers instead for completeness - dispatch(sendMessage(value, 'imBack')); - } else { - throw new Error('cannot send "imBack" with a non-string value'); - } - - break; - - case 'messageBack': - dispatch(sendMessageBack(value, text, displayText)); - - break; - - case 'postBack': - dispatch(sendPostBack(value)); - - break; - - case 'call': - case 'downloadFile': - case 'openUrl': - case 'playAudio': - case 'playVideo': - case 'showImage': - // TODO: [P3] We should support ponyfill for window.open - // This is as-of v3 - window.open(value); - break; - - case 'signin': - // TODO: [P3] We should prime the URL into the OAuthCard directly, instead of calling getSessionId on-demand - // This is to eliminate the delay between window.open() and location.href call - - const popup = window.open(); - - if (directLine.getSessionId) { - const subscription = directLine.getSessionId().subscribe(sessionId => { - popup.location.href = `${ value }${ encodeURIComponent(`&code_challenge=${ sessionId }`) }`; - - // HACK: Sometimes, the call complete asynchronously and we cannot unsubscribe - // Need to wait some short time here to make sure the subscription variable has setup - setImmediate(() => subscription.unsubscribe()); - }, error => { - // TODO: [P3] Let the user know something failed and we cannot proceed - // This is as-of v3 now - console.error(error); - }); - } else { - popup.location.href = value; - } - - break; - - default: - console.error(`Web Chat: received unknown card action "${ type }"`); - break; - } + onCardAction: cardAction => runMiddleware(({ cardAction: { type } }) => { + throw new Error(`Web Chat: received unknown card action "${ type }"`); + })({ + cardAction, + getSignInUrl: cardAction.type === 'signin' ? () => { + const { value } = cardAction; + + if (directLine.getSessionId) { + // TODO: [P3] We should change this one to async/await. + // This is the first place in this project to use async. + // Thus, we need to add @babel/plugin-transform-runtime and @babel/runtime. + + return observableToPromise(directLine.getSessionId()).then(sessionId => `${ value }${ encodeURIComponent(`&code_challenge=${ sessionId }`) }`); + } else { + return value; + } + } : null }) }; } @@ -381,9 +342,11 @@ ConnectedComposerWithStore.propTypes = { activityRenderer: PropTypes.func, adaptiveCardHostConfig: PropTypes.any, attachmentRenderer: PropTypes.func, + cardActionMiddleware: PropTypes.func, groupTimestamp: PropTypes.oneOfType([PropTypes.bool, PropTypes.number]), disabled: PropTypes.bool, grammars: PropTypes.arrayOf(PropTypes.string), + openUrlPonyfillFactory: PropTypes.func, referenceGrammarID: PropTypes.string, renderMarkdown: PropTypes.func, scrollToBottom: PropTypes.func, diff --git a/packages/component/src/Middleware/CardAction/createCoreMiddleware.js b/packages/component/src/Middleware/CardAction/createCoreMiddleware.js new file mode 100644 index 0000000000..f30bf15a8c --- /dev/null +++ b/packages/component/src/Middleware/CardAction/createCoreMiddleware.js @@ -0,0 +1,55 @@ +import { + sendMessage, + sendMessageBack, + sendPostBack +} from 'botframework-webchat-core'; + +export default function createDefaultCardActionMiddleware() { + return ({ dispatch }) => next => ({ cardAction, getSignInUrl }) => { + const { displayText, text, type, value } = cardAction; + + switch (type) { + case 'imBack': + if (typeof value === 'string') { + // TODO: [P4] Instead of calling dispatch, we should move to dispatchers instead for completeness + dispatch(sendMessage(value, 'imBack')); + } else { + throw new Error('cannot send "imBack" with a non-string value'); + } + + break; + + case 'messageBack': + dispatch(sendMessageBack(value, text, displayText)); + + break; + + case 'postBack': + dispatch(sendPostBack(value)); + + break; + + case 'call': + case 'downloadFile': + case 'openUrl': + case 'playAudio': + case 'playVideo': + case 'showImage': + window.open(value); + break; + + case 'signin': + // TODO: [P3] We should prime the URL into the OAuthCard directly, instead of calling getSessionId on-demand + // This is to eliminate the delay between window.open() and location.href call + + const popup = window.open(); + + getSignInUrl().then(url => popup.location.href = url); + + break; + + default: + return next({ cardAction, getSignInUrl }); + } + }; +} diff --git a/packages/component/src/Utils/observableToPromise.js b/packages/component/src/Utils/observableToPromise.js new file mode 100644 index 0000000000..805e1ab76a --- /dev/null +++ b/packages/component/src/Utils/observableToPromise.js @@ -0,0 +1,14 @@ +export default function observableToPromise(observable) { + return new Promise((resolve, reject) => { + const subscription = observable.subscribe(sessionId => { + resolve(sessionId); + + // HACK: Sometimes, the call complete asynchronously and we cannot unsubscribe + // Need to wait some short time here to make sure the subscription variable has setup + setImmediate(() => subscription.unsubscribe()); + }, error => { + reject(error); + setImmediate(() => subscription.unsubscribe()); + }); + }); +} diff --git a/samples/18.customization-open-url/README.md b/samples/18.customization-open-url/README.md new file mode 100644 index 0000000000..dcefe84674 --- /dev/null +++ b/samples/18.customization-open-url/README.md @@ -0,0 +1,135 @@ +# Sample - Customize open URL behavior + +Web Chat client that will show a confirmation dialog when opening a URL. + +# Test out the hosted sample + +- [Try out MockBot](https://microsoft.github.io/BotFramework-WebChat/18.customization-open-url) + +# Things to try out + +- `"openUrl"` card action + - Type `card sports` in the send box + - Click on the "Seattle vs Panthers" card + - A prompt will show to ask if you want to open the URL +- `"signin"` card action + - Type `oauth` in the send box + - Will directly send the user to GitHub authentication page + +# Code + +> Jump to [completed code](#completed-code) to see the end-result `index.html`. + +### Goals of this bot + +The `index.html` page has one main goal: + +- To intercept both `"openUrl"` and `"signin"` card action + - Other unintercepted card action, will use default behavior + +We'll start by using the [full-bundle CDN sample](./../01.a.getting-started-full-bundle/README.md) as our Web Chat template. + +```diff +… +window.WebChat.renderWebChat({ + directLine: window.WebChat.createDirectLine({ token }), ++ cardActionMiddleware: () => next => async ({ cardAction, getSignInUrl }) => { ++ const { type, value } = cardAction; ++ ++ switch (type) { ++ case 'signin': ++ const popup = window.open(); ++ const url = await getSignInUrl(); ++ ++ popup.location.href = url; ++ ++ break; ++ ++ case 'openUrl': ++ if (confirm(`Do you want to open this URL?\n\n${ value }`)) { ++ window.open(value, '_blank'); ++ } ++ ++ break; ++ ++ default: ++ return next({ cardAction, getSignInUrl }); ++ } + }, +… +``` + +To prevent getting blocked by a popup blocker, the `window.open()` must be called immediately inside `cardActionMiddleware`. + +> In this sample, we use a `confirm()` prompt for demonstration purpose only. It will cause popup blocker to block the URL. This is expected behavior. + +> Currently, when you click on "Sign in" of an OAuth card, it will get a fresh URL from our server before redirecting to the OAuth provider. To not getting blocked by a popup blocker, we need to call `window.open` first, then get the URL, and lastly redirect the user to the OAuth provider. + +## Completed code + +Here is the finished `index.html`: + +```diff + + +
+
+ + + +
+