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 ( + +
+ {message} +
+ prompt }, + ]} + agentKeys={['clipboard']} + storageKey="workflows-first-run-agent-help" + variant="outline" + onRun={() => reportPromptCopied()} + data-attr="workflows-first-run-copy-agent-prompt" + /> + {isCloudOrDev && ( + } + active={wizardShown} + aria-expanded={wizardShown} + onClick={() => setWizardShown(!wizardShown)} + data-attr="workflows-first-run-show-wizard" + > + Let the Wizard do it + + )} +
+ {wizardShown && ( + + )} +
+
+ ) +} diff --git a/products/workflows/frontend/firstRun/FirstRunWorkflowBanner.stories.tsx b/products/workflows/frontend/firstRun/FirstRunWorkflowBanner.stories.tsx new file mode 100644 index 000000000000..13b990d5a5a5 --- /dev/null +++ b/products/workflows/frontend/firstRun/FirstRunWorkflowBanner.stories.tsx @@ -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[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) }, + ], + }, + } +} + +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 + }, + ], +} +export default meta + +type Story = StoryObj<{}> + +function NarrowBanner(): JSX.Element { + return ( + +
+ +
+
+ ) +} + +export const Draft: Story = { + decorators: [mswDecorator(workflowWithStatus('draft'))], +} + +export const Sending: Story = { + decorators: [mswDecorator(workflowWithStatus('active'))], +} + +export const DraftNarrow: Story = { + render: () => , + decorators: [mswDecorator(workflowWithStatus('draft'))], +} + +export const SendingNarrow: Story = { + render: () => , + decorators: [mswDecorator(workflowWithStatus('active'))], +} diff --git a/products/workflows/frontend/firstRun/FirstRunWorkflowBanner.tsx b/products/workflows/frontend/firstRun/FirstRunWorkflowBanner.tsx new file mode 100644 index 000000000000..1c171cd4073d --- /dev/null +++ b/products/workflows/frontend/firstRun/FirstRunWorkflowBanner.tsx @@ -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{' '} + {' '} + 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 ( + +
+ This workflow is not sending yet. + + Enable it to start sending. + +
+
+ ) + } + + return ( + +
+ Your workflow is sending. + + The Metrics tab shows what it has sent. + +
+
+ ) +} diff --git a/products/workflows/frontend/firstRun/GalleryTemplateCard.tsx b/products/workflows/frontend/firstRun/GalleryTemplateCard.tsx new file mode 100644 index 000000000000..ec760422d286 --- /dev/null +++ b/products/workflows/frontend/firstRun/GalleryTemplateCard.tsx @@ -0,0 +1,64 @@ +import { useActions } from 'kea' + +import { IconStarFilled, IconWarning } from '@posthog/icons' +import { LemonTag } from '@posthog/lemon-ui' + +import { WorkflowTemplateCard } from '../Workflows/templates/WorkflowTemplateCard' +import { WorkflowTemplateSteps } from '../Workflows/templates/WorkflowTemplateSteps' +import { firstRunGalleryLogic, GalleryTemplate } from './firstRunGalleryLogic' +import { TemplateStartsOnLine } from './TemplateStartsOnLine' + +export function GalleryTemplateCard({ + galleryTemplate, + recommendedBecause, +}: { + galleryTemplate: GalleryTemplate + recommendedBecause?: string +}): JSX.Element { + const { pickTemplate } = useActions(firstRunGalleryLogic) + const { template, startsOn, ready } = galleryTemplate + + return ( + } className="shrink-0"> + Recommended starter + + ) : null + } + preview={ +
+ {recommendedBecause && {recommendedBecause}} + {template.image_url && ( + + )} + +
+ } + footer={ +
+ + {!ready && ( + + + {startsOn.kind === 'schedule' + ? 'Your app does not send any events yet' + : 'Your app does not send this event yet'} + + )} +
+ } + onClick={() => pickTemplate(template.id)} + data-attr={recommendedBecause ? 'first-run-recommended-template' : 'first-run-template'} + /> + ) +} diff --git a/products/workflows/frontend/firstRun/MakeItYoursActions.tsx b/products/workflows/frontend/firstRun/MakeItYoursActions.tsx new file mode 100644 index 000000000000..794d4ba5892a --- /dev/null +++ b/products/workflows/frontend/firstRun/MakeItYoursActions.tsx @@ -0,0 +1,106 @@ +import { useActions, useValues } from 'kea' + +import { LemonBanner, LemonButton, LemonSwitch, Link } from '@posthog/lemon-ui' + +import { urls } from 'scenes/urls' + +import { TestSendOutcome, firstRunMakeItYoursLogic } from './firstRunMakeItYoursLogic' + +export function MakeItYoursActions({ templateId }: { templateId: string }): JSX.Element { + const logic = firstRunMakeItYoursLogic({ templateId }) + const { + firstRunSender, + firstRunSenderLoading, + enableWorkflow, + enableDisabledReason, + sendTestDisabledReason, + createDisabledReason, + testSendOutcome, + testSendResultLoading, + createdWorkflowLoading, + createError, + } = useValues(logic) + const { sendTest, createWorkflow, setEnableWorkflow } = useActions(logic) + + return ( +
+ {!firstRunSender && !firstRunSenderLoading && ( + + No email sender is set up yet. Connect one in Channels{' '} + to send a test or enable the workflow. + + )} + {testSendOutcome && } + sendTest()} + loading={testSendResultLoading} + disabledReason={sendTestDisabledReason} + data-attr="first-run-send-test" + > + {testSendOutcome?.kind === 'sent' ? 'Send another test' : 'Send me a test'} + + {createError && ( + + Couldn't create the workflow: {createError} + + )} + createWorkflow()} + loading={createdWorkflowLoading} + disabledReason={createDisabledReason} + data-attr="first-run-create-workflow" + > + {enableWorkflow ? 'Enable and open workflow' : 'Open workflow'} + + + {enableHelp(enableWorkflow, firstRunSender?.display_name)} +
+ ) +} + +function enableHelp(enableWorkflow: boolean, senderName: string | undefined): string { + if (enableWorkflow && senderName) { + return `It starts sending right away, from ${senderName}.` + } + if (senderName) { + return `It opens as a draft and sends nothing until you enable it. Then it sends from ${senderName}.` + } + return 'It opens as a draft and sends nothing until you enable it.' +} + +function TestSendOutcomeBanner({ outcome }: { outcome: TestSendOutcome }): JSX.Element { + if (outcome.kind === 'sent') { + return ( + + Test sent to {outcome.recipient}. Check your inbox. + + ) + } + if (outcome.kind === 'skipped') { + return ( + + Nothing was sent. {outcome.reason} + + ) + } + return ( + + Couldn't send the test email. Try again. + + ) +} diff --git a/products/workflows/frontend/firstRun/MakeItYoursEmailList.tsx b/products/workflows/frontend/firstRun/MakeItYoursEmailList.tsx new file mode 100644 index 000000000000..c43f77d16d39 --- /dev/null +++ b/products/workflows/frontend/firstRun/MakeItYoursEmailList.tsx @@ -0,0 +1,44 @@ +import { useActions, useValues } from 'kea' + +import { LemonButton, LemonTag } from '@posthog/lemon-ui' + +import { firstRunMakeItYoursLogic } from './firstRunMakeItYoursLogic' + +export function MakeItYoursEmailList({ templateId }: { templateId: string }): JSX.Element { + const logic = firstRunMakeItYoursLogic({ templateId }) + const { templateEmails, openEmailId, editedEmailIds } = useValues(logic) + const { openEmail } = useActions(logic) + + return ( +
+ + {templateEmails.length} emails in this workflow + + {templateEmails.map((email, index) => { + const isOpen = email.id === openEmailId + return ( + openEmail(email.id)} + data-attr="first-run-email-list-item" + icon={{index + 1}} + sideIcon={ + editedEmailIds.includes(email.id) ? ( + + Edited + + ) : null + } + > + + {email.subject || 'No subject'} + {email.timing} + + + ) + })} +
+ ) +} diff --git a/products/workflows/frontend/firstRun/TemplateStartsOnLine.tsx b/products/workflows/frontend/firstRun/TemplateStartsOnLine.tsx new file mode 100644 index 000000000000..66793e70c62c --- /dev/null +++ b/products/workflows/frontend/firstRun/TemplateStartsOnLine.tsx @@ -0,0 +1,37 @@ +import { IconBolt, IconClock } from '@posthog/icons' +import { LemonTag } from '@posthog/lemon-ui' + +import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import { capitalizeFirstLetter } from 'lib/utils/strings' + +import type { GalleryTemplate } from './firstRunGalleryLogic' + +export function TemplateStartsOnLine({ galleryTemplate }: { galleryTemplate: GalleryTemplate }): JSX.Element { + const { startsOn, matchedEvent } = galleryTemplate + + if (startsOn.kind === 'schedule') { + return ( + + + {capitalizeFirstLetter(startsOn.detail)} + + ) + } + return ( + + {startsOn.kind === 'event' ? : } + {startsOn.kind === 'no_event' && No} + + {startsOn.detail && {startsOn.detail}} + + ) +} + +function EventChip({ event }: { event: string }): JSX.Element { + return ( + + + + ) +} diff --git a/products/workflows/frontend/firstRun/WorkflowsFirstRun.stories.tsx b/products/workflows/frontend/firstRun/WorkflowsFirstRun.stories.tsx new file mode 100644 index 000000000000..52b7ddc61ee0 --- /dev/null +++ b/products/workflows/frontend/firstRun/WorkflowsFirstRun.stories.tsx @@ -0,0 +1,69 @@ +import { MOCK_DEFAULT_TEAM } from 'lib/api.mock' + +import { Meta, StoryObj } from '@storybook/react' +import { useMountedLogic } from 'kea' +import { useEffect } from 'react' + +import { FEATURE_FLAGS } from 'lib/constants' +import { App } from 'scenes/App' +import { teamLogic } from 'scenes/teamLogic' +import { urls } from 'scenes/urls' + +import { mswDecorator } from '~/mocks/browser' +import { EMPTY_PAGINATED_RESPONSE, toPaginatedResponse } from '~/mocks/handlers' + +import { globalTemplates, projectThatSends } from './firstRunStoryFixtures' + +const meta: Meta = { + component: App, + title: 'Products/Workflows/First run gallery', + parameters: { + layout: 'fullscreen', + viewMode: 'story', + mockDate: '2026-10-05', + pageUrl: urls.workflows(), + featureFlags: [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN], + testOptions: { viewport: { width: 1440, height: 2400 } }, + }, + 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), + }, + }), + ], +} +export default meta + +type Story = StoryObj<{}> + +export const ProjectWithSignups: Story = { + decorators: [mswDecorator(projectThatSends(['signed_up', '$pageview', '$feature_view']))], +} + +export const ProjectWithOnlyPageviews: Story = { + decorators: [mswDecorator(projectThatSends(['$pageview']))], +} + +export const ProjectWithNoEvents: Story = { + decorators: [mswDecorator(projectThatSends([]))], + render: function ProjectWithNoEvents(): JSX.Element { + useMountedLogic(teamLogic) + useEffect(() => { + teamLogic.actions.loadCurrentTeamSuccess({ ...MOCK_DEFAULT_TEAM, ingested_event: false }) + }, []) + return + }, +} + +// A docked side panel leaves the scene about 520px wide on a laptop. This viewport gives the scene the same width. +export const ProjectWithSignupsInANarrowScene: Story = { + decorators: [mswDecorator(projectThatSends(['signed_up', '$pageview', '$feature_view']))], + parameters: { testOptions: { viewport: { width: 552, height: 2400 } } }, +} diff --git a/products/workflows/frontend/firstRun/WorkflowsFirstRunGallery.tsx b/products/workflows/frontend/firstRun/WorkflowsFirstRunGallery.tsx new file mode 100644 index 000000000000..bf29aeb5ec5b --- /dev/null +++ b/products/workflows/frontend/firstRun/WorkflowsFirstRunGallery.tsx @@ -0,0 +1,103 @@ +import { useActions, useValues } from 'kea' + +import { LemonBanner, LemonSegmentedButton, LemonSkeleton, Link } from '@posthog/lemon-ui' + +import { newWorkflowLogic } from '../Workflows/newWorkflowLogic' +import { WorkflowTemplateBlankPreview } from '../Workflows/templates/WorkflowTemplateBlankPreview' +import { WorkflowTemplateCard } from '../Workflows/templates/WorkflowTemplateCard' +import { FirstRunAgentHelp } from './FirstRunAgentHelp' +import { firstRunGalleryLogic, GalleryFilter } from './firstRunGalleryLogic' +import { GalleryTemplateCard } from './GalleryTemplateCard' + +// Each card keeps at least 20rem and the row fills up, so the column count follows the width the scene gives the gallery. +const GALLERY_GRID = 'grid gap-4 grid-cols-[repeat(auto-fill,minmax(min(20rem,100%),1fr))]' + +export function WorkflowsFirstRunGallery(): JSX.Element { + const { galleryTemplates, readyTemplates, shownTemplates, activeFilter, recommendedStarter, galleryLoadFailed } = + useValues(firstRunGalleryLogic) + const { setFilter, loadEmailTemplates } = useActions(firstRunGalleryLogic) + const { createEmptyWorkflow, showNewWorkflowModal } = useActions(newWorkflowLogic) + + return ( +
+
+

Start with an email template

