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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,4 @@ Hellotext.initialize('HELLOTEXT_BUSINESS_ID', configurationOptions)
| autoGenerateSession | Whether the library should automatically generate a session when no session is found in the query or the cookies | Boolean | true |
| forms | An object that controls how Hellotext should control the forms on the page. See [Forms](/docs/forms.md) documentation for more information. | Object | { autoMount: true, successMessage: true } |
| webchat | An object that overrides the dashboard webchat configuration, or `false` to disable automatic webchat mounting. See [Webchat](/docs/webchat.md). | Object \| false | Dashboard webchat when configured |
| whatsappWidget | An object that overrides the dashboard WhatsApp widget configuration, or `false` to disable automatic WhatsApp widget mounting. | Object \| false | Dashboard WhatsApp widget when configured |
85 changes: 85 additions & 0 deletions __tests__/api/whatsapp_widgets_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @jest-environment jsdom
*/

import WhatsAppWidgetsAPI from '../../src/api/whatsapp_widgets'
import Hellotext from '../../src/hellotext'
import { Configuration } from '../../src/core'

describe('WhatsAppWidgetsAPI', () => {
beforeEach(() => {
Configuration.apiRoot = 'https://api.hellotext.test/v1'
Configuration.whatsapp.placement = 'top-left'
Configuration.whatsapp.appearance = {
launcher: {
iconUrl: 'https://example.com/whatsapp.png'
}
}

Configuration.whatsapp.number = '+15551234567'
Configuration.whatsapp.body = 'Hello from install'
Hellotext.business = {
id: 'business-id',
data: { id: 'business-id' },
setData: jest.fn(),
setLocale: jest.fn()
}

global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
business: { id: 'business-id' },
html: '<article id="whatsapp-widget"></article>'
})
})
})

afterEach(() => {
jest.restoreAllMocks()
Configuration.apiRoot = 'https://api.hellotext.com/v1'
Configuration.whatsapp.placement = 'bottom-right'
Configuration.whatsapp.appearance = {}
Configuration.whatsapp.number = null
Configuration.whatsapp.body = null
})

it('fetches the public WhatsApp widget with placement, appearance, number, and body overrides', () => {
return WhatsAppWidgetsAPI.get('widget-id').then(element => {
const url = new URL(global.fetch.mock.calls[0][0])

expect(url.pathname).toBe('/v1/public/widgets/whatsapp/widget-id')
expect(url.searchParams.get('placement')).toBe('top-left')
expect(url.searchParams.get('whatsapp[appearance][launcher][icon_url]')).toBe('https://example.com/whatsapp.png')
expect(url.searchParams.get('whatsapp[number]')).toBe('+15551234567')
expect(url.searchParams.get('whatsapp[body]')).toBe('Hello from install')
expect(element.id).toBe('whatsapp-widget')
})
})

it('returns null when the widget request fails', () => {
global.fetch.mockResolvedValue({ ok: false })

return WhatsAppWidgetsAPI.get('widget-id').then(element => {
expect(element).toBeNull()
})
})

it('returns null when the widget request errors', () => {
global.fetch.mockRejectedValue(new Error('Network error'))

return WhatsAppWidgetsAPI.get('widget-id').then(element => {
expect(element).toBeNull()
})
})

it('returns null when the widget response is invalid JSON', () => {
global.fetch.mockResolvedValue({
ok: true,
json: jest.fn().mockRejectedValue(new Error('Invalid JSON'))
})

return WhatsAppWidgetsAPI.get('widget-id').then(element => {
expect(element).toBeNull()
})
})
})
44 changes: 44 additions & 0 deletions __tests__/core/whatsapp_configuration_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { WhatsApp } from '../../src/core'

describe('WhatsApp configuration', () => {
afterEach(() => {
WhatsApp.id = undefined
WhatsApp.container = 'body'
WhatsApp.placement = 'bottom-right'
WhatsApp.appearance = {}
WhatsApp.number = null
WhatsApp.body = null
})

it('assigns valid widget options', () => {
WhatsApp.assign({
id: 'widget-id',
container: '#widget-container',
placement: 'top-left',
appearance: {
launcher: {
iconUrl: 'https://example.com/whatsapp.png'
}
},
number: '+15551234567',
body: 'Hello from install'
})

expect(WhatsApp.id).toBe('widget-id')
expect(WhatsApp.container).toBe('#widget-container')
expect(WhatsApp.placement).toBe('top-left')
expect(WhatsApp.appearance.launcher.iconUrl).toBe('https://example.com/whatsapp.png')
expect(WhatsApp.number).toBe('+15551234567')
expect(WhatsApp.body).toBe('Hello from install')
})

it('rejects invalid launcher appearance keys', () => {
expect(() => {
WhatsApp.appearance = {
launcher: {
color: '#25D366'
}
}
}).toThrow('Invalid appearance launcher property: color')
})
})
88 changes: 87 additions & 1 deletion __tests__/hellotext_test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Hellotext from "../src/hellotext";
import API from "../src/api";
import { Configuration } from "../src/core";
import { Session, Webchat } from "../src/models";
import { Session, Webchat, WhatsAppWidget } from "../src/models";

