From 997b463ad23dea9eba5654bbb4dc43bb50848b82 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:50:25 +0000 Subject: [PATCH 1/4] feat(workflows): ask before enabling a workflow whose email domain is not verified Enable, in the editor and in the list, now names the unverified domains and says the workflow turns on now and starts sending once they verify. Confirming saves the usual active status, which the server records as waiting. Once a waited workflow goes live, its page shows a one-time "Started sending" notice. Behind workflows-email-domain-wizard. Ported from PostHog/posthog#123660. Refs Silthus/personal#196. Co-Authored-By: Claude Opus 5.5 --- .../EnableWorkflowPrompt.stories.tsx | 23 +++ .../Workflows/EnableWorkflowPrompt.tsx | 53 ++++++ .../frontend/Workflows/WorkflowActions.tsx | 14 +- .../frontend/Workflows/WorkflowScene.tsx | 2 + .../Workflows/WorkflowSceneHeader.tsx | 27 ++- .../WorkflowStartedSendingNotice.tsx | 27 +++ .../frontend/Workflows/WorkflowsTable.tsx | 11 ++ .../Workflows/unverifiedSenderDomains.ts | 7 + .../workflowLogic.sendOnceVerified.test.ts | 175 +++++++++++++++++- .../frontend/Workflows/workflowLogic.ts | 122 +++++++++++- .../frontend/Workflows/workflowsLogic.test.ts | 155 ++++++++++++++-- .../frontend/Workflows/workflowsLogic.ts | 94 +++++++++- 12 files changed, 679 insertions(+), 31 deletions(-) create mode 100644 products/workflows/frontend/Workflows/EnableWorkflowPrompt.stories.tsx create mode 100644 products/workflows/frontend/Workflows/EnableWorkflowPrompt.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx create mode 100644 products/workflows/frontend/Workflows/unverifiedSenderDomains.ts diff --git a/products/workflows/frontend/Workflows/EnableWorkflowPrompt.stories.tsx b/products/workflows/frontend/Workflows/EnableWorkflowPrompt.stories.tsx new file mode 100644 index 000000000000..5decdf9f39aa --- /dev/null +++ b/products/workflows/frontend/Workflows/EnableWorkflowPrompt.stories.tsx @@ -0,0 +1,23 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { EnableWorkflowPrompt } from './EnableWorkflowPrompt' + +const meta: Meta = { + title: 'Products/Workflows/Enable workflow prompt', + component: EnableWorkflowPrompt, + args: { + onConfirm: () => {}, + onCancel: () => {}, + }, + parameters: { + layout: 'fullscreen', + testOptions: { waitForSelector: '[data-attr="workflow-enable-prompt-confirm"]' }, + }, +} +export default meta + +type Story = StoryObj + +export const OneUnverifiedDomain: Story = { args: { domains: ['example.com'] } } + +export const SeveralUnverifiedDomains: Story = { args: { domains: ['example.com', 'mail.example.org'] } } diff --git a/products/workflows/frontend/Workflows/EnableWorkflowPrompt.tsx b/products/workflows/frontend/Workflows/EnableWorkflowPrompt.tsx new file mode 100644 index 000000000000..dda070775931 --- /dev/null +++ b/products/workflows/frontend/Workflows/EnableWorkflowPrompt.tsx @@ -0,0 +1,53 @@ +import { LemonButton, LemonModal } from '@posthog/lemon-ui' + +const domainList = new Intl.ListFormat('en', { type: 'conjunction' }) + +export interface EnableWorkflowPromptProps { + domains: string[] | null + onConfirm: () => void + onCancel: () => void + loading?: boolean +} + +export function EnableWorkflowPrompt({ + domains, + onConfirm, + onCancel, + loading, +}: EnableWorkflowPromptProps): JSX.Element { + const several = (domains?.length ?? 0) > 1 + return ( + + + Cancel + + + Enable + + + } + > +

+ {domainList.format(domains ?? [])}{' '} + {several ? "aren't verified yet" : "isn't verified yet"}. The workflow turns on now and starts sending + once {several ? 'these domains are' : 'the domain is'} verified. +

