Skip to content

Commit

Permalink
save conversation state to sessionStorage (#11)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
popestr authored May 19, 2023
1 parent 4240b1d commit 8f3e20d
Show file tree
Hide file tree
Showing 9 changed files with 122 additions and 14 deletions.
6 changes: 4 additions & 2 deletions apps/test-site/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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. |

2 changes: 1 addition & 1 deletion packages/chat-headless/docs/chat-headless.chatheadless.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export declare class ChatHeadless

| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(config)](./chat-headless.chatheadless._constructor_.md) | | Constructs a new instance of the <code>ChatHeadless</code> class |
| [(constructor)(config, saveToSessionStorage)](./chat-headless.chatheadless._constructor_.md) | | Constructs a new instance of the [ChatHeadless](./chat-headless.chatheadless.md) class. |

## Properties

Expand Down
2 changes: 1 addition & 1 deletion packages/chat-headless/etc/chat-headless.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export { ChatConfig }

// @public
export class ChatHeadless {
constructor(config: ChatConfig);
constructor(config: ChatConfig, saveToSessionStorage?: boolean);
addListener<T>(listener: StateListener<T>): Unsubscribe;
getNextMessage(text?: string, source?: MessageSource): Promise<MessageResponse>;
restartConversation(): void;
Expand Down
2 changes: 1 addition & 1 deletion packages/chat-headless/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
26 changes: 25 additions & 1 deletion packages/chat-headless/src/ChatHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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)
),
});
}
}

/**
Expand Down
16 changes: 16 additions & 0 deletions packages/chat-headless/src/slices/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 60 additions & 4 deletions packages/chat-headless/tests/chatheadless.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ChatHeadless,
ConversationState,
Message,
MessageNotes,
MessageSource,
Expand All @@ -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");

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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)
);
});
});

0 comments on commit 8f3e20d

Please sign in to comment.