diff --git a/.eslintrc.js b/.eslintrc.js index 30d79ae..9b4be4c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -46,5 +46,11 @@ module.exports = { 'react/jsx-filename-extension': [1, { extensions: ['.tsx', '.jsx'] }], 'react/require-default-props': 'off', 'no-use-before-define': 'off', + // Airbnb allows arrow functions but not regular functions which doesn't make any sense. + 'react/jsx-no-bind': 'off', + // This rule is annoying and sometimes just wrong. + 'no-shadow': 'off', + // The properties of object parameters should be allowed to be modified. + 'no-param-reassign': ['error', { props: false }], }, }; diff --git a/README.md b/README.md index d521b65..1d2369b 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,7 @@ `@ibm-watson/assistant-web-chat-react` is a React library to extend the [web chat](https://cloud.ibm.com/docs/watson-assistant?topic=watson-assistant-deploy-web-chat) feature of [IBM Watson Assistant](https://www.ibm.com/cloud/watson-assistant) within your React application. This makes it easier to provide user-defined response types written in React, add content to custom elements with React, have the web chat and your site communicate more easily, and more. -There are two utility functions provided by this library: - -1. The `WebChatContainer` function is a functional component that makes use of `withWebChat` to load web chat when the component is mounted. -2. The `withWebChat` function creates a high-order-component (HOC) that you can wrap around an existing component to inject `createWebChatInstance` into it so your component can create a new instance of web chat when appropriate. You can find more information in the [withWebChat documentation](./WITH_WEB_CHAT.md). +The primary utility provided by this library is the `WebChatContainer` functional component. This component will load and render an instance of web chat when it is mounted and destroy that instance when unmounted.
Table of contents @@ -40,14 +37,16 @@ yarn add @ibm-watson/assistant-web-chat-react The `WebChatContainer` function component is intended to make it as easy as possible to include web chat in your React application. To use, you simply need to render this component anywhere in your application and provide the [web chat configuration options object](https://web-chat.global.assistant.watson.cloud.ibm.com/docs.html?to=api-configuration#configurationobject) as a prop. ```javascript +const webChatOptions = { + integrationID: 'XXXX', + region: 'XXXX', + serviceInstanceID: 'XXXX', + // subscriptionID: 'only on enterprise plans', + // Note that there is no onLoad property here. The WebChatContainer component will override it. + // Use the onBeforeRender prop instead. +}; + function App() { - const webChatOptions = { - integrationID: 'XXXX', - region: 'XXXX', - serviceInstanceID: 'XXXX', - // subscriptionID: 'only on enterprise plans', - // Note that there is no onLoad property here. The WebChatContainer component will override it with its own. - }; return ; } ``` @@ -55,18 +54,21 @@ function App() { This component is also capable of managing custom responses. To do so, you need to pass a `renderCustomResponse` function as a prop. This function should return a React component that will render the content for the specific message for that response. You should make sure that the `WebChatContainer` component does not get unmounted in the middle of the life of your application because it will lose all custom responses that were previously received by web chat. ```javascript +const webChatOptions = { + integrationID: 'XXXX', + region: 'XXXX', + serviceInstanceID: 'XXXX', + // subscriptionID: 'only on enterprise plans', + // Note that there is no onLoad property here. The WebChatContainer component will override it. + // Use the onBeforeRender prop instead. +}; + function App() { - const webChatOptions = { - integrationID: 'XXXX', - region: 'XXXX', - serviceInstanceID: 'XXXX', - // subscriptionID: 'only on enterprise plans', - // Note that there is no onLoad property here. The WebChatContainer component will override it with its own. - }; return ; } function renderCustomResponse(event) { + // The event here will contain details for each custom response that needs to be rendered. return
My custom content
; } ``` @@ -75,7 +77,9 @@ function renderCustomResponse(event) { ### WebChatContainer API -The `WebChatContainer` function is a functional component that makes use of `withWebChat` to load web chat when the component is mounted. It can also manage React portals for custom responses. +The `WebChatContainer` function is a functional component that will load and render an instance of web chat when it is mounted and destroy that instance when unmounted. If the web chat configuration options change, it will also destroy the previous web chat and create a new one with the new configuration. It can also manage React portals for custom responses. + +Note that this component will call the [web chat render](https://web-chat.global.assistant.watson.cloud.ibm.com/docs.html?to=api-instance-methods#render) method for you. You do not need to call it yourself. #### Props @@ -83,20 +87,31 @@ The `WebChatContainer` function is a functional component that makes use of `wit | Attribute | Required | Type | Description | |-----------|----------|---------|-------------| -| config | Yes | object | The [web chat configuration options object](https://web-chat.global.assistant.watson.cloud.ibm.com/docs.html?to=api-configuration#configurationobject). Note that any `onLoad` property will be ignored. | +| config | Yes | object | The [web chat configuration options object](https://web-chat.global.assistant.watson.cloud.ibm.com/docs.html?to=api-configuration#configurationobject). Note that any `onLoad` property will be ignored. If this prop is changed and a new object provided, then the current web chat will be destroyed and a new one created with the new object. | | | instanceRef | No | MutableRefObject | A convenience prop that is a reference to the web chat instance. This component will set the value of this ref using the `current` property when the instance has been created. | | -| onBeforeRender | No | function | This is a callback function that is called after web chat has been loaded and before the `render` function is called. This function is passed a single argument which is the instance of web chat that was loaded. This function can be used to obtain a reference to the web chat instance if you want to make use of the instance functions that are available. | +| onBeforeRender | No | function | This is a callback function that is called after web chat has been loaded and before the `render` function is called. This function is passed a single argument which is the instance of web chat that was loaded. This function can be used to obtain a reference to the web chat instance if you want to make use of the instance methods that are available. | | | renderCustomResponse | No | function | This function is a callback function that will be called by this container to render custom responses. If this prop is provided, then the container will listen for custom response events from web chat and will generate a React portal for each event. This function will be called once during component render for each custom response event. This function takes two arguments. The first is the [custom response event](https://web-chat.global.assistant.watson.cloud.ibm.com/docs.html?to=api-events#customresponse) that triggered the custom response. The second is a convenience argument that is the instance of web chat. The function should return a `ReactNode` that renders the custom content for the response. | | +### Debugging + +In addition to the props above, the `WebChatContainer` component can output additional debug information. To enable this output, call the `setEnableDebug` global function. + +```javascript +setEnableDebug(true); + +function App() { + return ; +} +``` + ## Additional resources - [Watson Assistant](https://www.ibm.com/cloud/watson-assistant) - [Watson Assistant web chat feature documentation](https://cloud.ibm.com/docs/watson-assistant?topic=watson-assistant-deploy-web-chat) - [Watson Assistant web chat API documentation](https://web-chat.global.assistant.watson.cloud.ibm.com/docs.html?to=api-overview) -- [Higher order components](https://reactjs.org/docs/higher-order-components.html) ## License diff --git a/WITH_WEB_CHAT.MD b/WITH_WEB_CHAT.MD index 4b6eaa2..161657d 100644 --- a/WITH_WEB_CHAT.MD +++ b/WITH_WEB_CHAT.MD @@ -1,5 +1,7 @@ # Using withWebChat +**NOTE: This function is deprecated and will be removed in a future release of this library. Use the WebChatContainer component instead.** + The `WebChatContainer` component, makes use of the `withWebChat` function to create high-order-components that inject a function for creating instances of web chat. This document explains how to use that function if you wish to bypass the `WebChatContainer`.
diff --git a/src/CustomResponsePortalsContainer.tsx b/src/CustomResponsePortalsContainer.tsx index e915768..4082cf7 100644 --- a/src/CustomResponsePortalsContainer.tsx +++ b/src/CustomResponsePortalsContainer.tsx @@ -45,7 +45,7 @@ interface CustomResponsePortalsContainer { * responses that had been received prior to that point. */ function CustomResponsePortalsContainer({ webChatInstance, renderResponse }: CustomResponsePortalsContainer) { - // This state will be used to record all of the custom response events that are fired from the widget. These + // This state will be used to record all the custom response events that are fired from the widget. These // events contain the HTML elements that we will attach our portals to as well as the messages that we wish to // render in the message. const [customResponseEvents, setCustomResponseEvents] = useState([]); @@ -54,7 +54,7 @@ function CustomResponsePortalsContainer({ webChatInstance, renderResponse }: Cus // response events. useEffect(() => { // This handler will fire each time a custom response occurs and we will update our state by appending the event - // to the end of our events list. We have to make sure to create a new array in order to trigger a re-render. + // to the end of our events list. We have to make sure to create a new array in order to trigger a rerender. function customResponseHandler(event: CustomResponseEvent) { setCustomResponseEvents((eventsArray) => eventsArray.concat(event)); } diff --git a/src/WebChatContainer.tsx b/src/WebChatContainer.tsx index 662d737..5b6cd22 100644 --- a/src/WebChatContainer.tsx +++ b/src/WebChatContainer.tsx @@ -12,14 +12,42 @@ * */ -import React, { ReactNode, useEffect, useState, MutableRefObject } from 'react'; +import React, { ReactNode, useEffect, useState, MutableRefObject, useRef } from 'react'; import { CustomResponsePortalsContainer } from './CustomResponsePortalsContainer'; -import { withWebChat } from './withWebChat'; -import { AddedWithWebChatProps } from './types/WithWebChatTypes'; import { WebChatConfig } from './types/WebChatConfig'; import { WebChatInstance } from './types/WebChatInstance'; import { CustomResponseEvent } from './types/CustomResponseEvent'; +// The default host URL where the production version of web chat is hosted. +const DEFAULT_BASE_URL = 'https://web-chat.global.assistant.watson.appdomain.cloud'; + +// Indicate if debugging is enabled. +let debug = false; + +// The first time the container is mounted, we need to load the javascript for web chat from the CDN. This should +// only happen once. This Promise is used to ensure that and to allow anyone to wait for that script to be loaded. +let loadWebChatScriptPromise: Promise; + +// The URL that was used to load the web chat javascript. This is to ensure we don't attempt to load different scripts. +let loadedWebChatURL: string; + +interface ManagedWebChat { + /** + * The config for the web chat that is loaded. + */ + webChatConfig: WebChatConfig; + + /** + * Indicates if this instance of the web chat should be or has been destroyed. + */ + shouldDestroy: boolean; + + /** + * The instance of web chat that was loaded. + */ + instance: WebChatInstance; +} + interface WebChatContainerProps { /** * The config to use to load web chat. Note that the "onLoad" property is overridden by this component. If you @@ -43,6 +71,11 @@ interface WebChatContainerProps { * when the instance has been created. */ instanceRef?: MutableRefObject; + + /** + * Set the url where web chat assets are hosted. Used for development purposes. + */ + hostURL?: string; } /** @@ -55,63 +88,191 @@ interface WebChatContainerProps { * chat instance or need to perform additional customizations of web chat when it loads, use the onBeforeRender * callback prop to this component. */ -function WebChatContainer({ onBeforeRender, renderCustomResponse, config, instanceRef }: WebChatContainerProps) { - return ( - - ); -} - -type WebChatContainerInternalProps = WebChatContainerProps & AddedWithWebChatProps; - -/** - * This is the internal component that is passed the createWebChatInstance function for creating web chat. - */ -const WebChatContainerInternal = ({ - createWebChatInstance, +function WebChatContainer({ onBeforeRender, renderCustomResponse, config, instanceRef, -}: WebChatContainerInternalProps) => { - const [webChatInstance, setWebChatInstance] = useState(); + hostURL, +}: WebChatContainerProps) { + // A state value that contains the current instance of web chat. + const [instance, setInstance] = useState(); + + // The most recent web chat that was load by this component. + const managedWebChatRef = useRef(); + + // The previous web chat config. + const previousConfigRef = useRef(); useEffect(() => { - async function onWebChatLoad(instance: WebChatInstance) { - if (onBeforeRender) { - await onBeforeRender(instance); - } - instance.render(); - setWebChatInstance(instance); - } + const previousConfig = previousConfigRef.current; + previousConfigRef.current = config; - // Add the onLoad handler to the existing web chat options in the external config.js file. - const webChatOptions = { - ...config, - onLoad: onWebChatLoad, - }; + if (previousConfig !== config) { + // Each time the web chat config settings change (or this component is mounted), we need to destroy any previous + // web chat and create a new web chat. + destroyWebChat(managedWebChatRef.current, setInstance, instanceRef); - createWebChatInstance(webChatOptions); - }, []); + // We'll use this managed object to keep track of the web chat instance we are creating for this effect. + const managedWebChat: ManagedWebChat = { + instance: null, + shouldDestroy: false, + webChatConfig: config, + }; + managedWebChatRef.current = managedWebChat; - useEffect(() => { - if (instanceRef) { - // eslint-disable-next-line no-param-reassign - instanceRef.current = webChatInstance; + logger(managedWebChat.webChatConfig, `Creating a new web chat due to configuration change.`); + + // Kick off the creation of a new web chat. This is multistep, asynchronous process. + loadWebChat(managedWebChat, hostURL, setInstance, instanceRef, onBeforeRender).catch((error) => { + logger(managedWebChat.webChatConfig, 'An error occurred loading web chat', error); + destroyWebChat(managedWebChat, setInstance, instanceRef); + }); + + return () => { + logger(managedWebChat.webChatConfig, `Destroying web chat due to component unmounting.`); + destroyWebChat(managedWebChat, setInstance, instanceRef); + previousConfigRef.current = null; + }; } - }, [instanceRef, webChatInstance]); + return undefined; + }, [config, hostURL]); - if (renderCustomResponse && webChatInstance) { - return ; + if (renderCustomResponse && instance) { + return ; } return null; -}; +} + +/** + * Loads a new instance of web chat. + */ +async function loadWebChat( + managedWebChat: ManagedWebChat, + hostURL: string, + setInstance: (instance: WebChatInstance) => void, + instanceRef: MutableRefObject, + onBeforeRender: (instance: WebChatInstance) => Promise, +) { + const { webChatConfig } = managedWebChat; + + // The first step is to make sure the javascript for web chat is loaded. + await ensureWebChatScript(webChatConfig, hostURL); + + if (managedWebChat.shouldDestroy) { + logger(webChatConfig, `Destroying web chat before an instance is created.`); + destroyWebChat(managedWebChat, setInstance, instanceRef); + return; + } + + // Now create an instance of web chat. + if (webChatConfig.onLoad) { + const message = 'Do not use onLoad in the web chat config. Use the WebChatContainer onBeforeRender prop instead.'; + logger(webChatConfig, message); + } + logger(webChatConfig, `Creating web chat instance.`); + const configWithoutOnLoad: WebChatConfig = { + ...webChatConfig, + onLoad: null, + }; + const instance = await window.loadWatsonAssistantChat(configWithoutOnLoad); + + // Once the instance is created, call the onBeforeRender and then render. + await onBeforeRender?.(instance); + logger(webChatConfig, `Calling render.`); + await instance.render(); + + // Update the state of the parent component with the instance. + setInstance(instance); + managedWebChat.instance = instance; + if (instanceRef) { + instanceRef.current = instance; + } + + if (managedWebChat.shouldDestroy) { + logger(webChatConfig, `Destroying web chat after an instance is created but before calling onLoad.`); + destroyWebChat(managedWebChat, setInstance, instanceRef); + } +} + +/** + * Destroys an instance of web chat and marks it destroyed. + */ +function destroyWebChat( + managedWebChat: ManagedWebChat, + setInstance: (instance: WebChatInstance) => void, + instanceRef: MutableRefObject, +) { + if (managedWebChat) { + if (managedWebChat.instance) { + logger(managedWebChat.webChatConfig, `Destroying web chat instance.`); + managedWebChat.instance.destroy(); + } + + managedWebChat.shouldDestroy = true; + managedWebChat.instance = null; + } + setInstance(null); + if (instanceRef) { + instanceRef.current = null; + } +} + +/** + * A public function that can be used to turn logging off or on. + */ +function setEnableDebug(enableDebug: boolean) { + debug = enableDebug; +} + +/** + * A convenience function for logging to the console. + */ +function logger(webChatConfig: WebChatConfig, ...args: unknown[]) { + if (debug) { + const namespaceLabel = webChatConfig?.namespace ? `: Namespace ${webChatConfig.namespace}` : ''; + // eslint-disable-next-line no-console + console.log(`[Watson Assistant WebChatContainer${namespaceLabel}]`, ...args); + } +} + +/** + * Ensures that the javascript for web chat has been loaded. + */ +async function ensureWebChatScript(webChatConfig: WebChatConfig, hostURL: string) { + const useURL = hostURL || DEFAULT_BASE_URL; + const scriptURL = `${useURL.replace(/\/$/, '')}/versions/${ + webChatConfig.clientVersion || 'latest' + }/WatsonAssistantChatEntry.js`; -const WebChatContainerWithWebChat = withWebChat()(WebChatContainerInternal); + if (loadedWebChatURL && loadedWebChatURL !== scriptURL) { + const message = + 'Web chat has already been loaded using a different URL. This component does not support loading web chat' + + ' using multiple URLs including different versions of web chat.'; + logger(null, message); + } + + if (!loadWebChatScriptPromise) { + loadWebChatScriptPromise = loadWebChatScript(scriptURL); + } + await loadWebChatScriptPromise; +} + +/** + * Loads the web chat javascript from the CDN. + */ +function loadWebChatScript(url: string): Promise { + logger(null, `Loading the web chat javascript from ${url}.`); + + return new Promise((resolve, reject) => { + const scriptElement = document.createElement('script'); + scriptElement.setAttribute('id', 'with-web-chat'); + scriptElement.onload = () => resolve(); + scriptElement.onerror = () => reject(); + scriptElement.src = url; + document.head.appendChild(scriptElement); + }); +} -export { WebChatContainer, WebChatContainerProps }; +export { setEnableDebug, WebChatContainer, WebChatContainerProps }; diff --git a/src/__tests__/WebChatContainer.test.tsx b/src/__tests__/WebChatContainer.test.tsx index 340020c..af10f34 100644 --- a/src/__tests__/WebChatContainer.test.tsx +++ b/src/__tests__/WebChatContainer.test.tsx @@ -15,7 +15,7 @@ import React, { MutableRefObject } from 'react'; import { render } from '@testing-library/react'; import { WebChatContainer, WebChatContainerProps } from '../WebChatContainer'; -import { TEST_INSTANCE_CONFIG, waitForText, waitForWebChat } from '../test/testUtils'; +import { TEST_INSTANCE_CONFIG, waitForFind, waitForWebChat } from '../test/testUtils'; import { WebChatInstance } from '../types/WebChatInstance'; import { CustomResponseEvent } from '../types/CustomResponseEvent'; @@ -28,6 +28,35 @@ describe('WebChatContainer', () => { await waitForWebChat(findAllByPlaceholderText); }); + it('tests that the component loads a different web chat when the config changes', async () => { + const { findAllByPlaceholderText, rerender, findAllByLabelText } = render( + , + ); + await waitForWebChat(findAllByPlaceholderText); + + rerender( + , + ); + + await waitForWebChat(findAllByPlaceholderText); + // This second configuration should display the close and restart button. + await waitForFind('End conversation and close the chat window', findAllByLabelText); + }); + + it('tests that the component renders correctly when mounted, unmounted and re-mounted', async () => { + // This is basically what the React 18 strict mode does in development mode. + const { findAllByPlaceholderText, rerender } = render(); + rerender(
); + rerender(); + + await waitForWebChat(findAllByPlaceholderText); + }); + it('tests that the component renders custom responses', async () => { const instanceRef: MutableRefObject = { current: null }; let webChatInstance: WebChatInstance; @@ -64,14 +93,14 @@ describe('WebChatContainer', () => { // Send a message to get the first custom response. webChatInstance.send({ input: { text: 'custom response' } }); - await waitForText('This is a custom response! Count: 1.', findAllByText); + await waitForFind('This is a custom response! Count: 1.', findAllByText); expect(queryAllByText('This is a custom response! Count: 2.', { exact: false }).length).toEqual(0); // Send a message to get the second custom response and make sure both custom responses appear. webChatInstance.send({ input: { text: 'custom response' } }); - await waitForText('This is a custom response! Count: 2.', findAllByText); - await waitForText('This is a custom response! Count: 1.', findAllByText); + await waitForFind('This is a custom response! Count: 2.', findAllByText); + await waitForFind('This is a custom response! Count: 1.', findAllByText); expect(queryAllByText('This is a custom response! Count: 3.', { exact: false }).length).toEqual(0); expect(instanceRef.current).toBe(webChatInstance); diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 5432dfe..608dd57 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -24,18 +24,23 @@ const TEST_INSTANCE_CONFIG = { }; deepFreeze(TEST_INSTANCE_CONFIG); +type FindFunctions = + | ReturnType['findAllByText'] + | ReturnType['findAllByLabelText'] + | ReturnType['findAllByPlaceholderText']; + /** - * Waits for an element with the given text to appear in the document. + * Waits for an element with the given text to appear in the document using the given find function. */ -async function waitForText(text: string, findAllByText: ReturnType['findAllByText']) { - await findAllByText(text, { exact: false }, { timeout: 20000 }); +async function waitForFind(text: string, find: FindFunctions) { + await find(text, { exact: false }, { timeout: 15000 }); } /** * Waits for web chat to load and the text input field to appear. */ async function waitForWebChat(findAllByPlaceholderText: ReturnType['findAllByPlaceholderText']) { - await findAllByPlaceholderText('Type something...', { exact: false }, { timeout: 20000 }); + await waitForFind('Type something...', findAllByPlaceholderText); } -export { TEST_INSTANCE_CONFIG, waitForText, waitForWebChat }; +export { TEST_INSTANCE_CONFIG, waitForFind, waitForWebChat }; diff --git a/tsconfig.json b/tsconfig.json index 42273b8..787e91a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,10 +15,11 @@ "noImplicitAny": true, "strictNullChecks": false, "suppressImplicitAnyIndexErrors": true, - "noUnusedLocals": true, - "noUnusedParameters": true, "allowSyntheticDefaultImports": true, - "esModuleInterop": true + "esModuleInterop": true, + // These are useful for a final build but very annoying during development and debugging. + "noUnusedLocals": false, + "noUnusedParameters": false }, "include": ["src", "src/types"], "exclude": ["node_modules", "dist", "example", "rollup.config.js", "__tests__"]