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.test.tsx b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.test.tsx new file mode 100644 index 000000000000..943941b88be2 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.test.tsx @@ -0,0 +1,85 @@ +import '@testing-library/jest-dom' + +import { act, cleanup, render, screen, waitFor } 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 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/': 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 }, + }, + }) + initKeaTests() + featureFlagLogic.mount() + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD]: true }) + const logic = workflowLogic({ id: WORKFLOW_ID }) + logic.mount() + await act(async () => { + await logic.asyncActions.loadWorkflow() + }) + + render( + + + + + + ) + expect(screen.queryByText('Started sending.')).not.toBeInTheDocument() + + releaseStatus() + await waitFor(() => expect(workflowsEmailSuspensionLogic.values.suspensionStatusLoading).toBe(false)) + expect(screen.queryByText('Started sending.') !== null).toBe(shown) + logic.unmount() + }) +}) diff --git a/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx new file mode 100644 index 000000000000..817100d7d539 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowStartedSendingNotice.tsx @@ -0,0 +1,35 @@ +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 { suspensionStatus } = useValues(workflowsEmailSuspensionLogic) + + if (!suspensionStatus || suspensionStatus.email_sending_suspended) { + 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 abce4f9fd503..83c611bd9e42 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 WEBHOOK_TEMPLATE = { id: 'template-webhook', inputs_schema: [{ key: 'url', type: 'string', templating: true }] } const makeWorkflow = (overrides: Partial = {}): HogFlow => ({ @@ -44,18 +48,27 @@ 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, }) +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[] 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']) @@ -87,15 +100,205 @@ 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('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', + 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 wentLiveLifecycle: Partial = { status: 'active', went_live_at: '2026-05-03T08:30:00Z', updated_at: '2026-05-03T08:30:00Z', + unverified_email_senders: [], } const wentLive: HogFlow = { ...waiting, ...wentLiveLifecycle } const [trigger, exit] = waiting.actions @@ -135,6 +338,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) @@ -190,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 b63747313738..49629f189a4c 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' @@ -259,7 +260,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 = {} @@ -347,7 +348,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 @@ -424,7 +431,7 @@ export interface workflowLogicActions { autoSaveWorkflow: () => { value: true } - checkWaitingStatus: () => { + cancelEnablePrompt: () => { value: true } clearAutoSavePending: () => { @@ -436,9 +443,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 } @@ -3078,7 +3103,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: ( @@ -3355,6 +3389,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 }), @@ -3894,18 +3934,74 @@ 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: 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 }), + }, + ], }), 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 => { @@ -4547,6 +4643,25 @@ export const workflowLogic = kea([ loadWorkflowFailure: () => { actions.replayDeferredResourceEdited() }, + 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 }) + } + } + 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) { @@ -4571,6 +4686,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