From c339f3c8e8b717ec2f91759a1ffe611a9f512007 Mon Sep 17 00:00:00 2001 From: Deryk DeGuzman Date: Fri, 23 Feb 2024 15:19:27 -0800 Subject: [PATCH] chore: Mount React component in web component --- .github/workflows/deploy-pages-site.yml | 2 +- .github/workflows/deploy-pr-preview.yml | 2 +- .gitignore | 1 + build.js | 3 +- buildConfigs.js | 6 +- docs/buildConfigs.js | 6 +- docs/docsServer.js | 27 +++-- docs/package-lock.json | 115 +++++++++++++++++++ docs/package.json | 15 +++ docs/src/index.tsx | 30 +++-- package.json | 8 +- src/components/ChatView.tsx | 1 - src/index.scss | 3 + src/index.tsx | 121 +++++++++++++++++++- src/vui/components/flex/_flexContainer.scss | 1 - 15 files changed, 303 insertions(+), 38 deletions(-) create mode 100644 docs/package-lock.json create mode 100644 docs/package.json create mode 100644 src/index.scss diff --git a/.github/workflows/deploy-pages-site.yml b/.github/workflows/deploy-pages-site.yml index 4446948..d70ebbd 100644 --- a/.github/workflows/deploy-pages-site.yml +++ b/.github/workflows/deploy-pages-site.yml @@ -19,7 +19,7 @@ jobs: npm run build - name: Build Page Script - run: npm run buildDocs + run: npm run buildDocs --prefix docs/ - name: Deploy 🚀 uses: JamesIves/github-pages-deploy-action@v4 diff --git a/.github/workflows/deploy-pr-preview.yml b/.github/workflows/deploy-pr-preview.yml index 5712b46..367700e 100644 --- a/.github/workflows/deploy-pr-preview.yml +++ b/.github/workflows/deploy-pr-preview.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - run: npm i && npm run build && npm run buildDocs + - run: npm i && npm run build && npm i --prefix docs/ && npm run buildDocs --prefix docs/ if: github.event.action != 'closed' - uses: rossjrw/pr-preview-action@v1 with: diff --git a/.gitignore b/.gitignore index 085ed63..c6428cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /node_modules /lib +/docs/node_modules/ /docs/public/script.js /docs/public/script.js.map /coverage diff --git a/build.js b/build.js index d2df72c..cdab775 100644 --- a/build.js +++ b/build.js @@ -1,5 +1,4 @@ const { build } = require("esbuild"); -const { esm, cjs } = require("./buildConfigs"); +const { esm } = require("./buildConfigs"); build(esm); -build(cjs); diff --git a/buildConfigs.js b/buildConfigs.js index 4cd421b..9997489 100644 --- a/buildConfigs.js +++ b/buildConfigs.js @@ -7,11 +7,11 @@ const sharedConfig = { entryPoints: ["src/index.tsx", "src/useChat.ts"], logLevel: "info", treeShaking: true, - minify: true, - sourcemap: true, + minify: false, + sourcemap: false, external: [...Object.keys(dependencies), ...Object.keys(devDependencies), ...Object.keys(peerDependencies)], target: ["esnext", "node12.22.0"], - plugins: [cssPlugin(), sassPlugin({ type: "style" })], + plugins: [cssPlugin(), sassPlugin({ type: "css-text" })], outdir: "./lib", outbase: "./src" }; diff --git a/docs/buildConfigs.js b/docs/buildConfigs.js index 589a818..c35d9fa 100644 --- a/docs/buildConfigs.js +++ b/docs/buildConfigs.js @@ -7,9 +7,9 @@ module.exports = { define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development") }, - entryPoints: ["docs/src/index.tsx"], - outfile: "docs/public/script.js", - sourcemap: true, + entryPoints: ["src/index.tsx"], + outfile: "public/script.js", + sourcemap: false, plugins: [cssPlugin(), sassPlugin({ type: "style" })] } }; diff --git a/docs/docsServer.js b/docs/docsServer.js index 0b7154d..3706973 100644 --- a/docs/docsServer.js +++ b/docs/docsServer.js @@ -1,25 +1,38 @@ const esbuild = require("esbuild"); const chokidar = require("chokidar"); const liveServer = require("live-server"); -const { config: devScriptBuildConfig } = require("./buildConfigs"); +const { config: docsBuildConfig } = require("./buildConfigs"); +const { esm: packageBuildConfig } = require("../buildConfigs"); + +// Update chat package entry points since this script is executing in a subdirectory +const normalizedPackageBuildConfig = { + ...packageBuildConfig, + entryPoints: ["../src/index.tsx", "../src/useChat.ts"], + outdir: "../lib", + outbase: "../src" +}; (async () => { + // Builder for the component package + const packageBuilder = await esbuild.context(normalizedPackageBuildConfig); + // Builder for the development page - const devPageBuilder = await esbuild.context(devScriptBuildConfig); + const docsPageBuilder = await esbuild.context(docsBuildConfig); chokidar - // Watch for changes to dev env code or react-search src - .watch(["docs/src/*.{ts,tsx,scss}", "docs/src/**/*.{ts,tsx,scss}", "src/**/*.{ts,tsx,scss}"], { + // Watch for changes to docs code or react-search src + .watch(["src/*.{ts,tsx,scss}", "../src/**/*.{ts,tsx,scss}"], { interval: 0 // No delay }) - .on("all", () => { - devPageBuilder.rebuild(); + .on("all", async () => { + await packageBuilder.rebuild(); + docsPageBuilder.rebuild(); }); // `liveServer` local server for hot reload. liveServer.start({ open: true, port: +process.env.PORT || 8080, - root: "docs/public" + root: "public" }); })(); diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000..be69f9a --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": "docs", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "docs", + "version": "0.0.1", + "license": "ISC", + "dependencies": { + "@vectara/react-chatbot": "file:.." + } + }, + "..": { + "name": "@vectara/react-chatbot", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "classnames": "^2.3.2", + "lodash": "^4.17.21", + "prismjs": "^1.29.0", + "react-focus-on": "^3.9.1", + "uuid-by-string": "^4.0.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.2.0", + "@testing-library/react": "^12.1.5", + "@types/jest": "^29.5.11", + "@types/lodash": "^4.14.202", + "@types/prismjs": "^1.26.3", + "@typescript-eslint/eslint-plugin": "^5.50.0", + "@typescript-eslint/parser": "^5.50.0", + "axios": "^1.6.7", + "chokidar": "^3.5.3", + "cross-fetch": "^4.0.0", + "esbuild": "^0.19.9", + "esbuild-css-modules-plugin": "^3.1.0", + "esbuild-sass-plugin": "^2.16.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^8.6.0", + "eslint-plugin-react": "^7.33.2", + "jest": "^29.7.0", + "jest-css-modules": "^2.1.0", + "jest-environment-jsdom": "^29.7.0", + "live-server": "^1.2.2", + "markdown-to-jsx": "^7.3.2", + "prettier": "2.8.3", + "react": ">= 17.0.2", + "react-dom": ">= 17.0.2", + "react-icons": "^5.0.1", + "react-jsx-parser": "^1.29.0", + "react-router-dom": "^6.8.2", + "rimraf": "^5.0.5", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3" + }, + "peerDependencies": { + "axios": "^1.6.7", + "react": ">= 17.0.2", + "react-dom": ">= 17.0.2" + } + }, + "node_modules/@vectara/react-chatbot": { + "resolved": "..", + "link": true + } + }, + "dependencies": { + "@vectara/react-chatbot": { + "version": "file:..", + "requires": { + "@testing-library/jest-dom": "^6.2.0", + "@testing-library/react": "^12.1.5", + "@types/jest": "^29.5.11", + "@types/lodash": "^4.14.202", + "@types/prismjs": "^1.26.3", + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^5.50.0", + "@typescript-eslint/parser": "^5.50.0", + "axios": "^1.6.7", + "chokidar": "^3.5.3", + "classnames": "^2.3.2", + "cross-fetch": "^4.0.0", + "esbuild": "^0.19.9", + "esbuild-css-modules-plugin": "^3.1.0", + "esbuild-sass-plugin": "^2.16.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^8.6.0", + "eslint-plugin-react": "^7.33.2", + "jest": "^29.7.0", + "jest-css-modules": "^2.1.0", + "jest-environment-jsdom": "^29.7.0", + "live-server": "^1.2.2", + "lodash": "^4.17.21", + "markdown-to-jsx": "^7.3.2", + "prettier": "2.8.3", + "prismjs": "^1.29.0", + "react": ">= 17.0.2", + "react-dom": ">= 17.0.2", + "react-focus-on": "^3.9.1", + "react-icons": "^5.0.1", + "react-jsx-parser": "^1.29.0", + "react-router-dom": "^6.8.2", + "rimraf": "^5.0.5", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3", + "uuid-by-string": "^4.0.0" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..1dbf3dd --- /dev/null +++ b/docs/package.json @@ -0,0 +1,15 @@ +{ + "name": "docs", + "version": "0.0.1", + "description": "Docs page for React-Chatbot", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "docs": "node docsServer.js", + "buildDocs": "node build.js" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@vectara/react-chatbot": "file:.." + } +} diff --git a/docs/src/index.tsx b/docs/src/index.tsx index fa1df03..99173d4 100644 --- a/docs/src/index.tsx +++ b/docs/src/index.tsx @@ -2,7 +2,7 @@ import { ChangeEvent, ReactNode, useCallback, useState } from "react"; import ReactDOM from "react-dom"; import { BiLogoGithub } from "react-icons/bi"; import JsxParser from "react-jsx-parser"; -import { ReactChatbot } from "../../src"; +import { ReactChatbot } from "@vectara/react-chatbot"; import { VuiAppContent, VuiAppHeader, @@ -43,7 +43,7 @@ const generateCodeSnippet = ( const props = [ `customerId="${customerId === "" ? "" : customerId}"`, `corpusIds=${ - corpusIds?.length === 0 ? '""' : `["${corpusIds?.join('","').replace(/\s/g, "")}"]` + corpusIds?.length === 0 ? '""' : `{["${corpusIds?.join('","').replace(/\s/g, "")}"]}` }`, `apiKey="${apiKey === "" ? "" : apiKey}"` ]; @@ -61,7 +61,7 @@ const generateCodeSnippet = ( } if (emptyStateDisplay) { - props.push(`emptyStateDisplay={${emptyStateDisplay.replace(/\s/g, "")}}`); + props.push(`emptyStateDisplay={${emptyStateDisplay.replace(/\n/g, "").replace(/\s+/g, " ")}}`); } props.push(`isInitiallyOpen={ /* (optional) true, if the component should be initially opened */ }`); @@ -69,13 +69,13 @@ const generateCodeSnippet = ( return `import { ReactChatbot } from "@vectara/react-chatbot"; - export const App = () => ( -
- -
- );`; +export const App = () => ( +
+ +
+);`; }; const DEFAULT_CORPUS_IDS = ["1"]; @@ -96,7 +96,13 @@ const App = () => { const [emptyStateJsx, setEmptyStateJsx] = useState(""); const onUpdateCorpusIds = useCallback((e: ChangeEvent) => { - setCorpusIds(e.target.value.split(",")); + const sanitizedValue = e.target.value.trim(); + + if (sanitizedValue === "") { + setCorpusIds([]); + return; + } + setCorpusIds(sanitizedValue.split(",")); }, []); const onUpdateCustomerId = useCallback((e: ChangeEvent) => { @@ -138,7 +144,7 @@ const App = () => {

- Vectara React-Chatbot +

diff --git a/package.json b/package.json index 0756c1e..5c34cc1 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,11 @@ "name": "@vectara/react-chatbot", "version": "0.0.1", "description": "A Vectara-powered Chatbot component", - "main": "lib/index.cjs.js", - "module": "dist/index.esm.js", - "types": "dist/index.d.ts", + "main": "lib/index.js", + "module": "lib/index.js", + "types": "lib/index.d.ts", "scripts": { "build": "npm run clean && node build.js && tsc --emitDeclarationOnly --outDir lib", - "buildDocs": "node docs/build.js", - "docs": "node docs/docsServer.js", "clean": "rimraf dist", "lint": "eslint .", "test": "jest --coverage" diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 04b7dbe..48be598 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -5,7 +5,6 @@ import { ChatItem } from "./ChatItem"; import { useChat } from "../useChat"; import { Loader } from "./Loader"; import { ChatBubbleIcon, MinimizeIcon } from "./Icons"; -import "./chatView.scss"; const inputSizeToQueryInputSize = { large: "l", diff --git a/src/index.scss b/src/index.scss new file mode 100644 index 0000000..c0db6b9 --- /dev/null +++ b/src/index.scss @@ -0,0 +1,3 @@ +@import "./components/chatView.scss"; +@import "./components/loader.scss"; +@import "./vui/_index.scss"; diff --git a/src/index.tsx b/src/index.tsx index f6513bf..7461ebf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,10 @@ -import { FC, ReactNode } from "react"; +import React, { FC, ReactNode, useEffect, useRef } from "react"; +import * as ReactDOM from "react-dom"; import { ChatView } from "components/ChatView"; +// @ts-ignore +import cssText from "index.scss"; + export interface Props { // Vectara customer ID customerId: string; @@ -33,7 +37,7 @@ export interface Props { /** * A client-side chat component that queries specific corpora with a user-provided message. */ -export const ReactChatbot: FC = ({ +const ReactChatbotInternal: FC = ({ customerId, apiKey, corpusIds, @@ -60,3 +64,116 @@ export const ReactChatbot: FC = ({ ); }; + +class ReactChatbotWebComponent extends HTMLElement { + sheet!: CSSStyleSheet; + sr!: ShadowRoot; + mountPoint!: HTMLDivElement; + + // Props + customerId!: string; + corpusIds!: string[]; + apiKey!: string; + title!: string; + placeholder!: string; + isInitiallyOpen!: boolean; + zIndex!: number; + emptyStateDisplay!: ReactNode; + + static get observedAttributes() { + return [ + "customerid", + "corpusids", + "apikey", + "title", + "placeholder", + "inputsize", + "isinitiallyopen", + "zindex", + "emptystatedisplayupdatetime" + ]; + } + + constructor() { + super(); + this.sr = this.attachShadow({ mode: "open" }); + this.sheet = new CSSStyleSheet(); + this.sheet.replaceSync(cssText); + this.sr.adoptedStyleSheets = [this.sheet]; + this.mountPoint = document.createElement("div"); + this.sr.appendChild(this.mountPoint); + } + + public setEmptyStateDisplay(emptyStateDisplay: ReactNode) { + this.emptyStateDisplay = emptyStateDisplay; + + // In order to trigger a re-render with the updated property, + // we set an update timestamp as an attribute on this web component. + this.setAttribute("emptystatedisplayupdatetime", Date.now().toString()); + } + + public connectedCallback() { + const customerId = this.getAttribute("customerId") ?? ""; + const corpusIds = (this.getAttribute("corpusIds") ?? "").split(" "); + const apiKey = this.getAttribute("apiKey") ?? ""; + const title = this.getAttribute("title") ?? undefined; + const placeholder = this.getAttribute("placeholder") ?? undefined; + const inputSize = this.getAttribute("inputSize") ?? undefined; + const isInitiallyOpen = this.getAttribute("isInitiallyOpen") === "true"; + const emptyStateDisplay = this.emptyStateDisplay ?? undefined; + const zIndex = this.getAttribute("zIndex") !== null ? parseInt(this.getAttribute("zIndex")!) : undefined; + + ReactDOM.render( + <> + + , + this.mountPoint + ); + } + + attributeChangedCallback() { + this.connectedCallback(); + } +} + +window.customElements.get("react-chatbot") || window.customElements.define("react-chatbot", ReactChatbotWebComponent); + +export const ReactChatbot = (props: Props): ReactNode => { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current) return; + + // emptyStateDisplay is an object prop so we need to handle it differently + // If provided, we use a custom method to set it as property of the ReactChatbotWebComponent instance. + if (props.emptyStateDisplay) { + // @ts-ignore + (ref.current as ReactChatbotWebComponent).setEmptyStateDisplay(props.emptyStateDisplay); + } + }, [props]); + + const typedProps = props as Record; + const updatedProps = Object.keys(props).reduce((acc: Record, propName: string) => { + if (propName === "emptyStateDisplay") return acc; + if (propName === "corpusIds") { + acc[propName] = typedProps["corpusIds"].join(" "); + } else { + acc[propName] = typedProps[propName]; + } + + return acc; + }, {}); + + // @ts-ignore + return ; +}; diff --git a/src/vui/components/flex/_flexContainer.scss b/src/vui/components/flex/_flexContainer.scss index 38b7c07..10e6d1a 100644 --- a/src/vui/components/flex/_flexContainer.scss +++ b/src/vui/components/flex/_flexContainer.scss @@ -1,7 +1,6 @@ .vuiFlexContainer { display: flex; align-items: stretch; - background-color: red; } .vuiFlexContainer--fullWidth {