+ {galleryTemplates !== null && ( +

{gallerySubtitle(readyTemplates.length, activeFilter)}

+ )} +
+ + {galleryTemplates === null ? ( + galleryLoadFailed ? ( + + Couldn't load the template gallery. Try again, or use New workflow to start from a blank one. + + ) : ( +
+ {[0, 1, 2].map((index) => ( + + ))} +
+ ) + ) : ( + <> +
+ + size="small" + value={activeFilter} + onChange={setFilter} + options={[ + { + value: 'picked', + label: `Picked for your data (${readyTemplates.length})`, + disabledReason: + readyTemplates.length === 0 + ? 'Your app does not send the events these templates start on yet' + : undefined, + 'data-attr': 'first-run-filter-picked', + }, + { + value: 'all', + label: `All email templates (${galleryTemplates.length})`, + 'data-attr': 'first-run-filter-all', + }, + ]} + /> + + Browse all workflow templates + +
+
+ {shownTemplates.map((galleryTemplate) => ( + + ))} + } + onClick={createEmptyWorkflow} + data-attr="first-run-start-playing" + /> +
+ + )} +
+ ) +} + +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. */} +
+ editEmail(openEmail.id, value)} + /> +
+
+
+ +
+
+
+ ) +} diff --git a/products/workflows/frontend/firstRun/WorkflowsFirstRunTab.test.tsx b/products/workflows/frontend/firstRun/WorkflowsFirstRunTab.test.tsx new file mode 100644 index 000000000000..1a5909f4ee49 --- /dev/null +++ b/products/workflows/frontend/firstRun/WorkflowsFirstRunTab.test.tsx @@ -0,0 +1,81 @@ +import '@testing-library/jest-dom' + +import { cleanup, render, waitFor } from '@testing-library/react' + +import { navPanelProductPushWelcomeLogic } from 'lib/components/NavPanelAdvertisement/navPanelProductPushWelcomeLogic' +import { clearAllCachedHasData } from 'lib/components/ProductEmptyState/setupDetectionLogic' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { ProductKey } from '~/queries/schema/schema-general' +import { initKeaTests } from '~/test/init' + +import { WorkflowsFirstRunTab } from './WorkflowsFirstRunTab' + +const WELCOME = { + campaignId: 'campaign-1', + productKey: ProductKey.WORKFLOWS, + label: 'Workflows', + text: 'Message users when it matters.', +} + +describe('WorkflowsFirstRunTab', () => { + afterEach(() => { + cleanup() + clearAllCachedHasData() + }) + + it.each([ + { + project: 'without workflows', + workflowCount: 0, + shows: 'workflows-first-run-gallery', + enabled: true, + holdsWelcome: true, + }, + { project: 'with a workflow', workflowCount: 1, shows: 'workflows-table', enabled: true, holdsWelcome: false }, + { + project: 'without workflows when first run is off', + workflowCount: 0, + shows: 'workflows-table', + enabled: false, + holdsWelcome: false, + }, + ])('shows a project $project the $shows', async ({ workflowCount, shows, enabled, holdsWelcome }) => { + useMocks({ + get: { + '/api/projects/:team_id/hog_flows/': { + count: workflowCount, + results: [], + }, + }, + }) + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: enabled }) + const welcomeLogic = navPanelProductPushWelcomeLogic() + welcomeLogic.mount() + welcomeLogic.actions.setPendingWelcome(WELCOME) + + const { container, unmount } = render() + + welcomeLogic.actions.openWelcome(WELCOME) + expect(welcomeLogic.values.openFor).toEqual(enabled ? null : WELCOME) + + await waitFor(() => expect(container.querySelector(`[data-attr="${shows}"]`)).toBeInTheDocument()) + expect( + container.querySelectorAll('[data-attr="workflows-first-run-gallery"], [data-attr="workflows-table"]') + ).toHaveLength(1) + welcomeLogic.actions.openWelcome(WELCOME) + expect(welcomeLogic.values.openFor).toEqual(holdsWelcome ? null : WELCOME) + + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: false }) + welcomeLogic.actions.openWelcome(WELCOME) + expect(welcomeLogic.values.openFor).toEqual(WELCOME) + + unmount() + welcomeLogic.actions.openWelcome(WELCOME) + expect(welcomeLogic.values.openFor).toEqual(WELCOME) + welcomeLogic.unmount() + }) +}) diff --git a/products/workflows/frontend/firstRun/WorkflowsFirstRunTab.tsx b/products/workflows/frontend/firstRun/WorkflowsFirstRunTab.tsx new file mode 100644 index 000000000000..b7cf22279828 --- /dev/null +++ b/products/workflows/frontend/firstRun/WorkflowsFirstRunTab.tsx @@ -0,0 +1,41 @@ +import { useMountedLogic, useValues } from 'kea' +import { router } from 'kea-router' + +import { FEATURE_FLAGS } from 'lib/constants' +import { Spinner } from 'lib/lemon-ui/Spinner' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { workflowsSetupLogic } from '../emptyState/workflowsSetupLogic' +import { WorkflowsTable } from '../Workflows/WorkflowsTable' +import { FIRST_RUN_TEMPLATE_PARAM } from './firstRunGalleryLogic' +import { firstRunWelcomeHoldLogic } from './firstRunWelcomeHoldLogic' +import { WorkflowsFirstRunGallery } from './WorkflowsFirstRunGallery' +import { WorkflowsFirstRunMakeItYours } from './WorkflowsFirstRunMakeItYours' + +export function WorkflowsFirstRunTab(): JSX.Element { + const { setupStatus } = useValues(workflowsSetupLogic) + const { featureFlags } = useValues(featureFlagLogic) + useMountedLogic(firstRunWelcomeHoldLogic({ setupStatus })) + const { searchParams } = useValues(router) + const pickedTemplateId = searchParams[FIRST_RUN_TEMPLATE_PARAM] + + if (!featureFlags[FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]) { + return + } + + if (setupStatus === 'loading') { + return ( +
+ +
+ ) + } + if (setupStatus !== 'needs-setup') { + return + } + return typeof pickedTemplateId === 'string' ? ( + + ) : ( + + ) +} diff --git a/products/workflows/frontend/firstRun/buildFirstRunTestSend.ts b/products/workflows/frontend/firstRun/buildFirstRunTestSend.ts new file mode 100644 index 000000000000..40ac47d6360b --- /dev/null +++ b/products/workflows/frontend/firstRun/buildFirstRunTestSend.ts @@ -0,0 +1,78 @@ +import type { EmailTemplate } from 'scenes/hog-functions/email-templater/types' + +import type { CyclotronJobInvocationGlobals } from '~/types' + +import { createExampleEvent } from '../Workflows/hogflows/testEventFactory' +import type { HogFlow, HogFlowAction, HogFlowEdge } from '../Workflows/hogflows/types' +import { EXIT_NODE_ID, NEW_WORKFLOW, TRIGGER_NODE_ID } from '../Workflows/workflowLogic' +import type { FirstRunSender } from './firstRunSenderLogic' + +// Fixed id for the single email action in the synthetic test flow. The edges reference it, and it +// goes over as `current_action_id` so the worker runs just this node. +const TEST_EMAIL_ACTION_ID = 'send_test_email' + +export interface FirstRunTestSendInput { + email: EmailTemplate + sender: Pick + recipientEmail: string + workflowName: string + teamId: number | null +} + +export interface FirstRunTestSend { + configuration: HogFlow + globals: CyclotronJobInvocationGlobals + mock_async_functions: boolean + current_action_id: string +} + +export function buildFirstRunTestSend({ + email, + sender, + recipientEmail, + workflowName, + teamId, +}: FirstRunTestSendInput): FirstRunTestSend { + const emailAction: HogFlowAction = { + id: TEST_EMAIL_ACTION_ID, + type: 'function_email', + name: 'Send test email', + description: '', + created_at: 0, + updated_at: 0, + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + ...email, + // One visible recipient on a test, whatever the template carries. + cc: '', + bcc: '', + from: { integrationId: sender.id }, + to: { email: recipientEmail, name: '' }, + }, + templating: 'liquid', + }, + }, + }, + } + const edges: HogFlowEdge[] = [ + { from: TRIGGER_NODE_ID, to: TEST_EMAIL_ACTION_ID, type: 'continue' }, + { from: TEST_EMAIL_ACTION_ID, to: EXIT_NODE_ID, type: 'continue' }, + ] + const configuration: HogFlow = { + ...NEW_WORKFLOW, + team_id: teamId ?? NEW_WORKFLOW.team_id, + name: workflowName, + actions: [NEW_WORKFLOW.actions[0], emailAction, NEW_WORKFLOW.actions[1]], + edges, + } + return { + configuration, + globals: createExampleEvent(teamId ?? undefined, workflowName, '$pageview', recipientEmail), + // A test send is a real send. Mocking it would defeat the point. + mock_async_functions: false, + current_action_id: TEST_EMAIL_ACTION_ID, + } +} diff --git a/products/workflows/frontend/firstRun/buildFirstRunWorkflow.ts b/products/workflows/frontend/firstRun/buildFirstRunWorkflow.ts new file mode 100644 index 000000000000..f74709282b39 --- /dev/null +++ b/products/workflows/frontend/firstRun/buildFirstRunWorkflow.ts @@ -0,0 +1,75 @@ +import type { EmailTemplate } from 'scenes/hog-functions/email-templater/types' + +import { isEmailAction } from '../Workflows/hogflows/steps/types' +import type { HogFlow, HogFlowAction, HogFlowTemplate } from '../Workflows/hogflows/types' +import type { FirstRunSender } from './firstRunSenderLogic' +import { withFirstRunSender } from './withFirstRunSender' + +export interface FirstRunWorkflowInput { + template: HogFlowTemplate + matchedEvent: string | null + edits: Record + sender: Pick | null + enabled: boolean +} + +function withEdits(actions: HogFlowAction[], edits: Record): HogFlowAction[] { + return actions.map((action) => + isEmailAction(action) && edits[action.id] + ? { + ...action, + config: { + ...action.config, + inputs: { + ...action.config.inputs, + email: { ...action.config.inputs.email, value: edits[action.id] }, + }, + }, + } + : action + ) +} + +function triggerEvent(filters: Record | undefined): string | undefined { + return filters?.events?.[0]?.id +} + +function withMatchedEvent(actions: HogFlowAction[], matchedEvent: string | null): HogFlowAction[] { + return actions.map((action) => { + if (action.type !== 'trigger' || action.config.type !== 'event' || !matchedEvent) { + return action + } + const filters = action.config.filters ?? {} + if (triggerEvent(filters) === matchedEvent) { + return action + } + const [first, ...rest] = filters.events ?? [] + return { + ...action, + config: { + ...action.config, + filters: { + ...filters, + events: [{ ...first, id: matchedEvent, name: matchedEvent, type: 'events', order: 0 }, ...rest], + }, + }, + } + }) +} + +export function buildFirstRunWorkflow({ + template, + matchedEvent, + edits, + sender, + enabled, +}: FirstRunWorkflowInput): Partial { + const { id, team_id, created_at, updated_at, created_by, image_url, tags, scope, ...content } = template + const editedActions = withMatchedEvent(withEdits(template.actions, edits), matchedEvent) + return { + ...content, + actions: sender ? withFirstRunSender(editedActions, sender) : editedActions, + status: enabled ? 'active' : 'draft', + version: 1, + } +} diff --git a/products/workflows/frontend/firstRun/firstRunAgentHelpLogic.test.ts b/products/workflows/frontend/firstRun/firstRunAgentHelpLogic.test.ts new file mode 100644 index 000000000000..5a34d880ff90 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunAgentHelpLogic.test.ts @@ -0,0 +1,155 @@ +import { expectLogic } from 'kea-test-utils' +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 { firstRunAgentHelpLogic } from './firstRunAgentHelpLogic' + +const WELCOME = '019b6f44-f9a3-0000-c4a7-b8050d25d690' + +const TEMPLATES: Partial[] = [ + { + id: 'onboarding', + name: 'Onboarding', + scope: 'global', + starts_on: { kind: 'event', events: ['$pageview'], detail: '' }, + tags: [], + actions: [], + }, + { + id: WELCOME, + name: 'Welcome', + scope: 'global', + starts_on: { kind: 'event', events: ['user signed up', 'signed_up'], detail: '' }, + tags: [], + actions: [], + }, +] + +describe('firstRunAgentHelpLogic', () => { + let logic: ReturnType + let capture: jest.SpyInstance + + let seenEvents: string[] + let personProperties: string[] + let propertyRequests: number + + beforeEach(() => { + seenEvents = [] + personProperties = [] + propertyRequests = 0 + useMocks({ + get: { + '/api/projects/:team_id/hog_flow_templates/': { count: TEMPLATES.length, results: TEMPLATES }, + '/api/projects/:team_id/event_definitions/': ({ request }) => { + const names = new URL(request.url).searchParams.get('names')?.split(',') ?? [] + const seen = names.filter((name) => seenEvents.includes(name)) + return [ + 200, + { + count: seen.length, + results: seen.map((name) => ({ id: name, name, last_seen_at: '2026-10-01T00:00:00Z' })), + }, + ] + }, + '/api/projects/:team_id/property_definitions/': ({ request }) => { + propertyRequests++ + const params = new URL(request.url).searchParams + const names = params.get('properties')?.split(',') ?? [] + const definitions = + params.get('type') === 'person' ? names.filter((name) => personProperties.includes(name)) : [] + return [ + 200, + { count: definitions.length, results: definitions.map((name) => ({ id: name, name })) }, + ] + }, + }, + }) + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: true }) + capture = jest.spyOn(posthog, 'capture').mockImplementation() + }) + + async function openFirstRun(project: { + seenEvents: string[] + personProperties: string[] + enabled?: boolean + }): Promise { + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: project.enabled ?? true }) + seenEvents = project.seenEvents + personProperties = project.personProperties + logic = firstRunAgentHelpLogic() + logic.mount() + await expectLogic(logic).toFinishAllListeners() + } + + afterEach(() => { + logic?.unmount() + jest.restoreAllMocks() + }) + + const agentHelpShownCalls = (): unknown[][] => + capture.mock.calls.filter(([event]) => event === 'workflows first run agent help shown') + + it.each([ + { + project: 'without a signup event', + seenEvents: ['$pageview'], + personProperties: ['email'], + missing: 'signup_event', + }, + { + project: 'with signups but no email property', + seenEvents: ['signed_up'], + personProperties: ['name'], + missing: 'email', + }, + { + project: 'without a signup event or an email property', + seenEvents: [], + personProperties: [], + missing: 'signup_event', + }, + { + project: 'with signups and an email property', + seenEvents: ['signed_up', '$pageview'], + personProperties: ['email'], + missing: null, + }, + { + project: 'while first run is disabled', + seenEvents: ['signed_up'], + personProperties: [], + missing: null, + enabled: false, + }, + ])('asks a project $project for help with missing data: $missing', async ({ missing, ...project }) => { + await openFirstRun(project) + if (project.enabled === false) { + logic.actions.loadEmailProperty() + await expectLogic(logic).toFinishAllListeners() + } + + expect(logic.values.missingData).toEqual(missing) + expect(agentHelpShownCalls()).toEqual(missing ? [['workflows first run agent help shown', { missing }]] : []) + expect(propertyRequests).toEqual(project.enabled === false ? 0 : 1) + }) + + it.each([true, false])('reports a copied prompt only while first run is enabled=%s', async (enabled) => { + await openFirstRun({ seenEvents: ['signed_up'], personProperties: [] }) + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: enabled }) + + logic.actions.reportPromptCopied() + + if (enabled) { + expect(capture).toHaveBeenCalledWith('workflows first run agent prompt copied', { missing: 'email' }) + } else { + expect(capture).not.toHaveBeenCalledWith('workflows first run agent prompt copied', expect.anything()) + } + }) +}) diff --git a/products/workflows/frontend/firstRun/firstRunAgentHelpLogic.ts b/products/workflows/frontend/firstRun/firstRunAgentHelpLogic.ts new file mode 100644 index 000000000000..b2b8eecb139d --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunAgentHelpLogic.ts @@ -0,0 +1,164 @@ +import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import posthog from 'posthog-js' + +import { projectLogic } from 'scenes/projectLogic' + +import { propertyDefinitionsList } from '~/generated/core/api' + +import { firstRunGalleryLogic, GalleryTemplate, WELCOME_SEQUENCE_TEMPLATE_ID } from './firstRunGalleryLogic' + +// pinned: analytics property values +export type MissingData = 'signup_event' | 'email' + +const EMAIL_PERSON_PROPERTY = 'email' + +function findWelcomeTemplate(galleryTemplates: GalleryTemplate[] | null): GalleryTemplate | null { + return galleryTemplates?.find(({ template }) => template.id === WELCOME_SEQUENCE_TEMPLATE_ID) ?? null +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunAgentHelpLogicValues { + firstRunEnabled: boolean // firstRunGalleryLogic + galleryTemplates: GalleryTemplate[] | null // firstRunGalleryLogic + currentProjectId: number | null // projectLogic + hasEmailProperty: boolean | null + hasEmailPropertyLoading: boolean + missingData: MissingData | null + signupEventSeen: boolean | null + wizardShown: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunAgentHelpLogicActions { + loadSeenEventsSuccess: ( + seenEvents: string[], + payload?: string[] | undefined + ) => { + payload?: string[] + seenEvents: string[] + } // firstRunGalleryLogic + loadEmailProperty: () => any + loadEmailPropertyFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadEmailPropertySuccess: ( + hasEmailProperty: boolean, + payload?: any + ) => { + hasEmailProperty: boolean + payload?: any + } + reportAgentHelpShown: () => { + value: true + } + reportPromptCopied: () => { + value: true + } + setWizardShown: (wizardShown: boolean) => { + wizardShown: boolean + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunAgentHelpLogicMeta { + __keaTypeGenInternalSelectorTypes: { + signupEventSeen: (galleryTemplates: GalleryTemplate[] | null) => boolean | null + missingData: ( + signupEventSeen: boolean | null, + hasEmailProperty: boolean | null, + firstRunEnabled: any + ) => MissingData | null + } +} + +export type firstRunAgentHelpLogicType = MakeLogicType< + firstRunAgentHelpLogicValues, + firstRunAgentHelpLogicActions, + Record, + firstRunAgentHelpLogicMeta +> + +export const firstRunAgentHelpLogic = kea([ + path(['products', 'workflows', 'frontend', 'firstRun', 'firstRunAgentHelpLogic']), + connect(() => ({ + values: [projectLogic, ['currentProjectId'], firstRunGalleryLogic, ['galleryTemplates', 'firstRunEnabled']], + actions: [firstRunGalleryLogic, ['loadSeenEventsSuccess']], + })), + actions({ + reportAgentHelpShown: true, + reportPromptCopied: true, + setWizardShown: (wizardShown: boolean) => ({ wizardShown }), + }), + loaders(({ values }) => ({ + hasEmailProperty: [ + null as boolean | null, + { + loadEmailProperty: async (): Promise => { + if (!values.firstRunEnabled) { + return false + } + const response = await propertyDefinitionsList(String(values.currentProjectId), { + type: 'person', + properties: EMAIL_PERSON_PROPERTY, + limit: 1, + }) + return response.results.length > 0 + }, + }, + ], + })), + reducers({ + wizardShown: [false, { setWizardShown: (_, { wizardShown }) => wizardShown }], + }), + selectors({ + signupEventSeen: [ + (s) => [s.galleryTemplates], + (galleryTemplates: GalleryTemplate[] | null): boolean | null => { + const welcome = findWelcomeTemplate(galleryTemplates) + return welcome ? welcome.matchedEvent !== null : null + }, + ], + missingData: [ + (s) => [s.signupEventSeen, s.hasEmailProperty, s.firstRunEnabled], + ( + signupEventSeen: boolean | null, + hasEmailProperty: boolean | null, + firstRunEnabled: boolean + ): MissingData | null => { + if (!firstRunEnabled) { + return null + } + if (signupEventSeen === false) { + return 'signup_event' + } + return signupEventSeen && hasEmailProperty === false ? 'email' : null + }, + ], + }), + listeners(({ actions, values, cache }) => ({ + loadSeenEventsSuccess: () => actions.reportAgentHelpShown(), + loadEmailPropertySuccess: () => actions.reportAgentHelpShown(), + reportAgentHelpShown: () => { + if (!values.missingData || cache.agentHelpReported) { + return + } + cache.agentHelpReported = true + posthog.capture('workflows first run agent help shown', { missing: values.missingData }) + }, + reportPromptCopied: () => { + if (values.firstRunEnabled) { + posthog.capture('workflows first run agent prompt copied', { missing: values.missingData }) + } + }, + })), + afterMount(({ actions, values }) => { + if (values.firstRunEnabled) { + actions.loadEmailProperty() + } + }), +]) diff --git a/products/workflows/frontend/firstRun/firstRunGalleryLogic.test.ts b/products/workflows/frontend/firstRun/firstRunGalleryLogic.test.ts new file mode 100644 index 000000000000..181c3b64dbf8 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunGalleryLogic.test.ts @@ -0,0 +1,255 @@ +import { MOCK_DEFAULT_TEAM } from 'lib/api.mock' + +import { router } from 'kea-router' +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { removeProjectIdIfPresent } from 'lib/utils/kea-router' +import { teamLogic } from 'scenes/teamLogic' +import { urls } from 'scenes/urls' + +import { resumeKeaLoadersErrors, silenceKeaLoadersErrors } from '~/initKea' +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import type { HogFlowTemplateApi } from '../generated/api.schemas' +import { firstRunGalleryLogic } from './firstRunGalleryLogic' + +const WELCOME = '019b6f44-f9a3-0000-c4a7-b8050d25d690' + +function emailTemplate(id: string, startsOn: HogFlowTemplateApi['starts_on']): Partial { + return { + id, + name: id, + scope: 'global', + starts_on: startsOn, + tags: [], + actions: [], + } +} + +const TEMPLATES: Partial[] = [ + emailTemplate('onboarding', { kind: 'event', events: ['$pageview'], detail: 'on /onboarding' }), + emailTemplate('trial', { kind: 'event', events: ['trial_started'], detail: '' }), + emailTemplate(WELCOME, { kind: 'event', events: ['user signed up', 'signed_up', 'sign_up'], detail: '' }), + emailTemplate('re-engagement', { kind: 'no_event', events: ['$pageview'], detail: 'for 14 days' }), + emailTemplate('unused-features', { kind: 'schedule', events: [], detail: 'every 10 days' }), + { ...emailTemplate('slack-alert', null), name: 'A global template without email' }, + { ...emailTemplate('team-email', null), scope: 'team' }, +] + +describe('firstRunGalleryLogic', () => { + let logic: ReturnType + let requestedEventNames: string[][] + let templateRequests: number + let seenEvents: string[] + let plannedEvents: string[] + + beforeEach(() => { + requestedEventNames = [] + templateRequests = 0 + seenEvents = [] + plannedEvents = [] + useMocks({ + get: { + '/api/projects/:team_id/hog_flow_templates/': () => { + templateRequests++ + return [200, { count: TEMPLATES.length, results: TEMPLATES }] + }, + '/api/projects/:team_id/event_definitions/': ({ request }) => { + const names = new URL(request.url).searchParams.get('names')?.split(',') ?? [] + requestedEventNames.push(names) + const definitions = names + .filter((name) => seenEvents.includes(name) || plannedEvents.includes(name)) + .map((name) => ({ + id: name, + name, + last_seen_at: seenEvents.includes(name) ? '2026-10-01T00:00:00Z' : null, + })) + .reverse() + return [200, { count: definitions.length, results: definitions }] + }, + }, + }) + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: true }) + }) + + async function openGallery(project: { + seenEvents: string[] + plannedEvents?: string[] + ingestedEvent: boolean + enabled?: boolean + }): Promise { + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: project.enabled ?? true }) + seenEvents = project.seenEvents + plannedEvents = project.plannedEvents ?? [] + teamLogic.actions.loadCurrentTeamSuccess({ ...MOCK_DEFAULT_TEAM, ingested_event: project.ingestedEvent }) + logic = firstRunGalleryLogic() + logic.mount() + await expectLogic(logic).toFinishAllListeners() + } + + afterEach(() => { + logic?.unmount() + jest.restoreAllMocks() + }) + + const shownIds = (): string[] => logic.values.shownTemplates.map(({ template }) => template.id) + + it.each([ + { + project: 'that sends signups and pageviews', + seenEvents: ['signed_up', '$pageview'], + ingestedEvent: true, + recommended: WELCOME, + welcomeMatchedEvent: 'signed_up', + picked: [WELCOME, 'onboarding', 're-engagement', 'unused-features'], + all: [WELCOME, 'onboarding', 're-engagement', 'unused-features', 'trial'], + }, + { + project: 'that sends only pageviews', + seenEvents: ['$pageview'], + ingestedEvent: true, + recommended: 'onboarding', + welcomeMatchedEvent: null, + picked: ['onboarding', 're-engagement', 'unused-features'], + all: ['onboarding', 're-engagement', 'unused-features', 'trial', WELCOME], + }, + { + project: 'that sends several signup events', + seenEvents: ['sign_up', 'user signed up'], + ingestedEvent: true, + recommended: WELCOME, + welcomeMatchedEvent: 'user signed up', + picked: [WELCOME, 'unused-features'], + all: [WELCOME, 'unused-features', 'onboarding', 'trial', 're-engagement'], + }, + { + project: 'that only planned a signup event and sends pageviews', + seenEvents: ['$pageview'], + plannedEvents: ['signed_up'], + ingestedEvent: true, + recommended: 'onboarding', + welcomeMatchedEvent: null, + picked: ['onboarding', 're-engagement', 'unused-features'], + all: ['onboarding', 're-engagement', 'unused-features', 'trial', WELCOME], + }, + { + project: 'that sends only events no template starts on', + seenEvents: [], + ingestedEvent: true, + recommended: 'unused-features', + welcomeMatchedEvent: null, + picked: ['unused-features'], + all: ['unused-features', 'onboarding', 'trial', WELCOME, 're-engagement'], + }, + { + project: 'that sends no events', + seenEvents: [], + ingestedEvent: false, + recommended: null, + welcomeMatchedEvent: null, + picked: ['onboarding', 'trial', WELCOME, 're-engagement', 'unused-features'], + all: ['onboarding', 'trial', WELCOME, 're-engagement', 'unused-features'], + }, + ])( + 'tailors the gallery to a project $project', + async ({ recommended, welcomeMatchedEvent, picked, all, ...project }) => { + await openGallery(project) + + expect(logic.values.recommendedStarter?.templateId ?? null).toEqual(recommended) + expect( + logic.values.galleryTemplates?.find(({ template }) => template.id === WELCOME)?.matchedEvent + ).toEqual(welcomeMatchedEvent) + expect(shownIds()).toEqual(picked) + + logic.actions.setFilter('all') + expect(shownIds()).toEqual(all) + } + ) + + it.each([true, false])('loads template fit only while first run is enabled=%s', async (enabled) => { + await openGallery({ seenEvents: [], ingestedEvent: false, enabled }) + if (!enabled) { + logic.actions.loadEmailTemplates() + await expectLogic(logic).toFinishAllListeners() + } + + expect(templateRequests).toEqual(enabled ? 1 : 0) + expect(requestedEventNames).toEqual( + enabled ? [['$pageview', 'trial_started', 'user signed up', 'signed_up', 'sign_up']] : [] + ) + }) + + it('reports the gallery once its fit is known', async () => { + const capture = jest.spyOn(posthog, 'capture').mockImplementation() + await openGallery({ seenEvents: ['signed_up', '$pageview'], ingestedEvent: true }) + + expect(capture).toHaveBeenCalledWith('workflows first run gallery shown', { + ready_count: 4, + recommended_template_id: WELCOME, + }) + }) + + it.each([ + { pick: 'the recommended starter', templateId: WELCOME, recommended: true, ready: true, enabled: true }, + { pick: 'another ready template', templateId: 'onboarding', recommended: false, ready: true, enabled: true }, + { pick: 'a template that is not ready', templateId: 'trial', recommended: false, ready: false, enabled: true }, + { + pick: 'a loaded template after disabling first run', + templateId: WELCOME, + recommended: true, + ready: true, + enabled: false, + }, + ])('honors the flag when picking $pick', async ({ templateId, recommended, ready, enabled }) => { + const capture = jest.spyOn(posthog, 'capture').mockImplementation() + await openGallery({ seenEvents: ['signed_up', '$pageview'], ingestedEvent: true }) + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: enabled }) + const originalSearchParams = router.values.searchParams + + logic.actions.pickTemplate(templateId) + + if (enabled) { + expect(capture).toHaveBeenCalledWith('workflows first run template picked', { + template_id: templateId, + recommended, + ready, + }) + expect(removeProjectIdIfPresent(router.values.location.pathname)).toBe(urls.workflows()) + expect(router.values.searchParams).toEqual({ template: templateId }) + } else { + expect(capture).not.toHaveBeenCalledWith('workflows first run template picked', expect.anything()) + expect(router.values.searchParams).toEqual(originalSearchParams) + } + }) + + it.each([ + { failing: 'the templates request', endpoint: '/api/projects/:team_id/hog_flow_templates/' }, + { failing: 'the event definitions request', endpoint: '/api/projects/:team_id/event_definitions/' }, + ])('offers a retry when $failing fails, and recovers on it', async ({ endpoint }) => { + silenceKeaLoadersErrors() + let failing = true + const healthyResponse = endpoint.includes('templates') + ? { count: TEMPLATES.length, results: TEMPLATES } + : { count: 0, results: [] } + useMocks({ get: { [endpoint]: () => (failing ? [500, { detail: 'Server error' }] : [200, healthyResponse]) } }) + + try { + await openGallery({ seenEvents: [], ingestedEvent: true }) + expect(logic.values).toMatchObject({ galleryLoadFailed: true, galleryTemplates: null }) + + failing = false + logic.actions.loadEmailTemplates() + await expectLogic(logic).toFinishAllListeners() + + expect(logic.values.galleryLoadFailed).toBe(false) + expect(logic.values.galleryTemplates).toHaveLength(5) + } finally { + resumeKeaLoadersErrors() + } + }) +}) diff --git a/products/workflows/frontend/firstRun/firstRunGalleryLogic.ts b/products/workflows/frontend/firstRun/firstRunGalleryLogic.ts new file mode 100644 index 000000000000..6406362fbe5b --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunGalleryLogic.ts @@ -0,0 +1,305 @@ +import { MakeLogicType, actions, connect, kea, listeners, path, reducers, selectors, afterMount } from 'kea' +import { loaders } from 'kea-loaders' +import { router } from 'kea-router' +import posthog from 'posthog-js' + +import { FEATURE_FLAGS } from 'lib/constants' +import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { projectLogic } from 'scenes/projectLogic' +import { teamLogic } from 'scenes/teamLogic' +import { urls } from 'scenes/urls' + +import type { TeamPublicType, TeamType } from '~/types' + +import { eventDefinitionsList } from 'products/event_definitions/frontend/generated/api' + +import { hogFlowTemplatesList } from '../generated/api' +import type { HogFlowTemplateApi, HogFlowTemplateStartsOnApi } from '../generated/api.schemas' +import type { HogFlowTemplate } from '../Workflows/hogflows/types' + +// pinned: the id of the global welcome sequence in products/workflows/backend/templates/ +export const WELCOME_SEQUENCE_TEMPLATE_ID = '019b6f44-f9a3-0000-c4a7-b8050d25d690' + +// pinned: search param on the Workflows tab that addresses Make it yours, so bookmarks and reloads return to it +export const FIRST_RUN_TEMPLATE_PARAM = 'template' + +export type GalleryFilter = 'picked' | 'all' + +export interface StartableTemplate { + template: HogFlowTemplate + startsOn: HogFlowTemplateStartsOnApi +} + +export interface GalleryTemplate extends StartableTemplate { + matchedEvent: string | null + ready: boolean +} + +export interface RecommendedStarter { + templateId: string + reason: string +} + +function isEmailTemplate( + template: HogFlowTemplateApi +): template is HogFlowTemplateApi & { starts_on: HogFlowTemplateStartsOnApi } { + return template.scope === 'global' && !!template.starts_on +} + +function eventsToLookUp(emailTemplates: StartableTemplate[]): string[] { + return [...new Set(emailTemplates.flatMap(({ startsOn }) => startsOn.events))] +} + +function fitTemplate( + { template, startsOn }: StartableTemplate, + seenEvents: Set, + projectIngestedEvents: boolean +): GalleryTemplate { + if (startsOn.kind === 'schedule') { + return { template, startsOn, matchedEvent: null, ready: projectIngestedEvents } + } + const matchedEvent = startsOn.events.find((event) => seenEvents.has(event)) ?? null + return { template, startsOn, matchedEvent, ready: matchedEvent !== null } +} + +function recommend(galleryTemplates: GalleryTemplate[]): RecommendedStarter | null { + const welcome = galleryTemplates.find(({ template }) => template.id === WELCOME_SEQUENCE_TEMPLATE_ID) + if (welcome?.ready) { + return { + templateId: welcome.template.id, + reason: 'Every new signup gets it, and your app already sends a signup event.', + } + } + const firstReady = galleryTemplates.find(({ ready }) => ready) + return firstReady + ? { templateId: firstReady.template.id, reason: 'It works with the events your app sends today.' } + : null +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunGalleryLogicValues { + featureFlags: FeatureFlagsSet // featureFlagLogic + currentProjectId: number | null // projectLogic + currentTeam: TeamPublicType | TeamType | null // teamLogic + activeFilter: GalleryFilter + emailTemplates: StartableTemplate[] | null + emailTemplatesLoading: boolean + filter: GalleryFilter + firstRunEnabled: boolean + galleryLoadFailed: boolean + galleryTemplates: GalleryTemplate[] | null + readyTemplates: GalleryTemplate[] + recommendedStarter: RecommendedStarter | null + seenEvents: string[] | null + seenEventsLoading: boolean + shownTemplates: GalleryTemplate[] +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunGalleryLogicActions { + loadEmailTemplates: () => any + loadEmailTemplatesFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadEmailTemplatesSuccess: ( + emailTemplates: StartableTemplate[], + payload?: any + ) => { + emailTemplates: StartableTemplate[] + payload?: any + } + loadSeenEvents: (names: string[]) => string[] + loadSeenEventsFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadSeenEventsSuccess: ( + seenEvents: string[], + payload?: string[] + ) => { + seenEvents: string[] + payload?: string[] + } + pickTemplate: (templateId: string) => { + templateId: string + } + setFilter: (filter: GalleryFilter) => { + filter: GalleryFilter + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunGalleryLogicMeta { + __keaTypeGenInternalSelectorTypes: { + firstRunEnabled: (featureFlags: FeatureFlagsSet) => boolean + galleryTemplates: ( + emailTemplates: StartableTemplate[] | null, + seenEvents: string[] | null, + currentTeam: TeamPublicType | TeamType | null + ) => GalleryTemplate[] | null + recommendedStarter: (galleryTemplates: GalleryTemplate[] | null) => RecommendedStarter | null + readyTemplates: (galleryTemplates: GalleryTemplate[] | null) => GalleryTemplate[] + activeFilter: (filter: GalleryFilter, readyTemplates: GalleryTemplate[]) => GalleryFilter + shownTemplates: ( + galleryTemplates: GalleryTemplate[] | null, + recommendedStarter: RecommendedStarter | null, + activeFilter: GalleryFilter + ) => GalleryTemplate[] + } +} + +export type firstRunGalleryLogicType = MakeLogicType< + firstRunGalleryLogicValues, + firstRunGalleryLogicActions, + Record, + firstRunGalleryLogicMeta +> + +export const firstRunGalleryLogic = kea([ + path(['products', 'workflows', 'frontend', 'firstRun', 'firstRunGalleryLogic']), + connect(() => ({ + values: [projectLogic, ['currentProjectId'], teamLogic, ['currentTeam'], featureFlagLogic, ['featureFlags']], + })), + actions({ + setFilter: (filter: GalleryFilter) => ({ filter }), + pickTemplate: (templateId: string) => ({ templateId }), + }), + loaders(({ values }) => ({ + emailTemplates: [ + null as StartableTemplate[] | null, + { + loadEmailTemplates: async (): Promise => { + if (!values.firstRunEnabled) { + return [] + } + const response = await hogFlowTemplatesList(String(values.currentProjectId)) + return response.results.filter(isEmailTemplate).map((template) => ({ + template: template as unknown as HogFlowTemplate, + startsOn: template.starts_on, + })) + }, + }, + ], + seenEvents: [ + null as string[] | null, + { + loadSeenEvents: async (names: string[]): Promise => { + if (!values.firstRunEnabled || names.length === 0) { + return [] + } + const response = await eventDefinitionsList(String(values.currentProjectId), { + names, + limit: names.length, + }) + // A definition can exist before its event arrives, for example one the Wizard planned. + return response.results.filter(({ last_seen_at }) => last_seen_at !== null).map(({ name }) => name) + }, + }, + ], + })), + reducers({ + filter: ['picked' as GalleryFilter, { setFilter: (_, { filter }) => filter }], + galleryLoadFailed: [ + false, + { + loadEmailTemplates: () => false, + loadEmailTemplatesFailure: () => true, + loadSeenEventsFailure: () => true, + }, + ], + }), + selectors({ + firstRunEnabled: [ + (s) => [s.featureFlags], + (featureFlags: FeatureFlagsSet): boolean => !!featureFlags[FEATURE_FLAGS.WORKFLOWS_FIRST_RUN], + ], + galleryTemplates: [ + (s) => [s.emailTemplates, s.seenEvents, s.currentTeam], + ( + emailTemplates: StartableTemplate[] | null, + seenEvents: string[] | null, + currentTeam: TeamType | TeamPublicType | null + ): GalleryTemplate[] | null => { + if (!emailTemplates || !seenEvents) { + return null + } + const seen = new Set(seenEvents) + return emailTemplates.map((emailTemplate) => + fitTemplate(emailTemplate, seen, !!currentTeam?.ingested_event) + ) + }, + ], + recommendedStarter: [ + (s) => [s.galleryTemplates], + (galleryTemplates: GalleryTemplate[] | null): RecommendedStarter | null => + galleryTemplates ? recommend(galleryTemplates) : null, + ], + readyTemplates: [ + (s) => [s.galleryTemplates], + (galleryTemplates: GalleryTemplate[] | null): GalleryTemplate[] => + (galleryTemplates ?? []).filter(({ ready }) => ready), + ], + activeFilter: [ + (s) => [s.filter, s.readyTemplates], + (filter: GalleryFilter, readyTemplates: GalleryTemplate[]): GalleryFilter => + readyTemplates.length === 0 ? 'all' : filter, + ], + shownTemplates: [ + (s) => [s.galleryTemplates, s.recommendedStarter, s.activeFilter], + ( + galleryTemplates: GalleryTemplate[] | null, + recommendedStarter: RecommendedStarter | null, + activeFilter: GalleryFilter + ): GalleryTemplate[] => { + const rank = ({ template, ready }: GalleryTemplate): number => + template.id === recommendedStarter?.templateId ? 0 : ready ? 1 : 2 + return (galleryTemplates ?? []) + .filter(({ ready }) => activeFilter === 'all' || ready) + .sort((a, b) => rank(a) - rank(b)) + }, + ], + }), + listeners(({ actions, values }) => ({ + loadEmailTemplatesSuccess: ({ emailTemplates }) => { + if (values.firstRunEnabled) { + actions.loadSeenEvents(eventsToLookUp(emailTemplates)) + } + }, + loadSeenEventsSuccess: () => { + if (!values.firstRunEnabled) { + return + } + posthog.capture('workflows first run gallery shown', { + ready_count: values.readyTemplates.length, + recommended_template_id: values.recommendedStarter?.templateId ?? null, + }) + }, + pickTemplate: ({ templateId }) => { + if (!values.firstRunEnabled) { + return + } + const picked = values.galleryTemplates?.find(({ template }) => template.id === templateId) + if (!picked) { + return + } + posthog.capture('workflows first run template picked', { + template_id: templateId, + recommended: templateId === values.recommendedStarter?.templateId, + ready: picked.ready, + }) + router.actions.push(urls.workflows(), { [FIRST_RUN_TEMPLATE_PARAM]: templateId }) + }, + })), + afterMount(({ actions, values }) => { + if (values.firstRunEnabled) { + actions.loadEmailTemplates() + } + }), +]) diff --git a/products/workflows/frontend/firstRun/firstRunMakeItYoursLogic.test.ts b/products/workflows/frontend/firstRun/firstRunMakeItYoursLogic.test.ts new file mode 100644 index 000000000000..7c0f58a14f45 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunMakeItYoursLogic.test.ts @@ -0,0 +1,478 @@ +import { router } from 'kea-router' +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { SetupTaskId, 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 { emailTemplaterLogic } from 'scenes/hog-functions/email-templater/emailTemplaterLogic' +import type { EmailTemplate } from 'scenes/hog-functions/email-templater/types' +import { urls } from 'scenes/urls' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import type { IntegrationConfigApi } from 'products/integrations/frontend/generated/api.schemas' + +import type { HogFlowTemplateApi } from '../generated/api.schemas' +import type { HogFlow, HogFlowAction } from '../Workflows/hogflows/types' +import { firstRunMakeItYoursLogic } from './firstRunMakeItYoursLogic' + +const SENDER = { + id: 5, + kind: 'email', + display_name: 'Sender ', + config: { verified: true, email: 'sender@example.com' }, +} satisfies Partial + +function emailAction(id: string, subject: string): HogFlowAction { + return { + id, + type: 'function_email', + name: subject, + description: '', + created_at: 0, + updated_at: 0, + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + to: { email: '{{ person.properties.email }}', name: '' }, + from: { name: '', email: '' }, + subject, + html: `

