Skip to content
Closed
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
2 changes: 2 additions & 0 deletions products/workflows/frontend/Workflows/WorkflowScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -219,6 +220,7 @@ export function WorkflowScene(props: WorkflowSceneLogicProps): JSX.Element {
<BindLogic logic={workflowLogic} props={workflowProps}>
<WorkflowSceneHeader {...props} />
<WorkflowEmailPauseBanner />
<FirstRunWorkflowBanner />
{selfOptimisingEnabled && isSavedWorkflow && <WorkflowSuggestionsNotice id={props.id!} />}
{/* Only show Logs and Metrics tabs if the workflow has already been created */}
{!props.id || props.id === 'new' ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Meta, StoryObj } from '@storybook/react'
import { BindLogic } from 'kea'

import { FEATURE_FLAGS } from 'lib/constants'
import { App } from 'scenes/App'
import { urls } from 'scenes/urls'

import { mswDecorator } from '~/mocks/browser'
import { toPaginatedResponse } from '~/mocks/handlers'

import welcomeEmailSequence from '../../backend/templates/welcome_email_sequence_template.json'
import type { HogFlow } from '../Workflows/hogflows/types'
import { workflowLogic } from '../Workflows/workflowLogic'
import { OWN_SENDER } from './firstRunStoryFixtures'
import { FirstRunWorkflowBanner } from './FirstRunWorkflowBanner'
import { rememberFirstRunWorkflow } from './firstRunWorkflowStorage'
import { withFirstRunSender } from './withFirstRunSender'

const FIRST_RUN_WORKFLOW_ID = 'storybook-first-run-workflow'

function firstRunWorkflow(status: HogFlow['status']): HogFlow {
const { id, scope, image_url, ...content } = welcomeEmailSequence as unknown as HogFlow & {
scope: string
image_url: string
}
return {
...content,
actions: withFirstRunSender(content.actions, { id: OWN_SENDER.id! }),
id: FIRST_RUN_WORKFLOW_ID,
status,
version: 1,
created_at: '2026-10-05T12:00:00.000Z',
updated_at: '2026-10-05T12:00:00.000Z',
} as HogFlow
}

function workflowWithStatus(status: HogFlow['status']): Parameters<typeof mswDecorator>[0] {
return {
get: {
'/api/environments/:team_id/hog_flows/:id/': () => [200, firstRunWorkflow(status)],
'/api/projects/:team_id/hog_flow_templates/': { count: 0, results: [] },
'/api/environments/:team_id/messaging_categories': { count: 0, results: [] },
'/api/projects/:team_id/integrations/': toPaginatedResponse([OWN_SENDER]),
},
patch: {
'/api/environments/:team_id/hog_flows/:id/': async ({ request }) => [
200,
{ ...firstRunWorkflow(status), ...((await request.json()) as Partial<HogFlow>) },
],
},
}
}

const meta: Meta = {
component: App,
title: 'Products/Workflows/First run workflow banner',
parameters: {
layout: 'fullscreen',
viewMode: 'story',
mockDate: '2026-10-05',
pageUrl: urls.workflow(FIRST_RUN_WORKFLOW_ID, 'workflow'),
featureFlags: [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN],
testOptions: { viewport: { width: 1440, height: 900 }, waitForLoadersToDisappear: true },
},
decorators: [
(Story) => {
rememberFirstRunWorkflow(FIRST_RUN_WORKFLOW_ID)
return <Story />
},
],
}
export default meta

type Story = StoryObj<{}>

function NarrowBanner(): JSX.Element {
return (
<BindLogic logic={workflowLogic} props={{ id: FIRST_RUN_WORKFLOW_ID }}>
<div className="w-[520px] p-4">
<FirstRunWorkflowBanner />
</div>
</BindLogic>
)
}

export const Draft: Story = {
decorators: [mswDecorator(workflowWithStatus('draft'))],
}

export const Sending: Story = {
decorators: [mswDecorator(workflowWithStatus('active'))],
}

export const DraftNarrow: Story = {
render: () => <NarrowBanner />,
decorators: [mswDecorator(workflowWithStatus('draft'))],
}

export const SendingNarrow: Story = {
render: () => <NarrowBanner />,
decorators: [mswDecorator(workflowWithStatus('active'))],
}
85 changes: 85 additions & 0 deletions products/workflows/frontend/firstRun/FirstRunWorkflowBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useActions, useMountedLogic, useValues } from 'kea'

