diff --git a/frontend/src/generated/core/api.schemas.ts b/frontend/src/generated/core/api.schemas.ts
index 50e25291ef9a..558af757e588 100644
--- a/frontend/src/generated/core/api.schemas.ts
+++ b/frontend/src/generated/core/api.schemas.ts
@@ -790,6 +790,8 @@ export const BusinessModelEnumApi = {
* * `configure_workflow_trigger` - configure_workflow_trigger
* * `add_workflow_action` - add_workflow_action
* * `launch_workflow` - launch_workflow
+ * * `send_workflow_test_email` - send_workflow_test_email
+ * * `send_from_own_email_domain` - send_from_own_email_domain
* * `create_first_endpoint` - create_first_endpoint
* * `configure_endpoint` - configure_endpoint
* * `test_endpoint` - test_endpoint
@@ -861,6 +863,8 @@ export const AvailableSetupTaskIdsEnumApi = {
ConfigureWorkflowTrigger: 'configure_workflow_trigger',
AddWorkflowAction: 'add_workflow_action',
LaunchWorkflow: 'launch_workflow',
+ SendWorkflowTestEmail: 'send_workflow_test_email',
+ SendFromOwnEmailDomain: 'send_from_own_email_domain',
CreateFirstEndpoint: 'create_first_endpoint',
ConfigureEndpoint: 'configure_endpoint',
TestEndpoint: 'test_endpoint',
diff --git a/frontend/src/lib/components/NavPanelAdvertisement/MaybeProductPushWelcome.test.tsx b/frontend/src/lib/components/NavPanelAdvertisement/MaybeProductPushWelcome.test.tsx
new file mode 100644
index 000000000000..cdf8341816a0
--- /dev/null
+++ b/frontend/src/lib/components/NavPanelAdvertisement/MaybeProductPushWelcome.test.tsx
@@ -0,0 +1,46 @@
+import '@testing-library/jest-dom'
+
+import { act, cleanup, render, waitFor } from '@testing-library/react'
+
+import { sceneLogic } from 'scenes/sceneLogic'
+import { Scene } from 'scenes/sceneTypes'
+
+import { ProductKey } from '~/queries/schema/schema-general'
+import { initKeaTests } from '~/test/init'
+
+import { MaybeProductPushWelcome } from './MaybeProductPushWelcome'
+import { navPanelProductPushWelcomeLogic } from './navPanelProductPushWelcomeLogic'
+import type { PendingProductPushWelcome } from './navPanelProductPushWelcomeVisibility'
+
+const WELCOME: PendingProductPushWelcome = {
+ campaignId: 'campaign-1',
+ productKey: ProductKey.PRODUCT_ANALYTICS,
+ label: 'Product analytics',
+ text: 'Insights, funnels, trends, and retention.',
+}
+
+describe('MaybeProductPushWelcome', () => {
+ afterEach(() => cleanup())
+
+ it('opens the pending welcome automatically when the scene releases its hold', async () => {
+ initKeaTests()
+ sceneLogic.mount()
+ sceneLogic.actions.setExportedScene(
+ { component: (): null => null, productKey: ProductKey.PRODUCT_ANALYTICS },
+ Scene.SavedInsights,
+ undefined,
+ { params: {}, searchParams: {}, hashParams: {} }
+ )
+ const logic = navPanelProductPushWelcomeLogic()
+ logic.mount()
+ logic.actions.holdWelcome('first-run')
+ logic.actions.setPendingWelcome(WELCOME)
+
+ render()
+ expect(document.querySelector('[data-attr="product-push-welcome"]')).not.toBeInTheDocument()
+
+ act(() => logic.actions.releaseWelcome('first-run'))
+
+ await waitFor(() => expect(document.querySelector('[data-attr="product-push-welcome"]')).toBeInTheDocument())
+ })
+})
diff --git a/frontend/src/lib/components/NavPanelAdvertisement/MaybeProductPushWelcome.tsx b/frontend/src/lib/components/NavPanelAdvertisement/MaybeProductPushWelcome.tsx
index 95b36bc906d5..372ffb43524d 100644
--- a/frontend/src/lib/components/NavPanelAdvertisement/MaybeProductPushWelcome.tsx
+++ b/frontend/src/lib/components/NavPanelAdvertisement/MaybeProductPushWelcome.tsx
@@ -43,6 +43,7 @@ function ProductPushWelcome({
const { featureFlags, receivedFeatureFlags } = useValues(featureFlagLogic)
const { searchParams } = useValues(router)
const { status, skipped } = useValues(productSetupStatusLogic({ productKey: welcome.productKey }))
+ const { welcomeHeld } = useValues(navPanelProductPushWelcomeLogic)
const { openWelcome, closeWelcome } = useActions(navPanelProductPushWelcomeLogic)
const sceneEmptyState = activeExportedScene?.emptyState
@@ -68,12 +69,12 @@ function ProductPushWelcome({
})
useEffect(() => {
- if (!isOpen && ready) {
+ if (!isOpen && ready && !welcomeHeld) {
openWelcome(welcome)
}
- }, [isOpen, ready, welcome, openWelcome])
+ }, [isOpen, ready, welcomeHeld, welcome, openWelcome])
- if (!isOpen) {
+ if (!isOpen || welcomeHeld) {
return null
}
const config = gatesPushedProduct ? sceneEmptyState?.config : undefined
diff --git a/frontend/src/lib/components/NavPanelAdvertisement/navPanelProductPushWelcomeLogic.test.ts b/frontend/src/lib/components/NavPanelAdvertisement/navPanelProductPushWelcomeLogic.test.ts
new file mode 100644
index 000000000000..003c75bb6838
--- /dev/null
+++ b/frontend/src/lib/components/NavPanelAdvertisement/navPanelProductPushWelcomeLogic.test.ts
@@ -0,0 +1,60 @@
+import posthog from 'posthog-js'
+
+import { ProductKey } from '~/queries/schema/schema-general'
+import { initKeaTests } from '~/test/init'
+
+import { navPanelProductPushWelcomeLogic } from './navPanelProductPushWelcomeLogic'
+import type { PendingProductPushWelcome } from './navPanelProductPushWelcomeVisibility'
+
+const WELCOME: PendingProductPushWelcome = {
+ campaignId: 'campaign-1',
+ productKey: ProductKey.WORKFLOWS,
+ label: 'Workflows',
+ text: 'Message users when it matters.',
+}
+
+describe('navPanelProductPushWelcomeLogic', () => {
+ let logic: ReturnType
+
+ beforeEach(() => {
+ initKeaTests()
+ logic = navPanelProductPushWelcomeLogic()
+ logic.mount()
+ })
+
+ afterEach(() => {
+ logic.unmount()
+ jest.restoreAllMocks()
+ })
+
+ it('keeps the welcome waiting while a surface holds it, and opens it once the surface lets go', () => {
+ const capture = jest.spyOn(posthog, 'capture')
+ logic.actions.setPendingWelcome(WELCOME)
+ logic.actions.holdWelcome('first-run')
+
+ logic.actions.openWelcome(WELCOME)
+
+ expect(logic.values).toMatchObject({ openFor: null, pending: WELCOME })
+ expect(capture).not.toHaveBeenCalled()
+
+ logic.actions.releaseWelcome('first-run')
+ logic.actions.openWelcome(WELCOME)
+
+ expect(logic.values).toMatchObject({ openFor: WELCOME, pending: null })
+ expect(capture).toHaveBeenCalledWith('nav panel product push welcome shown', expect.anything())
+ })
+
+ it('puts an open welcome back on hold when a welcoming surface mounts', () => {
+ logic.actions.setPendingWelcome(WELCOME)
+ logic.actions.openWelcome(WELCOME)
+
+ logic.actions.holdWelcome('first-run')
+
+ expect(logic.values).toMatchObject({ openFor: null, pending: WELCOME })
+
+ logic.actions.releaseWelcome('first-run')
+ logic.actions.openWelcome(WELCOME)
+
+ expect(logic.values).toMatchObject({ openFor: WELCOME, pending: null })
+ })
+})
diff --git a/frontend/src/lib/components/NavPanelAdvertisement/navPanelProductPushWelcomeLogic.ts b/frontend/src/lib/components/NavPanelAdvertisement/navPanelProductPushWelcomeLogic.ts
index 57231b7dd3f4..5b4baf2f8df9 100644
--- a/frontend/src/lib/components/NavPanelAdvertisement/navPanelProductPushWelcomeLogic.ts
+++ b/frontend/src/lib/components/NavPanelAdvertisement/navPanelProductPushWelcomeLogic.ts
@@ -1,4 +1,4 @@
-import { MakeLogicType, actions, kea, listeners, path, reducers } from 'kea'
+import { MakeLogicType, actions, kea, listeners, path, reducers, selectors } from 'kea'
import posthog from 'posthog-js'
import { NAV_PANEL_CARD_TYPE } from './navPanelAdShared'
@@ -11,6 +11,8 @@ export type ProductPushWelcomeCloseSource = 'start_exploring' | 'modal_close'
export interface navPanelProductPushWelcomeLogicValues {
openFor: PendingProductPushWelcome | null
pending: PendingProductPushWelcome | null
+ welcomeHeld: boolean
+ welcomeHolders: string[]
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
@@ -18,17 +20,32 @@ export interface navPanelProductPushWelcomeLogicActions {
closeWelcome: (source: ProductPushWelcomeCloseSource) => {
source: ProductPushWelcomeCloseSource
}
+ holdWelcome: (holder: string) => {
+ holder: string
+ }
openWelcome: (welcome: PendingProductPushWelcome) => {
welcome: PendingProductPushWelcome
}
+ releaseWelcome: (holder: string) => {
+ holder: string
+ }
setPendingWelcome: (welcome: PendingProductPushWelcome) => {
welcome: PendingProductPushWelcome
}
}
+// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
+export interface navPanelProductPushWelcomeLogicMeta {
+ __keaTypeGenInternalSelectorTypes: {
+ welcomeHeld: (welcomeHolders: string[]) => boolean
+ }
+}
+
export type navPanelProductPushWelcomeLogicType = MakeLogicType<
navPanelProductPushWelcomeLogicValues,
- navPanelProductPushWelcomeLogicActions
+ navPanelProductPushWelcomeLogicActions,
+ Record,
+ navPanelProductPushWelcomeLogicMeta
>
/**
@@ -42,6 +59,8 @@ export const navPanelProductPushWelcomeLogic = kea ({ welcome }),
openWelcome: (welcome: PendingProductPushWelcome) => ({ welcome }),
closeWelcome: (source: ProductPushWelcomeCloseSource) => ({ source }),
+ holdWelcome: (holder: string) => ({ holder }),
+ releaseWelcome: (holder: string) => ({ holder }),
}),
reducers({
pending: [
@@ -61,9 +80,28 @@ export const navPanelProductPushWelcomeLogic = kea null,
},
],
+ welcomeHolders: [
+ [] as string[],
+ {
+ holdWelcome: (holders, { holder }) => (holders.includes(holder) ? holders : [...holders, holder]),
+ releaseWelcome: (holders, { holder }) => holders.filter((held) => held !== holder),
+ },
+ ],
}),
- listeners(({ selectors }) => ({
+ selectors({
+ welcomeHeld: [(s) => [s.welcomeHolders], (welcomeHolders: string[]): boolean => welcomeHolders.length > 0],
+ }),
+ listeners(({ actions, values, selectors }) => ({
+ holdWelcome: () => {
+ if (values.openFor) {
+ actions.setPendingWelcome(values.openFor)
+ }
+ },
openWelcome: ({ welcome }) => {
+ if (values.welcomeHeld) {
+ actions.setPendingWelcome(welcome)
+ return
+ }
posthog.capture('nav panel product push welcome shown', {
campaign_id: welcome.campaignId,
product_key: welcome.productKey,
diff --git a/frontend/src/lib/components/ProductSetup/ProductSetupPopover.tsx b/frontend/src/lib/components/ProductSetup/ProductSetupPopover.tsx
index 229934b0353e..f6377bd430fa 100644
--- a/frontend/src/lib/components/ProductSetup/ProductSetupPopover.tsx
+++ b/frontend/src/lib/components/ProductSetup/ProductSetupPopover.tsx
@@ -10,6 +10,7 @@ import { useHogfetti } from 'lib/components/Hogfetti/Hogfetti'
import { SetupTaskId } from 'lib/components/ProductSetup'
import { Popover } from 'lib/lemon-ui/Popover'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
+import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { inStorybook, inStorybookTestRunner } from 'lib/utils/dom'
import { addProductIntent } from 'lib/utils/product-intents'
import { teamLogic } from 'scenes/teamLogic'
@@ -91,7 +92,8 @@ export function ProductSetupPopover({
}
// Calculate other products with remaining tasks
- const otherProductsWithTasks = useOtherProductsWithTasks(selectedProduct, savedOnboardingTasks)
+ const { featureFlags } = useValues(featureFlagLogic)
+ const otherProductsWithTasks = useOtherProductsWithTasks(selectedProduct, savedOnboardingTasks, featureFlags)
// Hogfetti celebration
const { trigger: triggerHogfetti, HogfettiComponent } = useHogfetti()
@@ -852,7 +854,8 @@ function TaskActions({
function useOtherProductsWithTasks(
selectedProduct: ProductKey,
- savedOnboardingTasks: Record
+ savedOnboardingTasks: Record,
+ featureFlags: FeatureFlagsSet
): ProductWithTasks[] {
return useMemo(() => {
const currentCategory = productCategoryMap[selectedProduct]
@@ -861,7 +864,7 @@ function useOtherProductsWithTasks(
if (productKey === selectedProduct) {
return false
}
- const tasks = getTasksForProduct(productKey)
+ const tasks = getTasksForProduct(productKey, featureFlags)
const remainingTasks = tasks.filter((task) => {
const status = savedOnboardingTasks[task.id]
return status !== ActivationTaskStatus.COMPLETED && status !== ActivationTaskStatus.SKIPPED
@@ -869,7 +872,7 @@ function useOtherProductsWithTasks(
return remainingTasks.length > 0
}).map((productKey) => {
const productConfig = getProductSetupConfig(productKey)
- const tasks = getTasksForProduct(productKey)
+ const tasks = getTasksForProduct(productKey, featureFlags)
const remainingCount = tasks.filter((task) => {
const status = savedOnboardingTasks[task.id]
return status !== ActivationTaskStatus.COMPLETED && status !== ActivationTaskStatus.SKIPPED
@@ -894,5 +897,5 @@ function useOtherProductsWithTasks(
}
return a.name.localeCompare(b.name)
})
- }, [selectedProduct, savedOnboardingTasks])
+ }, [selectedProduct, savedOnboardingTasks, featureFlags])
}
diff --git a/frontend/src/lib/components/ProductSetup/productSetupLogic.test.ts b/frontend/src/lib/components/ProductSetup/productSetupLogic.test.ts
index 2ada01d14315..7da77658d948 100644
--- a/frontend/src/lib/components/ProductSetup/productSetupLogic.test.ts
+++ b/frontend/src/lib/components/ProductSetup/productSetupLogic.test.ts
@@ -2,6 +2,8 @@ import { router } from 'kea-router'
import { expectLogic } from 'kea-test-utils'
import { SetupTaskId } from 'lib/components/ProductSetup'
+import { FEATURE_FLAGS } from 'lib/constants'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { teamLogic } from 'scenes/teamLogic'
import { urls } from 'scenes/urls'
@@ -154,4 +156,37 @@ describe('productSetupLogic', () => {
aiLogic.unmount()
}
})
+
+ it.each([
+ {
+ firstRun: true,
+ tasks: [
+ { id: SetupTaskId.SendWorkflowTestEmail, locked: false },
+ { id: SetupTaskId.LaunchWorkflow, locked: false },
+ { id: SetupTaskId.SendFromOwnEmailDomain, locked: false },
+ ],
+ },
+ {
+ firstRun: false,
+ tasks: [
+ { id: SetupTaskId.SetUpFirstWorkflowChannel, locked: false },
+ { id: SetupTaskId.CreateFirstWorkflow, locked: false },
+ { id: SetupTaskId.ConfigureWorkflowTrigger, locked: false },
+ { id: SetupTaskId.AddWorkflowAction, locked: false },
+ { id: SetupTaskId.LaunchWorkflow, locked: true },
+ ],
+ },
+ ])('lists the Workflows quick start tasks with the first run flag on: $firstRun', async ({ firstRun, tasks }) => {
+ featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: firstRun })
+ const workflowsLogic = productSetupLogic({ productKey: ProductKey.WORKFLOWS })
+ workflowsLogic.mount()
+ try {
+ await setTeam({ onboarding_tasks: {} })
+
+ const productTasks = workflowsLogic.values.tasksWithState.filter((t) => t.taskType !== 'ai')
+ expect(productTasks.map((t) => ({ id: t.id, locked: !!t.lockedReason }))).toEqual(tasks)
+ } finally {
+ workflowsLogic.unmount()
+ }
+ })
})
diff --git a/frontend/src/lib/components/ProductSetup/productSetupLogic.ts b/frontend/src/lib/components/ProductSetup/productSetupLogic.ts
index 2d5208e0f551..5779529e9b6f 100644
--- a/frontend/src/lib/components/ProductSetup/productSetupLogic.ts
+++ b/frontend/src/lib/components/ProductSetup/productSetupLogic.ts
@@ -3,6 +3,7 @@ import { router } from 'kea-router'
import posthog from 'posthog-js'
import { SetupTaskId } from 'lib/components/ProductSetup'
+import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { preflightLogic } from 'lib/logic/preflightLogic'
import { organizationLogic } from 'scenes/organizationLogic'
import { inviteLogic } from 'scenes/settings/organization/inviteLogic'
@@ -30,6 +31,7 @@ export interface productSetupLogicValues {
optimisticTaskStatuses: Record // globalSetupLogic
isCurrentOrganizationNew: boolean // organizationLogic
isCloudOrDev: boolean | undefined // preflightLogic
+ featureFlags: FeatureFlagsSet // featureFlagLogic
hasReverseProxy: boolean | null // reverseProxyCheckerLogic
currentTeam: TeamPublicType | TeamType | null // teamLogic
activeTasks: SetupTaskWithState[]
@@ -116,7 +118,11 @@ export interface productSetupLogicMeta {
key: ProductKey
__keaTypeGenInternalSelectorTypes: {
productConfig: (productKey: ProductKey) => ProductSetupConfig | null
- allTasks: (productKey: ProductKey, isCloudOrDev: boolean | undefined) => SetupTask[]
+ allTasks: (
+ productKey: ProductKey,
+ isCloudOrDev: boolean | undefined,
+ featureFlags: FeatureFlagsSet
+ ) => SetupTask[]
savedOnboardingTasks: (
currentTeam: TeamPublicType | TeamType | null,
optimisticTaskStatuses: Record
@@ -176,6 +182,8 @@ export const productSetupLogic = kea([
['isCurrentOrganizationNew'],
preflightLogic,
['isCloudOrDev'],
+ featureFlagLogic,
+ ['featureFlags'],
globalSetupLogic,
['optimisticTaskStatuses'],
],
@@ -238,9 +246,9 @@ export const productSetupLogic = kea([
selectors({
productConfig: [(_, p) => [p.productKey], (productKey: ProductKey) => getProductSetupConfig(productKey)],
allTasks: [
- (s, p) => [p.productKey, s.isCloudOrDev],
- (productKey: ProductKey, isCloudOrDev: boolean | undefined) => {
- const tasks = getTasksForProduct(productKey)
+ (s, p) => [p.productKey, s.isCloudOrDev, s.featureFlags],
+ (productKey: ProductKey, isCloudOrDev: boolean | undefined, featureFlags: FeatureFlagsSet) => {
+ const tasks = getTasksForProduct(productKey, featureFlags)
if (!isCloudOrDev) {
return tasks.filter((task) => task.id !== SetupTaskId.SetUpReverseProxy)
}
diff --git a/frontend/src/lib/components/ProductSetup/productSetupRegistry.ts b/frontend/src/lib/components/ProductSetup/productSetupRegistry.ts
index 61a6e7b75b9f..40695ff6b5ed 100644
--- a/frontend/src/lib/components/ProductSetup/productSetupRegistry.ts
+++ b/frontend/src/lib/components/ProductSetup/productSetupRegistry.ts
@@ -1,3 +1,5 @@
+import { FEATURE_FLAGS } from 'lib/constants'
+import type { FeatureFlagsSet } from 'lib/logic/featureFlagLogic'
import { urls } from 'scenes/urls'
import { AvailableSetupTaskIdsEnumApi as SetupTaskId } from '~/generated/core/api.schemas'
@@ -642,6 +644,34 @@ export const PRODUCT_SETUP_REGISTRY: Partial urls.workflows(),
+ targetSelector: '[data-attr="first-run-recommended-template"]',
+ },
+ {
+ id: SetupTaskId.LaunchWorkflow,
+ title: 'Turn on your first workflow',
+ description: 'Enable a workflow so it starts sending emails.',
+ taskType: 'onboarding',
+ getUrl: () => urls.workflows(),
+ },
+ {
+ id: SetupTaskId.SendFromOwnEmailDomain,
+ title: 'Send from your own domain',
+ description: 'Verify your email domain so your emails come from your own address.',
+ taskType: 'onboarding',
+ getUrl: () => urls.workflows('channels'),
+ targetSelector: '[data-attr="new-channel-button"]',
+ },
+ ],
+ },
},
[ProductKey.ENDPOINTS]: {
@@ -710,22 +740,16 @@ export function getProductSetupConfig(productKey: ProductKey): ProductSetupConfi
return PRODUCT_SETUP_REGISTRY[productKey] ?? null
}
-/** Get all tasks for a product, optionally filtered by type. AI tasks are appended to every product. */
-export function getTasksForProduct(
- productKey: ProductKey,
- taskType?: 'setup' | 'onboarding' | 'explore' | 'ai' | 'all'
-): SetupTask[] {
+/** Get all tasks for a product. AI tasks are appended to every product. */
+export function getTasksForProduct(productKey: ProductKey, featureFlags: FeatureFlagsSet): SetupTask[] {
const config = getProductSetupConfig(productKey)
if (!config) {
return []
}
- const tasks = [...config.tasks, ...AI_TASKS]
- if (!taskType || taskType === 'all') {
- return tasks
- }
-
- return tasks.filter((t) => t.taskType === taskType)
+ const { tasksBehindFlag } = config
+ const productTasks = tasksBehindFlag && featureFlags[tasksBehindFlag.flag] ? tasksBehindFlag.tasks : config.tasks
+ return [...productTasks, ...AI_TASKS]
}
/** List of products that have setup flows configured */
diff --git a/frontend/src/lib/components/ProductSetup/types.ts b/frontend/src/lib/components/ProductSetup/types.ts
index ecfc42f82f14..210607560c2b 100644
--- a/frontend/src/lib/components/ProductSetup/types.ts
+++ b/frontend/src/lib/components/ProductSetup/types.ts
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react'
+import type { FeatureFlagKey } from 'lib/constants'
+
import type { AvailableSetupTaskIdsEnumApi } from '~/generated/core/api.schemas'
import type { ProductKey } from '~/queries/schema/schema-general'
@@ -61,6 +63,8 @@ export interface ProductSetupConfig {
productKey: ProductKey
title: string
tasks: SetupTask[]
+ /** Replaces `tasks` while the feature flag is on */
+ tasksBehindFlag?: { flag: FeatureFlagKey; tasks: SetupTask[] }
}
/** A pending attention highlight, bound to the route that asked for it */
diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx
index 11385a6b1238..dc99c24b6431 100644
--- a/frontend/src/lib/constants.tsx
+++ b/frontend/src/lib/constants.tsx
@@ -622,6 +622,7 @@ export const FEATURE_FLAGS = {
WORKFLOWS_DELAY_UNTIL_DATE: 'workflows-delay-until-date', // owner: @dmarchuk #team-workflows
WORKFLOWS_EMAIL_REPUTATION: 'workflows-email-reputation', // owner: #team-workflows
WORKFLOWS_EMAIL_SENDER_ROTATION: 'workflows-email-sender-rotation', // owner: @arthurdedeus #team-workflows
+ WORKFLOWS_FIRST_RUN: 'workflows-first-run', // owner: #team-workflows
WORKFLOWS_INTERNAL_EVENT_FILTERS: 'workflows-internal-event-filters', // owner: @haven #team-workflows
WORKFLOWS_ISP_SENDING_HEALTH: 'workflows-isp-sending-health', // owner: #team-workflows
WORKFLOWS_LINEAR_VIEW: 'workflows-linear-view', // owner: #team-workflows
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e23d4c6be512..c149653cb86a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5464,6 +5464,9 @@ importers:
'@storybook/react':
specifier: 'catalog:'
version: 10.4.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.4.6(@testing-library/dom@10.4.0)(@types/react@18.3.27)(prettier@3.8.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@6.0.3)
+ '@testing-library/dom':
+ specifier: '*'
+ version: 10.4.0
'@testing-library/react':
specifier: '*'
version: 14.3.1(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
diff --git a/posthog/models/team/setup_tasks.py b/posthog/models/team/setup_tasks.py
index df61551494ba..c3b8ea6ac317 100644
--- a/posthog/models/team/setup_tasks.py
+++ b/posthog/models/team/setup_tasks.py
@@ -82,6 +82,8 @@ class SetupTaskId(StrEnum):
ConfigureWorkflowTrigger = "configure_workflow_trigger"
AddWorkflowAction = "add_workflow_action"
LaunchWorkflow = "launch_workflow"
+ SendWorkflowTestEmail = "send_workflow_test_email"
+ SendFromOwnEmailDomain = "send_from_own_email_domain"
# Endpoints
CreateFirstEndpoint = "create_first_endpoint"
diff --git a/products/workflows/backend/presentation/views/hog_flow_template.py b/products/workflows/backend/presentation/views/hog_flow_template.py
index 0fd62e1c44fa..f75517834478 100644
--- a/products/workflows/backend/presentation/views/hog_flow_template.py
+++ b/products/workflows/backend/presentation/views/hog_flow_template.py
@@ -1,5 +1,7 @@
from typing import Any, Optional, cast
+from django.db import models
+
import structlog
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_field, extend_schema_view
@@ -134,6 +136,23 @@ def validate(self, data):
return data
+class HogFlowTemplateStartsOnSerializer(serializers.Serializer):
+ class Kind(models.TextChoices):
+ EVENT = "event", "Event"
+ NO_EVENT = "no_event", "No event"
+ SCHEDULE = "schedule", "Schedule"
+
+ kind = serializers.ChoiceField(
+ choices=Kind.choices,
+ help_text="Whether the template starts on an event, an absence of events, or a schedule.",
+ )
+ events = serializers.ListField(
+ child=serializers.CharField(),
+ help_text="Event names that can drive the template, in order of preference. Empty for schedules.",
+ )
+ detail = serializers.CharField(allow_blank=True, help_text="Short qualifier shown with the template's trigger.")
+
+
class HogFlowTemplateSerializer(serializers.Serializer):
"""
Serializer for creating hog flow templates.
@@ -155,6 +174,12 @@ class HogFlowTemplateSerializer(serializers.Serializer):
tags = serializers.ListField(
child=serializers.CharField(), required=False, default=list, help_text="Tags for filtering templates."
)
+ starts_on = HogFlowTemplateStartsOnSerializer(
+ read_only=True,
+ allow_null=True,
+ default=None,
+ help_text="What starts a global email template. Null for other templates.",
+ )
scope = serializers.ChoiceField(
choices=HogFlowTemplateScope.choices,
help_text="Who can use the template: this project only, or every project in the organization.",
diff --git a/products/workflows/backend/templates/announce_a_new_feature_template.json b/products/workflows/backend/templates/announce_a_new_feature_template.json
index d12e54e4bb48..9b4d179ded58 100644
--- a/products/workflows/backend/templates/announce_a_new_feature_template.json
+++ b/products/workflows/backend/templates/announce_a_new_feature_template.json
@@ -1,6 +1,11 @@
{
"id": "019b6fc4-282c-0000-50c9-aef7a834102d",
"name": "Announce a new feature",
+ "starts_on": {
+ "kind": "event",
+ "events": ["$feature_view"],
+ "detail": ""
+ },
"description": "Tell people about a new feature, but only the ones who can actually use it.",
"image_url": "https://res.cloudinary.com/dmukukwp6/image/upload/posthog_mobile_sessions_1aa340c741.jpg",
"scope": "global",
diff --git a/products/workflows/backend/templates/celebrate-milestone.json b/products/workflows/backend/templates/celebrate-milestone.json
index 59a4baf2b602..3b991dae7ec2 100644
--- a/products/workflows/backend/templates/celebrate-milestone.json
+++ b/products/workflows/backend/templates/celebrate-milestone.json
@@ -1,6 +1,11 @@
{
"id": "019cd30c-1652-0000-5ef1-980212d928fc",
"name": "Send congrats for a milestone reached",
+ "starts_on": {
+ "kind": "event",
+ "events": ["milestone_reached"],
+ "detail": ""
+ },
"description": "Congratulate someone when they reach a milestone, and point them at the next one.",
"version": 1,
"created_at": "2026-03-09T14:41:50.674613Z",
diff --git a/products/workflows/backend/templates/feature-adoption-tips.json b/products/workflows/backend/templates/feature-adoption-tips.json
index e7a6d5d70e63..7243fd464b0b 100644
--- a/products/workflows/backend/templates/feature-adoption-tips.json
+++ b/products/workflows/backend/templates/feature-adoption-tips.json
@@ -1,6 +1,11 @@
{
"id": "019cd2d3-79ae-0000-ebc6-28c983eb1e23",
"name": "Feature adoption tips",
+ "starts_on": {
+ "kind": "event",
+ "events": ["$pageview"],
+ "detail": "first time on a feature"
+ },
"description": "Send a short series of tips after someone starts using a feature, so they reach the point where it pays off.",
"version": 1,
"created_at": "2026-03-09T13:40:00.559123Z",
diff --git a/products/workflows/backend/templates/heavy-usage-detected.json b/products/workflows/backend/templates/heavy-usage-detected.json
index b4770d49ba34..091c0e7d4872 100644
--- a/products/workflows/backend/templates/heavy-usage-detected.json
+++ b/products/workflows/backend/templates/heavy-usage-detected.json
@@ -1,6 +1,11 @@
{
"id": "019cd31c-d834-0000-f997-6b771682bce7",
"name": "Heavy usage levels detected",
+ "starts_on": {
+ "kind": "event",
+ "events": ["usage_limit_near"],
+ "detail": ""
+ },
"description": "Warn someone when they get close to their usage limit, so they can upgrade or cut back in time.",
"version": 1,
"created_at": "2026-03-09T15:00:08.884504Z",
diff --git a/products/workflows/backend/templates/high-intent-notification.json b/products/workflows/backend/templates/high-intent-notification.json
index 74eae46014c0..fb64e953cbf6 100644
--- a/products/workflows/backend/templates/high-intent-notification.json
+++ b/products/workflows/backend/templates/high-intent-notification.json
@@ -1,6 +1,7 @@
{
"id": "019cd707-fd5b-0000-3090-053cbd91f8da",
"name": "Notify sales for high intent users",
+ "starts_on": null,
"description": "Alert your sales team in Slack when someone shows strong buying intent, so they can follow up while it is warm.",
"version": 1,
"created_at": "2026-03-10T09:15:51.003952Z",
diff --git a/products/workflows/backend/templates/negative-survey-response.json b/products/workflows/backend/templates/negative-survey-response.json
index ae2b3ee9d3d1..709f2df6b393 100644
--- a/products/workflows/backend/templates/negative-survey-response.json
+++ b/products/workflows/backend/templates/negative-survey-response.json
@@ -1,6 +1,7 @@
{
"id": "019cc379-c4c6-0000-9aee-d5efa598d0f4",
"name": "Negative survey response -> alert team",
+ "starts_on": null,
"description": "Alert your team in Slack when someone leaves negative survey feedback, so they can follow up.",
"version": 1,
"created_at": "2026-03-06T14:07:43.302433Z",
diff --git a/products/workflows/backend/templates/onboarding_started_but_not_completed_template.json b/products/workflows/backend/templates/onboarding_started_but_not_completed_template.json
index bb55ed42971f..d1856ba37a17 100644
--- a/products/workflows/backend/templates/onboarding_started_but_not_completed_template.json
+++ b/products/workflows/backend/templates/onboarding_started_but_not_completed_template.json
@@ -1,6 +1,11 @@
{
"id": "019b6fc7-ab48-0000-e2ac-82b2f714513a",
"name": "Onboarding started but not completed",
+ "starts_on": {
+ "kind": "event",
+ "events": ["$pageview"],
+ "detail": "on /onboarding"
+ },
"description": "Remind people who start onboarding but do not finish, without bothering the ones who did.",
"image_url": "https://res.cloudinary.com/dmukukwp6/image/upload/Hoggies_cover_thumbnail_94a9b19d5a.png",
"scope": "global",
@@ -33,56 +38,12 @@
"type": "event",
"value": "messaging/campaigns",
"operator": "icontains"
- },
- {
- "key": "email",
- "type": "person",
- "value": ["sara@posthog.com"],
- "operator": "exact"
}
]
}
],
"source": "events",
- "actions": [],
- "bytecode": [
- "_H",
- 1,
- 32,
- "$pageview",
- 32,
- "event",
- 1,
- 1,
- 11,
- 32,
- "%messaging/campaigns%",
- 32,
- "$current_url",
- 32,
- "properties",
- 1,
- 2,
- 2,
- "toString",
- 1,
- 18,
- 32,
- "sara@posthog.com",
- 32,
- "email",
- 32,
- "properties",
- 32,
- "person",
- 1,
- 3,
- 11,
- 3,
- 2,
- 3,
- 2
- ]
+ "actions": []
}
},
"trigger_masking": {
@@ -171,56 +132,12 @@
"type": "event",
"value": "messaging/campaigns",
"operator": "icontains"
- },
- {
- "key": "email",
- "type": "person",
- "value": ["sara@posthog.com"],
- "operator": "exact"
}
]
}
],
"source": "events",
- "actions": [],
- "bytecode": [
- "_H",
- 1,
- 32,
- "$pageview",
- 32,
- "event",
- 1,
- 1,
- 11,
- 32,
- "%messaging/campaigns%",
- 32,
- "$current_url",
- 32,
- "properties",
- 1,
- 2,
- 2,
- "toString",
- 1,
- 18,
- 32,
- "sara@posthog.com",
- 32,
- "email",
- 32,
- "properties",
- 32,
- "person",
- 1,
- 3,
- 11,
- 3,
- 2,
- 3,
- 2
- ]
+ "actions": []
}
},
"output_variable": null
diff --git a/products/workflows/backend/templates/re-engagement-workflow.json b/products/workflows/backend/templates/re-engagement-workflow.json
index 3248c7f3933d..2e60790f4ae4 100644
--- a/products/workflows/backend/templates/re-engagement-workflow.json
+++ b/products/workflows/backend/templates/re-engagement-workflow.json
@@ -1,6 +1,11 @@
{
"id": "019cd2a2-1f48-0000-3c3a-b665db575806",
"name": "Re-engagement workflow for inactive users",
+ "starts_on": {
+ "kind": "no_event",
+ "events": ["$pageview"],
+ "detail": "for 14 days"
+ },
"description": "Win back people who have not opened your product for a while, with a friendly reminder.",
"version": 1,
"created_at": "2026-03-09T12:46:06.152325Z",
diff --git a/products/workflows/backend/templates/repeated-failure-alert.json b/products/workflows/backend/templates/repeated-failure-alert.json
index 3b6424f15381..53ed8c2aa033 100644
--- a/products/workflows/backend/templates/repeated-failure-alert.json
+++ b/products/workflows/backend/templates/repeated-failure-alert.json
@@ -1,6 +1,7 @@
{
"id": "019cc330-b1cc-0000-85a3-4f18ffa9f2ee",
"name": "Alert team in case of a repeated failure",
+ "starts_on": null,
"description": "Alert your team in Slack when an action keeps failing, after a short wait that skips one-off errors.",
"version": 1,
"created_at": "2026-03-06T12:47:54.317218Z",
diff --git a/products/workflows/backend/templates/support-ticket-notification.json b/products/workflows/backend/templates/support-ticket-notification.json
index 123c913f8880..626379bd81a3 100644
--- a/products/workflows/backend/templates/support-ticket-notification.json
+++ b/products/workflows/backend/templates/support-ticket-notification.json
@@ -1,6 +1,7 @@
{
"id": "019ce1f1-5d12-0000-e318-4b3ebd4b2aea",
"name": "New support ticket notification",
+ "starts_on": null,
"description": "Notify your support team in Slack as soon as a new ticket comes in.",
"version": 1,
"created_at": "2026-03-12T12:06:57.554838Z",
diff --git a/products/workflows/backend/templates/ticket-unresolved-alert.json b/products/workflows/backend/templates/ticket-unresolved-alert.json
index e36c83bec502..54be288e8270 100644
--- a/products/workflows/backend/templates/ticket-unresolved-alert.json
+++ b/products/workflows/backend/templates/ticket-unresolved-alert.json
@@ -1,6 +1,7 @@
{
"id": "019ce1ff-cff1-0000-c9b5-6efb8119db5d",
"name": "Alert for unresolved tickets",
+ "starts_on": null,
"description": "Escalate a support ticket in Slack when it is still open after the time you set.",
"version": 1,
"created_at": "2026-03-12T12:22:44.465478Z",
diff --git a/products/workflows/backend/templates/trial-ending-reminder.json b/products/workflows/backend/templates/trial-ending-reminder.json
index c7a71ddfdc43..7ba710d60db4 100644
--- a/products/workflows/backend/templates/trial-ending-reminder.json
+++ b/products/workflows/backend/templates/trial-ending-reminder.json
@@ -1,6 +1,11 @@
{
"id": "019cc3b0-fd7d-0000-017d-c6e8ca4f737f",
"name": "Reminder for trial ending soon",
+ "starts_on": {
+ "kind": "event",
+ "events": ["$pageview"],
+ "detail": "ends in 3 days"
+ },
"description": "Email trial users before their trial runs out, and invite them to upgrade.",
"version": 1,
"created_at": "2026-03-06T15:08:02.302329Z",
diff --git a/products/workflows/backend/templates/trial_started_upgrade_nudge_template.json b/products/workflows/backend/templates/trial_started_upgrade_nudge_template.json
index 93dea4a4c04c..e10e436f2c3d 100644
--- a/products/workflows/backend/templates/trial_started_upgrade_nudge_template.json
+++ b/products/workflows/backend/templates/trial_started_upgrade_nudge_template.json
@@ -1,6 +1,11 @@
{
"id": "019b6fc6-ad68-0000-2c21-f701205f3f5c",
"name": "Trial started → upgrade nudge",
+ "starts_on": {
+ "kind": "event",
+ "events": ["trial_started"],
+ "detail": ""
+ },
"description": "Nudge trial users to upgrade when they show real usage, instead of when the clock runs down.",
"image_url": "https://res.cloudinary.com/dmukukwp6/image/upload/Elon_53dbe00271.jpg",
"scope": "global",
diff --git a/products/workflows/backend/templates/unlocking_advanced_features.json b/products/workflows/backend/templates/unlocking_advanced_features.json
index 37af4ed189d7..0b9d31d17de6 100644
--- a/products/workflows/backend/templates/unlocking_advanced_features.json
+++ b/products/workflows/backend/templates/unlocking_advanced_features.json
@@ -1,6 +1,11 @@
{
"id": "019cc2c0-99f0-0000-b510-b3774bc75c7e",
"name": "Unlocking advanced features",
+ "starts_on": {
+ "kind": "event",
+ "events": ["$pageview"],
+ "detail": "after a key action"
+ },
"description": "Unlock a feature when someone completes a key action, by setting a property you can target.",
"version": 1,
"created_at": "2026-03-06T10:45:28.176584Z",
diff --git a/products/workflows/backend/templates/unused-features-education.json b/products/workflows/backend/templates/unused-features-education.json
index 16b1b46c59cf..7a56585218e4 100644
--- a/products/workflows/backend/templates/unused-features-education.json
+++ b/products/workflows/backend/templates/unused-features-education.json
@@ -1,6 +1,11 @@
{
"id": "019cd710-ccd4-0000-a139-aa4f6f8b9a29",
"name": "Educate users for unused features",
+ "starts_on": {
+ "kind": "schedule",
+ "events": [],
+ "detail": "every 10 days"
+ },
"description": "Find people who have not tried an important feature yet, and send them a tip that gets them started.",
"version": 1,
"created_at": "2026-03-10T09:25:28.405138Z",
diff --git a/products/workflows/backend/templates/user-stuck-onboarding.json b/products/workflows/backend/templates/user-stuck-onboarding.json
index a632e209461e..64ac6f961714 100644
--- a/products/workflows/backend/templates/user-stuck-onboarding.json
+++ b/products/workflows/backend/templates/user-stuck-onboarding.json
@@ -1,6 +1,7 @@
{
"id": "019ce221-942b-0000-6929-15f99a355144",
"name": "User stuck in onboarding alert",
+ "starts_on": null,
"description": "Alert your team in Slack when someone starts onboarding and then gets stuck, so they can help.",
"version": 1,
"created_at": "2026-03-12T12:59:37.387738Z",
diff --git a/products/workflows/backend/templates/user-upgrade-webhook.json b/products/workflows/backend/templates/user-upgrade-webhook.json
index a92b3b71f587..6b98a7c95e07 100644
--- a/products/workflows/backend/templates/user-upgrade-webhook.json
+++ b/products/workflows/backend/templates/user-upgrade-webhook.json
@@ -1,6 +1,7 @@
{
"id": "019ce206-1ef1-0000-f1c1-3f9c433294c6",
"name": "Send a webhook when a user upgrades",
+ "starts_on": null,
"description": "Send a webhook when someone upgrades, so your CRM, billing tool, or backend can react.",
"version": 1,
"created_at": "2026-03-12T12:29:37.905693Z",
diff --git a/products/workflows/backend/templates/welcome_email_sequence_template.json b/products/workflows/backend/templates/welcome_email_sequence_template.json
index 28e7cf27e059..faf0481286a8 100644
--- a/products/workflows/backend/templates/welcome_email_sequence_template.json
+++ b/products/workflows/backend/templates/welcome_email_sequence_template.json
@@ -1,6 +1,11 @@
{
"id": "019b6f44-f9a3-0000-c4a7-b8050d25d690",
"name": "Welcome email sequence",
+ "starts_on": {
+ "kind": "event",
+ "events": ["user signed up", "signed_up", "sign_up", "signup", "user_signed_up"],
+ "detail": ""
+ },
"description": "Welcome new signups with an intro email, then check in on how they are getting on.",
"image_url": "https://res.cloudinary.com/dmukukwp6/image/upload/f_webp,h_270,w_480,c_fill,g_auto,q_auto/growth_think_905cd0fa65?_a=AXAH4nI0",
"scope": "global",
diff --git a/products/workflows/backend/tests/api/test_hog_flow_template.py b/products/workflows/backend/tests/api/test_hog_flow_template.py
index d483b095a43b..662cdc7ee43c 100644
--- a/products/workflows/backend/tests/api/test_hog_flow_template.py
+++ b/products/workflows/backend/tests/api/test_hog_flow_template.py
@@ -2,6 +2,9 @@
from posthog.test.base import APIBaseTest
+from django.test import SimpleTestCase
+
+from parameterized import parameterized
from rest_framework import status
from posthog.cdp.templates.fixtures import template_slack
@@ -16,6 +19,48 @@
webhook_template = MOCK_NODE_TEMPLATES[0]
+class TestGlobalTemplateStartsOn(SimpleTestCase):
+ def _assert_no_person_filters(self, value: object) -> None:
+ if isinstance(value, dict):
+ assert value.get("type") != "person", value
+ for child in value.values():
+ self._assert_no_person_filters(child)
+ elif isinstance(value, list):
+ for child in value:
+ self._assert_no_person_filters(child)
+
+ def test_email_templates_declare_what_starts_them(self) -> None:
+ clear_template_cache()
+ templates = load_global_templates()
+ assert templates
+
+ for template in templates:
+ with self.subTest(template=template["name"]):
+ self._assert_no_person_filters(template)
+ if not any(action["type"] == "function_email" for action in template["actions"]):
+ continue
+
+ starts_on = template["starts_on"]
+ assert starts_on["kind"] in {"event", "no_event", "schedule"}
+ assert isinstance(starts_on["detail"], str)
+ assert isinstance(starts_on["events"], list)
+ assert all(isinstance(event, str) and event.strip() for event in starts_on["events"])
+ assert len(starts_on["events"]) == len(set(starts_on["events"]))
+ if starts_on["kind"] == "schedule":
+ assert starts_on["events"] == []
+ else:
+ assert starts_on["events"]
+
+ triggers = [template["trigger"]] + [
+ action["config"] for action in template["actions"] if action["type"] == "trigger"
+ ]
+ for trigger in triggers:
+ if trigger["type"] == "event":
+ for event in trigger.get("filters", {}).get("events", []):
+ if event.get("id"):
+ assert event["id"] == starts_on["events"][0]
+
+
class TestHogFlowTemplateAPI(APIBaseTest):
def setUp(self):
super().setUp()
@@ -123,6 +168,26 @@ def test_template_creation(self):
# Verify inputs are preserved - only url was provided in custom_inputs
assert function_action["config"]["inputs"] == {"url": {"value": "https://custom.example.com"}}
+ @parameterized.expand([("team",), ("organization",)])
+ def test_custom_templates_cannot_set_starts_on(self, scope: str) -> None:
+ data = self._create_hog_flow_data()
+ data["scope"] = scope
+ data["starts_on"] = {"kind": "event", "events": ["signed_up"], "detail": ""}
+
+ created = self.client.post(f"/api/projects/{self.team.id}/hog_flow_templates", data)
+ assert created.status_code == status.HTTP_201_CREATED
+ assert created.json()["starts_on"] is None
+
+ template_id = created.json()["id"]
+ retrieved = self.client.get(f"/api/projects/{self.team.id}/hog_flow_templates/{template_id}")
+ assert retrieved.status_code == status.HTTP_200_OK
+ assert retrieved.json()["starts_on"] is None
+
+ listed = self.client.get(f"/api/projects/{self.team.id}/hog_flow_templates")
+ assert listed.status_code == status.HTTP_200_OK
+ template = next(template for template in listed.json()["results"] if template["id"] == template_id)
+ assert template["starts_on"] is None
+
def test_template_creation_with_multiple_function_actions(self):
"""Test that all function actions preserve their inputs when creating templates"""
trigger_action = {
@@ -487,6 +552,10 @@ def test_public_list_flow_templates(self):
# All returned templates should have scope='global'
for template in results:
assert template["scope"] == "global", f"Public endpoint returned non-global template: {template['id']}"
+ if any(action["type"] == "function_email" for action in template["actions"]):
+ assert template["starts_on"]["kind"] in {"event", "no_event", "schedule"}
+ else:
+ assert template["starts_on"] is None
def test_loads_templates_from_template_files(self):
"""Test that the API loads global templates from products/workflows/backend/templates/*.template.py"""
@@ -520,6 +589,10 @@ def test_loads_templates_from_template_files(self):
for template in api_results:
if template["id"] in file_template_ids:
assert template["scope"] == "global", f"Template {template['id']} from files should have scope='global'"
+ if any(action["type"] == "function_email" for action in template["actions"]):
+ assert template["starts_on"]["kind"] in {"event", "no_event", "schedule"}
+ else:
+ assert template["starts_on"] is None
def test_can_retrieve_individual_template_from_files(self):
"""Test that we can retrieve a specific template that's loaded from files"""
@@ -541,6 +614,18 @@ def test_can_retrieve_individual_template_from_files(self):
assert retrieved["name"] == template_name
assert retrieved["scope"] == "global"
+ def test_welcome_template_declares_signup_events(self) -> None:
+ response = self.client.get(
+ f"/api/projects/{self.team.id}/hog_flow_templates/019b6f44-f9a3-0000-c4a7-b8050d25d690"
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["starts_on"] == {
+ "kind": "event",
+ "events": ["user signed up", "signed_up", "sign_up", "signup", "user_signed_up"],
+ "detail": "",
+ }
+
def test_file_based_global_templates_not_accessible_via_write_endpoints(self):
"""Test that file-based global templates return 404 on update/delete (not in DB queryset)"""
clear_template_cache()
diff --git a/products/workflows/frontend/Broadcasts/broadcastTestSendLogic.ts b/products/workflows/frontend/Broadcasts/broadcastTestSendLogic.ts
index 154ec7237f22..5c484d74d3cd 100644
--- a/products/workflows/frontend/Broadcasts/broadcastTestSendLogic.ts
+++ b/products/workflows/frontend/Broadcasts/broadcastTestSendLogic.ts
@@ -9,7 +9,9 @@ import { userLogic } from 'scenes/userLogic'
import { UserType } from '~/types'
+import { completeTestEmailSetupTask } from '../completeTestEmailSetupTask'
import type { HogFlowApi } from '../generated/api.schemas'
+import { findTestSendSkipReason } from '../Workflows/hogflows/findTestSendSkipReason'
import { HogflowTestResult } from '../Workflows/hogflows/steps/types'
import { createExampleEvent } from '../Workflows/hogflows/testEventFactory'
import type { HogFlow, HogFlowAction, HogFlowEdge } from '../Workflows/hogflows/types'
@@ -26,21 +28,6 @@ import {
// and it goes over as `current_action_id` so the worker runs just this node.
const TEST_EMAIL_ACTION_ID = 'send_test_email'
-// Every reason the email worker declines a send is logged behind this prefix
-// (nodejs/src/cdp/services/messaging/email-validation.service.ts).
-const SKIP_LOG_PREFIX = 'Skipping send:'
-
-/**
- * The reason the worker declined to send, if it did.
- *
- * A declined send still finishes the step cleanly, so the invocation comes back `success` and the
- * reason appears only in the logs. Reading the status alone reports a test as delivered when
- * nothing was sent, and the sender waits for mail that never arrives.
- */
-export function findTestSendSkipReason(result: HogflowTestResult | null): string | undefined {
- return result?.logs?.find((log) => log.message?.includes(SKIP_LOG_PREFIX))?.message
-}
-
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface broadcastTestSendLogicValues {
previewPerson: BroadcastPreviewPerson | null // broadcastPreviewLogic
@@ -272,6 +259,7 @@ export const broadcastTestSendLogic = kea([
}
},
sendTestEmailSuccess: ({ testSendResult }) => {
+ completeTestEmailSetupTask(testSendResult)
// A declined send finishes the step cleanly, so check the reason before the status.
if (values.testSendSkipReason) {
lemonToast.warning('Nothing was sent, see why below')
diff --git a/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.test.ts b/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.test.ts
index 524ebf63c4f5..2b4cba4272bc 100644
--- a/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.test.ts
+++ b/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.test.ts
@@ -1,5 +1,9 @@
import { expectLogic } from 'kea-test-utils'
+import { SetupTaskId, globalSetupLogic } from 'lib/components/ProductSetup'
+import { FEATURE_FLAGS } from 'lib/constants'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
+
import { useMocks } from '~/mocks/jest'
import { initKeaTests } from '~/test/init'
import { IntegrationType } from '~/types'
@@ -7,14 +11,32 @@ import { IntegrationType } from '~/types'
import { emailSetupModalLogic } from './emailSetupModalLogic'
describe('emailSetupModalLogic', () => {
+ let verificationStatus: string
+ let completedSetupTasks: string[]
+
beforeEach(() => {
+ verificationStatus = 'pending'
+ completedSetupTasks = []
useMocks({
get: { '/api/environments/:team_id/integrations': { results: [] } },
post: {
- '/api/environments/:team_id/integrations/:id/email/verify': { status: 'pending', dnsRecords: [] },
+ '/api/environments/:team_id/integrations/:id/email/verify': () => [
+ 200,
+ { status: verificationStatus, dnsRecords: [] },
+ ],
+ },
+ patch: {
+ '/api/projects/:team_id/': async ({ request }) => {
+ const { onboarding_tasks } = (await request.json()) as { onboarding_tasks: Record }
+ completedSetupTasks = Object.keys(onboarding_tasks).filter(
+ (taskId) => onboarding_tasks[taskId] === 'completed'
+ )
+ return [200, {}]
+ },
},
})
initKeaTests()
+ globalSetupLogic.mount()
})
// A legacy or hand-edited integration row can hold a config without an `email` key. An unguarded
@@ -33,4 +55,26 @@ describe('emailSetupModalLogic', () => {
emailSender: expect.objectContaining({ email: '', name: '', provider: 'ses' }),
})
})
+
+ it.each([
+ { status: 'success', firstRun: true, completesOwnDomainTask: true },
+ { status: 'pending', firstRun: true, completesOwnDomainTask: false },
+ { status: 'success', firstRun: false, completesOwnDomainTask: false },
+ ])(
+ 'completes the own domain quick start task only when verification returns $status',
+ async ({ status, firstRun, completesOwnDomainTask }) => {
+ verificationStatus = status
+ featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: firstRun })
+ const logic = emailSetupModalLogic({
+ integration: { id: 1, kind: 'email', config: { email: 'hello@example.com' } } as IntegrationType,
+ onComplete: () => {},
+ onClose: () => {},
+ })
+ logic.mount()
+
+ await expectLogic(logic).toFinishAllListeners()
+
+ expect(completedSetupTasks.includes(SetupTaskId.SendFromOwnEmailDomain)).toBe(completesOwnDomainTask)
+ }
+ )
})
diff --git a/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.ts b/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.ts
index 8d8a70dda1ab..81429f896d0b 100644
--- a/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.ts
+++ b/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.ts
@@ -4,8 +4,11 @@ import type { DeepPartial, DeepPartialMap, FieldName, ValidationErrorType } from
import { loaders } from 'kea-loaders'
import api, { ApiError } from 'lib/api'
+import { SetupTaskId, globalSetupLogic } from 'lib/components/ProductSetup'
+import { FEATURE_FLAGS } from 'lib/constants'
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
import { lemonToast } from 'lib/lemon-ui/LemonToast'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { IntegrationType } from '~/types'
@@ -283,6 +286,9 @@ export const emailSetupModalLogic = kea([
verifyDomainSuccess: ({ verification }) => {
if (verification?.status === 'success') {
actions.loadIntegrations()
+ if (featureFlagLogic.findMounted()?.values.featureFlags[FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]) {
+ globalSetupLogic.findMounted()?.actions.markTaskAsCompleted(SetupTaskId.SendFromOwnEmailDomain)
+ }
}
},
})),
diff --git a/products/workflows/frontend/Channels/ownSenderQuickStartLogic.test.ts b/products/workflows/frontend/Channels/ownSenderQuickStartLogic.test.ts
new file mode 100644
index 000000000000..04c9fa646193
--- /dev/null
+++ b/products/workflows/frontend/Channels/ownSenderQuickStartLogic.test.ts
@@ -0,0 +1,72 @@
+import { expectLogic } from 'kea-test-utils'
+
+import { SetupTaskId, globalSetupLogic } from 'lib/components/ProductSetup'
+import { FEATURE_FLAGS } from 'lib/constants'
+import { integrationsLogic } from 'lib/integrations/integrationsLogic'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
+
+import { useMocks } from '~/mocks/jest'
+import { initKeaTests } from '~/test/init'
+
+import type { IntegrationConfigApi } from 'products/integrations/frontend/generated/api.schemas'
+
+import { ownSenderQuickStartLogic } from './ownSenderQuickStartLogic'
+
+const OWN_SENDER = {
+ id: 5,
+ kind: 'email',
+ display_name: 'Sender ',
+ config: { verified: true, email: 'sender@example.com' },
+} satisfies Partial
+
+const UNVERIFIED_OWN_SENDER = { ...OWN_SENDER, config: { verified: false, email: 'sender@example.com' } }
+
+const SANDBOX_SENDER = {
+ id: 7,
+ kind: 'email',
+ display_name: 'Example via PostHog ',
+ config: { provider: 'sandbox', verified: true, email: 'sandbox@example.com' },
+} satisfies Partial
+
+describe('ownSenderQuickStartLogic', () => {
+ let completedSetupTasks: string[]
+
+ beforeEach(() => {
+ completedSetupTasks = []
+ })
+
+ it.each([
+ { case: 'a verified own sender', integrations: [OWN_SENDER], firstRun: true, completes: true },
+ { case: 'an unverified own sender', integrations: [UNVERIFIED_OWN_SENDER], firstRun: true, completes: false },
+ { case: 'only the sandbox sender', integrations: [SANDBOX_SENDER], firstRun: true, completes: false },
+ { case: 'the first run flag off', integrations: [OWN_SENDER], firstRun: false, completes: false },
+ ])(
+ 'completes the own domain quick start task when the loaded senders hold $case: $completes',
+ async ({ integrations, firstRun, completes }) => {
+ useMocks({
+ get: { '/api/projects/:team_id/integrations/': () => [200, { results: integrations }] },
+ patch: {
+ '/api/projects/:team_id/': async ({ request }) => {
+ const { onboarding_tasks } = (await request.json()) as {
+ onboarding_tasks: Record
+ }
+ completedSetupTasks = Object.keys(onboarding_tasks).filter(
+ (taskId) => onboarding_tasks[taskId] === 'completed'
+ )
+ return [200, {}]
+ },
+ },
+ })
+ initKeaTests()
+ globalSetupLogic.mount()
+ featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: firstRun })
+
+ const logic = ownSenderQuickStartLogic()
+ logic.mount()
+ await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess'])
+ await expectLogic(logic).toFinishAllListeners()
+
+ expect(completedSetupTasks.includes(SetupTaskId.SendFromOwnEmailDomain)).toBe(completes)
+ }
+ )
+})
diff --git a/products/workflows/frontend/Channels/ownSenderQuickStartLogic.ts b/products/workflows/frontend/Channels/ownSenderQuickStartLogic.ts
new file mode 100644
index 000000000000..ec0cce662d50
--- /dev/null
+++ b/products/workflows/frontend/Channels/ownSenderQuickStartLogic.ts
@@ -0,0 +1,42 @@
+import { MakeLogicType, connect, kea, path, selectors } from 'kea'
+import { subscriptions } from 'kea-subscriptions'
+
+import { SetupTaskId, globalSetupLogic } from 'lib/components/ProductSetup'
+
+import { FirstRunSender, firstRunSenderLogic } from '../firstRun/firstRunSenderLogic'
+
+export interface ownSenderQuickStartLogicValues {
+ firstRunSender: FirstRunSender | null
+ ownDomainTaskDone: boolean
+}
+
+export interface ownSenderQuickStartLogicMeta {
+ __keaTypeGenInternalSelectorTypes: {
+ ownDomainTaskDone: (firstRunSender: FirstRunSender | null) => boolean
+ }
+}
+
+export type ownSenderQuickStartLogicType = MakeLogicType<
+ ownSenderQuickStartLogicValues,
+ Record,
+ Record,
+ ownSenderQuickStartLogicMeta
+>
+
+export const ownSenderQuickStartLogic = kea([
+ path(['products', 'workflows', 'frontend', 'Channels', 'ownSenderQuickStartLogic']),
+ connect({ values: [firstRunSenderLogic, ['firstRunSender']] }),
+ selectors({
+ ownDomainTaskDone: [
+ (s) => [s.firstRunSender],
+ (firstRunSender: FirstRunSender | null): boolean => !!firstRunSender,
+ ],
+ }),
+ subscriptions({
+ ownDomainTaskDone: (done: boolean) => {
+ if (done) {
+ globalSetupLogic.findMounted()?.actions.markTaskAsCompleted(SetupTaskId.SendFromOwnEmailDomain)
+ }
+ },
+ }),
+])
diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts
index a7efe158ed80..6cf6dab8fe5a 100644
--- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts
+++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts
@@ -10,6 +10,7 @@ import { userLogic } from 'scenes/userLogic'
import { IntegrationType, UserType } from '~/types'
+import { completeTestEmailSetupTask } from '../completeTestEmailSetupTask'
import { HogflowTestResult } from '../Workflows/hogflows/steps/types'
import { createExampleEvent } from '../Workflows/hogflows/testEventFactory'
import type { HogFlow, HogFlowAction, HogFlowEdge } from '../Workflows/hogflows/types'
@@ -247,6 +248,7 @@ export const messageTemplateTestSendLogic = kea {
+ completeTestEmailSetupTask(testSendResult)
if (testSendResult.status === 'success') {
lemonToast.success(`Test email sent to ${values.recipientEmail}`)
actions.setModalOpen(false)
diff --git a/products/workflows/frontend/Workflows/WorkflowScene.tsx b/products/workflows/frontend/Workflows/WorkflowScene.tsx
index de9b480ad762..e3784508d5bb 100644
--- a/products/workflows/frontend/Workflows/WorkflowScene.tsx
+++ b/products/workflows/frontend/Workflows/WorkflowScene.tsx
@@ -22,6 +22,7 @@ import { SceneContent } from '~/layout/scenes/components/SceneContent'
import { ProductKey } from '~/queries/schema/schema-general'
import { ActivityScope } from '~/types'
+import { FirstRunWorkflowBanner } from '../firstRun/FirstRunWorkflowBanner'
import { batchWorkflowJobsLogic } from './batchWorkflowJobsLogic'
import { NewWorkflowAgent } from './NewWorkflowAgent'
import { newWorkflowLogic } from './newWorkflowLogic'
@@ -219,6 +220,7 @@ export function WorkflowScene(props: WorkflowSceneLogicProps): JSX.Element {
+
{selfOptimisingEnabled && isSavedWorkflow && }
{/* Only show Logs and Metrics tabs if the workflow has already been created */}
{!props.id || props.id === 'new' ? (
diff --git a/products/workflows/frontend/Broadcasts/broadcastTestSend.test.ts b/products/workflows/frontend/Workflows/hogflows/findTestSendSkipReason.test.ts
similarity index 90%
rename from products/workflows/frontend/Broadcasts/broadcastTestSend.test.ts
rename to products/workflows/frontend/Workflows/hogflows/findTestSendSkipReason.test.ts
index 953830604fab..66f7903aea89 100644
--- a/products/workflows/frontend/Broadcasts/broadcastTestSend.test.ts
+++ b/products/workflows/frontend/Workflows/hogflows/findTestSendSkipReason.test.ts
@@ -1,5 +1,5 @@
-import { HogflowTestResult } from '../Workflows/hogflows/steps/types'
-import { findTestSendSkipReason } from './broadcastTestSendLogic'
+import { findTestSendSkipReason } from './findTestSendSkipReason'
+import { HogflowTestResult } from './steps/types'
const result = (partial: Partial): HogflowTestResult =>
({ status: 'success', nextActionId: null, ...partial }) as HogflowTestResult
diff --git a/products/workflows/frontend/Workflows/hogflows/findTestSendSkipReason.ts b/products/workflows/frontend/Workflows/hogflows/findTestSendSkipReason.ts
new file mode 100644
index 000000000000..f0deee3689fc
--- /dev/null
+++ b/products/workflows/frontend/Workflows/hogflows/findTestSendSkipReason.ts
@@ -0,0 +1,16 @@
+import type { HogflowTestResult } from './steps/types'
+
+// Every reason the email worker declines a send is logged behind this prefix
+// (nodejs/src/cdp/services/messaging/email-validation.service.ts).
+const SKIP_LOG_PREFIX = 'Skipping send:'
+
+/**
+ * The reason the worker declined to send, if it did.
+ *
+ * A declined send still finishes the step cleanly, so the invocation comes back `success` and the
+ * reason appears only in the logs. Reading the status alone reports a test as delivered when
+ * nothing was sent, and the sender waits for mail that never arrives.
+ */
+export function findTestSendSkipReason(result: HogflowTestResult | null): string | undefined {
+ return result?.logs?.find((log) => log.message?.includes(SKIP_LOG_PREFIX))?.message
+}
diff --git a/products/workflows/frontend/Workflows/hogflows/panel/testing/hogFlowEditorNotificationTestLogic.ts b/products/workflows/frontend/Workflows/hogflows/panel/testing/hogFlowEditorNotificationTestLogic.ts
index 0a3e3623ea1b..ba3bbab73738 100644
--- a/products/workflows/frontend/Workflows/hogflows/panel/testing/hogFlowEditorNotificationTestLogic.ts
+++ b/products/workflows/frontend/Workflows/hogflows/panel/testing/hogFlowEditorNotificationTestLogic.ts
@@ -14,9 +14,10 @@ import { EventsQuery, NodeKind } from '~/queries/schema/schema-general'
import { hogql } from '~/queries/utils'
import { CyclotronJobInvocationGlobals, FilterLogicalOperator, PersonType, PropertyFilterType } from '~/types'
+import { completeTestEmailSetupTask } from '../../../../completeTestEmailSetupTask'
import { WorkflowLogicProps, workflowLogic } from '../../../workflowLogic'
import { hogFlowEditorLogic } from '../../hogFlowEditorLogic'
-import { HogflowTestResult } from '../../steps/types'
+import { HogflowTestResult, isEmailAction } from '../../steps/types'
import { createExampleEvent } from '../../testEventFactory'
import type { HogFlow } from '../../types'
import { createGlobalsFromResponse } from './hogFlowEditorTestLogic'
@@ -350,6 +351,9 @@ export const hogFlowEditorNotificationTestLogic = kea {
try {
const parsedGlobals = JSON.parse(testInvocation.globals)
+ const testedAction = values.workflowSanitized.actions.find(
+ (action) => action.id === values.selectedNodeId
+ )
// Use emailAddressOverride if set, otherwise fall back to sampleGlobals person email
const emailToUse = values.emailAddressOverride ?? values.sampleGlobals?.person?.properties?.email
@@ -388,6 +392,9 @@ export const hogFlowEditorNotificationTestLogic = kea void
onEdit?: (e: React.MouseEvent) => void
onDelete?: (e: React.MouseEvent) => void
@@ -27,6 +29,7 @@ export function WorkflowTemplateCard({
preview,
badge,
footer,
+ highlighted,
onClick,
onEdit,
onDelete,
@@ -41,7 +44,10 @@ export function WorkflowTemplateCard({
type="button"
onClick={onClick}
data-attr={dataAttr}
- className="flex flex-col gap-2 w-full h-full p-4 text-left border rounded bg-surface-primary hover:border-primary hover:bg-surface-secondary transition-colors"
+ className={clsx(
+ 'flex flex-col gap-2 w-full h-full p-4 text-left border rounded bg-surface-primary hover:bg-surface-secondary transition-colors',
+ highlighted ? 'border-accent ring-1 ring-accent' : 'hover:border-primary'
+ )}
>
{preview}
diff --git a/products/workflows/frontend/WorkflowsScene.tsx b/products/workflows/frontend/WorkflowsScene.tsx
index 7aba15ff2148..9459b9a6e32f 100644
--- a/products/workflows/frontend/WorkflowsScene.tsx
+++ b/products/workflows/frontend/WorkflowsScene.tsx
@@ -1,10 +1,23 @@
-import { MakeLogicType, actions, kea, path, props, reducers, selectors, useActions, useValues } from 'kea'
+import {
+ MakeLogicType,
+ actions,
+ kea,
+ path,
+ props,
+ reducers,
+ selectors,
+ useActions,
+ useMountedLogic,
+ useValues,
+} from 'kea'
import { urlToAction } from 'kea-router'
import { LemonButton } from '@posthog/lemon-ui'
import { AccessControlAction } from 'lib/components/AccessControlAction'
+import { FEATURE_FLAGS } from 'lib/constants'
import { LemonTab, LemonTabs } from 'lib/lemon-ui/LemonTabs'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { trackedActionToUrl } from 'lib/logic/scenes/trackedActionToUrl'
import { addProductIntent } from 'lib/utils/product-intents'
import { capitalizeFirstLetter } from 'lib/utils/strings'
@@ -17,8 +30,10 @@ import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
import { ProductIntentContext, ProductKey } from '~/queries/schema/schema-general'
import { AccessControlLevel, AccessControlResourceType, Breadcrumb } from '~/types'
+import { ownSenderQuickStartLogic } from './Channels/ownSenderQuickStartLogic'
import { EmailSuspensionBanner } from './EmailSuspensionBanner'
import { workflowsEmptyState } from './emptyState/workflowsEmptyState'
+import { WorkflowsFirstRunTab } from './firstRun/WorkflowsFirstRunTab'
import { MessagingTabActions } from './MessagingTabActions'
import { messagingNavTabs } from './messagingTabs'
import { newWorkflowLogic } from './Workflows/newWorkflowLogic'
@@ -125,11 +140,13 @@ export const scene: SceneExport = {
export function WorkflowsScene(props: WorkflowsSceneProps = {}): JSX.Element {
const { currentTab } = useValues(workflowsSceneLogic(props))
const { startNewWorkflow } = useActions(newWorkflowLogic)
+ const { featureFlags } = useValues(featureFlagLogic)
+ useMountedLogic(ownSenderQuickStartLogic)
const tabs: LemonTab[] = [
{
label: 'Workflows',
key: 'workflows',
- content: ,
+ content: featureFlags[FEATURE_FLAGS.WORKFLOWS_FIRST_RUN] ? : ,
link: urls.workflows(),
},
...messagingNavTabs((tab) => urls.workflows(tab)),
diff --git a/products/workflows/frontend/completeTestEmailSetupTask.ts b/products/workflows/frontend/completeTestEmailSetupTask.ts
new file mode 100644
index 000000000000..e8b5617c163a
--- /dev/null
+++ b/products/workflows/frontend/completeTestEmailSetupTask.ts
@@ -0,0 +1,16 @@
+import { SetupTaskId, globalSetupLogic } from 'lib/components/ProductSetup'
+import { FEATURE_FLAGS } from 'lib/constants'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
+
+import { findTestSendSkipReason } from './Workflows/hogflows/findTestSendSkipReason'
+import type { HogflowTestResult } from './Workflows/hogflows/steps/types'
+
+export function completeTestEmailSetupTask(result: HogflowTestResult | null): void {
+ if (
+ featureFlagLogic.findMounted()?.values.featureFlags[FEATURE_FLAGS.WORKFLOWS_FIRST_RUN] &&
+ result?.status === 'success' &&
+ !findTestSendSkipReason(result)
+ ) {
+ globalSetupLogic.findMounted()?.actions.markTaskAsCompleted(SetupTaskId.SendWorkflowTestEmail)
+ }
+}
diff --git a/products/workflows/frontend/emptyState/workflowsEmptyState.tsx b/products/workflows/frontend/emptyState/workflowsEmptyState.tsx
index 113949495b46..2c66375bd91e 100644
--- a/products/workflows/frontend/emptyState/workflowsEmptyState.tsx
+++ b/products/workflows/frontend/emptyState/workflowsEmptyState.tsx
@@ -3,6 +3,7 @@ import { IconDecisionTree } from '@posthog/icons'
import { pngHoggie } from 'lib/brand/hoggies'
import type { SceneProductEmptyState } from 'lib/components/ProductEmptyState/types'
+import { FEATURE_FLAGS } from 'lib/constants'
import { Scene } from 'scenes/sceneTypes'
import { ProductKey } from '~/queries/schema/schema-general'
@@ -15,6 +16,8 @@ const HedgehogWorkflows = pngHoggie(workflowsPng)
export const workflowsEmptyState: SceneProductEmptyState = {
statusLogic: workflowsSetupLogic,
+ // The first run puts a template gallery on the Workflows tab itself, so the scene keeps its tabs.
+ bypassFeatureFlag: FEATURE_FLAGS.WORKFLOWS_FIRST_RUN,
// One scene serves every tab here, but only the workflow list is the product being gated.
// Channels, the message library, opt-outs, suppression, and reputation configure resources
// that stand on their own, and a person often sets a channel up before a first workflow.
diff --git a/products/workflows/frontend/firstRun/FirstRunAgentHelp.test.tsx b/products/workflows/frontend/firstRun/FirstRunAgentHelp.test.tsx
new file mode 100644
index 000000000000..14ce8f76c65a
--- /dev/null
+++ b/products/workflows/frontend/firstRun/FirstRunAgentHelp.test.tsx
@@ -0,0 +1,89 @@
+import '@testing-library/jest-dom'
+
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import posthog from 'posthog-js'
+
+import { FEATURE_FLAGS } from 'lib/constants'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
+
+import { useMocks } from '~/mocks/jest'
+import { initKeaTests } from '~/test/init'
+
+import type { HogFlowTemplateApi } from '../generated/api.schemas'
+import { FirstRunAgentHelp } from './FirstRunAgentHelp'
+
+const WELCOME: Partial = {
+ id: '019b6f44-f9a3-0000-c4a7-b8050d25d690',
+ name: 'Welcome',
+ scope: 'global',
+ starts_on: { kind: 'event', events: ['user signed up'], detail: '' },
+ tags: [],
+ actions: [],
+}
+
+describe('FirstRunAgentHelp', () => {
+ let seenEvents: string[]
+
+ beforeEach(() => {
+ seenEvents = []
+ useMocks({
+ get: {
+ '/api/projects/:team_id/hog_flow_templates/': { count: 1, results: [WELCOME] },
+ '/api/projects/:team_id/event_definitions/': () => [
+ 200,
+ {
+ count: seenEvents.length,
+ results: seenEvents.map((name) => ({ id: name, name, last_seen_at: '2026-10-01T00:00:00Z' })),
+ },
+ ],
+ '/api/projects/:team_id/property_definitions/': { count: 0, results: [] },
+ },
+ })
+ initKeaTests()
+ featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: true })
+ })
+
+ afterEach(() => {
+ cleanup()
+ jest.restoreAllMocks()
+ window.localStorage.clear()
+ })
+
+ function renderForProject(project: { seenEvents: string[] }): void {
+ seenEvents = project.seenEvents
+ render()
+ }
+
+ it.each([
+ {
+ project: 'without a signup event',
+ seenEvents: [],
+ missing: 'signup_event',
+ asks: 'Capture an event named exactly "user signed up"',
+ },
+ {
+ project: 'without an email property',
+ seenEvents: ['user signed up'],
+ missing: 'email',
+ asks: 'Make sure PostHog knows the email address of each user',
+ },
+ ])('copies the prompt for a project $project', async ({ seenEvents, missing, asks }) => {
+ const capture = jest.spyOn(posthog, 'capture').mockImplementation()
+ renderForProject({ seenEvents })
+ const writeText = jest.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
+
+ fireEvent.click(await screen.findByText('Copy prompt for your coding agent'))
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(expect.stringContaining(asks)))
+ expect(capture).toHaveBeenCalledWith('workflows first run agent prompt copied', { missing })
+ })
+
+ it('shows the setup agent command when asked to let the Wizard do it', async () => {
+ renderForProject({ seenEvents: [] })
+
+ fireEvent.click(await screen.findByText('Let the Wizard do it'))
+
+ expect(screen.getByText('npx -y @posthog/wizard@latest')).toBeInTheDocument()
+ })
+})
diff --git a/products/workflows/frontend/firstRun/FirstRunAgentHelp.tsx b/products/workflows/frontend/firstRun/FirstRunAgentHelp.tsx
new file mode 100644
index 000000000000..87e92dd7d695
--- /dev/null
+++ b/products/workflows/frontend/firstRun/FirstRunAgentHelp.tsx
@@ -0,0 +1,101 @@
+import { useActions, useValues } from 'kea'
+
+import { IconMagicWand } from '@posthog/icons'
+import { LemonBanner, LemonButton } from '@posthog/lemon-ui'
+
+import { AgentPromptButton } from 'lib/components/AgentPromptButton'
+import { useWizardCommand } from 'scenes/onboarding/shared/useWizardCommand'
+import { WizardCommandBlock } from 'scenes/onboarding/shared/wizard-sync/WizardCommandBlock'
+
+import { firstRunAgentHelpLogic, MissingData } from './firstRunAgentHelpLogic'
+
+const SIGNUP_EVENT_PROMPT = `My app uses PostHog. Send an event to PostHog each time someone signs up, so a PostHog workflow can email new users.
+
+1. Find the code that runs once a signup has succeeded. Prefer server-side code if the app has a backend.
+2. If PostHog is not set up in that part of the app yet, install and initialize the PostHog SDK the way the PostHog docs describe for this framework.
+3. Capture an event named exactly "user signed up" for the new user. Use the user's ID as the distinct ID.
+4. Set the user's email address as the "email" person property. A server SDK takes it in the person properties ($set) of that capture call. In posthog-js, call posthog.identify(userId, { email }).
+
+Follow the conventions of this codebase and keep the change small.`
+
+const EMAIL_PROMPT = `My app uses PostHog. Make sure PostHog knows the email address of each user, so a PostHog workflow can email them.
+
+1. Find where the app identifies users with PostHog. This usually happens after login and after signup.
+2. If the app never identifies users, identify them when they log in and when they sign up. Use the user's ID as the distinct ID.
+3. Set the user's email address as the "email" person property. In posthog-js, call posthog.identify(userId, { email }). A server SDK takes it in the person properties ($set) of an identify or capture call.
+
+Follow the conventions of this codebase and keep the change small.`
+
+interface AgentHelp {
+ type: 'info' | 'warning'
+ message: string
+ prompt: string
+ wizardCheck: string
+}
+
+const AGENT_HELP: Record = {
+ signup_event: {
+ type: 'info',
+ message:
+ 'You can test an email right now. To send it to real signups, your app needs to tell PostHog when someone signs up.',
+ prompt: SIGNUP_EVENT_PROMPT,
+ wizardCheck: 'Check that its changes send a "user signed up" event when someone signs up.',
+ },
+ email: {
+ type: 'warning',
+ message:
+ "People in this project have no email address yet, so they would miss your emails. Your app needs to send each user's email to PostHog.",
+ prompt: EMAIL_PROMPT,
+ wizardCheck: "Check that its changes set each user's email when your app identifies them.",
+ },
+}
+
+export function FirstRunAgentHelp(): JSX.Element | null {
+ const { missingData, wizardShown } = useValues(firstRunAgentHelpLogic)
+ const { reportPromptCopied, setWizardShown } = useActions(firstRunAgentHelpLogic)
+ const { isCloudOrDev } = useWizardCommand()
+
+ if (!missingData) {
+ return null
+ }
+ const { type, message, prompt, wizardCheck } = AGENT_HELP[missingData]
+
+ return (
+
+
+ )
+}
+
+function gallerySubtitle(readyCount: number, activeFilter: GalleryFilter): string {
+ if (readyCount === 0) {
+ return 'Your app does not send the events these templates need yet. Each card says what is missing.'
+ }
+ return activeFilter === 'picked'
+ ? 'These templates work with the events your app sends today. Pick one to make it yours.'
+ : 'Templates that work with the events your app sends today come first.'
+}
diff --git a/products/workflows/frontend/firstRun/WorkflowsFirstRunMakeItYours.stories.tsx b/products/workflows/frontend/firstRun/WorkflowsFirstRunMakeItYours.stories.tsx
new file mode 100644
index 000000000000..fb0ad9c7c99d
--- /dev/null
+++ b/products/workflows/frontend/firstRun/WorkflowsFirstRunMakeItYours.stories.tsx
@@ -0,0 +1,138 @@
+import { Meta, StoryObj } from '@storybook/react'
+import { within } from '@testing-library/dom'
+
+import { FEATURE_FLAGS } from 'lib/constants'
+import { App } from 'scenes/App'
+import { urls } from 'scenes/urls'
+
+import { mswDecorator } from '~/mocks/browser'
+import { EMPTY_PAGINATED_RESPONSE, toPaginatedResponse } from '~/mocks/handlers'
+
+import { userEvent } from 'storybook/test'
+
+import { FIRST_RUN_TEMPLATE_PARAM } from './firstRunGalleryLogic'
+import {
+ RE_ENGAGEMENT_ID,
+ OWN_SENDER,
+ TRIAL_UPGRADE_NUDGE_ID,
+ WELCOME_SEQUENCE_ID,
+ globalTemplates,
+ projectThatSends,
+} from './firstRunStoryFixtures'
+
+function makeItYoursUrl(templateId: string): string {
+ return `${urls.workflows()}?${FIRST_RUN_TEMPLATE_PARAM}=${templateId}`
+}
+
+const SKIP_REASON =
+ 'Skipping send: the domain "example.com" has no reachable mail servers, so this message would hard bounce.'
+
+const meta: Meta = {
+ component: App,
+ title: 'Products/Workflows/First run make it yours',
+ parameters: {
+ layout: 'fullscreen',
+ viewMode: 'story',
+ mockDate: '2026-10-05',
+ pageUrl: makeItYoursUrl(WELCOME_SEQUENCE_ID),
+ featureFlags: [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN],
+ testOptions: { viewport: { width: 1440, height: 1400 }, waitForLoadersToDisappear: true },
+ },
+ decorators: [
+ mswDecorator({
+ get: {
+ '/api/projects/:team_id/hog_flows/': EMPTY_PAGINATED_RESPONSE,
+ '/api/projects/:team_id/hog_flows/email_sending_suspension/': {
+ email_sending_suspended: false,
+ email_sending_suspended_at: null,
+ email_sending_suspension_reason: '',
+ },
+ '/api/projects/:team_id/hog_flow_templates/': toPaginatedResponse(globalTemplates),
+ '/api/projects/:team_id/integrations/': toPaginatedResponse([OWN_SENDER]),
+ '/api/projects/:team_id/messaging_templates/': EMPTY_PAGINATED_RESPONSE,
+ },
+ post: {
+ '/api/projects/:team_id/hog_flows/': [201, { id: 'wf-1', status: 'active' }],
+ '/api/projects/:team_id/hog_flows/new/invocations/': [
+ 200,
+ { status: 'success', logs: [], nextActionId: null },
+ ],
+ },
+ }),
+ mswDecorator(projectThatSends(['signed_up', '$pageview', '$feature_view'])),
+ ],
+}
+export default meta
+
+type Story = StoryObj<{}>
+
+export const TwoEmails: Story = {}
+
+export const ThreeEmails: Story = {
+ parameters: { pageUrl: makeItYoursUrl(TRIAL_UPGRADE_NUDGE_ID) },
+}
+
+export const SingleEmail: Story = {
+ parameters: { pageUrl: makeItYoursUrl(RE_ENGAGEMENT_ID) },
+}
+
+export const NoSender: Story = {
+ decorators: [mswDecorator({ get: { '/api/projects/:team_id/integrations/': EMPTY_PAGINATED_RESPONSE } })],
+}
+
+export const ViewerAccess: Story = {
+ render: function ViewerAccess(): JSX.Element {
+ ;(window as any).POSTHOG_APP_CONTEXT.resource_access_control.hog_flow = 'viewer'
+ return
+ },
+}
+
+export const TestSendSkipped: Story = {
+ decorators: [
+ mswDecorator({
+ post: {
+ '/api/projects/:team_id/hog_flows/new/invocations/': [
+ 200,
+ {
+ status: 'success',
+ nextActionId: null,
+ logs: [{ level: 'info', timestamp: '2026-10-05T00:00:00Z', message: SKIP_REASON }],
+ },
+ ],
+ },
+ }),
+ ],
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ await userEvent.click(await canvas.findByText('Send me a test', {}, { timeout: 15000 }))
+ await canvas.findByText(/Nothing was sent/, {}, { timeout: 15000 })
+ },
+}
+
+export const CreateRejected: Story = {
+ decorators: [
+ mswDecorator({
+ post: {
+ '/api/projects/:team_id/hog_flows/': [
+ 400,
+ {
+ type: 'validation_error',
+ code: 'invalid_input',
+ detail: 'Pick at least one event or property filter, or the trigger will never fire.',
+ attr: 'actions',
+ },
+ ],
+ },
+ }),
+ ],
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ await userEvent.click(await canvas.findByText('Enable and open workflow', {}, { timeout: 15000 }))
+ await canvas.findByText(/Couldn't create the workflow/, {}, { timeout: 15000 })
+ },
+}
+
+// A docked side panel leaves the scene about 520px wide on a laptop. This viewport gives the scene the same width.
+export const TwoEmailsInANarrowScene: Story = {
+ parameters: { testOptions: { viewport: { width: 552, height: 2000 } } },
+}
diff --git a/products/workflows/frontend/firstRun/WorkflowsFirstRunMakeItYours.tsx b/products/workflows/frontend/firstRun/WorkflowsFirstRunMakeItYours.tsx
new file mode 100644
index 000000000000..8ad587004df5
--- /dev/null
+++ b/products/workflows/frontend/firstRun/WorkflowsFirstRunMakeItYours.tsx
@@ -0,0 +1,102 @@
+import { useActions, useValues } from 'kea'
+import { router } from 'kea-router'
+
+import { IconArrowLeft } from '@posthog/icons'
+import { LemonBanner, LemonButton, LemonSkeleton } from '@posthog/lemon-ui'
+
+import { EmailTemplater } from 'scenes/hog-functions/email-templater/EmailTemplater'
+import { urls } from 'scenes/urls'
+
+import { firstRunGalleryLogic } from './firstRunGalleryLogic'
+import { firstRunMakeItYoursLogic } from './firstRunMakeItYoursLogic'
+import { MakeItYoursActions } from './MakeItYoursActions'
+import { MakeItYoursEmailList } from './MakeItYoursEmailList'
+import { TemplateStartsOnLine } from './TemplateStartsOnLine'
+
+export function WorkflowsFirstRunMakeItYours({ templateId }: { templateId: string }): JSX.Element | null {
+ const logic = firstRunMakeItYoursLogic({ templateId })
+ const {
+ firstRunEnabled,
+ galleryTemplates,
+ galleryLoadFailed,
+ pickedTemplate,
+ templateEmails,
+ openEmail,
+ openEmailPosition,
+ } = useValues(logic)
+ const { editEmail } = useActions(logic)
+ const { loadEmailTemplates } = useActions(firstRunGalleryLogic)
+ const backToGallery = (): void => router.actions.push(urls.workflows())
+
+ if (!firstRunEnabled) {
+ return null
+ }
+
+ if (galleryLoadFailed) {
+ return (
+
+ Couldn't load the template. Try again, or go back to the templates.
+
+ )
+ }
+ if (galleryTemplates === null) {
+ return
+ }
+ if (!pickedTemplate || !openEmail) {
+ return (
+
+ This template is not in the gallery anymore. Pick another one.
+
+ )
+ }
+
+ const { template } = pickedTemplate
+ const hasSeveralEmails = templateEmails.length > 1
+
+ return (
+
+
+ } onClick={backToGallery} data-attr="first-run-back">
+ All templates
+
+
+
Make it yours: {template.name}
+
+
+
+
+
+ {/* Below 56rem the side column no longer fits beside the editor: the email list moves above it and the actions below. */}
+
+ {hasSeveralEmails && (
+
+
+
+ )}
+
+ {hasSeveralEmails && openEmailPosition && (
+
+ Email {openEmailPosition.index} of {openEmailPosition.total}
+
+ )}
+ {/* The template type hides the From and To fields: the first-run sender is fixed, and the
+ recipient comes from the trigger. The editor remounts per email, so a canvas export can
+ never land on the email opened after it. */}
+