const getCookieValue = name => document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop()

Expand Down Expand Up @@ -48,17 +48,26 @@ describe("when trying to call methods before initializing the class", () => {

describe("when initializing business metadata", () => {
let loadWebchat
let loadWhatsAppWidget

beforeEach(() => {
loadWebchat = jest.spyOn(Webchat, 'load').mockResolvedValue({})
loadWhatsAppWidget = jest.spyOn(WhatsAppWidget, 'load').mockResolvedValue({})
})

afterEach(() => {
loadWebchat.mockRestore()
loadWhatsAppWidget.mockRestore()
Configuration.webchat.behaviour = null
Configuration.webchat.behaviourOverride = false
Configuration.webchat.appearance = {}
Configuration.webchat.whatsapp = {}
Configuration.whatsapp.id = undefined
Configuration.whatsapp.container = 'body'
Configuration.whatsapp.placement = 'bottom-right'
Configuration.whatsapp.appearance = {}
Configuration.whatsapp.number = null
Configuration.whatsapp.body = null
})

it("fetches public business data by default and stores it", async () => {
Expand Down Expand Up @@ -204,6 +213,83 @@ describe("when initializing business metadata", () => {
expect(loadWebchat).not.toHaveBeenCalled()
})

it("loads the dashboard WhatsApp widget when no explicit WhatsApp config is passed", async () => {
mockBusinessFetch(defaultBusiness({ whatsapp: { id: "dashboard-whatsapp-widget" } }))

await Hellotext.initialize("xy76ks")

expect(loadWhatsAppWidget).toHaveBeenCalledWith("dashboard-whatsapp-widget")
})

it("loads dashboard webchat and WhatsApp widget together", async () => {
mockBusinessFetch(defaultBusiness({
webchat: { id: "dashboard-webchat" },
whatsapp: { id: "dashboard-whatsapp-widget" },
}))

await Hellotext.initialize("xy76ks")

expect(loadWebchat).toHaveBeenCalledWith("dashboard-webchat")
expect(loadWhatsAppWidget).toHaveBeenCalledWith("dashboard-whatsapp-widget")
})

it("deep merges explicit local WhatsApp widget options with dashboard defaults", async () => {
mockBusinessFetch(defaultBusiness({
whatsapp: {
id: "dashboard-whatsapp-widget",
placement: "bottom-left",
appearance: {
launcher: {
iconUrl: "https://example.com/dashboard-whatsapp.png",
},
},
},
}))

await Hellotext.initialize("xy76ks", {
whatsappWidget: {
container: "#whatsapp-container",
appearance: {
launcher: {
iconUrl: "https://example.com/local-whatsapp.png",
},
},
},
})

expect(loadWhatsAppWidget).toHaveBeenCalledWith("dashboard-whatsapp-widget")
expect(Configuration.whatsapp.container).toEqual("#whatsapp-container")
expect(Configuration.whatsapp.placement).toEqual("bottom-left")
expect(Configuration.whatsapp.appearance).toEqual({
launcher: {
iconUrl: "https://example.com/local-whatsapp.png",
},
})
})

it("accepts whatsappWidget as the public WhatsApp widget config name", async () => {
mockBusinessFetch(defaultBusiness({ whatsapp: { id: "dashboard-whatsapp-widget" } }))

await Hellotext.initialize("xy76ks", {
whatsappWidget: {
number: "+15551234567",
body: "Hello from install",
},
})

expect(loadWhatsAppWidget).toHaveBeenCalledWith("dashboard-whatsapp-widget")
expect(Configuration.whatsapp.number).toEqual("+15551234567")
expect(Configuration.whatsapp.body).toEqual("Hello from install")
})

it("skips WhatsApp widget loading when whatsappWidget is false", async () => {
mockBusinessFetch(defaultBusiness({ whatsapp: { id: "dashboard-whatsapp-widget" } }))

await Hellotext.initialize("xy76ks", { whatsappWidget: false })

expect(loadWhatsAppWidget).not.toHaveBeenCalled()
})

it("does not break initialization when business fetch rejects", async () => {
API.businesses.get = jest.fn().mockRejectedValue(new Error("network error"))

Expand Down
121 changes: 121 additions & 0 deletions __tests__/models/whatsapp_widget_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* @jest-environment jsdom
*/

import API from '../../src/api'
import { Configuration } from '../../src/core'
import { WhatsAppWidget } from '../../src/models'

describe('WhatsAppWidget', () => {
const createStylesheet = ({ loaded = true } = {}) => {
const linkTag = document.createElement('link')
linkTag.rel = 'stylesheet'
linkTag.href = 'https://example.com/hellotext.css'
linkTag.setAttribute('data-hellotext-stylesheet', 'true')

if (loaded) {
linkTag.dataset.hellotextStylesheetLoaded = 'true'
}

document.head.appendChild(linkTag)

return linkTag
}

const markStylesheetLoaded = linkTag => {
Object.defineProperty(linkTag, 'sheet', {
value: {},
configurable: true
})
linkTag.dispatchEvent(new Event('load'))
}

beforeEach(() => {
document.body.innerHTML = '<main id="whatsapp-container"></main>'
Configuration.whatsapp.container = '#whatsapp-container'
jest.spyOn(API.whatsappWidgets, 'get')
})

afterEach(() => {
jest.restoreAllMocks()
document.body.innerHTML = ''
document.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
link.dispatchEvent(new Event('error'))
link.remove()
})
Configuration.whatsapp.container = 'body'
})

it('waits for the stylesheet before appending the widget HTML', () => {
const linkTag = createStylesheet({ loaded: false })
const article = document.createElement('article')
API.whatsappWidgets.get.mockResolvedValue(article)

return WhatsAppWidget.load('widget-id').then(widget => {
expect(document.querySelector('#whatsapp-container article')).toBeNull()

markStylesheetLoaded(linkTag)

return widget.rendered.then(() => {
expect(document.querySelector('#whatsapp-container article')).toBe(article)
expect(widget.mounted).toBe(true)
})
})
})

it('marks itself when webchat is already mounted', () => {
createStylesheet()
document.body.insertAdjacentHTML('beforeend', '<article class="hellotext--webchat"></article>')
const article = document.createElement('article')
article.className = 'hellotext--webchat hellotext--whatsapp-widget'
API.whatsappWidgets.get.mockResolvedValue(article)

return WhatsAppWidget.load('widget-id').then(widget => {
return widget.rendered.then(() => {
expect(widget.mounted).toBe(true)
expect(article.classList.contains('hellotext--with-webchat')).toBe(true)
expect(document.querySelector('.hellotext--webchat:not(.hellotext--whatsapp-widget)').classList.contains('hellotext--with-whatsapp-widget')).toBe(true)
})
})
})

it('does not mount when the API returns no widget HTML', () => {
createStylesheet()
API.whatsappWidgets.get.mockResolvedValue(null)

return WhatsAppWidget.load('widget-id').then(widget => {
return widget.rendered.then(() => {
expect(document.querySelector('#whatsapp-container').children.length).toBe(0)
expect(widget.mounted).toBe(false)
})
})
})

it('does not mount when the configured container is missing', () => {
createStylesheet()
Configuration.whatsapp.container = '#missing-container'
jest.spyOn(console, 'warn').mockImplementation(() => {})
API.whatsappWidgets.get.mockResolvedValue(document.createElement('article'))

return WhatsAppWidget.load('widget-id').then(widget => {
return widget.rendered.then(() => {
expect(widget.mounted).toBe(false)
expect(console.warn).toHaveBeenCalledWith('Hellotext WhatsApp widget was not mounted because the container #missing-container was not found.')
})
})
})

it('does not mount when the configured container selector is invalid', () => {
createStylesheet()
Configuration.whatsapp.container = '['
jest.spyOn(console, 'warn').mockImplementation(() => {})
API.whatsappWidgets.get.mockResolvedValue(document.createElement('article'))

return WhatsAppWidget.load('widget-id').then(widget => {
return widget.rendered.then(() => {
expect(widget.mounted).toBe(false)
expect(console.warn).toHaveBeenCalledWith('Hellotext WhatsApp widget was not mounted because the container [ was not found.')
})
})
})
})
2 changes: 1 addition & 1 deletion dist/hellotext.js

Large diffs are not rendered by default.

Loading