import { LemonBanner } from '@posthog/lemon-ui'

import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { pluralize } from 'lib/utils/strings'

import { isEmailAction } from '../Workflows/hogflows/steps/types'
import type { HogFlow } from '../Workflows/hogflows/types'
import { workflowLogic } from '../Workflows/workflowLogic'
import { firstRunWorkflowBannerLogic } from './firstRunWorkflowBannerLogic'

function triggerEventOf(workflow: HogFlow): string | null {
const trigger = workflow.actions.find((action) => action.type === 'trigger')
if (trigger?.type !== 'trigger' || trigger.config.type !== 'event') {
return null
}
return trigger.config.filters?.events?.[0]?.id ?? null
}

function WorkflowSummary({ workflow }: { workflow: HogFlow }): JSX.Element {
const emails = pluralize(workflow.actions.filter(isEmailAction).length, 'email')
const triggerEvent = triggerEventOf(workflow)

if (!triggerEvent) {
return <>Each person it reaches gets up to {emails}.</>
}
return (
<>
Each person who triggers{' '}
<PropertyKeyInfo
value={triggerEvent}
type={TaxonomicFilterGroupType.Events}
disablePopover
className="font-semibold"
/>{' '}
gets up to {emails}.
</>
)
}

export function FirstRunWorkflowBanner(): JSX.Element | null {
const { props: workflowLogicProps } = useMountedLogic(workflowLogic)
const logic = firstRunWorkflowBannerLogic(workflowLogicProps)
const { banner, originalWorkflow } = useValues(logic)
const { dismiss, viewMetrics } = useActions(logic)

if (!banner || !originalWorkflow) {
return null
}

if (banner === 'draft') {
return (
<LemonBanner type="info" onClose={dismiss} className="shrink-0">
<div className="flex flex-col gap-1">
<strong>This workflow is not sending yet.</strong>
<span className="font-normal">
<WorkflowSummary workflow={originalWorkflow} /> Enable it to start sending.
</span>
</div>
</LemonBanner>
)
}

return (
<LemonBanner
type="success"
onClose={dismiss}
className="shrink-0"
action={{
children: 'View metrics',
onClick: viewMetrics,
'data-attr': 'workflows-first-run-view-metrics',
}}
>
<div className="flex flex-col gap-1">
<strong>Your workflow is sending.</strong>
<span className="font-normal">
<WorkflowSummary workflow={originalWorkflow} /> The Metrics tab shows what it has sent.
</span>
</div>
</LemonBanner>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { router } from 'kea-router'
import { expectLogic } from 'kea-test-utils'

import { globalSetupLogic } from 'lib/components/ProductSetup'
import { FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { removeProjectIdIfPresent } from 'lib/utils/kea-router'
import { urls } from 'scenes/urls'

import { useMocks } from '~/mocks/jest'
import { initKeaTests } from '~/test/init'

import type { HogFlow } from '../Workflows/hogflows/types'
import { workflowLogic } from '../Workflows/workflowLogic'
import { firstRunWorkflowBannerLogic } from './firstRunWorkflowBannerLogic'
import { rememberFirstRunWorkflow } from './firstRunWorkflowStorage'

const FIRST_RUN_WORKFLOW_ID = 'wf-first-run'
const OTHER_WORKFLOW_ID = 'wf-other'

function workflow(id: string, status: HogFlow['status']): HogFlow {
return {
id,
name: 'Welcome email sequence',
description: 'Welcome new signups with an intro email.',
status,
version: 1,
trigger: { type: 'event', filters: { events: [{ id: '$pageview', type: 'events' }] } },
actions: [],
edges: [],
exit_condition: 'exit_only_at_end',
conversion: { filters: [] },
updated_at: '2026-10-05T00:00:00Z',
} as unknown as HogFlow
}

describe('firstRunWorkflowBannerLogic', () => {
let statuses: Record<string, HogFlow['status']>

function mountFor(id: string): ReturnType<typeof firstRunWorkflowBannerLogic.build> {
workflowLogic({ id }).mount()
const logic = firstRunWorkflowBannerLogic({ id })
logic.mount()
return logic
}

beforeEach(() => {
window.localStorage.clear()
statuses = { [FIRST_RUN_WORKFLOW_ID]: 'draft', [OTHER_WORKFLOW_ID]: 'draft' }
useMocks({
get: {
'/api/environments/:team_id/hog_flows/:id/': ({ params }) => [
200,
workflow(params.id as string, statuses[params.id as string]),
],
},
patch: {
'/api/environments/:team_id/hog_flows/:id/': async ({ params, request }) => {
const { status } = (await request.json()) as Partial<HogFlow>
statuses[params.id as string] = status ?? statuses[params.id as string]
return [200, workflow(params.id as string, statuses[params.id as string])]
},
},
})
initKeaTests()
featureFlagLogic.mount()
featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_FIRST_RUN], {
[FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: true,
})
globalSetupLogic.mount()
rememberFirstRunWorkflow(FIRST_RUN_WORKFLOW_ID)
})

it('shows the draft banner on the first-run draft and highlights Enable', async () => {
router.actions.push(urls.workflow(FIRST_RUN_WORKFLOW_ID, 'workflow'))
const logic = mountFor(FIRST_RUN_WORKFLOW_ID)

await expectLogic(logic).toFinishAllListeners().toMatchValues({ banner: 'draft' })
expect(globalSetupLogic.values.highlight).toEqual({
selector: '[data-attr="workflow-launch"]',
pathname: router.values.location.pathname,
})
})

it('turns into the sending banner once the header enables the workflow', async () => {
const logic = mountFor(FIRST_RUN_WORKFLOW_ID)
await expectLogic(logic).toFinishAllListeners().toMatchValues({ banner: 'draft' })

workflowLogic({ id: FIRST_RUN_WORKFLOW_ID }).actions.saveWorkflowPartial({ status: 'active' })

await expectLogic(logic).toFinishAllListeners().toMatchValues({ banner: 'sending' })
})

it('does not highlight Enable on a sending workflow', async () => {
statuses[FIRST_RUN_WORKFLOW_ID] = 'active'
const logic = mountFor(FIRST_RUN_WORKFLOW_ID)

await expectLogic(logic).toFinishAllListeners().toMatchValues({ banner: 'sending' })
expect(globalSetupLogic.values.highlight).toBeNull()
})

it.each([
['another workflow', OTHER_WORKFLOW_ID, true],
['the first-run workflow with the flag off', FIRST_RUN_WORKFLOW_ID, false],
])('shows nothing on %s', async (_, id, flagOn) => {
if (!flagOn) {
featureFlagLogic.actions.setFeatureFlags([], {})
}
const logic = mountFor(id)

await expectLogic(logic).toFinishAllListeners().toMatchValues({ banner: null })
expect(globalSetupLogic.values.highlight).toBeNull()
})

it('keeps a dismissed banner dismissed after enabling and when the workflow opens again', async () => {
const logic = mountFor(FIRST_RUN_WORKFLOW_ID)
await expectLogic(logic).toFinishAllListeners().toMatchValues({ banner: 'draft' })

logic.actions.dismiss()
workflowLogic({ id: FIRST_RUN_WORKFLOW_ID }).actions.saveWorkflowPartial({ status: 'active' })
await expectLogic(logic).toFinishAllListeners().toMatchValues({ banner: null })

logic.unmount()
const reopened = firstRunWorkflowBannerLogic({ id: FIRST_RUN_WORKFLOW_ID })
reopened.mount()

await expectLogic(reopened).toFinishAllListeners().toMatchValues({ banner: null })
})

it('opens the Metrics tab from the sending banner', async () => {
statuses[FIRST_RUN_WORKFLOW_ID] = 'active'
const logic = mountFor(FIRST_RUN_WORKFLOW_ID)
await expectLogic(logic).toFinishAllListeners()

logic.actions.viewMetrics()

expect(removeProjectIdIfPresent(router.values.location.pathname)).toEqual(
urls.workflow(FIRST_RUN_WORKFLOW_ID, 'metrics')
)
})
})
Loading
Loading