diff --git a/.env.example b/.env.example index 4fae26378..54fa036dd 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,6 @@ VITE_GA_TRACKING_ID="" # Optional - Extend test debug print limit # DEBUG_PRINT_LIMIT=200000 + +# Optional - ChatBot Config +VITE_CHATBOT_API_BASE_URL=""; \ No newline at end of file diff --git a/conf/inject.template.js b/conf/inject.template.js index 0e680e3e1..3bd666a62 100644 --- a/conf/inject.template.js +++ b/conf/inject.template.js @@ -14,4 +14,5 @@ window.injectedEnv = { VITE_UPLOADER_CLI_MAC_X64: "${REACT_APP_UPLOADER_CLI_MAC_X64}", VITE_UPLOADER_CLI_MAC_ARM: "${REACT_APP_UPLOADER_CLI_MAC_ARM}", VITE_HIDDEN_MODELS: "${HIDDEN_MODELS}", + VITE_CHATBOT_API_BASE_URL: "${REACT_APP_CHATBOT_API_BASE_URL}", }; diff --git a/docs/Nginx.mdx b/docs/Nginx.mdx index 5538d6dd0..8baf2bf3c 100644 --- a/docs/Nginx.mdx +++ b/docs/Nginx.mdx @@ -42,7 +42,7 @@ http { location /submissions { return 301 /submission-requests$is_args$args; } - + # 3.3.0 MIGRATION: Redirect /submission/:uuid to submission-request/:uuid location ~ ^/submission/([a-zA-Z0-9-]+)$ { return 301 /submission-request/$1; @@ -55,6 +55,12 @@ http { proxy_pass "https://hub-dev2.datacommons.cancer.gov/api/authn/"; } + # Chatbot Knowledge Base + location /api/chat { + # proxy_pass "https://hub-dev.datacommons.cancer.gov/api/chat"; + proxy_pass "https://hub-dev2.datacommons.cancer.gov/"; + } + # Backend location /api/graphql { # proxy_pass http://localhost:4040/api/graphql; @@ -65,7 +71,7 @@ http { # Frontend location / { allow all; - proxy_buffers 8 2048K; + proxy_buffers 8 2048K; proxy_buffer_size 32K; proxy_pass http://localhost:3010/; } diff --git a/package-lock.json b/package-lock.json index 16118e3bf..478ef9a7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,10 +35,12 @@ "react-hook-form": "^7.45.4", "react-markdown": "^9.0.1", "react-multi-carousel": "^2.8.4", + "react-rnd": "^10.5.3", "react-router-dom": "^6.11.2", "react-transition-group": "^4.4.5", "recharts": "^2.12.0", "redux": "^4.2.1", + "remark-gfm": "^4.0.1", "uuid": "^11.1.0", "vite": "^6.3.5", "vite-plugin-svgr": "^4.3.0", @@ -15086,6 +15088,16 @@ "dev": true, "license": "MIT" }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/marked": { "version": "4.3.0", "license": "MIT", @@ -15171,6 +15183,34 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.1.tgz", @@ -15194,6 +15234,107 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -15417,6 +15558,127 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", @@ -16680,6 +16942,16 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/re-resizable": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz", + "integrity": "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "18.2.0", "license": "MIT", @@ -16749,6 +17021,29 @@ "react": "^18.2.0" } }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react-ga4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-2.1.0.tgz", @@ -16832,6 +17127,27 @@ "version": "17.0.2", "license": "MIT" }, + "node_modules/react-rnd": { + "version": "10.5.3", + "resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.5.3.tgz", + "integrity": "sha512-s/sIT3pGZnQ+57egijkTp9mizjIWrJz68Pq6yd+F/wniFY3IriML18dUXnQe/HP9uMiJ+9MAp44hljG99fZu6Q==", + "license": "MIT", + "dependencies": { + "re-resizable": "^6.11.2", + "react-draggable": "^4.5.0", + "tslib": "2.6.2" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/react-rnd/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "license": "0BSD" + }, "node_modules/react-router": { "version": "6.30.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", @@ -17193,6 +17509,24 @@ } } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -17224,6 +17558,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/requestidlecallback": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/requestidlecallback/-/requestidlecallback-0.3.0.tgz", diff --git a/package.json b/package.json index 5daf7e619..1f591df52 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,12 @@ "react-hook-form": "^7.45.4", "react-markdown": "^9.0.1", "react-multi-carousel": "^2.8.4", + "react-rnd": "^10.5.3", "react-router-dom": "^6.11.2", "react-transition-group": "^4.4.5", "recharts": "^2.12.0", "redux": "^4.2.1", + "remark-gfm": "^4.0.1", "uuid": "^11.1.0", "vite": "^6.3.5", "vite-plugin-svgr": "^4.3.0", diff --git a/public/js/injectEnv.js b/public/js/injectEnv.js index f1b0c5a94..15bbc700e 100644 --- a/public/js/injectEnv.js +++ b/public/js/injectEnv.js @@ -12,4 +12,5 @@ window.injectedEnv = { VITE_FE_VERSION: "", VITE_BACKEND_API: "", VITE_HIDDEN_MODELS: "", + VITE_CHATBOT_API_BASE_URL: "", }; diff --git a/src/components/ChatBot/ChatBotView.stories.tsx b/src/components/ChatBot/ChatBotView.stories.tsx new file mode 100644 index 000000000..18b70f1c6 --- /dev/null +++ b/src/components/ChatBot/ChatBotView.stories.tsx @@ -0,0 +1,74 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import ChatBotView from "./ChatBotView"; +import { ChatBotProvider } from "./context/ChatBotContext"; +import { ChatConversationProvider } from "./context/ChatConversationContext"; +import { ChatDrawerProvider } from "./context/ChatDrawerContext"; + +type StoryArgs = { + label: string; + title: string; + endpointUrl: string; +}; + +const meta: Meta = { + title: "ChatBot / ChatBot", + component: ChatBotView, + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story, context) => ( + + + + + + + + ), + ], + argTypes: { + label: { + name: "Label", + control: "text", + description: "The label text displayed on the floating chat button.", + table: { + defaultValue: { summary: "Chat" }, + }, + }, + title: { + name: "Title", + control: "text", + description: "The title text displayed in the chat drawer header.", + table: { + defaultValue: { summary: "Chat" }, + }, + }, + endpointUrl: { + name: "Endpoint URL", + control: "text", + description: "The URL for the knowledge base API endpoint.", + table: { + defaultValue: { summary: import.meta.env.VITE_CHATBOT_API_BASE_URL || "" }, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Button: Story = { + name: "ChatBot", + parameters: meta.parameters, + args: { + label: "Chat", + title: "Chat", + endpointUrl: import.meta.env.VITE_CHATBOT_API_BASE_URL || "", + }, +}; diff --git a/src/components/ChatBot/ChatBotView.test.tsx b/src/components/ChatBot/ChatBotView.test.tsx new file mode 100644 index 000000000..3dc712089 --- /dev/null +++ b/src/components/ChatBot/ChatBotView.test.tsx @@ -0,0 +1,196 @@ +import React from "react"; +import { axe } from "vitest-axe"; + +import { render } from "@/test-utils"; + +import ChatBotView from "./ChatBotView"; + +vi.mock("./FloatingChatButton", () => ({ + default: ({ label, onClick }: { label: string; onClick: () => void }) => ( + + ), +})); + +vi.mock("./ChatDrawer", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("./ChatPanel", () => ({ + default: () =>
Chat Panel
, +})); + +const mockUseChatBotContext = vi.fn(); +const mockUseChatDrawerContext = vi.fn(); + +vi.mock("./context/ChatBotContext", () => ({ + useChatBotContext: () => mockUseChatBotContext(), +})); + +vi.mock("./context/ChatDrawerContext", () => ({ + useChatDrawerContext: () => mockUseChatDrawerContext(), +})); + +describe("Accessibility", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatBotContext.mockReturnValue({ + label: "Chat", + title: "Support", + knowledgeBaseUrl: "https://example.com", + }); + }); + + it("should have no accessibility violations when drawer is closed", async () => { + mockUseChatDrawerContext.mockReturnValue({ + isOpen: false, + isMinimized: false, + openDrawer: vi.fn(), + }); + + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations when drawer is open", async () => { + mockUseChatDrawerContext.mockReturnValue({ + isOpen: true, + isMinimized: false, + openDrawer: vi.fn(), + }); + + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatBotContext.mockReturnValue({ + label: "Chat", + title: "Support", + knowledgeBaseUrl: "https://example.com", + }); + }); + + it("should render without crashing", () => { + mockUseChatDrawerContext.mockReturnValue({ + isOpen: false, + isMinimized: false, + openDrawer: vi.fn(), + }); + + expect(() => render()).not.toThrow(); + }); + + it("should render FloatingChatButton when drawer is closed", () => { + mockUseChatDrawerContext.mockReturnValue({ + isOpen: false, + isMinimized: false, + openDrawer: vi.fn(), + }); + + const { getByTestId } = render(); + + expect(getByTestId("floating-chat-button")).toBeInTheDocument(); + }); + + it("should not render ChatDrawer when drawer is closed", () => { + mockUseChatDrawerContext.mockReturnValue({ + isOpen: false, + isMinimized: false, + openDrawer: vi.fn(), + }); + + const { queryByTestId } = render(); + + expect(queryByTestId("chat-drawer")).not.toBeInTheDocument(); + }); + + it("should render ChatDrawer when drawer is open", () => { + mockUseChatDrawerContext.mockReturnValue({ + isOpen: true, + isMinimized: false, + openDrawer: vi.fn(), + }); + + const { getByTestId } = render(); + + expect(getByTestId("chat-drawer")).toBeInTheDocument(); + }); + + it("should render ChatPanel inside ChatDrawer when open", () => { + mockUseChatDrawerContext.mockReturnValue({ + isOpen: true, + isMinimized: false, + openDrawer: vi.fn(), + }); + + const { getByTestId } = render(); + + expect(getByTestId("chat-panel")).toBeInTheDocument(); + }); + + it("should not render FloatingChatButton when drawer is open", () => { + mockUseChatDrawerContext.mockReturnValue({ + isOpen: true, + isMinimized: false, + openDrawer: vi.fn(), + }); + + const { queryByTestId } = render(); + + expect(queryByTestId("floating-chat-button")).not.toBeInTheDocument(); + }); + + it("should pass label from context to FloatingChatButton", () => { + mockUseChatBotContext.mockReturnValue({ + label: "Help Me", + title: "Support", + knowledgeBaseUrl: "https://example.com", + }); + mockUseChatDrawerContext.mockReturnValue({ + isOpen: false, + openDrawer: vi.fn(), + }); + + const { getByText } = render(); + + expect(getByText("Help Me")).toBeInTheDocument(); + }); + + it("should call openDrawer when FloatingChatButton is clicked", () => { + const openDrawer = vi.fn(); + mockUseChatDrawerContext.mockReturnValue({ + isOpen: false, + isMinimized: false, + openDrawer, + }); + + const { getByTestId } = render(); + + const button = getByTestId("floating-chat-button"); + button.click(); + + expect(openDrawer).toHaveBeenCalledTimes(1); + }); + + it("should render both FloatingChatButton and ChatDrawer when minimized", () => { + mockUseChatDrawerContext.mockReturnValue({ + isOpen: true, + isMinimized: true, + openDrawer: vi.fn(), + }); + + const { getByTestId } = render(); + + expect(getByTestId("floating-chat-button")).toBeInTheDocument(); + expect(getByTestId("chat-drawer")).toBeInTheDocument(); + }); +}); diff --git a/src/components/ChatBot/ChatBotView.tsx b/src/components/ChatBot/ChatBotView.tsx new file mode 100644 index 000000000..9bf74decd --- /dev/null +++ b/src/components/ChatBot/ChatBotView.tsx @@ -0,0 +1,31 @@ +import React, { useMemo } from "react"; + +import ChatDrawer from "./ChatDrawer"; +import ChatPanel from "./ChatPanel"; +import { useChatBotContext } from "./context/ChatBotContext"; +import { useChatDrawerContext } from "./context/ChatDrawerContext"; +import FloatingChatButton from "./FloatingChatButton"; + +/** + * The view component for the entire ChatBot. + */ +const ChatBot = (): JSX.Element => { + const { label } = useChatBotContext(); + const { isOpen, openDrawer, isMinimized } = useChatDrawerContext(); + + const showFloatingButton = useMemo(() => !isOpen || isMinimized, [isOpen, isMinimized]); + + return ( + <> + {showFloatingButton && } + + {isOpen && ( + + + + )} + + ); +}; + +export default React.memo(ChatBot); diff --git a/src/components/ChatBot/ChatDrawer.stories.tsx b/src/components/ChatBot/ChatDrawer.stories.tsx new file mode 100644 index 000000000..e12219526 --- /dev/null +++ b/src/components/ChatBot/ChatDrawer.stories.tsx @@ -0,0 +1,53 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import ChatDrawer from "./ChatDrawer"; +import { ChatBotProvider } from "./context/ChatBotContext"; +import { ChatConversationProvider } from "./context/ChatConversationContext"; +import { ChatDrawerProvider } from "./context/ChatDrawerContext"; + +const meta: Meta = { + title: "ChatBot / Chat Drawer", + component: ChatDrawer, + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( + + + + + + + + ), + ], + argTypes: { + children: { + description: "Child content rendered in the drawer body.", + table: { + disable: true, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children:
Chat content goes here
, + }, +}; + +export const WithCustomContent: Story = { + args: { + children: ( +
+

Custom Chat Panel

+

This is custom content inside the chat drawer.

+
+ ), + }, +}; diff --git a/src/components/ChatBot/ChatDrawer.test.tsx b/src/components/ChatBot/ChatDrawer.test.tsx new file mode 100644 index 000000000..60f5d5b29 --- /dev/null +++ b/src/components/ChatBot/ChatDrawer.test.tsx @@ -0,0 +1,478 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { axe } from "vitest-axe"; + +import { render } from "@/test-utils"; + +import ChatDrawer from "./ChatDrawer"; +import * as ChatBotContextModule from "./context/ChatBotContext"; +import * as ChatDrawerContextModule from "./context/ChatDrawerContext"; + +vi.mock("./context/ChatBotContext", () => ({ + useChatBotContext: vi.fn(), +})); + +vi.mock("./context/ChatDrawerContext", () => ({ + useChatDrawerContext: vi.fn(), +})); + +const mockUseChatBotContext = vi.mocked(ChatBotContextModule.useChatBotContext); + +const mockUseChatDrawerContext = vi.mocked(ChatDrawerContextModule.useChatDrawerContext); + +const defaultChatBotContext = { + title: "Test Chat", + label: "Chat", + knowledgeBaseUrl: "http://test.com", + metadata: {}, +}; + +const defaultChatDrawerContext = { + drawerRef: { current: null }, + heightPx: 600, + widthPx: 384, + x: 0, + y: 0, + isExpanded: true, + isMinimized: false, + isFullscreen: false, + isOpen: true, + onDragStop: vi.fn(), + onResizeStop: vi.fn(), + onToggleExpand: vi.fn(), + onToggleFullscreen: vi.fn(), + onMinimize: vi.fn(), + openDrawer: vi.fn(), + isConfirmingEndConversation: false, + onRequestEndConversation: vi.fn(), + onConfirmEndConversation: vi.fn(), + onCancelEndConversation: vi.fn(), +}; + +describe("Accessibility", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatBotContext.mockReturnValue(defaultChatBotContext); + mockUseChatDrawerContext.mockReturnValue(defaultChatDrawerContext); + }); + + it("should have no accessibility violations", async () => { + mockUseChatBotContext.mockReturnValue(defaultChatBotContext); + mockUseChatDrawerContext.mockReturnValue(defaultChatDrawerContext); + + const { container } = render( + +
Test content
+
+ ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations when minimized", async () => { + mockUseChatBotContext.mockReturnValue(defaultChatBotContext); + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isMinimized: true, + }); + + const { container } = render( + +
Test content
+
+ ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations in fullscreen", async () => { + mockUseChatBotContext.mockReturnValue(defaultChatBotContext); + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isFullscreen: true, + }); + + const { container } = render( + +
Test content
+
+ ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations with confirmation dialog", async () => { + mockUseChatBotContext.mockReturnValue(defaultChatBotContext); + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isConfirmingEndConversation: true, + }); + + const { container } = render( + +
Test content
+
+ ); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatBotContext.mockReturnValue(defaultChatBotContext); + mockUseChatDrawerContext.mockReturnValue(defaultChatDrawerContext); + }); + + it("should render without crashing", () => { + const { container } = render( + +
Test content
+
+ ); + + expect(container).toBeTruthy(); + }); + + it("should render children content", () => { + const { getByText } = render( + +
Custom child content
+
+ ); + + expect(getByText("Custom child content")).toBeInTheDocument(); + }); + + it("should show expand icon when collapsed", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isExpanded: false, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + expect(getByLabelText("Expand chat drawer")).toBeInTheDocument(); + }); + + it("should show collapse icon when expanded", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isExpanded: true, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + expect(getByLabelText("Collapse chat drawer")).toBeInTheDocument(); + }); + + it("should call onToggleExpand when expand button is clicked", () => { + const onToggleExpand = vi.fn(); + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + onToggleExpand, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + getByLabelText("Collapse chat drawer").click(); + + expect(onToggleExpand).toHaveBeenCalled(); + }); + + it("should show expand button even in fullscreen", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isFullscreen: true, + isExpanded: true, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + expect(getByLabelText("Collapse chat drawer")).toBeInTheDocument(); + }); + + it("should show fullscreen icon when not in fullscreen", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isFullscreen: false, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + expect(getByLabelText("Enter full screen")).toBeInTheDocument(); + }); + + it("should show exit fullscreen icon when in fullscreen", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isFullscreen: true, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + expect(getByLabelText("Exit full screen")).toBeInTheDocument(); + }); + + it("should call onToggleFullscreen when fullscreen button is clicked", () => { + const onToggleFullscreen = vi.fn(); + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + onToggleFullscreen, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + getByLabelText("Enter full screen").click(); + + expect(onToggleFullscreen).toHaveBeenCalled(); + }); + + it("should call onMinimize when minimize button is clicked", () => { + const onMinimize = vi.fn(); + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + onMinimize, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + getByLabelText("Minimize chat").click(); + + expect(onMinimize).toHaveBeenCalled(); + }); + + it("should call onRequestEndConversation when close button is clicked", () => { + const onRequestEndConversation = vi.fn(); + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + onRequestEndConversation, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + getByLabelText("End conversation").click(); + + expect(onRequestEndConversation).toHaveBeenCalled(); + }); + + it("should show confirmation dialog when isConfirmingEndConversation is true", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isConfirmingEndConversation: true, + }); + + const { getByRole, getByText } = render( + +
Test content
+
+ ); + + expect(getByRole("alertdialog")).toBeInTheDocument(); + expect(getByText("End Conversation")).toBeInTheDocument(); + }); + + it("should hide confirmation dialog when isConfirmingEndConversation is false", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isConfirmingEndConversation: false, + }); + + const { queryByRole } = render( + +
Test content
+
+ ); + + expect(queryByRole("alertdialog")).not.toBeInTheDocument(); + }); + + it("should call onConfirmEndConversation when Yes button is clicked", () => { + const onConfirmEndConversation = vi.fn(); + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isConfirmingEndConversation: true, + onConfirmEndConversation, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + getByLabelText("Yes").click(); + + expect(onConfirmEndConversation).toHaveBeenCalled(); + }); + + it("should call onCancelEndConversation when No button is clicked", () => { + const onCancelEndConversation = vi.fn(); + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isConfirmingEndConversation: true, + onCancelEndConversation, + }); + + const { getByLabelText } = render( + +
Test content
+
+ ); + + getByLabelText("No").click(); + + expect(onCancelEndConversation).toHaveBeenCalled(); + }); + + it("should set aria-hidden to true when minimized", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isMinimized: true, + }); + + const { container } = render( + +
Test content
+
+ ); + + const drawer = container.querySelector('[aria-hidden="true"]'); + expect(drawer).toBeInTheDocument(); + }); + + it("should set aria-hidden to false when not minimized", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isMinimized: false, + }); + + const { container } = render( + +
Test content
+
+ ); + + const drawer = container.querySelector('[aria-hidden="false"]'); + expect(drawer).toBeInTheDocument(); + }); + + it("should apply data-minimized attribute when minimized", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isMinimized: true, + }); + + const { container } = render( + +
Test content
+
+ ); + + const drawer = container.querySelector('[data-minimized="true"]'); + expect(drawer).toBeInTheDocument(); + }); + + it("should apply data-fullscreen attribute when in fullscreen", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isFullscreen: true, + }); + + const { container } = render( + +
Test content
+
+ ); + + const drawer = container.querySelector('[data-fullscreen="true"]'); + expect(drawer).toBeInTheDocument(); + }); + + it("should render drag borders when collapsed", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isExpanded: false, + isFullscreen: false, + isMinimized: false, + }); + + const { container } = render( + +
Test content
+
+ ); + + expect(container.querySelectorAll('[aria-label="Drag to move"]')).toHaveLength(4); + }); + + it("should not render drag borders when expanded", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isExpanded: true, + isFullscreen: false, + isMinimized: false, + }); + + const { container } = render( + +
Test content
+
+ ); + + expect(container.querySelector('[aria-label="Drag to move"]')).not.toBeInTheDocument(); + }); + + it("should not render drag borders when in fullscreen", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isExpanded: false, + isFullscreen: true, + isMinimized: false, + }); + + const { container } = render( + +
Test content
+
+ ); + + expect(container.querySelector('[aria-label="Drag to move"]')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ChatBot/ChatDrawer.tsx b/src/components/ChatBot/ChatDrawer.tsx new file mode 100644 index 000000000..1677cc690 --- /dev/null +++ b/src/components/ChatBot/ChatDrawer.tsx @@ -0,0 +1,378 @@ +import CloseIcon from "@mui/icons-material/Close"; +import HorizontalRuleIcon from "@mui/icons-material/HorizontalRule"; +import { Button, IconButton, Paper, Typography, styled } from "@mui/material"; +import React, { useMemo } from "react"; +import { Rnd } from "react-rnd"; + +import DraggableHandleSvg from "./assets/draggable-handle.svg?react"; +import DrawerViewIcon from "./assets/drawer-view-icon.svg?react"; +import ExitFullScreenIcon from "./assets/exit-full-screen-icon.svg?react"; +import FullScreenIcon from "./assets/full-screen-icon.svg?react"; +import ChatBotLogo from "./components/ChatBotLogo"; +import chatConfig from "./config/chatConfig"; +import { useChatDrawerContext } from "./context/ChatDrawerContext"; + +const StyledChatDrawer = styled(Paper)({ + position: "relative", + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + boxShadow: "none", + overflow: "hidden", + backgroundColor: "transparent", + border: 0, + '&[data-expanded="true"]': { + borderLeft: "2px solid #2982D7", + }, +}); + +const StyledChatHeader = styled("div")({ + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + padding: "0 12px 0 0", + backgroundColor: "transparent", + color: "white", + '&[data-expanded="true"]': { + position: "absolute", + top: 0, + right: 20, + padding: 0, + zIndex: 2, + cursor: "default", + }, + '&[data-fullscreen="true"]': { + position: "absolute", + top: 0, + right: 35, + padding: 0, + zIndex: 2, + cursor: "default", + }, +}); + +const StyledHeaderActions = styled("div")({ + boxSizing: "border-box", + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: "0 10px", + gap: "15px", + height: "21px", + backgroundColor: "#034AA3", + borderWidth: "0.75px 0.75px 0px 0.75px", + borderStyle: "solid", + borderColor: "#FFFFFF", + borderRadius: "8px 8px 0 0", + '&[data-expanded="true"], &[data-fullscreen="true"]': { + borderWidth: "0px 0.75px 0.75px 0.75px", + borderRadius: "0 0 8px 8px", + }, +}); + +const StyledChatBody = styled("div")({ + flex: 1, + display: "flex", + flexDirection: "column", + overflow: "hidden", + position: "relative", + minHeight: 0, + borderRadius: "8px", + '&[data-expanded="true"], &[data-fullscreen="true"]': { + borderRadius: 0, + }, +}); + +const ConfirmOverlay = styled("div")({ + position: "absolute", + inset: 0, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + padding: "18px 36px", + backgroundColor: "#E3E9F2", + border: "2px solid #2982D7", + borderRadius: "10px", + zIndex: 1, +}); + +const ConfirmTitle = styled(Typography)({ + fontFamily: "Inter", + fontStyle: "normal", + fontWeight: 500, + fontSize: "15px", + lineHeight: "22px", + textAlign: "center", + color: "#334B5A", + marginTop: "30px", +}); + +const ConfirmActions = styled("div")({ + display: "flex", + gap: 12, + justifyContent: "center", + marginTop: "30px", +}); + +const StyledIconButton = styled(IconButton)({ + color: "white", + padding: 0, + margin: "0 auto", + flex: "none", + flexGrow: 0, +}); + +const DrawerViewIconButton = styled(StyledIconButton)({ + "& svg": { + width: "16px", + height: "16px", + }, +}); + +const FullScreenIconButton = styled(StyledIconButton)({ + "& svg": { + width: "11px", + height: "11px", + }, +}); + +const MinimizeIconButton = styled(StyledIconButton)({ + "& svg": { + width: "15px", + height: "15px", + }, +}); + +const CloseIconButton = styled(StyledIconButton)({ + "& svg": { + width: "15px", + height: "15px", + }, +}); + +const StyledRndContainer = styled("div")({ + position: "fixed", + inset: 0, + pointerEvents: "none", + zIndex: 12000, +}); + +const StyledDraggableBorder = styled("div", { + shouldForwardProp: (prop) => prop !== "edge", +})<{ edge: "top" | "right" | "bottom" | "left" }>(({ edge }) => ({ + position: "absolute", + zIndex: 2, + background: "transparent", + cursor: "move", + touchAction: "none", + ...(edge === "top" && { top: 0, left: 0, right: 0, height: 10 }), + ...(edge === "right" && { top: 21, right: 0, bottom: 0, width: 10 }), + ...(edge === "bottom" && { bottom: 0, left: 0, right: 0, height: 10 }), + ...(edge === "left" && { top: 21, left: 0, bottom: 0, width: 10 }), +})); + +export type Props = { + /** + * Child content rendered in the drawer body. + */ + children: React.ReactNode; +}; + +/** + * ChatDrawer component provides a resizable, draggable chat interface with fullscreen and minimize capabilities. + */ +const ChatDrawer = ({ children }: Props): JSX.Element => { + const { + drawerRef, + heightPx, + widthPx, + x, + y, + isExpanded, + isMinimized, + isFullscreen, + onToggleExpand, + onToggleFullscreen, + onMinimize, + onDragStop, + onResizeStop, + isConfirmingEndConversation, + onRequestEndConversation, + onConfirmEndConversation, + onCancelEndConversation, + } = useChatDrawerContext(); + + const rndPosition = useMemo( + () => (isFullscreen ? { x: 0, y: 0 } : { x, y }), + [isFullscreen, x, y] + ); + + const rndSize = useMemo<{ width: number; height: number }>(() => { + if (isFullscreen) { + return { width: window.innerWidth, height: window.innerHeight }; + } + if (isExpanded) { + return { width: chatConfig.width.expanded, height: window.innerHeight }; + } + + return { width: widthPx, height: heightPx }; + }, [isFullscreen, isExpanded, widthPx, heightPx]); + + const disableInteraction = useMemo( + () => isExpanded || isFullscreen || isMinimized, + [isExpanded, isFullscreen, isMinimized] + ); + + const dataAttrs = useMemo( + () => ({ + "data-minimized": String(isMinimized), + "data-expanded": String(isExpanded), + "data-fullscreen": String(isFullscreen), + }), + [isMinimized, isExpanded, isFullscreen] + ); + + return ( + + { + e.preventDefault(); + }} + onDragStop={onDragStop} + onResizeStop={onResizeStop} + minWidth={chatConfig.width.min} + minHeight={chatConfig.height.min} + enableResizing={disableInteraction ? false : { topLeft: true }} + disableDragging={disableInteraction} + dragHandleClassName="rnd-drag-handle" + cancel="button" + bounds="parent" + resizeHandleStyles={disableInteraction ? undefined : { topLeft: { top: 25, left: 8 } }} + resizeHandleComponent={ + disableInteraction + ? undefined + : { + topLeft: ( + + ), + } + } + style={{ + opacity: isMinimized ? 0 : 1, + pointerEvents: isMinimized ? "none" : "auto", + }} + > + + {!disableInteraction && ( + <> + + + + + )} + + + + + + + {isFullscreen ? : } + + + + + + + + + + + + {!disableInteraction && ( + + )} + {children} + + {isConfirmingEndConversation ? ( + + + End Conversation + + + + + + + + ) : null} + + + + + ); +}; + +export default React.memo(ChatDrawer); diff --git a/src/components/ChatBot/ChatPanel.stories.tsx b/src/components/ChatBot/ChatPanel.stories.tsx new file mode 100644 index 000000000..ef21ba5c7 --- /dev/null +++ b/src/components/ChatBot/ChatPanel.stories.tsx @@ -0,0 +1,125 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { userEvent, within } from "@storybook/test"; +import React from "react"; + +import ChatPanel from "./ChatPanel"; +import { ChatBotProvider } from "./context/ChatBotContext"; +import { ChatConversationProvider } from "./context/ChatConversationContext"; +import { ChatDrawerProvider } from "./context/ChatDrawerContext"; + +const knowledgeBaseUrl = import.meta.env.VITE_CHATBOT_API_BASE_URL; + +type StoryArgs = { + endpointUrl: string; +}; + +const meta: Meta = { + title: "ChatBot / Chat Panel", + component: ChatPanel, + parameters: { + layout: "padded", + }, + argTypes: { + endpointUrl: { + name: "Endpoint URL", + control: "text", + description: "The URL for the knowledge base API endpoint.", + table: { + defaultValue: { summary: knowledgeBaseUrl || "not set" }, + }, + }, + }, + decorators: [ + (Story, context) => { + const { endpointUrl } = context.args; + + return ( + + + +
+ +
+
+
+
+ ); + }, + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: "Chat Panel", + args: { + endpointUrl: knowledgeBaseUrl || "", + }, +}; + +export const WithMessages: Story = { + args: { + endpointUrl: knowledgeBaseUrl || "", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for component to render + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + + // Find the textarea and send button + const textarea = canvas.getByRole("textbox"); + const sendButton = canvas.getByRole("button", { name: /send/i }); + + // Type a message + await userEvent.type(textarea, "How do I submit data?"); + + // Click send button + await userEvent.click(sendButton); + + // Wait for response + await new Promise((resolve) => { + setTimeout(resolve, 1500); + }); + }, +}; + +export const BotTyping: Story = { + args: { + endpointUrl: knowledgeBaseUrl || "", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for component to render + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + + // Find the textarea and send button + const textarea = canvas.getByRole("textbox"); + const sendButton = canvas.getByRole("button", { name: /send/i }); + + // Type a message + await userEvent.type(textarea, "How do I upload files?"); + + // Click send button + await userEvent.click(sendButton); + + // Story pauses here showing the bot typing indicator + }, +}; + +export const Empty: Story = { + name: "Empty State", + args: { + endpointUrl: knowledgeBaseUrl || "", + }, +}; diff --git a/src/components/ChatBot/ChatPanel.test.tsx b/src/components/ChatBot/ChatPanel.test.tsx new file mode 100644 index 000000000..7429b5ad3 --- /dev/null +++ b/src/components/ChatBot/ChatPanel.test.tsx @@ -0,0 +1,354 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { axe } from "vitest-axe"; + +import { render } from "@/test-utils"; + +import ChatPanel from "./ChatPanel"; +import * as ChatConversationContextModule from "./context/ChatConversationContext"; +import * as ChatDrawerContextModule from "./context/ChatDrawerContext"; + +vi.mock("./context/ChatDrawerContext", () => ({ + useChatDrawerContext: vi.fn(), +})); + +vi.mock("./context/ChatConversationContext", () => ({ + useChatConversationContext: vi.fn(), +})); + +const mockUseChatDrawerContext = vi.mocked(ChatDrawerContextModule.useChatDrawerContext); +const mockUseChatConversationContext = vi.mocked( + ChatConversationContextModule.useChatConversationContext +); + +const defaultChatDrawerContext = { + isFullscreen: false, + drawerRef: { current: null }, + heightPx: 600, + widthPx: 384, + x: 0, + y: 0, + isExpanded: true, + isMinimized: false, + isOpen: true, + onDragStop: vi.fn(), + onResizeStop: vi.fn(), + onToggleExpand: vi.fn(), + onToggleFullscreen: vi.fn(), + onMinimize: vi.fn(), + openDrawer: vi.fn(), + isConfirmingEndConversation: false, + onRequestEndConversation: vi.fn(), + onConfirmEndConversation: vi.fn(), + onCancelEndConversation: vi.fn(), +}; + +const defaultConversationState = { + greetingTimestamp: new Date("2024-01-15T09:00:00"), + messages: [], + inputValue: "", + isBotTyping: false, + setInputValue: vi.fn(), + sendMessage: vi.fn(), + handleKeyDown: vi.fn(), + endConversation: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockUseChatDrawerContext.mockReturnValue(defaultChatDrawerContext); + mockUseChatConversationContext.mockReturnValue(defaultConversationState); +}); + +vi.mock("./panel/MessageList", () => ({ + default: ({ messages, isBotTyping }: { messages: ChatMessage[]; isBotTyping: boolean }) => ( +
+ {messages.length} + {isBotTyping.toString()} +
+ ), +})); + +vi.mock("./panel/ChatComposer", () => ({ + default: ({ + value, + onChange, + onSend, + onKeyDown, + isSendDisabled, + }: { + value: string; + onChange: (value: string) => void; + onSend: () => void; + onKeyDown: React.KeyboardEventHandler; + isSendDisabled: boolean; + }) => ( +
+ onChange(e.target.value)} + aria-label="Chat message input" + /> + + +
+ ), +})); + +const createMockMessage = (overrides?: Partial): ChatMessage => ({ + id: "test-message-1", + text: "Test message", + sender: "bot", + timestamp: new Date("2024-01-15T14:30:00"), + senderName: "Support Bot", + variant: "default", + ...overrides, +}); + +describe("Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations with messages", async () => { + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + messages: [createMockMessage()], + }); + + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + it("should render without crashing", () => { + expect(() => render()).not.toThrow(); + }); + + it("should render MessageList component", () => { + const { getByTestId } = render(); + + expect(getByTestId("message-list")).toBeInTheDocument(); + }); + + it("should render ChatComposer component", () => { + const { getByTestId } = render(); + + expect(getByTestId("chat-composer")).toBeInTheDocument(); + }); + + it("should pass messages to MessageList", () => { + const messages = [createMockMessage({ id: "msg-1" }), createMockMessage({ id: "msg-2" })]; + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + messages, + }); + + const { getByTestId } = render(); + + expect(getByTestId("messages-count")).toHaveTextContent("2"); + }); + + it("should pass isBotTyping to MessageList", () => { + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + isBotTyping: true, + }); + + const { getByTestId } = render(); + + expect(getByTestId("bot-typing")).toHaveTextContent("true"); + }); + + it("should pass input value to ChatComposer", () => { + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + inputValue: "Hello world", + }); + + const { getByTestId } = render(); + + expect(getByTestId("composer-input")).toHaveValue("Hello world"); + }); + + it("should call setInputValue when composer input changes", async () => { + const setInputValue = vi.fn(); + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + setInputValue, + }); + + const { getByTestId } = render(); + + const input = getByTestId("composer-input") as HTMLInputElement; + userEvent.clear(input); + userEvent.type(input, "New text"); + + expect(setInputValue).toHaveBeenCalled(); + }); + + it("should call sendMessage when send button is clicked", () => { + const sendMessage = vi.fn(); + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + inputValue: "Test message", + sendMessage, + }); + + const { getByTestId } = render(); + + const sendButton = getByTestId("composer-send"); + sendButton.click(); + + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + it("should disable send button when input is empty", () => { + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + inputValue: "", + }); + + const { getByTestId } = render(); + + expect(getByTestId("composer-send")).toBeDisabled(); + }); + + it("should disable send button when input is only whitespace", () => { + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + inputValue: " ", + }); + + const { getByTestId } = render(); + + expect(getByTestId("composer-send")).toBeDisabled(); + }); + + it("should enable send button when input has text", () => { + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + inputValue: "Hello", + }); + + const { getByTestId } = render(); + + expect(getByTestId("composer-send")).not.toBeDisabled(); + }); + + it("should disable send button when bot is typing", () => { + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + inputValue: "Hello", + isBotTyping: true, + }); + + const { getByTestId } = render(); + + expect(getByTestId("composer-send")).toBeDisabled(); + }); + + it("should pass handleKeyDown to ChatComposer", () => { + const handleKeyDown = vi.fn(); + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + handleKeyDown, + }); + + const { getByTestId } = render(); + + const keyDownInput = getByTestId("composer-keydown"); + keyDownInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(handleKeyDown).toHaveBeenCalled(); + }); + + it("should update when conversation state changes", () => { + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + inputValue: "First", + }); + + const { getByTestId, unmount } = render(); + + expect(getByTestId("composer-input")).toHaveValue("First"); + + unmount(); + + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + inputValue: "Second", + }); + + const { getByTestId: getByTestId2 } = render(); + + expect(getByTestId2("composer-input")).toHaveValue("Second"); + }); + + it("should render correctly with multiple messages of different types", () => { + const messages = [ + createMockMessage({ id: "msg-1", sender: "user", text: "User message" }), + createMockMessage({ id: "msg-2", sender: "bot", text: "Bot response" }), + createMockMessage({ id: "msg-3", sender: "user", text: "Another user message" }), + ]; + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + messages, + }); + + const { getByTestId } = render(); + + expect(getByTestId("messages-count")).toHaveTextContent("3"); + }); + + it("should handle long messages correctly", () => { + const longMessage = "a".repeat(1000); + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + inputValue: longMessage, + }); + + const { getByTestId } = render(); + + expect(getByTestId("composer-input")).toHaveValue(longMessage); + expect(getByTestId("composer-send")).not.toBeDisabled(); + }); + + it("should render with empty messages list", () => { + mockUseChatConversationContext.mockReturnValue({ + ...defaultConversationState, + messages: [], + }); + + const { getByTestId } = render(); + + expect(getByTestId("messages-count")).toHaveTextContent("0"); + }); + + it("should render content inside fullscreen container when in fullscreen mode", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultChatDrawerContext, + isExpanded: true, + isFullscreen: true, + }); + + const { getByTestId, container } = render(); + + expect(getByTestId("message-list")).toBeInTheDocument(); + expect(getByTestId("chat-composer")).toBeInTheDocument(); + expect(container.querySelector(".MuiContainer-root")).toBeInTheDocument(); + }); +}); diff --git a/src/components/ChatBot/ChatPanel.tsx b/src/components/ChatBot/ChatPanel.tsx new file mode 100644 index 000000000..98c76d128 --- /dev/null +++ b/src/components/ChatBot/ChatPanel.tsx @@ -0,0 +1,101 @@ +import { Container, Stack, styled } from "@mui/material"; +import React, { useCallback, useMemo } from "react"; + +import { useChatConversationContext } from "./context/ChatConversationContext"; +import { useChatDrawerContext } from "./context/ChatDrawerContext"; +import ChatComposer from "./panel/ChatComposer"; +import MessageList from "./panel/MessageList"; + +const StyledStack = styled(Stack, { + shouldForwardProp: (prop) => prop !== "isExpanded" && prop !== "isFullscreen", +})<{ isExpanded?: boolean; isFullscreen?: boolean }>(({ isExpanded, isFullscreen }) => ({ + height: "100%", + background: "rgba(255, 255, 255, 0.75)", + border: "2px solid #2982D7", + boxShadow: "0px 4px 4px rgba(0, 0, 0, 0.45)", + backdropFilter: "blur(10px)", + borderRadius: "10px", + overflow: "hidden", + + position: "relative", + ...(isExpanded && { + background: "#FFFFFF", + backdropFilter: "none", + borderRadius: 0, + border: "none", + boxShadow: "none", + }), + ...(isFullscreen && { + background: "linear-gradient(180deg, #FFFFFF 0%, #C9E5F8 100%)", + backdropFilter: "none", + borderRadius: 0, + border: "none", + boxShadow: "none", + overflow: "auto", + }), +})); + +const StyledContainer = styled(Container, { + shouldForwardProp: (prop) => prop !== "isFullscreen", +})<{ isFullscreen?: boolean }>(({ isFullscreen }) => ({ + height: isFullscreen ? "auto" : "100%", + minHeight: isFullscreen ? "100%" : undefined, + display: "flex", + flexDirection: "column", + background: "transparent", +})); + +/** + * Renders the main chat interface with message history and user input composer. + */ +const ChatPanel = (): JSX.Element => { + const { isExpanded, isFullscreen } = useChatDrawerContext(); + const { messages, inputValue, isBotTyping, setInputValue, sendMessage, handleKeyDown } = + useChatConversationContext(); + + /** + * Determines if the send button should be disabled based on input state and bot typing status. + */ + const isSendDisabled = useMemo((): boolean => { + if (isBotTyping) { + return true; + } + + return inputValue?.trim()?.length === 0; + }, [inputValue, isBotTyping]); + + /** + * Handles input value changes and updates the state. + */ + const handleValueChange = useCallback( + (value: string): void => setInputValue(value), + [setInputValue] + ); + + const content = ( + <> + + + + ); + + return ( + + {isFullscreen ? ( + + {content} + + ) : ( + content + )} + + ); +}; + +export default React.memo(ChatPanel); diff --git a/src/components/ChatBot/Controller.test.tsx b/src/components/ChatBot/Controller.test.tsx new file mode 100644 index 000000000..83bc78495 --- /dev/null +++ b/src/components/ChatBot/Controller.test.tsx @@ -0,0 +1,92 @@ +import { render } from "@/test-utils"; + +vi.mock("./ChatBotView", () => ({ + default: () =>
, +})); + +vi.mock("./context/ChatBotContext", () => ({ + ChatBotProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("./context/ChatConversationContext", () => ({ + ChatConversationProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("./context/ChatDrawerContext", () => ({ + ChatDrawerProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +describe("ChatController", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it.each([ + { value: "https://example.com/api/chat", description: "a valid URL" }, + { value: "/api/chat", description: "a relative path" }, + ])("should render ChatBot when VITE_CHATBOT_API_BASE_URL is $description", async ({ value }) => { + vi.doMock("@/env", () => ({ + default: { VITE_CHATBOT_API_BASE_URL: value }, + })); + + const { default: Controller } = await import("./Controller"); + + const { getByTestId } = render(); + + expect(getByTestId("chatbot-view")).toBeInTheDocument(); + }); + + it.each([ + { value: undefined, description: "undefined" }, + { value: null, description: "null" }, + { value: "", description: "an empty string" }, + { value: " ", description: "whitespace only" }, + ])( + "should not render ChatBot when VITE_CHATBOT_API_BASE_URL is $description", + async ({ value }) => { + let mockEnv: Record = {}; + if (value === null) { + mockEnv = { VITE_CHATBOT_API_BASE_URL: null }; + } else if (value !== undefined) { + mockEnv = { VITE_CHATBOT_API_BASE_URL: value }; + } + + vi.doMock("@/env", () => ({ + default: mockEnv, + })); + + const { default: Controller } = await import("./Controller"); + + const { queryByTestId } = render(); + + expect(queryByTestId("chatbot-view")).not.toBeInTheDocument(); + } + ); + + it("should not render ChatBot when env is not defined", async () => { + vi.doMock("@/env", () => ({ + default: undefined, + })); + + const { default: Controller } = await import("./Controller"); + + const { queryByTestId } = render(); + + expect(queryByTestId("chatbot-view")).not.toBeInTheDocument(); + }); + + it("should use default props when not provided", async () => { + vi.doMock("@/env", () => ({ + default: { + VITE_CHATBOT_API_BASE_URL: "https://example.com/api/chat", + }, + })); + + const { default: Controller } = await import("./Controller"); + + const { getByTestId } = render(); + + expect(getByTestId("chatbot-view")).toBeInTheDocument(); + }); +}); diff --git a/src/components/ChatBot/Controller.tsx b/src/components/ChatBot/Controller.tsx new file mode 100644 index 000000000..589c66323 --- /dev/null +++ b/src/components/ChatBot/Controller.tsx @@ -0,0 +1,50 @@ +import { FC, memo } from "react"; + +import env from "@/env"; + +import ChatBotView from "./ChatBotView"; +import { ChatBotProvider } from "./context/ChatBotContext"; +import { ChatConversationProvider } from "./context/ChatConversationContext"; +import { ChatDrawerProvider } from "./context/ChatDrawerContext"; + +const MemoizedChatBotProvider = memo(ChatBotProvider); +const MemoizedChatConversationProvider = memo(ChatConversationProvider); +const MemoizedChatDrawerProvider = memo(ChatDrawerProvider); + +type Props = { + /** + * The floating button label. + */ + label?: string; + /** + * The title appearing within the chat. + */ + title?: string; +}; + +/** + * Controls the visibility of the ChatBot component and manages its providers. + */ +const ChatController: FC = ({ label, title }) => { + const { VITE_CHATBOT_API_BASE_URL } = env || {}; + + if (!VITE_CHATBOT_API_BASE_URL?.trim()) { + return null; + } + + return ( + + + + + + + + ); +}; + +export default ChatController; diff --git a/src/components/ChatBot/FloatingChatButton.stories.tsx b/src/components/ChatBot/FloatingChatButton.stories.tsx new file mode 100644 index 000000000..0bd489a19 --- /dev/null +++ b/src/components/ChatBot/FloatingChatButton.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; + +import FloatingChatButton from "./FloatingChatButton"; + +const meta: Meta = { + title: "ChatBot / FloatingChatButton", + component: FloatingChatButton, + args: { + label: "Chat", + onClick: fn(), + }, + parameters: { + layout: "fullscreen", + }, + decorators: [], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Button: Story = { + name: "Floating Chat Button", + parameters: meta.parameters, +}; diff --git a/src/components/ChatBot/FloatingChatButton.test.tsx b/src/components/ChatBot/FloatingChatButton.test.tsx new file mode 100644 index 000000000..ba01581c9 --- /dev/null +++ b/src/components/ChatBot/FloatingChatButton.test.tsx @@ -0,0 +1,180 @@ +import { act } from "@testing-library/react"; +import { axe } from "vitest-axe"; + +import { render } from "@/test-utils"; + +import FloatingChatButton from "./FloatingChatButton"; + +const defaultProps = { + label: "Chat", + onClick: vi.fn(), +}; + +describe("Accessibility", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should have no accessibility violations", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render without crashing", () => { + expect(() => render()).not.toThrow(); + }); + + it("should display the label text", () => { + const { getByText } = render(); + + expect(getByText("Help")).toBeInTheDocument(); + }); + + it("should render the chat icon", () => { + const { container } = render(); + + const icon = container.querySelector("svg"); + expect(icon).toBeInTheDocument(); + }); + + it("should call onClick when button is clicked", () => { + const onClick = vi.fn(); + const { getByRole } = render(); + + const button = getByRole("button"); + button.click(); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("should handle multiple clicks", () => { + const onClick = vi.fn(); + const { getByRole } = render(); + + const button = getByRole("button"); + button.click(); + button.click(); + button.click(); + + expect(onClick).toHaveBeenCalledTimes(3); + }); + + it("should update label when prop changes", () => { + const { rerender, getByText, queryByText } = render( + + ); + + expect(getByText("Chat")).toBeInTheDocument(); + + rerender(); + + expect(getByText("Help")).toBeInTheDocument(); + expect(queryByText("Chat")).not.toBeInTheDocument(); + }); + + it("should render with different label values", () => { + const { getByText } = render(); + + expect(getByText("Ask a Question")).toBeInTheDocument(); + }); + + it("should handle empty string label", () => { + const { getByRole } = render(); + + expect(getByRole("button")).toBeInTheDocument(); + }); + + it("should handle long label text", () => { + const longLabel = "This is a very long label that might need to wrap"; + const { getByText } = render(); + + expect(getByText(longLabel)).toBeInTheDocument(); + }); + + it("should be a button element", () => { + const { getByRole } = render(); + + const button = getByRole("button"); + expect(button.tagName).toBe("BUTTON"); + }); + + it("should render label and icon together", () => { + const { getByText, container } = render( + + ); + + expect(getByText("Support")).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); +}); + +describe("Speech Bubble Behavior", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + sessionStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + sessionStorage.clear(); + }); + + it("should show speech bubble after initial delay when not previously shown", async () => { + const { container } = render(); + + const speechBubble = container.querySelector("p"); + expect(speechBubble).toHaveStyle({ opacity: "0" }); + + await act(async () => { + vi.advanceTimersByTime(3000); + }); + + expect(sessionStorage.getItem("chatbot_bubble_shown")).toBe("true"); + }); + + it("should hide speech bubble after show duration", async () => { + render(); + + await act(async () => { + vi.advanceTimersByTime(3000); + }); + expect(sessionStorage.getItem("chatbot_bubble_shown")).toBe("true"); + + await act(async () => { + vi.advanceTimersByTime(7000); + }); + }); + + it("should not show speech bubble if already shown in session", async () => { + sessionStorage.setItem("chatbot_bubble_shown", "true"); + + const { container } = render(); + + const speechBubble = container.querySelector("p"); + expect(speechBubble).toHaveStyle({ opacity: "0" }); + + await act(async () => { + vi.advanceTimersByTime(10000); + }); + expect(speechBubble).toHaveStyle({ opacity: "0" }); + }); + + it("should clean up timers on unmount", async () => { + const { unmount } = render(); + + unmount(); + + await act(async () => { + vi.advanceTimersByTime(10000); + }); + expect(sessionStorage.getItem("chatbot_bubble_shown")).toBeNull(); + }); +}); diff --git a/src/components/ChatBot/FloatingChatButton.tsx b/src/components/ChatBot/FloatingChatButton.tsx new file mode 100644 index 000000000..337c9b98c --- /dev/null +++ b/src/components/ChatBot/FloatingChatButton.tsx @@ -0,0 +1,90 @@ +import { Typography, styled } from "@mui/material"; +import React, { useEffect, useState } from "react"; + +import ChatBotLogo from "./components/ChatBotLogo"; + +const StyledFloatingButtonWrapper = styled("div")({ + position: "fixed", + right: 0, + top: "65%", + transform: "translateY(-50%)", + zIndex: 10000, +}); + +const StyledSpeechBubble = styled(Typography, { + shouldForwardProp: (prop) => prop !== "visible", +})<{ visible: boolean }>(({ visible }) => ({ + position: "fixed", + right: "29px", + top: "calc(65% - 30px + 21px)", + minWidth: "187px", + zIndex: 9999, + display: "flex", + flexDirection: "row", + alignItems: "center", + padding: "17.5px 42px 17.5px 20px", + gap: "10px", + background: "#FFFFFF", + boxShadow: "0px 4px 4px rgba(0, 0, 0, 0.25)", + borderRadius: "18px", + fontFamily: "Inter", + fontStyle: "normal", + fontWeight: 700, + fontSize: "16px", + lineHeight: "18px", + letterSpacing: 0, + color: "#0A3E7F", + opacity: visible ? 1 : 0, + transition: "opacity 0.5s ease-in-out", + pointerEvents: visible ? "auto" : "none", +})); + +type Props = { + label: string; + onClick: React.MouseEventHandler; +}; + +const INITIAL_DELAY_MS = 3_000; +const SHOW_DURATION_MS = 7_000; +const SESSION_KEY = "chatbot_bubble_shown"; + +const FloatingChatButton = ({ label, onClick }: Props): JSX.Element => { + const [bubbleVisible, setBubbleVisible] = useState(false); + + useEffect(() => { + const hasShown = sessionStorage.getItem(SESSION_KEY); + if (hasShown) { + return undefined; + } + + const showTimeout = setTimeout(() => { + setBubbleVisible(true); + sessionStorage.setItem(SESSION_KEY, "true"); + }, INITIAL_DELAY_MS); + + const hideTimeout = setTimeout(() => { + setBubbleVisible(false); + }, INITIAL_DELAY_MS + SHOW_DURATION_MS); + + return () => { + clearTimeout(showTimeout); + clearTimeout(hideTimeout); + }; + }, []); + + return ( + <> + + + + {label} + + ); +}; + +export default React.memo(FloatingChatButton); diff --git a/src/components/ChatBot/api/knowledgeBaseClient.test.ts b/src/components/ChatBot/api/knowledgeBaseClient.test.ts new file mode 100644 index 000000000..b73e40540 --- /dev/null +++ b/src/components/ChatBot/api/knowledgeBaseClient.test.ts @@ -0,0 +1,914 @@ +import { Logger } from "@/utils"; + +import { getStoredSessionId, storeSessionId } from "../utils/sessionStorageUtils"; + +import { askQuestion, emitWithTypewriter, processStreamingResponse } from "./knowledgeBaseClient"; + +vi.mock("@/utils", () => ({ + Logger: { + error: vi.fn(), + info: vi.fn(), + }, +})); + +const TEST_API_URL = "https://test-api.example.com/"; + +/** + * Helper to create a mock ReadableStream that emits line-delimited JSON chunks + * + * @param {string[]} chunks - Array of string chunks to emit + * @returns {ReadableStream} ReadableStream emitting the chunks + */ +const createMockStream = (chunks: string[]): ReadableStream => { + const encoder = new TextEncoder(); + let index = 0; + + return new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + controller.enqueue(encoder.encode(chunks[index])); + index += 1; + } else { + controller.close(); + } + }, + }); +}; + +/** + * Helper to create a mock fetch response with streaming body + * + * @param {string[]} chunks - Array of string chunks to emit in the response body + * @param {object} options - Additional options for the Response + * @returns {Response} Mock Response object with streaming body + */ +const createMockResponse = ( + chunks: string[], + options: { ok?: boolean; status?: number } = {} +): Response => + ({ + ok: options.ok ?? true, + status: options.status ?? 200, + body: createMockStream(chunks), + }) as Response; + +describe("askQuestion", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + global.fetch = vi.fn(); + }); + + it("should successfully stream response chunks and return sessionId", async () => { + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "test-session-123" })}\n`, + `${JSON.stringify({ type: "response", output: "Hello " })}\n`, + `${JSON.stringify({ type: "response", output: "world" })}\n`, + `${JSON.stringify({ type: "response", output: "!" })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const onChunk = vi.fn(); + const result = await askQuestion({ + question: "Test question", + sessionId: null, + onChunk, + url: TEST_API_URL, + typewriterDelay: 0, + }); + + expect(result.sessionId).toBe("test-session-123"); + expect(result.citations).toEqual([]); + expect(onChunk).toHaveBeenCalledTimes(3); + expect(onChunk).toHaveBeenNthCalledWith(1, "Hello "); + expect(onChunk).toHaveBeenNthCalledWith(2, "world"); + expect(onChunk).toHaveBeenNthCalledWith(3, "!"); + expect(getStoredSessionId()).toBe("test-session-123"); + }); + + it("should handle streaming with existing sessionId", async () => { + storeSessionId("existing-session"); + + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "existing-session" })}\n`, + `${JSON.stringify({ type: "response", output: "Response" })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const onChunk = vi.fn(); + await askQuestion({ + question: "Test question", + sessionId: "existing-session", + onChunk, + url: TEST_API_URL, + }); + + expect(global.fetch).toHaveBeenCalledWith( + TEST_API_URL, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + question: "Test question", + sessionId: "existing-session", + conversationHistory: [], + }), + }) + ); + }); + + it("should handle multi-line chunks correctly", async () => { + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "session-1" })}\n${JSON.stringify({ + type: "response", + output: "First", + })}\n`, + `${JSON.stringify({ type: "response", output: " Second" })}\n${JSON.stringify({ + type: "response", + output: " Third", + })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const onChunk = vi.fn(); + const result = await askQuestion({ + question: "Test question", + onChunk, + url: TEST_API_URL, + typewriterDelay: 0, + }); + + expect(result.sessionId).toBe("session-1"); + expect(onChunk).toHaveBeenCalledTimes(3); + expect(onChunk).toHaveBeenNthCalledWith(1, "First"); + expect(onChunk).toHaveBeenNthCalledWith(2, " Second"); + expect(onChunk).toHaveBeenNthCalledWith(3, " Third"); + }); + + it("should handle incomplete JSON lines in buffer", async () => { + const partialJson = JSON.stringify({ type: "response", output: "Part 1" }); + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "session-1" })}\n${partialJson.slice(0, 20)}`, + `${partialJson.slice(20)}\n${JSON.stringify({ type: "response", output: "Part 2" })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const onChunk = vi.fn(); + await askQuestion({ + question: "Test question", + onChunk, + url: TEST_API_URL, + typewriterDelay: 0, + }); + + expect(onChunk).toHaveBeenCalledTimes(2); + expect(onChunk).toHaveBeenNthCalledWith(1, "Part 1"); + expect(onChunk).toHaveBeenNthCalledWith(2, "Part 2"); + }); + + it("should collect and return citations", async () => { + const mockCitation = { + documentName: "Test Document", + documentLink: "https://example.com/doc", + }; + + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "session-1" })}\n`, + `${JSON.stringify({ type: "response", output: "Answer" })}\n`, + `${JSON.stringify({ type: "citations", citations: [mockCitation] })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const onChunk = vi.fn(); + const onCitation = vi.fn(); + const result = await askQuestion({ + question: "Test question", + onChunk, + onCitation, + url: TEST_API_URL, + }); + + expect(result.citations).toEqual([mockCitation]); + expect(onCitation).toHaveBeenCalledWith(mockCitation); + expect(Logger.info).toHaveBeenCalledWith("[KnowledgeBase] Citations:", [mockCitation]); + }); + + it("should not collect citations when no citations event is sent", async () => { + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "session-1" })}\n`, + `${JSON.stringify({ type: "response", output: "Answer" })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const result = await askQuestion({ + question: "Test question", + onChunk: vi.fn(), + url: TEST_API_URL, + }); + + expect(result.citations).toEqual([]); + }); + + it("should log errors when present in response", async () => { + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "session-1" })}\n`, + `${JSON.stringify({ type: "response", output: "", error: "Something went wrong" })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + await askQuestion({ + question: "Test question", + onChunk: vi.fn(), + url: TEST_API_URL, + }); + + expect(Logger.error).toHaveBeenCalledWith( + "[KnowledgeBase] Stream error:", + "Something went wrong" + ); + }); + + it("should throw error when HTTP response is not ok", async () => { + vi.mocked(global.fetch).mockResolvedValue(createMockResponse([], { ok: false, status: 500 })); + + await expect( + askQuestion({ + question: "Test question", + url: TEST_API_URL, + typewriterDelay: 0, + }) + ).rejects.toThrow("HTTP error! status: 500"); + + expect(Logger.error).toHaveBeenCalledWith("[KnowledgeBase] Error:", expect.any(Error)); + }); + + it("should handle abort signal correctly", async () => { + const abortController = new AbortController(); + + vi.mocked(global.fetch).mockImplementation( + () => + new Promise((_, reject) => { + setTimeout(() => reject(new DOMException("Aborted", "AbortError")), 100); + }) + ); + + const promise = askQuestion({ + question: "Test question", + signal: abortController.signal, + url: TEST_API_URL, + }); + + abortController.abort(); + + await expect(promise).rejects.toThrow("Aborted"); + }); + + it("should handle malformed JSON lines gracefully", async () => { + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "session-1" })}\n`, + `${JSON.stringify({ type: "response", output: "Valid" })}\n`, + "{invalid json}\n", + `${JSON.stringify({ type: "response", output: "Still works" })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const onChunk = vi.fn(); + await askQuestion({ + question: "Test question", + onChunk, + url: TEST_API_URL, + typewriterDelay: 0, + }); + + expect(onChunk).toHaveBeenCalledTimes(2); + expect(onChunk).toHaveBeenNthCalledWith(1, "Valid"); + expect(onChunk).toHaveBeenNthCalledWith(2, "Still works"); + expect(Logger.error).toHaveBeenCalledWith( + "[KnowledgeBase] Failed to parse line:", + "{invalid json}", + expect.any(Error) + ); + }); + + it("should skip empty lines", async () => { + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "session-1" })}\n`, + "\n", + `${JSON.stringify({ type: "response", output: "Text" })}\n`, + "\n\n", + `${JSON.stringify({ type: "response", output: "More" })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const onChunk = vi.fn(); + await askQuestion({ + question: "Test question", + onChunk, + url: TEST_API_URL, + typewriterDelay: 0, + }); + + expect(onChunk).toHaveBeenCalledTimes(2); + }); + + it("should work without onChunk callback", async () => { + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "session-1" })}\n`, + `${JSON.stringify({ type: "response", output: "Text" })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const result = await askQuestion({ + question: "Test question", + url: TEST_API_URL, + }); + + expect(result.sessionId).toBe("session-1"); + expect(result.citations).toEqual([]); + }); + + it("should return null sessionId when no sessionId is received", async () => { + const mockChunks = [`${JSON.stringify({ type: "response", output: "Text" })}\n`]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const result = await askQuestion({ + question: "Test question", + onChunk: vi.fn(), + url: TEST_API_URL, + }); + + expect(result.sessionId).toBeNull(); + expect(result.citations).toEqual([]); + expect(getStoredSessionId()).toBeNull(); + }); + + it("should filter duplicate citations by documentLink", async () => { + const citation1 = { + documentName: "Document 1", + documentLink: "https://example.com/doc1", + }; + const citation2 = { + documentName: "Document 2", + documentLink: "https://example.com/doc2", + }; + const duplicateCitation = { + documentName: "Document 1 Updated", + documentLink: "https://example.com/doc1", + }; + + const mockChunks = [ + `${JSON.stringify({ type: "session", sessionId: "session-1" })}\n`, + `${JSON.stringify({ type: "citations", citations: [citation1] })}\n`, + `${JSON.stringify({ type: "citations", citations: [citation2] })}\n`, + `${JSON.stringify({ type: "citations", citations: [duplicateCitation] })}\n`, + ]; + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + const onCitation = vi.fn(); + const result = await askQuestion({ + question: "Test question", + onCitation, + url: TEST_API_URL, + }); + + expect(result.citations).toEqual([citation1, citation2]); + expect(onCitation).toHaveBeenCalledTimes(2); + expect(onCitation).toHaveBeenNthCalledWith(1, citation1); + expect(onCitation).toHaveBeenNthCalledWith(2, citation2); + }); + + it("should pass fetch signal to request", async () => { + const mockChunks = [`${JSON.stringify({ type: "session", sessionId: "session-1" })}\n`]; + const abortController = new AbortController(); + + vi.mocked(global.fetch).mockResolvedValue(createMockResponse(mockChunks)); + + await askQuestion({ + question: "Test question", + signal: abortController.signal, + url: TEST_API_URL, + }); + + expect(global.fetch).toHaveBeenCalledWith( + TEST_API_URL, + expect.objectContaining({ + signal: abortController.signal, + }) + ); + }); + + it("should handle network errors gracefully", async () => { + const networkError = new Error("Network failure"); + vi.mocked(global.fetch).mockRejectedValue(networkError); + + await expect( + askQuestion({ + question: "Test question", + url: TEST_API_URL, + }) + ).rejects.toThrow("Network failure"); + + expect(Logger.error).toHaveBeenCalledWith("[KnowledgeBase] Error:", networkError); + }); + + it("should handle stream reading errors", async () => { + const mockStream = new ReadableStream({ + start(controller) { + controller.error(new Error("Stream error")); + }, + }); + + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + status: 200, + body: mockStream, + } as Response); + + await expect( + askQuestion({ + question: "Test question", + url: TEST_API_URL, + }) + ).rejects.toThrow("Stream error"); + }); + + it("should throw error when url is not provided", async () => { + await expect( + askQuestion({ + question: "Test question", + url: "", + }) + ).rejects.toThrow("Knowledge base URL is required but was not provided"); + }); +}); + +describe("processStreamingResponse", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return session ID and citations from stream", async () => { + const chunks = [`${JSON.stringify({ type: "session", sessionId: "test-session-123" })}\n`]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + const result = await processStreamingResponse(reader, undefined, undefined, 0); + + expect(result.sessionId).toBe("test-session-123"); + expect(result.citations).toEqual([]); + }); + + it("should return null sessionId when no session ID found in stream", async () => { + const chunks = [`${JSON.stringify({ type: "response", output: "Hello world" })}\n`]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + const result = await processStreamingResponse(reader, undefined, undefined, 0); + + expect(result.sessionId).toBeNull(); + expect(result.citations).toEqual([]); + }); + + it("should handle incomplete JSON lines in buffer correctly", async () => { + const onChunk = vi.fn(); + const chunks = ['{"type": "response", "output": "Hel', 'lo world"}\n']; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + await processStreamingResponse(reader, onChunk, undefined, 0); + + expect(onChunk).toHaveBeenCalledWith("Hello world"); + }); + + it("should process multiple complete lines in single chunk", async () => { + const onChunk = vi.fn(); + const chunks = [ + `${JSON.stringify({ type: "response", output: "Line 1" })}\n${JSON.stringify({ + type: "response", + output: "Line 2", + })}\n`, + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + await processStreamingResponse(reader, onChunk, undefined, 0); + + expect(onChunk).toHaveBeenCalledTimes(2); + expect(onChunk).toHaveBeenNthCalledWith(1, "Line 1"); + expect(onChunk).toHaveBeenNthCalledWith(2, "Line 2"); + }); + + it("should not process incomplete final line without newline", async () => { + const onChunk = vi.fn(); + const chunks = [ + `${JSON.stringify({ type: "response", output: "Line 1" })}\n`, + JSON.stringify({ type: "response", output: "Line 2" }), + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + await processStreamingResponse(reader, onChunk, undefined, 0); + + expect(onChunk).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledWith("Line 1"); + }); + + it("should call onChunk for each complete line with output", async () => { + const onChunk = vi.fn(); + const chunks = [ + `${JSON.stringify({ type: "response", output: "First" })}\n`, + `${JSON.stringify({ type: "response", output: "Second" })}\n`, + `${JSON.stringify({ type: "response", output: "Third" })}\n`, + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + await processStreamingResponse(reader, onChunk, undefined, 0); + + expect(onChunk).toHaveBeenCalledTimes(3); + expect(onChunk).toHaveBeenNthCalledWith(1, "First"); + expect(onChunk).toHaveBeenNthCalledWith(2, "Second"); + expect(onChunk).toHaveBeenNthCalledWith(3, "Third"); + }); + + it("should only return first session ID found in stream", async () => { + const onChunk = vi.fn(); + const chunks = [ + `${JSON.stringify({ type: "session", sessionId: "test-123" })}\n`, + `${JSON.stringify({ type: "session", sessionId: "test-456" })}\n`, + `${JSON.stringify({ type: "response", output: "Text" })}\n`, + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + const result = await processStreamingResponse(reader, onChunk, undefined, 0); + + expect(result.sessionId).toBe("test-123"); + expect(onChunk).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledWith("Text"); + }); + + it("should handle empty stream (immediate done)", async () => { + const stream = createMockStream([]); + const reader = stream.getReader(); + + const result = await processStreamingResponse(reader, undefined, undefined, 0); + + expect(result.sessionId).toBeNull(); + expect(result.citations).toEqual([]); + }); + + it("should decode Uint8Array chunks correctly with TextDecoder", async () => { + const onChunk = vi.fn(); + const chunks = [`${JSON.stringify({ type: "response", output: "Hello 👋" })}\n`]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + await processStreamingResponse(reader, onChunk, undefined, 0); + + expect(onChunk).toHaveBeenCalledWith("Hello 👋"); + }); + + it("should handle chunks with only newlines", async () => { + const onChunk = vi.fn(); + const chunks = ["\n\n", `${JSON.stringify({ type: "response", output: "Hello" })}\n`, "\n"]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + await processStreamingResponse(reader, onChunk, undefined, 0); + + expect(onChunk).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledWith("Hello"); + }); + + it("should skip empty lines between valid JSON lines", async () => { + const onChunk = vi.fn(); + const chunks = [ + `${JSON.stringify({ type: "response", output: "First" })}\n\n${JSON.stringify({ + type: "response", + output: "Second", + })}\n`, + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + await processStreamingResponse(reader, onChunk, undefined, 0); + + expect(onChunk).toHaveBeenCalledTimes(2); + expect(onChunk).toHaveBeenNthCalledWith(1, "First"); + expect(onChunk).toHaveBeenNthCalledWith(2, "Second"); + }); + + it("should work without onChunk callback", async () => { + const chunks = [`${JSON.stringify({ type: "session", sessionId: "test-123" })}\n`]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + const result = await processStreamingResponse(reader, undefined, undefined, 0); + + expect(result.sessionId).toBe("test-123"); + expect(result.citations).toEqual([]); + }); + + it("should apply typewriter effect to multiple output chunks", async () => { + vi.useFakeTimers(); + const chunks = [ + `${JSON.stringify({ type: "response", output: "Hi" })}\n`, + `${JSON.stringify({ type: "response", output: " there" })}\n`, + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + const onChunk = vi.fn(); + + const promise = processStreamingResponse(reader, onChunk, undefined, 10); + + await vi.runAllTimersAsync(); + await promise; + + expect(onChunk).toHaveBeenCalledTimes(8); + expect(onChunk).toHaveBeenNthCalledWith(1, "H"); + expect(onChunk).toHaveBeenNthCalledWith(2, "i"); + expect(onChunk).toHaveBeenNthCalledWith(3, " "); + expect(onChunk).toHaveBeenNthCalledWith(4, "t"); + expect(onChunk).toHaveBeenNthCalledWith(5, "h"); + expect(onChunk).toHaveBeenNthCalledWith(6, "e"); + expect(onChunk).toHaveBeenNthCalledWith(7, "r"); + expect(onChunk).toHaveBeenNthCalledWith(8, "e"); + + vi.useRealTimers(); + }); + + it("should collect citations from stream", async () => { + const citation1 = { + documentName: "Doc 1", + documentLink: "https://example.com/doc1", + }; + const citation2 = { + documentName: "Doc 2", + documentLink: "https://example.com/doc2", + }; + + const chunks = [ + `${JSON.stringify({ type: "session", sessionId: "session-1" })}\n`, + `${JSON.stringify({ type: "response", output: "Text 1" })}\n`, + `${JSON.stringify({ type: "citations", citations: [citation1, citation2] })}\n`, + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + const onCitation = vi.fn(); + + const result = await processStreamingResponse(reader, undefined, onCitation, 0); + + expect(result.citations).toEqual([citation1, citation2]); + expect(onCitation).toHaveBeenCalledTimes(2); + expect(onCitation).toHaveBeenNthCalledWith(1, citation1); + expect(onCitation).toHaveBeenNthCalledWith(2, citation2); + }); + + it("should filter duplicate citations by documentLink in stream", async () => { + const citation1 = { + documentName: "Doc 1", + documentLink: "https://example.com/doc1", + }; + const citation2 = { + documentName: "Doc 2", + documentLink: "https://example.com/doc2", + }; + const duplicateCitation = { + documentName: "Doc 1 Updated", + documentLink: "https://example.com/doc1", + }; + + const chunks = [ + `${JSON.stringify({ type: "citations", citations: [citation1] })}\n`, + `${JSON.stringify({ type: "citations", citations: [citation2] })}\n`, + `${JSON.stringify({ type: "citations", citations: [duplicateCitation] })}\n`, + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + const onCitation = vi.fn(); + + const result = await processStreamingResponse(reader, undefined, onCitation, 0); + + expect(result.citations).toEqual([citation1, citation2]); + expect(onCitation).toHaveBeenCalledTimes(2); + }); + + it("should stop typewriter effect when abort signal is triggered during processing", async () => { + vi.useFakeTimers(); + + const chunks = [ + `${JSON.stringify({ type: "response", output: "This is a long message" })}\n`, + `${JSON.stringify({ type: "response", output: " that continues on" })}\n`, + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + const onChunk = vi.fn(); + const abortController = new AbortController(); + + const promise = processStreamingResponse( + reader, + onChunk, + undefined, + 10, + abortController.signal + ); + + await vi.advanceTimersByTimeAsync(30); + + abortController.abort(); + + await vi.runAllTimersAsync(); + await promise; + + const emittedChars = onChunk.mock.calls.length; + expect(emittedChars).toBeLessThan("This is a long message that continues on".length); + + vi.useRealTimers(); + }); + + it("should invoke onPulse callback and log pulse description", async () => { + const chunks = [ + `${JSON.stringify({ type: "pulse", description: "Searching documents..." })}\n`, + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + const onPulse = vi.fn(); + + await processStreamingResponse(reader, undefined, undefined, 0, undefined, onPulse); + + expect(onPulse).toHaveBeenCalledWith("Searching documents..."); + expect(Logger.info).toHaveBeenCalledWith("[KnowledgeBase] Pulse:", "Searching documents..."); + }); + + it("should silently ignore unknown event types and log them", async () => { + const onChunk = vi.fn(); + const chunks = [ + `${JSON.stringify({ type: "future_event", data: "something" })}\n`, + `${JSON.stringify({ type: "response", output: "Hello" })}\n`, + ]; + const stream = createMockStream(chunks); + const reader = stream.getReader(); + + await processStreamingResponse(reader, onChunk, undefined, 0); + + expect(onChunk).toHaveBeenCalledWith("Hello"); + expect(Logger.info).toHaveBeenCalledWith( + "[KnowledgeBase] Unknown event type:", + "future_event", + expect.objectContaining({ type: "future_event" }) + ); + }); +}); + +describe("emitWithTypewriter", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should emit characters one by one with typewriter delay", async () => { + const onChunk = vi.fn(); + + const promise = emitWithTypewriter("Hi", onChunk, 15); + await vi.runAllTimersAsync(); + await promise; + + expect(onChunk).toHaveBeenCalledTimes(2); + expect(onChunk).toHaveBeenNthCalledWith(1, "H"); + expect(onChunk).toHaveBeenNthCalledWith(2, "i"); + }); + + it("should emit entire text at once when typewriter delay is 0", async () => { + const onChunk = vi.fn(); + + await emitWithTypewriter("Hello", onChunk, 0); + + expect(onChunk).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledWith("Hello"); + }); + + it("should respect custom typewriter delay timing", async () => { + const onChunk = vi.fn(); + const customDelay = 50; + + const promise = emitWithTypewriter("AB", onChunk, customDelay); + await vi.runAllTimersAsync(); + await promise; + + expect(onChunk).toHaveBeenCalledTimes(2); + expect(onChunk).toHaveBeenNthCalledWith(1, "A"); + expect(onChunk).toHaveBeenNthCalledWith(2, "B"); + }); + + it("should use default 15ms delay when not specified", async () => { + const onChunk = vi.fn(); + + const promise = emitWithTypewriter("XY", onChunk); + await vi.runAllTimersAsync(); + await promise; + + expect(onChunk).toHaveBeenCalledTimes(2); + expect(onChunk).toHaveBeenNthCalledWith(1, "X"); + expect(onChunk).toHaveBeenNthCalledWith(2, "Y"); + }); + + it("should not call onChunk when not provided", async () => { + const promise = emitWithTypewriter("Hello", undefined, 100); + await promise; + + expect(vi.getTimerCount()).toBe(0); + }); + + it("should handle empty text", async () => { + const onChunk = vi.fn(); + + await emitWithTypewriter("", onChunk, 15); + + expect(onChunk).not.toHaveBeenCalled(); + }); + + it("should work with special characters and emojis", async () => { + const onChunk = vi.fn(); + + const promise = emitWithTypewriter("Hi👋", onChunk, 10); + await vi.runAllTimersAsync(); + await promise; + + expect(onChunk).toHaveBeenCalledTimes(4); + expect(onChunk).toHaveBeenNthCalledWith(1, "H"); + expect(onChunk).toHaveBeenNthCalledWith(2, "i"); + expect(onChunk.mock.calls[2][0] + onChunk.mock.calls[3][0]).toBe("👋"); + }); + + it("should emit all characters for longer text", async () => { + const onChunk = vi.fn(); + const text = "Hello World"; + + const promise = emitWithTypewriter(text, onChunk, 5); + await vi.runAllTimersAsync(); + await promise; + + expect(onChunk).toHaveBeenCalledTimes(text.length); + const emittedText = onChunk.mock.calls.map((call) => call[0]).join(""); + expect(emittedText).toBe(text); + }); + + it("should stop typewriter effect when abort signal is triggered", async () => { + const onChunk = vi.fn(); + const text = "Hello World"; + const abortController = new AbortController(); + + const promise = emitWithTypewriter(text, onChunk, 5, abortController.signal); + + await vi.advanceTimersByTimeAsync(10); + + abortController.abort(); + + await vi.runAllTimersAsync(); + await promise; + + expect(onChunk.mock.calls.length).toBeLessThan(text.length); + expect(onChunk).not.toHaveBeenCalledTimes(text.length); + }); + + it("should not emit any characters if already aborted", async () => { + const onChunk = vi.fn(); + const text = "Hello"; + const abortController = new AbortController(); + + abortController.abort(); + + const promise = emitWithTypewriter(text, onChunk, 5, abortController.signal); + await vi.runAllTimersAsync(); + await promise; + + expect(onChunk).not.toHaveBeenCalled(); + }); + + it("should emit all text immediately when delay is 0 and not aborted", async () => { + const onChunk = vi.fn(); + const text = "Hello"; + const abortController = new AbortController(); + + await emitWithTypewriter(text, onChunk, 0, abortController.signal); + + expect(onChunk).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledWith(text); + }); +}); diff --git a/src/components/ChatBot/api/knowledgeBaseClient.ts b/src/components/ChatBot/api/knowledgeBaseClient.ts new file mode 100644 index 000000000..462c11112 --- /dev/null +++ b/src/components/ChatBot/api/knowledgeBaseClient.ts @@ -0,0 +1,246 @@ +import { Logger } from "@/utils"; + +import chatConfig from "../config/chatConfig"; +import { storeSessionId } from "../utils/sessionStorageUtils"; + +export type AskKnowledgeBaseResponse = { + question: string; + answer: string; + citations?: ChatCitation[]; + sessionId?: string; +}; + +type AskQuestionArgs = { + question: string; + sessionId?: string | null; + conversationHistory?: ConversationHistory[]; + onChunk?: (chunk: string) => void; + onCitation?: (citation: ChatCitation) => void; + onPulse?: (description: string) => void; + signal?: AbortSignal; + url: string; + typewriterDelay?: number; +}; + +type AskQuestionResult = { + sessionId: string | null; + citations: ChatCitation[]; +}; + +/** + * Emits text with a typewriter effect (character by character) using a configurable delay. + * + * @param {string} text - The text to emit character by character + * @param {(chunk: string) => void} [onChunk] - Optional callback to invoke with each character + * @param {number} [typewriterDelay=10] - Delay in milliseconds between characters (0 to disable typewriter effect) + * @param {AbortSignal} [signal] - Optional abort signal to stop the typewriter effect + * @returns {Promise} + */ +export async function emitWithTypewriter( + text: string, + onChunk?: (chunk: string) => void, + typewriterDelay = 10, + signal?: AbortSignal +): Promise { + if (!onChunk || !text) { + return; + } + + // If typewriter delay is 0, emit the whole text at once + if (typewriterDelay === 0) { + onChunk(text); + return; + } + + for (let i = 0; i < text.length; i += 1) { + if (signal?.aborted) { + return; + } + onChunk(text[i]); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, typewriterDelay); + }); + } +} + +/** + * Processes the streaming response from the knowledge base API with typewriter effect. + * + * @param {ReadableStreamDefaultReader} reader - The stream reader for the response body + * @param {(chunk: string) => void} [onChunk] - Optional callback to invoke with each text chunk + * @param {(citation: ChatCitation) => void} [onCitation] - Optional callback to invoke with each citation + * @param {number} [typewriterDelay=10] - Delay in milliseconds between characters (0 to disable typewriter effect) + * @param {AbortSignal} [signal] - Optional abort signal to stop the typewriter effect + * @param {(description: string) => void} [onPulse] - Optional callback to invoke with pulse status descriptions + * @returns {Promise<{ sessionId: string | null; citations: ChatCitation[] }>} The session ID and collected citations from the response + */ +export async function processStreamingResponse( + reader: ReadableStreamDefaultReader, + onChunk?: (chunk: string) => void, + onCitation?: (citation: ChatCitation) => void, + typewriterDelay = 10, + signal?: AbortSignal, + onPulse?: (description: string) => void +): Promise<{ sessionId: string | null; citations: ChatCitation[] }> { + const decoder = new TextDecoder(); + let buffer = ""; + let currentSessionId: string | null = null; + const citations: ChatCitation[] = []; + const seenCitationLinks = new Set(); + let done = false; + + while (!done) { + // eslint-disable-next-line no-await-in-loop + const result = await reader.read(); + done = result.done; + + if (done) { + break; + } + + buffer += decoder.decode(result.value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (typeof line !== "string" || !line?.trim()) { + Logger.error("[KnowledgeBase] Received non-string or empty line:", line); + // eslint-disable-next-line no-continue + continue; + } + + try { + const parsed = JSON.parse(line); + + switch (parsed.type) { + case "session": + if (currentSessionId === null && parsed.sessionId) { + Logger.info("[KnowledgeBase] Received session ID:", parsed.sessionId); + currentSessionId = parsed.sessionId; + } + break; + + case "response": + if (parsed.output) { + // eslint-disable-next-line no-await-in-loop + await emitWithTypewriter(parsed.output, onChunk, typewriterDelay, signal); + } + break; + + case "citations": + if (!Array.isArray(parsed.citations)) { + break; + } + + for (const citation of parsed.citations) { + const link = citation.documentLink ?? ""; + if (link && seenCitationLinks.has(link)) { + // eslint-disable-next-line no-continue + continue; + } + if (link) { + seenCitationLinks.add(link); + } + + citations.push(citation); + onCitation?.(citation); + } + break; + + case "pulse": + if (parsed.description) { + Logger.info("[KnowledgeBase] Pulse:", parsed.description); + onPulse?.(parsed.description); + } + break; + + default: + Logger.info("[KnowledgeBase] Unknown event type:", parsed?.type, parsed); + break; + } + + if (parsed.error) { + Logger.error("[KnowledgeBase] Stream error:", parsed.error); + } + } catch (e) { + Logger.error("[KnowledgeBase] Failed to parse line:", line, e); + } + } + } + + return { sessionId: currentSessionId, citations }; +} + +/** + * Sends a question to the knowledge base API and streams the response. + * + * @param {AskQuestionArgs} args - The question arguments + * @param {string} args.question - The question text to send + * @param {string | null} [args.sessionId] - Optional session ID to continue a conversation + * @param {(chunk: string) => void} [args.onChunk] - Optional callback invoked with each text chunk as it arrives + * @param {(citation: ChatCitation) => void} [args.onCitation] - Optional callback invoked with each citation as it arrives + * @param {AbortSignal} [args.signal] - Optional abort signal to cancel the request + * @param {string} args.url - The knowledge base API endpoint URL + * @param {number} [args.typewriterDelay=10] - Delay in milliseconds between characters (0 to disable typewriter effect) + * @returns {Promise} The session ID and citations from the response + * @throws {Error} If the URL is not provided or the HTTP request fails + */ +export async function askQuestion({ + question, + sessionId = null, + conversationHistory = [], + onChunk, + onCitation, + onPulse, + signal, + url, + typewriterDelay = 10, +}: AskQuestionArgs): Promise { + if (!url) { + throw new Error("Knowledge base URL is required but was not provided"); + } + + try { + const truncatedQuestion = question.slice(0, chatConfig.maxInputTextLength); + const truncatedHistory = conversationHistory.slice(-chatConfig.maxConversationHistoryLength); + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + question: truncatedQuestion, + sessionId, + conversationHistory: truncatedHistory, + }), + signal, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const { sessionId: currentSessionId, citations } = await processStreamingResponse( + reader, + onChunk, + onCitation, + typewriterDelay, + signal, + onPulse + ); + + if (currentSessionId) { + storeSessionId(currentSessionId); + } + + if (citations.length > 0) { + Logger.info("[KnowledgeBase] Citations:", citations); + } + + return { sessionId: currentSessionId, citations }; + } catch (error) { + Logger.error("[KnowledgeBase] Error:", error); + throw error; + } +} diff --git a/src/components/ChatBot/assets/draggable-handle.svg b/src/components/ChatBot/assets/draggable-handle.svg new file mode 100644 index 000000000..cef85a0ee --- /dev/null +++ b/src/components/ChatBot/assets/draggable-handle.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/ChatBot/assets/drawer-view-icon.svg b/src/components/ChatBot/assets/drawer-view-icon.svg new file mode 100644 index 000000000..09531dbf1 --- /dev/null +++ b/src/components/ChatBot/assets/drawer-view-icon.svg @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/src/components/ChatBot/assets/exit-full-screen-icon.svg b/src/components/ChatBot/assets/exit-full-screen-icon.svg new file mode 100644 index 000000000..9d51c9bb7 --- /dev/null +++ b/src/components/ChatBot/assets/exit-full-screen-icon.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/src/components/ChatBot/assets/full-screen-icon.svg b/src/components/ChatBot/assets/full-screen-icon.svg new file mode 100644 index 000000000..595b12c45 --- /dev/null +++ b/src/components/ChatBot/assets/full-screen-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/ChatBot/assets/send-icon.svg b/src/components/ChatBot/assets/send-icon.svg new file mode 100644 index 000000000..0817a3d7c --- /dev/null +++ b/src/components/ChatBot/assets/send-icon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/ChatBot/assets/star-icon.svg b/src/components/ChatBot/assets/star-icon.svg new file mode 100644 index 000000000..6d5fb8008 --- /dev/null +++ b/src/components/ChatBot/assets/star-icon.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/ChatBot/components/ChatBotLogo.test.tsx b/src/components/ChatBot/components/ChatBotLogo.test.tsx new file mode 100644 index 000000000..92a4d25ca --- /dev/null +++ b/src/components/ChatBot/components/ChatBotLogo.test.tsx @@ -0,0 +1,96 @@ +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { axe } from "vitest-axe"; + +import ChatBotLogo from "./ChatBotLogo"; + +describe("Accessibility", () => { + it("should have no accessibility violations with default props", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations with floating variant", async () => { + const { container } = render( + + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations when clickable", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + it("should render without crashing", () => { + expect(() => render()).not.toThrow(); + }); + + it("should render with square variant by default", () => { + const { getByRole } = render(); + + const button = getByRole("button", { name: "Logo" }); + expect(button).toBeInTheDocument(); + }); + + it("should render with floating variant", () => { + const { getByRole } = render(); + + const button = getByRole("button", { name: "Logo" }); + expect(button).toBeInTheDocument(); + }); + + it("should be disabled when no onClick handler is provided", () => { + const { getByRole } = render(); + + const button = getByRole("button", { name: "Logo" }); + expect(button).toBeDisabled(); + }); + + it("should be enabled when onClick handler is provided", () => { + const { getByRole } = render(); + + const button = getByRole("button", { name: "Logo" }); + expect(button).not.toBeDisabled(); + }); + + it("should call onClick when clicked", async () => { + const handleClick = vi.fn(); + const { getByRole } = render(); + + const button = getByRole("button", { name: "Logo" }); + userEvent.click(button); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("should apply aria-label correctly", () => { + const { getByRole } = render(); + + expect(getByRole("button", { name: "Test Label" })).toBeInTheDocument(); + }); + + it("should render without animation by default", () => { + const { container } = render(); + + const button = container.querySelector("button"); + expect(button).toBeInTheDocument(); + }); + + it("should render with animation when animated prop is true", () => { + const { container } = render(); + + const button = container.querySelector("button"); + expect(button).toBeInTheDocument(); + }); + + it("should accept animated prop without crashing", () => { + expect(() => render()).not.toThrow(); + expect(() => render()).not.toThrow(); + }); +}); diff --git a/src/components/ChatBot/components/ChatBotLogo.tsx b/src/components/ChatBot/components/ChatBotLogo.tsx new file mode 100644 index 000000000..3fa81de7e --- /dev/null +++ b/src/components/ChatBot/components/ChatBotLogo.tsx @@ -0,0 +1,112 @@ +import { Button, ButtonProps, styled } from "@mui/material"; +import React from "react"; + +import StarIconSvg from "../assets/star-icon.svg?react"; + +type LogoVariant = "floating" | "square"; + +type StyledLogoButtonProps = { + logoVariant?: LogoVariant; + animated?: boolean; +} & Omit; + +const StyledLogoButton = styled(Button, { + shouldForwardProp: (prop) => prop !== "logoVariant" && prop !== "animated", +})(({ logoVariant = "square", animated = false, disabled = false }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "60px", + height: "60px", + minWidth: "60px", + borderWidth: logoVariant === "floating" ? "2.25px 0px 2.25px 2.25px" : "2.25px", + borderStyle: "solid", + borderColor: "#FCF1F1", + borderRadius: logoVariant === "floating" ? "15px 0px 0px 15px" : "15px", + boxShadow: "0px 4px 10px rgba(0, 0, 0, 0.4)", + boxSizing: "border-box", + overflow: "hidden", + position: "relative", + padding: 0, + cursor: disabled ? "default" : "pointer", + backgroundColor: "transparent", + isolation: "isolate", + transform: "translateZ(0)", + "&::before": { + content: '""', + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + transformOrigin: "center center", + transform: `scale(1.5) rotate(${animated ? "180deg" : "0deg"})`, + background: "linear-gradient(330.31deg, #663CFF 16.99%, #0146A2 58.31%, #37B6C9 93.35%)", + transition: "transform 0.4s ease", + backfaceVisibility: "hidden", + willChange: "transform", + }, + "& > *": { + position: "relative", + zIndex: 1, + }, + ...(!disabled && { + "&:hover::before": { + transform: "scale(1.5) rotate(180deg)", + }, + }), + transition: "background 0.4s ease", + "&.Mui-disabled": { + borderColor: "#FCF1F1", + boxShadow: "0px 4px 10px rgba(0, 0, 0, 0.4)", + }, + "&:hover": { + backgroundColor: "transparent", + boxShadow: "0px 4px 10px rgba(0, 0, 0, 0.4)", + }, +})); + +const StyledStarIcon = styled(StarIconSvg)({ + width: "35.5px", + height: "35.5px", +}); + +export type ChatBotLogoProps = { + /** + * Variant of the logo: + * - "square": All corners have border radius (default) + * - "floating": Right corners have no border radius, no right border + */ + variant?: "floating" | "square"; + /** + * Whether the gradient should be in the animated (rotated) state + */ + animated?: boolean; + /** + * Accessible label for the icon + */ + ariaLabel?: string; + /** + * Click handler - when not provided, button is disabled + */ + onClick?: React.MouseEventHandler; +}; + +const ChatBotLogo = ({ + variant = "square", + animated = false, + ariaLabel, + onClick, +}: ChatBotLogoProps): JSX.Element => ( + + + +); + +export default React.memo(ChatBotLogo); diff --git a/src/components/ChatBot/config/chatConfig.ts b/src/components/ChatBot/config/chatConfig.ts new file mode 100644 index 000000000..0478cfcff --- /dev/null +++ b/src/components/ChatBot/config/chatConfig.ts @@ -0,0 +1,59 @@ +/** + * Configuration settings for the ChatBot component. + */ +const chatConfig = { + /** + * The name of the support bot. + * NOTE: Not visually displayed, but used for message metadata. + */ + supportBotName: "CRDC Support", + /** + * The display name of the user. + * NOTE: Not visually displayed, but used for message metadata. + */ + userDisplayName: "You", + /** + * The initial message sent by the support bot when the chat starts a new conversation. + */ + initialMessage: "How can I help you?", + /** + * The maximum number of messages to include in conversation history. + */ + maxConversationHistoryLength: 100, + /** + * The maximum character length for user input text. + */ + maxInputTextLength: 5000, + /** + * The height configuration for the chat drawer. + */ + height: { + /** + * The height of the chat drawer when it is collapsed. + */ + collapsed: 368, + /** + * The minimum height of the chat drawer. + */ + min: 368, + }, + /** + * The width configuration for the chat drawer. + */ + width: { + /** + * The default width of the chat drawer. + */ + default: 400, + /** + * The minimum width of the chat drawer. + */ + min: 400, + /** + * The width of the chat drawer when expanded. + */ + expanded: 417, + }, +}; + +export default chatConfig; diff --git a/src/components/ChatBot/context/ChatBotContext.test.tsx b/src/components/ChatBot/context/ChatBotContext.test.tsx new file mode 100644 index 000000000..b68f95c1a --- /dev/null +++ b/src/components/ChatBot/context/ChatBotContext.test.tsx @@ -0,0 +1,197 @@ +import { render, renderHook } from "@/test-utils"; + +import { ChatBotProvider, useChatBotContext } from "./ChatBotContext"; + +describe("ChatBotContext > ChatBotProvider", () => { + it("should render children without crashing", () => { + const { getByText } = render( + +
Test Child
+
+ ); + + expect(getByText("Test Child")).toBeInTheDocument(); + }); + + it("should render with default props", () => { + const { result } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.title).toBe("Chat"); + expect(result.current.label).toBe("CRDC Assistant"); + expect(result.current.knowledgeBaseUrl).toBe(""); + }); + + it("should render with custom title", () => { + const { result } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.title).toBe("Support Chat"); + expect(result.current.label).toBe("CRDC Assistant"); + expect(result.current.knowledgeBaseUrl).toBe(""); + }); + + it("should render with custom label", () => { + const { result } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.title).toBe("Chat"); + expect(result.current.label).toBe("Help Center"); + expect(result.current.knowledgeBaseUrl).toBe(""); + }); + + it("should render with custom knowledgeBaseUrl", () => { + const { result } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.title).toBe("Chat"); + expect(result.current.label).toBe("CRDC Assistant"); + expect(result.current.knowledgeBaseUrl).toBe("https://api.example.com/chat"); + }); + + it("should render with all custom props", () => { + const { result } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.title).toBe("Customer Support"); + expect(result.current.label).toBe("Support"); + expect(result.current.knowledgeBaseUrl).toBe("https://api.example.com/kb"); + }); + + it("should update context when props change", () => { + const { result, rerender } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.title).toBe("First Title"); + + rerender(); + + const { result: result2 } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + }); + + expect(result2.current.title).toBe("Second Title"); + }); +}); + +describe("ChatBotContext > useChatBotContext", () => { + it("should throw error when used outside provider", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => { + renderHook(() => useChatBotContext()); + }).toThrow("useChatBotContext must be used within ChatBotProvider"); + + consoleErrorSpy.mockRestore(); + }); + + it("should return context value when used inside provider", () => { + const { result } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toEqual({ + title: "Test Title", + label: "Test Label", + knowledgeBaseUrl: "https://test.com", + }); + }); + + it("should return same object reference when props don't change", () => { + const { result, rerender } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + const firstValue = result.current; + rerender(); + const secondValue = result.current; + + expect(firstValue).toBe(secondValue); + }); + + it("should return new object reference when title changes", () => { + const Wrapper = ({ title, children }: { title: string; children: React.ReactNode }) => ( + {children} + ); + + const { result, rerender } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + initialProps: { title: "First" }, + }); + + const firstValue = result.current; + + rerender({ title: "Second" }); + + const { result: result2 } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + }); + + expect(firstValue).not.toBe(result2.current); + expect(result2.current.title).toBe("Second"); + }); + + it("should return new object reference when label changes", () => { + const Wrapper = ({ label, children }: { label: string; children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + }); + + const firstValue = result.current; + + const { result: result2 } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + }); + + expect(firstValue).not.toBe(result2.current); + expect(result2.current.label).toBe("Second"); + }); + + it("should return new object reference when knowledgeBaseUrl changes", () => { + const Wrapper = ({ url, children }: { url: string; children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + }); + + const firstValue = result.current; + + const { result: result2 } = renderHook(() => useChatBotContext(), { + wrapper: ({ children }) => {children}, + }); + + expect(firstValue).not.toBe(result2.current); + expect(result2.current.knowledgeBaseUrl).toBe("https://second.com"); + }); +}); diff --git a/src/components/ChatBot/context/ChatBotContext.tsx b/src/components/ChatBot/context/ChatBotContext.tsx new file mode 100644 index 000000000..1cce9cbd2 --- /dev/null +++ b/src/components/ChatBot/context/ChatBotContext.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useContext, useMemo } from "react"; + +type ChatBotContextValue = { + title: string; + label: string; + knowledgeBaseUrl: string; +}; + +const ChatBotContext = createContext(null); + +export const useChatBotContext = (): ChatBotContextValue => { + const context = useContext(ChatBotContext); + + if (!context) { + throw new Error("useChatBotContext must be used within ChatBotProvider"); + } + + return context; +}; + +export type ChatBotProviderProps = { + title?: string; + label?: string; + knowledgeBaseUrl?: string; + children: React.ReactNode; +}; + +export const ChatBotProvider: React.FC = ({ + title = "Chat", + label = "CRDC Assistant", + knowledgeBaseUrl = "", + children, +}) => { + const value = useMemo( + () => ({ + title, + label, + knowledgeBaseUrl, + }), + [title, label, knowledgeBaseUrl] + ); + + return {children}; +}; diff --git a/src/components/ChatBot/context/ChatConversationContext.test.tsx b/src/components/ChatBot/context/ChatConversationContext.test.tsx new file mode 100644 index 000000000..6bfe18516 --- /dev/null +++ b/src/components/ChatBot/context/ChatConversationContext.test.tsx @@ -0,0 +1,980 @@ +import { Box } from "@mui/material"; +import { render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import * as knowledgeBaseClient from "../api/knowledgeBaseClient"; +import * as chatUtils from "../utils/chatUtils"; +import * as conversationStorageUtils from "../utils/conversationStorageUtils"; +import * as sessionStorageUtils from "../utils/sessionStorageUtils"; + +import * as ChatBotContextModule from "./ChatBotContext"; +import { + chatReducer, + ChatConversationProvider, + useChatConversationContext, +} from "./ChatConversationContext"; + +vi.mock("../api/knowledgeBaseClient", () => ({ + askQuestion: vi.fn(), +})); + +vi.mock("../utils/chatUtils", async () => { + const actual = await vi.importActual("../utils/chatUtils"); + return { + ...actual, + createId: vi.fn(actual.createId), + createChatMessage: vi.fn(actual.createChatMessage), + }; +}); + +vi.mock("./ChatBotContext", () => ({ + useChatBotContext: vi.fn(), +})); + +vi.mock("../utils/sessionStorageUtils", () => ({ + getStoredSessionId: vi.fn(), + clearStoredSessionId: vi.fn(), +})); + +vi.mock("../utils/conversationStorageUtils", () => ({ + getStoredConversationMessages: vi.fn(), + storeConversationMessages: vi.fn(), + clearConversationMessages: vi.fn(), +})); + +const mockAskQuestion = vi.mocked(knowledgeBaseClient.askQuestion); +const mockUseChatBotContext = vi.mocked(ChatBotContextModule.useChatBotContext); +const mockGetStoredSessionId = vi.mocked(sessionStorageUtils.getStoredSessionId); +const mockClearStoredSessionId = vi.mocked(sessionStorageUtils.clearStoredSessionId); +const mockGetStoredConversationMessages = vi.mocked( + conversationStorageUtils.getStoredConversationMessages +); +const mockStoreConversationMessages = vi.mocked(conversationStorageUtils.storeConversationMessages); +const mockClearConversationMessages = vi.mocked(conversationStorageUtils.clearConversationMessages); +const mockCreateChatMessage = vi.mocked(chatUtils.createChatMessage); + +const TestParent = () => { + const conversation = useChatConversationContext(); + + return ( +
+
{conversation.greetingTimestamp.toISOString()}
+
{conversation.messages.length}
+
+ {conversation.messages.map((msg) => ( +
+ {msg.text} + {msg.citations && msg.citations.length > 0 && ( + + {msg.citations.map((c) => c.documentName).join(", ")} + + )} +
+ ))} +
+ conversation.setInputValue(e.target.value)} + onKeyDown={conversation.handleKeyDown} + /> +
{conversation.isBotTyping.toString()}
+ + + KeyDown + + +
+ ); +}; + +const renderWithProvider = async () => { + const view = render( + + + + ); + + await waitFor(() => { + expect(view.getByTestId("messages-count")).toBeInTheDocument(); + }); + + return view; +}; + +describe("ChatConversationContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatBotContext.mockReturnValue({ + title: "Test Chat", + label: "Chat", + knowledgeBaseUrl: "https://api.example.com/chat", + }); + mockGetStoredSessionId.mockReturnValue("test-session-id"); + mockAskQuestion.mockResolvedValue({ sessionId: "test-session-id", citations: [] }); + mockGetStoredConversationMessages.mockResolvedValue([]); + mockStoreConversationMessages.mockResolvedValue(); + mockClearConversationMessages.mockResolvedValue(); + }); + + describe("Initial State", () => { + it("should initialize with greeting message", async () => { + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("messages-count")).toHaveTextContent("1"); + }); + }); + + it("should initialize with empty input value", async () => { + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("input-value")).toHaveValue(""); + }); + }); + + it("should initialize with isBotTyping as false", async () => { + const { getByTestId } = await renderWithProvider(); + + expect(getByTestId("is-bot-typing")).toHaveTextContent("false"); + }); + + it("should initialize with greeting timestamp", async () => { + const { getByTestId } = await renderWithProvider(); + + const timestamp = getByTestId("greeting-timestamp").textContent; + expect(timestamp).toBeTruthy(); + expect(() => new Date(timestamp)).not.toThrow(); + }); + + it("should have stable greeting timestamp across re-renders", async () => { + const { getByTestId, rerender } = await renderWithProvider(); + + const firstTimestamp = getByTestId("greeting-timestamp").textContent; + rerender( + + + + ); + + await waitFor(() => { + expect(getByTestId("messages-count")).toBeInTheDocument(); + }); + + const secondTimestamp = getByTestId("greeting-timestamp").textContent; + + expect(firstTimestamp).toBe(secondTimestamp); + }); + }); + + describe("setInputValue", () => { + it("should update input value", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.clear(input); + userEvent.type(input, "Hello"); + + expect(input).toHaveValue("Hello"); + }); + + it("should update input value multiple times", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.clear(input); + userEvent.type(input, "Hello"); + + expect(input).toHaveValue("Hello"); + + userEvent.clear(input); + userEvent.type(input, "Hello World"); + + expect(input).toHaveValue("Hello World"); + }); + + it("should handle empty string", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + userEvent.clear(input); + + expect(input).toHaveValue(""); + }); + }); + + describe("sendMessage", () => { + it("should not send empty message", async () => { + const { getByTestId } = await renderWithProvider(); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + expect(getByTestId("messages-count")).toHaveTextContent("1"); + expect(mockAskQuestion).not.toHaveBeenCalled(); + }); + + it("should not send whitespace-only message", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, " "); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + expect(getByTestId("messages-count")).toHaveTextContent("1"); + expect(mockAskQuestion).not.toHaveBeenCalled(); + }); + + it("should add user message when sending", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Hello bot"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("messages-count")).toHaveTextContent("2"); + }); + }); + + it("should clear input after sending", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test message"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(input).toHaveValue(""); + }); + }); + + it("should set isBotTyping to true while waiting for response", async () => { + mockAskQuestion.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ sessionId: "test-session-id", citations: [] }), 100); + }) + ); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("is-bot-typing")).toHaveTextContent("true"); + }); + }); + + it("should call askQuestion with correct parameters", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "What is CRDC?"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(mockAskQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + question: "What is CRDC?", + sessionId: "test-session-id", + url: "https://api.example.com/chat", + signal: expect.any(AbortSignal), + }) + ); + }); + }); + + it("should handle streaming response with onChunk", async () => { + mockAskQuestion.mockImplementation(async ({ onChunk }) => { + if (onChunk) { + onChunk("Hello "); + onChunk("from "); + onChunk("bot"); + } + return { sessionId: "test-session-id", citations: [] }; + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Hi"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("messages-count")).toHaveTextContent("3"); + }); + + const messages = getByTestId("messages"); + expect(messages.textContent).toContain("Hello from bot"); + }); + + it("should set isBotTyping to false after first chunk received", async () => { + mockAskQuestion.mockImplementation(async ({ onChunk }) => { + if (onChunk) { + onChunk("First chunk"); + } + return { sessionId: "test-session-id", citations: [] }; + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("is-bot-typing")).toHaveTextContent("false"); + }); + }); + + it("should update bot message as chunks arrive", async () => { + mockAskQuestion.mockImplementation(async ({ onChunk }) => { + if (onChunk) { + onChunk("Part 1 "); + onChunk("Part 2"); + } + return { sessionId: "test-session-id", citations: [] }; + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("messages-count")).toHaveTextContent("3"); + }); + + const messages = getByTestId("messages"); + expect(messages.textContent).toContain("Part 1 Part 2"); + }); + + it("should handle error response", async () => { + mockAskQuestion.mockRejectedValue(new Error("API Error")); + + const { getByTestId, getByText } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByText(/unexpected error occurred/)).toBeInTheDocument(); + }); + + expect(getByTestId("is-bot-typing")).toHaveTextContent("false"); + }); + + it("should not send message when bot is typing", async () => { + mockAskQuestion.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ sessionId: "test-session-id", citations: [] }), 100); + }) + ); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "First message"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("is-bot-typing")).toHaveTextContent("true"); + }); + + const messageCount = getByTestId("messages-count").textContent; + + userEvent.clear(input); + userEvent.type(input, "Second message"); + userEvent.click(sendButton); + + expect(getByTestId("messages-count")).toHaveTextContent(messageCount); + }); + + it("should return early from sendMessage when bot is typing (via keydown)", async () => { + mockAskQuestion.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ sessionId: "test-session-id", citations: [] }), 200); + }) + ); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "First message{Enter}"); + + await waitFor(() => { + expect(getByTestId("is-bot-typing")).toHaveTextContent("true"); + }); + + const messageCount = getByTestId("messages-count").textContent; + const callCount = mockAskQuestion.mock.calls.length; + + userEvent.clear(input); + userEvent.type(input, "Second message{Enter}"); + + expect(getByTestId("messages-count")).toHaveTextContent(messageCount); + expect(mockAskQuestion).toHaveBeenCalledTimes(callCount); + }); + + it("should abort previous request when sending new message", async () => { + const abortSignals: AbortSignal[] = []; + + mockAskQuestion.mockImplementation(async ({ signal, onChunk }) => { + abortSignals.push(signal); + if (onChunk) { + onChunk("Response"); + } + await new Promise((resolve) => { + setTimeout(() => resolve(), 100); + }); + return { sessionId: "test-session-id", citations: [] }; + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + const sendButton = getByTestId("send-button"); + + userEvent.type(input, "First"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("is-bot-typing")).toHaveTextContent("false"); + }); + + userEvent.type(input, "Second"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(abortSignals).toHaveLength(2); + }); + + expect(abortSignals[0].aborted).toBe(true); + expect(abortSignals[1].aborted).toBe(false); + }); + + it("should handle AbortError gracefully", async () => { + const abortError = new Error("Aborted"); + abortError.name = "AbortError"; + mockAskQuestion.mockRejectedValue(abortError); + + const { getByTestId, queryByText } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("is-bot-typing")).toHaveTextContent("true"); + }); + + expect(queryByText(/unexpected error occurred/)).not.toBeInTheDocument(); + }); + }); + + describe("handleKeyDown", () => { + it("should send message on Enter key", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test message{Enter}"); + + await waitFor(() => { + expect(getByTestId("messages-count")).toHaveTextContent("2"); + }); + }); + + it("should not send message on Shift+Enter", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test message"); + + const keyDownButton = getByTestId("keydown-button"); + userEvent.type(keyDownButton, "{Shift>}{Enter}{/Shift}"); + + expect(getByTestId("messages-count")).toHaveTextContent("1"); + }); + + it("should not send message on other keys", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test message"); + + const keyDownButton = getByTestId("keydown-button"); + userEvent.type(keyDownButton, "a"); + + expect(getByTestId("messages-count")).toHaveTextContent("1"); + }); + }); + + describe("endConversation", () => { + it("should clear stored session ID", async () => { + const { getByTestId } = await renderWithProvider(); + + const endButton = getByTestId("end-conversation-button"); + userEvent.click(endButton); + + expect(mockClearStoredSessionId).toHaveBeenCalled(); + }); + + it("should abort active request", async () => { + let abortSignal: AbortSignal | null = null; + + mockAskQuestion.mockImplementation(async ({ signal }) => { + abortSignal = signal; + await new Promise((resolve) => { + setTimeout(() => resolve(), 100); + }); + return { sessionId: "test-session-id", citations: [] }; + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(abortSignal).not.toBeNull(); + }); + + const endButton = getByTestId("end-conversation-button"); + userEvent.click(endButton); + + expect(abortSignal?.aborted).toBe(true); + }); + + it("should abort request on unmount", async () => { + let abortSignal: AbortSignal | null = null; + + mockAskQuestion.mockImplementation(async ({ signal }) => { + abortSignal = signal; + await new Promise((resolve) => { + setTimeout(() => resolve(), 200); + }); + return { sessionId: "test-session-id", citations: [] }; + }); + + const { getByTestId, unmount } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(abortSignal).not.toBeNull(); + }); + + unmount(); + + expect(abortSignal?.aborted).toBe(true); + }); + }); + + describe("Reducer", () => { + it("should return current state for unknown action type", () => { + const initialState = { + messages: [], + inputValue: "test", + status: "idle" as const, + isInitialized: true, + }; + + const result = chatReducer(initialState, { type: "unknown" } as never); + + expect(result).toEqual(initialState); + }); + }); + + describe("Edge Cases", () => { + it("should handle multiple rapid setInputValue calls", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.clear(input); + userEvent.type(input, "Hello"); + + expect(input).toHaveValue("Hello"); + }); + + it("should use same bot message ID for streaming chunks", async () => { + mockAskQuestion.mockImplementation(async ({ onChunk }) => { + if (onChunk) { + onChunk("Chunk 1 "); + onChunk("Chunk 2"); + } + return { sessionId: "test-session-id", citations: [] }; + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + const messages = getByTestId("messages"); + expect(messages.textContent).toContain("Chunk 1 Chunk 2"); + }); + }); + + it("should trim message before sending", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, " Test message "); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(mockAskQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + question: "Test message", + }) + ); + }); + }); + + it("should handle Shift+Enter without sending message", async () => { + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test message"); + + const keydownButton = getByTestId("keydown-button"); + keydownButton.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ); + + expect(mockAskQuestion).not.toHaveBeenCalled(); + expect(input).toHaveValue("Test message"); + }); + + it("should not reset status when error for superseded request", async () => { + let firstResolve: (() => void) | null = null; + let callCount = 0; + + mockAskQuestion.mockImplementation(async ({ onChunk }) => { + callCount += 1; + if (callCount === 1) { + if (onChunk) { + onChunk("First"); + } + await new Promise((resolve) => { + firstResolve = resolve; + }); + throw new Error("Network error"); + } + if (onChunk) { + onChunk("Second"); + } + return { sessionId: "test-session-id", citations: [] }; + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "First"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("is-bot-typing")).toHaveTextContent("false"); + }); + + userEvent.clear(input); + userEvent.type(input, "Second"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(mockAskQuestion).toHaveBeenCalledTimes(2); + }); + + if (firstResolve) { + firstResolve(); + } + + await waitFor(() => { + const messages = getByTestId("messages"); + expect(messages.textContent).toContain("Second"); + }); + }); + + it("should handle non-abort errors in final catch", async () => { + mockAskQuestion.mockImplementation(async () => { + throw new Error("Synchronous error"); + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("is-bot-typing")).toHaveTextContent("false"); + }); + }); + + it("should reset status to idle when runReply throws non-abort error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const originalImplementation = mockCreateChatMessage.getMockImplementation(); + let shouldThrow = false; + + mockCreateChatMessage.mockImplementation((params) => { + if (shouldThrow && params.variant === "error") { + throw new Error("createChatMessage failed"); + } + return originalImplementation(params); + }); + + mockAskQuestion.mockImplementation(async () => { + shouldThrow = true; + throw new Error("API Error"); + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("is-bot-typing")).toHaveTextContent("false"); + }); + + mockCreateChatMessage.mockImplementation(originalImplementation); + consoleError.mockRestore(); + }); + + it("should return early when aborted after askQuestion completes", async () => { + let firstResolve: (() => void) | null = null; + let callCount = 0; + + mockAskQuestion.mockImplementation(async ({ onChunk }) => { + callCount += 1; + if (callCount === 1) { + if (onChunk) { + onChunk("First response"); + } + await new Promise((resolve) => { + firstResolve = resolve; + }); + return { sessionId: "test-session-id", citations: [] }; + } + if (onChunk) { + onChunk("Second response"); + } + return { sessionId: "test-session-id", citations: [] }; + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "First"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("is-bot-typing")).toHaveTextContent("false"); + }); + + userEvent.clear(input); + userEvent.type(input, "Second"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(mockAskQuestion).toHaveBeenCalledTimes(2); + }); + + if (firstResolve) { + firstResolve(); + } + + await waitFor( + () => { + const messages = getByTestId("messages"); + expect(messages.textContent).toContain("Second response"); + }, + { timeout: 2000 } + ); + }); + + it("should add citations to bot message when citations are provided", async () => { + const mockCitation: ChatCitation = { + documentName: "Test Citation", + documentLink: "https://example.com", + }; + + mockAskQuestion.mockImplementation(async ({ onChunk, onCitation }) => { + if (onChunk) { + onChunk("Response with citation"); + } + if (onCitation) { + onCitation(mockCitation); + } + return { sessionId: "test-session-id", citations: [mockCitation] }; + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + const messages = getByTestId("messages"); + expect(messages.textContent).toContain("Test Citation"); + }); + }); + + it("should handle multiple citations", async () => { + const citations: ChatCitation[] = [ + { documentName: "Citation 1", documentLink: "https://example.com/1" }, + { documentName: "Citation 2", documentLink: "https://example.com/2" }, + ]; + + mockAskQuestion.mockImplementation(async ({ onChunk, onCitation }) => { + if (onChunk) { + onChunk("Response"); + } + if (onCitation) { + citations.forEach((c) => onCitation(c)); + } + return { sessionId: "test-session-id", citations }; + }); + + const { getByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + const messages = getByTestId("messages"); + expect(messages.textContent).toContain("Citation 1"); + expect(messages.textContent).toContain("Citation 2"); + }); + }); + + it("should not add citations block when no citations provided", async () => { + mockAskQuestion.mockImplementation(async ({ onChunk }) => { + if (onChunk) { + onChunk("Response without citations"); + } + return { sessionId: "test-session-id", citations: [] }; + }); + + const { getByTestId, queryByTestId } = await renderWithProvider(); + + const input = getByTestId("input-value"); + userEvent.type(input, "Test"); + + const sendButton = getByTestId("send-button"); + userEvent.click(sendButton); + + await waitFor(() => { + expect(getByTestId("messages-count")).toHaveTextContent("3"); + }); + + const messagesContainer = getByTestId("messages"); + const botMessages = messagesContainer.querySelectorAll('[data-sender="bot"]'); + const lastBotMessage = botMessages[botMessages.length - 1]; + expect(lastBotMessage?.getAttribute("data-citations")).toBe("0"); + expect(queryByTestId(/^citations-/)).not.toBeInTheDocument(); + }); + }); + + it("should throw error when used outside provider", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => render()).toThrow( + "useChatConversationContext must be used within ChatConversationProvider" + ); + + consoleError.mockRestore(); + }); +}); diff --git a/src/components/ChatBot/context/ChatConversationContext.tsx b/src/components/ChatBot/context/ChatConversationContext.tsx new file mode 100644 index 000000000..a8530e724 --- /dev/null +++ b/src/components/ChatBot/context/ChatConversationContext.tsx @@ -0,0 +1,399 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useRef, +} from "react"; + +import { askQuestion } from "../api/knowledgeBaseClient"; +import chatConfig from "../config/chatConfig"; +import { createChatMessage, createId, isAbortError } from "../utils/chatUtils"; +import { + clearConversationMessages, + getStoredConversationMessages, + storeConversationMessages, +} from "../utils/conversationStorageUtils"; +import { clearStoredSessionId, getStoredSessionId } from "../utils/sessionStorageUtils"; + +import { useChatBotContext } from "./ChatBotContext"; + +type ChatState = { + messages: ChatMessage[]; + inputValue: string; + status: ChatStatus; + isInitialized: boolean; +}; + +type ChatAction = + | { type: "input_changed"; value: string } + | { type: "input_cleared" } + | { type: "message_added"; message: ChatMessage } + | { type: "status_changed"; status: ChatStatus } + | { type: "conversation_reset" } + | { type: "chat_initialized"; messages: ChatMessage[] }; + +export type ChatConversationActions = { + greetingTimestamp: Date; + messages: ChatMessage[]; + inputValue: string; + isBotTyping: boolean; + setInputValue: (value: string) => void; + sendMessage: (messageText?: string) => void; + handleKeyDown: React.KeyboardEventHandler; + endConversation: () => void; +}; + +/** + * Creates the initial greeting message for the chat. + */ +const createGreetingMessage = (): ChatMessage => + createChatMessage({ + text: chatConfig.initialMessage, + sender: "bot", + senderName: chatConfig.supportBotName, + }); + +/** + * Chat reducer to manage chat state transitions. + * + * @param state - The current chat state + * @param action - The Action to process + * @returns The updated chat state + */ +export const chatReducer = (state: ChatState, action: ChatAction): ChatState => { + switch (action.type) { + case "input_changed": { + return { ...state, inputValue: action.value }; + } + case "input_cleared": { + return { ...state, inputValue: "" }; + } + case "message_added": { + const existingIndex = state.messages.findIndex((msg) => msg.id === action.message.id); + if (existingIndex !== -1) { + const updatedMessages = [...state.messages]; + updatedMessages[existingIndex] = action.message; + return { ...state, messages: updatedMessages }; + } + + return { ...state, messages: [...state.messages, action.message] }; + } + case "status_changed": { + return { ...state, status: action.status }; + } + case "conversation_reset": { + return { + messages: [], + inputValue: "", + status: "idle", + isInitialized: true, + }; + } + case "chat_initialized": { + return { + ...state, + messages: action.messages, + isInitialized: true, + }; + } + default: { + return state; + } + } +}; + +/** + * Custom hook to manage chat conversation state and behavior. + * + * @returns {ChatConversationActions} An object containing chat state and action handlers. + */ +const useChatConversation = (): ChatConversationActions => { + const { knowledgeBaseUrl } = useChatBotContext(); + const greetingTimestampRef = useRef(new Date()); + + const [state, dispatch] = useReducer(chatReducer, { + messages: [], + inputValue: "", + status: "idle", + isInitialized: false, + }); + + const stateRef = useRef(state); + stateRef.current = state; + + /** + * Initializes the chat by loading conversation history from IndexedDB. + */ + const initializeChat = useCallback(async () => { + const storedMessages = await getStoredConversationMessages(); + const messages = [createGreetingMessage(), ...storedMessages]; + dispatch({ type: "chat_initialized", messages }); + }, []); + + useEffect(() => { + initializeChat(); + }, [initializeChat]); + + useEffect(() => { + if (state.isInitialized && state.messages.length > 0) { + storeConversationMessages(state.messages.slice(1)); + } + }, [state.messages, state.isInitialized]); + + const activeRequestRef = useRef<{ + requestId: string; + abortController: AbortController; + } | null>(null); + + useEffect( + () => () => { + activeRequestRef.current?.abortController.abort(); + activeRequestRef.current = null; + }, + [] + ); + + /** + * Handles errors that occur during bot reply requests. + */ + const handleReplyError = useCallback((error: unknown, requestId: string): void => { + const active = activeRequestRef.current; + if (!active || active.requestId !== requestId) { + return; + } + + if (active.abortController.signal.aborted || isAbortError(error)) { + return; + } + + dispatch({ + type: "message_added", + message: createChatMessage({ + text: "Sorry, an unexpected error occurred. Please try again later.", + sender: "bot", + senderName: chatConfig.supportBotName, + variant: "error", + }), + }); + + dispatch({ type: "status_changed", status: "idle" }); + }, []); + + /** + * Builds conversation history from messages for the API request. + * Excludes the initial greeting message. + */ + const buildConversationHistory = useCallback( + (messages: ChatMessage[]): ConversationHistory[] => + messages.slice(1).map((msg) => ({ + role: msg.sender === "user" ? "user" : "assistant", + content: msg.text, + })), + [] + ); + + /** + * Executes the bot reply request with streaming support. + */ + const runReply = useCallback( + async ( + userMessage: string, + requestId: string, + abortController: AbortController + ): Promise => { + try { + const botMessageId = createId("bot_msg_"); + let accumulatedText = ""; + const allCitations: ChatCitation[] = []; + let firstChunkReceived = false; + const conversationHistory = buildConversationHistory(stateRef.current.messages); + + await askQuestion({ + question: userMessage, + sessionId: getStoredSessionId(), + conversationHistory, + signal: abortController.signal, + url: knowledgeBaseUrl, + onChunk: (chunk: string) => { + if (!firstChunkReceived) { + dispatch({ type: "status_changed", status: "idle" }); + firstChunkReceived = true; + } + + accumulatedText += chunk; + dispatch({ + type: "message_added", + message: createChatMessage({ + id: botMessageId, + text: accumulatedText, + sender: "bot", + senderName: chatConfig.supportBotName, + }), + }); + }, + onCitation: (citation) => { + allCitations?.push(citation); + }, + }); + + const active = activeRequestRef.current; + if (!active || active.requestId !== requestId || active.abortController.signal.aborted) { + return; + } + + // Add citations to existing bot message if they exist + if (allCitations?.length > 0) { + dispatch({ + type: "message_added", + message: createChatMessage({ + id: botMessageId, + text: accumulatedText, + sender: "bot", + senderName: chatConfig.supportBotName, + citations: allCitations, + }), + }); + } + + dispatch({ type: "status_changed", status: "idle" }); + } catch (error) { + handleReplyError(error, requestId); + } + }, + [knowledgeBaseUrl, handleReplyError, buildConversationHistory] + ); + + /** + * Updates the input field value in the chat state. + */ + const setInputValue = useCallback((value: string): void => { + dispatch({ type: "input_changed", value }); + }, []); + + /** + * Sends the current input message to the bot. + * @param messageText - Optional message text to send directly (bypasses input field) + */ + const sendMessage = useCallback( + (messageText?: string): void => { + const { current } = stateRef; + const text = typeof messageText === "string" ? messageText.trim() : ""; + const value = text || current.inputValue?.trim(); + + if (!value) { + return; + } + + if (current.status === "bot_typing") { + return; + } + + if (current.messages.length === 0) { + dispatch({ + type: "message_added", + message: createGreetingMessage(), + }); + } + + dispatch({ + type: "message_added", + message: createChatMessage({ + text: value, + sender: "user", + senderName: chatConfig.userDisplayName, + }), + }); + + if (!text) { + dispatch({ type: "input_cleared" }); + } + dispatch({ type: "status_changed", status: "bot_typing" }); + + activeRequestRef.current?.abortController.abort(); + + const abortController = new AbortController(); + const requestId = createId("bot_reply_"); + activeRequestRef.current = { requestId, abortController }; + + runReply(value, requestId, abortController).catch((error: unknown) => { + if (!isAbortError(error)) { + dispatch({ type: "status_changed", status: "idle" }); + } + }); + }, + [runReply] + ); + + /** + * Handles keyboard events in the chat input. + */ + const handleKeyDown: React.KeyboardEventHandler = useCallback( + (event) => { + if (event.key !== "Enter") { + return; + } + + if (event.shiftKey) { + return; + } + + event.preventDefault(); + sendMessage(); + }, + [sendMessage] + ); + + /** + * Ends the current conversation and resets to initial state. + */ + const endConversation = useCallback((): void => { + clearStoredSessionId(); + clearConversationMessages(); + activeRequestRef.current?.abortController.abort(); + activeRequestRef.current = null; + greetingTimestampRef.current = new Date(); + dispatch({ type: "conversation_reset" }); + }, []); + + return { + greetingTimestamp: greetingTimestampRef.current, + messages: state.messages, + inputValue: state.inputValue, + isBotTyping: state.status === "bot_typing", + setInputValue, + sendMessage, + handleKeyDown, + endConversation, + }; +}; + +type ChatConversationContextValue = ChatConversationActions; + +const ChatConversationContext = createContext(null); + +export const useChatConversationContext = (): ChatConversationContextValue => { + const context = useContext(ChatConversationContext); + + if (!context) { + throw new Error("useChatConversationContext must be used within ChatConversationProvider"); + } + + return context; +}; + +export type ChatConversationProviderProps = { + children: React.ReactNode; +}; + +export const ChatConversationProvider: React.FC = ({ children }) => { + const conversationHook = useChatConversation(); + + const value = useMemo(() => conversationHook, [conversationHook]); + + return ( + {children} + ); +}; diff --git a/src/components/ChatBot/context/ChatDrawerContext.test.tsx b/src/components/ChatBot/context/ChatDrawerContext.test.tsx new file mode 100644 index 000000000..cf7790c1d --- /dev/null +++ b/src/components/ChatBot/context/ChatDrawerContext.test.tsx @@ -0,0 +1,597 @@ +import { waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { render } from "@/test-utils"; + +import * as useChatDrawerModule from "../hooks/useChatDrawer"; + +import { ChatConversationProvider } from "./ChatConversationContext"; +import { ChatDrawerProvider, useChatDrawerContext } from "./ChatDrawerContext"; + +vi.mock("../hooks/useChatDrawer", () => ({ + useChatDrawer: vi.fn(), +})); + +vi.mock("./ChatBotContext", () => ({ + useChatBotContext: vi.fn(() => ({ + isChatEnabled: true, + })), +})); + +vi.mock("./ChatConversationContext", () => ({ + ChatConversationProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, + useChatConversationContext: vi.fn(() => ({ + messages: [], + inputValue: "", + isBotTyping: false, + setInputValue: vi.fn(), + sendMessage: vi.fn(), + handleKeyDown: vi.fn(), + endConversation: vi.fn(), + greetingTimestamp: new Date(), + })), +})); + +const mockUseChatDrawer = vi.mocked(useChatDrawerModule.useChatDrawer); + +type TestParentProps = { + onRender?: (context: ReturnType) => void; + children?: React.ReactNode; +}; + +const TestParent = ({ onRender, children }: TestParentProps) => { + const context = useChatDrawerContext(); + + if (onRender) { + onRender(context); + } + + return ( +
+
{context.isOpen.toString()}
+
{context.isExpanded.toString()}
+
{context.heightPx}
+
{context.isMinimized.toString()}
+
{context.isFullscreen.toString()}
+
{context.isConfirmingEndConversation.toString()}
+ + + + + + + + {children} +
+ ); +}; + +const defaultChatDrawerHook = { + drawerRef: { current: null }, + isOpen: false, + isExpanded: true, + drawerHeightPx: 600, + drawerWidthPx: 384, + drawerX: 0, + drawerY: 0, + openDrawer: vi.fn(), + closeDrawer: vi.fn(), + handleDragStop: vi.fn(), + handleResizeStop: vi.fn(), + toggleExpand: vi.fn(), +}; + +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe("ChatDrawerContext > ChatDrawerProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatDrawer.mockReturnValue(defaultChatDrawerHook); + }); + it("should render children without crashing", () => { + const { getByText } = render( + +
Test Child
+
+ ); + + expect(getByText("Test Child")).toBeInTheDocument(); + }); + + it("should provide default values from hooks", () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId("is-open")).toHaveTextContent("false"); + expect(getByTestId("is-expanded")).toHaveTextContent("true"); + expect(getByTestId("height-px")).toHaveTextContent("600"); + expect(getByTestId("is-minimized")).toHaveTextContent("false"); + expect(getByTestId("is-fullscreen")).toHaveTextContent("false"); + expect(getByTestId("is-confirming")).toHaveTextContent("false"); + }); + + it("should provide drawer state from useChatDrawer hook", () => { + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + isOpen: true, + isExpanded: false, + drawerHeightPx: 800, + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId("is-open")).toHaveTextContent("true"); + expect(getByTestId("is-expanded")).toHaveTextContent("false"); + expect(getByTestId("height-px")).toHaveTextContent("800"); + }); + + it("should call openDrawer from hook when handleOpenDrawer is called", () => { + const openDrawer = vi.fn(); + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + isOpen: false, + openDrawer, + }); + + const { getByTestId } = render( + + + + ); + + const button = getByTestId("open-drawer"); + userEvent.click(button); + + expect(openDrawer).toHaveBeenCalled(); + }); + + it("should not call openDrawer when already open", () => { + const openDrawer = vi.fn(); + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + isOpen: true, + openDrawer, + }); + + const { getByTestId } = render( + + + + ); + + const button = getByTestId("open-drawer"); + userEvent.click(button); + + expect(openDrawer).not.toHaveBeenCalled(); + }); + + it("should reset minimized and confirming state when opening drawer", async () => { + const openDrawer = vi.fn(); + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + isOpen: true, + openDrawer, + }); + + const { getByTestId, rerender } = render( + + + + ); + + const minimizeButton = getByTestId("minimize"); + const requestEndButton = getByTestId("request-end"); + + userEvent.click(minimizeButton); + userEvent.click(requestEndButton); + + await waitFor(() => { + expect(getByTestId("is-minimized")).toHaveTextContent("true"); + expect(getByTestId("is-confirming")).toHaveTextContent("true"); + }); + + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + isOpen: false, + openDrawer, + }); + + rerender( + + + + ); + + const openButton = getByTestId("open-drawer"); + userEvent.click(openButton); + + await waitFor(() => { + expect(getByTestId("is-minimized")).toHaveTextContent("false"); + expect(getByTestId("is-confirming")).toHaveTextContent("false"); + }); + }); + + it("should set isMinimized to true when onMinimize is called", async () => { + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + isOpen: true, + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId("is-minimized")).toHaveTextContent("false"); + + const button = getByTestId("minimize"); + userEvent.click(button); + + await waitFor(() => { + expect(getByTestId("is-minimized")).toHaveTextContent("true"); + }); + }); + + it("should not minimize when drawer is not open", () => { + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + isOpen: false, + }); + + const { getByTestId } = render( + + + + ); + + const button = getByTestId("minimize"); + userEvent.click(button); + + expect(getByTestId("is-minimized")).toHaveTextContent("false"); + }); + + it("should toggle fullscreen state", async () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId("is-fullscreen")).toHaveTextContent("false"); + + const button = getByTestId("toggle-fullscreen"); + userEvent.click(button); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("true"); + }); + + userEvent.click(button); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("false"); + }); + }); + + it("should hide page scrollbar when fullscreen is enabled", async () => { + const { getByTestId } = render( + + + + ); + + expect(document.body.style.overflow).toBe(""); + + const button = getByTestId("toggle-fullscreen"); + userEvent.click(button); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("true"); + expect(document.body.style.overflow).toBe("hidden"); + }); + + userEvent.click(button); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("false"); + expect(document.body.style.overflow).toBe(""); + }); + }); + + it("should restore page scrollbar when conversation ends", async () => { + const { getByTestId } = render( + + + + ); + + const toggleFullscreenButton = getByTestId("toggle-fullscreen"); + userEvent.click(toggleFullscreenButton); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("true"); + expect(document.body.style.overflow).toBe("hidden"); + }); + + const requestEndButton = getByTestId("request-end"); + userEvent.click(requestEndButton); + + const confirmEndButton = getByTestId("confirm-end"); + userEvent.click(confirmEndButton); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("false"); + expect(document.body.style.overflow).toBe(""); + }); + }); + + it("should hide page scrollbar when fullscreen enabled and restore when unmounted", async () => { + const { getByTestId, unmount } = render( + + + + ); + + const button = getByTestId("toggle-fullscreen"); + userEvent.click(button); + + await waitFor(() => { + expect(document.body.style.overflow).toBe("hidden"); + }); + + unmount(); + + expect(document.body.style.overflow).toBe(""); + }); + + it("should set isConfirmingEndConversation to true when onRequestEndConversation is called", async () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId("is-confirming")).toHaveTextContent("false"); + + const button = getByTestId("request-end"); + userEvent.click(button); + + await waitFor(() => { + expect(getByTestId("is-confirming")).toHaveTextContent("true"); + }); + }); + + it("should set isConfirmingEndConversation to false when onCancelEndConversation is called", async () => { + const { getByTestId } = render( + + + + ); + + const requestButton = getByTestId("request-end"); + userEvent.click(requestButton); + + await waitFor(() => { + expect(getByTestId("is-confirming")).toHaveTextContent("true"); + }); + + const cancelButton = getByTestId("cancel-end"); + userEvent.click(cancelButton); + + await waitFor(() => { + expect(getByTestId("is-confirming")).toHaveTextContent("false"); + }); + }); + + it("should call endConversation and reset all state when onConfirmEndConversation is called", async () => { + const closeDrawer = vi.fn(); + + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + closeDrawer, + }); + + const { getByTestId } = render( + + + + ); + + const toggleFullscreenButton = getByTestId("toggle-fullscreen"); + userEvent.click(toggleFullscreenButton); + + const requestEndButton = getByTestId("request-end"); + userEvent.click(requestEndButton); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("true"); + expect(getByTestId("is-confirming")).toHaveTextContent("true"); + }); + + const confirmEndButton = getByTestId("confirm-end"); + userEvent.click(confirmEndButton); + + await waitFor(() => { + expect(closeDrawer).toHaveBeenCalled(); + expect(getByTestId("is-confirming")).toHaveTextContent("false"); + expect(getByTestId("is-minimized")).toHaveTextContent("false"); + expect(getByTestId("is-fullscreen")).toHaveTextContent("false"); + }); + }); + + it("should call toggleExpand when onToggleExpand is called", () => { + const toggleExpand = vi.fn(); + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + toggleExpand, + }); + + const { getByTestId } = render( + + + + ); + + const button = getByTestId("toggle-expand"); + userEvent.click(button); + + expect(toggleExpand).toHaveBeenCalled(); + }); + + it("should exit fullscreen when toggle expand is called while fullscreen and expanded", async () => { + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + isOpen: true, + isExpanded: true, + }); + + const { getByTestId } = render( + + + + ); + + const fullscreenButton = getByTestId("toggle-fullscreen"); + userEvent.click(fullscreenButton); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("true"); + }); + + const expandButton = getByTestId("toggle-expand"); + userEvent.click(expandButton); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("false"); + }); + }); + + it("should exit fullscreen and toggle expand when toggle expand is called while fullscreen but not expanded", async () => { + const toggleExpand = vi.fn(); + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + isOpen: true, + isExpanded: false, + toggleExpand, + }); + + const { getByTestId } = render( + + + + ); + + const fullscreenButton = getByTestId("toggle-fullscreen"); + userEvent.click(fullscreenButton); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("true"); + }); + + const expandButton = getByTestId("toggle-expand"); + userEvent.click(expandButton); + + await waitFor(() => { + expect(getByTestId("is-fullscreen")).toHaveTextContent("false"); + }); + expect(toggleExpand).toHaveBeenCalled(); + }); + + it("should provide drawerRef from hook", () => { + const mockRef = { current: document.createElement("div") }; + let capturedRef: React.RefObject | null = null; + + mockUseChatDrawer.mockReturnValue({ + ...defaultChatDrawerHook, + drawerRef: mockRef, + }); + + render( + + { + capturedRef = context.drawerRef; + }} + /> + + ); + + expect(capturedRef).toBe(mockRef); + }); +}); + +describe("ChatDrawerContext > useChatDrawerContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatDrawer.mockReturnValue(defaultChatDrawerHook); + }); + it("should throw error when used outside provider", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => { + render(); + }).toThrow("useChatDrawerContext must be used within ChatDrawerProvider"); + + consoleErrorSpy.mockRestore(); + }); + + it("should return context value when used inside provider", () => { + let capturedContext: ReturnType | null = null; + + render( + + { + capturedContext = context; + }} + /> + + ); + + expect(capturedContext).toBeDefined(); + expect(capturedContext).toHaveProperty("isOpen"); + expect(capturedContext).toHaveProperty("openDrawer"); + expect(capturedContext).toHaveProperty("drawerRef"); + expect(capturedContext).toHaveProperty("heightPx"); + expect(capturedContext).toHaveProperty("isExpanded"); + expect(capturedContext).toHaveProperty("isMinimized"); + expect(capturedContext).toHaveProperty("isFullscreen"); + expect(capturedContext).toHaveProperty("onDragStop"); + expect(capturedContext).toHaveProperty("onResizeStop"); + expect(capturedContext).toHaveProperty("onToggleExpand"); + expect(capturedContext).toHaveProperty("onToggleFullscreen"); + expect(capturedContext).toHaveProperty("onMinimize"); + expect(capturedContext).toHaveProperty("isConfirmingEndConversation"); + expect(capturedContext).toHaveProperty("onRequestEndConversation"); + expect(capturedContext).toHaveProperty("onConfirmEndConversation"); + expect(capturedContext).toHaveProperty("onCancelEndConversation"); + }); +}); diff --git a/src/components/ChatBot/context/ChatDrawerContext.tsx b/src/components/ChatBot/context/ChatDrawerContext.tsx new file mode 100644 index 000000000..c3fefc37f --- /dev/null +++ b/src/components/ChatBot/context/ChatDrawerContext.tsx @@ -0,0 +1,211 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { RndDragCallback, RndResizeCallback } from "react-rnd"; + +import { useChatDrawer } from "../hooks/useChatDrawer"; + +import { useChatConversationContext } from "./ChatConversationContext"; + +type ChatDrawerContextValue = { + // Drawer open state + isOpen: boolean; + openDrawer: () => void; + + // Drawer state + drawerRef: React.RefObject; + heightPx: number; + widthPx: number; + x: number; + y: number; + isExpanded: boolean; + isMinimized: boolean; + isFullscreen: boolean; + + // Drawer actions + onDragStop: RndDragCallback; + onResizeStop: RndResizeCallback; + onToggleExpand: () => void; + onToggleFullscreen: () => void; + onMinimize: () => void; + + // End conversation state + isConfirmingEndConversation: boolean; + onRequestEndConversation: () => void; + onConfirmEndConversation: () => void; + onCancelEndConversation: () => void; +}; + +const ChatDrawerContext = createContext(null); + +export const useChatDrawerContext = (): ChatDrawerContextValue => { + const context = useContext(ChatDrawerContext); + + if (!context) { + throw new Error("useChatDrawerContext must be used within ChatDrawerProvider"); + } + + return context; +}; + +export type ChatDrawerProviderProps = { + children: React.ReactNode; +}; + +export const ChatDrawerProvider: React.FC = ({ children }) => { + const { + drawerRef, + isOpen, + isExpanded, + drawerHeightPx, + drawerWidthPx, + drawerX, + drawerY, + openDrawer, + closeDrawer, + handleDragStop, + handleResizeStop, + toggleExpand, + } = useChatDrawer(); + + const { endConversation } = useChatConversationContext(); + + const [isMinimized, setIsMinimized] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isConfirmingEndConversation, setIsConfirmingEndConversation] = useState(false); + + /** + * Hide the page scrollbar when fullscreen mode is active to avoid double scrollbars. + */ + useEffect(() => { + if (isFullscreen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + + return () => { + document.body.style.overflow = ""; + }; + }, [isFullscreen]); + + /** + * Opens the chat drawer and removes the minimized state when the floating button is clicked. + */ + const handleOpenDrawer = useCallback((): void => { + setIsMinimized(false); + setIsConfirmingEndConversation(false); + + if (!isOpen) { + openDrawer(); + } + }, [isOpen, openDrawer]); + + /** + * Minimizes the chat drawer when the minimize button is clicked. + */ + const handleMinimizeDrawer = useCallback((): void => { + if (!isOpen) { + return; + } + + setIsMinimized(true); + }, [isOpen]); + + /** + * Toggles the fullscreen state of the chat drawer. + */ + const handleToggleFullscreen = useCallback((): void => { + setIsFullscreen((prev) => !prev); + }, []); + + /** + * Handles the drawer expand button click. + * If in fullscreen, exits fullscreen and enters expanded mode. + * Otherwise, toggles the expanded state. + */ + const handleToggleExpand = useCallback((): void => { + if (isFullscreen && isExpanded) { + setIsFullscreen(false); + return; + } + + if (isFullscreen) { + setIsFullscreen(false); + toggleExpand(); + return; + } + + toggleExpand(); + }, [isFullscreen, isExpanded, toggleExpand]); + + /** + * Begins the "End Conversation" confirmation flow. + */ + const handleRequestEndConversation = useCallback((): void => { + setIsConfirmingEndConversation(true); + }, []); + + /** + * Cancels the "End Conversation" confirmation flow. + */ + const handleCancelEndConversation = useCallback((): void => { + setIsConfirmingEndConversation(false); + }, []); + + /** + * Closes the chat drawer, clears the session, and resets state when the conversation ends. + */ + const handleEndConversation = useCallback((): void => { + endConversation(); + setIsConfirmingEndConversation(false); + setIsMinimized(false); + setIsFullscreen(false); + closeDrawer(); + }, [endConversation, closeDrawer]); + + const value = useMemo( + () => ({ + isOpen, + openDrawer: handleOpenDrawer, + drawerRef, + heightPx: drawerHeightPx, + widthPx: drawerWidthPx, + x: drawerX, + y: drawerY, + isExpanded, + isMinimized, + isFullscreen, + onDragStop: handleDragStop, + onResizeStop: handleResizeStop, + onToggleExpand: handleToggleExpand, + onToggleFullscreen: handleToggleFullscreen, + onMinimize: handleMinimizeDrawer, + isConfirmingEndConversation, + onRequestEndConversation: handleRequestEndConversation, + onConfirmEndConversation: handleEndConversation, + onCancelEndConversation: handleCancelEndConversation, + }), + [ + isOpen, + handleOpenDrawer, + drawerRef, + drawerHeightPx, + drawerWidthPx, + drawerX, + drawerY, + isExpanded, + isMinimized, + isFullscreen, + handleDragStop, + handleResizeStop, + handleToggleExpand, + handleToggleFullscreen, + handleMinimizeDrawer, + isConfirmingEndConversation, + handleRequestEndConversation, + handleEndConversation, + handleCancelEndConversation, + ] + ); + + return {children}; +}; diff --git a/src/components/ChatBot/hooks/useChatDrawer.test.tsx b/src/components/ChatBot/hooks/useChatDrawer.test.tsx new file mode 100644 index 000000000..9267f0a66 --- /dev/null +++ b/src/components/ChatBot/hooks/useChatDrawer.test.tsx @@ -0,0 +1,499 @@ +import { act, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { DraggableData } from "react-draggable"; + +import * as chatUtils from "../utils/chatUtils"; + +import type { useChatDrawerResult } from "./useChatDrawer"; +import { useChatDrawer } from "./useChatDrawer"; + +vi.mock("../utils/chatUtils", () => ({ + getViewportHeightPx: vi.fn(), +})); + +const mockGetViewportHeightPx = vi.mocked(chatUtils.getViewportHeightPx); + +const mockDragData = (x: number, y: number): DraggableData => ({ + x, + y, + node: null, + deltaX: 0, + deltaY: 0, + lastX: 0, + lastY: 0, +}); + +type TestParentProps = { + onRender?: (hook: useChatDrawerResult) => void; +}; + +const TestParent = ({ onRender }: TestParentProps) => { + const hook = useChatDrawer(); + + if (onRender) { + onRender(hook); + } + + return ( +
+
{hook.isOpen.toString()}
+
{hook.isExpanded.toString()}
+
{String(hook.drawerHeightPx)}
+
{String(hook.drawerWidthPx)}
+
{String(hook.drawerX)}
+
{String(hook.drawerY)}
+ + + +
+ ); +}; + +describe("useChatDrawer", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetViewportHeightPx.mockReturnValue(800); + + Object.defineProperty(document.documentElement, "clientWidth", { + value: 1024, + configurable: true, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 768, + configurable: true, + }); + }); + + describe("Initial State", () => { + it("should initialize with drawer closed", () => { + const { getByTestId } = render(); + + expect(getByTestId("is-open").textContent).toBe("false"); + }); + + it("should have default dimensions", () => { + const { getByTestId } = render(); + + expect(getByTestId("drawer-height-px").textContent).toBe("368"); + expect(getByTestId("drawer-width-px").textContent).toBe("400"); + }); + + it("should have zero position when closed", () => { + const { getByTestId } = render(); + + expect(getByTestId("drawer-x").textContent).toBe("0"); + expect(getByTestId("drawer-y").textContent).toBe("0"); + }); + + it("should not be expanded", () => { + const { getByTestId } = render(); + + expect(getByTestId("is-expanded").textContent).toBe("false"); + }); + + it("should provide a drawerRef", () => { + let hookResult: useChatDrawerResult | null = null; + + render( + { + hookResult = hook; + }} + /> + ); + + expect(hookResult).not.toBeNull(); + expect(hookResult.drawerRef).toBeDefined(); + }); + }); + + describe("openDrawer", () => { + it("should open the drawer", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("open-drawer")); + + expect(getByTestId("is-open").textContent).toBe("true"); + }); + + it("should set default dimensions on open", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("open-drawer")); + + expect(getByTestId("drawer-height-px").textContent).toBe("368"); + expect(getByTestId("drawer-width-px").textContent).toBe("400"); + }); + + it("should compute correct bottom-right aligned position", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("open-drawer")); + + // viewportHeight=800, viewportWidth=1024, drawerHeight=368, drawerWidth=400 + // floatingButtonCenterFromBottom = 800 * 0.35 = 280 + // bottomOffset = max(0, 280 - 368/2) = max(0, 96) = 96 + // x = 1024 - 400 = 624 + // y = 800 - 368 - 96 = 336 + expect(getByTestId("drawer-x").textContent).toBe("624"); + expect(getByTestId("drawer-y").textContent).toBe("336"); + }); + + it("should not change state if already open", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("open-drawer")); + const xAfterFirst = getByTestId("drawer-x").textContent; + const yAfterFirst = getByTestId("drawer-y").textContent; + + userEvent.click(getByTestId("open-drawer")); + + expect(getByTestId("is-open").textContent).toBe("true"); + expect(getByTestId("drawer-x").textContent).toBe(xAfterFirst); + expect(getByTestId("drawer-y").textContent).toBe(yAfterFirst); + }); + }); + + describe("closeDrawer", () => { + it("should close the drawer", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("open-drawer")); + userEvent.click(getByTestId("close-drawer")); + + expect(getByTestId("is-open").textContent).toBe("false"); + }); + + it("should reset dimensions and position on close", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("open-drawer")); + userEvent.click(getByTestId("close-drawer")); + + expect(getByTestId("drawer-height-px").textContent).toBe("368"); + expect(getByTestId("drawer-width-px").textContent).toBe("400"); + expect(getByTestId("drawer-x").textContent).toBe("0"); + expect(getByTestId("drawer-y").textContent).toBe("0"); + }); + + it("should reset expanded state on close", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("open-drawer")); + userEvent.click(getByTestId("toggle-expand")); + userEvent.click(getByTestId("close-drawer")); + + expect(getByTestId("is-expanded").textContent).toBe("false"); + }); + + it("should not change state if already closed", async () => { + const { getByTestId } = render(); + + const xBefore = getByTestId("drawer-x").textContent; + const yBefore = getByTestId("drawer-y").textContent; + + await act(async () => { + getByTestId("close-drawer").click(); + }); + + expect(getByTestId("is-open").textContent).toBe("false"); + expect(getByTestId("drawer-x").textContent).toBe(xBefore); + expect(getByTestId("drawer-y").textContent).toBe(yBefore); + }); + }); + + describe("toggleExpand", () => { + it("should expand the drawer", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("open-drawer")); + userEvent.click(getByTestId("toggle-expand")); + + expect(getByTestId("is-expanded").textContent).toBe("true"); + }); + + it("should collapse back to default position", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("open-drawer")); + userEvent.click(getByTestId("toggle-expand")); + userEvent.click(getByTestId("toggle-expand")); + + expect(getByTestId("is-expanded").textContent).toBe("false"); + expect(getByTestId("drawer-height-px").textContent).toBe("368"); + expect(getByTestId("drawer-width-px").textContent).toBe("400"); + expect(getByTestId("drawer-x").textContent).toBe("624"); + expect(getByTestId("drawer-y").textContent).toBe("336"); + }); + + it("should not change state if drawer is closed", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("toggle-expand")); + + expect(getByTestId("is-expanded").textContent).toBe("false"); + }); + + it("should set position to right edge when expanding", async () => { + const { getByTestId } = render(); + + userEvent.click(getByTestId("open-drawer")); + userEvent.click(getByTestId("toggle-expand")); + + // viewportWidth=1024, expandedWidth=417 → x = 1024 - 417 = 607 + expect(getByTestId("drawer-x").textContent).toBe("607"); + expect(getByTestId("drawer-y").textContent).toBe("0"); + }); + }); + + describe("handleDragStop", () => { + it("should update position on drag stop", () => { + let hookResult: useChatDrawerResult | null = null; + + const { getByTestId } = render( + { + hookResult = hook; + }} + /> + ); + + act(() => { + hookResult.openDrawer(); + }); + + act(() => { + hookResult.handleDragStop(null, mockDragData(100, 200)); + }); + + expect(getByTestId("drawer-x").textContent).toBe("100"); + expect(getByTestId("drawer-y").textContent).toBe("200"); + }); + }); + + describe("handleResizeStop", () => { + it("should update position and dimensions on resize stop", () => { + let hookResult: useChatDrawerResult | null = null; + + const { getByTestId } = render( + { + hookResult = hook; + }} + /> + ); + + act(() => { + hookResult.openDrawer(); + }); + + const mockRef = { style: { width: "500px", height: "600px" } } as HTMLElement; + + act(() => { + hookResult.handleResizeStop(null, null, mockRef, null, { x: 50, y: 100 }); + }); + + expect(getByTestId("drawer-x").textContent).toBe("50"); + expect(getByTestId("drawer-y").textContent).toBe("100"); + expect(getByTestId("drawer-width-px").textContent).toBe("500"); + expect(getByTestId("drawer-height-px").textContent).toBe("600"); + }); + }); + + describe("viewport resize", () => { + it("should clamp position when viewport shrinks", () => { + let hookResult: useChatDrawerResult | null = null; + const { getByTestId } = render( + { + hookResult = hook; + }} + /> + ); + + act(() => { + hookResult.openDrawer(); + }); + act(() => { + hookResult.handleDragStop(null, mockDragData(800, 500)); + }); + + Object.defineProperty(document.documentElement, "clientWidth", { + value: 600, + configurable: true, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 400, + configurable: true, + }); + act(() => { + window.dispatchEvent(new Event("resize")); + }); + + // maxX = 600 - 400 = 200, maxY = 400 - 368 = 32 + expect(getByTestId("drawer-x").textContent).toBe("200"); + expect(getByTestId("drawer-y").textContent).toBe("32"); + }); + + it("should update expanded position on viewport resize", () => { + let hookResult: useChatDrawerResult | null = null; + const { getByTestId } = render( + { + hookResult = hook; + }} + /> + ); + + act(() => { + hookResult.openDrawer(); + }); + act(() => { + hookResult.toggleExpand(); + }); + + Object.defineProperty(document.documentElement, "clientWidth", { + value: 800, + configurable: true, + }); + act(() => { + window.dispatchEvent(new Event("resize")); + }); + + // x = 800 - 417 = 383 + expect(getByTestId("drawer-x").textContent).toBe("383"); + expect(getByTestId("drawer-y").textContent).toBe("0"); + }); + + it("should not adjust position when already within bounds", () => { + let hookResult: useChatDrawerResult | null = null; + const { getByTestId } = render( + { + hookResult = hook; + }} + /> + ); + + act(() => { + hookResult.openDrawer(); + }); + act(() => { + hookResult.handleDragStop(null, mockDragData(100, 100)); + }); + + act(() => { + window.dispatchEvent(new Event("resize")); + }); + + expect(getByTestId("drawer-x").textContent).toBe("100"); + expect(getByTestId("drawer-y").textContent).toBe("100"); + }); + + it("should remove resize listener when drawer is closed", () => { + let hookResult: useChatDrawerResult | null = null; + const { getByTestId } = render( + { + hookResult = hook; + }} + /> + ); + + act(() => { + hookResult.openDrawer(); + }); + act(() => { + hookResult.handleDragStop(null, mockDragData(800, 500)); + }); + act(() => { + hookResult.closeDrawer(); + }); + + Object.defineProperty(document.documentElement, "clientWidth", { + value: 600, + configurable: true, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 400, + configurable: true, + }); + act(() => { + window.dispatchEvent(new Event("resize")); + }); + + expect(getByTestId("drawer-x").textContent).toBe("0"); + expect(getByTestId("drawer-y").textContent).toBe("0"); + }); + + it("should clamp width and height when viewport shrinks below drawer size", () => { + let hookResult: useChatDrawerResult | null = null; + const { getByTestId } = render( + { + hookResult = hook; + }} + /> + ); + + act(() => { + hookResult.openDrawer(); + }); + act(() => { + hookResult.handleDragStop(null, mockDragData(0, 0)); + }); + + Object.defineProperty(document.documentElement, "clientWidth", { + value: 350, + configurable: true, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 300, + configurable: true, + }); + act(() => { + window.dispatchEvent(new Event("resize")); + }); + + expect(getByTestId("drawer-width-px").textContent).toBe("350"); + expect(getByTestId("drawer-height-px").textContent).toBe("300"); + expect(getByTestId("drawer-x").textContent).toBe("0"); + expect(getByTestId("drawer-y").textContent).toBe("0"); + }); + }); + + describe("function reference stability", () => { + it("should maintain stable function references across renders", () => { + const references: useChatDrawerResult[] = []; + + const { rerender } = render( + { + references.push(hook); + }} + /> + ); + + rerender( + { + references.push(hook); + }} + /> + ); + + expect(references).toHaveLength(2); + expect(references[0].openDrawer).toBe(references[1].openDrawer); + expect(references[0].closeDrawer).toBe(references[1].closeDrawer); + expect(references[0].toggleExpand).toBe(references[1].toggleExpand); + expect(references[0].handleDragStop).toBe(references[1].handleDragStop); + expect(references[0].handleResizeStop).toBe(references[1].handleResizeStop); + }); + }); +}); diff --git a/src/components/ChatBot/hooks/useChatDrawer.ts b/src/components/ChatBot/hooks/useChatDrawer.ts new file mode 100644 index 000000000..0d87cade6 --- /dev/null +++ b/src/components/ChatBot/hooks/useChatDrawer.ts @@ -0,0 +1,258 @@ +import React, { useCallback, useEffect, useReducer, useRef } from "react"; +import type { RndDragCallback, RndResizeCallback } from "react-rnd"; + +import chatConfig from "../config/chatConfig"; +import { getViewportHeightPx } from "../utils/chatUtils"; + +type DrawerState = { + isOpen: boolean; + isExpanded: boolean; + heightPx: number; + widthPx: number; + x: number; + y: number; +}; + +type DrawerAction = + | { type: "opened"; viewportWidth: number; viewportHeight: number } + | { type: "closed" } + | { type: "position_changed"; x: number; y: number } + | { type: "dimensions_changed"; x: number; y: number; widthPx: number; heightPx: number } + | { type: "expand_toggled"; viewportWidth: number; viewportHeight: number } + | { type: "viewport_resized"; viewportWidth: number; viewportHeight: number }; + +/** + * Computes the default collapsed position for the chat drawer. + */ +const computeCollapsedPosition = ( + viewportWidth: number, + viewportHeight: number +): { x: number; y: number } => { + const FLOATING_BUTTON_POSITION_RATIO = 0.35; + + const drawerHeight = chatConfig.height.collapsed; + const drawerWidth = chatConfig.width.default; + const floatingButtonCenterFromBottom = viewportHeight * FLOATING_BUTTON_POSITION_RATIO; + const bottomOffset = Math.max(0, floatingButtonCenterFromBottom - drawerHeight / 2); + + return { + x: viewportWidth - drawerWidth, + y: viewportHeight - drawerHeight - bottomOffset, + }; +}; + +/** + * Reducer function to manage the state of the chat drawer. + */ +const reducer = (state: DrawerState, action: DrawerAction): DrawerState => { + switch (action.type) { + case "opened": { + if (state.isOpen) { + return state; + } + + const { x, y } = computeCollapsedPosition(action.viewportWidth, action.viewportHeight); + + return { + isOpen: true, + isExpanded: false, + heightPx: chatConfig.height.collapsed, + widthPx: chatConfig.width.default, + x, + y, + }; + } + case "closed": { + if (!state.isOpen) { + return state; + } + + return { + ...state, + isOpen: false, + isExpanded: false, + heightPx: chatConfig.height.collapsed, + widthPx: chatConfig.width.default, + x: 0, + y: 0, + }; + } + case "position_changed": { + const maxX = Math.max(0, document.documentElement.clientWidth - state.widthPx); + const maxY = Math.max(0, document.documentElement.clientHeight - state.heightPx); + + return { + ...state, + x: Math.max(0, Math.min(action.x, maxX)), + y: Math.max(0, Math.min(action.y, maxY)), + }; + } + case "dimensions_changed": { + return { + ...state, + x: action.x, + y: action.y, + widthPx: action.widthPx, + heightPx: action.heightPx, + }; + } + case "expand_toggled": { + if (!state.isOpen) { + return state; + } + + if (state.isExpanded) { + const { x, y } = computeCollapsedPosition(action.viewportWidth, action.viewportHeight); + + return { + ...state, + isExpanded: false, + heightPx: chatConfig.height.collapsed, + widthPx: chatConfig.width.default, + x, + y, + }; + } + + return { + ...state, + isExpanded: true, + x: action.viewportWidth - chatConfig.width.expanded, + y: 0, + }; + } + case "viewport_resized": { + if (!state.isOpen) { + return state; + } + + if (state.isExpanded) { + return { + ...state, + x: action.viewportWidth - chatConfig.width.expanded, + y: 0, + }; + } + + const newWidth = Math.min(state.widthPx, action.viewportWidth); + const newHeight = Math.min(state.heightPx, action.viewportHeight); + const maxX = Math.max(0, action.viewportWidth - newWidth); + const maxY = Math.max(0, action.viewportHeight - newHeight); + const newX = Math.max(0, Math.min(state.x, maxX)); + const newY = Math.max(0, Math.min(state.y, maxY)); + + if ( + newX === state.x && + newY === state.y && + newWidth === state.widthPx && + newHeight === state.heightPx + ) { + return state; + } + + return { ...state, x: newX, y: newY, widthPx: newWidth, heightPx: newHeight }; + } + default: { + return state; + } + } +}; + +export type useChatDrawerResult = { + drawerRef: React.RefObject; + isOpen: boolean; + isExpanded: boolean; + drawerHeightPx: number; + drawerWidthPx: number; + drawerX: number; + drawerY: number; + openDrawer: () => void; + closeDrawer: () => void; + handleDragStop: RndDragCallback; + handleResizeStop: RndResizeCallback; + toggleExpand: () => void; +}; + +/** + * Custom hook to manage the state and behavior of the chat drawer. + * + * @returns An object containing the state and actions for the chat drawer. + */ +export const useChatDrawer = (): useChatDrawerResult => { + const drawerRef = useRef(null); + + const [state, dispatch] = useReducer(reducer, { + isOpen: false, + isExpanded: false, + heightPx: chatConfig.height.collapsed, + widthPx: chatConfig.width.default, + x: 0, + y: 0, + }); + + const openDrawer = useCallback((): void => { + const viewportHeight = getViewportHeightPx(chatConfig.height.collapsed); + const viewportWidth = document.documentElement.clientWidth; + + dispatch({ type: "opened", viewportWidth, viewportHeight }); + }, []); + + const closeDrawer = useCallback((): void => { + dispatch({ type: "closed" }); + }, []); + + const toggleExpand = useCallback((): void => { + const viewportHeight = getViewportHeightPx(chatConfig.height.collapsed); + const viewportWidth = document.documentElement.clientWidth; + + dispatch({ type: "expand_toggled", viewportWidth, viewportHeight }); + }, []); + + const handleDragStop: RndDragCallback = useCallback((_e, data) => { + dispatch({ type: "position_changed", x: data.x, y: data.y }); + }, []); + + const handleResizeStop: RndResizeCallback = useCallback( + (_e, _direction, ref, _delta, position) => { + dispatch({ + type: "dimensions_changed", + x: position.x, + y: position.y, + widthPx: Math.round(parseFloat(ref.style.width)), + heightPx: Math.round(parseFloat(ref.style.height)), + }); + }, + [] + ); + + useEffect(() => { + if (!state.isOpen) { + return undefined; + } + + const handleResize = () => { + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + + dispatch({ type: "viewport_resized", viewportWidth, viewportHeight }); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [state.isOpen]); + + return { + drawerRef, + isOpen: state.isOpen, + isExpanded: state.isExpanded, + drawerHeightPx: state.heightPx, + drawerWidthPx: state.widthPx, + drawerX: state.x, + drawerY: state.y, + openDrawer, + closeDrawer, + handleDragStop, + handleResizeStop, + toggleExpand, + }; +}; diff --git a/src/components/ChatBot/index.test.tsx b/src/components/ChatBot/index.test.tsx new file mode 100644 index 000000000..fbfe709d7 --- /dev/null +++ b/src/components/ChatBot/index.test.tsx @@ -0,0 +1,29 @@ +import { axe } from "vitest-axe"; + +import { render } from "@/test-utils"; + +import ChatBot from "./index"; + +vi.mock("./Controller", () => ({ + default: () =>
Chat Controller
, +})); + +describe("Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + it("should render without crashing", () => { + expect(() => render()).not.toThrow(); + }); + + it("should export ChatController component", () => { + const { getByTestId } = render(); + + expect(getByTestId("chat-controller")).toBeInTheDocument(); + }); +}); diff --git a/src/components/ChatBot/index.tsx b/src/components/ChatBot/index.tsx new file mode 100644 index 000000000..3f6006fcb --- /dev/null +++ b/src/components/ChatBot/index.tsx @@ -0,0 +1,3 @@ +import ChatController from "./Controller"; + +export default ChatController; diff --git a/src/components/ChatBot/panel/BotTypingIndicator.test.tsx b/src/components/ChatBot/panel/BotTypingIndicator.test.tsx new file mode 100644 index 000000000..d54f88e00 --- /dev/null +++ b/src/components/ChatBot/panel/BotTypingIndicator.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { axe } from "vitest-axe"; + +import { render } from "@/test-utils"; + +import BotTypingIndicator from "./BotTypingIndicator"; + +describe("Accessibility", () => { + it("should have no accessibility violations", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + it("should render without crashing", () => { + expect(() => render()).not.toThrow(); + }); + + it("should render a status container with correct aria-label", () => { + const { container } = render(); + + const statusContainer = container.querySelector('[aria-label="Assistant is typing"]'); + expect(statusContainer).toBeInTheDocument(); + expect(statusContainer).toHaveAttribute("role", "status"); + }); + + it("should render the ChatBotLogo", () => { + const { container } = render(); + + const logo = container.querySelector("button"); + expect(logo).toBeInTheDocument(); + }); + + it("should render a loading spinner", () => { + const { container } = render(); + + const spinner = container.querySelector('[role="progressbar"]'); + expect(spinner).toBeInTheDocument(); + }); +}); diff --git a/src/components/ChatBot/panel/BotTypingIndicator.tsx b/src/components/ChatBot/panel/BotTypingIndicator.tsx new file mode 100644 index 000000000..cd066fe0f --- /dev/null +++ b/src/components/ChatBot/panel/BotTypingIndicator.tsx @@ -0,0 +1,45 @@ +import { CircularProgress, Stack, styled } from "@mui/material"; +import React from "react"; + +import ChatBotLogo from "../components/ChatBotLogo"; + +const StyledContainer = styled(Stack)({ + alignItems: "center", + marginBottom: "12px", +}); + +const StyledLogoWrapper = styled("div")({ + marginRight: "2px", + display: "flex", + alignItems: "center", + justifyContent: "center", + "& > button": { + transform: "scale(0.6667)", + transformOrigin: "center", + }, +}); + +const StyledProgress = styled(CircularProgress)({ + color: "#005EA2", +}); + +export type Props = { + /** + * The name of the bot sender to display. Defaults to the configured support bot name. + */ + senderName?: string; +}; + +/** + * Displays an animated typing indicator with the ChatBot logo and a loading spinner. + */ +const BotTypingIndicator = (): JSX.Element => ( + + + + + + +); + +export default React.memo(BotTypingIndicator); diff --git a/src/components/ChatBot/panel/ChatComposer.test.tsx b/src/components/ChatBot/panel/ChatComposer.test.tsx new file mode 100644 index 000000000..98b19af40 --- /dev/null +++ b/src/components/ChatBot/panel/ChatComposer.test.tsx @@ -0,0 +1,166 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { axe } from "vitest-axe"; + +import { fireEvent, render, waitFor } from "@/test-utils"; + +import * as ChatDrawerContextModule from "../context/ChatDrawerContext"; + +import ChatComposer from "./ChatComposer"; + +vi.mock("../context/ChatDrawerContext", () => ({ + useChatDrawerContext: vi.fn(), +})); + +const mockUseChatDrawerContext = vi.mocked(ChatDrawerContextModule.useChatDrawerContext); + +const defaultContext = { + isFullscreen: false, + drawerRef: { current: null }, + heightPx: 600, + widthPx: 384, + x: 0, + y: 0, + isExpanded: true, + isMinimized: false, + isOpen: true, + onDragStop: vi.fn(), + onResizeStop: vi.fn(), + onToggleExpand: vi.fn(), + onToggleFullscreen: vi.fn(), + onMinimize: vi.fn(), + openDrawer: vi.fn(), + isConfirmingEndConversation: false, + onRequestEndConversation: vi.fn(), + onConfirmEndConversation: vi.fn(), + onCancelEndConversation: vi.fn(), +}; + +const defaultProps = { + value: "", + onChange: vi.fn(), + onSend: vi.fn(), + onKeyDown: vi.fn(), + isSendDisabled: false, +}; + +describe("Accessibility", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatDrawerContext.mockReturnValue(defaultContext); + }); + + it("should have no accessibility violations", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatDrawerContext.mockReturnValue(defaultContext); + }); + + it("should render without crashing", () => { + expect(() => render()).not.toThrow(); + }); + + it("should render input field with placeholder", () => { + const { getByPlaceholderText } = render(); + + expect(getByPlaceholderText("Type your message...")).toBeInTheDocument(); + }); + + it("should render send button", () => { + const { getByRole } = render(); + + expect(getByRole("button", { name: /send message/i })).toBeInTheDocument(); + }); + + it("should display the provided value in the input", () => { + const { getByDisplayValue } = render(); + + expect(getByDisplayValue("Test message")).toBeInTheDocument(); + }); + + it("should call onChange when user types in the input", async () => { + const onChange = vi.fn(); + const { getByPlaceholderText } = render(); + + const input = getByPlaceholderText("Type your message..."); + userEvent.type(input, "Hello"); + + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith("H"); + }); + }); + + it("should call onSend when send button is clicked", async () => { + const onSend = vi.fn(); + const { getByRole } = render(); + + const sendButton = getByRole("button", { name: /send message/i }); + userEvent.click(sendButton); + + expect(onSend).toHaveBeenCalledTimes(1); + }); + + it("should call onKeyDown when user presses a key", async () => { + const onKeyDown = vi.fn(); + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText("Type your message..."); + userEvent.type(input, "{Enter}"); + + expect(onKeyDown).toHaveBeenCalled(); + }); + + it("should disable send button when isSendDisabled is true", () => { + const { getByRole } = render(); + + const sendButton = getByRole("button", { name: /send message/i }); + expect(sendButton).toBeDisabled(); + }); + + it("should enable send button when isSendDisabled is false", () => { + const { getByRole } = render(); + + const sendButton = getByRole("button", { name: /send message/i }); + expect(sendButton).toBeEnabled(); + }); + + it("should not call onSend when button is disabled", () => { + const onSend = vi.fn(); + const { getByRole } = render(); + + const sendButton = getByRole("button", { name: /send message/i }); + fireEvent.click(sendButton); + + expect(onSend).not.toHaveBeenCalled(); + }); + + it("should update displayed value when value prop changes", () => { + const { rerender, getByDisplayValue, queryByDisplayValue } = render( + + ); + + expect(getByDisplayValue("Initial")).toBeInTheDocument(); + + rerender(); + + expect(getByDisplayValue("Updated")).toBeInTheDocument(); + expect(queryByDisplayValue("Initial")).not.toBeInTheDocument(); + }); + + it("should handle empty value prop", () => { + const { getByPlaceholderText } = render(); + + const input = getByPlaceholderText("Type your message..."); + expect(input).toHaveValue(""); + }); +}); diff --git a/src/components/ChatBot/panel/ChatComposer.tsx b/src/components/ChatBot/panel/ChatComposer.tsx new file mode 100644 index 000000000..c3af5c421 --- /dev/null +++ b/src/components/ChatBot/panel/ChatComposer.tsx @@ -0,0 +1,154 @@ +import { East } from "@mui/icons-material"; +import { Box, IconButton, InputAdornment, styled } from "@mui/material"; +import React, { useCallback } from "react"; + +import StyledOutlinedInput from "@/components/StyledFormComponents/StyledOutlinedInput"; + +import chatConfig from "../config/chatConfig"; +import { useChatDrawerContext } from "../context/ChatDrawerContext"; + +const StyledBox = styled(Box, { + shouldForwardProp: (prop) => prop !== "isFullscreen", +})<{ isFullscreen?: boolean }>(({ isFullscreen }) => ({ + position: "relative", + zIndex: 2, + padding: "15px 14px", + ...(isFullscreen && { + position: "sticky", + bottom: 0, + background: "linear-gradient(180deg, rgba(201, 229, 248, 0) 0%, #C9E5F8 20%)", + marginTop: "auto", + }), +})); + +const StyledTextField = styled(StyledOutlinedInput)({ + "&.MuiOutlinedInput-root": { + borderRadius: "8px", + border: "0 !important", + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline, &:hover .MuiOutlinedInput-notchedOutline, & .MuiOutlinedInput-notchedOutline": + { + border: "1px solid #1545B5", + }, + "& .MuiInputBase-input": { + padding: "10px 15px", + fontFamily: "Inter", + fontStyle: "normal", + fontWeight: 600, + fontSize: "15px", + lineHeight: "22px", + color: "#3D4143", + }, + "& .MuiInputBase-input::placeholder": { + fontFamily: "Inter", + fontStyle: "normal", + fontWeight: 600, + fontSize: "15px", + lineHeight: "22px", + color: "#727272", + opacity: 1, + }, +}); + +const StyledSendButton = styled(IconButton)({ + padding: 0, + "&:hover": { + backgroundColor: "transparent", + }, + "&.Mui-disabled": { + opacity: 0.4, + }, +}); + +const SendIconCircle = styled(Box)({ + display: "flex", + alignItems: "center", + justifyContent: "center", + margin: "0 auto", + width: "28px", + height: "28px", + boxShadow: "inset 0px 4px 4px rgba(0, 0, 0, 0.25)", + filter: "drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.08))", + background: + "linear-gradient(326.08deg, #000000 5.77%, #000000 17.25%, #191919 62.29%, #FFFFFF 81.7%)", + border: "0.75px solid #FBD9D9", + borderRadius: "35px", +}); + +const StyledArrowIcon = styled(East)({ + color: "#FFFFFF", + fontSize: "20px", +}); + +export type Props = { + /** + * The current value of the input field. + */ + value: string; + /** + * Callback fired when the input value changes. + */ + onChange: (value: string) => void; + /** + * Callback fired when the send button is clicked. + */ + onSend: () => void; + /** + * Callback fired on keyboard events in the input field. + */ + onKeyDown: React.KeyboardEventHandler; + /** + * Indicates whether the send button should be disabled. + */ + isSendDisabled: boolean; +}; + +/** + * Input field and send button for composing and sending chat messages. + */ +const ChatComposer = ({ + value, + onChange, + onSend, + onKeyDown, + isSendDisabled, +}: Props): JSX.Element => { + const { isFullscreen } = useChatDrawerContext(); + /** + * Handles input value changes and propagates them to the parent. + */ + const handleInputChange = useCallback( + (event: React.ChangeEvent): void => { + const newValue = event.target.value; + if (newValue.length <= chatConfig.maxInputTextLength) { + onChange(newValue); + } + }, + [onChange] + ); + + return ( + + + + + + + + + } + /> + + ); +}; + +export default React.memo(ChatComposer); diff --git a/src/components/ChatBot/panel/ChatMessageItem.test.tsx b/src/components/ChatBot/panel/ChatMessageItem.test.tsx new file mode 100644 index 000000000..c2f29bf32 --- /dev/null +++ b/src/components/ChatBot/panel/ChatMessageItem.test.tsx @@ -0,0 +1,775 @@ +import { act, fireEvent, waitFor } from "@testing-library/react"; +import { axe } from "vitest-axe"; + +import { render } from "@/test-utils"; + +import * as ChatDrawerContextModule from "../context/ChatDrawerContext"; + +import ChatMessageItem, { formatMessageTime } from "./ChatMessageItem"; + +vi.mock("../context/ChatDrawerContext", () => ({ + useChatDrawerContext: vi.fn(), +})); + +const mockUseChatDrawerContext = vi.mocked(ChatDrawerContextModule.useChatDrawerContext); + +const defaultContext = { + isFullscreen: false, + drawerRef: { current: null }, + heightPx: 600, + widthPx: 384, + x: 0, + y: 0, + isExpanded: true, + isMinimized: false, + isOpen: true, + onDragStop: vi.fn(), + onResizeStop: vi.fn(), + onToggleExpand: vi.fn(), + onToggleFullscreen: vi.fn(), + onMinimize: vi.fn(), + openDrawer: vi.fn(), + isConfirmingEndConversation: false, + onRequestEndConversation: vi.fn(), + onConfirmEndConversation: vi.fn(), + onCancelEndConversation: vi.fn(), +}; + +const createMockMessage = (overrides?: Partial): ChatMessage => ({ + id: "test-message-1", + text: "Test message", + sender: "bot", + timestamp: new Date("2024-01-15T14:30:00"), + senderName: "Support Bot", + variant: "default", + ...overrides, +}); + +describe("Accessibility", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatDrawerContext.mockReturnValue(defaultContext); + }); + + it("should have no accessibility violations with bot message", async () => { + const message = createMockMessage(); + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations with user message", async () => { + const message = createMockMessage({ sender: "user", senderName: "You" }); + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations with error variant", async () => { + const message = createMockMessage({ variant: "error" }); + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatDrawerContext.mockReturnValue(defaultContext); + }); + + it("should render without crashing", () => { + const message = createMockMessage(); + expect(() => render()).not.toThrow(); + }); + + it("should render bot message text", () => { + const message = createMockMessage({ text: "Hello from bot" }); + const { getByText } = render(); + + expect(getByText("Hello from bot")).toBeInTheDocument(); + }); + + it("should render user message text", () => { + const message = createMockMessage({ sender: "user", text: "Hello from user" }); + const { getByText } = render(); + + expect(getByText("Hello from user")).toBeInTheDocument(); + }); + + it("should display formatted timestamp", () => { + const timestamp = new Date("2024-01-15T14:30:00"); + const message = createMockMessage({ timestamp }); + const { getByText } = render(); + + const formattedTime = formatMessageTime(timestamp); + expect(getByText(formattedTime)).toBeInTheDocument(); + }); + + it("should apply correct data attribute for bot messages", () => { + const message = createMockMessage({ sender: "bot" }); + const { container } = render(); + + const messageRow = container.querySelector('[data-is-user="false"]'); + expect(messageRow).toBeInTheDocument(); + }); + + it("should apply correct data attribute for user messages", () => { + const message = createMockMessage({ sender: "user" }); + const { container } = render(); + + const messageRow = container.querySelector('[data-is-user="true"]'); + expect(messageRow).toBeInTheDocument(); + }); + + it("should render with default variant when not specified", () => { + const message = createMockMessage({ variant: undefined }); + const { getByText } = render(); + + expect(getByText("Test message")).toBeInTheDocument(); + }); + + it("should render with info variant", () => { + const message = createMockMessage({ variant: "info", text: "Info message" }); + const { getByText } = render(); + + expect(getByText("Info message")).toBeInTheDocument(); + }); + + it("should render with error variant", () => { + const message = createMockMessage({ variant: "error", text: "Error message" }); + const { getByText } = render(); + + expect(getByText("Error message")).toBeInTheDocument(); + }); + + it("should handle multi-line text", () => { + const message = createMockMessage({ text: "Line 1\nLine 2\nLine 3" }); + const { container } = render(); + + const allDivs = container.querySelectorAll('div[data-is-user="false"]'); + const messageBubble = allDivs[allDivs.length - 1]; + const paragraph = messageBubble?.querySelector("p"); + expect(paragraph?.textContent).toContain("Line 1"); + expect(paragraph?.textContent).toContain("Line 2"); + expect(paragraph?.textContent).toContain("Line 3"); + }); + + it("should handle empty text", () => { + const message = createMockMessage({ text: "" }); + const { container } = render(); + + expect(container.querySelector('[data-is-user="false"]')).toBeInTheDocument(); + }); + + it("should return null when message is null", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("should return null when message is undefined", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("should update when message prop changes", () => { + const message1 = createMockMessage({ text: "First message" }); + const message2 = createMockMessage({ text: "Second message" }); + const { rerender, getByText, queryByText } = render(); + + expect(getByText("First message")).toBeInTheDocument(); + + rerender(); + + expect(getByText("Second message")).toBeInTheDocument(); + expect(queryByText("First message")).not.toBeInTheDocument(); + }); + + it("should format time correctly for AM hours", () => { + const timestamp = new Date("2024-01-15T09:15:00"); + const formatted = formatMessageTime(timestamp); + + expect(formatted).toMatch(/09:15 AM/); + }); + + it("should format time correctly for PM hours", () => { + const timestamp = new Date("2024-01-15T15:45:00"); + const formatted = formatMessageTime(timestamp); + + expect(formatted).toMatch(/03:45 PM/); + }); + + it("should handle midnight time", () => { + const timestamp = new Date("2024-01-15T00:00:00"); + const formatted = formatMessageTime(timestamp); + + expect(formatted).toMatch(/12:00 AM/); + }); + + it("should handle noon time", () => { + const timestamp = new Date("2024-01-15T12:00:00"); + const formatted = formatMessageTime(timestamp); + + expect(formatted).toMatch(/12:00 PM/); + }); +}); + +describe("Markdown Formatting", () => { + it("should render markdown bold text for bot messages", () => { + const message = createMockMessage({ text: "This is **bold** text", sender: "bot" }); + const { container } = render(); + + const strongElement = container.querySelector("strong"); + expect(strongElement).toBeInTheDocument(); + expect(strongElement?.textContent).toBe("bold"); + }); + + it("should render markdown italic text for bot messages", () => { + const message = createMockMessage({ text: "This is *italic* text", sender: "bot" }); + const { container } = render(); + + const emElement = container.querySelector("em"); + expect(emElement).toBeInTheDocument(); + expect(emElement?.textContent).toBe("italic"); + }); + + it("should render markdown links for bot messages", () => { + const message = createMockMessage({ + text: "Check [this link](https://example.com)", + sender: "bot", + }); + const { container } = render(); + + const linkElement = container.querySelector("a"); + expect(linkElement).toBeInTheDocument(); + expect(linkElement?.textContent).toBe("this link"); + expect(linkElement?.getAttribute("href")).toBe("https://example.com"); + }); + + it("should render markdown lists for bot messages", () => { + const message = createMockMessage({ + text: "Items:\n- Item 1\n- Item 2\n- Item 3", + sender: "bot", + }); + const { container } = render(); + + const listItems = container.querySelectorAll("li"); + expect(listItems).toHaveLength(3); + expect(listItems[0].textContent).toBe("Item 1"); + expect(listItems[1].textContent).toBe("Item 2"); + expect(listItems[2].textContent).toBe("Item 3"); + }); + + it("should render markdown code blocks for bot messages", () => { + const message = createMockMessage({ + text: "Here is code: `const x = 5;`", + sender: "bot", + }); + const { container } = render(); + + const codeElement = container.querySelector("code"); + expect(codeElement).toBeInTheDocument(); + expect(codeElement?.textContent).toBe("const x = 5;"); + }); + + it("should render markdown headings for bot messages", () => { + const message = createMockMessage({ text: "## Heading 2", sender: "bot" }); + const { container } = render(); + + const headingElement = container.querySelector("h2"); + expect(headingElement).toBeInTheDocument(); + expect(headingElement?.textContent).toBe("Heading 2"); + }); + + it("should NOT render markdown for user messages", () => { + const message = createMockMessage({ text: "This is **bold** text", sender: "user" }); + const { container, getByText } = render(); + + const strongElement = container.querySelector("strong"); + expect(strongElement).not.toBeInTheDocument(); + + expect(getByText("This is **bold** text")).toBeInTheDocument(); + }); + + it("should NOT render markdown links for user messages", () => { + const message = createMockMessage({ + text: "Check [this link](https://example.com)", + sender: "user", + }); + const { container, getByText } = render(); + + const linkElement = container.querySelector("a"); + expect(linkElement).not.toBeInTheDocument(); + + expect(getByText("Check [this link](https://example.com)")).toBeInTheDocument(); + }); + + it("should render multiple markdown elements together for bot messages", () => { + const message = createMockMessage({ + text: "**Bold** and *italic* with [link](https://example.com)", + sender: "bot", + }); + const { container } = render(); + + expect(container.querySelector("strong")).toBeInTheDocument(); + expect(container.querySelector("em")).toBeInTheDocument(); + expect(container.querySelector("a")).toBeInTheDocument(); + }); + + it("should render markdown paragraphs for bot messages", () => { + const message = createMockMessage({ + text: "Paragraph 1\n\nParagraph 2", + sender: "bot", + }); + const { container } = render(); + + const paragraphs = container.querySelectorAll("p"); + expect(paragraphs.length).toBeGreaterThanOrEqual(2); + }); + + it("should render markdown tables for bot messages", () => { + const message = createMockMessage({ + text: "| Column 1 | Column 2 |\n|----------|----------|\n| Data 1 | Data 2 |", + sender: "bot", + }); + const { container } = render(); + + const table = container.querySelector("table"); + expect(table).toBeInTheDocument(); + + const headers = container.querySelectorAll("th"); + expect(headers.length).toBeGreaterThanOrEqual(2); + + const cells = container.querySelectorAll("td"); + expect(cells.length).toBeGreaterThanOrEqual(2); + }); + + it("should render markdown images for bot messages", () => { + const message = createMockMessage({ + text: "![Alt text](https://example.com/image.jpg)", + sender: "bot", + }); + const { container } = render(); + + const img = container.querySelector("img"); + expect(img).toBeInTheDocument(); + expect(img?.getAttribute("src")).toBe("https://example.com/image.jpg"); + expect(img?.getAttribute("alt")).toBe("Alt text"); + }); + + it("should render markdown horizontal rules for bot messages", () => { + const message = createMockMessage({ + text: "Before\n\n---\n\nAfter", + sender: "bot", + }); + const { container } = render(); + + const hr = container.querySelector("hr"); + expect(hr).toBeInTheDocument(); + }); + + it("should render markdown strikethrough for bot messages", () => { + const message = createMockMessage({ + text: "This is ~~deleted~~ text", + sender: "bot", + }); + const { container } = render(); + + const del = container.querySelector("del"); + expect(del).toBeInTheDocument(); + expect(del?.textContent).toBe("deleted"); + }); + + it("should render markdown blockquotes for bot messages", () => { + const message = createMockMessage({ + text: "> This is a quote", + sender: "bot", + }); + const { container } = render(); + + const blockquote = container.querySelector("blockquote"); + expect(blockquote).toBeInTheDocument(); + }); + + it("should render code blocks with pre tags for bot messages", () => { + const message = createMockMessage({ + text: "```\nconst x = 5;\nconst y = 10;\n```", + sender: "bot", + }); + const { container } = render(); + + const pre = container.querySelector("pre"); + expect(pre).toBeInTheDocument(); + + const code = pre?.querySelector("code"); + expect(code).toBeInTheDocument(); + }); + + it("should render ordered lists for bot messages", () => { + const message = createMockMessage({ + text: "Steps:\n1. First step\n2. Second step\n3. Third step", + sender: "bot", + }); + const { container } = render(); + + const ol = container.querySelector("ol"); + expect(ol).toBeInTheDocument(); + + const listItems = ol?.querySelectorAll("li"); + expect(listItems?.length).toBe(3); + }); + + it("should render task lists with checkboxes for bot messages", () => { + const message = createMockMessage({ + text: "Tasks:\n- [ ] Unchecked task\n- [x] Completed task\n- [ ] Another task", + sender: "bot", + }); + const { container } = render(); + + const checkboxes = container.querySelectorAll("input[type='checkbox']"); + expect(checkboxes.length).toBe(3); + + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + }); + + it("should apply larger font size and padding to message bubble when in fullscreen mode", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultContext, + isFullscreen: true, + }); + + const message = createMockMessage({ text: "Test message content", sender: "bot" }); + const { getByText } = render(); + + const textElement = getByText("Test message content"); + const bubbleElement = textElement.parentElement as HTMLElement; + expect(bubbleElement).toHaveStyle({ fontSize: "18px" }); + expect(bubbleElement).toHaveStyle({ paddingInline: "4px" }); + }); + + it("should apply smaller font size and padding to message bubble when not in fullscreen mode", () => { + mockUseChatDrawerContext.mockReturnValue({ + ...defaultContext, + isFullscreen: false, + }); + + const message = createMockMessage({ text: "Test message content", sender: "bot" }); + const { getByText } = render(); + + const textElement = getByText("Test message content"); + const bubbleElement = textElement.parentElement as HTMLElement; + expect(bubbleElement).toHaveStyle({ fontSize: "16px" }); + expect(bubbleElement).toHaveStyle({ paddingInline: "4px" }); + }); + + it("should render links with target='_blank' and rel attributes in bot messages", () => { + const message = createMockMessage({ + text: "Check out [this link](https://example.com) for more info.", + sender: "bot", + }); + const { container } = render(); + + const link = container.querySelector("a"); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "https://example.com"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expect(link).toHaveTextContent("this link"); + }); + + it("should render multiple links with target='_blank' in bot messages", () => { + const message = createMockMessage({ + text: "Visit [site 1](https://example1.com) and [site 2](https://example2.com).", + sender: "bot", + }); + const { container } = render(); + + const links = container.querySelectorAll("a"); + expect(links.length).toBe(2); + + expect(links[0]).toHaveAttribute("href", "https://example1.com"); + expect(links[0]).toHaveAttribute("target", "_blank"); + expect(links[0]).toHaveAttribute("rel", "noopener noreferrer"); + + expect(links[1]).toHaveAttribute("href", "https://example2.com"); + expect(links[1]).toHaveAttribute("target", "_blank"); + expect(links[1]).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should not render links for user messages", () => { + const message = createMockMessage({ + text: "Check out https://example.com", + sender: "user", + }); + const { container } = render(); + + const link = container.querySelector("a"); + expect(link).not.toBeInTheDocument(); + }); +}); + +describe("PreComponent - Copy to Clipboard", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatDrawerContext.mockReturnValue(defaultContext); + + Object.assign(navigator, { + clipboard: { + writeText: vi.fn(() => Promise.resolve()), + }, + }); + }); + + it("should render copy button for code blocks", () => { + const message = createMockMessage({ + text: "```javascript\nconst x = 5;\n```", + sender: "bot", + }); + const { container } = render(); + + const copyButton = container.querySelector('button[title="Copy to clipboard"]'); + expect(copyButton).toBeInTheDocument(); + }); + + it("should copy code text to clipboard when button is clicked", async () => { + const message = createMockMessage({ + text: "```javascript\nconst x = 5;\nconsole.log(x);\n```", + sender: "bot", + }); + const { container } = render(); + + const copyButton = container.querySelector('button[title="Copy to clipboard"]'); + expect(copyButton).toBeInTheDocument(); + + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("const x = 5;\nconsole.log(x);"); + }); + }); + + it("should show check icon after successful copy", async () => { + const message = createMockMessage({ + text: "```python\nprint('hello')\n```", + sender: "bot", + }); + const { container } = render(); + + const copyButton = container.querySelector('button[title="Copy to clipboard"]'); + + fireEvent.click(copyButton); + + await waitFor(() => { + const copiedButton = container.querySelector('button[title="Copied!"]'); + expect(copiedButton).toBeInTheDocument(); + }); + }); + + it("should reset icon back to copy after 2 seconds", async () => { + vi.useFakeTimers(); + + const message = createMockMessage({ + text: "```bash\nls -la\n```", + sender: "bot", + }); + const { container } = render(); + + const copyButton = container.querySelector('button[title="Copy to clipboard"]'); + + fireEvent.click(copyButton); + + await waitFor(() => { + expect(container.querySelector('button[title="Copied!"]')).toBeInTheDocument(); + }); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + await waitFor(() => { + expect(container.querySelector('button[title="Copy to clipboard"]')).toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + + it("should handle code blocks with multiple languages", async () => { + const message = createMockMessage({ + text: "```typescript\nconst foo: string = 'bar';\n```", + sender: "bot", + }); + const { container } = render(); + + const copyButton = container.querySelector('button[title="Copy to clipboard"]'); + + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("const foo: string = 'bar';"); + }); + }); + + it("should strip trailing newline from copied text", async () => { + const message = createMockMessage({ + text: "```\nsome code\n```", + sender: "bot", + }); + const { container } = render(); + + const copyButton = container.querySelector('button[title="Copy to clipboard"]'); + + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("some code"); + }); + }); + + it("should add extra padding to pre element for copy button space", () => { + const message = createMockMessage({ + text: "```\ncode here\n```", + sender: "bot", + }); + const { container } = render(); + + const preElement = container.querySelector("pre"); + expect(preElement).toBeInTheDocument(); + expect(preElement).toHaveStyle({ paddingRight: "48px" }); + }); + + it("should position copy button in top right of code block", () => { + const message = createMockMessage({ + text: "```\ntest\n```", + sender: "bot", + }); + const { container } = render(); + + const copyButton = container.querySelector('button[title="Copy to clipboard"]'); + expect(copyButton).toBeInTheDocument(); + + const styles = window.getComputedStyle(copyButton); + expect(styles.position).toBe("absolute"); + }); + + it("should not render copy button for inline code", () => { + const message = createMockMessage({ + text: "This is `inline code` in text", + sender: "bot", + }); + const { container } = render(); + + const copyButton = container.querySelector('button[title="Copy to clipboard"]'); + expect(copyButton).not.toBeInTheDocument(); + }); + + it("should handle empty code blocks", async () => { + const message = createMockMessage({ + text: "```\n```", + sender: "bot", + }); + const { container } = render(); + + const copyButton = container.querySelector('button[title="Copy to clipboard"]'); + + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(""); + }); + }); + + it("should render date and divider when isFirstMessage is true", () => { + const message = createMockMessage({ + timestamp: new Date("2024-01-15T14:30:00"), + }); + const { container } = render(); + + expect(container.textContent).toContain("January 15, 2024"); + }); + + it("should not render date when isFirstMessage is false", () => { + const message = createMockMessage({ + timestamp: new Date("2024-01-15T14:30:00"), + }); + const { container } = render(); + + expect(container.textContent).not.toContain("January 15, 2024"); + }); + + it("should not render date when isFirstMessage is not provided", () => { + const message = createMockMessage({ + timestamp: new Date("2024-01-15T14:30:00"), + }); + const { container } = render(); + + expect(container.textContent).not.toContain("January 15, 2024"); + }); + + it("should render citations for bot messages with citations", () => { + const message = createMockMessage({ + sender: "bot", + text: "Here is some information", + citations: [ + { documentName: "Citation 1", documentLink: "https://example.com/1" }, + { documentName: "Citation 2", documentLink: "https://example.com/2" }, + ], + }); + const { getByText } = render(); + + expect(getByText("Citation 1")).toBeInTheDocument(); + expect(getByText("Citation 2")).toBeInTheDocument(); + }); + + it("should render citation chips as links", () => { + const message = createMockMessage({ + sender: "bot", + text: "Information with source", + citations: [{ documentName: "Source Doc", documentLink: "https://example.com/doc" }], + }); + const { container } = render(); + + const citationLink = container.querySelector('a[href="https://example.com/doc"]'); + expect(citationLink).toBeInTheDocument(); + expect(citationLink).toHaveAttribute("target", "_blank"); + expect(citationLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should render fallback citation label when title is missing", () => { + const message = createMockMessage({ + sender: "bot", + text: "Information", + citations: [{ documentName: "", documentLink: "https://example.com/1" }], + }); + const { getByText } = render(); + + expect(getByText("[1]")).toBeInTheDocument(); + }); + + it("should not render citations for user messages", () => { + const message = createMockMessage({ + sender: "user", + text: "User query", + citations: [{ documentName: "Citation", documentLink: "https://example.com" }], + }); + const { queryByText } = render(); + + expect(queryByText("Citation")).not.toBeInTheDocument(); + }); + + it("should not render citations container when citations array is empty", () => { + const message = createMockMessage({ + sender: "bot", + text: "No citations here", + citations: [], + }); + const { container } = render(); + + const chips = container.querySelectorAll(".MuiChip-root"); + expect(chips).toHaveLength(0); + }); +}); diff --git a/src/components/ChatBot/panel/ChatMessageItem.tsx b/src/components/ChatBot/panel/ChatMessageItem.tsx new file mode 100644 index 000000000..8389e206a --- /dev/null +++ b/src/components/ChatBot/panel/ChatMessageItem.tsx @@ -0,0 +1,535 @@ +import { Check, ContentCopy } from "@mui/icons-material"; +import { Box, Chip, IconButton, Typography, styled } from "@mui/material"; +import React, { CSSProperties, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import { useChatDrawerContext } from "../context/ChatDrawerContext"; + +const MessageRow = styled(Box)({ + display: "flex", + justifyContent: "flex-start", + marginBottom: "12px", + '&[data-is-user="true"]': { + justifyContent: "flex-end", + }, +}); + +const MessageColumn = styled(Box)({ + maxWidth: "100%", + width: "100%", + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + '&[data-is-user="true"]': { + alignItems: "flex-end", + width: "auto", + }, +}); + +const MessageMetaRow = styled(Box)({ + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: "4px", + paddingInline: "4px", +}); + +const MessageDateText = styled(Typography)({ + fontFamily: "Nunito", + fontStyle: "normal", + fontWeight: 300, + fontSize: "11px", + lineHeight: "19px", + color: "#3E3E3E", +}); + +const MessageDateDivider = styled(Box)({ + width: "0.5px", + height: "12px", + backgroundColor: "#3E3E3E", +}); + +const MessageTimestamp = styled(Typography)({ + fontFamily: "Nunito", + fontStyle: "normal", + fontWeight: 300, + fontSize: "11px", + lineHeight: "19px", + color: "#3E3E3E", +}); + +/** + * Style definitions for message bubbles based on message variant. + */ +const BOT_BUBBLE_STYLES: Record = { + default: { + backgroundColor: "transparent", + color: "#3D4143", + }, + info: { + backgroundColor: "transparent", + color: "#005EA2", + fontWeight: 600, + }, + error: { + backgroundColor: "transparent", + color: "#C05239", + fontWeight: 600, + }, +}; + +const MessageBubble = styled(Box, { + shouldForwardProp: (prop) => prop !== "variant" && prop !== "isFullscreen", +})<{ variant?: ChatMessageVariant; isFullscreen?: boolean }>(({ variant, isFullscreen }) => { + const safeVariant = (variant ?? "default") as ChatMessageVariant; + const style = BOT_BUBBLE_STYLES[safeVariant]; + + return { + width: "100%", + wordWrap: "break-word", + paddingInline: isFullscreen ? "16px" : "12px", + paddingBlock: isFullscreen ? "12px" : "8px", + borderRadius: "12px", + backgroundColor: style.backgroundColor, + border: style.border ?? "none", + color: style.color, + fontWeight: style.fontWeight ?? 400, + fontSize: isFullscreen ? "18px" : "16px", + lineHeight: 1.5, + whiteSpace: "pre-line", + fontFamily: "Inter", + + '&[data-is-user="true"]': { + position: "relative", + isolation: "isolate", + width: "fit-content", + borderRadius: "8px", + color: "#FFFFFF", + boxShadow: "-2px 4px 8px rgba(0, 0, 0, 0.25)", + backgroundImage: "linear-gradient(90deg, #2596E5 0%, #2C68C2 49.67%, #5B53D8 100%)", + backgroundRepeat: "no-repeat", + backgroundPosition: "left top", + backgroundSize: "100% 100%", + maxWidth: "100%", + minWidth: 0, + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + wordBreak: "break-word", + + "&::before": { + content: '""', + position: "absolute", + left: 0, + bottom: "-7px", + width: "100%", + height: "17px", + zIndex: -1, + pointerEvents: "none", + backgroundImage: "inherit", + backgroundRepeat: "inherit", + backgroundPosition: "inherit", + backgroundSize: "inherit", + clipPath: "circle(8.5px at calc(100% - 26.5px) 8.5px)", + WebkitClipPath: "circle(8.5px at calc(100% - 26.5px) 8.5px)", + }, + + "&::after": { + content: '""', + position: "absolute", + left: 0, + bottom: "-12px", + width: "100%", + height: "8px", + zIndex: -1, + pointerEvents: "none", + backgroundImage: "inherit", + backgroundRepeat: "inherit", + backgroundPosition: "inherit", + backgroundSize: "inherit", + clipPath: "circle(4px at calc(100% - 15.5px) 4px)", + WebkitClipPath: "circle(4px at calc(100% - 15.5px) 4px)", + }, + }, + + '&[data-is-user="false"]': { + borderTopLeftRadius: 0, + whiteSpace: "normal", + paddingInline: "4px", + paddingBlock: 0, + }, + + // Markdown styles for bot messages + "& p": { + margin: 0, + marginBottom: "8px", + whiteSpace: "pre-line", + "&:last-child": { + marginBottom: 0, + }, + }, + "& ul, & ol": { + margin: 0, + marginBottom: "8px", + paddingLeft: "20px", + "&:last-child": { + marginBottom: 0, + }, + }, + "& li": { + marginBottom: "4px", + paddingLeft: "4px", + lineHeight: 1.5, + }, + "& ol li": { + paddingLeft: "8px", + }, + "& input[type='checkbox']": { + marginRight: "8px", + cursor: "pointer", + appearance: "none", + width: "16px", + height: "16px", + border: "2px solid #005EA2", + borderRadius: "3px", + backgroundColor: "transparent", + position: "relative", + flexShrink: 0, + "&:checked": { + backgroundColor: "#005EA2", + border: "2px solid #005EA2", + }, + "&:checked::after": { + content: '""', + position: "absolute", + left: "4px", + top: "1px", + width: "4px", + height: "8px", + border: "solid white", + borderWidth: "0 2px 2px 0", + transform: "rotate(45deg)", + }, + }, + "& li:has(input[type='checkbox'])": { + listStyle: "none", + paddingLeft: 0, + display: "flex", + alignItems: "center", + }, + "& code": { + backgroundColor: "rgba(0,0,0,0.08)", + padding: "2px 4px", + borderRadius: "3px", + fontSize: "14px", + fontFamily: "monospace", + }, + "& pre": { + backgroundColor: "rgba(0,0,0,0.08)", + padding: "8px", + borderRadius: "4px", + overflow: "auto", + marginBottom: "8px", + "&:last-child": { + marginBottom: 0, + }, + }, + "& pre code": { + backgroundColor: "transparent", + padding: 0, + }, + "& strong": { + fontWeight: 700, + }, + "& em": { + fontStyle: "italic", + }, + "& a": { + textDecoration: "underline", + }, + "& h1, & h2, & h3, & h4, & h5, & h6": { + margin: 0, + marginBottom: "8px", + fontWeight: 600, + color: "#034AA3", + + "&:last-child": { + marginBottom: 0, + }, + }, + "& h1": { fontSize: "28px" }, + "& h2": { fontSize: "26px" }, + "& h3": { fontSize: "24px" }, + "& h4": { fontSize: "22px" }, + "& h5": { fontSize: "20px" }, + "& h6": { fontSize: "18px" }, + "& blockquote": { + borderLeft: "4px solid rgba(0,0,0,0.2)", + paddingLeft: "12px", + margin: 0, + marginBottom: "8px", + "&:last-child": { + marginBottom: 0, + }, + }, + "& hr": { + border: "none", + borderTop: "1px solid rgba(0,0,0,0.12)", + margin: "12px 0", + }, + "& table": { + borderCollapse: "collapse", + width: "fit-content", + maxWidth: "100%", + marginBottom: "8px", + fontSize: "14px", + backgroundColor: "transparent", + display: "block", + overflowX: "auto", + "&:last-child": { + marginBottom: 0, + }, + }, + "& th, & td": { + border: "1px solid rgba(0,0,0,0.12)", + padding: "6px 8px", + textAlign: "left", + backgroundColor: "#FFFFFF", + }, + "& th": { + backgroundColor: "#D0D0D0", + fontWeight: 600, + }, + "& img": { + maxWidth: "100%", + height: "auto", + borderRadius: "4px", + marginBottom: "8px", + "&:last-child": { + marginBottom: 0, + }, + }, + "& del": { + textDecoration: "line-through", + opacity: 0.7, + }, + }; +}); + +const CitationsContainer = styled(Box)({ + display: "flex", + flexWrap: "wrap", + gap: "5px", + marginTop: "6px", +}); + +const StyledCitationChip = styled(Chip)({ + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + padding: "1px 7px", + gap: "10px", + height: "auto", + background: "#EBEBEB", + borderRadius: "20px", + border: "1px solid #B0B0B0", + cursor: "pointer", + textDecoration: "none !important", + fontFamily: "Inter", + fontStyle: "normal", + fontWeight: 400, + fontSize: "8px", + lineHeight: "14px", + letterSpacing: "0.01em", + color: "#505E6D", + "&:hover": { + background: "#E0E0E0", + textDecoration: "none !important", + }, + "&:link, &:visited, &:active": { + textDecoration: "none !important", + }, + "& .MuiChip-label": { + padding: 0, + fontSize: "8px", + lineHeight: "14px", + color: "#505E6D", + }, +}) as typeof Chip; + +const StyledCopyButton = styled(IconButton, { + shouldForwardProp: (prop) => prop !== "isFullscreen", +})<{ isFullscreen?: boolean }>(({ isFullscreen }) => ({ + position: "absolute", + top: isFullscreen ? 7.5 : 6, + right: 8, + padding: "6px", + minWidth: "auto", + color: "rgba(0, 0, 0, 0.6)", + backgroundColor: "rgba(255, 255, 255, 0.8)", + backdropFilter: "blur(4px)", + zIndex: 1, + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.95)", + color: "rgba(0, 0, 0, 0.8)", + }, +})); + +/** + * Custom anchor component for ReactMarkdown that opens links in new tabs. + */ +const LinkComponent = ({ + node, + ...props +}: React.AnchorHTMLAttributes & { node?: unknown }) => ( + + {props.children} + +); + +/** + * Custom pre component for ReactMarkdown that includes a copy to clipboard button for code blocks. + */ +const PreComponent = ({ children }: { children: React.ReactNode }) => { + const { isFullscreen } = useChatDrawerContext(); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + const codeElement = React.Children.toArray(children).find( + (child) => React.isValidElement(child) && child.type === "code" + ); + + if (codeElement && React.isValidElement(codeElement)) { + const codeText = String(codeElement.props.children ?? "").replace(/\n$/, ""); + await navigator.clipboard.writeText(codeText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + + + {copied ? : } + +
{children}
+
+ ); +}; + +/** + * Formats a date object into a localized time string. + * + * @param date - The date to format + * @return Formatted time string in 12-hour format + * @example "02:30 PM" + */ +export const formatMessageTime = (date: Date): string => + new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }).format(date); + +/** + * Formats a date object into a full date string. + * + * @param date - The date to format + * @return Formatted date string + * @example "February 19, 2026" + */ +export const formatMessageDate = (date: Date): string => + new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }).format(date); + +type Props = { + /** + * The chat message object to render. + */ + message: ChatMessage; + /** + * Whether this is the first message in the conversation (greeting). + */ + isFirstMessage?: boolean; +}; + +/** + * Renders a single chat message with sender info, timestamp, and styled bubble. + */ +const ChatMessageItem = ({ message, isFirstMessage = false }: Props): JSX.Element => { + const { isFullscreen } = useChatDrawerContext(); + + if (!message) { + return null; + } + + const isUser = message.sender === "user"; + const dataIsUser = isUser ? "true" : "false"; + const hasCitations = message?.citations?.length > 0; + + return ( + + + + {isFirstMessage && ( + <> + {formatMessageDate(message.timestamp)} + + + )} + {formatMessageTime(message.timestamp)} + + + + {isUser ? ( + message.text + ) : ( + <> + + {message.text} + + {hasCitations && ( + + {message.citations?.map((citation, index) => ( + + ))} + + )} + + )} + + + + ); +}; + +export default React.memo(ChatMessageItem); diff --git a/src/components/ChatBot/panel/MessageList.test.tsx b/src/components/ChatBot/panel/MessageList.test.tsx new file mode 100644 index 000000000..8702a2c96 --- /dev/null +++ b/src/components/ChatBot/panel/MessageList.test.tsx @@ -0,0 +1,384 @@ +import { axe } from "vitest-axe"; + +import { render } from "@/test-utils"; + +import * as ChatDrawerContextModule from "../context/ChatDrawerContext"; + +import MessageList from "./MessageList"; + +vi.mock("../context/ChatDrawerContext", () => ({ + useChatDrawerContext: vi.fn(), +})); + +const mockUseChatDrawerContext = vi.mocked(ChatDrawerContextModule.useChatDrawerContext); + +const defaultContext = { + isFullscreen: false, + drawerRef: { current: null }, + heightPx: 600, + widthPx: 384, + x: 0, + y: 0, + isExpanded: true, + isMinimized: false, + isOpen: true, + onDragStop: vi.fn(), + onResizeStop: vi.fn(), + onToggleExpand: vi.fn(), + onToggleFullscreen: vi.fn(), + onMinimize: vi.fn(), + openDrawer: vi.fn(), + isConfirmingEndConversation: false, + onRequestEndConversation: vi.fn(), + onConfirmEndConversation: vi.fn(), + onCancelEndConversation: vi.fn(), +}; + +vi.mock("./ChatMessageItem", () => ({ + default: ({ message }: { message: ChatMessage }) => ( +
{message.text}
+ ), +})); + +vi.mock("./BotTypingIndicator", () => ({ + default: () =>
Typing...
, +})); + +const createMockMessage = (overrides?: Partial): ChatMessage => ({ + id: "test-message-1", + text: "Test message", + sender: "bot", + timestamp: new Date("2024-01-15T14:30:00"), + senderName: "Support Bot", + variant: "default", + ...overrides, +}); + +const defaultProps = { + messages: [] as ChatMessage[], + isBotTyping: false, +}; + +describe("Accessibility", () => { + beforeEach(() => { + Element.prototype.scrollTo = vi.fn(); + vi.clearAllMocks(); + mockUseChatDrawerContext.mockReturnValue(defaultContext); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it("should have no accessibility violations with empty messages", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations with messages", async () => { + const messages = [createMockMessage({ id: "msg-1", text: "Hello" })]; + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have no accessibility violations with typing indicator", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + beforeEach(() => { + Element.prototype.scrollTo = vi.fn(); + vi.clearAllMocks(); + mockUseChatDrawerContext.mockReturnValue(defaultContext); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should render without crashing", () => { + expect(() => render()).not.toThrow(); + }); + + it("should display welcome title", () => { + const { getByText } = render(); + + expect(getByText("How can I help you?")).toBeInTheDocument(); + }); + + it("should render messages when there are messages beyond the greeting", () => { + const messages = [ + createMockMessage({ id: "greeting", text: "How can I help you?", sender: "bot" }), + createMockMessage({ id: "msg-1", text: "Hello world" }), + ]; + const { getByTestId } = render(); + + expect(getByTestId("message-greeting")).toBeInTheDocument(); + expect(getByTestId("message-msg-1")).toBeInTheDocument(); + }); + + it("should render multiple messages", () => { + const messages = [ + createMockMessage({ id: "msg-1", text: "First message" }), + createMockMessage({ id: "msg-2", text: "Second message" }), + createMockMessage({ id: "msg-3", text: "Third message" }), + ]; + const { getByTestId } = render(); + + expect(getByTestId("message-msg-1")).toBeInTheDocument(); + expect(getByTestId("message-msg-2")).toBeInTheDocument(); + expect(getByTestId("message-msg-3")).toBeInTheDocument(); + }); + + it("should show typing indicator when isBotTyping is true", () => { + const { getByTestId } = render(); + + expect(getByTestId("bot-typing-indicator")).toBeInTheDocument(); + }); + + it("should not show typing indicator when isBotTyping is false", () => { + const { queryByTestId } = render(); + + expect(queryByTestId("bot-typing-indicator")).not.toBeInTheDocument(); + }); + + it("should render messages with typing indicator", () => { + const messages = [ + createMockMessage({ id: "greeting", text: "How can I help?", sender: "bot" }), + createMockMessage({ id: "msg-1", text: "Hello" }), + ]; + const { getByTestId } = render( + + ); + + expect(getByTestId("message-msg-1")).toBeInTheDocument(); + expect(getByTestId("bot-typing-indicator")).toBeInTheDocument(); + }); + + it("should update when messages prop changes", () => { + const messages1 = [ + createMockMessage({ id: "greeting", text: "How can I help?", sender: "bot" }), + createMockMessage({ id: "msg-1", text: "First" }), + ]; + const messages2 = [ + createMockMessage({ id: "greeting", text: "How can I help?", sender: "bot" }), + createMockMessage({ id: "msg-1", text: "First" }), + createMockMessage({ id: "msg-2", text: "Second" }), + ]; + const { rerender, getByTestId, queryByTestId } = render( + + ); + + expect(getByTestId("message-msg-1")).toBeInTheDocument(); + expect(queryByTestId("message-msg-2")).not.toBeInTheDocument(); + + rerender(); + + expect(getByTestId("message-msg-1")).toBeInTheDocument(); + expect(getByTestId("message-msg-2")).toBeInTheDocument(); + }); + + it("should update typing indicator when isBotTyping changes", () => { + const { rerender, getByTestId, queryByTestId } = render( + + ); + + expect(queryByTestId("bot-typing-indicator")).not.toBeInTheDocument(); + + rerender(); + + expect(getByTestId("bot-typing-indicator")).toBeInTheDocument(); + }); + + it("should handle messages with different senders", () => { + const messages = [ + createMockMessage({ id: "msg-1", sender: "bot", text: "Bot message" }), + createMockMessage({ id: "msg-2", sender: "user", text: "User message" }), + ]; + const { getByTestId } = render(); + + expect(getByTestId("message-msg-1")).toBeInTheDocument(); + expect(getByTestId("message-msg-2")).toBeInTheDocument(); + }); + + it("should maintain message order", () => { + const messages = [ + createMockMessage({ id: "msg-1", text: "First" }), + createMockMessage({ id: "msg-2", text: "Second" }), + createMockMessage({ id: "msg-3", text: "Third" }), + ]; + const { container } = render(); + + const messageElements = container.querySelectorAll('[data-testid^="message-"]'); + expect(messageElements[0]).toHaveAttribute("data-testid", "message-msg-1"); + expect(messageElements[1]).toHaveAttribute("data-testid", "message-msg-2"); + expect(messageElements[2]).toHaveAttribute("data-testid", "message-msg-3"); + }); + + it("should call scrollTo when element ref is available", () => { + const scrollToSpy = vi.fn(); + Element.prototype.scrollTo = scrollToSpy; + + render(); + + expect(scrollToSpy).toHaveBeenCalled(); + }); + + it("should scroll when message text changes (streaming)", () => { + const scrollToSpy = vi.fn(); + Element.prototype.scrollTo = scrollToSpy; + + const messages = [createMockMessage({ id: "msg-1", text: "Hello" })]; + const { rerender } = render(); + + expect(scrollToSpy).toHaveBeenCalledTimes(1); + + const updatedMessages = [createMockMessage({ id: "msg-1", text: "Hello world" })]; + rerender(); + + expect(scrollToSpy).toHaveBeenCalledTimes(2); + }); + + it("should scroll multiple times during streaming chunks", () => { + const scrollToSpy = vi.fn(); + Element.prototype.scrollTo = scrollToSpy; + + const messages = [createMockMessage({ id: "msg-1", text: "Hello" })]; + const { rerender } = render(); + + expect(scrollToSpy).toHaveBeenCalledTimes(1); + + rerender(); + expect(scrollToSpy).toHaveBeenCalledTimes(2); + + rerender( + + ); + expect(scrollToSpy).toHaveBeenCalledTimes(3); + + rerender( + + ); + expect(scrollToSpy).toHaveBeenCalledTimes(4); + }); + + it("should scroll when isBotTyping changes", () => { + const scrollToSpy = vi.fn(); + Element.prototype.scrollTo = scrollToSpy; + + const { rerender } = render(); + + expect(scrollToSpy).toHaveBeenCalledTimes(1); + + rerender(); + + expect(scrollToSpy).toHaveBeenCalledTimes(2); + }); + + it("should scroll with smooth behavior", () => { + const scrollToSpy = vi.fn(); + Element.prototype.scrollTo = scrollToSpy; + + render(); + + expect(scrollToSpy).toHaveBeenCalledWith( + expect.objectContaining({ + behavior: "smooth", + }) + ); + }); + + it("should not scroll when message text is unchanged", () => { + const scrollToSpy = vi.fn(); + Element.prototype.scrollTo = scrollToSpy; + + const messages = [createMockMessage({ id: "msg-1", text: "Hello" })]; + const { rerender } = render(); + + expect(scrollToSpy).toHaveBeenCalledTimes(1); + + rerender(); + + expect(scrollToSpy).toHaveBeenCalledTimes(1); + }); + + it("should apply correct font size to greeting title", () => { + const { getByText } = render(); + + const titleElement = getByText("How can I help you?"); + expect(titleElement).toHaveStyle({ fontSize: "14px" }); + }); + + it("should apply correct font size to greeting subtitle", () => { + const { container } = render(); + + const subtitleElement = container.querySelector("p"); + expect(subtitleElement).toHaveStyle({ fontSize: "13px" }); + }); + + it("should hide greeting when messages beyond the initial greeting exist", () => { + const messages = [ + createMockMessage({ id: "greeting", text: "How can I help?", sender: "bot" }), + createMockMessage({ id: "msg-1", text: "Hello" }), + ]; + const { queryByText } = render(); + + expect(queryByText("How can I help you?")).not.toBeInTheDocument(); + }); + + it("should show greeting when no messages exist", () => { + const { getByText } = render(); + + expect(getByText("How can I help you?")).toBeInTheDocument(); + }); + + it("should pass isFirstMessage=true to first message only", () => { + const messages = [ + createMockMessage({ id: "msg-1", text: "First" }), + createMockMessage({ id: "msg-2", text: "Second" }), + ]; + const { getByTestId } = render(); + + expect(getByTestId("message-msg-1")).toBeInTheDocument(); + expect(getByTestId("message-msg-2")).toBeInTheDocument(); + }); + + it("should scroll in fullscreen mode", () => { + const scrollToSpy = vi.fn(); + Element.prototype.scrollTo = scrollToSpy; + + mockUseChatDrawerContext.mockReturnValue({ + ...defaultContext, + isFullscreen: true, + }); + + render(); + + expect(scrollToSpy).toHaveBeenCalled(); + }); + + it("should scroll when expand state changes", () => { + const scrollToSpy = vi.fn(); + Object.defineProperty(Element.prototype, "scrollTop", { + set: scrollToSpy, + configurable: true, + }); + Object.defineProperty(Element.prototype, "scrollHeight", { + get: () => 1000, + configurable: true, + }); + + mockUseChatDrawerContext.mockReturnValue({ + ...defaultContext, + isExpanded: true, + }); + + render(); + + expect(scrollToSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/components/ChatBot/panel/MessageList.tsx b/src/components/ChatBot/panel/MessageList.tsx new file mode 100644 index 000000000..296ac2f4f --- /dev/null +++ b/src/components/ChatBot/panel/MessageList.tsx @@ -0,0 +1,217 @@ +import { Box, Button, Stack, Typography, styled } from "@mui/material"; +import React, { useEffect, useRef } from "react"; + +import ChatBotLogo from "../components/ChatBotLogo"; +import chatConfig from "../config/chatConfig"; +import { useChatDrawerContext } from "../context/ChatDrawerContext"; + +import BotTypingIndicator from "./BotTypingIndicator"; +import ChatMessageItem from "./ChatMessageItem"; + +const MessagesContainer = styled(Box, { + shouldForwardProp: (prop) => prop !== "isExpanded" && prop !== "isFullscreen", +})<{ isExpanded?: boolean; isFullscreen?: boolean }>(({ isExpanded, isFullscreen }) => ({ + flex: isFullscreen ? "none" : 1, + overflowY: isFullscreen ? "visible" : "auto", + paddingInline: "24.5px", + paddingTop: isExpanded ? "25px" : 0, + marginTop: 0, + marginRight: isFullscreen ? 0 : "10px", + overscrollBehavior: "contain", + ...(!isFullscreen && { + "&::-webkit-scrollbar": { + width: "7px", + }, + "&::-webkit-scrollbar-track": { + background: "transparent", + }, + "&::-webkit-scrollbar-thumb": { + background: "#7C7C7C", + borderRadius: "4px", + }, + }), +})); + +const ChatHeader = styled(Box, { + shouldForwardProp: (prop) => prop !== "isExpanded", +})<{ isExpanded?: boolean }>(({ isExpanded }) => ({ + textAlign: "center", + paddingTop: isExpanded ? 0 : "25px", +})); + +const StyledLogoWrapper = styled(Box)({ + display: "flex", + justifyContent: "center", + marginBottom: "12px", +}); + +const ChatTitle = styled(Typography)({ + marginBottom: "8px", + fontFamily: "Inter", + fontStyle: "normal", + fontWeight: "600", + fontSize: "14px", + lineHeight: "18px", + color: "#3D4143", +}); + +const ChatSubtitle = styled(Typography)({ + fontFamily: "Inter", + fontStyle: "normal", + fontWeight: 400, + fontSize: "13px", + lineHeight: "18px", + color: "#3E3E3E", +}); + +const StyledQuestionWrapper = styled(Stack)({ + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + columnGap: "12px", + rowGap: "5px", + margin: "12px 0 0", +}); + +const StyledQuestion = styled(Button)({ + display: "flex", + flexDirection: "row", + alignItems: "center", + padding: "0px 8px", + background: "#FFFFFF", + opacity: 0.8, + border: "1px solid #828282", + borderRadius: "8px", + + fontFamily: "Nunito", + fontStyle: "normal", + fontWeight: 600, + fontSize: "11px", + lineHeight: "22px", + textAlign: "center", + letterSpacing: "-0.0015em", + color: "#334B5A", + transition: "background 0.1s ease-in-out", + + "&:hover": { + color: "#FFFFFF", + background: "linear-gradient(90deg, #0081DF 0%, #2A70D8 48.08%, #554BEE 100%)", + }, +}); + +const defaultQuestions = [ + "What is a Submission Request", + "How do I request access?", + "How do I start a submission?", + "How do I upload data files", +]; + +export type Props = { + /** + * Array of chat messages to display in the message list. + */ + messages: readonly ChatMessage[]; + /** + * Indicates whether the bot is currently typing a response. + */ + isBotTyping: boolean; + /** + * Callback when a default question is clicked. + */ + onQuestionClick?: (question: string) => void; +}; + +/** + * Displays a scrollable list of chat messages with automatic scrolling to the latest message. + */ +const MessageList = ({ messages, isBotTyping, onQuestionClick }: Props): JSX.Element => { + const { isExpanded, isFullscreen } = useChatDrawerContext(); + const messagesContainerRef = useRef(null); + + const lastMessage = messages?.length > 0 ? messages[messages.length - 1] : null; + const lastMessageText = lastMessage?.text || ""; + + /** + * Gets the current scroll container based on view mode. + */ + const getScrollContainer = (): HTMLElement | null => { + const element = messagesContainerRef.current; + if (!element) { + return null; + } + + if (isFullscreen) { + return element.parentElement?.parentElement ?? null; + } + return element; + }; + + useEffect(() => { + const container = getScrollContainer(); + if (!container) { + return; + } + + container.scrollTop = container.scrollHeight; + }, [isFullscreen, isExpanded]); + + useEffect(() => { + const container = getScrollContainer(); + if (!container) { + return; + } + + container.scrollTo({ + top: container.scrollHeight, + behavior: "smooth", + }); + }, [lastMessageText, isBotTyping]); + + const hasMessages = messages.length > 1; + + return ( + + + + + + {!hasMessages && ( + <> + {chatConfig.initialMessage} + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud + exercitation ullamco laboris nisi ut aliquip. + + + + {defaultQuestions.map((question) => ( + onQuestionClick?.(question)} + > + {question} + + ))} + + + )} + + + {hasMessages && + messages?.map((message, index) => ( + + ))} + + {isBotTyping ? : null} + + ); +}; + +export default React.memo(MessageList); diff --git a/src/components/ChatBot/utils/chatUtils.test.ts b/src/components/ChatBot/utils/chatUtils.test.ts new file mode 100644 index 000000000..7c6680322 --- /dev/null +++ b/src/components/ChatBot/utils/chatUtils.test.ts @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import * as utils from "./chatUtils"; + +vi.mock("uuid", () => ({ + v4: vi.fn(() => "mock-uuid-1234"), +})); + +describe("getViewportHeightPx", () => { + it("should return window.innerHeight when window is available", () => { + const originalInnerHeight = window.innerHeight; + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 1024, + }); + + const result = utils.getViewportHeightPx(500); + + expect(result).toBe(1024); + + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: originalInnerHeight, + }); + }); + + it("should return fallback value when different window height is set", () => { + const originalInnerHeight = window.innerHeight; + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 768, + }); + + const result = utils.getViewportHeightPx(500); + + expect(result).toBe(768); + + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: originalInnerHeight, + }); + }); + + it("should return fallback value when window is undefined", () => { + const originalWindow = global.window; + delete global.window; + + const result = utils.getViewportHeightPx(500); + + expect(result).toBe(500); + + global.window = originalWindow; + }); +}); + +describe("isAbortError", () => { + it("should return true for AbortError", () => { + const error = new Error("Aborted"); + error.name = "AbortError"; + + const result = utils.isAbortError(error); + + expect(result).toBe(true); + }); + + it("should return false for regular Error", () => { + const error = new Error("Regular error"); + + const result = utils.isAbortError(error); + + expect(result).toBe(false); + }); + + it("should return false for non-Error objects", () => { + const notError = { name: "AbortError" }; + + const result = utils.isAbortError(notError); + + expect(result).toBe(false); + }); + + it("should return false for null", () => { + const result = utils.isAbortError(null); + + expect(result).toBe(false); + }); + + it("should return false for undefined", () => { + const result = utils.isAbortError(undefined); + + expect(result).toBe(false); + }); + + it("should return false for string", () => { + const result = utils.isAbortError("AbortError"); + + expect(result).toBe(false); + }); + + it("should return false for TypeError", () => { + const error = new TypeError("Type error"); + + const result = utils.isAbortError(error); + + expect(result).toBe(false); + }); +}); + +describe("createId", () => { + it("should create ID with prefix and UUID", () => { + const result = utils.createId("test_"); + + expect(result).toBe("test_mock-uuid-1234"); + }); + + it("should create ID with different prefix", () => { + const result = utils.createId("message_"); + + expect(result).toBe("message_mock-uuid-1234"); + }); + + it("should create ID with empty prefix", () => { + const result = utils.createId(""); + + expect(result).toBe("mock-uuid-1234"); + }); + + it("should create IDs with UUID format", () => { + const result1 = utils.createId("id_"); + const result2 = utils.createId("id_"); + + expect(result1).toContain("id_"); + expect(result2).toContain("id_"); + expect(result1).toContain("mock-uuid-1234"); + }); +}); + +describe("createChatMessage", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-15T10:30:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should create message with required fields", () => { + const result = utils.createChatMessage({ + text: "Hello world", + sender: "user", + senderName: "John Doe", + }); + + expect(result).toMatchObject({ + text: "Hello world", + sender: "user", + senderName: "John Doe", + variant: "default", + }); + expect(result.id).toContain("chat_msg_"); + expect(result.timestamp).toEqual(new Date("2024-01-15T10:30:00Z")); + }); + + it("should create message with custom variant", () => { + const result = utils.createChatMessage({ + text: "Error occurred", + sender: "bot", + senderName: "Support Bot", + variant: "error", + }); + + expect(result.variant).toBe("error"); + }); + + it("should create message with info variant", () => { + const result = utils.createChatMessage({ + text: "Information", + sender: "bot", + senderName: "Support Bot", + variant: "info", + }); + + expect(result.variant).toBe("info"); + }); + + it("should create message with custom id", () => { + const result = utils.createChatMessage({ + text: "Custom message", + sender: "user", + senderName: "Jane Doe", + id: "custom-id-123", + }); + + expect(result.id).toBe("custom-id-123"); + }); + + it("should generate unique id when not provided", () => { + const result = utils.createChatMessage({ + text: "Auto ID message", + sender: "bot", + senderName: "Bot", + }); + + expect(result.id).toContain("chat_msg_mock-uuid-1234"); + }); + + it("should create message with bot sender", () => { + const result = utils.createChatMessage({ + text: "Bot response", + sender: "bot", + senderName: "Support Bot", + }); + + expect(result.sender).toBe("bot"); + expect(result.senderName).toBe("Support Bot"); + }); + + it("should create message with user sender", () => { + const result = utils.createChatMessage({ + text: "User question", + sender: "user", + senderName: "User Name", + }); + + expect(result.sender).toBe("user"); + expect(result.senderName).toBe("User Name"); + }); + + it("should set timestamp to current time", () => { + const result = utils.createChatMessage({ + text: "Timestamp test", + sender: "user", + senderName: "Test User", + }); + + expect(result.timestamp).toEqual(new Date("2024-01-15T10:30:00Z")); + }); + + it("should create different timestamps for messages at different times", () => { + const result1 = utils.createChatMessage({ + text: "First message", + sender: "user", + senderName: "User", + }); + + vi.setSystemTime(new Date("2024-01-15T10:31:00Z")); + + const result2 = utils.createChatMessage({ + text: "Second message", + sender: "user", + senderName: "User", + }); + + expect(result1.timestamp).toEqual(new Date("2024-01-15T10:30:00Z")); + expect(result2.timestamp).toEqual(new Date("2024-01-15T10:31:00Z")); + }); +}); diff --git a/src/components/ChatBot/utils/chatUtils.ts b/src/components/ChatBot/utils/chatUtils.ts new file mode 100644 index 000000000..f1efca7f5 --- /dev/null +++ b/src/components/ChatBot/utils/chatUtils.ts @@ -0,0 +1,60 @@ +import { v4 } from "uuid"; + +/** + * Gets the current viewport height, or returns a fallback value if window is unavailable. + * + * @param {number} fallback - Default height if window is unavailable + * @return {number} Current viewport height in pixels + */ +export const getViewportHeightPx = (fallback: number): number => { + if (typeof window === "undefined") { + return fallback; + } + + return window.innerHeight; +}; + +/** + * Determines if an error is an AbortError. + * + * @param {unknown} error - Error object to check + * @return {boolean} True if error is an AbortError + */ +export const isAbortError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false; + } + + return error.name === "AbortError"; +}; + +/** + * Generates a unique identifier with the given prefix. + * + * @param {string} prefix - ID prefix + * @return {string} Unique ID with prefix and UUID + */ +export const createId = (prefix: string): string => `${prefix}${v4()}`; + +/** + * Creates a chat message object with provided content and metadata. + * + * @param {{ text: string; sender: ChatSender; senderName: string; variant?: ChatMessageVariant; id?: string; citations?: ChatCitation[] }} args - Message text, sender, name, optional variant, optional custom id, and optional citations + * @return {ChatMessage} New chat message object + */ +export const createChatMessage = (args: { + text: string; + sender: ChatSender; + senderName: string; + variant?: ChatMessageVariant; + id?: string; + citations?: ChatCitation[]; +}): ChatMessage => ({ + id: args.id ?? createId("chat_msg_"), + timestamp: new Date(), + variant: args.variant ?? "default", + text: args.text, + sender: args.sender, + senderName: args.senderName, + citations: args.citations, +}); diff --git a/src/components/ChatBot/utils/conversationStorageUtils.test.ts b/src/components/ChatBot/utils/conversationStorageUtils.test.ts new file mode 100644 index 000000000..f76ac4351 --- /dev/null +++ b/src/components/ChatBot/utils/conversationStorageUtils.test.ts @@ -0,0 +1,363 @@ +import { chatMessageFactory } from "@/test-utils/factories/chatbot/ChatMessageFactory"; +import { Logger } from "@/utils"; + +import * as utils from "./conversationStorageUtils"; + +vi.mock("@/utils", () => ({ + Logger: { + error: vi.fn(), + info: vi.fn(), + }, +})); + +const createMockIndexedDB = () => { + let store: Record = {}; + + const mockObjectStore = { + add: vi.fn((item: unknown) => { + const itemWithId = item as { id: string }; + store[itemWithId.id] = item; + return { onsuccess: null, onerror: null }; + }), + clear: vi.fn(() => { + store = {}; + return { onsuccess: null, onerror: null }; + }), + getAll: vi.fn(() => { + const request = { + result: Object.values(store), + onsuccess: null, + onerror: null, + }; + setTimeout(() => request.onsuccess?.(), 0); + return request; + }), + }; + + const mockTransaction = { + objectStore: vi.fn(() => mockObjectStore), + oncomplete: null, + onerror: null, + }; + + const mockDb = { + transaction: vi.fn(() => { + setTimeout(() => mockTransaction.oncomplete?.(), 0); + return mockTransaction; + }), + createObjectStore: vi.fn(), + objectStoreNames: { contains: vi.fn(() => false) }, + close: vi.fn(), + }; + + const mockRequest = { + result: mockDb, + onsuccess: null, + onerror: null, + onupgradeneeded: null, + }; + + const mockIndexedDB = { + open: vi.fn(() => { + setTimeout(() => { + mockRequest.onupgradeneeded?.({ target: { result: mockDb } }); + mockRequest.onsuccess?.(); + }, 0); + return mockRequest; + }), + }; + + return { + mockIndexedDB, + mockDb, + mockTransaction, + mockObjectStore, + store, + clearStore: () => { + store = {}; + }, + }; +}; + +describe("storeConversationMessages", () => { + let mockIDB: ReturnType; + const originalIndexedDB = globalThis.indexedDB; + + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + mockIDB = createMockIndexedDB(); + globalThis.indexedDB = mockIDB.mockIndexedDB as unknown as IDBFactory; + }); + + afterEach(() => { + globalThis.indexedDB = originalIndexedDB; + }); + + it("should store messages in IndexedDB", async () => { + const messages = [ + chatMessageFactory.build({ id: "msg-1" }), + chatMessageFactory.build({ id: "msg-2" }), + ]; + + await utils.storeConversationMessages(messages); + + expect(mockIDB.mockIndexedDB.open).toHaveBeenCalledWith("chatbot_conversation_db", 1); + expect(mockIDB.mockObjectStore.clear).toHaveBeenCalled(); + expect(mockIDB.mockObjectStore.add).toHaveBeenCalledTimes(2); + expect(sessionStorage.getItem("chatbot_conversation_session")).toBe("active"); + }); + + it("should mark session as active when storing messages", async () => { + const messages = [chatMessageFactory.build()]; + + await utils.storeConversationMessages(messages); + + expect(sessionStorage.getItem("chatbot_conversation_session")).toBe("active"); + }); + + it("should handle errors gracefully", async () => { + globalThis.indexedDB = { + open: vi.fn(() => { + const request = { + onerror: null, + onsuccess: null, + onupgradeneeded: null, + }; + setTimeout(() => request.onerror?.(), 0); + return request; + }), + } as unknown as IDBFactory; + + await utils.storeConversationMessages([chatMessageFactory.build()]); + + expect(Logger.error).toHaveBeenCalled(); + }); + + it("should close db and log error when transaction fails", async () => { + mockIDB.mockDb.transaction = vi.fn(() => { + setTimeout(() => mockIDB.mockTransaction.onerror?.(), 0); + return mockIDB.mockTransaction; + }); + + await utils.storeConversationMessages([chatMessageFactory.build()]); + + expect(mockIDB.mockDb.close).toHaveBeenCalled(); + expect(Logger.error).toHaveBeenCalledWith( + "conversationStorageUtils: Failed to store conversation messages in IndexedDB", + expect.any(Error) + ); + }); + + it("should log error when markSessionActive fails", async () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("Storage error"); + }); + + await utils.storeConversationMessages([chatMessageFactory.build()]); + + expect(Logger.error).toHaveBeenCalledWith( + "conversationStorageUtils: Failed to mark session as active in sessionStorage" + ); + + setItemSpy.mockRestore(); + }); +}); + +describe("getStoredConversationMessages", () => { + let mockIDB: ReturnType; + const originalIndexedDB = globalThis.indexedDB; + + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + mockIDB = createMockIndexedDB(); + globalThis.indexedDB = mockIDB.mockIndexedDB as unknown as IDBFactory; + }); + + afterEach(() => { + globalThis.indexedDB = originalIndexedDB; + }); + + it("should return empty array if session is not active", async () => { + const result = await utils.getStoredConversationMessages(); + + expect(result).toEqual([]); + }); + + it("should retrieve messages when session is active", async () => { + sessionStorage.setItem("chatbot_conversation_session", "active"); + + const storedMessage = chatMessageFactory.build({ + id: "msg-1", + text: "Test", + sender: "user", + timestamp: new Date("2026-01-01T12:00:00.000Z"), + senderName: "User", + variant: "default", + }); + + mockIDB.mockObjectStore.getAll = vi.fn(() => { + const request = { + result: [storedMessage], + onsuccess: null, + onerror: null, + }; + setTimeout(() => request.onsuccess?.(), 0); + return request; + }); + + const result = await utils.getStoredConversationMessages(); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("msg-1"); + expect(result[0].timestamp).toBeInstanceOf(Date); + }); + + it("should clear messages and return empty array when session is invalid", async () => { + const result = await utils.getStoredConversationMessages(); + + expect(result).toEqual([]); + }); + + it("should handle errors gracefully and return empty array", async () => { + sessionStorage.setItem("chatbot_conversation_session", "active"); + globalThis.indexedDB = { + open: vi.fn(() => { + const request = { + onerror: null, + onsuccess: null, + onupgradeneeded: null, + }; + setTimeout(() => request.onerror?.(), 0); + return request; + }), + } as unknown as IDBFactory; + + const result = await utils.getStoredConversationMessages(); + + expect(result).toEqual([]); + expect(Logger.error).toHaveBeenCalled(); + }); + + it("should clear stored messages when browser session ends", async () => { + const result = await utils.getStoredConversationMessages(); + + expect(result).toEqual([]); + }); + + it("should close db and log error when request fails", async () => { + sessionStorage.setItem("chatbot_conversation_session", "active"); + + mockIDB.mockObjectStore.getAll = vi.fn(() => { + const request = { + result: [], + onsuccess: null, + onerror: null, + }; + setTimeout(() => request.onerror?.(), 0); + return request; + }); + + const result = await utils.getStoredConversationMessages(); + + expect(result).toEqual([]); + expect(mockIDB.mockDb.close).toHaveBeenCalled(); + expect(Logger.error).toHaveBeenCalledWith( + "conversationStorageUtils: Failed to retrieve conversation messages from IndexedDB", + expect.any(Error) + ); + }); + + it("should return false and empty array when sessionStorage.getItem throws", async () => { + const getItemSpy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("Storage error"); + }); + + const result = await utils.getStoredConversationMessages(); + + expect(result).toEqual([]); + + getItemSpy.mockRestore(); + }); +}); + +describe("clearConversationMessages", () => { + let mockIDB: ReturnType; + const originalIndexedDB = globalThis.indexedDB; + + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + mockIDB = createMockIndexedDB(); + globalThis.indexedDB = mockIDB.mockIndexedDB as unknown as IDBFactory; + }); + + afterEach(() => { + globalThis.indexedDB = originalIndexedDB; + }); + + it("should clear all messages from IndexedDB", async () => { + sessionStorage.setItem("chatbot_conversation_session", "active"); + + await utils.clearConversationMessages(); + + expect(mockIDB.mockObjectStore.clear).toHaveBeenCalled(); + expect(sessionStorage.getItem("chatbot_conversation_session")).toBeNull(); + }); + + it("should clear session marker", async () => { + sessionStorage.setItem("chatbot_conversation_session", "active"); + + await utils.clearConversationMessages(); + + expect(sessionStorage.getItem("chatbot_conversation_session")).toBeNull(); + }); + + it("should handle errors gracefully", async () => { + globalThis.indexedDB = { + open: vi.fn(() => { + const request = { + onerror: null, + onsuccess: null, + onupgradeneeded: null, + }; + setTimeout(() => request.onerror?.(), 0); + return request; + }), + } as unknown as IDBFactory; + + await utils.clearConversationMessages(); + + expect(Logger.error).toHaveBeenCalled(); + }); + + it("should close db and log error when transaction fails", async () => { + mockIDB.mockDb.transaction = vi.fn(() => { + setTimeout(() => mockIDB.mockTransaction.onerror?.(), 0); + return mockIDB.mockTransaction; + }); + + await utils.clearConversationMessages(); + + expect(mockIDB.mockDb.close).toHaveBeenCalled(); + expect(Logger.error).toHaveBeenCalledWith( + "conversationStorageUtils: Failed to clear conversation messages from IndexedDB", + expect.any(Error) + ); + }); + + it("should log error when clearSessionKey fails", async () => { + const removeItemSpy = vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { + throw new Error("Storage error"); + }); + + await utils.clearConversationMessages(); + + expect(Logger.error).toHaveBeenCalledWith( + "conversationStorageUtils: Failed to clear session key from sessionStorage" + ); + + removeItemSpy.mockRestore(); + }); +}); diff --git a/src/components/ChatBot/utils/conversationStorageUtils.ts b/src/components/ChatBot/utils/conversationStorageUtils.ts new file mode 100644 index 000000000..f6bd882b7 --- /dev/null +++ b/src/components/ChatBot/utils/conversationStorageUtils.ts @@ -0,0 +1,173 @@ +import { Logger } from "@/utils"; + +const DB_NAME = "chatbot_conversation_db"; +const DB_VERSION = 1; +const STORE_NAME = "messages"; +const SESSION_KEY = "chatbot_conversation_session"; + +/** + * Checks if the current session is valid by verifying the session key in sessionStorage. + * + * @returns {boolean} True if the session is valid, false otherwise. + */ +const isSessionValid = (): boolean => { + try { + return sessionStorage.getItem(SESSION_KEY) === "active"; + } catch { + return false; + } +}; + +/** + * Marks the current session as active in sessionStorage. + */ +const markSessionActive = (): void => { + try { + sessionStorage.setItem(SESSION_KEY, "active"); + } catch { + Logger.error("conversationStorageUtils: Failed to mark session as active in sessionStorage"); + } +}; + +/** + * Clears the session key from sessionStorage. + */ +const clearSessionKey = (): void => { + try { + sessionStorage.removeItem(SESSION_KEY); + } catch { + Logger.error("conversationStorageUtils: Failed to clear session key from sessionStorage"); + } +}; + +/** + * Opens the IndexedDB database, creating the object store if needed. + * + * @returns {Promise} The opened database instance. + */ +const openDatabase = (): Promise => + new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + reject(new Error("Failed to open IndexedDB")); + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + // When a new version number higher than its current version is passed, it will create a new object store + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "id" }); + } + }; + }); + +/** + * Stores all conversation messages in IndexedDB. + * + * @param {ChatMessage[]} messages - The messages to store. + * @returns {Promise} + */ +export const storeConversationMessages = async (messages: ChatMessage[]): Promise => { + try { + markSessionActive(); + const db = await openDatabase(); + const transaction = db.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + + store.clear(); + messages.forEach((message) => { + store.add(message); + }); + + await new Promise((resolve, reject) => { + transaction.oncomplete = () => { + db.close(); + resolve(); + }; + transaction.onerror = () => { + db.close(); + reject(new Error("Failed to store conversation messages")); + }; + }); + } catch (error) { + Logger.error( + "conversationStorageUtils: Failed to store conversation messages in IndexedDB", + error + ); + } +}; + +/** + * Retrieves all stored conversation messages from IndexedDB. + * + * @returns {Promise} The stored messages, or empty array. + */ +export const getStoredConversationMessages = async (): Promise => { + try { + if (!isSessionValid()) { + await clearConversationMessages(); + return []; + } + + const db = await openDatabase(); + const transaction = db.transaction(STORE_NAME, "readonly"); + const store = transaction.objectStore(STORE_NAME); + const request = store.getAll(); + + return await new Promise((resolve, reject) => { + request.onsuccess = () => { + db.close(); + const messages: ChatMessage[] = request.result; + messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + resolve(messages); + }; + request.onerror = () => { + db.close(); + reject(new Error("Failed to retrieve conversation messages")); + }; + }); + } catch (error) { + Logger.error( + "conversationStorageUtils: Failed to retrieve conversation messages from IndexedDB", + error + ); + return []; + } +}; + +/** + * Clears all stored conversation messages from IndexedDB. + * + * @returns {Promise} + */ +export const clearConversationMessages = async (): Promise => { + try { + clearSessionKey(); + + const db = await openDatabase(); + const transaction = db.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + store.clear(); + + await new Promise((resolve, reject) => { + transaction.oncomplete = () => { + db.close(); + resolve(); + }; + transaction.onerror = () => { + db.close(); + reject(new Error("Failed to clear conversation messages")); + }; + }); + } catch (error) { + Logger.error( + "conversationStorageUtils: Failed to clear conversation messages from IndexedDB", + error + ); + } +}; diff --git a/src/components/ChatBot/utils/sessionStorageUtils.test.ts b/src/components/ChatBot/utils/sessionStorageUtils.test.ts new file mode 100644 index 000000000..ebe3dbaa4 --- /dev/null +++ b/src/components/ChatBot/utils/sessionStorageUtils.test.ts @@ -0,0 +1,79 @@ +import { Logger } from "@/utils"; + +import * as utils from "./sessionStorageUtils"; + +vi.mock("@/utils", () => ({ + Logger: { + error: vi.fn(), + }, +})); + +describe("getStoredSessionId", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + }); + + it("should return session ID when stored", () => { + sessionStorage.setItem("chatbot_session_id", "test-session"); + expect(utils.getStoredSessionId()).toBe("test-session"); + }); + + it("should return null when no session ID is stored", () => { + expect(utils.getStoredSessionId()).toBeNull(); + }); + + it("should return null when sessionStorage throws error", () => { + const spy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("Storage error"); + }); + expect(utils.getStoredSessionId()).toBeNull(); + expect(Logger.error).toHaveBeenCalledWith("Failed to retrieve session ID from session storage"); + spy.mockRestore(); + }); +}); + +describe("storeSessionId", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + }); + + it("should store session ID in sessionStorage", () => { + utils.storeSessionId("new-session"); + expect(sessionStorage.getItem("chatbot_session_id")).toBe("new-session"); + }); + + it("should log error when sessionStorage throws", () => { + const spy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("Storage error"); + }); + + utils.storeSessionId("session-id"); + expect(Logger.error).toHaveBeenCalledWith("Failed to store session ID in session storage"); + spy.mockRestore(); + }); +}); + +describe("clearStoredSessionId", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + }); + + it("should remove session ID from sessionStorage", () => { + sessionStorage.setItem("chatbot_session_id", "test-session"); + utils.clearStoredSessionId(); + expect(sessionStorage.getItem("chatbot_session_id")).toBeNull(); + }); + + it("should log error when sessionStorage throws", () => { + const spy = vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { + throw new Error("Storage error"); + }); + + utils.clearStoredSessionId(); + expect(Logger.error).toHaveBeenCalledWith("Failed to clear session ID from session storage"); + spy.mockRestore(); + }); +}); diff --git a/src/components/ChatBot/utils/sessionStorageUtils.ts b/src/components/ChatBot/utils/sessionStorageUtils.ts new file mode 100644 index 000000000..54204106c --- /dev/null +++ b/src/components/ChatBot/utils/sessionStorageUtils.ts @@ -0,0 +1,44 @@ +import { Logger } from "@/utils"; + +const SESSION_STORAGE_KEY = "chatbot_session_id"; + +/** + * Retrieves the stored session ID from session storage. + * + * @returns {string | null} The stored session ID, or null if not found. + */ +export const getStoredSessionId = (): string | null => { + try { + return sessionStorage.getItem(SESSION_STORAGE_KEY); + } catch { + Logger.error("Failed to retrieve session ID from session storage"); + return null; + } +}; + +/** + * Stores the session ID in session storage. + * + * @param {string} sessionId - The session ID to store. + * @returns {void} + */ +export const storeSessionId = (sessionId: string): void => { + try { + sessionStorage.setItem(SESSION_STORAGE_KEY, sessionId); + } catch { + Logger.error("Failed to store session ID in session storage"); + } +}; + +/** + * Clears the stored session ID from session storage. + * + * @returns {void} + */ +export const clearStoredSessionId = (): void => { + try { + sessionStorage.removeItem(SESSION_STORAGE_KEY); + } catch { + Logger.error("Failed to clear session ID from session storage"); + } +}; diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 6fcdacc97..7f57a9cd7 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -2,6 +2,8 @@ import { styled } from "@mui/material"; import { FC, ReactNode } from "react"; import { Outlet, ScrollRestoration } from "react-router-dom"; +import ChatBot from "@/components/ChatBot"; + import { SearchParamsProvider } from "../components/Contexts/SearchParamsContext"; import Footer from "../components/Footer"; import Header from "../components/Header"; @@ -28,6 +30,7 @@ const Layout: FC = ({ children }) => (