Skip to content
Draft
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
194 changes: 194 additions & 0 deletions frontend/common/__tests__/loadChat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import Utils from 'common/utils/utils'
import { getChatProvider, identifyChatUser, openChat } from 'common/loadChat'

jest.mock('common/utils/utils', () => ({
getFlagsmithHasFeature: jest.fn(),
getFlagsmithValue: jest.fn(),
isOrgOnFreePlan: jest.fn(),
}))
jest.mock('common/stores/account-store', () => ({ getUser: jest.fn() }))
jest.mock('common/project', () => ({}))

const mockUtils = Utils as jest.Mocked<typeof Utils>

const setFlags = (enabled: string[], hubspotChatValue?: string) => {
mockUtils.getFlagsmithHasFeature.mockImplementation((name: string) =>
enabled.includes(name),
)
mockUtils.getFlagsmithValue.mockImplementation(((name: string) =>
name === 'hubspot_chat' ? hubspotChatValue ?? null : null) as any)
}

const hubSpotWidget = (loaded: boolean) => ({
load: jest.fn(),
open: jest.fn(),
remove: jest.fn(),
status: jest.fn(() => ({ loaded })),
})

beforeEach(() => {
jest.resetAllMocks()
mockUtils.isOrgOnFreePlan.mockReturnValue(false)
// jest runs in node, so give the module a minimal window.
;(global as any).window = { location: { href: '' } }
})

describe('getChatProvider', () => {
it('returns hubspot when only hubspot_chat is on', () => {
// Given
setFlags(['hubspot_chat'])

// When / Then
expect(getChatProvider()).toBe('hubspot')
})

it('prefers hubspot while both flags are on', () => {
// Given
setFlags(['hubspot_chat', 'pylon_chat'])

// When / Then
expect(getChatProvider()).toBe('hubspot')
})

it('returns pylon when only pylon_chat is on', () => {
// Given
setFlags(['pylon_chat'])

// When / Then
expect(getChatProvider()).toBe('pylon')
})

it('keeps HubSpot to paid plans when hubspot_chat has no value', () => {
// Given
setFlags(['hubspot_chat', 'pylon_chat'])
mockUtils.isOrgOnFreePlan.mockReturnValue(true)

// When / Then
expect(getChatProvider()).toBeNull()
})

it('keeps HubSpot to paid plans for any value other than all', () => {
// Given
setFlags(['hubspot_chat'], 'paid')
mockUtils.isOrgOnFreePlan.mockReturnValue(true)

// When / Then
expect(getChatProvider()).toBeNull()
})

it('opens HubSpot to free plans when hubspot_chat is all', () => {
// Given
// Typed by hand in the dashboard, so case and spaces are forgiven.
setFlags(['hubspot_chat'], ' All ')
mockUtils.isOrgOnFreePlan.mockReturnValue(true)

// When / Then
expect(getChatProvider()).toBe('hubspot')
})

it('keeps Pylon to paid plans', () => {
// Given
setFlags(['pylon_chat'])
mockUtils.isOrgOnFreePlan.mockReturnValue(true)

// When / Then
expect(getChatProvider()).toBeNull()
})
})

describe('openChat', () => {
it('opens a loaded HubSpot widget', () => {
// Given
setFlags(['hubspot_chat'])
const widget = hubSpotWidget(true)
window.HubSpotConversations = { widget }

// When
openChat()

// Then
expect(widget.open).toHaveBeenCalled()
expect(window.location.href).toBe('')
})

it('loads the HubSpot widget open when it is not loaded yet', () => {
// Given
setFlags(['hubspot_chat'])
const widget = hubSpotWidget(false)
window.HubSpotConversations = { widget }

// When
openChat()

// Then
expect(widget.load).toHaveBeenCalledWith({ widgetOpen: true })
})

it('falls back to email when the HubSpot script never loaded', () => {
// Given
setFlags(['hubspot_chat'])

// When
openChat()

// Then
expect(window.location.href).toBe('mailto:support@flagsmith.com')
})

it('falls back to email when the org has no chat', () => {
// Given
setFlags([])

// When
openChat()

// Then
expect(window.location.href).toBe('mailto:support@flagsmith.com')
})
})

describe('identifyChatUser', () => {
it('hides Pylon and loads HubSpot when hubspot_chat is on', () => {
// Given
setFlags(['hubspot_chat', 'pylon_chat'])
const widget = hubSpotWidget(false)
window.HubSpotConversations = { widget }
window.Pylon = jest.fn()

// When
identifyChatUser()

// Then
expect(window.Pylon).toHaveBeenCalledWith('hideChatBubble')
expect(widget.load).toHaveBeenCalledWith()
})

it('queues the HubSpot load until the API is ready', () => {
// Given
setFlags(['hubspot_chat'])

// When
identifyChatUser()
const widget = hubSpotWidget(false)
window.HubSpotConversations = { widget }
window.hsConversationsOnReady?.forEach((callback) => callback())

// Then
expect(widget.load).toHaveBeenCalledWith()
})

it('removes HubSpot when the org moves to the free plan', () => {
// Given
setFlags(['hubspot_chat'])
mockUtils.isOrgOnFreePlan.mockReturnValue(true)
const widget = hubSpotWidget(true)
window.HubSpotConversations = { widget }

// When
identifyChatUser()

// Then
expect(widget.remove).toHaveBeenCalled()
expect(widget.load).not.toHaveBeenCalled()
})
})
93 changes: 83 additions & 10 deletions frontend/common/loadChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,47 @@ declare global {
interface Window {
Pylon?: ((...args: unknown[]) => void) & { q?: unknown[][] }
pylon?: { chat_settings: Record<string, string | undefined> }
hsConversationsSettings?: { loadImmediately?: boolean }
hsConversationsOnReady?: (() => void)[]
HubSpotConversations?: {
widget: {
load: (options?: { widgetOpen?: boolean }) => void
open: () => void
remove: () => void
status: () => { loaded: boolean }
}
}
}
}

