From c4d230918db5b4852adc9876619b7dcd4b5bb6f5 Mon Sep 17 00:00:00 2001 From: "Damon Lundin (IBM)" Date: Thu, 27 Jun 2024 07:57:45 -0500 Subject: [PATCH] fix: made some changes and fixes BREAKING CHANGE: Removed the deprecated withWebChat hook. BREAKING CHANGE: Renamed the "renderCustomResponse" prop to "renderUserDefinedResponse". BREAKING CHANGE: Removed the CustomResponsePortalsContainer component. - Added support for the "userDefinedResponse" event added in web chat 8.2.0. - Fixed an order bug that can occur if a user defined response is fired during the render process in some cases. This meant reworking the portals container interface which was a breaking change and we decided to just remove it from the public API. - Added support for usages on cp4d so the library will properly calculate the endpoints for the web chat entry script. --- README.md | 20 +- package-lock.json | 26 ++ package.json | 4 + src/CustomResponsePortalsContainer.tsx | 97 ------- src/UserDefinedResponsePortalsContainer.tsx | 87 ++++++ src/WebChatContainer.tsx | 109 ++++++-- src/__tests__/WebChatContainer.test.tsx | 34 +-- src/__tests__/withWebChat.test.tsx | 129 --------- src/entry.tsx | 17 +- ...seEvent.ts => UserDefinedResponseEvent.ts} | 17 +- src/types/WithWebChatTypes.ts | 67 ----- src/withWebChat.tsx | 260 ------------------ 12 files changed, 247 insertions(+), 620 deletions(-) delete mode 100644 src/CustomResponsePortalsContainer.tsx create mode 100644 src/UserDefinedResponsePortalsContainer.tsx delete mode 100644 src/__tests__/withWebChat.test.tsx rename src/types/{CustomResponseEvent.ts => UserDefinedResponseEvent.ts} (69%) delete mode 100644 src/types/WithWebChatTypes.ts delete mode 100644 src/withWebChat.tsx diff --git a/README.md b/README.md index 8217674..b475442 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,13 @@ function onBeforeRender(instance, setInstance) { } ``` -### Custom responses +### User defined responses -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. +This component is also capable of managing user defined responses. To do so, you need to pass a `renderUserDefinedResponse` function as a render 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 user defined responses that were previously received by web chat. + +You should treat the `renderUserDefinedResponse` prop like any typical React render prop; it is different from the `userDefinedResponse` event or a typical event handler. The event is fired only once when web chat initially receives the response from the server. The `renderUserDefinedResponse` prop however is called every time the App re-renders and it should return an up-to-date React component for the provided message item just like the render function would for a typical React component. + +Note: in web chat 8.2.0, the custom response event was renamed from `customResponse` to `userDefinedResponse`. If this library detects you are using a prior version of web chat, it will use the `customResponse` event instead of `userDefinedResponse`. ```javascript import React from 'react'; @@ -111,14 +115,14 @@ import { WebChatContainer } from '@ibm-watson/assistant-web-chat-react'; const webChatOptions = { /* Web chat options */ }; function App() { - return ; + return ; } -function renderCustomResponse(event) { - // The event here will contain details for each custom response that needs to be rendered. +function renderUserDefinedResponse(event) { + // The event here will contain details for each user defined response that needs to be rendered. // The "user_defined_type" property is just an example; it is not required. You can use any other property or // condition you want here. This makes it easier to handle different response types if you have more than - // one custom response type. + // one user defined response type. if (event.data.message.user_defined && event.data.message.user_defined.user_defined_type === 'my-custom-type') { return
My custom content
} @@ -164,7 +168,7 @@ function App() { ### WebChatContainer API -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. +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 user defined 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. You can use the `onBeforeRender` or `onAfterRender` prop to execute operations before or after render is called. @@ -178,7 +182,7 @@ Note that this component will call the [web chat render](https://web-chat.global | 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 methods that are available. | | onAfterRender | No | function | This is a callback function that is called after web chat has been loaded and after 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. | +| renderUserDefinedResponse | No | function | This function is a callback function that will be called by this container to render user defined responses. If this prop is provided, then the container will listen for user defined 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 user defined response event. This function takes two arguments. The first is the [user defined response event](https://web-chat.global.assistant.watson.cloud.ibm.com/docs.html?to=api-events#userDefinedResponse) that triggered the user defined response. The second is a convenience argument that is the instance of web chat. The function should return a `ReactNode` that renders the user defined content for the response. | `WebChatCustomElement` inherits all of the props from `WebChatContainer`. It also has the following additional optional props. diff --git a/package-lock.json b/package-lock.json index ddc1cc2..0a5716f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@ibm-watson/assistant-web-chat-react", "version": "1.1.1", "license": "MIT", + "dependencies": { + "semver-compare": "^1.0.0" + }, "devDependencies": { "@commitlint/cli": "^15.0.0", "@commitlint/config-conventional": "^15.0.0", @@ -24,6 +27,7 @@ "@types/proper-url-join": "^2.1.1", "@types/react": "^16.14.17", "@types/react-dom": "^16.9.14", + "@types/semver-compare": "^1.0.3", "@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/parser": "^5.30.6", "babel-core": "^6.26.3", @@ -3036,6 +3040,12 @@ "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", "dev": true }, + "node_modules/@types/semver-compare": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/semver-compare/-/semver-compare-1.0.3.tgz", + "integrity": "sha512-mVZkB2QjXmZhh+MrtwMlJ8BqUnmbiSkpd88uOWskfwB8yitBT0tBRAKt+41VRgZD9zr9Sc+Xs02qGgvzd1Rq/Q==", + "dev": true + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -16104,6 +16114,11 @@ "semver": "bin/semver.js" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==" + }, "node_modules/semver-diff": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", @@ -20442,6 +20457,12 @@ "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", "dev": true }, + "@types/semver-compare": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/semver-compare/-/semver-compare-1.0.3.tgz", + "integrity": "sha512-mVZkB2QjXmZhh+MrtwMlJ8BqUnmbiSkpd88uOWskfwB8yitBT0tBRAKt+41VRgZD9zr9Sc+Xs02qGgvzd1Rq/Q==", + "dev": true + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -30239,6 +30260,11 @@ "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==" + }, "semver-diff": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", diff --git a/package.json b/package.json index 419d801..f61c41b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/proper-url-join": "^2.1.1", "@types/react": "^16.14.17", "@types/react-dom": "^16.9.14", + "@types/semver-compare": "^1.0.3", "@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/parser": "^5.30.6", "babel-core": "^6.26.3", @@ -83,5 +84,8 @@ ], "publishConfig": { "access": "public" + }, + "dependencies": { + "semver-compare": "^1.0.0" } } diff --git a/src/CustomResponsePortalsContainer.tsx b/src/CustomResponsePortalsContainer.tsx deleted file mode 100644 index 4082cf7..0000000 --- a/src/CustomResponsePortalsContainer.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * (C) Copyright IBM Corp. 2022. - * - * Licensed under the MIT License (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * https://opensource.org/licenses/MIT - * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - * - */ - -import React, { ReactNode, useEffect, useState } from 'react'; -import ReactDOM from 'react-dom'; -import { CustomResponseEvent } from './types/CustomResponseEvent'; -import { WebChatInstance } from './types/WebChatInstance'; - -interface CustomResponsePortalsContainer { - /** - * The instance of a web chat that this component will register listeners on. - */ - webChatInstance: WebChatInstance; - - /** - * The function that this component will use to request the actual React content to display for each custom - * response. - */ - renderResponse: (event: CustomResponseEvent, instance: WebChatInstance) => ReactNode; -} - -/** - * This is a utility component that is used to manage all the custom responses that are rendered by web chat. When a - * custom response message is received by web chat, it will fire a "customResponse" event that provides an HTML element - * to which your application can attach custom content. React portals are a mechanism that allows you to render a - * component in your React application but attach that component to the HTML element that was provided by web chat. - * - * When this component is mounted, it will register a listener for the "customResponse" event from web chat. It will - * save each event in a list and then render a portal for each event. Each portal will be attached to the element that - * was created by web chat and is where that custom response should be attached. - * - * To use this component, all you need to do is mount is somewhere in your application; it doesn't really matter - * where but you should make sure that the component does not get unmounted because it will lose all the custom - * responses that had been received prior to that point. - */ -function CustomResponsePortalsContainer({ webChatInstance, renderResponse }: CustomResponsePortalsContainer) { - // 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([]); - - // When the component is mounted, register the custom response handler that will store the references to the custom - // 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 rerender. - function customResponseHandler(event: CustomResponseEvent) { - setCustomResponseEvents((eventsArray) => eventsArray.concat(event)); - } - - webChatInstance.on({ type: 'customResponse', handler: customResponseHandler }); - - // Remove the custom response handler. - return () => { - webChatInstance.off({ type: 'customResponse', handler: customResponseHandler }); - }; - }, [webChatInstance]); - - // All we need to do to enable the React portals is to render each portal somewhere in your application (it - // doesn't really matter where). - return ( - <> - {customResponseEvents.map(function mapEvent(event, index) { - return ( - // eslint-disable-next-line react/no-array-index-key - - {renderResponse(event, webChatInstance)} - - ); - })} - - ); -} - -/** - * This is the component that will attach a React portal to the given host element. The host element is the element - * provided by web chat where your custom response will be displayed in the DOM. This portal will attach any React - * children passed to it under this component so you can render the response using your own React application. Those - * children will be rendered under the given element where it lives in the DOM. - */ -function CustomResponseComponentPortal({ hostElement, children }: { hostElement: HTMLElement; children: ReactNode }) { - return ReactDOM.createPortal(children, hostElement); -} - -const CustomResponsePortalsContainerExport = React.memo(CustomResponsePortalsContainer); -export { CustomResponsePortalsContainerExport as CustomResponsePortalsContainer }; diff --git a/src/UserDefinedResponsePortalsContainer.tsx b/src/UserDefinedResponsePortalsContainer.tsx new file mode 100644 index 0000000..23f54f0 --- /dev/null +++ b/src/UserDefinedResponsePortalsContainer.tsx @@ -0,0 +1,87 @@ +/** + * (C) Copyright IBM Corp. 2022, 2024. + * + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + */ + +import React, { ReactNode } from 'react'; +import ReactDOM from 'react-dom'; +import { UserDefinedResponseEvent } from './types/UserDefinedResponseEvent'; +import { WebChatInstance } from './types/WebChatInstance'; +import { RenderUserDefinedResponse } from './WebChatContainer'; + +interface UserDefinedResponsePortalsContainer { + /** + * The instance of a web chat that this component will register listeners on. + */ + webChatInstance: WebChatInstance; + + /** + * The function that this component will use to request the actual React content to display for each user defined + * response. + */ + renderResponse: RenderUserDefinedResponse; + + /** + * The list of events that were fired that contain all the responses to render. + */ + userDefinedResponseEvents: UserDefinedResponseEvent[]; +} + +/** + * This is a utility component that is used to manage all the user defined responses that are rendered by web chat. + * When a user defined response message is received by web chat, it will fire a "userDefinedResponse" event that + * provides an HTML element to which your application can attach user defined content. React portals are a mechanism + * that allows you to render a component in your React application but attach that component to the HTML element + * that was provided by web chat. + * + * This component will render a portal for each user defined response. The contents of that portal will be + * determined by calling the provided "renderResponse" render prop. + */ +function UserDefinedResponsePortalsContainer({ + webChatInstance, + renderResponse, + userDefinedResponseEvents, +}: UserDefinedResponsePortalsContainer) { + // All we need to do to enable the React portals is to render each portal somewhere in your application (it + // doesn't really matter where). + return ( + <> + {userDefinedResponseEvents.map(function mapEvent(event, index) { + return ( + // eslint-disable-next-line react/no-array-index-key + + {renderResponse(event, webChatInstance)} + + ); + })} + + ); +} + +/** + * This is the component that will attach a React portal to the given host element. The host element is the element + * provided by web chat where your user defined response will be displayed in the DOM. This portal will attach any React + * children passed to it under this component so you can render the response using your own React application. Those + * children will be rendered under the given element where it lives in the DOM. + */ +function UserDefinedResponseComponentPortal({ + hostElement, + children, +}: { + hostElement: HTMLElement; + children: ReactNode; +}) { + return ReactDOM.createPortal(children, hostElement); +} + +const UserDefinedResponsePortalsContainerExport = React.memo(UserDefinedResponsePortalsContainer); +export { UserDefinedResponsePortalsContainerExport as UserDefinedResponsePortalsContainer }; diff --git a/src/WebChatContainer.tsx b/src/WebChatContainer.tsx index ba2d22b..caed974 100644 --- a/src/WebChatContainer.tsx +++ b/src/WebChatContainer.tsx @@ -1,5 +1,5 @@ /** - * (C) Copyright IBM Corp. 2022. + * (C) Copyright IBM Corp. 2022, 2024. * * Licensed under the MIT License (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -12,11 +12,12 @@ * */ -import React, { ReactNode, useEffect, useState, MutableRefObject, useRef } from 'react'; -import { CustomResponsePortalsContainer } from './CustomResponsePortalsContainer'; +import React, { ReactNode, useEffect, useState, MutableRefObject, useRef, Dispatch, SetStateAction } from 'react'; +import semverCompare from 'semver-compare'; +import { UserDefinedResponsePortalsContainer } from './UserDefinedResponsePortalsContainer'; import { WebChatConfig } from './types/WebChatConfig'; import { WebChatInstance } from './types/WebChatInstance'; -import { CustomResponseEvent } from './types/CustomResponseEvent'; +import { UserDefinedResponseEvent } from './types/UserDefinedResponseEvent'; // 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'; @@ -41,6 +42,16 @@ interface ManagedWebChat { instance: WebChatInstance; } +/** + * The type of the render function that is used to render user defined responses. This function should return a + * component that renders the display for the message contained in the given event. + * + * @param event The UserDefinedResponseEvent that was originally fired by web chat when the user defined response + * was first fired. + * @param instance The current instance of the web chat. + */ +type RenderUserDefinedResponse = (event: UserDefinedResponseEvent, instance: WebChatInstance) => ReactNode; + interface WebChatContainerProps { /** * The config to use to load web chat. Note that the "onLoad" property is overridden by this component. If you @@ -61,9 +72,9 @@ interface WebChatContainerProps { onAfterRender?: (instance: WebChatInstance) => Promise; /** - * This is the function that this component will call when a custom response should be rendered. + * This is the function that this component will call when a user defined response should be rendered. */ - renderCustomResponse?: (event: CustomResponseEvent, instance: WebChatInstance) => ReactNode; + renderUserDefinedResponse?: RenderUserDefinedResponse; /** * A convenience prop that is a reference to the web chat instance. This component will set the value of this ref @@ -78,10 +89,9 @@ interface WebChatContainerProps { } /** - * This is a component wrapper for using the withWebChat high-order-component as well as for providing support for - * handling custom responses in portals. This can be rendered anywhere in your application but you should make sure - * it doesn't get unmounted during in the middle of your App's life or it will lose any custom responses that were - * previously received. + * This is a component wrapper for web chat. This can be rendered anywhere in your application but you should make + * sure it doesn't get unmounted during in the middle of your App's life or it will lose any user defined responses + * that were previously received. * * Note that this container will override any config.onLoad property you have set. If you need access to the web * chat instance or need to perform additional customizations of web chat when it loads, use the onBeforeRender @@ -90,7 +100,7 @@ interface WebChatContainerProps { function WebChatContainer({ onBeforeRender, onAfterRender, - renderCustomResponse, + renderUserDefinedResponse, config, instanceRef, hostURL, @@ -98,6 +108,11 @@ function WebChatContainer({ // A state value that contains the current instance of web chat. const [instance, setInstance] = useState(); + // This state will be used to record all the user defined 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 [userDefinedResponseEvents, setUserDefinedResponseEvents] = useState([]); + // The most recent web chat that was load by this component. const managedWebChatRef = useRef(); @@ -124,7 +139,15 @@ function WebChatContainer({ 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, onAfterRender).catch((error) => { + loadWebChat( + managedWebChat, + hostURL, + setInstance, + instanceRef, + onBeforeRender, + onAfterRender, + setUserDefinedResponseEvents, + ).catch((error) => { logger(managedWebChat.webChatConfig, 'An error occurred loading web chat', error); destroyWebChat(managedWebChat, setInstance, instanceRef); }); @@ -138,8 +161,14 @@ function WebChatContainer({ return undefined; }, [config, hostURL]); - if (renderCustomResponse && instance) { - return ; + if (renderUserDefinedResponse && instance) { + return ( + + ); } return null; @@ -155,6 +184,7 @@ async function loadWebChat( instanceRef: MutableRefObject, onBeforeRender: (instance: WebChatInstance) => Promise, onAfterRender: (instance: WebChatInstance) => Promise, + setUserDefinedResponseEvents: Dispatch>, ) { const { webChatConfig } = managedWebChat; @@ -180,6 +210,10 @@ async function loadWebChat( }; const instance = await window.loadWatsonAssistantChat(configWithoutOnLoad); + // Even if it doesn't end up being used, we need to add a userDefinedResponse listener now so we can ensure that + // we catch any events that may be fired during the render call (like the welcome message). + addUserDefinedResponseHandler(instance, setUserDefinedResponseEvents); + // Once the instance is created, call the onBeforeRender and then render and then onAfterRender. await onBeforeRender?.(instance); logger(webChatConfig, `Calling render.`); @@ -199,6 +233,33 @@ async function loadWebChat( } } +/** + * Adds a "userDefinedResponse" event listener to the given web chat instance that will use the given set function + * to add new events to the list. + */ +function addUserDefinedResponseHandler( + webChatInstance: WebChatInstance, + setUserDefinedResponseEvents: Dispatch>, +) { + // This handler will fire each time a user defined 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. + function userDefinedResponseHandler(event: UserDefinedResponseEvent) { + setUserDefinedResponseEvents((eventsArray) => eventsArray.concat(event)); + } + + // Also make sure to clear the list if a restart occurs. + function restartHandler() { + setUserDefinedResponseEvents([]); + } + + // In web chat 8.2.0, the "customResponse" event was renamed to "userDefinedResponse". + const webChatVersion = webChatInstance.getWidgetVersion(); + const eventName = semverCompare(webChatVersion, '8.2.0') >= 0 ? 'userDefinedResponse' : 'customResponse'; + + webChatInstance.on({ type: eventName, handler: userDefinedResponseHandler }); + webChatInstance.on({ type: 'restartConversation', handler: restartHandler }); +} + /** * Destroys an instance of web chat and marks it destroyed. */ @@ -240,14 +301,24 @@ function logger(webChatConfig: WebChatConfig, ...args: unknown[]) { } } +/** + * Removes any trailing slash from the given string. + */ +function removeTrailingSlash(value: string) { + return value.replace(/\/$/, ''); +} + /** * 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`; + let useURL = DEFAULT_BASE_URL; + if (hostURL) { + useURL = removeTrailingSlash(hostURL); + } else if (webChatConfig.cloudPrivateHostURL) { + useURL = `${removeTrailingSlash(webChatConfig.cloudPrivateHostURL)}/static/webchat`; + } + const scriptURL = `${useURL}/versions/${webChatConfig.clientVersion || 'latest'}/WatsonAssistantChatEntry.js`; const loadedWebChatURL = (window as any).wacWebChatContainerScriptURL; if (loadedWebChatURL && loadedWebChatURL !== scriptURL) { @@ -273,4 +344,4 @@ async function ensureWebChatScript(webChatConfig: WebChatConfig, hostURL: string return (window as any).wacWebChatContainerScriptPromise; } -export { setEnableDebug, WebChatContainer, WebChatContainerProps, ensureWebChatScript }; +export { setEnableDebug, WebChatContainer, WebChatContainerProps, ensureWebChatScript, RenderUserDefinedResponse }; diff --git a/src/__tests__/WebChatContainer.test.tsx b/src/__tests__/WebChatContainer.test.tsx index b1b0a92..f90f63f 100644 --- a/src/__tests__/WebChatContainer.test.tsx +++ b/src/__tests__/WebChatContainer.test.tsx @@ -1,5 +1,5 @@ /** - * (C) Copyright IBM Corp. 2022. + * (C) Copyright IBM Corp. 2022, 2024. * * Licensed under the MIT License (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -17,7 +17,7 @@ import { render } from '@testing-library/react'; import { WebChatContainer, WebChatContainerProps } from '../WebChatContainer'; import { TEST_INSTANCE_CONFIG, waitForFind, waitForWebChat } from '../test/testUtils'; import { WebChatInstance } from '../types/WebChatInstance'; -import { CustomResponseEvent } from '../types/CustomResponseEvent'; +import { UserDefinedResponseEvent } from '../types/UserDefinedResponseEvent'; jest.setTimeout(20000); @@ -57,14 +57,14 @@ describe('WebChatContainer', () => { await waitForWebChat(findAllByPlaceholderText); }); - it('tests that the component renders custom responses', async () => { + it('tests that the component renders user defined responses', async () => { const instanceRef: MutableRefObject = { current: null }; let webChatInstance: WebChatInstance; let webChatAfterInstance: WebChatInstance; - // We'll use this map to assign a unique number to each event so we can generate a unique custom response for + // We'll use this map to assign a unique number to each event so we can generate a unique user defined response for // each message and make sure they are rendered correctly. - const eventMap = new Map(); + const eventMap = new Map(); const onBeforeRender: WebChatContainerProps['onBeforeRender'] = async (instance) => { webChatInstance = instance; @@ -74,13 +74,13 @@ describe('WebChatContainer', () => { webChatAfterInstance = instance; }; - const renderCustomResponse: WebChatContainerProps['renderCustomResponse'] = (event) => { + const renderUserDefinedResponse: WebChatContainerProps['renderUserDefinedResponse'] = (event) => { let count = eventMap.get(event); if (!count) { count = eventMap.size + 1; eventMap.set(event, count); } - return
This is a custom response! Count: {count}.
; + return
This is a user defined response! Count: {count}.
; }; const component = ( @@ -88,7 +88,7 @@ describe('WebChatContainer', () => { config={TEST_INSTANCE_CONFIG} onBeforeRender={onBeforeRender} onAfterRender={onAfterRender} - renderCustomResponse={renderCustomResponse} + renderUserDefinedResponse={renderUserDefinedResponse} instanceRef={instanceRef} /> ); @@ -96,18 +96,18 @@ describe('WebChatContainer', () => { await waitForWebChat(findAllByPlaceholderText); - // Send a message to get the first custom response. - webChatInstance.send({ input: { text: 'custom response' } }); + // Send a message to get the first user defined response. + webChatInstance.send({ input: { text: 'user defined response' } }); - await waitForFind('This is a custom response! Count: 1.', findAllByText); - expect(queryAllByText('This is a custom response! Count: 2.', { exact: false }).length).toEqual(0); + await waitForFind('This is a user defined response! Count: 1.', findAllByText); + expect(queryAllByText('This is a user defined 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' } }); + // Send a message to get the second user defined response and make sure both user defined responses appear. + webChatInstance.send({ input: { text: 'user defined response' } }); - 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); + await waitForFind('This is a user defined response! Count: 2.', findAllByText); + await waitForFind('This is a user defined response! Count: 1.', findAllByText); + expect(queryAllByText('This is a user defined response! Count: 3.', { exact: false }).length).toEqual(0); expect(instanceRef.current).toBe(webChatInstance); expect(instanceRef.current).toBe(webChatAfterInstance); diff --git a/src/__tests__/withWebChat.test.tsx b/src/__tests__/withWebChat.test.tsx deleted file mode 100644 index f14d6e5..0000000 --- a/src/__tests__/withWebChat.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -/** - * (C) Copyright IBM Corp. 2021. - * - * Licensed under the MIT License (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * https://opensource.org/licenses/MIT - * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - * - */ - -/* eslint-disable max-classes-per-file */ - -import '@testing-library/jest-dom'; -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; - -import { withWebChat, AddedWithWebChatProps, WebChatInstance } from '../entry'; -import { WebChatConfig } from '../types/WebChatConfig'; -import { TEST_INSTANCE_CONFIG, waitForWebChat } from '../test/testUtils'; - -jest.setTimeout(20000); - -interface ComponentToWrapProps extends AddedWithWebChatProps { - location: string; -} - -describe('withWebChat', () => { - it('should load web chat via createWebChatInstance prop and pass through original props with a functional component', async () => { - const ComponentToWrap = (props: ComponentToWrapProps) => { - const { location, createWebChatInstance } = props; - React.useEffect(() => { - createWebChatInstance({ - ...TEST_INSTANCE_CONFIG, - onLoad: (instance: WebChatInstance) => { - instance.render(); - }, - }); - }, []); - return
I am here in {location}!
; - }; - const WasWrappedComponent = withWebChat({ debug: true })(ComponentToWrap); - const { getByText, findAllByPlaceholderText } = render(); - // Extra props are correctly passed. - expect(getByText('Boston', { exact: false })).toBeInTheDocument(); - await waitForWebChat(findAllByPlaceholderText); - }); - - it('should load web chat via createWebChatInstance prop and pass through original props with a class component', async () => { - class ComponentToWrap extends React.Component { - // eslint-disable-next-line react/sort-comp - componentDidMount() { - const { createWebChatInstance } = this.props; - createWebChatInstance(this.webChatOptions); - } - - webChatOnLoad = (instance: WebChatInstance) => { - instance.render(); - }; - - // eslint-disable-next-line react/sort-comp - webChatOptions: WebChatConfig = { - ...TEST_INSTANCE_CONFIG, - onLoad: this.webChatOnLoad, - }; - - render() { - const { location } = this.props; - return
I am here in {location}!
; - } - } - - const WasWrappedComponent = withWebChat({ debug: true })(ComponentToWrap); - const { getByText, findAllByPlaceholderText } = render(); - // Extra props are correctly passed. - expect(getByText('Boston', { exact: false })).toBeInTheDocument(); - await waitForWebChat(findAllByPlaceholderText); - }); - - it('should load web chat via createWebChatInstance prop and pass through original props and a ref', async () => { - const ComponentToWrap = React.forwardRef((props: ComponentToWrapProps, ref: React.RefObject) => { - const { location, createWebChatInstance } = props; - React.useEffect(() => { - createWebChatInstance({ - ...TEST_INSTANCE_CONFIG, - onLoad: (instance: WebChatInstance) => { - instance.render(); - }, - }); - }, []); - return ( -
- I am from {location}: -
- ); - }); - const WasWrappedComponent = withWebChat({ debug: true })(ComponentToWrap); - class App extends React.Component { - onClickButton() { - if (this && this.locationRef && this.locationRef.current) { - this.locationRef.current.focus(); - } - } - - locationRef = React.createRef(); - - render() { - return ( -
- - -
- ); - } - } - const { getByText, findAllByPlaceholderText, getByPlaceholderText, getByTestId } = render(); - // Extra props are correctly passed. - expect(getByText('Boston', { exact: false })).toBeInTheDocument(); - await waitForWebChat(findAllByPlaceholderText); - const button = getByTestId('focus-button'); - fireEvent.click(button); - expect(getByPlaceholderText('Where are you from?')).toHaveFocus(); - }); -}); diff --git a/src/entry.tsx b/src/entry.tsx index 88bc53e..3731470 100644 --- a/src/entry.tsx +++ b/src/entry.tsx @@ -1,5 +1,5 @@ /** - * (C) Copyright IBM Corp. 2021, 2022. + * (C) Copyright IBM Corp. 2021, 2024. * * Licensed under the MIT License (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -12,22 +12,9 @@ * */ -import { withWebChat } from './withWebChat'; -import { AddedWithWebChatProps, WithWebChatConfig } from './types/WithWebChatTypes'; -import { CustomResponsePortalsContainer } from './CustomResponsePortalsContainer'; import { WebChatContainer, setEnableDebug } from './WebChatContainer'; import { WebChatConfig } from './types/WebChatConfig'; import { WebChatInstance } from './types/WebChatInstance'; import { WebChatCustomElement } from './WebChatCustomElement'; -export { - AddedWithWebChatProps, - CustomResponsePortalsContainer, - WebChatConfig, - WebChatContainer, - WebChatInstance, - withWebChat, - WithWebChatConfig, - setEnableDebug, - WebChatCustomElement, -}; +export { WebChatConfig, WebChatContainer, WebChatInstance, setEnableDebug, WebChatCustomElement }; diff --git a/src/types/CustomResponseEvent.ts b/src/types/UserDefinedResponseEvent.ts similarity index 69% rename from src/types/CustomResponseEvent.ts rename to src/types/UserDefinedResponseEvent.ts index a65d86c..007780b 100644 --- a/src/types/CustomResponseEvent.ts +++ b/src/types/UserDefinedResponseEvent.ts @@ -1,5 +1,5 @@ /** - * (C) Copyright IBM Corp. 2022. + * (C) Copyright IBM Corp. 2022, 2024. * * Licensed under the MIT License (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -13,23 +13,24 @@ */ /** - * This represents an event that is fired when web chat receives a custom response (response_type = "user_defined"). + * This represents an event that is fired when web chat receives a user defined response (response_type = + * "user_defined"). */ -interface CustomResponseEvent { +interface UserDefinedResponseEvent { /** * The type of the event. */ - type: 'customResponse'; + type: 'userDefinedResponse'; data: { /** - * The item within the MessageResponse.output.generic array that this custom response is for. + * The item within the MessageResponse.output.generic array that this user defined response is for. */ message: unknown; /** - * The full MessageResponse that this custom response is fired for. A MessageResponse may contain multiple items - * and an event will be fired for each. + * The full MessageResponse that this user defined response is fired for. A MessageResponse may contain multiple + * items and an event will be fired for each. */ fullMessage: unknown; @@ -40,4 +41,4 @@ interface CustomResponseEvent { }; } -export { CustomResponseEvent }; +export { UserDefinedResponseEvent }; diff --git a/src/types/WithWebChatTypes.ts b/src/types/WithWebChatTypes.ts deleted file mode 100644 index b0a07a7..0000000 --- a/src/types/WithWebChatTypes.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * (C) Copyright IBM Corp. 2022. - * - * Licensed under the MIT License (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * https://opensource.org/licenses/MIT - * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - * - */ - -import React from 'react'; -import { WebChatConfig } from './WebChatConfig'; -import { WebChatInstance } from './WebChatInstance'; - -/** - * Configuration object for withWebChat higher order component. - */ -interface WithWebChatConfig { - /** - * Adds logging for setup and tear down process of web chat. Helpful for seeing if your application is aggressively - * mounting and remounting web chat. - */ - debug?: boolean; - - /** - * Set the url where web chat assets are hosted. Used for development purposes. - */ - baseUrl?: string; -} - -/** - * Properties added by the withWebChat higher order component to passed components. - */ -interface AddedWithWebChatProps { - /** - * The method to create a web chat instance. - * - * @param config - A web chat configuration options object. - * @returns A promise resolving with an instance of web chat. - */ - createWebChatInstance: (config: WebChatConfig) => Promise; -} - -/** - * The ref that is added to WithWebChat by forwardRef. - */ -interface ForwardedRefProps { - forwardedRef: React.Ref; -} - -/** - * The props passed into the original component combined with the ref props we add later. - */ -type WithWebChatProps = T & ForwardedRefProps; - -/** - * Props passed to the original component with any reference to any props added by the HOC removed. - * We need to specifically say to not have two props of the same name here for TypeScript to correctly - * infer types. - */ -type OriginalProps = Omit; - -export { AddedWithWebChatProps, WithWebChatConfig, ForwardedRefProps, WithWebChatProps, OriginalProps }; diff --git a/src/withWebChat.tsx b/src/withWebChat.tsx deleted file mode 100644 index e74d877..0000000 --- a/src/withWebChat.tsx +++ /dev/null @@ -1,260 +0,0 @@ -/** - * (C) Copyright IBM Corp. 2022. - * - * Licensed under the MIT License (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * https://opensource.org/licenses/MIT - * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - * - */ - -/* eslint-disable react/jsx-props-no-spreading */ -/* eslint-disable no-underscore-dangle */ - -import React from 'react'; -import { WithWebChatConfig, OriginalProps, ForwardedRefProps, WithWebChatProps } from './types/WithWebChatTypes'; -import { WebChatConfig } from './types/WebChatConfig'; -import { WebChatInstance } from './types/WebChatInstance'; -import { ensureWebChatScript } from './WebChatContainer'; - -const DEFAULT_BASE_URL = 'https://web-chat.global.assistant.watson.appdomain.cloud'; - -// When withWebChat is first used, loadWebChatScript promise will be set to loadWebChatScript(baseUrl). -// This way we can have multiple withWebChat instances listen to this same promise. -let loadWebChatScriptPromise: Promise; - -/** - * Injects a method to create an instance of web chat into the props for the component. - * - * withWebChat takes a component and a config argument. The config argument contains two parameters. - * First, a "debug" option that takes a boolean. When turned on, this will add console.logs outlying the status of the - * web chat during each step of build and tear down. The second parameter is baseUrl. This is used to identify where - * the web chat should load from. config.baseUrl is used for internal debugging and development purposes, so you - * shouldn't have to touch that normally. - */ -function withWebChat(passedConfig: WithWebChatConfig = {}) { - return function withWebChatWithConfig( - WrappedComponent: React.ComponentType, - ): React.ForwardRefExoticComponent> & React.RefAttributes> { - const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; - - function WithWebChat(props: WithWebChatProps>) { - const config = { - baseUrl: passedConfig.baseUrl || DEFAULT_BASE_URL, - debug: passedConfig.debug || false, - }; - - // We track the passed web chat config and the promise that the web chat script has been loaded in state. - const [webChatConfig, setWebChatConfig] = React.useState(); - const webChatLoadedPromise = React.useRef( - new Deferred<(passedWebChatConfig: WebChatConfig) => Promise>(), - ); - - /** - * This function is passed as a prop. We are not able to immediately work to create an instance of web chat until - * the external script loads so we await createWebChatInstanceReady being resolved. We also need to be able to - * keep track of if the component is still mounted in all our promises, so we use createWebChatInstanceInternal - * that is able to keep track of mounted state. - */ - async function createWebChatInstance(passedWebChatConfig: WebChatConfig) { - setWebChatConfig(passedWebChatConfig); - const createWebChatInstanceInternal = await webChatLoadedPromise.current.promise; - return createWebChatInstanceInternal(passedWebChatConfig); - } - - // This effect only runs when createWebChatInstance is called. It contains a lot of promises while loading up web chat. - // Each of these promises checks if isMounted is still set to true before continuing. When the component unmounts, - // isMounted is set to false. This gives us safety from quick mounting and unmounting that can create problems - // if we don't stop the promises from continuing to run. - React.useEffect(() => { - let isMounted = true; - let instance: WebChatInstance; - - // If config.debug is true, this method will console.log out the passed messages, prefixed by an identifier to - // separate from non web chat console.log traffic. - function logger(...args: unknown[]) { - if (config.debug) { - // eslint-disable-next-line no-console - console.log( - `[IBM watsonx Assistant withWebChat${ - webChatConfig && webChatConfig.namespace ? `: Namespace "${webChatConfig.namespace}"` : '' - }]`, - args, - ); - } - } - - // This function manages all the steps to load a web chat and pass the instance back in a promise. It manages making sure - // the component is still mounted as it runs through async steps and any clean up of the instance on failures. - // eslint-disable-next-line consistent-return - async function createWebChatInstanceTemplate(passedWebChatConfig: WebChatConfig): Promise { - if (isMounted) { - logger('creating web chat instance'); - // eslint-disable-next-line @typescript-eslint/no-empty-function - const onLoadReference = passedWebChatConfig.onLoad || function noop() {}; - - // Wait for this version of web chat to load. - try { - if (instance) { - // eslint-disable-next-line no-console - console.warn( - '[IBM watsonx Assistant withWebChat] createWebChatInstance has already been called... destroying previous instance.', - ); - instance.destroy(); - } - if (window.loadWatsonAssistantChat) { - logger('web chat instance being created'); - instance = await window.loadWatsonAssistantChat(passedWebChatConfig); - } else { - throw new Error('window.loadWatsonAssistantChat is undefined'); - } - if (isMounted) { - logger('web chat instance created and returned to component'); - onLoadReference(instance); - } else if (instance) { - logger('web chat instance created but component unmounted...destroying instance and aborting.'); - instance.destroy(); - } - return instance; - } catch (error) { - if (instance) { - logger('web chat failed to create instance...destroying instance and aborting.'); - instance.destroy(); - } else { - logger('web chat failed to create instance...aborting.'); - } - // Throw the error back upstream to be handled inside the component. - throw new Error(error); - } - } else { - logger('web chat instance ready to be created, but component unmounted...aborting.'); - return instance; - } - } - - if (webChatConfig) { - logger('createWebChatInstance called'); - // If the script tag for web chat has not been injected on the page, do so now. - if (!loadWebChatScriptPromise) { - logger('appending web chat scripts to body'); - loadWebChatScriptPromise = ensureWebChatScript(webChatConfig, config.baseUrl); - } - - loadWebChatScriptPromise - .then(() => { - logger('web chat script loaded'); - if (isMounted) { - logger('web chat script loaded and component is still mounted. Setting createWebChatInstance.'); - webChatLoadedPromise.current.resolve(createWebChatInstanceTemplate); - } else { - logger('web chat script loaded and component is no longer mounted...aborting.'); - } - }) - .catch((error) => { - logger('web chat script failed', error); - if (isMounted) { - logger('web chat script failed to load. createWebChatInstance will reject.'); - webChatLoadedPromise.current.reject( - `[IBM watsonx Assistant withWebChat${ - webChatConfig && webChatConfig.namespace ? `: Namespace "${webChatConfig.namespace}"` : '' - }] web chat failed to load.`, - ); - } else { - logger('web chat script failed to load and component is no longer mounted...aborting.'); - } - }); - } - - return () => { - // By setting isMounted to false, we prevent post async code from running when the component is no longer mounted. - isMounted = false; - if (webChatConfig) { - if (instance) { - logger('component has unmounted...destroying web chat instance and aborting'); - instance.destroy(); - } else { - logger('component has unmounted before web chat instance was created...aborting'); - } - } - }; - }, [webChatConfig, webChatLoadedPromise]); - - const { forwardedRef, ...restPropsTemp } = props as ForwardedRefProps; - const rest = restPropsTemp as T; - return ; - } - - const WithForwardedRef = React.forwardRef((props: OriginalProps, ref: React.Ref) => ( - - )); - WithForwardedRef.displayName = displayName; - - return WithForwardedRef; - }; -} - -/** - * A class to mirror the old jQuery deferred to allow exposing of resolve, reject to external scripts to call. - * - * @see https://github.com/domenic/promises-unwrapping/blob/master/docs/states-and-fates.md - */ -class Deferred { - public promise: Promise; - - private fate: 'resolved' | 'unresolved'; - - private state: 'pending' | 'fulfilled' | 'rejected'; - - private _resolve: (value?: T | PromiseLike) => void; - - private _reject: (reason?: unknown) => void; - - constructor() { - this.state = 'pending'; - this.fate = 'unresolved'; - this.promise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; - }); - this.promise.then( - () => { - this.state = 'fulfilled'; - }, - () => { - this.state = 'rejected'; - }, - ); - } - - resolve(value?: T) { - this.fate = 'resolved'; - this._resolve(value); - } - - reject(reason?: unknown) { - this.fate = 'resolved'; - this._reject(reason); - } - - isResolved() { - return this.fate === 'resolved'; - } - - isPending() { - return this.state === 'pending'; - } - - isFulfilled() { - return this.state === 'fulfilled'; - } - - isRejected() { - return this.state === 'rejected'; - } -} - -export { withWebChat };