From 7fadfe635eda159103cad6586ce2ee5c602a83f7 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 10 Feb 2023 10:59:55 +0000 Subject: [PATCH 1/5] Alert when sending empty message --- ...rtEmptyMessage.multilineTextBox.enter.html | 53 +++++++ ...lertEmptyMessage.multilineTextBox.enter.js | 6 + ....sendBox.alertEmptyMessage.sendButton.html | 49 ++++++ ...ty.sendBox.alertEmptyMessage.sendButton.js | 6 + ...tEmptyMessage.singleLineTextBox.enter.html | 50 ++++++ ...ertEmptyMessage.singleLineTextBox.enter.js | 6 + packages/component/package-lock.json | 102 ++++++------- packages/component/package.json | 3 +- packages/component/src/BasicSendBox.tsx | 23 ++- .../src/SendBox/AutoResizeTextArea.tsx | 5 + packages/component/src/SendBox/SendButton.tsx | 21 +-- packages/component/src/SendBox/TextBox.tsx | 54 +++---- .../src/Utils/AccessibleInputText.tsx | 5 + .../internal/SendBox/SendBoxComposer.tsx | 142 ++++++++++++++++++ .../internal/SendBox/private/Context.ts | 7 + .../internal/SendBox/private/types.ts | 6 + .../SendBox/private/useCanSubmitSendBox.ts | 26 ++++ .../internal/SendBox/private/useContext.ts | 15 ++ .../internal/SendBox/useErrorMessageId.ts | 15 ++ .../providers/internal/SendBox/useSubmit.ts | 17 +++ packages/core/src/sagas/submitSendBoxSaga.js | 3 + .../globals/pageElements/sendBoxTextBox.js | 2 +- 22 files changed, 508 insertions(+), 108 deletions(-) create mode 100644 __tests__/html/accessibility.sendBox.alertEmptyMessage.multilineTextBox.enter.html create mode 100644 __tests__/html/accessibility.sendBox.alertEmptyMessage.multilineTextBox.enter.js create mode 100644 __tests__/html/accessibility.sendBox.alertEmptyMessage.sendButton.html create mode 100644 __tests__/html/accessibility.sendBox.alertEmptyMessage.sendButton.js create mode 100644 __tests__/html/accessibility.sendBox.alertEmptyMessage.singleLineTextBox.enter.html create mode 100644 __tests__/html/accessibility.sendBox.alertEmptyMessage.singleLineTextBox.enter.js create mode 100644 packages/component/src/providers/internal/SendBox/SendBoxComposer.tsx create mode 100644 packages/component/src/providers/internal/SendBox/private/Context.ts create mode 100644 packages/component/src/providers/internal/SendBox/private/types.ts create mode 100644 packages/component/src/providers/internal/SendBox/private/useCanSubmitSendBox.ts create mode 100644 packages/component/src/providers/internal/SendBox/private/useContext.ts create mode 100644 packages/component/src/providers/internal/SendBox/useErrorMessageId.ts create mode 100644 packages/component/src/providers/internal/SendBox/useSubmit.ts diff --git a/__tests__/html/accessibility.sendBox.alertEmptyMessage.multilineTextBox.enter.html b/__tests__/html/accessibility.sendBox.alertEmptyMessage.multilineTextBox.enter.html new file mode 100644 index 0000000000..998e548144 --- /dev/null +++ b/__tests__/html/accessibility.sendBox.alertEmptyMessage.multilineTextBox.enter.html @@ -0,0 +1,53 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/accessibility.sendBox.alertEmptyMessage.multilineTextBox.enter.js b/__tests__/html/accessibility.sendBox.alertEmptyMessage.multilineTextBox.enter.js new file mode 100644 index 0000000000..ad511ac776 --- /dev/null +++ b/__tests__/html/accessibility.sendBox.alertEmptyMessage.multilineTextBox.enter.js @@ -0,0 +1,6 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('accessibility requirement', () => { + describe('when pressing ENTER on an empty multiline send box', () => + test('should alert about empty message', () => runHTML('accessibility.sendBox.alertEmptyMessage.multilineTextBox.enter.html'))); +}); diff --git a/__tests__/html/accessibility.sendBox.alertEmptyMessage.sendButton.html b/__tests__/html/accessibility.sendBox.alertEmptyMessage.sendButton.html new file mode 100644 index 0000000000..a6645befe2 --- /dev/null +++ b/__tests__/html/accessibility.sendBox.alertEmptyMessage.sendButton.html @@ -0,0 +1,49 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/accessibility.sendBox.alertEmptyMessage.sendButton.js b/__tests__/html/accessibility.sendBox.alertEmptyMessage.sendButton.js new file mode 100644 index 0000000000..8768f845f0 --- /dev/null +++ b/__tests__/html/accessibility.sendBox.alertEmptyMessage.sendButton.js @@ -0,0 +1,6 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('accessibility requirement', () => { + describe('when clicking on send box with an empty send box', () => + test('should alert about empty message', () => runHTML('accessibility.sendBox.alertEmptyMessage.sendButton.html'))); +}); diff --git a/__tests__/html/accessibility.sendBox.alertEmptyMessage.singleLineTextBox.enter.html b/__tests__/html/accessibility.sendBox.alertEmptyMessage.singleLineTextBox.enter.html new file mode 100644 index 0000000000..52b0565b0a --- /dev/null +++ b/__tests__/html/accessibility.sendBox.alertEmptyMessage.singleLineTextBox.enter.html @@ -0,0 +1,50 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/accessibility.sendBox.alertEmptyMessage.singleLineTextBox.enter.js b/__tests__/html/accessibility.sendBox.alertEmptyMessage.singleLineTextBox.enter.js new file mode 100644 index 0000000000..64bfd9cb2d --- /dev/null +++ b/__tests__/html/accessibility.sendBox.alertEmptyMessage.singleLineTextBox.enter.js @@ -0,0 +1,6 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('accessibility requirement', () => { + describe('when pressing ENTER on an empty single line send box', () => + test('should alert about empty message', () => runHTML('accessibility.sendBox.alertEmptyMessage.singleLineTextBox.enter.html'))); +}); diff --git a/packages/component/package-lock.json b/packages/component/package-lock.json index 8fd766b655..d725db411c 100644 --- a/packages/component/package-lock.json +++ b/packages/component/package-lock.json @@ -24,7 +24,8 @@ "react-say": "2.1.0", "react-scroll-to-bottom": "4.2.0", "redux": "4.2.0", - "simple-update-in": "2.2.0" + "simple-update-in": "2.2.0", + "use-ref-from": "0.0.1" }, "devDependencies": { "@babel/cli": "^7.18.10", @@ -1788,12 +1789,12 @@ } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.14.7", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.14.7.tgz", - "integrity": "sha512-Wvzcw4mBYbTagyBVZpAJWI06auSIj033T/yNE0Zn1xcup83MieCddZA7ls3kme17L4NOGBrQ09Q+nKB41RLWBA==", + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.20.13.tgz", + "integrity": "sha512-p39/6rmY9uvlzRiLZBIB3G9/EBr66LBMcYm7fIDeSBNdRjF2AGD3rFZucUyAgGHC2N+7DdLvVi33uTjSE44FIw==", "dependencies": { - "core-js-pure": "^3.15.0", - "regenerator-runtime": "^0.13.4" + "core-js-pure": "^3.25.1", + "regenerator-runtime": "^0.13.11" }, "engines": { "node": ">=6.9.0" @@ -2632,9 +2633,9 @@ } }, "node_modules/core-js-pure": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.15.2.tgz", - "integrity": "sha512-D42L7RYh1J2grW8ttxoY1+17Y4wXZeKe7uyplAI3FkNQyI5OgBIAjUfFiTPfL1rs0qLpxaabITNbjKl1Sp82tA==", + "version": "3.27.2", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.27.2.tgz", + "integrity": "sha512-Cf2jqAbXgWH3VVzjyaaFkY1EBazxugUepGymDoeteyYr9ByX51kD2jdHZlsEF/xnJMyN3Prua7mQuzwMg6Zc9A==", "hasInstallScript": true, "funding": { "type": "opencollective", @@ -3770,18 +3771,6 @@ "react": ">= 16.8.6" } }, - "node_modules/react-scroll-to-bottom/node_modules/@babel/runtime-corejs3": { - "version": "7.15.4", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.15.4.tgz", - "integrity": "sha512-lWcAqKeB624/twtTc3w6w/2o9RqJPaNBhPGK6DKLSiwuVWC7WFkypWyNg+CpZoyJH0jVzv1uMtXZ/5/lQOLtCg==", - "dependencies": { - "core-js-pure": "^3.16.0", - "regenerator-runtime": "^0.13.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/react-scroll-to-bottom/node_modules/@emotion/css": { "version": "11.1.3", "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.1.3.tgz", @@ -3817,16 +3806,6 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/react-scroll-to-bottom/node_modules/core-js-pure": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.18.3.tgz", - "integrity": "sha512-qfskyO/KjtbYn09bn1IPkuhHl5PlJ6IzJ9s9sraJ1EqcuGyLGKzhSM1cY0zgyL9hx42eulQLZ6WaeK5ycJCkqw==", - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/react-scroll-to-bottom/node_modules/prop-types": { "version": "15.7.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", @@ -3877,9 +3856,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.7", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/regenerator-transform": { "version": "0.15.0", @@ -4219,6 +4198,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-ref-from": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/use-ref-from/-/use-ref-from-0.0.1.tgz", + "integrity": "sha512-RcY9O6iQGZ7B7Gvr4DBbLJBeZO81J/q+JV+Q6CIflM+ANqevrLA1Hcqy9ApPWHfjt6kHdjQ/081XJmC3hrRkmg==", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -5554,12 +5544,12 @@ } }, "@babel/runtime-corejs3": { - "version": "7.14.7", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.14.7.tgz", - "integrity": "sha512-Wvzcw4mBYbTagyBVZpAJWI06auSIj033T/yNE0Zn1xcup83MieCddZA7ls3kme17L4NOGBrQ09Q+nKB41RLWBA==", + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.20.13.tgz", + "integrity": "sha512-p39/6rmY9uvlzRiLZBIB3G9/EBr66LBMcYm7fIDeSBNdRjF2AGD3rFZucUyAgGHC2N+7DdLvVi33uTjSE44FIw==", "requires": { - "core-js-pure": "^3.15.0", - "regenerator-runtime": "^0.13.4" + "core-js-pure": "^3.25.1", + "regenerator-runtime": "^0.13.11" } }, "@babel/template": { @@ -6214,9 +6204,9 @@ } }, "core-js-pure": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.15.2.tgz", - "integrity": "sha512-D42L7RYh1J2grW8ttxoY1+17Y4wXZeKe7uyplAI3FkNQyI5OgBIAjUfFiTPfL1rs0qLpxaabITNbjKl1Sp82tA==" + "version": "3.27.2", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.27.2.tgz", + "integrity": "sha512-Cf2jqAbXgWH3VVzjyaaFkY1EBazxugUepGymDoeteyYr9ByX51kD2jdHZlsEF/xnJMyN3Prua7mQuzwMg6Zc9A==" }, "cosmiconfig": { "version": "7.0.1", @@ -7080,15 +7070,6 @@ "simple-update-in": "2.2.0" }, "dependencies": { - "@babel/runtime-corejs3": { - "version": "7.15.4", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.15.4.tgz", - "integrity": "sha512-lWcAqKeB624/twtTc3w6w/2o9RqJPaNBhPGK6DKLSiwuVWC7WFkypWyNg+CpZoyJH0jVzv1uMtXZ/5/lQOLtCg==", - "requires": { - "core-js-pure": "^3.16.0", - "regenerator-runtime": "^0.13.4" - } - }, "@emotion/css": { "version": "11.1.3", "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.1.3.tgz", @@ -7111,11 +7092,6 @@ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.18.3.tgz", "integrity": "sha512-tReEhtMReZaPFVw7dajMx0vlsz3oOb8ajgPoHVYGxr8ErnZ6PcYEvvmjGmXlfpnxpkYSdOQttjB+MvVbCGfvLw==" }, - "core-js-pure": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.18.3.tgz", - "integrity": "sha512-qfskyO/KjtbYn09bn1IPkuhHl5PlJ6IzJ9s9sraJ1EqcuGyLGKzhSM1cY0zgyL9hx42eulQLZ6WaeK5ycJCkqw==" - }, "prop-types": { "version": "15.7.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", @@ -7162,9 +7138,9 @@ } }, "regenerator-runtime": { - "version": "0.13.7", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "regenerator-transform": { "version": "0.15.0", @@ -7414,6 +7390,14 @@ "picocolors": "^1.0.0" } }, + "use-ref-from": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/use-ref-from/-/use-ref-from-0.0.1.tgz", + "integrity": "sha512-RcY9O6iQGZ7B7Gvr4DBbLJBeZO81J/q+JV+Q6CIflM+ANqevrLA1Hcqy9ApPWHfjt6kHdjQ/081XJmC3hrRkmg==", + "requires": { + "@babel/runtime-corejs3": "^7.20.7" + } + }, "uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/packages/component/package.json b/packages/component/package.json index bbdd85311c..e22643f427 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -80,7 +80,8 @@ "react-say": "2.1.0", "react-scroll-to-bottom": "4.2.0", "redux": "4.2.0", - "simple-update-in": "2.2.0" + "simple-update-in": "2.2.0", + "use-ref-from": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.6", diff --git a/packages/component/src/BasicSendBox.tsx b/packages/component/src/BasicSendBox.tsx index 652e2c8733..12fd192633 100644 --- a/packages/component/src/BasicSendBox.tsx +++ b/packages/component/src/BasicSendBox.tsx @@ -3,18 +3,21 @@ import { hooks } from 'botframework-webchat-api'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import React, { FC } from 'react'; -import type { WebChatActivity } from 'botframework-webchat-core'; import DictationInterims from './SendBox/DictationInterims'; import MicrophoneButton from './SendBox/MicrophoneButton'; +import SendBoxComposer from './providers/internal/SendBox/SendBoxComposer'; import SendButton from './SendBox/SendButton'; import SuggestedActions from './SendBox/SuggestedActions'; import TextBox from './SendBox/TextBox'; import UploadButton from './SendBox/UploadButton'; +import useErrorMessageId from './providers/internal/SendBox/useErrorMessageId'; import useStyleSet from './hooks/useStyleSet'; import useStyleToEmotionObject from './hooks/internal/useStyleToEmotionObject'; import useWebSpeechPonyfill from './hooks/useWebSpeechPonyfill'; +import type { WebChatActivity } from 'botframework-webchat-core'; + const { DictateState: { DICTATING, STARTING } } = Constants; @@ -50,11 +53,12 @@ type BasicSendBoxProps = { className?: string; }; -const BasicSendBox: FC = ({ className }) => { +const BasicSendBoxCore: FC = ({ className }) => { const [{ hideUploadButton, sendBoxButtonAlignment }] = useStyleOptions(); const [{ sendBox: sendBoxStyleSet }] = useStyleSet(); const [{ SpeechRecognition = undefined } = {}] = useWebSpeechPonyfill(); const [direction] = useDirection(); + const [errorMessageId] = useErrorMessageId(); const [speechInterimsVisible] = useSendBoxSpeechInterimsVisible(); const styleToEmotionObject = useStyleToEmotionObject(); @@ -70,6 +74,8 @@ const BasicSendBox: FC = ({ className }) => { return (
= ({ className }) => { ); }; -BasicSendBox.defaultProps = { +BasicSendBoxCore.defaultProps = { className: '' }; -BasicSendBox.propTypes = { +BasicSendBoxCore.propTypes = { className: PropTypes.string }; +const BasicSendBox: FC = (props: BasicSendBoxProps) => ( + + + +); + +BasicSendBox.defaultProps = BasicSendBoxCore.defaultProps; +BasicSendBox.propTypes = BasicSendBoxCore.propTypes; + export default BasicSendBox; export { useSendBoxSpeechInterimsVisible }; diff --git a/packages/component/src/SendBox/AutoResizeTextArea.tsx b/packages/component/src/SendBox/AutoResizeTextArea.tsx index 5c0c7091a3..3007ac1b44 100644 --- a/packages/component/src/SendBox/AutoResizeTextArea.tsx +++ b/packages/component/src/SendBox/AutoResizeTextArea.tsx @@ -13,6 +13,7 @@ import useEnterKeyHint from '../hooks/internal/useEnterKeyHint'; import useStyleSet from '../hooks/useStyleSet'; type AutoResizeTextAreaProps = { + 'aria-errormessage'?: string; 'aria-label'?: string; className?: string; 'data-id'?: string; @@ -35,6 +36,7 @@ type AutoResizeTextAreaProps = { const AutoResizeTextArea = forwardRef( ( { + 'aria-errormessage': ariaErrorMessage, 'aria-label': ariaLabel, className, 'data-id': dataId, @@ -66,6 +68,7 @@ const AutoResizeTextArea = forwardRef connectToWebChat( @@ -27,16 +28,10 @@ type SendButtonProps = { const SendButton: FC = ({ className }) => { const [disabled] = useDisabled(); - const focus = useFocus(); const localize = useLocalizer(); - const scrollToEnd = useScrollToEnd(); - const submitSendBox = useSubmitSendBox(); - - const handleClick = useCallback(() => { - focus('sendBoxWithoutKeyboard'); - scrollToEnd(); - submitSendBox(); - }, [focus, scrollToEnd, submitSendBox]); + const submit = useSubmit(); + + const handleClick = useCallback(() => submit({ setFocus: 'sendBoxWithoutKeyboard' }), [submit]); return ( ...selectors ); -function useTextBoxSubmit(): (setFocus?: boolean | 'sendBox') => void { - const [sendBoxValue] = useSendBoxValue(); - const focus = useFocus(); - const scrollToEnd = useScrollToEnd(); - const submitSendBox = useSubmitSendBox(); - - return useCallback( - setFocus => { - if (sendBoxValue) { - scrollToEnd(); - submitSendBox(); - - if (setFocus) { - if (setFocus === true) { - console.warn( - `"botframework-webchat: Passing "true" to "useTextBoxSubmit" is deprecated and will be removed on or after 2022-04-23. Please pass "sendBox" instead."` - ); - - focus('sendBox'); - } else { - focus(setFocus); - } - } - } +/** + * Submits the text box and optionally set the focus after send. + */ +type SubmitTextBoxFunction = { + /** + * Submits the text box, without setting the focus after send. + * + * @deprecated Instead of passing `false`, you should leave the `setFocus` argument `undefined`. + */ + (setFocus: false): void; + + /** + * Submits the text box and optionally set the focus after send. + */ + (setFocus?: 'sendBox' | 'sendBoxWithoutKeyboard'): void; +}; - return !!sendBoxValue; - }, - [focus, scrollToEnd, sendBoxValue, submitSendBox] +function useTextBoxSubmit(): SubmitTextBoxFunction { + const submit = useSubmit(); + + return useCallback( + (setFocus?: false | 'sendBox' | 'sendBoxWithoutKeyboard') => submit({ setFocus: setFocus || undefined }), + [submit] ); } diff --git a/packages/component/src/Utils/AccessibleInputText.tsx b/packages/component/src/Utils/AccessibleInputText.tsx index b59c08221c..5afd03c818 100644 --- a/packages/component/src/Utils/AccessibleInputText.tsx +++ b/packages/component/src/Utils/AccessibleInputText.tsx @@ -31,6 +31,7 @@ import useEnterKeyHint from '../hooks/internal/useEnterKeyHint'; // - If the widget is contained by a
, the developer need to filter out some `onSubmit` event caused by this widget type AccessibleInputTextProps = { + 'aria-errormessage'?: string; className?: string; disabled?: boolean; enterKeyHint?: string; @@ -51,6 +52,7 @@ type AccessibleInputTextProps = { const AccessibleInputText = forwardRef( ( { + 'aria-errormessage': ariaErrorMessage, className, disabled, enterKeyHint, @@ -77,6 +79,7 @@ const AccessibleInputText = forwardRef; + +const TIME_TO_QUEUE_ERROR_MESSAGE = 500; +const TIME_TO_RESET_ERROR_MESSAGE = 50; + +// This component is marked as internal because it is not fully implemented and is not ready to be consumed publicly. +// When it is done, it should provide and replace all the functionalities we did in Redux, including but not limited to: +// - Speech interims +// - Maintain text box value + +// In the old days, we use Redux to keep the send box state. +// However, when web devs put 2 send box on their page, it makes things complex because both send boxes will interact with each other. +// We would rather have them separate. If web devs want them to interact with each other, they will do the wiring themselves. + +type ErrorMessageStringMap = ReadonlyMap; + +// TODO: [P2] Complete this component. +const SendBoxComposer = ({ children }: PropsWithChildren<{}>) => { + const [connectivityStatus] = useConnectivityStatus(); + const [error, setError] = useState(false); + const [sendBoxValue] = useSendBoxValue(); + const apiSubmitSendBox = useSubmitSendBox(); + const focus = useFocus(); + const localize = useLocalizer(); + const scrollToEnd = useScrollToEnd(); + const styleToEmotionObject = useStyleToEmotionObject(); + const submitErrorMessageId = useUniqueId('webchat__send-box__error-message-id'); + const timeoutRef = useRef(undefined); + + const canSubmitSendBox = connectivityStatus === 'connected' && !!sendBoxValue; + const errorMessageStringMap = useMemo( + () => + Object.freeze( + new Map() + .set('empty', localize('SEND_BOX_IS_EMPTY_TOOLTIP_ALT')) + // TODO: [P0] We should add a new string for "Cannot send message while offline." + .set('offline', localize('CONNECTIVITY_STATUS_ALT_FATAL')) + ), + [localize] + ); + const focusRef = useRefFrom(focus); + const scrollToEndRef = useRefFrom(scrollToEnd); + const setErrorRef = useRef(setError); + const submitErrorMessageClassName = styleToEmotionObject(SUBMIT_ERROR_MESSAGE_STYLE) + ''; + const submitErrorMessageIdState = useMemo( + () => Object.freeze([error ? submitErrorMessageId : undefined]) as readonly [string | undefined], + [error, submitErrorMessageId] + ); + + setErrorRef.current = setError; + + const canSubmitSendBoxRef = useRefFrom(canSubmitSendBox); + + const submit = useCallback( + ({ setFocus } = {}) => { + (setFocus === 'sendBox' || setFocus === 'sendBoxWithoutKeyboard') && + focusRef.current?.(setFocus === 'sendBox' ? 'sendBox' : 'sendBoxWithoutKeyboard'); + + if (canSubmitSendBoxRef.current) { + scrollToEndRef.current?.(); + apiSubmitSendBox(); + } else { + timeoutRef.current && timeoutRef.current.forEach(clearTimeout); + + setErrorRef.current?.(false); + + timeoutRef.current = Object.freeze([ + setTimeout(() => setErrorRef.current?.('empty'), TIME_TO_RESET_ERROR_MESSAGE), + setTimeout(() => setErrorRef.current?.(false), TIME_TO_QUEUE_ERROR_MESSAGE) + ]) as readonly [Timeout, Timeout]; + } + }, + [apiSubmitSendBox, canSubmitSendBoxRef, focusRef, scrollToEndRef, setErrorRef, timeoutRef] + ); + + useEffect( + // Prevent `setTimeout()` from firing after unmount. + () => () => { + setErrorRef.current = undefined; + }, + [setErrorRef] + ); + + const context = useMemo( + () => ({ + submit, + submitErrorMessageIdState + }), + [submit, submitErrorMessageIdState] + ); + + return ( + + {children} + + {(error && errorMessageStringMap.get(error)) || ''} + + + ); +}; + +export default SendBoxComposer; diff --git a/packages/component/src/providers/internal/SendBox/private/Context.ts b/packages/component/src/providers/internal/SendBox/private/Context.ts new file mode 100644 index 0000000000..5e632bab32 --- /dev/null +++ b/packages/component/src/providers/internal/SendBox/private/Context.ts @@ -0,0 +1,7 @@ +import { createContext } from 'react'; + +import type { ContextType } from './types'; + +const SendBoxContext = createContext(undefined); + +export default SendBoxContext; diff --git a/packages/component/src/providers/internal/SendBox/private/types.ts b/packages/component/src/providers/internal/SendBox/private/types.ts new file mode 100644 index 0000000000..4999411414 --- /dev/null +++ b/packages/component/src/providers/internal/SendBox/private/types.ts @@ -0,0 +1,6 @@ +export type ContextType = { + submit: (options?: { setFocus?: 'sendBox' | 'sendBoxWithoutKeyboard' }) => void; + submitErrorMessageIdState: readonly [string]; +}; + +export type SendError = 'empty' | 'offline'; diff --git a/packages/component/src/providers/internal/SendBox/private/useCanSubmitSendBox.ts b/packages/component/src/providers/internal/SendBox/private/useCanSubmitSendBox.ts new file mode 100644 index 0000000000..0536d47759 --- /dev/null +++ b/packages/component/src/providers/internal/SendBox/private/useCanSubmitSendBox.ts @@ -0,0 +1,26 @@ +import { hooks } from 'botframework-webchat-api'; +import { useMemo } from 'react'; + +const { useConnectivityStatus, useSendBoxValue } = hooks; + +/** + * Returns `true` if send box can be submitted, otherwise, `false`. + * + * Currently, there are two reasons why the send box cannot be submitted: + * + * 1. Offline + * 2. Send box value is empty + * - It mimic the current logic, however, the current logic is a not insufficient + * - Better, checks if *trimmed* send box value is empty + * + * This is an internal hook and should not be public/exported. + * Primarily, because this logic is a duplication of the logic in Redux. + */ +export default function useCanSubmitSendBox(): readonly [boolean] { + const [connectivityStatus] = useConnectivityStatus(); + const [sendBoxValue] = useSendBoxValue(); + + const canSubmitSendBox = connectivityStatus === 'connected' && !!sendBoxValue; + + return useMemo(() => Object.freeze([canSubmitSendBox]) as readonly [boolean], [canSubmitSendBox]); +} diff --git a/packages/component/src/providers/internal/SendBox/private/useContext.ts b/packages/component/src/providers/internal/SendBox/private/useContext.ts new file mode 100644 index 0000000000..ec61c87d06 --- /dev/null +++ b/packages/component/src/providers/internal/SendBox/private/useContext.ts @@ -0,0 +1,15 @@ +import { useContext } from 'react'; + +import Context from './Context'; + +import type { ContextType } from './types'; + +export default function useSendBoxContext(): ContextType { + const context = useContext(Context); + + if (!context) { + throw new Error('botframework-webchat internal: This hook can only be used under .'); + } + + return context; +} diff --git a/packages/component/src/providers/internal/SendBox/useErrorMessageId.ts b/packages/component/src/providers/internal/SendBox/useErrorMessageId.ts new file mode 100644 index 0000000000..8487326a58 --- /dev/null +++ b/packages/component/src/providers/internal/SendBox/useErrorMessageId.ts @@ -0,0 +1,15 @@ +import useSendBoxContext from './private/useContext'; + +/** + * Subscribes to the `IDREF` of the error message occurred when the user submit the send box. + * + * This `IDREF` is intended to be use as the value for `aria-errormessage` and `aria-invalid` attribute. + * + * For example, if the user is clicking on the send button without a message, we will read an alert saying "cannot + * send empty message." This `IDREF` will be the HTML element of the hidden alert element. + * + * If there are no errors when submitting the send box, the `IDREF` will be `undefined`. + */ +export default function useSubmitErrorMessageId(): readonly [string | undefined] { + return useSendBoxContext().submitErrorMessageIdState; +} diff --git a/packages/component/src/providers/internal/SendBox/useSubmit.ts b/packages/component/src/providers/internal/SendBox/useSubmit.ts new file mode 100644 index 0000000000..1e17ab1f4e --- /dev/null +++ b/packages/component/src/providers/internal/SendBox/useSubmit.ts @@ -0,0 +1,17 @@ +import useSendBoxContext from './private/useContext'; + +type SubmitOptions = { + setFocus?: 'sendBox' | 'sendBoxWithoutKeyboard'; +}; + +/** + * Returns a callback function, when called, will send the value of the text box as a message. + * + * If the message cannot be send immediately, for example, the message is empty. An error message will be read + * and the error message element can be referenced by `useErrorMessageId()` hook. + * + * If the message can be send, after the message is queued, regardless whether it send successfully or not, the text box will be cleared. + */ +export default function useSubmit(): (submitOptions?: SubmitOptions) => void { + return useSendBoxContext().submit; +} diff --git a/packages/core/src/sagas/submitSendBoxSaga.js b/packages/core/src/sagas/submitSendBoxSaga.js index 8aeb834f7a..0fe169d1d0 100644 --- a/packages/core/src/sagas/submitSendBoxSaga.js +++ b/packages/core/src/sagas/submitSendBoxSaga.js @@ -10,6 +10,8 @@ function* submitSendBox() { yield takeEvery(SUBMIT_SEND_BOX, function* ({ payload: { channelData, method } }) { const sendBoxValue = yield select(sendBoxValueSelector); + // TODO: [P2] If the trimmed value is empty, we should not send. + // TODO: [P2] We should expose this logic ("cannot send empty message") to other components such as UI. if (sendBoxValue) { yield put(sendMessage(sendBoxValue.trim(), method, { channelData })); yield put(setSendBox('')); @@ -18,5 +20,6 @@ function* submitSendBox() { } export default function* submitSendBoxSaga() { + // TODO: [P2] We should expose this logic ("send only when connected") to other components such as UI. yield whileConnected(submitSendBox); } diff --git a/packages/test/page-object/src/globals/pageElements/sendBoxTextBox.js b/packages/test/page-object/src/globals/pageElements/sendBoxTextBox.js index db68218318..401a712821 100644 --- a/packages/test/page-object/src/globals/pageElements/sendBoxTextBox.js +++ b/packages/test/page-object/src/globals/pageElements/sendBoxTextBox.js @@ -1,4 +1,4 @@ -const CSS_SELECTOR = '[role="form"] > * > form > input[type="text"]'; +const CSS_SELECTOR = '[role="form"] > * > form > input[type="text"], [role="form"] > * > form textarea'; export default function sendBoxTextBox() { return document.querySelector(CSS_SELECTOR); From f1f7583d935af14b4b0e56590cb01c5444a2b1d3 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 10 Feb 2023 11:30:08 +0000 Subject: [PATCH 2/5] Add offline error --- CHANGELOG.md | 1 + ...rtEmptyMessage.multilineTextBox.enter.html | 7 ++- ....sendBox.alertEmptyMessage.sendButton.html | 20 ++++++- ...tEmptyMessage.singleLineTextBox.enter.html | 7 ++- ...ility.sendBox.alertOffline.sendButton.html | 53 +++++++++++++++++++ ...ibility.sendBox.alertOffline.sendButton.js | 6 +++ .../internal/SendBox/SendBoxComposer.tsx | 19 ++++--- .../SendBox/private/useCanSubmitSendBox.ts | 26 --------- .../testHelpers/createDirectLineEmulator.js | 10 +++- 9 files changed, 108 insertions(+), 41 deletions(-) create mode 100644 __tests__/html/accessibility.sendBox.alertOffline.sendButton.html create mode 100644 __tests__/html/accessibility.sendBox.alertOffline.sendButton.js delete mode 100644 packages/component/src/providers/internal/SendBox/private/useCanSubmitSendBox.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index feca5173af..0e276e2e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fixes [#4566](https://github.com/microsoft/BotFramework-WebChat/issues/4566). For YouTube and Vimeo `