+
+ ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowActions.tsx b/products/workflows/frontend/Workflows/WorkflowActions.tsx index 4816f4e88e66..c51a4bb39389 100644 --- a/products/workflows/frontend/Workflows/WorkflowActions.tsx +++ b/products/workflows/frontend/Workflows/WorkflowActions.tsx @@ -33,7 +33,7 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { showDraftActions, statusToggleDisabledReason, } = useValues(workflowLogic) - const { saveWorkflowPartial, submitWorkflow, discardChanges, publishDraft, discardDraft } = + const { saveWorkflowPartial, enableWorkflow, submitWorkflow, discardChanges, publishDraft, discardDraft } = useActions(workflowLogic) const { searchParams } = useValues(router) const editTemplateId = searchParams.editTemplateId as string | undefined @@ -82,9 +82,9 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { // reveals the per-field messages, and saveWorkflowPartial // guards the actual enable with a toast. onClick={() => - saveWorkflowPartial({ - status: workflow?.status === 'draft' ? 'active' : 'draft', - }) + workflow?.status === 'draft' + ? enableWorkflow() + : saveWorkflowPartial({ status: 'draft' }) } disabledReason={statusToggleDisabledReason} className="transition-colors duration-300 ease-in-out" @@ -105,9 +105,9 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { // reveals the per-field messages, and saveWorkflowPartial // guards the actual enable with a toast. onClick={() => - saveWorkflowPartial({ - status: workflow?.status === 'draft' ? 'active' : 'draft', - }) + workflow?.status === 'draft' + ? enableWorkflow() + : saveWorkflowPartial({ status: 'draft' }) } size="small" disabledReason={statusToggleDisabledReason} diff --git a/products/workflows/frontend/Workflows/WorkflowScene.tsx b/products/workflows/frontend/Workflows/WorkflowScene.tsx index 1bc06a9410ab..6dffbe7319d5 100644 --- a/products/workflows/frontend/Workflows/WorkflowScene.tsx +++ b/products/workflows/frontend/Workflows/WorkflowScene.tsx @@ -53,6 +53,7 @@ import { WorkflowMetrics } from './WorkflowMetrics' import { WorkflowRevisions } from './WorkflowRevisions' import { WorkflowSceneHeader } from './WorkflowSceneHeader' import { WorkflowSceneLogicProps, WorkflowTab, workflowSceneLogic } from './workflowSceneLogic' +import { WorkflowStartedSendingNotice } from './WorkflowStartedSendingNotice' import { TRIGGER_PREFILL_PARAM } from './workflowTriggerPrefill' export const scene: SceneExport = { @@ -254,6 +255,7 @@ export function WorkflowScene(props: WorkflowSceneLogicProps): JSX.Element { {isSavedWorkflow && } + {!isSavedWorkflow && } {suggestionsAvailable && isSavedWorkflow && } {/* Only show Logs and Metrics tabs if the workflow has already been created */} diff --git a/products/workflows/frontend/Workflows/WorkflowSceneHeader.tsx b/products/workflows/frontend/Workflows/WorkflowSceneHeader.tsx index 4f4e6f05abe7..916f6280283a 100644 --- a/products/workflows/frontend/Workflows/WorkflowSceneHeader.tsx +++ b/products/workflows/frontend/Workflows/WorkflowSceneHeader.tsx @@ -20,6 +20,7 @@ import { import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection' import { AccessControlLevel, AccessControlResourceType } from '~/types' +import { EnableWorkflowPrompt } from './EnableWorkflowPrompt' import { workflowSuggestionsAvailabilityLogic } from './suggestions/workflowSuggestionsAvailabilityLogic' import { WorkflowSuggestionsMenuItem } from './suggestions/WorkflowSuggestionsMenuItem' import { SaveAsTemplateModal } from './templates/SaveAsTemplateModal' @@ -32,9 +33,23 @@ import { WorkflowSceneLogicProps } from './workflowSceneLogic' import { WorkflowScenePanel } from './WorkflowScenePanel' export const WorkflowSceneHeader = (props: WorkflowSceneLogicProps = {}): JSX.Element => { - const { workflow, hasUnsavedChanges, hasStagedDraft, workflowLoading, workflowUserAccessLevel } = - useValues(workflowLogic) - const { setWorkflowValue, duplicate, archiveWorkflow, publishDraft, discardDraft } = useActions(workflowLogic) + const { + workflow, + hasUnsavedChanges, + hasStagedDraft, + workflowLoading, + workflowUserAccessLevel, + enablePromptDomains, + } = useValues(workflowLogic) + const { + setWorkflowValue, + duplicate, + archiveWorkflow, + publishDraft, + discardDraft, + confirmEnablePrompt, + cancelEnablePrompt, + } = useActions(workflowLogic) const { searchParams } = useValues(router) const editTemplateId = searchParams.editTemplateId as string | undefined const { showSaveAsTemplateModal } = useActions(workflowTemplateLogic({ ...props, editTemplateId })) @@ -48,6 +63,12 @@ export const WorkflowSceneHeader = (props: WorkflowSceneLogicProps = {}): JSX.El return ( <> + {sceneMenuBarEnabled && isSavedWorkflow && ( diff --git a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx new file mode 100644 index 000000000000..f17bbef5f002 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx @@ -0,0 +1,27 @@ +import { useActions, useValues } from 'kea' + +import { LemonBanner } from '@posthog/lemon-ui' + +import { workflowLogic } from './workflowLogic' + +export function WorkflowStartedSendingNotice(): JSX.Element | null { + const { showStartedSendingNotice } = useValues(workflowLogic) + const { dismissStartedSendingNotice } = useActions(workflowLogic) + + if (!showStartedSendingNotice) { + return null + } + + return ( + + Started sending. This workflow was waiting for its email domain and is live now. + + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowsTable.tsx b/products/workflows/frontend/Workflows/WorkflowsTable.tsx index 6ba2fab5d0ff..9888e151c2a0 100644 --- a/products/workflows/frontend/Workflows/WorkflowsTable.tsx +++ b/products/workflows/frontend/Workflows/WorkflowsTable.tsx @@ -34,6 +34,7 @@ import { AccessControlLevel, AccessControlResourceType } from '~/types' import { AutomationEmptyState } from '../setupGuide/AutomationEmptyState' import { AutomationSuggestionBanner } from '../setupGuide/AutomationSuggestionBanner' import { MessagingSetupReminderBanner } from '../setupGuide/MessagingSetupReminderBanner' +import { EnableWorkflowPrompt } from './EnableWorkflowPrompt' import { getHogFlowStep } from './hogflows/steps/HogFlowSteps' import { HogFlow } from './hogflows/types' import { workflowSuggestionsAvailabilityLogic } from './suggestions/workflowSuggestionsAvailabilityLogic' @@ -137,6 +138,8 @@ export function WorkflowsTable(): JSX.Element { selectedArchivedCount, localWorkflowTags, tags: allTags, + enablePrompt, + enablePromptSaving, } = useValues(logic) // Only an unfiltered Automations tab is truly empty. With a search or filter, the plain message fits. const showAutomationEmptyState = @@ -162,6 +165,8 @@ export function WorkflowsTable(): JSX.Element { updateWorkflowTags, setTagsFilter, loadTagsIfNeeded, + confirmEnablePrompt, + cancelEnablePrompt, } = useActions(logic) useOnMountEffect(() => { @@ -546,6 +551,12 @@ export function WorkflowsTable(): JSX.Element { emptyState={showAutomationEmptyState ? : 'No workflows matching filters'} /> + ) diff --git a/products/workflows/frontend/Workflows/unverifiedSenderDomains.ts b/products/workflows/frontend/Workflows/unverifiedSenderDomains.ts new file mode 100644 index 000000000000..c55f73d715db --- /dev/null +++ b/products/workflows/frontend/Workflows/unverifiedSenderDomains.ts @@ -0,0 +1,7 @@ +import type { HogFlowApi } from '../generated/api.schemas' + +type WithUnverifiedSenders = Partial> + +export function unverifiedSenderDomains(workflow: WithUnverifiedSenders | null | undefined): string[] { + return [...new Set((workflow?.unverified_email_senders ?? []).map((sender) => sender.domain))] +} diff --git a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts index 077f7efc172e..74552631d503 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts @@ -1,5 +1,8 @@ import { expectLogic } from 'kea-test-utils' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' @@ -7,6 +10,7 @@ import { HogFlow } from './hogflows/types' import { workflowLogic } from './workflowLogic' const WORKFLOW_ID = 'wf-send-once-verified-1' +const UNVERIFIED_SENDER = { id: 7, domain: 'example.com' } const makeWorkflow = (overrides: Partial = {}): HogFlow => ({ id: WORKFLOW_ID, @@ -43,6 +47,7 @@ const makeWorkflow = (overrides: Partial = {}): HogFlow => ({ trigger: { type: 'event', filters: {} } as HogFlow['trigger'], created_at: '2026-05-01T00:00:00.000Z', updated_at: '2026-05-01T00:00:00.000Z', + unverified_email_senders: [], ...overrides, }) @@ -52,9 +57,11 @@ describe('workflowLogic send once verified', () => { let storedWorkflow: HogFlow let retrieveCount: number - const mountWith = async (workflow: HogFlow): Promise => { + const mountWith = async (workflow: HogFlow, { flagOn = true }: { flagOn?: boolean } = {}): Promise => { storedWorkflow = workflow initKeaTests() + featureFlagLogic.mount() + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD]: flagOn }) logic = workflowLogic({ id: WORKFLOW_ID }) logic.mount() await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) @@ -86,16 +93,181 @@ describe('workflowLogic send once verified', () => { jest.useRealTimers() }) + it('asks before enabling a workflow with an unverified sender, naming the domain', async () => { + await mountWith(makeWorkflow({ unverified_email_senders: [UNVERIFIED_SENDER] })) + + await expectLogic(logic, () => { + logic.actions.enableWorkflow() + }) + .toDispatchActions(['openEnablePrompt']) + .toNotHaveDispatchedActions(['saveWorkflow']) + .toMatchValues({ enablePromptDomains: ['example.com'] }) + expect(patchBodies).toEqual([]) + }) + + it('saves the usual active status once the person confirms, and shows the waiting status', async () => { + await mountWith(makeWorkflow({ unverified_email_senders: [UNVERIFIED_SENDER] })) + await expectLogic(logic, () => { + logic.actions.enableWorkflow() + }).toDispatchActions(['openEnablePrompt']) + + await expectLogic(logic, () => { + logic.actions.confirmEnablePrompt() + }) + .toDispatchActions(['saveWorkflowSuccess']) + .toMatchValues({ enablePromptDomains: null }) + expect(patchBodies.map((body) => body.status)).toEqual(['active']) + expect(logic.values.originalWorkflow?.status).toEqual('waiting_for_domain') + }) + + it.each([ + ['succeeds', 200, 'saveWorkflowSuccess'], + ['fails', 500, 'saveWorkflowFailure'], + ])('keeps the prompt open and loading until the enable save %s', async (_name, responseCode, settledAction) => { + await mountWith(makeWorkflow({ unverified_email_senders: [UNVERIFIED_SENDER] })) + let releaseSave!: () => void + const saveReleased = new Promise((resolve) => { + releaseSave = resolve + }) + useMocks({ + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + patchBodies.push((await request.json()) as Record) + await saveReleased + return [responseCode, { ...storedWorkflow, status: 'waiting_for_domain' }] + }, + }, + }) + await expectLogic(logic, () => { + logic.actions.enableWorkflow() + }).toDispatchActions(['openEnablePrompt']) + + await expectLogic(logic, () => { + logic.actions.confirmEnablePrompt() + }) + .toDispatchActions(['saveWorkflow']) + .toMatchValues({ enablePromptDomains: ['example.com'], workflowLoading: true }) + + releaseSave() + await expectLogic(logic) + .toDispatchActions([settledAction]) + .toMatchValues({ enablePromptDomains: null, workflowLoading: false }) + expect(patchBodies.map((body) => body.status)).toEqual(['active']) + }) + + it('leaves the workflow off when the person cancels', async () => { + await mountWith(makeWorkflow({ unverified_email_senders: [UNVERIFIED_SENDER] })) + await expectLogic(logic, () => { + logic.actions.enableWorkflow() + }).toDispatchActions(['openEnablePrompt']) + + await expectLogic(logic, () => { + logic.actions.cancelEnablePrompt() + }) + .toDispatchActions(['cancelEnablePrompt']) + .toNotHaveDispatchedActions(['saveWorkflow']) + .toMatchValues({ enablePromptDomains: null }) + expect(patchBodies).toEqual([]) + expect(logic.values.originalWorkflow?.status).toEqual('draft') + }) + + it.each([ + ['the flag is off', [UNVERIFIED_SENDER], false], + ['every sender is verified', [], true], + ])('enables without asking when %s', async (_name, senders, flagOn) => { + await mountWith(makeWorkflow({ unverified_email_senders: senders }), { flagOn }) + + await expectLogic(logic, () => { + logic.actions.enableWorkflow() + }) + .toDispatchActions(['saveWorkflowSuccess']) + .toMatchValues({ enablePromptDomains: null }) + expect(patchBodies.map((body) => body.status)).toEqual(['active']) + }) + + it('enables without asking when the domain verified after the page loaded', async () => { + await mountWith(makeWorkflow({ unverified_email_senders: [UNVERIFIED_SENDER] })) + storedWorkflow = makeWorkflow({ unverified_email_senders: [] }) + + await expectLogic(logic, () => { + logic.actions.enableWorkflow() + }) + .toDispatchActions(['saveWorkflowSuccess']) + .toNotHaveDispatchedActions(['openEnablePrompt']) + expect(patchBodies.map((body) => body.status)).toEqual(['active']) + }) + + it('shows the started sending notice once after a waited go-live, until it is dismissed', async () => { + const wentLive = makeWorkflow({ + status: 'active', + enable_requested_at: '2026-05-02T10:00:00Z', + went_live_at: '2026-05-03T08:30:00Z', + }) + await mountWith(wentLive) + expect(logic.values.showStartedSendingNotice).toBe(true) + + logic.actions.dismissStartedSendingNotice() + expect(logic.values.showStartedSendingNotice).toBe(false) + + logic.unmount() + await mountWith(wentLive) + expect(logic.values.showStartedSendingNotice).toBe(false) + + logic.unmount() + await mountWith({ ...wentLive, went_live_at: '2026-05-09T08:30:00Z' }) + expect(logic.values.showStartedSendingNotice).toBe(true) + }) + + it.each([ + [ + 'a direct enable', + { status: 'active', enable_requested_at: '2026-05-02T10:00:00Z', went_live_at: '2026-05-02T10:00:00Z' }, + ], + ['a workflow still waiting', { status: 'waiting_for_domain', enable_requested_at: '2026-05-02T10:00:00Z' }], + [ + 'a workflow whose email sending is paused', + { + status: 'active', + enable_requested_at: '2026-05-02T10:00:00Z', + went_live_at: '2026-05-03T08:30:00Z', + email_sending_paused_at: '2026-05-01T09:00:00Z', + }, + ], + [ + 'a workflow turned off after it went live', + { status: 'draft', enable_requested_at: '2026-05-02T10:00:00Z', went_live_at: '2026-05-03T08:30:00Z' }, + ], + ] as [string, Partial][])('shows no started sending notice for %s', async (_name, overrides) => { + await mountWith(makeWorkflow(overrides)) + + expect(logic.values.showStartedSendingNotice).toBe(false) + }) + + it('shows no started sending notice with the flag off', async () => { + await mountWith( + makeWorkflow({ + status: 'active', + enable_requested_at: '2026-05-02T10:00:00Z', + went_live_at: '2026-05-03T08:30:00Z', + }), + { flagOn: false } + ) + + expect(logic.values.showStartedSendingNotice).toBe(false) + }) + describe('while the workflow waits for its domain', () => { const waiting = makeWorkflow({ status: 'waiting_for_domain', enable_requested_at: '2026-05-02T10:00:00Z', + unverified_email_senders: [UNVERIFIED_SENDER], }) const wentLive: HogFlow = { ...waiting, status: 'active', went_live_at: '2026-05-03T08:30:00Z', updated_at: '2026-05-03T08:30:00Z', + unverified_email_senders: [], } const mountWaiting = async (): Promise => { @@ -114,6 +286,7 @@ describe('workflowLogic send once verified', () => { await expectLogic(logic).toFinishAllListeners() expect(logic.values.originalWorkflow?.status).toEqual('active') + expect(logic.values.showStartedSendingNotice).toBe(true) expect(logic.values.workflow.name).toEqual('Renamed while waiting') expect(logic.values.hasUnsavedChanges).toBe(true) diff --git a/products/workflows/frontend/Workflows/workflowLogic.ts b/products/workflows/frontend/Workflows/workflowLogic.ts index 1d29b0b5e242..029dea66e9a0 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.ts @@ -88,6 +88,7 @@ import { } from './hogflows/types' import { openPublishConfirmDialog } from './PublishImpactDialog' import { ResourceSaveQueue } from './resourceSaveQueue' +import { unverifiedSenderDomains } from './unverifiedSenderDomains' import { checkWaitingStatusWhileVisible } from './waitingStatusCheck' import { prepareWorkflowDuplicate } from './workflowDuplication' import { prefilledEmailContent, prefilledWorkflow, withEmailSender, withPrefilledEmail } from './workflowEmailPrefill' @@ -255,7 +256,7 @@ function pickWorkflowEdits(workflow: HogFlow): Partial { return result as Partial } -const WORKFLOW_LIFECYCLE_FIELDS = ['status', 'enable_requested_at', 'went_live_at'] as const +const WORKFLOW_LIFECYCLE_FIELDS = ['status', 'enable_requested_at', 'went_live_at', 'unverified_email_senders'] as const function lifecycleUpdate(latest: HogFlow, fresh: HogFlow): Partial | null { const update: Record = {} @@ -343,7 +344,13 @@ export interface workflowLogicValues { aiEmailDraftLoading: boolean aiEmailDraftStatus: AiEmailDraftStatusProps | null autoSaveBlockedByValidation: boolean + dismissedStartedSendingNotices: Record + enablePromptDomains: string[] | null + enablePromptOpen: boolean + showStartedSendingNotice: boolean isWaitingForDomain: boolean + startedSendingNoticeKey: string | null + unverifiedSenderDomains: string[] autoSaveEnabled: boolean currentSchedule: HogFlowSchedule | null discardDisabledReason: string | undefined @@ -420,7 +427,7 @@ export interface workflowLogicActions { autoSaveWorkflow: () => { value: true } - checkWaitingStatus: () => { + cancelEnablePrompt: () => { value: true } clearAutoSavePending: () => { @@ -432,9 +439,27 @@ export interface workflowLogicActions { confirmPublishDraft: (confirmToken: string) => { confirmToken: string } + confirmEnablePrompt: () => { + value: true + } + enableWorkflow: () => { + value: true + } confirmResumeEmailSending: () => { value: true } + dismissStartedSendingNotice: () => { + value: true + } + openEnablePrompt: () => { + value: true + } + setStartedSendingNoticeDismissed: (noticeKey: string) => { + noticeKey: string + } + checkWaitingStatus: () => { + value: true + } setWorkflowLifecycle: (lifecycle: Partial) => { lifecycle: Partial } @@ -3074,7 +3099,16 @@ export interface workflowLogicMeta { aiEmailDraft: AiEmailDraftResult | null ) => AiEmailDraftStatusProps | null logicProps: (arg: WorkflowLogicProps) => WorkflowLogicProps + unverifiedSenderDomains: (originalWorkflow: HogFlow | null, featureFlags: FeatureFlagsSet) => string[] + enablePromptDomains: (enablePromptOpen: boolean, unverifiedSenderDomains: string[]) => string[] | null + startedSendingNoticeKey: (originalWorkflow: HogFlow | null) => string | null isWaitingForDomain: (originalWorkflow: HogFlow | null) => boolean + showStartedSendingNotice: ( + startedSendingNoticeKey: string | null, + dismissedStartedSendingNotices: Record, + emailSendingPaused: boolean, + featureFlags: FeatureFlagsSet + ) => boolean workflowUserAccessLevel: (originalWorkflow: HogFlow | null) => AccessControlLevel | null currentSchedule: (schedules: HogFlowSchedule[]) => HogFlowSchedule | null pendingSchedule: ( @@ -3351,6 +3385,12 @@ export const workflowLogic = kea([ setSchedules: (schedules: HogFlowSchedule[]) => ({ schedules }), restoreScheduleEdits: (edits: ScheduleEdits) => ({ edits }), saveWorkflowPartial: (workflow: Partial) => ({ workflow }), + enableWorkflow: true, + openEnablePrompt: true, + confirmEnablePrompt: true, + cancelEnablePrompt: true, + dismissStartedSendingNotice: true, + setStartedSendingNoticeDismissed: (noticeKey: string) => ({ noticeKey }), checkWaitingStatus: true, setWorkflowLifecycle: (lifecycle: Partial) => ({ lifecycle }), markSaveAttempted: (actionIds: string[]) => ({ actionIds }), @@ -3890,18 +3930,73 @@ export const workflowLogic = kea([ setResumeEmailSendingPending: (_, { pending }) => pending, }, ], + enablePromptOpen: [ + false, + { + openEnablePrompt: () => true, + cancelEnablePrompt: () => false, + // Stays open while the enable saves, so its loading confirm blocks a second enable. + saveWorkflowSuccess: () => false, + saveWorkflowFailure: () => false, + }, + ], originalWorkflow: { setWorkflowLifecycle: (state, { lifecycle }) => (state ? { ...state, ...lifecycle } : state), }, workflow: { setWorkflowLifecycle: (state, { lifecycle }) => ({ ...state, ...lifecycle }), }, + dismissedStartedSendingNotices: [ + {} as Record, + // pinned: storage key, renaming it shows every dismissed notice again + { persist: true, storageKey: 'workflows-started-sending-notices-dismissed' }, + { + setStartedSendingNoticeDismissed: (state, { noticeKey }) => ({ ...state, [noticeKey]: true }), + }, + ], }), selectors({ + unverifiedSenderDomains: [ + (s) => [s.originalWorkflow, s.featureFlags], + (originalWorkflow: HogFlow | null, featureFlags: FeatureFlagsSet): string[] => + featureFlags[FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD] + ? unverifiedSenderDomains(originalWorkflow) + : [], + ], + enablePromptDomains: [ + (s) => [s.enablePromptOpen, s.unverifiedSenderDomains], + (enablePromptOpen: boolean, unverifiedSenderDomains: string[]): string[] | null => + enablePromptOpen ? unverifiedSenderDomains : null, + ], isWaitingForDomain: [ (s) => [s.originalWorkflow], (originalWorkflow: HogFlow | null): boolean => originalWorkflow?.status === 'waiting_for_domain', ], + startedSendingNoticeKey: [ + (s) => [s.originalWorkflow], + (originalWorkflow: HogFlow | null): string | null => { + const { status, enable_requested_at, went_live_at } = originalWorkflow ?? {} + const wentLiveAfterWaiting = + status === 'active' && + !!enable_requested_at && + !!went_live_at && + dayjs(went_live_at).isAfter(dayjs(enable_requested_at)) + return wentLiveAfterWaiting ? `${originalWorkflow?.id}:${went_live_at}` : null + }, + ], + showStartedSendingNotice: [ + (s) => [s.startedSendingNoticeKey, s.dismissedStartedSendingNotices, s.emailSendingPaused, s.featureFlags], + ( + noticeKey: string | null, + dismissed: Record, + emailSendingPaused: boolean, + featureFlags: FeatureFlagsSet + ): boolean => + !!featureFlags[FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD] && + !emailSendingPaused && + !!noticeKey && + !dismissed[noticeKey], + ], aiEmailDraftStatus: [ (s) => [s.aiEmailDraftLoading, s.aiEmailDraftApplied, s.aiEmailDraft], (loading: boolean, applied: boolean, result: AiEmailDraftResult | null): AiEmailDraftStatusProps | null => { @@ -4543,6 +4638,24 @@ export const workflowLogic = kea([ loadWorkflowFailure: () => { actions.replayDeferredResourceEdited() }, + enableWorkflow: async () => { + if (!values.workflowHasActionErrors && values.unverifiedSenderDomains.length > 0 && props.id) { + // The domain may have verified in another tab since this page loaded. + const fresh = await api.hogFlows.getHogFlow(props.id).catch(() => null) + if (fresh) { + applyWorkflowLifecycle(cache, actions, { unverified_email_senders: fresh.unverified_email_senders }) + } + } + const asksFirst = !values.workflowHasActionErrors && values.unverifiedSenderDomains.length > 0 + if (asksFirst) { + actions.openEnablePrompt() + } else { + actions.saveWorkflowPartial({ status: 'active' }) + } + }, + confirmEnablePrompt: () => { + actions.saveWorkflowPartial({ status: 'active' }) + }, checkWaitingStatus: async () => { const latest = (cache.lastSavedWorkflow as HogFlow | undefined) ?? values.originalWorkflow if (!props.id || !latest || !values.isWaitingForDomain || values.workflowLoading) { @@ -4567,6 +4680,11 @@ export const workflowLogic = kea([ applyWorkflowLifecycle(cache, actions, lifecycle) } }, + dismissStartedSendingNotice: () => { + if (values.startedSendingNoticeKey) { + actions.setStartedSendingNoticeDismissed(values.startedSendingNoticeKey) + } + }, saveWorkflow: () => { cache.saveEditVersion = values.workflowEditVersion }, diff --git a/products/workflows/frontend/Workflows/workflowsLogic.test.ts b/products/workflows/frontend/Workflows/workflowsLogic.test.ts index 805c5763512e..ca42479f374a 100644 --- a/products/workflows/frontend/Workflows/workflowsLogic.test.ts +++ b/products/workflows/frontend/Workflows/workflowsLogic.test.ts @@ -1,25 +1,62 @@ +import { MOCK_DEFAULT_PROJECT, MOCK_DEFAULT_TEAM } from 'lib/api.mock' + import { router } from 'kea-router' import { expectLogic } from 'kea-test-utils' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' import type { HogFlow } from './hogflows/types' import { workflowsLogic } from './workflowsLogic' +const draftRow = { id: 'wf-1', name: 'Welcome', status: 'draft' } as HogFlow + describe('workflowsLogic', () => { let logic: ReturnType + let patchBodies: Record[] + let requestedTeamIds: string[] + let detailSenders: HogFlow['unverified_email_senders'] + + const mountWith = ({ + flagOn = true, + senders = [{ id: 7, domain: 'example.com' }], + team = MOCK_DEFAULT_TEAM, + }: { + flagOn?: boolean + senders?: HogFlow['unverified_email_senders'] + team?: typeof MOCK_DEFAULT_TEAM + } = {}): void => { + detailSenders = senders + initKeaTests(true, team, MOCK_DEFAULT_PROJECT) + featureFlagLogic.mount() + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD]: flagOn }) + logic = workflowsLogic() + logic.mount() + } beforeEach(() => { + patchBodies = [] + requestedTeamIds = [] useMocks({ get: { '/api/environments/:team_id/hog_flows/': { results: [], count: 0 }, '/api/projects/:team_id/hog_flows/': { results: [], count: 0 }, + '/api/environments/:team_id/hog_flows/:id/': ({ params }) => { + requestedTeamIds.push(String(params.team_id)) + return [200, { ...draftRow, unverified_email_senders: detailSenders }] + }, + }, + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ params, request }) => { + requestedTeamIds.push(String(params.team_id)) + patchBodies.push((await request.json()) as Record) + return [200, { ...draftRow, status: 'waiting_for_domain' }] + }, }, }) - initKeaTests() - logic = workflowsLogic() - logic.mount() }) afterEach(() => logic.unmount()) @@ -28,6 +65,7 @@ describe('workflowsLogic', () => { ['/workflows', 'active'], ['/workflows/workflows', 'waiting_for_domain'], ])('fills the filters from the query params on %s with status %s', (path, status) => { + mountWith() router.actions.push( `${path}?created_by=user-uuid&status=${status}&trigger_type=event&type=messaging&tags=${encodeURIComponent('["growth","billing"]')}` ) @@ -41,30 +79,114 @@ describe('workflowsLogic', () => { tags: ['growth', 'billing'], page: 1, }) + expect(logic.values.paramsFromFilters.status).toEqual(status) }) - it.each([ - ['draft', 'active'], - ['active', 'draft'], - ['waiting_for_domain', 'draft'], - ] as const)('the list toggle on a %s workflow saves %s', async (status, expected) => { - const workflow = { id: 'flow-1', name: 'Welcome', status } as HogFlow - const bodies: unknown[] = [] + it('asks before the list toggle enables a workflow with an unverified sender, naming the domain', async () => { + mountWith() + + await expectLogic(logic, () => { + logic.actions.toggleWorkflowStatus(draftRow) + }) + .toDispatchActions(['toggleWorkflowStatusSuccess']) + .toMatchValues({ enablePrompt: { workflowId: 'wf-1', domains: ['example.com'] } }) + expect(patchBodies).toEqual([]) + }) + + it('saves the usual active status once the person confirms', async () => { + mountWith() + logic.actions.toggleWorkflowStatus(draftRow) + await expectLogic(logic).toDispatchActions(['openEnablePrompt']) + + await expectLogic(logic, () => { + logic.actions.confirmEnablePrompt() + }) + .toDispatchActions(['loadWorkflows', 'confirmEnablePromptSuccess']) + .toMatchValues({ enablePrompt: null }) + expect(patchBodies).toEqual([{ status: 'active' }]) + }) + + it('keeps the prompt loading until the enable lands, even when a list reload lands first', async () => { + mountWith() + let releaseSave!: () => void + const saveReleased = new Promise((resolve) => { + releaseSave = resolve + }) useMocks({ patch: { - '/api/projects/:team_id/hog_flows/:id/': async ({ request }) => { - bodies.push(await request.json()) - return [200, { ...workflow, status: expected }] + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + patchBodies.push((await request.json()) as Record) + await saveReleased + return [200, { ...draftRow, status: 'waiting_for_domain' }] }, }, }) + logic.actions.toggleWorkflowStatus(draftRow) + await expectLogic(logic).toDispatchActions(['openEnablePrompt']) - await expectLogic(logic, () => logic.actions.toggleWorkflowStatus(workflow)).toFinishAllListeners() + logic.actions.confirmEnablePrompt() + await expectLogic(logic, () => { + logic.actions.loadWorkflows() + }).toDispatchActions(['loadWorkflowsSuccess']) - expect(bodies).toEqual([{ status: expected }]) + expect(logic.values.enablePromptSaving).toBe(true) + releaseSave() + await expectLogic(logic).toDispatchActions(['confirmEnablePromptSuccess']) + expect(logic.values.enablePromptSaving).toBe(false) }) + it('reads and saves the workflow in the current environment, not its parent project', async () => { + const childEnvironmentId = MOCK_DEFAULT_PROJECT.id + 1 + mountWith({ team: { ...MOCK_DEFAULT_TEAM, id: childEnvironmentId } }) + logic.actions.toggleWorkflowStatus(draftRow) + await expectLogic(logic).toDispatchActions(['openEnablePrompt']) + + await expectLogic(logic, () => { + logic.actions.confirmEnablePrompt() + }).toDispatchActions(['confirmEnablePromptSuccess']) + expect(requestedTeamIds).toEqual([String(childEnvironmentId), String(childEnvironmentId)]) + }) + + it('leaves the workflow off when the person cancels', async () => { + mountWith() + logic.actions.toggleWorkflowStatus(draftRow) + await expectLogic(logic).toDispatchActions(['openEnablePrompt']) + + await expectLogic(logic, () => { + logic.actions.cancelEnablePrompt() + }).toMatchValues({ enablePrompt: null }) + expect(patchBodies).toEqual([]) + }) + + it.each([ + ['the flag is off', { flagOn: false }], + ['every sender is verified', { senders: [] }], + ])('enables from the list without asking when %s', async (_name, setup) => { + mountWith(setup) + + await expectLogic(logic, () => { + logic.actions.toggleWorkflowStatus(draftRow) + }) + .toDispatchActions(['toggleWorkflowStatusSuccess']) + .toMatchValues({ enablePrompt: null }) + expect(patchBodies).toEqual([{ status: 'active' }]) + }) + + it.each(['active', 'waiting_for_domain'] as const)( + 'the list toggle on a %s workflow saves draft', + async (status) => { + mountWith() + + await expectLogic(logic, () => + logic.actions.toggleWorkflowStatus({ ...draftRow, status } as HogFlow) + ).toFinishAllListeners() + + expect(patchBodies).toEqual([{ status: 'draft' }]) + } + ) + it('saves a row tag edit with only the tags, and shows the name the server stored', async () => { + mountWith() const workflow = { id: 'flow-1', name: 'Churn win-back', tags: [] } as unknown as HogFlow const bodies: unknown[] = [] useMocks({ @@ -84,6 +206,7 @@ describe('workflowsLogic', () => { }) it("sends a row's next tag save only after the one before it lands", async () => { + mountWith() const workflow = { id: 'flow-1', name: 'Churn win-back', tags: [] } as unknown as HogFlow const bodies: string[][] = [] let markFirstInFlight = (): void => {} @@ -117,12 +240,14 @@ describe('workflowsLogic', () => { }) it('parses a url whose only filter is a tag', () => { + mountWith() router.actions.push(`/workflows?tags=${encodeURIComponent('["growth"]')}`) expect(logic.values.filters.tags).toEqual(['growth']) }) it('keeps the filters when a link back to the list carries no query params', () => { + mountWith() router.actions.push('/workflows?status=active&type=messaging') expect(logic.values.filters.status).toEqual('active') diff --git a/products/workflows/frontend/Workflows/workflowsLogic.ts b/products/workflows/frontend/Workflows/workflowsLogic.ts index 7e9cff23ecf9..b77fa7d82a90 100644 --- a/products/workflows/frontend/Workflows/workflowsLogic.ts +++ b/products/workflows/frontend/Workflows/workflowsLogic.ts @@ -6,6 +6,8 @@ import posthog from 'posthog-js' import { LemonDialog, PaginationManual, lemonToast } from '@posthog/lemon-ui' import api, { ApiConfig, CountedPaginatedResponse, HogFlowListType } from 'lib/api' +import { FEATURE_FLAGS } from 'lib/constants' +import { type FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic' import { buildUserScopedPersistenceConfig } from 'lib/logic/persistence' import { objectsEqual } from 'lib/utils/objects' import { urls } from 'scenes/urls' @@ -13,8 +15,9 @@ import { urls } from 'scenes/urls' import { deleteFromTree } from '~/layout/panel-layout/ProjectTree/projectTreeLogic' import { tagsModel } from '~/models/tagsModel' -import { hogFlowsPartialUpdate } from '../generated/api' +import { hogFlowsPartialUpdate, hogFlowsRetrieve } from '../generated/api' import type { HogFlow } from './hogflows/types' +import { unverifiedSenderDomains } from './unverifiedSenderDomains' import { prepareWorkflowDuplicate } from './workflowDuplication' export type WorkflowStatusFilter = 'all' | 'active' | 'waiting_for_domain' | 'draft' | 'archived' @@ -75,6 +78,13 @@ const DEFAULT_FILTERS: WorkflowsFilters = { export type WorkflowsResult = CountedPaginatedResponse +const currentTeamId = (): string => String(ApiConfig.getCurrentTeamId()) + +export interface EnablePrompt { + workflowId: string + domains: string[] +} + export interface WorkflowsListParams { search?: string status?: HogFlow['status'] @@ -90,7 +100,10 @@ export interface WorkflowsListParams { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface workflowsLogicValues { tags: string[] // tagsModel + featureFlags: FeatureFlagsSet // featureFlagLogic allArchivedSelected: boolean + enablePrompt: EnablePrompt | null + enablePromptSaving: boolean filters: WorkflowsFilters hasLoadedWorkflows: boolean localWorkflowTags: Record @@ -131,9 +144,33 @@ export interface workflowsLogicActions { workflow: HogFlow } } + cancelEnablePrompt: () => { + value: true + } clearArchivedWorkflowSelection: () => { value: true } + confirmEnablePrompt: () => { + value: true + } + confirmEnablePromptFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + confirmEnablePromptSuccess: ( + workflows: WorkflowsResult, + payload?: { + value: true + } + ) => { + workflows: WorkflowsResult + payload?: { + value: true + } + } deleteSelectedWorkflows: () => { value: true } @@ -180,6 +217,13 @@ export interface workflowsLogicActions { } } loadWorkflows: () => {} + openEnablePrompt: ( + workflowId: string, + domains: string[] + ) => { + workflowId: string + domains: string[] + } loadWorkflowsFailure: ( error: string, errorObject?: any @@ -297,7 +341,7 @@ export const workflowsLogic = kea([ key(() => 'workflowsLogic'), path(['products', 'workflows', 'frontend', 'workflowsLogic']), connect(() => ({ - values: [tagsModel, ['tags']], + values: [tagsModel, ['tags'], featureFlagLogic, ['featureFlags']], actions: [tagsModel, ['loadTags', 'loadTagsIfNeeded']], })), actions({ @@ -316,6 +360,9 @@ export const workflowsLogic = kea([ updateWorkflowTags: (workflow: HogFlow, tags: string[]) => ({ workflow, tags }), setSavedWorkflowTags: (id: string, tags: string[]) => ({ id, tags }), setTagsFilter: (tags: string[], source: 'filter' | 'tag_click') => ({ tags, source }), + openEnablePrompt: (workflowId: string, domains: string[]) => ({ workflowId, domains }), + confirmEnablePrompt: true, + cancelEnablePrompt: true, }), reducers(() => ({ filters: [ @@ -353,6 +400,24 @@ export const workflowsLogic = kea([ loadWorkflowsSuccess: () => new Set(), }, ], + enablePrompt: [ + null as EnablePrompt | null, + { + openEnablePrompt: (_, prompt) => prompt, + cancelEnablePrompt: () => null, + confirmEnablePromptSuccess: () => null, + confirmEnablePromptFailure: () => null, + }, + ], + // Its own flag, because a list reload that lands first would end the shared loading state. + enablePromptSaving: [ + false, + { + confirmEnablePrompt: () => true, + confirmEnablePromptSuccess: () => false, + confirmEnablePromptFailure: () => false, + }, + ], hasLoadedWorkflows: [ false as boolean, { @@ -381,7 +446,21 @@ export const workflowsLogic = kea([ return response }, toggleWorkflowStatus: async ({ workflow }) => { - await hogFlowsPartialUpdate(String(ApiConfig.getCurrentTeamId()), workflow.id, { + if ( + workflow.status === 'draft' && + values.featureFlags[FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD] + ) { + // The list rows leave the senders out, so the detail answers which are unverified. + const detail = workflow.unverified_email_senders + ? workflow + : await hogFlowsRetrieve(currentTeamId(), workflow.id) + const domains = unverifiedSenderDomains(detail) + if (domains.length > 0) { + actions.openEnablePrompt(workflow.id, domains) + return values.workflows + } + } + await hogFlowsPartialUpdate(currentTeamId(), workflow.id, { status: workflow.status === 'draft' ? 'active' : 'draft', }) // Reload instead of patching in place: the new status may no longer match the @@ -389,6 +468,15 @@ export const workflowsLogic = kea([ actions.loadWorkflows() return values.workflows }, + confirmEnablePrompt: async () => { + if (values.enablePrompt) { + await hogFlowsPartialUpdate(currentTeamId(), values.enablePrompt.workflowId, { + status: 'active', + }) + actions.loadWorkflows() + } + return values.workflows + }, duplicateWorkflow: async ({ workflow }) => { await api.hogFlows.createHogFlow(prepareWorkflowDuplicate(workflow)) // The copy is a draft; reload so it only shows when it matches the current filter From 26986e07ed21d843e94d64f3806da513a6988edb Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:11:37 +0000 Subject: [PATCH 2/4] fix(workflows): settle Enable on the last click and hide the notice while sending is off Two quick Enable clicks each read the workflow's senders. The earlier read could land after the person cancelled the later prompt and open it again. Only the last click acts on its read now. The Started sending notice also stays hidden while the project's email sending is suspended, not only while the workflow's own sending is paused. It reads the project's sending status only when a notice is due. Each workflow keeps its dismissed notices under its own storage entry, so two tabs that dismiss different workflows keep both dismissals. Refs Silthus/personal#196. Co-Authored-By: Claude Opus 5.5 --- .../WorkflowStartedSendingNotice.test.tsx | 77 +++++++++++++++++++ .../WorkflowStartedSendingNotice.tsx | 10 ++- .../workflowLogic.sendOnceVerified.test.ts | 34 +++++++- .../frontend/Workflows/workflowLogic.ts | 8 +- 4 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.test.tsx diff --git a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.test.tsx b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.test.tsx new file mode 100644 index 000000000000..49491aaa4131 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.test.tsx @@ -0,0 +1,77 @@ +import '@testing-library/jest-dom' + +import { act, cleanup, render, screen } from '@testing-library/react' +import { BindLogic, Provider } from 'kea' + +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { workflowsEmailSuspensionLogic } from '../workflowsEmailSuspensionLogic' +import { HogFlow } from './hogflows/types' +import { workflowLogic } from './workflowLogic' +import { WorkflowStartedSendingNotice } from './WorkflowStartedSendingNotice' + +const WORKFLOW_ID = 'wf-started-sending-1' + +const WENT_LIVE_AFTER_WAITING: HogFlow = { + id: WORKFLOW_ID, + name: 'Welcome', + actions: [], + edges: [], + conversion: { filters: [] }, + exit_condition: 'exit_only_at_end', + version: 1, + status: 'active', + team_id: 1, + trigger: { type: 'event', filters: {} } as HogFlow['trigger'], + created_at: '2026-05-01T00:00:00.000Z', + updated_at: '2026-05-03T08:30:00.000Z', + enable_requested_at: '2026-05-02T10:00:00Z', + went_live_at: '2026-05-03T08:30:00Z', +} + +describe('WorkflowStartedSendingNotice', () => { + afterEach(() => { + cleanup() + }) + + it.each([ + { name: 'shows once the workflow went live', suspended: false, shown: true }, + { name: 'stays hidden while the project cannot send email', suspended: true, shown: false }, + ])('$name', async ({ suspended, shown }) => { + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': ({ params }) => + params.id === 'email_sending_suspension' + ? [200, { email_sending_suspended: suspended }] + : [200, WENT_LIVE_AFTER_WAITING], + '/api/projects/:team_id/hog_function_templates/': { results: [], count: 0 }, + }, + }) + initKeaTests() + featureFlagLogic.mount() + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD]: true }) + const logic = workflowLogic({ id: WORKFLOW_ID }) + logic.mount() + workflowsEmailSuspensionLogic.mount() + await act(async () => { + await logic.asyncActions.loadWorkflow() + await workflowsEmailSuspensionLogic.asyncActions.loadSuspensionStatus() + }) + + render( + + + + + + ) + + expect(screen.queryByText('Started sending.') !== null).toBe(shown) + workflowsEmailSuspensionLogic.unmount() + logic.unmount() + }) +}) diff --git a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx index f17bbef5f002..aa3d5cdf79ac 100644 --- a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx +++ b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx @@ -2,13 +2,21 @@ import { useActions, useValues } from 'kea' import { LemonBanner } from '@posthog/lemon-ui' +import { workflowsEmailSuspensionLogic } from '../workflowsEmailSuspensionLogic' import { workflowLogic } from './workflowLogic' export function WorkflowStartedSendingNotice(): JSX.Element | null { const { showStartedSendingNotice } = useValues(workflowLogic) + + // Mount the project's sending status only when a notice is due, so other editor visits skip the request. + return showStartedSendingNotice ? : null +} + +function StartedSendingBanner(): JSX.Element | null { const { dismissStartedSendingNotice } = useActions(workflowLogic) + const { emailSendingSuspended } = useValues(workflowsEmailSuspensionLogic) - if (!showStartedSendingNotice) { + if (emailSendingSuspended) { return null } diff --git a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts index 503d12dd1be7..83c611bd9e42 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts @@ -52,6 +52,12 @@ const makeWorkflow = (overrides: Partial = {}): HogFlow => ({ ...overrides, }) +async function waitForHeldReads(heldReads: unknown[], count: number): Promise { + while (heldReads.length < count) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + describe('workflowLogic send once verified', () => { let logic: ReturnType let patchBodies: Record[] @@ -198,6 +204,31 @@ describe('workflowLogic send once verified', () => { expect(patchBodies.map((body) => body.status)).toEqual(['active']) }) + it('acts only on the last Enable click when the domain read of an earlier one lands late', async () => { + await mountWith(makeWorkflow({ unverified_email_senders: [UNVERIFIED_SENDER] })) + const heldReads: (() => void)[] = [] + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': async () => { + await new Promise((resolve) => heldReads.push(resolve)) + return [200, storedWorkflow] + }, + }, + }) + logic.actions.enableWorkflow() + logic.actions.enableWorkflow() + await waitForHeldReads(heldReads, 2) + + heldReads[0]() + await new Promise((resolve) => setImmediate(resolve)) + expect(logic.values.enablePromptOpen).toBe(false) + + heldReads[1]() + await expectLogic(logic).toDispatchActions(['openEnablePrompt']) + expect(logic.values.enablePromptDomains).toEqual(['example.com']) + expect(patchBodies).toEqual([]) + }) + it('shows the started sending notice once after a waited go-live, until it is dismissed', async () => { const wentLive = makeWorkflow({ status: 'active', @@ -363,7 +394,8 @@ describe('workflowLogic send once verified', () => { }) jest.useFakeTimers({ advanceTimers: true }) await mountWith(workflow) - await expectLogic(logic).toDispatchActions(['loadHogFunctionTemplatesByIdSuccess']) + await expectLogic(logic).toFinishAllListeners() + expect(logic.values.hogFunctionTemplatesById).toHaveProperty(WEBHOOK_TEMPLATE.id) storedWorkflow = { ...workflow, ...wentLiveLifecycle } logic.actions.setWorkflowValue('name', 'Renamed while waiting') diff --git a/products/workflows/frontend/Workflows/workflowLogic.ts b/products/workflows/frontend/Workflows/workflowLogic.ts index 3ad100d94171..49629f189a4c 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.ts @@ -3952,8 +3952,9 @@ export const workflowLogic = kea([ }, dismissedStartedSendingNotices: [ {} as Record, - // pinned: storage key, renaming it shows every dismissed notice again - { persist: true, storageKey: 'workflows-started-sending-notices-dismissed' }, + // pinned: the storage key derives from the logic path, which carries the workflow id. Each workflow + // keeps its own entry, so two tabs dismissing different workflows don't overwrite each other. + { persist: true }, { setStartedSendingNoticeDismissed: (state, { noticeKey }) => ({ ...state, [noticeKey]: true }), }, @@ -4642,10 +4643,11 @@ export const workflowLogic = kea([ loadWorkflowFailure: () => { actions.replayDeferredResourceEdited() }, - enableWorkflow: async () => { + enableWorkflow: async (_, breakpoint) => { if (!values.workflowHasActionErrors && values.unverifiedSenderDomains.length > 0 && props.id) { // The domain may have verified in another tab since this page loaded. const fresh = await api.hogFlows.getHogFlow(props.id).catch(() => null) + breakpoint() if (fresh) { applyWorkflowLifecycle(cache, actions, { unverified_email_senders: fresh.unverified_email_senders }) } From 967a6df436aa26627a437dced5cbf9e911ca8aa3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:17:51 +0000 Subject: [PATCH 3/4] fix(workflows): show the started sending notice only once the project can send The notice rendered while the project's sending status was still loading, and stayed when that request failed, so a suspended project could read "Started sending". It now waits for a loaded status that says the project can send. Refs Silthus/personal#196. Co-Authored-By: Claude Opus 5.5 --- .../WorkflowStartedSendingNotice.test.tsx | 30 ++++++++++++------- .../WorkflowStartedSendingNotice.tsx | 5 ++-- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.test.tsx b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.test.tsx index 49491aaa4131..943941b88be2 100644 --- a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.test.tsx +++ b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.test.tsx @@ -1,6 +1,6 @@ import '@testing-library/jest-dom' -import { act, cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { BindLogic, Provider } from 'kea' import { FEATURE_FLAGS } from 'lib/constants' @@ -39,15 +39,23 @@ describe('WorkflowStartedSendingNotice', () => { }) it.each([ - { name: 'shows once the workflow went live', suspended: false, shown: true }, - { name: 'stays hidden while the project cannot send email', suspended: true, shown: false }, - ])('$name', async ({ suspended, shown }) => { + { name: 'shows once the project can send email', status: 200, suspended: false, shown: true }, + { name: 'stays hidden while the project cannot send email', status: 200, suspended: true, shown: false }, + { name: 'stays hidden when the sending status fails to load', status: 500, suspended: false, shown: false }, + ])('$name', async ({ status, suspended, shown }) => { + let releaseStatus!: () => void + const statusReleased = new Promise((resolve) => { + releaseStatus = resolve + }) useMocks({ get: { - '/api/environments/:team_id/hog_flows/:id/': ({ params }) => - params.id === 'email_sending_suspension' - ? [200, { email_sending_suspended: suspended }] - : [200, WENT_LIVE_AFTER_WAITING], + '/api/environments/:team_id/hog_flows/:id/': async ({ params }) => { + if (params.id !== 'email_sending_suspension') { + return [200, WENT_LIVE_AFTER_WAITING] + } + await statusReleased + return [status, { email_sending_suspended: suspended }] + }, '/api/projects/:team_id/hog_function_templates/': { results: [], count: 0 }, }, }) @@ -56,10 +64,8 @@ describe('WorkflowStartedSendingNotice', () => { featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD]: true }) const logic = workflowLogic({ id: WORKFLOW_ID }) logic.mount() - workflowsEmailSuspensionLogic.mount() await act(async () => { await logic.asyncActions.loadWorkflow() - await workflowsEmailSuspensionLogic.asyncActions.loadSuspensionStatus() }) render( @@ -69,9 +75,11 @@ describe('WorkflowStartedSendingNotice', () => { ) + expect(screen.queryByText('Started sending.')).not.toBeInTheDocument() + releaseStatus() + await waitFor(() => expect(workflowsEmailSuspensionLogic.values.suspensionStatusLoading).toBe(false)) expect(screen.queryByText('Started sending.') !== null).toBe(shown) - workflowsEmailSuspensionLogic.unmount() logic.unmount() }) }) diff --git a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx index aa3d5cdf79ac..5202bef006cf 100644 --- a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx +++ b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx @@ -14,9 +14,10 @@ export function WorkflowStartedSendingNotice(): JSX.Element | null { function StartedSendingBanner(): JSX.Element | null { const { dismissStartedSendingNotice } = useActions(workflowLogic) - const { emailSendingSuspended } = useValues(workflowsEmailSuspensionLogic) + const { suspensionStatus } = useValues(workflowsEmailSuspensionLogic) - if (emailSendingSuspended) { + // Say it started only once the project's sending status confirms it can send. + if (!suspensionStatus || suspensionStatus.email_sending_suspended) { return null } From 0bea09d6f931a3f9831cc2097e775ed334d4a43f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:20:03 +0000 Subject: [PATCH 4/4] chore(workflows): drop a comment that restates the notice guard Refs Silthus/personal#196. Co-Authored-By: Claude Opus 5.5 --- .../frontend/Workflows/WorkflowStartedSendingNotice.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx index 5202bef006cf..817100d7d539 100644 --- a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx +++ b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx @@ -16,7 +16,6 @@ function StartedSendingBanner(): JSX.Element | null { const { dismissStartedSendingNotice } = useActions(workflowLogic) const { suspensionStatus } = useValues(workflowsEmailSuspensionLogic) - // Say it started only once the project's sending status confirms it can send. if (!suspensionStatus || suspensionStatus.email_sending_suspended) { return null }