Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { AnyPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types'

import {
AUDIENCE_PREFILL_PARAM,
parseBroadcastAudiencePrefill,
urlForNewBroadcastWithAudience,
} from './broadcastAudiencePrefill'

const COHORT_AUDIENCE: AnyPropertyFilter[] = [
{ key: 'id', type: PropertyFilterType.Cohort, value: 7, operator: PropertyOperator.In },
]

describe('broadcastAudiencePrefill', () => {
it('round-trips an audience through the URL', () => {
const url = urlForNewBroadcastWithAudience({ properties: COHORT_AUDIENCE, source: 'cohort' })
const raw = new URLSearchParams(url.split('?')[1]).get(AUDIENCE_PREFILL_PARAM)

expect(parseBroadcastAudiencePrefill(raw)).toEqual(COHORT_AUDIENCE)
})

it.each([
['nothing', undefined],
['a non-JSON string', 'not-json'],
['an empty audience', '[]'],
['an object instead of a list', '{"key":"email"}'],
['an event filter, which a broadcast audience cannot use', '[{"key":"$browser","type":"event","value":"x"}]'],
['a person filter missing its value', '[{"key":"email","type":"person","operator":"exact"}]'],
['a cohort filter without a cohort id', '[{"key":"id","type":"cohort","operator":"in"}]'],
['a person filter missing its operator', '[{"key":"email","type":"person","value":"a@example.com"}]'],
[
'an operator the backend does not know',
'[{"key":"email","type":"person","operator":"nonsense","value":"x"}]',
],
['a cohort filter with a negative id', '[{"key":"id","type":"cohort","value":-3}]'],
[
'one bad filter next to a good one',
'[{"key":"id","type":"cohort","value":7},{"key":"email","type":"person","operator":"exact","value":[]}]',
],
])('returns null for %s', (_label, raw) => {
expect(parseBroadcastAudiencePrefill(raw)).toBeNull()
})

it('keeps only the fields the audience uses, so a hidden field cannot make the backend drop the filter', () => {
const raw = JSON.stringify([
{ key: 'email', type: 'person', operator: 'exact', value: 'finance@example.com', group_type_index: -1 },
{ key: 'id', type: 'cohort', value: 7, operator: 'in', cohort_name: 'Beta', group_type_index: -1 },
])

expect(parseBroadcastAudiencePrefill(raw)).toEqual([
{
key: 'email',
type: PropertyFilterType.Person,
operator: PropertyOperator.Exact,
value: 'finance@example.com',
},
{
key: 'id',
type: PropertyFilterType.Cohort,
value: 7,
operator: PropertyOperator.In,
cohort_name: 'Beta',
},
])
})

it('accepts a person filter that needs no value', () => {
const audience = [{ key: 'email', type: PropertyFilterType.Person, operator: PropertyOperator.IsSet }]

expect(parseBroadcastAudiencePrefill(JSON.stringify(audience))).toEqual(audience)
})
})
96 changes: 96 additions & 0 deletions products/workflows/frontend/Broadcasts/broadcastAudiencePrefill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { combineUrl } from 'kea-router'

import { urls } from 'scenes/urls'

import { AnyPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types'

// pinned: URL search params, other products link to /broadcasts/new with these
export const AUDIENCE_PREFILL_PARAM = 'audience'
export const NAME_PREFILL_PARAM = 'name'
export const SOURCE_PREFILL_PARAM = 'source'

export interface BroadcastPrefill {
properties: AnyPropertyFilter[]
name?: string
/** The product surface the person came from, reported on the launch event. */
source?: string
}

export function urlForNewBroadcastWithAudience({ properties, name, source }: BroadcastPrefill): string {
return combineUrl(urls.broadcastNew(), {
[AUDIENCE_PREFILL_PARAM]: JSON.stringify(properties),
...(name ? { [NAME_PREFILL_PARAM]: name } : {}),
...(source ? { [SOURCE_PREFILL_PARAM]: source } : {}),
}).url
}

/** The audience a link asked for, or null when it is missing or any filter in it can't be applied. */
export function parseBroadcastAudiencePrefill(raw: unknown): AnyPropertyFilter[] | null {
let value = raw
if (typeof raw === 'string') {
try {
value = JSON.parse(raw)
} catch {
return null
}
}
if (!Array.isArray(value) || value.length === 0) {
return null
}
const filters = value.map(toAudienceFilter)
return filters.every((filter): filter is AnyPropertyFilter => filter !== null) ? filters : null
}

const PERSON_OPERATORS: string[] = Object.values(PropertyOperator)
const COHORT_OPERATORS: string[] = [PropertyOperator.In, PropertyOperator.NotIn]

function isFilterValue(value: unknown): boolean {
return (
typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)) || typeof value === 'boolean'
)
}

