From 8f3e20df88a07cdf1552f3d5997a4a65bff4cad6 Mon Sep 17 00:00:00 2001 From: Ryan Pope Date: Fri, 19 May 2023 16:57:06 -0400 Subject: [PATCH] save conversation state to sessionStorage (#11) adds logic in the initial state and ChatHeadless constructor to save/load from session storage. Also adds a minor adjustment to the test app so that it doesn't always send a request on load. J=CLIP-113 TEST=manual Ran test app, saw state saved in session storage. Refreshed page, conversation persists. --- apps/test-site/src/App.tsx | 6 +- package-lock.json | 11 +++- ...hat-headless.chatheadless._constructor_.md | 7 +- .../docs/chat-headless.chatheadless.md | 2 +- .../chat-headless/etc/chat-headless.api.md | 2 +- packages/chat-headless/package.json | 2 +- packages/chat-headless/src/ChatHeadless.ts | 26 +++++++- .../chat-headless/src/slices/conversation.ts | 16 +++++ .../chat-headless/tests/chatheadless.test.ts | 64 +++++++++++++++++-- 9 files changed, 122 insertions(+), 14 deletions(-) diff --git a/apps/test-site/src/App.tsx b/apps/test-site/src/App.tsx index d953390..0c9a954 100644 --- a/apps/test-site/src/App.tsx +++ b/apps/test-site/src/App.tsx @@ -29,8 +29,10 @@ function MyComponent(): JSX.Element { const [input, setInput] = useState(""); useEffect(() => { - actions.getNextMessage(); - }, [actions]); + if (messages.length === 0) { + actions.getNextMessage(); + } + }, [messages, actions]); const onClick = useCallback(() => { actions.getNextMessage(input); diff --git a/package-lock.json b/package-lock.json index 4a0b898..39483c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18455,7 +18455,7 @@ }, "packages/chat-headless": { "name": "@yext/chat-headless", - "version": "0.2.0", + "version": "0.3.0", "license": "BSD-3-Clause", "dependencies": { "@reduxjs/toolkit": "^1.9.5", @@ -18770,6 +18770,15 @@ "@types/yargs-parser": "*" } }, + "packages/chat-headless-react/node_modules/@yext/chat-headless": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@yext/chat-headless/-/chat-headless-0.2.0.tgz", + "integrity": "sha512-zqz9vqValCwikaaX2V2803WTgczj1PBf2FsJpqR1NN0NhyUhGJGJ5CJpBl34WAvWQK6+nvoy4SdLKN28MbqAyQ==", + "dependencies": { + "@reduxjs/toolkit": "^1.9.5", + "@yext/chat-core": "^0.2.0" + } + }, "packages/chat-headless-react/node_modules/ansi-styles": { "version": "4.3.0", "dev": true, diff --git a/packages/chat-headless/docs/chat-headless.chatheadless._constructor_.md b/packages/chat-headless/docs/chat-headless.chatheadless._constructor_.md index bf1db16..77d2f02 100644 --- a/packages/chat-headless/docs/chat-headless.chatheadless._constructor_.md +++ b/packages/chat-headless/docs/chat-headless.chatheadless._constructor_.md @@ -4,17 +4,18 @@ ## ChatHeadless.(constructor) -Constructs a new instance of the `ChatHeadless` class +Constructs a new instance of the [ChatHeadless](./chat-headless.chatheadless.md) class. **Signature:** ```typescript -constructor(config: ChatConfig); +constructor(config: ChatConfig, saveToSessionStorage?: boolean); ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| config | ChatConfig | | +| config | ChatConfig | The configuration for the [ChatHeadless](./chat-headless.chatheadless.md) instance | +| saveToSessionStorage | boolean | _(Optional)_ Whether to save the instance's [ConversationState](./chat-headless.conversationstate.md) to session storage. Defaults to true. | diff --git a/packages/chat-headless/docs/chat-headless.chatheadless.md b/packages/chat-headless/docs/chat-headless.chatheadless.md index ba226f1..7bd1526 100644 --- a/packages/chat-headless/docs/chat-headless.chatheadless.md +++ b/packages/chat-headless/docs/chat-headless.chatheadless.md @@ -16,7 +16,7 @@ export declare class ChatHeadless | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(config)](./chat-headless.chatheadless._constructor_.md) | | Constructs a new instance of the ChatHeadless class | +| [(constructor)(config, saveToSessionStorage)](./chat-headless.chatheadless._constructor_.md) | | Constructs a new instance of the [ChatHeadless](./chat-headless.chatheadless.md) class. | ## Properties diff --git a/packages/chat-headless/etc/chat-headless.api.md b/packages/chat-headless/etc/chat-headless.api.md index 1c8680e..4d2acb5 100644 --- a/packages/chat-headless/etc/chat-headless.api.md +++ b/packages/chat-headless/etc/chat-headless.api.md @@ -17,7 +17,7 @@ export { ChatConfig } // @public export class ChatHeadless { - constructor(config: ChatConfig); + constructor(config: ChatConfig, saveToSessionStorage?: boolean); addListener(listener: StateListener): Unsubscribe; getNextMessage(text?: string, source?: MessageSource): Promise; restartConversation(): void; diff --git a/packages/chat-headless/package.json b/packages/chat-headless/package.json index efae9ca..850af28 100644 --- a/packages/chat-headless/package.json +++ b/packages/chat-headless/package.json @@ -1,6 +1,6 @@ { "name": "@yext/chat-headless", - "version": "0.2.0", + "version": "0.3.0", "description": "A library for powering UI components for Yext Chat integrations", "main": "./dist/commonjs/index.js", "module": "./dist/esm/index.js", diff --git a/packages/chat-headless/src/ChatHeadless.ts b/packages/chat-headless/src/ChatHeadless.ts index 01e80a9..2d43dae 100644 --- a/packages/chat-headless/src/ChatHeadless.ts +++ b/packages/chat-headless/src/ChatHeadless.ts @@ -9,10 +9,12 @@ import { import { State } from "./models/state"; import { ReduxStateManager } from "./ReduxStateManager"; import { + loadSessionState, setConversationId, setIsLoading, setMessageNotes, setMessages, + STATE_SESSION_STORAGE_KEY, } from "./slices/conversation"; import { Store, Unsubscribe } from "@reduxjs/toolkit"; import { StateListener } from "./models"; @@ -28,9 +30,31 @@ export class ChatHeadless { private chatCore: ChatCore; private stateManager: ReduxStateManager; - constructor(config: ChatConfig) { + /** + * Constructs a new instance of the {@link ChatHeadless} class. + * + * @public + * + * @param config - The configuration for the {@link ChatHeadless} instance + * @param saveToSessionStorage - Whether to save the instance's {@link ConversationState} to session storage. Defaults to true. + */ + constructor(config: ChatConfig, saveToSessionStorage = true) { this.chatCore = new ChatCore(config); this.stateManager = new ReduxStateManager(); + if (saveToSessionStorage) { + this.setState({ + ...this.state, + conversation: loadSessionState(), + }); + this.addListener({ + valueAccessor: (s) => s.conversation, + callback: () => + sessionStorage.setItem( + STATE_SESSION_STORAGE_KEY, + JSON.stringify(this.state.conversation) + ), + }); + } } /** diff --git a/packages/chat-headless/src/slices/conversation.ts b/packages/chat-headless/src/slices/conversation.ts index ad0a924..e7bfd9c 100644 --- a/packages/chat-headless/src/slices/conversation.ts +++ b/packages/chat-headless/src/slices/conversation.ts @@ -2,11 +2,27 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { ConversationState } from "../models/slices/conversation"; import { Message, MessageNotes } from "@yext/chat-core"; +export const STATE_SESSION_STORAGE_KEY = "yext_chat_conversation_state"; + export const initialState: ConversationState = { messages: [], isLoading: false, }; +/** + * Loads the {@link ConversationState} from session storage. + */ +export const loadSessionState = (): ConversationState => { + if (!sessionStorage) { + console.warn( + "Session storage is not available. State will not be persisted across page refreshes." + ); + return initialState; + } + const savedState = sessionStorage.getItem(STATE_SESSION_STORAGE_KEY); + return savedState ? JSON.parse(savedState) : initialState; +}; + /** * Registers with Redux the slice of {@link State} pertaining to the loading status * of Chat Headless. diff --git a/packages/chat-headless/tests/chatheadless.test.ts b/packages/chat-headless/tests/chatheadless.test.ts index cec12cd..93c3d69 100644 --- a/packages/chat-headless/tests/chatheadless.test.ts +++ b/packages/chat-headless/tests/chatheadless.test.ts @@ -1,5 +1,6 @@ import { ChatHeadless, + ConversationState, Message, MessageNotes, MessageSource, @@ -13,7 +14,10 @@ import { MessageResponse, } from "@yext/chat-core"; import { ReduxStateManager } from "../src/ReduxStateManager"; -import { initialState } from "../src/slices/conversation"; +import { + initialState, + STATE_SESSION_STORAGE_KEY, +} from "../src/slices/conversation"; jest.mock("@yext/chat-core"); @@ -160,7 +164,7 @@ describe("Chat API methods work as expected", () => { }; it("getNextMessage works as expected", async () => { - const chatHeadless = new ChatHeadless(config); + const chatHeadless = new ChatHeadless(config, false); chatHeadless.setState({ conversation: initialState, meta: mockedMetaState, @@ -207,7 +211,7 @@ describe("Chat API methods work as expected", () => { it("updates loading status and throw error when an API request returns an error", async () => { const errorMessage = "Chat API error: FATAL_ERROR: Invalid API Key. (code: 1)"; - const chatHeadless = new ChatHeadless(config); + const chatHeadless = new ChatHeadless(config, false); const coreGetNextMessageSpy = jest .spyOn(ChatCore.prototype, "getNextMessage") .mockRejectedValue(errorMessage); @@ -231,7 +235,7 @@ describe("Chat API methods work as expected", () => { }); it("sends message array as is for initial message from bot", async () => { - const chatHeadless = new ChatHeadless(config); + const chatHeadless = new ChatHeadless(config, false); expect(chatHeadless.state.conversation.messages).toEqual([]); const coreGetNextMessageSpy = jest @@ -343,3 +347,55 @@ it("restartConversation works as expected", () => { }; expect(chatHeadless.state).toEqual(expectedState); }); + +describe("loadSessionState works as expected", () => { + const expectedState: ConversationState = { + conversationId: "dummy-id", + messages: [ + { + text: "How can I help you?", + source: MessageSource.BOT, + timestamp: "2023-05-15T17:39:58.019Z", + }, + ], + notes: { + currentGoal: "GOAL", + }, + isLoading: true, + }; + it("loads valid state from session storage", () => { + sessionStorage.setItem( + STATE_SESSION_STORAGE_KEY, + JSON.stringify(expectedState) + ); + const chatHeadless = new ChatHeadless(config); + expect(chatHeadless.state).toEqual({ + conversation: expectedState, + meta: {}, + }); + }); + + it("does not persist or load state when toggle is off", () => { + sessionStorage.setItem( + STATE_SESSION_STORAGE_KEY, + JSON.stringify(expectedState) + ); + const chatHeadless = new ChatHeadless(config, false); + expect(chatHeadless.state).toEqual({ + conversation: initialState, + meta: {}, + }); + const modifiedMessages = [ + ...expectedState.messages, + { + text: "This is a new message", + source: MessageSource.USER, + timestamp: "2023-05-15T17:39:58.019Z", + }, + ]; + chatHeadless.setMessages(modifiedMessages); + expect(sessionStorage.getItem(STATE_SESSION_STORAGE_KEY)).toEqual( + JSON.stringify(expectedState) + ); + }); +});