Skip to content

Commit fcf6f52

Browse files
authored
[ch-chat] Improve ch-chat's default values and fix items property not being reactive (#452)
* Improve ch-chat default values Breaking changes: - The `callbacks` property is no longer a required property. It now can be undefined. - The `generatingResponse` property is no longer a required property. It is now `false` by default. - The `isMobile` property is no longer a required property. It is now `false` by default. - The `loadingState` property is no longer a required property. It is now `"initial"` by default. - The `translations` property is no longer a required property. It now has a default value to implement English translations. * Fix ch-chat's "items" property not being reactive * Add unit tests
1 parent 62766b9 commit fcf6f52

File tree

3 files changed

+193
-28
lines changed

3 files changed

+193
-28
lines changed

src/components/chat/chat.tsx

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export class ChChat {
5151
/**
5252
* Specifies the callbacks required in the control.
5353
*/
54-
@Prop() readonly callbacks!: ChatInternalCallbacks;
54+
@Prop() readonly callbacks?: ChatInternalCallbacks | undefined;
5555

5656
/**
5757
* This property allows us to implement custom rendering for the code blocks.
@@ -66,7 +66,7 @@ export class ChChat {
6666
/**
6767
* `true` if a response for the assistant is being generated.
6868
*/
69-
@Prop() readonly generatingResponse!: boolean;
69+
@Prop() readonly generatingResponse?: boolean = false;
7070

7171
/**
7272
* Specifies an object containing an HTMLAnchorElement reference. Use this
@@ -84,7 +84,7 @@ export class ChChat {
8484
/**
8585
* Specifies if the chat is used in a mobile device.
8686
*/
87-
@Prop() readonly isMobile!: boolean;
87+
@Prop() readonly isMobile?: boolean = false;
8888

8989
/**
9090
* Specifies the items that the chat will display.
@@ -94,7 +94,7 @@ export class ChChat {
9494
/**
9595
* Specifies if the chat is waiting for the data to be loaded.
9696
*/
97-
@Prop({ mutable: true }) loadingState!: SmartGridDataState;
97+
@Prop({ mutable: true }) loadingState?: SmartGridDataState = "initial";
9898

9999
/**
100100
* Specifies the theme to be used for rendering the markdown.
@@ -105,7 +105,26 @@ export class ChChat {
105105
/**
106106
* Specifies the literals required in the control.
107107
*/
108-
@Prop() readonly translations!: ChatTranslations;
108+
@Prop() readonly translations: ChatTranslations = {
109+
accessibleName: {
110+
clearChat: "Clear chat",
111+
copyResponseButton: "Copy assistant response",
112+
downloadCodeButton: "Download code",
113+
imagePicker: "Select images",
114+
removeUploadedImage: "Remove uploaded image",
115+
sendButton: "Send",
116+
sendInput: "Message",
117+
stopGeneratingAnswerButton: "Stop generating answer"
118+
},
119+
placeholder: {
120+
sendInput: "Ask me a question..."
121+
},
122+
text: {
123+
copyCodeButton: "Copy code",
124+
processing: `Processing...`,
125+
sourceFiles: "Source files:"
126+
}
127+
};
109128

110129
/**
111130
* This property allows us to implement custom rendering of chat items.
@@ -264,7 +283,7 @@ export class ChChat {
264283
};
265284

266285
await this.#addUserMessageToRecordAndFocusInput(userMessageToAdd);
267-
this.callbacks.sendChatToLLM(this.items);
286+
this.callbacks?.sendChatToLLM(this.items);
268287

269288
// Queue a new re-render
270289
forceUpdate(this);
@@ -286,7 +305,7 @@ export class ChChat {
286305

287306
// Upload the image to the server asynchronously
288307
this.callbacks
289-
.uploadImage(imageToUpload.file)
308+
?.uploadImage(imageToUpload.file)
290309
.then(imageSrc => {
291310
userContent[index + 1] = {
292311
type: "image_url",
@@ -297,7 +316,7 @@ export class ChChat {
297316
this.uploadingImagesToTheServer--;
298317

299318
if (this.uploadingImagesToTheServer === 0) {
300-
this.callbacks.sendChatToLLM(this.items);
319+
this.callbacks?.sendChatToLLM(this.items);
301320
}
302321
})
303322
.catch(() => {
@@ -307,7 +326,7 @@ export class ChChat {
307326
this.uploadingImagesToTheServer--;
308327

309328
if (this.uploadingImagesToTheServer === 0) {
310-
this.callbacks.sendChatToLLM(this.items);
329+
this.callbacks?.sendChatToLLM(this.items);
311330
}
312331
});
313332
});
@@ -326,7 +345,7 @@ export class ChChat {
326345
#handleStopGenerating = (event: MouseEvent) => {
327346
event.stopPropagation();
328347

329-
this.callbacks.stopGeneratingAnswer!();
348+
this.callbacks?.stopGeneratingAnswer!();
330349
};
331350

332351
// #handleFilesChanged = (event: ImagePickerCustomEvent<FileList | null>) => {
@@ -479,7 +498,7 @@ export class ChChat {
479498
}}
480499
part="send-container"
481500
>
482-
{this.generatingResponse && this.callbacks.stopGeneratingAnswer && (
501+
{this.generatingResponse && this.callbacks?.stopGeneratingAnswer && (
483502
<button
484503
class="stop-generating-answer-button"
485504
part="stop-generating-answer-button"
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing";
2+
import { ChatMessage } from "../types";
3+
4+
const INITIAL_LOAD_RENDERED_CONTENT =
5+
'<div class="loading-chat" slot="empty-chat"></div><div class="send-container" part="send-container"><div class="send-input-wrapper" part="send-input-wrapper"><ch-edit part="send-input" class="ch-edit--cursor-text ch-edit--multiline hydrated" data-text-align=""></ch-edit></div><button aria-label="Send" title="Send" class="send-or-audio-button" part="send-button" type="button"></button></div>';
6+
7+
const EMPTY_RENDERED_CONTENT =
8+
'<slot name="empty-chat"></slot><div class="send-container" part="send-container"><div class="send-input-wrapper" part="send-input-wrapper"><ch-edit part="send-input" class="ch-edit--cursor-text ch-edit--multiline hydrated" data-text-align=""></ch-edit></div><button aria-label="Send" title="Send" class="send-or-audio-button" part="send-button" type="button"></button></div>';
9+
10+
const chatModel1: ChatMessage[] = [
11+
{
12+
id: "1",
13+
role: "user",
14+
content: "Something"
15+
},
16+
{
17+
id: "2",
18+
role: "assistant",
19+
content: "Something 2"
20+
},
21+
{
22+
id: "3",
23+
role: "user",
24+
content: "Something 3"
25+
}
26+
];
27+
28+
const chatModel2: ChatMessage[] = [
29+
{
30+
id: "1",
31+
role: "user",
32+
content: "A different text"
33+
},
34+
{
35+
id: "2",
36+
role: "assistant",
37+
content: "A different text 2"
38+
},
39+
{
40+
id: "3",
41+
role: "user",
42+
content: "A different text 3"
43+
}
44+
];
45+
46+
const USER_CELL = <T extends string>(content: T) =>
47+
`<ch-smart-grid-cell part="message user" role="gridcell" class="hydrated" data-did-load="true">${content}</ch-smart-grid-cell>`;
48+
49+
const ASSISTANT_CELL = () =>
50+
`<ch-smart-grid-cell part="message assistant undefined" role="gridcell" class="hydrated" data-did-load="true"><div aria-live="polite" aria-busy="false" class="assistant-content" part="assistant-content"><ch-markdown-viewer class="hydrated"></ch-markdown-viewer><button aria-label="Copy assistant response" title="Copy assistant response" part="copy-response-button" type="button"></button></div></ch-smart-grid-cell>`;
51+
52+
describe("[ch-chat][items reactivity]", () => {
53+
let page: E2EPage;
54+
let chatRef: E2EElement;
55+
56+
const getChatRenderedContent = () =>
57+
page.evaluate(() =>
58+
document.querySelector("ch-chat").shadowRoot.innerHTML.toString()
59+
);
60+
61+
const getChatRenderedItems = () =>
62+
page.evaluate(() =>
63+
document
64+
.querySelector("ch-chat")
65+
.shadowRoot.querySelector("ch-virtual-scroller")
66+
.innerHTML.toString()
67+
);
68+
69+
beforeEach(async () => {
70+
page = await newE2EPage({
71+
failOnConsoleError: true,
72+
html: `<ch-chat></ch-chat>`
73+
});
74+
chatRef = await page.find("ch-chat");
75+
});
76+
77+
it("should have a shadowRoot", () => {
78+
expect(chatRef.shadowRoot).toBeTruthy();
79+
});
80+
81+
it("should display the loading state by default", async () => {
82+
expect(await getChatRenderedContent()).toEqual(
83+
INITIAL_LOAD_RENDERED_CONTENT
84+
);
85+
});
86+
87+
it("should display the loading state by default even if the model has items", async () => {
88+
await chatRef.setProperty("items", chatModel1);
89+
await page.waitForChanges();
90+
91+
expect(await getChatRenderedContent()).toEqual(
92+
INITIAL_LOAD_RENDERED_CONTENT
93+
);
94+
});
95+
96+
it('should not render any items by default if loadingState = "all-records-loaded"', async () => {
97+
await chatRef.setProperty("loadingState", "all-records-loaded");
98+
await page.waitForChanges();
99+
100+
expect(await getChatRenderedContent()).toEqual(EMPTY_RENDERED_CONTENT);
101+
});
102+
103+
it('should render items if the model is not empty and the loadingState = "all-records-loaded"', async () => {
104+
await chatRef.setProperty("items", chatModel1);
105+
await chatRef.setProperty("loadingState", "more-data-to-fetch");
106+
await page.waitForChanges();
107+
108+
// Necessary to wait for the virtual scroller to render the items in the next frame
109+
await page.waitForChanges();
110+
111+
expect(await getChatRenderedItems()).toEqual(
112+
USER_CELL("Something") + ASSISTANT_CELL() + USER_CELL("Something 3")
113+
);
114+
});
115+
116+
it('should update the rendered items if the model is updated at runtime with loadingState = "all-records-loaded"', async () => {
117+
await chatRef.setProperty("items", chatModel1);
118+
await chatRef.setProperty("loadingState", "more-data-to-fetch");
119+
await page.waitForChanges();
120+
121+
// Necessary to wait for the virtual scroller to render the items in the next frame
122+
await page.waitForChanges();
123+
124+
expect(await getChatRenderedItems()).toEqual(
125+
USER_CELL("Something") + ASSISTANT_CELL() + USER_CELL("Something 3")
126+
);
127+
128+
await chatRef.setProperty("items", chatModel2);
129+
await page.waitForChanges();
130+
131+
expect(await getChatRenderedItems()).toEqual(
132+
USER_CELL("A different text") +
133+
ASSISTANT_CELL() +
134+
USER_CELL("A different text 3")
135+
);
136+
});
137+
});

src/virtual-scroller/virtual-scroller.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,9 @@ export class ChVirtualScroller implements ComponentInterface {
109109
*/
110110
@Prop() readonly items!: SmartGridModel | undefined;
111111
@Watch("items")
112-
itemsChanged(newItems: SmartGridModel, oldItems: SmartGridModel) {
113-
if (emptyItems(oldItems)) {
114-
this.#updateViewportItemsOnInitialRender(newItems);
115-
}
112+
itemsChanged(newItems: SmartGridModel) {
113+
this.#resetVirtualScrollerState();
114+
this.#setViewportItemsOnInitialRender(newItems);
116115
}
117116

118117
/**
@@ -335,7 +334,27 @@ export class ChVirtualScroller implements ComponentInterface {
335334
});
336335
};
337336

338-
#updateViewportItemsOnInitialRender = (items: SmartGridModel) => {
337+
#resetVirtualScrollerState = () => {
338+
this.#virtualStartSize = 0;
339+
this.#virtualEndSize = 0;
340+
341+
// Render the last items when the scroll is inverted
342+
if (this.inverseLoading) {
343+
const lastIndex = this.items.length - 1;
344+
345+
this.#startIndex = lastIndex;
346+
this.#endIndex = lastIndex;
347+
} else {
348+
this.#startIndex = 0;
349+
this.#endIndex = 0;
350+
}
351+
352+
if (this.mode === "virtual-scroll") {
353+
this.#virtualSizes = new Map();
354+
}
355+
};
356+
357+
#setViewportItemsOnInitialRender = (items: SmartGridModel) => {
339358
if (emptyItems(items)) {
340359
this.#emitVirtualItemsChange();
341360
return;
@@ -472,22 +491,12 @@ export class ChVirtualScroller implements ComponentInterface {
472491
// Listen for the render of the smart cells
473492
this.el.addEventListener("smartCellDidLoad", this.#handleRenderedCell);
474493

475-
if (this.mode === "virtual-scroll") {
476-
this.#virtualSizes = new Map();
477-
}
478-
479-
// Render the last items when the scroll is inverted
480-
if (this.inverseLoading) {
481-
const lastIndex = this.items.length - 1;
482-
483-
this.#startIndex = lastIndex;
484-
this.#endIndex = lastIndex;
485-
}
494+
this.#resetVirtualScrollerState();
486495
}
487496

488497
componentDidLoad(): void {
489498
this.#smartGrid = this.el.closest("ch-smart-grid");
490-
this.#updateViewportItemsOnInitialRender(this.items);
499+
this.#setViewportItemsOnInitialRender(this.items);
491500
}
492501

493502
disconnectedCallback(): void {

0 commit comments

Comments
 (0)