import flagsmith from '@flagsmith/flagsmith'
import AccountStore from './stores/account-store'
import getUserDisplayName from './utils/getUserDisplayName'
import Utils from './utils/utils'
import { AccountModel } from './types/responses'
import Project from './project'

const defaultPylonID = '028babb7-d93f-4e32-be6a-59db190a084f'
const SUPPORT_EMAIL_URL = 'mailto:support@flagsmith.com'

export type ChatProvider = 'hubspot' | 'pylon'

// hubspot_chat's value sets who gets HubSpot chat: "all" opens it to every
// plan, anything else (including no value) keeps it to paid plans.
const HUBSPOT_CHAT_ALL_PLANS = 'all'

// HubSpot wins while both flags are on, so turning hubspot_chat on is enough
// to switch provider.
export function getChatProvider(): ChatProvider | null {
const isFreePlan = Utils.isOrgOnFreePlan()
if (Utils.getFlagsmithHasFeature('hubspot_chat')) {
const audience = `${Utils.getFlagsmithValue('hubspot_chat') ?? ''}`
.trim()
.toLowerCase()
return audience === HUBSPOT_CHAT_ALL_PLANS || !isFreePlan ? 'hubspot' : null
}
if (Utils.getFlagsmithHasFeature('pylon_chat') && !isFreePlan) return 'pylon'
return null
}

async function loadPylon(pylonAppId: string) {
if (window.Pylon) {
Expand Down Expand Up @@ -57,40 +87,83 @@ function setupPylon() {
}
}

export function hidePylon() {
function hidePylon() {
if (typeof window.Pylon !== 'undefined') {
window.Pylon('hideChatBubble')
}
}

// The HubSpot script is added in libs.js with loadImmediately: false, so the
// widget only appears once we call load().
function showHubSpot() {
const load = () => {
const widget = window.HubSpotConversations?.widget
// Re-checked: the org may have changed before the API became ready.
if (widget && getChatProvider() === 'hubspot' && !widget.status().loaded) {
widget.load()
}
}
if (window.HubSpotConversations) {
load()
} else {
window.hsConversationsOnReady = [
...(window.hsConversationsOnReady || []),
load,
]
}
}

function removeHubSpot() {
window.HubSpotConversations?.widget.remove()
}

export function hideChat() {
hidePylon()
removeHubSpot()
}

export function identifyChatUser() {
if (flagsmith.hasFeature('pylon_chat') && !Utils.isOrgOnFreePlan()) {
const provider = getChatProvider()
if (provider === 'hubspot') {
hidePylon()
showHubSpot()
} else if (provider === 'pylon') {
removeHubSpot()
setupPylon()
if (typeof window.Pylon !== 'undefined') {
window.Pylon('showChatBubble')
}
} else {
hidePylon()
hideChat()
}
}

export function openChat() {
if (
flagsmith.hasFeature('pylon_chat') &&
!Utils.isOrgOnFreePlan() &&
typeof window.Pylon !== 'undefined'
) {
const provider = getChatProvider()
if (provider === 'hubspot' && window.HubSpotConversations) {
const { widget } = window.HubSpotConversations
if (widget.status().loaded) {
widget.open()
} else {
widget.load({ widgetOpen: true })
}
return
}
if (provider === 'pylon' && typeof window.Pylon !== 'undefined') {
window.Pylon('show')
setupPylon()
return
}
// No chat for this org, or the widget was blocked: email beats a dead click.
window.location.href = SUPPORT_EMAIL_URL
}

export default async function loadChat(forceDefaultAPIKey?: boolean) {
try {
const isWidget = document.location.href.includes('/widget')
if (isWidget) return

if (flagsmith.hasFeature('pylon_chat') && !Utils.isOrgOnFreePlan()) {
if (getChatProvider() === 'pylon') {
const pylonId = forceDefaultAPIKey ? defaultPylonID : Project.pylonAppId
if (pylonId) {
await loadPylon(pylonId)
Expand Down
4 changes: 2 additions & 2 deletions frontend/common/stores/account-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { sortBy } from 'lodash'
import Project from 'common/project'
import { getStore } from 'common/store'
import { setSelectedOrganisationId } from 'common/selectedOrganisationSlice'
import { hidePylon, identifyChatUser } from 'common/loadChat'
import { hideChat, identifyChatUser } from 'common/loadChat'
import { service } from 'common/service'
import { getBuildVersion } from 'common/services/useBuildVersion'
import { createOnboardingSupportOptIn } from 'common/services/useOnboardingSupportOptIn'
Expand Down Expand Up @@ -449,7 +449,7 @@ const controller = {
).finally(() => {
API.setCookie('t', '')
data.setToken(null)
hidePylon()
hideChat()
API.reset().finally(() => {
store.model = user
store.organisation = null
Expand Down
3 changes: 3 additions & 0 deletions frontend/web/project/libs.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ if(Project.linkedinPartnerTracking) {
}

if(Project.hubspot && !disableThirdPartyScripts) {
// Must be set before the script loads. The chat widget is loaded by
// common/loadChat behind the hubspot_chat flag, not by chatflow targeting.
window.hsConversationsSettings = { loadImmediately: false };
var script = document.createElement("script");
script.type = "text/javascript";
script.id = "hs-script-loader";
Expand Down
Loading