${subject}

`, + text: subject, + design: { body: { rows: [], values: { subject } } }, + }, + templating: 'liquid', + }, + }, + }, + } as HogFlowAction +} + +function delayAction(id: string, duration: string): HogFlowAction { + return { + id, + type: 'delay', + name: 'Delay', + description: '', + created_at: 0, + updated_at: 0, + config: { delay_duration: duration }, + } as HogFlowAction +} + +function branchAction(id: string): HogFlowAction { + return { + id, + type: 'conditional_branch', + name: 'Branch', + description: '', + created_at: 0, + updated_at: 0, + config: { conditions: [] }, + } as HogFlowAction +} + +const TRIGGER: HogFlowAction = { + id: 'trigger_node', + type: 'trigger', + name: 'Trigger', + description: '', + created_at: 0, + updated_at: 0, + config: { + type: 'event', + filters: { events: [{ id: 'user signed up', name: 'user signed up', type: 'events', order: 0 }] }, + }, +} as HogFlowAction + +const EXIT: HogFlowAction = { + id: 'exit_node', + type: 'exit', + name: 'Exit', + description: '', + created_at: 0, + updated_at: 0, + config: { reason: 'Done' }, +} as HogFlowAction + +// Three emails whose stored order differs from the flow order, with a branch on the way to the last one. +const SEQUENCE: Partial = { + id: 'onboarding-sequence', + name: 'Onboarding sequence', + scope: 'global', + tags: [], + starts_on: { kind: 'event', events: ['user signed up', 'signed_up'], detail: '' }, + actions: [ + TRIGGER, + emailAction('email_last', 'Last reminder'), + emailAction('email_first', 'Welcome!'), + delayAction('delay_1', '1d'), + emailAction('email_second', 'Still there?'), + delayAction('delay_2', '2d'), + branchAction('branch_1'), + EXIT, + ] as unknown as HogFlowTemplateApi['actions'], + edges: [ + { from: 'trigger_node', to: 'email_first', type: 'continue' }, + { from: 'email_first', to: 'delay_1', type: 'continue' }, + { from: 'delay_1', to: 'email_second', type: 'continue' }, + { from: 'email_second', to: 'delay_2', type: 'continue' }, + { from: 'delay_2', to: 'branch_1', type: 'continue' }, + { from: 'branch_1', to: 'exit_node', type: 'continue' }, + { from: 'branch_1', to: 'email_last', type: 'branch' }, + { from: 'email_last', to: 'exit_node', type: 'continue' }, + ], + exit_condition: 'exit_only_at_end', + conversion: { filters: [] }, +} + +const SINGLE: Partial = { + id: 'single-email', + name: 'One email', + scope: 'global', + tags: [], + starts_on: { kind: 'event', events: ['$pageview'], detail: '' }, + actions: [TRIGGER, emailAction('email_only', 'Hello'), EXIT] as unknown as HogFlowTemplateApi['actions'], + edges: [ + { from: 'trigger_node', to: 'email_only', type: 'continue' }, + { from: 'email_only', to: 'exit_node', type: 'continue' }, + ], +} + +function editedEmail(subject: string): EmailTemplate { + return { + subject, + html: `

