From 96635b3dda37045eaa1b4b96261d3ec5ba321088 Mon Sep 17 00:00:00 2001 From: anurag629 Date: Thu, 29 Oct 2026 01:14:12 +0530 Subject: [PATCH] feat(widget): update() for theme, branding and strings on a running chat --- .changeset/widget-update.md | 5 + e2e/tests/loader.spec.ts | 12 +- packages/widget/src/dom/fab.ts | 11 ++ packages/widget/src/dom/input.ts | 6 + packages/widget/src/dom/messages.ts | 4 +- packages/widget/src/dom/panel.ts | 98 ++++++++------ packages/widget/src/index.ts | 7 + packages/widget/src/loader/loader.ts | 21 ++- packages/widget/src/widget.ts | 100 ++++++++++++--- packages/widget/tests/api.test-d.ts | 12 +- packages/widget/tests/api.test.ts | 162 ++++++++++++++++++++++++ website/src/content/docs/widget/api.mdx | 23 ++++ 12 files changed, 399 insertions(+), 62 deletions(-) create mode 100644 .changeset/widget-update.md diff --git a/.changeset/widget-update.md b/.changeset/widget-update.md new file mode 100644 index 00000000..4b17dac7 --- /dev/null +++ b/.changeset/widget-update.md @@ -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. diff --git a/e2e/tests/loader.spec.ts b/e2e/tests/loader.spec.ts index 720ea99b..26a6984b 100644 --- a/e2e/tests/loader.spec.ts +++ b/e2e/tests/loader.spec.ts @@ -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; toggle(): void; newConversation(): void; version: string }; + type Api = { + sendMessage(text: string): Promise; + 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); diff --git a/packages/widget/src/dom/fab.ts b/packages/widget/src/dom/fab.ts index dc36dffd..ad0ce739 100644 --- a/packages/widget/src/dom/fab.ts +++ b/packages/widget/src/dom/fab.ts @@ -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(); } diff --git a/packages/widget/src/dom/input.ts b/packages/widget/src/dom/input.ts index 6addb15b..ed53101d 100644 --- a/packages/widget/src/dom/input.ts +++ b/packages/widget/src/dom/input.ts @@ -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(); } diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts index ec130783..20efd9b4 100644 --- a/packages/widget/src/dom/messages.ts +++ b/packages/widget/src/dom/messages.ts @@ -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'); diff --git a/packages/widget/src/dom/panel.ts b/packages/widget/src/dom/panel.ts index 362d8007..ae236a5c 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -24,9 +24,15 @@ export interface PanelOptions { onClose: () => void; } +/** The parts of the panel that `update()` can change. */ +export type PanelLabels = Pick; + export class Panel { private el: HTMLDivElement; private inline: boolean; + private options: PanelOptions; + private header: HTMLDivElement; + private footer: HTMLDivElement; messages: Messages; input: Input; messagesContainer: HTMLElement; @@ -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(); @@ -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 = `
${this.escapeHtml(options.branding.name)}
${this.escapeHtml(options.branding.subtitle)}
`; - 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'); @@ -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): 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 = `
${this.escapeHtml(branding.name)}
${this.escapeHtml(branding.subtitle)}
`; + 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) { diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 5539ab4b..fcc6ed72 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -1,6 +1,7 @@ import { Widget, type WidgetConfig, + type WidgetUpdate, type WidgetEventType, type WidgetEventHandler, } from './widget.js'; @@ -12,6 +13,7 @@ import { VERSION } from './version.js'; export { Widget, type WidgetConfig, + type WidgetUpdate, type WidgetEvents, type WidgetEventType, type WidgetEventHandler, @@ -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, diff --git a/packages/widget/src/loader/loader.ts b/packages/widget/src/loader/loader.ts index c8e260ca..fabcab1d 100644 --- a/packages/widget/src/loader/loader.ts +++ b/packages/widget/src/loader/loader.ts @@ -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 { @@ -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 | 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; } @@ -101,6 +103,8 @@ export function createLoader( // so a signed-in page doesn't load the chat early. let user: Parameters[0] | undefined; let context: Record | 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. */ @@ -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; }, @@ -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() { @@ -271,6 +289,7 @@ export function createLoader( widget = undefined; loading = undefined; config = undefined; + updates = []; }, on(event, handler) { diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 47f39019..f84fcd60 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -1,6 +1,6 @@ import { createShadowRoot, destroyShadowRoot } from './dom/shadow.js'; import { FAB } from './dom/fab.js'; -import { Panel } from './dom/panel.js'; +import { Panel, type PanelLabels } from './dom/panel.js'; import { WelcomeBubble } from './dom/bubble.js'; import { ChatClient } from './api/client.js'; import { ConversationStorage, type Persistence } from './storage.js'; @@ -107,6 +107,12 @@ export interface WidgetConfig { onPreChatSubmit?: (data: Record) => void; } +/** What `update()` can change on a running chat. */ +export type WidgetUpdate = Pick< + WidgetConfig, + 'branding' | 'welcomeMessage' | 'disclosure' | 'suggestions' | 'placeholder' | 'strings' +> & { theme?: Omit }; + /** What each widget event passes to its handlers. */ export interface WidgetEvents { open: []; @@ -247,15 +253,7 @@ export class Widget { position, inline: this.isInline, preChatEnabled: this.config.preChatForm?.enabled ?? false, - branding: { - name: this.config.branding?.name ?? this.locale.defaultName, - avatar: this.config.branding?.avatar, - subtitle: this.config.branding?.subtitle ?? this.locale.defaultSubtitle, - }, - placeholder: this.config.placeholder ?? this.locale.inputPlaceholder, - footerText: this.locale.poweredBy, - closeLabel: this.locale.closeButton, - sendLabel: this.locale.sendButton, + ...this.panelLabels(), messageActions: { onRetry: (messageId) => { void this.handleRetry(messageId); @@ -263,26 +261,18 @@ export class Widget { onRegenerate: (messageId) => { void this.handleRegenerate(messageId); }, - retryLabel: this.locale.retryButton, - regenerateLabel: this.locale.regenerateButton, - typingLabel: this.locale.typingIndicator, - sourcesLabel: this.locale.sourcesLabel, + ...this.actionLabels(), }, onSend: (text) => this.handleSend(text), onClose: () => this.close(), }); - const { disclosure } = this.config; - if (disclosure === false) { + if (this.config.disclosure === false) { console.info( "[chatcops] The AI disclosure is off (disclosure: false). Under the EU AI Act (Article 50), telling visitors they're talking to an AI is then up to you." ); - } else { - this.panel.messages.setDisclosure( - disclosure?.text ?? this.locale.disclosure, - disclosure?.learnMoreUrl ? { url: disclosure.learnMoreUrl, label: this.locale.disclosureLearnMore } : undefined - ); } + this.renderDisclosure(); // An inline chat is on screen from the start; a popup reads its saved // conversation when it's first opened, so nothing touches storage before. @@ -453,6 +443,74 @@ export class Widget { this.pageData = data && Object.keys(data).length ? { ...data } : undefined; } + /** + * Changes the look or wording of the running chat without loading it + * again: the theme (not `position`), branding, strings, placeholder, + * disclosure, welcome message and suggestions. What you leave out stays + * as it is. A conversation that has only the welcome message shows the + * new one; otherwise the welcome message and suggestions are for the + * next conversation. + */ + update(changes: WidgetUpdate): void { + const current = this.config; + const theme = { ...current.theme, ...changes.theme, position: current.theme?.position }; + this.config = { + ...current, + ...changes, + theme, + branding: { ...current.branding, ...changes.branding }, + strings: { ...current.strings, ...changes.strings }, + }; + this.locale = getWidgetLocale(this.config.locale ?? 'en', this.config.strings); + if (!this.mounted) return; + if (changes.theme) applyTheme(this.shadow, theme); + const l = this.locale; + this.fab?.setLabels(l.openChat, l.closeButton, l.unreadCount); + this.panel.update(this.panelLabels(), this.actionLabels()); + this.renderDisclosure(); + if (this.messages.length === 1 && this.messages[0].id === 'welcome') { + this.messages = []; + this.panel.messages.clear(); + this.showChatView(); + } + } + + private panelLabels(): PanelLabels { + const { branding, placeholder } = this.config; + const l = this.locale; + return { + branding: { + name: branding?.name ?? l.defaultName, + avatar: branding?.avatar, + subtitle: branding?.subtitle ?? l.defaultSubtitle, + }, + placeholder: placeholder ?? l.inputPlaceholder, + footerText: l.poweredBy, + closeLabel: l.closeButton, + sendLabel: l.sendButton, + }; + } + + private actionLabels() { + const l = this.locale; + return { + retryLabel: l.retryButton, + regenerateLabel: l.regenerateButton, + typingLabel: l.typingIndicator, + sourcesLabel: l.sourcesLabel, + }; + } + + private renderDisclosure(): void { + const { disclosure } = this.config; + if (disclosure === false) this.panel.messages.setDisclosure(); + else + this.panel.messages.setDisclosure( + disclosure?.text ?? this.locale.disclosure, + disclosure?.learnMoreUrl ? { url: disclosure.learnMoreUrl, label: this.locale.disclosureLearnMore } : undefined + ); + } + private isStale(generation: number): boolean { return this.destroyed || generation !== this.generation; } diff --git a/packages/widget/tests/api.test-d.ts b/packages/widget/tests/api.test-d.ts index 3b6a0627..260305a6 100644 --- a/packages/widget/tests/api.test-d.ts +++ b/packages/widget/tests/api.test-d.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest'; -import ChatCops, { type WidgetLocaleStrings } from '../src/index.js'; +import ChatCops, { type WidgetLocaleStrings, type WidgetUpdate } from '../src/index.js'; import type { ChatCopsLoader } from '../src/loader/loader.js'; describe('the widget API types', () => { @@ -24,6 +24,16 @@ describe('the widget API types', () => { ChatCops.setContext({ seats: 12 }); }); + it('types update() the same in both builds, without position', () => { + expectTypeOf(ChatCops.update).parameters.toEqualTypeOf<[WidgetUpdate]>(); + expectTypeOf().parameters.toEqualTypeOf<[WidgetUpdate]>(); + ChatCops.update({ theme: { mode: 'dark', accent: '#000' }, branding: { name: 'Sales' }, strings: { sendButton: 'Go' } }); + // @ts-expect-error: moving the chat needs init() + ChatCops.update({ theme: { position: 'bottom-left' } }); + // @ts-expect-error: the server can't change on a running chat + ChatCops.update({ apiUrl: '/other' }); + }); + it('takes partial strings and an optional direction in registerLocale', () => { expectTypeOf(ChatCops.registerLocale).parameters.toEqualTypeOf<[string, Partial, ({ dir?: 'ltr' | 'rtl' } | undefined)?]>(); ChatCops.registerLocale('nl', { sendButton: 'Verzenden' }); diff --git a/packages/widget/tests/api.test.ts b/packages/widget/tests/api.test.ts index 2287c4db..c6a833a8 100644 --- a/packages/widget/tests/api.test.ts +++ b/packages/widget/tests/api.test.ts @@ -198,6 +198,126 @@ describe('setUser and setContext', () => { }); }); +describe('update', () => { + const root = (widget: Widget) => internals(widget).shadow; + const host = (widget: Widget) => root(widget).host as HTMLElement; + + it('changes the theme in place and keeps what it leaves out', () => { + const widget = new Widget({ apiUrl: '/chat', theme: { mode: 'dark', accent: '#2563eb', fontFamily: 'Inter' } }); + widget.init(); + const panel = root(widget).querySelector('.cc-panel'); + widget.update({ theme: { accent: '#16a34a' } }); + expect(host(widget).style.getPropertyValue('--cc-theme-accent')).toBe('#16a34a'); + expect(host(widget).style.getPropertyValue('--cc-font')).toBe('Inter'); + expect(host(widget).dataset.ccMode).toBe('dark'); + widget.update({ theme: { mode: 'light' } }); + expect(host(widget).dataset.ccMode).toBe('light'); + expect(host(widget).style.getPropertyValue('--cc-theme-accent')).toBe('#16a34a'); + // The same panel: nothing was mounted again. + expect(root(widget).querySelector('.cc-panel')).toBe(panel); + widget.destroy(); + }); + + it('changes the branding, strings and placeholder, and the welcome message of an untouched chat', () => { + const widget = new Widget({ apiUrl: '/chat', branding: { name: 'Help', subtitle: 'Online' }, welcomeMessage: 'Hi!', suggestions: ['Pricing'] }); + widget.init(); + widget.open(); + widget.update({ + branding: { name: 'Sales' }, + placeholder: 'Ask sales', + strings: { sendButton: 'Go', closeButton: 'Shut', openChat: 'Talk to us', poweredBy: 'Made here' }, + welcomeMessage: 'Want a demo?', + suggestions: ['Book a demo'], + }); + const shadow = root(widget); + expect(shadow.querySelector('.cc-header-name')?.textContent).toBe('Sales'); + expect(shadow.querySelector('.cc-header-subtitle')?.textContent).toBe('Online'); + expect(shadow.querySelector('.cc-panel')?.getAttribute('aria-label')).toBe('Sales'); + expect(shadow.querySelector('.cc-header-close')?.getAttribute('aria-label')).toBe('Shut'); + expect(shadow.querySelector('.cc-input')?.placeholder).toBe('Ask sales'); + expect(shadow.querySelector('.cc-send-btn')?.getAttribute('aria-label')).toBe('Go'); + expect(shadow.querySelector('.cc-footer')?.textContent).toBe('Made here'); + // Open, so the button is the close button. + expect(shadow.querySelector('.cc-fab')?.getAttribute('aria-label')).toBe('Shut'); + expect(texts(widget)).toEqual(['Want a demo?']); + expect(shadow.textContent).toContain('Book a demo'); + expect(shadow.textContent).not.toContain('Pricing'); + widget.close(); + expect(shadow.querySelector('.cc-fab')?.getAttribute('aria-label')).toBe('Talk to us'); + widget.destroy(); + }); + + it('keeps focus on the close button when the header is redrawn', () => { + const widget = new Widget({ apiUrl: '/chat' }); + widget.init(); + widget.open(); + root(widget).querySelector('.cc-header-close')?.focus(); + widget.update({ branding: { name: 'Sales' } }); + expect(root(widget).activeElement).toBe(root(widget).querySelector('.cc-header-close')); + widget.destroy(); + }); + + it('leaves a conversation that has started alone, and uses the new welcome for the next one', async () => { + const widget = new Widget({ apiUrl: '/chat', welcomeMessage: 'Hi!' }); + widget.init(); + const stream = mockChatStream(); + const sent = widget.sendMessage('Hello'); + await tick(); + await reply(stream, 'Hey there.'); + await sent; + widget.update({ welcomeMessage: 'Want a demo?' }); + expect(texts(widget)[0]).toContain('Hi!'); + expect(texts(widget).at(-1)).toContain('Hey there.'); + widget.newConversation(); + expect(texts(widget)).toEqual(['Want a demo?']); + widget.destroy(); + }); + + it('changes, removes and brings back the disclosure', () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined); + const widget = new Widget({ apiUrl: '/chat' }); + widget.init(); + const note = () => root(widget).querySelector('.cc-disclosure'); + widget.update({ disclosure: { text: 'An AI answers here.', learnMoreUrl: 'https://example.com/ai' } }); + expect(note()?.textContent).toContain('An AI answers here.'); + expect(note()?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/ai'); + widget.update({ disclosure: false }); + expect(note()).toBeNull(); + widget.update({ disclosure: { text: 'Back again.' } }); + expect(note()?.textContent).toBe('Back again.'); + expect(root(widget).querySelectorAll('.cc-disclosure')).toHaveLength(1); + expect(info).not.toHaveBeenCalled(); + widget.destroy(); + }); + + it('uses new labels for the retry and typing parts drawn after it', () => { + const widget = new Widget({ apiUrl: '/chat' }); + widget.init(); + widget.update({ strings: { typingIndicator: 'Writing' } }); + const panel = (widget as unknown as { panel: { messages: { showTyping(): void } } }).panel; + panel.messages.showTyping(); + expect(root(widget).querySelector('.cc-typing')?.textContent).toContain('Writing'); + widget.destroy(); + }); + + it('before init, changes what init draws', () => { + const widget = new Widget({ apiUrl: '/chat', branding: { name: 'Help' } }); + widget.update({ branding: { name: 'Sales' }, theme: { accent: '#16a34a' } }); + widget.init(); + expect(root(widget).querySelector('.cc-header-name')?.textContent).toBe('Sales'); + expect(host(widget).style.getPropertyValue('--cc-theme-accent')).toBe('#16a34a'); + widget.destroy(); + }); + + it('cannot move the chat: position stays as it was', () => { + const widget = new Widget({ apiUrl: '/chat', theme: { position: 'bottom-left' } }); + widget.init(); + widget.update({ theme: { position: 'bottom-right' } as never }); + expect((internals(widget) as unknown as { config: { theme: { position: string } } }).config.theme.position).toBe('bottom-left'); + widget.destroy(); + }); +}); + describe('the ChatCops API (npm and single-file builds)', () => { it('toggles, and has the version', () => { ChatCops.init({ apiUrl: '/chat' }); @@ -213,6 +333,13 @@ describe('the ChatCops API (npm and single-file builds)', () => { expect(VERSION).toMatch(/^\d+\.\d+\.\d+/); }); + it('update changes the running chat', () => { + ChatCops.init({ apiUrl: '/chat', branding: { name: 'Help' } }); + ChatCops.update({ branding: { name: 'Sales' } }); + const root = document.querySelector('.chatcops-root')?.shadowRoot; + expect(root?.querySelector('.cc-header-name')?.textContent).toBe('Sales'); + }); + it('registerLocale adds a language, English for what it leaves out, and its direction', () => { ChatCops.registerLocale('xx', { sendButton: 'Send it', welcomeMessage: 'Hey there' }, { dir: 'rtl' }); const widget = ChatCops.init({ apiUrl: '/chat', locale: 'xx' }); @@ -330,4 +457,39 @@ describe('the script tag loader API', () => { expect(root?.querySelector('.cc-send-btn')?.getAttribute('aria-label')).toBe('Go'); loader.destroy(); }); + + it('keeps update() until the chat loads, and themes the launcher meanwhile', async () => { + const load = panel(); + const loader = createLoader(load); + loader.init({ apiUrl: '/chat', theme: { mode: 'dark', accent: '#2563eb' }, branding: { name: 'Help' } }); + loader.update({ theme: { accent: '#16a34a' }, strings: { openChat: 'Talk to us' } }); + loader.update({ branding: { name: 'Sales' } }); + expect(load).not.toHaveBeenCalled(); + const launcher = document.querySelector('.chatcops-root') as HTMLElement; + expect(launcher.style.getPropertyValue('--cc-theme-accent')).toBe('#16a34a'); + expect(launcher.dataset.ccMode).toBe('dark'); + expect(launcher.shadowRoot?.querySelector('.cc-fab')?.getAttribute('aria-label')).toBe('Talk to us'); + + loader.open(); + await tick(); + await tick(); + const root = document.querySelector('.chatcops-root')?.shadowRoot; + expect(root?.querySelector('.cc-header-name')?.textContent).toBe('Sales'); + expect((root?.host as HTMLElement | undefined)?.style.getPropertyValue('--cc-theme-accent')).toBe('#16a34a'); + loader.update({ branding: { name: 'Support' } }); + expect(root?.querySelector('.cc-header-name')?.textContent).toBe('Support'); + loader.destroy(); + }); + + it('drops updates made before init() again', async () => { + const loader = createLoader(panel()); + loader.init({ apiUrl: '/chat', branding: { name: 'Help' } }); + loader.update({ branding: { name: 'Sales' } }); + loader.init({ apiUrl: '/chat', branding: { name: 'Docs' } }); + loader.open(); + await tick(); + await tick(); + expect(document.querySelector('.chatcops-root')?.shadowRoot?.querySelector('.cc-header-name')?.textContent).toBe('Docs'); + loader.destroy(); + }); }); diff --git a/website/src/content/docs/widget/api.mdx b/website/src/content/docs/widget/api.mdx index ba30aeda..20d058b1 100644 --- a/website/src/content/docs/widget/api.mdx +++ b/website/src/content/docs/widget/api.mdx @@ -156,6 +156,29 @@ Details the bot should know for the next messages, such as the visitor's plan or ChatCops.setContext({ plan: 'Team', seats: '12' }); ``` +### `ChatCops.update(changes)` + +Change how the running chat looks or what it says, without loading it again. It takes these options from `init()`: + +- `theme`, except `position`: moving the chat needs `init()` +- `branding`, `placeholder` and `strings` +- `disclosure` +- `welcomeMessage` and `suggestions` + +What you leave out stays as it is, and that goes for each field inside `theme`, `branding` and `strings` too. A chat with only the welcome message shows the new one right away. Once the visitor has written, the new welcome message and suggestions wait for the next conversation. With the script tag build, a call before the chat has loaded is kept until it does, and the launcher button takes the new theme straight away. + +```typescript +// A single-page app moving to its pricing page: +ChatCops.update({ + branding: { name: 'Sales', subtitle: 'Usually replies in a minute' }, + welcomeMessage: 'Questions about plans? Ask here.', + suggestions: ['Compare plans', 'Is there a free trial?'], +}); + +// Following the site's own dark mode toggle: +ChatCops.update({ theme: { mode: 'dark' } }); +``` + ### `ChatCops.registerLocale(code, strings, options?)` Add a language the widget doesn't ship, or replace one of its own. Strings you leave out stay in English. Pass `{ dir: 'rtl' }` for a right-to-left language. Register before `init()`, then use the code as `locale`.