// Rebuilt from known fields only: the backend drops a filter with a field it rejects, which would widen the
// audience to everyone while the wizard still shows the filter.
function toAudienceFilter(filter: unknown): AnyPropertyFilter | null {
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
return null
}
const { key, type, operator, value, cohort_name } = filter as Record<string, unknown>
if (typeof key !== 'string' || key === '') {
return null
}
if (type === PropertyFilterType.Cohort) {
if (key !== 'id' || typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
return null
}
if (operator !== undefined && (typeof operator !== 'string' || !COHORT_OPERATORS.includes(operator))) {
return null
}
return {
key: 'id',
type: PropertyFilterType.Cohort,
value,
operator: (operator as PropertyOperator | undefined) ?? PropertyOperator.In,
...(typeof cohort_name === 'string' ? { cohort_name } : {}),
}
}
if (type !== PropertyFilterType.Person || typeof operator !== 'string' || !PERSON_OPERATORS.includes(operator)) {
return null
}
if (operator === PropertyOperator.IsSet || operator === PropertyOperator.IsNotSet) {
return { key, type: PropertyFilterType.Person, operator: operator as PropertyOperator }
}
const hasValue = Array.isArray(value)
? value.length > 0 && value.every(isFilterValue)
: isFilterValue(value) && value !== ''
if (!hasValue) {
return null
}
return {
key,
type: PropertyFilterType.Person,
operator: operator as PropertyOperator,
value: value as string | number | boolean | (string | number | boolean)[],
} as AnyPropertyFilter
}
27 changes: 27 additions & 0 deletions products/workflows/frontend/Broadcasts/broadcastUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const COMPOSER_DRAFT_PARAM = 'from'
export const COMPOSER_DRAFT_VALUE = 'ai_composer'

const COMPOSER_DRAFTS_STORAGE_KEY = 'broadcasts-ai-composer-drafts'
// pinned: session storage key
const ENTRY_SOURCES_STORAGE_KEY = 'broadcasts-entry-sources'

export type BroadcastPath = 'composer' | 'manual'

Expand Down Expand Up @@ -101,3 +103,28 @@ export function advanceAgentDraft(
export function editedFields(agentDraft: BroadcastFieldSnapshot, current: BroadcastFieldSnapshot): BroadcastField[] {
return BROADCAST_FIELDS.filter((field) => !objectsEqual(agentDraft[field], current[field]))
}

function readEntrySources(): Record<string, string> {
try {
const stored = JSON.parse(sessionStorage.getItem(ENTRY_SOURCES_STORAGE_KEY) || '{}')
return stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {}
} catch {
return {}
}
}

/** Kept for the tab, because saving the draft remounts the wizard under the draft's own URL. */
export function saveEntrySource(broadcastId: string, source: string): void {
try {
sessionStorage.setItem(
ENTRY_SOURCES_STORAGE_KEY,
JSON.stringify({ ...readEntrySources(), [broadcastId]: source })
)
} catch {
// Storage can be blocked. The launch then reports no entry source.
}
}

export function loadEntrySource(broadcastId: string | null | undefined): string | null {
return broadcastId ? (readEntrySources()[broadcastId] ?? null) : null
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AnyPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types

import type { HogFlowApi } from 'products/workflows/frontend/generated/api.schemas'

import { urlForNewBroadcastWithAudience } from './broadcastAudiencePrefill'
import { DEFAULT_BROADCAST_EMAIL, DELETED_SENDER_ERROR, broadcastWizardLogic } from './broadcastWizardLogic'

const LOCAL_AUDIENCE: AnyPropertyFilter[] = [
Expand Down Expand Up @@ -128,6 +129,47 @@ describe('broadcastWizardLogic', () => {
logic.unmount()
})

it('prefills the audience, name and source from a link without creating a draft', async () => {
const properties: AnyPropertyFilter[] = [
{ key: 'id', type: PropertyFilterType.Cohort, value: 7, operator: PropertyOperator.In },
]
logic.unmount()
router.actions.push(urlForNewBroadcastWithAudience({ properties, name: 'Fix shipped', source: 'cohort' }))
logic = broadcastWizardLogic({ id: 'new' })
logic.mount()

await expectLogic(logic)
.toDispatchActions(['prefillFromLink', 'loadBlastRadius'])
.toNotHaveDispatchedActions(['ensureDraft'])
expect(logic.values.audienceProperties).toEqual(properties)
expect(logic.values.name).toEqual('Fix shipped')
expect(logic.values.entrySource).toEqual('cohort')
expect(router.values.searchParams).toEqual({})
})

it('blocks the recipients step until the person chooses, when a link has an audience it cannot use', async () => {
logic.unmount()
router.actions.push(
`/broadcasts/new?audience=${encodeURIComponent('[{"key":"email","type":"person","operator":"exact"}]')}&name=Fix%20shipped`
)
logic = broadcastWizardLogic({ id: 'new' })
logic.mount()

await expectLogic(logic).toDispatchActions(['rejectLinkAudience']).toNotHaveDispatchedActions(['ensureDraft'])
expect(logic.values.name).toEqual('Fix shipped')
expect(logic.values.stepValidationErrors.recipients).toEqual(['Choose who gets this email'])

logic.actions.setAudienceProperties([
{ key: 'email', type: PropertyFilterType.Person, operator: PropertyOperator.IsSet },
])
expect(logic.values.stepValidationErrors.recipients).toEqual([])
logic.actions.setAudienceProperties([])
expect(logic.values.stepValidationErrors.recipients).toEqual(['Choose who gets this email'])

logic.actions.sendToEveryoneAfterRejectedLink()
expect(logic.values.stepValidationErrors.recipients).toEqual([])
})

test.each([
{ edited: 'only the email', latestName: 'Spring sale', expectedName: 'Spring sale, final' },
{ edited: 'the name too', latestName: 'Spring promo', expectedName: 'Spring promo' },
Expand Down
Loading