${subject} edited

`, + text: `${subject} edited`, + design: { body: { rows: [], values: { subject: `${subject} edited` } } }, + from: { integrationId: 99 }, + to: '{{ person.properties.email }}', + } as EmailTemplate +} + +function emailStepsOf(workflow: Partial): Record { + return Object.fromEntries( + (workflow.actions ?? []) + .filter((action) => action.type === 'function_email') + .map((action) => [action.id, action.config.inputs.email.value]) + ) +} + +describe('firstRunMakeItYoursLogic', () => { + let logic: ReturnType + let integrations: Partial[] + let createdWorkflows: Partial[] + let createResponse: [number, Record] + let invocations: Record[] + let invocationResponse: Record + let completedSetupTasks: string[] + + beforeEach(() => { + completedSetupTasks = [] + integrations = [SENDER] + createdWorkflows = [] + createResponse = [201, { id: 'wf-1', status: 'active' }] + invocations = [] + invocationResponse = { status: 'success', logs: [], nextActionId: null } + useMocks({ + get: { + '/api/projects/:team_id/hog_flow_templates/': { count: 2, results: [SEQUENCE, SINGLE] }, + '/api/projects/:team_id/event_definitions/': ({ request }) => { + const names = new URL(request.url).searchParams.get('names')?.split(',') ?? [] + const seen = names.filter((name) => ['signed_up', '$pageview'].includes(name)) + return [ + 200, + { + count: seen.length, + results: seen.map((name) => ({ id: name, name, last_seen_at: '2026-10-01T00:00:00Z' })), + }, + ] + }, + '/api/projects/:team_id/integrations/': () => [200, { results: integrations }], + }, + post: { + '/api/projects/:team_id/hog_flows/': async ({ request }) => { + createdWorkflows.push((await request.json()) as Partial) + return createResponse + }, + '/api/projects/:team_id/hog_flows/new/invocations/': async ({ request }) => { + invocations.push((await request.json()) as Record) + return [200, invocationResponse] + }, + }, + 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() + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: true }) + globalSetupLogic.mount() + window.POSTHOG_APP_CONTEXT!.resource_access_control = { hog_flow: 'editor' } as any + }) + + afterEach(() => { + logic?.unmount() + window.localStorage.clear() + }) + + async function open(templateId: string): Promise { + logic = firstRunMakeItYoursLogic({ templateId }) + logic.mount() + await expectLogic(logic).toFinishAllListeners() + await expectLogic(logic).toMatchValues({ pickedTemplate: expect.anything() }) + } + + it('lists the emails in flow order with their timing and opens the first one', async () => { + await open('onboarding-sequence') + + expect(logic.values.templateEmails.map(({ id, subject, timing }) => ({ id, subject, timing }))).toEqual([ + { id: 'email_first', subject: 'Welcome!', timing: 'Right away' }, + { id: 'email_second', subject: 'Still there?', timing: 'After 1 day' }, + { id: 'email_last', subject: 'Last reminder', timing: 'After 3 days' }, + ]) + expect(logic.values.openEmail?.id).toBe('email_first') + expect(logic.values.openEmailPosition).toEqual({ index: 1, total: 3 }) + }) + + it('keeps the edits of every email while switching between them', async () => { + await open('onboarding-sequence') + + logic.actions.editEmail('email_first', editedEmail('Welcome!')) + logic.actions.openEmail('email_second') + logic.actions.editEmail('email_second', editedEmail('Still there?')) + logic.actions.openEmail('email_first') + await expectLogic(logic).toFinishAllListeners() + + expect(logic.values.openEmail).toMatchObject({ + id: 'email_first', + edited: true, + email: { subject: 'Welcome!', text: 'Welcome! edited' }, + }) + expect(logic.values.editedEmailIds).toEqual(['email_first', 'email_second']) + expect(logic.values.openEmailPosition).toEqual({ index: 1, total: 3 }) + }) + + it.each([ + { enabled: true, status: 'active', firstRunEnabled: true, completesLaunchTask: true }, + { enabled: false, status: 'draft', firstRunEnabled: true, completesLaunchTask: false }, + { enabled: false, status: 'draft', firstRunEnabled: false, completesLaunchTask: false }, + ])( + 'honors first-run=$firstRunEnabled when creating a $status workflow with edits, sender and matched event', + async ({ enabled, status, firstRunEnabled, completesLaunchTask }) => { + createResponse = [201, { id: 'wf-1', status }] + const capture = jest.spyOn(posthog, 'capture').mockImplementation() + await open('onboarding-sequence') + logic.actions.editEmail('email_first', editedEmail('Welcome!')) + logic.actions.openEmail('email_last') + logic.actions.editEmail('email_last', editedEmail('Last reminder')) + logic.actions.setEnableWorkflow(enabled) + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: firstRunEnabled }) + const pathnameBefore = router.values.location.pathname + + logic.actions.createWorkflow() + await expectLogic(logic).toFinishAllListeners() + + if (!firstRunEnabled) { + expect(createdWorkflows).toHaveLength(0) + expect(router.values.location.pathname).toBe(pathnameBefore) + expect(window.localStorage.getItem('workflows-first-run-workflow-id')).toBeNull() + expect(capture).not.toHaveBeenCalledWith('workflows first run workflow created', expect.anything()) + return + } + + expect(createdWorkflows).toHaveLength(1) + const created = createdWorkflows[0] + expect(created).not.toHaveProperty('id') + expect(created).not.toHaveProperty('team_id') + expect(created).toMatchObject({ name: 'Onboarding sequence', status }) + expect(emailStepsOf(created)).toEqual({ + email_first: expect.objectContaining({ text: 'Welcome! edited', from: { integrationId: SENDER.id } }), + email_second: expect.objectContaining({ text: 'Still there?', from: { integrationId: SENDER.id } }), + email_last: expect.objectContaining({ + text: 'Last reminder edited', + from: { integrationId: SENDER.id }, + }), + }) + const trigger = created.actions?.find((action) => action.type === 'trigger') + expect(trigger?.config).toEqual({ + type: 'event', + filters: { events: [{ id: 'signed_up', name: 'signed_up', type: 'events', order: 0 }] }, + }) + expect(removeProjectIdIfPresent(router.values.location.pathname)).toBe(urls.workflow('wf-1', 'workflow')) + expect(window.localStorage.getItem('workflows-first-run-workflow-id')).toBe('wf-1') + expect(capture).toHaveBeenCalledWith('workflows first run workflow created', { + template_id: 'onboarding-sequence', + enabled, + }) + expect(completedSetupTasks.includes(SetupTaskId.LaunchWorkflow)).toBe(completesLaunchTask) + } + ) + + it.each([true, false])('sends the edited email only while first run is enabled=%s', async (enabled) => { + const capture = jest.spyOn(posthog, 'capture').mockImplementation() + await open('onboarding-sequence') + logic.actions.openEmail('email_second') + await expectLogic(logic).toFinishAllListeners() + logic.actions.editEmail('email_second', editedEmail('Still there?')) + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: enabled }) + + logic.actions.sendTest() + await expectLogic(logic).toFinishAllListeners() + + if (!enabled) { + expect(invocations).toHaveLength(0) + expect(logic.values.testSendOutcome).toBeNull() + expect(capture).not.toHaveBeenCalledWith('workflows first run test sent', expect.anything()) + return + } + + expect(invocations).toHaveLength(1) + const [invocation] = invocations + expect(invocation).toMatchObject({ mock_async_functions: false, current_action_id: 'send_test_email' }) + const emailSteps = emailStepsOf(invocation.configuration) + expect(emailSteps).toEqual({ + send_test_email: expect.objectContaining({ + text: 'Still there? edited', + from: { integrationId: SENDER.id }, + to: { email: 'john.doe@posthog.com', name: '' }, + }), + }) + expect(logic.values.testSendOutcome).toEqual({ kind: 'sent', recipient: 'john.doe@posthog.com' }) + expect(capture).toHaveBeenCalledWith('workflows first run test sent', { skipped: false }) + expect(completedSetupTasks).toContain(SetupTaskId.SendWorkflowTestEmail) + }) + + it('shows why the worker skipped a test send', async () => { + const capture = jest.spyOn(posthog, 'capture').mockImplementation() + const reason = 'Skipping send: the recipient address has no mail server.' + invocationResponse = { + status: 'success', + nextActionId: null, + logs: [{ level: 'info', timestamp: '2026-10-05T00:00:00Z', message: reason }], + } + await open('onboarding-sequence') + + logic.actions.sendTest() + await expectLogic(logic).toFinishAllListeners() + + expect(logic.values.testSendOutcome).toEqual({ kind: 'skipped', reason }) + expect(capture).toHaveBeenCalledWith('workflows first run test sent', { skipped: true }) + expect(completedSetupTasks).not.toContain(SetupTaskId.SendWorkflowTestEmail) + }) + + it('without a first-run sender disables test and enable with a reason, and still creates a draft', async () => { + integrations = [] + await open('onboarding-sequence') + + expect(logic.values.sendTestDisabledReason).toBe('Connect an email sender first') + expect(logic.values.enableDisabledReason).toBe('Connect an email sender first') + expect(logic.values.enableWorkflow).toBe(false) + + logic.actions.createWorkflow() + await expectLogic(logic).toFinishAllListeners() + + expect(createdWorkflows[0]).toMatchObject({ status: 'draft' }) + expect(emailStepsOf(createdWorkflows[0]).email_first.from).toEqual({ name: '', email: '' }) + }) + + it('shows the server message of a rejected create and opens nothing', async () => { + createResponse = [400, { type: 'validation_error', detail: 'Pick at least one event.', attr: 'actions' }] + await open('onboarding-sequence') + const pathnameBefore = router.values.location.pathname + + logic.actions.createWorkflow() + await expectLogic(logic).toFinishAllListeners() + + expect(logic.values.createError).toBe('Pick at least one event.') + expect(logic.values.createdWorkflowLoading).toBe(false) + expect(router.values.location.pathname).toBe(pathnameBefore) + expect(window.localStorage.getItem('workflows-first-run-workflow-id')).toBeNull() + }) + + describe('while the editor still holds a canvas edit it has not exported', () => { + let templater: ReturnType + + beforeEach(async () => { + useMocks({ get: { '/api/projects/:team_id/messaging_templates/': { count: 0, results: [] } } }) + await open('onboarding-sequence') + templater = emailTemplaterLogic({ type: 'native_email_template', value: null, onChange: () => {} }) + templater.mount() + templater.cache.pendingDesignEdit = true + }) + + afterEach(() => { + templater.unmount() + }) + + function editorExportsTheEdit(): void { + templater.cache.pendingDesignEdit = false + logic.actions.editEmail('email_first', editedEmail('Welcome!')) + } + + it('switches emails only after the edit arrived, and keeps it', async () => { + logic.actions.openEmail('email_second') + await expectLogic(logic).toDispatchActions(['openEmail']) + + expect(logic.values.openEmail?.id).toBe('email_first') + + editorExportsTheEdit() + await expectLogic(logic).toFinishAllListeners() + + expect(logic.values.openEmail?.id).toBe('email_second') + expect(logic.values.edits.email_first.text).toBe('Welcome! edited') + }) + + it.each( + ( + [ + { + action: 'sendTest', + requests: () => invocations, + text: (body: any) => emailStepsOf(body.configuration).send_test_email.text, + }, + { + action: 'createWorkflow', + requests: () => createdWorkflows, + text: (body: any) => emailStepsOf(body).email_first.text, + }, + ] as const + ).flatMap((testCase) => [ + { ...testCase, enabled: true }, + { ...testCase, enabled: false }, + ]) + )('$action waits for the edit and honors first-run=$enabled', async ({ action, requests, text, enabled }) => { + logic.actions[action]() + await expectLogic(logic).toDispatchActions([action]) + + expect(requests()).toHaveLength(0) + + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: enabled }) + editorExportsTheEdit() + await expectLogic(logic).toFinishAllListeners() + + expect(requests()).toHaveLength(enabled ? 1 : 0) + if (enabled) { + expect(text(requests()[0])).toBe('Welcome! edited') + } + }) + }) + + it('disables test and create for a user without editor access to workflows', async () => { + window.POSTHOG_APP_CONTEXT!.resource_access_control = { hog_flow: 'viewer' } as any + await open('onboarding-sequence') + + expect(logic.values.sendTestDisabledReason).toContain("You don't have sufficient permissions") + expect(logic.values.createDisabledReason).toContain("You don't have sufficient permissions") + }) +}) diff --git a/products/workflows/frontend/firstRun/firstRunMakeItYoursLogic.ts b/products/workflows/frontend/firstRun/firstRunMakeItYoursLogic.ts new file mode 100644 index 000000000000..6a1ffdb91b94 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunMakeItYoursLogic.ts @@ -0,0 +1,429 @@ +import { + BreakPointFunction, + LogicWrapper, + MakeLogicType, + actions, + connect, + kea, + key, + listeners, + path, + props, + reducers, + selectors, +} from 'kea' +import { loaders } from 'kea-loaders' +import { router } from 'kea-router' +import posthog from 'posthog-js' + +import api from 'lib/api' +import { SetupTaskId, globalSetupLogic } from 'lib/components/ProductSetup' +import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils' +import { emailTemplaterLogic } from 'scenes/hog-functions/email-templater/emailTemplaterLogic' +import type { EmailTemplate } from 'scenes/hog-functions/email-templater/types' +import { projectLogic } from 'scenes/projectLogic' +import { teamLogic } from 'scenes/teamLogic' +import { urls } from 'scenes/urls' +import { userLogic } from 'scenes/userLogic' + +import { AccessControlLevel, AccessControlResourceType, UserType } from '~/types' + +import { completeTestEmailSetupTask } from '../completeTestEmailSetupTask' +import { hogFlowsCreate } from '../generated/api' +import type { HogFlowApi } from '../generated/api.schemas' +import { findTestSendSkipReason } from '../Workflows/hogflows/findTestSendSkipReason' +import type { HogflowTestResult } from '../Workflows/hogflows/steps/types' +import { buildFirstRunTestSend } from './buildFirstRunTestSend' +import { buildFirstRunWorkflow } from './buildFirstRunWorkflow' +import { GalleryTemplate, firstRunGalleryLogic } from './firstRunGalleryLogic' +import { FirstRunSender, firstRunSenderLogic } from './firstRunSenderLogic' +import { rememberFirstRunWorkflow } from './firstRunWorkflowStorage' +import { TemplateEmail, listTemplateEmails } from './templateEmails' + +export interface FirstRunMakeItYoursLogicProps { + templateId: string +} + +export interface OpenEmail extends TemplateEmail { + edited: boolean +} + +export interface OpenEmailPosition { + index: number + total: number +} + +// The templater exports a canvas edit 500ms after the last change and flags the wait in its cache. Switching, +// sending or creating inside that window would act on the email without the edit, so every one of them waits. +async function waitForPendingCanvasEdit(breakpoint: BreakPointFunction): Promise { + while (emailTemplaterLogic.findMounted()?.cache.pendingDesignEdit) { + await breakpoint(100) + } +} + +export type TestSendOutcome = + | { kind: 'sent'; recipient: string } + | { kind: 'skipped'; reason: string } + | { kind: 'failed' } + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunMakeItYoursLogicValues { + galleryLoadFailed: boolean // firstRunGalleryLogic + galleryTemplates: GalleryTemplate[] | null // firstRunGalleryLogic + firstRunEnabled: boolean // firstRunSenderLogic + firstRunSender: FirstRunSender | null // firstRunSenderLogic + firstRunSenderLoading: boolean // firstRunSenderLogic + currentProjectId: number | null // projectLogic + currentTeamId: number | null // teamLogic + user: UserType | null // userLogic + createDisabledReason: string | null + createError: string | null + createdWorkflow: HogFlowApi | null + createdWorkflowLoading: boolean + editedEmailIds: string[] + edits: Record + enableDisabledReason: string | null + enableWorkflow: boolean + enableWorkflowChoice: boolean + openEmail: OpenEmail | null + openEmailId: string | null + openEmailIdOverride: string | null + openEmailPosition: OpenEmailPosition | null + pickedTemplate: GalleryTemplate | null + sendTestDisabledReason: string | null + senderDisabledReason: string | null + templateEmails: TemplateEmail[] + testSendFailed: boolean + testSendOutcome: TestSendOutcome | null + testSendResult: HogflowTestResult | null + testSendResultLoading: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunMakeItYoursLogicActions { + createWorkflow: (_: void) => void + createWorkflowFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + createWorkflowSuccess: ( + createdWorkflow: HogFlowApi | null, + payload?: void + ) => { + createdWorkflow: HogFlowApi | null + payload?: void + } + editEmail: ( + emailId: string, + email: EmailTemplate + ) => { + email: EmailTemplate + emailId: string + } + markTestSendFailed: () => { + value: true + } + openEmail: (emailId: string) => { + emailId: string + } + sendTest: (_: void) => void + sendTestFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + sendTestSuccess: ( + testSendResult: HogflowTestResult | null, + payload?: void + ) => { + testSendResult: HogflowTestResult | null + payload?: void + } + setCreateError: (error: string) => { + error: string + } + setEnableWorkflow: (enabled: boolean) => { + enabled: boolean + } + setOpenEmailId: (emailId: string) => { + emailId: string + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunMakeItYoursLogicMeta { + key: string + __keaTypeGenInternalSelectorTypes: { + pickedTemplate: (galleryTemplates: GalleryTemplate[] | null, templateId: string) => GalleryTemplate | null + templateEmails: (pickedTemplate: GalleryTemplate | null) => TemplateEmail[] + openEmailId: (openEmailIdOverride: string | null, templateEmails: TemplateEmail[]) => string | null + openEmail: ( + templateEmails: TemplateEmail[], + openEmailId: string | null, + edits: Record + ) => OpenEmail | null + openEmailPosition: (templateEmails: TemplateEmail[], openEmailId: string | null) => OpenEmailPosition | null + editedEmailIds: (edits: Record) => string[] + createDisabledReason: (firstRunEnabled: any) => string | null + senderDisabledReason: (firstRunSenderLoading: boolean, firstRunSender: FirstRunSender | null) => string | null + sendTestDisabledReason: ( + createDisabledReason: string | null, + senderDisabledReason: string | null + ) => string | null + enableDisabledReason: ( + createDisabledReason: string | null, + senderDisabledReason: string | null + ) => string | null + enableWorkflow: (enableWorkflowChoice: boolean, enableDisabledReason: string | null) => boolean + testSendOutcome: ( + testSendResult: HogflowTestResult | null, + testSendFailed: boolean, + user: UserType | null + ) => TestSendOutcome | null + } +} + +export type firstRunMakeItYoursLogicType = MakeLogicType< + firstRunMakeItYoursLogicValues, + firstRunMakeItYoursLogicActions, + FirstRunMakeItYoursLogicProps, + firstRunMakeItYoursLogicMeta +> + +export const firstRunMakeItYoursLogic: LogicWrapper = kea([ + props({} as FirstRunMakeItYoursLogicProps), + key((props) => props.templateId), + path((key) => ['products', 'workflows', 'frontend', 'firstRun', 'firstRunMakeItYoursLogic', key]), + connect(() => ({ + values: [ + firstRunGalleryLogic, + ['galleryTemplates', 'galleryLoadFailed'], + firstRunSenderLogic, + ['firstRunSender', 'firstRunSenderLoading', 'firstRunEnabled'], + projectLogic, + ['currentProjectId'], + teamLogic, + ['currentTeamId'], + userLogic, + ['user'], + ], + })), + actions({ + openEmail: (emailId: string) => ({ emailId }), + setOpenEmailId: (emailId: string) => ({ emailId }), + editEmail: (emailId: string, email: EmailTemplate) => ({ emailId, email }), + setEnableWorkflow: (enabled: boolean) => ({ enabled }), + setCreateError: (error: string) => ({ error }), + markTestSendFailed: true, + }), + // The loaders catch their own errors so the screen shows them in place, instead of the generic failure toast. + loaders(({ values, props, actions }) => ({ + testSendResult: [ + null as HogflowTestResult | null, + { + sendTest: async (_: void, breakpoint): Promise => { + await waitForPendingCanvasEdit(breakpoint) + const { openEmail, firstRunSender, user, pickedTemplate } = values + if (!openEmail || !firstRunSender || !user?.email) { + return null + } + try { + return await api.hogFlows.createTestInvocation( + 'new', + buildFirstRunTestSend({ + email: openEmail.email, + sender: firstRunSender, + recipientEmail: user.email, + workflowName: pickedTemplate?.template.name || 'Test email', + teamId: values.currentTeamId, + }) + ) + } catch { + actions.markTestSendFailed() + return null + } + }, + }, + ], + createdWorkflow: [ + null as HogFlowApi | null, + { + createWorkflow: async (_: void, breakpoint): Promise => { + await waitForPendingCanvasEdit(breakpoint) + if (!values.firstRunEnabled || !values.pickedTemplate) { + return null + } + const workflow = buildFirstRunWorkflow({ + template: values.pickedTemplate.template, + matchedEvent: values.pickedTemplate.matchedEvent, + edits: values.edits, + sender: values.firstRunSender, + enabled: values.enableWorkflow, + }) + try { + const created = await hogFlowsCreate( + String(values.currentProjectId), + workflow as Parameters[1] + ) + posthog.capture('workflows first run workflow created', { + template_id: props.templateId, + enabled: values.enableWorkflow, + }) + return created + } catch (error: any) { + actions.setCreateError(error?.detail || error?.message || 'Something went wrong') + return null + } + }, + }, + ], + })), + reducers({ + openEmailIdOverride: [null as string | null, { setOpenEmailId: (_, { emailId }) => emailId }], + edits: [ + {} as Record, + { editEmail: (state, { emailId, email }) => ({ ...state, [emailId]: email }) }, + ], + enableWorkflowChoice: [true, { setEnableWorkflow: (_, { enabled }) => enabled }], + testSendFailed: [false, { sendTest: () => false, markTestSendFailed: () => true }], + createError: [ + null as string | null, + { + createWorkflow: () => null, + setCreateError: (_, { error }) => error, + }, + ], + }), + selectors({ + pickedTemplate: [ + (s, p) => [s.galleryTemplates, p.templateId], + (galleryTemplates: GalleryTemplate[] | null, templateId: string): GalleryTemplate | null => + galleryTemplates?.find(({ template }) => template.id === templateId) ?? null, + ], + templateEmails: [ + (s) => [s.pickedTemplate], + (pickedTemplate: GalleryTemplate | null): TemplateEmail[] => + pickedTemplate + ? listTemplateEmails(pickedTemplate.template.actions, pickedTemplate.template.edges) + : [], + ], + openEmailId: [ + (s) => [s.openEmailIdOverride, s.templateEmails], + (override: string | null, templateEmails: TemplateEmail[]): string | null => + templateEmails.find(({ id }) => id === override)?.id ?? templateEmails[0]?.id ?? null, + ], + openEmail: [ + (s) => [s.templateEmails, s.openEmailId, s.edits], + ( + templateEmails: TemplateEmail[], + openEmailId: string | null, + edits: Record + ): OpenEmail | null => { + const templateEmail = templateEmails.find(({ id }) => id === openEmailId) + if (!templateEmail) { + return null + } + const edit = edits[templateEmail.id] + return { ...templateEmail, email: edit ?? templateEmail.email, edited: !!edit } + }, + ], + openEmailPosition: [ + (s) => [s.templateEmails, s.openEmailId], + (templateEmails: TemplateEmail[], openEmailId: string | null): OpenEmailPosition | null => { + const index = templateEmails.findIndex(({ id }) => id === openEmailId) + return index === -1 ? null : { index: index + 1, total: templateEmails.length } + }, + ], + editedEmailIds: [(s) => [s.edits], (edits: Record): string[] => Object.keys(edits)], + createDisabledReason: [ + (s) => [s.firstRunEnabled], + (firstRunEnabled: boolean): string | null => + firstRunEnabled + ? getAccessControlDisabledReason(AccessControlResourceType.Workflow, AccessControlLevel.Editor) + : 'First-run setup is not available', + ], + senderDisabledReason: [ + (s) => [s.firstRunSenderLoading, s.firstRunSender], + (firstRunSenderLoading: boolean, firstRunSender: FirstRunSender | null): string | null => { + if (firstRunSender) { + return null + } + return firstRunSenderLoading ? 'Checking your email senders' : 'Connect an email sender first' + }, + ], + sendTestDisabledReason: [ + (s) => [s.createDisabledReason, s.senderDisabledReason], + (createDisabledReason: string | null, senderDisabledReason: string | null): string | null => + createDisabledReason ?? senderDisabledReason, + ], + enableDisabledReason: [ + (s) => [s.createDisabledReason, s.senderDisabledReason], + (createDisabledReason: string | null, senderDisabledReason: string | null): string | null => + createDisabledReason ?? senderDisabledReason, + ], + enableWorkflow: [ + (s) => [s.enableWorkflowChoice, s.enableDisabledReason], + (enableWorkflowChoice: boolean, enableDisabledReason: string | null): boolean => + enableWorkflowChoice && !enableDisabledReason, + ], + testSendOutcome: [ + (s) => [s.testSendResult, s.testSendFailed, s.user], + ( + testSendResult: HogflowTestResult | null, + testSendFailed: boolean, + user: UserType | null + ): TestSendOutcome | null => { + if (testSendFailed) { + return { kind: 'failed' } + } + if (!testSendResult) { + return null + } + const skipReason = findTestSendSkipReason(testSendResult) + if (skipReason) { + return { kind: 'skipped', reason: skipReason } + } + if (testSendResult.status === 'success') { + return { kind: 'sent', recipient: user?.email ?? '' } + } + if (testSendResult.status === 'skipped') { + return { + kind: 'skipped', + reason: + testSendResult.logs?.map((log) => log.message).join(' ') || + 'The email step was skipped, so nothing was sent.', + } + } + return { kind: 'failed' } + }, + ], + }), + listeners(({ actions, values }) => ({ + openEmail: async ({ emailId }, breakpoint) => { + await waitForPendingCanvasEdit(breakpoint) + actions.setOpenEmailId(emailId) + }, + createWorkflowSuccess: ({ createdWorkflow }) => { + if (!createdWorkflow) { + return + } + rememberFirstRunWorkflow(createdWorkflow.id) + if (createdWorkflow.status === 'active') { + globalSetupLogic.findMounted()?.actions.markTaskAsCompleted(SetupTaskId.LaunchWorkflow) + } + router.actions.push(urls.workflow(createdWorkflow.id, 'workflow')) + }, + sendTestSuccess: () => { + const outcome = values.testSendOutcome + if (outcome?.kind === 'sent' || outcome?.kind === 'skipped') { + posthog.capture('workflows first run test sent', { skipped: outcome.kind === 'skipped' }) + } + completeTestEmailSetupTask(values.testSendResult) + }, + })), +]) diff --git a/products/workflows/frontend/firstRun/firstRunSenderLogic.test.ts b/products/workflows/frontend/firstRun/firstRunSenderLogic.test.ts new file mode 100644 index 000000000000..4aa7e6c97a16 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunSenderLogic.test.ts @@ -0,0 +1,71 @@ +import { expectLogic } from 'kea-test-utils' + +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 { firstRunSenderLogic } from './firstRunSenderLogic' + +const OWN_SENDER = { + id: 5, + kind: 'email', + display_name: 'Sender ', + config: { verified: true, email: 'sender@example.com' }, +} satisfies Partial + +describe('firstRunSenderLogic', () => { + let logic: ReturnType + let integrations: Partial[] + + beforeEach(() => { + integrations = [] + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => [200, { results: integrations }], + }, + }) + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: true }) + }) + + afterEach(() => { + logic?.unmount() + }) + + it('uses the first verified own email sender from the existing integrations list', async () => { + integrations = [ + { id: 3, kind: 'slack', config: { verified: true } }, + { ...OWN_SENDER, id: 4, config: { verified: false } }, + { ...OWN_SENDER, id: 7, config: { provider: 'sandbox', verified: true } }, + OWN_SENDER, + { ...OWN_SENDER, id: 6 }, + ] + logic = firstRunSenderLogic() + logic.mount() + + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.firstRunSender).toEqual(expect.objectContaining(OWN_SENDER)) + + featureFlagLogic.actions.setFeatureFlags([], {}) + expect(logic.values.firstRunSender).toBeNull() + expect(logic.values.firstRunSenderLoading).toBe(false) + }) + + it.each([ + { initialIntegrations: [] }, + { initialIntegrations: [{ ...OWN_SENDER, config: { verified: false } }] }, + { initialIntegrations: [{ ...OWN_SENDER, config: { provider: 'sandbox', verified: true } }] }, + ])('requires a verified own email sender', async ({ initialIntegrations }) => { + integrations = initialIntegrations + logic = firstRunSenderLogic() + logic.mount() + + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.firstRunSender).toBeNull() + }) +}) diff --git a/products/workflows/frontend/firstRun/firstRunSenderLogic.ts b/products/workflows/frontend/firstRun/firstRunSenderLogic.ts new file mode 100644 index 000000000000..616c5238d311 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunSenderLogic.ts @@ -0,0 +1,66 @@ +import { MakeLogicType, connect, kea, path, selectors } from 'kea' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { isObject } from 'lib/utils/guards' + +import type { IntegrationConfigApi } from 'products/integrations/frontend/generated/api.schemas' + +import type { IntegrationType } from '../../../../frontend/src/types' + +export type FirstRunSender = Pick + +export interface firstRunSenderLogicValues { + featureFlags: FeatureFlagsSet + integrations: IntegrationType[] | null + integrationsLoading: boolean + firstRunEnabled: boolean + firstRunSender: FirstRunSender | null + firstRunSenderLoading: boolean +} + +export interface firstRunSenderLogicMeta { + __keaTypeGenInternalSelectorTypes: { + firstRunEnabled: (featureFlags: FeatureFlagsSet) => boolean + firstRunSender: (integrations: IntegrationType[] | null, firstRunEnabled: boolean) => FirstRunSender | null + firstRunSenderLoading: (integrationsLoading: boolean, firstRunEnabled: boolean) => boolean + } +} + +export type firstRunSenderLogicType = MakeLogicType< + firstRunSenderLogicValues, + Record, + Record, + firstRunSenderLogicMeta +> + +export const firstRunSenderLogic = kea([ + path(['products', 'workflows', 'frontend', 'firstRun', 'firstRunSenderLogic']), + connect({ + values: [featureFlagLogic, ['featureFlags'], integrationsLogic, ['integrations', 'integrationsLoading']], + }), + selectors({ + firstRunEnabled: [ + (s) => [s.featureFlags], + (featureFlags: FeatureFlagsSet): boolean => !!featureFlags[FEATURE_FLAGS.WORKFLOWS_FIRST_RUN], + ], + firstRunSender: [ + (s) => [s.integrations, s.firstRunEnabled], + (integrations: IntegrationType[] | null, firstRunEnabled: boolean): FirstRunSender | null => + firstRunEnabled + ? (integrations?.find( + (sender) => + sender.kind === 'email' && + isObject(sender.config) && + sender.config.provider !== 'sandbox' && + sender.config.verified === true + ) ?? null) + : null, + ], + firstRunSenderLoading: [ + (s) => [s.integrationsLoading, s.firstRunEnabled], + (loading: boolean, firstRunEnabled: boolean): boolean => firstRunEnabled && loading, + ], + }), +]) diff --git a/products/workflows/frontend/firstRun/firstRunStoryFixtures.ts b/products/workflows/frontend/firstRun/firstRunStoryFixtures.ts new file mode 100644 index 000000000000..abc9bcb42675 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunStoryFixtures.ts @@ -0,0 +1,64 @@ +import { mswDecorator } from '~/mocks/browser' +import { toPaginatedResponse } from '~/mocks/handlers' + +import type { IntegrationConfigApi } from 'products/integrations/frontend/generated/api.schemas' + +import announceANewFeature from '../../backend/templates/announce_a_new_feature_template.json' +import celebrateMilestone from '../../backend/templates/celebrate-milestone.json' +import featureAdoptionTips from '../../backend/templates/feature-adoption-tips.json' +import heavyUsageDetected from '../../backend/templates/heavy-usage-detected.json' +import highIntentNotification from '../../backend/templates/high-intent-notification.json' +import onboardingStarted from '../../backend/templates/onboarding_started_but_not_completed_template.json' +import reEngagement from '../../backend/templates/re-engagement-workflow.json' +import trialEndingReminder from '../../backend/templates/trial-ending-reminder.json' +import trialStartedUpgradeNudge from '../../backend/templates/trial_started_upgrade_nudge_template.json' +import unlockingAdvancedFeatures from '../../backend/templates/unlocking_advanced_features.json' +import unusedFeaturesEducation from '../../backend/templates/unused-features-education.json' +import welcomeEmailSequence from '../../backend/templates/welcome_email_sequence_template.json' +import type { HogFlowTemplateApi } from '../generated/api.schemas' + +// The global templates in the order the API lists them, plus one without an email step that the gallery leaves out. +// The serializer defaults `tags` to an empty list, which some template files leave out. +export const globalTemplates = ( + [ + announceANewFeature, + onboardingStarted, + trialStartedUpgradeNudge, + welcomeEmailSequence, + celebrateMilestone, + featureAdoptionTips, + heavyUsageDetected, + highIntentNotification, + reEngagement, + trialEndingReminder, + unlockingAdvancedFeatures, + unusedFeaturesEducation, + ] as unknown as HogFlowTemplateApi[] +).map((template) => ({ ...template, tags: template.tags ?? [] })) + +export const WELCOME_SEQUENCE_ID = welcomeEmailSequence.id +export const TRIAL_UPGRADE_NUDGE_ID = trialStartedUpgradeNudge.id +export const RE_ENGAGEMENT_ID = reEngagement.id + +export const OWN_SENDER: Partial = { + id: 7, + kind: 'email', + display_name: 'Example ', + config: { verified: true, email: 'sender@example.com', name: 'Example' }, + created_at: '2026-10-01T00:00:00Z', +} + +export function projectThatSends(seenEvents: string[]): Parameters[0] { + return { + get: { + '/api/projects/:team_id/event_definitions/': ({ request }) => { + const names = new URL(request.url).searchParams.get('names')?.split(',') ?? [] + const seen = names.filter((name) => seenEvents.includes(name)) + return [ + 200, + toPaginatedResponse(seen.map((name) => ({ id: name, name, last_seen_at: '2026-10-01T00:00:00Z' }))), + ] + }, + }, + } +} diff --git a/products/workflows/frontend/firstRun/firstRunWelcomeHoldLogic.ts b/products/workflows/frontend/firstRun/firstRunWelcomeHoldLogic.ts new file mode 100644 index 000000000000..76f1f7979a00 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunWelcomeHoldLogic.ts @@ -0,0 +1,73 @@ +import { MakeLogicType, afterMount, beforeUnmount, connect, kea, listeners, path, props, propsChanged } from 'kea' + +import { navPanelProductPushWelcomeLogic } from 'lib/components/NavPanelAdvertisement/navPanelProductPushWelcomeLogic' +import type { ProductSetupStatus } from 'lib/components/ProductEmptyState/types' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import type { FeatureFlagsSet } from '../../../../frontend/src/lib/logic/featureFlagLogic' + +const WELCOME_HOLDER = 'workflows-first-run' + +export interface FirstRunWelcomeHoldLogicProps { + setupStatus: ProductSetupStatus +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunWelcomeHoldLogicValues { + featureFlags: FeatureFlagsSet // featureFlagLogic +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunWelcomeHoldLogicActions { + setFeatureFlags: ( + flags: string[], + variants: Record + ) => { + flags: string[] + variants: Record + } // featureFlagLogic + holdWelcome: (holder: string) => { + holder: string + } // navPanelProductPushWelcomeLogic + releaseWelcome: (holder: string) => { + holder: string + } // navPanelProductPushWelcomeLogic +} + +export type firstRunWelcomeHoldLogicType = MakeLogicType< + firstRunWelcomeHoldLogicValues, + firstRunWelcomeHoldLogicActions, + FirstRunWelcomeHoldLogicProps +> + +function updateWelcomeHold({ actions, props, values }: firstRunWelcomeHoldLogicType): void { + if ( + values.featureFlags[FEATURE_FLAGS.WORKFLOWS_FIRST_RUN] && + (props.setupStatus === 'loading' || props.setupStatus === 'needs-setup') + ) { + actions.holdWelcome(WELCOME_HOLDER) + } else { + actions.releaseWelcome(WELCOME_HOLDER) + } +} + +export const firstRunWelcomeHoldLogic = kea([ + props({} as FirstRunWelcomeHoldLogicProps), + path(['products', 'workflows', 'frontend', 'firstRun', 'firstRunWelcomeHoldLogic']), + connect(() => ({ + values: [featureFlagLogic, ['featureFlags']], + actions: [ + navPanelProductPushWelcomeLogic, + ['holdWelcome', 'releaseWelcome'], + featureFlagLogic, + ['setFeatureFlags'], + ], + })), + listeners((logic) => ({ setFeatureFlags: () => updateWelcomeHold(logic) })), + afterMount(updateWelcomeHold), + propsChanged(updateWelcomeHold), + beforeUnmount(({ actions }) => { + actions.releaseWelcome(WELCOME_HOLDER) + }), +]) diff --git a/products/workflows/frontend/firstRun/firstRunWorkflowBannerLogic.test.ts b/products/workflows/frontend/firstRun/firstRunWorkflowBannerLogic.test.ts new file mode 100644 index 000000000000..3a501d54ddde --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunWorkflowBannerLogic.test.ts @@ -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 + + function mountFor(id: string): ReturnType { + 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 + 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') + ) + }) +}) diff --git a/products/workflows/frontend/firstRun/firstRunWorkflowBannerLogic.ts b/products/workflows/frontend/firstRun/firstRunWorkflowBannerLogic.ts new file mode 100644 index 000000000000..56da231dfa20 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunWorkflowBannerLogic.ts @@ -0,0 +1,118 @@ +import { MakeLogicType, actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { router } from 'kea-router' +import { subscriptions } from 'kea-subscriptions' + +import { globalSetupLogic } from 'lib/components/ProductSetup' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic, FeatureFlagsSet } from 'lib/logic/featureFlagLogic' +import { urls } from 'scenes/urls' + +import type { HogFlow } from '../Workflows/hogflows/types' +import { WorkflowLogicProps, workflowLogic } from '../Workflows/workflowLogic' +import { getFirstRunWorkflowId } from './firstRunWorkflowStorage' + +export type FirstRunBannerState = 'draft' | 'sending' + +const ENABLE_BUTTON_SELECTOR = '[data-attr="workflow-launch"]' + +function bannerForStatus(status: HogFlow['status']): FirstRunBannerState | null { + if (status === 'draft') { + return 'draft' + } + return status === 'active' ? 'sending' : null +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunWorkflowBannerLogicValues { + featureFlags: FeatureFlagsSet // featureFlagLogic + originalWorkflow: HogFlow | null // workflowLogic + banner: FirstRunBannerState | null + dismissed: boolean + firstRunWorkflowId: string | null +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunWorkflowBannerLogicActions { + setHighlight: ( + selector: string, + pathname: string + ) => { + pathname: string + selector: string + } // globalSetupLogic + dismiss: () => { + value: true + } + viewMetrics: () => { + value: true + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunWorkflowBannerLogicMeta { + key: string + __keaTypeGenInternalSelectorTypes: { + banner: ( + originalWorkflow: HogFlow | null, + firstRunWorkflowId: string | null, + featureFlags: FeatureFlagsSet, + dismissed: boolean + ) => FirstRunBannerState | null + } +} + +export type firstRunWorkflowBannerLogicType = MakeLogicType< + firstRunWorkflowBannerLogicValues, + firstRunWorkflowBannerLogicActions, + WorkflowLogicProps, + firstRunWorkflowBannerLogicMeta +> + +export const firstRunWorkflowBannerLogic = kea([ + props({} as WorkflowLogicProps), + key((props): string => props.id ?? 'new'), + path((key) => ['products', 'workflows', 'frontend', 'firstRun', 'firstRunWorkflowBannerLogic', key]), + connect((props: WorkflowLogicProps) => ({ + values: [workflowLogic(props), ['originalWorkflow'], featureFlagLogic, ['featureFlags']], + actions: [globalSetupLogic, ['setHighlight']], + })), + actions({ + dismiss: true, + viewMetrics: true, + }), + reducers(() => ({ + firstRunWorkflowId: [getFirstRunWorkflowId(), {}], + dismissed: [false, { persist: true }, { dismiss: () => true }], + })), + selectors({ + banner: [ + (s) => [s.originalWorkflow, s.firstRunWorkflowId, s.featureFlags, s.dismissed], + ( + originalWorkflow: HogFlow | null, + firstRunWorkflowId: string | null, + featureFlags: FeatureFlagsSet, + dismissed: boolean + ): FirstRunBannerState | null => { + if (!featureFlags[FEATURE_FLAGS.WORKFLOWS_FIRST_RUN] || !originalWorkflow || dismissed) { + return null + } + if (!firstRunWorkflowId || originalWorkflow.id !== firstRunWorkflowId) { + return null + } + return bannerForStatus(originalWorkflow.status) + }, + ], + }), + listeners(({ props }) => ({ + viewMetrics: () => { + router.actions.push(urls.workflow(props.id ?? 'new', 'metrics')) + }, + })), + subscriptions(({ actions }) => ({ + banner: (banner: FirstRunBannerState | null, previousBanner: FirstRunBannerState | null | undefined) => { + if (banner === 'draft' && previousBanner !== 'draft') { + actions.setHighlight(ENABLE_BUTTON_SELECTOR, router.values.location.pathname) + } + }, + })), +]) diff --git a/products/workflows/frontend/firstRun/firstRunWorkflowStorage.ts b/products/workflows/frontend/firstRun/firstRunWorkflowStorage.ts new file mode 100644 index 000000000000..760a6344c857 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunWorkflowStorage.ts @@ -0,0 +1,18 @@ +// pinned: storage key, a rename would forget which workflow the first run created +const FIRST_RUN_WORKFLOW_KEY = 'workflows-first-run-workflow-id' + +export function rememberFirstRunWorkflow(workflowId: string): void { + try { + window.localStorage.setItem(FIRST_RUN_WORKFLOW_KEY, workflowId) + } catch { + // Storage can be full or blocked; the first run still works without the memory. + } +} + +export function getFirstRunWorkflowId(): string | null { + try { + return window.localStorage.getItem(FIRST_RUN_WORKFLOW_KEY) + } catch { + return null + } +} diff --git a/products/workflows/frontend/firstRun/templateEmails.ts b/products/workflows/frontend/firstRun/templateEmails.ts new file mode 100644 index 000000000000..60b49427ce50 --- /dev/null +++ b/products/workflows/frontend/firstRun/templateEmails.ts @@ -0,0 +1,70 @@ +import type { EmailTemplate } from 'scenes/hog-functions/email-templater/types' + +import { DurationUnit, parseDuration } from '../Workflows/hogflows/steps/durations' +import { getDurationText } from '../Workflows/hogflows/steps/stepDelayLogic' +import { isEmailAction } from '../Workflows/hogflows/steps/types' +import type { HogFlowAction, HogFlowEdge } from '../Workflows/hogflows/types' +import { getOrderedActions } from '../Workflows/templates/workflowTemplateDisplay' + +export interface TemplateEmail { + id: string + subject: string + timing: string + email: EmailTemplate +} + +const SECONDS_PER_UNIT: Record = { d: 86400, h: 3600, m: 60, s: 1 } + +function durationSeconds(duration: unknown): number { + const parsed = typeof duration === 'string' ? parseDuration(duration) : null + return parsed ? parsed.amount * SECONDS_PER_UNIT[parsed.unit] : 0 +} + +function describeTiming(seconds: number): string { + if (seconds <= 0) { + return 'Right away' + } + const unit = + (['d', 'h', 'm'] as DurationUnit[]).find((candidate) => seconds % SECONDS_PER_UNIT[candidate] === 0) ?? 's' + return `After ${getDurationText(`${seconds / SECONDS_PER_UNIT[unit]}${unit}`)}` +} + +// Seconds between the trigger and each step, summed over the delays on the first path that reaches it. +function delaysFromTrigger(actions: HogFlowAction[], edges: HogFlowEdge[]): Map { + const actionsById = new Map(actions.map((action) => [action.id, action])) + const trigger = actions.find((action) => action.type === 'trigger') + const delays = new Map() + if (!trigger) { + return delays + } + delays.set(trigger.id, 0) + const queue = [trigger.id] + for (let cursor = 0; cursor < queue.length; cursor++) { + const from = queue[cursor] + for (const edge of edges.filter((edge) => edge.from === from)) { + if (delays.has(edge.to)) { + continue + } + const target = actionsById.get(edge.to) + const ownDelay = target?.type === 'delay' ? durationSeconds(target.config.delay_duration) : 0 + delays.set(edge.to, (delays.get(from) ?? 0) + ownDelay) + queue.push(edge.to) + } + } + return delays +} + +export function listTemplateEmails(actions: HogFlowAction[], edges: HogFlowEdge[]): TemplateEmail[] { + const delays = delaysFromTrigger(actions, edges) + return getOrderedActions(actions, edges) + .filter(isEmailAction) + .map((action) => { + const email = action.config.inputs.email?.value as EmailTemplate + return { + id: action.id, + subject: email?.subject ?? '', + timing: describeTiming(delays.get(action.id) ?? 0), + email, + } + }) +} diff --git a/products/workflows/frontend/firstRun/withFirstRunSender.test.ts b/products/workflows/frontend/firstRun/withFirstRunSender.test.ts new file mode 100644 index 000000000000..97596e703514 --- /dev/null +++ b/products/workflows/frontend/firstRun/withFirstRunSender.test.ts @@ -0,0 +1,42 @@ +import welcomeSequence from '../../backend/templates/welcome_email_sequence_template.json' +import { HogFlowActionSchema } from '../Workflows/hogflows/steps/types' +import { withFirstRunSender } from './withFirstRunSender' + +describe('withFirstRunSender', () => { + it.each([false, true])( + 'assigns every email sender without changing other data (empty inputs: %s)', + (emptyInputs) => { + const actions = HogFlowActionSchema.array().parse(welcomeSequence.actions) + if (emptyInputs) { + actions.forEach((action) => { + if (action.type === 'function_email') { + action.config.inputs = {} + } + }) + } + const original = structuredClone(actions) + + const transformed = withFirstRunSender(actions, { id: 42 }) + const emails = transformed.filter((action) => action.type === 'function_email') + expect(emails).toHaveLength(2) + expect(emails.map((action) => action.config.inputs.email.value.from)).toEqual([ + { integrationId: 42 }, + { integrationId: 42 }, + ]) + transformed.forEach((action, index) => { + const originalAction = original[index] + if (action.type === 'function_email' && originalAction.type === 'function_email') { + const { from: originalFrom, ...originalEmail } = originalAction.config.inputs.email?.value ?? {} + const { from: transformedFrom, ...transformedEmail } = action.config.inputs.email.value + expect(transformedEmail).toEqual(originalEmail) + expect(action.config.inputs.email.templating).toEqual( + originalAction.config.inputs.email?.templating + ) + } else { + expect(action).toBe(actions[index]) + } + }) + expect(actions).toEqual(original) + } + ) +}) diff --git a/products/workflows/frontend/firstRun/withFirstRunSender.ts b/products/workflows/frontend/firstRun/withFirstRunSender.ts new file mode 100644 index 000000000000..fa0d0e2e8870 --- /dev/null +++ b/products/workflows/frontend/firstRun/withFirstRunSender.ts @@ -0,0 +1,25 @@ +import type { HogFlowAction } from '../Workflows/hogflows/types' +import type { FirstRunSender } from './firstRunSenderLogic' + +export function withFirstRunSender(actions: HogFlowAction[], sender: Pick): HogFlowAction[] { + return actions.map((action) => + action.type === 'function_email' + ? { + ...action, + config: { + ...action.config, + inputs: { + ...action.config.inputs, + email: { + ...action.config.inputs.email, + value: { + ...action.config.inputs.email?.value, + from: { integrationId: sender.id }, + }, + }, + }, + }, + } + : action + ) +} diff --git a/products/workflows/frontend/generated/api.schemas.ts b/products/workflows/frontend/generated/api.schemas.ts index 938975ebfb25..c10ecb60c45c 100644 --- a/products/workflows/frontend/generated/api.schemas.ts +++ b/products/workflows/frontend/generated/api.schemas.ts @@ -7,6 +7,33 @@ * PostHog API - generated * OpenAPI spec version: 1.0.0 */ +/** + * * `event` - Event + * * `no_event` - No event + * * `schedule` - Schedule + */ +export type HogFlowTemplateStartsOnSerializerKindEnumApi = + (typeof HogFlowTemplateStartsOnSerializerKindEnumApi)[keyof typeof HogFlowTemplateStartsOnSerializerKindEnumApi] + +export const HogFlowTemplateStartsOnSerializerKindEnumApi = { + Event: 'event', + NoEvent: 'no_event', + Schedule: 'schedule', +} as const + +export interface HogFlowTemplateStartsOnApi { + /** Whether the template starts on an event, an absence of events, or a schedule. + * + * * `event` - Event + * * `no_event` - No event + * * `schedule` - Schedule */ + kind: HogFlowTemplateStartsOnSerializerKindEnumApi + /** Event names that can drive the template, in order of preference. Empty for schedules. */ + events: string[] + /** Short qualifier shown with the template's trigger. */ + detail: string +} + /** * * `team` - Only team * * `organization` - Organization @@ -159,6 +186,8 @@ export interface HogFlowTemplateApi { image_url?: string | null /** Tags for filtering templates. */ tags?: string[] + /** What starts a global email template. Null for other templates. */ + readonly starts_on: HogFlowTemplateStartsOnApi | null /** Who can use the template: this project only, or every project in the organization. * * * `team` - Only team @@ -236,6 +265,8 @@ export interface PatchedHogFlowTemplateApi { image_url?: string | null /** Tags for filtering templates. */ tags?: string[] + /** What starts a global email template. Null for other templates. */ + readonly starts_on?: HogFlowTemplateStartsOnApi | null /** Who can use the template: this project only, or every project in the organization. * * * `team` - Only team diff --git a/products/workflows/frontend/testEmailSetupTask.test.ts b/products/workflows/frontend/testEmailSetupTask.test.ts new file mode 100644 index 000000000000..ccfd229b1e9a --- /dev/null +++ b/products/workflows/frontend/testEmailSetupTask.test.ts @@ -0,0 +1,173 @@ +import { MOCK_DEFAULT_TEAM } from 'lib/api.mock' + +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 { teamLogic } from 'scenes/teamLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { ActivationTaskStatus, TeamType } from '~/types' + +import { broadcastTestSendLogic } from './Broadcasts/broadcastTestSendLogic' +import { messageTemplateLogic } from './TemplateLibrary/messageTemplateLogic' +import { messageTemplateTestSendLogic } from './TemplateLibrary/messageTemplateTestSendLogic' +import { hogFlowEditorNotificationTestLogic } from './Workflows/hogflows/panel/testing/hogFlowEditorNotificationTestLogic' +import type { HogFlowAction } from './Workflows/hogflows/types' +import { NEW_WORKFLOW, workflowLogic } from './Workflows/workflowLogic' + +const EMAIL_ACTION: HogFlowAction = { + id: 'email_node', + type: 'function_email', + name: 'Welcome email', + description: '', + created_at: 0, + updated_at: 0, + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + from: { integrationId: 5 }, + to: { email: 'tester@example.com', name: '' }, + subject: 'Welcome', + text: 'Hello', + }, + }, + }, + }, +} + +async function sendTestEmail( + surface: 'template' | 'broadcast' | 'email step', + { mocked = false, actionId = 'email_node' }: { mocked?: boolean; actionId?: string } = {} +): Promise { + if (surface === 'email step') { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toFinishAllListeners() + workflow.actions.setWorkflowValues({ actions: [...NEW_WORKFLOW.actions, EMAIL_ACTION] }) + const logic = hogFlowEditorNotificationTestLogic({ id: 'new' }) + logic.mount() + await expectLogic(logic).toFinishAllListeners() + logic.actions.setSelectedNodeId(actionId) + logic.actions.setTestInvocationValues({ + globals: JSON.stringify({ person: { properties: { email: 'tester@example.com' } } }), + mock_async_functions: mocked, + }) + await expectLogic(logic, () => logic.actions.submitTestInvocation()).toFinishAllListeners() + return + } + if (surface === 'template') { + const template = messageTemplateLogic({ id: 'new' }) + template.mount() + template.actions.setTemplateValue('content.email.subject', 'Welcome') + template.actions.setTemplateValue('content.email.text', 'Hello') + } + const logic = + surface === 'template' ? messageTemplateTestSendLogic({ id: 'new' }) : broadcastTestSendLogic({ id: 'new' }) + logic.mount() + logic.actions.setRecipientEmail('tester@example.com') + await expectLogic(logic, () => logic.actions.sendTestEmail()).toFinishAllListeners() +} + +describe('test email quick start completion', () => { + let persistedTasks: Record + let invocationCount: number + beforeEach(() => { + localStorage.clear() + persistedTasks = {} + invocationCount = 0 + useMocks({ + get: { + '/api/projects/:team_id/integrations/': { results: [] }, + '/api/environments/:team_id/persons/': { results: [] }, + }, + patch: { + '/api/environments/:team_id/': async ({ request }) => { + const body = (await request.json()) as Pick + persistedTasks = body.onboarding_tasks ?? {} + return [200, { ...MOCK_DEFAULT_TEAM, ...body }] + }, + }, + }) + initKeaTests() + globalSetupLogic.mount() + }) + + describe.each(['template', 'broadcast', 'email step'] as const)('%s', (surface) => { + it.each([ + { outcome: 'delivered', status: 'success', logs: [], httpStatus: 200, completed: true }, + { + outcome: 'delivered with first run disabled', + status: 'success', + logs: [], + httpStatus: 200, + completed: false, + firstRun: false, + }, + { outcome: 'skipped', status: 'skipped', logs: [], httpStatus: 200, completed: false }, + { + outcome: 'declined by the email worker', + status: 'success', + logs: [ + { + level: 'info', + message: 'Skipping send: recipient has opted out.', + timestamp: '2026-01-01T00:00:00Z', + }, + ], + httpStatus: 200, + completed: false, + }, + { outcome: 'failed', status: 'error', logs: [], httpStatus: 200, completed: false }, + { outcome: 'rejected by the API', status: 'error', logs: [], httpStatus: 500, completed: false }, + ])( + 'completes the quick start task only when the test email is delivered: $outcome', + async ({ status, logs, httpStatus, completed, firstRun = true }) => { + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_FIRST_RUN]: firstRun }) + useMocks({ + post: { + '/api/environments/:team_id/hog_flows/:id/invocations/': () => { + invocationCount++ + return [httpStatus, { status, nextActionId: null, logs }] + }, + }, + }) + + await sendTestEmail(surface) + await expectLogic(teamLogic).toFinishAllListeners() + + expect(invocationCount).toBe(1) + expect(globalSetupLogic.values.optimisticTaskStatuses[SetupTaskId.SendWorkflowTestEmail]).toBe( + completed ? ActivationTaskStatus.COMPLETED : undefined + ) + expect(persistedTasks[SetupTaskId.SendWorkflowTestEmail]).toBe( + completed ? ActivationTaskStatus.COMPLETED : undefined + ) + } + ) + }) + + it.each([ + { run: 'a mocked email', mocked: true, actionId: 'email_node' }, + { run: 'a non-email step', mocked: false, actionId: 'exit_node' }, + ])('leaves the task incomplete after $run succeeds', async ({ mocked, actionId }) => { + useMocks({ + post: { + '/api/environments/:team_id/hog_flows/:id/invocations/': () => { + invocationCount++ + return [200, { status: 'success', nextActionId: null, logs: [] }] + }, + }, + }) + + await sendTestEmail('email step', { mocked, actionId }) + + expect(invocationCount).toBe(1) + expect(globalSetupLogic.values.optimisticTaskStatuses[SetupTaskId.SendWorkflowTestEmail]).toBeUndefined() + expect(persistedTasks[SetupTaskId.SendWorkflowTestEmail]).toBeUndefined() + }) +}) diff --git a/products/workflows/package.json b/products/workflows/package.json index e250226db894..0479d19fc474 100644 --- a/products/workflows/package.json +++ b/products/workflows/package.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@storybook/react": "catalog:", + "@testing-library/dom": "*", "@testing-library/react": "*", "@types/papaparse": "*", "kea-test-utils": "catalog:" diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 90efd12b44fa..4226ef3a2a88 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -14166,6 +14166,8 @@ export namespace Schemas { * * `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 @@ -14237,6 +14239,8 @@ export namespace Schemas { 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', @@ -53528,6 +53532,33 @@ export namespace Schemas { */ export type HogFlowTemplateVariablesItem = {[key: string]: string}; + /** + * * `event` - Event + * * `no_event` - No event + * * `schedule` - Schedule + */ + export type HogFlowTemplateStartsOnSerializerKindEnum = typeof HogFlowTemplateStartsOnSerializerKindEnum[keyof typeof HogFlowTemplateStartsOnSerializerKindEnum]; + + + export const HogFlowTemplateStartsOnSerializerKindEnum = { + Event: 'event', + NoEvent: 'no_event', + Schedule: 'schedule', + } as const; + + export interface HogFlowTemplateStartsOn { + /** Whether the template starts on an event, an absence of events, or a schedule. + * + * * `event` - Event + * * `no_event` - No event + * * `schedule` - Schedule */ + kind: HogFlowTemplateStartsOnSerializerKindEnum; + /** Event names that can drive the template, in order of preference. Empty for schedules. */ + events: string[]; + /** Short qualifier shown with the template's trigger. */ + detail: string; + } + /** * * `team` - Only team * * `organization` - Organization @@ -53587,6 +53618,8 @@ export namespace Schemas { image_url?: string | null; /** Tags for filtering templates. */ tags?: string[]; + /** What starts a global email template. Null for other templates. */ + readonly starts_on: HogFlowTemplateStartsOn | null; /** Who can use the template: this project only, or every project in the organization. * * * `team` - Only team @@ -78334,6 +78367,8 @@ export namespace Schemas { image_url?: string | null; /** Tags for filtering templates. */ tags?: string[]; + /** What starts a global email template. Null for other templates. */ + readonly starts_on?: HogFlowTemplateStartsOn | null; /** Who can use the template: this project only, or every project in the organization. * * * `team` - Only team