Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/widget-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

`ChatCops.update(changes)` changes the running chat without loading it again: the theme (all but `position`), `branding`, `placeholder`, `strings`, `disclosure`, `welcomeMessage` and `suggestions`. Fields left out keep their current values. A chat that has only its welcome message shows the new one; once the visitor has written, the new welcome message and suggestions are used from the next conversation. In the script tag build, calls made before the chat loads are kept until it does, and the launcher takes the new theme right away.
12 changes: 11 additions & 1 deletion e2e/tests/loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,25 @@ test('the chat loads its translation next to the panel, and falls back to Englis
test('a page can drive the chat through window.ChatCops before it has loaded', async ({ page }) => {
await page.goto('/');
await expect(page.locator(fab)).toBeVisible();
type Api = { sendMessage(text: string): Promise<void>; toggle(): void; newConversation(): void; version: string };
type Api = {
sendMessage(text: string): Promise<void>;
toggle(): void;
newConversation(): void;
update(changes: object): void;
version: string;
};
const version = await page.evaluate(() => (window as unknown as { ChatCops: Api }).ChatCops.version);
expect(version).toMatch(/^\d+\.\d+\.\d+/);
// Kept until the chat loads, and drawn when it does.
await page.evaluate(() => (window as unknown as { ChatCops: Api }).ChatCops.update({ branding: { name: 'Sales desk' } }));

await page.evaluate(() => (window as unknown as { ChatCops: Api }).ChatCops.sendMessage('Hi from the page'));
await expect(page.locator('.cc-panel')).toHaveClass(/cc-visible/);
await expect(page.locator('.cc-message-user').last()).toContainText('Hi from the page');
await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the test server.');

await expect(page.locator('.cc-header-name')).toHaveText('Sales desk');

await page.evaluate(() => (window as unknown as { ChatCops: Api }).ChatCops.newConversation());
await expect(page.locator('.cc-message-user')).toHaveCount(0);

Expand Down
11 changes: 11 additions & 0 deletions packages/widget/src/dom/fab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ export class FAB {
this.el.setAttribute('aria-expanded', String(open));
}

setLabels(openLabel: string, closeLabel: string, unreadLabel: string): void {
this.openLabel = openLabel;
this.closeLabel = closeLabel;
this.unreadLabel = unreadLabel;
const shown = this.badge.textContent;
this.el.setAttribute(
'aria-label',
this.el.classList.contains('cc-open') ? closeLabel : shown ? `${openLabel}, ${unreadLabel.replace('{count}', shown)}` : openLabel
);
}

focus(): void {
this.el.focus();
}
Expand Down
6 changes: 6 additions & 0 deletions packages/widget/src/dom/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export class Input {
parent.appendChild(this.area);
}

setLabels(placeholder: string, sendLabel: string): void {
this.textarea.placeholder = placeholder;
this.textarea.setAttribute('aria-label', placeholder);
this.sendBtn.setAttribute('aria-label', sendLabel);
}

focus(): void {
this.textarea.focus();
}
Expand Down
4 changes: 3 additions & 1 deletion packages/widget/src/dom/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,10 @@ export class Messages {
* The note that visitors are talking to an AI, always first in the log.
* Only http(s) links become a "Learn more" link.
*/
setDisclosure(text: string, learnMore?: { url: string; label: string }): void {
setDisclosure(text?: string, learnMore?: { url: string; label: string }): void {
this.disclosureEl?.remove();
this.disclosureEl = null;
if (!text) return;
const note = document.createElement('div');
note.className = 'cc-disclosure';
note.setAttribute('role', 'note');
Expand Down
98 changes: 61 additions & 37 deletions packages/widget/src/dom/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ export interface PanelOptions {
onClose: () => void;
}

/** The parts of the panel that `update()` can change. */
export type PanelLabels = Pick<PanelOptions, 'branding' | 'placeholder' | 'footerText' | 'closeLabel' | 'sendLabel'>;

export class Panel {
private el: HTMLDivElement;
private inline: boolean;
private options: PanelOptions;
private header: HTMLDivElement;
private footer: HTMLDivElement;
messages: Messages;
input: Input;
messagesContainer: HTMLElement;
Expand All @@ -45,7 +51,6 @@ export class Panel {
this.el.id = PANEL_ID;
this.el.setAttribute('role', 'dialog');
this.el.setAttribute('aria-modal', 'true');
this.el.setAttribute('aria-label', options.branding.name);
this.el.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
options.onClose();
Expand Down Expand Up @@ -78,38 +83,11 @@ export class Panel {
this.setHidden(true);
}

// Header
const header = document.createElement('div');
header.className = 'cc-header';

if (options.branding.avatar) {
const img = document.createElement('img');
img.className = 'cc-header-avatar';
img.src = options.branding.avatar;
img.alt = options.branding.name;
header.appendChild(img);
} else {
const placeholder = document.createElement('div');
placeholder.className = 'cc-header-avatar-placeholder';
placeholder.innerHTML = BOT_ICON;
header.appendChild(placeholder);
}

const info = document.createElement('div');
info.className = 'cc-header-info';
info.innerHTML = `<div class="cc-header-name">${this.escapeHtml(options.branding.name)}</div><div class="cc-header-subtitle">${this.escapeHtml(options.branding.subtitle)}</div>`;
header.appendChild(info);

if (!options.inline) {
const closeBtn = document.createElement('button');
closeBtn.className = 'cc-header-close';
closeBtn.setAttribute('aria-label', options.closeLabel);
closeBtn.innerHTML = CLOSE_ICON;
closeBtn.addEventListener('click', options.onClose);
header.appendChild(closeBtn);
}

this.el.appendChild(header);
this.options = options;
this.header = document.createElement('div');
this.header.className = 'cc-header';
this.renderHeader();
this.el.appendChild(this.header);

if (options.preChatEnabled) {
this.messagesContainer = document.createElement('div');
Expand All @@ -128,14 +106,60 @@ export class Panel {
});

// Footer
const footer = document.createElement('div');
footer.className = 'cc-footer';
footer.innerHTML = options.footerText;
this.el.appendChild(footer);
this.footer = document.createElement('div');
this.footer.className = 'cc-footer';
this.footer.innerHTML = options.footerText;
this.el.appendChild(this.footer);

root.appendChild(this.el);
}

/** New wording or branding, applied in place. */
update(labels: PanelLabels, actionLabels?: Partial<MessageActions>): void {
Object.assign(this.options, labels);
// Messages reads its labels from this same object as it renders.
if (this.options.messageActions) Object.assign(this.options.messageActions, actionLabels);
this.renderHeader();
this.input.setLabels(labels.placeholder, labels.sendLabel);
this.footer.innerHTML = labels.footerText;
}

private renderHeader(): void {
const { branding, closeLabel, inline, onClose } = this.options;
const hadFocus = this.header.contains((this.el.getRootNode() as ShadowRoot | Document).activeElement);
this.header.replaceChildren();
if (!inline) this.el.setAttribute('aria-label', branding.name);

if (branding.avatar) {
const img = document.createElement('img');
img.className = 'cc-header-avatar';
img.src = branding.avatar;
img.alt = branding.name;
this.header.appendChild(img);
} else {
const placeholder = document.createElement('div');
placeholder.className = 'cc-header-avatar-placeholder';
placeholder.innerHTML = BOT_ICON;
this.header.appendChild(placeholder);
}

const info = document.createElement('div');
info.className = 'cc-header-info';
info.innerHTML = `<div class="cc-header-name">${this.escapeHtml(branding.name)}</div><div class="cc-header-subtitle">${this.escapeHtml(branding.subtitle)}</div>`;
this.header.appendChild(info);

if (!inline) {
const closeBtn = document.createElement('button');
closeBtn.className = 'cc-header-close';
closeBtn.setAttribute('aria-label', closeLabel);
closeBtn.innerHTML = CLOSE_ICON;
closeBtn.addEventListener('click', onClose);
this.header.appendChild(closeBtn);
// Re-rendered under the visitor's focus: keep it on the close button.
if (hadFocus) closeBtn.focus();
}
}

show(): void {
this.el.classList.add('cc-visible');
if (!this.inline) {
Expand Down
7 changes: 7 additions & 0 deletions packages/widget/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Widget,
type WidgetConfig,
type WidgetUpdate,
type WidgetEventType,
type WidgetEventHandler,
} from './widget.js';
Expand All @@ -12,6 +13,7 @@ import { VERSION } from './version.js';
export {
Widget,
type WidgetConfig,
type WidgetUpdate,
type WidgetEvents,
type WidgetEventType,
type WidgetEventHandler,
Expand Down Expand Up @@ -87,6 +89,11 @@ const ChatCops = {
instance?.setContext(data);
},

/** Changes the theme, branding or wording of the running chat. What you leave out stays as it is. */
update(changes: WidgetUpdate): void {
instance?.update(changes);
},

/** The widget's version. */
version: VERSION,

Expand Down
21 changes: 20 additions & 1 deletion packages/widget/src/loader/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CHAT_ICON } from '../dom/icons.js';
import { HOST_ID, nextHostId, resolveDirection, setLanguageDirection } from '../dom/shadow.js';
import { applyTheme } from '../theme.js';
import type { WidgetLocaleStrings } from '../i18n.js';
import type { Widget, WidgetConfig, WidgetEventHandler, WidgetEventType } from '../widget.js';
import type { Widget, WidgetConfig, WidgetEventHandler, WidgetEventType, WidgetUpdate } from '../widget.js';
import { VERSION } from '../version.js';

export interface PanelModule {
Expand Down Expand Up @@ -34,6 +34,8 @@ export interface ChatCopsLoader {
setUser(user: { name?: string; email?: string; token?: string } | null): void;
/** Details the bot should know for the next messages. `null` removes them. Kept until the chat loads. */
setContext(data: Record<string, string> | null): void;
/** Changes the theme, branding or wording of the chat. What you leave out stays as it is. Kept until the chat loads. */
update(changes: WidgetUpdate): void;
/** The widget's version. */
readonly version: string;
}
Expand Down Expand Up @@ -101,6 +103,8 @@ export function createLoader(
// so a signed-in page doesn't load the chat early.
let user: Parameters<ChatCopsLoader['setUser']>[0] | undefined;
let context: Record<string, string> | null | undefined;
// update() calls before the chat loads, for this init() only.
let updates: WidgetUpdate[] = [];
const directionOf = (current: WidgetConfig) => current.dir ?? (current.locale ? registered.get(current.locale)?.dir : undefined);

/** The translated strings for a config, loaded once per locale. */
Expand Down Expand Up @@ -153,6 +157,8 @@ export function createLoader(
next.init();
if (user !== undefined) next.setUser(user);
if (context !== undefined) next.setContext(context);
for (const changes of updates) next.update(changes);
updates = [];
widget = next;
return next;
},
Expand Down Expand Up @@ -261,6 +267,18 @@ export function createLoader(
widget?.setContext(next);
},

update(changes) {
if (widget) {
widget.update(changes);
return;
}
updates.push(changes);
const shadow = launcher?.shadowRoot;
// The whole theme so far: a partial one would drop the mode it doesn't name.
if (changes.theme && shadow) applyTheme(shadow, Object.assign({}, config?.theme, ...updates.map((u) => u.theme)));
if (changes.strings?.openChat) button?.setAttribute('aria-label', changes.strings.openChat);
},

version: VERSION,

destroy() {
Expand All @@ -271,6 +289,7 @@ export function createLoader(
widget = undefined;
loading = undefined;
config = undefined;
updates = [];
},

on(event, handler) {
Expand Down
Loading
Loading